feat(demo-agent): port the product-demo agent CLI into the sandbox image - #115
Conversation
Ports packages/demo-agent from the OpenHackersClub repo into this rewrite and bakes it into infra/Dockerfile.sandbox behind a WITH_DEMO_AGENT build arg. Unblocks the product-demo run: every sandbox.exec currently fails exit 127 (demo-agent not in the image). - package renamed @fractalboxdev/flare-dispatch-demo-agent, deps aligned with this lockfile (effect 3.21.2 override; tsdown pinned 0.22.3 + unrun for pnpm 11's no-peer-auto-install) - Dockerfile builder stage builds from local repo context (no upstream sparse clone); packages/bedrock-sigv4 must be in the COPY set or pnpm silently leaves the workspace dep dangling and the bundle externalizes it - build-time `demo-agent --help` smoke fails the image on a broken bundle - wrangler.jsonc: RunSandbox (the class product-demo acquires by default) flips WITH_DEMO_AGENT; stale run comments updated - .dockerignore added (builder COPY needs a lean context)
There was a problem hiding this comment.
AI code review — 🛑 Request changes
Risk tier: full · 2 critical · 0 warnings · 2 suggestions
Reviewers: security
1. 💡 Suggestion — Unconditional Node builder adds cost to every sandbox image build
📍 infra/Dockerfile.sandbox:26-49
'demo-agent-builder' is always part of the build graph and is always referenced by the runtime stage, so even 'WITH_DEMO_AGENT=false' pulls 'node:22-bookworm-slim', enables Corepack, and copies the demo-agent workspace before running the stub branch. This adds avoidable image-pull and build-context work to every lean class build; use a separate opt-in Dockerfile/target or otherwise avoid referencing the builder stage when the feature is disabled.
2. 💡 Suggestion — Access cookie exchanges are performed serially during every attach
📍 packages/demo-agent/src/cdp.ts:288-327
Each short-lived 'record start', 'play', and 'record stop' attach fetches every configured Access host one at a time, so startup latency grows linearly with 'CF_ACCESS_HOSTS' and each exchange blocks the next. These independent exchanges can be run concurrently (for example with 'Promise.all') while preserving per-host error reporting, or cached/reused while the persistent browser session remains active.
3. 🛑 Critical — Record start writes an unusable session ID when --session-id is omitted
📍 packages/demo-agent/src/commands.ts:155-163
The fallback to 'session.sessionId()' returns the Puppeteer/CDP session identifier, not the real Browser Run session ID required by the Session Recording REST API (as the surrounding comment acknowledges). 'record stop' then fetches the recording using this invalid ID, so the default path cannot retrieve recordings. Require '--session-id' or obtain the real Browser Run ID from a reliable source instead of persisting the CDP ID.
4. 🛑 Critical — CF Access credentials are sent to arbitrary hosts from CF_ACCESS_HOSTS
📍 packages/demo-agent/src/cdp.ts:247-269
The comma-separated 'CF_ACCESS_HOSTS' value is trusted without validation and each entry receives both 'CF-Access-Client-Id' and 'CF-Access-Client-Secret'. A misconfigured or attacker-controlled host can therefore exfiltrate the service-token secret. Restrict entries to an explicit trusted-origin allowlist (or otherwise validate them against deployment configuration) before performing the exchange, and fail closed for untrusted hosts.
…s validated, opt-in image - record start now REQUIRES --session-id: the CDP-derived fallback id is a different namespace the recording REST API rejects, so persisting it wrote a session id no recording could ever be fetched by. The run always passes the real id from the pre-acquire; a loud failure feeds its re-acquire recovery. Dead CdpSession.sessionId() + test stub removed. - CF_ACCESS_HOSTS entries are validated as bare hostnames (URL round-trip, no scheme/port/path/userinfo/wildcard); accessHosts throws on the first invalid entry before any exchange, so the service-token pair never leaves for an unvalidated origin. Legacy global setExtraHTTPHeaders fallback removed — no host info means no credential leaves the process. - Access cookie exchanges run concurrently (Promise.all), per-host error reporting preserved. - demo-agent layer moved to its own opt-in infra/Dockerfile.sandbox-demo; infra/Dockerfile.sandbox is lean again, so lean class builds never pull the node builder stage or ship the bundle. RunSandbox builds from the demo file; stale run comments + AGENTS.md status updated.
|
All four items addressed in 1. Unconditional node builder (suggestion) — resolved structurally: the demo-agent layer now lives in its own opt-in 2. Serial Access exchanges (suggestion) — 3. Unusable session id when 4. Verification: |
There was a problem hiding this comment.
AI code review — 🛑 Request changes
Risk tier: full · 1 critical · 1 warnings · 0 suggestions
Reviewers: security 2 · performance
1. 🛑 Critical — Service-token credentials can be sent over plain HTTP
📍 packages/demo-agent/src/access-scope.ts:105-127
When the app host matches, 'exchangeUrlForHost' returns the caller-provided 'appUrl' verbatim, including an 'http://' scheme. 'cdp.ts' then sends 'CF-Access-Client-Secret' to that URL, allowing network observers or an HTTP endpoint to capture the deployment credential. Require an HTTPS app URL before including Access headers, and construct the exchange URL with 'https://' after validating the host.
2. ⚠️ Warning — CDP WebSocket credentials may be exposed in attach errors
📍 packages/demo-agent/src/cdp.ts:135-175
'CdpAttachFailed' stores the complete 'wsEndpoint' and error messages include it for invalid URLs and connection failures. Browser Rendering WebSocket endpoints commonly contain bearer credentials in query parameters; propagating the full endpoint into errors that are later printed can leak those credentials into logs. Redact query strings/userinfo before putting 'wsEndpoint' in error objects or messages.
…edentials from attach errors - exchangeUrlForHost only returns the caller's appUrl for the service-token exchange when it is https:; an http target falls back to the https host root, so the CF-Access-Client-Secret pair never travels unencrypted. - CdpAttachFailed.wsEndpoint is redacted at every construction site (query string, userinfo, fragment stripped) and underlying error messages are scrubbed of embedded ws/wss URLs — Browser Rendering re-attach endpoints carry bearer tokens in the query, which previously propagated into the dispatcher's logs on every failed attach. - 7 new tests: redaction matrix (cdp.test.ts) + the http-exchange fallback (access-scope.test.ts).
|
Both items addressed in 1. Service token over plain HTTP (critical) — 2. CDP WebSocket credentials in attach errors (warning) — two-layer fix in
Verification: |
What
Ports
packages/demo-agentfrom the upstream repo into this rewrite and bakes it into the sandbox image. Unblocks theproduct-demorun: everysandbox.execcurrently fails withtimeout: failed to run command 'demo-agent': No such file or directory(exit 127) — the run code was ported, its runtime dependency was not (marked "parity phase" inREWRITE.md/ the Dockerfile header).CLI contract (unchanged, byte-compatible)
The ported agent keeps the exact argv/env surface
runs/product-demo.tsalready passes (verified againstcommands.ts+schemas.ts):demo-agent --helpdemo-agent record start--cdp-ws --viewport --session-id-out --url --session-id--session-id-outdemo-agent play--cdp-ws --name --prose --screenshots --frames-dir --max-sec --model --url{status, durationMs, chapterStartMs, chapterEndMs, narrative, keyScreenshotPath}demo-agent record stop--cdp-ws --session-id-in --out{sessionId, eventCount}demo-agent gif--frames [--match] --out --max-width --max-bytes{gifPath, bytes, …}demo-agent write-prior--out --data--dataverbatim to--outEnv contract is likewise the one the run crosses (
loadSecrets, bare Worker names):CF_AI_GATEWAY_ID,CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKENrequired; optionalMODEL_API_KEY,CF_AI_GATEWAY_TOKEN,CF_ACCESS_CLIENT_ID/SECRET, AWS*whenbedrockRoleArnis passed. The model stack stays@effect/aiover the OpenAI wire protocol via AI Gateway (REWRITE.md PR5 is already true in the old agent — landed as-is, no rewrite), and Bedrock signing reuses the existing@fractalboxdev/flare-dispatch-bedrock-sigv4workspace package.Changes
packages/demo-agent/— full port (24 source files + 7 test files), renamed@fractalboxdev/flare-dispatch-demo-agent; deps aligned with this repo's lockfile (rooteffectoverride pins 3.21.2; all@effect/*resolve against it). Two build fixes:tsdownpinned to0.22.3(a fresh^0.22.3resolved0.22.14, whose config loader is broken without itsunrunpeer) +unrundevDep — pnpm 10+ defaultsauto-install-peers=false, so the old lockfile's implicit peer is no longer installed for free.dist/demo-agent.cjs(7.14 MB) with node shebang, smoke-tested (--help,write-prior,gifcontracts) both locally and inside the built image.infra/Dockerfile.sandbox-demo(new) — the full image variant: lean sandbox base + thedemo-agentlayer. Multi-stage builder runspnpm install --frozen-lockfile --filter @fractalboxdev/flare-dispatch-demo-agent+ tsdown from the local repo context — the oldDockerfile.examplesparse-cloned the upstream URL; that must not come along. Build-timedemo-agent --helpsmoke fails the image build on a broken bundle. One subtle fix discovered while proving the build: the builder COPY set must includepackages/bedrock-sigv4— with it missing, pnpm 11 silently leaves theworkspace:*dep dangling and tsdown externalizes the import, producing a bundle that requires a package absent from the runtime image.infra/Dockerfile.sandbox— back to lean-only: the demo-agent layer lives in its own opt-in file, so a lean class build never pulls the node builder stage or ships the bundle (review fix)..dockerignore(new) — the builder COPY needs a lean context (node_modules alone is ~1 GB).wrangler.jsonc—RunSandbox(the classproduct-demoacquires —sandbox.acquire({})gets the DEFAULT image class) builds from./infra/Dockerfile.sandbox-demo; browser/agent classes keep the lean file.runs/product-demo.ts— stale comments fixed: header + defineRun block now name the real image files instead of the removedflare-dispatch-demo:latestimage /recipes/product-demo/Dockerfile.example.Review fixes (pr-review on this PR)
infra/Dockerfile.sandbox-demo; lean class builds no longer reference the builder stage at all.attachCdpnow runs the per-hostCF_Authorizationcookie exchanges concurrently (Promise.all); per-host error reporting preserved, and each host's cookie is set independently.--session-id(critical) —record startnow REQUIRES--session-id; the CDP-derived fallback (session.sessionId(), a different id namespace the recording REST API rejects) is deleted along with the now-unusedCdpSession.sessionId()method and its test stub. The run always passes the real session id from the pre-acquire, so its recovery path (re-acquire + retry, then play without recording) is exactly what a loud failure feeds.CF_ACCESS_HOSTScredential exfiltration (critical) — every entry is validated as a bare hostname (no scheme/port/path/userinfo/wildcard) with a URL round-trip check, andaccessHostsTHROWS on the first invalid entry before any exchange — the service-token pair never leaves for an unvalidated origin. The legacy globalsetExtraHTTPHeadersfallback (which leaked the secret to every third-party origin when no host info existed) is removed: no host info ⇒ no credential leaves the process, fail closed. 12 new unit tests cover the validation.Verification (all local)
pnpm lint✓ ·pnpm typecheck✓ ·pnpm test✓ (172 files, 2198 tests — demo-agent's 65 included)docker build -f infra/Dockerfile.sandbox-demo✓ —/usr/local/bin/demo-agenton PATH,--helpexits 0 in-image; the leanDockerfile.sandboxalso builds and carries no agentruns/product-demo.tsparses (tryParseLastJsonetc.)Deploy notes
deploy.ymlCI runs the same checks;wrangler deployrebuilds the container images on the runner — no registry push needed.product-demo.model.playmust exist (the runEffect.dies without it —.summaryis dead config in this rewrite; the holistic summary is deterministic in-run), the demo-agent env varCF_AI_GATEWAY_IDmust be set, theRunSandboxclass must build from the demo image file, andCF_AI_GATEWAY_TOKENis required when the AI Gateway has Authenticated Gateway ON. First test dispatch should wait for those.Out of scope
flare-agent/ chromium parity layers (still pending)demo.ymlcomment, migration doc blockers) — tracked as follow-up