Skip to content

Weftra — Operator QA Guide

Audience: the operator (you) running Weftra (FA) and validating it end-to-end before trusting a use case in production. Scope: how to set up the accounts, engines, and integrations FA can drive, and a repeatable test matrix for each capability — with the exact command/endpoint and the expected result for each check.

Ground rules for QA

  • Never QA against a live production project. Enroll a throwaway test project (or a fork). The trader (AiAutonomousTrader) is live — do not point FA runs at it.
  • FA is project-agnostic by law. Nothing in FA knows what your command does. Every project-specific behavior is declared config (runtime_image, data_dirs, test_command, …). If a QA step seems to need FA to "know about" a project, that's a bug, not a test gap.
  • A safety claim is not evidence. When a test's expected result is "the secret is not present", assert it by scanning the actual output — don't trust a label.
  • Verified state, honestly reported. If a step is skipped (no vendor key, Docker unavailable), say so in your QA notes — a skipped check is not a pass.

Companion docs: docs/SETUP.md (install/config), docs/OPERATIONS.md (run/operate), docs/USER_GUIDE.md (feature lifecycle), docs/ENGINES.md (Engine HowTos) and docs/design/engine-runtime.md (engine internals).


0. Prerequisites — the QA baseline

Before any use-case test, get a clean baseline green.

CheckCommandExpected
Buildnpm run buildexit 0, no tsc errors
Full test suitenpx vitest runall green
FA diagnosenpm run diagnoseClaude CLI reachable
Server upcurl -s localhost:3100/health (or your PORT)200 / ok
Runtime modeecho $RUNTIME (default docker)docker for a real QA of the sandbox; local only for fast unit-style checks
Runtime image (docker)docker image inspect $FA_RUNTIME_IMAGE (default fa-runtime:latest)image exists

Auth-mode parity (NON-NEGOTIABLE). Anything touching an agent run must be validated under bothapi and oauth Claude auth (they differ in HOME/mounts/env and have diverged before). Where a test below says "run the agent", run it once with FA_AGENT_AUTH=api (needs ANTHROPIC_API_KEY) and once with FA_AGENT_AUTH=oauth (needs a valid ~/.claude cred dir). See docs/design/engine-runtime.md §7.


1. Engines — the mental model (read this before wiring vendors)

FA does not bundle Gemini/OpenAI/etc. It is the client; each non-Claude engine is a host binary you supply that speaks one of two transports. You declare engines in the FA_ENGINE_PROFILES env var (a JSON array) and FA spawns them inside the sandbox runtime, never directly on the host.

Profile shape (src/config.ts:5-12):

jsonc
{
  "id": "gemini",                 // required, unique; may NOT be "claude-code" or "acp" (reserved)
  "cmd": "gemini-acp",            // required: the host executable FA runs
  "args": ["--flag"],            // optional
  "authEnv": {                    // optional: vendor creds, injected as -e KEY=value into the sandbox
    "GOOGLE_API_KEY": "AI..."
  },
  "transport": "acp"             // optional: "acp" (default) | "headless"
}

Built-in engine ids (no profile needed): claude-code (the default) and acp (a generic ACP engine driven by FA_ACP_AGENT_CMD/FA_ACP_AGENT_ARGS).

Two transports:

  • acp (default) — vendor-neutral Agent Client Protocol, JSON-RPC 2.0 over the subprocess's stdio, protocol version 1.0. Supports a mid-run per-tool permission gate. Your cmd must be an ACP-speaking agent.
  • headless — single-shot: FA writes the prompt to stdin, captures stdout as the result. No per-tool gate (run-level governance only). Use for a vendor CLI that has a --print/pipe mode but doesn't speak ACP.

The two hard security facts to verify (they encode the #173 trust boundary):

  1. A non-Claude engine gets no FA host secrets — the engine subprocess env is built from nothing: only a minimal OS base allowlist (PATH, HOME, locale) plus the declared authEnv is added (spec 119, 2026-07-18). ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, GITHUB_TOKEN, ADMIN_API_KEY, and SESSION_SECRET are absent on both LocalRuntime and DockerRuntime because they are never copied in. Under DockerRuntime, authEnv values additionally arrive via the spawn env (name-only -e KEY docker flags — values not in argv). Smoke-test caveat:npm run diagnose:engine always uses LocalRuntime regardless of RUNTIME=docker — it does not exercise the DockerRuntime container env seam. It also reports anthropicStripped:true based on ENV:KEY=absent lines the engine emits; a real vendor CLI (codex, gemini) prints none, so the flag is true by absence of evidence, not confirmed measurement. The mock-based CI tests are the verifiable check (the mocks explicitly echo and assert env keys).
  2. Vendor creds declared in the profile's authEnv are delivered to the engine subprocess. The env-from-nothing model (fact 1) means only authEnv keys (plus the base allowlist) are present — no FA host vars leak alongside them. DockerRuntime path gap for reference shims: the adapters/codex-acp/ and adapters/gemini-acp/ shims ship as host files — the fa-runtime container image does not include them. The node /abs/path/adapters/… recipe therefore requires baking the adapter into the image or bind-mounting it before using DockerRuntime. See docs/ENGINES.md §2.2 for options. RUNTIME=local also works (env-from-nothing isolation still holds) but runs the vendor CLI unsandboxed on the FA host.

2. Engine cookbook — run a loop with each engine

Authoritative drive paths live in docs/ENGINES.md (Engine HowTos) (per-vendor cmd/args/auth + the full live-run playbook). This section is the fast copy-paste version. If the two ever disagree, ENGINES.md wins — update this to match.

The honest state (read once): FA's client side — ACP handshake, credential isolation, routing, smoke test — is built and stable. The vendor adapter (the cmd) is what varies, and most vendor --acp flags are experimental or community-maintained (see each caveat). When a vendor CLI has no ACP server flag yet, you wrap it in a thin ACP shim (stdin JSON-RPC → the vendor's REST/CLI). FA does not ship vendor adapters — it's the neutral client.

2.0 The loop, once — reuse for every engine below

Every engine follows the same three steps; only the profile changes. Set the profile, point a test project at the engine id, submit a feature, watch it run:

bash
# 1) Declare the engine (paste ONE recipe's JSON from §2.2+ here), then restart FA.
export FA_ENGINE_PROFILES='[ <profile-from-below> ]'
#    …restart FA…  then confirm it registered:
curl -s localhost:3100/api/engines            # → engines[] must include your id

# 1a) SMOKE-TEST before trusting it (handshake + Anthropic-cred isolation, one command):
npm run diagnose:engine -- <engine-id>        # expect PASS; anthropicStripped:true for non-Claude

# 2) A throwaway project routed to that engine:
curl -sX POST localhost:3100/api/projects -H 'Content-Type: application/json' -d '{
  "name":"vendor-validation","repo_url":"https://github.com/you/test-repo.git",
  "autonomy_mode":"full_auto","engine":"<engine-id>" }'          # save api_key → PROJECT_KEY

# 3) A minimal feature = the loop:
curl -sX POST localhost:3100/api/features -H "Authorization: Bearer $PROJECT_KEY" \
  -H 'Content-Type: application/json' -d '{
  "title":"vendor-validation: hello",
  "description":"Add hello.txt containing the string VENDOR_VALIDATED." }' # save id → FEATURE_ID

# watch: http://localhost:3100/logs.html?id=<FEATURE_ID>
# expect: pending → queued → in_progress → implemented → draft PR with hello.txt

To route only one role (e.g. author=vendor, reviewer=Claude) instead of the whole run, use engine_routing_policy (§3) rather than the project engine field.

2.1 Claude — built-in claude-code (default, no profile)

  • api: ANTHROPIC_API_KEY (console.anthropic.com → API Keys), FA_AGENT_AUTH=api.
  • oauth: sign in the Claude CLI on the host so ~/.claude holds creds (your Max/Pro pool), FA_AGENT_AUTH=oauth, FA_AGENT_OAUTH_DIR=$HOME/.claude. Leave ANTHROPIC_API_KEY unset — an empty value poisons oauth.
  • Smoke: npm run diagnose:engine -- claude-codePASS, PONG, anthropicStripped: n/a (Claude is meant to hold Anthropic creds).

2.2 Gemini (Google) — reference ACP shim (adapters/gemini-acp/)

Turnkey path (Shape B, spec 117 inc-1). The Gemini CLI does not natively speak FA's ACP protocol; the reference shim at adapters/gemini-acp/index.mjs wraps gemini -p and is CI-verified.

Install: npm install -g @google/gemini-cli (tested ≥ 1.5) → confirms gemini on PATH.
Key: Google AI Studio (aistudio.google.com) → Get API keyGOOGLE_API_KEY.

json
{"id":"gemini","cmd":"node","args":["/abs/path/to/adapters/gemini-acp/index.mjs"],
 "authEnv":{"GOOGLE_API_KEY":"AIza..."}}

Vertex (service account) instead of API key: "authEnv":{"GOOGLE_APPLICATION_CREDENTIALS":"/run/secrets/gcp-sa.json","GOOGLE_CLOUD_PROJECT":"…","GOOGLE_CLOUD_LOCATION":"us-central1"}.

Smoke → anthropicStripped:true, authEnv keys: [GOOGLE_API_KEY].
See adapters/gemini-acp/README.md for full setup and live-run validation steps.

2.3 OpenAI Codex — reference ACP shim (adapters/codex-acp/)

Turnkey path (Shape B, spec 117 inc-2). The Codex CLI does not natively speak FA's ACP protocol; the reference shim at adapters/codex-acp/index.mjs wraps codex exec and is CI-verified.

Install: npm install -g @openai/codex (tested ≥ 0.1) → confirms codex on PATH.
Key: platform.openai.com → API keys → Create new secret keyOPENAI_API_KEY.

json
{"id":"codex","cmd":"node","args":["/abs/path/to/adapters/codex-acp/index.mjs"],
 "authEnv":{"OPENAI_API_KEY":"sk-proj-..."}}

Org-scoped: add "OPENAI_ORG":"org-..." to authEnv.

Smoke → anthropicStripped:true, authEnv keys: [OPENAI_API_KEY] (see smoke-test caveats in §1 — for real Codex CLI the flag is inferred, not measured; CI mocks provide the verifiable check).

DockerRuntime gap: under the default RUNTIME=docker the shim path is not in the container image — bake it in or bind-mount it before using DockerRuntime. RUNTIME=local also works (env-from-nothing isolation holds) but runs the vendor CLI unsandboxed on the FA host. See docs/ENGINES.md §2.2 for options and adapters/codex-acp/README.md for full setup.

2.4 GitHub Copilot — gh copilot --acp

Drives the local gh-copilot extension (gh extension install github/gh-copilot), not the cloud agent. Token needs the copilot scope.

json
{"id":"copilot","cmd":"gh","args":["copilot","--acp"],"authEnv":{"GH_TOKEN":"gho_..."}}

Caveat (important): gh copilot --acp is a proposed flag — not in the stable gh-copilot release as of 2026-07. Watch github/gh-copilot release notes; until it ships, this recipe won't run without a shim. The cloud Copilot agent is unsuitable for FA (no per-run isolation, no headless mode).

2.5 Local model — Ollama / vLLM (self-hosted, no cloud key)

Both expose an OpenAI-compatible HTTP API locally, so drive them with an OpenAI-compatible adapter/ shim pointed at the local endpoint. Network matters: the sandbox must reach the host server — set FA_RUNTIME_NETWORK to a network that resolves your Ollama/vLLM host (default bridge; none would block it; use host/a named network as your setup requires).

json
{"id":"local-llama","cmd":"acp-openai-shim","args":["--model","llama3"],
 "authEnv":{"OPENAI_BASE_URL":"http://host.docker.internal:11434/v1","OPENAI_API_KEY":"ollama"}}

(vLLM: point OPENAI_BASE_URL at http://<vllm-host>:8000/v1.) OPENAI_API_KEY is a throwaway placeholder these servers ignore. Smoke → PASS, anthropicStripped:true. If the smoke test can't reach the server, it's almost always FA_RUNTIME_NETWORK.

2.6 OpenHands — run the OpenHands agent as a backend

OpenHands (Agent Canvas) enables ACP by default and hosts an agent loop; FA drives it as any other ACP engine. Point cmd at your OpenHands ACP server entrypoint (exact binary/args depend on your OpenHands install/version — see OpenHands docs; the roadmap tracks pinning it like openhands-sdk).

json
{"id":"openhands","cmd":"openhands-acp","transport":"acp","authEnv":{"LLM_API_KEY":"...","LLM_MODEL":"anthropic/claude-…"}}

OpenHands runs its own model underneath — its creds go in authEnv (whatever your OpenHands config expects, e.g. LLM_API_KEY/LLM_MODEL), and FA still strips its own ANTHROPIC_* from the subprocess. Caveat: the cmd/authEnv here are illustrative — confirm your OpenHands version's ACP entrypoint and env before relying on it. Smoke → PASS, anthropicStripped:true.

The generic built-in acp engine (no profile): instead of a profile you can set FA_ACP_AGENT_CMD (+ FA_ACP_AGENT_ARGS) and use engine id acp. Handy for a single ad-hoc ACP agent; profiles are better when you run several.

2.7 Engine discovery & the "typo routes to default" trap

  • List registered engines: GET /api/engines{ "engines": ["claude-code","acp","gemini",…], "default": "claude-code" }. Confirm your new id appears here after restart — if it doesn't, the profile didn't parse.
  • Trap: if you route to an engine id that isn't registered (a typo), FA silently falls back to the default and only appends a note to the run's routing source. So a wrong id looks like it worked. Always smoke-test the exact id you'll route to, and after a run, check the routing source names the engine you intended — not claude-code (fallback).

Not built yet (spec 115): the admin-gated, secret-redacting GET /api/admin/engines (returns id/transport/builtIn/isDefault, never cmd/authEnv) and the dashboard engine picker. Until it lands, use GET /api/engines (ids only) + the smoke test.


3. Engine routing QA (Tier 0 declarative vs Tier 1 classifier)

Route which engine plays each role (author, reviewer, fixer) — set engine_routing_policy (JSON) on a project (default for its features) or per feature (override).

Feature categories (Tier-1 classifier output): small-patch, large-integration, bugfix, refactor, other.

Tier 0 — declarative (a role = a fixed engine id):

json
{"author":"gemini","reviewer":"claude-code","fixer":"codex"}

QA: submit a feature, confirm the author run used gemini, the review used claude-code. Tier 0 always beats Tier 1.

Tier 1 — classifier (route by category):

json
{"author":{"classify":true,"category":{"large-integration":"claude-code","small-patch":"gemini"}}}

QA: submit one small-patch-ish feature and one large-integration feature; confirm each was routed to the mapped engine. The classifier runs one short headless call and fails closed to other on any error — so an unmapped/failed classification lands on the role's default, not a crash.

Resolver precedence to keep in mind (highest first): feature static engine field → feature Tier-0 → project Tier-0 → project static field → Tier-1 classifier map → global default (claude-code).


4. @weftra PR-comment mentions (spec 113, phase 1)

What phase 1 does: detect a mention of the bot handle in a PR comment, authorize the commenter, and notifyno execution (mention → /revise is phase 2, not built). QA is about detection + authorization, because the whole point is that an untrusted comment body cannot make FA act.

The handle is configurable: FA_BOT_HANDLE (default weftra). Detection is @<handle>, case-insensitive.

Authorization is by the comment author's VCS identity — never the comment text. For a mention to be authorized, the commenter must be a registered FA approver on that project, matched by VCS handle:

  1. Create/curate an FA user whose vcs_login equals the commenter's GitHub/GitLab login (dashboard user form → VCS login field).
  2. Add that user as an approver on the test project. (getProjectApproverByVcsHandle joins users.vcs_login + project_approvers.)

Operator prerequisites: a VCS token so FA can read comments — GITHUB_TOKEN (and/or GITLAB_TOKEN+GITLAB_HOST, BITBUCKET_TOKEN+BITBUCKET_USERNAME). For push delivery, FA_INBOUND_TRIGGERS_ENABLED (default on) + a GitHub webhook to POST /…/triggers/github for issue_comment events. Otherwise FA picks up mentions on the polling path during PR-review processing.

Provider coverage: the inbound webhook issue_comment interceptor is GitHub-only today. GitLab/Bitbucket mentions surface via the polling path only.

Test matrix:

CaseSetupActionExpected
Authorized mentionCommenter is an approver on the project (vcs_login mapped)Comment @weftra please look on the feature's PRmention_authorized run_event; pending-mention count ++; notification fires (role po); no /revise
Unauthorized mentionCommenter is NOT an approverSame comment from a random accountmention_unauthorized run_event; no notification; no action
Bot's own commentFA's own bot account commentsIgnored (isBot) — no event
Duplicate deliverySame comment id delivered twice (webhook + poll)Processed at most once (dedup by comment id)
Wrong handleComment mentions @someoneelseNo mention detected

Security assertion: put an instruction in the comment body ("delete the branch", "run env"). Expect FA to record/notify only — the body is never executed. That's the phase-1 contract.


5. Core platform use-case matrix

The lifecycle checks every install should pass. Use a disposable test project.

5.1 Enrollment & autonomy modes

ModeSubmit a featureExpected transition
full_autoPOST /api/featurespending → queued immediately → in_progress → implemented
auto_safesamepending → analyzing → (clarification if needed) → queued → …
po_approvalsamepending → awaiting_approval (waits for approver) → analyzing → …

5.2 Clarifications

Submit a deliberately under-specified feature in auto_safe. Expect status clarification_needed with questions; answer via the dashboard; expect it to resume to queued.

5.3 Standard implementation → PR

Expect: clone → branch feature/<slug>-<id> → agent run → test_command → commit → push → draft PR. Verify the PR exists, is draft, targets the right base (respect a per-feature base_branch override).

5.4 Spec-kit pipeline

On a spec-kit-enabled project, submit with use_spec_kit:true. Expect /speckit.specify → clarify → plan → tasks → analyze → implement, pausing at clarification gates.

5.5 Verbatim spec handoff

Submit with spec_content + spec_path. Expect the branch file at spec_path to be byte-identical to what you sent (no regeneration; use_spec_kit ignored). Diff to confirm.

5.6 PR revision (/revise)

On an implemented feature with a PR, add review comments, POST /api/features/:id/revise. Expect status revising, the agent applies feedback, pushes the same branch (no new PR), returns to implemented. "No comments found" reverts to implemented.

5.7 Merge polling & manual PR

Merge the PR on GitHub; within PR_CHECK_INTERVAL_MS expect auto-transition to merged. For a feature whose auto-PR failed (branch pushed, no pr_url), POST /api/features/:id/create-pr opens the draft PR.

5.8 Cancel / retry

Cancel an in-flight feature → cancelled, workspace wiped, re-runnable via retry (no retry_count bump). Cancelling a revising feature reverts to implemented (PR left intact).

5.9 Data provisioning & workspace confinement

Declare data_dirs with a missing source → expect the run to fail up front with an actionable error (not silently reach outside the workspace). With a valid source, expect a read-only symlink in the workspace, added to .git/info/exclude, and setup_command run.

5.10 Notifications & webhooks

Configure a channel (Telegram/Discord/Slack/Email) with notify rules; drive a status change; confirm delivery to the right audience (po/submitter/all). Set callback_url (feature overrides project); confirm the POST is HMAC-SHA256 signed with the project key even for a per-feature URL.

5.11 Login methods (see also the login-setup flow)

  • API key — always on.
  • Local email+password — needs SESSION_SECRET; password is set via the reset-email flow, which needs SMTP (being made portal-configurable — spec 116).
  • GoogleGOOGLE_CLIENT_ID/SECRET. OIDC/SSOOIDC_ISSUER/CLIENT_ID/SECRET. Check GET /auth/status reflects which are enabled. Accounts are invite-only (admin pre-provisions).

5.12 Fleet fan-out & campaigns

Fan one spec across N test projects; confirm N features/PRs and a campaign rollup. (See the Fleet view and the campaigns CTA in the dashboard header.)


6. Security / trust-boundary QA (do these every release)

These directly exercise the #173 lessons. Each expects a negative result you must assert against real output, not a label.

CheckHowExpected
Non-Claude engine has no Anthropic credsnpm run diagnose:engine -- <vendor-id>anthropicStripped: true; FA secrets absent (from-nothing env): YES ✓
Subprocess env built from nothing (spec 119)npm run diagnose:engine -- <vendor-id>Child env contains ONLY base allowlist + authEnv; GITHUB_TOKEN/ADMIN_API_KEY/SESSION_SECRET absent on every runtime
Vendor secret values never in docker run argvReview engineAuthArgs output or unit tests-e KEY name-only flags; no KEY=VALUE form in /proc
Vendor secret not leaked to responses/logsInspect the smoke-test result + run logsauthEnv keys may appear; values never do
Execution only in the sandboxConfirm RUNTIME=docker; grep the runagent/engine spawn goes through the runtime, not host spawn
Project key can't execute host actionsTry a project-key call to any admin/execution route401/403 — authz matches blast radius
Mention body never executed§4 security assertiondetect/notify only
Engine profile secrets not in GET /api/enginescurl localhost:3100/api/enginesids + default only; no cmd/args/authEnv

7. Environment variable reference (QA-relevant)

Engines / transport / routing

  • FA_ENGINE_PROFILES — JSON array of engine profiles (default []).
  • FA_ACP_AGENT_CMD / FA_ACP_AGENT_ARGS — command/args for the built-in acp engine.
  • RUNTIMEdocker (default) | local. FA_RUNTIME_IMAGE (default fa-runtime:latest).
  • FA_RUNTIME_NETWORKbridge (default) | none | network name.

Claude / agent auth

  • FA_AGENT_AUTHapi (default) | oauth. FA_AGENT_OAUTH_DIR (default $HOME/.claude).
  • ANTHROPIC_API_KEY (api mode; leave unset for oauth). FA_AGENT_MAX_RUN_MS (oauth preflight, 4h).

Permission gate (ACP transport only)

  • FA_PERMISSION_POLICYallow_all (default) | deny_all | ask | governed.
  • FA_PERMISSION_TIMEOUT_MS — default 30 min.

Mentions / VCS

  • FA_BOT_HANDLE — mention handle (default weftra). FA_INBOUND_TRIGGERS_ENABLED (default on).
  • GITHUB_TOKEN; GITLAB_TOKEN+GITLAB_HOST; BITBUCKET_TOKEN+BITBUCKET_USERNAME.
  • PR_CHECK_INTERVAL_MS — merge/review poll cadence (default 10 min).

Budgets (all engines)

  • FA_MAX_BUDGET_TOKENS, FA_MAX_BUDGET_SECONDS (uncapped by default), FA_BUDGET_WARN_PCT (80).

8. A minimal end-to-end QA run (copy/paste checklist)

  1. npm run build && npx vitest run → green.
  2. Bring up FA (RUNTIME=docker), GET /health → ok.
  3. npm run diagnose:engine -- claude-code under both FA_AGENT_AUTH=api and oauth → PASS.
  4. Wire one vendor engine (§2.2/2.3/2.4), restart, confirm it's in GET /api/engines, npm run diagnose:engine -- <id> → PASS + anthropicStripped:true.
  5. Enroll a disposable project; run one feature per autonomy mode (§5.1) to implemented + draft PR.
  6. Route the author role to the vendor engine (§3 Tier 0); confirm the run used it (not a fallback).
  7. Merge the PR → confirm auto merged.
  8. Mentions (§4): authorized comment → notify; unauthorized → silent; instruction-in-body → not executed.
  9. Security sweep (§6) — all negatives asserted against real output.
  10. Record results, noting any skipped checks (missing vendor key, Docker off) as skipped, not passed.

Keep this guide honest about BUILT vs SPEC-ONLY. As of writing: engine profiles, ACP + headless transports, Tier-0/Tier-1 routing, per-engine auth isolation, GET /api/engines, the engine smoke test, and spec-113 phase-1 mentions are built. The admin engine list + dashboard picker (spec 115) and mention execution (phase 2) are not — don't QA them until they land.

Released under the MIT License.