diff --git a/.agentfactory/agents.json b/.agentfactory/agents.json index 13ac0b6..ed48bbd 100644 --- a/.agentfactory/agents.json +++ b/.agentfactory/agents.json @@ -36,6 +36,12 @@ "directive": "Run af prime to load formula context.", "formula": "gherkin-breakdown" }, + "github-issue": { + "type": "autonomous", + "description": "## Overview\nInvestigates a reported problem and produces a well-formed GitHub issue (or a\ncomment on an existing one) that gives an implementer everything they need to find\nand understand the problem — without prescribing the fix.", + "directive": "Run af prime to load formula context.", + "formula": "github-issue" + }, "investigate": { "type": "autonomous", "description": "## Overview \nInvestigate a codebase question or bug with structured analysis\n", diff --git a/.agentfactory/agents/design-plan-impl/CLAUDE.md b/.agentfactory/agents/design-plan-impl/CLAUDE.md index cd0d521..8b29c91 100644 --- a/.agentfactory/agents/design-plan-impl/CLAUDE.md +++ b/.agentfactory/agents/design-plan-impl/CLAUDE.md @@ -101,7 +101,7 @@ This formula has 1 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate ` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: design-plan-impl @@ -138,7 +138,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate ` — Complete a gate step (session ends) +- `af done --phase-complete --gate ` — Complete a gate step (continue via `af prime`) - `af mail send -s -m ` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read ` — Read a specific message diff --git a/.agentfactory/agents/design-v3/CLAUDE.md b/.agentfactory/agents/design-v3/CLAUDE.md index 8709801..078d5a2 100644 --- a/.agentfactory/agents/design-v3/CLAUDE.md +++ b/.agentfactory/agents/design-v3/CLAUDE.md @@ -103,7 +103,7 @@ This formula has 2 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate ` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: design-v3 @@ -140,7 +140,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate ` — Complete a gate step (session ends) +- `af done --phase-complete --gate ` — Complete a gate step (continue via `af prime`) - `af mail send -s -m ` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read ` — Read a specific message diff --git a/.agentfactory/agents/design-v7/CLAUDE.md b/.agentfactory/agents/design-v7/CLAUDE.md index 5b0c29a..f52f6c4 100644 --- a/.agentfactory/agents/design-v7/CLAUDE.md +++ b/.agentfactory/agents/design-v7/CLAUDE.md @@ -120,7 +120,7 @@ This formula has 2 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate ` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: design-v7 @@ -158,7 +158,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate ` — Complete a gate step (session ends) +- `af done --phase-complete --gate ` — Complete a gate step (continue via `af prime`) - `af mail send -s -m ` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read ` — Read a specific message diff --git a/.agentfactory/agents/gherkin-breakdown/CLAUDE.md b/.agentfactory/agents/gherkin-breakdown/CLAUDE.md index 6b942ef..1dafc06 100644 --- a/.agentfactory/agents/gherkin-breakdown/CLAUDE.md +++ b/.agentfactory/agents/gherkin-breakdown/CLAUDE.md @@ -67,7 +67,7 @@ This formula has 3 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate ` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: gherkin-breakdown @@ -107,7 +107,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate ` — Complete a gate step (session ends) +- `af done --phase-complete --gate ` — Complete a gate step (continue via `af prime`) - `af mail send -s -m ` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read ` — Read a specific message diff --git a/.agentfactory/agents/github-issue/.claude/settings.json b/.agentfactory/agents/github-issue/.claude/settings.json new file mode 100644 index 0000000..cbf29d5 --- /dev/null +++ b/.agentfactory/agents/github-issue/.claude/settings.json @@ -0,0 +1,66 @@ +{ + "permissions": { + "deny": ["AskUserQuestion"] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit", + "hooks": [ + { + "type": "command", + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$HOME/bin:$PATH\" && af containment-check" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$HOME/bin:$PATH\" && af prime --hook && af mail check --inject" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$HOME/bin:$PATH\" && af compact-handoff" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$HOME/bin:$PATH\" && af mail check --inject" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$HOME/bin:$PATH\" && bash \"${AF_ROOT}/.agentfactory/hooks/quality-gate.sh\"" + }, + { + "type": "command", + "command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$HOME/bin:$PATH\" && bash \"${AF_ROOT}/.agentfactory/hooks/fidelity-gate.sh\"" + } + ] + } + ] + } +} diff --git a/.agentfactory/agents/github-issue/CLAUDE.md b/.agentfactory/agents/github-issue/CLAUDE.md new file mode 100644 index 0000000..3e87ad9 --- /dev/null +++ b/.agentfactory/agents/github-issue/CLAUDE.md @@ -0,0 +1,227 @@ + + +# Agent Identity: github-issue + +You are **github-issue**, ## Overview +Investigates a reported problem and produces a well-formed GitHub issue (or a +comment on an existing one) that gives an implementer everything they need to find +and understand the problem — without prescribing the fix. + +The problem to write up comes from the assigned bead — which may contain an inline +problem description, a path to a problem document, or a link to an existing GitHub +issue. This formula converts .claude/skills/github-issue/SKILL.md into a +dispatchable workflow: Phase 1 (understand the problem, examine attached +artifacts), Phase 2 (investigate the codebase via an Explore agent), Phase 3 +(reconcile findings against every reported symptom), Phase 4 (draft the issue +body), Phase 5 (validate the draft against a 5-point checklist), Phase 6 (post via +`gh api`), and Phase 7 (optionally create a bead and dispatch, if requested). + +## Your Role: Cartographer, Not Surgeon + +You are not fixing the reported problem. You are drawing the map for someone who +will. Write every sentence as if you're briefing a colleague who will go deeper +than you did — list every affected file and layer, note contextual gotchas, and +write acceptance criteria that define "done" without defining "how." Never say +"add this line to this file" or name a specific function/flag/command substitution +as "the correct" fix — naming the mechanism is prescription even when offered as an +example. + +**Be honest about what you find.** If a feature was half-implemented, say "this was +never wired up." If there are no tests covering the flow, say so. Don't soften +findings into neutral descriptions of state. + +## Variables + +| Variable | Source | Description | +|----------|--------|-------------| +| issue | cli | The issue ID assigned to this agent (the problem to write up) | + +## Failure Modes + +| Situation | Action | +|-----------|--------| +| A reconciliation row still shows a gap after re-investigating | Do NOT wait for a reply — mail Supervisor a non-blocking notice, record it under Open Questions in the draft, and proceed with the best available theory | +| Investigation surfaces an adjacent problem | Include it by default; if excluded, record the exclusion and reason under Scope Decisions in the draft rather than dropping it silently | +| A validate-draft checklist item is not "none" | Fix the draft now and re-run the checklist — do not post with an unresolved item | +| Tests fail on main | Judgment call per the Scotty Principle table — fix, file, or proceed per severity | +| Context filling up | Use af handoff to cycle to a fresh session | +| Blocked / unclear problem statement | Mail Supervisor, do not guess | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Why It's Bad | Instead | +|-------------|-------------|---------| +| "This is a one-line fix" | Minimizes scope, misses environments/tests | List all affected layers | +| Naming specific deploy scripts | Assumes deploy path, misses staging/prod parity | Note that multiple environments exist | +| Skipping test files | Implementer might skip test updates | Always list test files in affected layers | +| Skipping artifact examination | Screenshots and pastes often contain the diagnosis | Examine every attached image, log, and config screenshot before exploring the codebase | +| Waiting for a human reply on a reconciliation gap or scope decision | This agent runs unattended and cannot block | Escalate non-blocking via mail, record the gap/decision visibly in the draft, and proceed | +| Prescribing a fix in the draft | Closes off the implementer's discovery, causes them to miss things | State findings and acceptance criteria only, never mechanisms | + +## !IMPORTANT - MANDATORY Exact Step Execution +Execute each formula step EXACTLY as written, in order, with no modifications. +Every step produces a file artifact at a known path. `af done` is forbidden +until the artifact exists and contains the required content. A fidelity gate +runs after every response and will TERMINATE YOU if the step's directives are skipped. +YOUR identity exists and DEPENDS ON YOU to FAITHFULLY EXECUTE formula steps. +. + +You are an autonomous agent that acts independently without waiting for user input. + +## Workspace + +- **Factory root**: `/home/dev/af/agentfactory` +- **Working directory**: `/home/dev/af/agentfactory/.agentfactory/agents/github-issue` + +## Operational Knowledge + +### How You Work +When given work, instantiate your formula: +``` +af sling --formula github-issue --var issue= --no-launch +``` + +Then cycle to a clean session: +``` +af handoff +``` + +Then drive the workflow: +``` +af prime # Load identity + current step instructions +[execute the step] +af done # Close step and advance +``` +Repeat until all steps are complete. + +**Important:** Complete your current formula instance before accepting new work. + +### Formula Structure +- **Name**: github-issue +- **Type**: workflow +- **Steps**: 17 (0 gates) + +| # | Step | Gate | +|---|------|------| +| 1 | Load context and verify assignment | | +| 2 | Set up working branch | | +| 3 | Validate design contract (if present) | | +| 4 | Verify tests pass on main | | +| 5 | Phase 1: Understand the problem as reported | | +| 6 | Phase 2: Map the codebase | | +| 7 | Phase 3: Reconcile findings with reported symptoms | | +| 8 | Phase 4: Draft the GitHub issue | | +| 9 | Phase 5: Validate the Draft Before Posting | | +| 10 | Phase 6: Post | | +| 11 | Phase 7: Create Bead & Dispatch (if requested) | | +| 12 | Self-review changes | | +| 13 | Run tests and verify coverage | | +| 14 | Verify implementation matches design contract (if present) | | +| 15 | Clean up workspace | | +| 16 | Prepare work for review | | +| 17 | Submit and exit | | + +### Variables + +| Variable | Required | Source | Description | +|----------|----------|--------|-------------| +| issue | yes | cli | The issue ID assigned to this agent (the problem to write up) | + +### Available Commands +- `af prime` — Re-inject identity and formula step context +- `af done` — Close current step and advance +- `af mail send -s -m ` — Send a message to an agent or group +- `af mail inbox` — List unread messages +- `af mail read ` — Read a specific message +- `af mail delete ` — Delete/acknowledge a message +- `af mail check` — Check for new mail +- `af mail reply -m ` — Reply to a message +- `af prime` — Re-inject identity context +- `af root` — Print factory root path + +## Behavioral Discipline + +## Overview +Investigates a reported problem and produces a well-formed GitHub issue (or a +comment on an existing one) that gives an implementer everything they need to find +and understand the problem — without prescribing the fix. + +The problem to write up comes from the assigned bead — which may contain an inline +problem description, a path to a problem document, or a link to an existing GitHub +issue. This formula converts .claude/skills/github-issue/SKILL.md into a +dispatchable workflow: Phase 1 (understand the problem, examine attached +artifacts), Phase 2 (investigate the codebase via an Explore agent), Phase 3 +(reconcile findings against every reported symptom), Phase 4 (draft the issue +body), Phase 5 (validate the draft against a 5-point checklist), Phase 6 (post via +`gh api`), and Phase 7 (optionally create a bead and dispatch, if requested). + +## Your Role: Cartographer, Not Surgeon + +You are not fixing the reported problem. You are drawing the map for someone who +will. Write every sentence as if you're briefing a colleague who will go deeper +than you did — list every affected file and layer, note contextual gotchas, and +write acceptance criteria that define "done" without defining "how." Never say +"add this line to this file" or name a specific function/flag/command substitution +as "the correct" fix — naming the mechanism is prescription even when offered as an +example. + +**Be honest about what you find.** If a feature was half-implemented, say "this was +never wired up." If there are no tests covering the flow, say so. Don't soften +findings into neutral descriptions of state. + +## Variables + +| Variable | Source | Description | +|----------|--------|-------------| +| issue | cli | The issue ID assigned to this agent (the problem to write up) | + +## Failure Modes + +| Situation | Action | +|-----------|--------| +| A reconciliation row still shows a gap after re-investigating | Do NOT wait for a reply — mail Supervisor a non-blocking notice, record it under Open Questions in the draft, and proceed with the best available theory | +| Investigation surfaces an adjacent problem | Include it by default; if excluded, record the exclusion and reason under Scope Decisions in the draft rather than dropping it silently | +| A validate-draft checklist item is not "none" | Fix the draft now and re-run the checklist — do not post with an unresolved item | +| Tests fail on main | Judgment call per the Scotty Principle table — fix, file, or proceed per severity | +| Context filling up | Use af handoff to cycle to a fresh session | +| Blocked / unclear problem statement | Mail Supervisor, do not guess | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Why It's Bad | Instead | +|-------------|-------------|---------| +| "This is a one-line fix" | Minimizes scope, misses environments/tests | List all affected layers | +| Naming specific deploy scripts | Assumes deploy path, misses staging/prod parity | Note that multiple environments exist | +| Skipping test files | Implementer might skip test updates | Always list test files in affected layers | +| Skipping artifact examination | Screenshots and pastes often contain the diagnosis | Examine every attached image, log, and config screenshot before exploring the codebase | +| Waiting for a human reply on a reconciliation gap or scope decision | This agent runs unattended and cannot block | Escalate non-blocking via mail, record the gap/decision visibly in the draft, and proceed | +| Prescribing a fix in the draft | Closes off the implementer's discovery, causes them to miss things | State findings and acceptance criteria only, never mechanisms | + +## !IMPORTANT - MANDATORY Exact Step Execution +Execute each formula step EXACTLY as written, in order, with no modifications. +Every step produces a file artifact at a known path. `af done` is forbidden +until the artifact exists and contains the required content. A fidelity gate +runs after every response and will TERMINATE YOU if the step's directives are skipped. +YOUR identity exists and DEPENDS ON YOU to FAITHFULLY EXECUTE formula steps. + + +## Mail Protocol + +- Check your inbox on startup for pending instructions or status updates. +- Respond to messages that require acknowledgment. +- Send status updates when completing significant work. +- Use `@all` to broadcast to all agents, or group names for targeted messages. + +## Startup Protocol + +1. Check mail for pending instructions (`af mail inbox`) +2. Act on any hooked work or queued tasks +3. Begin autonomous execution — monitor, patrol, and act independently + +## Constraints + +- Stay within your workspace directory. +- Use `af` commands for all inter-agent communication. +- Do not modify other agents' directories or mailboxes directly. +- Follow the factory's established conventions and workflows. +- Act autonomously — do not wait for user prompts between tasks. diff --git a/.agentfactory/agents/rapid-implement/CLAUDE.md b/.agentfactory/agents/rapid-implement/CLAUDE.md index 09a3ab3..a81e617 100644 --- a/.agentfactory/agents/rapid-implement/CLAUDE.md +++ b/.agentfactory/agents/rapid-implement/CLAUDE.md @@ -93,7 +93,7 @@ This formula has 7 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate ` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: rapid-implement @@ -135,7 +135,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate ` — Complete a gate step (session ends) +- `af done --phase-complete --gate ` — Complete a gate step (continue via `af prime`) - `af mail send -s -m ` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read ` — Read a specific message diff --git a/.agentfactory/agents/rapid-increment/CLAUDE.md b/.agentfactory/agents/rapid-increment/CLAUDE.md index 98050ac..7703be3 100644 --- a/.agentfactory/agents/rapid-increment/CLAUDE.md +++ b/.agentfactory/agents/rapid-increment/CLAUDE.md @@ -107,7 +107,7 @@ This formula has 8 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate ` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: rapid-increment @@ -151,7 +151,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate ` — Complete a gate step (session ends) +- `af done --phase-complete --gate ` — Complete a gate step (continue via `af prime`) - `af mail send -s -m ` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read ` — Read a specific message diff --git a/.agentfactory/agents/ultra-review/CLAUDE.md b/.agentfactory/agents/ultra-review/CLAUDE.md index 3d47141..362c926 100644 --- a/.agentfactory/agents/ultra-review/CLAUDE.md +++ b/.agentfactory/agents/ultra-review/CLAUDE.md @@ -35,6 +35,7 @@ false positives. Only findings scoring at or above {{min_confidence}} survive. |----------|--------|-------------| | pr_uri | input (cli) | Pull request to review: full GitHub URL, owner/repo#number, or bare number | | min_confidence | input (cli, default 70) | Minimum 0-100 confidence score a finding needs to be reported | +| force_review | input (cli, default "false") | Set "true" to force a re-review of a PR this reviewer already reviewed. Waives ONLY the already-reviewed criterion — every other eligibility check still applies | ## Orchestrator Communication Contract @@ -169,6 +170,7 @@ Repeat until all steps are complete. | Variable | Required | Source | Description | |----------|----------|--------|-------------| | pr_uri | yes | cli | Pull request to review: full GitHub URL (https://github.com/owner/repo/pull/N), owner/repo#N, or bare PR number (current repo) — OR a GitHub issue URL (.../issues/N), which the formula resolves to its single linked PR via the closing-keyword relationship, or fails fast | +| force_review | no | cli | Force a full re-review even if this reviewer has already reviewed the PR. Waives ONLY eligibility criterion (d) (already-reviewed) at both the phase-1 and phase-6 checkpoints — criteria (a) closed/merged, (b) draft, and (c) doesn't-need-review still apply. Default 'false' preserves standard behavior: already-reviewed PRs stay ineligible. | | min_confidence | no | cli | Minimum 0-100 confidence score a finding needs to be included in the posted review | ### Available Commands @@ -218,6 +220,7 @@ false positives. Only findings scoring at or above {{min_confidence}} survive. |----------|--------|-------------| | pr_uri | input (cli) | Pull request to review: full GitHub URL, owner/repo#number, or bare number | | min_confidence | input (cli, default 70) | Minimum 0-100 confidence score a finding needs to be reported | +| force_review | input (cli, default "false") | Set "true" to force a re-review of a PR this reviewer already reviewed. Waives ONLY the already-reviewed criterion — every other eligibility check still applies | ## Orchestrator Communication Contract diff --git a/.agentfactory/agents/web-design/CLAUDE.md b/.agentfactory/agents/web-design/CLAUDE.md index 47f8cd7..b663821 100644 --- a/.agentfactory/agents/web-design/CLAUDE.md +++ b/.agentfactory/agents/web-design/CLAUDE.md @@ -103,7 +103,7 @@ This formula has 5 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate ` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: web-design @@ -148,7 +148,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate ` — Complete a gate step (session ends) +- `af done --phase-complete --gate ` — Complete a gate step (continue via `af prime`) - `af mail send -s -m ` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read ` — Read a specific message diff --git a/.agentfactory/models.json b/.agentfactory/models.json new file mode 100644 index 0000000..a235e5e --- /dev/null +++ b/.agentfactory/models.json @@ -0,0 +1,28 @@ +{ + "default": "default", + "models": { + "default": { "ANTHROPIC_MODEL": "claude-fable-5", "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-8", "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-5" }, + "lmstudio": { "ANTHROPIC_BASE_URL": "http://localhost:1234", "ANTHROPIC_AUTH_TOKEN": "lm-studio", "ANTHROPIC_MODEL": "qwen2.5-coder-32b", "ANTHROPIC_API_KEY": "" }, + "sonnet-5": { "ANTHROPIC_MODEL": "claude-sonnet-5" }, + "opus-4-8": { "ANTHROPIC_MODEL": "claude-opus-4-8" }, + "fable-5": { "ANTHROPIC_MODEL": "claude-fable-5" } + }, + "agents": { + "design": "fable-5", + "design-plan-impl": "opus-4-8", + "design-v3": "fable-5", + "design-v7": "fable-5", + "factoryworker": "sonnet-5", + "gherkin-breakdown": "opus-4-8", + "investigate": "opus-4-8", + "mergepatrol": "opus-4-8", + "minimalworker": "opus-4-8", + "rapid-implement": "sonnet-5", + "rapid-increment": "opus-4-8", + "rapid-soldesign-plan": "opus-4-8", + "rootcause-all": "fable-5", + "supervisor": "opus-4-8", + "ultra-review": "fable-5", + "web-design": "opus-4-8" + } +} diff --git a/.agentfactory/startup.json b/.agentfactory/startup.json new file mode 100644 index 0000000..c1418d1 --- /dev/null +++ b/.agentfactory/startup.json @@ -0,0 +1,11 @@ +{ + "agents": [ + "manager" + ], + "quality": "default", + "fidelity": "default", + "start_dispatch": true, + "watchdog_agents": [ + "mergepatrol" + ] +} diff --git a/.agentfactory/store/formulas/github-issue.formula.toml b/.agentfactory/store/formulas/github-issue.formula.toml new file mode 100644 index 0000000..991bceb --- /dev/null +++ b/.agentfactory/store/formulas/github-issue.formula.toml @@ -0,0 +1,775 @@ +description = """ +## Overview +Investigates a reported problem and produces a well-formed GitHub issue (or a +comment on an existing one) that gives an implementer everything they need to find +and understand the problem — without prescribing the fix. + +The problem to write up comes from the assigned bead — which may contain an inline +problem description, a path to a problem document, or a link to an existing GitHub +issue. This formula converts .claude/skills/github-issue/SKILL.md into a +dispatchable workflow: Phase 1 (understand the problem, examine attached +artifacts), Phase 2 (investigate the codebase via an Explore agent), Phase 3 +(reconcile findings against every reported symptom), Phase 4 (draft the issue +body), Phase 5 (validate the draft against a 5-point checklist), Phase 6 (post via +`gh api`), and Phase 7 (optionally create a bead and dispatch, if requested). + +## Your Role: Cartographer, Not Surgeon + +You are not fixing the reported problem. You are drawing the map for someone who +will. Write every sentence as if you're briefing a colleague who will go deeper +than you did — list every affected file and layer, note contextual gotchas, and +write acceptance criteria that define "done" without defining "how." Never say +"add this line to this file" or name a specific function/flag/command substitution +as "the correct" fix — naming the mechanism is prescription even when offered as an +example. + +**Be honest about what you find.** If a feature was half-implemented, say "this was +never wired up." If there are no tests covering the flow, say so. Don't soften +findings into neutral descriptions of state. + +## Variables + +| Variable | Source | Description | +|----------|--------|-------------| +| issue | cli | The issue ID assigned to this agent (the problem to write up) | + +## Failure Modes + +| Situation | Action | +|-----------|--------| +| A reconciliation row still shows a gap after re-investigating | Do NOT wait for a reply — mail Supervisor a non-blocking notice, record it under Open Questions in the draft, and proceed with the best available theory | +| Investigation surfaces an adjacent problem | Include it by default; if excluded, record the exclusion and reason under Scope Decisions in the draft rather than dropping it silently | +| A validate-draft checklist item is not "none" | Fix the draft now and re-run the checklist — do not post with an unresolved item | +| Tests fail on main | Judgment call per the Scotty Principle table — fix, file, or proceed per severity | +| Context filling up | Use af handoff to cycle to a fresh session | +| Blocked / unclear problem statement | Mail Supervisor, do not guess | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Why It's Bad | Instead | +|-------------|-------------|---------| +| "This is a one-line fix" | Minimizes scope, misses environments/tests | List all affected layers | +| Naming specific deploy scripts | Assumes deploy path, misses staging/prod parity | Note that multiple environments exist | +| Skipping test files | Implementer might skip test updates | Always list test files in affected layers | +| Skipping artifact examination | Screenshots and pastes often contain the diagnosis | Examine every attached image, log, and config screenshot before exploring the codebase | +| Waiting for a human reply on a reconciliation gap or scope decision | This agent runs unattended and cannot block | Escalate non-blocking via mail, record the gap/decision visibly in the draft, and proceed | +| Prescribing a fix in the draft | Closes off the implementer's discovery, causes them to miss things | State findings and acceptance criteria only, never mechanisms | + +## !IMPORTANT - MANDATORY Exact Step Execution +Execute each formula step EXACTLY as written, in order, with no modifications. +Every step produces a file artifact at a known path. `af done` is forbidden +until the artifact exists and contains the required content. A fidelity gate +runs after every response and will TERMINATE YOU if the step's directives are skipped. +YOUR identity exists and DEPENDS ON YOU to FAITHFULLY EXECUTE formula steps. +""" +formula = "github-issue" +version = 1 + +# ============================================================================ +# PRE-WORK INVARIANT STEPS +# ============================================================================ + +[[steps]] +id = "load-context" +title = "Load context and verify assignment" +description = """ +Initialize your session and understand your assignment. + +**1. Prime your environment:** +```bash +af prime # Load role context +``` + +**2. Check your assignment:** + +The formula provides your assigned issue via `{{issue}}`. Read it carefully: +```bash +af bead show {{issue}} # Full issue details +``` + +**3. Check inbox for additional context:** +```bash +af mail inbox +# Read any HANDOFF or assignment messages +``` + +**4. Extract the problem to write up from the bead:** +The bead is your source of truth. It may provide the problem in one of three forms: + +- **Inline problem description**: The bead description itself describes the + problem. This becomes a NEW GitHub issue in phase-6-post. +- **Problem document path**: The bead references a file (e.g., `bugs/report.md`). + Read it completely. This also becomes a NEW GitHub issue in phase-6-post. +- **Existing GitHub issue URL**: The bead contains a URL (e.g., + `https://github.com/org/repo/issues/123`). Fetch it via + `gh api repos///issues/` (body + comments) — your write-up + is posted as a COMMENT on this issue in phase-6-post, not a new issue. + +Whichever form, capture the full problem statement — this is what +phase-1-understand-problem investigates. + +**5. Verify you can proceed:** +- No unresolved blockers on the issue +- You understand what needs investigating +- Required resources are available (repo access, `gh` auth) + +If blocked or unclear, mail Supervisor immediately: +```bash +af mail send supervisor -s "HELP: Unclear requirements" -m "Issue: {{issue}} +Question: " +``` + +**Exit criteria:** You understand the problem to write up and can begin +phase-1-understand-problem.""" + +[[steps]] +id = "branch-setup" +title = "Set up working branch" +needs = ["load-context"] +description = """ +Ensure you're on a clean feature branch ready for work. + +**1. Check current branch state:** +```bash +git status +git branch --show-current +``` + +**2. If not on a feature branch, create one:** +```bash +# Standard naming: agent/ or feature/ +git checkout -b agent/ +``` + +**3. Ensure clean working state:** +```bash +git status # Should show "working tree clean" +git stash list # Should be empty +``` + +If dirty state from previous work: +```bash +# If changes are relevant to this issue: +git add -A && git commit -m "WIP: " + +# If changes are unrelated cruft: +git stash push -m "unrelated changes before {{issue}}" +# Or discard if truly garbage: +git checkout -- . +``` + +**4. Sync with main:** +```bash +git fetch origin +git rebase origin/{{default_branch}} # Get latest, rebase your branch +``` + +If rebase conflicts: +- Resolve them carefully +- Test after resolution +- If stuck, mail Supervisor + +**Exit criteria:** You're on a clean feature branch, rebased on latest main.""" + +[[steps]] +id = "validate-contract" +title = "Validate design contract (if present)" +needs = ["branch-setup"] +description = """ +Gate: incoming design contract inspection (Poka-yoke). + +Check if a design contract exists for this work: +```bash +# Look for design documents related to this issue +ls .designs/{{issue}}/design-doc.md 2>/dev/null || echo "No design contract found" +``` + +**If design contract EXISTS:** +- Read it completely +- Extract key requirements and constraints +- Note any specific implementation guidance +- These become verification points for self-verify later + +**If NO design contract exists:** +- This is acceptable — not all work has a prior design +- Close this step with reason "No design contract found" +- Proceed without contract verification + +**Exit criteria:** Design contract reviewed (if present), or step closed as N/A.""" + +[[steps]] +id = "preflight-tests" +title = "Verify tests pass on main" +needs = ["validate-contract"] +description = """ +Check if the codebase is healthy BEFORE starting your work. + +**The Scotty Principle:** Don't walk past a broken warp core. But also don't +let someone else's mess consume your entire mission. + +**1. Check tests on main:** +```bash +git stash # Save your branch state +git checkout origin/{{default_branch}} +# Discover the project's test command from CLAUDE.md, Makefile, package.json, etc. +# Then run it. Examples: make test, npm test, cargo test, go test ./... +``` + +**2. If tests PASS:** +```bash +git checkout - # Back to your branch +git stash pop # Restore state +``` +Continue to phase-1-understand-problem step. + +**3. If tests FAIL on main:** + +Make a judgment call: + +| Situation | Action | +|-----------|--------| +| Quick fix (<15 min) | Fix it, commit to main, then continue | +| Medium fix (15-60 min) | Fix if it blocks your work, else file bead | +| Big fix (>1 hour) | File bead, notify Supervisor, proceed with your work | + +**Quick fix path:** +```bash +# Fix the issue +git add +git commit -m "fix: (pre-existing failure)" +git push origin {{default_branch}} +git checkout - +git stash pop +git rebase origin/{{default_branch}} # Get your fix +``` + +**File and proceed path:** +```bash +af bead create --title "Pre-existing test failure: " \ + --type bug --priority 1 + +af mail send supervisor -s "NOTICE: Main has failing tests" \ + -m "Found pre-existing test failures on main. +Filed: +Proceeding with my assigned work ({{issue}})." + +git checkout - +git stash pop +``` + +**Context consideration:** +If fixing pre-existing failures consumed significant context: +```bash +af handoff -s "Fixed pre-existing failures, ready for assigned work" \ + -m "Issue: {{issue}} +Fixed: +Ready to start: phase-1-understand-problem step" +``` +Fresh session continues from phase-1-understand-problem. + +**Exit criteria:** Tests pass on main (or issue filed), ready to begin +phase-1-understand-problem.""" + +# ============================================================================ +# DOMAIN STEPS — from .claude/skills/github-issue/SKILL.md +# ============================================================================ + +[[steps]] +id = "phase-1-understand-problem" +title = "Phase 1: Understand the problem as reported" +needs = ["preflight-tests"] +description = """ +Understand the problem captured in load-context before touching the codebase. + +**1. If the bead's problem source is a GitHub issue URL**, you already fetched its +full body and comments in load-context via `gh api`. Re-read it carefully. + +**2. Read the problem description carefully** — note the exact symptoms, what was +expected, and what actually happened. Do not paraphrase away specifics. + +**Examine all attached artifacts.** Screenshots, pasted logs, email contents, and +configuration screenshots often carry more signal than the codebase itself. These +are first-class evidence, not supplementary detail. + +- Download images with authenticated requests: + `curl -sL -H "Authorization: token $(gh auth token)" -o file.png ""` +- Use the Read tool to visually examine downloaded images +- Note every concrete detail visible in artifacts (field values, status indicators, + error codes, configuration states) + +**Anti-pattern:** Do NOT skip artifact examination — screenshots and pastes often +contain the diagnosis. + +**Exit criteria:** The problem as reported is fully understood, including every +detail visible in attached artifacts.""" + +[[steps]] +id = "phase-2-investigate-codebase" +title = "Phase 2: Map the codebase" +needs = ["phase-1-understand-problem"] +description = """ +Use an **Explore agent** (subagent_type=Explore) to map the technical landscape. The +prompt should ask the agent to trace the full data/control flow related to the +problem understood in phase-1-understand-problem. Be thorough — request that the +agent search across all relevant directories (src, test, terraform, deploy scripts, +config, etc.). + +The investigation must produce: +- **Affected layers**: Frontend, backend, infrastructure, tests, docs — every layer + the problem touches or that an implementer would need to understand +- **Affected files**: Absolute paths with brief descriptions of each file's role +- **Data flow**: How data moves through the system (form -> API -> handler -> output) +- **The break point**: Where exactly the expected behavior diverges from actual + behavior +- **Contextual gotchas**: Things that aren't broken but are relevant — orphan + fields, naming mismatches, environment differences (staging vs prod), format + differences (stored codes vs display labels), etc. + +Save the findings to `todos/github-issue/investigation.md` — phase-3-reconcile-findings +and phase-4-draft-issue both consume this file. + +**Anti-pattern:** Do NOT name a specific deploy script or assume a single +environment — list all that exist and let the write-up note them. + +**Exit criteria:** `todos/github-issue/investigation.md` exists with all five +categories of findings above.""" + +[[steps]] +id = "phase-3-reconcile-findings" +title = "Phase 3: Reconcile findings with reported symptoms" +needs = ["phase-2-investigate-codebase"] +description = """ +**This phase is critical.** Before drafting anything, verify that your codebase +findings actually explain the reported symptoms captured in +phase-1-understand-problem. + +Enumerate every symptom from the problem statement and the specific finding that +explains it: + +| Reported symptom (quote) | Finding that explains it (file:line or "gap") | +|--------------------------|-----------------------------------------------| +| ... | ... | + +**If my findings say "X shouldn't happen" but the report says it does — the report +is right and the investigation is incomplete.** Re-run phase-2-investigate-codebase +with a narrower, more targeted Explore prompt before continuing. + +**Non-stalling gap handling (this agent runs unattended — it must NEVER wait for a +human reply):** + +If a row still shows a "gap" after a second investigation pass, do NOT wait for a +reply and do NOT block or stall the formula on it. Instead: +1. Send a non-blocking, fire-and-forget notice: `af mail send supervisor -s "QUESTION: " -m "Issue: {{issue}}\nGap: \nWhat I need: "` — this is informational, not a blocking request; do not wait for a reply. +2. Record the gap verbatim under an `## Open Questions` heading you will carry into + the draft in phase-4-draft-issue, so the gap stays visible to whoever reads the + posted issue instead of silently disappearing. +3. Proceed with the best available theory for that row rather than halting the + formula. + +**Adjacent findings (non-stalling scoping decision):** + +Adjacent findings expand the deliverable **by default**. If the investigation +surfaced problems beyond the reported one, include them in the write-up. Do NOT +wait for a reply to decide whether to scope one out — an unattended agent cannot +block on that. Instead: +- Default: include the adjacent finding. +- If you judge an adjacent finding should be excluded (e.g., clearly out of scope, + a separate unrelated system), you may exclude it, but you MUST record the + exclusion and your reasoning under a `## Scope Decisions` heading you will carry + into the draft — never drop a finding silently. + +**Exit criteria:** Every reported symptom is mapped to a finding or an explicit, +mailed, and drafted-visible Open Question; every adjacent finding is either +included or has a recorded Scope Decision. Save the reconciliation table, Open +Questions, and Scope Decisions to `todos/github-issue/reconciliation.md` for +phase-4-draft-issue to consume.""" + +[[steps]] +id = "phase-4-draft-issue" +title = "Phase 4: Draft the GitHub issue" +needs = ["phase-3-reconcile-findings"] +description = """ +Draft the body locally to `todos/github-issue/draft.md`. Do NOT post yet — +phase-5-validate-draft validates the draft and phase-6-post posts it. + +**If a GitHub issue already exists** (the bead's problem source was a GitHub issue +URL): the draft is posted as a comment in phase-6-post. +**If no GitHub issue exists** (the bead's problem source was inline text or a +file): the draft becomes the body of a new issue in phase-6-post. + +Structure: + +```markdown +## Investigation Notes + +[1-2 sentence summary of what's happening and where the break occurs] + +### Affected Layers & Files + +**[Layer Name]** +- `path/to/file` — What this file does in context of the problem + +[Repeat for each layer: Frontend, Backend, Infrastructure, Tests, Docs, etc.] + +### Additional Context + +- [Gotcha 1: something non-obvious the implementer should know] +- [Gotcha 2: environment differences, data format issues, etc.] +- [Gotcha 3: related but not broken things worth being aware of] + +### Acceptance Criteria + +- [Observable behavior that defines "done"] +- [Edge cases or existing behavior that must be preserved] +- [Test coverage expectations] + +### Open Questions + +[Any gap rows recorded in phase-3-reconcile-findings — omit this section entirely +if there were none] + +### Scope Decisions + +[Any adjacent-finding include/exclude decisions recorded in +phase-3-reconcile-findings — omit this section entirely if every adjacent finding +was included with no exclusions to record] +``` + +**Writing guidelines:** +- Every file mentioned must be a real path confirmed by phase-2-investigate-codebase +- Acceptance criteria must be testable statements, not implementation steps +- Do NOT prescribe a fix — you are drawing the map, not writing the solution. + Never say "add this line to this file" or name a specific function/flag/command + substitution as "the correct" / "the equivalent" fix. + +**Exit criteria:** `todos/github-issue/draft.md` exists with the full structure +above populated from phase-2 and phase-3 findings.""" + +[[steps]] +id = "phase-5-validate-draft" +title = "Phase 5: Validate the Draft Before Posting" +needs = ["phase-4-draft-issue"] +description = """ +**MANDATORY pre-post checklist** — do NOT close this step, and do NOT proceed to +phase-6-post, until every item below returns "none". Answer each by quoting the +relevant passage from `todos/github-issue/draft.md`, or writing "none" only after +reading every section of the draft. + +1. **Scope constraints** — Does the draft constrain the scope of the fix (e.g., "no + changes to X", "read-only on Y", "cleanup only", "no new features")? Quote every + such constraint. For each: did the bead's problem source state this, or did you + add it? If you added it, remove it. +2. **Prescribed mechanisms** — Does the draft name a specific function, flag, file + path, command substitution, variable, or implementation approach as "the + correct" / "the right" / "the equivalent" / "the substitute"? Quote each. Remove + every one — naming the mechanism is prescription, including when offered as an + example. +3. **Mechanical transforms** — Does any list or table in the draft imply the + implementer must transform each enumerated item identically to satisfy an + acceptance criterion (e.g., "N sites to rewrite", "zero matches after cleanup")? + Quote each. Rewrite as behavioral AC or keep the enumeration as context only. +4. **Regex-compliance AC** — Does any AC item pass if `grep` returns specific match + counts (zero matches, N matches, exact string present/absent)? Quote each. + Rewrite as observable behavioral outcome. +5. **Symptom coverage** — Does every reported symptom from + phase-3-reconcile-findings's table appear in the draft, tied to the finding that + explains it (or listed under Open Questions)? If not, add it or re-open + phase-2-investigate-codebase. + +**If ANY item is not "none"**, fix `todos/github-issue/draft.md` now and re-run this +checklist until all five return "none". Do NOT proceed to phase-6-post with an +unresolved item. + +**Exit criteria:** All five checklist items return "none" against the current draft.""" + +[[steps]] +id = "phase-6-post" +title = "Phase 6: Post" +needs = ["phase-5-validate-draft"] +description = """ +Post `todos/github-issue/draft.md` now that phase-5-validate-draft has passed. + +**Existing issue comment** (bead's problem source was a GitHub issue URL): +```bash +gh api repos/{owner}/{repo}/issues/{number}/comments -X POST -f body="$(cat todos/github-issue/draft.md)" +``` + +**New issue** (bead's problem source was inline text or a file): +```bash +gh api repos/{owner}/{repo}/issues -X POST -f title="" -f body="$(cat todos/github-issue/draft.md)" +``` + +Record the resulting issue URL (or comment URL) — prepare-for-review needs it. + +**Exit criteria:** The draft is posted; the issue/comment URL is recorded.""" + +[[steps]] +id = "phase-7-bead-dispatch" +title = "Phase 7: Create Bead & Dispatch (if requested)" +needs = ["phase-6-post"] +description = """ +**Conditional step.** If the bead's problem source did NOT request dispatch (no +"dispatch this" / "assign an agent" language), close this step immediately with +reason "N/A: dispatch not requested". + +If dispatch was requested: + +1. Create a bead with `af bead create`: + - `--type bug` for defects, `--type feature` for new behavior + - `--title "..."` summarizing the problem + - Description should reference the GitHub issue URL from phase-6-post for full + context +2. Dispatch with `af sling`: + - `af sling --agent <specialist> "task description"` for specialist agents + (e.g., `design-v5`, `ultra-implement`, `rootcause-all`) + - `af sling --formula <name>` to instantiate a formula + +**Exit criteria:** Bead created and dispatched, or step closed as N/A.""" + +# ============================================================================ +# POST-WORK INVARIANT STEPS +# ============================================================================ + +[[steps]] +id = "self-review" +title = "Self-review changes" +needs = ["phase-7-bead-dispatch"] +description = """ +Review your own changes before running tests. + +**1. Review the diff:** +```bash +git diff origin/{{default_branch}}...HEAD # All changes vs main +git log --oneline origin/{{default_branch}}..HEAD # All commits +``` + +**2. Check for common issues:** + +| Category | Look For | +|----------|----------| +| Bugs | Off-by-one, null handling, edge cases | +| Security | Injection, auth bypass, exposed secrets | +| Style | Naming, formatting, code organization | +| Completeness | Missing error handling, incomplete paths | +| Cruft | Debug prints, commented code, TODOs | + +**3. Fix issues found:** +Don't just note them - fix them now. Amend or add commits as needed. + +**4. Verify no unintended changes:** +```bash +git diff --stat origin/{{default_branch}}...HEAD +# Only files relevant to {{issue}} should appear +``` + +If you accidentally modified unrelated files, remove those changes. + +**Exit criteria:** Changes are clean, reviewed, and ready for testing.""" + +[[steps]] +id = "run-tests" +title = "Run tests and verify coverage" +needs = ["self-review"] +description = """ +Verify your changes don't break anything and are properly tested. + +**1. Run the full test suite:** +```bash +# Discover the project's test command from CLAUDE.md, Makefile, package.json, etc. +# Then run it. Examples: make test, npm test, cargo test, go test ./... +``` + +**ALL TESTS MUST PASS.** Do not proceed with failures. + +**2. If tests fail:** +- Read the failure output carefully +- Determine if your change caused it: + - If yes: Fix it. Return to phase-1-understand-problem if needed. + - If no (pre-existing): File a bead, but still must pass for your PR + +```bash +# Check if failure exists on main: +git stash +git checkout {{default_branch}} +# Discover the project's test command from CLAUDE.md, Makefile, package.json, etc. +# Then run it. Examples: make test, npm test, cargo test, go test ./... +git checkout - +git stash pop +``` + +**3. Verify test coverage for new code:** +- New features should have tests +- Bug fixes should have regression tests +- If you added significant code without tests, add them now + +**4. Validate terraform if .tf files were modified:** +```bash +# Check if any .tf files were changed in this branch +TF_CHANGED=$(git diff --name-only origin/{{default_branch}}...HEAD -- '*.tf' | head -1) +if [ -n "$TF_CHANGED" ]; then + echo "Terraform files modified — running validation..." + TF_DIR=$(dirname "$TF_CHANGED") + (cd "$TF_DIR" && terraform validate) || { echo "FAIL: terraform validate failed"; exit 1; } + terraform fmt -check -recursive "$TF_DIR" || { echo "FAIL: terraform fmt check failed — run 'terraform fmt'"; exit 1; } + echo "Terraform validation passed." +fi +``` + +**5. Run any other quality checks:** +```bash +# Discover linting/build commands from CLAUDE.md, Makefile, package.json, etc. +# Run linter if configured +# Run build check if applicable +``` + +**Exit criteria:** All tests pass, terraform valid (if applicable), new code has appropriate test coverage.""" + +[[steps]] +id = "self-verify" +title = "Verify implementation matches design contract (if present)" +needs = ["run-tests"] +description = """ +Gate: verify implementation matches design contract (Jidoka). + +**If a design contract was found in validate-contract:** +- Re-read the design contract from `.designs/{{issue}}/design-doc.md` +- For each requirement/constraint in the contract: + - Verify the implementation addresses it + - Check that no constraints are violated + - Cross-reference with the actual code changes +- Document any deviations with justification + +**If NO design contract was found in validate-contract:** +- Close this step with reason "No design contract to verify against" +- Proceed to cleanup-workspace + +**Exit criteria:** Implementation verified against design contract (if present), or step closed as N/A.""" + +[[steps]] +id = "cleanup-workspace" +title = "Clean up workspace" +needs = ["self-verify"] +description = """ +**Skill artifact cleanup:** +The github-issue process creates working files that should NOT be merged. Remove +them before finalizing: +```bash +rm -rf todos/github-issue/ +``` + +The github-issue formula produces no other file artifacts — the work product is a +GitHub issue (or comment) posted via `gh api`. Verify no accidental files were left +behind. + +**1. Check for uncommitted changes:** +```bash +git status +``` +Must show "working tree clean". If not: +- Commit legitimate changes +- Discard garbage: `git checkout -- .` + +**2. Check for untracked files:** +```bash +git status --porcelain +``` +Should be empty. If not: +- Add to .gitignore if appropriate +- Remove if temporary: `rm <file>` +- Commit if needed + +**3. Check stash:** +```bash +git stash list +``` +Should be empty. If not: +- Pop and commit: `git stash pop && git add -A && git commit` +- Or drop if garbage: `git stash drop` + +**4. Push your branch (only if commits exist):** +```bash +if [ -n "$(git log origin/{{default_branch}}..HEAD --oneline)" ]; then + git push -u origin $(git branch --show-current) +fi +``` + +**5. Verify nothing left behind:** +```bash +git status # Clean +git stash list # Empty +``` + +**Exit criteria:** Working artifacts removed, workspace clean, no cruft.""" + +[[steps]] +id = "prepare-for-review" +title = "Prepare work for review" +needs = ["cleanup-workspace"] +description = """ +Verify work is complete and ready for submission. + +**Note:** Do NOT close the issue that assigned this work. The Supervisor closes it +after review. + +**1. Verify the assignment shows your work:** +```bash +af bead show {{issue}} +# Status should still be 'in_progress' (you're working on it) +``` + +**2. Add completion notes:** +```bash +af bead update {{issue}} --notes "Posted: <new issue URL or comment URL from phase-6-post> +Symptom coverage: <N of N reported symptoms mapped to findings> +Open Questions recorded: <count, or 'none'> +Scope Decisions recorded: <count, or 'none'> +Dispatch: <bead ID + agent, or 'not requested'>" +``` + +**Exit criteria:** Assignment updated with completion notes.""" + +[[steps]] +id = "submit-and-exit" +title = "Submit and exit" +needs = ["prepare-for-review"] +description = """ +Signal completion. This formula's work product is a posted GitHub issue or +comment, not a code PR — so this step focuses on signaling done. + +**1. Create a PR (only if commits exist on the branch):** +```bash +if [ -n "$(git log origin/{{default_branch}}..HEAD --oneline)" ]; then + gh pr create --title "chore: github-issue write-up for {{issue}}" \ + --body "Investigation and write-up run for {{issue}}." +fi +``` +If no commits exist on the branch, skip PR creation. + +**2. Signal completion:** +```bash +af done +``` + +This closes the current formula step. When all steps are complete, it: +- Mails WORK_DONE to the dispatcher +- Cleans up the checkpoint and runtime artifacts + +**Ephemeral Agent Model:** +Once you signal completion, you're done. The Supervisor will: +1. Review the posted issue/comment (and any PR, if one was created) +2. Close your assignment after review + +**3. You're done:** +Your work is submitted — the issue/comment is posted to GitHub. + +If you have context remaining, you may: +- Pick up new work from `af prime` +- Or use `af handoff` to cycle to a fresh session + +**Exit criteria:** `af done` signaled, agent done.""" + +[vars] +[vars.issue] +description = "The issue ID assigned to this agent (the problem to write up)" +required = true +source = "cli" diff --git a/.agentfactory/store/formulas/ultra-review.formula.toml b/.agentfactory/store/formulas/ultra-review.formula.toml index f6a56be..70bbf69 100644 --- a/.agentfactory/store/formulas/ultra-review.formula.toml +++ b/.agentfactory/store/formulas/ultra-review.formula.toml @@ -32,6 +32,7 @@ false positives. Only findings scoring at or above {{min_confidence}} survive. |----------|--------|-------------| | pr_uri | input (cli) | Pull request to review: full GitHub URL, owner/repo#number, or bare number | | min_confidence | input (cli, default 70) | Minimum 0-100 confidence score a finding needs to be reported | +| force_review | input (cli, default "false") | Set "true" to force a re-review of a PR this reviewer already reviewed. Waives ONLY the already-reviewed criterion — every other eligibility check still applies | ## Orchestrator Communication Contract @@ -121,6 +122,12 @@ type = "number" required = false default = "70" +[inputs.force_review] +description = "Force a full re-review even if this reviewer has already reviewed the PR. Waives ONLY eligibility criterion (d) (already-reviewed) at both the phase-1 and phase-6 checkpoints — criteria (a) closed/merged, (b) draft, and (c) doesn't-need-review still apply. Default 'false' preserves standard behavior: already-reviewed PRs stay ineligible." +type = "string" +required = false +default = "false" + # ============================================================================ # PRE-WORK INVARIANT STEPS # ============================================================================ @@ -269,12 +276,16 @@ ls "$REVIEW_DIR" 2>/dev/null ``` **If a prior review of this PR exists in `$REVIEW_DIR`** (findings.md, verdict.md): -- Read it. Decide: has the PR gained new commits since (compare head sha in target.md - against current `gh pr view --json headRefOid`)? -- If unchanged AND a comment was already posted: this PR is already reviewed — - note it in `$REVIEW_DIR/eligibility.md` ahead of time; phase-1-eligibility will confirm. -- If changed: archive the prior artifacts (`mv "$REVIEW_DIR" "$REVIEW_DIR.old"`) and - re-create the directory fresh. +- If {{force_review}} is "true": do NOT treat prior review state as a short-circuit — + archive the prior artifacts (`mv "$REVIEW_DIR" "$REVIEW_DIR.old"`), re-create the + directory fresh, and let phase-1-eligibility apply its own force_review-aware check. + This is the operator's forced-re-review path; it must not be pre-empted here. +- Otherwise, read it. Decide: has the PR gained new commits since (compare head sha in + target.md against current `gh pr view --json headRefOid`)? + - If unchanged AND a comment was already posted: this PR is already reviewed — + note it in `$REVIEW_DIR/eligibility.md` ahead of time; phase-1-eligibility will confirm. + - If changed: archive the prior artifacts (`mv "$REVIEW_DIR" "$REVIEW_DIR.old"`) and + re-create the directory fresh. **2. Check the dispatch mail / bead for special constraints:** e.g., a different confidence threshold, a request to always post even with @@ -325,8 +336,14 @@ this is a quick classification, not analysis. **Entry criteria:** `$REVIEW_DIR/target.md` exists. +**force_review override:** When {{force_review}} is "true", criterion (d) below is +waived — criteria (a), (b), and (c) still apply regardless. If {{force_review}} is +"true", omit criterion (d) from the sub-agent prompt entirely (do not ask it to check +prior reviews at all); otherwise include it verbatim as written. + **Spawn ONE sub-agent** (Agent tool, `subagent_type: "general-purpose"`, -`model: "haiku"` if available) with this prompt (fill in repo/number): +`model: "haiku"` if available) with this prompt (fill in repo/number; drop criterion +(d) when {{force_review}} is "true", per the override note above): ``` Check pull request <owner/repo>#<number> using the gh CLI and report whether it @@ -345,7 +362,9 @@ And: gh api repos/<owner>/<repo>/pulls/<number>/reviews --jq '.[].body' Return exactly: ELIGIBLE or INELIGIBLE, followed by one sentence of reasoning. ``` -**Write `$REVIEW_DIR/eligibility.md`** with the verdict, the reasoning, and a timestamp. +**Write `$REVIEW_DIR/eligibility.md`** with the verdict, the reasoning, a timestamp, and +whether {{force_review}} was applied (so phase-6's re-check and self-review can see it +was an intentional override, not a protocol lapse). **Decision logic:** - IF ELIGIBLE → proceed to phase-2-context-gathering. @@ -620,9 +639,11 @@ individually), then dispatches ultra-implement for the comments left standing. requested) or eligibility.md says INELIGIBLE, close this step with that reason. **1. Repeat the eligibility check** (same cheap sub-agent prompt as -phase-1-eligibility). The PR may have closed, merged, or been reviewed while -you worked. If now INELIGIBLE: update eligibility.md, mail the dispatcher, -do NOT post, and close this step. +phase-1-eligibility, including the SAME {{force_review}} override — if {{force_review}} +is "true", criterion (d) is omitted here too, exactly as at phase-1; criteria (a), (b), +and (c) still apply at this checkpoint regardless). The PR may have closed, merged, or +gained ANOTHER reviewer's review while you worked. If now INELIGIBLE: update +eligibility.md, mail the dispatcher, do NOT post, and close this step. **2. Capture the head sha** (used as the review's commit_id and for permalinks): ```bash diff --git a/.claude/skills/formula-create/SKILL.md b/.claude/skills/formula-create/SKILL.md index f560a04..23636f5 100644 --- a/.claude/skills/formula-create/SKILL.md +++ b/.claude/skills/formula-create/SKILL.md @@ -247,7 +247,7 @@ After creating the formula, inform the user: Formula created: .agentfactory/store/formulas/<name>.formula.toml To inspect: - bd formula show <name> # View formula details + af formula show <name> --json # View formula inputs and vars To use immediately (current workspace): af sling --formula <name> --var x=y # Execute the formula diff --git a/.claude/skills/formula-create/skillmd-mode.md b/.claude/skills/formula-create/skillmd-mode.md index 6063e61..fe6c8cb 100644 --- a/.claude/skills/formula-create/skillmd-mode.md +++ b/.claude/skills/formula-create/skillmd-mode.md @@ -50,14 +50,14 @@ execution formula regardless of the skill being converted. | N+2 | `run-tests` | Full test suite, terraform validation if applicable | | N+3 | `self-verify` | Gate: verify implementation matches design contract (Jidoka) | | N+4 | `cleanup-workspace` | Skill artifact removal, workspace hygiene, push branch | -| N+5 | `prepare-for-review` | `bd update` with completion notes | +| N+5 | `prepare-for-review` | `af bead update` with completion notes | | N+6 | `submit-and-exit` | `af done`, become recyclable | **The `needs` chain must be unbroken.** The first domain step needs `["preflight-tests"]`. The `self-review` step needs `["<last-domain-step-or-gate>"]`. Every step between has a `needs` pointing to the previous step. -**The `load-context` step MUST include requirement source detection.** Polecats receive +**The `load-context` step MUST include requirement source detection.** Agents receive requirements through their assigned bead, which may provide them in different forms. Include this three-branch pattern in every `load-context` step: @@ -141,12 +141,12 @@ Run this command: ## 10.5 Work Step Content Strategy Step descriptions are **self-contained**: -- Embed the relevant phase content directly (the polecat can execute without reading external files) +- Embed the relevant phase content directly (the agent can execute without reading external files) - Preserve all bash code blocks from the skill phase - Preserve all enforcement language - Include relevant anti-patterns from the skill's Anti-Patterns section - Use source-agnostic language for requirements. Skills often say "proposal" or "spec document", - but polecats receive work through beads. Replace "read the proposal" with "read the requirements + but agents receive work through beads. Replace "read the proposal" with "read the requirements (from bead description, proposal file, or GitHub issue)". Never assume a specific file exists — the bead is always the starting point. @@ -254,7 +254,7 @@ rm -f test_results.txt Derive the artifact list from the skill's `## Outputs` section. Only remove files that the skill creates as working artifacts — not production code or test files. -**Adaptation 3 — `prepare-for-review`:** Replace the generic `bd update` notes with a +**Adaptation 3 — `prepare-for-review`:** Replace the generic `af bead update` notes with a skill-specific completion summary describing the artifacts produced. For example, a design formula should mention the design-doc.md and dimension analyses; a TDD formula should mention the fix and test files. diff --git a/README.md b/README.md index 529c670..1465aa3 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ cd agentfactory This builds a container with all prerequisites, clones your target repo, and runs `quickstart.sh` inside it. When it finishes, the container is ready for `af up`. +A clean install now also **reveals the web console before the shell**: when it finishes it prints the loopback URL `http://127.0.0.1:<HOSTPORT>/` (and opens your browser on macOS) immediately before dropping you into the interactive shell — so you no longer have to run `--web` yourself just to first see it. Use `--web` only to **re-open** the console later. See the **Web Console (optional)** section below and [`web/README.md`](web/README.md) for details. + #### Using quickstart.sh (inside the docker container @ docker exec -it -u dev "af_ghusername_repo" bash) ```bash diff --git a/USING_AGENTFACTORY.md b/USING_AGENTFACTORY.md index bae515f..9613cf9 100644 --- a/USING_AGENTFACTORY.md +++ b/USING_AGENTFACTORY.md @@ -18,6 +18,7 @@ Create an instruction set workflow (formula) with `/formula-create /path/to/your ## Setup container and install agentfactory alongside repo (the easy way) 1. IFF you haven't setup AgentFactory, run: `./quickdocker.sh <github-repo-path>` 1a. IFF you haven't setup AgentFactory, when the above completes, run: `claude` and make sure to authenticate. +1b. When that clean `./quickdocker.sh <github-repo-path>` install finishes, it now **reveals the web console automatically** — printing the loopback URL `http://127.0.0.1:<HOSTPORT>/` (and opening your browser on macOS) **before** it drops you into the shell, so you no longer have to run `--web` yourself just to first see it. To **re-open** the console later, run `./quickdocker.sh <github-repo-path> --web`. See [`web/README.md`](web/README.md) for the full web-console runbook. 2. IFF you have AgentFactory setup, run: `docker exec -it -u dev "af_ghusername_repo" bash`, then: `./quickstart.sh` 2a. To **redeploy** agents after that initial setup (regenerate every specialist template and re-bootstrap the factory in one command), run from your project root (e.g. `~/af/myproject`): `af install --agents`. This is the one-command replacement for the manual two-script ritual — it runs **both** `agent-gen-all.sh` then `quickstart.sh`, non-interactively, so quickstart's usual terminal `exec bash` / manual `exit` is handled automatically (no manual `exit` needed). It operates on an **already-initialized factory**: `agent-gen-all.sh` runs first and aborts if `.agentfactory/store/formulas/` is absent, *before* `quickstart.sh` could bootstrap a cold factory — so for a first-time / cold-start setup run steps 1–2 (`quickdocker.sh` / `quickstart.sh`) first, then use `af install --agents` for subsequent redeploys. It is the **same command** described under *Batch regeneration with `af install --agents`* below — see there for the `af up` restart reminder, data-safety rule, and `--no-build` semantics. 3. (optionally) enable the quality gate: `af quality on` (the `fidelity` gate is on by default and only fires when an agent is running a formula to keep it honest) diff --git a/hooks/fidelity-gate.sh b/hooks/fidelity-gate.sh index 1b4df19..8c1a8a5 100755 --- a/hooks/fidelity-gate.sh +++ b/hooks/fidelity-gate.sh @@ -14,6 +14,23 @@ ROLE=${AF_ROLE:-$(basename "$(pwd)")} AGENT_RUNTIME="$(pwd)/.runtime" +# notify_grader_unavailable emits a one-time notice per agent per cause (issue #508) +# when the haiku grader cannot produce a verdict, so a persistent outage — which +# fails the gate open every turn (ADR-007 never-block) — becomes visible instead of +# silent. Idempotent via a .runtime marker (the fidelity-gate per-agent-state idiom), +# so it is one mail per cause, not a per-turn storm. Sent to the agent's own inbox +# (ADR-007: no fire-and-forget escalation into a possibly-absent recipient). +notify_grader_unavailable() { + cause="$1" + marker="$AGENT_RUNTIME/grader_notice_$cause" + [ -f "$marker" ] && return 0 + mkdir -p "$AGENT_RUNTIME" 2>/dev/null + : > "$marker" 2>/dev/null + af mail send "$ROLE" -s "GRADER_UNAVAILABLE" \ + -m "fidelity gate grader unavailable ($cause): the haiku grader produced no verdict this turn, so the gate is failing open. Check that the claude CLI is on PATH and ~/.claude credentials are valid." \ + 2>/dev/null +} + # Find prompt file via af root FACTORY_ROOT=${AF_ROOT:-$(af root 2>/dev/null)} if [ -z "$FACTORY_ROOT" ]; then @@ -154,6 +171,7 @@ fi # Check claude CLI is available if ! command -v claude &>/dev/null; then echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) EXIT8: no_claude_binary" >> "$AGENT_RUNTIME/fidelity_debug.log" 2>/dev/null + notify_grader_unavailable "no_claude_binary" echo '{"ok": true}' exit 0 fi @@ -183,13 +201,19 @@ $TOOL_CONTEXT" fi # Run evaluation via haiku -VERDICT=$(claude -p --model haiku --max-turns 1 \ +VERDICT=$(env -i HOME="$HOME" PATH="$PATH" claude -p --model haiku --max-turns 1 \ --system-prompt "You are a JSON-only fidelity gate. You receive an assistant's response, the current formula step's contract, and the tool calls executed this turn. Evaluate adherence to the step contract considering BOTH the text AND the tool evidence. Respond with ONLY valid JSON, nothing else. $(cat "$PROMPT_FILE")" \ "$EVAL_INPUT" 2>/dev/null) # Strip markdown code fences if present VERDICT=$(echo "$VERDICT" | sed 's/^```json//;s/^```//;/^$/d') +# An empty verdict means the grader produced nothing (unavailable / transient) and the +# gate is failing open this turn — surface it once per cause (idempotent), never block. +if [ -z "$VERDICT" ]; then + notify_grader_unavailable "empty_verdict" +fi + COUNTER_FILE="$AGENT_RUNTIME/fidelity_violations" if [ -n "$VERDICT" ] && echo "$VERDICT" | jq -e '.ok == false' &>/dev/null; then COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0) diff --git a/hooks/quality-gate.sh b/hooks/quality-gate.sh index c423624..18d0b1c 100755 --- a/hooks/quality-gate.sh +++ b/hooks/quality-gate.sh @@ -9,6 +9,23 @@ ROLE=${AF_ROLE:-$(basename "$(pwd)")} AGENT_RUNTIME="$(pwd)/.runtime" +# notify_grader_unavailable emits a one-time notice per agent per cause (issue #508) +# when the haiku grader cannot produce a verdict, so a persistent outage — which +# fails the gate open every turn (ADR-007 never-block) — becomes visible instead of +# silent. Idempotent via a .runtime marker (the fidelity-gate per-agent-state idiom), +# so it is one mail per cause, not a per-turn storm. Sent to the agent's own inbox +# (ADR-007: no fire-and-forget escalation into a possibly-absent recipient). +notify_grader_unavailable() { + cause="$1" + marker="$AGENT_RUNTIME/grader_notice_$cause" + [ -f "$marker" ] && return 0 + mkdir -p "$AGENT_RUNTIME" 2>/dev/null + : > "$marker" 2>/dev/null + af mail send "$ROLE" -s "GRADER_UNAVAILABLE" \ + -m "quality gate grader unavailable ($cause): the haiku grader produced no verdict this turn, so the gate is failing open. Check that the claude CLI is on PATH and ~/.claude credentials are valid." \ + 2>/dev/null +} + # Find prompt file via af root FACTORY_ROOT=${AF_ROOT:-$(af root 2>/dev/null)} if [ -z "$FACTORY_ROOT" ]; then @@ -95,6 +112,7 @@ fi # Check claude CLI is available if ! command -v claude &>/dev/null; then echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) EXIT6: no_claude_binary" >> "$AGENT_RUNTIME/quality_debug.log" 2>/dev/null + notify_grader_unavailable "no_claude_binary" echo '{"ok": true}' exit 0 fi @@ -109,13 +127,19 @@ $TOOL_CONTEXT" fi # Run evaluation via haiku -VERDICT=$(claude -p --model haiku --max-turns 1 \ +VERDICT=$(env -i HOME="$HOME" PATH="$PATH" claude -p --model haiku --max-turns 1 \ --system-prompt "You are a JSON-only quality gate. You receive an assistant's response along with the tool calls it executed. Evaluate the response considering BOTH the text AND the tool evidence. Respond with ONLY valid JSON, nothing else. $(cat "$PROMPT_FILE")" \ "$EVAL_INPUT" 2>/dev/null) # Strip markdown code fences if present VERDICT=$(echo "$VERDICT" | sed 's/^```json//;s/^```//;/^$/d') +# An empty verdict means the grader produced nothing (unavailable / transient) and the +# gate is failing open this turn — surface it once per cause (idempotent), never block. +if [ -z "$VERDICT" ]; then + notify_grader_unavailable "empty_verdict" +fi + # Mail verdict to self only on failure if [ -n "$VERDICT" ] && echo "$VERDICT" | jq -e '.ok == false' &>/dev/null; then af mail send "$ROLE" -s "QUALITY_GATE" -m "$VERDICT" 2>/dev/null diff --git a/internal/cmd/compact_handoff.go b/internal/cmd/compact_handoff.go index 27eaf54..25a8e06 100644 --- a/internal/cmd/compact_handoff.go +++ b/internal/cmd/compact_handoff.go @@ -110,10 +110,11 @@ func runCompactHandoffCore(ctx context.Context, cwd string, interactive bool) er // Step 6 — Recycle via shared respawn return respawnSession(RespawnOptions{ - FactoryRoot: factoryRoot, - AgentName: agentName, - AgentEntry: *agentEntry, - PaneID: pane, + FactoryRoot: factoryRoot, + AgentName: agentName, + AgentEntry: *agentEntry, + PaneID: pane, + AgentWorkDir: cwd, }) } diff --git a/internal/cmd/config_models.go b/internal/cmd/config_models.go new file mode 100644 index 0000000..e361a14 --- /dev/null +++ b/internal/cmd/config_models.go @@ -0,0 +1,399 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "github.com/stempeck/agentfactory/internal/config" + "github.com/stempeck/agentfactory/internal/fsutil" +) + +// This file adds the read-only model diagnostics (`show`/`check`, issue #508) and +// the fitness-attestation write path (`attest`) under the existing `af config +// models` group (config_set.go). They never mutate models.json — `show`/`check` load +// it read-only and `attest` writes only the factory-root fitness marker. Secret +// material is never printed: a literal ANTHROPIC_AUTH_TOKEN renders as `****`, a +// `file:` reference prints as-is, and `check` passes the resolved token into the +// probe seam but never into the output. All three commands live in the cmd layer, +// which may do I/O and read env (ADR-004 confines only the library). + +const ( + authTokenKey = "ANTHROPIC_AUTH_TOKEN" + baseURLKey = "ANTHROPIC_BASE_URL" + modelKey = "ANTHROPIC_MODEL" + secretPrefix = "file:" + + modelsProbeTimeout = 5 * time.Second +) + +var configModelsShowAgent string + +var configModelsShowCmd = &cobra.Command{ + Use: "show", + Short: "Print the model registry with secrets redacted", + Long: `Load models.json read-only and print the effective registry: each profile's +env exports (a literal ANTHROPIC_AUTH_TOKEN redacted to ****, a file: reference shown +as-is), the agents assignment map, and the default. With --agent <name>, also explain +which profile that agent resolves to.`, + RunE: runConfigModelsShow, +} + +var configModelsCheckCmd = &cobra.Command{ + Use: "check [profile]", + Short: "Transport-level probe of endpoint-bearing model profiles", + Long: `Probe endpoint-bearing profiles (or a single named profile): resolve the +file: secret (exists / non-empty / mode), reach ANTHROPIC_BASE_URL, and confirm the +profile's model id appears in GET /v1/models (warn, not block). This is transport-level +only — necessary, not sufficient, for fitness. Never prints token material.`, + Args: cobra.MaximumNArgs(1), + RunE: runConfigModelsCheck, +} + +var configModelsAttestCmd = &cobra.Command{ + Use: "attest <profile>", + Short: "Record a fitness attestation for a model profile", + Long: `Write a factory-root .runtime/model_fitness/<profile>.json attestation (who, +when, stage results). The selecting-launch interlock refuses an unattested +non-loopback profile until this is recorded, unless --skip-fitness is passed. The +attestation lives under .runtime, so an environment reset requires re-attestation.`, + Args: cobra.ExactArgs(1), + RunE: runConfigModelsAttest, +} + +func init() { + configModelsShowCmd.Flags().StringVar(&configModelsShowAgent, "agent", "", "Explain which profile the named agent resolves to") + configModelsCmd.AddCommand(configModelsShowCmd) + configModelsCmd.AddCommand(configModelsCheckCmd) + configModelsCmd.AddCommand(configModelsAttestCmd) +} + +// httpProbe fetches the gateway's advertised model ids via GET <baseURL>/v1/models +// under a bounded timeout. Declared as a package-level var (ADR-009 seam) so tests +// substitute a canned response with no real network (ADR-018: no network in +// `make test`) — this is the FIRST http client in internal/cmd, so the seam is +// mandatory. It receives the resolved token to authenticate the request; the token +// is never returned or logged (T-2 redaction stays at the output layer). Modelled on +// runGitDetect's context.WithTimeout bound and ghPRStatus's (T, error) inline-decode. +var httpProbe = func(baseURL, authToken string) ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), modelsProbeTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/v1/models", nil) + if err != nil { + return nil, err + } + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET /v1/models returned %s", resp.Status) + } + var body struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, err + } + ids := make([]string, 0, len(body.Data)) + for _, m := range body.Data { + ids = append(ids, m.ID) + } + return ids, nil +} + +func runConfigModelsShow(cmd *cobra.Command, _ []string) error { + _, cfg, err := loadModelsForRead() + if err != nil { + return err + } + + var buf bytes.Buffer + if cfg.Default != "" { + fmt.Fprintf(&buf, "default: %s\n", cfg.Default) + } else { + fmt.Fprintln(&buf, "default: (none — global default model)") + } + + profiles := sortedMapKeys(cfg.Models) + if len(profiles) == 0 { + fmt.Fprintln(&buf, "\n(no model profiles defined)") + } + for _, name := range profiles { + fmt.Fprintf(&buf, "\nprofile %s:\n", name) + tw := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + for _, k := range sortedMapKeys(cfg.Models[name]) { + fmt.Fprintf(tw, " %s\t%s\n", k, displayModelValue(k, cfg.Models[name][k])) + } + tw.Flush() + } + + if len(cfg.Agents) > 0 { + fmt.Fprintln(&buf, "\nagents:") + tw := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + for _, a := range sortedMapKeys(cfg.Agents) { + fmt.Fprintf(tw, " %s\t%s\n", a, cfg.Agents[a]) + } + tw.Flush() + } + + if configModelsShowAgent != "" { + fmt.Fprintf(&buf, "\n%s\n", explainAgentPrecedence(cfg, configModelsShowAgent)) + } + + fmt.Fprint(cmd.OutOrStdout(), buf.String()) + return nil +} + +func runConfigModelsCheck(cmd *cobra.Command, args []string) error { + root, cfg, err := loadModelsForRead() + if err != nil { + return err + } + + var names []string + if len(args) > 0 && args[0] != "" { + if _, ok := cfg.Models[args[0]]; !ok { + return fmt.Errorf("unknown model profile %q: not defined in models.json", args[0]) + } + names = []string{args[0]} + } else { + for name, profile := range cfg.Models { + if profile[baseURLKey] != "" { + names = append(names, name) + } + } + sort.Strings(names) + } + + out := cmd.OutOrStdout() + fmt.Fprintln(out, "af config models check — transport-level only (necessary, not sufficient for fitness).") + + hardFailures := 0 + for _, name := range names { + if checkProfile(out, root, name, cfg.Models[name]) { + hardFailures++ + } + } + if hardFailures > 0 { + return fmt.Errorf("%d transport check(s) failed", hardFailures) + } + return nil +} + +// checkProfile probes one profile and returns true if a HARD failure (missing secret +// or unreachable endpoint) occurred. Soft issues (permissive mode, git-tracked secret, +// model id absent) are warned but not counted. Never prints token material. +func checkProfile(out io.Writer, root, name string, profile map[string]string) (hardFailure bool) { + base := profile[baseURLKey] + if base == "" { + fmt.Fprintf(out, "profile %q: no ANTHROPIC_BASE_URL — nothing to probe (local/default model)\n", name) + return false + } + + secret := profile[authTokenKey] + if strings.HasPrefix(secret, secretPrefix) { + path := secretRefPath(root, secret) + info, statErr := os.Stat(path) + if statErr != nil || info.IsDir() || info.Size() == 0 { + fmt.Fprintf(out, "profile %q: secret reference ANTHROPIC_AUTH_TOKEN → %s: file not found\n", name, path) + return true + } + if info.Mode().Perm()&0o077 != 0 { + fmt.Fprintf(out, "profile %q: warning: secret %s is group/other-accessible (mode %04o); tighten to 0600\n", name, path, info.Mode().Perm()) + } + if tracked := runGitDetect(root, "git", "ls-files", "--error-unmatch", path); tracked != "" { + fmt.Fprintf(out, "profile %q: warning: secret %s is tracked by git; move it out of version control\n", name, path) + } + data, readErr := os.ReadFile(path) + if readErr != nil { + fmt.Fprintf(out, "profile %q: secret reference ANTHROPIC_AUTH_TOKEN → %s: %v\n", name, path, readErr) + return true + } + secret = strings.TrimSpace(string(data)) + } + + ids, probeErr := httpProbe(base, secret) + if probeErr != nil { + fmt.Fprintf(out, "profile %q: ANTHROPIC_BASE_URL %s unreachable: %v\n", name, base, probeErr) + return true + } + if want := profile[modelKey]; want != "" && !modelIDPresent(ids, want) { + fmt.Fprintf(out, "profile %q: model %q not in GET /v1/models response (gateway may need registration)\n", name, want) + return false + } + fmt.Fprintf(out, "profile %q: reachable; model %q present at %s\n", name, profile[modelKey], base) + return false +} + +func runConfigModelsAttest(cmd *cobra.Command, args []string) error { + if len(args) == 0 || args[0] == "" { + return fmt.Errorf("usage: af config models attest <profile>") + } + profile := args[0] + root, cfg, err := loadModelsForRead() + if err != nil { + return err + } + if _, ok := cfg.Models[profile]; !ok { + return fmt.Errorf("unknown model profile %q: not defined in models.json", profile) + } + + att := modelFitnessAttestation{ + Profile: profile, + AttestedBy: attestedBy(), + AttestedAt: time.Now().UTC().Format(time.RFC3339), + Stages: map[string]string{"transport": "attested"}, + } + if err := writeModelFitnessAttestation(root, att); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Attested model profile %q at %s\n", profile, modelFitnessPath(root, profile)) + return nil +} + +// modelFitnessAttestation is the factory-root fitness marker. Its shape is +// implementer-defined; the design pins only "who / when / stage results". The +// launch interlock (hasFitnessAttestation) treats any file whose Profile matches as +// valid, so the record must at least carry the profile name. +type modelFitnessAttestation struct { + Profile string `json:"profile"` + AttestedBy string `json:"attested_by"` + AttestedAt string `json:"attested_at"` + Stages map[string]string `json:"stages,omitempty"` +} + +// hasFitnessAttestation reports whether a valid attestation exists for profile at the +// factory-root .runtime/model_fitness/<profile>.json. Fail-closed: any read/parse +// error or a profile mismatch counts as UNATTESTED, so a corrupt marker never grants +// fitness. Read by the selecting-launch interlock in resolveLaunchModelEnv. +func hasFitnessAttestation(root, profile string) bool { + data, err := os.ReadFile(modelFitnessPath(root, profile)) + if err != nil { + return false + } + var att modelFitnessAttestation + if err := json.Unmarshal(data, &att); err != nil { + return false + } + return att.Profile == profile +} + +// writeModelFitnessAttestation writes the attestation atomically to the factory-root +// .runtime/model_fitness dir (mirrors writeUpLastRun's MkdirAll + SaveModelsConfig's +// MarshalIndent + fsutil.WriteFileAtomic). The path is built inline — there is no +// RuntimeDir helper in internal/config. +func writeModelFitnessAttestation(root string, att modelFitnessAttestation) error { + dir := filepath.Join(root, ".runtime", "model_fitness") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(att, "", " ") + if err != nil { + return fmt.Errorf("marshaling attestation: %w", err) + } + data = append(data, '\n') + return fsutil.WriteFileAtomic(modelFitnessPath(root, att.Profile), data, 0o644) +} + +func modelFitnessPath(root, profile string) string { + return filepath.Join(root, ".runtime", "model_fitness", profile+".json") +} + +// secretRefPath resolves a file: secret reference to a path. A relative path is taken +// against root (the factory root), matching the Phase-2 emission deref; an absolute +// path is used as-is. Shared by the launch preflight (resolveLaunchModelEnv) and +// `check`. +func secretRefPath(root, ref string) string { + path := strings.TrimPrefix(ref, secretPrefix) + if filepath.IsAbs(path) { + return path + } + return filepath.Join(root, path) +} + +// displayModelValue applies the T-2 redaction contract to a single export value: a +// literal ANTHROPIC_AUTH_TOKEN becomes ****, a file: reference prints as-is, and every +// other key prints verbatim (ANTHROPIC_API_KEY is validated to always be empty). +func displayModelValue(key, val string) string { + if key == authTokenKey && val != "" && !strings.HasPrefix(val, secretPrefix) { + return "****" + } + return val +} + +// explainAgentPrecedence describes which models.json level an agent resolves through +// (agents map > default). A per-launch --model or a .runtime/model_override marker can +// still override at launch time — noted so the explanation is not read as absolute. +func explainAgentPrecedence(cfg *config.ModelsConfig, agent string) string { + if m, ok := cfg.Agents[agent]; ok { + return fmt.Sprintf("agent %q resolves to profile %q (via the agents map; a per-launch --model still overrides)", agent, m) + } + if cfg.Default != "" { + return fmt.Sprintf("agent %q resolves to profile %q (via default; a per-launch --model still overrides)", agent, cfg.Default) + } + return fmt.Sprintf("agent %q has no models.json selection (falls back to the global default model)", agent) +} + +func attestedBy() string { + for _, k := range []string{"AF_ACTOR", "USER", "LOGNAME"} { + if v := os.Getenv(k); v != "" { + return v + } + } + return "unknown" +} + +// loadModelsForRead is the read-only prologue shared by show/check/attest: resolve the +// factory root from the cwd, then LoadModelsConfig (NO stdin-decode, NO Save — that is +// the write path runConfigModelsSet uses). +func loadModelsForRead() (string, *config.ModelsConfig, error) { + wd, err := getWd() + if err != nil { + return "", nil, err + } + root, err := config.FindFactoryRoot(wd) + if err != nil { + return "", nil, err + } + cfg, err := config.LoadModelsConfig(root) + if err != nil { + return "", nil, err + } + return root, cfg, nil +} + +func modelIDPresent(ids []string, id string) bool { + for _, x := range ids { + if x == id { + return true + } + } + return false +} + +// sortedMapKeys returns the map keys sorted, for deterministic output. +func sortedMapKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/cmd/config_models_test.go b/internal/cmd/config_models_test.go new file mode 100644 index 0000000..b1e2f52 --- /dev/null +++ b/internal/cmd/config_models_test.go @@ -0,0 +1,263 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stempeck/agentfactory/internal/config" +) + +// runModelsCmd invokes a config-models subcommand's RunE in-process with positional +// args, capturing stdout+stderr into one buffer (mirrors runConfigSet, but threads +// args so `check`/`attest` can receive a profile name). +func runModelsCmd(t *testing.T, fn func(*cobra.Command, []string) error, args ...string) (string, error) { + t.Helper() + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + cmd.SetIn(strings.NewReader("")) + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + err := fn(cmd, args) + return buf.String(), err +} + +// --- AC-4: `show` redaction --- + +func TestConfigModelsShow_RedactsLiteralToken(t *testing.T) { + root := setupConfigFactory(t) + writeValidModels(t, root, &config.ModelsConfig{ + Models: map[string]map[string]string{ + // literal token on a LOOPBACK endpoint (the only place the validator allows a + // literal sk- token) — show must still redact it to ****. + "loop": { + "ANTHROPIC_MODEL": "local", + "ANTHROPIC_BASE_URL": "http://localhost:1234", + "ANTHROPIC_AUTH_TOKEN": "sk-SECRETLITERAL", + "ANTHROPIC_API_KEY": "", + }, + // a file: reference must print verbatim. + "remote": { + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_BASE_URL": "https://gw.example:4000", + "ANTHROPIC_AUTH_TOKEN": "file:secrets/x.key", + }, + }, + }) + + out, err := runModelsCmd(t, runConfigModelsShow) + if err != nil { + t.Fatalf("runConfigModelsShow: %v (out=%q)", err, out) + } + if !strings.Contains(out, "****") { + t.Errorf("show must redact a literal ANTHROPIC_AUTH_TOKEN to ****; out=%q", out) + } + if !strings.Contains(out, "file:secrets/x.key") { + t.Errorf("show must print a file: reference verbatim; out=%q", out) + } + if strings.Contains(out, "sk-SECRETLITERAL") { + t.Errorf("show must NEVER print the literal secret value (T-2); out=%q", out) + } +} + +// --- AC-4: `check` runs entirely through the httpProbe seam --- + +func TestConfigModelsCheck_UsesHTTPProbeSeam(t *testing.T) { + root := setupConfigFactory(t) + writeValidModels(t, root, &config.ModelsConfig{ + Models: map[string]map[string]string{ + "codex": { + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_BASE_URL": "https://gw.example:4000", + "ANTHROPIC_AUTH_TOKEN": "file:secrets/codex.key", + }, + }, + }) + writeSecretFile(t, root, "secrets/codex.key", "sk-real-value") + + orig := httpProbe + called := false + httpProbe = func(baseURL, authToken string) ([]string, error) { + called = true + return []string{"gpt-5.3-codex"}, nil // model present + } + t.Cleanup(func() { httpProbe = orig }) + + out, err := runModelsCmd(t, runConfigModelsCheck, "codex") + if err != nil { + t.Fatalf("check with a reachable, model-present gateway must succeed; err=%v out=%q", err, out) + } + if !called { + t.Error("check must reach the gateway ONLY through the httpProbe seam (it was never called)") + } + if !strings.Contains(out, "transport-level only") { + t.Errorf("check output must state it is transport-level only; out=%q", out) + } + if strings.Contains(out, "sk-real-value") { + t.Errorf("check must NEVER print token material; out=%q", out) + } +} + +func TestConfigModelsCheck_ModelAbsent_Warns(t *testing.T) { + root := setupConfigFactory(t) + writeValidModels(t, root, &config.ModelsConfig{ + Models: map[string]map[string]string{ + "codex": { + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_BASE_URL": "https://gw.example:4000", + "ANTHROPIC_AUTH_TOKEN": "file:secrets/codex.key", + }, + }, + }) + writeSecretFile(t, root, "secrets/codex.key", "sk-real-value") + + orig := httpProbe + httpProbe = func(baseURL, authToken string) ([]string, error) { + return []string{"some-other-model"}, nil // the profile's model id is ABSENT + } + t.Cleanup(func() { httpProbe = orig }) + + out, _ := runModelsCmd(t, runConfigModelsCheck, "codex") + if !strings.Contains(out, "not in GET /v1/models response") { + t.Errorf("a model id absent from the gateway must warn (not block); out=%q", out) + } +} + +func TestConfigModelsCheck_Unreachable_FailsNonZero(t *testing.T) { + root := setupConfigFactory(t) + writeValidModels(t, root, &config.ModelsConfig{ + Models: map[string]map[string]string{ + "codex": { + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_BASE_URL": "https://gw.example:4000", + "ANTHROPIC_AUTH_TOKEN": "file:secrets/codex.key", + }, + }, + }) + writeSecretFile(t, root, "secrets/codex.key", "sk-real-value") + + orig := httpProbe + httpProbe = func(baseURL, authToken string) ([]string, error) { + return nil, os.ErrDeadlineExceeded // unreachable + } + t.Cleanup(func() { httpProbe = orig }) + + out, err := runModelsCmd(t, runConfigModelsCheck, "codex") + if err == nil { + t.Fatalf("an unreachable endpoint must fail non-zero; out=%q", out) + } + if !strings.Contains(err.Error()+out, "unreachable") { + t.Errorf("the failure must state the endpoint is unreachable; err=%v out=%q", err, out) + } +} + +// TestConfigModels_HTTPProbeSeamExists backs the AC-4 grep gate +// (`grep -n 'var httpProbe' internal/cmd/config_models.go`). +func TestConfigModels_HTTPProbeSeamExists(t *testing.T) { + src, err := os.ReadFile("config_models.go") + if err != nil { + t.Fatalf("read config_models.go: %v", err) + } + if !strings.Contains(string(src), "var httpProbe") { + t.Error("config_models.go must declare the ADR-009 package-var seam `var httpProbe`") + } +} + +// --- AC-6: `attest` writes the attestation file --- + +func TestConfigModelsAttest_WritesAttestationFile(t *testing.T) { + root := setupConfigFactory(t) + writeValidModels(t, root, &config.ModelsConfig{ + Models: map[string]map[string]string{ + "codex": { + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_BASE_URL": "https://gw.example:4000", + "ANTHROPIC_AUTH_TOKEN": "file:secrets/codex.key", + }, + }, + }) + + out, err := runModelsCmd(t, runConfigModelsAttest, "codex") + if err != nil { + t.Fatalf("attest must succeed for a defined profile; err=%v out=%q", err, out) + } + attDir := filepath.Join(root, ".runtime", "model_fitness") + attPath := filepath.Join(attDir, "codex.json") + data, err := os.ReadFile(attPath) + if err != nil { + t.Fatalf("attest must write %s; %v", attPath, err) + } + var att struct { + Profile string `json:"profile"` + AttestedBy string `json:"attested_by"` + AttestedAt string `json:"attested_at"` + } + if err := json.Unmarshal(data, &att); err != nil { + t.Fatalf("attestation must be valid JSON: %v", err) + } + if att.Profile != "codex" { + t.Errorf("attestation must name the profile; got %+v", att) + } + if att.AttestedAt == "" { + t.Errorf("attestation must record when (attested_at); got %+v", att) + } + // Atomic write: no temp residue. + for _, e := range mustReadDir(t, attDir) { + if strings.HasSuffix(e, ".tmp") { + t.Errorf("temp residue after atomic attestation write: %s", e) + } + } +} + +func TestConfigModelsAttest_UndefinedProfile_Rejected(t *testing.T) { + root := setupConfigFactory(t) + writeValidModels(t, root, &config.ModelsConfig{ + Models: map[string]map[string]string{"codex": {"ANTHROPIC_MODEL": "gpt-5.3-codex"}}, + }) + _ = root + + _, err := runModelsCmd(t, runConfigModelsAttest, "ghost") + if err == nil { + t.Fatal("attest of an undefined profile must be rejected") + } + if !strings.Contains(err.Error(), "ghost") { + t.Errorf("rejection should name the undefined profile; got %v", err) + } +} + +// --- AC-2: subcommands registered --- + +func TestConfigModels_ShowCheckAttest_Registered(t *testing.T) { + findChild := func(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil + } + for _, sub := range []string{"show", "check", "attest"} { + if findChild(configModelsCmd, sub) == nil { + t.Errorf("`config models %s` is not registered under configModelsCmd", sub) + } + } +} + +// --- AC-5: --skip-fitness flag registered on both entrypoints --- + +func TestSling_SkipFitnessFlag_Registered(t *testing.T) { + if slingCmd.Flags().Lookup("skip-fitness") == nil { + t.Error("af sling must register a --skip-fitness flag") + } +} + +func TestUp_SkipFitnessFlag_Registered(t *testing.T) { + if upCmd.Flags().Lookup("skip-fitness") == nil { + t.Error("af up must register a --skip-fitness flag") + } +} diff --git a/internal/cmd/config_set.go b/internal/cmd/config_set.go index 8713087..030dcfb 100644 --- a/internal/cmd/config_set.go +++ b/internal/cmd/config_set.go @@ -45,6 +45,20 @@ leaves the existing file untouched.`, RunE: runConfigStartupSet, } +var configModelsCmd = &cobra.Command{ + Use: "models", + Short: "Manage model configuration (models.json)", +} + +var configModelsSetCmd = &cobra.Command{ + Use: "set", + Short: "Replace models.json from a JSON document on stdin", + Long: `Read a complete ModelsConfig as JSON on stdin, validate it, and write it +atomically to models.json. On validation failure the command exits non-zero and +leaves the existing file untouched.`, + RunE: runConfigModelsSet, +} + func init() { // No --json flag: these commands only ever read JSON from stdin, so a --json flag // would be a dead control (never consulted) that misrepresents the CLI contract. @@ -53,6 +67,9 @@ func init() { configStartupCmd.AddCommand(configStartupSetCmd) configCmd.AddCommand(configStartupCmd) + + configModelsCmd.AddCommand(configModelsSetCmd) + configCmd.AddCommand(configModelsCmd) } // decodeJSONStdin decodes a single JSON document from the command's input (stdin @@ -86,7 +103,13 @@ func runConfigDispatchSet(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("loading agents.json for cross-file validation: %w", err) } - if err := config.ValidateDispatchConfig(&cfg, agents); err != nil { + // models.json feeds the per-mapping model cross-check. This is a NON-selecting + // read (af config dispatch set writes dispatch.json, never launches a profile), so + // a validation error in a half-edited models.json must not block an otherwise-valid + // dispatch write: it warns and falls through with a nil config, skipping the + // cross-check rather than hard-failing (PR #482). + models := loadModelsConfigForCrossCheck(root, cmd.ErrOrStderr()) + if err := config.ValidateDispatchConfig(&cfg, agents, models); err != nil { return err } @@ -119,3 +142,28 @@ func runConfigStartupSet(cmd *cobra.Command, _ []string) error { fmt.Fprintln(cmd.OutOrStdout(), "Startup configuration saved.") return nil } + +func runConfigModelsSet(cmd *cobra.Command, _ []string) error { + wd, err := getWd() + if err != nil { + return err + } + root, err := config.FindFactoryRoot(wd) + if err != nil { + return err + } + + var cfg config.ModelsConfig + if err := decodeJSONStdin(cmd, &cfg); err != nil { + return err + } + + // SaveModelsConfig re-runs validateModelsConfig before the atomic write, so an + // invalid registry (AF_* key, real api key, incomplete endpoint, undefined + // agent/default model) is rejected and the existing file is left untouched. + if err := config.SaveModelsConfig(config.ModelsConfigPath(root), &cfg); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Models configuration saved.") + return nil +} diff --git a/internal/cmd/config_set_test.go b/internal/cmd/config_set_test.go index 1267bcd..1a94794 100644 --- a/internal/cmd/config_set_test.go +++ b/internal/cmd/config_set_test.go @@ -65,6 +65,96 @@ func TestConfigDispatchSet_RejectsUnknownAgent(t *testing.T) { } } +// TestConfigDispatchSet_TolerantOfIncompleteModelsJson (PR #482): af config dispatch +// set is NOT a profile-selecting path, so a validation-failing models.json (here an +// incomplete endpoint — base_url without auth_token) must NOT hard-fail an +// otherwise-valid dispatch write. Mirrors the launch path's warn-and-fall-through +// policy: warn, drop the model cross-check, proceed. +func TestConfigDispatchSet_TolerantOfIncompleteModelsJson(t *testing.T) { + root := setupConfigFactory(t) + + // Incomplete endpoint: base_url without auth_token — LoadModelsConfig rejects it. + if err := os.WriteFile(config.ModelsConfigPath(root), + []byte(`{"models":{"local":{"ANTHROPIC_BASE_URL":"http://x:1"}}}`), 0o644); err != nil { + t.Fatalf("seed models.json: %v", err) + } + + // A dispatch doc with NO model-bearing mapping — models.json is irrelevant to it. + body := `{"repos":["o/r"],"trigger_label":"agentic","mappings":[{"labels":["bug"],"agent":"debugger"}]}` + out, err := runConfigSet(t, runConfigDispatchSet, body) + if err != nil { + t.Fatalf("an incomplete models.json must not block an unrelated dispatch write; err=%v out=%q", err, out) + } + if !strings.Contains(out, "models.json") { + t.Errorf("the fall-through must warn about ignoring models.json; out=%q", out) + } + if _, e := config.LoadDispatchConfig(root); e != nil { + t.Fatalf("dispatch.json should have been written despite the bad models.json: %v", e) + } +} + +// TestConfigDispatchSet_ValidModelsJson_UndefinedModel_StillRejected is the +// no-regression guard: when models.json loads cleanly, the per-mapping model +// cross-check must STILL reject a mapping naming an undefined model. The +// tolerance added for a BROKEN models.json must not weaken validation of a GOOD one. +func TestConfigDispatchSet_ValidModelsJson_UndefinedModel_StillRejected(t *testing.T) { + root := setupConfigFactory(t) + + if err := os.WriteFile(config.ModelsConfigPath(root), + []byte(`{"models":{"opus":{"ANTHROPIC_MODEL":"claude-opus-4-8"}}}`), 0o644); err != nil { + t.Fatalf("seed models.json: %v", err) + } + + body := `{"repos":["o/r"],"trigger_label":"agentic","mappings":[{"labels":["bug"],"agent":"debugger","model":"ghost-model"}]}` + _, err := runConfigSet(t, runConfigDispatchSet, body) + if err == nil { + t.Fatal("a mapping naming an undefined model must still be rejected when models.json is valid") + } + if !strings.Contains(err.Error(), "ghost-model") { + t.Errorf("rejection should name the undefined model; got %v", err) + } +} + +// TestLoadModelsConfigForCrossCheck_IncompleteEndpoint_WarnsReturnsNil unit-tests the +// shared helper both af dispatch and af config dispatch set use: a validation-failing +// models.json (incomplete endpoint) must warn and fall through to nil (so +// ValidateDispatchConfig skips the model cross-check), never a hard error. +func TestLoadModelsConfigForCrossCheck_IncompleteEndpoint_WarnsReturnsNil(t *testing.T) { + root := t.TempDir() + os.MkdirAll(filepath.Join(root, ".agentfactory"), 0o755) + os.WriteFile(config.ModelsConfigPath(root), + []byte(`{"models":{"local":{"ANTHROPIC_BASE_URL":"http://x:1"}}}`), 0o644) + + var warn bytes.Buffer + if got := loadModelsConfigForCrossCheck(root, &warn); got != nil { + t.Errorf("a validation-failing models.json must fall through to nil; got %+v", got) + } + if !strings.Contains(warn.String(), "models.json") { + t.Errorf("must warn about ignoring models.json; got %q", warn.String()) + } +} + +// TestLoadModelsConfigForCrossCheck_Valid_ReturnsConfig is the companion: a clean +// models.json loads, is returned (so the cross-check still runs), and does not warn. +func TestLoadModelsConfigForCrossCheck_Valid_ReturnsConfig(t *testing.T) { + root := t.TempDir() + os.MkdirAll(filepath.Join(root, ".agentfactory"), 0o755) + os.WriteFile(config.ModelsConfigPath(root), + []byte(`{"models":{"opus":{"ANTHROPIC_MODEL":"claude-opus-4-8"}}}`), 0o644) + + var warn bytes.Buffer + got := loadModelsConfigForCrossCheck(root, &warn) + if got == nil { + t.Fatal("a valid models.json must be returned, not nil") + } + if _, ok := got.Models["opus"]; !ok { + t.Errorf("returned config missing the 'opus' profile: %+v", got) + } + if warn.Len() != 0 { + t.Errorf("a valid models.json must not warn; got %q", warn.String()) + } +} + func TestConfigSet_AtomicValidatedWrite(t *testing.T) { root := setupConfigFactory(t) afDir := filepath.Join(root, ".agentfactory") @@ -128,6 +218,76 @@ func TestConfigSet_CommandsRegisteredUnderConfig(t *testing.T) { } } +// TestConfigModelsSet_RoundTrip (issue #480): `af config models set` reads a +// ModelsConfig on stdin, validates it (via SaveModelsConfig's internal +// validateModelsConfig), and writes models.json atomically — and rejects malformed +// input without touching the file. It also asserts the command is reachable under the +// EXISTING config parent. +func TestConfigModelsSet_RoundTrip(t *testing.T) { + root := setupConfigFactory(t) + afDir := filepath.Join(root, ".agentfactory") + + // A valid registry persists and reloads identically. + body := `{"default":"opus","models":{"opus":{"ANTHROPIC_MODEL":"claude-opus-4-8"},"lmstudio":{"ANTHROPIC_BASE_URL":"http://localhost:1234","ANTHROPIC_AUTH_TOKEN":"lm-studio","ANTHROPIC_MODEL":"local","ANTHROPIC_API_KEY":""}},"agents":{"manager":"opus"}}` + out, err := runConfigSet(t, runConfigModelsSet, body) + if err != nil { + t.Fatalf("runConfigModelsSet: %v (out=%q)", err, out) + } + for _, e := range mustReadDir(t, afDir) { + if strings.HasSuffix(e, ".tmp") { + t.Errorf("temp residue after atomic write: %s", e) + } + } + loaded, err := config.LoadModelsConfig(root) + if err != nil { + t.Fatalf("reload models.json: %v", err) + } + if loaded.Default != "opus" { + t.Errorf("default round-trip = %q, want \"opus\"", loaded.Default) + } + if loaded.Agents["manager"] != "opus" { + t.Errorf("agents map round-trip = %v, want manager->opus", loaded.Agents) + } + if loaded.Models["opus"]["ANTHROPIC_MODEL"] != "claude-opus-4-8" { + t.Errorf("opus profile round-trip mismatch: %+v", loaded.Models["opus"]) + } + if v, ok := loaded.Models["lmstudio"]["ANTHROPIC_API_KEY"]; !ok || v != "" { + t.Errorf("empty ANTHROPIC_API_KEY must survive round-trip: present=%v val=%q", ok, v) + } + + // An invalid registry (incomplete endpoint: base_url without auth_token) is rejected + // by the on-write validator and leaves the existing file untouched. + before, _ := os.ReadFile(config.ModelsConfigPath(root)) + bad := `{"models":{"broken":{"ANTHROPIC_BASE_URL":"http://localhost:9999"}}}` + if _, err := runConfigSet(t, runConfigModelsSet, bad); err == nil { + t.Error("expected error for an incomplete-endpoint registry") + } + after, _ := os.ReadFile(config.ModelsConfigPath(root)) + if !bytes.Equal(before, after) { + t.Errorf("models.json was modified on a rejected write:\nbefore=%s\nafter=%s", before, after) + } + + // Malformed JSON on stdin is rejected non-zero. + if _, err := runConfigSet(t, runConfigModelsSet, `{not json`); err == nil { + t.Error("expected error for malformed JSON stdin") + } +} + +func TestConfigModelsSet_RegisteredUnderConfig(t *testing.T) { + findChild := func(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil + } + models := findChild(configCmd, "models") + if models == nil || findChild(models, "set") == nil { + t.Error("`config models set` is not registered under configCmd") + } +} + func mustReadDir(t *testing.T, dir string) []string { t.Helper() entries, err := os.ReadDir(dir) diff --git a/internal/cmd/dispatch.go b/internal/cmd/dispatch.go index 44f89f0..75de60a 100644 --- a/internal/cmd/dispatch.go +++ b/internal/cmd/dispatch.go @@ -139,11 +139,17 @@ func runDispatch(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("loading agent config: %w", err) } + // models.json feeds the per-mapping model cross-check (issue #480). Loading it + // is a NON-selecting read: a validation error (e.g. a half-edited incomplete + // endpoint) must not brick the daemon, so it warns and falls through with a nil + // config — the cross-check is skipped, not hard-failed (PR #482). + modelsCfg := loadModelsConfigForCrossCheck(root, cmd.ErrOrStderr()) // Cross-validate via the shared single source of truth: every mapping agent and an - // explicitly set notify_on_complete must exist in agents.json. config.ValidateDispatchConfig - // is the same validator the CLI/web config-write paths use, so the rule cannot drift. - if err := config.ValidateDispatchConfig(dispatchCfg, agentsCfg); err != nil { + // explicitly set notify_on_complete must exist in agents.json, and every mapping + // model must be a defined models.json profile. config.ValidateDispatchConfig is the + // same validator the CLI/web config-write paths use, so the rule cannot drift. + if err := config.ValidateDispatchConfig(dispatchCfg, agentsCfg, modelsCfg); err != nil { return err } @@ -219,7 +225,7 @@ func runDispatch(cmd *cobra.Command, args []string) error { continue } - agent := matchItemToAgent(item, itemMappings[i]) + agent, model := matchItemToAgent(item, itemMappings[i]) if agent == "" { stats.skipped++ continue @@ -259,7 +265,7 @@ func runDispatch(cmd *cobra.Command, args []string) error { // Phase 2 adds no workflow branch here; the captured sling stdout is // the Phase-3 fallback source for instance-ID capture and is discarded // on the non-workflow path (C-10: observable behavior unchanged). - if _, err := dispatchItem(root, agent, item.URL, dispatchCfg.NotifyOnComplete); err != nil { + if _, err := dispatchItem(root, agent, item.URL, dispatchCfg.NotifyOnComplete, model); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "dispatch %s failed: %v\n", itemKey, err) stats.errors++ continue @@ -332,9 +338,12 @@ func queryGitHubPRs(repo, triggerLabel string) ([]ghItem, error) { return items, nil } -// matchItemToAgent returns the agent name for the first mapping whose labels -// are ALL present on the item (AND semantics), or empty string if no mapping matches. -func matchItemToAgent(item ghItem, mappings []config.DispatchMapping) string { +// matchItemToAgent returns the agent name and the per-mapping model (issue #480) for +// the first mapping whose labels are ALL present on the item (AND semantics), or two +// empty strings if no mapping matches. The model is surfaced from the SAME matched +// mapping so the dispatch argv can thread `--model` without re-matching; an empty +// model leaves the agent on its durable default. +func matchItemToAgent(item ghItem, mappings []config.DispatchMapping) (agent, model string) { itemLabels := make(map[string]bool, len(item.Labels)) for _, l := range item.Labels { itemLabels[l.Name] = true @@ -348,10 +357,10 @@ func matchItemToAgent(item ghItem, mappings []config.DispatchMapping) string { } } if allMatch && len(m.Labels) > 0 { - return m.Agent + return m.Agent, m.Model } } - return "" + return "", "" } // groupMappingsBySource splits mappings into issue and PR groups based on Source field. @@ -401,7 +410,24 @@ var editItemLabels = func(repo string, number int, source string, add, remove [] return exec.Command("gh", args...).Run() // one atomic gh edit (W-8) } -// dispatchItem invokes af sling --agent <name> --reset [--caller <caller>] <itemURL>. +// buildSlingArgs builds the `af sling` argv for a dispatched item: +// `sling --agent <name> --reset [--caller <caller>] [--model <model>] <itemURL>`. +// --reset is unconditional; --caller and --model (issue #480) are appended only when +// non-empty, mirroring each other, before the positional itemURL. Factored out of +// dispatchItem so the argv contract is unit-testable without spawning a subprocess. +func buildSlingArgs(agent, caller, model, itemURL string) []string { + args := []string{"sling", "--agent", agent, "--reset"} + if caller != "" { + args = append(args, "--caller", caller) + } + if model != "" { + args = append(args, "--model", model) + } + args = append(args, itemURL) + return args +} + +// dispatchItem invokes af sling --agent <name> --reset [--caller <caller>] [--model <model>] <itemURL>. // // It returns sling's captured stdout alongside the exit error. The operator still // sees that stdout live (it is tee'd through os.Stdout via io.MultiWriter), so the @@ -410,21 +436,19 @@ var editItemLabels = func(repo string, number int, source string, add, remove [] // is the dir-agnostic fallback source for captureInstanceID (issue #378 K4); the // non-workflow caller discards it. // +// model (issue #480) is the matched mapping's per-mapping model profile; empty +// leaves the slung agent on its durable default (no --model appended). +// // Package-var seam (issue #378 Phase 3): the workflow engine slings phases through // this var so tests swap it to a recorder without spawning a real `af sling` // subprocess. Promotion is behavior-preserving for production (same argv, same // stderr, same exit semantics) — C-10 observable behavior unchanged. -var dispatchItem = func(root, agent, itemURL, caller string) (string, error) { +var dispatchItem = func(root, agent, itemURL, caller, model string) (string, error) { afBin, err := os.Executable() if err != nil { afBin = "af" } - args := []string{"sling", "--agent", agent, "--reset"} - if caller != "" { - args = append(args, "--caller", caller) - } - args = append(args, itemURL) - c := exec.Command(afBin, args...) + c := exec.Command(afBin, buildSlingArgs(agent, caller, model, itemURL)...) c.Dir = root var buf bytes.Buffer c.Stdout = io.MultiWriter(os.Stdout, &buf) @@ -1315,7 +1339,9 @@ func (w *workflowCtx) slingPhase(phase, agent string, attempts int) error { if err != nil { return fmt.Errorf("%w for %q: %w", errResolvePhaseInput, phase, err) } - stdout, err := dispatchItem(w.root, agent, inputURL, w.dispatchCfg.NotifyOnComplete) + // Workflow phases carry no per-mapping model today (model threading is scoped to + // the non-workflow dispatch path); pass "" so the phase agent stays on its default. + stdout, err := dispatchItem(w.root, agent, inputURL, w.dispatchCfg.NotifyOnComplete, "") if err != nil { return err } diff --git a/internal/cmd/dispatch_increment_test.go b/internal/cmd/dispatch_increment_test.go index e387340..2889300 100644 --- a/internal/cmd/dispatch_increment_test.go +++ b/internal/cmd/dispatch_increment_test.go @@ -47,7 +47,7 @@ func TestResling_PreservesAttemptsWhenSlingFails(t *testing.T) { var slingCount int orig := dispatchItem - dispatchItem = func(root, agent, itemURL, caller string) (string, error) { + dispatchItem = func(root, agent, itemURL, caller, model string) (string, error) { slingCount++ return "", fmt.Errorf("sling boom (af unreachable)") } @@ -87,7 +87,7 @@ func TestResling_CeilingFiresOnPersistentSlingFailure(t *testing.T) { var slingCount int orig := dispatchItem - dispatchItem = func(root, agent, itemURL, caller string) (string, error) { + dispatchItem = func(root, agent, itemURL, caller, model string) (string, error) { slingCount++ return "", fmt.Errorf("sling boom (af unreachable)") } diff --git a/internal/cmd/dispatch_model_test.go b/internal/cmd/dispatch_model_test.go new file mode 100644 index 0000000..9d9f7e8 --- /dev/null +++ b/internal/cmd/dispatch_model_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "testing" + + "github.com/stempeck/agentfactory/internal/config" +) + +// TestDispatch_MappingModel_ThreadsArgv (issue #480): a dispatch mapping's +// Model is surfaced from the matched mapping (matchItemToAgent) and threaded into the +// `af sling` argv as `--model <name>` (buildSlingArgs) — and omitted when the mapping +// names no model. These are the two links in the chain dispatch.go wires together at +// the call site (matchItemToAgent -> dispatchItem). +func TestDispatch_MappingModel_ThreadsArgv(t *testing.T) { + mappings := []config.DispatchMapping{ + {Labels: []string{"bug"}, Agent: "debugger", Model: "opus"}, + {Labels: []string{"chore"}, Agent: "janitor"}, // no model + } + + // 1. The matched mapping's Model is surfaced alongside the agent (no re-match). + item := ghItem{Labels: []ghLabel{{Name: "bug"}}} + agent, model := matchItemToAgent(item, mappings) + if agent != "debugger" { + t.Fatalf("matchItemToAgent agent = %q, want \"debugger\"", agent) + } + if model != "opus" { + t.Fatalf("matchItemToAgent must surface the mapping model; got %q, want \"opus\"", model) + } + + // 2. buildSlingArgs threads `--model opus` (immediately followed by the value) into + // the sling argv, before the positional itemURL. + args := buildSlingArgs(agent, "manager", model, "https://example/issues/1") + if !containsAdjacent(args, "--model", "opus") { + t.Errorf("argv must contain \"--model\" immediately followed by \"opus\"; got %v", args) + } + if args[len(args)-1] != "https://example/issues/1" { + t.Errorf("itemURL must remain the final positional arg; got %v", args) + } + + // 3. A mapping with no model must NOT emit --model. + choreItem := ghItem{Labels: []ghLabel{{Name: "chore"}}} + cAgent, cModel := matchItemToAgent(choreItem, mappings) + if cAgent != "janitor" || cModel != "" { + t.Fatalf("no-model mapping: agent=%q model=%q, want janitor/\"\"", cAgent, cModel) + } + noModelArgs := buildSlingArgs(cAgent, "manager", cModel, "https://example/issues/2") + for _, a := range noModelArgs { + if a == "--model" { + t.Errorf("no --model must be emitted when the mapping names no model; got %v", noModelArgs) + } + } +} + +// containsAdjacent reports whether want is immediately followed by val in args. +func containsAdjacent(args []string, want, val string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == want && args[i+1] == val { + return true + } + } + return false +} diff --git a/internal/cmd/dispatch_phase2_test.go b/internal/cmd/dispatch_phase2_test.go index 2644e06..ecdbc38 100644 --- a/internal/cmd/dispatch_phase2_test.go +++ b/internal/cmd/dispatch_phase2_test.go @@ -24,7 +24,7 @@ func TestMatchItemToAgent_ANDSemantics_AllLabelsMatch(t *testing.T) { {Labels: []string{"bug", "backend"}, Agent: "debugger"}, } - got := matchItemToAgent(item, mappings) + got, _ := matchItemToAgent(item, mappings) if got != "debugger" { t.Errorf("matchItemToAgent() = %q, want %q", got, "debugger") } @@ -41,7 +41,7 @@ func TestMatchItemToAgent_ANDSemantics_PartialMatchFails(t *testing.T) { {Labels: []string{"bug", "backend"}, Agent: "debugger"}, } - got := matchItemToAgent(item, mappings) + got, _ := matchItemToAgent(item, mappings) if got != "" { t.Errorf("matchItemToAgent() = %q, want empty string (partial match should fail)", got) } @@ -58,7 +58,7 @@ func TestMatchItemToAgent_ANDSemantics_SingleLabelBackwardCompat(t *testing.T) { {Labels: []string{"bug-triage"}, Agent: "debugger"}, } - got := matchItemToAgent(item, mappings) + got, _ := matchItemToAgent(item, mappings) if got != "debugger" { t.Errorf("matchItemToAgent() = %q, want %q", got, "debugger") } @@ -76,7 +76,7 @@ func TestMatchItemToAgent_ANDSemantics_FirstMatchWins(t *testing.T) { {Labels: []string{"docs"}, Agent: "writer"}, } - got := matchItemToAgent(item, mappings) + got, _ := matchItemToAgent(item, mappings) if got != "debugger" { t.Errorf("matchItemToAgent() = %q, want %q (first match wins)", got, "debugger") } @@ -94,7 +94,7 @@ func TestMatchItemToAgent_ANDSemantics_NoMatch(t *testing.T) { {Labels: []string{"docs"}, Agent: "writer"}, } - got := matchItemToAgent(item, mappings) + got, _ := matchItemToAgent(item, mappings) if got != "" { t.Errorf("matchItemToAgent() = %q, want empty string", got) } diff --git a/internal/cmd/dispatch_phase3_test.go b/internal/cmd/dispatch_phase3_test.go index 2a4e628..41109e3 100644 --- a/internal/cmd/dispatch_phase3_test.go +++ b/internal/cmd/dispatch_phase3_test.go @@ -60,7 +60,7 @@ func recordLabelEdits(t *testing.T, edits *[]labelEdit) { func recordSlings(t *testing.T, store *memstore.Store, slung *[]string, ids *[]string) { t.Helper() orig := dispatchItem - dispatchItem = func(root, agent, itemURL, caller string) (string, error) { + dispatchItem = func(root, agent, itemURL, caller, model string) (string, error) { *slung = append(*slung, agent) iss, err := store.Create(context.Background(), issuestore.CreateParams{ Title: "Formula: " + agent, Type: issuestore.TypeEpic, @@ -437,7 +437,7 @@ func TestWorkflow_NotComplete_IdleAgent_ReslingsSamePhase_ClearsRecord(t *testin state := dispatchState{Dispatched: map[string]dispatchEntry{ "owner/repo#7": { Agent: "impl", Workflow: "feature-workflow", Phase: "enhancement", - PhaseInstanceID: notComplete.ID, + PhaseInstanceID: notComplete.ID, PhaseDispatchedAt: time.Now().Add(-time.Hour), // beyond the retry window Attempts: 1, }, @@ -533,8 +533,8 @@ func TestWorkflow_Item_NeverTouchesNonWorkflowRetryWindow(t *testing.T) { state := dispatchState{Dispatched: map[string]dispatchEntry{ "owner/repo#7": { Agent: "impl", Workflow: "feature-workflow", Phase: "enhancement", - PhaseInstanceID: complete.ID, - DispatchedAt: time.Now(), // within the non-workflow retry window + PhaseInstanceID: complete.ID, + DispatchedAt: time.Now(), // within the non-workflow retry window PhaseDispatchedAt: time.Now(), }, }} @@ -616,7 +616,7 @@ func TestWorkflow_PhaseAgent_DirectLabelLookup_NotShadowedByWorkflowLabelMapping // Sanity: the shadowing first-match path WOULD mis-route. shadowItem := ghItem{Labels: labels("agentic", "feature-workflow", "enhancement")} - if got := matchItemToAgent(shadowItem, cfg.Mappings); got != "WRONG" { + if got, _ := matchItemToAgent(shadowItem, cfg.Mappings); got != "WRONG" { t.Fatalf("precondition: matchItemToAgent first-match = %q, want WRONG (shadow setup)", got) } diff --git a/internal/cmd/dispatch_source_aware_resolution_test.go b/internal/cmd/dispatch_source_aware_resolution_test.go index fa6596a..a62509f 100644 --- a/internal/cmd/dispatch_source_aware_resolution_test.go +++ b/internal/cmd/dispatch_source_aware_resolution_test.go @@ -25,7 +25,7 @@ import ( func recordSlingsWithURL(t *testing.T, store *memstore.Store, slung, urls, ids *[]string) { t.Helper() orig := dispatchItem - dispatchItem = func(root, agent, itemURL, caller string) (string, error) { + dispatchItem = func(root, agent, itemURL, caller, model string) (string, error) { *slung = append(*slung, agent) *urls = append(*urls, itemURL) iss, err := store.Create(context.Background(), issuestore.CreateParams{ diff --git a/internal/cmd/dispatch_status_json_test.go b/internal/cmd/dispatch_status_json_test.go index fcc697b..ef501ee 100644 --- a/internal/cmd/dispatch_status_json_test.go +++ b/internal/cmd/dispatch_status_json_test.go @@ -48,7 +48,7 @@ func TestRunDispatch_NoInlineCrossValidationDuplicate(t *testing.T) { if strings.Contains(string(src), `notify_on_complete agent %q not found in agents.json`) { t.Errorf("internal/cmd/dispatch.go still contains the inline notify cross-validation; replace it with config.ValidateDispatchConfig") } - if !strings.Contains(string(src), "config.ValidateDispatchConfig(dispatchCfg, agentsCfg)") { - t.Errorf("runDispatch should delegate to config.ValidateDispatchConfig(dispatchCfg, agentsCfg)") + if !strings.Contains(string(src), "config.ValidateDispatchConfig(dispatchCfg, agentsCfg, modelsCfg)") { + t.Errorf("runDispatch should delegate to config.ValidateDispatchConfig(dispatchCfg, agentsCfg, modelsCfg)") } } diff --git a/internal/cmd/dispatch_test.go b/internal/cmd/dispatch_test.go index e89b5b2..84f15ad 100644 --- a/internal/cmd/dispatch_test.go +++ b/internal/cmd/dispatch_test.go @@ -55,7 +55,7 @@ func TestMatchItemToAgent(t *testing.T) { item.Labels = append(item.Labels, ghLabel{Name: l}) } - got := matchItemToAgent(item, mappings) + got, _ := matchItemToAgent(item, mappings) if got != tc.want { t.Errorf("matchItemToAgent() = %q, want %q", got, tc.want) } @@ -758,7 +758,7 @@ func TestMatchItemToAgent_MultiLabel_AND(t *testing.T) { item.Labels = append(item.Labels, ghLabel{Name: l}) } - got := matchItemToAgent(item, mappings) + got, _ := matchItemToAgent(item, mappings) if got != tc.want { t.Errorf("matchItemToAgent() = %q, want %q", got, tc.want) } @@ -776,7 +776,7 @@ func TestMatchItemToAgent_MultiLabel_AND(t *testing.T) { URL: "https://github.com/owner/repo/issues/2", Labels: []ghLabel{{Name: "a"}, {Name: "b"}, {Name: "c"}}, } - got := matchItemToAgent(item, m) + got, _ := matchItemToAgent(item, m) if got != "agent2" { t.Errorf("matchItemToAgent() = %q, want %q", got, "agent2") } diff --git a/internal/cmd/done.go b/internal/cmd/done.go index 3b0aa9c..e0097ae 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -39,7 +39,7 @@ If more steps remain, it outputs the next step. If all steps are complete, it mails WORK_DONE to the dispatcher and cleans up the checkpoint. For gate steps, use --phase-complete --gate <id> to register as a gate waiter. -The session ends and a fresh agent is dispatched when the gate resolves.`, +Run 'af prime' afterward to load your next step and continue.`, RunE: runDone, } @@ -137,8 +137,7 @@ func runDoneCore(ctx context.Context, cwd string, phaseComplete bool, gate strin } // Try to close the gate bead as phase-complete signal _ = store.Close(ctx, gate, "") - fmt.Printf("✓ Phase complete. Gate %s registered. Session ending.\n", gate) - return nil + fmt.Printf("✓ Phase complete. Gate %s registered. Continue: run 'af prime' to load your next step.\n", gate) } openChildren, err := store.List(ctx, issuestore.Filter{ diff --git a/internal/cmd/done_test.go b/internal/cmd/done_test.go index bd6d3bb..139e48d 100644 --- a/internal/cmd/done_test.go +++ b/internal/cmd/done_test.go @@ -221,6 +221,67 @@ func TestRunDone_PhaseComplete_RequiresGate(t *testing.T) { } } +// Issue #517: a gate step that is also a formula's FINAL step must still +// reach the WORK_DONE completion path (formula-instance epic closed, +// .runtime/hooked_formula removed) instead of short-circuiting at the gate +// branch's early return. +func TestRunDone_PhaseCompleteGate_FinalStep_ReachesCompletion(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + workDir := filepath.Join(dir, ".agentfactory", "agents", "manager") + ctx := t.Context() + + mem := memstore.New() + instance, err := mem.Create(ctx, issuestore.CreateParams{ + Title: "Formula: gate-final-step-test", + Type: issuestore.TypeEpic, + Labels: []string{"formula-instance"}, + }) + if err != nil { + t.Fatalf("seed instance: %v", err) + } + + // The ONLY child step — this is the formula's final (and only) step, and + // the agent treats it as a gate: `af done --phase-complete --gate <id>`. + step, err := mem.Create(ctx, issuestore.CreateParams{ + Title: "Step: GATE final approval", + Parent: instance.ID, + Type: issuestore.TypeTask, + Labels: []string{"formula-step"}, + Assignee: "manager", + }) + if err != nil { + t.Fatalf("seed step: %v", err) + } + + origNewIssueStore := newIssueStore + newIssueStore = func(_, _ string) (issuestore.Store, error) { return mem, nil } + defer func() { newIssueStore = origNewIssueStore }() + + writeRuntimeFile(t, workDir, "hooked_formula", instance.ID) + writeStepPrimed(workDir, step.ID, "") + + // phaseComplete=true, gate=<step.ID> (agents always pass the step's own + // bead ID as the gate id, per prime.go) — this is the scenario that, before + // the #517 fix, returns early at done.go's gate branch before ever reaching + // the completion check. + if err := runDoneCore(ctx, workDir, true, step.ID); err != nil { + t.Fatalf("runDoneCore (gate, final step): %v", err) + } + + // Proof the completion path ran, not the gate branch's early return: the + // formula-instance epic must be terminal. + got, err := mem.Get(ctx, instance.ID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + if !got.Status.IsTerminal() { + t.Errorf("formula-instance epic status = %q, want terminal — a final-step gate must reach the WORK_DONE completion path", got.Status) + } + if _, err := os.Stat(filepath.Join(workDir, ".runtime", "hooked_formula")); !os.IsNotExist(err) { + t.Error("hooked_formula should be cleaned up when a final-step gate reaches completion") + } +} + // AC12: TestRunDone_RemovesCheckpoint — checkpoint removed on completion func TestRunDone_RemovesCheckpoint(t *testing.T) { dir := t.TempDir() diff --git a/internal/cmd/formula.go b/internal/cmd/formula.go index 486c728..b1239c6 100644 --- a/internal/cmd/formula.go +++ b/internal/cmd/formula.go @@ -472,7 +472,7 @@ func generateAgentTemplate(f *formula.Formula, agentName string, agentType strin b.WriteString("- `af prime` — Re-inject identity and formula step context\n") b.WriteString("- `af done` — Close current step and advance\n") if hasGates { - b.WriteString("- `af done --phase-complete --gate <id>` — Complete a gate step (session ends)\n") + b.WriteString("- `af done --phase-complete --gate <id>` — Complete a gate step (continue via `af prime`)\n") } b.WriteString("- `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group\n") b.WriteString("- `af mail inbox` — List unread messages\n") @@ -638,7 +638,7 @@ func generateGateProtocol(f *formula.Formula) string { b.WriteString("that cannot be closed until an external condition is met. When you reach a gate step:\n") b.WriteString("1. Complete the work described in the step\n") b.WriteString("2. Run `af done --phase-complete --gate <gate-id>`\n") - b.WriteString("3. Your session ends. A fresh agent resumes when the gate resolves.\n\n") + b.WriteString("3. Then run `af prime` to load your next step and continue.\n\n") return b.String() } diff --git a/internal/cmd/formula_test.go b/internal/cmd/formula_test.go index 23253df..af05e44 100644 --- a/internal/cmd/formula_test.go +++ b/internal/cmd/formula_test.go @@ -314,6 +314,45 @@ func TestGenerateAgentTemplate_DualGateDetection(t *testing.T) { } } +// Issue #517: the gate protocol's step 3 must instruct the agent to continue +// via `af prime` in the same session, not claim its session ends. +func TestGenerateAgentTemplate_GateProtocol_ContinuationWording(t *testing.T) { + f := &formula.Formula{ + Name: "gated-formula", + Type: formula.TypeWorkflow, + Steps: []formula.Step{ + {ID: "step1", Title: "Do Work", Description: "Work"}, + {ID: "step2", Title: "Wait", Description: "Gate step", Gate: &formula.Gate{ + Type: "approval", ID: "gate-1", + }}, + }, + } + + content := generateAgentTemplate(f, f.Name, "autonomous") + + if strings.Contains(content, "Your session ends. A fresh agent resumes when the gate resolves.") { + t.Error("gate protocol still contains the stale 'session ends' sentence") + } + // Broader than the exact sentence above: the generated doc has more than one + // spot that used to claim a session-ending lifecycle (the Gate Steps section + // AND the Available Commands bullet) — assert the whole doc is clean, not + // just the one sentence this fix originally targeted. + if strings.Contains(content, "session ends") { + t.Error("generated agent doc still contains a 'session ends' claim somewhere") + } + if !strings.Contains(content, "af prime") { + t.Error("gate protocol should instruct the agent to continue via `af prime`, not stop") + } + // The rest of the gate protocol (steps 1-2, header, count, structure table) + // must survive unchanged — this is a reword, not a removal. + if !strings.Contains(content, "### Gate Steps") { + t.Error("gate protocol section header missing") + } + if !strings.Contains(content, "af done --phase-complete --gate") { + t.Error("gate completion command missing from gate protocol") + } +} + func TestGenerateAgentTemplate_NameOverride(t *testing.T) { f := &formula.Formula{ Name: "investigate", diff --git a/internal/cmd/github_issue_formula_test.go b/internal/cmd/github_issue_formula_test.go new file mode 100644 index 0000000..f477171 --- /dev/null +++ b/internal/cmd/github_issue_formula_test.go @@ -0,0 +1,242 @@ +package cmd + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stempeck/agentfactory/internal/formula" +) + +// github_issue_formula_test.go — af-40ad72f3 / GitHub issue #491. +// +// .claude/skills/github-issue/SKILL.md is mandatory-by-protocol (the manager persona +// names it as the required issue-filing path) but was interactive-only: no formula or +// roster agent could be dispatched to run it unattended. This test proves the +// skillmd-mode conversion (internal/cmd/install_formulas/github-issue.formula.toml) +// exists, preserves the required workflow shape, and — the acceptance-critical part — +// redefines the skill's two "ask the user" points (Phase 3's per-symptom gap row, and +// the adjacent-finding scoping decision) so an unattended agent never stalls waiting +// for a human reply, while still surfacing the gap/decision visibly in its output. + +const githubIssueFormulaPath = "install_formulas/github-issue.formula.toml" + +func loadGithubIssueFormula(t *testing.T) *formula.Formula { + t.Helper() + data, err := os.ReadFile(githubIssueFormulaPath) + if err != nil { + t.Fatalf("read %s: %v — run /formula-create against .claude/skills/github-issue/SKILL.md", githubIssueFormulaPath, err) + } + f, err := formula.Parse(data) + if err != nil { + t.Fatalf("parse %s: %v", githubIssueFormulaPath, err) + } + return f +} + +// TestGithubIssueFormulaValid proves the formula parses, validates (DAG has no +// cycles, no dangling `needs`), and declares itself as the workflow type every other +// work-execution formula in this repo uses. +func TestGithubIssueFormulaValid(t *testing.T) { + f := loadGithubIssueFormula(t) + + if f.Name != "github-issue" { + t.Errorf("formula name = %q, want %q", f.Name, "github-issue") + } + if f.Type != formula.TypeWorkflow { + t.Errorf("formula type = %q, want %q", f.Type, formula.TypeWorkflow) + } + if err := f.Validate(); err != nil { + t.Errorf("formula.Validate(): %v", err) + } + if _, err := f.TopologicalSort(); err != nil { + t.Errorf("TopologicalSort(): %v (needs chain broken or cyclic)", err) + } +} + +// TestGithubIssueFormulaInvariantSteps proves all 10 mandatory invariant steps from +// the skillmd-mode conversion process (Section 10.2 of .claude/skills/formula-create/ +// skillmd-mode.md) are present — omitting any of these is a structural regression +// every work-execution formula in this repo must avoid. +func TestGithubIssueFormulaInvariantSteps(t *testing.T) { + f := loadGithubIssueFormula(t) + + want := []string{ + "load-context", "branch-setup", "validate-contract", "preflight-tests", + "self-review", "run-tests", "self-verify", "cleanup-workspace", + "prepare-for-review", "submit-and-exit", + } + have := map[string]bool{} + for _, s := range f.Steps { + have[s.ID] = true + } + for _, id := range want { + if !have[id] { + t.Errorf("missing invariant step %q", id) + } + } +} + +// TestGithubIssueFormulaNeedsChainUnbroken proves every step except load-context +// declares a `needs` predecessor, per skillmd-mode.md Section 10.2 ("The needs chain +// must be unbroken"). +func TestGithubIssueFormulaNeedsChainUnbroken(t *testing.T) { + f := loadGithubIssueFormula(t) + + for _, s := range f.Steps { + if s.ID == "load-context" { + continue + } + if len(s.Needs) == 0 { + t.Errorf("step %q has no `needs` — chain is broken", s.ID) + } + } +} + +// TestGithubIssueFormulaIssueVarCLISource proves [vars.issue] uses source = "cli", +// matching every other shipped work formula (issue #98 / TestSpecialistFormulasIssueSourceCLI +// convention) rather than the legacy hook_bead source. +func TestGithubIssueFormulaIssueVarCLISource(t *testing.T) { + f := loadGithubIssueFormula(t) + + v, ok := f.Vars["issue"] + if !ok { + t.Fatal("[vars.issue] not declared") + } + if v.Source != "cli" { + t.Errorf("[vars.issue].source = %q, want %q", v.Source, "cli") + } + if !v.Required { + t.Error("[vars.issue] must be required") + } +} + +// TestGithubIssueFormulaDomainPhasesPresent proves every phase of the original +// SKILL.md's 7-phase process (Understand -> Investigate -> Reconcile -> Draft -> +// Validate -> Post -> Bead/Dispatch) is represented as a formula step, so the +// conversion didn't silently drop a phase. +func TestGithubIssueFormulaDomainPhasesPresent(t *testing.T) { + f := loadGithubIssueFormula(t) + + var allDescriptions strings.Builder + ids := map[string]bool{} + for _, s := range f.Steps { + ids[s.ID] = true + allDescriptions.WriteString(s.Title) + allDescriptions.WriteString("\n") + allDescriptions.WriteString(s.Description) + allDescriptions.WriteString("\n") + } + + phaseCount := 0 + for id := range ids { + if strings.HasPrefix(id, "phase-") { + phaseCount++ + } + } + if phaseCount < 6 { + t.Errorf("found %d phase-* steps, want at least 6 (one per SKILL.md phase, phase 7 may be merged as conditional)", phaseCount) + } + + body := allDescriptions.String() + for _, must := range []string{"gh api", "Validate the Draft"} { + if !strings.Contains(body, must) { + t.Errorf("no step mentions %q — a domain phase looks missing or content wasn't preserved", must) + } + } +} + +// TestGithubIssueFormulaAskUserPointsNonStalling is the acceptance-critical test for +// GitHub issue #491's second AC: "Investigation gaps that would have required asking +// a user are handled without stalling and are visible in the output." +// +// The original SKILL.md blocks on the user in exactly two places (both in its Phase +// 3): a per-symptom "gap" row in the reconciliation table ("ask the user"), and the +// adjacent-finding scoping decision ("Scoping a finding out requires asking the user +// first"). An unattended dispatched agent cannot wait for a human reply, so the +// formula step covering Phase 3 must NOT contain either blocking instruction, and +// must instead replace it with a non-blocking escalation (mail, not a wait) plus an +// explicit, visible record of the open question / scope decision in the drafted +// artifact. +func TestGithubIssueFormulaAskUserPointsNonStalling(t *testing.T) { + f := loadGithubIssueFormula(t) + + step := f.GetStep("phase-3-reconcile-findings") + if step == nil { + t.Fatal("step \"phase-3-reconcile-findings\" not found") + } + body := step.Description + + forbidden := []string{"ask the user", "asking the user"} + for _, phrase := range forbidden { + if regexp.MustCompile(`(?i)` + regexp.QuoteMeta(phrase)).MatchString(body) { + t.Errorf("phase-3-reconcile-findings still contains blocking instruction %q — must be replaced with non-stalling escalation", phrase) + } + } + + requiredMarkers := map[string]string{ + "af mail send supervisor": "non-blocking escalation path (fire-and-forget mail, not a wait)", + "Open Questions": "visible record of unresolved symptom gaps in the drafted artifact", + "Scope Decisions": "visible record of adjacent-finding include/exclude decisions", + } + for marker, reason := range requiredMarkers { + if !strings.Contains(body, marker) { + t.Errorf("phase-3-reconcile-findings missing %q (%s)", marker, reason) + } + } + + if !regexp.MustCompile(`(?i)do not (wait|block|stall)`).MatchString(body) { + t.Error(`phase-3-reconcile-findings must explicitly instruct the agent not to wait/block/stall on a reply`) + } +} + +// TestGithubIssueFormulaMandatoryDirective proves the formula description ends with +// the verbatim MANDATORY execution directive every skillmd-mode-derived formula +// carries (skillmd-mode.md Section 10.7 item 6). +func TestGithubIssueFormulaMandatoryDirective(t *testing.T) { + f := loadGithubIssueFormula(t) + + const marker = "## !IMPORTANT - MANDATORY Exact Step Execution" + if !strings.Contains(f.Description, marker) { + t.Errorf("formula description missing mandatory execution directive %q", marker) + } + if !strings.Contains(f.Description, "TERMINATE YOU") { + t.Error("formula description missing the fidelity-gate termination clause") + } +} + +// TestGithubIssueFormulaSyncedToStore is a targeted early signal for the generic +// TestFormulaDriftSourceVsInstalled check: the new formula must be copied to the +// on-disk store as well as the embedded source, or `af sling --formula github-issue` +// fails to find it in a fresh factory. +func TestGithubIssueFormulaSyncedToStore(t *testing.T) { + const mirrorPath = "../../.agentfactory/store/formulas/github-issue.formula.toml" + if _, err := os.Stat(filepath.Clean(mirrorPath)); err != nil { + t.Errorf("mirror not found at %s: %v — run `make sync-formulas`", mirrorPath, err) + } +} + +// TestGithubIssueTemplateNotDrifted is the github-issue-specific, non-skipped +// counterpart to TestFormulaTemplateDrift (which stays globally skipped for the +// general "brand new formula has no committed template yet" chicken-and-egg case +// documented on that test). Once a template IS committed for a formula — which +// af-40ad72f3 / GitHub issue #491 requires so `make check-regen` (CI) has something +// to actually compare against on a fresh checkout — this test proves it wasn't +// hand-edited out of sync with what `af formula agent-gen` would produce from the +// current formula source. +func TestGithubIssueTemplateNotDrifted(t *testing.T) { + f := loadGithubIssueFormula(t) + + const tmplPath = "../templates/roles/github-issue.md.tmpl" + committed, err := os.ReadFile(tmplPath) + if err != nil { + t.Fatalf("read committed template %s: %v — run `af formula agent-gen github-issue` and commit the result", tmplPath, err) + } + + regenerated := generateAgentTemplate(f, f.Name, "autonomous") + if string(committed) != regenerated { + t.Errorf("template drift: %s does not match regenerated output from %s — rerun `af formula agent-gen github-issue` and commit the refreshed template", tmplPath, githubIssueFormulaPath) + } +} diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index fc0f7d8..61b3354 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -126,11 +126,12 @@ func runHandoffCore(ctx context.Context, cwd, subject, message string, collect, fmt.Printf("Handing off %s...\n", agentName) return respawnSession(RespawnOptions{ - FactoryRoot: factoryRoot, - AgentName: agentName, - AgentEntry: *agentEntry, - PaneID: pane, - CmdPrefix: sleepPrefix, + FactoryRoot: factoryRoot, + AgentName: agentName, + AgentEntry: *agentEntry, + PaneID: pane, + CmdPrefix: sleepPrefix, + AgentWorkDir: cwd, }) } diff --git a/internal/cmd/handoff_test.go b/internal/cmd/handoff_test.go index 3fdcfad..ac58147 100644 --- a/internal/cmd/handoff_test.go +++ b/internal/cmd/handoff_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/stempeck/agentfactory/internal/config" ) func TestHandoff_NotInsideTmux(t *testing.T) { @@ -204,6 +206,105 @@ func TestHandoff_TransitiveEndpointDependency(t *testing.T) { } } +// TestHandoff_OverrideSurvives — a --model override persisted to +// .runtime/model_override is re-resolved on respawn and the rebuilt startup +// command carries the override's model. models.json has NO agents-map default, +// so the ONLY source of the model is the marker. +func TestHandoff_OverrideSurvives(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{"sonnet": {"ANTHROPIC_MODEL": "claude-sonnet-4-6"}}, + }) + + t.Run("non_worktree", func(t *testing.T) { + agentDir := config.AgentDir(dir, "manager") + writeRuntimeFile(t, agentDir, "model_override", "sonnet") + + mock := &mockTmux{} + err := respawnSession(RespawnOptions{ + FactoryRoot: dir, + AgentName: "manager", + AgentEntry: config.AgentEntry{Type: "interactive"}, // Model empty → no legacy mask + AgentWorkDir: agentDir, + PaneID: "%0", + Tx: mock, + }) + if err != nil { + t.Fatalf("respawnSession: %v", err) + } + if _, statErr := os.Stat(filepath.Join(agentDir, ".runtime", "model_override")); statErr != nil { + t.Error("model_override marker must persist across respawn") + } + if len(mock.respawnPaneCalls) != 1 { + t.Fatalf("RespawnPane should be called once, got %d", len(mock.respawnPaneCalls)) + } + if cmd := mock.respawnPaneCalls[0].cmd; !strings.Contains(cmd, "claude-sonnet-4-6") { + t.Errorf("respawn command must carry the override model claude-sonnet-4-6, got: %s", cmd) + } + }) + + // Worktree case: handoff/compact pass NO WorktreePath but the agent + // dir lives under a worktree. The marker is written there; the respawn marker-read + // must match the write via AgentWorkDir. A read/write path mismatch fails here. + t.Run("worktree_via_agentworkdir", func(t *testing.T) { + wtAgentDir := filepath.Join(dir, ".agentfactory", "worktrees", "wt-xyz", ".agentfactory", "agents", "manager") + if err := os.MkdirAll(wtAgentDir, 0o755); err != nil { + t.Fatal(err) + } + writeRuntimeFile(t, wtAgentDir, "model_override", "sonnet") + + mock := &mockTmux{} + err := respawnSession(RespawnOptions{ + FactoryRoot: dir, + AgentName: "manager", + AgentEntry: config.AgentEntry{Type: "interactive"}, + AgentWorkDir: wtAgentDir, // handoff/compact pass cwd (the worktree agent dir) + PaneID: "%0", + Tx: mock, + }) + if err != nil { + t.Fatalf("respawnSession: %v", err) + } + if cmd := mock.respawnPaneCalls[0].cmd; !strings.Contains(cmd, "claude-sonnet-4-6") { + t.Errorf("respawn must read the worktree marker via AgentWorkDir and carry claude-sonnet-4-6, got: %s", cmd) + } + }) +} + +// TestHandoff_AgentsMapDefaultSurvives — a durable +// models.json.agents default with NO --model flag and NO marker must re-apply on +// every respawn. AgentEntry.Model is kept EMPTY so the legacy emit cannot mask the +// bug. This FAILS if respawn reads only the marker (the marker path test would +// still pass), proving respawn re-resolves the FULL precedence chain. +func TestHandoff_AgentsMapDefaultSurvives(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{"sonnet": {"ANTHROPIC_MODEL": "claude-sonnet-4-6"}}, + Agents: map[string]string{"manager": "sonnet"}, + }) + // Deliberately: NO marker written, NO --model, AgentEntry.Model empty. + + mock := &mockTmux{} + err := respawnSession(RespawnOptions{ + FactoryRoot: dir, + AgentName: "manager", + AgentEntry: config.AgentEntry{Type: "interactive"}, + AgentWorkDir: config.AgentDir(dir, "manager"), + PaneID: "%0", + Tx: mock, + }) + if err != nil { + t.Fatalf("respawnSession: %v", err) + } + if len(mock.respawnPaneCalls) != 1 { + t.Fatalf("RespawnPane should be called once, got %d", len(mock.respawnPaneCalls)) + } + if cmd := mock.respawnPaneCalls[0].cmd; !strings.Contains(cmd, "claude-sonnet-4-6") { + t.Errorf("a durable agents-map default must survive respawn; respawn must re-resolve "+ + "the full chain, not just the marker. got: %s", cmd) + } +} + func TestHandoff_IdleDelayComputation(t *testing.T) { tests := []struct { cycles int diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index 1c64d9d..0b5f1f7 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "io" "os" "os/exec" "strings" @@ -16,6 +17,22 @@ import ( "github.com/stempeck/agentfactory/internal/tmux" ) +// loadModelsConfigForCrossCheck loads models.json for a NON-selecting cross-check +// caller (`af dispatch`, `af config dispatch set`). Neither path launches from a +// profile, so a models.json validation error must NOT be fatal — mirroring the launch +// path's non-selecting tolerance (resolveLaunchModelEnv's fall-through): it warns and +// returns nil, which ValidateDispatchConfig treats as "skip the per-mapping model +// cross-check". The profile-WRITING path (`af config models set` / SaveModelsConfig) +// stays strict — this tolerance is only for the read/cross-check callers. +func loadModelsConfigForCrossCheck(root string, warn io.Writer) *config.ModelsConfig { + cfg, err := config.LoadModelsConfig(root) + if err != nil { + fmt.Fprintf(warn, "warning: ignoring models.json for the dispatch model cross-check (%v); proceeding without it\n", err) + return nil + } + return cfg +} + type respawnTmux interface { ClearHistory(pane string) error RespawnPane(pane, command string) error @@ -55,6 +72,12 @@ type RespawnOptions struct { CmdPrefix string WorktreePath string WorktreeID string + // AgentWorkDir is the agent's actual working dir — where the + // .runtime/model_override marker lives (issue #480). The three respawn call + // sites disagree on WorktreePath (handoff/compact pass none but run from the + // worktree agent dir; watchdog sets WorktreePath), so each passes its known + // agent dir here to make the respawn marker-read match the launch marker-write. + AgentWorkDir string Tx respawnTmux } @@ -68,6 +91,19 @@ func respawnSession(opts RespawnOptions) error { // (handoff/compact-handoff/watchdog) — without this, respawned agents would // commit without them (issue #371 G-B sibling-entrypoint). wireGitIdentity(mgr, opts.FactoryRoot, opts.WorktreePath) + + // Re-resolve the FULL model precedence chain on every respawn (issue #480): + // BOTH a --model override (captured by the .runtime/model_override marker) AND a + // durable models.json.agents default must survive handoff/compact/watchdog — + // reading only the marker would silently revert a durable-default agent to the + // global model on the first handoff. A respawn carries no explicit flag (cliModel + // ""), so a broken models.json warns + falls through to the global default + // rather than failing. Emission is structural: BuildStartupCommand() re-emits the + // set, so no second emission path is added here (handoff_test transitivity guard). + if _, env, _ := resolveLaunchModelEnv(opts.FactoryRoot, opts.AgentName, respawnAgentDir(opts), "", opts.AgentEntry.Model, false, os.Stderr); len(env) > 0 { + mgr.SetModelEnv(env) + } + respawnCmd := opts.CmdPrefix + mgr.BuildStartupCommand() tx := opts.Tx if tx == nil { @@ -77,6 +113,20 @@ func respawnSession(opts RespawnOptions) error { return tx.RespawnPane(opts.PaneID, respawnCmd) } +// respawnAgentDir derives the agent working dir holding the .runtime/model_override +// marker so the respawn read matches the launch write (issue #480). It +// prefers the explicit AgentWorkDir (handoff/compact pass their cwd; watchdog passes +// resolveAgentDir), then the worktree-derived dir, then the factory-root dir. +func respawnAgentDir(opts RespawnOptions) string { + if opts.AgentWorkDir != "" { + return opts.AgentWorkDir + } + if opts.WorktreePath != "" { + return config.AgentDir(opts.WorktreePath, opts.AgentName) + } + return config.AgentDir(opts.FactoryRoot, opts.AgentName) +} + // detectGitIdentity reads the ambient git identity (user.name/user.email) as // resolved from dir — the cmd-layer I/O that feeds the pure config.ResolveIdentity // (ADR-004: the library does no shell-out). Either value is "" when unset. diff --git a/internal/cmd/hermetic_test.go b/internal/cmd/hermetic_test.go index 759a3aa..f6dae21 100644 --- a/internal/cmd/hermetic_test.go +++ b/internal/cmd/hermetic_test.go @@ -13,10 +13,10 @@ import ( ) // fakeTmux is the single hermetic tmux double for issue #309 Phase 2. It -// satisfies BOTH the internal/session tmuxClient (13 methods, incl. the #412 -// Phase-4 ShowOption read-back) and the internal/cmd cmdTmux (9 methods) seam -// interfaces — the 17-method distinct union — recording every would-be op in -// order and returning benign values. It +// satisfies BOTH the internal/session tmuxClient (14 methods, incl. the #412 +// Phase-4 ShowOption read-back and the #508 UnsetEnvironment) and the internal/cmd +// cmdTmux (9 methods) seam interfaces — the 18-method distinct union — recording +// every would-be op in order and returning benign values. It // performs NO real I/O, never sleeps, and never shells out; a default-suite test // that installs it via setupHermeticSessions cannot reach the real tmux server. // @@ -72,6 +72,11 @@ func (f *fakeTmux) SetEnvironment(sess, key, value string) error { return nil } +func (f *fakeTmux) UnsetEnvironment(sess, key string) error { + f.record(fmt.Sprintf("UnsetEnvironment %s %s", sess, key)) + return nil +} + // --- tmuxClient-only methods --- func (f *fakeTmux) SetOption(sess, name, value string) error { diff --git a/internal/cmd/hook_pair_smoke_test.go b/internal/cmd/hook_pair_smoke_test.go index 1cbbfb9..26028cb 100644 --- a/internal/cmd/hook_pair_smoke_test.go +++ b/internal/cmd/hook_pair_smoke_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -49,6 +50,116 @@ func TestHookPair_SequentialSmoke(t *testing.T) { runHookSmoke(t, qualityGate, payload, tmpDir) } +// gateHookFiles are the four copies of the two Stop-hook gate scripts: the two +// top-level runtime copies and their two embedded install_hooks twins. All four +// must carry the identical grader construction (TestInstallHooks_NoDrift enforces +// byte-identity per pair; the tests below enforce the intended content). +func gateHookFiles(repoRoot string) []string { + return []string{ + filepath.Join(repoRoot, "hooks", "fidelity-gate.sh"), + filepath.Join(repoRoot, "hooks", "quality-gate.sh"), + filepath.Join(repoRoot, "internal", "cmd", "install_hooks", "fidelity-gate.sh"), + filepath.Join(repoRoot, "internal", "cmd", "install_hooks", "quality-gate.sh"), + } +} + +// graderLine returns the single line of a gate script that invokes the haiku +// grader (the line containing `claude -p --model haiku`). The env construction +// under test lives entirely on this line. +func graderLine(t *testing.T, content, file string) string { + t.Helper() + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, "claude -p --model haiku") { + return line + } + } + t.Fatalf("%s: no grader line (`claude -p --model haiku`) found", file) + return "" +} + +// TestHookGrader_UsesAllowlistNotDenylist pins Issue #508 W10 (AC-1): each hook's +// haiku grader is built from an ALLOWLIST (`env -i HOME=… PATH=…`) rather than the +// old six-var `env -u` DENYLIST. The allowlist is structurally immune to every +// current and future redirect var a per-agent local-model profile may inject — +// only HOME (for ~/.claude creds) and PATH (to find the binary) survive into the +// grader's environment, so it always reaches the ambient Anthropic endpoint. +// +// Asserting the allowlist form across all four copies also locks the source and +// embedded mirrors together at the grader line; TestInstallHooks_NoDrift enforces +// byte-identity, this test enforces the intended content. +func TestHookGrader_UsesAllowlistNotDenylist(t *testing.T) { + repoRoot := findRepoRoot(t) + + const graderAllowlist = `env -i HOME="$HOME" PATH="$PATH" claude -p --model haiku` + + for _, f := range gateHookFiles(repoRoot) { + data, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + content := string(data) + + if !strings.Contains(content, graderAllowlist) { + t.Errorf("%s: grader is not allowlist-constructed; expected to contain:\n %s", f, graderAllowlist) + } + + // The env -u denylist must be gone entirely (it is drift-prone: it omits at + // least ANTHROPIC_MODEL/SMALL_FAST_MODEL/DEFAULT_HAIKU_MODEL/SUBAGENT_MODEL). + if strings.Contains(content, "env -u ") { + t.Errorf("%s: an env -u denylist remains — W10 replaces it with the env -i allowlist", f) + } + + // No bare (unwrapped) grader invocation. + if strings.Contains(content, "$(claude -p --model haiku") { + t.Errorf("%s: bare grader invocation remains (must be env -i wrapped)", f) + } + } +} + +// TestHookGrader_DecoyVarsNeutralizedByAllowlist is the HARD decoy-var test (AC-3). +// A decoy profile may set EVERY known redirect/model-shaping var; under `env -i` +// none of them can reach the grader's environment because only HOME and PATH are +// passed through. This asserts structurally on the constructed grader command form +// — no real network and no real claude binary are needed. It is the drift-proof +// property the allowlist inversion buys over extending the denylist. +func TestHookGrader_DecoyVarsNeutralizedByAllowlist(t *testing.T) { + repoRoot := findRepoRoot(t) + + decoyVars := []string{ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", + "CLAUDE_CODE_EFFORT_LEVEL", + } + + for _, f := range gateHookFiles(repoRoot) { + data, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + line := graderLine(t, string(data), f) + + // The grader's env is constructed from an allowlist that carries ONLY HOME and PATH. + if !strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "VERDICT=$(")), `env -i HOME="$HOME" PATH="$PATH" claude`) { + t.Errorf("%s: grader line is not the exact env -i HOME/PATH allowlist form:\n %s", f, line) + } + + // No decoy var name may appear on the grader line — under env -i the allowlist + // cannot enumerate any of them, so a decoy profile setting all of them is inert. + for _, v := range decoyVars { + if strings.Contains(line, v) { + t.Errorf("%s: decoy var %q appears on the grader line — it could reach the grader env:\n %s", f, v, line) + } + } + } +} + func runHookSmoke(t *testing.T, scriptPath string, payload []byte, workDir string) { t.Helper() cmd := exec.Command("bash", scriptPath) diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 2b6d86c..ca1e08a 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -145,6 +145,9 @@ func runInstallInit(cmd *cobra.Command) error { "dispatch.json": `{"repos":[],"trigger_label":"agentic","notify_on_complete":"manager","mappings":[],"interval_seconds":300,"retry_after_seconds":1800}`, // Opinionated defaults for fresh installs (see TestLoadStartupConfig_ScaffoldLoads). "startup.json": `{"agents":["manager"],"quality":"default","fidelity":"default","start_dispatch":true,"watchdog_agents":["manager","supervisor"]}`, + // Per-agent model registry (issue #480); the default model tracks quickstart.sh + // (see TestInstallScaffold_DefaultModel_MatchesQuickstart). + "models.json": `{"default":"default","models":{"default":{"ANTHROPIC_MODEL":"claude-fable-5","ANTHROPIC_DEFAULT_OPUS_MODEL":"claude-opus-4-8","ANTHROPIC_DEFAULT_SONNET_MODEL":"claude-sonnet-5"},"lmstudio":{"ANTHROPIC_BASE_URL":"http://localhost:1234","ANTHROPIC_AUTH_TOKEN":"lm-studio","ANTHROPIC_MODEL":"qwen2.5-coder-32b","ANTHROPIC_API_KEY":""},"sonnet-5":{"ANTHROPIC_MODEL":"claude-sonnet-5"}},"agents":{"factoryworker":"sonnet-5"}}`, } for name, content := range starterConfigs { diff --git a/internal/cmd/install_formulas/github-issue.formula.toml b/internal/cmd/install_formulas/github-issue.formula.toml new file mode 100644 index 0000000..991bceb --- /dev/null +++ b/internal/cmd/install_formulas/github-issue.formula.toml @@ -0,0 +1,775 @@ +description = """ +## Overview +Investigates a reported problem and produces a well-formed GitHub issue (or a +comment on an existing one) that gives an implementer everything they need to find +and understand the problem — without prescribing the fix. + +The problem to write up comes from the assigned bead — which may contain an inline +problem description, a path to a problem document, or a link to an existing GitHub +issue. This formula converts .claude/skills/github-issue/SKILL.md into a +dispatchable workflow: Phase 1 (understand the problem, examine attached +artifacts), Phase 2 (investigate the codebase via an Explore agent), Phase 3 +(reconcile findings against every reported symptom), Phase 4 (draft the issue +body), Phase 5 (validate the draft against a 5-point checklist), Phase 6 (post via +`gh api`), and Phase 7 (optionally create a bead and dispatch, if requested). + +## Your Role: Cartographer, Not Surgeon + +You are not fixing the reported problem. You are drawing the map for someone who +will. Write every sentence as if you're briefing a colleague who will go deeper +than you did — list every affected file and layer, note contextual gotchas, and +write acceptance criteria that define "done" without defining "how." Never say +"add this line to this file" or name a specific function/flag/command substitution +as "the correct" fix — naming the mechanism is prescription even when offered as an +example. + +**Be honest about what you find.** If a feature was half-implemented, say "this was +never wired up." If there are no tests covering the flow, say so. Don't soften +findings into neutral descriptions of state. + +## Variables + +| Variable | Source | Description | +|----------|--------|-------------| +| issue | cli | The issue ID assigned to this agent (the problem to write up) | + +## Failure Modes + +| Situation | Action | +|-----------|--------| +| A reconciliation row still shows a gap after re-investigating | Do NOT wait for a reply — mail Supervisor a non-blocking notice, record it under Open Questions in the draft, and proceed with the best available theory | +| Investigation surfaces an adjacent problem | Include it by default; if excluded, record the exclusion and reason under Scope Decisions in the draft rather than dropping it silently | +| A validate-draft checklist item is not "none" | Fix the draft now and re-run the checklist — do not post with an unresolved item | +| Tests fail on main | Judgment call per the Scotty Principle table — fix, file, or proceed per severity | +| Context filling up | Use af handoff to cycle to a fresh session | +| Blocked / unclear problem statement | Mail Supervisor, do not guess | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Why It's Bad | Instead | +|-------------|-------------|---------| +| "This is a one-line fix" | Minimizes scope, misses environments/tests | List all affected layers | +| Naming specific deploy scripts | Assumes deploy path, misses staging/prod parity | Note that multiple environments exist | +| Skipping test files | Implementer might skip test updates | Always list test files in affected layers | +| Skipping artifact examination | Screenshots and pastes often contain the diagnosis | Examine every attached image, log, and config screenshot before exploring the codebase | +| Waiting for a human reply on a reconciliation gap or scope decision | This agent runs unattended and cannot block | Escalate non-blocking via mail, record the gap/decision visibly in the draft, and proceed | +| Prescribing a fix in the draft | Closes off the implementer's discovery, causes them to miss things | State findings and acceptance criteria only, never mechanisms | + +## !IMPORTANT - MANDATORY Exact Step Execution +Execute each formula step EXACTLY as written, in order, with no modifications. +Every step produces a file artifact at a known path. `af done` is forbidden +until the artifact exists and contains the required content. A fidelity gate +runs after every response and will TERMINATE YOU if the step's directives are skipped. +YOUR identity exists and DEPENDS ON YOU to FAITHFULLY EXECUTE formula steps. +""" +formula = "github-issue" +version = 1 + +# ============================================================================ +# PRE-WORK INVARIANT STEPS +# ============================================================================ + +[[steps]] +id = "load-context" +title = "Load context and verify assignment" +description = """ +Initialize your session and understand your assignment. + +**1. Prime your environment:** +```bash +af prime # Load role context +``` + +**2. Check your assignment:** + +The formula provides your assigned issue via `{{issue}}`. Read it carefully: +```bash +af bead show {{issue}} # Full issue details +``` + +**3. Check inbox for additional context:** +```bash +af mail inbox +# Read any HANDOFF or assignment messages +``` + +**4. Extract the problem to write up from the bead:** +The bead is your source of truth. It may provide the problem in one of three forms: + +- **Inline problem description**: The bead description itself describes the + problem. This becomes a NEW GitHub issue in phase-6-post. +- **Problem document path**: The bead references a file (e.g., `bugs/report.md`). + Read it completely. This also becomes a NEW GitHub issue in phase-6-post. +- **Existing GitHub issue URL**: The bead contains a URL (e.g., + `https://github.com/org/repo/issues/123`). Fetch it via + `gh api repos/<org>/<repo>/issues/<number>` (body + comments) — your write-up + is posted as a COMMENT on this issue in phase-6-post, not a new issue. + +Whichever form, capture the full problem statement — this is what +phase-1-understand-problem investigates. + +**5. Verify you can proceed:** +- No unresolved blockers on the issue +- You understand what needs investigating +- Required resources are available (repo access, `gh` auth) + +If blocked or unclear, mail Supervisor immediately: +```bash +af mail send supervisor -s "HELP: Unclear requirements" -m "Issue: {{issue}} +Question: <what you need clarified>" +``` + +**Exit criteria:** You understand the problem to write up and can begin +phase-1-understand-problem.""" + +[[steps]] +id = "branch-setup" +title = "Set up working branch" +needs = ["load-context"] +description = """ +Ensure you're on a clean feature branch ready for work. + +**1. Check current branch state:** +```bash +git status +git branch --show-current +``` + +**2. If not on a feature branch, create one:** +```bash +# Standard naming: agent/<your-name> or feature/<issue-id> +git checkout -b agent/<name> +``` + +**3. Ensure clean working state:** +```bash +git status # Should show "working tree clean" +git stash list # Should be empty +``` + +If dirty state from previous work: +```bash +# If changes are relevant to this issue: +git add -A && git commit -m "WIP: <description>" + +# If changes are unrelated cruft: +git stash push -m "unrelated changes before {{issue}}" +# Or discard if truly garbage: +git checkout -- . +``` + +**4. Sync with main:** +```bash +git fetch origin +git rebase origin/{{default_branch}} # Get latest, rebase your branch +``` + +If rebase conflicts: +- Resolve them carefully +- Test after resolution +- If stuck, mail Supervisor + +**Exit criteria:** You're on a clean feature branch, rebased on latest main.""" + +[[steps]] +id = "validate-contract" +title = "Validate design contract (if present)" +needs = ["branch-setup"] +description = """ +Gate: incoming design contract inspection (Poka-yoke). + +Check if a design contract exists for this work: +```bash +# Look for design documents related to this issue +ls .designs/{{issue}}/design-doc.md 2>/dev/null || echo "No design contract found" +``` + +**If design contract EXISTS:** +- Read it completely +- Extract key requirements and constraints +- Note any specific implementation guidance +- These become verification points for self-verify later + +**If NO design contract exists:** +- This is acceptable — not all work has a prior design +- Close this step with reason "No design contract found" +- Proceed without contract verification + +**Exit criteria:** Design contract reviewed (if present), or step closed as N/A.""" + +[[steps]] +id = "preflight-tests" +title = "Verify tests pass on main" +needs = ["validate-contract"] +description = """ +Check if the codebase is healthy BEFORE starting your work. + +**The Scotty Principle:** Don't walk past a broken warp core. But also don't +let someone else's mess consume your entire mission. + +**1. Check tests on main:** +```bash +git stash # Save your branch state +git checkout origin/{{default_branch}} +# Discover the project's test command from CLAUDE.md, Makefile, package.json, etc. +# Then run it. Examples: make test, npm test, cargo test, go test ./... +``` + +**2. If tests PASS:** +```bash +git checkout - # Back to your branch +git stash pop # Restore state +``` +Continue to phase-1-understand-problem step. + +**3. If tests FAIL on main:** + +Make a judgment call: + +| Situation | Action | +|-----------|--------| +| Quick fix (<15 min) | Fix it, commit to main, then continue | +| Medium fix (15-60 min) | Fix if it blocks your work, else file bead | +| Big fix (>1 hour) | File bead, notify Supervisor, proceed with your work | + +**Quick fix path:** +```bash +# Fix the issue +git add <files> +git commit -m "fix: <description> (pre-existing failure)" +git push origin {{default_branch}} +git checkout - +git stash pop +git rebase origin/{{default_branch}} # Get your fix +``` + +**File and proceed path:** +```bash +af bead create --title "Pre-existing test failure: <description>" \ + --type bug --priority 1 + +af mail send supervisor -s "NOTICE: Main has failing tests" \ + -m "Found pre-existing test failures on main. +Filed: <bead-id> +Proceeding with my assigned work ({{issue}})." + +git checkout - +git stash pop +``` + +**Context consideration:** +If fixing pre-existing failures consumed significant context: +```bash +af handoff -s "Fixed pre-existing failures, ready for assigned work" \ + -m "Issue: {{issue}} +Fixed: <what you fixed> +Ready to start: phase-1-understand-problem step" +``` +Fresh session continues from phase-1-understand-problem. + +**Exit criteria:** Tests pass on main (or issue filed), ready to begin +phase-1-understand-problem.""" + +# ============================================================================ +# DOMAIN STEPS — from .claude/skills/github-issue/SKILL.md +# ============================================================================ + +[[steps]] +id = "phase-1-understand-problem" +title = "Phase 1: Understand the problem as reported" +needs = ["preflight-tests"] +description = """ +Understand the problem captured in load-context before touching the codebase. + +**1. If the bead's problem source is a GitHub issue URL**, you already fetched its +full body and comments in load-context via `gh api`. Re-read it carefully. + +**2. Read the problem description carefully** — note the exact symptoms, what was +expected, and what actually happened. Do not paraphrase away specifics. + +**Examine all attached artifacts.** Screenshots, pasted logs, email contents, and +configuration screenshots often carry more signal than the codebase itself. These +are first-class evidence, not supplementary detail. + +- Download images with authenticated requests: + `curl -sL -H "Authorization: token $(gh auth token)" -o file.png "<url>"` +- Use the Read tool to visually examine downloaded images +- Note every concrete detail visible in artifacts (field values, status indicators, + error codes, configuration states) + +**Anti-pattern:** Do NOT skip artifact examination — screenshots and pastes often +contain the diagnosis. + +**Exit criteria:** The problem as reported is fully understood, including every +detail visible in attached artifacts.""" + +[[steps]] +id = "phase-2-investigate-codebase" +title = "Phase 2: Map the codebase" +needs = ["phase-1-understand-problem"] +description = """ +Use an **Explore agent** (subagent_type=Explore) to map the technical landscape. The +prompt should ask the agent to trace the full data/control flow related to the +problem understood in phase-1-understand-problem. Be thorough — request that the +agent search across all relevant directories (src, test, terraform, deploy scripts, +config, etc.). + +The investigation must produce: +- **Affected layers**: Frontend, backend, infrastructure, tests, docs — every layer + the problem touches or that an implementer would need to understand +- **Affected files**: Absolute paths with brief descriptions of each file's role +- **Data flow**: How data moves through the system (form -> API -> handler -> output) +- **The break point**: Where exactly the expected behavior diverges from actual + behavior +- **Contextual gotchas**: Things that aren't broken but are relevant — orphan + fields, naming mismatches, environment differences (staging vs prod), format + differences (stored codes vs display labels), etc. + +Save the findings to `todos/github-issue/investigation.md` — phase-3-reconcile-findings +and phase-4-draft-issue both consume this file. + +**Anti-pattern:** Do NOT name a specific deploy script or assume a single +environment — list all that exist and let the write-up note them. + +**Exit criteria:** `todos/github-issue/investigation.md` exists with all five +categories of findings above.""" + +[[steps]] +id = "phase-3-reconcile-findings" +title = "Phase 3: Reconcile findings with reported symptoms" +needs = ["phase-2-investigate-codebase"] +description = """ +**This phase is critical.** Before drafting anything, verify that your codebase +findings actually explain the reported symptoms captured in +phase-1-understand-problem. + +Enumerate every symptom from the problem statement and the specific finding that +explains it: + +| Reported symptom (quote) | Finding that explains it (file:line or "gap") | +|--------------------------|-----------------------------------------------| +| ... | ... | + +**If my findings say "X shouldn't happen" but the report says it does — the report +is right and the investigation is incomplete.** Re-run phase-2-investigate-codebase +with a narrower, more targeted Explore prompt before continuing. + +**Non-stalling gap handling (this agent runs unattended — it must NEVER wait for a +human reply):** + +If a row still shows a "gap" after a second investigation pass, do NOT wait for a +reply and do NOT block or stall the formula on it. Instead: +1. Send a non-blocking, fire-and-forget notice: `af mail send supervisor -s "QUESTION: <topic>" -m "Issue: {{issue}}\nGap: <the symptom>\nWhat I need: <the specific question>"` — this is informational, not a blocking request; do not wait for a reply. +2. Record the gap verbatim under an `## Open Questions` heading you will carry into + the draft in phase-4-draft-issue, so the gap stays visible to whoever reads the + posted issue instead of silently disappearing. +3. Proceed with the best available theory for that row rather than halting the + formula. + +**Adjacent findings (non-stalling scoping decision):** + +Adjacent findings expand the deliverable **by default**. If the investigation +surfaced problems beyond the reported one, include them in the write-up. Do NOT +wait for a reply to decide whether to scope one out — an unattended agent cannot +block on that. Instead: +- Default: include the adjacent finding. +- If you judge an adjacent finding should be excluded (e.g., clearly out of scope, + a separate unrelated system), you may exclude it, but you MUST record the + exclusion and your reasoning under a `## Scope Decisions` heading you will carry + into the draft — never drop a finding silently. + +**Exit criteria:** Every reported symptom is mapped to a finding or an explicit, +mailed, and drafted-visible Open Question; every adjacent finding is either +included or has a recorded Scope Decision. Save the reconciliation table, Open +Questions, and Scope Decisions to `todos/github-issue/reconciliation.md` for +phase-4-draft-issue to consume.""" + +[[steps]] +id = "phase-4-draft-issue" +title = "Phase 4: Draft the GitHub issue" +needs = ["phase-3-reconcile-findings"] +description = """ +Draft the body locally to `todos/github-issue/draft.md`. Do NOT post yet — +phase-5-validate-draft validates the draft and phase-6-post posts it. + +**If a GitHub issue already exists** (the bead's problem source was a GitHub issue +URL): the draft is posted as a comment in phase-6-post. +**If no GitHub issue exists** (the bead's problem source was inline text or a +file): the draft becomes the body of a new issue in phase-6-post. + +Structure: + +```markdown +## Investigation Notes + +[1-2 sentence summary of what's happening and where the break occurs] + +### Affected Layers & Files + +**[Layer Name]** +- `path/to/file` — What this file does in context of the problem + +[Repeat for each layer: Frontend, Backend, Infrastructure, Tests, Docs, etc.] + +### Additional Context + +- [Gotcha 1: something non-obvious the implementer should know] +- [Gotcha 2: environment differences, data format issues, etc.] +- [Gotcha 3: related but not broken things worth being aware of] + +### Acceptance Criteria + +- [Observable behavior that defines "done"] +- [Edge cases or existing behavior that must be preserved] +- [Test coverage expectations] + +### Open Questions + +[Any gap rows recorded in phase-3-reconcile-findings — omit this section entirely +if there were none] + +### Scope Decisions + +[Any adjacent-finding include/exclude decisions recorded in +phase-3-reconcile-findings — omit this section entirely if every adjacent finding +was included with no exclusions to record] +``` + +**Writing guidelines:** +- Every file mentioned must be a real path confirmed by phase-2-investigate-codebase +- Acceptance criteria must be testable statements, not implementation steps +- Do NOT prescribe a fix — you are drawing the map, not writing the solution. + Never say "add this line to this file" or name a specific function/flag/command + substitution as "the correct" / "the equivalent" fix. + +**Exit criteria:** `todos/github-issue/draft.md` exists with the full structure +above populated from phase-2 and phase-3 findings.""" + +[[steps]] +id = "phase-5-validate-draft" +title = "Phase 5: Validate the Draft Before Posting" +needs = ["phase-4-draft-issue"] +description = """ +**MANDATORY pre-post checklist** — do NOT close this step, and do NOT proceed to +phase-6-post, until every item below returns "none". Answer each by quoting the +relevant passage from `todos/github-issue/draft.md`, or writing "none" only after +reading every section of the draft. + +1. **Scope constraints** — Does the draft constrain the scope of the fix (e.g., "no + changes to X", "read-only on Y", "cleanup only", "no new features")? Quote every + such constraint. For each: did the bead's problem source state this, or did you + add it? If you added it, remove it. +2. **Prescribed mechanisms** — Does the draft name a specific function, flag, file + path, command substitution, variable, or implementation approach as "the + correct" / "the right" / "the equivalent" / "the substitute"? Quote each. Remove + every one — naming the mechanism is prescription, including when offered as an + example. +3. **Mechanical transforms** — Does any list or table in the draft imply the + implementer must transform each enumerated item identically to satisfy an + acceptance criterion (e.g., "N sites to rewrite", "zero matches after cleanup")? + Quote each. Rewrite as behavioral AC or keep the enumeration as context only. +4. **Regex-compliance AC** — Does any AC item pass if `grep` returns specific match + counts (zero matches, N matches, exact string present/absent)? Quote each. + Rewrite as observable behavioral outcome. +5. **Symptom coverage** — Does every reported symptom from + phase-3-reconcile-findings's table appear in the draft, tied to the finding that + explains it (or listed under Open Questions)? If not, add it or re-open + phase-2-investigate-codebase. + +**If ANY item is not "none"**, fix `todos/github-issue/draft.md` now and re-run this +checklist until all five return "none". Do NOT proceed to phase-6-post with an +unresolved item. + +**Exit criteria:** All five checklist items return "none" against the current draft.""" + +[[steps]] +id = "phase-6-post" +title = "Phase 6: Post" +needs = ["phase-5-validate-draft"] +description = """ +Post `todos/github-issue/draft.md` now that phase-5-validate-draft has passed. + +**Existing issue comment** (bead's problem source was a GitHub issue URL): +```bash +gh api repos/{owner}/{repo}/issues/{number}/comments -X POST -f body="$(cat todos/github-issue/draft.md)" +``` + +**New issue** (bead's problem source was inline text or a file): +```bash +gh api repos/{owner}/{repo}/issues -X POST -f title="<title>" -f body="$(cat todos/github-issue/draft.md)" +``` + +Record the resulting issue URL (or comment URL) — prepare-for-review needs it. + +**Exit criteria:** The draft is posted; the issue/comment URL is recorded.""" + +[[steps]] +id = "phase-7-bead-dispatch" +title = "Phase 7: Create Bead & Dispatch (if requested)" +needs = ["phase-6-post"] +description = """ +**Conditional step.** If the bead's problem source did NOT request dispatch (no +"dispatch this" / "assign an agent" language), close this step immediately with +reason "N/A: dispatch not requested". + +If dispatch was requested: + +1. Create a bead with `af bead create`: + - `--type bug` for defects, `--type feature` for new behavior + - `--title "..."` summarizing the problem + - Description should reference the GitHub issue URL from phase-6-post for full + context +2. Dispatch with `af sling`: + - `af sling --agent <specialist> "task description"` for specialist agents + (e.g., `design-v5`, `ultra-implement`, `rootcause-all`) + - `af sling --formula <name>` to instantiate a formula + +**Exit criteria:** Bead created and dispatched, or step closed as N/A.""" + +# ============================================================================ +# POST-WORK INVARIANT STEPS +# ============================================================================ + +[[steps]] +id = "self-review" +title = "Self-review changes" +needs = ["phase-7-bead-dispatch"] +description = """ +Review your own changes before running tests. + +**1. Review the diff:** +```bash +git diff origin/{{default_branch}}...HEAD # All changes vs main +git log --oneline origin/{{default_branch}}..HEAD # All commits +``` + +**2. Check for common issues:** + +| Category | Look For | +|----------|----------| +| Bugs | Off-by-one, null handling, edge cases | +| Security | Injection, auth bypass, exposed secrets | +| Style | Naming, formatting, code organization | +| Completeness | Missing error handling, incomplete paths | +| Cruft | Debug prints, commented code, TODOs | + +**3. Fix issues found:** +Don't just note them - fix them now. Amend or add commits as needed. + +**4. Verify no unintended changes:** +```bash +git diff --stat origin/{{default_branch}}...HEAD +# Only files relevant to {{issue}} should appear +``` + +If you accidentally modified unrelated files, remove those changes. + +**Exit criteria:** Changes are clean, reviewed, and ready for testing.""" + +[[steps]] +id = "run-tests" +title = "Run tests and verify coverage" +needs = ["self-review"] +description = """ +Verify your changes don't break anything and are properly tested. + +**1. Run the full test suite:** +```bash +# Discover the project's test command from CLAUDE.md, Makefile, package.json, etc. +# Then run it. Examples: make test, npm test, cargo test, go test ./... +``` + +**ALL TESTS MUST PASS.** Do not proceed with failures. + +**2. If tests fail:** +- Read the failure output carefully +- Determine if your change caused it: + - If yes: Fix it. Return to phase-1-understand-problem if needed. + - If no (pre-existing): File a bead, but still must pass for your PR + +```bash +# Check if failure exists on main: +git stash +git checkout {{default_branch}} +# Discover the project's test command from CLAUDE.md, Makefile, package.json, etc. +# Then run it. Examples: make test, npm test, cargo test, go test ./... +git checkout - +git stash pop +``` + +**3. Verify test coverage for new code:** +- New features should have tests +- Bug fixes should have regression tests +- If you added significant code without tests, add them now + +**4. Validate terraform if .tf files were modified:** +```bash +# Check if any .tf files were changed in this branch +TF_CHANGED=$(git diff --name-only origin/{{default_branch}}...HEAD -- '*.tf' | head -1) +if [ -n "$TF_CHANGED" ]; then + echo "Terraform files modified — running validation..." + TF_DIR=$(dirname "$TF_CHANGED") + (cd "$TF_DIR" && terraform validate) || { echo "FAIL: terraform validate failed"; exit 1; } + terraform fmt -check -recursive "$TF_DIR" || { echo "FAIL: terraform fmt check failed — run 'terraform fmt'"; exit 1; } + echo "Terraform validation passed." +fi +``` + +**5. Run any other quality checks:** +```bash +# Discover linting/build commands from CLAUDE.md, Makefile, package.json, etc. +# Run linter if configured +# Run build check if applicable +``` + +**Exit criteria:** All tests pass, terraform valid (if applicable), new code has appropriate test coverage.""" + +[[steps]] +id = "self-verify" +title = "Verify implementation matches design contract (if present)" +needs = ["run-tests"] +description = """ +Gate: verify implementation matches design contract (Jidoka). + +**If a design contract was found in validate-contract:** +- Re-read the design contract from `.designs/{{issue}}/design-doc.md` +- For each requirement/constraint in the contract: + - Verify the implementation addresses it + - Check that no constraints are violated + - Cross-reference with the actual code changes +- Document any deviations with justification + +**If NO design contract was found in validate-contract:** +- Close this step with reason "No design contract to verify against" +- Proceed to cleanup-workspace + +**Exit criteria:** Implementation verified against design contract (if present), or step closed as N/A.""" + +[[steps]] +id = "cleanup-workspace" +title = "Clean up workspace" +needs = ["self-verify"] +description = """ +**Skill artifact cleanup:** +The github-issue process creates working files that should NOT be merged. Remove +them before finalizing: +```bash +rm -rf todos/github-issue/ +``` + +The github-issue formula produces no other file artifacts — the work product is a +GitHub issue (or comment) posted via `gh api`. Verify no accidental files were left +behind. + +**1. Check for uncommitted changes:** +```bash +git status +``` +Must show "working tree clean". If not: +- Commit legitimate changes +- Discard garbage: `git checkout -- .` + +**2. Check for untracked files:** +```bash +git status --porcelain +``` +Should be empty. If not: +- Add to .gitignore if appropriate +- Remove if temporary: `rm <file>` +- Commit if needed + +**3. Check stash:** +```bash +git stash list +``` +Should be empty. If not: +- Pop and commit: `git stash pop && git add -A && git commit` +- Or drop if garbage: `git stash drop` + +**4. Push your branch (only if commits exist):** +```bash +if [ -n "$(git log origin/{{default_branch}}..HEAD --oneline)" ]; then + git push -u origin $(git branch --show-current) +fi +``` + +**5. Verify nothing left behind:** +```bash +git status # Clean +git stash list # Empty +``` + +**Exit criteria:** Working artifacts removed, workspace clean, no cruft.""" + +[[steps]] +id = "prepare-for-review" +title = "Prepare work for review" +needs = ["cleanup-workspace"] +description = """ +Verify work is complete and ready for submission. + +**Note:** Do NOT close the issue that assigned this work. The Supervisor closes it +after review. + +**1. Verify the assignment shows your work:** +```bash +af bead show {{issue}} +# Status should still be 'in_progress' (you're working on it) +``` + +**2. Add completion notes:** +```bash +af bead update {{issue}} --notes "Posted: <new issue URL or comment URL from phase-6-post> +Symptom coverage: <N of N reported symptoms mapped to findings> +Open Questions recorded: <count, or 'none'> +Scope Decisions recorded: <count, or 'none'> +Dispatch: <bead ID + agent, or 'not requested'>" +``` + +**Exit criteria:** Assignment updated with completion notes.""" + +[[steps]] +id = "submit-and-exit" +title = "Submit and exit" +needs = ["prepare-for-review"] +description = """ +Signal completion. This formula's work product is a posted GitHub issue or +comment, not a code PR — so this step focuses on signaling done. + +**1. Create a PR (only if commits exist on the branch):** +```bash +if [ -n "$(git log origin/{{default_branch}}..HEAD --oneline)" ]; then + gh pr create --title "chore: github-issue write-up for {{issue}}" \ + --body "Investigation and write-up run for {{issue}}." +fi +``` +If no commits exist on the branch, skip PR creation. + +**2. Signal completion:** +```bash +af done +``` + +This closes the current formula step. When all steps are complete, it: +- Mails WORK_DONE to the dispatcher +- Cleans up the checkpoint and runtime artifacts + +**Ephemeral Agent Model:** +Once you signal completion, you're done. The Supervisor will: +1. Review the posted issue/comment (and any PR, if one was created) +2. Close your assignment after review + +**3. You're done:** +Your work is submitted — the issue/comment is posted to GitHub. + +If you have context remaining, you may: +- Pick up new work from `af prime` +- Or use `af handoff` to cycle to a fresh session + +**Exit criteria:** `af done` signaled, agent done.""" + +[vars] +[vars.issue] +description = "The issue ID assigned to this agent (the problem to write up)" +required = true +source = "cli" diff --git a/internal/cmd/install_formulas/ultra-review.formula.toml b/internal/cmd/install_formulas/ultra-review.formula.toml index f6a56be..70bbf69 100644 --- a/internal/cmd/install_formulas/ultra-review.formula.toml +++ b/internal/cmd/install_formulas/ultra-review.formula.toml @@ -32,6 +32,7 @@ false positives. Only findings scoring at or above {{min_confidence}} survive. |----------|--------|-------------| | pr_uri | input (cli) | Pull request to review: full GitHub URL, owner/repo#number, or bare number | | min_confidence | input (cli, default 70) | Minimum 0-100 confidence score a finding needs to be reported | +| force_review | input (cli, default "false") | Set "true" to force a re-review of a PR this reviewer already reviewed. Waives ONLY the already-reviewed criterion — every other eligibility check still applies | ## Orchestrator Communication Contract @@ -121,6 +122,12 @@ type = "number" required = false default = "70" +[inputs.force_review] +description = "Force a full re-review even if this reviewer has already reviewed the PR. Waives ONLY eligibility criterion (d) (already-reviewed) at both the phase-1 and phase-6 checkpoints — criteria (a) closed/merged, (b) draft, and (c) doesn't-need-review still apply. Default 'false' preserves standard behavior: already-reviewed PRs stay ineligible." +type = "string" +required = false +default = "false" + # ============================================================================ # PRE-WORK INVARIANT STEPS # ============================================================================ @@ -269,12 +276,16 @@ ls "$REVIEW_DIR" 2>/dev/null ``` **If a prior review of this PR exists in `$REVIEW_DIR`** (findings.md, verdict.md): -- Read it. Decide: has the PR gained new commits since (compare head sha in target.md - against current `gh pr view --json headRefOid`)? -- If unchanged AND a comment was already posted: this PR is already reviewed — - note it in `$REVIEW_DIR/eligibility.md` ahead of time; phase-1-eligibility will confirm. -- If changed: archive the prior artifacts (`mv "$REVIEW_DIR" "$REVIEW_DIR.old"`) and - re-create the directory fresh. +- If {{force_review}} is "true": do NOT treat prior review state as a short-circuit — + archive the prior artifacts (`mv "$REVIEW_DIR" "$REVIEW_DIR.old"`), re-create the + directory fresh, and let phase-1-eligibility apply its own force_review-aware check. + This is the operator's forced-re-review path; it must not be pre-empted here. +- Otherwise, read it. Decide: has the PR gained new commits since (compare head sha in + target.md against current `gh pr view --json headRefOid`)? + - If unchanged AND a comment was already posted: this PR is already reviewed — + note it in `$REVIEW_DIR/eligibility.md` ahead of time; phase-1-eligibility will confirm. + - If changed: archive the prior artifacts (`mv "$REVIEW_DIR" "$REVIEW_DIR.old"`) and + re-create the directory fresh. **2. Check the dispatch mail / bead for special constraints:** e.g., a different confidence threshold, a request to always post even with @@ -325,8 +336,14 @@ this is a quick classification, not analysis. **Entry criteria:** `$REVIEW_DIR/target.md` exists. +**force_review override:** When {{force_review}} is "true", criterion (d) below is +waived — criteria (a), (b), and (c) still apply regardless. If {{force_review}} is +"true", omit criterion (d) from the sub-agent prompt entirely (do not ask it to check +prior reviews at all); otherwise include it verbatim as written. + **Spawn ONE sub-agent** (Agent tool, `subagent_type: "general-purpose"`, -`model: "haiku"` if available) with this prompt (fill in repo/number): +`model: "haiku"` if available) with this prompt (fill in repo/number; drop criterion +(d) when {{force_review}} is "true", per the override note above): ``` Check pull request <owner/repo>#<number> using the gh CLI and report whether it @@ -345,7 +362,9 @@ And: gh api repos/<owner>/<repo>/pulls/<number>/reviews --jq '.[].body' Return exactly: ELIGIBLE or INELIGIBLE, followed by one sentence of reasoning. ``` -**Write `$REVIEW_DIR/eligibility.md`** with the verdict, the reasoning, and a timestamp. +**Write `$REVIEW_DIR/eligibility.md`** with the verdict, the reasoning, a timestamp, and +whether {{force_review}} was applied (so phase-6's re-check and self-review can see it +was an intentional override, not a protocol lapse). **Decision logic:** - IF ELIGIBLE → proceed to phase-2-context-gathering. @@ -620,9 +639,11 @@ individually), then dispatches ultra-implement for the comments left standing. requested) or eligibility.md says INELIGIBLE, close this step with that reason. **1. Repeat the eligibility check** (same cheap sub-agent prompt as -phase-1-eligibility). The PR may have closed, merged, or been reviewed while -you worked. If now INELIGIBLE: update eligibility.md, mail the dispatcher, -do NOT post, and close this step. +phase-1-eligibility, including the SAME {{force_review}} override — if {{force_review}} +is "true", criterion (d) is omitted here too, exactly as at phase-1; criteria (a), (b), +and (c) still apply at this checkpoint regardless). The PR may have closed, merged, or +gained ANOTHER reviewer's review while you worked. If now INELIGIBLE: update +eligibility.md, mail the dispatcher, do NOT post, and close this step. **2. Capture the head sha** (used as the review's commit_id and for permalinks): ```bash diff --git a/internal/cmd/install_hooks/fidelity-gate.sh b/internal/cmd/install_hooks/fidelity-gate.sh index 1b4df19..8c1a8a5 100755 --- a/internal/cmd/install_hooks/fidelity-gate.sh +++ b/internal/cmd/install_hooks/fidelity-gate.sh @@ -14,6 +14,23 @@ ROLE=${AF_ROLE:-$(basename "$(pwd)")} AGENT_RUNTIME="$(pwd)/.runtime" +# notify_grader_unavailable emits a one-time notice per agent per cause (issue #508) +# when the haiku grader cannot produce a verdict, so a persistent outage — which +# fails the gate open every turn (ADR-007 never-block) — becomes visible instead of +# silent. Idempotent via a .runtime marker (the fidelity-gate per-agent-state idiom), +# so it is one mail per cause, not a per-turn storm. Sent to the agent's own inbox +# (ADR-007: no fire-and-forget escalation into a possibly-absent recipient). +notify_grader_unavailable() { + cause="$1" + marker="$AGENT_RUNTIME/grader_notice_$cause" + [ -f "$marker" ] && return 0 + mkdir -p "$AGENT_RUNTIME" 2>/dev/null + : > "$marker" 2>/dev/null + af mail send "$ROLE" -s "GRADER_UNAVAILABLE" \ + -m "fidelity gate grader unavailable ($cause): the haiku grader produced no verdict this turn, so the gate is failing open. Check that the claude CLI is on PATH and ~/.claude credentials are valid." \ + 2>/dev/null +} + # Find prompt file via af root FACTORY_ROOT=${AF_ROOT:-$(af root 2>/dev/null)} if [ -z "$FACTORY_ROOT" ]; then @@ -154,6 +171,7 @@ fi # Check claude CLI is available if ! command -v claude &>/dev/null; then echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) EXIT8: no_claude_binary" >> "$AGENT_RUNTIME/fidelity_debug.log" 2>/dev/null + notify_grader_unavailable "no_claude_binary" echo '{"ok": true}' exit 0 fi @@ -183,13 +201,19 @@ $TOOL_CONTEXT" fi # Run evaluation via haiku -VERDICT=$(claude -p --model haiku --max-turns 1 \ +VERDICT=$(env -i HOME="$HOME" PATH="$PATH" claude -p --model haiku --max-turns 1 \ --system-prompt "You are a JSON-only fidelity gate. You receive an assistant's response, the current formula step's contract, and the tool calls executed this turn. Evaluate adherence to the step contract considering BOTH the text AND the tool evidence. Respond with ONLY valid JSON, nothing else. $(cat "$PROMPT_FILE")" \ "$EVAL_INPUT" 2>/dev/null) # Strip markdown code fences if present VERDICT=$(echo "$VERDICT" | sed 's/^```json//;s/^```//;/^$/d') +# An empty verdict means the grader produced nothing (unavailable / transient) and the +# gate is failing open this turn — surface it once per cause (idempotent), never block. +if [ -z "$VERDICT" ]; then + notify_grader_unavailable "empty_verdict" +fi + COUNTER_FILE="$AGENT_RUNTIME/fidelity_violations" if [ -n "$VERDICT" ] && echo "$VERDICT" | jq -e '.ok == false' &>/dev/null; then COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0) diff --git a/internal/cmd/install_hooks/quality-gate.sh b/internal/cmd/install_hooks/quality-gate.sh index c423624..18d0b1c 100755 --- a/internal/cmd/install_hooks/quality-gate.sh +++ b/internal/cmd/install_hooks/quality-gate.sh @@ -9,6 +9,23 @@ ROLE=${AF_ROLE:-$(basename "$(pwd)")} AGENT_RUNTIME="$(pwd)/.runtime" +# notify_grader_unavailable emits a one-time notice per agent per cause (issue #508) +# when the haiku grader cannot produce a verdict, so a persistent outage — which +# fails the gate open every turn (ADR-007 never-block) — becomes visible instead of +# silent. Idempotent via a .runtime marker (the fidelity-gate per-agent-state idiom), +# so it is one mail per cause, not a per-turn storm. Sent to the agent's own inbox +# (ADR-007: no fire-and-forget escalation into a possibly-absent recipient). +notify_grader_unavailable() { + cause="$1" + marker="$AGENT_RUNTIME/grader_notice_$cause" + [ -f "$marker" ] && return 0 + mkdir -p "$AGENT_RUNTIME" 2>/dev/null + : > "$marker" 2>/dev/null + af mail send "$ROLE" -s "GRADER_UNAVAILABLE" \ + -m "quality gate grader unavailable ($cause): the haiku grader produced no verdict this turn, so the gate is failing open. Check that the claude CLI is on PATH and ~/.claude credentials are valid." \ + 2>/dev/null +} + # Find prompt file via af root FACTORY_ROOT=${AF_ROOT:-$(af root 2>/dev/null)} if [ -z "$FACTORY_ROOT" ]; then @@ -95,6 +112,7 @@ fi # Check claude CLI is available if ! command -v claude &>/dev/null; then echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) EXIT6: no_claude_binary" >> "$AGENT_RUNTIME/quality_debug.log" 2>/dev/null + notify_grader_unavailable "no_claude_binary" echo '{"ok": true}' exit 0 fi @@ -109,13 +127,19 @@ $TOOL_CONTEXT" fi # Run evaluation via haiku -VERDICT=$(claude -p --model haiku --max-turns 1 \ +VERDICT=$(env -i HOME="$HOME" PATH="$PATH" claude -p --model haiku --max-turns 1 \ --system-prompt "You are a JSON-only quality gate. You receive an assistant's response along with the tool calls it executed. Evaluate the response considering BOTH the text AND the tool evidence. Respond with ONLY valid JSON, nothing else. $(cat "$PROMPT_FILE")" \ "$EVAL_INPUT" 2>/dev/null) # Strip markdown code fences if present VERDICT=$(echo "$VERDICT" | sed 's/^```json//;s/^```//;/^$/d') +# An empty verdict means the grader produced nothing (unavailable / transient) and the +# gate is failing open this turn — surface it once per cause (idempotent), never block. +if [ -z "$VERDICT" ]; then + notify_grader_unavailable "empty_verdict" +fi + # Mail verdict to self only on failure if [ -n "$VERDICT" ] && echo "$VERDICT" | jq -e '.ok == false' &>/dev/null; then af mail send "$ROLE" -s "QUALITY_GATE" -m "$VERDICT" 2>/dev/null diff --git a/internal/cmd/install_scaffold_test.go b/internal/cmd/install_scaffold_test.go index 949c462..c33cfaf 100644 --- a/internal/cmd/install_scaffold_test.go +++ b/internal/cmd/install_scaffold_test.go @@ -3,7 +3,9 @@ package cmd import ( "encoding/json" "os" + "path/filepath" "reflect" + "regexp" "strings" "testing" @@ -114,3 +116,101 @@ func TestInstallScaffold_StartupAgentsDefaultManagerOnly(t *testing.T) { t.Errorf("scaffold watchdog_agents = %v, want [manager supervisor] (keep the #408 fix)", startup.WatchdogAgents) } } + +// TestInstallScaffold_ModelsJsonSeeded pins the models.json seed (issue #480): a fresh +// `af install --init` must seed a models.json that PASSES ITS OWN VALIDATOR — because +// LoadModelsConfig runs validateModelsConfig on every launch, a seed with an incomplete +// endpoint (base_url without auth_token) or a non-empty ANTHROPIC_API_KEY would brick +// the next launch / teach the broken local-profile pattern. This source-parses the REAL +// install.go literal and round-trips it through the REAL LoadModelsConfig (not a grep), +// then asserts the lmstudio profile ships a complete local-endpoint set — with the +// explicit ANTHROPIC_API_KEY clear and a genuinely local model id. +func TestInstallScaffold_ModelsJsonSeeded(t *testing.T) { + data, err := os.ReadFile("install.go") + if err != nil { + t.Fatalf("read install.go: %v", err) + } + modelsLiteral := extractScaffoldLiteral(t, string(data), `"models.json":`) + + // Round-trip through the REAL loader/validator: write the seed to a temp + // factory and LoadModelsConfig must return nil error. + root := t.TempDir() + afDir := filepath.Join(root, ".agentfactory") + if err := os.MkdirAll(afDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(config.ModelsConfigPath(root), []byte(modelsLiteral), 0o644); err != nil { + t.Fatalf("write seed models.json: %v", err) + } + loaded, err := config.LoadModelsConfig(root) + if err != nil { + t.Fatalf("seed models.json must pass its own validator; LoadModelsConfig: %v\nliteral: %s", err, modelsLiteral) + } + + // The default profile must exist; its model id is asserted against quickstart.sh + // by TestInstallScaffold_DefaultModel_MatchesQuickstart, not pinned here. + if _, ok := loaded.Models["default"]; !ok { + t.Fatalf("seed must define a `default` profile; got models=%v", loaded.Models) + } + + // lmstudio profile ships the COMPLETE local-endpoint set: base_url + auth_token + // + ANTHROPIC_MODEL + the ANTHROPIC_API_KEY:"" clear. + lm, ok := loaded.Models["lmstudio"] + if !ok { + t.Fatalf("seed must define an `lmstudio` example profile; got models=%v", loaded.Models) + } + if lm["ANTHROPIC_BASE_URL"] == "" { + t.Error("lmstudio profile must set a non-empty ANTHROPIC_BASE_URL") + } + if lm["ANTHROPIC_AUTH_TOKEN"] == "" { + t.Error("lmstudio profile must set a non-empty ANTHROPIC_AUTH_TOKEN (base_url requires auth_token)") + } + // The local-endpoint example must name a model an LM Studio server can actually + // serve — a cloud id here means copying the example verbatim yields an agent + // requesting a nonexistent model (PR #482 review). + if got := lm["ANTHROPIC_MODEL"]; got != "qwen2.5-coder-32b" { + t.Errorf("lmstudio profile ANTHROPIC_MODEL = %q, want the local id %q", got, "qwen2.5-coder-32b") + } + if v, ok := lm["ANTHROPIC_API_KEY"]; !ok || v != "" { + t.Errorf("lmstudio profile must ship ANTHROPIC_API_KEY:\"\" (explicit key-clear); present=%v val=%q", ok, v) + } + + // The durable per-agent home must be present (even if empty) for + // `af config models set` to write into. + if loaded.Agents == nil { + t.Error("seed should ship an `agents` map (the regen-immune per-agent default home)") + } +} + +// TestInstallScaffold_DefaultModel_MatchesQuickstart pins install-seed ↔ quickstart +// consistency: a fresh `af install --init` and a quickstart.sh bootstrap must yield the +// SAME default model. The expected id is parsed from quickstart.sh's own +// ${ANTHROPIC_MODEL:-...} fallback — never hardcoded — so a future quickstart model bump +// fails here until the seed follows. The drift this guards against actually happened: +// quickstart.sh moved to claude-fable-5 while the seed stayed on claude-opus-4-8, and a +// literal-vs-literal assertion kept CI green (PR #482 review). +func TestInstallScaffold_DefaultModel_MatchesQuickstart(t *testing.T) { + qs, err := os.ReadFile("../../quickstart.sh") + if err != nil { + t.Fatalf("read quickstart.sh: %v", err) + } + m := regexp.MustCompile(`\$\{ANTHROPIC_MODEL:-([^}]+)\}`).FindSubmatch(qs) + if m == nil { + t.Fatal("quickstart.sh no longer contains an ${ANTHROPIC_MODEL:-...} default — update this parity test alongside it") + } + want := string(m[1]) + + data, err := os.ReadFile("install.go") + if err != nil { + t.Fatalf("read install.go: %v", err) + } + modelsLiteral := extractScaffoldLiteral(t, string(data), `"models.json":`) + var seed config.ModelsConfig + if err := json.Unmarshal([]byte(modelsLiteral), &seed); err != nil { + t.Fatalf("unmarshal scaffold models.json literal: %v\nliteral: %s", err, modelsLiteral) + } + + if got := seed.Models["default"]["ANTHROPIC_MODEL"]; got != want { + t.Errorf("seeded default profile ANTHROPIC_MODEL = %q, but quickstart.sh defaults to %q — af install --init and a quickstart bootstrap must agree on the default model", got, want) + } +} diff --git a/internal/cmd/install_skills/formula-create/SKILL.md b/internal/cmd/install_skills/formula-create/SKILL.md index f560a04..23636f5 100644 --- a/internal/cmd/install_skills/formula-create/SKILL.md +++ b/internal/cmd/install_skills/formula-create/SKILL.md @@ -247,7 +247,7 @@ After creating the formula, inform the user: Formula created: .agentfactory/store/formulas/<name>.formula.toml To inspect: - bd formula show <name> # View formula details + af formula show <name> --json # View formula inputs and vars To use immediately (current workspace): af sling --formula <name> --var x=y # Execute the formula diff --git a/internal/cmd/install_skills/formula-create/skillmd-mode.md b/internal/cmd/install_skills/formula-create/skillmd-mode.md index 6063e61..fe6c8cb 100644 --- a/internal/cmd/install_skills/formula-create/skillmd-mode.md +++ b/internal/cmd/install_skills/formula-create/skillmd-mode.md @@ -50,14 +50,14 @@ execution formula regardless of the skill being converted. | N+2 | `run-tests` | Full test suite, terraform validation if applicable | | N+3 | `self-verify` | Gate: verify implementation matches design contract (Jidoka) | | N+4 | `cleanup-workspace` | Skill artifact removal, workspace hygiene, push branch | -| N+5 | `prepare-for-review` | `bd update` with completion notes | +| N+5 | `prepare-for-review` | `af bead update` with completion notes | | N+6 | `submit-and-exit` | `af done`, become recyclable | **The `needs` chain must be unbroken.** The first domain step needs `["preflight-tests"]`. The `self-review` step needs `["<last-domain-step-or-gate>"]`. Every step between has a `needs` pointing to the previous step. -**The `load-context` step MUST include requirement source detection.** Polecats receive +**The `load-context` step MUST include requirement source detection.** Agents receive requirements through their assigned bead, which may provide them in different forms. Include this three-branch pattern in every `load-context` step: @@ -141,12 +141,12 @@ Run this command: ## 10.5 Work Step Content Strategy Step descriptions are **self-contained**: -- Embed the relevant phase content directly (the polecat can execute without reading external files) +- Embed the relevant phase content directly (the agent can execute without reading external files) - Preserve all bash code blocks from the skill phase - Preserve all enforcement language - Include relevant anti-patterns from the skill's Anti-Patterns section - Use source-agnostic language for requirements. Skills often say "proposal" or "spec document", - but polecats receive work through beads. Replace "read the proposal" with "read the requirements + but agents receive work through beads. Replace "read the proposal" with "read the requirements (from bead description, proposal file, or GitHub issue)". Never assume a specific file exists — the bead is always the starting point. @@ -254,7 +254,7 @@ rm -f test_results.txt Derive the artifact list from the skill's `## Outputs` section. Only remove files that the skill creates as working artifacts — not production code or test files. -**Adaptation 3 — `prepare-for-review`:** Replace the generic `bd update` notes with a +**Adaptation 3 — `prepare-for-review`:** Replace the generic `af bead update` notes with a skill-specific completion summary describing the artifacts produced. For example, a design formula should mention the design-doc.md and dimension analyses; a TDD formula should mention the fix and test files. diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 9092bbf..1bc40ea 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -37,6 +37,7 @@ func init() { sendCmd.Flags().StringP("message", "m", "", "Message body (required)") sendCmd.Flags().String("priority", "normal", "Priority: urgent, high, normal, low") sendCmd.Flags().String("reply-to", "", "ID of message being replied to") + sendCmd.Flags().String("from", "", "Send as this identity (agents.json member or 'operator'); skips sender auto-detection") _ = sendCmd.MarkFlagRequired("subject") _ = sendCmd.MarkFlagRequired("message") mailCmd.AddCommand(sendCmd) @@ -98,15 +99,41 @@ func runMailSend(cmd *cobra.Command, args []string) error { body, _ := cmd.Flags().GetString("message") priorityStr, _ := cmd.Flags().GetString("priority") replyToID, _ := cmd.Flags().GetString("reply-to") + from, _ := cmd.Flags().GetString("from") wd, err := getWd() if err != nil { return err } - sender, err := detectSender(wd) - if err != nil { - return err + // The cmd layer is the only From enforcement point — Router and the + // Message constructors accept any sender verbatim. "operator" is the + // single identity accepted without agents.json membership (the + // web console's mail sentinel, #500). + var sender string + if from != "" { + if from != "operator" { + if err := config.ValidateAgentName(from); err != nil { + return fmt.Errorf("invalid --from %q: %w", from, err) + } + root, err := config.FindFactoryRoot(wd) + if err != nil { + return fmt.Errorf("validating --from: %w", err) + } + agentsCfg, err := config.LoadAgentConfig(config.AgentsConfigPath(root)) + if err != nil { + return fmt.Errorf("validating --from: %w", err) + } + if _, ok := agentsCfg.Agents[from]; !ok { + return fmt.Errorf("--from %q is not an agents.json member (or the literal \"operator\")", from) + } + } + sender = from + } else { + sender, err = detectSender(wd) + if err != nil { + return err + } } var msg *mail.Message @@ -420,4 +447,3 @@ func newMailboxForSender(sender, wd string) (*mail.Mailbox, error) { } return mail.NewMailbox(sender, store), nil } - diff --git a/internal/cmd/mail_test.go b/internal/cmd/mail_test.go index ff9481e..778f09e 100644 --- a/internal/cmd/mail_test.go +++ b/internal/cmd/mail_test.go @@ -1,9 +1,14 @@ package cmd import ( + "bytes" + "context" "os" "path/filepath" + "strings" "testing" + + "github.com/stempeck/agentfactory/internal/mail" ) // setupWorktreeFixture creates a realistic worktree filesystem layout: @@ -127,6 +132,168 @@ func TestDetectSender_WrongButNoError_NoAF_ROLE_Errors(t *testing.T) { } } +// setupMailSendFixture creates a two-member factory (alice, bob) including +// the messaging.json that mail.NewRouter hard-requires. The one-member +// helpers above predate cobra-level send coverage and write neither. +func setupMailSendFixture(t *testing.T) string { + t.Helper() + factoryRoot := t.TempDir() + + afDir := filepath.Join(factoryRoot, ".agentfactory") + if err := os.MkdirAll(afDir, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(afDir, "factory.json"), []byte(`{}`), 0o644) + os.WriteFile(filepath.Join(afDir, "agents.json"), + []byte(`{"agents":{"alice":{"type":"autonomous","description":"test agent"},"bob":{"type":"autonomous","description":"test agent"}}}`), 0o644) + os.WriteFile(filepath.Join(afDir, "messaging.json"), []byte(`{"groups":{}}`), 0o644) + + return factoryRoot +} + +// resetMailSendFlags restores every `mail send` flag to its default and +// clears Changed. Flag state persists across rootCmd.Execute() calls (see +// the warning in install_test.go), so every Execute in this file must be +// followed by a reset or values leak into sibling tests. +func resetMailSendFlags(t *testing.T) { + t.Helper() + sendCmd, _, err := rootCmd.Find([]string{"mail", "send"}) + if err != nil { + t.Fatalf("finding mail send command: %v", err) + } + for _, name := range []string{"from", "subject", "message", "priority", "reply-to"} { + f := sendCmd.Flags().Lookup(name) + if f == nil { + continue + } + if err := f.Value.Set(f.DefValue); err != nil { + t.Fatalf("resetting --%s: %v", name, err) + } + f.Changed = false + } +} + +// execMailSend drives `af mail send` through the real cobra root (the +// runInstallInDir pattern) with the mandatory flag reset afterwards. +func execMailSend(t *testing.T, args ...string) error { + t.Helper() + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs(append([]string{"mail", "send"}, args...)) + err := rootCmd.Execute() + resetMailSendFlags(t) + return err +} + +// TestMailSend_FromFlag_SkipsDetectSender pins the --from contract: from the +// factory root (the one cwd where detectSender fails but factory-root +// discovery succeeds — the web tier's exact situation) an explicit member +// sender is used verbatim and auto-detection is never consulted. +func TestMailSend_FromFlag_SkipsDetectSender(t *testing.T) { + factoryRoot := setupMailSendFixture(t) + store := installMemStore(t) + t.Chdir(factoryRoot) + t.Setenv("AF_ROLE", "") + + if err := execMailSend(t, "bob", "-s", "s", "-m", "m", "--from", "alice"); err != nil { + t.Fatalf("mail send --from alice from factory root: %v", err) + } + + msgs, err := mail.NewMailbox("bob", store).List(context.Background()) + if err != nil { + t.Fatalf("listing bob's mailbox: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("bob's mailbox has %d messages, want 1", len(msgs)) + } + if msgs[0].From != "alice" { + t.Errorf("stored From = %q, want %q", msgs[0].From, "alice") + } +} + +func TestMailSend_FromFlag_Validated(t *testing.T) { + cases := []struct { + name string + from string + wantErr bool + errPart string + wantFrom string + }{ + {name: "shape reject", from: "bad name!", wantErr: true, errPart: `"bad name!"`}, + {name: "reserved reject", from: "dispatch", wantErr: true, errPart: `"dispatch"`}, + {name: "non-member reject", from: "ceo", wantErr: true, errPart: "not an agents.json member"}, + {name: "operator accept", from: "operator", wantFrom: "operator"}, + {name: "member accept", from: "alice", wantFrom: "alice"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + factoryRoot := setupMailSendFixture(t) + store := installMemStore(t) + t.Chdir(factoryRoot) + t.Setenv("AF_ROLE", "") + + err := execMailSend(t, "bob", "-s", "s", "-m", "m", "--from", tc.from) + + msgs, listErr := mail.NewMailbox("bob", store).List(context.Background()) + if listErr != nil { + t.Fatalf("listing bob's mailbox: %v", listErr) + } + if tc.wantErr { + if err == nil { + t.Fatalf("mail send --from %q should fail", tc.from) + } + if !strings.Contains(err.Error(), tc.errPart) { + t.Errorf("error %q does not mention %s", err, tc.errPart) + } + if len(msgs) != 0 { + t.Errorf("rejected send stored %d messages, want 0 (validation must precede Router.Send)", len(msgs)) + } + return + } + if err != nil { + t.Fatalf("mail send --from %q: %v", tc.from, err) + } + if len(msgs) != 1 { + t.Fatalf("bob's mailbox has %d messages, want 1", len(msgs)) + } + if msgs[0].From != tc.wantFrom { + t.Errorf("stored From = %q, want %q", msgs[0].From, tc.wantFrom) + } + }) + } +} + +// TestMailSend_NoFromFlag_UsesDetectSender pins the absent-flag regression +// contract: without --from, the send path resolves the sender exactly as +// today (getWd → detectSender), which hooks and Go-side subprocess callers +// depend on. +func TestMailSend_NoFromFlag_UsesDetectSender(t *testing.T) { + factoryRoot := setupMailSendFixture(t) + store := installMemStore(t) + aliceDir := filepath.Join(factoryRoot, ".agentfactory", "agents", "alice") + if err := os.MkdirAll(aliceDir, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(aliceDir) + t.Setenv("AF_ROLE", "") + + if err := execMailSend(t, "bob", "-s", "s", "-m", "m"); err != nil { + t.Fatalf("mail send without --from from alice's dir: %v", err) + } + + msgs, err := mail.NewMailbox("bob", store).List(context.Background()) + if err != nil { + t.Fatalf("listing bob's mailbox: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("bob's mailbox has %d messages, want 1", len(msgs)) + } + if msgs[0].From != "alice" { + t.Errorf("stored From = %q, want %q (detectSender path)", msgs[0].From, "alice") + } +} + func TestDetectSender_WorktreeAgent_AF_ROLE_Fallback(t *testing.T) { // Set up a worktree where path-based detection works, // but also verify AF_ROLE is respected when paths fail. diff --git a/internal/cmd/model_selection_phase3_test.go b/internal/cmd/model_selection_phase3_test.go new file mode 100644 index 0000000..14dbf49 --- /dev/null +++ b/internal/cmd/model_selection_phase3_test.go @@ -0,0 +1,283 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stempeck/agentfactory/internal/config" +) + +// writeSecretFile stages a secret file at <root>/<rel> so the W3 preflight's os.Stat +// succeeds (issue #508). Returns the absolute path. +func writeSecretFile(t *testing.T, root, rel, content string) string { + t.Helper() + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir secret dir: %v", err) + } + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatalf("write secret file: %v", err) + } + return p +} + +// writeAttestationFixture stages a valid factory-root fitness attestation so the W13 +// interlock reads it as attested. Written directly (not via `attest`) to keep the +// resolver test decoupled from the command's writer. +func writeAttestationFixture(t *testing.T, root, profile string) { + t.Helper() + dir := filepath.Join(root, ".runtime", "model_fitness") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir model_fitness: %v", err) + } + body := fmt.Sprintf(`{"profile":%q,"attested_by":"tester","attested_at":"2026-07-06T00:00:00Z","stages":{"transport":"pass"}}`, profile) + if err := os.WriteFile(filepath.Join(dir, profile+".json"), []byte(body), 0o644); err != nil { + t.Fatalf("write attestation: %v", err) + } +} + +// codexProfile is a non-loopback remote profile whose auth token is a file: ref. +func codexModels() *config.ModelsConfig { + return &config.ModelsConfig{ + Models: map[string]map[string]string{ + "codex": { + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_BASE_URL": "https://gw.example:4000", + "ANTHROPIC_AUTH_TOKEN": "file:secrets/codex.key", + }, + }, + } +} + +// --- W3: launch preflight (AC-3) --- + +func TestResolveLaunchModelEnv_MissingSecretFile_SelectingFailsFast(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, codexModels()) + // deliberately do NOT create secrets/codex.key + + var warn bytes.Buffer + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "codex", "", false, &warn) + if err == nil { + t.Fatalf("expected fail-fast for a missing file: secret on a selecting launch; got name=%q env=%v", name, env) + } + if !strings.Contains(err.Error(), "codex") { + t.Errorf("error must name the profile 'codex'; got: %v", err) + } + wantPath := filepath.Join(dir, "secrets", "codex.key") + if !strings.Contains(err.Error(), wantPath) { + t.Errorf("error must name the resolved secret path %q; got: %v", wantPath, err) + } + if env != nil { + t.Errorf("no export set must be produced on fail-fast; got: %v", env) + } +} + +func TestResolveLaunchModelEnv_MissingSecretFile_RespawnWarnsFallsThrough(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + cfg := codexModels() + cfg.Agents = map[string]string{"manager": "codex"} + writeValidModels(t, dir, cfg) + // secret file absent + + var warn bytes.Buffer + // cliModel "" => respawn / non-selecting. + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "", "", false, &warn) + if err != nil { + t.Fatalf("a respawn must NOT fail on a missing secret; got err: %v", err) + } + if name != "" || len(env) != 0 { + t.Errorf("respawn with a missing secret must fall through to the global default (\"\", nil); got name=%q env=%v", name, env) + } + w := warn.String() + if !strings.Contains(w, "codex") { + t.Errorf("warn must name the abandoned model 'codex'; got: %q", w) + } + if !strings.Contains(w, filepath.Join(dir, "secrets", "codex.key")) { + t.Errorf("warn must name the missing secret path; got: %q", w) + } + if !strings.Contains(w, "global default") { + t.Errorf("warn must say it is falling back to the global default; got: %q", w) + } +} + +// --- W13: fitness attestation interlock (AC-5) --- + +func TestResolveLaunchModelEnv_UnattestedNonLoopback_SelectingRefused(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, codexModels()) + writeSecretFile(t, dir, "secrets/codex.key", "sk-real-value") // W3 passes; W13 is the gate + // no attestation + + var warn bytes.Buffer + _, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "codex", "", false, &warn) + if err == nil { + t.Fatal("a selecting launch of an unattested non-loopback profile must be refused") + } + if !strings.Contains(err.Error(), "af config models attest") { + t.Errorf("refusal must name `af config models attest`; got: %v", err) + } + if !strings.Contains(err.Error(), "--skip-fitness") { + t.Errorf("refusal must name `--skip-fitness`; got: %v", err) + } + if env != nil { + t.Errorf("no export set must be produced on refusal; got: %v", env) + } +} + +func TestResolveLaunchModelEnv_LoopbackProfile_LaunchesUnaffected(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{ + "lmstudio": { + "ANTHROPIC_MODEL": "local", + "ANTHROPIC_BASE_URL": "http://localhost:1234", + "ANTHROPIC_AUTH_TOKEN": "lm-studio", + "ANTHROPIC_API_KEY": "", + }, + }, + }) + + var warn bytes.Buffer + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "lmstudio", "", false, &warn) + if err != nil { + t.Fatalf("a loopback profile must be exempt from the attestation interlock; got err: %v", err) + } + if name != "lmstudio" || len(env) == 0 { + t.Errorf("loopback selecting launch must succeed with an export set; got name=%q env=%v", name, env) + } +} + +func TestResolveLaunchModelEnv_NoEndpointProfile_NeedsNoAttestation(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{"sonnet": {"ANTHROPIC_MODEL": "claude-sonnet-4-6"}}, + }) + + var warn bytes.Buffer + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "sonnet", "", false, &warn) + if err != nil { + t.Fatalf("a plain model-id profile (no endpoint) must NOT require attestation (AC-1 no-regression); got err: %v", err) + } + if name != "sonnet" || modelEnvValue(env, "ANTHROPIC_MODEL") != "claude-sonnet-4-6" { + t.Errorf("no-endpoint selecting launch must succeed; got name=%q env=%v", name, env) + } +} + +func TestResolveLaunchModelEnv_SkipFitness_ProceedsWithLog(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, codexModels()) + writeSecretFile(t, dir, "secrets/codex.key", "sk-real-value") + + var warn bytes.Buffer + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "codex", "", true, &warn) + if err != nil { + t.Fatalf("--skip-fitness must let the launch proceed; got err: %v", err) + } + if name != "codex" || len(env) == 0 { + t.Errorf("skip-fitness launch must return the export set; got name=%q env=%v", name, env) + } + if !strings.Contains(warn.String(), "skip-fitness") { + t.Errorf("skip-fitness must emit a loud override log line; got: %q", warn.String()) + } +} + +func TestResolveLaunchModelEnv_Respawn_NonLoopback_NeverBricks(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + cfg := codexModels() + cfg.Agents = map[string]string{"manager": "codex"} + writeValidModels(t, dir, cfg) + writeSecretFile(t, dir, "secrets/codex.key", "sk-real-value") + // no attestation + + var warn bytes.Buffer + // cliModel "" => respawn: must never reach the refuse branch. + _, _, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "", "", false, &warn) + if err != nil { + t.Fatalf("a respawn of an unattested non-loopback profile must never brick; got err: %v", err) + } +} + +func TestResolveLaunchModelEnv_Attested_NonLoopback_Launches(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, codexModels()) + writeSecretFile(t, dir, "secrets/codex.key", "sk-real-value") + writeAttestationFixture(t, dir, "codex") + + var warn bytes.Buffer + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "codex", "", false, &warn) + if err != nil { + t.Fatalf("an attested non-loopback profile must launch; got err: %v", err) + } + if name != "codex" || len(env) == 0 { + t.Errorf("attested selecting launch must return the export set; got name=%q env=%v", name, env) + } +} + +// --- W3/W13 edge paths (present in the code; pinned per code-review suggestion) --- + +func TestResolveLaunchModelEnv_EmptySecretFile_SelectingFailsFast(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, codexModels()) + writeSecretFile(t, dir, "secrets/codex.key", "") // exists but zero-length + + var warn bytes.Buffer + _, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "codex", "", false, &warn) + if err == nil { + t.Fatal("an empty (zero-length) secret file must fail the preflight on a selecting launch") + } + if env != nil { + t.Errorf("no export set must be produced on fail-fast; got %v", env) + } +} + +func TestResolveLaunchModelEnv_AbsoluteSecretPath_MissingFailsFast(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + absPath := filepath.Join(t.TempDir(), "abs-codex.key") // absolute, deliberately not created + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{ + "codex": { + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_BASE_URL": "https://gw.example:4000", + "ANTHROPIC_AUTH_TOKEN": "file:" + absPath, + }, + }, + }) + + var warn bytes.Buffer + _, _, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "codex", "", false, &warn) + if err == nil { + t.Fatal("a missing absolute file: secret path must fail fast") + } + if !strings.Contains(err.Error(), absPath) { + t.Errorf("error must name the absolute secret path %q (used as-is, not joined to root); got %v", absPath, err) + } +} + +func TestResolveLaunchModelEnv_CorruptAttestation_FailsClosed(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, codexModels()) + writeSecretFile(t, dir, "secrets/codex.key", "sk-real-value") // W3 passes + + // A corrupt (non-JSON) attestation must be treated as UNATTESTED (fail-closed). + fitDir := filepath.Join(dir, ".runtime", "model_fitness") + if err := os.MkdirAll(fitDir, 0o755); err != nil { + t.Fatalf("mkdir model_fitness: %v", err) + } + if err := os.WriteFile(filepath.Join(fitDir, "codex.json"), []byte("not json{{{"), 0o644); err != nil { + t.Fatalf("write corrupt attestation: %v", err) + } + + var warn bytes.Buffer + _, _, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "codex", "", false, &warn) + if err == nil { + t.Fatal("a corrupt attestation must be treated as unattested (fail-closed) and refused") + } + if !strings.Contains(err.Error(), "af config models attest") { + t.Errorf("refusal must name the attest command; got %v", err) + } +} diff --git a/internal/cmd/model_selection_test.go b/internal/cmd/model_selection_test.go new file mode 100644 index 0000000..b42a051 --- /dev/null +++ b/internal/cmd/model_selection_test.go @@ -0,0 +1,308 @@ +package cmd + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/stempeck/agentfactory/internal/config" + "github.com/stempeck/agentfactory/internal/issuestore" + "github.com/stempeck/agentfactory/internal/issuestore/memstore" +) + +// writeValidModels writes a validated models.json into the factory at root. +func writeValidModels(t *testing.T, root string, cfg *config.ModelsConfig) { + t.Helper() + if err := config.SaveModelsConfig(config.ModelsConfigPath(root), cfg); err != nil { + t.Fatalf("SaveModelsConfig: %v", err) + } +} + +// writeRawModels writes raw bytes to models.json, bypassing validation — the only +// way to stage a malformed or incomplete-endpoint file (SaveModelsConfig rejects them). +func writeRawModels(t *testing.T, root, raw string) { + t.Helper() + if err := os.WriteFile(config.ModelsConfigPath(root), []byte(raw), 0o644); err != nil { + t.Fatalf("write raw models.json: %v", err) + } +} + +// --- flag + marker helpers --- + +func TestSling_ModelFlag_Registered(t *testing.T) { + if slingCmd.Flags().Lookup("model") == nil { + t.Error("af sling must register a --model flag") + } +} + +func TestModelOverrideMarker_RoundTrip(t *testing.T) { + dir := t.TempDir() + if got := readModelOverride(dir); got != "" { + t.Errorf("readModelOverride on empty dir = %q, want \"\"", got) + } + writeModelOverride(dir, "sonnet") + if got := readModelOverride(dir); got != "sonnet" { + t.Errorf("readModelOverride after write = %q, want \"sonnet\"", got) + } + // Unconditional overwrite (mirrors writeDispatchedMarker, not write-if-absent). + writeModelOverride(dir, "opus") + if got := readModelOverride(dir); got != "opus" { + t.Errorf("writeModelOverride must overwrite a stale marker; got %q, want \"opus\"", got) + } + // Marker lives at .runtime/model_override (so --reset's .runtime wipe cleans it). + if _, err := os.Stat(config.AgentDir(dir, "x")); err == nil { + _ = err // no-op; keep import set stable + } +} + +// --- override beats the per-agent default --- + +func TestSling_ModelFlag_OverridesDefault(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{ + "opus": {"ANTHROPIC_MODEL": "claude-opus-4-8"}, + "sonnet": {"ANTHROPIC_MODEL": "claude-sonnet-4-6"}, + }, + Agents: map[string]string{"manager": "opus"}, + }) + + var warn bytes.Buffer + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "sonnet", "", false, &warn) + if err != nil { + t.Fatalf("resolveLaunchModelEnv: %v", err) + } + if name != "sonnet" { + t.Errorf("resolved name = %q, want \"sonnet\"", name) + } + if got := modelEnvValue(env, "ANTHROPIC_MODEL"); got != "claude-sonnet-4-6" { + t.Errorf("ANTHROPIC_MODEL = %q, want \"claude-sonnet-4-6\" (override must beat the per-agent default)", got) + } + if got := modelEnvValue(env, "ANTHROPIC_MODEL"); got == "claude-opus-4-8" { + t.Error("override must NOT resolve to the per-agent default claude-opus-4-8") + } +} + +// --- fail-fast before launch --- + +func TestSling_UnknownProfile_FailsBeforeLaunch(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{"opus": {"ANTHROPIC_MODEL": "claude-opus-4-8"}}, + }) + + var warn bytes.Buffer + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "ghost-profile", "", false, &warn) + if err == nil { + t.Fatalf("expected fail-fast error for unknown --model profile; got name=%q env=%v", name, env) + } + if !strings.Contains(err.Error(), "ghost-profile") { + t.Errorf("error should name the bad profile, got: %v", err) + } + if env != nil { + t.Errorf("no export set must be produced on fail-fast, got: %v", env) + } +} + +func TestSling_IncompleteEndpoint_FailsBeforeLaunch(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + // base_url without auth_token — SaveModelsConfig would reject this, so write raw. + writeRawModels(t, dir, `{"models":{"local":{"ANTHROPIC_MODEL":"claude-local","ANTHROPIC_BASE_URL":"http://localhost:8080"}}}`) + + var warn bytes.Buffer + _, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "local", "", false, &warn) + if err == nil { + t.Fatal("expected fail-fast error for incomplete endpoint (base_url without auth_token)") + } + if env != nil { + t.Errorf("no export set must be produced on fail-fast, got: %v", env) + } +} + +// --- malformed models.json blast radius --- + +func TestSling_MalformedModelsJson_DefaultAgentStillLaunches(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeRawModels(t, dir, "this is not json{{{") + + var warn bytes.Buffer + // No explicit --model (cliModel == "") — a default-model launch must NOT be bricked. + name, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "", "", false, &warn) + if err != nil { + t.Fatalf("a malformed models.json must NOT brick a default-model agent; got err: %v", err) + } + if len(env) != 0 { + t.Errorf("default launch must fall through to the global default (empty env), got: %v", env) + } + if name != "" { + t.Errorf("no model name should resolve on fall-through, got: %q", name) + } + if warn.Len() == 0 { + t.Error("the malformed-config fall-through should log a warning to the warn writer") + } +} + +func TestSling_MalformedModelsJson_ExplicitProfileFailsLoud(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeRawModels(t, dir, "this is not json{{{") + + var warn bytes.Buffer + // Explicit --model "opus" — a profile-selecting launch on a broken registry must fail loud. + _, env, err := resolveLaunchModelEnv(dir, "manager", config.AgentDir(dir, "manager"), "opus", "", false, &warn) + if err == nil { + t.Fatal("a profile-selecting launch must fail loud when models.json cannot be loaded") + } + if env != nil { + t.Errorf("no export set must be produced on fail-loud, got: %v", env) + } +} + +// --- per-agent default survives a simulated regen --- + +// TestModelsAssignment_SurvivesRegen pins the durability claim that justifies the +// top-level models.json.agents map (issue #480): a per-agent default declared +// there is regen-IMMUNE because agent-gen rebuilds AgentEntry WITHOUT a Model and +// `af install --agents`/`agent-gen --delete` `os.RemoveAll(wsDir)` the agent WORKSPACE +// (formula.go:231-236, :423) — but models.json lives at the FACTORY ROOT, outside the +// wiped workspace. This SIMULATES the regen (remove + rebuild the agent workspace dir) +// rather than invoking the real agent-gen, then proves the agent STILL resolves its +// profile from the agents map with no marker and no legacy entry model. +func TestModelsAssignment_SurvivesRegen(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{ + "opus": {"ANTHROPIC_MODEL": "claude-opus-4-8"}, + "sonnet": {"ANTHROPIC_MODEL": "claude-sonnet-4-6"}, + }, + Agents: map[string]string{"manager": "opus"}, + }) + + // Pre-regen: the per-agent default resolves from the agents map. + agentDir := config.AgentDir(dir, "manager") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatalf("seed agent workspace: %v", err) + } + var warn bytes.Buffer + name, env, err := resolveLaunchModelEnv(dir, "manager", agentDir, "", "", false, &warn) + if err != nil { + t.Fatalf("pre-regen resolveLaunchModelEnv: %v", err) + } + if name != "opus" || modelEnvValue(env, "ANTHROPIC_MODEL") != "claude-opus-4-8" { + t.Fatalf("pre-regen: agent must resolve its agents-map default opus; got name=%q env=%v", name, env) + } + + // Simulate `agent-gen --delete` + regen: the agent WORKSPACE is wiped and rebuilt + // (mirrors formula.go:423 os.RemoveAll(wsDir)). models.json at the factory root is + // untouched. + if err := os.RemoveAll(agentDir); err != nil { + t.Fatalf("simulate workspace wipe: %v", err) + } + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatalf("simulate workspace rebuild: %v", err) + } + + // Post-regen: the agents-map default must STILL resolve (the durability guarantee). + name, env, err = resolveLaunchModelEnv(dir, "manager", agentDir, "", "", false, &warn) + if err != nil { + t.Fatalf("post-regen resolveLaunchModelEnv: %v", err) + } + if name != "opus" { + t.Errorf("post-regen resolved name = %q, want \"opus\" (per-agent default must survive regen)", name) + } + if got := modelEnvValue(env, "ANTHROPIC_MODEL"); got != "claude-opus-4-8" { + t.Errorf("post-regen ANTHROPIC_MODEL = %q, want \"claude-opus-4-8\" (durable agents-map default lost across regen)", got) + } +} + +// --- model resolution is orthogonal to formula instantiation --- + +func TestModelResolution_OrthogonalToFormula(t *testing.T) { + const toml = ` +formula = "ortho" +type = "workflow" +version = 1 + +[[steps]] +id = "step1" +title = "Alpha" +description = "First step" + +[[steps]] +id = "step2" +title = "Beta" +description = "Second step" +needs = ["step1"] +` + type stepSnap struct { + title string + desc string + deps int + } + instantiate := func(root, agentDir string) map[string]stepSnap { + store := memstore.New() + orig := newIssueStore + newIssueStore = func(wd, actor string) (issuestore.Store, error) { return store, nil } + defer func() { newIssueStore = orig }() + + params := InstantiateParams{ + Ctx: t.Context(), + FormulaName: "ortho", + AgentName: "test-agent", + Root: root, + WorkDir: agentDir, + } + var buf bytes.Buffer + _, stepIDs, _, err := instantiateFormulaWorkflow(params, &buf) + if err != nil { + t.Fatalf("instantiateFormulaWorkflow: %v", err) + } + snap := make(map[string]stepSnap, len(stepIDs)) + for sid, bid := range stepIDs { + iss, err := store.Get(t.Context(), bid) + if err != nil { + t.Fatalf("store.Get(%s): %v", sid, err) + } + snap[sid] = stepSnap{title: iss.Title, desc: iss.Description, deps: len(iss.BlockedBy)} + } + return snap + } + + // Run A: no models.json — baseline formula graph. + rootA, agentDirA := createTestFormulaFactoryWithTOML(t, "ortho", "test-agent", toml) + baseline := instantiate(rootA, agentDirA) + + // Run B: a model selection is active (agents-map default + marker), then resolve it. + rootB, agentDirB := createTestFormulaFactoryWithTOML(t, "ortho", "test-agent", toml) + writeValidModels(t, rootB, &config.ModelsConfig{ + Models: map[string]map[string]string{"sonnet": {"ANTHROPIC_MODEL": "claude-sonnet-4-6"}}, + Agents: map[string]string{"test-agent": "sonnet"}, + }) + writeRuntimeFile(t, agentDirB, "model_override", "sonnet") + withModel := instantiate(rootB, agentDirB) + + // The model selection must actually be active in run B (else the test proves nothing). + var warn bytes.Buffer + _, env, err := resolveLaunchModelEnv(rootB, "test-agent", agentDirB, "", "", false, &warn) + if err != nil { + t.Fatalf("resolveLaunchModelEnv (run B): %v", err) + } + if modelEnvValue(env, "ANTHROPIC_MODEL") != "claude-sonnet-4-6" { + t.Fatalf("run B must have an active model selection, got env: %v", env) + } + + // The formula graph must be byte-identical regardless of the model selection. + if len(baseline) != len(withModel) { + t.Fatalf("step count differs: baseline %d vs with-model %d", len(baseline), len(withModel)) + } + for sid, b := range baseline { + w, ok := withModel[sid] + if !ok { + t.Errorf("step %q present in baseline but missing with a model selection (resolution must not drop steps)", sid) + continue + } + if b != w { + t.Errorf("step %q perturbed by model selection: baseline %+v vs with-model %+v", sid, b, w) + } + } +} diff --git a/internal/cmd/pr509_watchdog_redirect_test.go b/internal/cmd/pr509_watchdog_redirect_test.go new file mode 100644 index 0000000..a56f6f8 --- /dev/null +++ b/internal/cmd/pr509_watchdog_redirect_test.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/stempeck/agentfactory/internal/config" +) + +// TestWatchdog_BareConnectionErrorNoFalsePositive covers the PR #509 review finding +// that "connection refused" / "connection timed out" were matched as bare substrings. +// "connection refused" is Go's ECONNREFUSED text — an agent running go test, curl, or +// ssh against a closed port prints it for reasons unrelated to its model gateway, and a +// first match forcibly checkpoints and respawns the session. A benign local connection +// error with no model-gateway context must NOT trip detection. +func TestWatchdog_BareConnectionErrorNoFalsePositive(t *testing.T) { + outputs := []string{ + "dial tcp 127.0.0.1:4000: connect: connection refused", + "read tcp 127.0.0.1:54233->127.0.0.1:6379: connection timed out", + "go: could not connect to proxy: dial tcp 10.0.0.5:443: connect: connection refused", + } + for _, output := range outputs { + detected, pattern := detectErrorPattern(output) + if detected { + t.Errorf("benign local connection error must not trigger a respawn: %q -> %q", output, pattern) + } + } +} + +// TestRespawn_ProfileSwitch_ClearsStaleRedirectVars covers the PR #509 review finding +// that the redirect-var hygiene ran only in Start(), never on the respawn paths. It +// drives the real respawn path (respawnSession -> session.NewManager -> +// BuildStartupCommand -> RespawnPane) with a model-only profile and asserts the +// respawned command clears the stale model-redirect vars a prior profile could have +// left on the reused session. +func TestRespawn_ProfileSwitch_ClearsStaleRedirectVars(t *testing.T) { + dir := setupTestFactoryForDone(t, "manager") + writeValidModels(t, dir, &config.ModelsConfig{ + Models: map[string]map[string]string{"sonnet": {"ANTHROPIC_MODEL": "claude-sonnet-4-6"}}, + Agents: map[string]string{"manager": "sonnet"}, + }) + + mock := &mockTmux{} + err := respawnSession(RespawnOptions{ + FactoryRoot: dir, + AgentName: "manager", + AgentEntry: config.AgentEntry{Type: "interactive"}, + AgentWorkDir: config.AgentDir(dir, "manager"), + PaneID: "%0", + Tx: mock, + }) + if err != nil { + t.Fatalf("respawnSession: %v", err) + } + if len(mock.respawnPaneCalls) != 1 { + t.Fatalf("RespawnPane should be called once, got %d", len(mock.respawnPaneCalls)) + } + cmd := mock.respawnPaneCalls[0].cmd + for _, want := range []string{ + "ANTHROPIC_SMALL_FAST_MODEL=''", + "ANTHROPIC_DEFAULT_OPUS_MODEL=''", + "ANTHROPIC_DEFAULT_SONNET_MODEL=''", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=''", + "CLAUDE_CODE_SUBAGENT_MODEL=''", + } { + if !strings.Contains(cmd, want) { + t.Errorf("respawn with a model-only profile must clear stale redirect var %q so a prior profile cannot survive the reused session; got: %s", want, cmd) + } + } +} diff --git a/internal/cmd/quickdocker_test.go b/internal/cmd/quickdocker_test.go index 27fb7b1..adef39e 100644 --- a/internal/cmd/quickdocker_test.go +++ b/internal/cmd/quickdocker_test.go @@ -738,3 +738,134 @@ func TestWebBridgeHostListenerToolGuard(t *testing.T) { } }) } + +// TestQuickdockerWebRevealAtCompletion guards Issue #479 Phase 1 (commit 85c465fd) against +// silent regression. Phase 1 made three coordinated edits with no test: +// +// 1. the completion-path web reveal `if ! ( _web_bridge "$CONTAINER_NAME" … )` runs AFTER +// the `Setup complete!` banner and BEFORE the final `docker exec … bash`, so the URL is +// revealed before the user's single landing shell (quickdocker.sh:702 → :705); +// 2. Step-8's `docker exec` exports `-e AF_QUICKDOCKER_DRIVEN=1` (quickdocker.sh:641); and +// 3. quickstart.sh's redundant `exec bash` is guarded by `-z "${AF_QUICKDOCKER_DRIVEN:-}"` +// (quickstart.sh:719 → :721), so the driven install lands ONE shell, not two. +// +// (2) and (3) are the two halves of a single coordinated edit (K6): dropping EITHER reopens +// the double-shell hop, so BOTH are asserted (peer review's single enforcement gap). +// +// These are true mutation guards, not presence checks — the ordering subtests use +// strings.Index ORDER comparisons so moving/dropping any edit goes RED (AC#3). The search is +// anchored from `Setup complete!` because both `_web_bridge "$CONTAINER_NAME"` (the --web arm +// at :381) and `docker exec -it -u dev "$CONTAINER_NAME" bash` (the --shell arm at :375) +// appear a SECOND time before the banner; a whole-file Index would bind to the wrong lines. +// The export is asserted with a whole-file Contains, not a `[^\n]*…quickstart.sh` regex — +// Step-8 spans two physical lines (the `\` continuation on :641, `./quickstart.sh` on :642), +// so `[^\n]*` cannot cross the newline and that regex would be a false-green. +// +// The isolation subtests run a docker-free bash harness (the structure mirrors the relay +// wrapper, with `_web_bridge` stubbed) to pin the central safety property: the mandatory +// subshell `( … )` confines `_web_bridge`'s `exit 1` under `set -euo pipefail`, so the shell +// always lands. No docker — they run in the CI `unit` lane (bash only). +func TestQuickdockerWebRevealAtCompletion(t *testing.T) { + root := findModuleRoot(t) + read := func(name string) string { + data, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("reading %s: %v", name, err) + } + return string(data) + } + + // ── AC#3 mutation guard: completion reveal runs after the banner and BEFORE the shell ── + t.Run("completion_reveal_then_shell", func(t *testing.T) { + content := read("quickdocker.sh") + idxBanner := strings.Index(content, "Setup complete!") + if idxBanner < 0 { + t.Fatal("AC: quickdocker.sh missing the `Setup complete!` banner anchor") + } + // Anchor from the banner so we bind to the COMPLETION call (:702) and the FINAL shell + // (:705), not the pre-banner --web (:381) / --shell (:375) arms that share the literals. + tail := content[idxBanner:] + idxWeb := strings.Index(tail, `_web_bridge "$CONTAINER_NAME"`) + idxShell := strings.Index(tail, `docker exec -it -u dev "$CONTAINER_NAME" bash`) + if idxWeb < 0 || idxShell < 0 || idxWeb >= idxShell { + t.Errorf("AC#2/#3: completion _web_bridge (idx=%d) must run AFTER `Setup complete!` and "+ + "BEFORE the final `docker exec … bash` (idx=%d); -1 means the line was moved or removed", + idxWeb, idxShell) + } + // The reveal must stay subshell-wrapped: `_web_bridge` has five `exit 1` paths under + // `set -euo pipefail`, so only `if ! ( … )` confines them and lets the shell below land. + if !regexp.MustCompile(`if ! \( _web_bridge "\$CONTAINER_NAME"`).MatchString(tail) { + t.Error(`AC#3: completion reveal must be wrapped ` + "`if ! ( _web_bridge \"$CONTAINER_NAME\" … )`" + + ` so a bridge exit 1 is confined and the shell still lands (do not tidy the subshell away)`) + } + }) + + // ── K6 export half: Step-8 must carry the coordination var (whole-file; occurs once) ── + t.Run("step8_exports_driven_var", func(t *testing.T) { + if !strings.Contains(read("quickdocker.sh"), "-e AF_QUICKDOCKER_DRIVEN=1") { + t.Error("K6/AC#3: Step-8 `docker exec` must export `-e AF_QUICKDOCKER_DRIVEN=1` so quickstart " + + "can suppress its redundant in-container `exec bash` (dropping it reopens the double-shell hop)") + } + }) + + // ── K6 guard half (REQUIRED — peer review's single enforcement gap): quickstart's + // `exec bash` must be gated by the var, ordered guard-before-exec so dropping the + // guard alone goes RED even if the export half is still present. ── + t.Run("quickstart_exec_bash_guarded", func(t *testing.T) { + qs := read("quickstart.sh") + idxGuard := strings.Index(qs, `-z "${AF_QUICKDOCKER_DRIVEN:-}"`) + idxExec := strings.Index(qs, "exec bash") + if idxGuard < 0 || idxExec < 0 || idxGuard >= idxExec { + t.Errorf(`K6/AC#3: quickstart.sh `+"`exec bash`"+` (idx=%d) must be guarded by `+ + "`-z \"${AF_QUICKDOCKER_DRIVEN:-}\"`"+` (idx=%d) — ship both halves of K6 or neither`, + idxExec, idxGuard) + } + }) + + // ── Central safety property, docker-free: the subshell confines a failing bridge ── + // Mirrors the relay wrapper shape at quickdocker.sh:702-705 with `_web_bridge` stubbed to + // fail; the "shell that lands" is modelled by `echo LANDED` (no docker in the unit lane). + t.Run("isolation_subshell_confines_exit", func(t *testing.T) { + harness := "set -euo pipefail\n" + + "_web_bridge(){ echo boom >&2; exit 1; }\n" + + `if ! ( _web_bridge "x" "y" ); then echo NOTE >&2; fi` + "\n" + + "echo LANDED\n" + var stdout, stderr bytes.Buffer + cmd := exec.Command("bash", "-c", harness) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("AC#4: the `if ! ( _web_bridge … )` wrapper must NOT fail fatally when the bridge "+ + "exit 1's — the subshell should confine it: %v\nstderr:\n%s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "LANDED") { + t.Errorf("AC#4: the shell must always land — stdout missing LANDED:\nstdout:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "NOTE") { + t.Errorf("AC#4: the non-fatal note branch must run when the bridge fails — stderr missing NOTE:\nstderr:\n%s", stderr.String()) + } + }) + + // ── Negative control: PROVE the subshell is load-bearing. Without `( … )`, `_web_bridge`'s + // `exit 1` exits the whole script (exit, not return), so LANDED is never reached. This is + // why quickdocker.sh:702 must keep the subshell — it pins the mechanism, not just the + // current outcome. ── + t.Run("isolation_without_subshell_aborts", func(t *testing.T) { + harness := "set -euo pipefail\n" + + "_web_bridge(){ echo boom >&2; exit 1; }\n" + + `if ! _web_bridge "x" "y"; then echo NOTE >&2; fi` + "\n" + + "echo LANDED\n" + var stdout bytes.Buffer + cmd := exec.Command("bash", "-c", harness) + cmd.Stdout = &stdout + cmd.Stderr = io.Discard + err := cmd.Run() + if err == nil { + t.Error("control: without the subshell, `_web_bridge`'s `exit 1` should abort the script (non-zero exit)") + } + if strings.Contains(stdout.String(), "LANDED") { + t.Errorf("control: without the subshell the shell must NOT land — got LANDED, which means the "+ + "subshell at quickdocker.sh:702 is not what confines the exit:\nstdout:\n%s", stdout.String()) + } + }) +} diff --git a/internal/cmd/skill_foreign_cli_absence_test.go b/internal/cmd/skill_foreign_cli_absence_test.go new file mode 100644 index 0000000..99355a0 --- /dev/null +++ b/internal/cmd/skill_foreign_cli_absence_test.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "fmt" + "io/fs" + "regexp" + "strings" + "testing" +) + +// skill_foreign_cli_absence_test.go — foreign-CLI/vocabulary absence lint for +// embedded skills (issue #484). +// +// plan-work and plan-review were imported verbatim from the predecessor system +// and shipped with `bd`/`gt` invocations that no factory environment provides, +// breaking gherkin-engineering at story decomposition. Every prior interlock +// stopped at the skill file's front door: ValidateSkills os.Stat's SKILL.md, +// TestEmbeddedFormulaSkillsAvailable fs.Stat's the embedded copy, the af prime +// pre-flight scans only step-bead text, and TestNoDirectBdInFormulas walks only +// install_formulas/. This lint closes the dead zone by reading the CONTENT of +// every embedded skill markdown file, in the mold of +// formula_literal_absence_test.go / formula_dispatcher_coupling_absence_test.go: +// pattern class + allowlist hook + free check function + self-negative test. +// +// The patterns enumerate the foreign command surface (verb lists) rather than +// matching any `bd `/`gt ` token, so legitimate content stays clean: shell +// `-gt 0` comparisons, `gt doctor` in docker-exec examples, and file paths like +// internal/rig/manager.go. Bare "convoy" is deliberately NOT a pattern — it is +// a native formula type (type = "convoy"); only the `gt convoy` CLI form is +// foreign. + +// foreignGtInvocationPattern matches the predecessor dispatch/lifecycle CLI, +// replaced by af sling / af agents / af mail / af done. Named separately +// because the formula-scoped test applies ONLY this class. +var foreignGtInvocationPattern = regexp.MustCompile(`\bgt\s+(?:sling|convoy|rig|nudge|polecats?|handoff|done|prime|install|mail|verify|hook|formula|up|down)\b`) + +var foreignSkillCLIPatterns = []*regexp.Regexp{ + // bd invocations — the predecessor issue-store CLI, replaced by `af bead` + regexp.MustCompile(`\bbd\s+(?:create|show|update|list|close|dep|ready|prime|sync|agent|config|formula)\b`), + foreignGtInvocationPattern, + // command substitution over either foreign CLI + regexp.MustCompile(`\$\((?:bd|gt)\s`), + // predecessor-system vocabulary with no factory meaning + regexp.MustCompile(`(?i)\btarget_rig\b`), + regexp.MustCompile(`(?i)\bpolecats?\b`), + regexp.MustCompile(`(?i)\bmayor\b`), + regexp.MustCompile(`(?i)\bgas\s?town\b`), +} + +// foreignSkillCLIAllowlist suppresses lines that match a pattern above but are +// known-legitimate. bd/gt inside `docker exec … bash -c '…'` (rootcause-review / +// rootcause-implementfix walkthrough examples) run against an example +// container's own toolchain, not the factory PATH. +var foreignSkillCLIAllowlist = []*regexp.Regexp{ + regexp.MustCompile(`\bdocker exec\b`), +} + +// checkForeignCLIRefs returns one human-readable violation per line of content +// that references the predecessor system's CLIs or vocabulary. A nil result +// means clean. Free function so the self-negative test can prove the check +// bites without touching real skills. +func checkForeignCLIRefs(content string) []string { + var violations []string + for i, line := range strings.Split(content, "\n") { + if foreignCLIAllowlisted(line) { + continue + } + for _, re := range foreignSkillCLIPatterns { + if m := re.FindString(line); m != "" { + violations = append(violations, + fmt.Sprintf("line %d: foreign reference %q in %q", i+1, m, strings.TrimSpace(line))) + break // one violation per line is enough to fail + } + } + } + return violations +} + +func foreignCLIAllowlisted(line string) bool { + for _, re := range foreignSkillCLIAllowlist { + if re.MatchString(line) { + return true + } + } + return false +} + +// TestSkillForeignCLIAbsence fails if any embedded skill file references the +// predecessor system's CLIs or vocabulary. Skills are agent-executable +// instructions: a foreign CLI in a skill breaks the formula that delegates to +// it, at runtime, with no earlier warning (issue #484). Every embedded file is +// scanned (SKILL.md, companion docs, shell scripts) — all of it is shipped +// agent-facing content. +func TestSkillForeignCLIAbsence(t *testing.T) { + err := fs.WalkDir(skillsFS, "install_skills", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + data, err := skillsFS.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + for _, v := range checkForeignCLIRefs(string(data)) { + t.Errorf("%s: %s — replace with af-native tooling (af bead / af sling / af agents / af mail; see issue #484)", path, v) + } + return nil + }) + if err != nil { + t.Fatalf("walking skillsFS: %v", err) + } +} + +// TestFormulaForeignGtAbsence extends the guard to source formulas for the gt +// class only: bd in formulas is already blocked by TestNoDirectBdInFormulas, +// and vocabulary is skill-scoped because several formulas legitimately carry +// historical prose (and `-gt 0` style shell arithmetic must stay legal, which +// the verb-list pattern guarantees). +func TestFormulaForeignGtAbsence(t *testing.T) { + gtPattern := foreignGtInvocationPattern + for _, name := range listFormulas(t, "install_formulas") { + data, err := formulasFS.ReadFile("install_formulas/" + name) + if err != nil { + t.Fatalf("reading %s: %v", name, err) + } + for i, line := range strings.Split(string(data), "\n") { + if foreignCLIAllowlisted(line) { + continue + } + if m := gtPattern.FindString(line); m != "" { + t.Errorf("%s:%d: foreign gt command %q in %q", name, i+1, m, strings.TrimSpace(line)) + } + } + } +} + +// TestSkillForeignCLILintSelfNegative proves the lint is not vacuous: every +// foreign-class fixture MUST be flagged and every known-legitimate residual +// MUST NOT be. If this regresses, a green TestSkillForeignCLIAbsence is +// worthless. +func TestSkillForeignCLILintSelfNegative(t *testing.T) { + mustFlag := []string{ + "bd create --type=epic --title=\"$EPIC_TITLE\"", + "bd dep add <story-id> <dep-id>", + "bd list --status=open", + "EPIC_ID=$(bd create --type=epic --json | jq -r .id)", + "gt rig list", + "gt convoy create \"<epic-name>\" <ids> --notify", + "gt sling <story-id> <rig>", + "Default target_rig from gt rig list", + "Report the plan to the Mayor", + "Polecats receive work through beads", + "author: \"Gas Town\"", + "imported from gastown without adaptation", + } + for _, s := range mustFlag { + if v := checkForeignCLIRefs(s); len(v) == 0 { + t.Errorf("self-negative bite failed: lint did NOT flag %q (the check is vacuous)", s) + } + } + + mustNotFlag := []string{ + "if [ \"$COUNT\" -gt 0 ]; then", + "type = \"convoy\"", + "Convoy formulas run legs in parallel", + "af bead create --type epic --title \"...\"", + "af sling --agent factoryworker \"implement af-1234\"", + "docker exec -u dev container_name bash -c 'bd config get issue-prefix'", + "docker exec -u dev <container> bash -c 'cd ~/gt && gt doctor'", + "see internal/rig/manager.go:604 for the old callsite", + "the word debd should not match, nor should mgt sling", + "git log --grep=\"bd migration\" --oneline", + } + for _, s := range mustNotFlag { + if v := checkForeignCLIRefs(s); len(v) > 0 { + t.Errorf("false-positive: lint should not flag legitimate content %q, got %v", s, v) + } + } +} diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 8ba8183..94e26ee 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -36,6 +36,8 @@ var ( slingReset bool slingCaller string slingPersistent bool + slingModel string + slingSkipFitness bool ) // InstantiateParams contains parameters for formula instantiation. @@ -85,6 +87,8 @@ func init() { slingCmd.Flags().StringVar(&slingCaller, "caller", "", "Explicit caller identity for WORK_DONE mail (used by dispatch)") slingCmd.Flags().MarkHidden("caller") slingCmd.Flags().BoolVar(&slingPersistent, "persistent", false, "Keep session alive after formula completion (do not auto-terminate). !IMPORTANT! ONLY used in formulas instructions, not ad-hoc specialist dispatch. Use with caution: the session will not auto-terminate on formula completion.") + slingCmd.Flags().StringVar(&slingModel, "model", "", "Per-agent model profile (or raw model id) from models.json — overrides the per-agent default for this launch") + slingCmd.Flags().BoolVar(&slingSkipFitness, "skip-fitness", false, "Launch a non-loopback model profile without a fitness attestation (loud override; see `af config models attest`)") rootCmd.AddCommand(slingCmd) } @@ -250,7 +254,7 @@ func dispatchToSpecialist(cmd *cobra.Command, root, callerWd, agentName, task st return nil } - return launchAgentSession(cmd, root, agentName, worktreePath, worktreeID) + return launchAgentSession(cmd, root, agentName, worktreePath, worktreeID, slingModel, slingSkipFitness) } // resolveSpecialistAgent loads agents.json and validates that the named agent @@ -353,7 +357,7 @@ func runFormulaInstantiation(cmd *cobra.Command, root, wd string, args []string) } } - if err := launchAgentSession(cmd, root, agentName, worktreePath, worktreeID); err != nil { + if err := launchAgentSession(cmd, root, agentName, worktreePath, worktreeID, slingModel, slingSkipFitness); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "warning: session launch failed: %v\n", err) // Non-fatal — beads were created successfully } @@ -817,7 +821,7 @@ func detectAgentName(wd, root string) (string, error) { // test suite hits its global timeout. The seam mirrors newIssueStore in // helpers.go and lets dispatch-path tests exercise the full pipeline // without depending on tmux + claude being present on PATH. -var launchAgentSession = func(cmd *cobra.Command, root, agentName, worktreePath, worktreeID string) error { +var launchAgentSession = func(cmd *cobra.Command, root, agentName, worktreePath, worktreeID, cliModel string, skipFitness bool) error { t := tmux.NewTmux() if !t.IsAvailable() { return fmt.Errorf("tmux is not installed or not available") @@ -842,6 +846,28 @@ var launchAgentSession = func(cmd *cobra.Command, root, agentName, worktreePath, } } wireGitIdentity(mgr, root, worktreePath) + + // Resolve the per-agent model export set (issue #480) and fail fast BEFORE + // mgr.Start() — an unknown profile or incomplete endpoint must never launch a + // half-configured tmux session. The marker dir matches where a respawn + // will read it (worktree agent dir when a worktree exists). + agentDir := config.AgentDir(root, agentName) + if worktreePath != "" { + agentDir = config.AgentDir(worktreePath, agentName) + } + modelName, modelEnv, modelErr := resolveLaunchModelEnv(root, agentName, agentDir, cliModel, entry.Model, skipFitness, cmd.ErrOrStderr()) + if modelErr != nil { + return modelErr + } + if len(modelEnv) > 0 { + mgr.SetModelEnv(modelEnv) + } + // Persist ONLY an explicit per-launch --model override (precedence step 2); a + // durable agents-map/agents.json default writes no marker (it resolves durably). + if cliModel != "" && modelName != "" { + writeModelOverride(agentDir, modelName) + } + if err := mgr.Start(); err != nil { if err == session.ErrAlreadyRunning { fmt.Fprintf(cmd.OutOrStdout(), "%s: already running\n", session.SessionName(agentName)) @@ -851,11 +877,21 @@ var launchAgentSession = func(cmd *cobra.Command, root, agentName, worktreePath, } var parts []string - if entry.Model != "" { - parts = append(parts, "model: "+entry.Model) + displayModel := entry.Model + if modelName != "" { + displayModel = modelName + } + if displayModel != "" { + parts = append(parts, "model: "+displayModel) + } + // Endpoint echo from the resolved set (names only, never auth_token), falling + // back to the legacy field; empty when neither applies. + endpoint := entry.BaseURL + if u := modelEnvValue(modelEnv, "ANTHROPIC_BASE_URL"); u != "" { + endpoint = u } - if entry.BaseURL != "" { - parts = append(parts, "endpoint: "+entry.BaseURL) + if endpoint != "" { + parts = append(parts, "endpoint: "+endpoint) } if len(parts) > 0 { fmt.Fprintf(cmd.OutOrStdout(), "Launched %s (%s)\n", session.SessionName(agentName), strings.Join(parts, ", ")) @@ -865,6 +901,145 @@ var launchAgentSession = func(cmd *cobra.Command, root, agentName, worktreePath, return nil } +// resolveLaunchModelEnv resolves the per-agent model-env export set for a launch +// (issue #480). It loads models.json, reads the .runtime/model_override marker +// (precedence step 2), applies the unknown-profile / incomplete-endpoint fail-fast, +// and runs the pure config.ResolveModelEnv. It is the single shared resolver for +// every launch path (sling launch and respawn; af up in Phase 4) so fail-fast and +// precedence stay uniform. +// +// A non-empty cliModel makes the launch "profile-selecting": a broken models.json or +// a bad selection fails loud (a structured error the caller must return BEFORE +// mgr.Start()). A launch that selects no profile (cliModel == "", e.g. every respawn) +// treats a load/resolve error as non-fatal — it warns and falls through to the global +// default (empty set) so one bad models.json cannot brick default-model agents. +// +// The marker is read here in the cmd layer and passed INTO the pure resolver; the +// resolver never reads a file or the environment (ADR-004). +func resolveLaunchModelEnv(root, agentName, agentDir, cliModel, legacyModel string, skipFitness bool, warn io.Writer) (string, []config.EnvVar, error) { + profileSelecting := cliModel != "" + + cfg, err := config.LoadModelsConfig(root) + if err != nil { + if profileSelecting { + return "", nil, fmt.Errorf("cannot select model %q: %w", cliModel, err) + } + fmt.Fprintf(warn, "warning: ignoring models.json (%v); using the global default model\n", err) + return "", nil, nil + } + + // Unknown-profile fail-fast: an explicit --model naming something not in a populated + // registry is a typo, not a raw-id passthrough — the pure resolver would silently + // pass it through (models.go raw-id branch). A raw model id with NO registry stays a + // passthrough (`--model claude-opus-4-8` works with no models.json). + if profileSelecting && len(cfg.Models) > 0 { + if _, ok := cfg.Models[cliModel]; !ok { + return "", nil, fmt.Errorf("unknown model profile %q: not defined in models.json (defined: %s)", cliModel, knownProfiles(cfg)) + } + } + + marker := readModelOverride(agentDir) + name, env, ok, err := config.ResolveModelEnv(cfg, agentName, cliModel, marker, legacyModel) + if err != nil { + if profileSelecting { + return "", nil, fmt.Errorf("model %q: %w", name, err) + } + fmt.Fprintf(warn, "warning: ignoring models.json model %q (%v); using the global default model\n", name, err) + return "", nil, nil + } + if !ok { + return "", nil, nil + } + + // Launch preflight (issue #508): a resolved ANTHROPIC_AUTH_TOKEN carrying a + // file: secret reference must point at a real, non-empty file BEFORE mgr.Start(). The + // cmd layer may do this I/O (ADR-004 confines only the library). A selecting launch + // fails fast naming profile + path; a respawn (cliModel "") warns naming the abandoned + // model and falls through to the global default so one missing secret can never brick a + // default-model agent (the 43052536 posture). The path resolves like Phase-2's emission + // deref: relative to the factory root, absolute as-is. + if tok := modelEnvValue(env, "ANTHROPIC_AUTH_TOKEN"); strings.HasPrefix(tok, "file:") { + path := secretRefPath(root, tok) + if info, statErr := os.Stat(path); statErr != nil || info.IsDir() || info.Size() == 0 { + if profileSelecting { + return "", nil, fmt.Errorf("model %q: secret reference ANTHROPIC_AUTH_TOKEN → %s: file not found", name, path) + } + fmt.Fprintf(warn, "warning: agent %s: model %q secret %s missing; falling back to %s\n", agentName, name, path, globalDefaultDesc(legacyModel)) + return "", nil, nil + } + } + + // Fitness attestation interlock (issue #508): a selecting launch of a profile + // with a NON-loopback endpoint requires a recorded fitness attestation (af config models + // attest) unless --skip-fitness is passed. Loopback profiles are exempt, a profile with + // no endpoint (a plain model-id passthrough) is not gated at all, and a respawn + // (non-selecting) never reaches the refuse branch — so a routine handoff can never brick. + if endpoint := modelEnvValue(env, "ANTHROPIC_BASE_URL"); profileSelecting && endpoint != "" && !config.IsLoopbackEndpoint(endpoint) { + if skipFitness { + fmt.Fprintf(warn, "warning: --skip-fitness: launching model %q on non-loopback endpoint %s without a fitness attestation (operator override)\n", name, endpoint) + } else if !hasFitnessAttestation(root, name) { + return "", nil, fmt.Errorf("model %q targets a non-loopback endpoint with no fitness attestation; run `af config models attest %s` after verifying it, or pass --skip-fitness to override", name, name) + } + } + + return name, env, nil +} + +// globalDefaultDesc names the fallback target for a non-selecting launch whose selected +// profile's secret is missing: the agent's legacy default model when it has one, else the +// implicit global default. Used to name both the abandoned and the fallback model in the +// respawn secret-missing warning. +func globalDefaultDesc(legacyModel string) string { + if legacyModel != "" { + return fmt.Sprintf("global default model %s", legacyModel) + } + return "global default model" +} + +// knownProfiles returns the sorted, comma-joined profile names defined in cfg, so a +// fail-fast error can point the operator at the valid choices. +func knownProfiles(cfg *config.ModelsConfig) string { + names := make([]string, 0, len(cfg.Models)) + for n := range cfg.Models { + names = append(names, n) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +// modelEnvValue returns the value of the first export with the given key, or "". +func modelEnvValue(env []config.EnvVar, key string) string { + for _, e := range env { + if e.Key == key { + return e.Value + } + } + return "" +} + +// writeModelOverride persists the resolved per-launch model override to +// <agentDir>/.runtime/model_override (issue #480). Written unconditionally +// (mirrors writeDispatchedMarker) so a fresh --model overwrites a stale marker; the +// existing --reset .runtime wipe cleans it. Only the per-launch --model override is +// persisted here — never an agents.json/models.json.agents default (those resolve +// durably without a marker, so a one-off override never becomes sticky). +func writeModelOverride(agentDir, name string) { + runtimeDir := filepath.Join(agentDir, ".runtime") + os.MkdirAll(runtimeDir, 0o755) + os.WriteFile(filepath.Join(runtimeDir, "model_override"), []byte(name), 0o644) +} + +// readModelOverride reads the per-launch model override marker from +// <workDir>/.runtime/model_override (mirrors readHookedFormulaID). Returns "" when +// absent; the value is passed into the pure resolver as the precedence-step-2 marker. +func readModelOverride(workDir string) string { + data, err := os.ReadFile(filepath.Join(workDir, ".runtime", "model_override")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + // formulaUsesBeadSources returns true if any variable in the formula uses a bead-based source. func formulaUsesBeadSources(vars map[string]formula.Var) bool { for _, v := range vars { diff --git a/internal/cmd/sling_test.go b/internal/cmd/sling_test.go index c5c4f95..3a7341d 100644 --- a/internal/cmd/sling_test.go +++ b/internal/cmd/sling_test.go @@ -44,7 +44,7 @@ func installMemStore(t *testing.T) *memstore.Store { func installNoopLaunchSession(t *testing.T) { t.Helper() orig := launchAgentSession - launchAgentSession = func(*cobra.Command, string, string, string, string) error { + launchAgentSession = func(*cobra.Command, string, string, string, string, string, bool) error { return nil } t.Cleanup(func() { launchAgentSession = orig }) @@ -1966,7 +1966,7 @@ func TestRunFormulaInstantiation_PassesWorktreeToLaunch(t *testing.T) { var capturedWTPath, capturedWTID string orig := launchAgentSession - launchAgentSession = func(_ *cobra.Command, _, _, wtPath, wtID string) error { + launchAgentSession = func(_ *cobra.Command, _, _, wtPath, wtID, _ string, _ bool) error { capturedWTPath = wtPath capturedWTID = wtID return nil @@ -3157,7 +3157,7 @@ func installCapturingLaunchSession(t *testing.T) *launchCapture { t.Helper() cap := &launchCapture{} orig := launchAgentSession - launchAgentSession = func(_ *cobra.Command, _, agentName, worktreePath, worktreeID string) error { + launchAgentSession = func(_ *cobra.Command, _, agentName, worktreePath, worktreeID, _ string, _ bool) error { cap.agentName = agentName cap.worktreePath = worktreePath cap.worktreeID = worktreeID @@ -3734,14 +3734,16 @@ func TestLaunchAgentSession_ModelInOutput(t *testing.T) { t.Fatal(err) } content := string(src) - if !strings.Contains(content, `entry.Model != ""`) { - t.Error("sling.go: launchAgentSession must check entry.Model before printing launch message") + // Issue #480: the echo now prints the RESOLVED model name (--model / profile / + // agents-map / legacy), preferring it over the legacy entry.Model fallback. + if !strings.Contains(content, `displayModel`) { + t.Error("sling.go: launchAgentSession must print the resolved model name (displayModel)") } if !strings.Contains(content, `"model: "`) { - t.Error("sling.go: launchAgentSession must include model label in launch message when model is set") + t.Error("sling.go: launchAgentSession must include model label in launch message when a model is set") } if !strings.Contains(content, `"Launched %s\n"`) { - t.Error("sling.go: launchAgentSession must preserve backward-compatible Launched format when model is empty") + t.Error("sling.go: launchAgentSession must preserve backward-compatible Launched format when no model is set") } } @@ -3751,11 +3753,25 @@ func TestLaunchMessage_WithEndpoint(t *testing.T) { t.Fatal(err) } content := string(src) - if !strings.Contains(content, `entry.BaseURL != ""`) { - t.Error("sling.go: launchAgentSession must check entry.BaseURL before printing launch message") + // Issue #480: the endpoint echo prefers the resolved set's ANTHROPIC_BASE_URL + // (falling back to the legacy entry.BaseURL) and must NEVER print the auth token. + if !strings.Contains(content, `entry.BaseURL`) { + t.Error("sling.go: launchAgentSession must consult entry.BaseURL as the endpoint fallback") + } + if !strings.Contains(content, `"ANTHROPIC_BASE_URL"`) { + t.Error("sling.go: launchAgentSession must source the endpoint echo from the resolved set's ANTHROPIC_BASE_URL") + } + // The echo must never APPEND the auth token to the printed parts. The #508 W3 + // preflight legitimately references ANTHROPIC_AUTH_TOKEN elsewhere in the file (it + // stats the secret file and names it in a fail-fast error, api.md:125), so assert + // the token never appears in an echo `append(parts, …)` — not merely anywhere. + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, "append(parts,") && strings.Contains(line, "ANTHROPIC_AUTH_TOKEN") { + t.Error("sling.go: launchAgentSession echo must never print ANTHROPIC_AUTH_TOKEN") + } } if !strings.Contains(content, `"endpoint: "`) { - t.Error("sling.go: launchAgentSession must include endpoint label in launch message when base_url is set") + t.Error("sling.go: launchAgentSession must include endpoint label in launch message when an endpoint is set") } if !strings.Contains(content, `"Launched %s\n"`) { t.Error("sling.go: launchAgentSession must preserve backward-compatible Launched format when no fields set") diff --git a/internal/cmd/up.go b/internal/cmd/up.go index 6f5aab2..7063ded 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -41,7 +41,19 @@ Positional 'af up <names>' ignores startup.json and starts exactly those agents. RunE: runUp, } +// upModel is the optional per-launch model profile (or raw model id) applied to +// EVERY agent started by an `af up` invocation (issue #480). It resolves through +// the same shared resolveLaunchModelEnv helper sling/respawn use, so fail-fast and +// precedence stay uniform across entrypoints. +var upModel string + +// upSkipFitness applies the fitness-attestation-skip override (issue #508) to EVERY agent started by an +// `af up` invocation: launch a non-loopback model profile without a fitness attestation. +var upSkipFitness bool + func init() { + upCmd.Flags().StringVar(&upModel, "model", "", "Per-agent model profile (or raw model id) from models.json — applied to every agent started by this `af up`") + upCmd.Flags().BoolVar(&upSkipFitness, "skip-fitness", false, "Launch non-loopback model profiles without a fitness attestation (loud override; see `af config models attest`)") rootCmd.AddCommand(upCmd) } @@ -236,6 +248,21 @@ func runUp(cmd *cobra.Command, args []string) error { Ambiguous: rr.Ambiguous, OpenCount: rr.OpenCount, }) + // Per-agent model selection (issue #480): resolve the model-env export set + // through the SHARED resolver (same one sling/respawn use) BEFORE Start, so + // fail-fast and precedence stay uniform. A profile-selecting `--model` that + // cannot resolve is surfaced per-agent (warn + allOK=false + continue), mirroring + // every other best-effort sub-failure in this loop — one bad agent must not abort + // the rest. agentDir (worktree-aware) was derived just above. + modelName, modelEnv, modelErr := resolveLaunchModelEnv(root, name, agentDir, upModel, entry.Model, upSkipFitness, cmd.ErrOrStderr()) + if modelErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", name, modelErr) + allOK = false + continue + } + if len(modelEnv) > 0 { + mgr.SetModelEnv(modelEnv) + } if err := mgr.Start(); err != nil { if errors.Is(err, session.ErrAlreadyRunning) { fmt.Fprintf(cmd.OutOrStdout(), "%s: already running\n", session.SessionName(name)) @@ -250,11 +277,25 @@ func runUp(cmd *cobra.Command, args []string) error { continue } var parts []string - if entry.Model != "" { - parts = append(parts, "model: "+entry.Model) + // Echo the RESOLVED model (issue #480 discoverability, design-doc.md:109), + // falling back to the legacy entry.Model — mirrors the sling.go launch echo + // (PR #482). Without this, --model on an empty-entry.Model agent prints no + // model, and an overridden agent prints the stale original. + displayModel := entry.Model + if modelName != "" { + displayModel = modelName + } + if displayModel != "" { + parts = append(parts, "model: "+displayModel) + } + // Endpoint echo from the resolved set (names only, never auth_token), falling + // back to the legacy field; empty when neither applies. + endpoint := entry.BaseURL + if u := modelEnvValue(modelEnv, "ANTHROPIC_BASE_URL"); u != "" { + endpoint = u } - if entry.BaseURL != "" { - parts = append(parts, "endpoint: "+entry.BaseURL) + if endpoint != "" { + parts = append(parts, "endpoint: "+endpoint) } if len(parts) > 0 { fmt.Fprintf(cmd.OutOrStdout(), "Started %s (%s)\n", session.SessionName(name), strings.Join(parts, ", ")) diff --git a/internal/cmd/up_test.go b/internal/cmd/up_test.go index ce39a81..40f7b8e 100644 --- a/internal/cmd/up_test.go +++ b/internal/cmd/up_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/spf13/cobra" + "github.com/stempeck/agentfactory/internal/config" "github.com/stempeck/agentfactory/internal/session" ) @@ -217,36 +218,130 @@ title = "Step 1" } } -func TestRunUp_ModelInOutput(t *testing.T) { - src, err := os.ReadFile("up.go") - if err != nil { - t.Fatal(err) - } - content := string(src) - if !strings.Contains(content, `entry.Model != ""`) { - t.Error("up.go: runUp must check entry.Model before printing start message") +// TestUp_ModelFlag (issue #480): `af up` registers a --model flag and routes +// the selection through the SHARED resolveLaunchModelEnv helper before mgr.Start — +// proven behaviorally by `af up --model <unknown>` failing loud on the up path (only +// the shared resolver emits the "unknown model profile" error) while staying +// best-effort (warn + non-fatal aggregate, not an abort). +func TestUp_ModelFlag(t *testing.T) { + // (1) The flag is registered on the real command (mirrors TestSling_ModelFlag_Registered). + if upCmd.Flags().Lookup("model") == nil { + t.Fatal("af up must register a --model flag") } - if !strings.Contains(content, `"model: "`) { - t.Error("up.go: runUp must include model label in start message when model is set") + + // (2) Behavioral: an explicit --model naming an undefined profile fails loud through + // the shared resolver wired into the up path. + const agentName = "uptest-model" + root := t.TempDir() + initTestGitRepo(t, root) + setupHermeticSessions(t) + + afDir := filepath.Join(root, ".agentfactory") + os.MkdirAll(afDir, 0o755) + os.WriteFile(filepath.Join(afDir, "factory.json"), []byte(`{"type":"factory","version":1,"name":"test"}`), 0o644) + os.WriteFile(filepath.Join(afDir, "agents.json"), + []byte(`{"agents":{"`+agentName+`":{"type":"autonomous","description":"t"},"manager":{"type":"interactive","description":"orchestrator"}}}`), 0o644) + // A valid registry WITHOUT a "ghost" profile, so --model ghost is an unknown-profile + // fail-fast (not a raw-id passthrough, which needs an empty registry). + writeValidModels(t, root, &config.ModelsConfig{ + Models: map[string]map[string]string{"opus": {"ANTHROPIC_MODEL": "claude-opus-4-8"}}, + }) + + t.Setenv("AF_WORKTREE", root) + t.Setenv("AF_WORKTREE_ID", "wt-mgr000") + t.Setenv("AF_ROLE", "manager") + os.MkdirAll(filepath.Join(afDir, "agents", agentName), 0o755) + t.Chdir(filepath.Join(afDir, "agents")) + + upModel = "ghost" + t.Cleanup(func() { upModel = "" }) + + cmd := &cobra.Command{} + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + + err := runUp(cmd, []string{agentName}) + out := buf.String() + if err == nil { + t.Fatalf("af up --model ghost must not fully succeed; out=%q", out) } - if !strings.Contains(content, `"Started %s\n"`) { - t.Error("up.go: runUp must preserve backward-compatible Started format when model is empty") + if !strings.Contains(out, "ghost") { + t.Errorf("af up --model must surface the shared resolver's unknown-profile error naming 'ghost'; out=%q err=%v", out, err) } } -func TestUpStartMessage_WithEndpoint(t *testing.T) { - src, err := os.ReadFile("up.go") - if err != nil { - t.Fatal(err) +// runUpStartCapture starts a single agent through the hermetic af up path and returns +// the combined output. The started "echoagent" reuses `root` as its worktree (a +// formula-bearing AF_ROLE "caller" keeps AF_WORKTREE per the #188 guard) and its workDir +// is pre-created, so mgr.Start() succeeds and the launch echo is reached. agentEntryJSON +// is the agents.json entry object for echoagent; cliModel is the --model override ("" = +// none). It fatals if the agent never reaches the "Started" echo. +func runUpStartCapture(t *testing.T, agentEntryJSON, cliModel string) string { + t.Helper() + const agentName = "echoagent" + root := t.TempDir() + initTestGitRepo(t, root) + setupHermeticSessions(t) + + afDir := filepath.Join(root, ".agentfactory") + os.MkdirAll(afDir, 0o755) + os.WriteFile(filepath.Join(afDir, "factory.json"), []byte(`{"type":"factory","version":1,"name":"test"}`), 0o644) + os.WriteFile(filepath.Join(afDir, "agents.json"), + []byte(`{"agents":{"`+agentName+`":`+agentEntryJSON+`,"caller":{"type":"autonomous","description":"c","formula":"noop"},"manager":{"type":"interactive","description":"orchestrator"}}}`), 0o644) + os.MkdirAll(config.AgentDir(root, agentName), 0o755) + + t.Setenv("AF_WORKTREE", root) + t.Setenv("AF_WORKTREE_ID", "wt-mgr000") + t.Setenv("AF_ROLE", "caller") + t.Chdir(filepath.Join(afDir, "agents")) + + upModel = cliModel + t.Cleanup(func() { upModel = "" }) + + cmd := &cobra.Command{} + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + + err := runUp(cmd, []string{agentName}) + out := buf.String() + if !strings.Contains(out, "Started "+session.SessionName(agentName)) { + t.Fatalf("agent must reach the Started launch echo; err=%v out=%q", err, out) } - content := string(src) - if !strings.Contains(content, `entry.BaseURL != ""`) { - t.Error("up.go: runUp must check entry.BaseURL before printing start message") + return out +} + +// TestRunUp_ModelInOutput is the behavioral no-regression guard for the legacy +// model-fallback echo: an agent with a plain agents.json Model and no --model override +// still shows "model: <entry.Model>" in the Started line. (Replaces the former +// source-scraping assertion that pinned the pre-PR#482 `entry.Model` echo pattern.) +func TestRunUp_ModelInOutput(t *testing.T) { + out := runUpStartCapture(t, `{"type":"autonomous","description":"e","model":"legacy-m"}`, "") + if !strings.Contains(out, "model: legacy-m") { + t.Errorf("legacy entry.Model must still be echoed when no override resolves; out=%q", out) } - if !strings.Contains(content, `"endpoint: "`) { - t.Error("up.go: runUp must include endpoint label in start message when base_url is set") +} + +// TestUpStartMessage_WithEndpoint is the behavioral no-regression guard for the legacy +// endpoint echo: an agent with a legacy base_url still shows "endpoint: <base_url>". +// (Replaces the former source-scraping assertion.) +func TestUpStartMessage_WithEndpoint(t *testing.T) { + out := runUpStartCapture(t, + `{"type":"autonomous","description":"e","model":"legacy-m","base_url":"http://legacy:9","auth_token":"tok"}`, "") + if !strings.Contains(out, "endpoint: http://legacy:9") { + t.Errorf("legacy entry.BaseURL must still be echoed as the endpoint; out=%q", out) } - if !strings.Contains(content, `"Started %s\n"`) { - t.Error("up.go: runUp must preserve backward-compatible Started format when no fields set") +} + +// TestUp_LaunchEcho_UsesResolvedModel (PR #482): the af up launch +// echo must show the RESOLVED model name, not the raw agents.json entry.Model. Here the +// agent's entry.Model is EMPTY and --model supplies a raw-id passthrough (empty registry), +// so the buggy echo (which read entry.Model) printed no model at all; after the fix it +// prints the resolved name. Behavioral (executes runUp), not source-scraping. +func TestUp_LaunchEcho_UsesResolvedModel(t *testing.T) { + out := runUpStartCapture(t, `{"type":"autonomous","description":"e"}`, "raw-model-x") + if !strings.Contains(out, "model: raw-model-x") { + t.Errorf("af up launch echo must show the RESOLVED model name even when entry.Model is empty; out=%q", out) } } diff --git a/internal/cmd/watchdog.go b/internal/cmd/watchdog.go index 7b123f8..f8d697f 100644 --- a/internal/cmd/watchdog.go +++ b/internal/cmd/watchdog.go @@ -96,13 +96,60 @@ func checkpointBeforeKill(agentDir, reason string) { } } +// apiRequestMarker anchors the two generic connection-error needles below to a model +// API call. "connection refused" / "connection timed out" are Go's bare transport-error +// text — an agent running go test, curl, or ssh against a closed port prints them for +// reasons unrelated to its model gateway, so matching the bare substring would respawn a +// working agent on a false positive. Requiring the Anthropic messages path to co-occur +// scopes the match to a genuine gateway failure (Claude Code posts every model request to +// <base_url>/v1/messages), the same way the LiteLLM needles are scoped by the litellm. +// error-class prefix. +const apiRequestMarker = "/v1/messages" + +// endpointFailureSignatures enumerates the substrings a down or misbehaving per-agent +// endpoint (issue #508) surfaces into the pane. Matching is by explicit substring; when +// context is non-empty it must ALSO be present, which scopes an otherwise-generic needle +// to model-gateway output. The 5xx entries carry the full HTTP reason phrase — never a +// bare digit or a loose "HTTP" — so a 4xx/200 line cannot trip them (the watchdog +// negatives pin 400/200). The LiteLLM entries use specific error-class prefixes so a +// benign secrets path like "file:.agentfactory/secrets/litellm.key" cannot false-positive. +// The returned cause is human-readable and flows verbatim into the operator escalation +// mail so a gateway outage reads as "endpoint failure" rather than a generic respawn. +var endpointFailureSignatures = []struct{ needle, context, cause string }{ + {"502 Bad Gateway", "", "endpoint failure: HTTP 502 Bad Gateway"}, + {"503 Service Unavailable", "", "endpoint failure: HTTP 503 Service Unavailable"}, + {"504 Gateway Timeout", "", "endpoint failure: HTTP 504 Gateway Timeout"}, + {"connection refused", apiRequestMarker, "endpoint failure: connection refused (endpoint unreachable)"}, + {"connection timed out", apiRequestMarker, "endpoint failure: connection timed out (endpoint unreachable)"}, + {"unsupported_api_for_model", "", "endpoint failure: unsupported_api_for_model (model not served on this endpoint)"}, + {"litellm.InternalServerError", "", "endpoint failure: LiteLLM proxy internal server error"}, + {"litellm.ServiceUnavailableError", "", "endpoint failure: LiteLLM proxy service unavailable"}, + {"litellm.APIConnectionError", "", "endpoint failure: LiteLLM proxy connection error"}, + {"litellm.Timeout", "", "endpoint failure: LiteLLM proxy timeout"}, +} + func detectErrorPattern(output string) (bool, string) { if strings.Contains(output, "Invalid signature in thinking block") { return true, "Invalid signature in thinking block" } + for _, sig := range endpointFailureSignatures { + if strings.Contains(output, sig.needle) && (sig.context == "" || strings.Contains(output, sig.context)) { + return true, sig.cause + } + } return false, "" } +// watchdogFailureMail formats the operator escalation mail for a detected session +// failure. It is a pure formatter (no send) so the cause threading — including the +// endpoint-failure signatures — is directly unit-testable; recoverAgent feeds its +// output to sendHandoffMail (a no-op under `go test`). +func watchdogFailureMail(agentName, pattern string) (subject, body string) { + subject = fmt.Sprintf("WATCHDOG: %s session failure detected: %s", agentName, pattern) + body = fmt.Sprintf("Watchdog detected failure in agent %s: %s. Session will be respawned.", agentName, pattern) + return subject, body +} + func checkSilence(agentName, output string, state map[string]*watchdogAgentState, threshold int) bool { h := sha256.Sum256([]byte(strings.TrimSpace(output))) hash := hex.EncodeToString(h[:]) @@ -204,9 +251,8 @@ func recoverAgent(root, agentName string, entry config.AgentEntry, pattern strin fmt.Fprintf(os.Stderr, "watchdog: %s: failed to write last_error: %v\n", agentName, err) } - _ = sendHandoffMail(escalationTarget, - fmt.Sprintf("WATCHDOG: %s session failure detected: %s", agentName, pattern), - fmt.Sprintf("Watchdog detected failure in agent %s: %s. Session will be respawned.", agentName, pattern)) + subject, body := watchdogFailureMail(agentName, pattern) + _ = sendHandoffMail(escalationTarget, subject, body) if !shouldAutoRecover(entry.Type) { fmt.Fprintf(os.Stderr, "watchdog: %s: interactive agent, alert-only (no respawn)\n", agentName) @@ -215,10 +261,11 @@ func recoverAgent(root, agentName string, entry config.AgentEntry, pattern strin meta, absWtPath := resolveWorktreeMeta(root, agentName) opts := RespawnOptions{ - FactoryRoot: root, - AgentName: agentName, - AgentEntry: entry, - PaneID: session.SessionName(agentName), + FactoryRoot: root, + AgentName: agentName, + AgentEntry: entry, + PaneID: session.SessionName(agentName), + AgentWorkDir: agentDir, } if meta != nil { opts.WorktreePath = absWtPath diff --git a/internal/cmd/watchdog_test.go b/internal/cmd/watchdog_test.go index ced933c..0090e99 100644 --- a/internal/cmd/watchdog_test.go +++ b/internal/cmd/watchdog_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/spf13/cobra" @@ -65,6 +66,11 @@ func TestWatchdog_NoFalsePositive(t *testing.T) { "Reading files...\nRunning tests...\nAll 42 tests passed", "Analyzing code in internal/cmd/watchdog.go", "HTTP 200 OK", + // Bare transport errors from local tooling (go test / curl / ssh against a + // closed port) carry no model-gateway context and must not be mistaken for an + // endpoint outage, or a working agent gets respawned on a false positive. + "dial tcp 127.0.0.1:4000: connect: connection refused", + "read tcp 127.0.0.1:54233->127.0.0.1:6379: connection timed out", } for _, output := range outputs { detected, pattern := detectErrorPattern(output) @@ -74,6 +80,63 @@ func TestWatchdog_NoFalsePositive(t *testing.T) { } } +// TestWatchdog_DetectsEndpointSignatures is the positive half of the endpoint-failure +// detection (issue #508): detectErrorPattern recognizes the enumerated signatures a down +// or misbehaving gateway surfaces into the pane (connection refused/timeout, gateway 5xx, +// the codex 400 unsupported_api_for_model code, LiteLLM proxy error classes) and returns +// a human-readable cause naming the endpoint failure. The connection cases carry the +// model API request context (/v1/messages) that scopes them to a real gateway failure. +func TestWatchdog_DetectsEndpointSignatures(t *testing.T) { + cases := []struct { + name string + output string + }{ + {"http_502", "upstream error: 502 Bad Gateway"}, + {"http_503", "the gateway returned 503 Service Unavailable"}, + {"http_504", "504 Gateway Timeout from the proxy"}, + {"conn_refused", `Post "http://127.0.0.1:4000/v1/messages": dial tcp 127.0.0.1:4000: connect: connection refused`}, + {"conn_timeout", `Post "https://gw.local/v1/messages": connection timed out`}, + {"unsupported_api", `{"error":{"code":"unsupported_api_for_model"}}`}, + {"litellm_500", "litellm.InternalServerError: llm provider raised an error"}, + {"litellm_503", "litellm.ServiceUnavailableError: upstream is down"}, + {"litellm_conn", "litellm.APIConnectionError: could not reach the endpoint"}, + {"litellm_timeout", "litellm.Timeout: request exceeded the deadline"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + detected, cause := detectErrorPattern(tc.output) + if !detected { + t.Fatalf("expected an endpoint signature to be detected in %q", tc.output) + } + if !strings.Contains(cause, "endpoint failure") { + t.Errorf("cause should name an endpoint failure, got %q", cause) + } + }) + } +} + +// TestWatchdog_EndpointCauseNamedInEscalationMail pins the escalation-mail half of +// W11: the cause detectErrorPattern returns is threaded verbatim into the operator +// escalation mail, so the operator sees "endpoint failure: …" rather than a generic +// respawn. sendHandoffMail is a no-op under `go test`, so the mail text is proven +// through the pure watchdogFailureMail formatter that recoverAgent feeds. +func TestWatchdog_EndpointCauseNamedInEscalationMail(t *testing.T) { + detected, cause := detectErrorPattern("the gateway returned 503 Service Unavailable") + if !detected { + t.Fatal("expected 503 to be detected") + } + subject, body := watchdogFailureMail("worker_a", cause) + if !strings.Contains(subject, "worker_a") { + t.Errorf("escalation subject should name the agent, got %q", subject) + } + if !strings.Contains(body, cause) { + t.Errorf("escalation body should thread the detected cause %q verbatim, got %q", cause, body) + } + if !strings.Contains(body, "endpoint failure") { + t.Errorf("escalation body should name the endpoint failure, got %q", body) + } +} + func TestWatchdog_SilenceDetection(t *testing.T) { state := make(map[string]*watchdogAgentState) output := "some static output that never changes" diff --git a/internal/cmd/worktree_integration_test.go b/internal/cmd/worktree_integration_test.go index e201750..899a8f4 100644 --- a/internal/cmd/worktree_integration_test.go +++ b/internal/cmd/worktree_integration_test.go @@ -723,7 +723,7 @@ func TestLaunchAgentSession_EmptyWorktreePath(t *testing.T) { cobraCmd.SetOut(&stdout) cobraCmd.SetErr(&stderr) - err := launchAgentSession(cobraCmd, workspace, "manager", "", "") + err := launchAgentSession(cobraCmd, workspace, "manager", "", "", "", false) if err == nil { t.Fatal("expected error from launchAgentSession with empty worktree path, got nil") } diff --git a/internal/config/config.go b/internal/config/config.go index cd7da18..fcede14 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,8 +59,12 @@ var validAgentName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`) // reservedNames are agent names that conflict with agentfactory internals. // "dispatch" is reserved because session.SessionName("dispatch") produces the // af-dispatch session name, which collides with the dispatcher's tmux session. +// "operator" is reserved because the web console's mail sender uses it as a +// synthetic identity (--from operator); a real agent with that name would be +// indistinguishable from the console's mail, defeating the sender boundary. var reservedNames = map[string]bool{ "dispatch": true, + "operator": true, } // MessagingConfig holds the contents of messaging.json diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e794908..4074e06 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -324,14 +324,14 @@ func TestValidateAgentName_ExactlyMaxLength(t *testing.T) { func TestValidateAgentName_InvalidChars(t *testing.T) { invalid := []string{ - "../../etc", // path traversal - "agent;rm", // shell injection - "agent name", // spaces - "123start", // starts with digit - ".hidden", // starts with dot - "agent\ttab", // tab - "agent/slash", // slash - "", // empty (covered separately but included for completeness) + "../../etc", // path traversal + "agent;rm", // shell injection + "agent name", // spaces + "123start", // starts with digit + ".hidden", // starts with dot + "agent\ttab", // tab + "agent/slash", // slash + "", // empty (covered separately but included for completeness) } for _, name := range invalid { if name == "" { @@ -733,6 +733,16 @@ func TestValidateAgentName_ReservedDispatch(t *testing.T) { } } +func TestValidateAgentName_ReservedOperator(t *testing.T) { + err := ValidateAgentName("operator") + if err == nil { + t.Fatal("expected error for reserved name \"operator\"") + } + if !strings.Contains(err.Error(), "reserved") { + t.Errorf("error = %q, want it to contain 'reserved'", err.Error()) + } +} + // --- RemoveAgentEntry tests --- func TestRemoveAgentEntry_FormulaAgent(t *testing.T) { diff --git a/internal/config/dispatch.go b/internal/config/dispatch.go index 2d3a653..9983d1c 100644 --- a/internal/config/dispatch.go +++ b/internal/config/dispatch.go @@ -32,6 +32,11 @@ type DispatchMapping struct { Labels []string `json:"labels,omitempty"` Source string `json:"source,omitempty"` Agent string `json:"agent"` + + // Model (issue #480) optionally pins the per-mapping model profile threaded to + // `af sling --model` at dispatch time; empty leaves the agent on its durable + // default. ValidateDispatchConfig cross-checks it against models.json. + Model string `json:"model,omitempty"` } // Workflow defines an ordered multi-phase pipeline keyed by a single @@ -90,7 +95,15 @@ func SaveDispatchConfig(path string, cfg *DispatchConfig) error { // factory that has no "manager" agent and leaves notify_on_complete unset still has an // otherwise-valid dispatch.json and must not be blocked from saving it. Only an // explicitly named, non-existent notify agent is an error. -func ValidateDispatchConfig(disp *DispatchConfig, agents *AgentConfig) error { +// +// models (issue #480) is nil-tolerant: when non-nil AND at least one profile is +// defined, every mapping's Model must name a profile defined in models.json — +// mirroring the unknown-agent check — so a dispatch.json that routes to a typo'd +// profile is rejected at the write/start boundary rather than failing loud only when +// the item is finally slung. With NO registry (nil models, absent models.json, or +// zero profiles) a mapping's model is a raw id passed straight to `claude --model`, +// exactly as the launch path treats it, so the cross-check is skipped. +func ValidateDispatchConfig(disp *DispatchConfig, agents *AgentConfig, models *ModelsConfig) error { if disp == nil { return fmt.Errorf("dispatch config is nil") } @@ -101,6 +114,11 @@ func ValidateDispatchConfig(disp *DispatchConfig, agents *AgentConfig) error { if _, ok := agents.Agents[m.Agent]; !ok { return fmt.Errorf("dispatch mapping references unknown agent %q", m.Agent) } + if models != nil && len(models.Models) > 0 && m.Model != "" { + if _, ok := models.Models[m.Model]; !ok { + return fmt.Errorf("dispatch mapping references undefined model %q", m.Model) + } + } } if disp.NotifyOnComplete != "" { if _, ok := agents.Agents[disp.NotifyOnComplete]; !ok { diff --git a/internal/config/dispatch_model_test.go b/internal/config/dispatch_model_test.go new file mode 100644 index 0000000..c9f52b7 --- /dev/null +++ b/internal/config/dispatch_model_test.go @@ -0,0 +1,86 @@ +package config + +import ( + "strings" + "testing" +) + +// TestValidateDispatchConfig_UnknownModelProfile_Fails (issue #480): a +// dispatch mapping whose Model names a profile absent from models.json must be +// rejected by ValidateDispatchConfig — mirroring the unknown-agent cross-check — so +// a typo'd profile is caught at the write/start boundary, not silently at sling +// time. A nil models argument must skip the cross-check (back-compat). +func TestValidateDispatchConfig_UnknownModelProfile_Fails(t *testing.T) { + agents := &AgentConfig{Agents: map[string]AgentEntry{ + "debugger": {Type: "autonomous", Description: "d"}, + "manager": {Type: "interactive", Description: "m"}, + }} + models := &ModelsConfig{ + Models: map[string]map[string]string{"opus": {"ANTHROPIC_MODEL": "claude-opus-4-8"}}, + } + + // A mapping pointing at a model profile that does not exist in models.json. + disp := &DispatchConfig{ + Repos: []string{"owner/repo"}, + TriggerLabel: "agentic", + Mappings: []DispatchMapping{{Labels: []string{"bug"}, Agent: "debugger", Model: "ghost"}}, + } + err := ValidateDispatchConfig(disp, agents, models) + if err == nil { + t.Fatal("ValidateDispatchConfig accepted a mapping naming an undefined model profile") + } + if !strings.Contains(err.Error(), "ghost") { + t.Errorf("error %q should name the undefined model %q", err.Error(), "ghost") + } + + // A mapping naming a DEFINED profile passes. + good := &DispatchConfig{ + Repos: []string{"owner/repo"}, + TriggerLabel: "agentic", + Mappings: []DispatchMapping{{Labels: []string{"bug"}, Agent: "debugger", Model: "opus"}}, + } + if err := ValidateDispatchConfig(good, agents, models); err != nil { + t.Fatalf("a mapping naming a defined model profile must pass, got: %v", err) + } + + // nil models skips the model cross-check entirely (back-compat): even a mapping + // with a non-existent profile must not error when no registry is supplied. + if err := ValidateDispatchConfig(disp, agents, nil); err != nil { + t.Fatalf("nil models must skip the model cross-check, got: %v", err) + } +} + +// TestValidateDispatchConfig_RawModelId_EmptyRegistry_Passes: with no models.json (or +// one defining zero profiles) a mapping's model is a raw id passed straight to +// `claude --model`, exactly as the launch path treats it — so validation must not +// reject it. Without this carve-out a raw-id mapping aborts the entire dispatch run +// and blocks `af config dispatch set` on factories that never created a models.json +// (PR #482 review). +func TestValidateDispatchConfig_RawModelId_EmptyRegistry_Passes(t *testing.T) { + agents := &AgentConfig{Agents: map[string]AgentEntry{ + "debugger": {Type: "autonomous", Description: "d"}, + }} + disp := &DispatchConfig{ + Repos: []string{"owner/repo"}, + TriggerLabel: "agentic", + Mappings: []DispatchMapping{{Labels: []string{"bug"}, Agent: "debugger", Model: "claude-opus-4-8"}}, + } + + // The absent-file shape, produced by the real loader: a NON-nil empty config. + absent, err := LoadModelsConfig(t.TempDir()) + if err != nil { + t.Fatalf("LoadModelsConfig on a root without models.json must not error, got: %v", err) + } + if absent == nil { + t.Fatal("LoadModelsConfig contract changed: expected a non-nil empty config for an absent file") + } + if err := ValidateDispatchConfig(disp, agents, absent); err != nil { + t.Errorf("raw model id must pass validation when models.json is absent, got: %v", err) + } + + // A present models.json that defines zero profiles behaves the same. + empty := &ModelsConfig{Models: map[string]map[string]string{}} + if err := ValidateDispatchConfig(disp, agents, empty); err != nil { + t.Errorf("raw model id must pass validation against a zero-profile registry, got: %v", err) + } +} diff --git a/internal/config/dispatch_notify_test.go b/internal/config/dispatch_notify_test.go index fd7da02..b25deab 100644 --- a/internal/config/dispatch_notify_test.go +++ b/internal/config/dispatch_notify_test.go @@ -18,7 +18,7 @@ func TestValidateDispatchConfig_EmptyNotify_NoManagerAgent_Accepted(t *testing.T // NotifyOnComplete intentionally left empty: it defaults to "manager" at // runtime, but an absent manager must not fail validation. } - if err := ValidateDispatchConfig(disp, agents); err != nil { + if err := ValidateDispatchConfig(disp, agents, nil); err != nil { t.Fatalf("empty notify_on_complete with no manager agent must be accepted, got: %v", err) } @@ -29,7 +29,7 @@ func TestValidateDispatchConfig_EmptyNotify_NoManagerAgent_Accepted(t *testing.T Mappings: []DispatchMapping{{Labels: []string{"bug"}, Agent: "debugger"}}, NotifyOnComplete: "ghost", } - if err := ValidateDispatchConfig(dispExplicit, agents); err == nil { + if err := ValidateDispatchConfig(dispExplicit, agents, nil); err == nil { t.Fatal("explicitly set notify_on_complete=ghost (absent) must still be rejected") } } diff --git a/internal/config/dispatch_workflow_test.go b/internal/config/dispatch_workflow_test.go index 9b22797..0cec1b2 100644 --- a/internal/config/dispatch_workflow_test.go +++ b/internal/config/dispatch_workflow_test.go @@ -225,7 +225,7 @@ func TestValidateDispatchConfig_WorkflowPhaseAgentHasNoFormula_Rejected(t *testi Mappings: []DispatchMapping{{Labels: []string{"build"}, Agent: "builder"}}, Workflows: []Workflow{{Label: "ship", Phases: []string{"build"}}}, } - err := ValidateDispatchConfig(disp, agents) + err := ValidateDispatchConfig(disp, agents, nil) if err == nil { t.Fatal("expected error: phase agent has no formula") } @@ -251,7 +251,7 @@ func TestValidateDispatchConfig_WorkflowPhaseAgentWithFormula_OK(t *testing.T) { }, Workflows: []Workflow{{Label: "ship", Phases: []string{"build", "test"}}}, } - if err := ValidateDispatchConfig(disp, agents); err != nil { + if err := ValidateDispatchConfig(disp, agents, nil); err != nil { t.Fatalf("valid formula-bearing workflow rejected: %v", err) } } diff --git a/internal/config/endpoint.go b/internal/config/endpoint.go new file mode 100644 index 0000000..b079f14 --- /dev/null +++ b/internal/config/endpoint.go @@ -0,0 +1,74 @@ +package config + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// IsLoopbackEndpoint reports whether baseURL points at a loopback host (localhost, +// the 127.0.0.0/8 range, or ::1). It is the ONE loopback classifier for the root +// module: the models.json validator (this file), the launch preflight, +// and the legacy auth_token guard all share it instead of +// re-deriving the rule. The web module has its own copy +// (web/internal/server/server.go hostnameIsLoopback) that cannot cross the +// separate-module boundary, so this is a fresh implementation, not a re-export. +// +// The explicit string switch must precede net.ParseIP because ParseIP("localhost") +// is nil — the seeded lmstudio profile (http://localhost:1234) would otherwise be +// misclassified as non-loopback. A parse failure or empty host yields false +// (fail-closed: an unparseable base_url must not silently exempt a literal token +// from the credential guard). +func IsLoopbackEndpoint(baseURL string) bool { + u, err := url.Parse(baseURL) + if err != nil { + return false + } + host := u.Hostname() // strips :port and IPv6 brackets + switch host { + case "localhost", "127.0.0.1", "::1": + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// secretRefPrefix marks an ANTHROPIC_AUTH_TOKEN value as a reference to a secret +// file (dereferenced at launch time in Phase 2) rather than a literal token. +const secretRefPrefix = "file:" + +// secretRefShellMetacharacters are rejected in a file: reference path so a token +// value can never smuggle a command into the shell launch line the session layer +// builds. It is a denylist (mirroring internal/cmd/containment.go's ContainsAny +// mechanism) widened to command separators and whitespace; a legitimate secret +// path (e.g. .agentfactory/secrets/x.key) needs none of these, only alphanumerics +// and / . - _. +const secretRefShellMetacharacters = " \t\r\n;|&$`<>(){}[]!*?~\"'\\" + +// isSecretRef reports whether tok uses the file: reference convention. +func isSecretRef(tok string) bool { + return strings.HasPrefix(tok, secretRefPrefix) +} + +// validateSecretRefShape is a PURE shape check — it never stats the referenced +// file, so validateModelsConfig stays pure and deterministic. A file: reference +// must carry a non-empty path free of shell metacharacters. +func validateSecretRefShape(name, tok string) error { + path := strings.TrimPrefix(tok, secretRefPrefix) + if path == "" || strings.ContainsAny(path, secretRefShellMetacharacters) { + return fmt.Errorf("%w: model %q has an invalid %s %q: a file: reference must be a non-empty path with no shell metacharacters", ErrInvalidType, name, envAuthToken, tok) + } + return nil +} + +// looksLikeCredential is the narrow heuristic behind the literal-credential guard: +// it flags only an obvious provider-key shape (sk- prefix), so dummy literals like +// "tok" or lmstudio's "lm-studio" stay legal. It is intentionally conservative — +// the file: convention plus the secret-file dereference is the actual guarantee; +// this guard is a best-effort backstop that turns the most likely operator mistake +// (a real key pasted onto a remote endpoint) into a loud config error. Do NOT +// broaden it: a wider heuristic would reject the legitimate literal "tok" fixture. +func looksLikeCredential(tok string) bool { + return strings.HasPrefix(tok, "sk-") +} diff --git a/internal/config/endpoint_test.go b/internal/config/endpoint_test.go new file mode 100644 index 0000000..e026544 --- /dev/null +++ b/internal/config/endpoint_test.go @@ -0,0 +1,30 @@ +package config + +import "testing" + +func TestIsLoopbackEndpoint(t *testing.T) { + tests := []struct { + name string + baseURL string + want bool + }{ + {"localhost with port (lmstudio seed)", "http://localhost:1234", true}, + {"127.0.0.1 with port", "http://127.0.0.1:4000", true}, + {"127.0.0.0/8 range member", "http://127.0.0.2:8080", true}, + {"IPv6 loopback bracketed", "http://[::1]:8080", true}, + {"https loopback", "https://localhost", true}, + {"non-loopback hostname", "https://api.example.com", false}, + {"bare non-loopback host", "http://x:4000", false}, + {"non-loopback public IP", "http://8.8.8.8:4000", false}, + {"private but non-loopback IP", "http://10.0.0.5:4000", false}, + {"empty string", "", false}, + {"malformed url", "://not a url", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsLoopbackEndpoint(tc.baseURL); got != tc.want { + t.Errorf("IsLoopbackEndpoint(%q) = %v, want %v", tc.baseURL, got, tc.want) + } + }) + } +} diff --git a/internal/config/models.go b/internal/config/models.go new file mode 100644 index 0000000..9315ede --- /dev/null +++ b/internal/config/models.go @@ -0,0 +1,228 @@ +package config + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "sort" + + "github.com/stempeck/agentfactory/internal/fsutil" +) + +// ModelsConfig holds the contents of .agentfactory/models.json — the registry of +// per-agent model "export sets" for issue #480. The schema is intentionally +// generic: a profile is a plain map of env exports (name → {ENV_KEY: value}), so +// a new model or export key is a config edit with no code change. Empty values +// are preserved (notably ANTHROPIC_API_KEY:"" is an explicit clear). Like +// startup.json an absent file yields an empty config, never a not-found error. +type ModelsConfig struct { + Default string `json:"default,omitempty"` + Models map[string]map[string]string `json:"models"` + Agents map[string]string `json:"agents,omitempty"` +} + +// EnvVar is one ordered, empty-value-preserving export. ResolveModelEnv returns a +// slice of these so the launch chokepoint emits them deterministically. +type EnvVar struct{ Key, Value string } + +const ( + envModel = "ANTHROPIC_MODEL" + envAPIKey = "ANTHROPIC_API_KEY" + envBaseURL = "ANTHROPIC_BASE_URL" + envAuthToken = "ANTHROPIC_AUTH_TOKEN" +) + +// afIdentityKeys are the identity vars session.Manager owns (ADR-003/ADR-004). A +// profile that named one would spoof agent identity, so they are denylisted from +// every profile's export keys. +var afIdentityKeys = map[string]bool{ + "AF_ROLE": true, + "AF_ACTOR": true, + "AF_ROOT": true, + "AF_WORKTREE": true, + "AF_WORKTREE_ID": true, +} + +// LoadModelsConfig loads and validates .agentfactory/models.json. An absent file +// returns an empty config + nil error (NOT a not-found error), mirroring +// LoadStartupConfig. +func LoadModelsConfig(root string) (*ModelsConfig, error) { + path := ModelsConfigPath(root) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &ModelsConfig{}, nil + } + return nil, fmt.Errorf("reading models config: %w", err) + } + var cfg ModelsConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing models config: %w", err) + } + if err := validateModelsConfig(&cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +// SaveModelsConfig validates then atomically writes the models config to path +// (an absolute path, not a root) via fsutil.WriteFileAtomic. Mirrors +// SaveStartupConfig. +func SaveModelsConfig(path string, cfg *ModelsConfig) error { + if err := validateModelsConfig(cfg); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshaling models config: %w", err) + } + data = append(data, '\n') + return fsutil.WriteFileAtomic(path, data, 0644) +} + +// validateModelsConfig is pure (no env reads) and fail-loud. It rejects, across +// EVERY profile: identity-var keys, a non-empty ANTHROPIC_API_KEY, a malformed +// ANTHROPIC_BASE_URL, and an incomplete endpoint (base_url without auth_token). +// It also rejects an agents/default entry naming a model absent from Models. +func validateModelsConfig(cfg *ModelsConfig) error { + for name, profile := range cfg.Models { + if err := validateModelProfile(name, profile); err != nil { + return err + } + } + for agent, model := range cfg.Agents { + if _, ok := cfg.Models[model]; !ok { + return fmt.Errorf("%w: agent %q references undefined model %q", ErrMissingField, agent, model) + } + } + if cfg.Default != "" { + if _, ok := cfg.Models[cfg.Default]; !ok { + return fmt.Errorf("%w: default references undefined model %q", ErrMissingField, cfg.Default) + } + } + return nil +} + +func validateModelProfile(name string, profile map[string]string) error { + for key, val := range profile { + if afIdentityKeys[key] { + return fmt.Errorf("%w: model %q sets identity var %q reserved for the session manager", ErrInvalidType, name, key) + } + if key == envAPIKey && val != "" { + return fmt.Errorf("%w: model %q sets a non-empty %s; a real key must not appear in a launch line (use \"\" to clear)", ErrInvalidType, name, envAPIKey) + } + if key == envBaseURL && val != "" { + u, err := url.Parse(val) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("model %q has invalid base_url %q: must start with http:// or https://", name, val) + } + } + } + + // The ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL keys are coupled, so read them + // directly (map iteration above is random-order). A file: value is a secret + // reference (shape-checked here, dereferenced at launch in Phase 2); anything + // else that looks like a real credential is rejected on a non-loopback endpoint. + // The file: convention plus the Phase-2 dereference is the actual secret-exposure + // guarantee — the sk- guard below is a defense-in-depth backstop, not the barrier. + if tok := profile[envAuthToken]; isSecretRef(tok) { + if err := validateSecretRefShape(name, tok); err != nil { + return err + } + } else if looksLikeCredential(tok) && !IsLoopbackEndpoint(profile[envBaseURL]) { + return fmt.Errorf("%w: model %q sets %s to what looks like a literal credential for a non-loopback endpoint; store it in .agentfactory/secrets/ and use a \"file:<path>\" reference", ErrInvalidType, name, envAuthToken) + } + + return checkEndpointComplete(name, profile) +} + +// checkEndpointComplete rejects an incomplete endpoint: a profile that sets ANTHROPIC_BASE_URL must +// also set a non-empty ANTHROPIC_AUTH_TOKEN, else the launched agent cannot +// authenticate. Shared by the load-time validator and the resolver. +func checkEndpointComplete(name string, profile map[string]string) error { + if profile[envBaseURL] != "" && profile[envAuthToken] == "" { + return fmt.Errorf("%w: model %q sets %s without %s (incomplete endpoint)", ErrMissingField, name, envBaseURL, envAuthToken) + } + return nil +} + +// ResolveModelEnv is the pure deterministic resolver. It picks a selection by +// precedence (cliModel > marker > cfg.Agents[agent] > legacyEntryModel > +// cfg.Default), then: +// - empty selection ⇒ ok=false (inherit today's global default) +// - selection names a profile ⇒ ordered []EnvVar (ANTHROPIC_MODEL first, then +// remaining keys sorted; empty values kept), or an err if that profile's +// endpoint is incomplete +// - selection is a raw id ⇒ emit ANTHROPIC_MODEL only (passthrough) +// +// The marker value is supplied by the cmd layer; the resolver never reads a file +// or the environment (ADR-004). The returned name is the lookup key (profile name +// or raw id), kept distinct from the model id carried in ANTHROPIC_MODEL. +func ResolveModelEnv(cfg *ModelsConfig, agent, cliModel, marker, legacyEntryModel string) (string, []EnvVar, bool, error) { + selection := firstNonEmpty(cliModel, marker, agentModel(cfg, agent), legacyEntryModel, defaultModel(cfg)) + if selection == "" { + return "", nil, false, nil + } + + profile := lookupProfile(cfg, selection) + if profile == nil { + return selection, []EnvVar{{Key: envModel, Value: selection}}, true, nil + } + + if err := checkEndpointComplete(selection, profile); err != nil { + return selection, nil, false, err + } + return selection, orderedEnv(profile), true, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func agentModel(cfg *ModelsConfig, agent string) string { + if cfg == nil || agent == "" { + return "" + } + return cfg.Agents[agent] +} + +func defaultModel(cfg *ModelsConfig) string { + if cfg == nil { + return "" + } + return cfg.Default +} + +func lookupProfile(cfg *ModelsConfig, name string) map[string]string { + if cfg == nil || cfg.Models == nil { + return nil + } + return cfg.Models[name] +} + +// orderedEnv flattens a profile into a deterministic slice: ANTHROPIC_MODEL first +// (if present), then the remaining keys in sorted order. Empty values are kept. +func orderedEnv(profile map[string]string) []EnvVar { + env := make([]EnvVar, 0, len(profile)) + if v, ok := profile[envModel]; ok { + env = append(env, EnvVar{Key: envModel, Value: v}) + } + keys := make([]string, 0, len(profile)) + for k := range profile { + if k == envModel { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + env = append(env, EnvVar{Key: k, Value: profile[k]}) + } + return env +} diff --git a/internal/config/models_test.go b/internal/config/models_test.go new file mode 100644 index 0000000..0149d02 --- /dev/null +++ b/internal/config/models_test.go @@ -0,0 +1,363 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestLoadModelsConfig_RoundTrip(t *testing.T) { + dir := t.TempDir() + afDir := filepath.Join(dir, ".agentfactory") + if err := os.MkdirAll(afDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + cfg := &ModelsConfig{ + Default: "opus", + Models: map[string]map[string]string{ + "opus": { + "ANTHROPIC_MODEL": "claude-opus-4-8", + "ANTHROPIC_API_KEY": "", // empty value MUST survive (explicit clear semantics) + }, + "local": { + "ANTHROPIC_MODEL": "claude-local", + "ANTHROPIC_BASE_URL": "http://localhost:8080", + "ANTHROPIC_AUTH_TOKEN": "tok", + }, + }, + Agents: map[string]string{"manager": "opus"}, + } + + if err := SaveModelsConfig(ModelsConfigPath(dir), cfg); err != nil { + t.Fatalf("SaveModelsConfig: %v", err) + } + assertNoTempResidue(t, afDir) + + loaded, err := LoadModelsConfig(dir) + if err != nil { + t.Fatalf("LoadModelsConfig: %v", err) + } + if !reflect.DeepEqual(loaded.Models, cfg.Models) { + t.Errorf("Models round-trip mismatch:\n got %#v\nwant %#v", loaded.Models, cfg.Models) + } + if v, ok := loaded.Models["opus"]["ANTHROPIC_API_KEY"]; !ok || v != "" { + t.Errorf("empty ANTHROPIC_API_KEY did not survive round-trip: present=%v val=%q", ok, v) + } + if loaded.Default != cfg.Default { + t.Errorf("Default mismatch: got %q want %q", loaded.Default, cfg.Default) + } + if !reflect.DeepEqual(loaded.Agents, cfg.Agents) { + t.Errorf("Agents mismatch: got %#v want %#v", loaded.Agents, cfg.Agents) + } +} + +func TestLoadModelsConfig_AbsentFile_NoError(t *testing.T) { + dir := t.TempDir() // no models.json written + + cfg, err := LoadModelsConfig(dir) + if err != nil { + t.Fatalf("expected nil error for absent file, got %v", err) + } + if errors.Is(err, ErrNotFound) { + t.Fatalf("absent file must NOT return ErrNotFound") + } + if cfg == nil { + t.Fatal("expected non-nil cfg for absent file") + } +} + +func TestValidateModelsConfig(t *testing.T) { + tests := []struct { + name string + cfg *ModelsConfig + wantErr bool + substr string + }{ + { + name: "rejects AF_ROLE identity denylist key", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"AF_ROLE": "manager"}}}, + wantErr: true, + substr: "AF_ROLE", + }, + { + name: "rejects non-empty ANTHROPIC_API_KEY", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_API_KEY": "sk-secret"}}}, + wantErr: true, + substr: "ANTHROPIC_API_KEY", + }, + { + name: "rejects base_url without auth_token (incomplete endpoint)", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_BASE_URL": "http://localhost:8080"}}}, + wantErr: true, + substr: "ANTHROPIC_AUTH_TOKEN", + }, + { + name: "rejects malformed base_url", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_BASE_URL": "ftp://nope", "ANTHROPIC_AUTH_TOKEN": "tok"}}}, + wantErr: true, + substr: "base_url", + }, + { + name: "rejects agents naming undefined model", + cfg: &ModelsConfig{ + Models: map[string]map[string]string{"opus": {"ANTHROPIC_MODEL": "claude-opus-4-8"}}, + Agents: map[string]string{"mgr": "ghost"}, + }, + wantErr: true, + substr: "ghost", + }, + { + name: "rejects default naming undefined model", + cfg: &ModelsConfig{ + Models: map[string]map[string]string{"opus": {"ANTHROPIC_MODEL": "claude-opus-4-8"}}, + Default: "ghost", + }, + wantErr: true, + substr: "ghost", + }, + { + name: "accepts empty ANTHROPIC_API_KEY (explicit clear)", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_MODEL": "claude-opus-4-8", "ANTHROPIC_API_KEY": ""}}}, + wantErr: false, + }, + { + name: "accepts empty config", + cfg: &ModelsConfig{}, + wantErr: false, + }, + { + name: "accepts a complete endpoint profile", + cfg: &ModelsConfig{Models: map[string]map[string]string{ + "local": {"ANTHROPIC_MODEL": "m", "ANTHROPIC_BASE_URL": "https://api.example.com", "ANTHROPIC_AUTH_TOKEN": "tok"}, + }}, + wantErr: false, + }, + { + name: "rejects sk-shaped literal auth_token on non-loopback endpoint", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_BASE_URL": "https://api.example.com", "ANTHROPIC_AUTH_TOKEN": "sk-live-abc"}}}, + wantErr: true, + substr: "file:", + }, + { + name: "accepts sk-shaped literal auth_token on loopback endpoint (locality exemption)", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_BASE_URL": "http://localhost:1234", "ANTHROPIC_AUTH_TOKEN": "sk-live-abc"}}}, + wantErr: false, + }, + { + name: "accepts file: secret reference on loopback endpoint", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4000", "ANTHROPIC_AUTH_TOKEN": "file:.agentfactory/secrets/x.key"}}}, + wantErr: false, + }, + { + name: "rejects file: reference containing shell metacharacters", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_BASE_URL": "http://x:4000", "ANTHROPIC_AUTH_TOKEN": "file:a; rm -rf /"}}}, + wantErr: true, + substr: "file:", + }, + { + name: "rejects empty file: reference path", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_BASE_URL": "http://x:4000", "ANTHROPIC_AUTH_TOKEN": "file:"}}}, + wantErr: true, + substr: "file:", + }, + { + // Documented, design-accepted residual gap (security.md V2): the sk- heuristic is + // defense-in-depth, NOT the guarantee. A real credential without the sk- shape passes + // validation on a non-loopback endpoint; the file: convention + Phase-2 deref is the + // actual protection. Pinned so a future heuristic change is a deliberate, visible decision. + name: "residual gap: non-sk- literal on non-loopback passes (file: convention is the real guard)", + cfg: &ModelsConfig{Models: map[string]map[string]string{"p": {"ANTHROPIC_BASE_URL": "https://api.example.com", "ANTHROPIC_AUTH_TOKEN": "abcdef123456"}}}, + wantErr: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateModelsConfig(tc.cfg) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if tc.wantErr && tc.substr != "" && !strings.Contains(err.Error(), tc.substr) { + t.Errorf("error %q should contain %q", err.Error(), tc.substr) + } + }) + } +} + +func TestResolveModelEnv_ExpandsFullSet(t *testing.T) { + cfg := &ModelsConfig{ + Models: map[string]map[string]string{ + "opus": { + "ANTHROPIC_MODEL": "claude-opus-4-8", + "ANTHROPIC_API_KEY": "", // empty preserved + "ANTHROPIC_BETA": "context-1m", + "EXTRA_FLAG": "1", + }, + }, + } + name, env, ok, err := ResolveModelEnv(cfg, "", "opus", "", "") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !ok { + t.Fatal("expected ok=true") + } + if name != "opus" { + t.Errorf("name: got %q want %q", name, "opus") + } + want := []EnvVar{ + {Key: "ANTHROPIC_MODEL", Value: "claude-opus-4-8"}, + {Key: "ANTHROPIC_API_KEY", Value: ""}, + {Key: "ANTHROPIC_BETA", Value: "context-1m"}, + {Key: "EXTRA_FLAG", Value: "1"}, + } + if !reflect.DeepEqual(env, want) { + t.Errorf("env ordering:\n got %#v\nwant %#v", env, want) + } +} + +func TestResolveModelEnv_Precedence(t *testing.T) { + cfg := &ModelsConfig{ + Default: "dft", + Models: map[string]map[string]string{ + "cli": {"ANTHROPIC_MODEL": "m-cli"}, + "mark": {"ANTHROPIC_MODEL": "m-mark"}, + "agentm": {"ANTHROPIC_MODEL": "m-agent"}, + "legacy": {"ANTHROPIC_MODEL": "m-legacy"}, + "dft": {"ANTHROPIC_MODEL": "m-default"}, + }, + Agents: map[string]string{"mgr": "agentm"}, + } + + if name, _, ok, _ := ResolveModelEnv(cfg, "mgr", "cli", "mark", "legacy"); !ok || name != "cli" { + t.Errorf("cli precedence: got name=%q ok=%v, want cli", name, ok) + } + if name, _, ok, _ := ResolveModelEnv(cfg, "mgr", "", "mark", "legacy"); !ok || name != "mark" { + t.Errorf("marker precedence: got name=%q ok=%v, want mark", name, ok) + } + if name, _, ok, _ := ResolveModelEnv(cfg, "mgr", "", "", "legacy"); !ok || name != "agentm" { + t.Errorf("agents precedence: got name=%q ok=%v, want agentm", name, ok) + } + if name, _, ok, _ := ResolveModelEnv(cfg, "unknown", "", "", "legacy"); !ok || name != "legacy" { + t.Errorf("legacy precedence: got name=%q ok=%v, want legacy", name, ok) + } + if name, _, ok, _ := ResolveModelEnv(cfg, "unknown", "", "", ""); !ok || name != "dft" { + t.Errorf("default precedence: got name=%q ok=%v, want dft", name, ok) + } + + empty := &ModelsConfig{Models: map[string]map[string]string{}} + if name, env, ok, err := ResolveModelEnv(empty, "nobody", "", "", ""); ok || err != nil || env != nil || name != "" { + t.Errorf("empty selection: got name=%q env=%v ok=%v err=%v, want zero/false", name, env, ok, err) + } +} + +func TestResolveModelEnv_RawStringPassthrough(t *testing.T) { + want := []EnvVar{{Key: "ANTHROPIC_MODEL", Value: "claude-opus-4-8"}} + + name, env, ok, err := ResolveModelEnv(&ModelsConfig{}, "", "claude-opus-4-8", "", "") + if err != nil || !ok { + t.Fatalf("raw passthrough: ok=%v err=%v", ok, err) + } + if name != "claude-opus-4-8" { + t.Errorf("name: got %q want claude-opus-4-8", name) + } + if !reflect.DeepEqual(env, want) { + t.Errorf("env: got %#v want %#v", env, want) + } + + if _, env2, ok2, err2 := ResolveModelEnv(nil, "", "claude-sonnet-4-6", "", ""); !ok2 || err2 != nil || + !reflect.DeepEqual(env2, []EnvVar{{Key: "ANTHROPIC_MODEL", Value: "claude-sonnet-4-6"}}) { + t.Errorf("nil cfg passthrough failed: env=%#v ok=%v err=%v", env2, ok2, err2) + } + + cfg := &ModelsConfig{Models: map[string]map[string]string{"opus": {"ANTHROPIC_MODEL": "claude-opus-4-8"}}} + if name3, env3, ok3, err3 := ResolveModelEnv(cfg, "", "claude-haiku-4-5", "", ""); !ok3 || err3 != nil || + name3 != "claude-haiku-4-5" || !reflect.DeepEqual(env3, []EnvVar{{Key: "ANTHROPIC_MODEL", Value: "claude-haiku-4-5"}}) { + t.Errorf("unknown-name passthrough failed: name=%q env=%#v ok=%v err=%v", name3, env3, ok3, err3) + } +} + +func TestResolveModelEnv_IncompleteEndpoint_Errors(t *testing.T) { + cfg := &ModelsConfig{ + Models: map[string]map[string]string{ + "local": { + "ANTHROPIC_MODEL": "claude-local", + "ANTHROPIC_BASE_URL": "http://localhost:8080", + // no ANTHROPIC_AUTH_TOKEN — incomplete endpoint + }, + }, + } + _, env, ok, err := ResolveModelEnv(cfg, "", "local", "", "") + if err == nil { + t.Fatal("expected error for incomplete endpoint, got nil") + } + if ok { + t.Errorf("expected ok=false on error, got ok=true") + } + if env != nil { + t.Errorf("expected nil env on error, got %#v", env) + } + if !strings.Contains(err.Error(), "ANTHROPIC_AUTH_TOKEN") { + t.Errorf("error should mention ANTHROPIC_AUTH_TOKEN, got %v", err) + } +} + +// A profile that is present but declares zero exports resolves as a MATCHED +// profile (ok=true, empty env) — distinct from an unmatched raw id, which would +// emit ANTHROPIC_MODEL. Pins the chosen semantics for the empty-profile edge. +func TestResolveModelEnv_EmptyProfileMatchesNotPassthrough(t *testing.T) { + cfg := &ModelsConfig{Models: map[string]map[string]string{"blank": {}}} + name, env, ok, err := ResolveModelEnv(cfg, "", "blank", "", "") + if err != nil || !ok { + t.Fatalf("empty profile should match: ok=%v err=%v", ok, err) + } + if name != "blank" { + t.Errorf("name: got %q want blank", name) + } + if len(env) != 0 { + t.Errorf("matched empty profile should emit no exports, got %#v", env) + } +} + +func TestResolveModelEnv_Deterministic(t *testing.T) { + cfg := &ModelsConfig{ + Models: map[string]map[string]string{ + "multi": { + "ANTHROPIC_MODEL": "claude-opus-4-8", + "ZETA": "z", + "ALPHA": "a", + "MIKE": "m", + "BRAVO": "", // empty preserved + }, + }, + } + var first []EnvVar + for i := 0; i < 50; i++ { + _, env, ok, err := ResolveModelEnv(cfg, "", "multi", "", "") + if err != nil || !ok { + t.Fatalf("iter %d: ok=%v err=%v", i, ok, err) + } + if first == nil { + first = env + continue + } + if !reflect.DeepEqual(env, first) { + t.Fatalf("iter %d nondeterministic:\n got %#v\nfirst %#v", i, env, first) + } + } + want := []EnvVar{ + {Key: "ANTHROPIC_MODEL", Value: "claude-opus-4-8"}, + {Key: "ALPHA", Value: "a"}, + {Key: "BRAVO", Value: ""}, + {Key: "MIKE", Value: "m"}, + {Key: "ZETA", Value: "z"}, + } + if !reflect.DeepEqual(first, want) { + t.Errorf("ordering:\n got %#v\nwant %#v", first, want) + } +} diff --git a/internal/config/paths.go b/internal/config/paths.go index e5238ea..33f2272 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -18,6 +18,7 @@ func AgentsConfigPath(root string) string { return filepath.Join(root, dotDir func MessagingConfigPath(root string) string { return filepath.Join(root, dotDir, "messaging.json") } func DispatchConfigPath(root string) string { return filepath.Join(root, dotDir, "dispatch.json") } func StartupConfigPath(root string) string { return filepath.Join(root, dotDir, "startup.json") } +func ModelsConfigPath(root string) string { return filepath.Join(root, dotDir, "models.json") } func HooksDir(root string) string { return filepath.Join(root, dotDir, "hooks") } // GitHooksDir is the af-managed git hooks directory (issue #371). It is diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go index f9826be..4852fb1 100644 --- a/internal/config/paths_test.go +++ b/internal/config/paths_test.go @@ -209,3 +209,19 @@ func TestBuildHostConfigPath(t *testing.T) { t.Errorf("BuildHostConfigPath: got %q, want %q", got, want) } } + +func TestStartupConfigPath(t *testing.T) { + got := StartupConfigPath("/tmp/myproject") + want := filepath.Join("/tmp/myproject", ".agentfactory", "startup.json") + if got != want { + t.Errorf("StartupConfigPath: got %q, want %q", got, want) + } +} + +func TestModelsConfigPath(t *testing.T) { + got := ModelsConfigPath("/tmp/myproject") + want := filepath.Join("/tmp/myproject", ".agentfactory", "models.json") + if got != want { + t.Errorf("ModelsConfigPath: got %q, want %q", got, want) + } +} diff --git a/internal/config/save_test.go b/internal/config/save_test.go index 68f2f3f..7fc348a 100644 --- a/internal/config/save_test.go +++ b/internal/config/save_test.go @@ -43,7 +43,7 @@ func TestSaveDispatchConfig_AtomicCrossFileValidated(t *testing.T) { TriggerLabel: "agentic", Mappings: []DispatchMapping{{Labels: []string{"bug"}, Agent: "ghost"}}, } - if err := ValidateDispatchConfig(bad, agents); err == nil { + if err := ValidateDispatchConfig(bad, agents, nil); err == nil { t.Fatal("ValidateDispatchConfig accepted a mapping to an unknown agent") } else if !strings.Contains(err.Error(), "ghost") { t.Errorf("error %q should name the unknown agent %q", err.Error(), "ghost") @@ -59,7 +59,7 @@ func TestSaveDispatchConfig_AtomicCrossFileValidated(t *testing.T) { Mappings: []DispatchMapping{{Labels: []string{"bug"}, Agent: "debugger"}}, NotifyOnComplete: "ghost", } - if err := ValidateDispatchConfig(badNotify, agents); err == nil { + if err := ValidateDispatchConfig(badNotify, agents, nil); err == nil { t.Error("ValidateDispatchConfig accepted an unknown notify_on_complete agent") } @@ -72,7 +72,7 @@ func TestSaveDispatchConfig_AtomicCrossFileValidated(t *testing.T) { IntervalSecs: 600, RetryAfterSecs: 3600, } - if err := ValidateDispatchConfig(good, agents); err != nil { + if err := ValidateDispatchConfig(good, agents, nil); err != nil { t.Fatalf("valid config rejected: %v", err) } if err := SaveDispatchConfig(DispatchConfigPath(dir), good); err != nil { diff --git a/internal/session/hermetic.go b/internal/session/hermetic.go index 13d4624..a7a9b5e 100644 --- a/internal/session/hermetic.go +++ b/internal/session/hermetic.go @@ -3,7 +3,7 @@ package session // TmuxForTest exposes the unexported tmuxClient interface to external test // helpers (notably internal/cmd) that must inject a recording fake via // InstallHermeticForTest. Because it is a type alias, any value implementing the -// 11-method tmuxClient union is assignable to it from another package without +// 14-method tmuxClient union is assignable to it from another package without // that package having to name the unexported interface. type TmuxForTest = tmuxClient diff --git a/internal/session/hermetic_test.go b/internal/session/hermetic_test.go index d5ca85f..4865609 100644 --- a/internal/session/hermetic_test.go +++ b/internal/session/hermetic_test.go @@ -74,6 +74,11 @@ func (f *fakeTmux) SetEnvironment(sess, key, value string) error { return nil } +func (f *fakeTmux) UnsetEnvironment(sess, key string) error { + f.record(fmt.Sprintf("UnsetEnvironment %s %s", sess, key)) + return nil +} + // --- tmuxClient-only methods --- func (f *fakeTmux) SetOption(sess, name, value string) error { diff --git a/internal/session/legacy_authtoken_test.go b/internal/session/legacy_authtoken_test.go new file mode 100644 index 0000000..75111fa --- /dev/null +++ b/internal/session/legacy_authtoken_test.go @@ -0,0 +1,104 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stempeck/agentfactory/internal/config" +) + +// startAgentWithEntry mirrors startMouseAgent (mouseoption_test.go) but lets the +// caller supply the AgentEntry, so the W12 read-path guard can be exercised through +// the real Manager.Start() path with a legacy remote endpoint + credential token. +func startAgentWithEntry(t *testing.T, entry config.AgentEntry) *Manager { + t.Helper() + + origMem := checkAvailableMemoryFunc + checkAvailableMemoryFunc = func() (uint64, error) { return 100000, nil } + t.Cleanup(func() { checkAvailableMemoryFunc = origMem }) + + fake := newFakeTmux() + restore := InstallHermeticForTest("af-test-", func() TmuxForTest { return fake }) + t.Cleanup(restore) + + tmpDir := t.TempDir() + wtPath := filepath.Join(tmpDir, ".worktrees", "wt-test") + agentDir := filepath.Join(wtPath, ".agentfactory", "agents", "legacyagent") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatalf("creating agent dir: %v", err) + } + + mgr := NewManager(tmpDir, "legacyagent", entry) + if err := mgr.SetWorktree(wtPath, "wt-test"); err != nil { + t.Fatalf("SetWorktree: %v", err) + } + return mgr +} + +// credentialShapedToken is a fake provider-key-shaped literal used only to trip the +// sk- heuristic in tests. Split so no scanner mistakes it for a real leaked key. +const credentialShapedToken = "sk-" + "REDACTEDdeadbeefcafe0123456789" + +// TestStart_WarnsOnCredentialTokenOnRemoteBaseURL pins Issue #508 W12 (AC-5): a +// credential-shaped literal auth_token on a NON-loopback legacy base_url produces a +// LOUD, non-fatal stderr warning at the session boundary that names the agent, the +// agents.json file, and the file: secret-reference alternative. It must NOT fail +// the launch (the 43052536 warn-only posture). +func TestStart_WarnsOnCredentialTokenOnRemoteBaseURL(t *testing.T) { + mgr := startAgentWithEntry(t, config.AgentEntry{ + Type: "autonomous", + Description: "legacy remote endpoint", + BaseURL: "https://api.openai.com/v1", + AuthToken: credentialShapedToken, + }) + + var startErr error + out := captureStderr(t, func() { startErr = mgr.Start() }) + + if startErr != nil { + t.Fatalf("W12 guard is warn-only; Start() must not fail, got: %v", startErr) + } + for _, want := range []string{"legacyagent", "agents.json", "file:", "credential-shaped auth_token"} { + if !strings.Contains(out, want) { + t.Errorf("credential-on-remote warning must name %q; stderr:\n%s", want, out) + } + } +} + +// TestStart_NoCredentialWarnOnLoopbackBaseURL proves loopback endpoints are exempt: +// the same credential-shaped token on http://localhost does NOT warn (the seeded +// lmstudio/loopback profiles are legitimate and must stay quiet). +func TestStart_NoCredentialWarnOnLoopbackBaseURL(t *testing.T) { + mgr := startAgentWithEntry(t, config.AgentEntry{ + Type: "autonomous", + Description: "loopback endpoint", + BaseURL: "http://localhost:1234/v1", + AuthToken: credentialShapedToken, + }) + + out := captureStderr(t, func() { _ = mgr.Start() }) + + if strings.Contains(out, "credential-shaped auth_token") { + t.Errorf("loopback endpoint must be exempt from the credential-on-remote warning; stderr:\n%s", out) + } +} + +// TestStart_NoCredentialWarnForNonCredentialToken proves the guard is narrow: a +// non-credential literal (e.g. the legitimate "tok" fixture) on a remote base_url +// does not warn — only the sk- provider-key shape trips it. +func TestStart_NoCredentialWarnForNonCredentialToken(t *testing.T) { + mgr := startAgentWithEntry(t, config.AgentEntry{ + Type: "autonomous", + Description: "remote endpoint, dummy token", + BaseURL: "https://api.openai.com/v1", + AuthToken: "tok", + }) + + out := captureStderr(t, func() { _ = mgr.Start() }) + + if strings.Contains(out, "credential-shaped auth_token") { + t.Errorf("a non-credential token must not warn; stderr:\n%s", out) + } +} diff --git a/internal/session/pr509_redirect_test.go b/internal/session/pr509_redirect_test.go new file mode 100644 index 0000000..cdc4b2d --- /dev/null +++ b/internal/session/pr509_redirect_test.go @@ -0,0 +1,108 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stempeck/agentfactory/internal/config" +) + +// TestBuildStartupCommand_AuthTokenOnly_PreservesToken covers the PR #509 review +// finding on the inline emitter: an agents.json entry that sets auth_token but leaves +// base_url empty must keep its token. The pre-fix inline path emitted +// ANTHROPIC_AUTH_TOKEN='<tok>' and then, because base_url was empty, immediately +// emitted ANTHROPIC_AUTH_TOKEN='' in the same command — last-write-wins clobbered the +// token, so an auth_token-only agent silently authenticated with an empty token. +func TestBuildStartupCommand_AuthTokenOnly_PreservesToken(t *testing.T) { + entry := config.AgentEntry{ + Type: "autonomous", Description: "auth-token-only, no base_url", + AuthToken: "authonly-tok", + } + mgr := NewManager("/tmp/factory", "testagent", entry) + // A resolved model set that carries no ANTHROPIC_BASE_URL is the branch where the + // legacy auth_token carry — and the token clobber — live. + mgr.SetModelEnv([]config.EnvVar{{Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}}) + + cmd := mgr.BuildStartupCommand() + + if !strings.Contains(cmd, "ANTHROPIC_AUTH_TOKEN='authonly-tok'") { + t.Errorf("auth_token-only config must carry its token, got: %s", cmd) + } + if strings.Contains(cmd, "ANTHROPIC_AUTH_TOKEN=''") { + t.Errorf("auth_token-only config must NOT clear its just-set token; the empty clobber reappeared: %s", cmd) + } + // The empty base_url must still be cleared so no stale endpoint survives a reused session. + if !strings.Contains(cmd, "ANTHROPIC_BASE_URL=''") { + t.Errorf("an empty base_url must still emit the structural clear, got: %s", cmd) + } +} + +// TestStart_AuthTokenOnly_PreservesToken is the Start() tmux twin of the finding above: +// the recorded env ops must set the auth token and never overwrite it with an empty +// value. Drives the full Start() path through the recording hermetic fake. +func TestStart_AuthTokenOnly_PreservesToken(t *testing.T) { + origMem := checkAvailableMemoryFunc + checkAvailableMemoryFunc = func() (uint64, error) { return 100000, nil } + t.Cleanup(func() { checkAvailableMemoryFunc = origMem }) + + fake := installHermeticSession(t) + + tmpDir := t.TempDir() + wtPath := filepath.Join(tmpDir, ".worktrees", "wt-test") + agentDir := filepath.Join(wtPath, ".agentfactory", "agents", "authagent") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatalf("creating agent dir: %v", err) + } + mgr := NewManager(tmpDir, "authagent", config.AgentEntry{ + Type: "autonomous", Description: "auth-token-only", + AuthToken: "authonly-tok", + }) + if err := mgr.SetWorktree(wtPath, "wt-test"); err != nil { + t.Fatalf("SetWorktree: %v", err) + } + mgr.SetModelEnv([]config.EnvVar{{Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}}) + + if err := mgr.Start(); err != nil { + t.Fatalf("Start: unexpected error: %v", err) + } + sessionID := mgr.SessionID() + + if !hasOp(fake.ops, "SetEnvironment "+sessionID+" ANTHROPIC_AUTH_TOKEN=authonly-tok") { + t.Errorf("Start must set the auth token for an auth_token-only config; ops=%v", fake.ops) + } + if hasOp(fake.ops, "SetEnvironment "+sessionID+" ANTHROPIC_AUTH_TOKEN=") { + t.Errorf("Start must NOT clobber the just-set auth token with an empty clear; ops=%v", fake.ops) + } +} + +// TestBuildStartupCommand_ProfileSwitch_ClearsAllStaleRedirectVars covers the PR #509 +// review finding that the inline emitter (the only emitter the respawn paths reach) +// cleared just ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN, leaving the six model-redirect +// vars able to survive a profile switch on a reused session. A model-only resolved set +// must clear every redirect-family var it does not carry — at parity with Start()'s +// hygiene pass — while never touching ANTHROPIC_API_KEY. +func TestBuildStartupCommand_ProfileSwitch_ClearsAllStaleRedirectVars(t *testing.T) { + entry := config.AgentEntry{Type: "autonomous", Description: "model-only profile"} + mgr := NewManager("/tmp/factory", "testagent", entry) + mgr.SetModelEnv([]config.EnvVar{{Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}}) + + cmd := mgr.BuildStartupCommand() + + for _, want := range []string{ + "ANTHROPIC_SMALL_FAST_MODEL=''", + "ANTHROPIC_DEFAULT_OPUS_MODEL=''", + "ANTHROPIC_DEFAULT_SONNET_MODEL=''", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=''", + "CLAUDE_CODE_SUBAGENT_MODEL=''", + } { + if !strings.Contains(cmd, want) { + t.Errorf("a model-only profile switch must clear stale redirect var %q on the reused session; got: %s", want, cmd) + } + } + // ANTHROPIC_API_KEY is deliberately excluded from redirect hygiene. + if strings.Contains(cmd, "ANTHROPIC_API_KEY=''") { + t.Errorf("ANTHROPIC_API_KEY must never be auto-cleared by redirect hygiene; got: %s", cmd) + } +} diff --git a/internal/session/seam_test.go b/internal/session/seam_test.go index 062d558..405a227 100644 --- a/internal/session/seam_test.go +++ b/internal/session/seam_test.go @@ -9,7 +9,7 @@ import ( // fakeTmuxClient is a no-op tmuxClient double used to prove the newManagerTmux // seam wires an injected client into NewManager. It implements the exact -// 13-method tmuxClient union. +// 14-method tmuxClient union. type fakeTmuxClient struct{ id string } func (f *fakeTmuxClient) HasSession(name string) (bool, error) { return false, nil } @@ -17,6 +17,7 @@ func (f *fakeTmuxClient) IsClaudeRunning(session string) bool { retu func (f *fakeTmuxClient) KillSession(name string) error { return nil } func (f *fakeTmuxClient) NewSession(name, workDir string) error { return nil } func (f *fakeTmuxClient) SetEnvironment(session, key, value string) error { return nil } +func (f *fakeTmuxClient) UnsetEnvironment(session, key string) error { return nil } func (f *fakeTmuxClient) SetOption(session, name, value string) error { return nil } func (f *fakeTmuxClient) ShowOption(session, name string) (string, error) { return "", nil } func (f *fakeTmuxClient) WaitForShellReady(session string, timeout time.Duration) error { diff --git a/internal/session/session.go b/internal/session/session.go index e6766ed..ebd66ae 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -45,8 +45,38 @@ const ( envGitConfigValue0 = "GIT_CONFIG_VALUE_0" envCoauthorName = "AF_COAUTHOR_NAME" envCoauthorEmail = "AF_COAUTHOR_EMAIL" + + // secretRefPrefix marks a file:<path> indirection for a secret-bearing env value + // (issue #508). The canonical validator lives in internal/config as an + // unexported symbol, so the launch chokepoint recognizes the prefix locally + // rather than importing it. + secretRefPrefix = "file:" ) +// redirectFamilyVars enumerates the endpoint/model redirect env the launch chokepoint +// owns. Start()'s session-env hygiene pass (issue #508) unsets any of these NOT in +// the effective set so a profile switch on a reused session leaves no stale redirect +// var. envBaseURL/envAuthToken use the consts (TestEndpointConstants_NoDuplicateStrings +// forbids their string literals outside the const block). +// +// ANTHROPIC_API_KEY is deliberately EXCLUDED: security.md I2 decides it is never +// auto-cleared — default-profile agents may legitimately authenticate via an ambient +// Anthropic key. Leaving it out of the hygiene family keeps API_KEY handling +// byte-identical to today's behavior (the zero-regression choice on this fleet-wide +// chokepoint). A profile that wants it cleared declares ANTHROPIC_API_KEY:"" explicitly, +// which lands in the effective set and emits the inline clear — so it is untouched here +// regardless. +var redirectFamilyVars = []string{ + envBaseURL, + envAuthToken, + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", +} + var checkAvailableMemoryFunc = checkAvailableMemory func checkAvailableMemory() (uint64, error) { @@ -123,7 +153,7 @@ func readDarwinMemAvailableMB() (uint64, error) { return (freePages + inactivePages) * pageSize / (1024 * 1024), nil } -// tmuxClient is the exact union of the 13 *tmux.Tmux methods that Manager.Start() +// tmuxClient is the exact union of the 14 *tmux.Tmux methods that Manager.Start() // and Manager.Stop() call. Typing Manager.tmux to this interface is the seam that // lets tests inject a fake; the compile assertion below guarantees the real // client still satisfies it. @@ -133,6 +163,7 @@ type tmuxClient interface { KillSession(name string) error NewSession(name, workDir string) error SetEnvironment(session, key, value string) error + UnsetEnvironment(session, key string) error SetOption(session, name, value string) error ShowOption(session, name string) (string, error) WaitForShellReady(session string, timeout time.Duration) error @@ -161,6 +192,12 @@ type Manager struct { worktreeID string buildHost *config.BuildHostConfig + // Resolved per-agent model-env export set (issue #480). When non-empty it is + // emitted at the launch chokepoint in place of the legacy Model/BaseURL/ + // AuthToken fields (presence-gate); empty values are kept so a profile can + // clear an ambient var (e.g. ANTHROPIC_API_KEY=''). Set via SetModelEnv. + modelEnv []config.EnvVar + // Git identity to export when no ambient identity resolves (issue #371 AC-2). // Empty ⇒ not exported (presence-gate / C-4); set via SetGitIdentity. gitAuthorName string @@ -227,6 +264,42 @@ func (m *Manager) SetBuildHost(cfg *config.BuildHostConfig) { m.buildHost = cfg } +// SetModelEnv configures the resolved per-agent model-env export set (issue #480). +// The cmd layer (Phase 3) resolves it via config.ResolveModelEnv and hands it in +// after NewManager. A nil/empty set leaves the legacy Model/BaseURL/AuthToken +// emission path unchanged (presence-gate); a non-empty set is emitted at both +// launch sites and supersedes the legacy fields. +func (m *Manager) SetModelEnv(env []config.EnvVar) { + m.modelEnv = env +} + +// modelFromModelEnv returns the ANTHROPIC_MODEL value carried in the resolved set, +// or "" if the set does not define one (e.g. a base_url-only profile). The CLI +// --model flag and the ANTHROPIC_MODEL env are sourced from this single value so +// they never disagree. The key is scanned by name, not by position, because the +// resolver only places ANTHROPIC_MODEL first when the profile defines it. +func modelFromModelEnv(env []config.EnvVar) string { + for _, ev := range env { + if ev.Key == "ANTHROPIC_MODEL" { + return ev.Value + } + } + return "" +} + +// modelEnvHasKey reports whether the resolved set already carries the given key. Used +// by both emission twins to decide whether a legacy endpoint must still be emitted: a +// model-only passthrough set (PR #482) carries no ANTHROPIC_BASE_URL, so the legacy +// endpoint must travel with it rather than be suppressed. +func modelEnvHasKey(env []config.EnvVar, key string) bool { + for _, ev := range env { + if ev.Key == key { + return true + } + } + return false +} + // SessionID returns the tmux session name for this agent. func (m *Manager) SessionID() string { return SessionName(m.agentName) @@ -286,6 +359,22 @@ func (m *Manager) Start() error { m.agentName, set, unset) } + // Issue #508: a legacy agents.json remote endpoint carrying a credential- + // shaped literal auth_token is the most likely operator secret-leak mistake. Warn + // LOUDLY but never fail (the 43052536 warn-only posture) so the operator moves the + // key to a file: reference. Loopback endpoints are exempt (the seeded lmstudio + // profile is legitimate) via the shared Phase-1 classifier. The sk- heuristic is + // inlined to match Phase-1's looksLikeCredential shape without exporting it — + // internal/config stays silent by convention (ADR-004); this warn layer is the + // session boundary, mirroring the XOR-warn precedent above. base_url is already + // URL-validated in validateAgentConfig (config.go); this adds no validation. + if strings.HasPrefix(m.agentEntry.AuthToken, "sk-") && m.agentEntry.BaseURL != "" && !config.IsLoopbackEndpoint(m.agentEntry.BaseURL) { + fmt.Fprintf(os.Stderr, + "warning: agent %s has a credential-shaped auth_token on a non-loopback base_url %q in %s — "+ + "move the secret out of config: use a file: reference (auth_token: \"file:.agentfactory/secrets/%s.key\") instead of a literal token\n", + m.agentName, m.agentEntry.BaseURL, config.AgentsConfigPath(m.factoryRoot), m.agentName) + } + // Set environment variables (best-effort) _ = m.tmux.SetEnvironment(sessionID, "AF_ROOT", m.factoryRoot) _ = m.tmux.SetEnvironment(sessionID, "AF_ROLE", m.agentName) @@ -294,17 +383,84 @@ func (m *Manager) Start() error { _ = m.tmux.SetEnvironment(sessionID, "AF_WORKTREE", m.worktreePath) _ = m.tmux.SetEnvironment(sessionID, "AF_WORKTREE_ID", m.worktreeID) } - if m.agentEntry.Model != "" { - _ = m.tmux.SetEnvironment(sessionID, "ANTHROPIC_MODEL", m.agentEntry.Model) - } - if m.agentEntry.BaseURL != "" { - if err := m.tmux.SetEnvironment(sessionID, envBaseURL, m.agentEntry.BaseURL); err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to set %s for %s: %v\n", envBaseURL, sessionID, err) + // effective records the redirect-family keys this launch actually emits, so the + // hygiene pass below can unset the rest (issue #508). It is populated in lockstep + // with the SetEnvironment calls to guarantee it never diverges from what was emitted. + effective := map[string]bool{} + if len(m.modelEnv) > 0 { + // Resolved model-env set supersedes the legacy fields (issue #480): emit the + // whole set (empty values clear) and skip the legacy trio below so the + // tmux env and the inline command never disagree. + // + // Deliberate twin asymmetry (issue #508): the tmux env carries a file:<path> + // ANTHROPIC_AUTH_TOKEN as the raw placeholder VERBATIM — NOT the $(cat …) deref + // buildStartupCommand emits inline, and NOT the resolved secret. tmux + // set-environment does no shell evaluation, so a "$(cat …)" string would be + // stored literally, and a resolved secret would be readable via + // `tmux show-environment`. The file:→$(cat …) transform lives ONLY in + // buildStartupCommand's inline loop. + for _, ev := range m.modelEnv { + _ = m.tmux.SetEnvironment(sessionID, ev.Key, ev.Value) + effective[ev.Key] = true + } + // A model-only resolved set (a legacy agent whose Model is not a defined + // profile, or no models.json at all) carries no endpoint. Keep the legacy + // BaseURL/AuthToken travelling with it so a mixed-provider agent still reaches + // its endpoint (PR #482: regression of #262). + if !modelEnvHasKey(m.modelEnv, envBaseURL) { + if m.agentEntry.BaseURL != "" { + if err := m.tmux.SetEnvironment(sessionID, envBaseURL, m.agentEntry.BaseURL); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to set %s for %s: %v\n", envBaseURL, sessionID, err) + } + effective[envBaseURL] = true + } + if m.agentEntry.AuthToken != "" { + if err := m.tmux.SetEnvironment(sessionID, envAuthToken, m.agentEntry.AuthToken); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to set %s for %s: %v\n", envAuthToken, sessionID, err) + } + effective[envAuthToken] = true + } + // No endpoint travels at all after the legacy carry: blank any stale redirect + // endpoint on the reused session, mirroring the inline KEY='' clear (issue + // #508). Computed AFTER the carry so a legacy endpoint is never clobbered (PR + // #482 regression class). The auth-token clear is further gated on an empty + // auth_token: an auth_token-only config (base_url empty, token set just above) + // must keep its token rather than lose it to a last-write-wins clear. + if m.agentEntry.BaseURL == "" { + _ = m.tmux.SetEnvironment(sessionID, envBaseURL, "") + effective[envBaseURL] = true + if m.agentEntry.AuthToken == "" { + _ = m.tmux.SetEnvironment(sessionID, envAuthToken, "") + effective[envAuthToken] = true + } + } + } + } else { + if m.agentEntry.Model != "" { + _ = m.tmux.SetEnvironment(sessionID, "ANTHROPIC_MODEL", m.agentEntry.Model) + effective["ANTHROPIC_MODEL"] = true + } + if m.agentEntry.BaseURL != "" { + if err := m.tmux.SetEnvironment(sessionID, envBaseURL, m.agentEntry.BaseURL); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to set %s for %s: %v\n", envBaseURL, sessionID, err) + } + effective[envBaseURL] = true + } + if m.agentEntry.AuthToken != "" { + if err := m.tmux.SetEnvironment(sessionID, envAuthToken, m.agentEntry.AuthToken); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to set %s for %s: %v\n", envAuthToken, sessionID, err) + } + effective[envAuthToken] = true } } - if m.agentEntry.AuthToken != "" { - if err := m.tmux.SetEnvironment(sessionID, envAuthToken, m.agentEntry.AuthToken); err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to set %s for %s: %v\n", envAuthToken, sessionID, err) + // Session-env hygiene (issue #508): a respawn / profile switch reuses the + // tmux session, so a redirect var set by a prior profile survives in the session env + // (respawn-pane inherits it) unless we actively remove it. Unset every + // redirect-family var NOT in the effective set emitted above, leaving a switched-away + // endpoint with no stale value. No-op when nothing is stale. + for _, key := range redirectFamilyVars { + if !effective[key] { + _ = m.tmux.UnsetEnvironment(sessionID, key) } } // Git identity fallback (best-effort; presence-gated — issue #371 AC-2/C-4). @@ -397,11 +553,66 @@ func (m *Manager) buildStartupCommand() string { exports += fmt.Sprintf(" AF_WORKTREE=%s AF_WORKTREE_ID=%s", shellQuote(m.worktreePath), shellQuote(m.worktreeID)) } - if m.agentEntry.BaseURL != "" { - exports += fmt.Sprintf(" %s=%s", envBaseURL, shellQuote(m.agentEntry.BaseURL)) - } - if m.agentEntry.AuthToken != "" { - exports += fmt.Sprintf(" %s=%s", envAuthToken, shellQuote(m.agentEntry.AuthToken)) + if len(m.modelEnv) > 0 { + // Resolved model-env set supersedes the legacy fields (issue #480), in the + // same slot the legacy exports occupied. Every value is single-quoted via + // shellQuote (shell-injection inert); an empty value emits KEY='' to clear it. + // effective records the redirect-family keys this launch actually emits so the + // hygiene pass below can clear the rest — the inline twin of Start()'s pass. + effective := map[string]bool{} + for _, ev := range m.modelEnv { + // A file:<path> ANTHROPIC_AUTH_TOKEN is dereferenced to "$(cat '<abs>')" so + // the pane shell reads the secret at exec time — the value never lands on + // the launch line or in scrollback (issue #508). Only the path passes + // through shellQuote; the surrounding double-quotes and $(cat …) are + // literal, because shellQuote would single-quote the whole token and + // disable the command substitution. A relative path resolves against the + // factory root so $(cat …) reads the right file whatever the pane's cwd. + if ev.Key == envAuthToken && strings.HasPrefix(ev.Value, secretRefPrefix) { + path := strings.TrimPrefix(ev.Value, secretRefPrefix) + if !filepath.IsAbs(path) { + path = filepath.Join(m.factoryRoot, path) + } + exports += fmt.Sprintf(" %s=\"$(cat %s)\"", ev.Key, shellQuote(path)) + } else { + exports += fmt.Sprintf(" %s=%s", ev.Key, shellQuote(ev.Value)) + } + effective[ev.Key] = true + } + // A model-only resolved set carries no endpoint; keep the legacy BaseURL/ + // AuthToken travelling with it (PR #482: regression of #262). Mirrors + // the Start() tmux-env twin above. + if !modelEnvHasKey(m.modelEnv, envBaseURL) { + if m.agentEntry.BaseURL != "" { + exports += fmt.Sprintf(" %s=%s", envBaseURL, shellQuote(m.agentEntry.BaseURL)) + effective[envBaseURL] = true + } + if m.agentEntry.AuthToken != "" { + exports += fmt.Sprintf(" %s=%s", envAuthToken, shellQuote(m.agentEntry.AuthToken)) + effective[envAuthToken] = true + } + } + // Redirect-var hygiene at parity with Start(): emit an explicit KEY='' for every + // redirect-family var this launch does NOT carry, so a value a prior profile left + // on a reused session survives no switch. This is the ONLY clear the respawn paths + // (handoff / compact-handoff / watchdog recoverAgent all rebuild through here) ever + // emit, so it must cover the whole family — not just base_url/auth_token (issue + // #508). Computed on the EFFECTIVE env AFTER the legacy carry so a carried endpoint + // is never clobbered (PR #482 regression class); an auth_token-only config keeps + // its token because envAuthToken is in the effective set; ANTHROPIC_API_KEY is not + // in this family, so it is never auto-cleared. + for _, key := range redirectFamilyVars { + if !effective[key] { + exports += fmt.Sprintf(" %s=''", key) + } + } + } else { + if m.agentEntry.BaseURL != "" { + exports += fmt.Sprintf(" %s=%s", envBaseURL, shellQuote(m.agentEntry.BaseURL)) + } + if m.agentEntry.AuthToken != "" { + exports += fmt.Sprintf(" %s=%s", envAuthToken, shellQuote(m.agentEntry.AuthToken)) + } } // Git identity fallback (presence-gated — only when no ambient identity resolved). if m.gitAuthorName != "" && m.gitAuthorEmail != "" { @@ -438,7 +649,14 @@ func (m *Manager) buildStartupCommand() string { } claude := "claude --dangerously-skip-permissions" - if m.agentEntry.Model != "" { + if len(m.modelEnv) > 0 { + // Single source of truth: the CLI flag mirrors the set's ANTHROPIC_MODEL + // (issue #480). A set without a model key (base_url-only profile) omits + // --model and lets the CLI fall back to its own default. + if model := modelFromModelEnv(m.modelEnv); model != "" { + claude += " --model " + shellQuote(model) + } + } else if m.agentEntry.Model != "" { claude += " --model " + shellQuote(m.agentEntry.Model) } if m.initialPrompt != "" { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 79bf485..be3fe47 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -795,3 +795,463 @@ func TestEndpointConstants_NoDuplicateStrings(t *testing.T) { } } } + +// --- Per-agent model-env set tests (issue #480 Phase 2) --- + +// TestBuildStartupCommand_ClearsAPIKey is the deliberate inverse of +// TestBuildStartupCommand_NoAPIKey: when a profile sets ANTHROPIC_API_KEY:"" the +// command MUST emit ANTHROPIC_API_KEY='' to clear an ambient cloud key. +func TestBuildStartupCommand_ClearsAPIKey(t *testing.T) { + entry := config.AgentEntry{Type: "autonomous", Description: "test"} + mgr := NewManager("/tmp/factory", "testagent", entry) + mgr.SetModelEnv([]config.EnvVar{{Key: "ANTHROPIC_API_KEY", Value: ""}}) + + cmd := mgr.BuildStartupCommand() + + if !strings.Contains(cmd, "ANTHROPIC_API_KEY=''") { + t.Errorf("empty-value profile entry should emit ANTHROPIC_API_KEY='' to clear it, got: %s", cmd) + } +} + +// TestProfile_EmitsFullSet asserts the full resolved set is emitted (each value +// single-quoted), --model is sourced from the set's ANTHROPIC_MODEL, and the +// legacy agentEntry fields are NOT emitted (no double-export). The legacy fields +// are set to DISTINCT values precisely so their absence proves the legacy-skip. +func TestProfile_EmitsFullSet(t *testing.T) { + entry := config.AgentEntry{ + Type: "autonomous", Description: "test", + Model: "legacy-model", BaseURL: "http://legacy", AuthToken: "legacy-tok", + } + mgr := NewManager("/tmp/factory", "testagent", entry) + mgr.SetModelEnv([]config.EnvVar{ + {Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}, + {Key: "ANTHROPIC_BASE_URL", Value: "http://localhost:1234"}, + {Key: "ANTHROPIC_AUTH_TOKEN", Value: "tok123"}, + {Key: "ANTHROPIC_DEFAULT_OPUS_MODEL", Value: "claude-opus-4"}, + }) + + cmd := mgr.BuildStartupCommand() + + for _, want := range []string{ + "ANTHROPIC_MODEL='claude-opus-4'", + "ANTHROPIC_BASE_URL='http://localhost:1234'", + "ANTHROPIC_AUTH_TOKEN='tok123'", + "ANTHROPIC_DEFAULT_OPUS_MODEL='claude-opus-4'", + } { + if !strings.Contains(cmd, want) { + t.Errorf("command should contain %q, got: %s", want, cmd) + } + } + if !strings.Contains(cmd, "--model 'claude-opus-4'") { + t.Errorf("--model should be sourced from the set's ANTHROPIC_MODEL, got: %s", cmd) + } + for _, banned := range []string{"http://legacy", "legacy-tok", "--model 'legacy-model'"} { + if strings.Contains(cmd, banned) { + t.Errorf("legacy emission must be skipped when modelEnv is present; found %q in: %s", banned, cmd) + } + } +} + +// TestBuildStartupCommand_ModelOnlySet_KeepsLegacyEndpoint +// (PR #482): a legacy mixed-provider agent whose Model is NOT a defined models.json +// profile resolves through ResolveModelEnv's raw-id branch to a set of ONLY +// ANTHROPIC_MODEL. That non-empty set must NOT suppress the legacy BaseURL/AuthToken — +// otherwise the endpoint is silently dropped and the agent falls back to the default +// Anthropic endpoint (regression of #262). The set carries no +// ANTHROPIC_BASE_URL, so the legacy endpoint must still travel with the model. +func TestBuildStartupCommand_ModelOnlySet_KeepsLegacyEndpoint(t *testing.T) { + entry := config.AgentEntry{ + Type: "autonomous", Description: "test", + Model: "legacy-model", BaseURL: "http://legacy:1234", AuthToken: "legacy-tok", + } + mgr := NewManager("/tmp/factory", "testagent", entry) + // Raw-id passthrough set (no matching profile / no models.json): ANTHROPIC_MODEL only. + mgr.SetModelEnv([]config.EnvVar{{Key: "ANTHROPIC_MODEL", Value: "legacy-model"}}) + + cmd := mgr.BuildStartupCommand() + + for _, want := range []string{ + "ANTHROPIC_MODEL='legacy-model'", + "ANTHROPIC_BASE_URL='http://legacy:1234'", + "ANTHROPIC_AUTH_TOKEN='legacy-tok'", + } { + if !strings.Contains(cmd, want) { + t.Errorf("a model-only resolved set must still carry the legacy endpoint; missing %q in: %s", want, cmd) + } + } +} + +// TestBuildStartupCommand_ModelOnlySet_NoLegacyEndpoint_EmitsNoEndpoint is the negative +// companion: the legacy-endpoint fallback is presence-gated, so an agent with NO legacy +// BaseURL/AuthToken and a model-only resolved set carries no endpoint. Issue #508 W2 +// changed "emit nothing" into "emit an explicit structural clear" so a redirect var +// inherited on a reused session is overwritten rather than silently surviving (AC-4). +// A NON-EMPTY endpoint value must still never travel. +func TestBuildStartupCommand_ModelOnlySet_NoLegacyEndpoint_EmitsNoEndpoint(t *testing.T) { + entry := config.AgentEntry{Type: "autonomous", Description: "test", Model: "legacy-model"} + mgr := NewManager("/tmp/factory", "testagent", entry) + mgr.SetModelEnv([]config.EnvVar{{Key: "ANTHROPIC_MODEL", Value: "legacy-model"}}) + + cmd := mgr.BuildStartupCommand() + + for _, want := range []string{"ANTHROPIC_BASE_URL=''", "ANTHROPIC_AUTH_TOKEN=''"} { + if !strings.Contains(cmd, want) { + t.Errorf("no endpoint travels, so an explicit structural clear is expected; missing %q in: %s", want, cmd) + } + } + // No real (non-empty) endpoint value may travel — the clear must be empty. + if strings.Contains(cmd, "ANTHROPIC_BASE_URL='http") { + t.Errorf("no legacy endpoint is set, so no real ANTHROPIC_BASE_URL value should travel, got: %s", cmd) + } +} + +// TestProfile_ShellInjectionInert asserts every model-env value is routed through +// shellQuote: a quote-laden value stays single-quote-wrapped, never bare. +func TestProfile_ShellInjectionInert(t *testing.T) { + const payload = `'; rm -rf / #` + entry := config.AgentEntry{Type: "autonomous", Description: "test"} + mgr := NewManager("/tmp/factory", "testagent", entry) + mgr.SetModelEnv([]config.EnvVar{{Key: "ANTHROPIC_AUTH_TOKEN", Value: payload}}) + + cmd := mgr.BuildStartupCommand() + + quoted := shellQuote(payload) + if !strings.Contains(cmd, "ANTHROPIC_AUTH_TOKEN="+quoted) { + t.Errorf("model-env value with metacharacters should be shell-quoted (%s), got: %s", quoted, cmd) + } + if strings.Contains(cmd, "ANTHROPIC_AUTH_TOKEN='; rm") { + t.Error("model-env value should be quoted, not bare — shell injection possible") + } +} + +// TestHandoff_ModelEnvParity is a behavioral parity check: the respawn entrypoint +// (BuildStartupCommand) re-emits the same set identically. Respawn rebuilds a +// Manager and calls BuildStartupCommand, so a deterministic re-build of the same +// state is the in-package guarantee that start and respawn agree. +func TestHandoff_ModelEnvParity(t *testing.T) { + entry := config.AgentEntry{Type: "autonomous", Description: "test"} + mgr := NewManager("/tmp/factory", "testagent", entry) + mgr.SetModelEnv([]config.EnvVar{ + {Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}, + {Key: "ANTHROPIC_BASE_URL", Value: "http://localhost:1234"}, + {Key: "ANTHROPIC_AUTH_TOKEN", Value: "tok123"}, + }) + + first := mgr.BuildStartupCommand() + second := mgr.BuildStartupCommand() + + if first != second { + t.Errorf("respawn must re-emit an identical command.\nfirst: %s\nsecond: %s", first, second) + } + for _, want := range []string{ + "ANTHROPIC_BASE_URL='http://localhost:1234'", + "ANTHROPIC_AUTH_TOKEN='tok123'", + "--model 'claude-opus-4'", + } { + if !strings.Contains(first, want) { + t.Errorf("re-emitted command should carry %q, got: %s", want, first) + } + } +} + +// --- Issue #508 W2: file: deref, structural clears, session-env hygiene --- + +// TestBuildStartupCommand_FileRefDerefsSecret proves the deref (AC-3): a file:-ref +// ANTHROPIC_AUTH_TOKEN is emitted as ANTHROPIC_AUTH_TOKEN="$(cat '<abs-path>')" so the +// pane shell substitutes at exec time and the secret VALUE never lands on the launch +// line or in scrollback. The set carries ANTHROPIC_BASE_URL (a real redirect always +// has a base_url), so the structural clear does not fire over the deref. +func TestBuildStartupCommand_FileRefDerefsSecret(t *testing.T) { + const secret = "sk-super-secret-gateway-value-DO-NOT-LEAK" + entry := config.AgentEntry{Type: "autonomous", Description: "test"} + mgr := NewManager("/tmp/factory", "testagent", entry) + mgr.SetModelEnv([]config.EnvVar{ + {Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}, + {Key: "ANTHROPIC_BASE_URL", Value: "https://gateway.internal"}, + {Key: "ANTHROPIC_AUTH_TOKEN", Value: "file:secrets/gw.tok"}, + }) + + cmd := mgr.BuildStartupCommand() + + if !strings.Contains(cmd, `ANTHROPIC_AUTH_TOKEN="$(cat '`) { + t.Errorf("file:-ref token must be dereferenced to \"$(cat '<path>')\", got: %s", cmd) + } + // A relative file: path resolves against the factory root so $(cat …) reads the + // right file regardless of the pane's working directory. + if !strings.Contains(cmd, `$(cat '/tmp/factory/secrets/gw.tok')`) { + t.Errorf("relative file: path must be joined to the factory root, got: %s", cmd) + } + // The raw file: placeholder must NOT survive on the inline line — it is transformed. + if strings.Contains(cmd, "file:secrets/gw.tok") { + t.Errorf("raw file: placeholder must not appear inline (it is dereferenced), got: %s", cmd) + } + // The secret VALUE must never appear (only the path does); the deref reads the file + // at exec time, so the literal contents can never reach the built command. + if strings.Contains(cmd, secret) { + t.Errorf("the secret value must never appear on the launch line, got: %s", cmd) + } +} + +// TestBuildStartupCommand_NoEndpoint_EmitsStructuralClears proves the structural clear +// (AC-4): a no-endpoint / no-legacy resolved set emits explicit ANTHROPIC_BASE_URL='' +// and ANTHROPIC_AUTH_TOKEN='' so a stale redirect var inherited on a reused session is +// overwritten. The set carries a model + a default var but no ANTHROPIC_BASE_URL. +func TestBuildStartupCommand_NoEndpoint_EmitsStructuralClears(t *testing.T) { + entry := config.AgentEntry{Type: "autonomous", Description: "test"} + mgr := NewManager("/tmp/factory", "testagent", entry) + mgr.SetModelEnv([]config.EnvVar{ + {Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}, + {Key: "ANTHROPIC_DEFAULT_OPUS_MODEL", Value: "claude-opus-4"}, + }) + + cmd := mgr.BuildStartupCommand() + + for _, want := range []string{"ANTHROPIC_BASE_URL=''", "ANTHROPIC_AUTH_TOKEN=''"} { + if !strings.Contains(cmd, want) { + t.Errorf("no-endpoint set should emit the explicit clear %q, got: %s", want, cmd) + } + } +} + +// TestStart_NoEndpointProfile_UnsetsStaleRedirect proves the Start() hygiene twin (AC-8): +// a no-endpoint model-only profile clears ANTHROPIC_BASE_URL and unsets the other +// redirect-family vars on the (reused) session, so a profile switch leaves no stale +// redirect var. It drives the full Start() path through the recording hermetic fake. +func TestStart_NoEndpointProfile_UnsetsStaleRedirect(t *testing.T) { + mgr, fake := startMouseAgent(t, nil) + mgr.SetModelEnv([]config.EnvVar{{Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}}) + + if err := mgr.Start(); err != nil { + t.Fatalf("Start: unexpected error: %v", err) + } + + sessionID := mgr.SessionID() + staleBaseURLSet := "SetEnvironment " + sessionID + " ANTHROPIC_BASE_URL=" + unsetBaseURL := "UnsetEnvironment " + sessionID + " ANTHROPIC_BASE_URL" + unsetStaleModel := "UnsetEnvironment " + sessionID + " ANTHROPIC_DEFAULT_OPUS_MODEL" + + var baseURLCleared, staleModelUnset bool + for _, op := range fake.ops { + // A non-empty base_url set on a no-endpoint profile is a contamination bug. + if strings.HasPrefix(op, staleBaseURLSet) && op != staleBaseURLSet { + t.Errorf("no-endpoint profile must not set a non-empty ANTHROPIC_BASE_URL; got op %q", op) + } + if op == staleBaseURLSet || op == unsetBaseURL { + baseURLCleared = true + } + if op == unsetStaleModel { + staleModelUnset = true + } + } + if !baseURLCleared { + t.Errorf("expected an empty-value clear or unset of ANTHROPIC_BASE_URL; ops=%v", fake.ops) + } + // A redirect var NOT in the effective set must be unset by the hygiene pass so a + // prior profile's value cannot survive the switch. + if !staleModelUnset { + t.Errorf("hygiene pass must unset a redirect var absent from the effective set (ANTHROPIC_DEFAULT_OPUS_MODEL); ops=%v", fake.ops) + } +} + +// envOpsForSession returns the SetEnvironment/UnsetEnvironment ops the recorder +// tagged with exactly sess (the op's session field is fields[1]). Used by the +// two-manager isolation test to partition one shared recorder's ops per session. +func envOpsForSession(ops []string, sess string) []string { + var out []string + for _, op := range ops { + f := strings.Fields(op) + if len(f) >= 2 && (f[0] == "SetEnvironment" || f[0] == "UnsetEnvironment") && f[1] == sess { + out = append(out, op) + } + } + return out +} + +// hasOp reports whether ops contains an exact match for want. +func hasOp(ops []string, want string) bool { + for _, op := range ops { + if op == want { + return true + } + } + return false +} + +// isRedirectFamilyEnvOp reports whether op is a Set/Unset of a redirect-family var +// (the ANTHROPIC_* / CLAUDE_CODE_SUBAGENT_MODEL set the hygiene pass governs). +func isRedirectFamilyEnvOp(op string) bool { + f := strings.Fields(op) + if len(f) < 3 || (f[0] != "SetEnvironment" && f[0] != "UnsetEnvironment") { + return false + } + key := f[2] + if f[0] == "SetEnvironment" { + key = strings.SplitN(f[2], "=", 2)[0] + } + for _, rk := range redirectFamilyVars { + if key == rk { + return true + } + } + return false +} + +// TestStart_TwoManagers_NoRedirectCrossContamination is the literal two-manager +// isolation proof the AC-9 session row names (issue #508 AC-4). Two managers with +// DIFFERENT profiles — an OpenAI-via-LiteLLM endpoint profile (A) and a no-endpoint +// Anthropic model-only profile (B) — are started against ONE shared hermetic tmux +// recorder. Each Manager writes env only to its OWN session (SessionName(agentName)), +// so the isolation guarantee is: +// - A's session carries A's real ANTHROPIC_BASE_URL + the raw file: token placeholder +// (the tmux twin verbatim, never a resolved secret); +// - B's session emits the explicit ANTHROPIC_BASE_URL='' / ANTHROPIC_AUTH_TOKEN='' +// structural clears and unsets its stale redirect var (the hygiene pass); +// - NO op tagged with B's session ever carries A's endpoint URL or token (A cannot +// leak into B), and NO op tagged with A's session is the empty clear (B starting +// never clobbers A's live endpoint); +// - every redirect-family env op is tagged with exactly one of the two DISTINCT +// session IDs (no op escapes to a shared or third session). +// +// The AC-4 substance (no stale redirect survives a switch; every value is shell-inert) +// is already covered by TestStart_NoEndpointProfile_UnsetsStaleRedirect and +// TestProfile_ShellInjectionInert; this additive func closes the AC-9 wording literally +// and pins the cross-session guarantee those single-manager tests cannot express. +func TestStart_TwoManagers_NoRedirectCrossContamination(t *testing.T) { + origMem := checkAvailableMemoryFunc + checkAvailableMemoryFunc = func() (uint64, error) { return 100000, nil } + t.Cleanup(func() { checkAvailableMemoryFunc = origMem }) + + fake := installHermeticSession(t) // ONE recorder shared by both managers + + tmpDir := t.TempDir() + wtPath := filepath.Join(tmpDir, ".worktrees", "wt-test") + + const gatewayURL = "https://gateway.internal" + const gatewayTok = "file:secrets/a.tok" + + startManager := func(name string, env []config.EnvVar) *Manager { + if err := os.MkdirAll(config.AgentDir(wtPath, name), 0o755); err != nil { + t.Fatalf("creating agent dir for %s: %v", name, err) + } + mgr := NewManager(tmpDir, name, config.AgentEntry{Type: "autonomous", Description: "test"}) + if err := mgr.SetWorktree(wtPath, "wt-test"); err != nil { + t.Fatalf("SetWorktree(%s): %v", name, err) + } + mgr.SetModelEnv(env) + if err := mgr.Start(); err != nil { + t.Fatalf("Start(%s): unexpected error: %v", name, err) + } + return mgr + } + + // A: an OpenAI-via-LiteLLM endpoint profile (base_url + file: token). + mgrA := startManager("manager-a", []config.EnvVar{ + {Key: "ANTHROPIC_MODEL", Value: "gpt-x"}, + {Key: "ANTHROPIC_BASE_URL", Value: gatewayURL}, + {Key: "ANTHROPIC_AUTH_TOKEN", Value: gatewayTok}, + }) + // B: a no-endpoint Anthropic model-only profile — its ANTHROPIC_DEFAULT_OPUS_MODEL + // is absent from the effective set, so the hygiene pass must unset it. + mgrB := startManager("manager-b", []config.EnvVar{ + {Key: "ANTHROPIC_MODEL", Value: "claude-opus-4"}, + }) + + sessA, sessB := mgrA.SessionID(), mgrB.SessionID() + if sessA == sessB { + t.Fatalf("two managers must have distinct session IDs; both were %q", sessA) + } + + opsA := envOpsForSession(fake.ops, sessA) + opsB := envOpsForSession(fake.ops, sessB) + if len(opsA) == 0 || len(opsB) == 0 { + t.Fatalf("non-vacuity: both sessions must record env ops; A=%d B=%d\nops=%v", len(opsA), len(opsB), fake.ops) + } + + // A carries its real endpoint + the raw file: token placeholder (tmux twin). + for _, want := range []string{ + "SetEnvironment " + sessA + " ANTHROPIC_BASE_URL=" + gatewayURL, + "SetEnvironment " + sessA + " ANTHROPIC_AUTH_TOKEN=" + gatewayTok, + } { + if !hasOp(opsA, want) { + t.Errorf("manager A session missing %q; opsA=%v", want, opsA) + } + } + + // B emits the explicit structural clears and unsets its stale redirect (hygiene). + for _, want := range []string{ + "SetEnvironment " + sessB + " ANTHROPIC_BASE_URL=", + "SetEnvironment " + sessB + " ANTHROPIC_AUTH_TOKEN=", + } { + if !hasOp(opsB, want) { + t.Errorf("manager B session missing structural clear %q; opsB=%v", want, opsB) + } + } + if !hasOp(opsB, "UnsetEnvironment "+sessB+" ANTHROPIC_DEFAULT_OPUS_MODEL") { + t.Errorf("manager B hygiene must unset the stale ANTHROPIC_DEFAULT_OPUS_MODEL; opsB=%v", opsB) + } + + // Isolation A→B: A's endpoint URL / token never appear under B's session. + for _, op := range opsB { + if strings.Contains(op, gatewayURL) { + t.Errorf("manager A endpoint URL leaked into manager B session: %q", op) + } + if strings.Contains(op, gatewayTok) { + t.Errorf("manager A token leaked into manager B session: %q", op) + } + } + // Isolation B→A: B's empty structural clear never lands on A's live endpoint. + for _, op := range opsA { + if op == "SetEnvironment "+sessA+" ANTHROPIC_BASE_URL=" || + op == "SetEnvironment "+sessA+" ANTHROPIC_AUTH_TOKEN=" { + t.Errorf("manager B structural clear clobbered manager A's live endpoint: %q", op) + } + } + + // Completeness: every redirect-family env op belongs to exactly one of the two + // distinct sessions — none escaped to a shared or third session name. + for _, op := range fake.ops { + if !isRedirectFamilyEnvOp(op) { + continue + } + sess := strings.Fields(op)[1] + if sess != sessA && sess != sessB { + t.Errorf("redirect-family op escaped to an unexpected session %q: %q", sess, op) + } + } +} + +// TestStart_TmuxTwinCarriesFileRefPlaceholder locks the deliberate twin asymmetry +// (issue #508 W2): the Start() tmux twin mirrors a file:-ref ANTHROPIC_AUTH_TOKEN as the +// RAW placeholder verbatim — never the "$(cat …)" deref (tmux set-environment does no +// shell evaluation, so it would be stored literally) and never a resolved secret (it +// would be readable via `tmux show-environment`). The deref lives ONLY in the inline +// command that buildStartupCommand types into the pane. +func TestStart_TmuxTwinCarriesFileRefPlaceholder(t *testing.T) { + mgr, fake := startMouseAgent(t, nil) + mgr.SetModelEnv([]config.EnvVar{ + {Key: "ANTHROPIC_BASE_URL", Value: "https://gateway.internal"}, + {Key: "ANTHROPIC_AUTH_TOKEN", Value: "file:secrets/gw.tok"}, + }) + + if err := mgr.Start(); err != nil { + t.Fatalf("Start: unexpected error: %v", err) + } + + sessionID := mgr.SessionID() + want := "SetEnvironment " + sessionID + " ANTHROPIC_AUTH_TOKEN=file:secrets/gw.tok" + var found bool + for _, op := range fake.ops { + if op == want { + found = true + } + // The tmux twin (a SetEnvironment op) must never carry the $(cat …) deref. + // (The SendKeysDelayed op legitimately carries the inline deref — exclude it.) + if strings.HasPrefix(op, "SetEnvironment ") && + strings.Contains(op, "ANTHROPIC_AUTH_TOKEN") && strings.Contains(op, "$(cat") { + t.Errorf("tmux twin must carry the raw file: placeholder, not the $(cat …) deref; got op %q", op) + } + } + if !found { + t.Errorf("tmux twin must SetEnvironment the raw file: placeholder verbatim; want op %q, ops=%v", want, fake.ops) + } +} diff --git a/internal/templates/roles/design-plan-impl.md.tmpl b/internal/templates/roles/design-plan-impl.md.tmpl index aa22a7b..1047b3f 100644 --- a/internal/templates/roles/design-plan-impl.md.tmpl +++ b/internal/templates/roles/design-plan-impl.md.tmpl @@ -39,7 +39,7 @@ This formula has 1 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate <gate-id>` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: design-plan-impl @@ -76,7 +76,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate <id>` — Complete a gate step (session ends) +- `af done --phase-complete --gate <id>` — Complete a gate step (continue via `af prime`) - `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read <id>` — Read a specific message diff --git a/internal/templates/roles/design-v3.md.tmpl b/internal/templates/roles/design-v3.md.tmpl index 730929b..6ee6c44 100644 --- a/internal/templates/roles/design-v3.md.tmpl +++ b/internal/templates/roles/design-v3.md.tmpl @@ -39,7 +39,7 @@ This formula has 2 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate <gate-id>` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: design-v3 @@ -76,7 +76,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate <id>` — Complete a gate step (session ends) +- `af done --phase-complete --gate <id>` — Complete a gate step (continue via `af prime`) - `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read <id>` — Read a specific message diff --git a/internal/templates/roles/design-v7.md.tmpl b/internal/templates/roles/design-v7.md.tmpl index a072086..dc2392a 100644 --- a/internal/templates/roles/design-v7.md.tmpl +++ b/internal/templates/roles/design-v7.md.tmpl @@ -39,7 +39,7 @@ This formula has 2 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate <gate-id>` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: design-v7 @@ -77,7 +77,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate <id>` — Complete a gate step (session ends) +- `af done --phase-complete --gate <id>` — Complete a gate step (continue via `af prime`) - `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read <id>` — Read a specific message diff --git a/internal/templates/roles/gherkin-breakdown.md.tmpl b/internal/templates/roles/gherkin-breakdown.md.tmpl index 9989d23..b933573 100644 --- a/internal/templates/roles/gherkin-breakdown.md.tmpl +++ b/internal/templates/roles/gherkin-breakdown.md.tmpl @@ -44,7 +44,7 @@ This formula has 3 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate <gate-id>` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: gherkin-breakdown @@ -84,7 +84,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate <id>` — Complete a gate step (session ends) +- `af done --phase-complete --gate <id>` — Complete a gate step (continue via `af prime`) - `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read <id>` — Read a specific message diff --git a/internal/templates/roles/github-issue.md.tmpl b/internal/templates/roles/github-issue.md.tmpl new file mode 100644 index 0000000..86e768b --- /dev/null +++ b/internal/templates/roles/github-issue.md.tmpl @@ -0,0 +1,165 @@ +<!-- Generated by af formula agent-gen from github-issue v1 --> + +# Agent Identity: {{ .Role }} + +You are **{{ .Role }}**, {{ .Description }}. + +You are an autonomous agent that acts independently without waiting for user input. + +## Workspace + +- **Factory root**: `{{ .RootDir }}` +- **Working directory**: `{{ .WorkDir }}` + +## Operational Knowledge + +### How You Work +When given work, instantiate your formula: +``` +af sling --formula github-issue --var issue=<the-issue-id-assigned-to-this-agent> --no-launch +``` + +Then cycle to a clean session: +``` +af handoff +``` + +Then drive the workflow: +``` +af prime # Load identity + current step instructions +[execute the step] +af done # Close step and advance +``` +Repeat until all steps are complete. + +**Important:** Complete your current formula instance before accepting new work. + +### Formula Structure +- **Name**: github-issue +- **Type**: workflow +- **Steps**: 17 (0 gates) + +| # | Step | Gate | +|---|------|------| +| 1 | Load context and verify assignment | | +| 2 | Set up working branch | | +| 3 | Validate design contract (if present) | | +| 4 | Verify tests pass on main | | +| 5 | Phase 1: Understand the problem as reported | | +| 6 | Phase 2: Map the codebase | | +| 7 | Phase 3: Reconcile findings with reported symptoms | | +| 8 | Phase 4: Draft the GitHub issue | | +| 9 | Phase 5: Validate the Draft Before Posting | | +| 10 | Phase 6: Post | | +| 11 | Phase 7: Create Bead & Dispatch (if requested) | | +| 12 | Self-review changes | | +| 13 | Run tests and verify coverage | | +| 14 | Verify implementation matches design contract (if present) | | +| 15 | Clean up workspace | | +| 16 | Prepare work for review | | +| 17 | Submit and exit | | + +### Variables + +| Variable | Required | Source | Description | +|----------|----------|--------|-------------| +| issue | yes | cli | The issue ID assigned to this agent (the problem to write up) | + +### Available Commands +- `af prime` — Re-inject identity and formula step context +- `af done` — Close current step and advance +- `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group +- `af mail inbox` — List unread messages +- `af mail read <id>` — Read a specific message +- `af mail delete <id>` — Delete/acknowledge a message +- `af mail check` — Check for new mail +- `af mail reply <id> -m <message>` — Reply to a message +- `af prime` — Re-inject identity context +- `af root` — Print factory root path + +## Behavioral Discipline + +## Overview +Investigates a reported problem and produces a well-formed GitHub issue (or a +comment on an existing one) that gives an implementer everything they need to find +and understand the problem — without prescribing the fix. + +The problem to write up comes from the assigned bead — which may contain an inline +problem description, a path to a problem document, or a link to an existing GitHub +issue. This formula converts .claude/skills/github-issue/SKILL.md into a +dispatchable workflow: Phase 1 (understand the problem, examine attached +artifacts), Phase 2 (investigate the codebase via an Explore agent), Phase 3 +(reconcile findings against every reported symptom), Phase 4 (draft the issue +body), Phase 5 (validate the draft against a 5-point checklist), Phase 6 (post via +`gh api`), and Phase 7 (optionally create a bead and dispatch, if requested). + +## Your Role: Cartographer, Not Surgeon + +You are not fixing the reported problem. You are drawing the map for someone who +will. Write every sentence as if you're briefing a colleague who will go deeper +than you did — list every affected file and layer, note contextual gotchas, and +write acceptance criteria that define "done" without defining "how." Never say +"add this line to this file" or name a specific function/flag/command substitution +as "the correct" fix — naming the mechanism is prescription even when offered as an +example. + +**Be honest about what you find.** If a feature was half-implemented, say "this was +never wired up." If there are no tests covering the flow, say so. Don't soften +findings into neutral descriptions of state. + +## Variables + +| Variable | Source | Description | +|----------|--------|-------------| +| issue | cli | The issue ID assigned to this agent (the problem to write up) | + +## Failure Modes + +| Situation | Action | +|-----------|--------| +| A reconciliation row still shows a gap after re-investigating | Do NOT wait for a reply — mail Supervisor a non-blocking notice, record it under Open Questions in the draft, and proceed with the best available theory | +| Investigation surfaces an adjacent problem | Include it by default; if excluded, record the exclusion and reason under Scope Decisions in the draft rather than dropping it silently | +| A validate-draft checklist item is not "none" | Fix the draft now and re-run the checklist — do not post with an unresolved item | +| Tests fail on main | Judgment call per the Scotty Principle table — fix, file, or proceed per severity | +| Context filling up | Use af handoff to cycle to a fresh session | +| Blocked / unclear problem statement | Mail Supervisor, do not guess | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Why It's Bad | Instead | +|-------------|-------------|---------| +| "This is a one-line fix" | Minimizes scope, misses environments/tests | List all affected layers | +| Naming specific deploy scripts | Assumes deploy path, misses staging/prod parity | Note that multiple environments exist | +| Skipping test files | Implementer might skip test updates | Always list test files in affected layers | +| Skipping artifact examination | Screenshots and pastes often contain the diagnosis | Examine every attached image, log, and config screenshot before exploring the codebase | +| Waiting for a human reply on a reconciliation gap or scope decision | This agent runs unattended and cannot block | Escalate non-blocking via mail, record the gap/decision visibly in the draft, and proceed | +| Prescribing a fix in the draft | Closes off the implementer's discovery, causes them to miss things | State findings and acceptance criteria only, never mechanisms | + +## !IMPORTANT - MANDATORY Exact Step Execution +Execute each formula step EXACTLY as written, in order, with no modifications. +Every step produces a file artifact at a known path. `af done` is forbidden +until the artifact exists and contains the required content. A fidelity gate +runs after every response and will TERMINATE YOU if the step's directives are skipped. +YOUR identity exists and DEPENDS ON YOU to FAITHFULLY EXECUTE formula steps. + + +## Mail Protocol + +- Check your inbox on startup for pending instructions or status updates. +- Respond to messages that require acknowledgment. +- Send status updates when completing significant work. +- Use `@all` to broadcast to all agents, or group names for targeted messages. + +## Startup Protocol + +1. Check mail for pending instructions (`af mail inbox`) +2. Act on any hooked work or queued tasks +3. Begin autonomous execution — monitor, patrol, and act independently + +## Constraints + +- Stay within your workspace directory. +- Use `af` commands for all inter-agent communication. +- Do not modify other agents' directories or mailboxes directly. +- Follow the factory's established conventions and workflows. +- Act autonomously — do not wait for user prompts between tasks. diff --git a/internal/templates/roles/manager.md.tmpl b/internal/templates/roles/manager.md.tmpl index fb5a819..557ccee 100644 --- a/internal/templates/roles/manager.md.tmpl +++ b/internal/templates/roles/manager.md.tmpl @@ -113,6 +113,7 @@ tmux capture-pane -p -t af-<agent> -S -30 - QUALITY + EFFICACY > SPEED + EFFICIENCY — Never prioritize speed over correctness. - Use established skills for their designated purpose — don't improvise, skills ARE your INSTRUCTIONS. - When you don't know something, say "I don't know" — never speculate and present it as fact. Brutal truth. Be honest. +- Never report agent or factory state without showing the command output that proves it. An agent's mail is a claim, not a fact — verify it yourself or label it as the agent's unverified claim. - When you commit to actions, track them and execute them. Don't stop halfway and wait to be told what to do. ## Context Exhaustion Protocol diff --git a/internal/templates/roles/rapid-implement.md.tmpl b/internal/templates/roles/rapid-implement.md.tmpl index edd5976..4526048 100644 --- a/internal/templates/roles/rapid-implement.md.tmpl +++ b/internal/templates/roles/rapid-implement.md.tmpl @@ -39,7 +39,7 @@ This formula has 7 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate <gate-id>` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: rapid-implement @@ -81,7 +81,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate <id>` — Complete a gate step (session ends) +- `af done --phase-complete --gate <id>` — Complete a gate step (continue via `af prime`) - `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read <id>` — Read a specific message diff --git a/internal/templates/roles/rapid-increment.md.tmpl b/internal/templates/roles/rapid-increment.md.tmpl index b2cb27f..3100bbe 100644 --- a/internal/templates/roles/rapid-increment.md.tmpl +++ b/internal/templates/roles/rapid-increment.md.tmpl @@ -39,7 +39,7 @@ This formula has 8 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate <gate-id>` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: rapid-increment @@ -83,7 +83,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate <id>` — Complete a gate step (session ends) +- `af done --phase-complete --gate <id>` — Complete a gate step (continue via `af prime`) - `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read <id>` — Read a specific message diff --git a/internal/templates/roles/ultra-review.md.tmpl b/internal/templates/roles/ultra-review.md.tmpl index 3508e74..db8bd01 100644 --- a/internal/templates/roles/ultra-review.md.tmpl +++ b/internal/templates/roles/ultra-review.md.tmpl @@ -63,6 +63,7 @@ Repeat until all steps are complete. | Variable | Required | Source | Description | |----------|----------|--------|-------------| | pr_uri | yes | cli | Pull request to review: full GitHub URL (https://github.com/owner/repo/pull/N), owner/repo#N, or bare PR number (current repo) — OR a GitHub issue URL (.../issues/N), which the formula resolves to its single linked PR via the closing-keyword relationship, or fails fast | +| force_review | no | cli | Force a full re-review even if this reviewer has already reviewed the PR. Waives ONLY eligibility criterion (d) (already-reviewed) at both the phase-1 and phase-6 checkpoints — criteria (a) closed/merged, (b) draft, and (c) doesn't-need-review still apply. Default 'false' preserves standard behavior: already-reviewed PRs stay ineligible. | | min_confidence | no | cli | Minimum 0-100 confidence score a finding needs to be included in the posted review | ### Available Commands @@ -112,6 +113,7 @@ false positives. Only findings scoring at or above {{ "{{" }}min_confidence{{ "} |----------|--------|-------------| | pr_uri | input (cli) | Pull request to review: full GitHub URL, owner/repo#number, or bare number | | min_confidence | input (cli, default 70) | Minimum 0-100 confidence score a finding needs to be reported | +| force_review | input (cli, default "false") | Set "true" to force a re-review of a PR this reviewer already reviewed. Waives ONLY the already-reviewed criterion — every other eligibility check still applies | ## Orchestrator Communication Contract diff --git a/internal/templates/roles/web-design.md.tmpl b/internal/templates/roles/web-design.md.tmpl index f51127c..91d341c 100644 --- a/internal/templates/roles/web-design.md.tmpl +++ b/internal/templates/roles/web-design.md.tmpl @@ -39,7 +39,7 @@ This formula has 5 gate checkpoints. Some steps have gates — structural interl that cannot be closed until an external condition is met. When you reach a gate step: 1. Complete the work described in the step 2. Run `af done --phase-complete --gate <gate-id>` -3. Your session ends. A fresh agent resumes when the gate resolves. +3. Then run `af prime` to load your next step and continue. ### Formula Structure - **Name**: web-design @@ -84,7 +84,7 @@ that cannot be closed until an external condition is met. When you reach a gate ### Available Commands - `af prime` — Re-inject identity and formula step context - `af done` — Close current step and advance -- `af done --phase-complete --gate <id>` — Complete a gate step (session ends) +- `af done --phase-complete --gate <id>` — Complete a gate step (continue via `af prime`) - `af mail send <to> -s <subject> -m <message>` — Send a message to an agent or group - `af mail inbox` — List unread messages - `af mail read <id>` — Read a specific message diff --git a/internal/tmux/guard_test.go b/internal/tmux/guard_test.go index 9d3f33f..668de93 100644 --- a/internal/tmux/guard_test.go +++ b/internal/tmux/guard_test.go @@ -130,6 +130,7 @@ func TestGuard_ZeroProductionRealOps(t *testing.T) { _ = tx.KillSession("af-test-ab12cd34-x") _ = tx.NewSession("af-test-ab12cd34-y", "/tmp") _ = tx.SetEnvironment("af-test-ab12cd34-z", "K", "V") + _ = tx.UnsetEnvironment("af-test-ab12cd34-z", "K") if _, err := tx.HasSession("af-manager"); err != nil { t.Errorf("HasSession read-only probe returned err: %v", err) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 3ddd459..b9b54dd 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -525,6 +525,17 @@ func (t *Tmux) SetEnvironment(session, key, value string) error { return err } +// UnsetEnvironment removes an environment variable from the session (the -u form of +// set-environment). Guarded identically to SetEnvironment: -u is still a set-environment +// op, so the ADR-018 guard classification stays correct. +func (t *Tmux) UnsetEnvironment(session, key string) error { + if t.guardOp("set-environment", session) { + return nil + } + _, err := t.run("set-environment", "-t", session, "-u", key) + return err +} + // SetOption sets a session-scoped tmux option (set-option -t). It mirrors // SetEnvironment: the op carries a -t target, so the ADR-018 guard can protect it // (the op-string literal "set-option" names the real op in the guard panic). diff --git a/internal/tmux/tmux_integration_test.go b/internal/tmux/tmux_integration_test.go index 6e7973f..347a0de 100644 --- a/internal/tmux/tmux_integration_test.go +++ b/internal/tmux/tmux_integration_test.go @@ -172,6 +172,32 @@ func TestSetEnvironment(t *testing.T) { } } +// TestUnsetEnvironment mirrors TestSetEnvironment for the #508 W2 wrapper: it drives the +// real set-environment -u form against a live af-test-* session. Guard-gated like its +// twin, so it runs only under `make test-integration`. +func TestUnsetEnvironment(t *testing.T) { + if !hasTmux() { + t.Skip("tmux not available") + } + + tx := NewTmux() + name := "af-test-unsetenv" + + _ = tx.KillSession(name) + + if err := tx.NewSession(name, "/tmp"); err != nil { + t.Fatalf("NewSession: %v", err) + } + defer tx.KillSession(name) + + if err := tx.SetEnvironment(name, "TEST_VAR", "hello"); err != nil { + t.Fatalf("SetEnvironment: %v", err) + } + if err := tx.UnsetEnvironment(name, "TEST_VAR"); err != nil { + t.Fatalf("UnsetEnvironment: %v", err) + } +} + // TestSetOption_ReadBack is the T-3 read-back for Issue #412 Fix A (AC #6): a // real af-test-* session reports both applied options. NewSession raises the // GLOBAL history-limit to 50000 (best-effort) before creating its window; diff --git a/quickdocker.sh b/quickdocker.sh index 1f1b3dd..9bcebd3 100755 --- a/quickdocker.sh +++ b/quickdocker.sh @@ -168,7 +168,10 @@ _web_bridge() { if [[ -n "$_mpid" ]] && kill -0 "$_mpid" 2>/dev/null && [[ -n "$_mport" ]] && _port_in_use "$_mport"; then local _rurl="http://127.0.0.1:${_mport}/" echo "🔗 Open your factory: ${_rurl}" - command -v open >/dev/null 2>&1 && open "$_rurl" 2>/dev/null || true + command -v open >/dev/null 2>&1 && open "$_rurl" 2>/dev/null \ + || { command -v xdg-open >/dev/null 2>&1 && xdg-open "$_rurl" 2>/dev/null; } \ + || { command -v wslview >/dev/null 2>&1 && wslview "$_rurl" 2>/dev/null; } \ + || true return 0 fi rm -f "$_marker" 2>/dev/null || true # stale marker (listener never bound / died) -> re-setup @@ -316,7 +319,10 @@ rm -f "$marker" "$handler"' # (7) Reveal the URL and return immediately. echo "🔗 Open your factory: ${_url}" - command -v open >/dev/null 2>&1 && open "$_url" 2>/dev/null || true + command -v open >/dev/null 2>&1 && open "$_url" 2>/dev/null \ + || { command -v xdg-open >/dev/null 2>&1 && xdg-open "$_url" 2>/dev/null; } \ + || { command -v wslview >/dev/null 2>&1 && wslview "$_url" 2>/dev/null; } \ + || true } # Parse arguments @@ -638,7 +644,7 @@ step_done step 8 "Running quickstart.sh (this may take a few minutes)..." -docker exec -it -u dev -w "${PROJECTS_DIR}/${AF_DIR}" "$CONTAINER_NAME" \ +docker exec -it -u dev -w "${PROJECTS_DIR}/${AF_DIR}" -e AF_QUICKDOCKER_DRIVEN=1 "$CONTAINER_NAME" \ ./quickstart.sh step_done @@ -680,6 +686,8 @@ fi echo "" echo " Connect:" echo " docker exec -it -u dev $CONTAINER_NAME bash" +echo " Web console:" +echo " printed above (re-open later with ./quickdocker.sh $REPO_PATH --web)" echo "" echo " Start agents:" echo " af up" @@ -690,4 +698,14 @@ echo "==========================================" echo "" # Connect to the container +# Open the web console for this freshly-installed container, THEN drop the user into the +# shell (Issue #479). MUST be a subshell: _web_bridge has five `exit 1` paths and the +# script runs `set -euo pipefail`, so a non-subshell `exit` would abort setup before reaching +# the shell — and `_web_bridge … || true` would NOT save it (the shell exits before `||`). +# The subshell inherits `set -e`, so any non-zero command inside also trips the `if !` +# branch — which is fine: still non-fatal, the shell below always lands. Do not "tidy" the +# subshell away. +if ! ( _web_bridge "$CONTAINER_NAME" "${WORKSPACE_DIR}/${REPO_NAME}" ); then + echo " Web console not started — continuing to shell. Re-run './quickdocker.sh ${REPO_PATH} --web' once the cause is fixed." >&2 +fi docker exec -it -u dev "$CONTAINER_NAME" bash diff --git a/quickstart.sh b/quickstart.sh index ac6e592..40f8043 100755 --- a/quickstart.sh +++ b/quickstart.sh @@ -399,9 +399,9 @@ configure_shell() { { echo "$begin_marker" - echo 'export ANTHROPIC_MODEL="${ANTHROPIC_MODEL:-claude-opus-4-8}"' + echo 'export ANTHROPIC_MODEL="${ANTHROPIC_MODEL:-claude-fable-5}"' echo 'export ANTHROPIC_DEFAULT_OPUS_MODEL="${ANTHROPIC_DEFAULT_OPUS_MODEL:-claude-opus-4-8}"' - echo 'export ANTHROPIC_DEFAULT_SONNET_MODEL="${ANTHROPIC_DEFAULT_SONNET_MODEL:-claude-sonnet-4-6}"' + echo 'export ANTHROPIC_DEFAULT_SONNET_MODEL="${ANTHROPIC_DEFAULT_SONNET_MODEL:-claude-sonnet-5}"' echo 'export CLAUDE_CODE_EFFORT_LEVEL="${CLAUDE_CODE_EFFORT_LEVEL:-xhigh}"' echo 'export CLAUDE_CODE_DISABLE_AUTO_MEMORY="${CLAUDE_CODE_DISABLE_AUTO_MEMORY:-1}"' echo "$end_marker" @@ -716,7 +716,7 @@ main() { main "$@" # If running in Docker, restart shell to pick up PATH changes -if [[ -f /.dockerenv ]]; then +if [[ -f /.dockerenv && -z "${AF_QUICKDOCKER_DRIVEN:-}" ]]; then echo "Restarting shell to apply PATH changes..." exec bash fi diff --git a/web/README.md b/web/README.md index 3fec178..89ead43 100644 --- a/web/README.md +++ b/web/README.md @@ -46,6 +46,8 @@ quickdocker.sh user/myrepo --web # -> 🔗 Open your factory: http://127.0.0.1:<HOSTPORT>/ ``` +> **Clean installs auto-reveal the console first.** A clean `quickdocker.sh <repo>` install now reveals the console **before the shell** — it prints `🔗 Open your factory: http://127.0.0.1:<HOSTPORT>/` (and opens your browser on macOS) immediately before dropping you into the interactive shell. So `--web` is the way to **re-open** the console later, not the way to see it the first time. + `--web` means **"reveal my URL"**, not "start a session": - It prints the clickable link exactly as `🔗 Open your factory: http://127.0.0.1:<HOSTPORT>/` diff --git a/web/cmd/afweb/main.go b/web/cmd/afweb/main.go index 62e709a..e17136b 100644 --- a/web/cmd/afweb/main.go +++ b/web/cmd/afweb/main.go @@ -31,37 +31,39 @@ import ( ) func main() { - // K4 (#432): resolve the VALIDATED factory root — AF_ROOT-first-then-cwd, walked up to a real + // Resolve the VALIDATED factory root (#432) — AF_ROOT-first-then-cwd, walked up to a real // .agentfactory/factory.json — instead of the raw os.Getwd()/AF_ROOT value that could silently - // serve a wrong-but-valid (or non-existent) factory (R2). Validating here also fixes the ADR-010 + // serve a wrong-but-valid (or non-existent) factory. Validating here also fixes the ADR-010 // rendezvous-file placement for free (the same validated string feeds rendezvous.Ensure below). // Fail loud when no factory resolves (mirrors the log.Fatalf precedents below): a bounded, - // clearly-logged exit that the detached/bounded relaunch guard tolerates without busy-looping - // (LOW-e). + // clearly-logged exit that the detached/bounded relaunch guard tolerates without busy-looping. root, err := config.ResolveFactoryRoot() if err != nil { log.Fatalf("afweb: cannot resolve factory root: %v", err) } - // LOW-b: Wrapper.root (pre-flight) and ExecRunner.root (the af child's cwd) are sourced from the + // Wrapper.root (pre-flight) and ExecRunner.root (the af child's cwd) are sourced from the // SAME validated root above, so they can never diverge. runner := exec.NewExecRunner(root) wrapper := exec.NewWrapper(runner, root) rm := readmodel.New(wrapper, readmodel.NewTmuxLiveness()) + capture := readmodel.NewTmuxCapture() // #500: per-agent read-only session-snapshot reader (probe-first) forms := formschema.New(wrapper) disp := dispatch.New(wrapper) settings := config.New(root, wrapper) - protos := proto.New(root) // C7: serve on-disk prototypes under <root>/.designs/ - feedbackWriter := feedback.New(root, rm) // C6: gate-verify via the read-model (Option a; no new exec) + protos := proto.New(root) // serve on-disk prototypes under <root>/.designs/ + feedbackWriter := feedback.New(root, rm) // gate-verify via the read-model (no new exec) opts := []server.Option{ - server.WithRoot(root), // K5: surface the served factory root via /healthz (Gap 7) + server.WithRoot(root), // surfaced via /healthz so a wrong-but-valid root is visible, not silent server.WithFormReader(forms), server.WithDispatchReader(disp), server.WithSettings(settings), server.WithFormulaResolver(settings), // #455: Sling form resolves the DECLARED formula from agents.json (same *config.Service) server.WithPrototypes(protos), server.WithFeedback(feedbackWriter), + server.WithTailer(capture), // #500: GET /api/agents/{name}/detail session snapshot + server.WithMailer(wrapper), // #500: POST /api/agents/{name}/mail (Wrapper.MailSend, sender=operator) } if bind := os.Getenv("AF_BIND"); bind != "" { opts = append(opts, server.WithBind(bind)) @@ -89,8 +91,8 @@ func main() { log.Printf("afweb: web UI already running at %s; nothing to do", url) return } - _ = ln // the listener stays open; srv.Listen() serves on it in its own goroutine - log.Printf("afweb: factory root: %s", root) // K5: surface the resolved root (Gap 7; ux.md log-variant) + _ = ln // the listener stays open; srv.Listen() serves on it in its own goroutine + log.Printf("afweb: factory root: %s", root) // so an operator can see which factory this process resolved to log.Printf("afweb: serving the Floor at %s/", url) log.Printf("afweb: session token: %s (required only when the bind is not loopback)", srv.Token()) diff --git a/web/internal/entrypoint/guard_test.go b/web/internal/entrypoint/guard_test.go index 53fc664..b7f43c5 100644 --- a/web/internal/entrypoint/guard_test.go +++ b/web/internal/entrypoint/guard_test.go @@ -1,8 +1,10 @@ // Package entrypoint hosts the hermetic test for the container-bootstrap web-UI launch guard -// (quickstart.sh, Phase 5 / C0). It lives in its OWN package — outside the dir list scanned by -// web/internal/server/lint_test.go's no-shell lint — so that running the real guard via -// `exec.Command("bash", ...)` here does not trip that lint. There is no production code in this -// package; it exists to make the IFF-available launch contract CI-visible. +// (quickstart.sh, Phase 5 / C0). web/internal/server/lint_test.go's no-shell lint now walks the +// WHOLE module, so this file is no longer "outside the scanned set"; instead it is NARROWLY +// EXEMPTED by path (isExemptFromShellLint) so that running the real guard via +// `exec.Command("bash", ...)` here does not trip the shell lint. The exemption scopes ONLY the +// shell class — the mutating-af and tmux-input classes still apply here. There is no production +// code in this package; it exists to make the IFF-available launch contract CI-visible. package entrypoint import ( diff --git a/web/internal/exec/runner.go b/web/internal/exec/runner.go index ebb3bbd..056e08a 100644 --- a/web/internal/exec/runner.go +++ b/web/internal/exec/runner.go @@ -1,14 +1,14 @@ -// Package exec is the C2 command-injection-safe af exec wrapper for the web module. +// Package exec is the command-injection-safe af exec wrapper for the web module. // // Every af invocation is built as an argv array (program "af" + literal arguments) and run // via os/exec — NEVER through a shell. This mirrors the canonical af-core idiom at // internal/tmux/tmux.go:163-177 (exec.Command with a fixed program name + an argv slice, no // shell interpolation). A field value such as "; rm -rf" is carried as ONE literal argv -// element and can never be interpreted as a command (Security Decision 2). +// element and can never be interpreted as a command. // // All execution goes through the Runner interface so the server, read-model, and handlers // depend on the seam — never on os/exec directly. Tests inject a hermetic fake so the suite -// can assert argv/serialization without ever spawning a real af (FR-3 / ADR-018). No unit or +// can assert argv/serialization without ever spawning a real af (ADR-018). No unit or // acceptance test issues a real down/sling/--reset against the live tree. package exec @@ -70,8 +70,8 @@ type ExecRunner struct { } // NewExecRunner returns a Runner that invokes the `af` binary on PATH, pinning the spawned af -// child's working directory to root. root is required — this is the SOLE constructor form -// (HIGH-1 compile-time interlock): a root-less production caller will not compile, so the #432 +// child's working directory to root. root is required — this is the SOLE constructor form, +// a compile-time interlock: a root-less production caller will not compile, so the #432 // bug cannot silently return via a forgotten root. Pass "" to opt into the prior cwd-inheriting // behaviour (used by unit tests that reject the verb before any exec). func NewExecRunner(root string) *ExecRunner { @@ -106,7 +106,8 @@ func (e *ExecRunner) run(ctx context.Context, stdin []byte, verb string, args .. } argv := afArgv(verb, args...) cmd := e.execCommand(ctx, argv[0], argv[1:]...) - if e.root != "" { // K3: pin the af child to the resolved factory root (channel is cmd.Dir, NOT AF_ROOT — Gap 3) + if e.root != "" { // pin the af child's working directory to the resolved factory root, never via an + // inherited AF_ROOT env var, which could be silently wrong or misattribute the sender cmd.Dir = e.root } var stdout, stderr bytes.Buffer @@ -132,15 +133,14 @@ func (e *ExecRunner) run(ctx context.Context, stdin []byte, verb string, args .. // factoryLockKey is the per-"agent" serialization key for factory-wide up/down. const factoryLockKey = "@factory" -// Wrapper is the high-level C2 surface the server depends on. It validates every input +// Wrapper is the high-level surface the server depends on. It validates every input // against the allowlists, serializes mutations per agent (UI-vs-itself), and performs a // best-effort cross-actor pre-flight before any mutating exec. // -// Concurrency note (H-P3): the per-agent mutex closes only UI-vs-itself races. Cross-actor +// Concurrency note: the per-agent mutex closes only UI-vs-itself races. Cross-actor // races (UI vs CLI operator vs the autonomous orchestrator) are NOT eliminable here — af's // underlying locks are advisory/PID-TOCTOU (internal/lock/lock.go:52). The dispatched-marker -// pre-flight is a bounded-TOCTOU mitigation, not a guarantee; the residual is an Accepted -// Residual Risk. +// pre-flight is a bounded-TOCTOU mitigation, not a guarantee; the residual is an accepted risk. type Wrapper struct { runner Runner root string // factory root; "" disables the on-disk dispatched-marker pre-flight @@ -191,7 +191,7 @@ func (w *Wrapper) mutate(ctx context.Context, lockKey, preflightAgent, verb stri defer l.Unlock() // Best-effort cross-actor pre-flight, performed immediately before the exec to minimize - // the TOCTOU window. Refuse to collide with an orchestrated/dispatched run (protects AC-8). + // the TOCTOU window. Refuse to collide with an orchestrated/dispatched run. if preflightAgent != "" && w.isDispatched(preflightAgent) { return Result{}, ErrAgentOrchestrated } @@ -234,12 +234,12 @@ func (w *Wrapper) DownAgent(ctx context.Context, name string, reset bool) (Resul // idle) agent otherwise errors on Formula Succession — af refuses with "prior formula still // active; use --reset" (internal/cmd/sling.go) — so the UI path force-resets runtime state to // keep a UI-slung run byte-identical to the hand-slung `af sling --agent <name> --reset …` -// argv the operator would type (AC-7/AC-8). --var travels as ONE literal argv element per field +// argv the operator would type. --var travels as ONE literal argv element per field // (varArgs), never a comma-joined StringSliceVar value. // // task is the operator's primary text. af-core's `af sling [task]` takes it as the POSITIONAL // argument (Use: "sling [task]"; Args: cobra.MaximumNArgs(1)), so it is emitted AFTER a `--` -// terminator: `[--agent <name> --reset --var k=v … -- <task>]`. The `--` is mandatory (#440 H1) — +// terminator: `[--agent <name> --reset --var k=v … -- <task>]`. The `--` is mandatory (#440) — // with default pflag a task beginning with `-`/`--` would otherwise be misparsed as a flag and // re-trigger af's `task description required` error. The task is value-validated (validateTask: // reject control chars; allow commas and a leading dash) but never key-checked — it is the @@ -257,13 +257,13 @@ func (w *Wrapper) Sling(ctx context.Context, name, task string, vars map[string] return Result{}, err } args := append([]string{"--agent", name, "--reset"}, vargs...) - args = append(args, "--", task) // `--` terminator (H1) then the positional task + args = append(args, "--", task) // `--` terminator then the positional task return w.mutate(ctx, name, name, "sling", args...) } // AgentsListJSON returns the raw stdout of `af agents list --json` (a read; no lock, no // pre-flight). The command always exits 0 and encodes failure as {"state":"error",...}; the -// read-model (File 3) branches on that .state shape, not on the process exit code. +// read-model branches on that .state shape, not on the process exit code. func (w *Wrapper) AgentsListJSON(ctx context.Context) (string, error) { res, err := w.runner.Run(ctx, "agents", "list", "--json") if err != nil { @@ -274,7 +274,7 @@ func (w *Wrapper) AgentsListJSON(ctx context.Context) (string, error) { // FormulaShowJSON returns the raw stdout of `af formula show <formula> --json` (a read; no lock, // no pre-flight). Like AgentsListJSON the command always exits 0 and encodes failure as -// {"state":"error",...}; the form-schema reader (C4) branches on that .state shape, not on the +// {"state":"error",...}; the form-schema reader branches on that .state shape, not on the // process exit code. The "formula" verb is already on the allowlist (validate.go). func (w *Wrapper) FormulaShowJSON(ctx context.Context, formula string) (string, error) { res, err := w.runner.Run(ctx, "formula", "show", formula, "--json") @@ -304,7 +304,7 @@ func (w *Wrapper) DispatchStatusJSON(ctx context.Context) (string, error) { // canonical validator/writer: it validates (struct + cross-file ValidateDispatchConfig for dispatch) // and writes atomically (temp+rename), exiting non-zero with a friendly stderr message on any // validation failure — which RunStdin surfaces in the returned error. The web module never -// re-declares the config schema nor re-implements validation on the write side (H-P1). +// re-declares the config schema nor re-implements validation on the write side. // // file is checked against the {dispatch,startup} allowlist BEFORE exec (factory.json is read-only — // there is no `af config factory set`), so a caller can never smuggle an arbitrary subcommand as the @@ -316,3 +316,34 @@ func (w *Wrapper) ConfigSet(ctx context.Context, file string, payload []byte) (R } return w.runner.RunStdin(ctx, payload, "config", file, "set") } + +// mailFooter is appended to every web-sent body so recipients don't wait on a reply-blackhole: +// the wrapper-pinned sender below is not a monitored mailbox. +const mailFooter = "\n\n(sent from the web console; replies to 'operator' are not monitored)" + +// MailSend sends operator mail to an agent's mailbox: `af mail send <name> --subject=<s> +// --message=<body+footer>` with the constant sender `operator`. Recipient name (trim, then the +// copied af-core shape rule — the wrapper convention), subject, and body are all validated +// BEFORE any exec; the sender is a wrapper constant, never caller input (root af enforces +// members ∪ operator on it — internal/cmd/mail.go). Single-token `=` flag forms so a +// dash-leading value can never re-parse as a flag, and the subcommand is fixed to +// `send` — a caller can never smuggle a different mail subcommand. +// +// MailSend deliberately calls runner.Run DIRECTLY — no mutate(), no per-agent lock, no +// .runtime/dispatched pre-flight, the same direct-Run exception ConfigSet above makes. Mail's +// primary recipients ARE dispatched agents: the pre-flight would refuse exactly the mail this +// feature exists to send. Mail targets the agent's MAILBOX, not its running session, so there +// is no session mutation to serialize or protect. +func (w *Wrapper) MailSend(ctx context.Context, name, subject, body string) (Result, error) { + name = trimAgent(name) + if err := ValidateAgentName(name); err != nil { + return Result{}, err + } + if err := validateMailSubject(subject); err != nil { + return Result{}, err + } + if err := validateMailBody(body); err != nil { + return Result{}, err + } + return w.runner.Run(ctx, "mail", "send", name, "--subject="+subject, "--message="+body+mailFooter, "--from=operator") +} diff --git a/web/internal/exec/runner_test.go b/web/internal/exec/runner_test.go index 206c6b1..acb6645 100644 --- a/web/internal/exec/runner_test.go +++ b/web/internal/exec/runner_test.go @@ -5,6 +5,7 @@ import ( "os" osexec "os/exec" "path/filepath" + "strings" "sync" "testing" ) @@ -422,7 +423,7 @@ func TestValidateAgentName(t *testing.T) { t.Errorf("ValidateAgentName(%q) = %v, want nil", v, err) } } - invalid := []string{"", "agent;rm", "../../etc", "agent name", "123start", "dispatch"} + invalid := []string{"", "agent;rm", "../../etc", "agent name", "123start", "dispatch", "operator"} for _, v := range invalid { if err := ValidateAgentName(v); err == nil { t.Errorf("ValidateAgentName(%q) = nil, want error", v) @@ -596,7 +597,7 @@ func TestRun_AllVerbs_CarryCmdDir(t *testing.T) { factoryRoot := t.TempDir() // The exact allowlist from validate.go (allowedVerbs). A new verb added there without being added // here is itself a prompt to re-confirm this invariant. - verbs := []string{"up", "down", "sling", "agents", "formula", "dispatch", "step", "config"} + verbs := []string{"up", "down", "sling", "agents", "formula", "dispatch", "step", "config", "mail"} for _, verb := range verbs { t.Run(verb, func(t *testing.T) { er := NewExecRunner(factoryRoot) @@ -617,3 +618,219 @@ func TestRun_AllVerbs_CarryCmdDir(t *testing.T) { }) } } + +// ---- #500 Phase 1: MailSend (the web mail composer's write path) ---- + +// mail is now on the allowlist (#500 Phase 1) so the real runner permits the verb (the wrapper +// fixes the subcommand to `send`). +func TestValidateVerb_AllowsMail(t *testing.T) { + if err := ValidateVerb("mail"); err != nil { + t.Fatalf("mail must be allowlisted: %v", err) + } +} + +// MailSend's argv is byte-pinned: single-token `=` flag forms (a dash-leading value can never +// re-parse as a flag — design-doc "Mail argv form"), the wrapper-pinned constant sender, and +// the fixed reply-blackhole footer inside the --message element. A shell-hostile body travels +// as ONE literal argv element (C2 argv-array exec — no shell, no splitting). +func TestWrapper_MailSend_ArgvExact(t *testing.T) { + const footer = "\n\n(sent from the web console; replies to 'operator' are not monitored)" + + fr := newFakeRunner() + w := NewWrapper(fr, "") + body := "status: green\nnext: verify" // multi-line is legal mail (validateMailBody allows \n) + if _, err := w.MailSend(context.Background(), "designer", "Build status", body); err != nil { + t.Fatalf("MailSend: %v", err) + } + c := fr.lastCall() + if c.Verb != "mail" { + t.Fatalf("verb = %q, want mail", c.Verb) + } + want := []string{"send", "designer", "--subject=Build status", "--message=" + body + footer, "--from=operator"} + if len(c.Args) != len(want) { + t.Fatalf("mail argv = %v, want %v", c.Args, want) + } + for i := range want { + if c.Args[i] != want[i] { + t.Fatalf("mail argv[%d] = %q, want %q (full: %v)", i, c.Args[i], want[i], c.Args) + } + } + if c.Stdin != nil { + t.Fatalf("MailSend is a plain Run — it must not pipe stdin, got %q", c.Stdin) + } + + // A shell-looking body stays ONE literal --message element (plus the footer). + fr2 := newFakeRunner() + w2 := NewWrapper(fr2, "") + if _, err := w2.MailSend(context.Background(), "designer", "s", "; rm -rf /"); err != nil { + t.Fatalf("a shell-looking body should be accepted as one literal element, got %v", err) + } + got := fr2.lastArgs() + if len(got) != 5 || got[3] != "--message=; rm -rf /"+footer { + t.Fatalf("shell-looking body must be one literal --message element, got %v", got) + } +} + +// MailSend is DIRECT exec (design-doc "Mail concurrency class"): no per-agent lock and no +// .runtime/dispatched pre-flight, because mail's primary recipients ARE dispatched agents — +// mail targets the mailbox, not the running session. Proven both ways: (a) while the SAME +// agent's mutate-lock is held by a parked Sling, MailSend must not see ErrAgentBusy; (b) while +// the dispatched marker exists, MailSend must not see ErrAgentOrchestrated — while DownAgent +// on the same wrapper still must, proving the marker is real and only MailSend bypasses it. +func TestWrapper_MailSend_NoLock(t *testing.T) { + // (a) same-agent mutate-lock held by a parked Sling. + fr := newFakeRunner() + fr.entered = make(chan string, 1) + fr.block = make(chan struct{}) + w := NewWrapper(fr, "") + + var wg sync.WaitGroup + var slingErr error + wg.Add(1) + go func() { + defer wg.Done() + _, slingErr = w.Sling(context.Background(), "alpha", "t", nil) + }() + <-fr.entered // the Sling is now parked INSIDE Run, holding alpha's mutate-lock + + // Unhook the park for subsequent calls so MailSend itself is not blocked by the fake. + fr.mu.Lock() + blockCh := fr.block + fr.entered, fr.block = nil, nil + fr.mu.Unlock() + + if _, err := w.MailSend(context.Background(), "alpha", "subject", "body"); err != nil { + t.Fatalf("MailSend while alpha's mutate-lock is held = %v, want nil (must not be ErrAgentBusy)", err) + } + if c := fr.lastCall(); c.Verb != "mail" { + t.Fatalf("MailSend must exec while the lock is held; last verb = %q", c.Verb) + } + close(blockCh) + wg.Wait() + if slingErr != nil { + t.Fatalf("parked Sling err = %v, want nil", slingErr) + } + + // (b) dispatched marker present. + root := t.TempDir() + runtimeDir := filepath.Join(root, ".agentfactory", "agents", "alpha", ".runtime") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtimeDir, "dispatched"), []byte("@dispatch"), 0o644); err != nil { + t.Fatal(err) + } + fr2 := newFakeRunner() + w2 := NewWrapper(fr2, root) + if _, err := w2.DownAgent(context.Background(), "alpha", false); err != ErrAgentOrchestrated { + t.Fatalf("DownAgent(dispatched) err = %v, want ErrAgentOrchestrated (the marker must be effective)", err) + } + if _, err := w2.MailSend(context.Background(), "alpha", "subject", "body"); err != nil { + t.Fatalf("MailSend(dispatched agent) err = %v, want nil (mail must reach dispatched agents)", err) + } + if c := fr2.lastCall(); c.Verb != "mail" { + t.Fatalf("MailSend(dispatched) must exec; last verb = %q", c.Verb) + } +} + +// Invalid recipient/subject/body is rejected BEFORE any exec (the ConfigSet precedent): +// the fake records zero calls. A multi-line body is ACCEPTED — mail is legitimately +// multi-line, which is exactly why validateMailBody is a NEW rule and not validateVar reuse. +func TestWrapper_MailSend_ValidationRejectsBeforeExec(t *testing.T) { + cases := []struct { + label, name, subject, body string + }{ + {"empty subject", "alpha", "", "body"}, + {"empty body", "alpha", "subject", ""}, + {"oversize subject >200", "alpha", strings.Repeat("a", 201), "body"}, + {"oversize body >10000", "alpha", "subject", strings.Repeat("b", 10001)}, + {"ESC in subject", "alpha", "esc\x1bhere", "body"}, + {"ESC in body", "alpha", "subject", "esc\x1bhere"}, + {"carriage return in body", "alpha", "subject", "line1\r\nline2"}, + {"newline in subject", "alpha", "two\nlines", "body"}, + {"bad agent name", "a;rm", "subject", "body"}, + {"reserved agent name", "dispatch", "subject", "body"}, + } + for _, tc := range cases { + fr := newFakeRunner() + w := NewWrapper(fr, "") + if _, err := w.MailSend(context.Background(), tc.name, tc.subject, tc.body); err == nil { + t.Errorf("%s: MailSend returned nil error, want rejection", tc.label) + } + if fr.callCount() != 0 { + t.Errorf("%s: rejected mail must never exec (recorded %d calls)", tc.label, fr.callCount()) + } + } + + fr := newFakeRunner() + w := NewWrapper(fr, "") + if _, err := w.MailSend(context.Background(), "alpha", "subject", "line1\nline2\n\ttabbed"); err != nil { + t.Fatalf("a multi-line body must be accepted, got %v", err) + } + if fr.callCount() != 1 { + t.Fatalf("accepted mail must exec exactly once, got %d", fr.callCount()) + } +} + +// validateMailSubject — non-empty, ≤200 RUNES, and the validateVar value predicate. Tab is +// allowed (the predicate is copied verbatim — IMPLREADME Gotcha 9) and the cap counts runes, +// not bytes (Gotcha 10), so a 200-rune multibyte subject is legal. +func TestValidateMailSubject(t *testing.T) { + good := []string{ + "Build status", + "unicode café ☃ 日本語", + "tab\there", // predicate copied verbatim from validateVar: tab allowed (Gotcha 9) + strings.Repeat("a", 200), + strings.Repeat("é", 200), // 200 runes / 400 bytes — the cap is runes (Gotcha 10) + } + for _, s := range good { + if err := validateMailSubject(s); err != nil { + t.Errorf("validateMailSubject(%q) = %v, want nil", s, err) + } + } + bad := []string{ + "", + strings.Repeat("a", 201), + "two\nlines", + "carriage\rreturn", + "esc\x1bseq", + "null\x00byte", + "del\x7fhere", + } + for _, s := range bad { + if err := validateMailSubject(s); err == nil { + t.Errorf("validateMailSubject(%q) = nil, want error", s) + } + } +} + +// validateMailBody — non-empty, ≤10000 RUNES; \n and \t allowed (mail is multi-line), \r, +// the other C0 controls, and 0x7f rejected. A deliberately NEW rule: validateVar rejects \n. +func TestValidateMailBody(t *testing.T) { + good := []string{ + "one line", + "line1\nline2\nline3", + "tab\there", + "unicode café ☃ 日本語", + strings.Repeat("b", 10000), + } + for _, b := range good { + if err := validateMailBody(b); err != nil { + t.Errorf("validateMailBody(%q) = %v, want nil", b, err) + } + } + bad := []string{ + "", + strings.Repeat("b", 10001), + "carriage\rreturn", + "esc\x1bseq", + "null\x00byte", + "bell\x07now", + "del\x7fhere", + } + for _, b := range bad { + if err := validateMailBody(b); err == nil { + t.Errorf("validateMailBody(%q) = nil, want error", b) + } + } +} diff --git a/web/internal/exec/validate.go b/web/internal/exec/validate.go index 10b3ea0..b924e03 100644 --- a/web/internal/exec/validate.go +++ b/web/internal/exec/validate.go @@ -4,25 +4,28 @@ import ( "fmt" "regexp" "strings" + "unicode/utf8" ) // This file re-implements the small af-core slices the web module needs. It deliberately // does NOT import internal/… — Go's internal seal plus the separate go.mod make that -// compiler-impossible, and the duplication is the point (compiler-enforced C-2 decoupling). +// compiler-impossible, and the duplication is the point (a compile error, not a lint, +// catches this module drifting from the root module's validation rules). // // Sources (copied, not imported): -// - validAgentName / reservedNames / ValidateAgentName: internal/config/config.go:57,62-64,294-309 -// - verb set: the Phase-1 allowlist from the design (IMPLREADME File 1) -// - --var key rule: design-doc C2 +// - validAgentName / reservedNames / ValidateAgentName: internal/config/config.go:57,65-68,299-313 +// - verb set: the CLI verb allowlist +// - --var key rule: identifier-shape validation // validAgentName mirrors internal/config/config.go:57. var validAgentName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`) -// reservedNames mirrors internal/config/config.go:62-64 ("dispatch" collides with the -// af-dispatch infra session). -var reservedNames = map[string]bool{"dispatch": true} +// reservedNames mirrors internal/config/config.go's map: "dispatch" collides with the +// af-dispatch infra session; "operator" collides with the web console's synthetic mail +// sender identity, which would otherwise be indistinguishable from a real agent of that name. +var reservedNames = map[string]bool{"dispatch": true, "operator": true} -// ValidateAgentName mirrors internal/config/config.go:294-309 in full — the regex ALONE is +// ValidateAgentName mirrors internal/config/config.go:299-313 in full — the regex ALONE is // insufficient; the empty / length / reserved checks are load-bearing. func ValidateAgentName(name string) error { if name == "" { @@ -40,8 +43,8 @@ func ValidateAgentName(name string) error { return nil } -// allowedVerbs is the Phase-1 verb allowlist (IMPLREADME File 1). Any verb outside this set -// is refused before reaching exec — there is no generic command passthrough (C-6). +// allowedVerbs is the verb allowlist. Any verb outside this set is refused before reaching +// exec — there is no generic command passthrough. var allowedVerbs = map[string]bool{ "up": true, "down": true, @@ -51,6 +54,7 @@ var allowedVerbs = map[string]bool{ "dispatch": true, "step": true, "config": true, // Phase 3: `af config <dispatch|startup> set` — the curated settings write path. + "mail": true, // Phase 1 (#500): the wrapper fixes the subcommand to `send` — the web mail composer's write path. } // ValidateVerb refuses any verb outside the allowlist. @@ -61,7 +65,7 @@ func ValidateVerb(verb string) error { return nil } -// validVarKey constrains --var keys to a safe identifier shape (design-doc C2). +// validVarKey constrains --var keys to a safe identifier shape. var validVarKey = regexp.MustCompile(`^[A-Za-z0-9_]+$`) // validateVar checks a single --var key/value pair. The key must be an identifier; the value @@ -80,6 +84,44 @@ func validateVar(key, value string) error { return nil } +// validateMailSubject checks the web composer's subject line: non-empty, at most 200 runes, +// and validateVar's value predicate copied verbatim (so a literal tab IS allowed, exactly as +// in --var values). The cap counts RUNES, not bytes: byte len() would unfairly reject short +// multibyte-unicode subjects. +func validateMailSubject(s string) error { + if s == "" { + return fmt.Errorf("mail subject cannot be empty") + } + if utf8.RuneCountInString(s) > 200 { + return fmt.Errorf("mail subject too long (max 200 characters)") + } + for _, r := range s { + if r == '\n' || r == '\r' || (r < 0x20 && r != '\t') || r == 0x7f { + return fmt.Errorf("invalid mail subject: control characters are not allowed") + } + } + return nil +} + +// validateMailBody checks the web composer's body: non-empty, at most 10000 runes, multi-line +// allowed. This is a deliberately NEW rule, NOT a validateVar reuse: mail bodies are +// legitimately multi-line, so \n joins \t as permitted while \r, the other C0 controls, and +// 0x7f stay rejected. +func validateMailBody(b string) error { + if b == "" { + return fmt.Errorf("mail body cannot be empty") + } + if utf8.RuneCountInString(b) > 10000 { + return fmt.Errorf("mail body too long (max 10000 characters)") + } + for _, r := range b { + if r == '\r' || (r < 0x20 && r != '\t' && r != '\n') || r == 0x7f { + return fmt.Errorf("invalid mail body: control characters other than newline and tab are not allowed") + } + } + return nil +} + // validateTask checks the operator's positional task value. It applies the SAME value rule as // validateVar's value loop — reject newlines, carriage returns, other C0 control characters, and // 0x7f (tab is allowed) — but NOT the validVarKey identifier regex: the task is free text, so diff --git a/web/internal/readmodel/capture.go b/web/internal/readmodel/capture.go new file mode 100644 index 0000000..20afef9 --- /dev/null +++ b/web/internal/readmodel/capture.go @@ -0,0 +1,104 @@ +package readmodel + +import ( + "bytes" + "context" + osexec "os/exec" + "strconv" + "strings" + "time" +) + +// TailView is the honest per-agent session snapshot. Live comes from the +// membership probe — NEVER from capture success (a prefix-matching capture of the wrong +// session would otherwise report a false live). Output is plain text ("" when !Live); +// CapturedAt is the server clock at capture (zero value when !Live); Lines echoes the +// requested line count on every path. The snake_case time.Time tag follows the assembled_at +// precedent (readmodel.go:75) — RFC3339 default marshaling is what the frontend's +// receipt-anchored age logic (Phase 3) expects. +type TailView struct { + Live bool `json:"live"` + Output string `json:"output"` + CapturedAt time.Time `json:"captured_at"` + Lines int `json:"lines"` +} + +// TmuxCapture is the web-tier pane-snapshot reader, a sibling of TmuxLiveness: the same +// injectable execCommand seam (liveness.go:14-21) plus a private runCmd seam so tests can +// simulate exec outcomes as pure Go. The runCmd seam exists because the server lint raw-scans +// this package's test files for shell literals — canned tmux output can never come from a +// shell, and a run-func fake needs no process at all. +type TmuxCapture struct { + execCommand func(ctx context.Context, name string, args ...string) *osexec.Cmd + runCmd func(cmd *osexec.Cmd) (stdout, stderr string, err error) + now func() time.Time +} + +// NewTmuxCapture returns a TmuxCapture backed by the real tmux binary on PATH. +func NewTmuxCapture() *TmuxCapture { + return &TmuxCapture{execCommand: osexec.CommandContext, runCmd: runBuffered, now: time.Now} +} + +// runBuffered executes cmd with buffered stdout/stderr (the liveness.go:27-35 shape). +func runBuffered(cmd *osexec.Cmd) (string, string, error) { + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +// Tail returns the last lines of the agent's pane as an honest TailView. +// +// It is probe-first: session membership is established by an EXACT string compare against +// `list-sessions` output before any capture, and the capture itself uses the exact-match +// target form — never a bare `-t af-<name>`, which tmux PREFIX-matches (verified on tmux 3.4: +// with only af-design-v7 running, `capture-pane -t af-design` exits 0 and silently captures +// the wrong agent's terminal — a cross-agent information leak). The `=` exact idiom mirrors +// the root module's HasSession (internal/tmux/tmux.go:249-255) and the #201 GC fix; the +// root's own CapturePane (tmux.go:493-498) still uses the bare form and is deliberately NOT +// copied. name is expected already validated/trimmed (the Phase-2 handler validates recipient +// names), so the probe's sessionName(name) and the capture target agree. +// +// Absent session and no-server both yield the honest zero view (Live=false, nil error) — the +// module's no-server-is-not-an-error posture (liveness.go:11-13). A capture-time "can't find" +// stderr (reachable only in the probe→capture race window) classifies as the same honest +// absence; any OTHER probe/capture failure is a real error — genuine errors are never +// silently swallowed as absence. lines is used as given: clamping it (default 120, 1-500) +// is the caller's responsibility, not this function's. +func (t *TmuxCapture) Tail(ctx context.Context, name string, lines int) (TailView, error) { + absent := TailView{Lines: lines} + + probe := t.execCommand(ctx, "tmux", "list-sessions", "-F", "#{session_name}") + out, stderr, err := t.runCmd(probe) + if err != nil { + if isNoServer(stderr) { + return absent, nil + } + return TailView{}, err + } + session := sessionName(name) // the package's single session-name helper, kept consistent with the probe below + present := false + for _, s := range splitSessions(out) { + if strings.TrimSpace(s) == session { + present = true + break + } + } + if !present { + return absent, nil + } + + // '=' pins the exact session name; the ':' suffix pins the target-pane form (the + // colon-less '=name' fails with "can't find pane" on tmux 3.4, verified). Plain text + // only — never -e, which would let tmux interpret escape sequences in captured output. + capture := t.execCommand(ctx, "tmux", "capture-pane", "-p", "-t", "=af-"+name+":", "-S", "-"+strconv.Itoa(lines)) + text, cstderr, err := t.runCmd(capture) + if err != nil { + if strings.Contains(strings.ToLower(cstderr), "can't find") { + return absent, nil + } + return TailView{}, err + } + return TailView{Live: true, Output: text, CapturedAt: t.now(), Lines: lines}, nil +} diff --git a/web/internal/readmodel/capture_test.go b/web/internal/readmodel/capture_test.go new file mode 100644 index 0000000..4f8b96c --- /dev/null +++ b/web/internal/readmodel/capture_test.go @@ -0,0 +1,255 @@ +package readmodel + +import ( + "context" + "encoding/json" + "errors" + osexec "os/exec" + "sync" + "testing" + "time" +) + +// ---- #500 Phase 1: TmuxCapture test double ---- +// +// Injection strategy (IMPLREADME Gotcha 1): the execCommand seam records the name+args +// PARAMETERS it is asked to construct (option (a) — recording the parameters directly, so +// argv[0] is never lost to the captureCmdDir argv[0]-replacement caveat), and the private +// runCmd seam (option (c)) returns canned stdout/stderr/err as pure Go. No process is ever +// spawned and nothing here shells out — the server lint raw-scans this file for shell +// literals, so canned output can never come from a shell. + +// cannedRun is one canned runCmd result, consumed in call order. +type cannedRun struct { + stdout string + stderr string + err error +} + +// errExit stands in for a non-zero tmux exit. +var errExit = errors.New("exit status 1") + +// captureFake wires a TmuxCapture whose exec layer is fully faked: argv construction is +// recorded; execution returns the canned results in order. +type captureFake struct { + mu sync.Mutex + argvs [][]string // element 0 is the program name; the rest are the args + runs []cannedRun + n int +} + +func newCaptureFake(t *testing.T, runs ...cannedRun) (*TmuxCapture, *captureFake) { + t.Helper() + fx := &captureFake{runs: runs} + tc := NewTmuxCapture() + tc.execCommand = func(ctx context.Context, name string, args ...string) *osexec.Cmd { + fx.mu.Lock() + fx.argvs = append(fx.argvs, append([]string{name}, args...)) + fx.mu.Unlock() + return osexec.CommandContext(ctx, name, args...) // constructed only — the faked runCmd never runs it + } + tc.runCmd = func(cmd *osexec.Cmd) (string, string, error) { + fx.mu.Lock() + defer fx.mu.Unlock() + if fx.n >= len(fx.runs) { + t.Fatalf("unexpected exec #%d: only %d canned results wired", fx.n+1, len(fx.runs)) + } + r := fx.runs[fx.n] + fx.n++ + return r.stdout, r.stderr, r.err + } + return tc, fx +} + +func (f *captureFake) calls() [][]string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([][]string, len(f.argvs)) + copy(out, f.argvs) + return out +} + +func assertArgv(t *testing.T, label string, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s argv = %v, want %v", label, got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("%s argv[%d] = %q, want %q (full: %v)", label, i, got[i], want[i], got) + } + } +} + +// The capture argv is byte-pinned to the exact-match target form. tmux PREFIX-matches a bare +// `-t af-<name>` (verified on tmux 3.4 — verification-report §K): with only af-design-v7 +// running, `capture-pane -t af-design` exits 0 and silently captures the WRONG agent's +// terminal. Only the '=af-<name>:' form is safe; this pins those bytes against BOTH a live +// and an absent session fixture, and forbids the bare form and -e everywhere. +func TestTmuxCapture_ArgvExactMatch(t *testing.T) { + // Live fixture: the probe lists the session, then the capture runs. + tc, fx := newCaptureFake(t, + cannedRun{stdout: "af-designer\naf-other\n"}, + cannedRun{stdout: "pane text\n"}, + ) + if _, err := tc.Tail(context.Background(), "designer", 120); err != nil { + t.Fatalf("Tail(live): %v", err) + } + calls := fx.calls() + if len(calls) != 2 { + t.Fatalf("expected probe + capture (2 execs), got %d: %v", len(calls), calls) + } + assertArgv(t, "probe", calls[0], []string{"tmux", "list-sessions", "-F", "#{session_name}"}) + assertArgv(t, "capture", calls[1], []string{"tmux", "capture-pane", "-p", "-t", "=af-designer:", "-S", "-120"}) + + // Absent fixture: the probe misses; no capture argv is ever constructed. + tc2, fx2 := newCaptureFake(t, cannedRun{stdout: "af-other\n"}) + if _, err := tc2.Tail(context.Background(), "designer", 120); err != nil { + t.Fatalf("Tail(absent): %v", err) + } + + for _, argv := range append(fx.calls(), fx2.calls()...) { + for i := 0; i+1 < len(argv); i++ { + if argv[i] == "-t" && argv[i+1] == "af-designer" { + t.Fatalf("bare -t af-designer target constructed (prefix-match hazard): %v", argv) + } + } + for _, a := range argv { + if a == "-e" { + t.Fatalf("capture must be plain text — never -e (data.md D2-B): %v", argv) + } + } + } +} + +// Absent session ⇒ the honest zero view (Live false, empty Output, zero CapturedAt), nil error +// — the module's no-server-is-not-an-error posture (liveness.go:11-13). Lines echoes the +// requested count on every path (data.md D2-B: "the line count actually requested"). +func TestTmuxCapture_AbsentSession_HonestValue(t *testing.T) { + tc, _ := newCaptureFake(t, cannedRun{stdout: "af-other\n"}) + v, err := tc.Tail(context.Background(), "designer", 120) + if err != nil { + t.Fatalf("absent session must not be an error, got %v", err) + } + if v.Live || v.Output != "" || !v.CapturedAt.IsZero() { + t.Fatalf("absent session must be the honest zero view, got %+v", v) + } + if v.Lines != 120 { + t.Fatalf("Lines = %d, want the requested 120", v.Lines) + } + + // No tmux server at all is the same honest absence, not an error (isNoServer precedent). + tc2, _ := newCaptureFake(t, cannedRun{stderr: "no server running on /tmp/tmux-1000/default", err: errExit}) + v2, err := tc2.Tail(context.Background(), "designer", 120) + if err != nil { + t.Fatalf("no-server must not be an error, got %v", err) + } + if v2.Live { + t.Fatalf("no-server must be Live=false, got %+v", v2) + } +} + +// Probe-first: when the membership probe misses, NO capture exec happens — the probe is the +// ONLY invocation. This keeps the prefix-match hazard structurally unreachable for absent +// sessions. +func TestTmuxCapture_ProbeFirst(t *testing.T) { + tc, fx := newCaptureFake(t, cannedRun{stdout: "af-other\naf-third\n"}) + if _, err := tc.Tail(context.Background(), "designer", 50); err != nil { + t.Fatalf("Tail: %v", err) + } + calls := fx.calls() + if len(calls) != 1 { + t.Fatalf("probe miss must yield exactly 1 exec (the probe), got %d: %v", len(calls), calls) + } + if calls[0][1] != "list-sessions" { + t.Fatalf("the single exec must be the list-sessions probe, got %v", calls[0]) + } +} + +// Capture-time stderr classification (design-doc L365 / Gotcha 4): the probe→capture race +// (session died in between) answers lowercase "can't find …" on tmux 3.4 and classifies as +// honest absence (nil error). Any OTHER failure — capture or probe — is a real error; the +// design-doc supersedes data.md's looser degrade-on-any-failure sketch, and the 8b434fc3 +// honesty doctrine forbids silently degrading unexpected failures. +func TestTmuxCapture_StderrClassification(t *testing.T) { + // can't find ⇒ absent, nil error. + tc, _ := newCaptureFake(t, + cannedRun{stdout: "af-designer\n"}, + cannedRun{stderr: "can't find session: af-designer", err: errExit}, + ) + v, err := tc.Tail(context.Background(), "designer", 120) + if err != nil { + t.Fatalf("the can't-find race must classify as absent, got err %v", err) + } + if v.Live { + t.Fatalf("the can't-find race must be Live=false, got %+v", v) + } + + // Any other capture stderr ⇒ error. + tc2, _ := newCaptureFake(t, + cannedRun{stdout: "af-designer\n"}, + cannedRun{stderr: "server exited unexpectedly", err: errExit}, + ) + if _, err := tc2.Tail(context.Background(), "designer", 120); err == nil { + t.Fatalf("a non-can't-find capture failure must surface as an error") + } + + // An unexpected PROBE failure (not no-server) is an error too — never a silent lie. + tc3, _ := newCaptureFake(t, cannedRun{stderr: "permission denied", err: errExit}) + if _, err := tc3.Tail(context.Background(), "designer", 120); err == nil { + t.Fatalf("an unexpected probe failure must surface as an error") + } +} + +// A successful capture returns honest content: stdout verbatim, the server clock stamp, and +// the requested line count. +func TestTmuxCapture_SuccessfulCapture_Content(t *testing.T) { + tc, _ := newCaptureFake(t, + cannedRun{stdout: "af-designer\n"}, + cannedRun{stdout: "line1\nline2"}, + ) + stamp := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + tc.now = func() time.Time { return stamp } + v, err := tc.Tail(context.Background(), "designer", 42) + if err != nil { + t.Fatalf("Tail: %v", err) + } + if !v.Live { + t.Fatalf("successful capture must be Live=true, got %+v", v) + } + if v.Output != "line1\nline2" { + t.Fatalf("Output = %q, want the captured pane text verbatim", v.Output) + } + if !v.CapturedAt.Equal(stamp) { + t.Fatalf("CapturedAt = %v, want the injected clock %v", v.CapturedAt, stamp) + } + if v.Lines != 42 { + t.Fatalf("Lines = %d, want 42", v.Lines) + } +} + +// TailView marshals with the design-pinned snake_case keys (data.md D2-B; the assembled_at +// precedent, readmodel.go:75) — the JSON contract Phase 2's DetailView embeds. +func TestTmuxCapture_TailViewJSONShape(t *testing.T) { + b, err := json.Marshal(TailView{ + Live: true, + Output: "x", + CapturedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), + Lines: 7, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"live", "output", "captured_at", "lines"} { + if _, ok := m[key]; !ok { + t.Errorf("TailView JSON is missing the %q key (got %s)", key, b) + } + } + if len(m) != 4 { + t.Errorf("TailView JSON must have exactly the 4 design-pinned keys, got %s", b) + } +} diff --git a/web/internal/server/detail_test.go b/web/internal/server/detail_test.go new file mode 100644 index 0000000..1b48ae7 --- /dev/null +++ b/web/internal/server/detail_test.go @@ -0,0 +1,397 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stempeck/agentfactory-web/internal/exec" + "github.com/stempeck/agentfactory-web/internal/readmodel" +) + +// ============================================================================ +// #500 — GET /api/agents/{name}/detail + POST /api/agents/{name}/mail +// (Shared fakes/helpers — fakeMutator, fakeAssembler, fakeRunner, loopbackPOST, +// writeAgentsJSON — live in server_test.go, same package.) +// ============================================================================ + +// fakeTailer returns a canned TailView and records the (name, lines) of each call. It never execs. +type fakeTailer struct { + view readmodel.TailView + err error + calls int + lastName string + lastLines int +} + +func (f *fakeTailer) Tail(ctx context.Context, name string, lines int) (readmodel.TailView, error) { + f.calls++ + f.lastName, f.lastLines = name, lines + return f.view, f.err +} + +// fakeMailSender records the (name, subject, body) of each MailSend and returns a canned result. +type fakeMailSender struct { + res exec.Result + err error + calls int + name string + subject string + body string +} + +func (f *fakeMailSender) MailSend(ctx context.Context, name, subject, body string) (exec.Result, error) { + f.calls++ + f.name, f.subject, f.body = name, subject, body + return f.res, f.err +} + +// fakeFormula is a hermetic FormulaResolver double for the detail route's declared_formula field. +type fakeFormula struct { + formula string + found bool + err error +} + +func (f fakeFormula) AgentFormula(ctx context.Context, name string) (string, bool, error) { + return f.formula, f.found, f.err +} + +var _ Tailer = (*fakeTailer)(nil) +var _ MailSender = (*fakeMailSender)(nil) +var _ FormulaResolver = fakeFormula{} + +// backslash is a single '\' char, built from its rune so no literal escape sequence appears in +// this source file (keeps the control-char JSON fixtures free of an escape the tooling mangles). +var backslash = string(rune(0x5c)) + +// AC-1 — the detail payload carries all twelve AgentView keys, the RUNNING vs DECLARED formula as +// DISTINCT fields (#455), and tail.{live,output,captured_at,lines}; plus Cache-Control: no-store. +func TestHandleAgentDetail_Fields(t *testing.T) { + views := []readmodel.AgentView{{ + Name: "alpha", Type: "worker", Formula: "running-formula", Running: true, + Status: "working", StepID: "s1", StepTitle: "Do the thing", StepState: "ready", + Inputs: map[string]string{"issue": "42"}, + }} + tl := &fakeTailer{view: readmodel.TailView{Live: true, Output: "pane text", Lines: 120}} + s := New(&fakeMutator{}, fakeAssembler{views: views}, nil, + WithTailer(tl), + WithFormulaResolver(fakeFormula{formula: "declared-formula", found: true})) + + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/agents/alpha/detail", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("detail: code = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", got) + } + + var env struct { + OK bool `json:"ok"` + Data struct { + Agent map[string]any `json:"agent"` + DeclaredFormula string `json:"declared_formula"` + Tail map[string]any `json:"tail"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode detail: %v; body=%s", err, rec.Body.String()) + } + if !env.OK { + t.Fatalf("ok=false; body=%s", rec.Body.String()) + } + for _, k := range []string{"name", "type", "formula", "running", "status", "step_id", "step_title", "step_state", "is_gate", "gate_id", "inputs", "assembled_at"} { + if _, ok := env.Data.Agent[k]; !ok { + t.Errorf("agent.%s missing from detail payload", k) + } + } + if env.Data.Agent["formula"] != "running-formula" { + t.Errorf("agent.formula = %v, want running-formula (the RUNNING formula)", env.Data.Agent["formula"]) + } + if env.Data.DeclaredFormula != "declared-formula" { + t.Errorf("declared_formula = %q, want declared-formula (agents.json), DISTINCT from agent.formula", env.Data.DeclaredFormula) + } + for _, k := range []string{"live", "output", "captured_at", "lines"} { + if _, ok := env.Data.Tail[k]; !ok { + t.Errorf("tail.%s missing", k) + } + } + if tl.lastLines != 120 { + t.Errorf("default line count = %d, want 120", tl.lastLines) + } +} + +// A gated agent surfaces gate_id / is_gate honestly. +func TestHandleAgentDetail_GateFixture(t *testing.T) { + views := []readmodel.AgentView{{Name: "gamma", Running: true, Status: "gated", IsGate: true, GateID: "design-feedback-1"}} + s := New(&fakeMutator{}, fakeAssembler{views: views}, nil, WithTailer(&fakeTailer{view: readmodel.TailView{Live: true, Lines: 120}})) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/agents/gamma/detail", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("gate detail: code=%d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, `"gate_id":"design-feedback-1"`) || !strings.Contains(body, `"is_gate":true`) { + t.Errorf("gate fixture did not surface is_gate/gate_id: %s", body) + } +} + +// A stopped agent is a VALUE (200 + tail.live=false + declared_formula), NEVER an error. +func TestHandleAgentDetail_StoppedAgent(t *testing.T) { + views := []readmodel.AgentView{{Name: "beta", Running: false, Status: "stopped"}} + s := New(&fakeMutator{}, fakeAssembler{views: views}, nil, + WithTailer(&fakeTailer{view: readmodel.TailView{Live: false, Lines: 120}}), + WithFormulaResolver(fakeFormula{formula: "beta-formula", found: true})) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/agents/beta/detail", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("stopped detail: code=%d, want 200 (stopped is a value); body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, `"live":false`) { + t.Errorf("tail.live should be false for a stopped agent: %s", body) + } + if !strings.Contains(body, `"declared_formula":"beta-formula"`) { + t.Errorf("declared_formula should still surface for a stopped agent: %s", body) + } +} + +// An unknown agent is an honest 404 (read-model membership is the oracle). +func TestHandleAgentDetail_Unknown404(t *testing.T) { + s := New(&fakeMutator{}, fakeAssembler{views: []readmodel.AgentView{{Name: "alpha"}}}, nil, WithTailer(&fakeTailer{})) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/agents/ghost/detail", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown detail: code=%d, want 404; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "agent ghost not found") { + t.Errorf("want honest 404 copy 'agent ghost not found', got %s", rec.Body.String()) + } +} + +// The ?lines= query is SILENTLY clamped to [1,500] (default 120) — never a 4xx (scale.md S3-1). +func TestHandleAgentDetail_LinesClampNever4xx(t *testing.T) { + views := []readmodel.AgentView{{Name: "alpha", Running: true, Status: "working"}} + cases := []struct { + q string + want int + }{ + {"", 120}, {"abc", 120}, {"50", 50}, {"0", 1}, {"-5", 1}, {"9999", 500}, {"500", 500}, {"1", 1}, + } + for _, c := range cases { + tl := &fakeTailer{view: readmodel.TailView{Live: true}} + s := New(&fakeMutator{}, fakeAssembler{views: views}, nil, WithTailer(tl)) + url := "/api/agents/alpha/detail" + if c.q != "" { + url += "?lines=" + c.q + } + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) + if rec.Code != http.StatusOK { + t.Errorf("lines=%q: code=%d, want 200 (clamp is never a 4xx)", c.q, rec.Code) + } + if tl.lastLines != c.want { + t.Errorf("lines=%q: clamped to %d, want %d", c.q, tl.lastLines, c.want) + } + } +} + +// nil Tailer / nil MailSender ⇒ clean 500 (nil-seam parity with /dispatch, /settings). +func TestAgentDetailAndMail_NilDeps500(t *testing.T) { + s := New(&fakeMutator{}, fakeAssembler{views: []readmodel.AgentView{{Name: "alpha", Running: true}}}, nil) + + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/agents/alpha/detail", nil)) + if rec.Code != http.StatusInternalServerError || !strings.Contains(rec.Body.String(), "tailer not configured") { + t.Fatalf("detail nil tailer: code=%d body=%s, want 500 'tailer not configured'", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, loopbackPOST("/api/agents/alpha/mail", `{"subject":"s","body":"b"}`)) + if rec.Code != http.StatusInternalServerError || !strings.Contains(rec.Body.String(), "mail sender not configured") { + t.Fatalf("mail nil mailer: code=%d body=%s, want 500 'mail sender not configured'", rec.Code, rec.Body.String()) + } +} + +// AC-4 — a valid mail POST queues with the honest async copy and threads (name, subject, body). +func TestHandleAgentMail_Success(t *testing.T) { + ms := &fakeMailSender{} + s := New(&fakeMutator{}, fakeAssembler{views: []readmodel.AgentView{{Name: "alpha", Running: true}}}, nil, WithMailer(ms)) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, loopbackPOST("/api/agents/alpha/mail", `{"subject":"hello","body":"world"}`)) + if rec.Code != http.StatusOK { + t.Fatalf("mail: code=%d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Mail queued for alpha — delivery is asynchronous") { + t.Errorf("want honest async copy, got %s", rec.Body.String()) + } + if ms.calls != 1 || ms.name != "alpha" || ms.subject != "hello" || ms.body != "world" { + t.Errorf("MailSend args = (calls=%d, %q,%q,%q), want (1, alpha,hello,world)", ms.calls, ms.name, ms.subject, ms.body) + } +} + +// Empty subject / empty body / control chars ⇒ direct 400 with the friendly copy; MailSend not +// called. The control-char bodies are VALID JSON (a  escape, assembled from the backslash +// rune) whose decoded value carries a real control char, so the handler's content validation — not +// the JSON decoder — is what rejects them. +func TestHandleAgentMail_ContentValidation400(t *testing.T) { + views := []readmodel.AgentView{{Name: "alpha", Running: true}} + ctrlSubj := `{"subject":"a` + backslash + `u0001b","body":"b"}` + ctrlBody := `{"subject":"s","body":"a` + backslash + `u0001b"}` + cases := []struct{ name, body, wantMsg string }{ + {"empty subject", `{"subject":" ","body":"b"}`, "subject is required"}, + {"empty body", `{"subject":"s","body":""}`, "message body is required"}, + {"control-char subject", ctrlSubj, "control"}, + {"control-char body", ctrlBody, "control"}, + } + for _, c := range cases { + ms := &fakeMailSender{} + s := New(&fakeMutator{}, fakeAssembler{views: views}, nil, WithMailer(ms)) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, loopbackPOST("/api/agents/alpha/mail", c.body)) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: code=%d, want 400; body=%s", c.name, rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), c.wantMsg) { + t.Errorf("%s: want message containing %q, got %s", c.name, c.wantMsg, rec.Body.String()) + } + if ms.calls != 0 { + t.Errorf("%s: MailSend must NOT be called on a 400 (got %d)", c.name, ms.calls) + } + } +} + +// Mail to an unknown agent is a 404; MailSend never fires. +func TestHandleAgentMail_Unknown404(t *testing.T) { + ms := &fakeMailSender{} + s := New(&fakeMutator{}, fakeAssembler{views: []readmodel.AgentView{{Name: "alpha", Running: true}}}, nil, WithMailer(ms)) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, loopbackPOST("/api/agents/ghost/mail", `{"subject":"s","body":"b"}`)) + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown mail: code=%d, want 404; body=%s", rec.Code, rec.Body.String()) + } + if ms.calls != 0 { + t.Errorf("MailSend must not fire for an unknown agent (got %d)", ms.calls) + } +} + +// A DISPATCHED agent is STILL mailable — mail bypasses mutate()'s dispatched-marker pre-flight +// (its primary recipients ARE dispatched agents). Driven through the REAL exec.Wrapper. +func TestHandleAgentMail_DispatchedAgentStillMailable(t *testing.T) { + root := t.TempDir() + writeAgentsJSON(t, root, `{"agents":{"alpha":{"formula":"minimalworker"}}}`) + markerDir := filepath.Join(root, ".agentfactory", "agents", "alpha", ".runtime") + if err := os.MkdirAll(markerDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(markerDir, "dispatched"), []byte("1"), 0o644); err != nil { + t.Fatal(err) + } + + fr := &fakeRunner{} + w := exec.NewWrapper(fr, root) // non-empty root ⇒ the dispatched pre-flight is ACTIVE for mutate() + s := New(w, fakeAssembler{views: []readmodel.AgentView{{Name: "alpha", Running: true}}}, nil, WithMailer(w)) + + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, loopbackPOST("/api/agents/alpha/mail", `{"subject":"s","body":"b"}`)) + if rec.Code != http.StatusOK { + t.Fatalf("dispatched-agent mail: code=%d, want 200 (mail bypasses the pre-flight); body=%s", rec.Code, rec.Body.String()) + } + if _, ok := fr.argsFor("mail"); !ok { + t.Errorf("the mail verb never reached the Runner (was it wrongly refused?); verbs=%v", fr.verbs) + } +} + +// TestValidateMailContent unit-tests the friendly-copy validator directly (control chars supplied +// via string(rune(1)) so no JSON/escape layer is involved). +func TestValidateMailContent(t *testing.T) { + ctrl := rune(1) + if msg, ok := validateMailContent(" ", "b"); ok || msg != "subject is required" { + t.Errorf("empty subject: msg=%q ok=%v", msg, ok) + } + if msg, ok := validateMailContent("s", ""); ok || msg != "message body is required" { + t.Errorf("empty body: msg=%q ok=%v", msg, ok) + } + if _, ok := validateMailContent("a"+string(ctrl)+"b", "b"); ok { + t.Error("control-char subject must be rejected") + } + if _, ok := validateMailContent("s", "a"+string(ctrl)+"b"); ok { + t.Error("control-char body must be rejected") + } + if _, ok := validateMailContent(strings.Repeat("x", 201), "b"); ok { + t.Error("subject > 200 runes must be rejected") + } + if _, ok := validateMailContent("s", strings.Repeat("x", 10001)); ok { + t.Error("body > 10000 runes must be rejected") + } + if _, ok := validateMailContent("s", "line1\nline2\twith tab"); !ok { + t.Error("a multi-line body with newline+tab must be ACCEPTED") + } +} + +// Guard parity: detail GET needs a token off-loopback; mail POST enforces the Origin CSRF allowlist. +func TestAgentDetailMail_Guarded(t *testing.T) { + const tok = "0123456789abcdef0123456789abcdef" + views := []readmodel.AgentView{{Name: "alpha", Running: true}} + + exposed := New(&fakeMutator{}, fakeAssembler{views: views}, nil, + WithBind("0.0.0.0:8080"), WithToken(tok), WithTailer(&fakeTailer{view: readmodel.TailView{Live: true}}), WithMailer(&fakeMailSender{})) + rec := httptest.NewRecorder() + exposed.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/agents/alpha/detail", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("detail off-loopback, no token: code=%d, want 401", rec.Code) + } + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agents/alpha/detail", nil) + req.Header.Set("Authorization", "Bearer "+tok) + exposed.Handler().ServeHTTP(rec, req) + if rec.Code == http.StatusUnauthorized { + t.Fatalf("detail with a valid token should be accepted, got 401") + } + + local := New(&fakeMutator{}, fakeAssembler{views: views}, nil, WithTailer(&fakeTailer{}), WithMailer(&fakeMailSender{})) + rec = httptest.NewRecorder() + bad := httptest.NewRequest(http.MethodPost, "/api/agents/alpha/mail", strings.NewReader(`{"subject":"s","body":"b"}`)) + bad.Header.Set("Origin", "http://evil.example.com") + local.Handler().ServeHTTP(rec, bad) + if rec.Code != http.StatusForbidden { + t.Fatalf("mail bad Origin: code=%d, want 403", rec.Code) + } +} + +// The CSP lands on the HTML document ONLY (never on API JSON); the detail route carries no-store. +func TestSecurityHeaders_CSPOnDocumentNotAPI(t *testing.T) { + doc := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("<!doctype html><title>x")) + }) + views := []readmodel.AgentView{{Name: "alpha", Running: true}} + s := New(&fakeMutator{}, fakeAssembler{views: views}, doc, WithTailer(&fakeTailer{view: readmodel.TailView{Live: true}})) + + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + csp := rec.Header().Get("Content-Security-Policy") + if csp == "" { + t.Error("document GET / must carry a Content-Security-Policy") + } else if !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "'unsafe-inline'") { + t.Errorf("unexpected CSP policy: %q", csp) + } + + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/agents", nil)) + if got := rec.Header().Get("Content-Security-Policy"); got != "" { + t.Errorf("API JSON must NOT carry a CSP, got %q", got) + } + + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/agents/alpha/detail", nil)) + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("detail Cache-Control = %q, want no-store", got) + } +} diff --git a/web/internal/server/lint_test.go b/web/internal/server/lint_test.go index cc736d8..b5a6a5b 100644 --- a/web/internal/server/lint_test.go +++ b/web/internal/server/lint_test.go @@ -1,6 +1,7 @@ package server import ( + "io/fs" "os" "path/filepath" "regexp" @@ -8,68 +9,161 @@ import ( "testing" ) -// This source-lint lives in the server package (not internal/exec) on purpose: it must embed -// the very patterns it forbids as fixtures, and the AC#1 grep gate -// -// ! grep -rnE 'sh -c|exec\.Command\("(sh|bash)"' internal/exec/ -// -// scans internal/exec/ recursively (including _test.go). Keeping the fixtures here keeps -// internal/exec/ clean while still running under AC#6 -// -// go test ./internal/exec/ ./internal/server/ -run 'TestRunner_InjectableFake|TestExec_NoLiveTreeMutation' +// This source-lint lives in the server package (not internal/exec) on purpose: it must embed the +// very patterns it forbids as fixtures. It scans the WHOLE web module by WalkDir (the +// extractability_test.go:30-55 precedent), replacing the former hardcoded 7-dir list that missed 5 +// of the 11 internal packages and silently skipped a typo'd/absent dir — so a NEW package can no +// longer escape the lint (design-doc L416). Three forbidden classes, each self-negatived: +// - shell-string exec (argv arrays only, never a shell) — AC#1/AC#6 +// - real mutating af invocation (mutations go only through the injectable Runner) — AC#6 +// - tmux INPUT/interaction primitive (the web tmux surface is READ-only) — AC-3 -var forbiddenShell = regexp.MustCompile(`sh -c|exec\.Command\("(sh|bash)"`) +// forbiddenShell flags a shell interpreter spawned via exec.Command / osexec.CommandContext with +// "sh"/"bash" as the program. The CommandContext spelling is included (peer review Gap 2) so the +// readmodel seam's exact shape can never smuggle a shell. +var forbiddenShell = regexp.MustCompile(`sh -c|Command(Context)?\((ctx, )?"(sh|bash)"`) -var mutatingExec = regexp.MustCompile(`exec\.Command\("af"[^)]*"(down|sling)"`) +// mutatingExec flags a REAL mutating af invocation. `mail` joins down/sling (#500): a rogue direct +// exec.Command("af","mail",…) must be caught (it would otherwise bypass the Runner seam). +var mutatingExec = regexp.MustCompile(`exec\.Command\("af"[^)]*"(down|sling|mail)"`) -func scanForbidden(t *testing.T, dir string) (shellHits, mutateHits []string) { +// forbiddenTmuxInput flags any tmux INPUT/interaction primitive. Matched in QUOTED-argv context so +// prose and identifiers ("attachment", "// attach …", sendKeys) never false-positive; the READ +// primitives the web tmux surface legitimately uses ("capture-pane", "list-sessions", +// "has-session") are deliberately absent from the set. Verified 2026-07-03: ZERO occurrences +// module-wide, so the pattern starts clean — non-vacuity is proven by the self-negative below. +var forbiddenTmuxInput = regexp.MustCompile(`"(send-keys|paste-buffer|load-buffer|set-buffer|attach-session|attach)"`) + +// isExemptFromShellLint reports whether path is the ONE file allowed to carry a shell-exec literal: +// internal/entrypoint/guard_test.go legitimately runs the shipped quickstart.sh bash guard via +// exec.Command("bash", …) to make the IFF-available launch contract CI-visible. The exemption is +// narrow — keyed on the exact path suffix — so no OTHER file inherits it (proven non-broad by the +// self-negative). It scopes ONLY the shell class; the mutating-af and tmux-input classes still apply. +func isExemptFromShellLint(path string) bool { + return strings.HasSuffix(filepath.ToSlash(path), "internal/entrypoint/guard_test.go") +} + +type offense struct { + path string + kind string +} + +const ( + kindShell = "shell-string exec" + kindMutate = "mutating af invocation" + kindTmuxInput = "tmux input primitive" +) + +// scanTree walks root and flags every .go file (INCLUDING _test.go — build tags don't exempt raw +// bytes) that carries a forbidden pattern. lint_test.go itself is skipped (it holds the fixtures); +// non-source dirs are skipped (the extractability_test.go skip switch). Taking root as a parameter +// lets the planted-file test drive a fresh temp tree through the same code path. +func scanTree(t *testing.T, root string) []offense { t.Helper() - entries, err := os.ReadDir(dir) - if err != nil { - return nil, nil // package may not exist yet; skip gracefully - } - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { - continue + var offenses []offense + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + if d.IsDir() { + switch d.Name() { + case ".gotmp", "vendor", "testdata", ".git": + return filepath.SkipDir + } + return nil } - if e.Name() == "lint_test.go" { // this file holds the fixtures; exclude it - continue + if !strings.HasSuffix(d.Name(), ".go") { + return nil } - b, err := os.ReadFile(filepath.Join(dir, e.Name())) - if err != nil { - t.Fatalf("read %s: %v", e.Name(), err) + if d.Name() == "lint_test.go" { // this file holds the fixtures; never scan it + return nil + } + b, rerr := os.ReadFile(path) + if rerr != nil { + return rerr } src := string(b) - if forbiddenShell.MatchString(src) { - shellHits = append(shellHits, filepath.Join(dir, e.Name())) + if forbiddenShell.MatchString(src) && !isExemptFromShellLint(path) { + offenses = append(offenses, offense{path, kindShell}) } if mutatingExec.MatchString(src) { - mutateHits = append(mutateHits, filepath.Join(dir, e.Name())) + offenses = append(offenses, offense{path, kindMutate}) + } + if forbiddenTmuxInput.MatchString(src) { + offenses = append(offenses, offense{path, kindTmuxInput}) } + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) } - return shellHits, mutateHits + return offenses } +func moduleRoot() string { return filepath.Join("..", "..") } // web/internal/server -> web + // AC1/AC6 — no shell-string exec, and no real live-tree mutation, anywhere in the web module // source. Mutations go only through the injectable Runner/fake. func TestExec_NoLiveTreeMutation(t *testing.T) { - moduleRoot := filepath.Join("..", "..") // web/internal/server -> web - dirs := []string{ - filepath.Join(moduleRoot, "internal", "exec"), - filepath.Join(moduleRoot, "internal", "server"), - filepath.Join(moduleRoot, "internal", "readmodel"), - filepath.Join(moduleRoot, "internal", "web"), - filepath.Join(moduleRoot, "internal", "proto"), // C7: read-only static serving, no exec - filepath.Join(moduleRoot, "internal", "feedback"), // C6: file write + read-model, no exec - filepath.Join(moduleRoot, "cmd", "afweb"), + for _, o := range scanTree(t, moduleRoot()) { + switch o.kind { + case kindShell: + t.Errorf("shell-string exec found in %s — use argv arrays only (never a shell)", o.path) + case kindMutate: + t.Errorf("real mutating af invocation found in %s — mutations must go through the Runner seam", o.path) + } + } +} + +// TestLint_NoSessionInputPrimitives — AC-3's read-only invariant as a CI property: no tmux INPUT +// primitive may appear anywhere in the web tmux surface. The web console reads panes; it never +// types into them. +func TestLint_NoSessionInputPrimitives(t *testing.T) { + for _, o := range scanTree(t, moduleRoot()) { + if o.kind == kindTmuxInput { + t.Errorf("tmux INPUT primitive found in %s — the web tmux surface is READ-ONLY (AC-3): no send-keys/paste-buffer/load-buffer/set-buffer/attach", o.path) + } } - for _, d := range dirs { - shellHits, mutateHits := scanForbidden(t, d) - if len(shellHits) > 0 { - t.Errorf("shell-string exec found in %v — use argv arrays only (never a shell)", shellHits) +} + +// TestLint_WalkDetectsNewDir — design-doc L416: the module WalkDir (not a hardcoded dir list) +// detects a planted forbidden file in a NEW, never-listed dir. Hermetic: a temp tree with a nested +// package the lint has no knowledge of. +func TestLint_WalkDetectsNewDir(t *testing.T) { + root := t.TempDir() + newpkg := filepath.Join(root, "internal", "totally", "newpkg") + if err := os.MkdirAll(newpkg, 0o755); err != nil { + t.Fatal(err) + } + // Assembled from fragments so THIS file's raw bytes never carry the contiguous forbidden literal. + planted := "package newpkg\n\nfunc x() { _ = " + `exec.Command(` + `"sh", "-c", "boom") }` + "\n" + if err := os.WriteFile(filepath.Join(newpkg, "rogue.go"), []byte(planted), 0o644); err != nil { + t.Fatal(err) + } + found := false + for _, o := range scanTree(t, root) { + if o.kind == kindShell { + found = true } - if len(mutateHits) > 0 { - t.Errorf("real mutating af invocation found in %v — mutations must go through the Runner seam", mutateHits) + } + if !found { + t.Error("module walk failed to detect a planted shell-exec in a NEW dir — a new package could silently escape the lint") + } +} + +// TestLint_EntrypointExemptionIsNarrow proves the shell-lint exemption is scoped to exactly +// entrypoint/guard_test.go and does not leak to any sibling (e.g. a same-basename file elsewhere). +func TestLint_EntrypointExemptionIsNarrow(t *testing.T) { + if !isExemptFromShellLint(filepath.FromSlash("web/internal/entrypoint/guard_test.go")) { + t.Error("the legitimate entrypoint/guard_test.go bash-guard test must be exempt") + } + for _, p := range []string{ + filepath.FromSlash("web/internal/server/guard_test.go"), + filepath.FromSlash("web/internal/entrypoint/other_test.go"), + filepath.FromSlash("web/cmd/afweb/main.go"), + } { + if isExemptFromShellLint(p) { + t.Errorf("exemption wrongly leaked to %q — it must be narrow", p) } } } @@ -82,6 +176,7 @@ func TestExec_NoLiveTreeMutation_SelfNegative(t *testing.T) { shDashC, "bash -" + "c " + "script", `exec.Command(` + `"sh", "-c", payload)`, + `osexec.CommandContext(` + `ctx, "bash", "-c", payload)`, // CommandContext spelling (Gap 2) } for _, s := range mustFlagShell { if !forbiddenShell.MatchString(s) { @@ -91,6 +186,7 @@ func TestExec_NoLiveTreeMutation_SelfNegative(t *testing.T) { mustFlagMutate := []string{ `exec.Command(` + `"af", "down", name)`, `exec.Command(` + `"af", "sling", "--agent", name)`, + `exec.Command(` + `"af", "mail", "send", name)`, // #500: mail must be caught } for _, s := range mustFlagMutate { if !mutatingExec.MatchString(s) { @@ -101,6 +197,7 @@ func TestExec_NoLiveTreeMutation_SelfNegative(t *testing.T) { `exec.Command(` + `"af", "agents", "list", "--json")`, `afArgv("down", name)`, `cmd.Stdout = &stdout`, + `osexec.CommandContext(` + `ctx, "tmux", "list-sessions")`, // a tmux READ is not a shell } for _, s := range mustNotFlag { if forbiddenShell.MatchString(s) { @@ -111,3 +208,34 @@ func TestExec_NoLiveTreeMutation_SelfNegative(t *testing.T) { } } } + +// TestLint_NoSessionInputPrimitives_SelfNegative proves forbiddenTmuxInput matches every input +// primitive (in quoted-argv context) and never false-positives on the READ primitives or prose. +func TestLint_NoSessionInputPrimitives_SelfNegative(t *testing.T) { + mustFlag := []string{ + `execCommand(ctx, "tmux", ` + `"send-` + `keys", "-t", s)`, + `"paste-` + `buffer"`, + `"load-` + `buffer"`, + `"set-` + `buffer"`, + `"` + `attach"`, + `"attach-` + `session"`, + } + for _, s := range mustFlag { + if !forbiddenTmuxInput.MatchString(s) { + t.Errorf("forbiddenTmuxInput failed to flag %q", s) + } + } + mustNotFlag := []string{ + `"capture-pane"`, // the web surface's actual snapshot read + `"list-sessions"`, // the liveness/probe read + `"has-session"`, // the exact-match membership probe + `attachment := true`, // prose/identifier — no quotes around a primitive + `// attach to the flow`, // comment + `var sendKeys = false`, // camelCase identifier, no hyphen, no quotes + } + for _, s := range mustNotFlag { + if forbiddenTmuxInput.MatchString(s) { + t.Errorf("forbiddenTmuxInput false-positive on %q", s) + } + } +} diff --git a/web/internal/server/server.go b/web/internal/server/server.go index 430914a..44523bd 100644 --- a/web/internal/server/server.go +++ b/web/internal/server/server.go @@ -1,18 +1,20 @@ -// Package server is the C1 loopback HTTP server for the web module. +// Package server is the loopback HTTP server for the web module. // // It binds 127.0.0.1:0 (ephemeral, stdlib net/http + Go 1.22 ServeMux), answers a uniform -// {ok, message, data} JSON envelope on every response, and exposes ONLY the Phase-1 -// allowlisted verbs through the exec wrapper — there is no generic command passthrough (C-6). +// {ok, message, data} JSON envelope on every response, and exposes ONLY the allowlisted verbs +// through the exec wrapper — there is no generic command passthrough. // // Two security gates on every state-changing request: // - CSRF: an Origin/Host allowlist (loopback only) is enforced on every POST/PUT. // - Auth: a session token (crypto/rand mint, crypto/subtle constant-time verify) is -// MANDATORY whenever the effective bind is not pure loopback (design-doc CR-1, which -// overrides security.md Decision 1's loopback-no-auth recommendation). On pure loopback -// the token is optional but the Origin check still applies on POST. +// MANDATORY whenever the effective bind is not pure loopback — pure loopback matches the +// established same-machine trust model (any local process running as the same user can +// connect), but the moment an operator widens the bind beyond loopback that trust model no +// longer holds, so a token is required. On pure loopback the token is optional but the +// Origin check still applies on POST. // // Destructive ops (--reset) require confirm:true in the request body, refused otherwise — a -// server-side guard, not merely a client affordance (Security Decision 5). +// server-side guard, not merely a client affordance. package server import ( @@ -26,7 +28,9 @@ import ( "net" "net/http" "net/url" + "strconv" "strings" + "unicode/utf8" "github.com/stempeck/agentfactory-web/internal/config" "github.com/stempeck/agentfactory-web/internal/dispatch" @@ -43,7 +47,7 @@ type Mutator interface { DownFactory(ctx context.Context, reset bool) (exec.Result, error) DownAgent(ctx context.Context, name string, reset bool) (exec.Result, error) // Sling carries the operator's task as the af-sling positional argument (after a `--` - // terminator) plus the remaining user-providable fields as vars (#440 K1). + // terminator) plus the remaining user-providable fields as vars (#440). Sling(ctx context.Context, name, task string, vars map[string]string) (exec.Result, error) } @@ -52,18 +56,18 @@ type Assembler interface { Assemble(ctx context.Context) ([]readmodel.AgentView, error) } -// FormReader yields the user-providable form schema for a formula by name (C4). +// FormReader yields the user-providable form schema for a formula by name. // formschema.Reader satisfies it. type FormReader interface { Read(ctx context.Context, formula string) (formschema.Schema, error) } -// DispatchReader yields the read-only dispatch status view (C3). dispatch.Reader satisfies it. +// DispatchReader yields the read-only dispatch status view. dispatch.Reader satisfies it. type DispatchReader interface { Status(ctx context.Context) (dispatch.View, error) } -// SettingsService is the curated config read + the af-routed write (C5). config.Service satisfies +// SettingsService is the curated config read + the af-routed write. config.Service satisfies // it: Read returns the secret-stripped settings, Write routes the edited config through // `af config set` (atomic + cross-file validated inside af-core). type SettingsService interface { @@ -79,14 +83,14 @@ type FormulaResolver interface { AgentFormula(ctx context.Context, name string) (formula string, found bool, err error) } -// Prototypes is the C7 static/prototype server: it enumerates the servable prototype dirs and +// Prototypes is the static/prototype server: it enumerates the servable prototype dirs and // serves their on-disk assets, traversal-contained. proto.Server satisfies it. type Prototypes interface { List() []proto.Prototype http.Handler // ServeHTTP serves GET /proto/{id}/{asset...} } -// Feedback is the C6 gate-aware feedback writer (H-5). It writes feedback-form.md ONLY when the +// Feedback is the gate-aware feedback writer. It writes feedback-form.md ONLY when the // owning agent is verified parked at the matching design-feedback-{N} gate. feedback.Writer // satisfies it. OpenFrom is the pure (no-exec) predicate used to annotate GET /api/prototypes from a // single already-fetched view set. @@ -95,6 +99,21 @@ type Feedback interface { OpenFrom(views []readmodel.AgentView, id string) bool } +// Tailer is the honest per-agent session-snapshot reader (#500). readmodel.TmuxCapture +// satisfies it via Tail. It is probe-first and never reports a false live; an absent session is +// an honest zero TailView, not an error. The detail handler passes tail output through WITHOUT +// logging it (sessions print secrets). +type Tailer interface { + Tail(ctx context.Context, name string, lines int) (readmodel.TailView, error) +} + +// MailSender is the operator-mail write surface (#500). exec.Wrapper satisfies it via MailSend, +// which pins the sender to `operator`, fixes the subcommand to `send`, and bypasses the +// dispatched-marker pre-flight (mail's primary recipients ARE dispatched agents). +type MailSender interface { + MailSend(ctx context.Context, name, subject, body string) (exec.Result, error) +} + // Envelope is the uniform response shape on every endpoint. type Envelope struct { OK bool `json:"ok"` @@ -106,15 +125,18 @@ type Envelope struct { type Server struct { mut Mutator reader Assembler - form FormReader // C4 form-schema reader (nil ⇒ the /form and /sling routes 500) + form FormReader // form-schema reader (nil ⇒ the /form and /sling routes 500) formula FormulaResolver // #455 static-config (agents.json) formula resolver (nil ⇒ /form and /sling routes 500) - dispatch DispatchReader // C3 dispatch reader (nil ⇒ GET /api/dispatch 500) - settings SettingsService // C5 settings read/write (nil ⇒ /api/settings routes 500) - proto Prototypes // C7 prototype server (nil ⇒ the /api/prototypes and /proto routes are unregistered) - feedback Feedback // C6 feedback writer (nil ⇒ the feedback route is unregistered) + dispatch DispatchReader // dispatch reader (nil ⇒ GET /api/dispatch 500) + settings SettingsService // settings read/write (nil ⇒ /api/settings routes 500) + proto Prototypes // prototype server (nil ⇒ the /api/prototypes and /proto routes are unregistered) + feedback Feedback // feedback writer (nil ⇒ the feedback route is unregistered) + tailer Tailer // #500 session-snapshot reader (nil ⇒ the agent-detail route 500s) + mailer MailSender // #500 operator-mail sender (nil ⇒ the agent-mail route 500s) static http.Handler - root string // K5: the resolved factory root this console serves; surfaced via GET /healthz (Gap 7) + root string // the resolved factory root this console serves; surfaced via GET /healthz so a + // wrong-but-valid root is visible, not silent bindAddr string loopback bool // true ⇒ token optional (Origin still enforced on POST) @@ -139,17 +161,17 @@ func WithToken(tok string) Option { return func(s *Server) { s.token = tok } } -// WithFormReader wires the C4 form-schema reader used by the /form and /sling routes. +// WithFormReader wires the form-schema reader used by the /form and /sling routes. func WithFormReader(fr FormReader) Option { return func(s *Server) { s.form = fr } } -// WithDispatchReader wires the C3 dispatch reader used by GET /api/dispatch. +// WithDispatchReader wires the dispatch reader used by GET /api/dispatch. func WithDispatchReader(dr DispatchReader) Option { return func(s *Server) { s.dispatch = dr } } -// WithSettings wires the C5 settings service used by GET /api/settings and PUT /api/settings/{file}. +// WithSettings wires the settings service used by GET /api/settings and PUT /api/settings/{file}. func WithSettings(ss SettingsService) Option { return func(s *Server) { s.settings = ss } } @@ -160,19 +182,29 @@ func WithFormulaResolver(fr FormulaResolver) Option { return func(s *Server) { s.formula = fr } } -// WithPrototypes wires the C7 prototype server used by GET /api/prototypes and GET /proto/{id}/... +// WithPrototypes wires the prototype server used by GET /api/prototypes and GET /proto/{id}/... func WithPrototypes(p Prototypes) Option { return func(s *Server) { s.proto = p } } -// WithFeedback wires the C6 gate-aware feedback writer used by POST /api/prototypes/{id}/feedback. +// WithFeedback wires the gate-aware feedback writer used by POST /api/prototypes/{id}/feedback. func WithFeedback(f Feedback) Option { return func(s *Server) { s.feedback = f } } +// WithTailer wires the #500 session-snapshot reader used by the agent-detail route. +func WithTailer(t Tailer) Option { + return func(s *Server) { s.tailer = t } +} + +// WithMailer wires the #500 operator-mail sender used by the agent-mail route. +func WithMailer(m MailSender) Option { + return func(s *Server) { s.mailer = m } +} + // WithRoot records the resolved factory root this console serves. It is surfaced via GET /healthz -// (K5 / Gap 7) so an operator — or an automated probe — can see WHICH factory the console resolved -// to, making a wrong-but-valid root visible rather than silent. +// so an operator — or an automated probe — can see WHICH factory the console resolved to, making +// a wrong-but-valid root visible rather than silent. func WithRoot(root string) Option { return func(s *Server) { s.root = root } } @@ -211,24 +243,45 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/factory/down", s.handleFactoryDown) s.mux.HandleFunc("POST /api/agents/{name}/down", s.handleAgentDown) s.mux.HandleFunc("POST /api/agents/{name}/sling", s.handleAgentSling) + s.mux.HandleFunc("GET /api/agents/{name}/detail", s.handleAgentDetail) + s.mux.HandleFunc("POST /api/agents/{name}/mail", s.handleAgentMail) s.mux.HandleFunc("GET /api/dispatch", s.handleDispatch) s.mux.HandleFunc("GET /api/settings", s.handleSettings) s.mux.HandleFunc("PUT /api/settings/{file}", s.handleSettingsWrite) if s.proto != nil { - // C7: enumeration (read) + traversal-contained on-disk static serving. The static subtree + // Enumeration (read) + traversal-contained on-disk static serving. The static subtree // is mounted under StripPrefix so the proto handler receives "{id}/{asset}". s.mux.HandleFunc("GET /api/prototypes", s.handlePrototypes) s.mux.Handle("GET /proto/", http.StripPrefix("/proto/", s.proto)) } if s.feedback != nil { - // C6: state-changing, gate-verified feedback write (H-5). + // state-changing, gate-verified feedback write. s.mux.HandleFunc("POST /api/prototypes/{id}/feedback", s.handleFeedback) } if s.static != nil { - s.mux.Handle("/", s.static) + // The CSP lands on the served HTML document ONLY — never on the API JSON responses + // (write() stays header-clean). The /proto/ mount is deliberately NOT wrapped: prototype + // HTML is operator-authored local content already neutralised by the iframe `sandbox` + // attribute — a recorded exclusion, not an oversight. + s.mux.Handle("/", withDocumentCSP(s.static)) } } +// cspPolicy is the restrictive document Content-Security-Policy. The 'unsafe-inline' in +// style-src is LOAD-BEARING: index.html carries inline style="" attributes. The Google-Fonts +// @import in variables.css is intentionally left to fall back to the declared offline stacks +// rather than allowlisting a font CDN, matching variables.css's own offline-fallback comment. +const cspPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" + +// withDocumentCSP wraps the HTML document handler so the CSP header is set on document responses +// (set before the wrapped handler writes its status, so it sticks). +func withDocumentCSP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", cspPolicy) + next.ServeHTTP(w, r) + }) +} + // Listen binds the configured loopback address (127.0.0.1:0 by default), starts serving in a // goroutine, and returns the listener so callers can read the ephemeral Addr(). func (s *Server) Listen() (net.Listener, error) { @@ -318,7 +371,7 @@ func hostnameIsLoopback(host string) bool { // answer even when the bind is non-loopback so the loopback probe never blocks on auth — mirroring // mcpstore's unauthenticated /health (lifecycle.go:268-277). // -// K5/Gap 7: it also carries the resolved factory root under data so an operator (or an automated +// It also carries the resolved factory root under data so an operator (or an automated // check) can see WHICH factory the console resolved to. The root sits under Data (json:"data,omitempty") // so the bare {ok:true} liveness contract is preserved — rendezvous.healthCheck checks ONLY the 200 // status, never the body, so surfacing the root cannot break the liveness probe. @@ -339,7 +392,7 @@ func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) { } // handleDispatch (read) returns the dispatcher's running state and the dispatched issues/PRs from -// `af dispatch status --json` (AC-4). af-core already computes dispatcher + per-agent liveness, so +// `af dispatch status --json`. af-core already computes dispatcher + per-agent liveness, so // the view surfaces those directly. A read failure (e.g. the {"state":"error"} envelope) is a 502. func (s *Server) handleDispatch(w http.ResponseWriter, r *http.Request) { if !s.guard(w, r, false) { @@ -358,7 +411,7 @@ func (s *Server) handleDispatch(w http.ResponseWriter, r *http.Request) { } // handleSettings (read) returns the curated settings: editable dispatch.json/startup.json, the -// read-only factory.json, and the secret-free agent roster (AC-6). Per-agent secrets +// read-only factory.json, and the secret-free agent roster. Per-agent secrets // (Model/BaseURL/AuthToken) are stripped by construction in the config package, never here. func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { if !s.guard(w, r, false) { @@ -378,8 +431,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { // handleSettingsWrite (state-changing) persists an edited config file. {file} ∈ {dispatch,startup} // (factory.json is read-only). The raw request body IS the complete edited config document — it is -// fed straight to `af config set` on stdin (H-P1: the web module never decodes it into a -// typed struct nor re-implements validation). af-core validates (struct + cross-file) and writes +// fed straight to `af config set` on stdin: the web module never decodes it into a +// typed struct nor re-implements validation. af-core validates (struct + cross-file) and writes // atomically; on a non-zero exit its friendly per-field message is surfaced as the validation error. func (s *Server) handleSettingsWrite(w http.ResponseWriter, r *http.Request) { if !s.guard(w, r, true) { @@ -467,7 +520,7 @@ func decodeDownBody(r *http.Request) downBody { // handleAgentForm (read) returns the user-providable form schema for an idle agent's configured // formula. It resolves {name} → formula via the agent's DECLARED agents.json config (#455), then -// the C4 reader filters out the auto-sourced (identity-bearing) vars (INV-2) and orders required-first. +// the form reader filters out the auto-sourced (identity-bearing) vars and orders required-first. func (s *Server) handleAgentForm(w http.ResponseWriter, r *http.Request) { if !s.guard(w, r, false) { return @@ -487,10 +540,10 @@ func (s *Server) handleAgentForm(w http.ResponseWriter, r *http.Request) { // handleAgentSling (state-changing) dispatches a task to an agent, emitting the byte-identical // `af sling --agent --reset --var k=v … -- ` argv (one --var per field, the task as -// the positional after a `--` terminator) through C2. The body is the structured {task, vars} -// shape: `task` is the operator's primary text (value-validated in the wrapper, never key-checked), -// and every `vars` key is validated against the formula's user-providable field set first — an -// unknown vars key (e.g. an auto-sourced var) is refused with 400 and Sling is never invoked (INV-2). +// the positional after a `--` terminator) through the exec wrapper. The body is the structured +// {task, vars} shape: `task` is the operator's primary text (value-validated in the wrapper, never +// key-checked), and every `vars` key is validated against the formula's user-providable field set +// first — an unknown vars key (e.g. an auto-sourced var) is refused with 400 and Sling is never invoked. func (s *Server) handleAgentSling(w http.ResponseWriter, r *http.Request) { if !s.guard(w, r, true) { return @@ -510,7 +563,7 @@ func (s *Server) handleAgentSling(w http.ResponseWriter, r *http.Request) { s.write(w, http.StatusBadGateway, Envelope{OK: false, Message: err.Error()}) return } - // INV-2: reject any VARS key that is not a user-providable field of this formula. Written as a + // Reject any VARS key that is not a user-providable field of this formula. Written as a // direct 400 (writeMutation's isValidationErr would not recognise this message and would // mis-map it to 502). The task is the positional argument — value-validated by validateTask in // the wrapper, NEVER key-checked here (it does not appear in schema.FieldNames()). @@ -533,7 +586,7 @@ func (s *Server) handleAgentSling(w http.ResponseWriter, r *http.Request) { // envelope (500 if no form reader / no resolver is configured, 502 on a read failure, 404 if the // agent is unknown, 422 if it has no configured formula) and returns ok=false in those cases. func (s *Server) resolveFormula(ctx context.Context, w http.ResponseWriter, name string) (string, bool) { - if s.form == nil { // keep — unchanged + if s.form == nil { s.write(w, http.StatusInternalServerError, Envelope{OK: false, Message: "form reader not configured"}) return "", false } @@ -557,9 +610,195 @@ func (s *Server) resolveFormula(ctx context.Context, w http.ResponseWriter, name return formula, true } +// DetailView is the per-agent detail payload (#500). Agent embeds readmodel.AgentView VERBATIM +// (twelve snake_case keys) — never a fork (#455). DeclaredFormula is the agents.json formula, +// carried as a SEPARATE, distinctly-labelled field from Agent.Formula (the RUNNING formula). Tail +// is the honest session snapshot. +type DetailView struct { + Agent readmodel.AgentView `json:"agent"` + DeclaredFormula string `json:"declared_formula"` + Tail readmodel.TailView `json:"tail"` +} + +// handleAgentDetail (read) returns the honest per-agent detail projection: the read-model +// AgentView, the DECLARED (agents.json) formula, and a read-only tmux session snapshot. Membership +// is the read-model's decision (unknown ⇒ 404); the FormulaResolver only ANNOTATES declared_formula +// and never 404s (a race-window unresolved formula is carried honestly as ""). The tail output is +// passed through WITHOUT being logged anywhere (sessions print secrets), and the +// response is Cache-Control: no-store, since the snapshot is secret-bearing. +func (s *Server) handleAgentDetail(w http.ResponseWriter, r *http.Request) { + if !s.guard(w, r, false) { + return + } + if s.tailer == nil { + s.write(w, http.StatusInternalServerError, Envelope{OK: false, Message: "tailer not configured"}) + return + } + name := r.PathValue("name") + views, err := s.reader.Assemble(r.Context()) + if err != nil { + s.write(w, http.StatusBadGateway, Envelope{OK: false, Message: err.Error()}) + return + } + agent, found := findAgentView(views, name) + if !found { + s.write(w, http.StatusNotFound, Envelope{OK: false, Message: "agent " + name + " not found"}) + return + } + + // declared_formula annotates from static config; a nil resolver, an unknown agent, or an empty + // formula all carry "" honestly (the 404 decision already belongs to read-model membership above). + declared := "" + if s.formula != nil { + if f, ok, ferr := s.formula.AgentFormula(r.Context(), name); ferr == nil && ok { + declared = f + } + } + + lines := clampLines(r.URL.Query().Get("lines")) + tail, err := s.tailer.Tail(r.Context(), name, lines) + if err != nil { + s.write(w, http.StatusBadGateway, Envelope{OK: false, Message: err.Error()}) + return + } + + w.Header().Set("Cache-Control", "no-store") + s.write(w, http.StatusOK, Envelope{OK: true, Data: DetailView{Agent: agent, DeclaredFormula: declared, Tail: tail}}) +} + +// handleAgentMail (state-changing) queues operator mail to an agent's mailbox. Flow: +// guard → nil-seam 500 → decode → name shape/membership 404 → content validation (direct 400 with +// the friendly copy) → MailSend (502 on failure) → 200 with the honest async copy. Content +// validation lives HERE (not only in the wrapper) because a fake MailSender never rejects, and +// writeMutation/isValidationErr would mis-map these messages to 502. +func (s *Server) handleAgentMail(w http.ResponseWriter, r *http.Request) { + if !s.guard(w, r, true) { + return + } + if s.mailer == nil { + s.write(w, http.StatusInternalServerError, Envelope{OK: false, Message: "mail sender not configured"}) + return + } + name := r.PathValue("name") + subject, body, ok := decodeMailBody(r) + if !ok { + s.write(w, http.StatusBadRequest, Envelope{OK: false, Message: `invalid mail body: expected {"subject":"…","body":"…"}`}) + return + } + + // Name shape + read-model membership both resolve to the same honest 404 (an invalid name can + // never be a member). Trimming mirrors Wrapper.MailSend's trimAgent so the check agrees with exec. + trimmed := strings.TrimRight(strings.TrimSpace(name), "/") + if verr := exec.ValidateAgentName(trimmed); verr != nil { + s.write(w, http.StatusNotFound, Envelope{OK: false, Message: "agent " + name + " not found"}) + return + } + views, err := s.reader.Assemble(r.Context()) + if err != nil { + s.write(w, http.StatusBadGateway, Envelope{OK: false, Message: err.Error()}) + return + } + if _, member := findAgentView(views, trimmed); !member { + s.write(w, http.StatusNotFound, Envelope{OK: false, Message: "agent " + name + " not found"}) + return + } + + if msg, valid := validateMailContent(subject, body); !valid { + s.write(w, http.StatusBadRequest, Envelope{OK: false, Message: msg}) + return + } + + res, err := s.mailer.MailSend(r.Context(), trimmed, subject, body) + if err != nil { + s.write(w, http.StatusBadGateway, Envelope{OK: false, Message: err.Error()}) + return + } + s.write(w, http.StatusOK, Envelope{OK: true, Message: "Mail queued for " + trimmed + " — delivery is asynchronous", Data: map[string]int{"exit_code": res.ExitCode}}) +} + +// findAgentView returns the AgentView whose Name matches name (exact), and whether it was found. +// It is the read-model membership oracle shared by the detail and mail handlers. +func findAgentView(views []readmodel.AgentView, name string) (readmodel.AgentView, bool) { + for _, v := range views { + if v.Name == name { + return v, true + } + } + return readmodel.AgentView{}, false +} + +// clampLines maps the ?lines= query to a snapshot line count. It NEVER errors: +// absent/non-numeric ⇒ the default 120; out-of-range ⇒ clamped to [1,500]. The clamped value is +// what the handler passes to Tail and what the response's tail.lines reports. +func clampLines(raw string) int { + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return 120 + } + if n < 1 { + return 1 + } + if n > 500 { + return 500 + } + return n +} + +// mailBody is the composer POST shape. +type mailBody struct { + Subject string `json:"subject"` + Body string `json:"body"` +} + +// decodeMailBody decodes {subject, body}. A missing/empty body decodes to empty strings (the +// content validation then rejects them as required); a malformed body ⇒ ok=false for a 400. +func decodeMailBody(r *http.Request) (subject, body string, ok bool) { + if r.Body == nil { + return "", "", true + } + var b mailBody + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + if errors.Is(err, io.EOF) { + return "", "", true + } + return "", "", false + } + return b.Subject, b.Body, true +} + +// validateMailContent applies friendly error copy and the ≤200/≤10000 rune caps at the handler +// level. It is at least as strict as Wrapper.MailSend's +// pre-exec validation, so anything it accepts the wrapper also accepts (no content path can slip to +// a 502). Subject: single-line, no C0 controls except tab. Body: multi-line (newline + tab allowed). +func validateMailContent(subject, body string) (msg string, valid bool) { + if strings.TrimSpace(subject) == "" { + return "subject is required", false + } + if utf8.RuneCountInString(subject) > 200 { + return "subject too long (max 200 characters)", false + } + for _, r := range subject { + if r == '\n' || r == '\r' || (r < 0x20 && r != '\t') || r == 0x7f { + return "subject cannot contain control characters", false + } + } + if strings.TrimSpace(body) == "" { + return "message body is required", false + } + if utf8.RuneCountInString(body) > 10000 { + return "message body too long (max 10000 characters)", false + } + for _, r := range body { + if r == '\r' || (r < 0x20 && r != '\t' && r != '\n') || r == 0x7f { + return "message body cannot contain control characters other than newline and tab", false + } + } + return "", true +} + // slingBody is the structured POST body of a sling request: the operator's positional task plus the // remaining user-providable fields as vars. `task` binds to the formula's effective field -// (Schema.Primary) as the af-sling positional; each `vars` entry travels as one --var (#440 K1). +// (Schema.Primary) as the af-sling positional; each `vars` entry travels as one --var (#440). type slingBody struct { Task string `json:"task"` Vars map[string]string `json:"vars"` @@ -585,7 +824,7 @@ func decodeSlingBody(r *http.Request) (task string, vars map[string]string, ok b return body.Task, body.Vars, true } -// handlePrototypes (read) returns the enumerated, servable prototype dirs (AC-5). Each entry is +// handlePrototypes (read) returns the enumerated, servable prototype dirs. Each entry is // annotated with feedback_open — whether the owning agent is verified parked at the matching gate — // so the UI can disable the feedback panel honestly when feedback is not currently open. Enumeration // is graceful: no .designs/ yet ⇒ an empty list, never a 502. @@ -610,10 +849,10 @@ func (s *Server) handlePrototypes(w http.ResponseWriter, r *http.Request) { } // handleFeedback (state-changing) writes the prototype's feedback-form.md — but ONLY when the owning -// agent is verified parked at the matching design-feedback-{N} gate (C6 / H-5). An off-gate (or +// agent is verified parked at the matching design-feedback-{N} gate. An off-gate (or // no-such-agent) submission returns ok:false with the honest "feedback not currently open" message // and writes nothing; a transport failure reading gate state is a 502. The form is the SOLE -// AUTHORITY that releases the gate (data.md Decision 3) — there is no alternate feedback channel. +// AUTHORITY that releases the gate — there is no alternate feedback channel. func (s *Server) handleFeedback(w http.ResponseWriter, r *http.Request) { if !s.guard(w, r, true) { return diff --git a/web/internal/web/agentdetail_layout_test.go b/web/internal/web/agentdetail_layout_test.go new file mode 100644 index 0000000..596a184 --- /dev/null +++ b/web/internal/web/agentdetail_layout_test.go @@ -0,0 +1,274 @@ +package web + +import ( + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// Issue af-f4f082bc / #511: the per-agent "View" page rendered the read-only Session Snapshot small +// and left-pinned because #view-agent reused the asymmetric .cols left track (~360px) and the +// Settings-sized shared .readonly-block (240px, 12px muted). The design contract and tests pinned the +// snapshot's HONESTY/BEHAVIOR but never its size/width/prominence, so a cramped-but-correct layout +// shipped and passed CI invisibly. +// +// The web module is pure-Go with no JS/DOM/visual runner (see agentdetail_test.go:10-14), so — matching +// that source-scan precedent — these are SOURCE-LEVEL structural assertions over the embedded static +// assets that raise the snapshot's PRESENTATION from Advisory to Guard. Every assertion carries a +// self-negative proving the check is not vacuous. + +// (1) A dedicated rule (#agent-snapshot or .snapshot-block) sizes the snapshot; width/height are +// judged in Go from the captured rule body. `[^{}]` keeps a match inside one rule (no brace crossing). +var reSnapshotSizingRule = regexp.MustCompile(`(?:#agent-snapshot|\.snapshot-block)\b[^{}]*\{([^{}]*)\}`) + +// width:100% only — anchored so `max-width:100%` cannot satisfy it. +var reFullWidthDecl = regexp.MustCompile(`(?:^|;)\s*width\s*:\s*100%`) + +// A real height (min-height/height) in vh or px — the leading boundary excludes `max-height`. +var reHeightDecl = regexp.MustCompile(`(?:^|[;{\s])(?:min-)?height\s*:\s*([0-9.]+)\s*(vh|px)`) + +// (2) index.html wraps #agent-snapshot in a dedicated full-width snapshot-panel, not the .cols left +// track. Proximity keeps the match tied to the pane grab, not its -label/-note/-captured siblings. +var reSnapshotInPanel = regexp.MustCompile(`snapshot-panel[\s\S]{0,600}?id="agent-snapshot"`) + +// (3) the #agent-snapshot
 open tag — proves it dropped the shared .readonly-block for .snapshot-block.
+var reSnapshotPreTag = regexp.MustCompile(`]*id="agent-snapshot"[^>]*>`)
+
+// (3b / note a) the shared .readonly-block rule survives untouched for Settings' #set-factory.
+var reReadonlyBlockRule = regexp.MustCompile(`\.readonly-block\b[^{}]*\{[^{}]*\}`)
+
+// (4) a scroll-to-bottom assignment (scrollTop <- scrollHeight). Tied to renderAgentSnapshot by
+// extracting that function's body (funcBody) so a scroll elsewhere cannot satisfy it — RE2 forbids
+// the >1000 bounded window a single regex would need.
+var reScrollAssign = regexp.MustCompile(`scrollTop\s*=\s*[^;]*scrollHeight`)
+
+// (facts) legible label/value pairs — a CSS rule (with a `{`) for the .facts/.fact/.fk/.fv family.
+var reFactsFamilyRule = regexp.MustCompile(`\.(?:facts|fact|fk|fv)\b[^{}]*\{`)
+
+// funcBody returns the brace-matched body (including the outer braces) of `function ` — used to
+// tie an assertion to one function without a >1000 bounded regex window (RE2's cap).
+func funcBody(src, name string) string {
+	idx := strings.Index(src, "function "+name)
+	if idx < 0 {
+		return ""
+	}
+	rest := src[idx:]
+	open := strings.IndexByte(rest, '{')
+	if open < 0 {
+		return ""
+	}
+	depth := 0
+	for i := open; i < len(rest); i++ {
+		switch rest[i] {
+		case '{':
+			depth++
+		case '}':
+			depth--
+			if depth == 0 {
+				return rest[open : i+1]
+			}
+		}
+	}
+	return rest[open:]
+}
+
+// snapshotSizing reports whether a dedicated snapshot rule exists and whether the union of such rules
+// declares full width and a height taller than the retired 240px cap (vh is inherently taller on any
+// real viewport; a px height must exceed 240).
+func snapshotSizing(css string) (found, fullWidth, tall bool) {
+	rules := reSnapshotSizingRule.FindAllStringSubmatch(css, -1)
+	if len(rules) == 0 {
+		return false, false, false
+	}
+	found = true
+	for _, m := range rules {
+		body := m[1]
+		if reFullWidthDecl.MatchString(body) {
+			fullWidth = true
+		}
+		for _, h := range reHeightDecl.FindAllStringSubmatch(body, -1) {
+			if h[2] == "vh" {
+				tall = true
+			} else if v, err := strconv.ParseFloat(h[1], 64); err == nil && v > 240 {
+				tall = true
+			}
+		}
+	}
+	return found, fullWidth, tall
+}
+
+// Scenario: Snapshot has its own dedicated sizing rule that is full-width and taller than the old 240px cap.
+func TestSnapshot_DedicatedFullWidthTallSizingRule(t *testing.T) {
+	mainCSS := readAsset(t, filepath.Join(staticDir, "styles", "main.css"))
+	found, fullWidth, tall := snapshotSizing(mainCSS)
+	if !found {
+		t.Error("main.css: a rule targeting #agent-snapshot (or a dedicated .snapshot-block) is required so the snapshot is sized as the page's primary element, not by the Settings-sized .readonly-block")
+	}
+	if !fullWidth {
+		t.Error("main.css: the snapshot sizing rule must declare width:100% (full content width), not the narrow ~360px .cols left track")
+	}
+	if !tall {
+		t.Error("main.css: the snapshot sizing rule must declare a height taller than the retired 240px cap (e.g. a vh height), so the newest output is not below a 240px fold")
+	}
+}
+
+func TestSnapshot_DedicatedFullWidthTallSizingRule_SelfNegative(t *testing.T) {
+	good := `.app .snapshot-block{ background:var(--panel-2); width:100%; min-height:60vh; font-size:13px; }`
+	if f, w, tall := snapshotSizing(good); !f || !w || !tall {
+		t.Errorf("fixture invalid: the FIXED rule must read found+fullWidth+tall (got %v/%v/%v)", f, w, tall)
+	}
+	noRule := `.app .readonly-block{ font-size:12px; color:var(--muted); max-height:240px; }`
+	if f, _, _ := snapshotSizing(noRule); f {
+		t.Error("snapshotSizing false-positive: a stylesheet with no snapshot rule must report not-found")
+	}
+	capped := `.app .snapshot-block{ width:100%; height:240px; }`
+	if _, _, tall := snapshotSizing(capped); tall {
+		t.Error("snapshotSizing false-positive: a snapshot still capped at 240px must NOT count as tall")
+	}
+	maxOnly := `.app .snapshot-block{ max-width:100%; max-height:600px; }`
+	if _, w, tall := snapshotSizing(maxOnly); w || tall {
+		t.Errorf("snapshotSizing false-positive: max-width/max-height must not satisfy full-width/tall (got w=%v tall=%v)", w, tall)
+	}
+}
+
+// Scenario: Snapshot sits in a full-width container rather than the narrow asymmetric .cols left track.
+func TestSnapshot_SitsInFullWidthContainerNotNarrowColsTrack(t *testing.T) {
+	indexHTML := readAsset(t, filepath.Join(staticDir, "index.html"))
+	if !reSnapshotInPanel.MatchString(indexHTML) {
+		t.Error("index.html: #agent-snapshot must live in a dedicated full-width snapshot-panel container, not the asymmetric .cols left track")
+	}
+	if c := strings.Count(indexHTML, `id="view-agent"`); c != 1 {
+		t.Errorf(`index.html: exactly one id="view-agent" section expected after the re-layout, got %d`, c)
+	}
+	// AC / contract: no input element in the snapshot's zone — never a raw terminal.
+	if m := reSnapshotInPanel.FindString(indexHTML); m != "" {
+		zone := snapshotZone(indexHTML)
+		for _, bad := range []string{" plus a small tail), used to prove no input element shares the read-only zone.
+func snapshotZone(html string) string {
+	i := strings.Index(html, "snapshot-panel")
+	if i < 0 {
+		return ""
+	}
+	j := strings.Index(html[i:], "
") + if j < 0 { + return html[i:] + } + return html[i : i+j+len("")] +} + +func TestSnapshot_SitsInFullWidthContainer_SelfNegative(t *testing.T) { + fixed := `
` + if !reSnapshotInPanel.MatchString(fixed) { + t.Error("reSnapshotInPanel failed to match the FIXED full-width snapshot-panel layout") + } + old := `
` + if reSnapshotInPanel.MatchString(old) { + t.Error("reSnapshotInPanel false-positive on the OLD narrow .cols left-track layout") + } +} + +// Scenario: Snapshot no longer inherits the shared Settings-sized .readonly-block; Settings stays intact. +func TestSnapshot_DoesNotInheritReadonlyBlock(t *testing.T) { + indexHTML := readAsset(t, filepath.Join(staticDir, "index.html")) + mainCSS := readAsset(t, filepath.Join(staticDir, "styles", "main.css")) + + tag := reSnapshotPreTag.FindString(indexHTML) + if tag == "" { + t.Fatal("index.html: the #agent-snapshot
 element is missing")
+	}
+	if strings.Contains(tag, "readonly-block") {
+		t.Errorf("index.html: #agent-snapshot must NOT carry the shared .readonly-block class (the 240px/12px Settings sizing leaks in): %s", tag)
+	}
+	if !strings.Contains(tag, "snapshot-block") {
+		t.Errorf("index.html: #agent-snapshot must carry its own dedicated .snapshot-block class: %s", tag)
+	}
+	// note (a): the shared class survives untouched so Settings' #set-factory stays presentable.
+	if !reReadonlyBlockRule.MatchString(mainCSS) {
+		t.Error("main.css: the shared .readonly-block rule must remain (still used by Settings' #set-factory) — do not delete it, only stop the snapshot from sharing it")
+	}
+	if !strings.Contains(mainCSS, "240px") {
+		t.Error("main.css: the .readonly-block 240px cap must remain untouched for Settings (the fix scopes AROUND the shared class, it does not resize it)")
+	}
+	setFactoryTag := regexp.MustCompile(`<[^>]*id="set-factory"[^>]*>`).FindString(indexHTML)
+	if setFactoryTag == "" || !strings.Contains(setFactoryTag, "readonly-block") {
+		t.Errorf("index.html: Settings' #set-factory must still use .readonly-block (unchanged): %q", setFactoryTag)
+	}
+}
+
+func TestSnapshot_DoesNotInheritReadonlyBlock_SelfNegative(t *testing.T) {
+	oldTag := `
`
+	if tag := reSnapshotPreTag.FindString(oldTag); !strings.Contains(tag, "readonly-block") {
+		t.Error("fixture invalid: the OLD pre tag must contain readonly-block (the leak the fix removes)")
+	}
+	newTag := `
`
+	tag := reSnapshotPreTag.FindString(newTag)
+	if strings.Contains(tag, "readonly-block") {
+		t.Error("fixture invalid: the FIXED pre tag must not contain readonly-block")
+	}
+	if !strings.Contains(tag, "snapshot-block") {
+		t.Error("fixture invalid: the FIXED pre tag must contain the dedicated snapshot-block class")
+	}
+}
+
+// Scenario: Facts render as legible label/value pairs.
+func TestFacts_RenderAsLegiblePairs(t *testing.T) {
+	mainCSS := readAsset(t, filepath.Join(staticDir, "styles", "main.css"))
+	if !reFactsFamilyRule.MatchString(mainCSS) {
+		t.Error("main.css: a rule for the .facts/.fact/.fk/.fv family is required so renderAgentFacts' label/value pairs render legibly (not run-together like 'statusIdle')")
+	}
+}
+
+func TestFacts_RenderAsLegiblePairs_SelfNegative(t *testing.T) {
+	with := `.app .fact{ display:flex; gap:12px; } .app .fk{ color:var(--muted); } .app .fv{ font-weight:600; }`
+	if !reFactsFamilyRule.MatchString(with) {
+		t.Error("reFactsFamilyRule failed to match a valid .fact/.fk/.fv rule block")
+	}
+	without := `.app .factory-note{ color:red; } .app .something{ font-size:12px; }`
+	if reFactsFamilyRule.MatchString(without) {
+		t.Error("reFactsFamilyRule false-positive on CSS with no .facts-family rule (.factory-note must not count)")
+	}
+}
+
+// Scenario: The snapshot scrolls the newest output into view on refresh.
+func TestSnapshot_ScrollsNewestOutputIntoView(t *testing.T) {
+	appJS := readAsset(t, filepath.Join(staticDir, "app.js"))
+	body := funcBody(appJS, "renderAgentSnapshot")
+	if body == "" {
+		t.Fatal("app.js: renderAgentSnapshot function not found")
+	}
+	if !reScrollAssign.MatchString(body) {
+		t.Error("app.js: renderAgentSnapshot must scroll the newest output into view (set scrollTop from scrollHeight) so the latest bytes are visible on open and after each 5s tick")
+	}
+	// The scroll edit must not violate the textContent-proximity tripwire (agentdetail_test.go:26).
+	if !rePaneTextContent.MatchString(appJS) {
+		t.Error("app.js: the scroll edit must preserve the byId('agent-snapshot')->textContent proximity (AC-3, still filled via textContent)")
+	}
+}
+
+func TestSnapshot_ScrollsNewestOutputIntoView_SelfNegative(t *testing.T) {
+	fixed := `function renderAgentSnapshot(d){ var pre = byId('agent-snapshot'); if(!pre) return; var atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 4; pre.textContent = d.tail.output || ''; if (atBottom) pre.scrollTop = pre.scrollHeight; }`
+	if body := funcBody(fixed, "renderAgentSnapshot"); !reScrollAssign.MatchString(body) {
+		t.Error("reScrollAssign failed to match the FIXED renderAgentSnapshot body")
+	}
+	old := `function renderAgentSnapshot(d){ var pre = byId('agent-snapshot'); if(!pre) return; pre.textContent = d.tail.output || ''; }`
+	if body := funcBody(old, "renderAgentSnapshot"); reScrollAssign.MatchString(body) {
+		t.Error("reScrollAssign false-positive on the OLD renderAgentSnapshot (no scroll-to-bottom)")
+	}
+	// funcBody must isolate the function — a scroll in a LATER function must not leak in.
+	twoFns := `function renderAgentSnapshot(d){ var pre = byId('agent-snapshot'); pre.textContent = ''; }` + "\n" +
+		`function other(){ el.scrollTop = el.scrollHeight; }`
+	if body := funcBody(twoFns, "renderAgentSnapshot"); reScrollAssign.MatchString(body) {
+		t.Error("funcBody leaked past renderAgentSnapshot into a later function's scroll code")
+	}
+}
diff --git a/web/internal/web/agentdetail_test.go b/web/internal/web/agentdetail_test.go
new file mode 100644
index 0000000..0ba25fb
--- /dev/null
+++ b/web/internal/web/agentdetail_test.go
@@ -0,0 +1,166 @@
+package web
+
+import (
+	"path/filepath"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+// Issue #500 Phase 3: the per-agent detail View. The web module is pure-Go with no JS/DOM runtime
+// (web/go.mod has no require block), so — following the source-scan precedent in nav_test.go and
+// web/internal/server/lint_test.go — these are SOURCE-LEVEL structural assertions over the embedded
+// static assets. `readAsset` and `staticDir` are shared with nav_test.go (same package). Every
+// assertion carries a self-negative proving the check is not vacuous.
+
+// The retired placeholder literal. Scoped to static/app.js ONLY — it also lives across ~17 .designs/
+// docs and in this test's own fixtures, which must NOT count (Gotcha 8 / peer review erratum #16).
+const laterPhaseLiteral = "arrives in a later phase"
+
+// (a) boot()'s poll branch is keyed on the parameterized "agent/" route (poll-only-while-open).
+var reBootPollsAgentRoute = regexp.MustCompile(`function\s+boot\b[\s\S]*?currentRoute\.indexOf\('agent/'\)[\s\S]*?AgentDetailViewModel\.refresh`)
+
+// (b) the snapshot pane (#agent-snapshot) is filled via textContent within a short window of being
+// grabbed — never innerHTML (AC-3). `agent-snapshot')` matches only the pane grab, not the
+// -note/-captured/-label siblings.
+var rePaneTextContent = regexp.MustCompile(`agent-snapshot'\)[\s\S]{0,600}?textContent`)
+
+// (c) innerHTML lint: EVERY innerHTML assignment module-asset-wide must be an empty-string clear.
+// RE2 has no lookahead, so we count all assignments and all empty clears and require equality.
+var reAnyInnerHTMLAssign = regexp.MustCompile(`innerHTML\s*=\s*[^=]`)
+var reEmptyInnerHTMLAssign = regexp.MustCompile("innerHTML\\s*=\\s*(''|\"\"|" + "``" + ")")
+
+// (d) dark cards are dimmed (ux.md U3-1): main.css must carry a .sign.dark rule for the
+// 'sign s-idle dark' class darkCard emits.
+var reDarkDimRule = regexp.MustCompile(`\.sign\.dark\b[^{]*\{`)
+
+// AC-6 (site scope) — the "later phase" placeholder is gone from static/app.js.
+func TestAppJS_NoLaterPhasePlaceholder(t *testing.T) {
+	appJS := readAsset(t, filepath.Join(staticDir, "app.js"))
+	if n := strings.Count(appJS, laterPhaseLiteral); n != 0 {
+		t.Errorf("static/app.js must not contain the %q placeholder, found %d occurrence(s)", laterPhaseLiteral, n)
+	}
+}
+
+func TestAppJS_NoLaterPhasePlaceholder_SelfNegative(t *testing.T) {
+	oldSite := `viewAgent: function (name) { toast('Agent detail for ' + name + ' ` + laterPhaseLiteral + `'); }`
+	if strings.Count(oldSite, laterPhaseLiteral) == 0 {
+		t.Error("fixture invalid: the OLD viewAgent site should contain the placeholder literal")
+	}
+	fixedSite := `viewAgent: function (name) { AppViewModel.navigate('agent/' + name); }`
+	if strings.Count(fixedSite, laterPhaseLiteral) != 0 {
+		t.Error("fixture invalid: the FIXED viewAgent site must not contain the placeholder literal")
+	}
+}
+
+// The sixth view exists in index.html and is registered in VIEW_IDS (else showView blanks the page).
+func TestIndexHTML_ViewAgentSectionPresent(t *testing.T) {
+	indexHTML := readAsset(t, filepath.Join(staticDir, "index.html"))
+	appJS := readAsset(t, filepath.Join(staticDir, "app.js"))
+	if c := strings.Count(indexHTML, `id="view-agent"`); c != 1 {
+		t.Errorf(`index.html: exactly one id="view-agent" section expected, got %d`, c)
+	}
+	if !strings.Contains(appJS, `'view-agent'`) {
+		t.Error("app.js: VIEW_IDS must contain 'view-agent' (Gotcha 2 — else showView('view-agent') hides every section)")
+	}
+}
+
+// AC-2/AC-3/scale.md — boot() polls only while the agent view is open, and the snapshot pane is
+// filled via textContent.
+func TestAgentDetail_StructuralInvariants(t *testing.T) {
+	appJS := readAsset(t, filepath.Join(staticDir, "app.js"))
+	if !reBootPollsAgentRoute.MatchString(appJS) {
+		t.Error("app.js: boot() must poll AgentDetailViewModel.refresh() keyed on the 'agent/' route (poll-only-while-open)")
+	}
+	if !strings.Contains(appJS, `byId('agent-snapshot')`) {
+		t.Error("app.js: the read-only snapshot pane #agent-snapshot must be present")
+	}
+	if !rePaneTextContent.MatchString(appJS) {
+		t.Error("app.js: the snapshot pane must be filled via textContent (AC-3), never innerHTML")
+	}
+}
+
+// AC-3 (D5) — no non-empty innerHTML assignment anywhere in the module assets.
+func TestAppJS_NoNonEmptyInnerHTML(t *testing.T) {
+	appJS := readAsset(t, filepath.Join(staticDir, "app.js"))
+	all := reAnyInnerHTMLAssign.FindAllString(appJS, -1)
+	empt := reEmptyInnerHTMLAssign.FindAllString(appJS, -1)
+	if len(all) != len(empt) {
+		t.Errorf("app.js: every innerHTML assignment must be an empty-string clear (found %d assignments, %d empty clears) — render via textContent / DOM nodes, never innerHTML = markup", len(all), len(empt))
+	}
+}
+
+// Self-negatives for the structural regexes — each matches the FIXED form and rejects the OLD form.
+func TestAgentDetail_Structural_SelfNegative(t *testing.T) {
+	oldBoot := `function boot() { wire(); FloorViewModel.refresh(); setInterval(function(){ FloorViewModel.refresh(); if (AppViewModel.currentRoute === 'dispatch') { DispatchViewModel.refresh(); } }, 5000); }`
+	newBoot := `function boot() { setInterval(function(){ if (AppViewModel.currentRoute.indexOf('agent/') === 0) { AgentDetailViewModel.refresh(); } }, 5000); }`
+	if reBootPollsAgentRoute.MatchString(oldBoot) {
+		t.Error("reBootPollsAgentRoute false-positive on the OLD boot() (no agent poll)")
+	}
+	if !reBootPollsAgentRoute.MatchString(newBoot) {
+		t.Error("reBootPollsAgentRoute failed to match the FIXED boot()")
+	}
+
+	oldSnap := `var pre = byId('agent-snapshot'); pre.innerHTML = tail.output;`
+	newSnap := `var pre = byId('agent-snapshot'); pre.textContent = tail.output;`
+	if rePaneTextContent.MatchString(oldSnap) {
+		t.Error("rePaneTextContent false-positive on an innerHTML-filled snapshot")
+	}
+	if !rePaneTextContent.MatchString(newSnap) {
+		t.Error("rePaneTextContent failed to match the textContent-filled snapshot")
+	}
+}
+
+// AC-2b — the stopped-agents dark group has its host markup, pinned from BOTH ends of the JS↔HTML
+// contract: renderDarkGroup's first line silently no-ops when #dark-grid is absent (Gotcha 11), so
+// the ids must exist in index.html exactly once AND app.js must still bind them by the same names.
+func TestIndexHTML_DarkGroupHostPresent(t *testing.T) {
+	indexHTML := readAsset(t, filepath.Join(staticDir, "index.html"))
+	appJS := readAsset(t, filepath.Join(staticDir, "app.js"))
+	for _, id := range []string{"dark-label", "dark-count", "dark-grid"} {
+		if c := strings.Count(indexHTML, `id="`+id+`"`); c != 1 {
+			t.Errorf(`index.html: exactly one id=%q expected, got %d (renderDarkGroup no-ops without its host)`, id, c)
+		}
+		if !strings.Contains(appJS, `byId('`+id+`')`) {
+			t.Errorf("app.js: renderDarkGroup must bind byId('%s') — the JS side of the dark-group contract", id)
+		}
+	}
+}
+
+// AC-2b / ux.md U3-1 — dark cards are visually dimmed: darkCard emits class 'sign s-idle dark'
+// and main.css styles it.
+func TestMainCSS_DarkCardsDimmed(t *testing.T) {
+	mainCSS := readAsset(t, filepath.Join(staticDir, "styles", "main.css"))
+	appJS := readAsset(t, filepath.Join(staticDir, "app.js"))
+	if !strings.Contains(appJS, `'sign s-idle dark'`) {
+		t.Error("app.js: darkCard must emit class 'sign s-idle dark' (ux.md U3-1)")
+	}
+	if !reDarkDimRule.MatchString(mainCSS) {
+		t.Error("main.css: a .sign.dark rule is required so dark cards render dimmed relative to lit cards (ux.md U3-1)")
+	}
+}
+
+func TestMainCSS_DarkCardsDimmed_SelfNegative(t *testing.T) {
+	with := `.app .sign.dark{ opacity:.55; }`
+	without := `.app .sign.s-idle{    --lit:var(--line); }` + "\n" + `.app .sign .darken{ color:red; }`
+	if !reDarkDimRule.MatchString(with) {
+		t.Error("reDarkDimRule failed to match a valid dim rule")
+	}
+	if reDarkDimRule.MatchString(without) {
+		t.Error("reDarkDimRule false-positive on CSS without a .sign.dark rule")
+	}
+}
+
+func TestAppJS_NoNonEmptyInnerHTML_SelfNegative(t *testing.T) {
+	good := `host.innerHTML = '';`
+	bad := `host.innerHTML = card(a);`
+	if len(reAnyInnerHTMLAssign.FindAllString(good, -1)) != 1 {
+		t.Error("reAnyInnerHTMLAssign should match an innerHTML clear exactly once")
+	}
+	if len(reAnyInnerHTMLAssign.FindAllString(good, -1)) != len(reEmptyInnerHTMLAssign.FindAllString(good, -1)) {
+		t.Error("an empty clear must be counted as an allowed (empty) assignment")
+	}
+	if len(reAnyInnerHTMLAssign.FindAllString(bad, -1)) == len(reEmptyInnerHTMLAssign.FindAllString(bad, -1)) {
+		t.Error("a non-empty innerHTML assignment must NOT be counted as an empty clear")
+	}
+}
diff --git a/web/internal/web/contracttrace_test.go b/web/internal/web/contracttrace_test.go
new file mode 100644
index 0000000..382f77d
--- /dev/null
+++ b/web/internal/web/contracttrace_test.go
@@ -0,0 +1,261 @@
+package web
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+// Issue #500 Phase 3A: the class-level interlock. The design contract
+// (.designs/web/final/design-contract.yaml) promises viewmodel bindings and screens; nothing
+// previously read those promises back against the shipped code, which is how the contract-required
+// View button dead-ended as a toast stub for months (analyst H1/H3). This test mechanically traces
+// every contract `viewmodel:` binding to a real member of its named viewmodel in static/app.js
+// (and rejects bare toast(...) stub bodies), and every contract screen id to its section in
+// static/index.html. Same source-scan posture as nav_test.go; `staticDir`/`readAsset` are shared
+// package helpers (nav_test.go) and `laterPhaseLiteral` comes from agentdetail_test.go.
+//
+// Scope: this interlock covers the operator-console contract ONLY. The sibling
+// .designs/web-ui/final/design-contract.yaml is the Formula Editor's (issue #445) — its viewmodels
+// and screens do not exist in this app, so the path below is hardcoded and must NEVER become a
+// .designs/**/final/ glob (a glob returns both contracts and fails spuriously).
+var contractPath = filepath.Join("..", "..", "..", ".designs", "web", "final", "design-contract.yaml")
+
+// The screen-id → index.html section-id map is explicit because it is NOT mechanical:
+// existing screens follow "view-"+id, but the agent-detail screen's shipped section is
+// id="view-agent" (pinned by VIEW_IDS and agentdetail_test.go; renaming is out of scope).
+var contractScreenSections = map[string]string{
+	"floor":        "view-floor",
+	"sling":        "view-sling",
+	"dispatch":     "view-dispatch",
+	"prototypes":   "view-prototypes",
+	"settings":     "view-settings",
+	"agent-detail": "view-agent",
+}
+
+// All contract bindings are double-quoted scalars on their own line (47 at the time of writing).
+var reContractBinding = regexp.MustCompile(`(?m)^\s+viewmodel: "([^"]+)"$`)
+
+// Screen ids sit at exactly 2-space `- id:` indent; element ids sit at 6-space indent and the
+// global_chrome items (also 2-space) are excluded by scoping extraction to the screens: region.
+var reContractScreenID = regexp.MustCompile(`(?m)^  - id: (\S+)\s*$`)
+
+// vmObjectBody slices the object-literal body of a top-level `var  = {` viewmodel out of
+// app.js. Every viewmodel is declared at 2-space indent inside the IIFE and closes with a
+// 2-space `};`, so the first "\n  };" terminates the literal; the fallbacks keep the checker
+// usable on trailing declarations and fixtures.
+func vmObjectBody(appJS, vmName string) (string, bool) {
+	decl := "var " + vmName + " = {"
+	start := strings.Index(appJS, decl)
+	if start < 0 {
+		return "", false
+	}
+	rest := appJS[start:]
+	if end := strings.Index(rest, "\n  };"); end >= 0 {
+		return rest[:end+len("\n  };")], true
+	}
+	if end := strings.Index(rest, "\n  var "); end >= 0 {
+		return rest[:end], true
+	}
+	return rest, true
+}
+
+// checkBinding validates one `viewmodel:` value against app.js. Shapes handled (all 47 current
+// values): bare object ("ConfirmViewModel"), bare member ("AppViewModel.goHome"), call-with-args
+// ("FloorViewModel.viewAgent(agent.name)"), and property paths ("SettingsViewModel.data.startup.agents",
+// "SettingsViewModel.data.dispatch.mappings[].label"). The FIRST segment after the viewmodel must be
+// a real member (4-space member indent — the VM literal's member position); deeper segments describe
+// data shape, not code, and are not traced. Method members must not be bare toast(...) stubs.
+func checkBinding(binding, appJS string) []string {
+	expr := binding
+	if i := strings.Index(expr, "("); i >= 0 {
+		expr = expr[:i]
+	}
+	segs := strings.Split(expr, ".")
+	for i := range segs {
+		segs[i] = strings.TrimSuffix(strings.TrimSpace(segs[i]), "[]")
+	}
+	root := segs[0]
+	body, ok := vmObjectBody(appJS, root)
+	if !ok {
+		return []string{fmt.Sprintf("binding %q: viewmodel object %q not declared in app.js (no `var %s = {`)", binding, root, root)}
+	}
+	if len(segs) == 1 {
+		return nil // bare-object binding — the declaration itself is the contract
+	}
+	member := segs[1]
+	memberRe := regexp.MustCompile(`(?m)^    ` + regexp.QuoteMeta(member) + `\s*:`)
+	if !memberRe.MatchString(body) {
+		return []string{fmt.Sprintf("binding %q: %q is not a member of %s in app.js — stale binding (the phantom-claim class this test closes)", binding, member, root)}
+	}
+	// A body that is ONLY a toast call is a stub (the exact #500 View-button failure). Bodies
+	// merely CONTAINING toast (e.g. an error branch) do not match: after the single toast
+	// statement the regex requires the closing brace immediately.
+	stubRe := regexp.MustCompile(regexp.QuoteMeta(member) + `\s*:\s*function\s*\([^)]*\)\s*\{\s*toast\([^;]*\);?\s*\}`)
+	if stubRe.MatchString(body) {
+		return []string{fmt.Sprintf("binding %q: %s.%s is a bare toast(...) stub — bind real behavior, not a placeholder", binding, root, member)}
+	}
+	return nil
+}
+
+// traceContract is a pure checker over content strings so the self-negative fixtures drive the
+// SAME code path as the real assets (precedent: scanTree in web/internal/server/lint_test.go).
+// It returns human-readable violations; empty means the contract traces clean.
+func traceContract(contractYAML, appJS, indexHTML string) []string {
+	var violations []string
+
+	// Bindings live in global_chrome + screens — everything BEFORE viewmodel_contracts:
+	// (that section restates members as data rows, not `viewmodel:` bindings).
+	bindingRegion := contractYAML
+	if i := strings.Index(contractYAML, "\nviewmodel_contracts:"); i >= 0 {
+		bindingRegion = contractYAML[:i]
+	}
+	bindings := reContractBinding.FindAllStringSubmatch(bindingRegion, -1)
+	if len(bindings) == 0 {
+		violations = append(violations, "no viewmodel bindings extracted — contract shape or extraction regex broken (non-vacuity guard)")
+	}
+	for _, m := range bindings {
+		violations = append(violations, checkBinding(m[1], appJS)...)
+	}
+
+	// Screen ids: scoped to the screens: region (global_chrome shares the 2-space indent).
+	screenRegion := ""
+	if strings.HasPrefix(bindingRegion, "screens:") {
+		screenRegion = bindingRegion
+	} else if i := strings.Index(bindingRegion, "\nscreens:"); i >= 0 {
+		screenRegion = bindingRegion[i:]
+	}
+	if screenRegion == "" {
+		violations = append(violations, "no screens: section found in the contract (non-vacuity guard)")
+		return violations
+	}
+	ids := reContractScreenID.FindAllStringSubmatch(screenRegion, -1)
+	if len(ids) == 0 {
+		violations = append(violations, "no screen ids extracted from the screens: section (non-vacuity guard)")
+	}
+	for _, m := range ids {
+		id := m[1]
+		section, ok := contractScreenSections[id]
+		if !ok {
+			violations = append(violations, fmt.Sprintf("unknown screen id %q — extend contractScreenSections (the id→section map is explicit: agent-detail → view-agent)", id))
+			continue
+		}
+		if c := strings.Count(indexHTML, `id="`+section+`"`); c != 1 {
+			violations = append(violations, fmt.Sprintf("screen %q: expected exactly one id=%q section in index.html, found %d — a contract screen without its section is the dead-end class #500 closes", id, section, c))
+		}
+	}
+	return violations
+}
+
+// TestContractTrace_AllBindingsReal: every contract binding resolves to real code and every
+// contract screen has its section. Green means the contract tells the truth; a dead binding or a
+// sectionless screen fails web-unit CI hard.
+func TestContractTrace_AllBindingsReal(t *testing.T) {
+	raw, err := os.ReadFile(contractPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			// The public/extracted repo ships web/ but not .designs/ (todos/public_repo_files.md),
+			// so absence is a legitimate checkout shape there. Monorepo CI runs on a full checkout
+			// with no paths filters, so this skip never fires where the interlock matters.
+			t.Skipf("design contract absent at %s — OSS/extracted checkout; skipping contract trace", contractPath)
+		}
+		t.Fatalf("read %s: %v", contractPath, err)
+	}
+	appJS := readAsset(t, filepath.Join(staticDir, "app.js"))
+	indexHTML := readAsset(t, filepath.Join(staticDir, "index.html"))
+	for _, v := range traceContract(string(raw), appJS, indexHTML) {
+		t.Error(v)
+	}
+}
+
+// TestContractTrace_AllBindingsReal_SelfNegative proves the checker is not vacuous: the SAME
+// traceContract code path must pass a consistent fixture, flag a planted toast-stub binding
+// (the historical #500 failure, rebuilt verbatim), flag a mapped-but-sectionless screen id, flag
+// an unmapped screen id, and flag a member that does not exist.
+func TestContractTrace_AllBindingsReal_SelfNegative(t *testing.T) {
+	has := func(vs []string, substr string) bool {
+		for _, v := range vs {
+			if strings.Contains(v, substr) {
+				return true
+			}
+		}
+		return false
+	}
+
+	cleanContract := `version: 1
+
+screens:
+  - id: agent-detail
+    route: "(in-memory) agent/"
+    elements:
+      - id: agent-view
+        viewmodel: "FloorViewModel.viewAgent(agent.name)"
+      - id: agent-facts
+        viewmodel: "AgentDetailViewModel.refresh"
+
+viewmodel_contracts:
+  - name: "AgentDetailViewModel"
+`
+	fixedViewAgent := "    viewAgent: function (name) { AppViewModel.navigate('agent/' + name); },\n"
+	cleanAppJS := "  var FloorViewModel = {\n" +
+		fixedViewAgent +
+		"  };\n" +
+		"  var AgentDetailViewModel = {\n" +
+		"    data: null,\n" +
+		"    refresh: function () {\n" +
+		"      return API.get('/x').then(render).catch(function (e) { toast(String(e)); });\n" +
+		"    },\n" +
+		"  };\n" +
+		"  var AppViewModel = {\n" +
+		"    navigate: function (route) { showView(route); },\n" +
+		"  };\n"
+	cleanIndexHTML := `
` + + // (a) Consistent fixture → ZERO violations (guards against an always-failing checker). This + // also proves two non-flags: a toast call inside a larger body (refresh's catch) is not a + // stub, and 6-space element ids are not extracted as screen ids. + if vs := traceContract(cleanContract, cleanAppJS, cleanIndexHTML); len(vs) != 0 { + t.Errorf("checker must report ZERO violations on the consistent fixture, got %d: %v", len(vs), vs) + } + + // (b) Planted toast-stub binding — the OLD viewAgent body, verbatim (agentdetail_test.go + // fixture precedent; the placeholder scan reads only static/app.js, so fixtures are safe). + oldStub := " viewAgent: function (name) { toast('Agent detail for ' + name + ' " + laterPhaseLiteral + "'); },\n" + stubAppJS := strings.Replace(cleanAppJS, fixedViewAgent, oldStub, 1) + if stubAppJS == cleanAppJS { + t.Fatal("fixture invalid: stub replacement did not apply") + } + if vs := traceContract(cleanContract, stubAppJS, cleanIndexHTML); !has(vs, "toast(...) stub") { + t.Errorf("checker must flag a binding whose body is ONLY a toast(...) call, got: %v", vs) + } + + // (c) Mapped screen id whose section is missing from index.html. + if vs := traceContract(cleanContract, cleanAppJS, `
`); !has(vs, `id="view-agent"`) { + t.Errorf("checker must flag the agent-detail screen when index.html lacks its view-agent section, got: %v", vs) + } + + // (d) Unmapped screen id — forces the explicit map to grow with the contract. + phantomContract := strings.Replace(cleanContract, " - id: agent-detail", " - id: phantom", 1) + if vs := traceContract(phantomContract, cleanAppJS, cleanIndexHTML); !has(vs, "unknown screen id") { + t.Errorf("checker must flag a screen id missing from contractScreenSections, got: %v", vs) + } + + // (e) Binding to a member that does not exist (the stale-binding / phantom-claim class). + staleContract := strings.Replace(cleanContract, + `viewmodel: "AgentDetailViewModel.refresh"`, + `viewmodel: "AgentDetailViewModel.launchSequence"`, 1) + if vs := traceContract(staleContract, cleanAppJS, cleanIndexHTML); !has(vs, "not a member") { + t.Errorf("checker must flag a binding whose member does not exist on the viewmodel, got: %v", vs) + } + + // (f) Binding whose viewmodel object is not declared at all. + ghostContract := strings.Replace(cleanContract, + `viewmodel: "AgentDetailViewModel.refresh"`, + `viewmodel: "GhostViewModel.run"`, 1) + if vs := traceContract(ghostContract, cleanAppJS, cleanIndexHTML); !has(vs, "not declared") { + t.Errorf("checker must flag a binding to an undeclared viewmodel object, got: %v", vs) + } +} diff --git a/web/internal/web/static/app.js b/web/internal/web/static/app.js index 4f6569a..c38130a 100644 --- a/web/internal/web/static/app.js +++ b/web/internal/web/static/app.js @@ -1,6 +1,6 @@ /* ============================================================================ app.js — Direction A · "Neon Bazaar" Floor view (Phase 1, vanilla JS). - Implements the four Phase-1 view-models from design-contract.yaml: + Implements the four Phase-1 view-models: AppViewModel · PowerBarViewModel · ConfirmViewModel · FloorViewModel No framework. Reads /api/agents (honest read-model) and drives the allowlisted control verbs (af up / af down / af down --reset) through the @@ -171,7 +171,7 @@ }, search: function (q) { this.query = (q || '').toLowerCase(); render(); }, filterByStatus: function (f) { this.statusFilter = f || 'all'; syncFilterButtons(); render(); }, - viewAgent: function (name) { toast('Agent detail for ' + name + ' arrives in a later phase'); }, + viewAgent: function (name) { AppViewModel.navigate('agent/' + name); }, downAgent: function (name) { return API.post('/api/agents/' + encodeURIComponent(name) + '/down', { reset: false, confirm: false }) .then(report(name + ' is stopping')).then(function () { FloorViewModel.refresh(); }); @@ -189,7 +189,7 @@ // SlingViewModel — pick an idle agent, build its form, sling a task. // Issues the identical `af sling --agent --reset --var k=v …` argv // the operator would run by hand; the server hides identity-bearing vars - // (INV-2) and rejects unknown keys. Sling is fire-and-forget: success copy + // and rejects unknown keys. Sling is fire-and-forget: success copy // says "starting", never "working". // ========================================================================= var SlingViewModel = { @@ -247,7 +247,7 @@ var vars = collectFormValues(this.schema); if (primary) delete vars[primary]; // the task is the positional; it must not double-bind as a --var. - // Validate EVERY required field (not just the task) before dispatch (K7). required_unless + // Validate EVERY required field (not just the task) before dispatch. required_unless // fields are a hint only — the CLI is the arbiter — so they are intentionally not blocked here. var fields = (this.schema.fields || []); for (var i = 0; i < fields.length; i++) { @@ -262,7 +262,7 @@ var self = this; var dispatch = function () { return self._postSling(name, task, vars); }; - // --reset blast-radius guard (K6): always-`--reset` would discard a live formula step. If the + // --reset blast-radius guard: always-`--reset` would discard a live formula step. If the // chosen idle agent still holds a live (non-terminal) step, require a browser confirm first. // This is a UI affordance, NOT an af-core runtime prompt (ADR-014 unaffected). var sel = this._agentByName(name); @@ -300,7 +300,7 @@ }; // ---- view toggles (exactly one #view-* section visible at a time) ---- - var VIEW_IDS = ['view-floor', 'view-sling', 'view-dispatch', 'view-settings', 'view-prototypes']; + var VIEW_IDS = ['view-floor', 'view-sling', 'view-dispatch', 'view-settings', 'view-prototypes', 'view-agent']; function showView(id) { VIEW_IDS.forEach(function (v) { var s = byId(v); if (s) s.hidden = (v !== id); }); } @@ -343,7 +343,7 @@ var host = byId('sling-form-host'); if (!host) return; host.innerHTML = ''; var fields = (schema && schema.fields) || []; - // Server-authoritative: which field the positional task effectively binds to (K3). Blank == the + // Server-authoritative: which field the positional task effectively binds to. Blank == the // synthetic-task-box signal (e.g. design-v7, whose issue is hook-sourced) — never re-derived client-side. var primary = (schema && schema.primary) || ''; var primaryField = null; @@ -388,7 +388,8 @@ var wrap = el('div', 'formrow taskbox'); var lbl = el('label', 'lbl'); lbl.setAttribute('for', 'sling-field-' + key); - // L2 — drive the label from the primary field so distinct agents visibly differ. + // Drive the label from the primary field so distinct agents visibly differ, rather than + // the same hardcoded placeholder/label for every agent. lbl.appendChild(document.createTextNode(synthetic ? 'Task / issue reference' : (f.description || f.name))); lbl.appendChild(document.createTextNode(' ')); lbl.appendChild(el('span', 'req', 'required')); @@ -397,7 +398,8 @@ ta.id = 'sling-field-' + key; ta.setAttribute('data-key', key); ta.setAttribute('aria-describedby', 'sling-field-err-' + key); - // L2 — name the bound field in the placeholder so the box is distinct per agent. + // Name the bound field in the placeholder so the box is distinct per agent, same reasoning + // as the label above. ta.placeholder = synthetic ? 'Paste a GitHub issue/PR URL, a path to a problem description, or describe the task…' : 'Paste a GitHub issue/PR URL, a path, or a description — this becomes “' + (f.name || f.description) + '”.'; @@ -430,7 +432,7 @@ row.appendChild(lbl); var ctl = controlFor(f); row.appendChild(ctl); - // Required (non-primary) fields get the same per-field error machinery as the task box (K7) — + // Required (non-primary) fields get the same per-field error machinery as the task box — // generalized, not a parallel system: same sling-field-err- id + clear-on-input. if (required) { ctl.addEventListener('input', onFieldInput); @@ -472,8 +474,8 @@ var err = byId('sling-field-err-' + key); if (err) err.hidden = false; var ta = byId('sling-field-' + key); if (ta) ta.focus(); } - // Persistent inline failure surface (K7): a sling failure renders env.message here, not as an - // ephemeral toast. Reuses the .validation danger class (#sling-error in index.html). + // Persistent inline failure surface: a sling failure renders env.message here, not as an + // ephemeral toast. Reuses the .validation danger class (the sling-error region in index.html). function showSlingError(msg) { var box = byId('sling-error'); if (!box) return; box.textContent = msg || 'sling failed'; @@ -483,7 +485,7 @@ function clearSlingError() { var box = byId('sling-error'); if (box) { box.hidden = true; box.textContent = ''; } } - // A live (non-terminal) formula step means re-slinging (always `--reset`) would discard work (K6). + // A live (non-terminal) formula step means re-slinging (always `--reset`) would discard work. // step_state strings come from the /api/agents read-model (af-core agents.go): ready/blocked are // live; all_complete/no_formula/error/empty (and the empty string) are terminal. function isTerminalStep(state) { @@ -845,6 +847,206 @@ function hide(id) { var e = byId(id); if (e) e.hidden = true; } function showValidation(id, msg) { var e = byId(id); if (e) { e.textContent = msg; e.hidden = false; } } + // ========================================================================= + // AgentDetailViewModel — the sixth view (route "agent/", #500). Renders + // the honest per-agent detail from GET /api/agents/{name}/detail: a status + // header + receipt-anchored freshness, a conditional gate banner, the + // facts grid (RUNNING vs DECLARED formula kept DISTINCT — #455), a read-only + // session snapshot filled via textContent ONLY, and a mail composer. + // Ages anchor to Date.now() at RESPONSE RECEIPT — the + // clock-skew-vulnerable server-stamp math in tickStale is deliberately NOT reused. + // ========================================================================= + var AgentDetailViewModel = { + name: '', + data: null, // last successful detail payload {agent, declared_formula, tail} + receivedAt: 0, // Date.now() at receipt of `data` — the anchor for every age + stale: false, // true after a failed refresh: keep the last snapshot, warn honestly, keep polling + + activate: function (name) { + this.name = name; + this.data = null; + this.receivedAt = 0; + this.stale = false; + byId('agent-name').textContent = name; + byId('agent-mail-ok').hidden = true; + hide('agent-mail-err'); + showView('view-agent'); + renderAgentDetail(); // paint the loading shell immediately + return this.refresh(); + }, + refresh: function () { + var self = this; + var name = this.name; + if (!name) return; + return API.get('/api/agents/' + encodeURIComponent(name) + '/detail').then(function (env) { + if (self.name !== name) return; // a newer navigation won + if (!env || !env.ok) { + self.stale = true; // keep the last snapshot; the freshness line flips to the explicit warning + renderAgentDetail(); + return; + } + self.data = env.data || {}; + self.receivedAt = Date.now(); + self.stale = false; + renderAgentDetail(); + }).catch(function () { + if (self.name !== name) return; + self.stale = true; + renderAgentDetail(); + }); + }, + send: function () { + var self = this; + var name = this.name; + if (!name) return; + var subject = String((byId('agent-mail-subject') || {}).value || '').trim(); + var body = String((byId('agent-mail-body') || {}).value || '').trim(); + hide('agent-mail-err'); + byId('agent-mail-ok').hidden = true; + if (!subject) { showAgentMailError('Add a subject before sending.'); return; } + if (!body) { showAgentMailError('Write a message before sending.'); return; } + var btn = byId('agent-mail-send'); + if (btn) { btn.disabled = true; btn.textContent = 'Sending…'; } + return API.post('/api/agents/' + encodeURIComponent(name) + '/mail', { subject: subject, body: body }).then(function (env) { + if (btn) { btn.disabled = false; btn.textContent = 'Send mail'; } + if (env && env.ok) { + var msg = 'Mail queued for ' + name + ' — the agent reads it when it next checks its inbox.'; + if (self.data && self.data.tail && self.data.tail.live) { + msg += ' It will see a notification banner in its session.'; + } + byId('agent-mail-ok-msg').textContent = msg; + byId('agent-mail-ok').hidden = false; + byId('agent-mail-ok').scrollIntoView({ block: 'nearest' }); + byId('agent-mail-subject').value = ''; + byId('agent-mail-body').value = ''; + } else { + showAgentMailError((env && env.message) || 'mail failed'); + } + return env; + }).catch(function (e) { + if (btn) { btn.disabled = false; btn.textContent = 'Send mail'; } + showAgentMailError(String(e)); + }); + } + }; + + // Receipt-anchored age formatter: the argument is a millisecond delta measured from + // Date.now() at RESPONSE RECEIPT — never new Date(serverStamp), which tickStale uses and which is + // clock-skew-vulnerable. Returns a trailing-"ago" phrase (or "just now"). + function agoText(ms) { + var s = Math.max(0, Math.round((ms || 0) / 1000)); + if (s < 2) return 'just now'; + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.round(s / 60) + 'm ago'; + if (s < 86400) return Math.round(s / 3600) + 'h ago'; + return Math.round(s / 86400) + 'd ago'; + } + + function renderAgentDetail() { + var vm = AgentDetailViewModel; + var d = vm.data; + var agent = (d && d.agent) || null; + + var badgeHost = byId('agent-badge'); + if (badgeHost) { + badgeHost.innerHTML = ''; + var st = STATUS[(agent && agent.status)] || STATUS.idle; + var badge = el('span', st.neutral ? 'badge neutral' : 'badge'); + if (!st.neutral) badge.appendChild(el('span', 'pip')); + badge.appendChild(document.createTextNode(st.label)); + badgeHost.appendChild(badge); + } + + // Freshness strip — receipt-anchored. Stale ⇒ explicit warning; else "Ns ago". + var fresh = byId('agent-fresh'); + if (fresh) { + if (vm.stale && vm.receivedAt) { fresh.textContent = 'snapshot may be stale — last captured ' + agoText(Date.now() - vm.receivedAt) + '; retrying'; } + else if (vm.receivedAt) { fresh.textContent = 'updated ' + agoText(Date.now() - vm.receivedAt); } + else if (vm.stale) { fresh.textContent = 'could not load — retrying'; } + else { fresh.textContent = 'loading…'; } + } + + // Gate banner (conditional): only when the agent is parked at a gate; the composer sits below it. + var gate = byId('agent-gate'); + if (gate) { + var isGate = !!(agent && agent.is_gate); + gate.hidden = !isGate; + if (isGate) { byId('agent-gate-text').textContent = 'Parked at gate ' + (agent.gate_id || '—') + ' — your input is needed'; } + } + + renderAgentFacts(agent, d); + renderAgentSnapshot(d); + } + + // Facts grid. RUNNING (agent.formula) vs CONFIGURED (declared_formula) are DISTINCT labels — + // never merged (#455). A reset agent with no recorded run state renders an honest empty state, + // never blank cells that imply retained-but-hidden data. + function renderAgentFacts(agent, d) { + var host = byId('agent-facts'); if (!host) return; + host.innerHTML = ''; + if (!agent) { host.appendChild(factRow('status', 'loading…')); return; } + var noRun = !agent.step_id && !agent.step_state && !agent.formula; + host.appendChild(factRow('status', (STATUS[agent.status] || STATUS.idle).label)); + host.appendChild(factRow('running formula', agent.formula || '—')); + host.appendChild(factRow('configured formula', (d && d.declared_formula) || '—')); + if (noRun) { + host.appendChild(factRow('step', 'no recorded run state')); + } else { + host.appendChild(factRow('step', (agent.step_title || agent.step_id || '—') + (agent.step_state ? ' · ' + agent.step_state : ''))); + } + host.appendChild(factRow('gate', agent.is_gate ? ('parked · ' + (agent.gate_id || '—')) : 'none')); + if (agent.inputs && Object.keys(agent.inputs).length) { + Object.keys(agent.inputs).forEach(function (k) { host.appendChild(factRow(k, String(agent.inputs[k]))); }); + } + } + function factRow(k, v) { + var row = el('div', 'fact'); + row.appendChild(el('span', 'fk', k)); + row.appendChild(el('span', 'fv', v)); + return row; + } + + // Read-only session snapshot. The pane is filled via textContent ONLY — NEVER + // innerHTML — so terminal bytes can never inject markup. live=false renders the last-known state + // plus a hint that mail still queues normally (the bead persists; only the delivery banner is + // skipped); a per-pane "captured Ns ago" is receipt-anchored. + function renderAgentSnapshot(d) { + var pre = byId('agent-snapshot'); if (!pre) return; + var note = byId('agent-snapshot-note'); + var captured = byId('agent-snapshot-captured'); + var tail = (d && d.tail) || null; + // Follow the newest output ONLY when the operator is already pinned to the bottom, so a manual + // scroll-back to read older output is never yanked away by the 5s refresh. + var pinned = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 4; + if (!tail) { + pre.textContent = ''; + if (note) note.hidden = true; + if (captured) captured.hidden = true; + } else if (tail.live === false) { + pre.textContent = tail.output || ''; + if (note) { + note.textContent = 'No live session — showing last-known agent state. This agent is stopped — your mail will wait in its inbox.'; + note.hidden = false; + } + if (captured) captured.hidden = true; + } else { + pre.textContent = tail.output || ''; + if (note) note.hidden = true; + if (captured) { + if (tail.captured_at) { captured.textContent = 'captured ' + agoText(Date.now() - AgentDetailViewModel.receivedAt); captured.hidden = false; } + else { captured.hidden = true; } + } + } + if (pinned) pre.scrollTop = pre.scrollHeight; + } + + function showAgentMailError(msg) { + var box = byId('agent-mail-err'); if (!box) return; + box.textContent = msg || 'mail failed'; + box.hidden = false; + box.scrollIntoView({ block: 'nearest' }); + } + // ========================================================================= // AppViewModel — shell / nav / staleness. // ========================================================================= @@ -852,6 +1054,15 @@ currentRoute: 'floor', lastUpdated: '', navigate: function (route) { + // Parameterized detail route ("agent/") is parsed FIRST — BEFORE the syncNav(route) + // below — because a bare syncNav('agent/x') matches no nav anchor and would clear every + // highlight. The Floor tab stays lit while a detail view is open. + if (route.indexOf('agent/') === 0) { + this.currentRoute = route; + syncNav('floor'); + AgentDetailViewModel.activate(route.slice(6)); + return; + } this.currentRoute = route; syncNav(route); // move the highlight first, for every route if (route === 'sling') { SlingViewModel.activate(); return; } @@ -859,7 +1070,7 @@ if (route === 'settings') { SettingsViewModel.activate(); return; } if (route === 'prototypes') { PrototypesViewModel.activate(); return; } if (route === 'floor') { showFloor(); return; } - toast(cap(route) + ' view arrives in a later phase'); + this.goHome(); // unknown route → home, silently }, goHome: function () { this.currentRoute = 'floor'; syncNav('floor'); showFloor(); }, refresh: function () { return FloorViewModel.refresh(); } @@ -892,7 +1103,10 @@ var lit = FloorViewModel.agents.filter(function (a) { return a.running; }); var allowed = FILTERS[FloorViewModel.statusFilter]; var q = FloorViewModel.query; - var shown = lit.filter(function (a) { + // The "Stopped" segment shows ONLY dark cards: the lit grid is emptied for it (no FILTERS['stopped'] + // entry — that would wrongly run against the running grid). + var stoppedOnly = FloorViewModel.statusFilter === 'stopped'; + var shown = stoppedOnly ? [] : lit.filter(function (a) { if (allowed && allowed.indexOf(a.status) === -1) return false; if (q && a.name.toLowerCase().indexOf(q) === -1) return false; return true; @@ -903,7 +1117,60 @@ byId('lit-count').textContent = String(lit.length); byId('lit-label').textContent = String(lit.length); + // #empty stays keyed on lit.length: "No agents running" remains + // literally true even when dark cards are visible — stopped agents are, by definition, not running. byId('empty').hidden = lit.length !== 0; + + renderDarkGroup(q); // additive pass, AFTER the untouched lit pipeline above + } + + // The dimmed "Dark" group (#500): stopped agents rendered as View-only cards, appended AFTER the + // lit pipeline. Keyed on !a.running — NEVER status === "stopped", which diverges on the + // liveness-probe-failure path. It honors the "Stopped" segment itself: visible only under the All + // or Stopped filters. + function renderDarkGroup(q) { + var darkGrid = byId('dark-grid'); if (!darkGrid) return; + var f = FloorViewModel.statusFilter; + var show = (f === 'all' || f === 'stopped'); + var dark = show ? FloorViewModel.agents.filter(function (a) { return !a.running; }) : []; + if (q) { dark = dark.filter(function (a) { return a.name.toLowerCase().indexOf(q) > -1; }); } + + darkGrid.innerHTML = ''; + dark.forEach(function (a) { darkGrid.appendChild(darkCard(a)); }); + + var label = byId('dark-label'); + if (label) label.hidden = dark.length === 0; + var dc = byId('dark-count'); if (dc) dc.textContent = String(dark.length); + darkGrid.hidden = dark.length === 0; + } + + // darkCard is the stopped-agent card variant: dimmed, name + "Stopped" badge + last-known formula, + // and a View button ONLY — no Down/Reset menu, since a stopped agent has nothing to stop. + // The card() builder is left byte-untouched. + function darkCard(a) { + var li = el('li', 'sign s-idle dark'); + li.setAttribute('data-name', a.name); + li.setAttribute('data-status', 'stopped'); + + var badges = el('div', 'badges'); + var badge = el('span', 'badge neutral'); + badge.appendChild(document.createTextNode('Stopped')); + badges.appendChild(badge); + li.appendChild(badges); + + li.appendChild(el('div', 'name', a.name)); + + var step = el('div', 'step'); + step.appendChild(el('span', 'tt', a.formula ? ('last: ' + a.formula) : 'no recorded formula')); + li.appendChild(step); + + var acts = el('div', 'acts'); + var view = el('button', 'btn primary', 'View'); + view.type = 'button'; + view.addEventListener('click', function () { FloorViewModel.viewAgent(a.name); }); + acts.appendChild(view); + li.appendChild(acts); + return li; } function el(tag, cls, text) { @@ -1023,6 +1290,8 @@ var sar = byId('set-add-row'); if (sar) sar.addEventListener('click', function () { SettingsViewModel.addRow(); }); var ssave = byId('set-save'); if (ssave) ssave.addEventListener('click', function () { SettingsViewModel.save(); }); var pf = byId('proto-fb-form'); if (pf) pf.addEventListener('submit', function (e) { e.preventDefault(); PrototypesViewModel.send(); }); + var amf = byId('agent-mail-form'); if (amf) amf.addEventListener('submit', function (e) { e.preventDefault(); AgentDetailViewModel.send(); }); + var aback = byId('agent-back'); if (aback) aback.addEventListener('click', function () { AppViewModel.goHome(); }); document.querySelectorAll('.seg button').forEach(function (b) { b.addEventListener('click', function () { FloorViewModel.filterByStatus(b.getAttribute('data-filter')); }); }); @@ -1039,8 +1308,11 @@ FloorViewModel.refresh(); setInterval(function () { FloorViewModel.refresh(); - // Poll the dispatch feed too while its view is active (same 5s cadence; scale.md Decision 1). + // Poll the dispatch feed too while its view is active (same 5s cadence). if (AppViewModel.currentRoute === 'dispatch') { DispatchViewModel.refresh(); } + // Poll the open agent-detail view too (poll-ONLY-while-open) — keyed on the + // parameterized "agent/" route so the snapshot refreshes every 5s exactly while it is visible. + if (AppViewModel.currentRoute.indexOf('agent/') === 0) { AgentDetailViewModel.refresh(); } }, 5000); setInterval(tickStale, 1000); // honest staleness clock window.setTimeout(function () { document.body.classList.remove('boot'); }, 1400); @@ -1058,4 +1330,5 @@ window.DispatchViewModel = DispatchViewModel; window.SettingsViewModel = SettingsViewModel; window.PrototypesViewModel = PrototypesViewModel; + window.AgentDetailViewModel = AgentDetailViewModel; })(); diff --git a/web/internal/web/static/index.html b/web/internal/web/static/index.html index 2cde81a..83db088 100644 --- a/web/internal/web/static/index.html +++ b/web/internal/web/static/index.html @@ -53,6 +53,7 @@

The Floor

+ @@ -61,6 +62,11 @@

Running · 0 lit

    + + + + @@ -243,6 +249,68 @@

    Your feedback

    + + + + diff --git a/web/internal/web/static/styles/main.css b/web/internal/web/static/styles/main.css index 776abcf..e0992b2 100644 --- a/web/internal/web/static/styles/main.css +++ b/web/internal/web/static/styles/main.css @@ -226,6 +226,7 @@ body.app{ .app .sign.s-wait{ --lit:var(--neon-violet); } .app .sign.s-error{ --lit:var(--danger); } .app .sign.s-idle{ --lit:var(--line); } +.app .sign.dark{ opacity:.55; } /* dims stopped-agent cards to set them apart from running ones (#500) */ .app .sign .name{ font-family:var(--font-display); font-weight:700; letter-spacing:.02em; @@ -419,7 +420,7 @@ body.app{ .app .toast[hidden]{ display:none; } /* ============================ SLING — 2-COL LAYOUT + FORM ============================ */ -/* Ported from the operator-APPROVED prototype (prototype/styles/main.css:381-450). */ +/* Ported from the operator-APPROVED prototype (Direction A · Neon Bazaar). */ .app .cols{ display:grid; gap:var(--space-7); grid-template-columns:minmax(0,360px) minmax(0,1fr); align-items:start; } .app .cols.even{ grid-template-columns:minmax(0,1fr) minmax(0,1fr); } .app .panel{ background:var(--panel); border:1px solid var(--line); border-radius:var(--r); padding:var(--space-6); } @@ -510,8 +511,8 @@ body.app{ .app .pill-new{ font-family:var(--font-display); font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--neon-magenta); border:1px solid var(--neon-magenta); border-radius:999px; padding:2px 8px; margin-inline-start:auto; } /* the "new" pill */ /* ============================ PROTOTYPES ============================ */ -/* Ported from the operator-APPROVED prototype (Direction A · Neon Bazaar): - .designs/407/prototype/styles/main.css L476-508 — the binding visual reference for this view. */ +/* Ported from the operator-APPROVED prototype (Direction A · Neon Bazaar) — + the binding visual reference for this view. */ .app .proto-cols{ display:grid; gap:var(--space-7); grid-template-columns:minmax(0,300px) minmax(0,1fr) minmax(0,300px); align-items:start; } .app .proto-list{ display:flex; flex-direction:column; gap:var(--space-3); } .app .proto-list button{ @@ -563,8 +564,23 @@ body.app{ .app .switch input:checked + .track{ background:rgba(32,229,255,.18); border-color:var(--neon-cyan); } .app .switch input:checked + .track::after{ left:23px; background:var(--neon-cyan); box-shadow:0 0 8px var(--neon-cyan); } .app .switch input:focus-visible + .track{ outline:2px solid var(--focus); outline-offset:2px; } +/* Settings-only read-only viewer (Settings' #set-factory). Deliberately small — do NOT reuse it for + the agent Session snapshot (issue af-f4f082bc): the snapshot is the View page's PRIMARY element and + has its own .snapshot-block rule below. Sizing them together is the sizing-leak this fix retires. */ .app .readonly-block{ background:var(--panel-2); border:1px solid var(--line); border-radius:var(--r-sm); padding:12px 14px; font-size:12px; color:var(--muted); white-space:pre-wrap; word-break:break-word; max-height:240px; overflow:auto; margin:0; } +/* Agent facts grid — legible label/value pairs (renderAgentFacts emits div.fact > span.fk + span.fv); + without this the pairs render run-together ("statusIdle"). */ +.app .facts{ display:flex; flex-direction:column; gap:var(--space-2); } +.app .fact{ display:flex; align-items:baseline; justify-content:space-between; gap:var(--space-5); padding:6px 0; border-top:1px solid var(--line-soft); } +.app .fact:first-child{ border-top:0; } +.app .fk{ font-family:var(--font-mono); font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); flex:none; } +.app .fv{ font-family:var(--font-mono); font-size:13px; color:var(--text); text-align:right; word-break:break-word; } + +/* Session snapshot — the View page's PRIMARY element: full-width, tall, and readable. Scoped to the + snapshot alone so Settings' .readonly-block stays small. Replaces the old cramped 240px/12px sizing. */ +.app .snapshot-block{ background:var(--panel-2); border:1px solid var(--line); border-radius:var(--r-sm); padding:var(--space-5); font-size:13px; line-height:1.5; color:var(--text); white-space:pre-wrap; word-break:break-word; width:100%; min-height:60vh; max-height:70vh; overflow:auto; margin:0; } + /* ============================ RESPONSIVE ============================ */ @media (max-width:860px){ .app .cols, .app .cols.even, .app .proto-cols{ grid-template-columns:1fr; }