Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions cloudflare-workers/oc-gateway/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -35,15 +37,15 @@ Rule: `/{provider}/<tail>` → `<OR base for provider> + <tail>`, 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:<uuid>`; 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 <token>` **or** `x-api-key: <token>`. Verify = alg-pin + signature + `exp`/`iat` + `org`/`agt` present. Failure → `401`.
**Transport:** `Authorization: Bearer <token>` **or** `x-api-key: <token>`. Verify = alg-pin + signature + `exp`/`iat` + exact bare-org/agent claim shapes. Failure → `401`.

### 3. Enforcement grain (co-location refinement)

Expand Down Expand Up @@ -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. 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

Expand All @@ -93,11 +96,27 @@ 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` | 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
`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

Expand All @@ -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`.
4 changes: 2 additions & 2 deletions cloudflare-workers/oc-gateway/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions cloudflare-workers/oc-gateway/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 3 additions & 3 deletions cloudflare-workers/oc-gateway/scripts/mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<base64-pkcs8-pem> node ... scripts/mint.ts ...
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions cloudflare-workers/oc-gateway/src/index.ts
Original file line number Diff line number Diff line change
@@ -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', {
Expand Down Expand Up @@ -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.
Expand All @@ -60,6 +58,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)
Expand Down Expand Up @@ -101,7 +101,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
Expand Down Expand Up @@ -230,7 +230,9 @@ async function admin(req: Request, env: Env, url: URL): Promise<Response> {
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 }) });
Expand All @@ -242,7 +244,9 @@ async function admin(req: Request, env: Env, url: URL): Promise<Response> {
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" } });
Expand Down
8 changes: 2 additions & 6 deletions cloudflare-workers/oc-gateway/src/orgkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -32,8 +30,6 @@ const cache = new Map<string, CacheEntry>();

/** Resolve the org's OpenRouter inference key, or null if unavailable. Never throws. */
export async function resolveOrgKey(env: OrgKeyEnv, orgId: string, nowMs: number): Promise<string | null> {
if (env.TEST_OR_KEY) return env.TEST_OR_KEY;

const hit = cache.get(orgId);
if (hit && hit.exp > nowMs) return hit.key;

Expand Down
11 changes: 8 additions & 3 deletions cloudflare-workers/oc-gateway/src/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "";
Expand Down Expand Up @@ -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" };
}
Expand Down
Loading