fix(opencode): refresh isolated model cache

This commit is contained in:
Dotta 2026-09-03 23:45:37 -05:00
parent 24917f8293
commit 556785beab
7 changed files with 282 additions and 38 deletions

View File

@ -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

View File

@ -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",

View File

@ -29,14 +29,26 @@ function resolveOpenCodeCommand(input: unknown): string {
return asString(input, envOverride);
}
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
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<string, string> {
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const envInput =
typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
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<string, string>) {
function discoveryCacheKey(
command: string,
cwd: string,
env: Record<string, string>,
) {
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<AdapterModel[]> {
export async function discoverOpenCodeModels(
input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
refresh?: boolean;
} = {},
): Promise<AdapterModel[]> {
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<AdapterModel[]> {
export async function discoverOpenCodeModelsCached(
input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {},
): Promise<AdapterModel[]> {
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<AdapterModel[]> {
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 ? ", ..." : ""}`,
);

View File

@ -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,

View File

@ -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),
),

View File

@ -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();

View File

@ -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'",