diff --git a/README.md b/README.md index 8394a29..fef1007 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ A brief can declare its own `model` and `effort` for one task, which win over th Workers run their harness interactively so they can be steered and watched. For Claude Code that means first-run dialogs, and `hand spawn` and `hand promote` answer the workspace-trust and bypass-permissions ones for you, then confirm the worker is actually running before reporting success. A worker that never comes up fails the spawn instead of being reported as started. -The exception is Claude Code's managed-settings approval prompt on hosts with organization-managed settings: accepting it is a host-wide trust decision, so `hand` refuses it and tells you to accept it yourself once and respawn. +Claude Code's managed-settings approval prompt and Codex's directory-trust prompt are exceptions: each enables settings or policies that can run project or host code, so `hand` refuses to accept the security decision for you and tells you to run the harness yourself once before respawning. ## Contributing diff --git a/cmd/gatepreflight_test.go b/cmd/gatepreflight_test.go index e3f78c1..9e5e8a3 100644 --- a/cmd/gatepreflight_test.go +++ b/cmd/gatepreflight_test.go @@ -204,8 +204,8 @@ func TestPromoteRefusesWhenNoMistakesGateNotInitialized(t *testing.T) { } } -// Pins the ordering from atqamz/secondhand#156. Codex carries no prompt, so resolveTier always -// has something to warn about here, and a gate refusal must preempt that warning. +// Pins the ordering from atqamz/secondhand#156: a gate refusal must preempt launch warnings because +// no launch will occur. func TestPromoteGateRefusalWarnsNothingAboutTheLaunch(t *testing.T) { path := fakeNoMistakesPath(t, "repo not initialized (run 'no-mistakes init' first)") setupPromoteHomeGate(t, path) diff --git a/cmd/launch_test.go b/cmd/launch_test.go index a371e14..c93ae35 100644 --- a/cmd/launch_test.go +++ b/cmd/launch_test.go @@ -104,13 +104,14 @@ esac } const ( - launchEchoFrame = "$ cd '/tmp/wt' && claude --dangerously-skip-permissions 'Read the brief'" - launchReadyFrame = "Welcome to Claude Code\n\n> \n ? for shortcuts" - launchBypassOnFrame = "> \n secondhand (fm/x)\n bypass permissions on (shift+tab to cycle)" - launchTrustFrame = "Do you trust the files in this folder?\n> 1. Yes, I trust this folder\n 2. No\n\nEnter to confirm" - launchBypassFrame = "WARNING: Bypass Permissions mode\n 1. Yes, I accept\n> 2. No, exit\n\nEnter to confirm" - launchSettingsFrame = "Managed settings require approval\n\nSettings requiring approval:\n hooks\n> 1. Yes, I trust these settings\n 2. No, exit Claude Code\n\nEnter to confirm" - launchUnknownFrame = "Some brand new dialog\n> 1. Sure\n 2. Nope\n\nEnter to confirm" + launchEchoFrame = "$ cd '/tmp/wt' && claude --dangerously-skip-permissions 'Read the brief'" + launchReadyFrame = "Welcome to Claude Code\n\n> \n ? for shortcuts" + launchBypassOnFrame = "> \n secondhand (fm/x)\n bypass permissions on (shift+tab to cycle)" + launchTrustFrame = "Do you trust the files in this folder?\n> 1. Yes, I trust this folder\n 2. No\n\nEnter to confirm" + launchBypassFrame = "WARNING: Bypass Permissions mode\n 1. Yes, I accept\n> 2. No, exit\n\nEnter to confirm" + launchSettingsFrame = "Managed settings require approval\n\nSettings requiring approval:\n hooks\n> 1. Yes, I trust these settings\n 2. No, exit Claude Code\n\nEnter to confirm" + launchCodexTrustFrame = "Do you trust the contents of this directory?\n\nTrusting enables project-local config, hooks, and exec policies.\n> 1. Yes, continue\n 2. No, quit\n\nPress enter to continue" + launchUnknownFrame = "Some brand new dialog\n> 1. Sure\n 2. Nope\n\nEnter to confirm" ) func TestConfirmLaunch(t *testing.T) { @@ -179,6 +180,12 @@ func TestConfirmLaunch(t *testing.T) { frames: []launchFrame{live(launchSettingsFrame)}, wantErr: "waiting on the managed settings prompt", }, + { + name: "refuses the codex directory trust prompt instead of answering it", + harness: "codex", + frames: []launchFrame{{text: launchCodexTrustFrame, agent: "codex"}}, + wantErr: "waiting on the directory trust prompt", + }, { // The refused signature is catalogued after the answerable one, so answering by list // order would send keys into a dialog hand has decided not to answer. @@ -223,12 +230,12 @@ func TestConfirmLaunch(t *testing.T) { wantKeys: "Enter\n", }, { - // codex has no verified agent detection, so "no agent" cannot be blamed on the harness + // grok has no verified agent detection, so "no agent" cannot be blamed on the harness // without also naming the thing hand has not checked. name: "an unverified harness that is never labeled names the unexercised detection", - harness: "codex", - frames: []launchFrame{exited("$ cd '/tmp/wt' && codex --file '/tmp/brief.md'")}, - wantErr: "no agent detected in pane; herdr agent detection for harness codex has not been exercised", + harness: "grok", + frames: []launchFrame{exited("$ cd '/tmp/wt' && grok --trust --file '/tmp/brief.md'")}, + wantErr: "no agent detected in pane; herdr agent detection for harness grok has not been exercised", }, } diff --git a/cmd/tier_test.go b/cmd/tier_test.go index 0584ff0..492d671 100644 --- a/cmd/tier_test.go +++ b/cmd/tier_test.go @@ -153,11 +153,11 @@ func TestResolveTierWarnsWhenHarnessCannotApplyEffort(t *testing.T) { } } -// All three prompt-less builders are near-copies, so proving only codex warns is what lets grok or -// pi regress. Exact-match rather than Contains because the single combined line is the point: the +// Both prompt-less builders are near-copies, so proving only one warns lets the other regress. +// Exact-match rather than Contains because the single combined line is the point: the // alternative was three warnings per launch all naming the same harness. func TestResolveTierWarnsOnceForEverythingAHarnessCannotCarry(t *testing.T) { - for _, harnessName := range []string{harness.Codex, harness.Grok, harness.Pi} { + for _, harnessName := range []string{harness.Grok, harness.Pi} { t.Run(harnessName, func(t *testing.T) { home := t.TempDir() briefAbs := writeTierBrief(t, home, declaredBrief) @@ -181,7 +181,7 @@ func TestResolveTierWarnsOnceForEverythingAHarnessCannotCarry(t *testing.T) { // A brief with no front matter has no disclaimer to drop, so the operator-decision rule is the // only thing left and the line must not claim otherwise. func TestResolveTierWarnsOnPromptDropWithNothingDeclared(t *testing.T) { - for _, harnessName := range []string{harness.Codex, harness.Grok, harness.Pi} { + for _, harnessName := range []string{harness.Grok, harness.Pi} { t.Run(harnessName, func(t *testing.T) { home := t.TempDir() briefAbs := writeTierBrief(t, home, "# Title\n\nno declaration here\n") @@ -216,10 +216,10 @@ func TestResolveTierNoWarningWhenNoModelResolved(t *testing.T) { briefAbs := writeTierBrief(t, home, "---\neffort: brief-effort\n---\n# Title\n") cmd, stderr := newTierTestCmd() - if _, _, _, err := resolveTier(cmd, home, briefAbs, harness.Codex, "", ""); err != nil { + if _, _, _, err := resolveTier(cmd, home, briefAbs, harness.Grok, "", ""); err != nil { t.Fatal(err) } - want := `warning: harness "codex" cannot carry effort "brief-effort", the operator-decision rule, the front-matter disclaimer; launching anyway` + "\n" + want := `warning: harness "grok" cannot carry effort "brief-effort", the operator-decision rule, the front-matter disclaimer; launching anyway` + "\n" if stderr.String() != want { t.Fatalf("stderr = %q, want exactly %q (no model named, none was resolved)", stderr.String(), want) } diff --git a/internal/harness/harness.go b/internal/harness/harness.go index f7ca8c4..e635c4e 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -51,9 +51,9 @@ type FirstRunPrompts struct { Unrecognized *regexp.Regexp } -// Verified signatures per harness. Only claude has been verified against a real first run (see -// cmd/launch.go); every other harness gets the zero value until one is, which leaves its launch -// confirmed on agent presence alone - one parking on a dialog is still reported as started. +// Verified signatures per harness. Claude and codex have been observed on real first runs; every +// other harness gets the zero value until one is, leaving its launch confirmed on agent presence +// alone even if it parks on a dialog. var firstRunPrompts = map[string]FirstRunPrompts{ // Interactive claude gates on first-run dialogs --print skipped, and a fresh worktree path means // the trust one appears on every spawn, not just a fresh host (SPECS.md, launch templates). @@ -90,6 +90,16 @@ var firstRunPrompts = map[string]FirstRunPrompts{ // the dialog for a started worker. Unrecognized: regexp.MustCompile(`Enter\s+to\s+confirm`), }, + Codex: { + Known: []FirstRunPrompt{ + { + Name: "directory trust", + Match: regexp.MustCompile(`Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?`), + Refuse: "trusting this directory enables project-local config, hooks, and exec policies; hand will not accept that security decision for you; run codex yourself in this checkout once and choose whether to trust it, then respawn", + }, + }, + Unrecognized: regexp.MustCompile(`Press\s+enter\s+to\s+continue`), + }, } // FirstRunPromptsFor returns name's verified first-run signatures, or the zero value if name has @@ -99,11 +109,12 @@ func FirstRunPromptsFor(name string) FirstRunPrompts { return firstRunPrompts[name] } -// The harnesses whose panes herdr has been observed labeling with an agent, by running the real -// binary in a real pane. The others ship a detection manifest under herdr's agent-detection state -// dir, read but never exercised here because no binary for them is installed on this host. +// The harnesses whose panes herdr has been observed labeling with an agent. Codex CLI 0.146.0 was +// launched through hand, reported as codex while resident, and cleared after /quit; the false +// entries still rely only on herdr's shipped detection manifests. var agentDetectionVerified = map[string]bool{ Claude: true, + Codex: true, OpenCode: true, } @@ -124,15 +135,18 @@ type Options struct { var modelCapable = map[string]bool{ Claude: true, + Codex: true, OpenCode: true, } var effortCapable = map[string]bool{ Claude: true, + Codex: true, } var promptCapable = map[string]bool{ Claude: true, + Codex: true, OpenCode: true, } @@ -158,8 +172,8 @@ func CarriesPrompt(name string) bool { // watch classifies its lifecycle, and a no-mistakes pipeline drives many turns a one-shot cannot. func Build(name string, opts Options) (string, error) { var launch string - // Flags for claude and opencode are verified against the installed CLI's own --help, and this file - // is the source of truth for those two. + // Flags for claude, codex, and opencode are verified against the installed CLI's own --help, and + // this file is the source of truth for those three. switch name { case Claude: launch = buildClaude(opts) @@ -195,11 +209,19 @@ func buildClaude(o Options) string { return strings.Join(args, " ") } -// No binary for codex, grok or pi exists on this host, so these three fall back to SPECS.md's -// template syntax and need re-verifying for interactive launch, not just flag names, once one is -// installable. +// Launches Codex CLI 0.146.0 interactively with its positional prompt. Its help and config schema +// expose the flags below; paste-burst buffering otherwise absorbs hand send's immediate Enter, and +// auto effort means inherit Codex's default rather than pass a literal value. func buildCodex(o Options) string { - return fmt.Sprintf("codex --file %s", shellQuote(o.Brief)) + args := []string{"codex", "--dangerously-bypass-approvals-and-sandbox", "-c", shellQuote("disable_paste_burst=true")} + if o.Model != "" { + args = append(args, "--model", shellQuote(o.Model)) + } + if o.Effort != "" && o.Effort != "auto" { + args = append(args, "-c", shellQuote(fmt.Sprintf(`model_reasoning_effort="%s"`, o.Effort))) + } + args = append(args, shellQuote(briefPrompt(o))) + return strings.Join(args, " ") } func buildGrok(o Options) string { diff --git a/internal/harness/harness_test.go b/internal/harness/harness_test.go index bab189b..9234ed2 100644 --- a/internal/harness/harness_test.go +++ b/internal/harness/harness_test.go @@ -85,12 +85,37 @@ func TestBuildCodex(t *testing.T) { if err != nil { t.Fatal(err) } - want := "cd '/tmp/wt' && codex --file '/tmp/brief.md'" + want := "cd '/tmp/wt' && codex --dangerously-bypass-approvals-and-sandbox -c 'disable_paste_burst=true' " + quotedPrompt("/tmp/brief.md") if got != want { t.Fatalf("got %q, want %q", got, want) } } +func TestBuildCodexWithModelAndEffort(t *testing.T) { + got, err := Build(Codex, Options{Worktree: "/tmp/wt", Brief: "/tmp/brief.md", Model: "gpt-5.6-codex", Effort: "high"}) + if err != nil { + t.Fatal(err) + } + want := "cd '/tmp/wt' && codex --dangerously-bypass-approvals-and-sandbox -c 'disable_paste_burst=true' --model 'gpt-5.6-codex' -c 'model_reasoning_effort=\"high\"' " + quotedPrompt("/tmp/brief.md") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestBuildCodexOmitsAutoEffort(t *testing.T) { + got, err := Build(Codex, Options{Worktree: "/tmp/wt", Brief: "/tmp/brief.md", Effort: "auto"}) + if err != nil { + t.Fatal(err) + } + want := "cd '/tmp/wt' && codex --dangerously-bypass-approvals-and-sandbox -c 'disable_paste_burst=true' " + quotedPrompt("/tmp/brief.md") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + if !SupportsEffort(Codex) { + t.Fatal("SupportsEffort(Codex) = false, want true for explicit non-auto efforts") + } +} + func TestBuildGrok(t *testing.T) { got, err := Build(Grok, Options{Worktree: "/tmp/wt", Brief: "/tmp/brief.md"}) if err != nil { @@ -179,14 +204,22 @@ func TestBuildOpenCodeFrontMatterDisclaimer(t *testing.T) { } } +func TestBuildCodexFrontMatterDisclaimer(t *testing.T) { + got, err := Build(Codex, Options{Worktree: "/tmp/wt", Brief: "/tmp/brief.md", BriefHasFrontMatter: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "dispatch metadata") { + t.Fatalf("got %q, want the front matter disclaimed", got) + } +} + // Pins the only channel that rule has: a worker's worktree is never under the fleet home, so the // AGENTS.md copy of it never reaches the worker. func TestBuildCarriesOperatorDecisionRule(t *testing.T) { quoted := shellQuote(agentsmd.OperatorDecisionRule) escaped := quoted[1 : len(quoted)-1] - // Codex, Grok and Pi launch with a bare --file and no inline prompt, so they are out of reach - // until one of them is verified. - for _, name := range []string{Claude, OpenCode} { + for _, name := range []string{Claude, Codex, OpenCode} { got, err := Build(name, Options{Worktree: "/tmp/wt", Brief: "/tmp/brief.md"}) if err != nil { t.Fatalf("Build(%q) error: %v", name, err) @@ -222,7 +255,8 @@ func TestSupportsEffort(t *testing.T) { if err != nil { t.Fatalf("Build(%q) error: %v", name, err) } - if emits := strings.Contains(got, "--effort 'some-effort'"); emits != SupportsEffort(name) { + emits := strings.Contains(got, "--effort 'some-effort'") || strings.Contains(got, `model_reasoning_effort="some-effort"`) + if emits != SupportsEffort(name) { t.Errorf("SupportsEffort(%q) = %v but Build(%q) = %q", name, SupportsEffort(name), name, got) } } @@ -305,23 +339,23 @@ func TestFirstRunPromptsClaude(t *testing.T) { } } -// Pins the two harnesses actually run in a real pane and observed being labeled by herdr; the rest -// must stay false until each is exercised the same way. +// Pins the harnesses actually run in a real pane and observed being labeled by herdr; the rest must +// stay false until each is exercised the same way. func TestAgentDetectionVerified(t *testing.T) { - for _, name := range []string{Claude, OpenCode} { + for _, name := range []string{Claude, Codex, OpenCode} { if !AgentDetectionVerified(name) { t.Errorf("AgentDetectionVerified(%q) = false, want true", name) } } - for _, name := range []string{Codex, Grok, Pi, "nonexistent"} { + for _, name := range []string{Grok, Pi, "nonexistent"} { if AgentDetectionVerified(name) { t.Errorf("AgentDetectionVerified(%q) = true, want false until herdr detection is exercised against it", name) } } } -func TestFirstRunPromptsUnverifiedHarness(t *testing.T) { - for _, name := range []string{Codex, Grok, Pi, OpenCode, "nonexistent"} { +func TestFirstRunPromptsWithoutVerifiedSignatures(t *testing.T) { + for _, name := range []string{Grok, Pi, OpenCode, "nonexistent"} { if got := FirstRunPromptsFor(name); got.Ready != nil || got.Known != nil || got.Unrecognized != nil { t.Errorf("FirstRunPromptsFor(%q) = %+v, want no unverified signatures", name, got) }