diff --git a/devlog/_plan/260906_grok_catalog_and_patch/000_research.md b/devlog/_plan/260906_grok_catalog_and_patch/000_research.md new file mode 100644 index 0000000000..7b97dc7bee --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/000_research.md @@ -0,0 +1,17 @@ +# Grok catalog selection and Codex patch parity + +Class C3, spec-satisfaction repair. The user clarified that filtering means enabled model visibility in Pi/Aside, not assistant output filtering. No output-filter changes are authorized by this unit. Prior Chat patch fixes must remain effective at every Codex Responses tool completion boundary. + +Scope: export catalog selection, refresh of already-owned Pi/Aside integrations, native Responses custom-tool repair, focused regression tests, matching docs. No live user config changes, deployment, release, unrelated cleanup, local test suites or local typecheck. Push --no-verify and admin merge are explicitly authorized. Probes and hosted CI are authorized. + +Evidence: src/clients/config-export/constants.ts selects openai-completions for Pi; Aside uses the same builder. src/server/management/model-rows.ts:218 filters disabled only. src/cli/opencode.ts:373 likewise ignores selectedModels. src/codex/catalog/provider-fetch.ts:1992 is canonical for allowlist plus disabled and pending selection. src/server/management/model-routes.ts visibility writes converge Codex only. Both explicit sync paths refresh only MCode among owned file integrations. Live read-only snapshot: /v1/models and existing Aside managed file currently contain xai/grok-4.6 only; do not claim that the live snapshot reproduced a full catalog leak. Synthetic allowlist and stale-owned-file scenarios will establish the gaps. + +Independent patch analysis: native custom exec is skipped by repairable||aliased in responses-custom-tool-repair.ts:206. Its item.done/input.done preserve raw patch while response.completed repairs it. Function apply_patch helper aliases can stream raw patch before compiling final JS. Existing arbitrary JS and foreign namespace boundaries stay byte-exact. + +Dependencies: 010 export selection -> 020 owned file convergence -> 030 Codex patch completion parity. Each has its own PABCD and reviewable PR. The patch layer is a separate user-requested stabilization concern published after catalog layers in the requested stack. + +Resource scope: existing GitHub repo credentials, read-only local configuration with no secret output; at most 24 synthetic live-provider calls, each <=120 seconds; six-hour execution window. No explicit token budget. Probe scripts stay ignored .tmp; public notes contain no credentials or private requests. DONE = regression probes, actual hosted exact-head test/typecheck CI, independent review, registered stack merged and fetched-dev ancestry. BLOCKED only for a persistent external dependency; no stopping on CI queueing. Each later P rechecks current source and carries earlier evidence. Escalation: reclaim failed delegated scope; new write delegation requires P amendment. + +## Baseline probes + +`bun .tmp/grok-stabilization/catalog-probe.ts` exit 0: management/CLI × pi/aside each emitted grok-4.3, grok-4.5, grok-4.6 despite selectedModels=[grok-4.6]; full management roster was three. `bun .tmp/grok-stabilization/patch-probe.ts` exit 0: native custom exec emitted one raw delta, input.done uncompiled and item.done uncompiled; function apply_patch alias emitted one raw preview despite compiled final. Both are observation probes before repair, not passing acceptance assertions. `python3 .tmp/grok-stabilization/verify-roadmap.py` exit 0 checks numbered roadmap artifacts and actual existing source target paths. No local suite or typecheck was run. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md new file mode 100644 index 0000000000..3b687e9b1c --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md @@ -0,0 +1,10 @@ +# 010 Export catalog visibility + +Loop: spec-satisfaction repair; trigger: selectedModels ignored by export projection. Goal: Pi/Aside export obeys the same provider allowlist, blocklist, pending selection as routed catalog. No change to full management catalog or routing authorization. + +MODIFY src/server/management/model-rows.ts: import filterCatalogVisibleModels. In loadExportModels compute visible routed row identities from filterCatalogVisibleModels(rows.filter(row => !row.native), config); return only !row.disabled and (row.native || visible set contains row), then map toExportModel. Preserve native visibility semantics. +MODIFY src/cli/opencode.ts: use same canonical filter once for rows with non-native provider/id identity, then exclude those not retained before seen.add. Do not infer provider identities for legacy rows missing them; keep existing disabled and Direct-native checks. Preserve order, custom/combo aliases and per-row metadata; do not duplicate allowlist matching. +MODIFY tests/server/management-client-config-route.test.ts and tests/cli/cli-export-command.test.ts: fixtures with xai selectedModels=[grok-4.6], full three-model roster, blocklist override, empty allowlist, slash-bearing ids, disabled duplicate. Render both pi and aside through production loader and CLI projection. A nonempty allowlist retains only selected IDs; a ready provider with an empty allowlist retains its full otherwise-visible roster; pending initial selection keeps routed rows hidden. Management still offers all IDs. CLI consumers must reload their configured state after discovery because the request can persist the initial selection. +MODIFY docs-site/src/content/docs/guides/integrations.md: explain selected list applies to generated catalogs. + +Verifier: standalone synthetic imports of loadExportModels/exportModelsFromProxyRows plus Pi/Aside serializers; no bun:test. CI runs existing focused regressions, typecheck and full platform suite. Before C record exact source SHA and probe output. Stop after export boundaries agree; next cycle refreshes old owned files. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md new file mode 100644 index 0000000000..83f13e0879 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md @@ -0,0 +1,17 @@ +# 020 Converge already-owned Pi/Aside catalogs + +Depends on 010 filtered loader. Loop spec-satisfaction repair. Goal: a model visibility/selection change and explicit sync refresh existing connected Pi/Aside files. No adoption of unowned/manual files, no recreation of removed blocks, no override of drift. + +NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input) iterates explicit client list [pi, aside] (sync may include mcode), passes lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. +MODIFY src/server/management/model-routes.ts: local async convergence helper calls existing convergeCodexCatalog then new owned refresh for pi/aside with port from URL/config and lazy loadExportModels(config); attach clientIntegrations outcome to disabled-models, model-visibility, selected-models and model-preset writes. Keep successful config persistence even when one file refuses refresh; return warning outcome. +MODIFY src/server/management/config-routes.ts and src/cli/dispatch.ts: expand current MCode-only owned refresh to mcode/pi/aside via helper; preserve native Grok/Desktop gates and refused-sync behavior. +MODIFY existing tests/clients/sync-client-integrations.test.ts and tests/server/management-integration-routes.test.ts: fake IO/store or isolated home seeds owned pi/aside with two models, refresh with selected one, assert hidden row removed and other provider fields preserved. Prove unowned, removed and drifted configs untouched; one failure does not block other client. Add route-driven visibility refresh coverage using injected convergence. +UPDATE structure/09_client-integrations.md and owning docs page with ownership/refusal semantics. + +Verification: standalone isolated writer probe using synthetic models and temp homes, then exact-head hosted CI. C4 care for automatic owned-file writes: independent review must confirm ownership/no-clobber and per-client failure boundaries. Final enforcement is existing coordinated writer; refresh helper is an early caller, not a permission boundary. Known bypass: manually calling writer with explicit adoption; no such call in this unit. Stop when file projection converges or produces truthful refusal. + +## Audit amendment: overlapping refreshes + +The existing constant refresh mutation-flight key incorrectly joins different model selections. MODIFY src/integrations/owned-refresh.ts to use a unique per-refresh operation key (crypto.randomUUID), making overlapping refreshes explicitly busy rather than reporting another desired catalog as success. Implicit refresh never joins an explicit HTTP mutation. Add controlled overlap with distinct old/new rosters: second call reports integration_mutation_busy; first result describes only its own write. Subsequent retry applies the new roster. Return per-client failures; never retry stale snapshots automatically. + +Add a ManagementApiDeps refreshOwnedCatalogIntegrations seam for route verification, defaulting to the real helper. Creation: exported helper/deps type; consumption: model routes and explicit sync. No serialization/deserialization: runtime-only dependency injection. Tests use fake IO/store or temporary home, never actual user-owned files. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md new file mode 100644 index 0000000000..84743c0fa9 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md @@ -0,0 +1,13 @@ +# 030 Codex native Responses patch completion parity + +Depends on recorded export layers for stack delivery; runtime independent. Class C3, spec-satisfaction repair. Goal: same repaired executable input at deltas/input.done/item.done/response.completed for complete patches misrouted as exec. No arbitrary JavaScript rewriting. + +MODIFY src/server/responses-custom-tool-repair.ts: register same-name routed custom calls in addition to aliases. Track original wire name and target; hold custom input deltas when code-mode exec may be a patch envelope or when helper alias requires compilation. Accumulate under TranslatorBudget, release on done/dispose. Run restoreRoutedCustomCalls for same-name custom items, and use existing resolveCodeModeHelperName/compileCodeModeHelperInput at input.done. Do not place exec in repairNames. Preserve ordinary JavaScript streaming where monotonic; once raw prefix would diverge, withhold to authoritative completion. Suppress function helper-alias progressive previews rather than emitting raw patch before compiled JS. +MODIFY tests/responses/responses-custom-tool-repair.test.ts: native custom exec raw/wrapped complete patch, fragmented marker, input.done and output_item.done plus terminal snapshots; function apply_patch wrapper alias; invalid/incomplete envelopes and valid JS remain exact; flat catalogs and foreign namespaces do not retarget; cancellation frees retained buffers. +UPDATE existing patch compatibility docs and structure/11_compatibility-contracts.md to describe completion-boundary parity. + +Verifier: pure standalone synthetic SSE-block imports, compare outputs at each lifecycle edge and execute generated JS against a recording tools.apply_patch stub (no filesystem writes). Probe must assert monotonic preview or held preview, one call, exact canonical patch data. CI runs added regressions and existing bridge/native compatibility tests plus full suite/typecheck. Complete only after independent review and exact-head CI; register requested stack and admin merge after verified heads. Fetch dev and prove every merge SHA ancestor. D records parity inventory and public PR links. + +## Audit amendment + +Executable repair is limited to authorized code-mode exec and recognized helper aliases; unrelated same-name native custom tools keep raw input byte-for-byte. Explicit negative: render_diagram input JSON string {"input":"literal"} is not unwrapped. Separate scenarios cover missing input.done, terminal-only completion, failed/incomplete after held deltas, and disposal. Authoritative completion wins over previews. Failure never synthesizes successful completion. All retained buffers release. One simulated execution means choose the client-consumed completed item once, not execute every redundant lifecycle representation. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 06a98ea33e..9694394318 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -21,6 +21,10 @@ file, and removes it again. Twelve clients work this way, each with a switch: | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | +Generated catalogs include only enabled models from each provider selection. This applies to both +downloads and managed integrations, including Pi and Aside. The management model list still shows +the full roster so you can enable additional models. + The managed OpenCode integration owns two fragments: `provider.opencodex` (opencode V1) and `providers.opencodex` (opencode V2). Only the V2 block carries the per-model reasoning-effort variants, so both are written and kept in sync; they name the same provider and model ids, and diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 73e47552d4..c576435432 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -171,12 +171,14 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep rejectArgs(args, USAGE); const spec = EXPORT_CLIENTS[client]; - const config = (deps.configImpl ?? loadConfig)(); const root = await runtimeBaseUrl(deps); const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); if (!Array.isArray(rows)) { throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); } + // Discovery can persist pending -> ready selection. Read from the caller's + // config source after the response, rather than filtering with a stale snapshot. + const config = (deps.configImpl ?? loadConfig)(); const models = exportModelsFromProxyRows(rows, config); // The text is the client's OWN format — YAML, TOML and JSON5 clients would // otherwise receive a JSON rendering their parser reads differently. diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index d56f5745e5..adcdea1095 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -39,7 +39,7 @@ import type { OpencodeProviderBlocks, OpencodeV2ProviderBlock, } from "../clients/config-export"; -import { visibleNativeSlugs } from "../codex/catalog"; +import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { providerCodexAccountMode } from "../providers/registry"; @@ -376,12 +376,17 @@ export function opencodeCatalogFromProxyRows( config: OcxConfig, ): OpencodeCatalogModel[] { const omitNative = providerCodexAccountMode("openai", config.providers?.openai) === "direct"; + const routedRows = rows.filter((row): row is OpencodeProxyModelRow & { provider: string; id: string } => + row.native !== true && typeof row.provider === "string" && typeof row.id === "string"); + const visibleRouted = new Set(filterCatalogVisibleModels(routedRows, config)); const seen = new Set(); const catalog: OpencodeCatalogModel[] = []; for (const row of rows) { const namespaced = row.namespaced?.trim(); if (!namespaced || row.disabled === true) continue; if (omitNative && row.native === true) continue; + if (row.native !== true && typeof row.provider === "string" && typeof row.id === "string" + && !visibleRouted.has(row)) continue; if (seen.has(namespaced)) continue; seen.add(namespaced); catalog.push({ @@ -632,14 +637,14 @@ export function opencodeNotFoundHint( } export async function cmdOpencode(args: string[]): Promise { - const config = loadConfig(); - const live = await ensureProxyForOpencode(config); + const startupConfig = loadConfig(); + const live = await ensureProxyForOpencode(startupConfig); if (!live) { console.error("❌ Proxy did not become healthy after starting."); return 1; } - const apiKey = opencodeApiKey(config); + const apiKey = opencodeApiKey(startupConfig); let proxyModels: OpencodeProxyModelRow[]; try { proxyModels = await fetchOpencodeProxyModels(live, apiKey); @@ -648,6 +653,8 @@ export async function cmdOpencode(args: string[]): Promise { console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); return 1; } + // /api/models may have completed and persisted initial provider selection. + const config = loadConfig(); const catalog = opencodeCatalogFromProxyRows(proxyModels, config); const blocks = buildOpencodeProviderBlocksFromCatalog(live.port, catalog, live.hostname, config); const baseUrl = blocks.v1.options.baseURL; diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 4635a9fbfd..6d9ec08853 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -11,6 +11,7 @@ import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, + filterCatalogVisibleModels, accountBoundNativeOpenAiSlugsBySelector, nativeDefaultReasoningEffort, NATIVE_OPENAI_MODELS, @@ -217,5 +218,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { */ export async function loadExportModels(config: OcxConfig): Promise { const rows = await listManagementModelRows(config); - return rows.filter(row => !row.disabled).map(toExportModel); + // Management deliberately lists the full roster so hidden models can be enabled. + // A client picker must also honor the provider selection, not just its blocklist. + const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config)); + return rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); } diff --git a/structure/09_client-integrations.md b/structure/09_client-integrations.md index 0b1f7cc181..0ae6c15e78 100644 --- a/structure/09_client-integrations.md +++ b/structure/09_client-integrations.md @@ -40,6 +40,13 @@ TOML temporal scalars cannot survive the JSON-cloned merge representation with t intact. The common parser refuses documents containing them before either status or mutation proceeds, including nested arrays and inline tables. Quoted date strings remain supported. +## Catalog visibility + +Management export and CLI export apply the canonical routed catalog visibility filter before +serialization: provider selections, disabled models, and pending initial selection all constrain +the client roster. The full management list remains available for selection. Native rows retain +their existing visibility rules. + ## Fast model selectors The serving proxy resolves `fastRowAvailable` on every management model row, including its diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 28fce31d44..4c9808677a 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -430,3 +430,116 @@ describe("export row filtering", () => { expect(model?.defaultReasoningEffort).toBe("high"); }); }); + +describe("export allowlist parity", () => { + test("the first export rereads selection completed during model discovery", async () => { + const previous = process.env.OPENCODEX_HOME; + const home = tempDir(); + const path = join(home, "config.json"); + const pending = config({ + defaultProvider: "pending", fastRows: false, + providers: { pending: { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", liveModels: false, + models: ["chosen", "other"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } }, + }); + const ready = structuredClone(pending); + ready.providers.pending!.initialModelSelection!.status = "ready"; + ready.providers.pending!.selectedModels = ["chosen"]; + const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); + expect(exportModelsFromProxyRows(rows, pending)).toEqual([]); + let requests = 0; + try { + process.env.OPENCODEX_HOME = home; + writeFileSync(path, JSON.stringify(pending)); + const code = await handleExportCommand(["--client", "pi", "--json"], { + baseUrl: "http://127.0.0.1:10123", + fetchImpl: async input => { + expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); + requests += 1; + // The server publishes its finalized selection before returning the rows. + writeFileSync(path, JSON.stringify(ready)); + return Response.json(rows); + }, + }); + expect(code).toBe(0); + expect(requests).toBe(1); + expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + .toEqual(["pending/chosen"]); + expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + + test("post-discovery filtering retains injected config provenance instead of reading local policy", async () => { + const previous = process.env.OPENCODEX_HOME; + const home = tempDir(); + const path = join(home, "config.json"); + const local = config({ providers: { custom: { + adapter: "openai-chat", baseUrl: "https://local.example.test/v1", selectedModels: ["local-only"], + } } }); + const remote = config({ providers: { custom: { + adapter: "openai-chat", baseUrl: "https://remote.example.test/v1", selectedModels: ["remote-only"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } } }); + const ready = structuredClone(remote); + ready.providers.custom!.initialModelSelection!.status = "ready"; + let resolved = remote; + const events: string[] = []; + try { + process.env.OPENCODEX_HOME = home; + const localBytes = JSON.stringify(local); + writeFileSync(path, localBytes); + const code = await handleExportCommand(["--client", "pi", "--json"], { + baseUrl: "http://127.0.0.1:10123", + configImpl: () => { events.push("config"); return structuredClone(resolved); }, + fetchImpl: async () => { + events.push("fetch"); + resolved = ready; + return Response.json(["local-only", "remote-only"].map(id => ({ provider: "custom", id, namespaced: `custom/${id}` }))); + }, + }); + expect(code).toBe(0); + expect(events).toEqual(["fetch", "config"]); + expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + .toEqual(["custom/remote-only"]); + expect(readFileSync(path, "utf8")).toBe(localBytes); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + + test("filters the full management roster before deduplication and keeps other providers", () => { + const cfg = config(); + cfg.providers.xai = { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", + selectedModels: ["grok-4.6"], + }; + const rows = [ + { provider: "xai", id: "grok-4.5", namespaced: "xai/grok-4.5", disabled: false }, + { provider: "xai", id: "grok-4.6", namespaced: "xai/grok-4.6", disabled: true }, + { provider: "xai", id: "grok-4.6", namespaced: "xai/grok-4.6", reasoningEfforts: ["high"] }, + { provider: "other", id: "model", namespaced: "other/model" }, + ]; + const exported = exportModelsFromProxyRows(rows, cfg); + expect(exported.map(row => row.namespaced)).toEqual(["xai/grok-4.6", "other/model"]); + expect(exported[0]!.reasoningEfforts).toEqual(["high"]); + cfg.disabledModels = ["xai/grok-4.6"]; + expect(exportModelsFromProxyRows(rows, cfg).map(row => row.namespaced)).toEqual(["other/model"]); + }); + + test("uses the catalog's encoded-id selection equivalence", () => { + const cfg = config(); + cfg.providers.slash = { + adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", selectedModels: ["org-model"], + }; + expect(exportModelsFromProxyRows([ + { provider: "slash", id: "org/model", namespaced: "slash/org-model" }, + { provider: "slash", id: "other", namespaced: "slash/other" }, + ], cfg).map(row => row.namespaced)).toEqual(["slash/org-model"]); + }); +}); diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index e1aed8a045..4483e5ea99 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearModelCache } from "../../src/codex/model-cache"; @@ -16,6 +17,7 @@ import { buildOpencodeProviderBlockFromCatalog, buildOpencodeProviderBlocksFromCatalog, buildOpencodeV2ProviderBlock, + cmdOpencode, fetchOpencodeProxyModels, isOpencodeRuntimeConfigError, mergeOpencodeRuntimeConfig, @@ -33,6 +35,7 @@ import { serializeOpencodeRuntimeConfig, } from "../../src/cli/opencode"; import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; function cfg(extra?: Partial): OcxConfig { return { @@ -236,6 +239,69 @@ describe("ocx opencode proxy model catalog", () => { const RESOLVED = "proxy-only-resolved-key"; const PROVIDER = "proxyenv"; + test("the first launcher reads selection persisted during /api/models before building both provider blocks", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-opencode-discovery-selection-")); + const envKeys = ["OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", OPENCODE_CONFIG_CONTENT_ENV]; + const previous = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + const configPath = join(home, "config.json"); + const pending = cfg({ + defaultProvider: "pending", fastRows: false, + providers: { pending: { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", liveModels: false, + models: ["chosen", "other"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } }, + }); + const ready = structuredClone(pending); + ready.providers.pending!.initialModelSelection!.status = "ready"; + ready.providers.pending!.selectedModels = ["chosen"]; + const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); + expect(opencodeCatalogFromProxyRows(rows, pending)).toEqual([]); + const liveness = await import("../../src/server/proxy-liveness"); + const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ + port: 10123, hostname: "127.0.0.1", pid: null, source: "config", + }); + const fetcher = spyOn(globalThis, "fetch").mockImplementation(async input => { + expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); + expect(JSON.parse(readFileSync(configPath, "utf8")).providers.pending.initialModelSelection.status).toBe("pending"); + writeFileSync(configPath, JSON.stringify(ready)); + return Response.json(rows); + }); + let inline = ""; + // Exercise cmdOpencode through env construction without launching an installed + // OpenCode or proxy process. All config reads still use the actual temp files. + const spawn = spyOn(childProcess, "spawn").mockImplementation((...args) => { + inline = args[2]?.env?.[OPENCODE_CONFIG_CONTENT_ENV] ?? ""; + const child = new childProcess.ChildProcess(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + try { + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + process.env.XDG_CONFIG_HOME = join(home, "xdg"); + delete process.env[OPENCODE_CONFIG_CONTENT_ENV]; + mkdirSync(process.env.CODEX_HOME); + writeFileSync(configPath, JSON.stringify(pending)); + expect(await cmdOpencode([])).toBe(0); + expect(finder).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledTimes(1); + const injected = JSON.parse(inline); + expect(Object.keys(injected.provider.opencodex.models)).toEqual(["pending/chosen"]); + expect(Object.keys(injected.providers.opencodex.models)).toEqual(["pending/chosen"]); + expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); + } finally { + finder.mockRestore(); fetcher.mockRestore(); spawn.mockRestore(); stderr.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }); + test("uses /api/models namespaced selectors and resolves env-backed provider keys only in the proxy", async () => { const originalFetch = globalThis.fetch; let requestedAuth: string | undefined; diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 1f3f2b2908..7ed7fd17fe 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -8,7 +8,7 @@ import { seedCodexModelEntitlementsForTests, } from "../../src/codex/model-entitlements"; import { handleManagementAPI } from "../../src/server/management-api"; -import { loadExportModels } from "../../src/server/management/model-rows"; +import { listManagementModelRows, loadExportModels } from "../../src/server/management/model-rows"; import { OPENCODE_API_KEY_ENV, OPENCODE_CONFIG_SCHEMA, @@ -645,3 +645,32 @@ describe("default Fast availability reaches external exports", () => { expect(result.providers.opencodex.models.map(model => model.id)).not.toContain("fixture/m--fast"); }); }); + +describe("Pi and Aside provider selection", () => { + test.each(["pi", "aside"] as const)("%s exports selected Grok models while management retains the full roster", async client => { + const config = baseConfig({ + fastRows: false, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", + liveModels: false, models: ["grok-4.6", "grok-4.5", "grok-4.3"], + selectedModels: ["grok-4.6"], + }, + }, + }); + const ids = async () => { + const models = await loadExportModels(config); + const doc = buildClientConfig(client, { baseUrl: "http://127.0.0.1:10100/v1", config, models }) as PiGeneratedConfig; + return doc.providers.opencodex!.models.map(model => model.id).filter(id => id.startsWith("xai/")); + }; + const management = await listManagementModelRows(config); + expect(management.filter(row => row.provider === "xai")).toHaveLength(3); + expect(await ids()).toEqual(["xai/grok-4.6"]); + config.disabledModels = ["xai/grok-4.6"]; + expect(await ids()).toEqual([]); + config.disabledModels = []; + config.providers.xai!.selectedModels = []; + expect(await ids()).toEqual(["xai/grok-4.3", "xai/grok-4.5", "xai/grok-4.6"]); + }); +}); diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index 28050c5369..caaad660aa 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -38,6 +38,35 @@ test("replay does not recover unseen envelopes, other parents, or other callers" expect(calls).toBe(1); }); +test("a rotated token for the same account cannot reuse the previous credential's recovery", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response(recoverySse(calls === 1 ? "Original credential assignment." : "Rotated credential assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const exp = Math.floor(Date.now() / 1000) + 3_600; + const headers = codexHeaders("acct-caller"); + headers.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp })}`); + const rotatedHeaders = new Headers(headers); + rotatedHeaders.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp: exp + 1 })}`); + const original = new Request("http://localhost/v1/responses", { headers }); + const rotated = new Request("http://localhost/v1/responses", { headers: rotatedHeaders }); + expect(await recoverEncryptedAgentTask(original, encryptedInput(), {}, config)).toBe(true); + const missed = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(rotated, missed, config)).toBe(0); + expect(missed).toEqual(encryptedInput()); + expect(calls).toBe(1); + const replay = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(original, replay, config)).toBe(1); + expect(JSON.stringify(replay)).toContain("Original credential assignment."); + // The rotated credential is valid, but must perform its own admitted recovery. + const fresh = encryptedInput(); + expect(await recoverEncryptedAgentTask(rotated, fresh, {}, config)).toBe(true); + expect(JSON.stringify(fresh)).toContain("Rotated credential assignment."); + expect(calls).toBe(2); +}); + test("Responses handler restores a cached task in a continued child turn", async () => { const { post, providerResponse } = await import("../helpers/agent-task-recovery"); let recoveries = 0;