From 5c465a69f0934e7a0b1d473015b01ee308e3af19 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Mon, 13 Jul 2026 15:59:31 +0100 Subject: [PATCH 1/2] feat(agent): lock production gateway and Flue artifact boundary --- cloudflare-workers/oc-gateway/README.md | 37 +++- .../oc-gateway/package-lock.json | 4 +- cloudflare-workers/oc-gateway/package.json | 5 +- cloudflare-workers/oc-gateway/scripts/mint.ts | 6 +- cloudflare-workers/oc-gateway/src/index.ts | 14 +- cloudflare-workers/oc-gateway/src/token.ts | 11 +- .../oc-gateway/test/integration.test.ts | 18 +- .../oc-gateway/test/logic.test.ts | 50 ++++- cloudflare-workers/oc-gateway/wrangler.toml | 47 +++-- cmd/oc/internal/commands/agent_deploy_flue.go | 188 ++++++++++++++---- .../commands/agent_deploy_flue_test.go | 133 +++++++++++-- 11 files changed, 404 insertions(+), 109 deletions(-) diff --git a/cloudflare-workers/oc-gateway/README.md b/cloudflare-workers/oc-gateway/README.md index 9f7c46a24..247f114fb 100644 --- a/cloudflare-workers/oc-gateway/README.md +++ b/cloudflare-workers/oc-gateway/README.md @@ -1,6 +1,8 @@ -# oc-gateway — thin OC Worker over OpenRouter (W3, productionized) +# Agent model gateway -**Buildout W3** for the Flue-native agent type (`oc-bg-agents .agents/work/flue-native-buildout.md`, design `013 §4`, contract **#1**). Productionizes the `spike/oc-gateway` (#486) reference to the **resolved token seam** (2026-07-05, option b + the co-location refinement). +The framework-neutral model gateway for hosted OpenComputer agent Workers. Flue is the first +adapter, but the permanent Worker and operator configuration are not framework-named. Contract and +rationale live in `oc-bg-agents` design 013 §4 and work item 022 W7-P. It **extends** the shipped managed-model path (does not replace it): org-level spend keeps flowing through the org's single OpenRouter inference key → the existing `model_meter` cron → Autumn (`opencomputer/cloudflare-workers/api-edge/src/{model_billing,model_meter,openrouter}.ts`, `token-billing.md`). The gateway only adds the injection point a CF Worker needs (it can't use the box secrets-proxy) plus **org+agt budget enforcement + best-effort per-session sub-metering**. It pushes **nothing** to Autumn. @@ -35,15 +37,15 @@ Rule: `/{provider}/` → ` + `, query string p **Resolved token seam.** The token is **per-DEPLOY**, not per-session. Flue's `registerProvider` `apiKey` is a static string only, and its provider registry is isolate-global while CF co-locates many session-DOs of one agent's script in one isolate — so per-session data injected via `registerProvider` (the token OR the header) **races** across co-located sessions. Therefore the token carries only `(org, agt)` and the **hard cost-safety boundary is at the org+agt grain**. **Claims:** `{ org, agt, iat, exp, ep? }` — **no** `sub:session`, **no** `bud`. -- `org` — selects the org's OpenRouter inference key (never leaves the gateway). -- `agt` — the deploy this token authorizes; the enforcement + lease-fence key with `org`. +- `org` — bare lowercase UUID from canonical owner `oc-org:`; selects the org's OpenRouter key. +- `agt` — canonical `^agt_[0-9a-f]{24}$` id for the deployed agent. - `ep` — optional monotonic deploy epoch; a token below the current lease floor is fenced. **Prod hardening over the spike:** - **EdDSA (Ed25519):** the minter (W7 deploy pipeline) holds the private key; the gateway holds only `GATEWAY_TOKEN_PUBLIC_KEY` — a compromised gateway can't forge tokens. Alg pinned (rejects `none`/HS256 swaps). - **Lease-epoch fence** (`DeployLease` DO, per `${org}:${agt}`): the floor rises to a token's `ep` on first use, so a **rotated** deploy's higher-epoch token instantly supersedes older tokens (401 `token_superseded`). A **revoke without redeploy** is `POST /admin/lease/bump {org, agt, min_epoch}`. -**Transport:** `Authorization: Bearer ` **or** `x-api-key: `. Verify = alg-pin + signature + `exp`/`iat` + `org`/`agt` present. Failure → `401`. +**Transport:** `Authorization: Bearer ` **or** `x-api-key: `. Verify = alg-pin + signature + `exp`/`iat` + exact bare-org/agent claim shapes. Failure → `401`. ### 3. Enforcement grain (co-location refinement) @@ -72,7 +74,8 @@ POST {GATEWAY_ORKEY_URL} Authorization: Bearer {GATEWAY_ORKEY_SECRET} body { → 200 {"key": "sk-or-..."} (resolveManagedSecret for the org's active managed credential) ``` -The plaintext is cached per-org in-isolate with a 60 s TTL. `TEST_OR_KEY` short-circuits resolution for the acceptance run. **This route is the one control-plane seam W3 needs sessions-api to add** (see "seam questions"). +The plaintext is cached per-org in-isolate with a 60 s TTL. `TEST_OR_KEY` is an in-process/local-test +override only and must be absent from the production Worker. ### 7. Prompt-caching safety @@ -93,12 +96,28 @@ Some models route (via OpenRouter) to a backend that rejects Anthropic `cache_co | `src/token.ts` | EdDSA per-deploy token mint/verify (Web Crypto, no deps) | | `src/budget.ts` | `SpendCounter` DO — keyed spend counter + hard gate (µ$ integers); org+agt (hard) + per-session (tracked) | | `src/deploylease.ts` | `DeployLease` DO — per-(org,agt) lease-epoch floor (rotation/revocation fence) | -| `src/orgkey.ts` | org OR-key resolution via the dedicated sessions-api seam (`TEST_OR_KEY` override for tests) | +| `src/orgkey.ts` | org OR-key resolution via the dedicated sessions-api seam (`TEST_OR_KEY` is test-only) | | `src/cost.ts` | per-response cost extraction (JSON + SSE) | | `src/models.ts` | `cache_control` safety (strip for unsafe models) | | `scripts/mint.ts` | mint a per-deploy token for live verification | | `test/` | `logic` (20) + `integration` (11) — **31 green** | +## Production deployment + +The permanent Worker identity is `oc-agent-gateway-prod`, exposed only at its Workers.dev URL. Its +fresh `SpendCounter` and `DeployLease` state is owned by that Worker. Production config fixes +`GATEWAY_ORKEY_URL` to `https://api.opencomputer.dev/internal/gateway/org-key`; it does not configure +`TEST_OR_KEY` or `AGENT_BUDGET_USD_DEFAULT`. + +Default deploy fails intentionally. Production requires the explicit command: + +```bash +npm --prefix cloudflare-workers/oc-gateway run deploy:production +``` + +Set `GATEWAY_TOKEN_PUBLIC_KEY`, `GATEWAY_ORKEY_SECRET`, and `GATEWAY_ADMIN_SECRET` for the +`production` Wrangler environment one at a time. Never print their values. + ## Verification status - **In-process integration (green, CI-able):** `npx vitest run` drives the real worker handler + real `SpendCounter`/`DeployLease` DOs with `fetch` stubbed to a mock OpenRouter. Proves: 401 (no/expired/superseded token), forward with **org-key injection** (deploy token never reaches OR; session header never egresses) + `usage.include`, body passthrough, **org+agt hard enforcement** with bounded overshoot, **co-location** (two sessions share the org+agt cap), **per-session tracked-but-never-gated**, `cache_control` strip, admin provision. @@ -122,8 +141,8 @@ curl -sN -X POST http://localhost:8799/anthropic/v1/messages \ **Acceptance (buildout W3):** a real turn completes gateway → OpenRouter; the deploy token verifies (org+agt) and yields **no raw provider key** to the tenant; the org+agt budget refuses on-path (402); per-session spend is tracked by `X-OC-Session`. Org spend stays on the existing OpenRouter→Autumn cron. -## Seam questions for the control plane (W1/W7) +## Control-plane seams -1. **Org OR-key route (required to leave `TEST_OR_KEY`):** sessions-api must expose `POST {GATEWAY_ORKEY_URL}` (dedicated bearer) returning `{key}` = `resolveManagedSecret` for the org's active managed credential. +1. **Org OR-key route:** sessions-api exposes `POST {GATEWAY_ORKEY_URL}` with a dedicated bearer and returns `{key}` from the org's active managed credential. 2. **Per-agent budget provisioning (optional):** if a per-(org,agt) cap other than `AGENT_BUDGET_USD_DEFAULT` is wanted, W1/W7 calls `POST /admin/agent/budget`. 3. **Lease epoch (`ep`) minting:** W7 should mint a monotonic per-(org,agt) `ep` into the deploy token so rotation auto-fences; a leaked token is revoked via `POST /admin/lease/bump`. diff --git a/cloudflare-workers/oc-gateway/package-lock.json b/cloudflare-workers/oc-gateway/package-lock.json index 313878895..70d7aa151 100644 --- a/cloudflare-workers/oc-gateway/package-lock.json +++ b/cloudflare-workers/oc-gateway/package-lock.json @@ -1,11 +1,11 @@ { - "name": "oc-gateway", + "name": "oc-agent-gateway", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "oc-gateway", + "name": "oc-agent-gateway", "version": "0.1.0", "devDependencies": { "@cloudflare/workers-types": "^4.20240924.0", diff --git a/cloudflare-workers/oc-gateway/package.json b/cloudflare-workers/oc-gateway/package.json index 528267450..64cf60c33 100644 --- a/cloudflare-workers/oc-gateway/package.json +++ b/cloudflare-workers/oc-gateway/package.json @@ -1,11 +1,12 @@ { - "name": "oc-gateway", + "name": "oc-agent-gateway", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "wrangler dev", - "deploy": "wrangler deploy", + "deploy": "node -e \"throw new Error('choose an explicit environment; production is deploy:production')\"", + "deploy:production": "wrangler deploy --env production", "typecheck": "tsc --noEmit", "test": "vitest run", "mint": "node --experimental-strip-types scripts/mint.ts" diff --git a/cloudflare-workers/oc-gateway/scripts/mint.ts b/cloudflare-workers/oc-gateway/scripts/mint.ts index 21437f61b..899c51eb0 100644 --- a/cloudflare-workers/oc-gateway/scripts/mint.ts +++ b/cloudflare-workers/oc-gateway/scripts/mint.ts @@ -3,7 +3,7 @@ // On first use it also generates an Ed25519 keypair. // // Generate + mint (the exact CP + gateway provisioning values on stderr; token on stdout): -// node --experimental-strip-types scripts/mint.ts --org org_1 --agent agt_1 --ep 1 +// node --experimental-strip-types scripts/mint.ts --org 11111111-1111-4111-8111-111111111111 --agent agt_0123456789abcdef01234567 --ep 1 // → set the gateway's GATEWAY_TOKEN_PUBLIC_KEY secret from the printed value. // Reuse the control-plane private value so the gateway public key stays fixed: // V3_GATEWAY_TOKEN_PRIVATE_KEY= node ... scripts/mint.ts ... @@ -45,8 +45,8 @@ const now = Math.floor(Date.now() / 1000); const ttl = Number(arg("ttl", "3600")); const ep = arg("ep"); const claims: DeployClaims = { - org: arg("org", "org_1")!, - agt: arg("agent", "agt_1")!, + org: arg("org", "11111111-1111-4111-8111-111111111111")!, + agt: arg("agent", "agt_0123456789abcdef01234567")!, ep: ep != null ? Number(ep) : undefined, iat: now, exp: now + ttl, diff --git a/cloudflare-workers/oc-gateway/src/index.ts b/cloudflare-workers/oc-gateway/src/index.ts index daa8b8f90..bdbdd7905 100644 --- a/cloudflare-workers/oc-gateway/src/index.ts +++ b/cloudflare-workers/oc-gateway/src/index.ts @@ -1,4 +1,4 @@ -// oc-gateway — the thin OC Worker over OpenRouter (design 013 §4, buildout contract #1 / W3). +// oc-agent-gateway — the thin OC Worker over OpenRouter (design 013 §4, contract #1 / W3). // // An unmodified Flue app registers the managed provider INSIDE defineAgent (resolved token seam): // registerProvider('anthropic', { @@ -60,6 +60,8 @@ export interface Env { } const OR_BASE_DEFAULT = "https://openrouter.ai/api"; // == credential.ts MANAGED_ANTHROPIC_BASE +const ORG_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const AGENT_ID = /^agt_[0-9a-f]{24}$/; // Map a gateway path prefix → the OpenRouter path prefix (credential.ts managed bases). // /anthropic/v1/messages → https://openrouter.ai/api/v1/messages (Claude-Code path) @@ -101,7 +103,7 @@ export default { const url = new URL(req.url); if (req.method === "GET" && url.pathname === "/healthz") { - return json({ status: "ok", service: "oc-gateway" }); + return json({ status: "ok", service: "oc-agent-gateway" }); } // Control-plane admin routes (provision an org+agt budget, revoke a deploy lease). Guarded by a @@ -230,7 +232,9 @@ async function admin(req: Request, env: Env, url: URL): Promise { if (url.pathname === "/admin/agent/budget") { const org = typeof body.org === "string" ? body.org : null; const agt = typeof body.agt === "string" ? body.agt : null; - if (!org || !agt) return json({ error: { type: "bad_request", message: "org, agt required" } }, 400); + if (!org || !agt || !ORG_ID.test(org) || !AGENT_ID.test(agt)) { + return json({ error: { type: "bad_request", message: "canonical bare org UUID and agent id required" } }, 400); + } const budgetMicro = body.budget_usd === null ? null : parseUsdMicro(String(body.budget_usd)); const stub = env.SPEND_COUNTER.get(env.SPEND_COUNTER.idFromName(`agt:${org}:${agt}`)); const r = await stub.fetch("https://do/provision", { method: "POST", body: JSON.stringify({ budget_micro: budgetMicro }) }); @@ -242,7 +246,9 @@ async function admin(req: Request, env: Env, url: URL): Promise { const org = typeof body.org === "string" ? body.org : null; const agt = typeof body.agt === "string" ? body.agt : null; const minEpoch = typeof body.min_epoch === "number" ? body.min_epoch : null; - if (!org || !agt || minEpoch == null) return json({ error: { type: "bad_request", message: "org, agt, min_epoch required" } }, 400); + if (!org || !agt || !ORG_ID.test(org) || !AGENT_ID.test(agt) || minEpoch == null) { + return json({ error: { type: "bad_request", message: "canonical bare org UUID, agent id and min_epoch required" } }, 400); + } const stub = env.DEPLOY_LEASE.get(env.DEPLOY_LEASE.idFromName(`${org}:${agt}`)); const r = await stub.fetch("https://do/bump", { method: "POST", body: JSON.stringify({ min_epoch: minEpoch }) }); return new Response(r.body, { status: r.status, headers: { "content-type": "application/json" } }); diff --git a/cloudflare-workers/oc-gateway/src/token.ts b/cloudflare-workers/oc-gateway/src/token.ts index 6be8a1d7a..ee43a4a56 100644 --- a/cloudflare-workers/oc-gateway/src/token.ts +++ b/cloudflare-workers/oc-gateway/src/token.ts @@ -21,7 +21,7 @@ // GATEWAY_TOKEN_PUBLIC_KEY = base64url(raw 32-byte Ed25519 public key). export interface DeployClaims { - /** org id — selects the org's OpenRouter inference key (never leaves the gateway). */ + /** Bare lowercase org UUID — selects the org's OpenRouter inference key. */ org: string; /** agent id — the deploy this token authorizes; attribution + the lease-fence key with `org`. */ agt: string; @@ -35,6 +35,8 @@ export interface DeployClaims { const enc = new TextEncoder(); const dec = new TextDecoder(); const ED = { name: "Ed25519" } as const; +const ORG_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const AGENT_ID = /^agt_[0-9a-f]{24}$/; function b64urlEncode(bytes: Uint8Array): string { let s = ""; @@ -91,9 +93,12 @@ export async function verifyDeployToken(publicKeyB64url: string, token: string, } catch { return { ok: false, reason: "bad_payload" }; } - if (typeof claims.exp !== "number" || claims.exp <= nowSec) return { ok: false, reason: "expired" }; - if (typeof claims.iat === "number" && claims.iat > nowSec + 60) return { ok: false, reason: "future_iat" }; + if (!Number.isSafeInteger(claims.exp) || claims.exp <= nowSec) return { ok: false, reason: "expired" }; + if (!Number.isSafeInteger(claims.iat) || claims.iat <= 0) return { ok: false, reason: "bad_iat" }; + if (claims.iat > nowSec + 60) return { ok: false, reason: "future_iat" }; if (!claims.org || !claims.agt) return { ok: false, reason: "missing_claims" }; + if (!ORG_ID.test(claims.org)) return { ok: false, reason: "bad_org" }; + if (!AGENT_ID.test(claims.agt)) return { ok: false, reason: "bad_agent" }; if (claims.ep !== undefined && (!Number.isSafeInteger(claims.ep) || claims.ep < 0)) { return { ok: false, reason: "bad_epoch" }; } diff --git a/cloudflare-workers/oc-gateway/test/integration.test.ts b/cloudflare-workers/oc-gateway/test/integration.test.ts index 1ce24ed82..57a7eea3b 100644 --- a/cloudflare-workers/oc-gateway/test/integration.test.ts +++ b/cloudflare-workers/oc-gateway/test/integration.test.ts @@ -14,6 +14,8 @@ import { generateKeyPair, mintDeployToken } from "../src/token.js"; const OR_KEY = "sk-or-v1-FAKE-org-key"; const OR_BASE = "https://mock-openrouter.test/api"; +const ORG_ID = "11111111-1111-4111-8111-111111111111"; +const AGENT_ID = "agt_0123456789abcdef01234567"; // EdDSA keypair for the suite: the minter (control plane) holds PRIV, the gateway holds PUB. let PRIV: CryptoKey; @@ -91,7 +93,7 @@ const post = (token?: string, session?: string, body = MSG) => new Request("http }); const mint = async (o: Partial<{ org: string; agt: string; ep: number; iat: number; exp: number }> = {}) => { const now = Math.floor(Date.now() / 1000); - return mintDeployToken(PRIV, { org: "org_1", agt: "agt_1", iat: now, exp: now + 3600, ...o }); + return mintDeployToken(PRIV, { org: ORG_ID, agt: AGENT_ID, iat: now, exp: now + 3600, ...o }); }; const stateOf = async (inst: { fetch(r: Request): Promise }) => (await inst.fetch(new Request("https://do/state"))).json() as Promise<{ spent_micro: number }>; @@ -180,10 +182,10 @@ describe("gateway on-path flow (resolved seam)", () => { expect(ry.status).toBe(200); expect(rz.status).toBe(402); // org+agt cap hit across sessions // best-effort per-session tracking recorded each session's own spend separately - expect((await stateOf(spend.instances.get("sess:org_1:agt_1:ses_x")!)).spent_micro).toBe(20_000); - expect((await stateOf(spend.instances.get("sess:org_1:agt_1:ses_y")!)).spent_micro).toBe(20_000); + expect((await stateOf(spend.instances.get(`sess:${ORG_ID}:${AGENT_ID}:ses_x`)!)).spent_micro).toBe(20_000); + expect((await stateOf(spend.instances.get(`sess:${ORG_ID}:${AGENT_ID}:ses_y`)!)).spent_micro).toBe(20_000); // and the authoritative org+agt grain summed them - expect((await stateOf(spend.instances.get("agt:org_1:agt_1")!)).spent_micro).toBe(40_000); + expect((await stateOf(spend.instances.get(`agt:${ORG_ID}:${AGENT_ID}`)!)).spent_micro).toBe(40_000); }); it("per-session counter is TRACKED but NEVER gated: a session over its own spend is not 402'd", async () => { @@ -192,12 +194,12 @@ describe("gateway on-path flow (resolved seam)", () => { const token = await mint(); for (let i = 0; i < 3; i++) { const c = ctx(); const r = await worker.fetch(post(token, "ses_hot"), env, c); await drain(c); expect(r.status).toBe(200); } // the session accumulated $0.30 but was never blocked (no per-session hard gate) - expect((await stateOf(spend.instances.get("sess:org_1:agt_1:ses_hot")!)).spent_micro).toBe(300_000); + expect((await stateOf(spend.instances.get(`sess:${ORG_ID}:${AGENT_ID}:ses_hot`)!)).spent_micro).toBe(300_000); }); it("fences a superseded deploy lease epoch (401 token_superseded)", async () => { vi.stubGlobal("fetch", mockFetch(0.001)); - const { env } = mkEnv(); // same DEPLOY_LEASE namespace → same lease for org_1:agt_1 + const { env } = mkEnv(); // same DEPLOY_LEASE namespace → same lease for the canonical org+agent const t2 = await mint({ ep: 2 }); const c2 = ctx(); const r2 = await worker.fetch(post(t2, "ses_ep"), env, c2); await drain(c2); expect(r2.status).toBe(200); // adopt epoch 2 @@ -226,10 +228,10 @@ describe("gateway on-path flow (resolved seam)", () => { vi.stubGlobal("fetch", mockFetch(0.05)); const { env } = mkEnv({ GATEWAY_ADMIN_SECRET: "adm" }); // unauthorized admin call is rejected - const bad = await worker.fetch(new Request("https://gw.test/admin/agent/budget", { method: "POST", headers: { authorization: "Bearer nope", "content-type": "application/json" }, body: JSON.stringify({ org: "org_1", agt: "agt_1", budget_usd: 0.04 }) }), env, ctx()); + const bad = await worker.fetch(new Request("https://gw.test/admin/agent/budget", { method: "POST", headers: { authorization: "Bearer nope", "content-type": "application/json" }, body: JSON.stringify({ org: ORG_ID, agt: AGENT_ID, budget_usd: 0.04 }) }), env, ctx()); expect(bad.status).toBe(401); // provision a $0.04 cap - const prov = await worker.fetch(new Request("https://gw.test/admin/agent/budget", { method: "POST", headers: { authorization: "Bearer adm", "content-type": "application/json" }, body: JSON.stringify({ org: "org_1", agt: "agt_1", budget_usd: 0.04 }) }), env, ctx()); + const prov = await worker.fetch(new Request("https://gw.test/admin/agent/budget", { method: "POST", headers: { authorization: "Bearer adm", "content-type": "application/json" }, body: JSON.stringify({ org: ORG_ID, agt: AGENT_ID, budget_usd: 0.04 }) }), env, ctx()); expect(prov.status).toBe(200); const token = await mint(); const c1 = ctx(); const r1 = await worker.fetch(post(token, "s1"), env, c1); await drain(c1); // 0→0.05 diff --git a/cloudflare-workers/oc-gateway/test/logic.test.ts b/cloudflare-workers/oc-gateway/test/logic.test.ts index c14049e2b..37d431891 100644 --- a/cloudflare-workers/oc-gateway/test/logic.test.ts +++ b/cloudflare-workers/oc-gateway/test/logic.test.ts @@ -3,17 +3,20 @@ // and cost extraction. The full on-path flow (forward + meter) is exercised by test/integration.test.ts. // Run: npx vitest run -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { generateKeyPair, mintDeployToken, verifyDeployToken, type DeployClaims } from "../src/token.js"; import { costFromJson, costFromStream } from "../src/cost.js"; import { unsafeModelMatchers, modelNeedsCacheStrip, stripCacheControl } from "../src/models.js"; import { SpendCounter } from "../src/budget.js"; import { DeployLease } from "../src/deploylease.js"; +import { _clearOrgKeyCache, resolveOrgKey } from "../src/orgkey.js"; const now = 1_800_000_000; +const ORG_ID = "11111111-1111-4111-8111-111111111111"; +const AGENT_ID = "agt_0123456789abcdef01234567"; const b64url = (o: unknown) => btoa(JSON.stringify(o)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const claims = (o: Partial = {}): DeployClaims => ({ - org: "org_1", agt: "agt_1", ep: 2, iat: now, exp: now + 3600, ...o, + org: ORG_ID, agt: AGENT_ID, ep: 2, iat: now, exp: now + 3600, ...o, }); function fakeState(): DurableObjectState { @@ -29,8 +32,8 @@ describe("deploy token (EdDSA, per-deploy {org, agt})", () => { const v = await verifyDeployToken(publicKeyB64url, await mintDeployToken(privateKey, claims()), now); expect(v.ok).toBe(true); if (v.ok) { - expect(v.claims.org).toBe("org_1"); - expect(v.claims.agt).toBe("agt_1"); + expect(v.claims.org).toBe(ORG_ID); + expect(v.claims.agt).toBe(AGENT_ID); expect(v.claims.ep).toBe(2); // resolved seam: no per-session data in the token const raw = v.claims as unknown as Record; @@ -63,6 +66,17 @@ describe("deploy token (EdDSA, per-deploy {org, agt})", () => { expect(v.ok).toBe(false); if (!v.ok) expect(v.reason).toBe("missing_claims"); }); + it.each([ + ["canonical owner instead of bare UUID", { org: `oc-org:${ORG_ID}` }, "bad_org"], + ["uppercase org UUID", { org: "ABCDEFAB-CDEF-4ABC-8DEF-ABCDEFABCDEF" }, "bad_org"], + ["non-canonical agent id", { agt: "agt_1" }, "bad_agent"], + ])("rejects %s", async (_name, overrides, reason) => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const token = await mintDeployToken(privateKey, claims(overrides)); + const verified = await verifyDeployToken(publicKeyB64url, token, now); + expect(verified.ok).toBe(false); + if (!verified.ok) expect(verified.reason).toBe(reason); + }); it("pins alg=EdDSA — rejects an alg-swap (none/HS256) header", async () => { const { privateKey, publicKeyB64url } = await generateKeyPair(); const [, p, s] = (await mintDeployToken(privateKey, claims())).split("."); @@ -72,6 +86,34 @@ describe("deploy token (EdDSA, per-deploy {org, agt})", () => { }); }); +describe("org-key identity seam", () => { + afterEach(() => { _clearOrgKeyCache(); vi.restoreAllMocks(); }); + + it("sends the token's bare UUID exactly once to the sessions API", async () => { + let request: { url: string; auth: string | null; body: unknown } | undefined; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + request = { + url: String(input), + auth: new Headers(init?.headers).get("authorization"), + body: JSON.parse(String(init?.body)), + }; + return new Response(JSON.stringify({ key: "test-only-key" }), { status: 200 }); + })); + + const key = await resolveOrgKey({ + GATEWAY_ORKEY_URL: "https://api.opencomputer.dev/internal/gateway/org-key", + GATEWAY_ORKEY_SECRET: "dedicated-bearer", + }, ORG_ID, Date.now()); + + expect(key).toBe("test-only-key"); + expect(request).toEqual({ + url: "https://api.opencomputer.dev/internal/gateway/org-key", + auth: "Bearer dedicated-bearer", + body: { org: ORG_ID }, + }); + }); +}); + describe("SpendCounter DO — budget gate + idempotency + provision", () => { it("gates on the budget (spent < budget), applies the default on first check", async () => { const c = new SpendCounter(fakeState()); diff --git a/cloudflare-workers/oc-gateway/wrangler.toml b/cloudflare-workers/oc-gateway/wrangler.toml index 8b103ca1e..0d652ce47 100644 --- a/cloudflare-workers/oc-gateway/wrangler.toml +++ b/cloudflare-workers/oc-gateway/wrangler.toml @@ -1,15 +1,15 @@ -name = "oc-gateway" +name = "oc-agent-gateway-local" main = "src/index.ts" compatibility_date = "2025-06-01" compatibility_flags = ["nodejs_compat"] +workers_dev = false -# Spend counter + hard gate (design 013 §4/§8). Used at org+agt grain (hard 402) and per-session -# grain (tracked-only). Strongly consistent, so concurrent calls can't double-spend past the cap. +# Local/dev Durable Objects. Production repeats these non-inheritable bindings under its explicit +# environment and gets fresh state under the permanent Worker identity. [[durable_objects.bindings]] name = "SPEND_COUNTER" class_name = "SpendCounter" -# Per-(org, agt) lease-epoch floor — fences a rotated/revoked deploy token (design 013 §4). [[durable_objects.bindings]] name = "DEPLOY_LEASE" class_name = "DeployLease" @@ -18,16 +18,29 @@ class_name = "DeployLease" tag = "v1" new_sqlite_classes = ["SpendCounter", "DeployLease"] -# Secrets (set with `wrangler secret put …`, never in this file): -# GATEWAY_TOKEN_PUBLIC_KEY — base64url raw 32-byte Ed25519 PUBLIC key (minter holds the private key) -# GATEWAY_ORKEY_SECRET — bearer for the dedicated sessions-api org-OR-key seam (carries a live key) -# GATEWAY_ADMIN_SECRET — bearer that guards the control-plane admin routes (/admin/*) -# TEST_OR_KEY — acceptance-test single OR key override (bypasses the seam); never in prod -# OC_INGEST_AUTH — optional, per-session spend telemetry auth - -[vars] -# GATEWAY_ORKEY_URL — the internal sessions-api route that returns an org's OR key {org}→{key}. -# AGENT_BUDGET_USD_DEFAULT — default HARD budget (USD) per org+agt for an unprovisioned grain; unset = uncapped. -# OPENROUTER_BASE — leave unset for prod (https://openrouter.ai/api); override for a mock in tests. -# CACHE_CONTROL_UNSAFE_MODELS — extra comma-separated model patterns to strip cache_control for. -# OC_INGEST_URL — optional per-session spend sink. +# Production is deliberately available only through `npm run deploy:production`. +[env.production] +name = "oc-agent-gateway-prod" +workers_dev = true + +[[env.production.durable_objects.bindings]] +name = "SPEND_COUNTER" +class_name = "SpendCounter" + +[[env.production.durable_objects.bindings]] +name = "DEPLOY_LEASE" +class_name = "DeployLease" + +[[env.production.migrations]] +tag = "v1" +new_sqlite_classes = ["SpendCounter", "DeployLease"] + +[env.production.vars] +GATEWAY_ORKEY_URL = "https://api.opencomputer.dev/internal/gateway/org-key" + +# Production secrets (set for --env production; never in this file): +# GATEWAY_TOKEN_PUBLIC_KEY — base64url raw 32-byte Ed25519 public key +# GATEWAY_ORKEY_SECRET — bearer for the dedicated org-key route +# GATEWAY_ADMIN_SECRET — bearer for /admin/* +# TEST_OR_KEY is test-only and MUST be absent from production. +# AGENT_BUDGET_USD_DEFAULT is intentionally absent; managed OpenRouter/Autumn limits are authoritative. diff --git a/cmd/oc/internal/commands/agent_deploy_flue.go b/cmd/oc/internal/commands/agent_deploy_flue.go index d0f26d161..581c1f30f 100644 --- a/cmd/oc/internal/commands/agent_deploy_flue.go +++ b/cmd/oc/internal/commands/agent_deploy_flue.go @@ -3,13 +3,13 @@ package commands // Flue deploy flow (design 013 §6 — the Worker-for-Platforms Durable-Object model). // When agent.toml declares `[runtime] family = "flue"`, `oc agent deploy` does NOT // read prompt.md/skills/; it runs the app's own `flue build --target cloudflare`, then -// stages the WHOLE built dir (the entry module + the no_bundle assets/ tree) as one -// tar.gz in R2 via a presigned PUT, and POSTs the deployment referencing only the R2 -// bundle digest + the (small, strict-JSON) generated wrangler + the entrypoint agent -// name — NO module bytes in the JSON, so the API host stays byte-free. The CP records +// stages only regular .js/.mjs modules as one tar.gz in R2 via a presigned PUT, and +// POSTs the deployment referencing only the R2 bundle digest + a small canonical +// Flue descriptor + the entrypoint agent name — NO module bytes in the JSON, so the +// API host stays byte-free. The CP records // a `verifying` deploy; an off-host runner fetches the bundle, composes, mints the -// per-deploy token, WfP-uploads, and canary-verifies before activating. The existing -// deployment poll absorbs the verify latency (verifying → ready|failed). +// per-deploy token, WfP-uploads, and finalizes. The existing deployment poll absorbs +// the runner latency (verifying → ready|failed). import ( "bytes" @@ -21,7 +21,10 @@ import ( "net/http" "os" "os/exec" + pathpkg "path" "path/filepath" + "regexp" + "strings" "time" "github.com/opensandbox/opensandbox/cmd/oc/internal/bundle" @@ -34,10 +37,38 @@ const ( // flueBuildOutputDir is `flue build --target cloudflare`'s output root; the tool // writes the Cloudflare build under dist// (wrangler.json + the entry module + // assets/), so we discover the wrangler beneath it rather than assume a flat layout. - flueBuildOutputDir = "dist" - flueBundleMaxBytes = 64 << 20 // server caps the staged bundle at 64 MiB — fail early + flueBuildOutputDir = "dist" + flueBundleMaxBytes = 64 << 20 // server caps the staged bundle at 64 MiB — fail early + flueCompatibilityDate = "2026-04-01" ) +var flueBindingIdentifier = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`) + +type flueDOBinding struct { + Name string `json:"name"` + ClassName string `json:"class_name"` +} + +type flueWranglerDescriptor struct { + Main string `json:"main"` + CompatibilityDate string `json:"compatibility_date"` + CompatibilityFlags []string `json:"compatibility_flags"` + NoBundle bool `json:"no_bundle"` + DurableObjects struct { + Bindings []flueDOBinding `json:"bindings"` + } `json:"durable_objects"` +} + +type generatedFlueWrangler struct { + Main string `json:"main"` + CompatibilityDate string `json:"compatibility_date"` + CompatibilityFlags []string `json:"compatibility_flags"` + NoBundle bool `json:"no_bundle"` + DurableObjects struct { + Bindings []json.RawMessage `json:"bindings"` + } `json:"durable_objects"` +} + // artifactUploadResponse is the reply from POST /v3/agents/:id/artifacts. AlreadyUploaded // is set (and URL omitted) when the content-addressed object already exists: R2 is // write-once, so the server refuses to re-issue a PUT for a pinned digest (a re-issuable @@ -84,7 +115,8 @@ func deployFlue(cmd *cobra.Command, sc *client.Client, dir string, m *manifest, return err } - // 4. Stage the whole built dir as one content-addressed tar.gz + read the wrangler. + // 4. Extract the strict descriptor and stage only regular module files. Raw + // wrangler.json contains build-local paths and never leaves this machine. // The digest is sha256 of the blob the server and box will hash byte-for-byte. files, wrangler, err := readFlueBundle(filepath.Join(dir, flueBuildOutputDir)) if err != nil { @@ -104,7 +136,7 @@ func deployFlue(cmd *cobra.Command, sc *client.Client, dir string, m *manifest, return err } - // 6. Deployment referencing the R2 bundle digest + the generated wrangler (no + // 6. Deployment referencing the R2 bundle digest + the canonical descriptor (no // module bytes in the JSON). The CP keys the flue-DO path off the agent's // runtime="flue" + the presence of flue_bundle_digest/flue_wrangler, then hands // off to the off-host runner (fetch → compose → mint → WfP-upload) → verifying. @@ -117,7 +149,7 @@ func deployFlue(cmd *cobra.Command, sc *client.Client, dir string, m *manifest, "model": m.Model, "runtime": map[string]string{"type": rt}, "flue_bundle_digest": digest, // sha256: of the tar.gz staged in R2 - "flue_wrangler": wrangler, // the generated wrangler.json object, verbatim + "flue_wrangler": wrangler, // strict adapter descriptor; never raw wrangler.json "flue_agent_name": m.Name, // entrypoint agent (agent.toml name → DO admit address) } body := map[string]interface{}{"input": input, "activate": !noActivate} @@ -130,7 +162,7 @@ func deployFlue(cmd *cobra.Command, sc *client.Client, dir string, m *manifest, } d := env.Deployment - // 7. Poll to terminal — the CP canary boots the tenant DO before activating. + // 7. Poll to terminal while the off-host runner uploads and finalizes the deployment. if !terminalState(d.State) && d.State != "" { to, _ := cmd.Flags().GetInt("timeout") d, err = pollDeployment(cmd, sc, id, d.ID, time.Duration(to)*time.Second) @@ -191,38 +223,31 @@ func runFlueBuild(ctx context.Context, dir string) error { return nil } -// readFlueBundle locates the generated wrangler under the `flue build` output, parses -// it, and reads the whole build dir into a fileset (the entry module + the assets/ -// tree). `flue build --target cloudflare` writes dist//{wrangler.json,
, -// assets/…}; a flat dist/ is also accepted. The fileset is rooted at the wrangler's dir -// so wrangler.main resolves at the bundle root. Returns the fileset + the wrangler -// object (forwarded to the CP verbatim as flue_wrangler). -func readFlueBundle(distDir string) ([]bundle.File, map[string]interface{}, error) { +// readFlueBundle locates the generated wrangler, extracts the exact Flue descriptor, +// and reads only regular .js/.mjs modules rooted at the wrangler's directory. The raw +// wrangler resolution dump, .vite state, source maps and non-modules are never archived. +func readFlueBundle(distDir string) ([]bundle.File, flueWranglerDescriptor, error) { wranglerPath, err := findGeneratedWrangler(distDir) if err != nil { - return nil, nil, err + return nil, flueWranglerDescriptor{}, err } raw, err := os.ReadFile(wranglerPath) if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", wranglerPath, err) + return nil, flueWranglerDescriptor{}, fmt.Errorf("read %s: %w", wranglerPath, err) } - var wrangler map[string]interface{} - if err := json.Unmarshal(raw, &wrangler); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", wranglerPath, err) - } - main, _ := wrangler["main"].(string) - if main == "" { - return nil, nil, fmt.Errorf("%s has no `main` — the flue build did not produce a Worker entry module", wranglerPath) + wrangler, err := extractFlueWranglerDescriptor(raw) + if err != nil { + return nil, flueWranglerDescriptor{}, fmt.Errorf("parse %s: %w", wranglerPath, err) } bundleRoot := filepath.Dir(wranglerPath) - files, err := readBundleFiles(bundleRoot) + files, err := readBundleModules(bundleRoot) if err != nil { - return nil, nil, err + return nil, flueWranglerDescriptor{}, err } // The entry module wrangler.main names MUST be in the bundle (the runner uploads it // to WfP as metadata.main_module). - mainRel := filepath.ToSlash(main) + mainRel := wrangler.Main found := false for _, f := range files { if f.Path == mainRel { @@ -231,11 +256,89 @@ func readFlueBundle(distDir string) ([]bundle.File, map[string]interface{}, erro } } if !found { - return nil, nil, fmt.Errorf("entry module %q (wrangler.main) is not in the build output %s", mainRel, bundleRoot) + return nil, flueWranglerDescriptor{}, fmt.Errorf("entry module %q (wrangler.main) is not in the module output %s", mainRel, bundleRoot) } return files, wrangler, nil } +func safeFlueModulePath(value string) bool { + if value == "" || strings.HasPrefix(value, "/") || strings.Contains(value, `\`) { + return false + } + if ext := pathpkg.Ext(value); ext != ".js" && ext != ".mjs" { + return false + } + if pathpkg.Clean(value) != value { + return false + } + for _, segment := range strings.Split(value, "/") { + if segment == "" || segment == "." || segment == ".." { + return false + } + } + return true +} + +func extractFlueWranglerDescriptor(raw []byte) (flueWranglerDescriptor, error) { + var generated generatedFlueWrangler + if err := json.Unmarshal(raw, &generated); err != nil { + return flueWranglerDescriptor{}, err + } + if !safeFlueModulePath(generated.Main) { + return flueWranglerDescriptor{}, fmt.Errorf("main must be a safe relative .js/.mjs module path") + } + if generated.CompatibilityDate != flueCompatibilityDate { + return flueWranglerDescriptor{}, fmt.Errorf("compatibility_date must be %s", flueCompatibilityDate) + } + if len(generated.CompatibilityFlags) != 1 || generated.CompatibilityFlags[0] != "nodejs_compat" { + return flueWranglerDescriptor{}, fmt.Errorf(`compatibility_flags must be exactly ["nodejs_compat"]`) + } + if !generated.NoBundle { + return flueWranglerDescriptor{}, fmt.Errorf("no_bundle must be true") + } + + bindings := make([]flueDOBinding, 0, len(generated.DurableObjects.Bindings)) + names := map[string]bool{} + classes := map[string]bool{} + registryCount := 0 + for _, rawBinding := range generated.DurableObjects.Bindings { + var fields map[string]json.RawMessage + if err := json.Unmarshal(rawBinding, &fields); err != nil { + return flueWranglerDescriptor{}, fmt.Errorf("invalid durable-object binding: %w", err) + } + if len(fields) != 2 || fields["name"] == nil || fields["class_name"] == nil { + return flueWranglerDescriptor{}, fmt.Errorf("durable-object bindings may contain only name and class_name") + } + var binding flueDOBinding + if err := json.Unmarshal(rawBinding, &binding); err != nil { + return flueWranglerDescriptor{}, fmt.Errorf("invalid durable-object binding: %w", err) + } + if !flueBindingIdentifier.MatchString(binding.Name) || !flueBindingIdentifier.MatchString(binding.ClassName) { + return flueWranglerDescriptor{}, fmt.Errorf("durable-object binding names and class names must be non-empty JavaScript identifiers") + } + if names[binding.Name] || classes[binding.ClassName] { + return flueWranglerDescriptor{}, fmt.Errorf("durable-object binding names and class names must be unique") + } + names[binding.Name] = true + classes[binding.ClassName] = true + if binding.Name == "FLUE_REGISTRY" && binding.ClassName == "FlueRegistry" { + registryCount++ + } + bindings = append(bindings, binding) + } + if registryCount != 1 { + return flueWranglerDescriptor{}, fmt.Errorf("FLUE_REGISTRY must bind FlueRegistry exactly once") + } + + var descriptor flueWranglerDescriptor + descriptor.Main = generated.Main + descriptor.CompatibilityDate = flueCompatibilityDate + descriptor.CompatibilityFlags = []string{"nodejs_compat"} + descriptor.NoBundle = true + descriptor.DurableObjects.Bindings = bindings + return descriptor, nil +} + // findGeneratedWrangler returns the generated wrangler.json path — dist/wrangler.json // (flat) or the single dist//wrangler.json `flue build` writes. Zero or more than // one candidate is an error (nothing built / ambiguous output). @@ -258,9 +361,9 @@ func findGeneratedWrangler(distDir string) (string, error) { } } -// readBundleFiles walks the build output into a fileset with normalized modes and -// forward-slash, root-relative paths (the tar the CP fetches + unpacks off-host). -func readBundleFiles(root string) ([]bundle.File, error) { +// readBundleModules walks the output into a module-only fileset with normalized modes +// and forward-slash, root-relative paths. Symlinks and special files fail closed. +func readBundleModules(root string) ([]bundle.File, error) { if info, err := os.Stat(root); err != nil || !info.IsDir() { return nil, fmt.Errorf("build output %s not found — did `flue build --target cloudflare` run?", root) } @@ -270,12 +373,22 @@ func readBundleFiles(root string) ([]bundle.File, error) { return walkErr } if d.IsDir() { + if p != root && d.Name() == ".vite" { + return filepath.SkipDir + } return nil } + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("build output contains symlink %s; only regular modules are allowed", p) + } rel, err := filepath.Rel(root, p) if err != nil { return err } + rel = filepath.ToSlash(rel) + if !safeFlueModulePath(rel) { + return nil // excludes wrangler.json, source maps and every non-.js/.mjs file + } content, err := os.ReadFile(p) if err != nil { return err @@ -284,8 +397,11 @@ func readBundleFiles(root string) ([]bundle.File, error) { if err != nil { return err } + if !st.Mode().IsRegular() { + return fmt.Errorf("build output contains non-regular module %s", p) + } files = append(files, bundle.File{ - Path: filepath.ToSlash(rel), + Path: rel, Mode: bundle.NormalizeMode(int(st.Mode().Perm())), Content: content, }) diff --git a/cmd/oc/internal/commands/agent_deploy_flue_test.go b/cmd/oc/internal/commands/agent_deploy_flue_test.go index cdf56625c..ca897113e 100644 --- a/cmd/oc/internal/commands/agent_deploy_flue_test.go +++ b/cmd/oc/internal/commands/agent_deploy_flue_test.go @@ -4,12 +4,12 @@ package commands // plane implements the presigned-PUT bundle upload (POST /v3/agents/:id/artifacts + the // signed PUT) and the deployment create + a verifying→ready poll. A throwaway // `node_modules/.bin/flue` script stands in for the app's flue CLI, so the real -// runFlueBuild exec path runs and produces a dist// with the entry module + an -// assets/ file. deployFlue is driven end to end; we assert the uploaded bytes are -// byte-exactly the canonical tar.gz of the whole build dir for the digest the CLI +// runFlueBuild exec path runs and produces a deliberately noisy dist//. deployFlue +// is driven end to end; we assert the uploaded bytes are byte-exactly the canonical +// module-only tar.gz for the digest the CLI // advertised (the content-address chain), and that the POSTed deployment body is the -// byte-free DO request: flue_bundle_digest + flue_wrangler + flue_agent_name, with no -// module bytes / no framework_artifact_digest. +// byte-free DO request: flue_bundle_digest + the canonical flue_wrangler descriptor + +// flue_agent_name, with no raw Wrangler dump, module bytes, or framework_artifact_digest. import ( "bytes" @@ -34,9 +34,11 @@ import ( // trailing newline). The build is nested a directory deep, with an assets/ file, to // exercise the dist// discovery and prove the WHOLE tree is staged. const ( + fakeAgentID = "agt_0123456789abcdef01234567" e2eModuleBody = `export default { fetch() { return new Response("ok"); } };` e2eAssetBody = `export const chunk = 1;` - e2eWranglerBody = `{"name":"e2e-flue","main":"index.js","compatibility_date":"2026-04-01","compatibility_flags":["nodejs_compat"],"durable_objects":{"bindings":[{"name":"AGENT","class_name":"FlueE2EAgent"}]},"migrations":[{"tag":"v1","new_sqlite_classes":["FlueE2EAgent"]}]}` + e2eRuntimeBody = `export const runtime = "flue";` + e2eWranglerBody = `{"$schema":"../../node_modules/wrangler/config-schema.json","name":"e2e-flue","main":"index.js","compatibility_date":"2026-04-01","compatibility_flags":["nodejs_compat"],"no_bundle":true,"configPath":"/Users/developer/project/flue.config.ts","userConfigPath":"/Users/developer/project/wrangler.json","durable_objects":{"bindings":[{"name":"AGENT","class_name":"FlueE2EAgent"},{"name":"FLUE_REGISTRY","class_name":"FlueRegistry"}]},"vars":{"MUST_NOT_LEAVE":"raw-wrangler"},"migrations":[{"tag":"attacker-owned","new_sqlite_classes":["Wrong"]}],"routes":["example.com/*"],"services":[{"binding":"OTHER","service":"victim"}]}` ) type fakeCP struct { @@ -63,13 +65,13 @@ func (f *fakeCP) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeJSON(map[string]any{"data": []any{}}) case r.Method == "POST" && r.URL.Path == "/v3/agents": _ = json.NewDecoder(r.Body).Decode(&f.createBody) - writeJSON(map[string]any{"id": "agt_e2e", "name": "e2e-flue", "model": "anthropic/claude-sonnet-5", "runtime": "flue"}) - case r.Method == "PUT" && r.URL.Path == "/v3/agents/agt_e2e/config": + writeJSON(map[string]any{"id": fakeAgentID, "name": "e2e-flue", "model": "anthropic/claude-sonnet-5", "runtime": "flue"}) + case r.Method == "PUT" && r.URL.Path == "/v3/agents/"+fakeAgentID+"/config": _ = json.NewDecoder(r.Body).Decode(&f.configPutBody) writeJSON(map[string]any{ "vars": f.configPutBody["vars"], "deployment_required": true, }) - case r.Method == "POST" && r.URL.Path == "/v3/agents/agt_e2e/artifacts": + case r.Method == "POST" && r.URL.Path == "/v3/agents/"+fakeAgentID+"/artifacts": var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.artifactDigest, _ = body["digest"].(string) @@ -84,17 +86,17 @@ func (f *fakeCP) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, _ = buf.ReadFrom(r.Body) f.uploaded = buf.Bytes() w.WriteHeader(http.StatusOK) - case r.Method == "POST" && r.URL.Path == "/v3/agents/agt_e2e/deployments": + case r.Method == "POST" && r.URL.Path == "/v3/agents/"+fakeAgentID+"/deployments": _ = json.NewDecoder(r.Body).Decode(&f.deployBody) writeJSON(map[string]any{"deployment": map[string]any{"id": "dep_1", "state": "verifying"}}) - case r.Method == "GET" && r.URL.Path == "/v3/agents/agt_e2e/deployments/dep_1": + case r.Method == "GET" && r.URL.Path == "/v3/agents/"+fakeAgentID+"/deployments/dep_1": f.getCount++ if f.getCount < 2 { writeJSON(map[string]any{"id": "dep_1", "state": "verifying"}) // verifying → … } else { writeJSON(map[string]any{"id": "dep_1", "state": "ready", "active": true, "revision_id": "rev_1"}) // → terminal } - case r.Method == "GET" && r.URL.Path == "/v3/agents/agt_e2e/revisions": + case r.Method == "GET" && r.URL.Path == "/v3/agents/"+fakeAgentID+"/revisions": writeJSON(map[string]any{"data": []any{}}) default: http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound) @@ -107,25 +109,28 @@ func TestDeployFlueDoEndToEnd(t *testing.T) { } // A Flue app dir: manifest + clean source + a stand-in `flue` bin whose - // `build --target cloudflare` writes a deterministic dist/e2e_flue/ (entry + asset - // + wrangler), a directory deep. + // `build --target cloudflare` writes a deterministic but noisy dist/e2e_flue/. dir := t.TempDir() writeFile(t, filepath.Join(dir, "agent.toml"), "name = \"e2e-flue\"\nmodel = \"anthropic/claude-sonnet-5\"\n\n[runtime]\nfamily = \"flue\"\n", 0o644) writeFile(t, filepath.Join(dir, "src", "opencomputer.ts"), "import { serveOC } from '@opencomputer/flue';\nexport default serveOC(agent);\n", 0o644) - buildScript := "#!/bin/sh\nset -e\nmkdir -p dist/e2e_flue/assets\n" + + buildScript := "#!/bin/sh\nset -e\nmkdir -p dist/e2e_flue/assets dist/e2e_flue/.vite dist/e2e_flue/.flue-vite\n" + "cat > dist/e2e_flue/index.js <<'JS'\n" + e2eModuleBody + "\nJS\n" + "cat > dist/e2e_flue/assets/chunk.js <<'JS'\n" + e2eAssetBody + "\nJS\n" + + "cat > dist/e2e_flue/.flue-vite/runtime.mjs <<'JS'\n" + e2eRuntimeBody + "\nJS\n" + + "printf '%s' '{\"version\":3}' > dist/e2e_flue/.vite/manifest.json\n" + + "printf '%s' '{\"version\":3}' > dist/e2e_flue/index.js.map\n" + + "printf '%s' 'not a module' > dist/e2e_flue/README.txt\n" + "cat > dist/e2e_flue/wrangler.json <<'JSON'\n" + e2eWranglerBody + "\nJSON\n" writeFile(t, filepath.Join(dir, "node_modules", ".bin", "flue"), buildScript, 0o755) - // What the CLI must produce from that dist/e2e_flue/ (modes normalized to 0644, - // rooted at the wrangler's dir so index.js/assets are at the tar root). + // Only regular modules leave the machine. Raw Wrangler metadata, .vite state, + // source maps and non-modules are absent; .flue-vite is a legitimate module path. expectedFiles := []bundle.File{ {Path: "index.js", Mode: 0o644, Content: []byte(e2eModuleBody + "\n")}, {Path: "assets/chunk.js", Mode: 0o644, Content: []byte(e2eAssetBody + "\n")}, - {Path: "wrangler.json", Mode: 0o644, Content: []byte(e2eWranglerBody + "\n")}, + {Path: ".flue-vite/runtime.mjs", Mode: 0o644, Content: []byte(e2eRuntimeBody + "\n")}, } expectedTarGz, err := bundle.Pack(expectedFiles) if err != nil { @@ -165,7 +170,7 @@ func TestDeployFlueDoEndToEnd(t *testing.T) { t.Errorf("create body carried a prompt for a flue agent: %v", f.createBody["prompt"]) } - // The content address the CLI advertised == the digest of the whole-dir bundle, and + // The content address the CLI advertised == the digest of the module-only bundle, and // the PUT body is byte-exactly that canonical tar.gz — the full upload integrity chain. if f.artifactDigest != expectedDigest { t.Errorf("advertised digest = %s, want %s", f.artifactDigest, expectedDigest) @@ -205,7 +210,7 @@ func TestDeployFlueDoEndToEnd(t *testing.T) { t.Errorf("flue DO deploy carried a framework_artifact_digest: %v", input["framework_artifact_digest"]) } - // flue_wrangler: the generated wrangler forwarded verbatim (DO bindings intact). + // flue_wrangler is the exact canonical descriptor, not the generated resolution dump. wr, _ := input["flue_wrangler"].(map[string]any) if wr == nil { t.Fatalf("input.flue_wrangler missing: %v", input) @@ -216,6 +221,27 @@ func TestDeployFlueDoEndToEnd(t *testing.T) { if wr["durable_objects"] == nil { t.Errorf("flue_wrangler lost durable_objects: %v", wr) } + wantKeys := map[string]bool{ + "main": true, "compatibility_date": true, "compatibility_flags": true, + "no_bundle": true, "durable_objects": true, + } + if len(wr) != len(wantKeys) { + t.Errorf("flue_wrangler keys = %v, want only canonical descriptor", wr) + } + for key := range wr { + if !wantKeys[key] { + t.Errorf("raw Wrangler capability %q escaped into deployment: %v", key, wr[key]) + } + } + if wr["compatibility_date"] != flueCompatibilityDate || wr["no_bundle"] != true { + t.Errorf("flue_wrangler profile changed: %v", wr) + } + encodedWrangler, _ := json.Marshal(wr) + for _, leaked := range []string{"/Users/developer", "MUST_NOT_LEAVE", "attacker-owned", "example.com", "victim"} { + if strings.Contains(string(encodedWrangler), leaked) { + t.Errorf("raw Wrangler value %q escaped into deployment: %s", leaked, encodedWrangler) + } + } // The verifying→ready sequence was actually polled. if f.getCount < 2 { @@ -226,6 +252,71 @@ func TestDeployFlueDoEndToEnd(t *testing.T) { } } +func TestExtractFlueWranglerDescriptorRejectsCapabilityVariation(t *testing.T) { + valid := func() map[string]any { + var value map[string]any + if err := json.Unmarshal([]byte(e2eWranglerBody), &value); err != nil { + t.Fatal(err) + } + return value + } + tests := []struct { + name string + mutate func(map[string]any) + }{ + {name: "unsafe main", mutate: func(value map[string]any) { value["main"] = "../index.js" }}, + {name: "profile variation", mutate: func(value map[string]any) { value["compatibility_date"] = "2026-07-01" }}, + {name: "extra compatibility flag", mutate: func(value map[string]any) { + value["compatibility_flags"] = []any{"nodejs_compat", "unsafe"} + }}, + {name: "bundling enabled", mutate: func(value map[string]any) { value["no_bundle"] = false }}, + {name: "foreign script binding", mutate: func(value map[string]any) { + bindings := value["durable_objects"].(map[string]any)["bindings"].([]any) + bindings[0].(map[string]any)["script_name"] = "victim-worker" + }}, + {name: "missing registry", mutate: func(value map[string]any) { + value["durable_objects"].(map[string]any)["bindings"] = []any{ + map[string]any{"name": "AGENT", "class_name": "FlueE2EAgent"}, + } + }}, + {name: "duplicate binding name", mutate: func(value map[string]any) { + value["durable_objects"].(map[string]any)["bindings"] = []any{ + map[string]any{"name": "FLUE_REGISTRY", "class_name": "FlueE2EAgent"}, + map[string]any{"name": "FLUE_REGISTRY", "class_name": "FlueRegistry"}, + } + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + value := valid() + tc.mutate(value) + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if _, err := extractFlueWranglerDescriptor(raw); err == nil { + t.Fatalf("expected %s to be rejected", tc.name) + } + }) + } +} + +func TestReadBundleModulesRejectsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink semantics differ on Windows") + } + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.js") + writeFile(t, outside, "export const secret = true;", 0o644) + writeFile(t, filepath.Join(root, "index.js"), "export {};", 0o644) + if err := os.Symlink(outside, filepath.Join(root, "leak.js")); err != nil { + t.Fatal(err) + } + if _, err := readBundleModules(root); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink rejection, got %v", err) + } +} + func TestSyncManifestVarsReplacesDesiredVars(t *testing.T) { f := &fakeCP{} srv := httptest.NewServer(f) @@ -236,7 +327,7 @@ func TestSyncManifestVarsReplacesDesiredVars(t *testing.T) { cmd := &cobra.Command{} cmd.SetContext(context.Background()) m := &manifest{Vars: map[string]string{"PUBLIC_MODE": "careful", "MAX_ITEMS": "12"}} - if err := syncManifestVars(cmd, sc, "agt_e2e", m); err != nil { + if err := syncManifestVars(cmd, sc, fakeAgentID, m); err != nil { t.Fatalf("syncManifestVars: %v", err) } vars, _ := f.configPutBody["vars"].(map[string]any) From 7a284e44dba3849013dfe3802f943d51892d8930 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Mon, 13 Jul 2026 18:50:16 +0100 Subject: [PATCH 2/2] fix(agent-worker): close production boundary review gaps --- cloudflare-workers/oc-gateway/README.md | 10 +++---- cloudflare-workers/oc-gateway/src/index.ts | 2 -- cloudflare-workers/oc-gateway/src/orgkey.ts | 8 ++---- .../oc-gateway/test/integration.test.ts | 27 +++++++++++++++++-- .../oc-gateway/test/logic.test.ts | 6 +++++ cloudflare-workers/oc-gateway/wrangler.toml | 2 +- cmd/oc/internal/commands/agent_deploy_flue.go | 25 ++++++++++------- .../commands/agent_deploy_flue_test.go | 14 +++++++--- 8 files changed, 65 insertions(+), 29 deletions(-) diff --git a/cloudflare-workers/oc-gateway/README.md b/cloudflare-workers/oc-gateway/README.md index 247f114fb..1e89f6e84 100644 --- a/cloudflare-workers/oc-gateway/README.md +++ b/cloudflare-workers/oc-gateway/README.md @@ -74,8 +74,8 @@ POST {GATEWAY_ORKEY_URL} Authorization: Bearer {GATEWAY_ORKEY_SECRET} body { → 200 {"key": "sk-or-..."} (resolveManagedSecret for the org's active managed credential) ``` -The plaintext is cached per-org in-isolate with a 60 s TTL. `TEST_OR_KEY` is an in-process/local-test -override only and must be absent from the production Worker. +The plaintext is cached per-org in-isolate with a 60 s TTL. There is no single-key test or production +override: missing seam configuration returns no key, and tests exercise the same org-scoped request. ### 7. Prompt-caching safety @@ -96,18 +96,18 @@ Some models route (via OpenRouter) to a backend that rejects Anthropic `cache_co | `src/token.ts` | EdDSA per-deploy token mint/verify (Web Crypto, no deps) | | `src/budget.ts` | `SpendCounter` DO — keyed spend counter + hard gate (µ$ integers); org+agt (hard) + per-session (tracked) | | `src/deploylease.ts` | `DeployLease` DO — per-(org,agt) lease-epoch floor (rotation/revocation fence) | -| `src/orgkey.ts` | org OR-key resolution via the dedicated sessions-api seam (`TEST_OR_KEY` is test-only) | +| `src/orgkey.ts` | fail-closed org OR-key resolution via the dedicated sessions-api seam | | `src/cost.ts` | per-response cost extraction (JSON + SSE) | | `src/models.ts` | `cache_control` safety (strip for unsafe models) | | `scripts/mint.ts` | mint a per-deploy token for live verification | -| `test/` | `logic` (20) + `integration` (11) — **31 green** | +| `test/` | `logic` (25) + `integration` (11) — **36 green** | ## Production deployment The permanent Worker identity is `oc-agent-gateway-prod`, exposed only at its Workers.dev URL. Its fresh `SpendCounter` and `DeployLease` state is owned by that Worker. Production config fixes `GATEWAY_ORKEY_URL` to `https://api.opencomputer.dev/internal/gateway/org-key`; it does not configure -`TEST_OR_KEY` or `AGENT_BUDGET_USD_DEFAULT`. +`AGENT_BUDGET_USD_DEFAULT`. Default deploy fails intentionally. Production requires the explicit command: diff --git a/cloudflare-workers/oc-gateway/src/index.ts b/cloudflare-workers/oc-gateway/src/index.ts index bdbdd7905..24afd01ef 100644 --- a/cloudflare-workers/oc-gateway/src/index.ts +++ b/cloudflare-workers/oc-gateway/src/index.ts @@ -46,8 +46,6 @@ export interface Env { // Org OR-key seam (orgkey.ts): dedicated internal sessions-api route + its bearer secret. GATEWAY_ORKEY_URL?: string; GATEWAY_ORKEY_SECRET?: string; - // Acceptance-test single-key override (bypasses the seam). Never set in multi-org prod. - TEST_OR_KEY?: string; // Bearer that guards the control-plane admin routes (/admin/*). Unset → admin routes 404. GATEWAY_ADMIN_SECRET?: string; // Override OpenRouter base for tests; default = prod. diff --git a/cloudflare-workers/oc-gateway/src/orgkey.ts b/cloudflare-workers/oc-gateway/src/orgkey.ts index bfc1e636d..58931482b 100644 --- a/cloudflare-workers/oc-gateway/src/orgkey.ts +++ b/cloudflare-workers/oc-gateway/src/orgkey.ts @@ -13,14 +13,12 @@ // → 404/other on no active managed key. // // The plaintext is cached PER ORG in-isolate with a short TTL — it bounds exposure (evaporates with -// the isolate) and avoids hammering the seam on every model call in a turn. TEST_OR_KEY short-circuits -// resolution for the acceptance run against a throwaway $1-capped key (no sealed dev credential needed). +// the isolate) and avoids hammering the seam on every model call in a turn. There is deliberately no +// single-key override: every environment exercises the org-scoped seam and missing config fails closed. export interface OrgKeyEnv { GATEWAY_ORKEY_URL?: string; GATEWAY_ORKEY_SECRET?: string; - /** Acceptance-test / single-key override — bypasses the seam. Never set in multi-org prod. */ - TEST_OR_KEY?: string; } interface CacheEntry { @@ -32,8 +30,6 @@ const cache = new Map(); /** Resolve the org's OpenRouter inference key, or null if unavailable. Never throws. */ export async function resolveOrgKey(env: OrgKeyEnv, orgId: string, nowMs: number): Promise { - if (env.TEST_OR_KEY) return env.TEST_OR_KEY; - const hit = cache.get(orgId); if (hit && hit.exp > nowMs) return hit.key; diff --git a/cloudflare-workers/oc-gateway/test/integration.test.ts b/cloudflare-workers/oc-gateway/test/integration.test.ts index 57a7eea3b..243b13deb 100644 --- a/cloudflare-workers/oc-gateway/test/integration.test.ts +++ b/cloudflare-workers/oc-gateway/test/integration.test.ts @@ -11,9 +11,11 @@ import worker, { Env } from "../src/index.js"; import { SpendCounter } from "../src/budget.js"; import { DeployLease } from "../src/deploylease.js"; import { generateKeyPair, mintDeployToken } from "../src/token.js"; +import { _clearOrgKeyCache } from "../src/orgkey.js"; const OR_KEY = "sk-or-v1-FAKE-org-key"; const OR_BASE = "https://mock-openrouter.test/api"; +const OR_KEY_URL = "https://api.opencomputer.dev/internal/gateway/org-key"; const ORG_ID = "11111111-1111-4111-8111-111111111111"; const AGENT_ID = "agt_0123456789abcdef01234567"; @@ -53,6 +55,14 @@ let lastHeadersToOR: Headers | null; function mockFetch(perCallCost: number) { return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); + if (url === OR_KEY_URL) { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer dedicated-org-key-bearer"); + expect(JSON.parse(String(init?.body))).toEqual({ org: ORG_ID }); + return new Response(JSON.stringify({ key: OR_KEY }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } lastHeadersToOR = new Headers(init?.headers); lastAuthToOR = lastHeadersToOR.get("authorization"); lastBodyToOR = init?.body ? JSON.parse(init.body as string) : null; @@ -70,7 +80,15 @@ function mockFetch(perCallCost: number) { function mkEnv(extra: Partial = {}) { const spend = fakeNamespace(SpendCounter); const lease = fakeNamespace(DeployLease); - const env = { GATEWAY_TOKEN_PUBLIC_KEY: PUB, TEST_OR_KEY: OR_KEY, OPENROUTER_BASE: OR_BASE, SPEND_COUNTER: spend.ns, DEPLOY_LEASE: lease.ns, ...extra } as Env; + const env = { + GATEWAY_TOKEN_PUBLIC_KEY: PUB, + GATEWAY_ORKEY_URL: OR_KEY_URL, + GATEWAY_ORKEY_SECRET: "dedicated-org-key-bearer", + OPENROUTER_BASE: OR_BASE, + SPEND_COUNTER: spend.ns, + DEPLOY_LEASE: lease.ns, + ...extra, + } as Env; return { env, spend, lease }; } @@ -99,7 +117,12 @@ const stateOf = async (inst: { fetch(r: Request): Promise }) => (await inst.fetch(new Request("https://do/state"))).json() as Promise<{ spent_micro: number }>; describe("gateway on-path flow (resolved seam)", () => { - beforeEach(() => { lastAuthToOR = null; lastBodyToOR = null; lastHeadersToOR = null; }); + beforeEach(() => { + _clearOrgKeyCache(); + lastAuthToOR = null; + lastBodyToOR = null; + lastHeadersToOR = null; + }); afterEach(() => vi.restoreAllMocks()); it("GET /healthz → ok", async () => { diff --git a/cloudflare-workers/oc-gateway/test/logic.test.ts b/cloudflare-workers/oc-gateway/test/logic.test.ts index 37d431891..503e11134 100644 --- a/cloudflare-workers/oc-gateway/test/logic.test.ts +++ b/cloudflare-workers/oc-gateway/test/logic.test.ts @@ -89,6 +89,12 @@ describe("deploy token (EdDSA, per-deploy {org, agt})", () => { describe("org-key identity seam", () => { afterEach(() => { _clearOrgKeyCache(); vi.restoreAllMocks(); }); + it("fails closed without the dedicated route and bearer", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch"); + expect(await resolveOrgKey({}, ORG_ID, Date.now())).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("sends the token's bare UUID exactly once to the sessions API", async () => { let request: { url: string; auth: string | null; body: unknown } | undefined; vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/cloudflare-workers/oc-gateway/wrangler.toml b/cloudflare-workers/oc-gateway/wrangler.toml index 0d652ce47..0d82b6a4f 100644 --- a/cloudflare-workers/oc-gateway/wrangler.toml +++ b/cloudflare-workers/oc-gateway/wrangler.toml @@ -42,5 +42,5 @@ GATEWAY_ORKEY_URL = "https://api.opencomputer.dev/internal/gateway/org-key" # GATEWAY_TOKEN_PUBLIC_KEY — base64url raw 32-byte Ed25519 public key # GATEWAY_ORKEY_SECRET — bearer for the dedicated org-key route # GATEWAY_ADMIN_SECRET — bearer for /admin/* -# TEST_OR_KEY is test-only and MUST be absent from production. +# No single-key test override exists; production and tests both exercise the org-key seam. # AGENT_BUDGET_USD_DEFAULT is intentionally absent; managed OpenRouter/Autumn limits are authoritative. diff --git a/cmd/oc/internal/commands/agent_deploy_flue.go b/cmd/oc/internal/commands/agent_deploy_flue.go index 581c1f30f..c44f0227d 100644 --- a/cmd/oc/internal/commands/agent_deploy_flue.go +++ b/cmd/oc/internal/commands/agent_deploy_flue.go @@ -225,7 +225,8 @@ func runFlueBuild(ctx context.Context, dir string) error { // readFlueBundle locates the generated wrangler, extracts the exact Flue descriptor, // and reads only regular .js/.mjs modules rooted at the wrangler's directory. The raw -// wrangler resolution dump, .vite state, source maps and non-modules are never archived. +// wrangler resolution dump, .vite state and source maps are known control artifacts and +// are never archived; any other non-module fails loudly instead of producing a broken deploy. func readFlueBundle(distDir string) ([]bundle.File, flueWranglerDescriptor, error) { wranglerPath, err := findGeneratedWrangler(distDir) if err != nil { @@ -362,7 +363,8 @@ func findGeneratedWrangler(distDir string) (string, error) { } // readBundleModules walks the output into a module-only fileset with normalized modes -// and forward-slash, root-relative paths. Symlinks and special files fail closed. +// and forward-slash, root-relative paths. Symlinks, special files and unexpected regular +// files fail closed. Only the generated wrangler, source maps and .vite state are ignored. func readBundleModules(root string) ([]bundle.File, error) { if info, err := os.Stat(root); err != nil || !info.IsDir() { return nil, fmt.Errorf("build output %s not found — did `flue build --target cloudflare` run?", root) @@ -386,19 +388,22 @@ func readBundleModules(root string) ([]bundle.File, error) { return err } rel = filepath.ToSlash(rel) - if !safeFlueModulePath(rel) { - return nil // excludes wrangler.json, source maps and every non-.js/.mjs file - } - content, err := os.ReadFile(p) - if err != nil { - return err - } st, err := d.Info() if err != nil { return err } if !st.Mode().IsRegular() { - return fmt.Errorf("build output contains non-regular module %s", p) + return fmt.Errorf("build output contains non-regular file %s", p) + } + if rel == "wrangler.json" || strings.HasSuffix(rel, ".map") { + return nil + } + if !safeFlueModulePath(rel) { + return fmt.Errorf("build output contains unsupported file %q; expected only .js/.mjs modules", rel) + } + content, err := os.ReadFile(p) + if err != nil { + return err } files = append(files, bundle.File{ Path: rel, diff --git a/cmd/oc/internal/commands/agent_deploy_flue_test.go b/cmd/oc/internal/commands/agent_deploy_flue_test.go index ca897113e..14b32cdc5 100644 --- a/cmd/oc/internal/commands/agent_deploy_flue_test.go +++ b/cmd/oc/internal/commands/agent_deploy_flue_test.go @@ -121,12 +121,11 @@ func TestDeployFlueDoEndToEnd(t *testing.T) { "cat > dist/e2e_flue/.flue-vite/runtime.mjs <<'JS'\n" + e2eRuntimeBody + "\nJS\n" + "printf '%s' '{\"version\":3}' > dist/e2e_flue/.vite/manifest.json\n" + "printf '%s' '{\"version\":3}' > dist/e2e_flue/index.js.map\n" + - "printf '%s' 'not a module' > dist/e2e_flue/README.txt\n" + "cat > dist/e2e_flue/wrangler.json <<'JSON'\n" + e2eWranglerBody + "\nJSON\n" writeFile(t, filepath.Join(dir, "node_modules", ".bin", "flue"), buildScript, 0o755) - // Only regular modules leave the machine. Raw Wrangler metadata, .vite state, - // source maps and non-modules are absent; .flue-vite is a legitimate module path. + // Only regular modules leave the machine. Raw Wrangler metadata, .vite state and + // source maps are absent; .flue-vite is a legitimate module path. expectedFiles := []bundle.File{ {Path: "index.js", Mode: 0o644, Content: []byte(e2eModuleBody + "\n")}, {Path: "assets/chunk.js", Mode: 0o644, Content: []byte(e2eAssetBody + "\n")}, @@ -317,6 +316,15 @@ func TestReadBundleModulesRejectsSymlink(t *testing.T) { } } +func TestReadBundleModulesRejectsUnexpectedRegularFile(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "index.js"), "export {};", 0o644) + writeFile(t, filepath.Join(root, "runtime.wasm"), "not really wasm", 0o644) + if _, err := readBundleModules(root); err == nil || !strings.Contains(err.Error(), "unsupported file") { + t.Fatalf("expected unsupported-file rejection, got %v", err) + } +} + func TestSyncManifestVarsReplacesDesiredVars(t *testing.T) { f := &fakeCP{} srv := httptest.NewServer(f)