From 556785beab7c97b688bae146e670491dcd2b8286 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 3 Sep 2026 23:45:37 -0500 Subject: [PATCH] fix(opencode): refresh isolated model cache --- .github/workflows/runner-full-stack-e2e.yml | 1 + .../opencode-local/src/server/models.test.ts | 171 ++++++++++++++++-- .../opencode-local/src/server/models.ts | 137 +++++++++++--- tests/runner-e2e/harness-env.ts | 5 + tests/runner-e2e/launch.ts | 1 + tests/runner-e2e/support.test.ts | 2 + tests/runner-e2e/workflow-security.test.ts | 3 + 7 files changed, 282 insertions(+), 38 deletions(-) diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 1e49688578..7b371a48c8 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -931,6 +931,7 @@ jobs: PAPERCLIP_E2E_CAMPAIGN_ID: gha-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.executionId }} PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }} PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }} + XDG_CACHE_HOME: ${{ runner.temp }}/paperclip-runner-e2e-xdg-cache run: pnpm test:e2e:runner -- --id "${{ matrix.executionId }}" - name: Upload access-controlled packaged cell evidence diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts index b4fde5986a..153096d29a 100644 --- a/packages/adapters/opencode-local/src/server/models.test.ts +++ b/packages/adapters/opencode-local/src/server/models.test.ts @@ -18,7 +18,8 @@ describe("openCode models", () => { }); it("returns an empty list when discovery command is unavailable", async () => { - process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + process.env.PAPERCLIP_OPENCODE_COMMAND = + "__paperclip_missing_opencode_command__"; await expect(listOpenCodeModels()).resolves.toEqual([]); }); @@ -29,7 +30,9 @@ describe("openCode models", () => { }); it("accepts a provider/model id without running discovery", () => { - expect(requireOpenCodeModelId("openai/gpt-5.2-codex")).toBe("openai/gpt-5.2-codex"); + expect(requireOpenCodeModelId("openai/gpt-5.2-codex")).toBe( + "openai/gpt-5.2-codex", + ); }); it("rejects malformed provider/model ids before discovery", () => { @@ -42,7 +45,8 @@ describe("openCode models", () => { }); it("proceeds with the configured model when discovery cannot run (probe is best-effort, never fatal)", async () => { - process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + process.env.PAPERCLIP_OPENCODE_COMMAND = + "__paperclip_missing_opencode_command__"; await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "openai/gpt-5", @@ -51,23 +55,35 @@ describe("openCode models", () => { }); it("skips the availability check when OPENCODE_ALLOW_ALL_MODELS is set in the run env", async () => { - process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + process.env.PAPERCLIP_OPENCODE_COMMAND = + "__paperclip_missing_opencode_command__"; await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", env: { OPENCODE_ALLOW_ALL_MODELS: "true" }, }), ).resolves.toEqual([ - { id: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", label: "anthropic/tensorix/deepseek/deepseek-chat-v3.1" }, + { + id: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", + label: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", + }, ]); }); it("honours OPENCODE_ALLOW_ALL_MODELS from the process env", async () => { - process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + process.env.PAPERCLIP_OPENCODE_COMMAND = + "__paperclip_missing_opencode_command__"; process.env.OPENCODE_ALLOW_ALL_MODELS = "1"; await expect( - ensureOpenCodeModelConfiguredAndAvailable({ model: "anthropic/gateway/some-model" }), - ).resolves.toEqual([{ id: "anthropic/gateway/some-model", label: "anthropic/gateway/some-model" }]); + ensureOpenCodeModelConfiguredAndAvailable({ + model: "anthropic/gateway/some-model", + }), + ).resolves.toEqual([ + { + id: "anthropic/gateway/some-model", + label: "anthropic/gateway/some-model", + }, + ]); }); it("still enforces provider/model format when OPENCODE_ALLOW_ALL_MODELS is set", async () => { @@ -120,20 +136,147 @@ describe("openCode models", () => { expect(spy).toHaveBeenCalledTimes(3); }); - it("surfaces the last error once retries are exhausted", async () => { - vi.useFakeTimers(); + it("refreshes a stale non-empty catalog before rejecting the configured model", async () => { const spy = vi .spyOn(serverUtils, "runChildProcess") - .mockResolvedValue({ - exitCode: 1, + .mockResolvedValueOnce({ + exitCode: 0, signal: null, timedOut: false, - stdout: "", - stderr: "queued behind another opencode run", + stdout: "openrouter/example/stale-model\n", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: + "openrouter/example/stale-model\nopenrouter/deepseek/deepseek-v4-flash-0731\n", + stderr: "", pid: 1, startedAt: new Date().toISOString(), }); + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }), + ).resolves.toContainEqual({ + id: "openrouter/deepseek/deepseek-v4-flash-0731", + label: "openrouter/deepseek/deepseek-v4-flash-0731", + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[0]?.[2]).toEqual(["models"]); + expect(spy.mock.calls[1]?.[2]).toEqual(["models", "--refresh"]); + }); + + it("still rejects when a refreshed non-empty catalog omits the configured model", async () => { + const spy = vi + .spyOn(serverUtils, "runChildProcess") + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "openrouter/example/stale-model\n", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "openrouter/example/current-model\n", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }); + + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }), + ).rejects.toThrow( + "Configured OpenCode model is unavailable: openrouter/deepseek/deepseek-v4-flash-0731", + ); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1]?.[2]).toEqual(["models", "--refresh"]); + }); + + it("still rejects from the original catalog when refresh returns no models", async () => { + const spy = vi + .spyOn(serverUtils, "runChildProcess") + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "openrouter/example/stale-model\n", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }); + + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }), + ).rejects.toThrow("Available models: openrouter/example/stale-model"); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1]?.[2]).toEqual(["models", "--refresh"]); + }); + + it("still rejects from the original catalog when refresh fails", async () => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const spy = vi + .spyOn(serverUtils, "runChildProcess") + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "openrouter/example/stale-model\n", + stderr: "", + pid: 1, + startedAt: new Date().toISOString(), + }) + .mockRejectedValueOnce(new Error("refresh unavailable")); + + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }), + ).rejects.toThrow("Available models: openrouter/example/stale-model"); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1]?.[2]).toEqual(["models", "--refresh"]); + expect(warning).toHaveBeenCalledWith( + expect.stringContaining( + 'refresh failed for "openrouter/deepseek/deepseek-v4-flash-0731"', + ), + ); + }); + + it("surfaces the last error once retries are exhausted", async () => { + vi.useFakeTimers(); + const spy = vi.spyOn(serverUtils, "runChildProcess").mockResolvedValue({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "queued behind another opencode run", + pid: 1, + startedAt: new Date().toISOString(), + }); + const promise = discoverOpenCodeModels(); const assertion = expect(promise).rejects.toThrow( "`opencode models` failed: queued behind another opencode run", diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index 5af8c203c9..92199830f6 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -29,14 +29,26 @@ function resolveOpenCodeCommand(input: unknown): string { return asString(input, envOverride); } -const discoveryCache = new Map(); +const discoveryCache = new Map< + string, + { expiresAt: number; models: AdapterModel[] } +>(); const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const; -const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID", "HOME"]); +const VOLATILE_ENV_KEY_EXACT = new Set([ + "PWD", + "OLDPWD", + "SHLVL", + "_", + "TERM_SESSION_ID", + "HOME", +]); export function requireOpenCodeModelId(input: unknown): string { const model = asString(input, "").trim(); if (!isValidOpenCodeModelId(model)) { - throw new Error("OpenCode requires `adapterConfig.model` in provider/model format."); + throw new Error( + "OpenCode requires `adapterConfig.model` in provider/model format.", + ); } return model; } @@ -84,9 +96,10 @@ export function parseOpenCodeModelsOutput(stdout: string): AdapterModel[] { } function normalizeEnv(input: unknown): Record { - const envInput = typeof input === "object" && input !== null && !Array.isArray(input) - ? (input as Record) - : {}; + const envInput = + typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Record) + : {}; const env: Record = {}; for (const [key, value] of Object.entries(envInput)) { if (typeof value === "string") env[key] = value; @@ -103,7 +116,11 @@ function hashValue(value: string): string { return createHash("sha256").update(value).digest("hex"); } -function discoveryCacheKey(command: string, cwd: string, env: Record) { +function discoveryCacheKey( + command: string, + cwd: string, + env: Record, +) { const envKey = Object.entries(env) .filter(([key]) => !isVolatileEnvKey(key)) .sort(([a], [b]) => a.localeCompare(b)) @@ -118,11 +135,14 @@ function pruneExpiredDiscoveryCache(now: number) { } } -export async function discoverOpenCodeModels(input: { - command?: unknown; - cwd?: unknown; - env?: unknown; -} = {}): Promise { +export async function discoverOpenCodeModels( + input: { + command?: unknown; + cwd?: unknown; + env?: unknown; + refresh?: boolean; + } = {}, +): Promise { const command = resolveOpenCodeCommand(input.command); const cwd = asString(input.cwd, process.cwd()); const env = normalizeEnv(input.env); @@ -139,7 +159,14 @@ export async function discoverOpenCodeModels(input: { // image). Fall back to process.env.HOME. } // Prevent OpenCode from writing an opencode.json into the working directory. - const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env, ...(resolvedHome ? { HOME: resolvedHome } : {}), OPENCODE_DISABLE_PROJECT_CONFIG: "true" })); + const runtimeEnv = normalizeEnv( + ensurePathInEnv({ + ...process.env, + ...env, + ...(resolvedHome ? { HOME: resolvedHome } : {}), + OPENCODE_DISABLE_PROJECT_CONFIG: "true", + }), + ); const maxAttempts = MODELS_DISCOVERY_RETRY_DELAYS_MS.length + 1; let lastError: Error | undefined; @@ -148,7 +175,7 @@ export async function discoverOpenCodeModels(input: { const result = await runChildProcess( `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, command, - ["models"], + ["models", ...(input.refresh ? ["--refresh"] : [])], { cwd, env: runtimeEnv, @@ -159,10 +186,17 @@ export async function discoverOpenCodeModels(input: { ); if (result.timedOut) { - lastError = new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`); + lastError = new Error( + `\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`, + ); } else if ((result.exitCode ?? 1) !== 0) { - const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); - lastError = new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed."); + const detail = + firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); + lastError = new Error( + detail + ? `\`opencode models\` failed: ${detail}` + : "`opencode models` failed.", + ); } else { return sortModels(parseOpenCodeModelsOutput(result.stdout)); } @@ -175,11 +209,13 @@ export async function discoverOpenCodeModels(input: { throw lastError ?? new Error("`opencode models` failed."); } -export async function discoverOpenCodeModelsCached(input: { - command?: unknown; - cwd?: unknown; - env?: unknown; -} = {}): Promise { +export async function discoverOpenCodeModelsCached( + input: { + command?: unknown; + cwd?: unknown; + env?: unknown; + } = {}, +): Promise { const command = resolveOpenCodeCommand(input.command); const cwd = asString(input.cwd, process.cwd()); const env = normalizeEnv(input.env); @@ -194,6 +230,29 @@ export async function discoverOpenCodeModelsCached(input: { return models; } +async function refreshOpenCodeModelsCached(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +}): Promise { + const command = resolveOpenCodeCommand(input.command); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + const models = await discoverOpenCodeModels({ + command, + cwd, + env, + refresh: true, + }); + if (models.length > 0) { + discoveryCache.set(discoveryCacheKey(command, cwd, env), { + expiresAt: Date.now() + MODELS_CACHE_TTL_MS, + models, + }); + } + return models; +} + export function isTruthyEnvFlag(value: string | undefined): boolean { if (value === undefined) return false; const v = value.trim().toLowerCase(); @@ -214,7 +273,11 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: { // we still enforce the provider/model format above and do not second-guess // the configured model. Prefer the explicit run env, then the process env. const env = normalizeEnv(input.env); - if (isTruthyEnvFlag(env.OPENCODE_ALLOW_ALL_MODELS ?? process.env.OPENCODE_ALLOW_ALL_MODELS)) { + if ( + isTruthyEnvFlag( + env.OPENCODE_ALLOW_ALL_MODELS ?? process.env.OPENCODE_ALLOW_ALL_MODELS, + ) + ) { return [{ id: model, label: model }]; } @@ -250,7 +313,33 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: { } if (!models.some((entry) => entry.id === model)) { - const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); + // `opencode models` reads a persistent models.dev cache. Long-lived runner + // hosts can therefore report a stale non-empty catalog even while the + // configured provider serves the model. Refresh once before treating a + // cached miss as authoritative; a successful refresh that still omits the + // model retains the strict availability rejection below. + try { + const refreshedModels = await refreshOpenCodeModelsCached({ + command: input.command, + cwd: input.cwd, + env: input.env, + }); + if (refreshedModels.some((entry) => entry.id === model)) { + return refreshedModels; + } + if (refreshedModels.length > 0) models = refreshedModels; + } catch (err) { + console.warn( + `[opencode-local] Model availability refresh failed for "${model}" (${ + err instanceof Error ? err.message : String(err) + }); preserving the cached availability rejection.`, + ); + } + + const sample = models + .slice(0, 12) + .map((entry) => entry.id) + .join(", "); throw new Error( `Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, ); diff --git a/tests/runner-e2e/harness-env.ts b/tests/runner-e2e/harness-env.ts index d591e859f6..8a98fe1a73 100644 --- a/tests/runner-e2e/harness-env.ts +++ b/tests/runner-e2e/harness-env.ts @@ -136,6 +136,11 @@ export function assertIsolatedServerEnvironment( "Paperclip server paths escape the isolated temporary root", ); } + if (env.XDG_CACHE_HOME !== path.join(expected.temporaryRoot, "xdg-cache")) { + throw new Error( + "Paperclip server cache does not use the allocated temporary root", + ); + } for (const key of [ ...CREDENTIAL_NAMES, ...DATABASE_KEYS, diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index d0fe4ef3d2..1830d4b424 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -397,6 +397,7 @@ async function runAttempt(input: { const childEnv: NodeJS.ProcessEnv = { ...process.env, PATH: providerPath, + XDG_CACHE_HOME: path.join(temporaryRoot, "xdg-cache"), PAPERCLIP_RUNNER_E2E_EXECUTION_IDS: JSON.stringify( executions.map((candidate) => candidate.id), ), diff --git a/tests/runner-e2e/support.test.ts b/tests/runner-e2e/support.test.ts index c1e1f8a8c5..f1fe348abf 100644 --- a/tests/runner-e2e/support.test.ts +++ b/tests/runner-e2e/support.test.ts @@ -596,6 +596,7 @@ describe("runner E2E server isolation", () => { { PAPERCLIP_HOME: "/tmp/cell/paperclip-home", PAPERCLIP_CONFIG: "/tmp/cell/paperclip-home/instances/e2e/config.json", + XDG_CACHE_HOME: "/tmp/cell/xdg-cache", PAPERCLIP_AGENT_JWT_SECRET: "generated-agent-jwt", PAPERCLIP_DECISION_SIGNING_SECRET: "generated-decision-key", PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "generated-tool-key", @@ -608,6 +609,7 @@ describe("runner E2E server isolation", () => { expect(env.OPENAI_ORG_ID).toBeUndefined(); expect(env.PAPERCLIP_API_KEY).toBeUndefined(); expect(env.PAPERCLIP_AGENT_API_KEY).toBeUndefined(); + expect(env.XDG_CACHE_HOME).toBe("/tmp/cell/xdg-cache"); expect(env.PAPERCLIP_AGENT_JWT_SECRET).toBe("generated-agent-jwt"); expect(env.PAPERCLIP_TASK_BRIDGE_TOKEN).toBeUndefined(); expect(env.PAPERCLIP_SETUP_TOKEN).toBeUndefined(); diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 240a59cc5d..44f2549980 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -398,6 +398,9 @@ describe("public repository paid workflow security", () => { "PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }}", ); } + expect(paidJob).toContain( + "XDG_CACHE_HOME: ${{ runner.temp }}/paperclip-runner-e2e-xdg-cache", + ); for (const [secret, condition] of Object.entries({ OPENAI_API_KEY: "matrix.credentialName == 'OPENAI_API_KEY'", ANTHROPIC_API_KEY: "matrix.credentialName == 'ANTHROPIC_API_KEY'",