Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions cmd/gatepreflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 18 additions & 11 deletions cmd/launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
},
}

Expand Down
12 changes: 6 additions & 6 deletions cmd/tier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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)
}
Expand Down
46 changes: 34 additions & 12 deletions internal/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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,
}

Expand All @@ -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,
}

Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 45 additions & 11 deletions internal/harness/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
Expand Down