Skip to content

feat(demo-agent): port the product-demo agent CLI into the sandbox image - #115

Merged
iyansr merged 3 commits into
mainfrom
feat/demo-agent-port
Aug 11, 2026
Merged

feat(demo-agent): port the product-demo agent CLI into the sandbox image#115
iyansr merged 3 commits into
mainfrom
feat/demo-agent-port

Conversation

@iyansr

@iyansr iyansr commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

Ports packages/demo-agent from the upstream repo into this rewrite and bakes it into the sandbox image. Unblocks the product-demo run: every sandbox.exec currently fails with timeout: 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" in REWRITE.md / the Dockerfile header).

CLI contract (unchanged, byte-compatible)

The ported agent keeps the exact argv/env surface runs/product-demo.ts already passes (verified against commands.ts + schemas.ts):

Invocation Required flags Output contract
demo-agent --help warmup smoke (run ignores exit)
demo-agent record start --cdp-ws --viewport --session-id-out --url --session-id writes session id to --session-id-out
demo-agent play --cdp-ws --name --prose --screenshots --frames-dir --max-sec --model --url last stdout line JSON {status, durationMs, chapterStartMs, chapterEndMs, narrative, keyScreenshotPath}
demo-agent record stop --cdp-ws --session-id-in --out last stdout line JSON {sessionId, eventCount}
demo-agent gif --frames [--match] --out --max-width --max-bytes JSON {gifPath, bytes, …}
demo-agent write-prior --out --data writes --data verbatim to --out

Env contract is likewise the one the run crosses (loadSecrets, bare Worker names): CF_AI_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN required; optional MODEL_API_KEY, CF_AI_GATEWAY_TOKEN, CF_ACCESS_CLIENT_ID/SECRET, AWS * when bedrockRoleArn is passed. The model stack stays @effect/ai over 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-sigv4 workspace 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 (root effect override pins 3.21.2; all @effect/* resolve against it). Two build fixes:
    • tsdown pinned to 0.22.3 (a fresh ^0.22.3 resolved 0.22.14, whose config loader is broken without its unrun peer) + unrun devDep — pnpm 10+ defaults auto-install-peers=false, so the old lockfile's implicit peer is no longer installed for free.
    • Bundle verified standalone: dist/demo-agent.cjs (7.14 MB) with node shebang, smoke-tested (--help, write-prior, gif contracts) both locally and inside the built image.
  • infra/Dockerfile.sandbox-demo (new) — the full image variant: lean sandbox base + the demo-agent layer. Multi-stage builder runs pnpm install --frozen-lockfile --filter @fractalboxdev/flare-dispatch-demo-agent + tsdown from the local repo context — the old Dockerfile.example sparse-cloned the upstream URL; that must not come along. Build-time demo-agent --help smoke fails the image build on a broken bundle. One subtle fix discovered while proving the build: the builder COPY set must include packages/bedrock-sigv4 — with it missing, pnpm 11 silently leaves the workspace:* 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.jsoncRunSandbox (the class product-demo acquires — 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 removed flare-dispatch-demo:latest image / recipes/product-demo/Dockerfile.example.

Review fixes (pr-review on this PR)

  1. Unconditional node builder (suggestion) — resolved structurally: the demo-agent layer moved to its own opt-in infra/Dockerfile.sandbox-demo; lean class builds no longer reference the builder stage at all.
  2. Serial Access exchanges (suggestion)attachCdp now runs the per-host CF_Authorization cookie exchanges concurrently (Promise.all); per-host error reporting preserved, and each host's cookie is set independently.
  3. Unusable session id without --session-id (critical)record start now REQUIRES --session-id; the CDP-derived fallback (session.sessionId(), a different id namespace the recording REST API rejects) is deleted along with the now-unused CdpSession.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.
  4. CF_ACCESS_HOSTS credential exfiltration (critical) — every entry is validated as a bare hostname (no scheme/port/path/userinfo/wildcard) with a URL round-trip check, and accessHosts THROWS on the first invalid entry before any exchange — the service-token pair never leaves for an unvalidated origin. The legacy global setExtraHTTPHeaders fallback (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-agent on PATH, --help exits 0 in-image; the lean Dockerfile.sandbox also builds and carries no agent
  • Contract audit: every option name + stdout JSON shape matches what runs/product-demo.ts parses (tryParseLastJson etc.)

Deploy notes

  • On merge, deploy.yml CI runs the same checks; wrangler deploy rebuilds the container images on the runner — no registry push needed.
  • Consumer-side deployment config is not part of this PR (tracked in the plan doc): CONFIG_KV product-demo.model.play must exist (the run Effect.dies without it — .summary is dead config in this rewrite; the holistic summary is deterministic in-run), the demo-agent env var CF_AI_GATEWAY_ID must be set, the RunSandbox class must build from the demo image file, and CF_AI_GATEWAY_TOKEN is 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)
  • Consumer-side doc reconciliation (demo.yml comment, migration doc blockers) — tracked as follow-up

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)

@flaredispatch-fractalboxdev flaredispatch-fractalboxdev Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI code review — 🛑 Request changes

Risk tier: full · 2 critical · 0 warnings · 2 suggestions

Reviewers: security ⚠️ · performance 2 · code-quality 1 · documentation ⚠️ · release-management ⚠️ · compliance 1 · agents-md ⚠️

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.

📋 View full logs & reviewed diff ↗

…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.
@iyansr

iyansr commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

All four items addressed in e421cfb:

1. Unconditional node builder (suggestion) — resolved structurally: the demo-agent layer now lives in its own opt-in infra/Dockerfile.sandbox-demo; infra/Dockerfile.sandbox is lean-only again and its class builds never reference the builder stage (verified: lean build runs without pulling node:22 — 2.1s local build, no agent in image). RunSandbox builds from the demo file.

2. Serial Access exchanges (suggestion)attachCdp now runs the per-host CF_Authorization cookie exchanges concurrently via Promise.all (packages/demo-agent/src/cdp.ts). Per-host error reporting is preserved: each host's no-cookie case logs and continues, and an exchange failure rejects the attach exactly as the serial loop did.

3. Unusable session id when --session-id omitted (critical)record start now REQUIRES --session-id; the CDP-derived fallback (session.sessionId()) is deleted along with the unused CdpSession.sessionId() method and its test stub. The run always passes the real Browser Run session id from the recording pre-acquire, and its recovery path (re-acquire + retry, then play without recording) is exactly what the loud failure feeds — strictly better than silently persisting an id the recording REST API rejects.

4. CF_ACCESS_HOSTS credential exfiltration (critical)accessHosts now validates every entry as a bare hostname (no scheme/port/path/userinfo/wildcard) plus a URL round-trip check, and throws on the first invalid entry before any exchange — the service-token pair never leaves for an unvalidated origin, fail closed. The legacy global setExtraHTTPHeaders fallback (which leaked the secret to every third-party origin when no host info existed) is removed: no host info ⇒ no credential leaves the process. 12 new unit tests cover the validation matrix (access-scope.test.ts).

Verification: pnpm lint/typecheck/test green (2210 tests), both image variants built locally (demo image smoke-tests demo-agent --help at build time; lean image carries no agent).

@flaredispatch-fractalboxdev flaredispatch-fractalboxdev Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI code review — 🛑 Request changes

Risk tier: full · 1 critical · 1 warnings · 0 suggestions

Reviewers: security 2 · performance ⚠️ · code-quality ⚠️ · documentation ⚠️ · release-management 0 · compliance ⚠️ · agents-md 0

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.

📋 View full logs & reviewed diff ↗

…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).
@iyansr

iyansr commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Both items addressed in 6019b99:

1. Service token over plain HTTP (critical)exchangeUrlForHost now returns the caller's appUrl for the exchange ONLY when it is https:; an http:// target (a misconfiguration for an Access-gated app) falls back to the https://<host>/ root, so the CF-Access-Client-Id/Secret pair never travels unencrypted. Covered by a new test (access-scope.test.ts).

2. CDP WebSocket credentials in attach errors (warning) — two-layer fix in cdp.ts:

  • CdpAttachFailed.wsEndpoint is passed through redactWsEndpoint() at all six construction sites (userinfo, query string, and fragment stripped; scheme/host/port/path kept for identification).
  • Underlying error messages (URL validation, puppeteer connect failures) can embed the endpoint verbatim, so scrubWsUrls() also strips every ws/wss URL from the message text — the credential can't leak through the field OR the message.
  • redactWsEndpoint is exported and covered by 6 new tests (cdp.test.ts).

Verification: pnpm lint/typecheck/test green (2218 tests, agent suite 73), bundle rebuilt + --help smoke passes.

@iyansr
iyansr merged commit b6e6fba into main Aug 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant