Engine HowTos — Setup, Drive Paths & Validation
How to point Weftra at any coding-agent backend — Claude, Gemini, OpenAI Codex, GitHub Copilot, a local Ollama/vLLM model, or OpenHands — plus the per-engine auth and the live-run validation playbook. For a fast copy-paste cookbook and the full QA matrix, see
QA_GUIDE.md.
Spec 048 — Declarative engine profiles.
FA is a neutral orchestrator: it speaks Agent Client Protocol (ACP) over stdio and routes work to whichever agent binary the operator declares. No vendor SDK lives in FA's code (constitution §VII). This document records, per candidate engine, the ACP/headless drive path an operator uses, the auth the engine expects, and known durability caveats. It also provides the human live-run playbook an operator follows when validating a new vendor backend.
1. ACP drive model recap
Every non-Claude backend uses the generic AcpEngine path:
FA → runtime.wrap(cmd, args) → spawn subprocess → JSON-RPC 2.0 / stdioThe subprocess speaks ACP (initialize → session/new → session/prompt → session/update notifications → final session/prompt response). FA never inspects what the subprocess is; it just follows the protocol.
A profile is declared in FA_ENGINE_PROFILES:
[
{
"id": "gemini",
"cmd": "gemini-cli",
"args": ["--acp"],
"authEnv": { "GOOGLE_API_KEY": "AIza..." }
}
]FA registers the profile as a named engine. The project or feature's engine field selects it. The subprocess env for any non-Claude engine is built from nothing — not a copy of FA's process.env. The child receives only a minimal base allowlist (PATH, HOME, TMPDIR, LANG, LC_ALL) plus the profile's declared authEnv. FA host secrets (GITHUB_TOKEN, ADMIN_API_KEY, SESSION_SECRET, ANTHROPIC_API_KEY) are absent by construction on every runtime — DockerRuntime and LocalRuntime alike (spec 119). Under DockerRuntime, authEnv values travel via the spawn env with name-only -e KEY docker flags, so secret values never appear in host process argv. Note the container also carries the runtime image's own baked-in env — the guarantee is "no FA host secrets and nothing beyond base + authEnv from FA", not an empty environment.
Smoke-test caveats (read before trusting diagnose:engine):npm run diagnose:engine always uses LocalRuntime regardless of the RUNTIME setting — it does not exercise the DockerRuntime container env seam. The anthropicStripped:true flag is inferred from ENV:KEY=absent lines the engine happens to emit; a real codex exec or gemini CLI prints none, so the flag is true by absence of evidence, not confirmed measurement. The CI test suite uses a mock CLI that explicitly echoes env keys — that is the verifiable isolation check. Use diagnose:engine as a quick operational sanity check, not as a proof of credential isolation.
Tool-event observability parity (spec 328). AcpEngine's session/update handler now maps the real ACP tool_call / tool_call_update notifications to tool_use/tool_result RunEvents carrying toolCallId (the correlation key between a call and its update(s)/result) and status (the pending → in_progress → completed/failed lifecycle), in addition to the existing name/inputPreview/preview fields — at parity with claude-code's tool_use↔tool_result correlation (spec 250). Both fields are optional on the RunEvent shape, so any renderer (live-log view, run-event ledger, provenance) that already displays a claude-code run's tool events shows an ACP run's tool lifecycle the same way — no engine-dependent thinning. FA's own flat-mock/shim adapters ({toolName}/{toolResult}) are unaffected: they never carry toolCallId/status and continue to map exactly as before. Tool input/output is still redacted before it reaches any surface, same as every other captured string (spec 219) — richer capture never bypasses redaction.
Permission-reply wire shape (spec 416). A spec-conformant session/request_permission request carries an options[] array (each { optionId, kind, name? }, kind one of allow_once / allow_always / reject_once / reject_always) and expects a reply naming one of them — { outcome: { outcome: 'selected', optionId } } or { outcome: { outcome: 'cancelled' } }. AcpEngine selects allow_once for an allow decision and never allow_always — a standing grant would let the agent skip the resolver for every later call of the same tool in the run, silently hollowing out governed/ask; if the agent offers no per-call allow_once option, FA replies cancelled (fails closed) rather than grant more than the policy decided. A deny decision selects reject_once, falling back to reject_always, then cancelled. The selection logic is the pure, exported selectPermissionOutcome(allow, options) in acp-engine.ts. The flat { allow: true | false } reply is a legacy path, sent only when a request carries no options at all — this is what FA's own reference shims (adapters/gemini-acp, adapters/codex-acp) and tests/fixtures/mock-acp-agent.mjs speak by default. It is a compatibility shim for FA's existing adapters, not a second protocol: a new shim or adapter must send spec-shaped options, not rely on the flat reply.
2. Candidate engine drive paths
2.1 Gemini CLI (Google Generative Language / Vertex AI)
Shape B — reference ACP shim (adapters/gemini-acp/). Turnkey as of spec 117 inc-1 (2026-07-18).
Investigation finding: The Google Gemini CLI (@google/gemini-cli, command: gemini) does not natively speak FA's ACP (JSON-RPC 2.0 / stdio) protocol — it has no initialize / session/new / session/prompt handshake. It does support non-interactive print mode (gemini -p "prompt"). The reference shim at adapters/gemini-acp/index.mjs wraps that interface and exposes it as ACP. The vendor's agentic loop is rented, not reimplemented (constitution §VII).
Prerequisites:
npm install -g @google/gemini-cli # installs `gemini` on PATH
gemini --version # confirm; tested against ≥ 1.5FA_ENGINE_PROFILES recipe (copy-paste):
API key (Google AI Studio):
{
"id": "gemini",
"cmd": "node",
"args": ["/abs/path/to/adapters/gemini-acp/index.mjs"],
"authEnv": { "GOOGLE_API_KEY": "AIza..." }
}Vertex AI (service account / Application Default Credentials):
{
"id": "gemini",
"cmd": "node",
"args": ["/abs/path/to/adapters/gemini-acp/index.mjs"],
"authEnv": {
"GOOGLE_APPLICATION_CREDENTIALS": "/run/secrets/gcp-sa.json",
"GOOGLE_CLOUD_PROJECT": "my-gcp-project",
"GOOGLE_CLOUD_LOCATION": "us-central1"
}
}After restarting FA, validate:
curl http://localhost:3100/api/engines # → "gemini" must appear in engines[]
npm run diagnose:engine -- gemini # → PASS, anthropicStripped: trueSee adapters/gemini-acp/README.md for full setup instructions.
Durability note: The shim depends on the gemini -p / --model flags. Pin the CLI version in your environment (@google/gemini-cli@<version>) and retest when upgrading.
2.2 OpenAI Codex CLI
Shape B — reference ACP shim (adapters/codex-acp/). Turnkey as of spec 117 inc-2 (2026-07-18).
Investigation finding: The OpenAI Codex CLI (@openai/codex, command: codex) does not natively speak FA's ACP (JSON-RPC 2.0 / stdio) protocol — it has no initialize / session/new / session/prompt handshake. It does support non-interactive execution mode (codex exec <prompt>). The reference shim at adapters/codex-acp/index.mjs wraps that interface and exposes it as ACP. The vendor's agentic loop is rented, not reimplemented (constitution §VII).
Prerequisites:
npm install -g @openai/codex # installs `codex` on PATH
codex --version # confirm; tested against ≥ 0.1FA_ENGINE_PROFILES recipe (copy-paste):
API key (OpenAI platform):
{
"id": "codex",
"cmd": "node",
"args": ["/abs/path/to/adapters/codex-acp/index.mjs"],
"authEnv": { "OPENAI_API_KEY": "sk-proj-..." }
}Org-scoped usage:
{
"id": "codex",
"cmd": "node",
"args": ["/abs/path/to/adapters/codex-acp/index.mjs"],
"authEnv": {
"OPENAI_API_KEY": "sk-proj-...",
"OPENAI_ORG": "org-..."
}
}After restarting FA, validate:
curl http://localhost:3100/api/engines # → "codex" must appear in engines[]
npm run diagnose:engine -- codex # → PASS, anthropicStripped: trueSee adapters/codex-acp/README.md for full setup instructions.
DockerRuntime path note (default RUNTIME=docker): FA runs the engine cmd inside the feature-workspace container (docker exec -w /work <cid> node <path>), but only the workspace directory is bind-mounted at /work — the fa-runtime image does not include adapters/codex-acp/. To use this shim under DockerRuntime, either (a) bake it into the image (COPY adapters/ /adapters/ in the Dockerfile and update args to /adapters/codex-acp/index.mjs), or (b) bind-mount the adapter directory into the container and update args accordingly. For local development only, RUNTIME=local also works — the env-from-nothing isolation guarantee (§1) still holds, but the vendor CLI runs unsandboxed on the FA host filesystem. The same gap applies to adapters/gemini-acp/ (inc-1); a systemic fix (baking adapters into the image) is tracked separately.
Durability note: The shim depends on codex exec accepting the task as a positional argument and the --model flag being stable. Pin the CLI version in your environment (@openai/codex@<version>) and retest when upgrading.
2.3 GitHub Copilot CLI
Target: GitHub Copilot models (GPT-4o Copilot) via the local gh copilot extension CLI.
ACP drive path:
cmd: gh
args: ["copilot", "--acp"]This drives the local CLI agent (gh extension install github/gh-copilot), not the remote Copilot cloud agent. The remote cloud agent uses user OAuth tokens and cannot be meaningfully sandboxed per-run; the local CLI is the stable seam FA targets.
Auth: Copilot access is attached to the authenticated GitHub user. The CLI reads credentials from GH_TOKEN or the gh credential store:
"authEnv": { "GH_TOKEN": "gho_..." }The token must have the copilot scope. If using the gh credential store (host-mounted creds), no authEnv is needed, but the ~/.config/gh directory must be available inside the runtime container.
Smoke-probe caveat (spec 395 / RM-181): the engine smoke probe (npm run diagnose:engine / POST /api/admin/engines/:id/smoke-test) hands the vendor CLI a scratch HOME/XDG_CONFIG_HOME/XDG_DATA_HOME/XDG_STATE_HOME/ XDG_CACHE_HOME instead of the operator's, so a profile relying on the hostgh credential store will no longer authenticate under the probe: gh resolves hosts.yml under $XDG_CONFIG_HOME, which the probe points at a throwaway directory, and the run fails on a vendor auth error (probeHomeIsolated stays true — the isolation held; it is the credential that is missing). This is intended. The real agent run does not get the operator's $HOME either, so a probe that did was reporting a pass the run could not reproduce. A Copilot profile that needs to pass the smoke test must declare authEnv: { "GH_TOKEN": "gho_..." } explicitly rather than depending on the host credential store.
Do not work around this by declaring HOME (or an XDG_* var) in authEnv: authEnv is applied after FA's overlay (buildSubprocessEnv in src/services/engine/subprocess-env.ts), so such a key points the vendor back at the operator's credential store. FA detects exactly that — the probe compares every overlay key against the engine's declared authEnv key names and against any ENV: line the agent reports (assessProbeIsolation in src/services/engine/smoke-test.ts) — reports probeHomeIsolated: false, and the CLI treats it as a FAIL with a non-zero exit (the pass computation in src/tools/diagnose-engine.ts).
Durability caveat: gh copilot --acp is a proposed interface. As of 2026-07 the ACP flag is not yet in the stable gh-copilot extension release. Watch the github/gh-copilot release notes. The user-token-only cloud agent is unsuitable for FA (no per-run isolation, no headless mode).
3. Human live-run validation playbook
One-command shortcut (P2 exit): Before stepping through this playbook manually, try the engine smoke-test CLI:
bashnpm run diagnose:engine -- <engineId>This validates that the declared engine drives end-to-end and that Anthropic credentials are isolated — in a single command. See
docs/OPERATIONS.md §"Validating a declared engine profile"for details. Use the full manual playbook below when you need to validate a complete feature run (clone → implement → PR), not just the protocol handshake.
This is the mandatory human step before declaring a vendor backend production-ready. CI tests prove FA's protocol and auth isolation with a mock agent; this playbook proves a real vendor agent completes a real FA feature run end-to-end.
Prerequisites:
- FA running locally or on a dev server (any autonomy mode)
- Real vendor credentials for the engine being validated
- A test Git repository FA can clone and push to
FA_ENGINE_PROFILESset with the real credential inauthEnv
Step 1 — Declare the profile
Set FA_ENGINE_PROFILES before starting FA:
export FA_ENGINE_PROFILES='[{
"id": "gemini-live",
"cmd": "gemini-cli",
"args": ["--acp"],
"authEnv": { "GOOGLE_API_KEY": "AIza...REAL...KEY" }
}]'Restart FA. Verify the profile is registered:
curl http://localhost:3100/api/engines
# → { "engines": ["claude-code", "acp", "gemini-live"], "default": "claude-code" }Step 2 — Create a test project targeting the new engine
curl -X POST http://localhost:3100/api/projects \
-H 'Content-Type: application/json' \
-d '{
"name": "vendor-validation-project",
"repo_url": "https://github.com/you/test-repo.git",
"autonomy_mode": "full_auto",
"engine": "gemini-live"
}'
# Save the returned api_key as PROJECT_KEY.Step 3 — Submit a minimal feature
curl -X POST http://localhost:3100/api/features \
-H "Authorization: Bearer $PROJECT_KEY" \
-H 'Content-Type: application/json' \
-d '{
"title": "vendor-validation: add hello-world",
"description": "Add a file hello.txt containing the string VENDOR_VALIDATED."
}'
# Save the returned feature id as FEATURE_ID.Step 4 — Observe the run
Watch the live log:
http://localhost:3100/logs.html?id=<FEATURE_ID>Or poll the feature status:
watch -n 5 curl -s http://localhost:3100/api/features/$FEATURE_ID | jq .statusExpected progression: pending → queued → in_progress → implemented (or failed if the vendor agent errors — inspect the log).
Step 5 — Confirm sandbox and auth integrity
While the run is in in_progress, verify:
Sandbox seam held: the agent process was spawned via
runtime.wrap()(check the feature log for the vendor agent command, not a direct invocation).Subprocess env built from nothing (spec 119): FA constructs the child env from an explicit base allowlist +
authEnvonly — no copy ofprocess.env. NeitherANTHROPIC_API_KEYnor FA host secrets (GITHUB_TOKEN,ADMIN_API_KEY,SESSION_SECRET) are forwarded to the vendor command on any runtime. The smoke test asserts this:npm run diagnose:engine -- <engineId>. What this proves: FA's own secrets andANTHROPIC_*are absent from the vendor subprocess (anthropicStripped,parentSecretsAbsent). What it does not prove: that the vendor had no other credential to fall back on — see the next item.Vendor auth used — conditioned on
probeHomeIsolated(spec 395 / RM-181): the smoke probe hands a non-default engine a scratchHOME/XDG_*of its own instead of the operator's, so the usual ambient credential paths (~/.codex/auth.json,~/.config/gh,~/.gemini/) resolve inside a throwaway directory.What
probeHomeIsolated: truemeans, precisely: FA created the scratch dirs, handed all five keys to the engine, the engine declared itsauthEnvkey names (Engine.declaredAuthEnvKeys) and none of them is an overlay key (such a key would override the overlay —authEnvis applied last inbuildSubprocessEnv), and noENV:line the agent reported named a different value for any of them. Those are the checks inassessProbeIsolation(src/services/engine/smoke-test.ts); nothing else is claimed — in particular it is not a statement about paths a vendor binary hard-codes rather than reading from the environment. An engine that declares (declaredAuthEnvKeys === undefinedmarks every overlay key defeated inassessProbeIsolation); the profile-built engines always declare one.When it is
false, the vendor process did not run under the scratch dirs, so the run says nothing about the profile authenticating on its declaredauthEnvalone — treat "vendor auth used" as unproven. The CLI reports this as a FAIL and exits non-zero even when the engine answered. If the scratch dirs cannot be created at all, the probe refuses to run rather than falling back to the operator's$HOME:ok: false, with the reason inerrorMessage.probeHomeIsolatedis'n/a'forclaude-code(its credential legitimately is host state by design).
Step 6 — Inspect the result
When the feature reaches implemented:
curl http://localhost:3100/api/features/$FEATURE_ID | jq '{status, pr_url, branch_name}'pr_urlshould point to a real draft PR on the test repo.- Clone the branch and verify
hello.txtexists and containsVENDOR_VALIDATED. - Optionally merge the PR and verify FA transitions to
merged.
Step 7 — Record the result
Document the test in your deployment notes:
Engine: gemini-live (gemini-cli --acp)
Date: <date>
Validator: <name>
Feature: <feature id>
PR: <pr url>
Outcome: PASS / FAIL
Notes: <any issues encountered>If the run failed, inspect the feature log for ACP protocol errors, permission denials, or vendor-side auth errors, fix the profile config, and repeat from Step 3.
Live-run validation log — append a row (per Step 7's template above) each time this playbook is completed against a real spec-conformant agent:
| Date | Agent + version | Auth mode(s) | Outcome | Notes |
|---|---|---|---|---|
| (none recorded yet) |
Spec 416 AC7 requires one row here — a governed-policy, write-kind-tool run showing the gate fire, the approver answer, and the tool proceed, validated under every auth mode the chosen agent supports — before that spec is treated as merge-ready. This is a manual, operator-run step; it is not satisfied by the automated test suite (§8 ACs 1-6 there prove the wire shape and policy dispatch against the mock, not a live agent).
4. Governance with non-Claude engines
ACP permission gating (specs 020/021) applies identically to all ACP engines. Set permission_policy=governed on the project to intercept write-kind tool calls mid-run regardless of which engine is running. The policy is enforced by FA's resolvePermission function before the response is sent to the agent — the vendor agent cannot bypass it.
See docs/USER_GUIDE.md §"ACP permission gating" for the full recipe.
4a. Honest metering and platform-wide budget caps (Spec 235)
Two governance properties are enforced by the platform, not by any one engine — so they hold identically whether a feature is routed to claude-code, acp, or a declared headless profile.
Cost: "unknown" vs. measured $0.00
EngineMetrics.costUsd and ClaudeResult.costUsd are number | null:
null— the engine/transport did not measure cost for this run. ACP'ssession/promptresponse and a declared headless command's stdout carry no cost figure, so both engines reportcostUsd: null.claude-codealways reports a real, measured number (including a legitimate0, e.g. underoauthauth where there is no per-token billing).0— the engine genuinely measured zero cost. This is a real, tracked result — never confused with "unknown".
This distinction is load-bearing all the way to the operator-facing spend figures: a null run cost is never coerced to 0 and booked as a measured run. It does not inflate run_count, and it adds nothing to cumulative spend (getProjectCostSummary / getProjectReport in src/models/features.ts, and the Spend (cumulative) line in scheduled reports). A null-cost feature's dashboard row and provenance artifact render "unknown"/"—", never $0.0000.
If you write a new engine or headless profile whose transport doesn't surface a cost figure, emit costUsd: null — do not default to 0.
This applies to the cost metric only — not to token usage. inputTokens / outputTokens are reported by every transport (ACP's usage payload, the headless contract), and those numbers are real. They are accumulated independently of the cost guard, so a null-cost run's measured tokens still reach the per-project totals, getFleetRollup, and the scheduled digest's Tokens: XK in + YK out line. Withholding a dollar figure FA doesn't have is honesty; dropping consumption FA did measure would just be a second, quieter lie.
Budget cap: every engine enforces it, or refuses the task
EngineTask.maxBudgetTokens (a hard token ceiling, when declared) is enforced live by claude-code's stream loop. An engine that has no live enforcement point for it (ACP's single request/response exchange, a headless command's single stdin→stdout call) does not run the task uncapped — it refuses, failing fast inside its own run() with an error naming the engine, before any subprocess work happens. The refusal surfaces as an ordinary run failure (structured failure cause, spec 212) — the same path any other pre-flight rejection takes.
This is driven by a capability signal, not a hard-coded engine-id list: Engine declares a required enforcesBudgetCap: boolean. claude-code sets it true; AcpEngine and HeadlessEngine set it false and call the shared requireBudgetCapEnforcement() guard (src/services/engine/types.ts) at the top of run(). A future engine that gains real cap enforcement flips its own enforcesBudgetCap to true — no change needed anywhere else.
The binding does not depend on an engine remembering to call that guard. registerEngine() (src/services/engine/index.ts) is the single choke point every reachable engine passes through, and it applies the guard to any engine that does not declare enforcesBudgetCap: true — including an engine registered by out-of-tree code, and including one that omits the field entirely (undefined is treated as non-enforcing, the fail-safe direction). Engines keep their own in-run() call, so the refusal still originates inside the engine; the registry only makes it unskippable. Cap enforcement never moves into runEngine, which stays a pure passthrough.
Practical effect: a project with max_budget_tokens set must stay on claude-code (or a future engine that declares enforcesBudgetCap: true) if it wants that run to actually execute — routing it to acp/headless fails the run rather than silently dropping the cap.
The cap VALUE: the most restrictive declared level wins
Enforcing a cap is only a governance control if the party being governed cannot choose the number. max_budget_tokens is declared at three levels — per feature, per project, and instance-wide (FA_MAX_BUDGET_TOKENS) — and the first two are tenant-writable: a project key sets max_budget_tokens at POST /api/features and project.max_budget_tokens at PATCH /api/project, and the validator only requires a positive integer (there is no upper bound to validate against, because the ceiling is a different level's business).
So resolveBudgetTokens() (src/services/agent/budget.ts) takes the minimum of whatever levels are declared, not the first one it finds:
- A feature or project may tighten its own spend below the level above it.
- Neither can raise the ceiling: an operator who sets
FA_MAX_BUDGET_TOKENS=250000, or a project cap of100000, binds every run under it no matter what a tenant submits per feature. - A level that is absent (or a non-positive value that reached the DB by some path the validators don't cover) declares nothing and simply doesn't constrain. All three absent = uncapped, unchanged.
resolveBudgetSeconds() applies the identical rule to the wall-clock cap (max_budget_seconds / FA_MAX_BUDGET_SECONDS), which is tenant-writable through the same two routes.
4b. Output-log visibility parity (spec 332 / RM-103 v2)
Every engine now writes its run to the same per-job workspace log (workspaces/logs/<jobId>.log) — claude-code has always done this; the ACP engine was previously blind (an ACP parse-fail left only the agent-init line, no output). AcpEngine.run() streams the session's text and tool-call events through the shared sink/formatter in src/services/agent/workspace-log.ts (the exact file claude-runner.ts also writes to), so a security/conformance round run under an ACP engine is now as debuggable as one run under claude-code.
Redacted by default. Every line funnels through redactSecrets before it touches disk — identical to claude-code's existing discipline (spec 132 §0.3). A secret an ACP agent echoes from repo/env content is redacted the same way a claude-code run's would be. Tool-call input/result lines are bounded to the same 200/300-char caps claude-runner.ts's formatStreamEvent applies — unconditionally, in every mode, log_full_output included (claude applies its caps with no escape, and the flag below changes redaction only, never bounds) — so an ACP log is never a richer, unbounded surface where claude's equivalent line is truncated. The model's own streamed text ([text] lines) is uncapped in both engines, so a parse-fail's verdict text is always fully visible.
Opt-in log_full_output (per engine profile), OFF by default. Set logFullOutput: true on an FA_ENGINE_PROFILES entry to skip the third-party shape-based redaction of that engine's workspace log lines, for deep debugging of output the shape patterns would otherwise mangle:
[{"id": "gemini-live", "cmd": "gemini-acp", "logFullOutput": true}]When enabled, the log carries an explicit [warning] header stating the run's output may not be caught by shape-based redaction and may contain sensitive text. This does NOT disable masking of FA's own known configured secret literals (redactKnownSecretValues, src/utils/secrets.ts), this engine's own configured authEnv values, or FA's own deliberately-minted token shapes (the fa_ API-key pattern and the fa_helper_ run-token pattern, spec 248 — redactKnownSecretValues applies both regardless of full) — only the third-party heuristic shape-pattern catch-all (GitHub/GitLab/Anthropic/Bearer) is what the flag opts out of (CLAUDE.md trust-boundary law #5: a per-engine debug flag cannot waive "secrets never appear in argv, logs, or provenance"). It is an admin/operator engine-config concern (declared alongside cmd/args/ authEnv), never a tenant/feature field.
Know the log's real audience before enabling it. The workspace log is not a host-filesystem-only artifact: GET /api/features/:id/logs serves the file verbatim — no read-time redaction — to admins and to every approver linked to that project. Enabling log_full_output therefore widens what that whole audience can read through the API, not just what lands on the operator's disk. The flag's warning header states this explicitly.
Engine-agnostic invariant, scoped to what THIS module writes. Neither workspace-log.ts nor acp-engine.ts writes anything to the run_events ledger or the audit export — only the redacted verdict/parse-fail status is recorded there, identical for claude-code and every ACP engine, exactly as before this feature. One separate, pre-existing mechanism touches the file: claim-stale-diagnosis.ts's readJobLogTailLive/jobLogPath copies the tail of the feature's author-run log (<featureId>.log only — prefixed role logs such as security-<fid>.log are not read by it) into a claim_stale_diagnosis run event when a claim goes stale, re-applying full redactSecrets (shape patterns included) to the tail first — so shape-known tokens written under log_full_output are still masked before any of those bytes can reach the ledger. That path is unchanged by this feature; its residual gap (the tail re-redaction has no per-run literal pool, same as every write path) is a pre-existing item tracked against that file, out of scope here.
4c. The sandboxed browser tool (Spec 350 / RM-120)
FA can build UI but, until this, could not SEE it — the reviewer only ever read a diff. The browser tool is a per-run, operator-granted capability that renders a declared app URL inside the sandbox and returns a screenshot + accessibility-tree DOM + console errors to the agent as multimodal input. It is advisory only: it sets no verdict and gates no merge.
How a run gets it. There is no settable "enable browser tool" field. A run is granted the tool purely because its resolved runtime_image is the operator-curated fa-runtime-browser:latest image — the same admin-only field that already selects any other runtime image (docs/OPERATIONS.md §4h-7). Setting runtime_image is already outside a project key's or an agent profile's reach, so the grant inherits that enforcement rather than adding a new one.
What FA writes, and what it doesn't. FA writes NO browser driver. The tool is the official @playwright/mcp server, run inside the fa-runtime-browser image (containers/fa-runtime- browser/Dockerfile — Chromium via Playwright, PLAYWRIGHT_BROWSERS_PATH=/ms-playwright). FA's job is purely the TS wiring: select the image, register @playwright/mcp in the run's .fa-mcp.json routed through the spec-348 MCP gateway (never wired directly), scope allowed_tools to exactly that tool set plus Read, and bind egress to the declared app host(s). See docs/OPERATIONS.md §4h-11 for the full mechanism (the sandbox MCP-gateway transport, the runtime-wrap spawn, the egress union, the permission-gate composition).
Governance in one sentence. Reads (browser_snapshot, browser_take_screenshot, console/network inspection) auto-allow under the ACP permission gate; anything that acts on the page (browser_click, browser_type, browser_navigate, dialogs, …) is routed to the approver under a governed policy — the browser tool reuses the SAME ToolKind classification every other tool call goes through (governedDecision, src/services/engine/permission-gate.ts), adding no bespoke approval path.
Not a tenant-settable or profile-settable field. runtime_image is absent from SELF_CONFIGURABLE_PROJECT_FIELDS, present in TENANT_FORBIDDEN_FEATURE_FIELDS, and not a legal key in an agent profile's closed vocabulary (spec 285) — a project key or a profile can never turn this on for itself.
Consumers. Spec 286 inc-2 (spec 347, the rendered designer review) is the first role built on top of this primitive; any future UX-focused agent run can be granted it the same way.
5. Agent roles
FA defines four agent roles (a closed set). Each role can be routed to a different engine via engine_routing_policy or per-role static fields:
| Role | Static field (project / feature) | When it runs | Semantics |
|---|---|---|---|
author | engine / engine | Implements the feature (the main coding agent). | Transitions feature to implemented. |
reviewer | reviewer_engine / reviewer_engine | Spec-conformance review after implemented. | Posts a VCS APPROVE/REQUEST_CHANGES review. Terminal on reviewer_final. |
fixer | fixer_engine / fixer_engine | On-demand, admin-triggered targeted remediation of an open adversarial security finding (POST /api/features/:id/fix-security, Spec 136 inc-1). | Addresses exactly the findings, pushes to the same branch (no new PR). Bounded rounds + escalate-on-no-progress (security_fixer_final). Never auto-merges. |
security | security_engine / security_engine | Adversarial security review after implemented (Spec 120). | ESCALATE-ONLY. Posts a PR comment; cannot approve or merge. Terminal on security_final. Re-runs after every revise. |
revise | revise_engine / revise_engine | Addresses PR review comments on an already-implemented feature (POST /api/features/:id/revise, Spec 191). | Fetches the PR's review feedback, re-clones the branch, applies the changes, and pushes to the SAME branch (no new PR). Returns to implemented. |
reviewer, reviewer_model, and reviewer_engine resolve via the standard feature→project→routing-policy→default chain (unlike fixer/security/revise below), but who may write them is admin-only (Spec 206 inc-1): a project key can never set reviewer, reviewer_model, or reviewer_engine on PATCH /api/project or POST /api/features (both reject with 400/403) — only PATCH /api/projects/:id or POST /api/features/admin (admin key) can. This closes a tenant→operator cost-escalation path: without it, a project key could turn on the reviewer and, via a partial-progress REQUEST_CHANGES loop, draw the operator's admin-configured escalation model at round-2. See docs/USER_GUIDE.md §"The spec-conformance reviewer" for the full lifecycle.
The fixer role resolves via an admin-only chain, like security — NOT the standard feature→project→routing-policy→default chain used by author/reviewer. Only the write-locked static fields (feature.fixer_engine, then project.fixer_engine) and the default apply; engine_routing_policy and the Tier-1 classifier are skipped even though they apply to other roles, because both are tenant-reachable (engine_routing_policy is self-configurable at the project level and unguarded on POST /api/features) while fixer_engine/fixer_model are not — consulting policy would let a tenant silently override the admin-set engine the moment /fix-security runs. fixer_engine/ fixer_model are themselves admin-only settable (a project key can never set them, mirroring the security-role write lockdown). See docs/OPERATIONS.md §4d for the trigger, round cap, and gate-independence guarantee.
The security role engine is resolved from admin-controlled config only — tenant-reachable fields (feature static fields, engine_routing_policy) are intentionally skipped so a tenant cannot route their own PR to a weaker model:
project.security_engine(static field — admin key required to set)DEFAULT_ENGINE_ID(claude-code)
engine_routing_policy["security"] (at either project or feature level) is ignored for the security role. To route the security reviewer to a different backend than the implementer (recommended — uncorrelated-errors principle), use the admin API to set project.security_engine directly:
curl -X PATCH https://fa.example.com/api/projects/$PROJECT_ID \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"security_reviewer": "adversarial", "security_engine": "gemini-profile", "security_model": "gemini-2.5-pro"}'See docs/USER_GUIDE.md §"The adversarial security reviewer" for the full lifecycle and observability guide.
The revise role resolves via the admin-only chain — the same shape as fixer, NOT the standard feature→project→routing-policy→default chain used by author/reviewer/analyze/answerer. revise_model/revise_engine are settable only via PATCH /api/projects/:id or POST /api/features/admin (admin key); PATCH /api/project (self-service) and POST /api/features (project key) reject both with 400. Although POST /api/features/:id/revise is itself already project-key reachable, revise is the remediation agent for the tiered auto-merge loop — the same role fixer_model/ fixer_engine are locked down to protect — so a tenant must not be able to choose its own cost tier or credential set for its own remediation agent. Precedence when unset: feature revise_model/revise_engine → project default → the full author-role chain (so an admin's project.engine pin is inherited exactly as it was before Spec 191, including routing policy and the Tier-1 classifier at that layer) → config.agentModel / DEFAULT_ENGINE_ID. Unlike routing-policy/classifier values, the static revise_engine/project_revise_engine fields are validated against the registered engine list both at the resolver (an unknown id is skipped, not silently defaulted while being recorded as if it ran) and at the two admin write boundaries. See docs/USER_GUIDE.md §"Revise model and engine (Spec 191)" for the full recipe.
5a. Model escalation on retry (spec 205)
Two of the roles above are bounded remediation stages that can retry: revise (round 2, when the Conformance Reviewer's fix-list wasn't fully addressed) and fixer (the Security Fixer, round 2+ of SECURITY_FIX_MAX_ROUNDS). Both may now be configured to escalate to a different model on the retry rather than re-running the model that just failed to converge — measured directly on PR #418 (2026-07-31): the Security Fixer's default model declined to act on round 1 (pushed=false, 0 files); round 2 on a stronger model produced the correct fix.
Two new admin-only fields, mirroring revise_model/fixer_model exactly (project default + feature override, PATCH /api/projects/:id / POST /api/features/admin only — a project key is rejected with 400 on every path):
| Field | Applies to | Effective from |
|---|---|---|
revise_escalation_model | the revise role | round 2 (the first retry) |
fixer_escalation_model | the fixer role (Security Fixer) | round 2 (the first retry) |
Unset = today's behavior, byte-identical. Round 1 (the first attempt) is never escalated regardless of configuration — only a genuine retry after a non-converging round uses the escalation model. There is no automatic model selection here (that's the Tier-1 classifier's job, §5 above) — this only decides whether to re-run the same model or switch, once a round is known to be a retry.
Trigger gating — a tenant can never drive escalation spend. The escalation fields are admin-only, but POST /api/features/:id/revise is project-key-reachable and FA-side uncapped, so the revise round counter alone must not select the escalation tier. Every revise trigger is attributed in the run-events ledger before the run starts (revise_requested {actor} from the two routes; reviewer_revise from the conformance reviewer's auto-revise loop), and the flow escalates only when the latest trigger is the reviewer loop or an admin — a project-key trigger, or a run with no recorded trigger at all, resolves the plain round-1 model no matter how high the round count is (fail closed).
Why a reviewer trigger is trustworthy — and it is not trustworthy by itself. The reviewer loop only mints reviewer_revise events for a project whose conformance reviewer is turned on, so trusting that trigger is only sound while a tenant cannot turn the reviewer on. Two further conditions therefore gate it, both operator-controlled:
reviewer/reviewer_model/reviewer_enginemust be operator-only. They are (Spec 206 inc-1, §5 above), and the flow re-checks it structurally at gate time against the live project-key-writable allowlists. If that lockdown ever regresses, a reviewer trigger stops being escalation-eligible instead of silently becoming tenant-drivable.- The conformance reviewer must be live for the feature right now — resolved from those same admin-only fields, mirroring the security-reviewer-live guard on the Fixer queue. A stale
reviewer_reviseleft on the ledger by a since-disabled reviewer cannot keep drawing the escalation model.
Without these, a leaked project key could flip its own project to spec_conformance, farm partial-progress REQUEST_CHANGES verdicts, and put every round-2 revise on the operator's most expensive model. Each revise_started records escalation_gate — admin, reviewer, reviewer_not_live, reviewer_arming_not_operator_only, untrusted_trigger, or no_trigger — so the ledger says which condition decided, for escalations and refusals. The round counter that selects the escalation tier is isolated the same way: each attempt's revise_started/revise_credit_paused rows are stamped escalation_eligible at origination, and the escalation round counts only stamped attempts. A tenant-triggered revise therefore neither runs the escalation model itself nor advances the round a later reviewer-/admin-triggered run reads — looping the project-key endpoint cannot flip the next trusted run's first attempt from the base model onto the escalation model. (The overall round in the payload still tallies every attempt, for audit; escalation_round records the isolated counter that did the selecting.) The Fixer half needs no such gate: /fix-security is admin-only and bounded by SECURITY_FIX_MAX_ROUNDS.
curl -X PATCH https://fa.example.com/api/projects/$PROJECT_ID \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"revise_escalation_model": "a-stronger-model", "fixer_escalation_model": "a-stronger-model"}'Independence corollary. The Security Reviewer (security_model) is deliberately run on a different model than the author for uncorrelated errors. If the resolved Fixer model (after escalation) ends up equal to security_model, the round still runs — this is a warning, not a block — but its security_fixer_started run-event carries independence_warning: true so the same correlation risk security_verdict already flags for the author is visible for the Fixer too. Pick an escalation model that differs from security_model to keep independence intact.
Auditability. Every escalated attempt is recorded in the run-events ledger with the model that actually ran, an escalated: true flag, and its trigger — revise_started for the revise role (mirrors security_fixer_started, which already carried model and now also carries escalated/independence_warning) and security_fixer_started for the fixer role. This is what a human merger reads to answer "which model produced this commit." revise_started is written at attempt origination, immediately before the engine runs (exactly like security_fixer_started), and the write is not best-effort: if the ledger insert fails, the attempt does not run — so an attempt that later pauses on credit exhaustion still has its audit row, and a ledger failure can never silently reset the round counter back onto the model that already failed. A credit pause writes a pairing revise_credit_paused event so the resumed call recomputes the same round (a resume continues the interrupted attempt — a paused first attempt resumes unescalated; a paused escalated attempt resumes escalated, never silently de-escalated).
No vendor model id is ever hardcoded in FA's source for this — the escalation model is pure per-project/feature declarative config, same as security_model.
6. Reliability: startup + idle deadlines (spec 149)
FA is unattended by construction, and AGENT_MAX_CONCURRENCY defaults to 1 — so one engine subprocess that is spawned but never makes progress (alive, but hung) can silently stall the whole engine loop forever. Both drive paths (AcpEngine and HeadlessEngine) bound every run with two independent deadlines instead of waiting indefinitely on a live-but-stuck child:
| Deadline | Env var | Default | What it bounds | Error prefix |
|---|---|---|---|---|
| Startup | ENGINE_STARTUP_TIMEOUT_MS | 90000 (90s) | ACP only — the handshake (initialize + session/new) before any progress signal exists. | engine_startup_timeout: |
| Idle / activity | ENGINE_IDLE_TIMEOUT_MS | 600000 (10 min) | ACP: the session/prompt phase, reset by ANY parsed message (a session/update notification, a permission request, the final response). Headless: the whole run, reset by any stdout/stderr data from the child. | engine_idle_timeout: |
Setting either var to 0 disables that deadline (waits indefinitely again — pre-spec-149 behavior). Both are instance-level operator config; there is no per-project or per-feature override in this increment.
Why two clocks, not one. Startup has no in-flight progress signal to reset a clock against — a server that never replies to initialize looks identical to one that's about to reply in 5 more seconds, so it's bounded by a flat wall-clock deadline. The prompt/run phase can legitimately run for a long time (a large task, a slow model), so it must NOT be wall-clock bounded — it's bounded by inactivity instead: any observed progress resets the clock, so a run that keeps streaming updates never times out no matter how long it takes in total.
On breach: the engine runs its existing cleanup (ACP: best-effort session/cancel + SIGTERM; headless: SIGTERM) and throws an Error whose message starts with engine_startup_timeout: or engine_idle_timeout: and names the engine id (and, for a startup breach, which handshake phase hung). There is no new UI surface for this — the breach fails the run through the existing failure path (feature failed, run_events, dashboard/Active Runs), which already renders the error message; the classified prefix makes the cause legible there without a dedicated view.
# docs/SETUP.md "Production Deployment" block also lists these.
ENGINE_STARTUP_TIMEOUT_MS=90000 # 0 disables (ACP handshake only)
ENGINE_IDLE_TIMEOUT_MS=600000 # 0 disables (ACP prompt phase; whole run for headless)See src/services/engine/deadlines.ts for the shared timer/error-classification primitives and tests/engine-transport-deadlines.test.ts for the full contract (mock servers that hang at each phase, a mock that proves periodic activity resets the idle clock, and env-configurability including 0 disabling).