From 0a1aa9cbb9ae09bb11650c6681dc4389b9b3be76 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Sun, 12 Jul 2026 01:26:20 +0100 Subject: [PATCH 1/4] fix(flue): scope gateway tokens and fence deploy epochs Require gateway:invoke, validate scoped deploy claims, reject stale or epoch-less leases after provisioning, namespace session counters by tenant, and use constant-time admin auth. Keep X-OC-Session attribution best-effort and non-authoritative. --- cloudflare-workers/oc-gateway/scripts/mint.ts | 27 ++++++++++++----- cloudflare-workers/oc-gateway/src/budget.ts | 6 ++-- .../oc-gateway/src/deploylease.ts | 3 +- cloudflare-workers/oc-gateway/src/index.ts | 22 +++++++++++--- cloudflare-workers/oc-gateway/src/token.ts | 19 ++++++++++-- .../oc-gateway/test/integration.test.ts | 12 ++++---- .../oc-gateway/test/logic.test.ts | 17 ++++++++--- sdks/flue/src/gateway.ts | 29 +++++++++---------- 8 files changed, 92 insertions(+), 43 deletions(-) diff --git a/cloudflare-workers/oc-gateway/scripts/mint.ts b/cloudflare-workers/oc-gateway/scripts/mint.ts index 953c4c93..aa2ebc44 100644 --- a/cloudflare-workers/oc-gateway/scripts/mint.ts +++ b/cloudflare-workers/oc-gateway/scripts/mint.ts @@ -2,16 +2,23 @@ // prod, binding the token as the tenant script's OC_SESSION_TOKEN env var; this is the dev/e2e helper. // On first use it also generates an Ed25519 keypair. // -// Generate + mint (PUBLIC + PRIVATE key printed on stderr; the token on stdout): +// 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 // → set the gateway's GATEWAY_TOKEN_PUBLIC_KEY secret from the printed value. -// Reuse a private key so the gateway's public key stays fixed across mints: -// GATEWAY_TOKEN_PRIVATE_KEY= node ... scripts/mint.ts --org org_1 --agent agt_1 --ep 2 +// Reuse the control-plane private value so the gateway public key stays fixed: +// V3_GATEWAY_TOKEN_PRIVATE_KEY= node ... scripts/mint.ts ... import { mintDeployToken, type DeployClaims } from "../src/token.ts"; const b64url = (buf: ArrayBuffer) => Buffer.from(buf).toString("base64url"); -const fromB64url = (s: string) => Buffer.from(s, "base64url"); +const pemFromDer = (der: ArrayBuffer) => { + const body = Buffer.from(der).toString("base64").match(/.{1,64}/g)?.join("\n") ?? ""; + return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`; +}; +const derFromPemB64 = (value: string) => { + const pem = Buffer.from(value, "base64").toString("utf8"); + return Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""), "base64"); +}; function arg(name: string, def?: string): string | undefined { const i = process.argv.indexOf(`--${name}`); @@ -21,14 +28,17 @@ function arg(name: string, def?: string): string | undefined { const ED = { name: "Ed25519" } as const; let privateKey: CryptoKey; -const existing = process.env.GATEWAY_TOKEN_PRIVATE_KEY; +const existing = process.env.V3_GATEWAY_TOKEN_PRIVATE_KEY; if (existing) { - privateKey = await crypto.subtle.importKey("pkcs8", fromB64url(existing), ED, true, ["sign"]); + privateKey = await crypto.subtle.importKey("pkcs8", derFromPemB64(existing), ED, true, ["sign"]); } else { const kp = (await crypto.subtle.generateKey(ED, true, ["sign", "verify"])) as CryptoKeyPair; privateKey = kp.privateKey; - console.error(`GATEWAY_TOKEN_PUBLIC_KEY=${b64url(await crypto.subtle.exportKey("raw", kp.publicKey))}`); - console.error(`GATEWAY_TOKEN_PRIVATE_KEY=${b64url(await crypto.subtle.exportKey("pkcs8", kp.privateKey))}`); + const publicValue = b64url(await crypto.subtle.exportKey("raw", kp.publicKey)); + const privateValue = Buffer.from(pemFromDer(await crypto.subtle.exportKey("pkcs8", kp.privateKey)), "utf8").toString("base64"); + console.error(`V3_GATEWAY_TOKEN_PRIVATE_KEY=${privateValue}`); + console.error(`V3_GATEWAY_TOKEN_PUBLIC_KEY=${publicValue}`); + console.error(`GATEWAY_TOKEN_PUBLIC_KEY=${publicValue}`); } const now = Math.floor(Date.now() / 1000); @@ -38,6 +48,7 @@ const claims: DeployClaims = { org: arg("org", "org_1")!, agt: arg("agent", "agt_1")!, ep: ep != null ? Number(ep) : undefined, + scopes: ["gateway:invoke", "sandbox:use", "repo:use", "ingest:write"], iat: now, exp: now + ttl, }; diff --git a/cloudflare-workers/oc-gateway/src/budget.ts b/cloudflare-workers/oc-gateway/src/budget.ts index 430ec25d..53afc5e3 100644 --- a/cloudflare-workers/oc-gateway/src/budget.ts +++ b/cloudflare-workers/oc-gateway/src/budget.ts @@ -59,8 +59,10 @@ export class SpendCounter { // POST /check {default_budget_micro?} → GATE a NEW call (used ONLY at the hard org+agt grain). On // first sight of an unprovisioned key, adopt the gateway's default cap (server-side; never from the - // token). Allowed while spent < budget; the last in-flight call can overshoot by at most one call's - // cost — bounded and acceptable for "refuse past the limit". Runs BEFORE the model call, DO-serialized. + // token). Allowed while spent < budget. Because /add lands after each response, the real overshoot + // bound is budget + the sum of every call concurrently in flight at the check boundary (not one + // call). The org's OpenRouter key cap remains the hard monetary ceiling. Runs before the call and + // is DO-serialized. if (url.pathname === "/check") { const s = await this.load(); if (!s.provisioned && typeof body.default_budget_micro === "number") { diff --git a/cloudflare-workers/oc-gateway/src/deploylease.ts b/cloudflare-workers/oc-gateway/src/deploylease.ts index 839f2792..190b0c60 100644 --- a/cloudflare-workers/oc-gateway/src/deploylease.ts +++ b/cloudflare-workers/oc-gateway/src/deploylease.ts @@ -37,7 +37,8 @@ export class DeployLease { if (url.pathname === "/gate") { const l = await this.load(); const ep = typeof body.ep === "number" ? body.ep : null; - if (ep == null) return Response.json({ ok: true, fenced: false, floor: l.floor }); // no epoch → no fence + // Rollout compatibility only while unprovisioned. Once a floor exists, omission fails closed. + if (ep == null) return Response.json({ ok: l.floor === 0, fenced: l.floor > 0, floor: l.floor, missing_epoch: l.floor > 0 }); if (ep < l.floor) return Response.json({ ok: false, fenced: true, floor: l.floor }); if (ep > l.floor) { l.floor = ep; diff --git a/cloudflare-workers/oc-gateway/src/index.ts b/cloudflare-workers/oc-gateway/src/index.ts index 2d420329..9138e746 100644 --- a/cloudflare-workers/oc-gateway/src/index.ts +++ b/cloudflare-workers/oc-gateway/src/index.ts @@ -81,6 +81,18 @@ function bearer(h: Headers): string | null { return x ? x.trim() : null; } +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +function validSessionId(value: string | null): string | null { + const id = value?.trim() ?? ""; + return id.length <= 128 && /^ses_[A-Za-z0-9_-]+$/.test(id) ? id : null; +} + const json = (obj: unknown, status = 200) => new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }); @@ -106,13 +118,13 @@ export default { const token = bearer(req.headers); if (!token) return json({ error: { type: "unauthorized", message: "missing deploy token" } }, 401); const nowSec = Math.floor(Date.now() / 1000); - const v = await verifyDeployToken(env.GATEWAY_TOKEN_PUBLIC_KEY, token, nowSec); + const v = await verifyDeployToken(env.GATEWAY_TOKEN_PUBLIC_KEY, token, nowSec, "gateway:invoke"); if (!v.ok) return json({ error: { type: "unauthorized", message: `invalid deploy token: ${v.reason}` } }, 401); const { org: orgId, agt: agentId, ep } = v.claims; // Best-effort per-session attribution — the header may be stale under co-location; used for // tracking only, never gated. Absent → we simply skip the per-session record (the call proceeds). - const sessionId = req.headers.get("x-oc-session")?.trim() || null; + const sessionId = validSessionId(req.headers.get("x-oc-session")); // (b) lease-epoch fence — a rotated/revoked deploy token stops verifying (per org+agt, DO-serialized). const leaseStub = env.DEPLOY_LEASE.get(env.DEPLOY_LEASE.idFromName(`${orgId}:${agentId}`)); @@ -186,7 +198,9 @@ export default { ctx.waitUntil( meter(meterCopy, isStream, env, { agentBudget, - sessionCounter: sessionId ? env.SPEND_COUNTER.get(env.SPEND_COUNTER.idFromName(`sess:${sessionId}`)) : null, + sessionCounter: sessionId + ? env.SPEND_COUNTER.get(env.SPEND_COUNTER.idFromName(`sess:${orgId}:${agentId}:${sessionId}`)) + : null, sessionId, orgId, agentId, }), ); @@ -208,7 +222,7 @@ function parseUsdMicro(usd?: string): number | null { async function admin(req: Request, env: Env, url: URL): Promise { if (!env.GATEWAY_ADMIN_SECRET) return json({ error: { type: "not_found" } }, 404); const auth = bearer(req.headers); - if (auth !== env.GATEWAY_ADMIN_SECRET) return json({ error: { type: "unauthorized" } }, 401); + if (!auth || !timingSafeEqual(auth, env.GATEWAY_ADMIN_SECRET)) return json({ error: { type: "unauthorized" } }, 401); if (req.method !== "POST") return json({ error: { type: "method_not_allowed" } }, 405); const body = (await req.json().catch(() => ({}))) as Record; diff --git a/cloudflare-workers/oc-gateway/src/token.ts b/cloudflare-workers/oc-gateway/src/token.ts index 311bd99e..e47d11a8 100644 --- a/cloudflare-workers/oc-gateway/src/token.ts +++ b/cloudflare-workers/oc-gateway/src/token.ts @@ -17,7 +17,7 @@ // explicitly-revoked deploy token stops verifying even before `exp`. // // MINT↔VERIFY CONTRACT (W7 mints; the gateway verifies): -// alg "EdDSA"; claims { org, agt, iat, exp, ep? }; the gateway is configured with +// alg "EdDSA"; claims { org, agt, iat, exp, ep?, scopes[] }; the gateway is configured with // GATEWAY_TOKEN_PUBLIC_KEY = base64url(raw 32-byte Ed25519 public key). export interface DeployClaims { @@ -27,6 +27,8 @@ export interface DeployClaims { agt: string; /** lease/deploy epoch (monotonic per (org, agt)). Below the DeployLease floor → fenced. Omit = no fence. */ ep?: number; + /** Explicit tenant capabilities; the gateway requires `gateway:invoke`. */ + scopes: Array<"gateway:invoke" | "sandbox:use" | "repo:use" | "ingest:write">; /** issued-at / expiry (seconds). */ iat: number; exp: number; @@ -57,7 +59,12 @@ async function importPublicKey(publicKeyB64url: string): Promise { export type VerifyResult = { ok: true; claims: DeployClaims } | { ok: false; reason: string }; /** Verify a per-deploy EdDSA token: alg pin + signature + exp/iat + required claims (org, agt). */ -export async function verifyDeployToken(publicKeyB64url: string, token: string, nowSec: number): Promise { +export async function verifyDeployToken( + publicKeyB64url: string, + token: string, + nowSec: number, + requiredScope?: DeployClaims["scopes"][number], +): Promise { const parts = token.split("."); if (parts.length !== 3) return { ok: false, reason: "malformed" }; const [header, payload, sig] = parts; @@ -94,6 +101,14 @@ export async function verifyDeployToken(publicKeyB64url: string, token: string, 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 (!claims.org || !claims.agt) return { ok: false, reason: "missing_claims" }; + if (!Array.isArray(claims.scopes) || claims.scopes.some((scope) => + scope !== "gateway:invoke" && scope !== "sandbox:use" && scope !== "repo:use" && scope !== "ingest:write")) { + return { ok: false, reason: "bad_scopes" }; + } + if (claims.ep !== undefined && (!Number.isSafeInteger(claims.ep) || claims.ep < 0)) { + return { ok: false, reason: "bad_epoch" }; + } + if (requiredScope && !claims.scopes.includes(requiredScope)) return { ok: false, reason: "missing_scope" }; return { ok: true, claims }; } diff --git a/cloudflare-workers/oc-gateway/test/integration.test.ts b/cloudflare-workers/oc-gateway/test/integration.test.ts index a698ab3b..49cf79f7 100644 --- a/cloudflare-workers/oc-gateway/test/integration.test.ts +++ b/cloudflare-workers/oc-gateway/test/integration.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect, beforeEach, beforeAll, vi, afterEach } from "vite 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 { generateKeyPair, mintDeployToken, type DeployClaims } from "../src/token.js"; const OR_KEY = "sk-or-v1-FAKE-org-key"; const OR_BASE = "https://mock-openrouter.test/api"; @@ -89,9 +89,9 @@ const post = (token?: string, session?: string, body = MSG) => new Request("http }, body, }); -const mint = async (o: Partial<{ org: string; agt: string; ep: number; iat: number; exp: number }> = {}) => { +const mint = async (o: Partial<{ org: string; agt: string; ep: number; scopes: DeployClaims["scopes"]; 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_1", agt: "agt_1", scopes: ["gateway:invoke"], 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,8 +180,8 @@ 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:ses_x")!)).spent_micro).toBe(20_000); - expect((await stateOf(spend.instances.get("sess:ses_y")!)).spent_micro).toBe(20_000); + 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); // and the authoritative org+agt grain summed them expect((await stateOf(spend.instances.get("agt:org_1:agt_1")!)).spent_micro).toBe(40_000); }); @@ -192,7 +192,7 @@ 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:ses_hot")!)).spent_micro).toBe(300_000); + expect((await stateOf(spend.instances.get("sess:org_1:agt_1:ses_hot")!)).spent_micro).toBe(300_000); }); it("fences a superseded deploy lease epoch (401 token_superseded)", async () => { diff --git a/cloudflare-workers/oc-gateway/test/logic.test.ts b/cloudflare-workers/oc-gateway/test/logic.test.ts index 1067d6db..c1b1ae37 100644 --- a/cloudflare-workers/oc-gateway/test/logic.test.ts +++ b/cloudflare-workers/oc-gateway/test/logic.test.ts @@ -13,7 +13,7 @@ import { DeployLease } from "../src/deploylease.js"; const now = 1_800_000_000; 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_1", agt: "agt_1", ep: 2, scopes: ["gateway:invoke", "sandbox:use"], iat: now, exp: now + 3600, ...o, }); function fakeState(): DurableObjectState { @@ -58,11 +58,18 @@ describe("deploy token (EdDSA, per-deploy {org, agt})", () => { }); it("rejects a token missing org/agt", async () => { const { privateKey, publicKeyB64url } = await generateKeyPair(); - const t = await mintDeployToken(privateKey, { org: "", agt: "", iat: now, exp: now + 3600 }); + const t = await mintDeployToken(privateKey, { org: "", agt: "", scopes: ["gateway:invoke"], iat: now, exp: now + 3600 }); const v = await verifyDeployToken(publicKeyB64url, t, now); expect(v.ok).toBe(false); if (!v.ok) expect(v.reason).toBe("missing_claims"); }); + it("requires the named capability", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const token = await mintDeployToken(privateKey, claims({ scopes: ["sandbox:use"] })); + const v = await verifyDeployToken(publicKeyB64url, token, now, "gateway:invoke"); + expect(v.ok).toBe(false); + if (!v.ok) expect(v.reason).toBe("missing_scope"); + }); 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("."); @@ -116,10 +123,12 @@ describe("DeployLease DO — lease-epoch fence + bump (revocation)", () => { expect(stale.fenced).toBe(true); expect(stale.ok).toBe(false); }); - it("a token with no epoch is never fenced (opt-in fence)", async () => { + it("an epoch-less token fails closed once a floor exists", async () => { const l = new DeployLease(fakeState()); await call(l, "/gate", { ep: 5 }); // raise the floor - expect((await call(l, "/gate", {})).fenced).toBe(false); // no ep → passes + const missing = await call(l, "/gate", {}); + expect(missing.fenced).toBe(true); + expect(missing.ok).toBe(false); }); it("bump revokes without a redeploy (raise the floor above the live token)", async () => { const l = new DeployLease(fakeState()); diff --git a/sdks/flue/src/gateway.ts b/sdks/flue/src/gateway.ts index eb8c9e9a..5cf20cfa 100644 --- a/sdks/flue/src/gateway.ts +++ b/sdks/flue/src/gateway.ts @@ -8,10 +8,10 @@ // The secret DOES live on the per-REQUEST env (`c.env`) — the same source ocSandbox reads at run time. // Fix: bind the apiKey at RUN scope from the exported `route` middleware, reading OC_SESSION_TOKEN from // `c.env` by DIRECT property access (NEVER spread c.env — the CF env is a proxy and spreading it throws) -// and OC_GATEWAY from the ambient snapshot, once per isolate. The token is per-DEPLOY (identical for every -// session), so the MODULE(isolate)-scoped registry holds one static value with NO cross-session race; the -// upstream per-request headers(ctx)/getApiKey(ctx) ask is only needed IF a PER-SESSION token is later -// introduced. registerProvider still takes a static apiKey / getApiKey has no turn context — fine here. +// and OC_GATEWAY from the ambient snapshot. The token is per-DEPLOY (identical for every session), so +// rebinding it is race-free. The route also sets a best-effort X-OC-Session header; that static registry +// header can race under co-location and is never an authorization boundary. Exact attribution still needs +// the upstream per-request headers(ctx) resolver. import { registerProvider } from "@flue/runtime"; import type { AgentInitializerContext, AgentRouteHandler } from "@flue/runtime"; @@ -38,12 +38,13 @@ export interface OcEnv { * model specifier resolves); attaches the apiKey only when the per-deploy OC_SESSION_TOKEN is present in * this snapshot. Returns true only when the apiKey actually landed — callers use that to stop rebinding. * No-op when OC_GATEWAY is unset (local `flue dev` falls through to pi-ai's env-var key lookup). */ -function bindOcProvider(env: OcEnv): boolean { +function bindOcProvider(env: OcEnv, sessionId?: string): boolean { const gw = env.OC_GATEWAY; if (!gw) return false; registerProvider("anthropic", { baseUrl: `${gw.replace(/\/+$/, "")}/anthropic`, ...(env.OC_SESSION_TOKEN ? { apiKey: env.OC_SESSION_TOKEN } : {}), + ...(sessionId ? { headers: { "X-OC-Session": sessionId } } : {}), }); return Boolean(env.OC_SESSION_TOKEN); } @@ -61,16 +62,13 @@ export function useOcGateway(ctx: AgentInitializerContext): void { bindOcProvider(ocResolveEnv(ctx.env)); } -/** Set once the run-scope apiKey bind lands (module/isolate-scoped; the token is deploy-static so one - * bind serves every co-located session — no per-session race). */ -let ocProviderBound = false; - /** * The HTTP-transport opt-in every OC-hosted agent MUST export as `route` (an agent is reachable at * `/agents/:name/:id` only when its module exports `route` — flue-app.ts). ALSO the run-scope binder for * the token seam: on each request (where the request ALS is entered and OC_SESSION_TOKEN is readable) it - * (re)binds the provider's apiKey, once per isolate, BEFORE the turn's model call runs via `next()`. A - * first `?view=updates` read that predates the token just leaves the flag false and retries next request. + * (re)binds the provider's apiKey and best-effort session attribution BEFORE the turn's model call + * runs via `next()`. The provider registry is isolate-global, so the session header is visibility-only + * until Flue exposes a per-request provider resolver; hard enforcement remains org+agent in the token. * The OC dispatch Worker is the auth boundary (013 §3 B5), so the transport itself adds none. */ export const route: AgentRouteHandler = async (c, next) => { @@ -79,10 +77,9 @@ export const route: AgentRouteHandler = async (c, next) => { // key"). The secret IS on the REQUEST env (c.env) — the same source ocSandbox reads in createSessionEnv. // Read the token from c.env by DIRECT property access (never spread c.env — the CF env is a proxy and // spreading it throws, which would break the request), and take OC_GATEWAY from the ambient snapshot. - if (!ocProviderBound) { - const amb = ocResolveEnv(undefined); - const token = (c.env as OcEnv | undefined)?.OC_SESSION_TOKEN ?? amb.OC_SESSION_TOKEN; - ocProviderBound = bindOcProvider({ ...amb, ...(token ? { OC_SESSION_TOKEN: token } : {}) }); - } + const amb = ocResolveEnv(undefined); + const token = (c.env as OcEnv | undefined)?.OC_SESSION_TOKEN ?? amb.OC_SESSION_TOKEN; + const sessionId = c.req.param("id") || undefined; + bindOcProvider({ ...amb, ...(token ? { OC_SESSION_TOKEN: token } : {}) }, sessionId); return next(); }; From 0946f4d7eecd0aec32b40f7f24a71ada9aa1524a Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Sun, 12 Jul 2026 01:26:26 +0100 Subject: [PATCH 2/4] feat(flue): add config and secret CLI and SDK surfaces Sync non-secret agent.toml [vars] before deploy without erasing egress policy. Add oc agent config and oc agent secret commands plus the typed oc.agents.config SDK resource, preserving environment binding names through request normalization. --- cmd/oc/internal/commands/agent.go | 1 + cmd/oc/internal/commands/agent_config.go | 320 ++++++++++++++++++ cmd/oc/internal/commands/agent_deploy.go | 8 +- cmd/oc/internal/commands/agent_deploy_flue.go | 7 + .../commands/agent_deploy_flue_test.go | 53 +++ sdks/typescript/src/agents/agents.ts | 4 + sdks/typescript/src/agents/config.test.ts | 37 ++ sdks/typescript/src/agents/config.ts | 52 +++ sdks/typescript/src/agents/index.ts | 4 + sdks/typescript/src/agents/normalize.test.ts | 10 + sdks/typescript/src/agents/normalize.ts | 8 +- 11 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 cmd/oc/internal/commands/agent_config.go create mode 100644 sdks/typescript/src/agents/config.test.ts create mode 100644 sdks/typescript/src/agents/config.ts diff --git a/cmd/oc/internal/commands/agent.go b/cmd/oc/internal/commands/agent.go index 3d95c1d8..cf230107 100644 --- a/cmd/oc/internal/commands/agent.go +++ b/cmd/oc/internal/commands/agent.go @@ -173,6 +173,7 @@ var agentCmd = &cobra.Command{ func init() { registerAgentCrud() registerAgentDeploy() + registerAgentConfig() registerAgentSchedules() rootCmd.AddCommand(sessionCmd) } diff --git a/cmd/oc/internal/commands/agent_config.go b/cmd/oc/internal/commands/agent_config.go new file mode 100644 index 00000000..56f7910e --- /dev/null +++ b/cmd/oc/internal/commands/agent_config.go @@ -0,0 +1,320 @@ +package commands + +// Flue Worker configuration. Non-secret vars can also live in agent.toml's +// [vars] section; secret values are accepted only by the write-only secret API. + +import ( + "bufio" + "fmt" + "net/url" + "os" + "sort" + "strings" + + "github.com/opensandbox/opensandbox/cmd/oc/internal/client" + "github.com/spf13/cobra" +) + +type agentConfig struct { + Vars map[string]string `json:"vars"` + EgressAllowlist []string `json:"egress_allowlist"` + DeploymentRequired bool `json:"deployment_required,omitempty"` +} + +type agentSecret struct { + Name string `json:"name"` + Last4 string `json:"last4"` + UpdatedAt string `json:"updated_at"` + SyncStatus string `json:"sync_status"` +} + +type agentSecretList struct { + Data []agentSecret `json:"data"` +} + +func agentConfigPath(id string) string { return "/v3/agents/" + id + "/config" } +func agentSecretsPath(id string) string { return "/v3/agents/" + id + "/secrets" } +func agentSecretPath(id, name string) string { + return agentSecretsPath(id) + "/" + url.PathEscape(name) +} + +func getAgentConfig(cmd *cobra.Command, sc *client.Client, id string) (agentConfig, error) { + var cfg agentConfig + err := sc.Get(cmd.Context(), agentConfigPath(id), &cfg) + if cfg.Vars == nil { + cfg.Vars = map[string]string{} + } + return cfg, err +} + +func putAgentConfig(cmd *cobra.Command, sc *client.Client, id string, cfg agentConfig) (agentConfig, error) { + var saved agentConfig + err := sc.PutJSON(cmd.Context(), agentConfigPath(id), map[string]interface{}{ + "vars": cfg.Vars, "egress_allowlist": cfg.EgressAllowlist, + }, &saved) + return saved, err +} + +// syncManifestVars applies [vars] before a Flue deployment is enqueued. A nil +// Vars map means no section was supplied, so an ordinary deploy never erases +// config that was managed through the API/dashboard. +func syncManifestVars(cmd *cobra.Command, sc *client.Client, id string, m *manifest) error { + if m.Vars == nil { + return nil + } + cfg, err := getAgentConfig(cmd, sc, id) + if err != nil { + return fmt.Errorf("read agent config: %w", err) + } + cfg.Vars = m.Vars + if _, err := putAgentConfig(cmd, sc, id, cfg); err != nil { + return fmt.Errorf("sync agent.toml [vars]: %w", err) + } + return nil +} + +func parseConfigVars(values []string) (map[string]string, error) { + out := make(map[string]string, len(values)) + for _, raw := range values { + name, value, ok := strings.Cut(raw, "=") + name = strings.TrimSpace(name) + if !ok || name == "" { + return nil, fmt.Errorf("invalid --var %q (want NAME=VALUE)", raw) + } + out[name] = value + } + return out, nil +} + +func sortedUnique(values []string) []string { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + seen[value] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +var agentConfigCmd = &cobra.Command{ + Use: "config [id|name]", + Short: "Show or update a Flue agent's vars and outbound host allowlist", + Long: "Show a Flue agent's Worker configuration. Update it with --var, --unset-var,\n" + + "--allow-host, or --deny-host. Vars take effect on the next deploy; secrets use\n" + + "`oc agent secret` and never appear here.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sc, err := sessionsClient(cmd) + if err != nil { + return err + } + id, err := targetAgentID(cmd, sc, args) + if err != nil { + return err + } + cfg, err := getAgentConfig(cmd, sc, id) + if err != nil { + return err + } + + setVars, _ := cmd.Flags().GetStringArray("var") + unsetVars, _ := cmd.Flags().GetStringArray("unset-var") + allowHosts, _ := cmd.Flags().GetStringArray("allow-host") + denyHosts, _ := cmd.Flags().GetStringArray("deny-host") + clearVars, _ := cmd.Flags().GetBool("clear-vars") + clearEgress, _ := cmd.Flags().GetBool("clear-egress") + changing := len(setVars)+len(unsetVars)+len(allowHosts)+len(denyHosts) > 0 || clearVars || clearEgress + if changing { + parsed, err := parseConfigVars(setVars) + if err != nil { + return err + } + if clearVars { + cfg.Vars = map[string]string{} + } + for name, value := range parsed { + cfg.Vars[name] = value + } + for _, name := range unsetVars { + delete(cfg.Vars, strings.TrimSpace(name)) + } + if clearEgress { + cfg.EgressAllowlist = nil + } + cfg.EgressAllowlist = append(cfg.EgressAllowlist, allowHosts...) + denied := make(map[string]struct{}, len(denyHosts)) + for _, host := range denyHosts { + denied[strings.TrimSpace(host)] = struct{}{} + } + hosts := cfg.EgressAllowlist[:0] + for _, host := range sortedUnique(cfg.EgressAllowlist) { + if _, remove := denied[host]; !remove { + hosts = append(hosts, host) + } + } + cfg.EgressAllowlist = hosts + cfg, err = putAgentConfig(cmd, sc, id, cfg) + if err != nil { + return err + } + } + + printer.Print(cfg, func() { + if len(cfg.Vars) == 0 { + fmt.Println("Vars: (none)") + } else { + fmt.Println("Vars:") + keys := make([]string, 0, len(cfg.Vars)) + for key := range cfg.Vars { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + fmt.Printf(" %s=%s\n", key, cfg.Vars[key]) + } + } + if len(cfg.EgressAllowlist) == 0 { + fmt.Println("Outbound hosts: (none; fail-closed)") + } else { + fmt.Println("Outbound hosts:") + for _, host := range cfg.EgressAllowlist { + fmt.Printf(" %s\n", host) + } + } + if changing { + fmt.Println("Saved. Vars apply on the next deploy; egress policy applies shortly.") + } + }) + return nil + }, +} + +var agentSecretCmd = &cobra.Command{ + Use: "secret", + Short: "Manage write-only Flue Worker secrets", +} + +var agentSecretListCmd = &cobra.Command{ + Use: "list [id|name]", + Aliases: []string{"ls"}, + Short: "List secret metadata (values are never returned)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sc, err := sessionsClient(cmd) + if err != nil { + return err + } + id, err := targetAgentID(cmd, sc, args) + if err != nil { + return err + } + var res agentSecretList + if err := sc.Get(cmd.Context(), agentSecretsPath(id), &res); err != nil { + return err + } + printer.Print(res.Data, func() { + if len(res.Data) == 0 { + fmt.Println("No secrets.") + return + } + rows := make([][]string, 0, len(res.Data)) + for _, secret := range res.Data { + rows = append(rows, []string{secret.Name, secret.Last4, secret.SyncStatus, formatAge(secret.UpdatedAt)}) + } + printer.Table([]string{"NAME", "LAST4", "STATUS", "UPDATED"}, rows) + }) + return nil + }, +} + +var agentSecretSetCmd = &cobra.Command{ + Use: "set [value]", + Short: "Set or rotate a Worker secret", + Long: "Set a write-only Flue Worker secret. Prefer --from-stdin so the value does not\n" + + "enter shell history. The live Worker is updated in place when already deployed.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + sc, err := sessionsClient(cmd) + if err != nil { + return err + } + id, err := targetAgentID(cmd, sc, nil) + if err != nil { + return err + } + fromStdin, _ := cmd.Flags().GetBool("from-stdin") + var value string + switch { + case fromStdin && len(args) == 2: + return fmt.Errorf("pass the value or --from-stdin, not both") + case fromStdin: + scanner := bufio.NewScanner(os.Stdin) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("reading stdin: %w", err) + } + value = strings.Join(lines, "\n") + case len(args) == 2: + value = args[1] + default: + return fmt.Errorf("provide a value or use --from-stdin") + } + if value == "" { + return fmt.Errorf("secret value cannot be empty") + } + var saved agentSecret + if err := sc.PutJSON(cmd.Context(), agentSecretPath(id, args[0]), map[string]string{"value": value}, &saved); err != nil { + return err + } + printer.Print(saved, func() { fmt.Printf("Secret %s set (%s).\n", saved.Name, saved.SyncStatus) }) + return nil + }, +} + +var agentSecretDeleteCmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete a Worker secret", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sc, err := sessionsClient(cmd) + if err != nil { + return err + } + id, err := targetAgentID(cmd, sc, nil) + if err != nil { + return err + } + if err := sc.Delete(cmd.Context(), agentSecretPath(id, args[0])); err != nil { + return err + } + fmt.Printf("Secret %s deleted.\n", args[0]) + return nil + }, +} + +func registerAgentConfig() { + agentConfigCmd.Flags().String("agent", "", "Target agent id or name (else the cwd agent.toml)") + agentConfigCmd.Flags().StringArray("var", nil, "Set a non-secret binding (NAME=VALUE; repeatable)") + agentConfigCmd.Flags().StringArray("unset-var", nil, "Remove a non-secret binding (repeatable)") + agentConfigCmd.Flags().StringArray("allow-host", nil, "Allow outbound HTTPS to a hostname (repeatable; supports *.example.com)") + agentConfigCmd.Flags().StringArray("deny-host", nil, "Remove a hostname from the outbound allowlist (repeatable)") + agentConfigCmd.Flags().Bool("clear-vars", false, "Remove all non-secret bindings") + agentConfigCmd.Flags().Bool("clear-egress", false, "Return outbound HTTPS to fail-closed defaults") + + for _, command := range []*cobra.Command{agentSecretListCmd, agentSecretSetCmd, agentSecretDeleteCmd} { + command.Flags().String("agent", "", "Target agent id or name (else the cwd agent.toml)") + } + agentSecretSetCmd.Flags().Bool("from-stdin", false, "Read the secret value from stdin") + agentSecretCmd.AddCommand(agentSecretListCmd, agentSecretSetCmd, agentSecretDeleteCmd) + agentCmd.AddCommand(agentConfigCmd, agentSecretCmd) +} diff --git a/cmd/oc/internal/commands/agent_deploy.go b/cmd/oc/internal/commands/agent_deploy.go index d877ca4e..e45140ec 100644 --- a/cmd/oc/internal/commands/agent_deploy.go +++ b/cmd/oc/internal/commands/agent_deploy.go @@ -16,8 +16,12 @@ import ( // ── agent.toml manifest + deploy bundle ── type manifest struct { - Name string `toml:"name"` - Model string `toml:"model"` + Name string `toml:"name"` + Model string `toml:"model"` + // Non-secret Flue Worker bindings. A nil map means the [vars] section was + // omitted and deploy must leave the remote config untouched; an explicitly + // empty [vars] section clears it. Secrets never belong in agent.toml. + Vars map[string]string `toml:"vars"` Runtime struct { Family string `toml:"family"` Type string `toml:"type"` diff --git a/cmd/oc/internal/commands/agent_deploy_flue.go b/cmd/oc/internal/commands/agent_deploy_flue.go index e13f95d9..63957f4c 100644 --- a/cmd/oc/internal/commands/agent_deploy_flue.go +++ b/cmd/oc/internal/commands/agent_deploy_flue.go @@ -71,6 +71,13 @@ func deployFlue(cmd *cobra.Command, sc *client.Client, dir string, m *manifest, if err != nil { return err } + // [vars] is part of the deployment input even though the values live in the + // agent config resource. Persist it before enqueueing so the off-host runner + // cannot race ahead and compose the Worker with stale bindings. Preserve the + // separately managed egress allowlist. Secrets are intentionally CLI/API only. + if err := syncManifestVars(cmd, sc, id, m); err != nil { + return err + } // 3. Build the app with its own `flue` CLI (a devDependency). if err := runFlueBuild(cmd.Context(), dir); err != nil { diff --git a/cmd/oc/internal/commands/agent_deploy_flue_test.go b/cmd/oc/internal/commands/agent_deploy_flue_test.go index ba0c059d..29a749bb 100644 --- a/cmd/oc/internal/commands/agent_deploy_flue_test.go +++ b/cmd/oc/internal/commands/agent_deploy_flue_test.go @@ -43,6 +43,8 @@ type fakeCP struct { mu sync.Mutex self string createBody map[string]any + configGets int + configPutBody map[string]any artifactDigest string artifactSize float64 uploaded []byte @@ -63,6 +65,15 @@ func (f *fakeCP) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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 == "GET" && r.URL.Path == "/v3/agents/agt_e2e/config": + f.configGets++ + writeJSON(map[string]any{"vars": map[string]string{}, "egress_allowlist": []string{"api.example.com"}}) + case r.Method == "PUT" && r.URL.Path == "/v3/agents/agt_e2e/config": + _ = json.NewDecoder(r.Body).Decode(&f.configPutBody) + writeJSON(map[string]any{ + "vars": f.configPutBody["vars"], "egress_allowlist": f.configPutBody["egress_allowlist"], + "deployment_required": true, + }) case r.Method == "POST" && r.URL.Path == "/v3/agents/agt_e2e/artifacts": var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) @@ -215,6 +226,48 @@ func TestDeployFlueDoEndToEnd(t *testing.T) { if f.getCount < 2 { t.Errorf("expected the poll to observe verifying→ready (got %d GETs)", f.getCount) } + if f.configGets != 0 || f.configPutBody != nil { + t.Errorf("manifest without [vars] unexpectedly touched remote config: gets=%d put=%v", f.configGets, f.configPutBody) + } +} + +func TestSyncManifestVarsPreservesEgress(t *testing.T) { + f := &fakeCP{} + srv := httptest.NewServer(f) + defer srv.Close() + f.self = srv.URL + + sc := client.NewSessionsAPI(srv.URL, "test-key") + 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 { + t.Fatalf("syncManifestVars: %v", err) + } + if f.configGets != 1 { + t.Fatalf("config GETs = %d, want 1", f.configGets) + } + vars, _ := f.configPutBody["vars"].(map[string]any) + if vars["PUBLIC_MODE"] != "careful" || vars["MAX_ITEMS"] != "12" { + t.Errorf("vars PUT = %#v", f.configPutBody["vars"]) + } + hosts, _ := f.configPutBody["egress_allowlist"].([]any) + if len(hosts) != 1 || hosts[0] != "api.example.com" { + t.Errorf("egress allowlist was not preserved: %#v", f.configPutBody["egress_allowlist"]) + } +} + +func TestParseConfigVars(t *testing.T) { + got, err := parseConfigVars([]string{"MODE=fast", "EMPTY="}) + if err != nil { + t.Fatal(err) + } + if got["MODE"] != "fast" || got["EMPTY"] != "" { + t.Fatalf("parsed = %#v", got) + } + if _, err := parseConfigVars([]string{"BROKEN"}); err == nil { + t.Fatal("expected malformed --var to fail") + } } func TestDeployFlueBlocksOnLeakedKey(t *testing.T) { diff --git a/sdks/typescript/src/agents/agents.ts b/sdks/typescript/src/agents/agents.ts index c45d8372..90c81b35 100644 --- a/sdks/typescript/src/agents/agents.ts +++ b/sdks/typescript/src/agents/agents.ts @@ -5,6 +5,7 @@ import { type Deployment, type InlineSkillFile, } from "./deployments.js"; import { Schedules } from "./schedules.js"; +import { AgentConfigResource } from "./config.js"; export interface CreateAgentParams { name: string; @@ -71,6 +72,8 @@ export class Agents { readonly deploymentSource: DeploymentSourceResource; /** Cron for agents — schedules that fire a session per slot (015). */ readonly schedules: Schedules; + /** Flue Worker vars, outbound host policy, and write-only secrets. */ + readonly config: AgentConfigResource; constructor(private readonly http: Http) { this.deployments = new Deployments(http); @@ -79,6 +82,7 @@ export class Agents { this.skills = new Skills(http); this.deploymentSource = new DeploymentSourceResource(http); this.schedules = new Schedules(http); + this.config = new AgentConfigResource(http); } create(params: CreateAgentParams): Promise { diff --git a/sdks/typescript/src/agents/config.test.ts b/sdks/typescript/src/agents/config.test.ts new file mode 100644 index 00000000..1b7354dc --- /dev/null +++ b/sdks/typescript/src/agents/config.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { OpenComputer } from "./client.js"; + +describe("Flue agent config", () => { + it("normalizes config and serializes replacement writes", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(input), init }); + if (init?.method === "PUT") { + return Response.json({ vars: { MODE: "safe" }, egress_allowlist: ["api.example.com"], deployment_required: true }); + } + return Response.json({ vars: {}, egress_allowlist: [] }); + }); + const oc = new OpenComputer({ apiKey: "test", baseUrl: "https://cp.example/v3", fetch: fetch as typeof globalThis.fetch }); + + const saved = await oc.agents.config.put("agt_1", { + vars: { MODE: "safe" }, egressAllowlist: ["api.example.com"], + }); + + expect(saved.egressAllowlist).toEqual(["api.example.com"]); + expect(saved.deploymentRequired).toBe(true); + expect(calls[0]?.url).toBe("https://cp.example/v3/agents/agt_1/config"); + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + vars: { MODE: "safe" }, egress_allowlist: ["api.example.com"], + }); + }); + + it("never expects values when listing write-only secrets", async () => { + const fetch = vi.fn(async () => Response.json({ + data: [{ name: "GITHUB_TOKEN", last4: "1234", updated_at: "2026-07-12T00:00:00Z", sync_status: "synced" }], + })); + const oc = new OpenComputer({ apiKey: "test", baseUrl: "https://cp.example/v3", fetch: fetch as typeof globalThis.fetch }); + const secrets = await oc.agents.config.listSecrets("agt_1"); + expect(secrets).toEqual([{ name: "GITHUB_TOKEN", last4: "1234", updatedAt: "2026-07-12T00:00:00Z", syncStatus: "synced" }]); + expect(secrets[0]).not.toHaveProperty("value"); + }); +}); diff --git a/sdks/typescript/src/agents/config.ts b/sdks/typescript/src/agents/config.ts new file mode 100644 index 00000000..0f3f5435 --- /dev/null +++ b/sdks/typescript/src/agents/config.ts @@ -0,0 +1,52 @@ +import type { Http } from "./http.js"; + +/** Non-secret bindings and the fail-closed outbound host policy for a Flue agent. */ +export interface FlueAgentConfig { + vars: Record; + egressAllowlist: string[]; + /** True after a config write because vars are baked into the next deployment. */ + deploymentRequired?: boolean; +} + +/** Replacement config. Read first when you want merge semantics. */ +export interface PutFlueAgentConfigParams { + vars?: Record; + egressAllowlist?: string[]; +} + +/** Write-only secret metadata. The value is never returned. */ +export interface FlueAgentSecret { + name: string; + last4: string; + updatedAt: string; + syncStatus: "synced" | "pending_deploy" | "error"; +} + +/** Flue Worker config and write-only secret bindings (`oc.agents.config`). */ +export class AgentConfigResource { + constructor(private readonly http: Http) {} + + get(agentId: string): Promise { + return this.http.request("GET", `/agents/${agentId}/config`); + } + + /** Replace non-secret vars and the outbound allowlist. Omitted fields become empty. */ + put(agentId: string, params: PutFlueAgentConfigParams): Promise { + return this.http.request("PUT", `/agents/${agentId}/config`, { body: params }); + } + + /** List names/status only; OpenComputer never returns secret values. */ + async listSecrets(agentId: string): Promise { + const result = await this.http.request<{ data: FlueAgentSecret[] }>("GET", `/agents/${agentId}/secrets`); + return result.data; + } + + /** Create or rotate a secret. A deployed Worker is updated in place when possible. */ + setSecret(agentId: string, name: string, value: string): Promise { + return this.http.request("PUT", `/agents/${agentId}/secrets/${encodeURIComponent(name)}`, { body: { value } }); + } + + deleteSecret(agentId: string, name: string): Promise { + return this.http.request("DELETE", `/agents/${agentId}/secrets/${encodeURIComponent(name)}`); + } +} diff --git a/sdks/typescript/src/agents/index.ts b/sdks/typescript/src/agents/index.ts index 22f6a680..855d4d96 100644 --- a/sdks/typescript/src/agents/index.ts +++ b/sdks/typescript/src/agents/index.ts @@ -37,6 +37,10 @@ export type { Schedule, ScheduleRun, ScheduleState, RunOutcome, Overlap, CreateScheduleParams, UpdateScheduleParams, RunsPage, } from "./schedules.js"; +export { AgentConfigResource } from "./config.js"; +export type { + FlueAgentConfig, PutFlueAgentConfigParams, FlueAgentSecret, +} from "./config.js"; export { verifyWebhook, WebhookVerificationError } from "./webhooks.js"; export type { WebhookDelivery, VerifyWebhookOptions } from "./webhooks.js"; diff --git a/sdks/typescript/src/agents/normalize.test.ts b/sdks/typescript/src/agents/normalize.test.ts index bcadbe24..168ddb04 100644 --- a/sdks/typescript/src/agents/normalize.test.ts +++ b/sdks/typescript/src/agents/normalize.test.ts @@ -31,6 +31,11 @@ describe("normalize (API response → idiomatic TS)", () => { }); expect(out.refs).toEqual({ pull_number: 42, owner_repo: "acme/widgets", nested: { keep_me: true } }); }); + + it("passes env binding names through verbatim", () => { + const out = normalize<{ vars?: Record }>({ vars: { PUBLIC_MODE: "safe" } }); + expect(out.vars).toEqual({ PUBLIC_MODE: "safe" }); + }); }); describe("serialize (request body → snake_case)", () => { @@ -44,4 +49,9 @@ describe("serialize (request body → snake_case)", () => { const out = serialize({ refs: { pullNumber: 42, owner_repo: "acme/widgets" } }) as Record; expect(out.refs).toEqual({ pullNumber: 42, owner_repo: "acme/widgets" }); }); + + it("leaves env binding names opaque on request bodies", () => { + const out = serialize({ vars: { PUBLIC_MODE: "safe" } }) as Record; + expect(out.vars).toEqual({ PUBLIC_MODE: "safe" }); + }); }); diff --git a/sdks/typescript/src/agents/normalize.ts b/sdks/typescript/src/agents/normalize.ts index 23cb8fbb..2c609666 100644 --- a/sdks/typescript/src/agents/normalize.ts +++ b/sdks/typescript/src/agents/normalize.ts @@ -1,8 +1,8 @@ // Normalize API responses to idiomatic TS: snake_case keys → camelCase, and a known set // of numeric fields that the API serializes as strings (bigints) → numbers. Opaque // subtrees are passed through untouched: `raw` (source-specific adapter data), -// `metadata`, and `refs` (caller-owned routing JSON — must round-trip verbatim, -// keys included). +// `metadata`, `refs`, and `vars` (caller-owned routing/config JSON — must +// round-trip verbatim, keys included). const NUMERIC = new Set([ "seq", "head", "inputCursor", "inputFromSeq", "inputToSeq", "exitCode", "bytes", "port", @@ -18,7 +18,7 @@ export function normalize(value: unknown): T { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { const ck = camel(k); - if (ck === "raw" || ck === "metadata" || ck === "refs") { out[ck] = v; continue; } // opaque: verbatim, keys untouched + if (ck === "raw" || ck === "metadata" || ck === "refs" || ck === "vars") { out[ck] = v; continue; } // opaque: verbatim, keys untouched let nv: unknown = normalize(v); if (NUMERIC.has(ck) && typeof nv === "string" && nv !== "" && !Number.isNaN(Number(nv))) { nv = Number(nv); @@ -34,7 +34,7 @@ export function normalize(value: unknown): T { // snake_case). Values are left untouched, and opaque/user-owned subtrees (raw, body, // metadata, refs, input) are passed through without key-mangling. const snakeKey = (k: string): string => k.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase()); -const OPAQUE = new Set(["raw", "body", "metadata", "refs", "input"]); +const OPAQUE = new Set(["raw", "body", "metadata", "refs", "input", "vars"]); export function serialize(value: unknown): unknown { if (Array.isArray(value)) return value.map((v) => serialize(v)); From 715721b80120a342da6151c87d808c18d560295a Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Sun, 12 Jul 2026 01:26:32 +0100 Subject: [PATCH 3/4] feat(web): manage Flue Worker config and write-only secrets Add Flue-only AgentDetail settings for vars, fail-closed outbound hosts, and live secret rotation. Validate responses at the API boundary, show sync state, preserve secret write-only semantics, and keep the settings layout usable at narrow widths. Also coerce bigint event body sizes so spilled events remain visible. --- web/src/api/client.ts | 139 +++++-- web/src/api/mock.ts | 52 ++- web/src/api/schemas.ts | 23 +- web/src/components/agent-flue-config.tsx | 500 +++++++++++++++++++++++ web/src/components/api-hint.tsx | 6 +- web/src/pages/AgentDetail.tsx | 54 ++- 6 files changed, 733 insertions(+), 41 deletions(-) create mode 100644 web/src/components/agent-flue-config.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0923fbdc..9e1ed343 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -32,6 +32,8 @@ export type { AgentSkills, SkillItem, AgentDeploy, + FlueAgentConfig, + FlueAgentSecret, Session, AgentSnapshot, SessionEvent, @@ -88,7 +90,11 @@ function errorMessage(body: unknown, status: number): string { function errorType(body: unknown): string | undefined { if (body && typeof body === 'object') { const err = (body as Record).error - if (err && typeof err === 'object' && typeof (err as Record).type === 'string') { + if ( + err && + typeof err === 'object' && + typeof (err as Record).type === 'string' + ) { return (err as Record).type as string } } @@ -139,7 +145,11 @@ export async function apiFetch( if (!res.ok) { const body: unknown = await res.json().catch(() => ({})) - throw new ApiError(errorMessage(body, res.status), res.status, errorType(body)) + throw new ApiError( + errorMessage(body, res.status), + res.status, + errorType(body), + ) } if (res.status === 204) { @@ -461,13 +471,47 @@ export const updateAgent = ( S.AgentSchema, ) +// Flue Worker config. PUT is a replacement operation, so UI/SDK callers that +// want merge semantics read first and send both vars + egress_allowlist. +export const getFlueAgentConfig = (id: string) => + apiFetch(`/v3/agents/${id}/config`, {}, S.FlueAgentConfigSchema) + +export const putFlueAgentConfig = ( + id: string, + body: { vars: Record; egress_allowlist: string[] }, +) => + apiFetch( + `/v3/agents/${id}/config`, + { method: 'PUT', body: JSON.stringify(body) }, + S.FlueAgentConfigSchema, + ) + +export const getFlueAgentSecrets = (id: string) => + apiFetch(`/v3/agents/${id}/secrets`, {}, S.FlueAgentSecretListSchema).then( + (r) => r.data, + ) + +export const putFlueAgentSecret = (id: string, name: string, value: string) => + apiFetch( + `/v3/agents/${id}/secrets/${encodeURIComponent(name)}`, + { method: 'PUT', body: JSON.stringify({ value }) }, + S.FlueAgentSecretSchema, + ) + +export const deleteFlueAgentSecret = (id: string, name: string) => + apiFetch(`/v3/agents/${id}/secrets/${encodeURIComponent(name)}`, { + method: 'DELETE', + }) + // Agent Revisions (design 009) — the deploy history of an agent's behavior. List // endpoints wrap rows in { data: [...] }; callers want the array. Rollback = activate // an earlier revision (by id or number); it moves the production pointer. export const getAgentRevisions = (agentId: string) => - apiFetch(`/v3/agents/${agentId}/revisions`, {}, S.AgentRevisionListSchema).then( - (r) => r.data, - ) + apiFetch( + `/v3/agents/${agentId}/revisions`, + {}, + S.AgentRevisionListSchema, + ).then((r) => r.data) export const getAgentDeploys = (agentId: string) => apiFetch(`/v3/agents/${agentId}/deploys`, {}, S.AgentDeployListSchema).then( @@ -515,22 +559,36 @@ export interface ScheduleRun { } export const getSchedules = (agentId: string) => - apiFetch<{ schedules: Schedule[] }>(`/v3/agents/${agentId}/schedules`).then((r) => r.schedules) + apiFetch<{ schedules: Schedule[] }>(`/v3/agents/${agentId}/schedules`).then( + (r) => r.schedules, + ) export const createSchedule = ( agentId: string, - body: { name: string; cron: string; tz?: string | null; input: string; overlap?: ScheduleOverlap }, + body: { + name: string + cron: string + tz?: string | null + input: string + overlap?: ScheduleOverlap + }, ) => - apiFetch<{ schedule: Schedule }>( - `/v3/agents/${agentId}/schedules`, - { method: 'POST', body: JSON.stringify(body) }, - ).then((r) => r.schedule) + apiFetch<{ schedule: Schedule }>(`/v3/agents/${agentId}/schedules`, { + method: 'POST', + body: JSON.stringify(body), + }).then((r) => r.schedule) // PATCH accepts any of { cron, tz, input, overlap, paused }. `paused` toggles pause/resume. export const updateSchedule = ( agentId: string, scheduleId: string, - body: Partial<{ cron: string; tz: string | null; input: string; overlap: ScheduleOverlap; paused: boolean }>, + body: Partial<{ + cron: string + tz: string | null + input: string + overlap: ScheduleOverlap + paused: boolean + }>, ) => apiFetch<{ schedule: Schedule }>( `/v3/agents/${agentId}/schedules/${scheduleId}`, @@ -538,7 +596,9 @@ export const updateSchedule = ( ).then((r) => r.schedule) export const deleteSchedule = (agentId: string, scheduleId: string) => - apiFetch(`/v3/agents/${agentId}/schedules/${scheduleId}`, { method: 'DELETE' }) + apiFetch(`/v3/agents/${agentId}/schedules/${scheduleId}`, { + method: 'DELETE', + }) // Test-fire now — enacts synchronously (a failed fire still returns a run with outcome:"failed"). export const fireSchedule = (agentId: string, scheduleId: string) => @@ -547,7 +607,11 @@ export const fireSchedule = (agentId: string, scheduleId: string) => { method: 'POST', body: JSON.stringify({}) }, ).then((r) => r.run) -export const getScheduleRuns = (agentId: string, scheduleId: string, limit = 10) => +export const getScheduleRuns = ( + agentId: string, + scheduleId: string, + limit = 10, +) => apiFetch<{ runs: ScheduleRun[]; next_cursor: string | null }>( `/v3/agents/${agentId}/schedules/${scheduleId}/runs?limit=${limit}`, ).then((r) => r.runs) @@ -562,24 +626,42 @@ export const getAgentSkills = (agentId: string) => export const putAgentSkills = (agentId: string, zip: File | Blob) => apiFetch( `/v3/agents/${agentId}/skills`, - { method: 'PUT', body: zip, headers: { 'Content-Type': 'application/zip' } }, + { + method: 'PUT', + body: zip, + headers: { 'Content-Type': 'application/zip' }, + }, S.DeployResultSchema, ) // Remove all skills → deploys a revision from the active behavior with no skills. export const deleteAgentSkills = (agentId: string) => - apiFetch(`/v3/agents/${agentId}/skills`, { method: 'DELETE' }, S.DeployResultSchema) + apiFetch( + `/v3/agents/${agentId}/skills`, + { method: 'DELETE' }, + S.DeployResultSchema, + ) // The OC GitHub App (deploy) install-state + pickable repos — org-scoped admin read. -export const getDeployApp = () => apiFetch('/v3/github/deploy-app', {}, S.DeployAppSchema) +export const getDeployApp = () => + apiFetch('/v3/github/deploy-app', {}, S.DeployAppSchema) // Deployment source — link an agent to a repo dir for push-to-deploy (deploy-from-github). export const getDeploymentSource = (agentId: string) => - apiFetch(`/v3/agents/${agentId}/deployment-source`, {}, S.DeploymentSourceResponseSchema) + apiFetch( + `/v3/agents/${agentId}/deployment-source`, + {}, + S.DeploymentSourceResponseSchema, + ) export const linkDeploymentSource = ( agentId: string, - body: { repo: string; path: string; production_ref?: string; deploy_now?: boolean }, + body: { + repo: string + path: string + production_ref?: string + deploy_now?: boolean + }, ) => apiFetch( `/v3/agents/${agentId}/deployment-source`, @@ -588,7 +670,9 @@ export const linkDeploymentSource = ( ) export const unlinkDeploymentSource = (agentId: string) => - apiFetch(`/v3/agents/${agentId}/deployment-source`, { method: 'DELETE' }) + apiFetch(`/v3/agents/${agentId}/deployment-source`, { + method: 'DELETE', + }) // Deploy the linked repo's current production-branch HEAD now (no git push needed). // Returns { deployment } — fire-and-refetch, so we don't validate the body. @@ -679,7 +763,12 @@ export const rotateCredential = (id: string, key: string) => // Sessions — the durable runs. export const getSessions = ( - params: { agent?: string; status?: string; limit?: number; cursor?: string } = {}, + params: { + agent?: string + status?: string + limit?: number + cursor?: string + } = {}, ) => { const q = new URLSearchParams() if (params.agent) q.set('agent', params.agent) @@ -741,11 +830,9 @@ export const getSessionEvents = (id: string, level?: string) => // Turns — the per-submission execution records behind a session (state, timing, // usage, error). Read-only; powers the submission-health panel. Newest first. export const getSessionTurns = (id: string) => - apiFetch( - `/v3/sessions/${id}/turns`, - {}, - S.SessionTurnListSchema, - ).then((r) => r.data) + apiFetch(`/v3/sessions/${id}/turns`, {}, S.SessionTurnListSchema).then( + (r) => r.data, + ) // The latest turn + its result event (if the turn produced one). export const getSessionResult = (id: string) => diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts index 31173625..e4feca9c 100644 --- a/web/src/api/mock.ts +++ b/web/src/api/mock.ts @@ -472,6 +472,37 @@ const agents = [ revision: 1, created_at: at(6), }, + { + id: 'agt_flue_demo', + name: 'Issue Fixer', + prompt: null, + prompt_hash: null, + model: 'anthropic/claude-haiku-4-5', + runtime: 'flue', + credential_id: 'managed', + revision: 4, + created_at: at(2), + }, +] + +const flueConfig = { + vars: { LOG_LEVEL: 'info', PUBLIC_MODE: 'careful' }, + egress_allowlist: ['api.github.com', '*.linear.app'], +} + +const flueSecrets = [ + { + name: 'LINEAR_API_KEY', + last4: '8fa2', + updated_at: at(1), + sync_status: 'synced', + }, + { + name: 'SENTRY_DSN', + last4: '91bc', + updated_at: at(3), + sync_status: 'pending_deploy', + }, ] // ── Deploy from GitHub (admin) ─────────────────────────────────────────────── @@ -806,7 +837,10 @@ const sessionTurns = [ completed_at: at(0, 2), active_seconds: 2.1, usage: {}, - error: { code: 'provision_infra', message: 'brain sandbox failed to start' }, + error: { + code: 'provision_infra', + message: 'brain sandbox failed to start', + }, }, ] @@ -923,6 +957,8 @@ const ROUTES: Array<[RegExp, Handler]> = [ [/^\/agents$/, () => []], // Durable Agent Sessions — lists return the { data: [...] } envelope. [/^\/v3\/agents\/[^/]+\/slack$/, () => slackConnection], + [/^\/v3\/agents\/[^/]+\/config$/, () => flueConfig], + [/^\/v3\/agents\/[^/]+\/secrets$/, () => ({ data: flueSecrets })], [/^\/v3\/github\/deploy-app$/, () => deployApp], [ /^\/v3\/agents\/[^/]+\/deployment-source$/, @@ -932,6 +968,7 @@ const ROUTES: Array<[RegExp, Handler]> = [ [/^\/v3\/agents\/[^/]+\/deploys$/, () => ({ data: agentDeploys })], [/^\/v3\/agents\/[^/]+\/revisions$/, () => ({ data: agentRevisions })], [/^\/v3\/agents\/[^/]+\/skills$/, () => agentSkills], + [/^\/v3\/agents\/agt_flue_demo$/, () => agents[2]], [/^\/v3\/agents\/[^/]+$/, () => agents[0]], [/^\/v3\/agents$/, () => ({ data: agents })], [/^\/v3\/credentials$/, () => ({ data: credentials })], @@ -959,6 +996,19 @@ const ROUTES: Array<[RegExp, Handler]> = [ // Mutations the preview needs to echo something parseable (e.g. the Slack // wizard's POST …/slack/manifest → manifest+steps). Everything else 204-ish. const POST_ROUTES: [RegExp, () => unknown][] = [ + [ + /^\/v3\/agents\/[^/]+\/config$/, + () => ({ ...flueConfig, deployment_required: true }), + ], + [ + /^\/v3\/agents\/[^/]+\/secrets\/[^/]+$/, + () => ({ + name: 'NEW_SECRET', + last4: 'cdef', + updated_at: at(0), + sync_status: 'synced', + }), + ], [/^\/v3\/agents\/[^/]+\/slack\/manifest$/, () => slackManifest], [ /^\/v3\/agents\/[^/]+\/slack$/, diff --git a/web/src/api/schemas.ts b/web/src/api/schemas.ts index 661890bc..d9abfbb9 100644 --- a/web/src/api/schemas.ts +++ b/web/src/api/schemas.ts @@ -345,6 +345,23 @@ export const AgentListSchema = z.object({ next_cursor: z.string().nullish(), }) +// Flue Worker bindings. Vars are intentionally plain/readable; secret values +// are write-only and only their metadata crosses back into the dashboard. +export const FlueAgentConfigSchema = z.object({ + vars: z.record(z.string(), z.string()), + egress_allowlist: z.array(z.string()), + deployment_required: z.boolean().optional(), +}) +export const FlueAgentSecretSchema = z.object({ + name: z.string(), + last4: z.string(), + updated_at: z.string(), + sync_status: z.string(), +}) +export const FlueAgentSecretListSchema = z.object({ + data: z.array(FlueAgentSecretSchema), +}) + // Agent Revisions (design 009) — immutable deployed versions of an agent's behavior. // `active` flags the production pointer; rollback = activate an earlier revision. export const AgentRevisionSchema = z.object({ @@ -542,7 +559,9 @@ export const SessionEventSchema = z.object({ // `body` is absent/partial, `content_ref` points at the blob, `body_bytes` is the size. content_ref: z.string().nullish(), body_truncated: z.boolean().nullish(), - body_bytes: z.number().nullish(), + // PostgreSQL bigint values are serialized as strings by the sessions API. + // Coerce here so spilled event bodies remain visible in the live timeline. + body_bytes: z.coerce.number().nullish(), refs: record.nullish(), source: z.string().optional(), turn_id: z.string().nullable().optional(), @@ -603,6 +622,8 @@ export const DeliverySchema = z.object({ }) export type Agent = z.infer +export type FlueAgentConfig = z.infer +export type FlueAgentSecret = z.infer export type AgentRevision = z.infer export type AgentSkills = z.infer export type SkillItem = z.infer diff --git a/web/src/components/agent-flue-config.tsx b/web/src/components/agent-flue-config.tsx new file mode 100644 index 00000000..e8247564 --- /dev/null +++ b/web/src/components/agent-flue-config.tsx @@ -0,0 +1,500 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Plus, Trash2 } from 'lucide-react' +import { + deleteFlueAgentSecret, + getFlueAgentConfig, + getFlueAgentSecrets, + putFlueAgentConfig, + putFlueAgentSecret, + type FlueAgentConfig, + type FlueAgentSecret, +} from '@/api/client' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { Field, FieldError, Input, Textarea } from '@/components/form' +import { + Panel, + PanelContent, + PanelDescription, + PanelHeader, + PanelTitle, +} from '@/components/panel' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { notifyError, notifySuccess } from '@/lib/errors' +import { cn } from '@/lib/utils' + +const BINDING_NAME = /^[A-Z][A-Z0-9_]{0,63}$/ + +type BindingRow = { id: string; name: string; value: string } + +const bindingRows = (vars: Record): BindingRow[] => + Object.entries(vars) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, value]) => ({ id: crypto.randomUUID(), name, value })) + +function normalizedHosts(value: string): string[] { + return [ + ...new Set( + value + .split(/[,\n]/) + .map((host) => host.trim().toLowerCase()) + .filter(Boolean), + ), + ].sort() +} + +function validHost(value: string): boolean { + const host = value.startsWith('*.') ? value.slice(2) : value + return ( + value.length <= 253 && + !value.includes('://') && + !value.includes('/') && + !value.includes(':') && + /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(host) && + host.includes('.') + ) +} + +function payloadFor(rows: BindingRow[], egress: string) { + const vars: Record = {} + for (const row of rows) { + const name = row.name.trim() + if (name) vars[name] = row.value + } + return { vars, egress_allowlist: normalizedHosts(egress) } +} + +function configIssue(rows: BindingRow[], egress: string): string | null { + const populated = rows.filter((row) => row.name.trim() || row.value) + const names = populated.map((row) => row.name.trim()) + if (names.some((name) => !name)) return 'Every value needs a variable name.' + const invalid = names.find( + (name) => + !BINDING_NAME.test(name) || + name.startsWith('OC_') || + name.startsWith('FLUE_'), + ) + if (invalid) + return `${invalid} is invalid or reserved. Use uppercase letters, numbers, and underscores.` + if (new Set(names).size !== names.length) + return 'Variable names must be unique.' + const badHost = normalizedHosts(egress).find((host) => !validHost(host)) + if (badHost) return `${badHost} is not a valid hostname.` + return null +} + +export function AgentFlueConfig({ agentId }: { agentId: string }) { + const config = useQuery({ + queryKey: ['agent-flue-config', agentId], + queryFn: () => getFlueAgentConfig(agentId), + }) + const secrets = useQuery({ + queryKey: ['agent-flue-secrets', agentId], + queryFn: () => getFlueAgentSecrets(agentId), + }) + + if (config.isLoading || secrets.isLoading) { + return ( +
+ + +
+ ) + } + + return ( + <> + {config.data ? ( + + ) : ( + void config.refetch()} + /> + )} + {secrets.data ? ( + + ) : ( + void secrets.refetch()} + /> + )} + + ) +} + +function ConfigEditor({ + agentId, + config, +}: { + agentId: string + config: FlueAgentConfig +}) { + const queryClient = useQueryClient() + const [rows, setRows] = useState(() => bindingRows(config.vars)) + const [egress, setEgress] = useState(() => config.egress_allowlist.join('\n')) + const issue = configIssue(rows, egress) + const payload = payloadFor(rows, egress) + const current = payloadFor( + bindingRows(config.vars), + config.egress_allowlist.join('\n'), + ) + const dirty = JSON.stringify(payload) !== JSON.stringify(current) + + const save = useMutation({ + mutationFn: () => putFlueAgentConfig(agentId, payload), + onSuccess: (saved) => { + queryClient.setQueryData(['agent-flue-config', agentId], saved) + notifySuccess( + 'Worker configuration saved.', + 'Variables apply on the next deploy. Outbound policy updates shortly.', + ) + }, + onError: (error) => + notifyError("Couldn't save Worker configuration.", error), + }) + + return ( + + +
+ Worker environment + + Non-secret bindings and the hosts this agent may call over HTTPS. + +
+
+ +
+
+
+

+ Variables +

+

+ These values are readable configuration. Put credentials and + tokens in Worker secrets below. +

+
+ +
+ + {rows.length ? ( +
+ {rows.map((row, index) => ( +
+ + setRows((old) => + old.map((item) => + item.id === row.id + ? { + ...item, + name: event.target.value.toUpperCase(), + } + : item, + ), + ) + } + placeholder="VARIABLE_NAME" + className="font-mono sm:w-2/5" + autoCapitalize="characters" + autoCorrect="off" + spellCheck={false} + /> + + setRows((old) => + old.map((item) => + item.id === row.id + ? { ...item, value: event.target.value } + : item, + ), + ) + } + placeholder="Value" + className="font-mono sm:flex-1" + spellCheck={false} + /> + +
+ ))} +
+ ) : ( +

+ No variables. This Worker receives only OpenComputer-managed + bindings and secrets. +

+ )} +
+ + +