From ab6eb5b009795b75f4ea8aad1ebe7b8390248b9f Mon Sep 17 00:00:00 2001 From: Mohamed Habib Date: Mon, 14 Sep 2026 15:40:07 -0700 Subject: [PATCH 1/2] fix: validate serverless agent model selections Compile exact static model selections into agent artifacts and reject dynamic selections that cannot be registered. Derive the authoritative registry from the digest-checked artifact at the public edge, propagate it through deployment registration, and expose the admitted registry/default in public responses. This makes invalid models fail at deployment instead of surfacing as runtime downgrades. --- cli/src/api.ts | 3 + cli/src/dev.test.ts | 3 + cli/src/dev.ts | 1 + cli/src/project.test.ts | 53 ++++++++++++ cli/src/project.ts | 69 +++++++++++---- .../api-edge/src/managed_agents.test.ts | 70 ++++++++++++++- .../api-edge/src/managed_agents.ts | 85 ++++++++++++++++++- 7 files changed, 266 insertions(+), 18 deletions(-) diff --git a/cli/src/api.ts b/cli/src/api.ts index f075ef9f..bd09d6a3 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -42,6 +42,8 @@ export interface ManagedAgentDeployment { projectDeploymentId?: string; localAgentId?: string; createdAt: string; + models?: Array<{ provider: string; model: string }>; + defaultModel?: { provider: string; model: string }; } export interface ManagedAgentEvent { @@ -964,6 +966,7 @@ export class OpenComputerClient { redirectOrigins?: Array<{ origin: string; pathPrefix?: string }>; }>; memory: MemoryDeclaration[]; + models: Array<{ provider: string; model: string }>; projectDeployment?: { id: string; digest: string; diff --git a/cli/src/dev.test.ts b/cli/src/dev.test.ts index 61d5d2a8..de5ef4f5 100644 --- a/cli/src/dev.test.ts +++ b/cli/src/dev.test.ts @@ -87,6 +87,9 @@ test("development publish builds an immutable artifact under the development ali assert.equal(input?.agentId, "hello-agent"); assert.equal(input?.alias, "development"); assert.equal(input?.source.digest, result.built.digest); + assert.deepEqual(input?.models, [ + { provider: "openrouter", model: "anthropic/claude-sonnet-4.6" }, + ]); assert.match(result.deployment.id, /^hello-agent:[a-f0-9]{64}$/); } finally { await rm(parent, { recursive: true, force: true }); diff --git a/cli/src/dev.ts b/cli/src/dev.ts index 74c1cce8..c6bae34d 100644 --- a/cli/src/dev.ts +++ b/cli/src/dev.ts @@ -66,6 +66,7 @@ async function registerBuiltDeployment( connections: built.connections, httpConnections: built.httpConnections, memory: built.memory, + models: built.models, ...(projectDeployment ? { projectDeployment } : {}), source: { digest: built.digest, diff --git a/cli/src/project.test.ts b/cli/src/project.test.ts index 311a793b..67804920 100644 --- a/cli/src/project.test.ts +++ b/cli/src/project.test.ts @@ -424,6 +424,59 @@ export default function Agent() { } }); +test("the compiler enumerates literal model selections in a conditional", async () => { + const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-model-conditional-")); + try { + const initialized = await initializeAgentProject(resolve(parent, "app")); + await writeFile( + resolve(initialized.agentRoot, "agent.ts"), + `import { useInput, useModel } from "@opencomputer/agent"; +export default function Agent() { + const input = useInput(); + useModel(input.text?.includes("hard") + ? "anthropic/claude-sonnet-5" + : "anthropic/claude-haiku-4.5"); + return "Help with the request."; +} +`, + ); + + const runtime = await prepareAgent(initialized.agentRoot); + const manifest = JSON.parse( + await readFile(resolve(runtime, ".opencomputer", "reactive.json"), "utf8"), + ) as { models: Array<{ provider: string; model: string }> }; + assert.deepEqual(manifest.models, [ + { provider: "openrouter", model: "anthropic/claude-haiku-4.5" }, + { provider: "openrouter", model: "anthropic/claude-sonnet-5" }, + ]); + } finally { + await rm(parent, { recursive: true, force: true }); + } +}); + +test("the compiler rejects a model selection it cannot register", async () => { + const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-model-dynamic-")); + try { + const initialized = await initializeAgentProject(resolve(parent, "app")); + await writeFile( + resolve(initialized.agentRoot, "agent.ts"), + `import { useModel } from "@opencomputer/agent"; +const model = "anthropic/claude-sonnet-5"; +export default function Agent() { + useModel(model); + return "Help with the request."; +} +`, + ); + await assert.rejects( + prepareAgent(initialized.agentRoot), + /useModel\(\) must use a literal model selection/, + ); + } finally { + await rm(parent, { recursive: true, force: true }); + } +}); + test("the compiler records secret-backed HTTP connections without secret values", async () => { const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-egress-")); const root = resolve(parent, "app"); diff --git a/cli/src/project.ts b/cli/src/project.ts index ac8326ba..05d85eb8 100644 --- a/cli/src/project.ts +++ b/cli/src/project.ts @@ -41,6 +41,7 @@ export interface BuiltAgentArtifact { connections: string[]; httpConnections: HttpConnectionManifest[]; memory: MemoryDeclaration[]; + models: Array<{ provider: string; model: string }>; body: Buffer; digest: string; elapsedMs: number; @@ -2297,6 +2298,44 @@ function staticModelSelections( ts.ScriptKind.TS, ); const selections: Array<{ provider: string; model: string }> = []; + const selectionValues = ( + value: ts.Expression, + ): Array<{ provider: string; model: string }> | undefined => { + if (ts.isStringLiteralLike(value)) { + return [{ provider: "openrouter", model: value.text }]; + } + if (ts.isObjectLiteralExpression(value)) { + let provider: string | undefined; + let model: string | undefined; + for (const property of value.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const name = ts.isIdentifier(property.name) + ? property.name.text + : ts.isStringLiteralLike(property.name) + ? property.name.text + : undefined; + if (!name || !ts.isStringLiteralLike(property.initializer)) continue; + if (name === "provider") provider = property.initializer.text; + if (name === "model") model = property.initializer.text; + } + return provider && model ? [{ provider, model }] : undefined; + } + if (ts.isConditionalExpression(value)) { + const whenTrue = selectionValues(value.whenTrue); + const whenFalse = selectionValues(value.whenFalse); + return whenTrue && whenFalse ? [...whenTrue, ...whenFalse] : undefined; + } + if ( + ts.isParenthesizedExpression(value) || + ts.isAsExpression(value) || + ts.isTypeAssertionExpression(value) || + ts.isSatisfiesExpression(value) || + ts.isNonNullExpression(value) + ) { + return selectionValues(value.expression); + } + return undefined; + }; const visit = (node: ts.Node): void => { if ( ts.isCallExpression(node) && @@ -2304,23 +2343,14 @@ function staticModelSelections( node.expression.text === "useModel" ) { const value = node.arguments[0]; - if (value && ts.isStringLiteralLike(value)) { - selections.push({ provider: "openrouter", model: value.text }); - } else if (value && ts.isObjectLiteralExpression(value)) { - let provider: string | undefined; - let model: string | undefined; - for (const property of value.properties) { - if (!ts.isPropertyAssignment(property)) continue; - const name = ts.isIdentifier(property.name) - ? property.name.text - : ts.isStringLiteralLike(property.name) - ? property.name.text - : undefined; - if (!name || !ts.isStringLiteralLike(property.initializer)) continue; - if (name === "provider") provider = property.initializer.text; - if (name === "model") model = property.initializer.text; + if (value) { + const values = selectionValues(value); + if (!values) { + throw new Error( + "useModel() must use a literal model selection or a conditional whose branches are literal selections", + ); } - if (provider && model) selections.push({ provider, model }); + selections.push(...values); } } ts.forEachChild(node, visit); @@ -2333,6 +2363,10 @@ function staticModelSelections( candidate.provider === selection.provider && candidate.model === selection.model, ) === index, + ).sort((left, right) => + `${left.provider}/${left.model}`.localeCompare( + `${right.provider}/${right.model}`, + ), ); } @@ -2815,10 +2849,12 @@ export async function buildAgentArtifact( connections?: string[]; httpConnections?: HttpConnectionManifest[]; memory?: MemoryDeclaration[]; + models?: Array<{ provider: string; model: string }>; }; const connections = [...new Set(reactive.connections ?? [])].sort(); const httpConnections = reactive.httpConnections ?? []; const memory = reactive.memory ?? []; + const models = reactive.models ?? []; const body = Buffer.from( JSON.stringify({ version: 1, @@ -2833,6 +2869,7 @@ export async function buildAgentArtifact( connections, httpConnections, memory, + models, body, digest: createHash("sha256").update(body).digest("hex"), elapsedMs: Math.round(performance.now() - startedAt), diff --git a/cloudflare-workers/api-edge/src/managed_agents.test.ts b/cloudflare-workers/api-edge/src/managed_agents.test.ts index a221ed49..762e4092 100644 --- a/cloudflare-workers/api-edge/src/managed_agents.test.ts +++ b/cloudflare-workers/api-edge/src/managed_agents.test.ts @@ -1329,6 +1329,44 @@ describe("managed agents proxy", () => { }); }); + it("preserves the unavailable model id in deployment validation errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { + error: { + code: "invalid_model_selection", + message: + 'Model "openrouter/anthropic/claude-sonnet-999" is not available in the OpenRouter catalog', + }, + }, + { status: 409 }, + ), + ), + ); + + const response = await proxyManagedAgents( + new Request( + "https://app.opencomputer.dev/api/managed-agents/deployments/test", + ), + { + OC_MANAGED_AGENTS_SECRET: "test-secret", + MANAGED_AGENTS_API_URL: "https://managedagents.test", + }, + { orgID: "org_test", userID: "user_test" }, + "/api/managed-agents", + ); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: { + code: "invalid_model_selection", + message: + 'Model "openrouter/anthropic/claude-sonnet-999" is not available in the OpenRouter catalog', + }, + }); + }); + it("does not expose arbitrary private backend routes", async () => { const fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); @@ -1348,7 +1386,25 @@ describe("managed agents proxy", () => { }); it("uploads source without exposing provider details to the CLI", async () => { - const source = JSON.stringify({ version: 1, files: [] }); + const source = JSON.stringify({ + version: 1, + files: [ + { + path: ".opencomputer/reactive.json", + content: btoa( + JSON.stringify({ + version: 2, + models: [ + { + provider: "openrouter", + model: "anthropic/claude-sonnet-5", + }, + ], + }), + ), + }, + ], + }); const digestBytes = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(source), @@ -1435,6 +1491,12 @@ describe("managed agents proxy", () => { provider: { kind: "document", maxBytes: 8192 }, }, ], + models: [ + { + provider: "openrouter", + model: "anthropic/claude-sonnet-5", + }, + ], source: { digest, size: source.length, @@ -1480,6 +1542,12 @@ describe("managed agents proxy", () => { provider: { kind: "document", maxBytes: 8192 }, }, ], + models: [ + { + provider: "openrouter", + model: "anthropic/claude-sonnet-5", + }, + ], }); expect(JSON.stringify(await response.json())).not.toMatch( /bucket|imageArn|arn:aws|uploads\.test/i, diff --git a/cloudflare-workers/api-edge/src/managed_agents.ts b/cloudflare-workers/api-edge/src/managed_agents.ts index 04643570..b1fe813c 100644 --- a/cloudflare-workers/api-edge/src/managed_agents.ts +++ b/cloudflare-workers/api-edge/src/managed_agents.ts @@ -164,7 +164,9 @@ async function publicErrorResponse(upstream: Response): Promise { } else if (upstream.status === 404) { message = "The requested agent resource was not found."; } else if (upstream.status === 409) { - if (backendCode === "destination_verification_failed") { + if (backendCode === "invalid_model_selection") { + message = backendMessage || "The deployment selects an unavailable model."; + } else if (backendCode === "destination_verification_failed") { if ( backendMessage === "Invite the Slack app to this conversation first" ) { @@ -360,6 +362,26 @@ function publicDeployment(value: unknown): Record { channels: strings(deployment.channels), connections: strings(deployment.connections), createdAt: deployment.createdAt, + ...(Array.isArray(deployment.models) + ? { + models: deployment.models.flatMap((value) => { + const model = record(value); + return model && + typeof model.provider === "string" && + typeof model.model === "string" + ? [{ provider: model.provider, model: model.model }] + : []; + }), + } + : {}), + ...(record(deployment.defaultModel) + ? { + defaultModel: { + provider: record(deployment.defaultModel)?.provider, + model: record(deployment.defaultModel)?.model, + }, + } + : {}), ...(Array.isArray(deployment.memory) ? { memory: deployment.memory.map(publicMemoryDeclaration) } : {}), @@ -1416,6 +1438,60 @@ async function sha256Hex(value: Uint8Array): Promise { .join(""); } +function deploymentModelsFromArtifact( + source: string, +): Array<{ provider: string; model: string }> | null { + let bundle: unknown; + try { + bundle = JSON.parse(source); + } catch { + return null; + } + const files = record(bundle)?.files; + if (!Array.isArray(files)) return null; + const manifestFile = files.find( + (value) => record(value)?.path === ".opencomputer/reactive.json", + ); + if (!manifestFile) return []; + const content = record(manifestFile)?.content; + if (typeof content !== "string") return null; + let manifest: unknown; + try { + manifest = JSON.parse(atob(content)); + } catch { + return null; + } + const models = record(manifest)?.models; + if (!Array.isArray(models) || models.length > 100) return null; + const result: Array<{ provider: string; model: string }> = []; + for (const value of models) { + const model = record(value); + if ( + !model || + typeof model.provider !== "string" || + typeof model.model !== "string" || + !model.provider || + !model.model + ) { + return null; + } + if ( + !result.some( + (candidate) => + candidate.provider === model.provider && + candidate.model === model.model, + ) + ) { + result.push({ provider: model.provider, model: model.model }); + } + } + return result.sort((left, right) => + `${left.provider}/${left.model}`.localeCompare( + `${right.provider}/${right.model}`, + ), + ); +} + async function deploySourceAgent( request: Request, base: string, @@ -1445,6 +1521,12 @@ async function deploySourceAgent( "The agent source size or digest did not match.", ); } + const models = deploymentModelsFromArtifact(source.body); + if (!models) { + return invalidDeploymentResponse( + "The agent artifact contains an invalid reactive model registry.", + ); + } const uploadHeaders = new Headers(upstreamHeaders); uploadHeaders.set("content-type", "application/json"); @@ -1504,6 +1586,7 @@ async function deploySourceAgent( ? body.httpConnections : [], memory: Array.isArray(body.memory) ? body.memory : [], + models, ...(body.projectDeployment && typeof body.projectDeployment === "object" ? { projectDeployment: body.projectDeployment } : {}), From a1b024370cbeaffc20e22aee688ae46d76835e21 Mon Sep 17 00:00:00 2001 From: Mohamed Habib Date: Mon, 14 Sep 2026 15:53:05 -0700 Subject: [PATCH 2/2] release: bump CLI packages to 0.7.2 --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- create-start/package.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 4067f3f6..732c7f5f 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencomputer/cli", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencomputer/cli", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "@opencode-ai/sdk": "1.18.4", "ai": "^7.0.45", diff --git a/cli/package.json b/cli/package.json index 16f51ee8..ce93b6fd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@opencomputer/cli", - "version": "0.7.1", + "version": "0.7.2", "description": "Build, test, deploy, and share OpenComputer agents as code.", "type": "module", "bin": { diff --git a/create-start/package.json b/create-start/package.json index 8b347555..9a8dff92 100644 --- a/create-start/package.json +++ b/create-start/package.json @@ -1,6 +1,6 @@ { "name": "@opencomputer/create-start", - "version": "0.7.1", + "version": "0.7.2", "description": "Create a hello-world OpenComputer agent application.", "type": "module", "bin": { @@ -18,7 +18,7 @@ "node": ">=22.0.0" }, "dependencies": { - "@opencomputer/cli": "0.7.1" + "@opencomputer/cli": "0.7.2" }, "publishConfig": { "access": "public"