From 821c68cc2326bc6ca6a0dbbe66ee2dc00d24c28f Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Sun, 5 Jul 2026 23:40:53 +0100 Subject: [PATCH 1/2] @opencomputer/flue: CF-native scaffolding (W4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the serveOC/brain-box package with the Flue CF-native shape (design 013 §4/§5). A stock `flue build --target cloudflare` app opts into OC via: - useOcGateway(ctx) + DEFAULT_MODEL (claude-haiku-4.5, prompt-caching-safe): point managed anthropic at env.OC_GATEWAY. Called INSIDE the defineAgent initializer (top-level is tree-shaken — 1a). - route: the HTTP-transport opt-in every OC agent must export. - ocSandbox(env): a durable OC-fleet SandboxApi (files/exec over the OC sandbox HTTP API; stat/mkdir/rm via shell, mirroring cloudflareSandbox). - ocRepoTools(env): publish_pull_request (repo plane, used by W10). - ./app: default hosting app (flue() + /health + observe→OC_INGEST). ./wire: telemetry-only side-effect for apps with their own app.ts. Builds + typechecks clean against @flue/runtime@1.0.0-beta.9. Verified end-to-end: the re-scaffolded oc-flue-starter `flue build --target cloudflare`s with zero hand-editing and the wiring (useOcGateway/registerProvider, ocSandbox, /health, haiku model) is present in the bundle (not tree-shaken). TOKEN SEAM — OPEN, needs orchestrator decision (see gateway.ts): Flue's registerProvider takes only a static apiKey and its getApiKey(providerId) callback gets no request context, and the provider registry is isolate-global (shared across co-located DO instances) — so a per-SESSION token/header/baseUrl via registerProvider RACES. Wired the buildable env shape; robust per-session attribution needs the upstream `headers(ctx)` ask. Co-Authored-By: Claude Opus 4.8 (1M context) --- sdks/flue/.gitignore | 3 + sdks/flue/README.md | 47 +++++++++++++++ sdks/flue/package.json | 48 +++++++++++++++ sdks/flue/src/app.ts | 18 ++++++ sdks/flue/src/gateway.ts | 51 ++++++++++++++++ sdks/flue/src/index.ts | 15 +++++ sdks/flue/src/observe.ts | 27 +++++++++ sdks/flue/src/sandbox.ts | 124 +++++++++++++++++++++++++++++++++++++++ sdks/flue/src/tools.ts | 39 ++++++++++++ sdks/flue/src/wire.ts | 8 +++ sdks/flue/tsconfig.json | 18 ++++++ 11 files changed, 398 insertions(+) create mode 100644 sdks/flue/.gitignore create mode 100644 sdks/flue/README.md create mode 100644 sdks/flue/package.json create mode 100644 sdks/flue/src/app.ts create mode 100644 sdks/flue/src/gateway.ts create mode 100644 sdks/flue/src/index.ts create mode 100644 sdks/flue/src/observe.ts create mode 100644 sdks/flue/src/sandbox.ts create mode 100644 sdks/flue/src/tools.ts create mode 100644 sdks/flue/src/wire.ts create mode 100644 sdks/flue/tsconfig.json diff --git a/sdks/flue/.gitignore b/sdks/flue/.gitignore new file mode 100644 index 00000000..f4e2c6d6 --- /dev/null +++ b/sdks/flue/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/sdks/flue/README.md b/sdks/flue/README.md new file mode 100644 index 00000000..b46e580c --- /dev/null +++ b/sdks/flue/README.md @@ -0,0 +1,47 @@ +# @opencomputer/flue + +Make a stock [Flue](https://flue.dev) agent OpenComputer-native. A Flue app built with +`flue build --target cloudflare` runs unchanged as an OpenComputer durable session (a Workers-for-Platforms +tenant script); this package supplies the OC-specific wiring the app opts into. + +## What it gives you + +- **`useOcGateway(ctx)` + `DEFAULT_MODEL`** — point the managed `anthropic` provider at the OC model + gateway (org key injected + per-session metering). Call it **inside** your `defineAgent` initializer. +- **`route`** — the HTTP-transport opt-in every OC-hosted agent must export (`export { route }`). +- **`ocSandbox(env)`** — a durable OpenComputer-fleet sandbox as the agent's `SandboxApi` (workspace + survives across turns; also serves the repo plane). +- **`ocRepoTools(env)`** — `publish_pull_request` and friends (open PRs as the OpenComputer GitHub App). +- **`@opencomputer/flue/app`** — a default hosting app (`flue()` routes + `/health` + telemetry). Or + `import '@opencomputer/flue/wire'` from your own `app.ts` for telemetry only. + +## Minimal agent + +```ts +import { defineAgent, defineAgentProfile } from '@flue/runtime'; +import { useOcGateway, route, ocSandbox, DEFAULT_MODEL, type OcSandboxEnv } from '@opencomputer/flue'; + +export { route }; + +export default defineAgent((ctx) => { + useOcGateway(ctx); + return { + profile: defineAgentProfile({ instructions: 'You help customers.' }), + model: DEFAULT_MODEL, // prompt-caching-safe + sandbox: ocSandbox(ctx.env), + }; +}); +``` + +`src/app.ts`: + +```ts +export { default } from '@opencomputer/flue/app'; +``` + +Then `flue build --target cloudflare` and `oc agent deploy`. See `oc-flue-starter` for a full example. + +## Environment (set on the tenant script by the OC deploy) + +`OC_GATEWAY`, `OC_SESSION_TOKEN`, `OC_INGEST`, `OC_SANDBOX_API` (+ `OC_SANDBOX_ID` or a resolve seam), +`OC_REPO_API`. Reserved `OC_`/`FLUE_` prefixes are OC-managed. diff --git a/sdks/flue/package.json b/sdks/flue/package.json new file mode 100644 index 00000000..f80c30d7 --- /dev/null +++ b/sdks/flue/package.json @@ -0,0 +1,48 @@ +{ + "name": "@opencomputer/flue", + "version": "0.2.0", + "description": "Make a stock Flue agent OpenComputer-native — OC model gateway, OC-fleet sandbox, publish/repo tools, and the default hosting app.", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22.19" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./app": { + "types": "./dist/app.d.ts", + "default": "./dist/app.js" + }, + "./wire": { + "types": "./dist/wire.d.ts", + "default": "./dist/wire.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "sideEffects": [ + "./dist/wire.js" + ], + "peerDependencies": { + "@flue/runtime": "1.0.0-beta.9" + }, + "dependencies": { + "hono": "^4.6.0", + "valibot": "^1.1.0" + }, + "devDependencies": { + "@flue/runtime": "1.0.0-beta.9", + "@types/node": "^22.10.0", + "typescript": "^5.9.3", + "vitest": "^3.0.0" + } +} diff --git a/sdks/flue/src/app.ts b/sdks/flue/src/app.ts new file mode 100644 index 00000000..342402b8 --- /dev/null +++ b/sdks/flue/src/app.ts @@ -0,0 +1,18 @@ +// The default OC hosting app. A scaffolded starter uses this as its `src/app.ts` (or the CF build +// generates an equivalent when no app.ts exists). It mounts Flue's routes, adds the `/health` probe the +// OC deploy/activate step needs (stock Flue exposes NO health route — Spike B finding), and installs the +// telemetry forwarder. Apps that own their `app.ts` instead `import '@opencomputer/flue/wire'` (§wire). +// +// Default export = a `Fetchable` (Hono qualifies), per Flue's routing contract. + +import { Hono } from "hono"; +import { flue } from "@flue/runtime/routing"; +import { installOcObserver } from "./observe.js"; + +installOcObserver(); + +const app = new Hono(); +app.get("/health", (c) => c.json({ status: "ok" })); +app.route("/", flue()); + +export default app; diff --git a/sdks/flue/src/gateway.ts b/sdks/flue/src/gateway.ts new file mode 100644 index 00000000..486ae93a --- /dev/null +++ b/sdks/flue/src/gateway.ts @@ -0,0 +1,51 @@ +// OC model-gateway wiring (design 013 §4). A stock Flue app points its managed `anthropic` provider at +// the OC gateway (a thin Worker over OpenRouter that injects the org key + meters per session). +// +// TOKEN SEAM — OPEN, confirm with the orchestrator. The buildout's seam (a) ("per-turn token → Flue's +// per-call getApiKey(providerId) callback") is NOT achievable: `registerProvider` accepts only a STATIC +// `apiKey`, and Flue's internal `getApiKey(providerId)` gets no request/turn context (providers.ts:199). +// Worse, the provider registry is MODULE(isolate)-scoped and shared across co-located DO instances, so a +// per-SESSION `apiKey`/`headers`/`baseUrl` set via registerProvider RACES across sessions in one isolate. +// → Robust per-session attribution needs the upstream ask (per-request `headers(ctx)` on registerProvider, +// buildout "Upstream asks"). Interim, buildable shape below: a static env token (works one-session-per- +// isolate) with metering by `token.sub` at the gateway. See the W4 hand-off note. + +import { registerProvider } from "@flue/runtime"; +import type { AgentInitializerContext, AgentRouteHandler } from "@flue/runtime"; + +/** Default managed model — MUST be prompt-caching-safe (Constraint): `claude-3-haiku` fails via + * OpenRouter→Bedrock; `claude-haiku-4.5` works. Cheap + caching-safe for the scaffolded starter. */ +export const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; + +export interface OcEnv { + /** Deployed gateway Worker base URL (set per tenant script by the OC deploy). */ + OC_GATEWAY?: string; + /** Signed session/deploy JWT the gateway verifies (`{sub, org, agt, bud}`); never a raw provider key. */ + OC_SESSION_TOKEN?: string; + /** Telemetry sink for `observe()` (operator panel + spend attribution). */ + OC_INGEST?: string; + [key: string]: unknown; +} + +/** + * Point the managed `anthropic` provider at the OC gateway. **Call this INSIDE the `defineAgent` + * initializer** — top-level module code is stripped by the CF build (proven in 1a), and the initializer + * body runs per harness init with `env` available. No-op when `OC_GATEWAY` is unset (local `flue dev` + * falls through to pi-ai's env-var key lookup). `anthropic` is a catalog id, so `baseUrl` alone rehydrates + * the wire protocol. + */ +export function useOcGateway(ctx: AgentInitializerContext): void { + const gw = ctx.env.OC_GATEWAY; + if (!gw) return; + registerProvider("anthropic", { + baseUrl: `${gw.replace(/\/+$/, "")}/anthropic`, + ...(ctx.env.OC_SESSION_TOKEN ? { apiKey: ctx.env.OC_SESSION_TOKEN } : {}), + }); +} + +/** + * 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). Pass-through: the OC dispatch + * Worker is the auth boundary (013 §3 B5), so the app adds none. + */ +export const route: AgentRouteHandler = async (_c, next) => next(); diff --git a/sdks/flue/src/index.ts b/sdks/flue/src/index.ts new file mode 100644 index 00000000..12168f24 --- /dev/null +++ b/sdks/flue/src/index.ts @@ -0,0 +1,15 @@ +// @opencomputer/flue — make a stock Flue agent OpenComputer-native (design 013 §4/§5). +// - useOcGateway + route + DEFAULT_MODEL: point managed anthropic at the OC gateway; HTTP-transport opt-in. +// - ocSandbox: a durable OC-fleet sandbox as the agent's SandboxApi. +// - ocRepoTools: publish/repo tools (repo plane). +// - installOcObserver: forward lifecycle/usage to OC_INGEST. +// Default hosting app is at `@opencomputer/flue/app`; `@opencomputer/flue/wire` is the telemetry-only +// side-effect for apps with their own app.ts. + +export { useOcGateway, route, DEFAULT_MODEL } from "./gateway.js"; +export type { OcEnv } from "./gateway.js"; +export { ocSandbox, WORKSPACE_CWD } from "./sandbox.js"; +export type { OcSandboxEnv } from "./sandbox.js"; +export { installOcObserver } from "./observe.js"; +export { ocRepoTools } from "./tools.js"; +export type { OcRepoEnv } from "./tools.js"; diff --git a/sdks/flue/src/observe.ts b/sdks/flue/src/observe.ts new file mode 100644 index 00000000..ecc31fa3 --- /dev/null +++ b/sdks/flue/src/observe.ts @@ -0,0 +1,27 @@ +// Telemetry: forward Flue lifecycle/usage observations to OC_INGEST for the operator panel + spend +// attribution (design 013 §4; buildout Integration seams). The DO transcript stays authoritative — the +// TAILER is the event-truth path; `observe()` is a best-effort side channel, so this is fire-and-forget +// and never blocks or breaks a run. `observe` subscribers receive `ctx.env`, so this reads OC_INGEST per +// event and can be installed once at module load (isolate-scoped, matching observe's own scope). + +import { observe } from "@flue/runtime"; + +interface CtxEnv { OC_INGEST?: string; OC_SESSION_TOKEN?: string } + +/** Install the OC observation forwarder. Returns the unsubscribe fn. No-op per event when OC_INGEST unset. */ +export function installOcObserver(): () => void { + return observe((obs, ctx) => { + try { + const env = (ctx as { env?: CtxEnv }).env; + if (!env?.OC_INGEST) return; + const session = (obs as { session?: string }).session ?? (ctx as { id?: string }).id; + void fetch(env.OC_INGEST, { + method: "POST", + headers: { "content-type": "application/json", ...(env.OC_SESSION_TOKEN ? { authorization: `Bearer ${env.OC_SESSION_TOKEN}` } : {}) }, + body: JSON.stringify({ session, agent: (ctx as { agentName?: string }).agentName, event: obs }), + }).catch(() => {}); + } catch { + /* telemetry must never break the run */ + } + }); +} diff --git a/sdks/flue/src/sandbox.ts b/sdks/flue/src/sandbox.ts new file mode 100644 index 00000000..99e4b0c2 --- /dev/null +++ b/sdks/flue/src/sandbox.ts @@ -0,0 +1,124 @@ +// ocSandbox — a Flue `SandboxApi`/`SandboxFactory` (design 013 §5) driving an OpenComputer fleet sandbox +// over its public HTTP API, so the workspace is DURABLE across turns (git checkout + build cache survive) +// and also serves the repo plane (§5.2). Public-seam impl, no trick. +// +// The session's sandbox is provisioned by the control plane at Flue-session create (§5.2); this client +// resolves it per instance and proxies exec/fs. Endpoints (from @opencomputer/sdk, all fetch-based so +// they run in a CF DO): +// exec POST {base}/sandboxes/{id}/exec/run {args:["-c",cmd],cwd,envs,timeout} -> {exitCode,stdout,stderr} +// read GET {base}/sandboxes/{id}/files?path= +// write PUT {base}/sandboxes/{id}/files?path= (body = content) +// list GET {base}/sandboxes/{id}/files/list?path= -> [{name,...}] +// stat/exists/mkdir/rm compose over exec (shell), mirroring cloudflareSandbox. + +import { createSandboxSessionEnv } from "@flue/runtime"; +import type { SandboxApi, SandboxFactory, FileStat, ShellResult, SessionEnv } from "@flue/runtime"; +import type { OcEnv } from "./gateway.js"; + +/** Constant workspace cwd (matches the OC session contract — flue resolves skills at `${cwd}/.agents/skills`). */ +export const WORKSPACE_CWD = "/workspace"; + +export interface OcSandboxEnv extends OcEnv { + /** OC sandbox API base, e.g. `https://app.opencomputer.dev/api`. */ + OC_SANDBOX_API?: string; + /** Pre-resolved sandbox id, when the control plane injects it; else resolved lazily (see below). */ + OC_SANDBOX_ID?: string; +} + +class OcSandboxApi implements SandboxApi { + constructor(private readonly base: string, private readonly token: string, private sandboxId: string) {} + + private headers(extra?: Record): Record { + return { authorization: `Bearer ${this.token}`, ...extra }; + } + private url(suffix: string): string { + return `${this.base.replace(/\/+$/, "")}/sandboxes/${this.sandboxId}${suffix}`; + } + + async exec(command: string, options?: { cwd?: string; env?: Record; timeoutMs?: number; signal?: AbortSignal }): Promise { + const body: Record = { args: ["-c", command], timeout: Math.ceil((options?.timeoutMs ?? 60_000) / 1000) }; + if (options?.cwd) body.cwd = options.cwd; + if (options?.env) body.envs = options.env; + const resp = await fetch(this.url("/exec/run"), { method: "POST", headers: this.headers({ "content-type": "application/json" }), body: JSON.stringify(body), signal: options?.signal }); + if (!resp.ok) throw new Error(`oc sandbox exec failed: ${resp.status} ${(await resp.text()).slice(0, 200)}`); + const r = (await resp.json()) as { exitCode?: number; stdout?: string; stderr?: string }; + return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", exitCode: r.exitCode ?? 0 }; + } + + async readFile(path: string): Promise { + const resp = await fetch(this.url(`/files?path=${encodeURIComponent(path)}`), { headers: this.headers() }); + if (!resp.ok) throw new Error(`oc sandbox read ${path}: ${resp.status}`); + return resp.text(); + } + async readFileBuffer(path: string): Promise { + const resp = await fetch(this.url(`/files?path=${encodeURIComponent(path)}`), { headers: this.headers() }); + if (!resp.ok) throw new Error(`oc sandbox read ${path}: ${resp.status}`); + return new Uint8Array(await resp.arrayBuffer()); + } + async writeFile(path: string, content: string | Uint8Array): Promise { + const resp = await fetch(this.url(`/files?path=${encodeURIComponent(path)}`), { method: "PUT", headers: this.headers({ "content-type": "application/octet-stream" }), body: content }); + if (!resp.ok) throw new Error(`oc sandbox write ${path}: ${resp.status}`); + } + async readdir(path: string): Promise { + const resp = await fetch(this.url(`/files/list?path=${encodeURIComponent(path)}`), { headers: this.headers() }); + if (!resp.ok) throw new Error(`oc sandbox list ${path}: ${resp.status}`); + const entries = (await resp.json()) as Array<{ name?: string; path?: string }>; + return entries.map((e) => e.name ?? (e.path ?? "").split("/").pop() ?? "").filter(Boolean); + } + + // stat/exists/mkdir/rm over the shell (mirrors cloudflareSandbox — the files API has no stat/mkdir/rm). + async stat(path: string): Promise { + const r = await this.exec(`stat -L -c '%s/%F' ${shq(path)}`); + if (r.exitCode !== 0) throw new Error(`oc sandbox stat ${path}: ${r.stderr.slice(0, 120)}`); + const [sizeStr, kind = ""] = r.stdout.trim().split("/"); + return { isFile: /regular file/.test(kind), isDirectory: /directory/.test(kind), size: Number(sizeStr) || undefined }; + } + async exists(path: string): Promise { + return (await this.exec(`test -e ${shq(path)}`)).exitCode === 0; + } + async mkdir(path: string, options?: { recursive?: boolean }): Promise { + const r = await this.exec(`mkdir ${options?.recursive ? "-p " : ""}${shq(path)}`); + if (r.exitCode !== 0) throw new Error(`oc sandbox mkdir ${path}: ${r.stderr.slice(0, 120)}`); + } + async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { + const flags = `${options?.recursive ? "r" : ""}${options?.force ? "f" : ""}`; + const r = await this.exec(`rm ${flags ? `-${flags} ` : ""}${shq(path)}`); + if (r.exitCode !== 0 && !options?.force) throw new Error(`oc sandbox rm ${path}: ${r.stderr.slice(0, 120)}`); + } +} + +function shq(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + +/** Resolve the session's OC sandbox id (control-plane seam). Uses the injected id when present, else a + * documented resolve endpoint keyed by the session id. Kept a single point so W1/W5 can pin the contract. */ +async function resolveSandboxId(env: OcSandboxEnv, sessionId: string): Promise { + if (env.OC_SANDBOX_ID) return env.OC_SANDBOX_ID; + const base = (env.OC_SANDBOX_API ?? "").replace(/\/+$/, ""); + const resp = await fetch(`${base}/flue/session-sandbox?session=${encodeURIComponent(sessionId)}`, { + method: "POST", + headers: { authorization: `Bearer ${env.OC_SESSION_TOKEN ?? ""}` }, + }); + if (!resp.ok) throw new Error(`oc sandbox resolve failed for ${sessionId}: ${resp.status}`); + return ((await resp.json()) as { sandbox_id: string }).sandbox_id; +} + +/** + * The OC-fleet sandbox factory. Set `sandbox: ocSandbox(env)` in your `defineAgent` initializer; the OC + * template scaffolds exactly this. Lazily resolves the session's sandbox (keyed by the DO instance id = + * `ses_`) on first tool use, so no sandbox is provisioned for tool-free turns (§5). + */ +export function ocSandbox(env: OcSandboxEnv, opts?: { cwd?: string }): SandboxFactory { + const cwd = opts?.cwd ?? WORKSPACE_CWD; + return { + async createSessionEnv({ id }: { id: string }): Promise { + if (!env.OC_SANDBOX_API && !env.OC_SANDBOX_ID) { + throw new Error("[oc-flue] ocSandbox: set OC_SANDBOX_API (+ OC_SESSION_TOKEN) or OC_SANDBOX_ID — the OC sandbox binding is not configured."); + } + const sandboxId = await resolveSandboxId(env, id); + const api = new OcSandboxApi((env.OC_SANDBOX_API ?? "").replace(/\/+$/, ""), env.OC_SESSION_TOKEN ?? "", sandboxId); + return createSandboxSessionEnv(api, cwd); + }, + }; +} diff --git a/sdks/flue/src/tools.ts b/sdks/flue/src/tools.ts new file mode 100644 index 00000000..695abf97 --- /dev/null +++ b/sdks/flue/src/tools.ts @@ -0,0 +1,39 @@ +// OC repo-plane tools (design 013 §5.2) — `defineTool`s an agent adds to reach the platform's +// checkout/publish capabilities from inside a Flue turn. They POST to a Flue-session-authed OC endpoint +// that runs the EXISTING `runPublishAction` → isolated repo-op → GitHub-App-mint path (identity stays the +// App; the DO never sees a git token). Consumed by W10; the endpoint contract is pinned there. + +import { defineTool } from "@flue/runtime"; +import * as v from "valibot"; +import type { OcEnv } from "./gateway.js"; + +export interface OcRepoEnv extends OcEnv { + /** Base URL of the Flue-session-authed OC repo/publish endpoints (control plane). */ + OC_REPO_API?: string; +} + +/** Build the OC repo tools bound to `env`. Add to a coding agent's `tools` in its initializer. */ +export function ocRepoTools(env: OcRepoEnv) { + const base = (env.OC_REPO_API ?? "").replace(/\/+$/, ""); + const headers = () => ({ authorization: `Bearer ${env.OC_SESSION_TOKEN ?? ""}`, "content-type": "application/json" }); + + return [ + defineTool({ + name: "publish_pull_request", + description: + "Open or update a GitHub pull request from the changes in the agent's workspace. Identity stays the OpenComputer GitHub App; requires an attached source repo on the session.", + input: v.object({ + title: v.string(), + body: v.string(), + branch: v.optional(v.string()), + }), + async run(ctx) { + if (!base) throw new Error("[oc-flue] publish_pull_request: OC_REPO_API is not configured."); + const resp = await fetch(`${base}/publish`, { method: "POST", headers: headers(), body: JSON.stringify(ctx.input) }); + const text = await resp.text(); + if (!resp.ok) throw new Error(`publish_pull_request failed: ${resp.status} ${text.slice(0, 200)}`); + return text; + }, + }), + ]; +} diff --git a/sdks/flue/src/wire.ts b/sdks/flue/src/wire.ts new file mode 100644 index 00000000..c4909c60 --- /dev/null +++ b/sdks/flue/src/wire.ts @@ -0,0 +1,8 @@ +// Side-effect module for apps that own their `app.ts`: `import '@opencomputer/flue/wire'` to forward +// Flue observations to OC_INGEST without adopting the default app. (Add `/health` to your own Hono app +// too — the OC deploy/activate probe expects it.) Marked in package.json `sideEffects` so it survives +// tree-shaking. + +import { installOcObserver } from "./observe.js"; + +installOcObserver(); diff --git a/sdks/flue/tsconfig.json b/sdks/flue/tsconfig.json new file mode 100644 index 00000000..dd873a81 --- /dev/null +++ b/sdks/flue/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["node"], + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} From 53a473caa7c8b1ce5f9b2cb09bfe2ae89c7a3bfb Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Tue, 7 Jul 2026 23:20:30 +0100 Subject: [PATCH 2/2] fix(@opencomputer/flue): 3 W6-surfaced package defects W6 live bring-up hit three defects W4's bundle-grep acceptance missed. Fixed at the package layer so the scaffolded starter needs no workarounds. 1. DEFAULT_MODEL dot->dash. `anthropic/claude-haiku-4.5` is absent from pi-ai's model catalog, so pi-ai can't derive max output tokens and defaults max_tokens=1 -> empty completions. Use the catalog id `anthropic/claude-haiku-4-5` (dashes; OpenRouter routes it too). 2. ctx.env empty on the CF build. On `flue build --target cloudflare` the real Worker bindings live on the ambient `cloudflare:workers` env, not the per-agent `ctx.env` Flue threads in (that is empty for OC bindings), so OC_GATEWAY was unset -> the anthropic provider never registered ("Unknown model specifier"). New cf-env.ts reads the ambient env via a guarded lazy import (falls back to the passed env for local dev / node); useOcGateway + ocSandbox read through it. 3. Default `@opencomputer/flue/app` 500s. app.ts mounted flue() from `@flue/runtime/routing`; the generated CF entry seeds the runtime via configureFlueRuntime from `@flue/runtime/internal` and its no-app.ts path builds the app with createDefaultFlueApp() from that same entry. Compose via createDefaultFlueApp() so flue()'s module-scoped runtimeConfig is the instance the build configures (+ keep /health and installOcObserver) -> no "flue() route invoked before runtime was configured". Verified: scaffolded starter `flue build --target cloudflare` + wrangler dev -> GET /health 200; POST /agents turn 202 -> model call reaches the gateway with model=claude-haiku-4-5, max_tokens=64000 (not 1); full turn settles completed with a non-empty agent message. Co-Authored-By: Claude Opus 4.8 --- sdks/flue/src/app.ts | 28 ++++++++++++++++------- sdks/flue/src/cf-env.ts | 33 +++++++++++++++++++++++++++ sdks/flue/src/cloudflare-workers.d.ts | 6 +++++ sdks/flue/src/gateway.ts | 22 ++++++++++++------ sdks/flue/src/sandbox.ts | 13 ++++++++--- 5 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 sdks/flue/src/cf-env.ts create mode 100644 sdks/flue/src/cloudflare-workers.d.ts diff --git a/sdks/flue/src/app.ts b/sdks/flue/src/app.ts index 342402b8..5159b911 100644 --- a/sdks/flue/src/app.ts +++ b/sdks/flue/src/app.ts @@ -1,18 +1,30 @@ -// The default OC hosting app. A scaffolded starter uses this as its `src/app.ts` (or the CF build -// generates an equivalent when no app.ts exists). It mounts Flue's routes, adds the `/health` probe the -// OC deploy/activate step needs (stock Flue exposes NO health route — Spike B finding), and installs the -// telemetry forwarder. Apps that own their `app.ts` instead `import '@opencomputer/flue/wire'` (§wire). +// The default OC hosting app. A scaffolded starter uses this as its `src/app.ts`. It composes the +// SAME app Flue's Cloudflare build generates for the no-`app.ts` case, then adds the `/health` probe +// the OC deploy/activate step needs (stock Flue exposes NO health route — Spike B finding) and installs +// the telemetry forwarder. Apps that own their `app.ts` instead `import '@opencomputer/flue/wire'` (§wire). +// +// WHY `createDefaultFlueApp()` from `@flue/runtime/internal` (and NOT `flue()` from +// `@flue/runtime/routing`): `flue()`'s route handlers read the module-scoped `runtimeConfig` at REQUEST +// time, which the generated Cloudflare entry sets via `configureFlueRuntime(...)` (imported from +// `@flue/runtime/internal`) at module load. The generated entry's no-`app.ts` path builds its app with +// `createDefaultFlueApp()` — the exact same `@flue/runtime/internal` entry — so the mounted `flue()` and +// the `configureFlueRuntime()` that seeds it share one module instance and requests never hit +// "flue() route invoked before runtime was configured". A `src/app.ts` that instead mounted `flue()` +// from `@flue/runtime/routing` (a different published entry) risked resolving a second `@flue/runtime` +// module instance whose `runtimeConfig` is never configured → every request 500s. Composing via the +// build's own entry keeps this app on the configured instance. // // Default export = a `Fetchable` (Hono qualifies), per Flue's routing contract. -import { Hono } from "hono"; -import { flue } from "@flue/runtime/routing"; +import { createDefaultFlueApp } from "@flue/runtime/internal"; import { installOcObserver } from "./observe.js"; installOcObserver(); -const app = new Hono(); +// createDefaultFlueApp() mounts flue() at '/' and installs Flue's canonical notFound/onError envelopes. +// Adding a path-specific '/health' route afterwards is safe — flue() only registers its own concrete +// paths (/agents, /workflows, /runs, /channels), so GET /health matches this handler directly. +const app = createDefaultFlueApp(); app.get("/health", (c) => c.json({ status: "ok" })); -app.route("/", flue()); export default app; diff --git a/sdks/flue/src/cf-env.ts b/sdks/flue/src/cf-env.ts new file mode 100644 index 00000000..c29a6ad2 --- /dev/null +++ b/sdks/flue/src/cf-env.ts @@ -0,0 +1,33 @@ +// Ambient Cloudflare-Workers env access (design 013 §4). On the `flue build --target cloudflare` +// build the real Worker bindings (`OC_GATEWAY`, `OC_SESSION_TOKEN`, `OC_SANDBOX_*`, `OC_INGEST`, …) +// live on the AMBIENT env exported by `cloudflare:workers` — the same one Flue's generated entry reads +// (`import { env } from 'cloudflare:workers'`). The per-agent `ctx.env` Flue threads into the +// initializer is EMPTY for these bindings, so OC helpers must read the ambient env instead. +// +// `cloudflare:workers` only resolves inside workerd, so importing it statically would break local +// `flue dev` on the node target and the package's own vitest. Load it lazily + guarded: on CF the +// dynamic import resolves and `ambientEnv` is populated during module graph evaluation (before any +// request); everywhere else the import rejects, is caught, and callers fall back to the passed env. + +let ambientEnv: Record | undefined; +try { + // `@vite-ignore` so consumer/test bundlers don't try to statically resolve the workerd built-in; + // on CF this is a runtime import of the ambient module, off CF it throws and we fall back. + const mod = (await import(/* @vite-ignore */ "cloudflare:workers")) as { + env?: Record; + }; + ambientEnv = mod.env; +} catch { + ambientEnv = undefined; +} + +/** + * Resolve the effective OC env: the Cloudflare ambient bindings layered over `fallback` (ambient wins). + * On CF this returns the real Worker bindings even though `ctx.env` is empty; off CF (local dev / node / + * tests) it returns `fallback` unchanged so an explicitly-passed env still works. + */ +export function ocResolveEnv>(fallback: T | undefined): T { + const base = (fallback ?? {}) as T; + if (!ambientEnv) return base; + return { ...base, ...ambientEnv } as T; +} diff --git a/sdks/flue/src/cloudflare-workers.d.ts b/sdks/flue/src/cloudflare-workers.d.ts new file mode 100644 index 00000000..3102313c --- /dev/null +++ b/sdks/flue/src/cloudflare-workers.d.ts @@ -0,0 +1,6 @@ +// Minimal ambient type for the workerd-only `cloudflare:workers` virtual module, so the guarded +// dynamic import in `cf-env.ts` typechecks without pulling in `@cloudflare/workers-types`. The real +// module (present only on the `--target cloudflare` build) exports the ambient Worker `env` bindings. +declare module "cloudflare:workers" { + export const env: Record; +} diff --git a/sdks/flue/src/gateway.ts b/sdks/flue/src/gateway.ts index 486ae93a..d9c3bcae 100644 --- a/sdks/flue/src/gateway.ts +++ b/sdks/flue/src/gateway.ts @@ -12,10 +12,14 @@ import { registerProvider } from "@flue/runtime"; import type { AgentInitializerContext, AgentRouteHandler } from "@flue/runtime"; +import { ocResolveEnv } from "./cf-env.js"; /** Default managed model — MUST be prompt-caching-safe (Constraint): `claude-3-haiku` fails via - * OpenRouter→Bedrock; `claude-haiku-4.5` works. Cheap + caching-safe for the scaffolded starter. */ -export const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; + * OpenRouter→Bedrock; `claude-haiku-4-5` works. Use the pi-ai CATALOG id with DASHES + * (`claude-haiku-4-5`), never a dot (`claude-haiku-4.5`): the dotted id is absent from pi-ai's model + * catalog, so pi-ai can't derive the model's max output tokens and defaults `max_tokens` to 1 → empty + * completions. The dashed id resolves in the catalog and OpenRouter routes it too. Cheap + caching-safe. */ +export const DEFAULT_MODEL = "anthropic/claude-haiku-4-5"; export interface OcEnv { /** Deployed gateway Worker base URL (set per tenant script by the OC deploy). */ @@ -30,16 +34,20 @@ export interface OcEnv { /** * Point the managed `anthropic` provider at the OC gateway. **Call this INSIDE the `defineAgent` * initializer** — top-level module code is stripped by the CF build (proven in 1a), and the initializer - * body runs per harness init with `env` available. No-op when `OC_GATEWAY` is unset (local `flue dev` - * falls through to pi-ai's env-var key lookup). `anthropic` is a catalog id, so `baseUrl` alone rehydrates - * the wire protocol. + * body runs per harness init. Reads the CF ambient env (`cloudflare:workers`), not `ctx.env`: on the + * `--target cloudflare` build the real Worker bindings live on the ambient env and `ctx.env` is empty + * for them (Flue's generated entry threads `instance.env`, which lacks the OC bindings), so reading + * `ctx.env` alone would leave `OC_GATEWAY` unset and the provider unregistered ("Unknown model + * specifier"). No-op when `OC_GATEWAY` is unset (local `flue dev` falls through to pi-ai's env-var key + * lookup). `anthropic` is a catalog id, so `baseUrl` alone rehydrates the wire protocol. */ export function useOcGateway(ctx: AgentInitializerContext): void { - const gw = ctx.env.OC_GATEWAY; + const env = ocResolveEnv(ctx.env); + const gw = env.OC_GATEWAY; if (!gw) return; registerProvider("anthropic", { baseUrl: `${gw.replace(/\/+$/, "")}/anthropic`, - ...(ctx.env.OC_SESSION_TOKEN ? { apiKey: ctx.env.OC_SESSION_TOKEN } : {}), + ...(env.OC_SESSION_TOKEN ? { apiKey: env.OC_SESSION_TOKEN } : {}), }); } diff --git a/sdks/flue/src/sandbox.ts b/sdks/flue/src/sandbox.ts index 99e4b0c2..c66f835d 100644 --- a/sdks/flue/src/sandbox.ts +++ b/sdks/flue/src/sandbox.ts @@ -14,6 +14,7 @@ import { createSandboxSessionEnv } from "@flue/runtime"; import type { SandboxApi, SandboxFactory, FileStat, ShellResult, SessionEnv } from "@flue/runtime"; import type { OcEnv } from "./gateway.js"; +import { ocResolveEnv } from "./cf-env.js"; /** Constant workspace cwd (matches the OC session contract — flue resolves skills at `${cwd}/.agents/skills`). */ export const WORKSPACE_CWD = "/workspace"; @@ -108,16 +109,22 @@ async function resolveSandboxId(env: OcSandboxEnv, sessionId: string): Promise { - if (!env.OC_SANDBOX_API && !env.OC_SANDBOX_ID) { + const resolved = ocResolveEnv(env); + if (!resolved.OC_SANDBOX_API && !resolved.OC_SANDBOX_ID) { throw new Error("[oc-flue] ocSandbox: set OC_SANDBOX_API (+ OC_SESSION_TOKEN) or OC_SANDBOX_ID — the OC sandbox binding is not configured."); } - const sandboxId = await resolveSandboxId(env, id); - const api = new OcSandboxApi((env.OC_SANDBOX_API ?? "").replace(/\/+$/, ""), env.OC_SESSION_TOKEN ?? "", sandboxId); + const sandboxId = await resolveSandboxId(resolved, id); + const api = new OcSandboxApi((resolved.OC_SANDBOX_API ?? "").replace(/\/+$/, ""), resolved.OC_SESSION_TOKEN ?? "", sandboxId); return createSandboxSessionEnv(api, cwd); }, };