From 824c2728a294b6fa05d7b490b7a16f3a221acd3b Mon Sep 17 00:00:00 2001 From: Claude Agento Date: Sat, 18 Apr 2026 15:19:20 -0700 Subject: [PATCH 1/2] feat(daemon): CLI runner resolves args from pipe JSON When call.Args doesn't have a value for a declared arg, the CLI runner falls back to parsing the pipe JSON and extracting the field by name. Planner-provided args take precedence. Enables multi-stage pipelines where Claude produces JSON that a CLI stage consumes as command flags. beadle-vjo --- .beads/issues.jsonl | 2 + .ethos/missions.jsonl | 2 + internal/daemon/runner.go | 18 +++++++ internal/daemon/runner_test.go | 85 ++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index edd1d15..e9bff71 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -27,6 +27,7 @@ {"id":"beadle-4yg","title":"Pipeline v2: Stage 1 — Task-level design","description":"Worker: bwk. Evaluator: mdm. Read architecture doc (docs/pipeline-v2-design.md) and current code. Produce exact struct changes, function signatures, migration steps, test cases for T1-T7. Output: implementation spec detailed enough for mechanical execution. Parent: beadle-mvd","status":"closed","priority":2,"issue_type":"task","owner":"claude@punt-labs.com","created_at":"2026-04-18T07:56:16.70317152-07:00","created_by":"J F","updated_at":"2026-04-18T08:08:39.624098561-07:00","closed_at":"2026-04-18T08:08:39.624098561-07:00","close_reason":"Closed"} {"id":"beadle-5ck","title":"feat(daemon): per-command MCP config wiring from command YAML","status":"closed","priority":1,"issue_type":"task","owner":"claude@punt-labs.com","created_at":"2026-04-14T19:44:23.320125431-07:00","created_by":"J F","updated_at":"2026-04-14T20:19:14.855038248-07:00","closed_at":"2026-04-14T20:19:14.855042528-07:00"} {"id":"beadle-5f7","title":"SessionStart hook: auto-allow permissions + command deployment","status":"closed","priority":1,"issue_type":"task","owner":"jmf@pobox.com","created_at":"2026-03-15T12:40:13.416903-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-15T12:57:40.992793-07:00","closed_at":"2026-03-15T12:57:40.992793-07:00","close_reason":"SessionStart hook already exists and is wired in hooks.json. Permissions are auto-allowed."} +{"id":"beadle-5ma","title":"fix(release): include beadle-daemon in release binaries","description":"The release workflow (release.yml) only builds and uploads beadle-email. beadle-daemon is not included in GitHub Releases. Users must build from source. Both binaries should be cross-compiled and uploaded as release assets.","status":"open","priority":2,"issue_type":"bug","owner":"claude@punt-labs.com","created_at":"2026-04-18T15:05:22.502604109-07:00","created_by":"J F","updated_at":"2026-04-18T15:05:22.502604109-07:00"} {"id":"beadle-5tk","title":"feat(daemon): command YAML loader with GPG signature verification","status":"closed","priority":1,"issue_type":"task","owner":"claude@punt-labs.com","created_at":"2026-04-14T19:43:52.994660168-07:00","created_by":"J F","updated_at":"2026-04-14T20:19:14.735543354-07:00","closed_at":"2026-04-14T20:19:14.735549124-07:00"} {"id":"beadle-5xx","title":"Fix permission model bugs: remove owner override and removeContact gate","status":"closed","priority":1,"issue_type":"bug","owner":"jmf@pobox.com","created_at":"2026-03-19T21:45:39.127152-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-19T21:50:19.545956-07:00","closed_at":"2026-03-19T21:50:19.545956-07:00","close_reason":"Closed"} {"id":"beadle-607","title":"Set license to MIT: add LICENSE file, update README","status":"closed","priority":2,"issue_type":"task","owner":"jmf@pobox.com","created_at":"2026-03-18T06:03:36.687775-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-18T07:08:41.554646-07:00","closed_at":"2026-03-18T07:08:41.554646-07:00","close_reason":"Merged in PR #34. MIT LICENSE file added, README updated."} @@ -106,6 +107,7 @@ {"id":"beadle-twc","title":"Add --json, --verbose, --quiet global flags to CLI","status":"closed","priority":1,"issue_type":"task","owner":"jmf@pobox.com","created_at":"2026-03-18T06:03:37.253054-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-18T09:03:41.54664-07:00","closed_at":"2026-03-18T09:03:41.54664-07:00","close_reason":"Merged in PR #38. CLI parity (list/read/send/move/folders), global flags (--json/--verbose/--quiet), install/uninstall subcommands."} {"id":"beadle-tx7","title":"download_attachment: extract and return attachment content by part index","description":"read_message lists attachment metadata (filename, content type, size) but cannot extract the actual content. Add a download_attachment tool that takes message_id and part index, extracts the MIME part bytes, and returns them (base64 for binary, text for text parts). Add suppress-output handler. Enables reading attached documents, saving files locally, forwarding attachments.","status":"closed","priority":1,"issue_type":"feature","owner":"jmf@pobox.com","created_at":"2026-03-13T23:53:55.404699-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-14T10:55:45.209781-07:00","closed_at":"2026-03-14T10:55:45.209781-07:00","close_reason":"Implemented in feat/download-attachment branch"} {"id":"beadle-uce","title":"feat(daemon): Planner interface with LLM and Rule implementations","status":"closed","priority":1,"issue_type":"task","owner":"claude@punt-labs.com","created_at":"2026-04-14T19:43:58.103063784-07:00","created_by":"J F","updated_at":"2026-04-14T20:19:14.814887152-07:00","closed_at":"2026-04-14T20:19:14.814891862-07:00"} +{"id":"beadle-vjo","title":"feat(daemon): CLI runner resolves args from pipe JSON","description":"CLI runner falls back to pipe JSON fields when planner doesn't provide args. Enables multi-stage pipelines where stage 0 (Claude) produces JSON that stage 1 (CLI) consumes as command flags. Planner-provided args take precedence over pipe fields.","status":"in_progress","priority":2,"issue_type":"task","owner":"claude@punt-labs.com","created_at":"2026-04-18T15:15:15.280908795-07:00","created_by":"J F","updated_at":"2026-04-18T15:15:24.78236094-07:00"} {"id":"beadle-vkh","title":"Fix ethos repo config contract: active → agent","status":"closed","priority":1,"issue_type":"bug","owner":"jmf@pobox.com","created_at":"2026-03-21T22:37:14.757186-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-21T23:03:19.439572-07:00","closed_at":"2026-03-21T23:03:19.439572-07:00","close_reason":"Closed"} {"id":"beadle-vwm","title":"PGP verification consistency: read_message and trust model fixes","description":"PR #1 review feedback. Three related issues:\n\n1. `read_message` handler doesn't run PGP verification like `list_messages` — user sees `verified` in listing but `unverified` when reading the same message\n2. `sign.go` declares `Content-Transfer-Encoding: quoted-printable` but writes body verbatim — change to `7bit`\n3. Dead re-parse in `verify.go` L159-163 — `mail.ReadMessage` result immediately discarded\n\nFiles: `internal/mcp/tools.go`, `internal/pgp/sign.go`, `internal/pgp/verify.go`","status":"closed","priority":1,"issue_type":"bug","owner":"jmf@pobox.com","created_at":"2026-03-13T11:15:55.307398-07:00","created_by":"jmf-pobox","updated_at":"2026-03-13T11:17:01.718592-07:00","closed_at":"2026-03-13T11:17:01.718592-07:00","close_reason":"Closed"} {"id":"beadle-vyv","title":"design: beadle as orchestrator — MCP server spawns Claude Code for inbox processing","description":"## Problem\n\nThe current two-layer polling design (DES-015) has a sync gap: the MCP server detects new mail (background goroutine) but relies on a durable CronCreate job in Claude Code for processing. These can drift — CronCreate expires after 7 days, manual intervention breaks sync, session crashes leave one layer orphaned.\n\nIn the Docker sandbox model, this gets worse: who starts Claude Code? Today Claude Code starts beadle. In a sandbox, beadle is the long-running daemon.\n\n## Solution\n\nInvert the launch. beadle spawns Claude Code with a prompt file:\n\n claude --prompt-file .beadle/startup.md\n\nWhere startup.md is:\n\n /loop 5m /inbox 5m\n\nThis single command sets both layers atomically:\n1. /loop 5m creates the durable CronCreate job that fires /inbox every 5m (processing layer)\n2. /loop immediately executes /inbox 5m on the first fire\n3. /inbox 5m calls set_poll_interval → MCP server background goroutine starts (detection layer)\n\nBoth layers in sync from the first turn. No SessionStart hooks, no CLAUDE.md instructions, no hoping the model acts. The prompt IS a user message — the model always processes it.\n\n## Sandbox startup sequence\n\n sbx run claude --prompt-file .beadle/startup.md\n\nOr beadle spawns it internally:\n\n exec.Command(\"claude\", \"--prompt-file\", promptPath)\n\nThe sync problem disappears because there is no gap between \"server starts\" and \"processing starts.\" One atomic launch.\n\n## Open Questions\n\n- Does claude --prompt-file exist today? What flags control headless/batch execution?\n- Can beadle inside a sandbox spawn claude inside the same sandbox?\n- What happens when the Claude Code session ends (context limit, crash)? Does beadle detect and re-launch?\n- Does this violate \"zero agent authority\"? The owner's signed config (poll_interval in email.json) is the authorization. The prompt file is a static, auditable artifact.\n- How does this interact with N:1 multi-session via mcp-proxy? The prompt-file session is the \"processing\" session; interactive sessions connect separately.\n\n## Relationship to Current Work\n\n- Not blocking beadle-o1w (Docker image). Ship the current two-layer design first.\n- This is a future architecture for when beadle runs as a long-lived sandbox daemon.\n- The PreToolUse sync-check hook is the interim enforcement for the current design.","status":"closed","priority":2,"issue_type":"feature","owner":"claude@punt-labs.com","created_at":"2026-04-12T08:32:53.391598859-07:00","created_by":"J F","updated_at":"2026-04-14T00:05:40.329372659-07:00","closed_at":"2026-04-14T00:05:40.329375869-07:00"} diff --git a/.ethos/missions.jsonl b/.ethos/missions.jsonl index 19bba61..cb36b5d 100644 --- a/.ethos/missions.jsonl +++ b/.ethos/missions.jsonl @@ -33,3 +33,5 @@ {"id":"m-2026-04-18-077","created_at":"2026-04-18T14:56:51Z","closed_at":"2026-04-18T15:37:18Z","status":"failed","type":"report","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":[".tmp/review-pipeline-v2.md"],"success_criteria":["findings reported with severity and file:line references"],"rounds_used":1,"rounds_budgeted":1,"verdict":"fail","files_changed":[],"pipeline":"standard-2026-04-18-7c0858"} {"id":"m-2026-04-18-106","created_at":"2026-04-18T19:42:09Z","closed_at":"2026-04-18T19:47:19Z","status":"closed","type":"implement","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":["internal/mcp/"],"success_criteria":["make check passes","new code has tests"],"rounds_used":1,"rounds_budgeted":3,"verdict":"pass","files_changed":["internal/mcp/tools.go","internal/mcp/format.go","internal/mcp/smoke_test.go","internal/mcp/handler_test.go"],"pipeline":"quick-2026-04-18-cc18e3"} {"id":"m-2026-04-18-107","created_at":"2026-04-18T19:42:09Z","closed_at":"2026-04-18T19:52:22Z","status":"closed","type":"report","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":[".tmp/review-batch-move.md"],"success_criteria":["findings reported with severity and file:line references"],"rounds_used":1,"rounds_budgeted":1,"verdict":"pass","files_changed":[],"pipeline":"quick-2026-04-18-cc18e3"} +{"id":"m-2026-04-18-082","created_at":"2026-04-18T15:37:31Z","closed_at":"2026-04-18T22:15:56Z","status":"closed","type":"implement","leader":"claude","worker":"bwk","evaluator":"djb","ticket":"beadle-976","write_set":["internal/daemon/runner.go","internal/daemon/runner_test.go","internal/daemon/pipeline.go","internal/daemon/pipeline_test.go","internal/daemon/pipeline_edge_test.go","internal/daemon/mission.go"],"success_criteria":["CLIRunner sets explicit minimal Env on exec.Cmd","stderr buffers capped at 1MB","escapeYAMLValue not used for pipe values in buildStageContract","fireElse has no pipe parameter","go test -race passes"],"rounds_used":1,"rounds_budgeted":2,"verdict":"pass","files_changed":["internal/daemon/runner.go","internal/daemon/runner_test.go","internal/daemon/pipeline.go","internal/daemon/mission.go"]} +{"id":"m-2026-04-18-125","created_at":"2026-04-18T22:16:01Z","closed_at":"2026-04-18T22:19:09Z","status":"closed","type":"implement","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":["internal/daemon/"],"success_criteria":["make check passes","new code has tests"],"rounds_used":1,"rounds_budgeted":3,"verdict":"pass","files_changed":["internal/daemon/runner.go","internal/daemon/runner_test.go"],"pipeline":"quick-2026-04-18-7d7a42"} diff --git a/internal/daemon/runner.go b/internal/daemon/runner.go index 029dd25..3933be3 100644 --- a/internal/daemon/runner.go +++ b/internal/daemon/runner.go @@ -3,6 +3,7 @@ package daemon import ( "bytes" "context" + "encoding/json" "fmt" "io" "os" @@ -135,6 +136,13 @@ func (r *CLIRunner) Run(ctx context.Context, e *Executor, p *Pipeline, idx int, return "", err } + // Best-effort parse of pipe as JSON so declared args can fall back + // to fields from the previous stage's output. + var pipeFields map[string]any + if pipe != "" { + _ = json.Unmarshal([]byte(pipe), &pipeFields) + } + args := make([]string, len(cmd.FixedArgs)) copy(args, cmd.FixedArgs) @@ -147,6 +155,9 @@ func (r *CLIRunner) Run(ctx context.Context, e *Executor, p *Pipeline, idx int, for _, decl := range cmd.Args { val, ok := call.Args[decl.Name] + if !ok && pipeFields != nil { + val, ok = pipeFields[decl.Name] + } if !ok { continue } @@ -210,6 +221,13 @@ func (r *CLIRunner) Run(ctx context.Context, e *Executor, p *Pipeline, idx int, // runCompound chains multiple binaries via io.Pipe, running all steps // concurrently under a shared context timeout. func (r *CLIRunner) runCompound(ctx context.Context, e *Executor, cmd *Command, pipe string) (string, error) { + // Best-effort parse of pipe as JSON for future arg interpolation. + var pipeFields map[string]any + if pipe != "" { + _ = json.Unmarshal([]byte(pipe), &pipeFields) + } + _ = pipeFields // reserved for compound arg interpolation + timeout := 30 * time.Second if cmd.Timeout != "" { if d, err := time.ParseDuration(cmd.Timeout); err == nil { diff --git a/internal/daemon/runner_test.go b/internal/daemon/runner_test.go index 856079f..71462bc 100644 --- a/internal/daemon/runner_test.go +++ b/internal/daemon/runner_test.go @@ -442,3 +442,88 @@ func TestCLIRunner_CompoundPipeStdin(t *testing.T) { require.NoError(t, err) assert.Equal(t, "PIPE DATA", result) } + +func TestCLIRunner_ArgsFromPipe(t *testing.T) { + _, wl := setupWhitelist(t, "echo") + runner := &CLIRunner{Whitelist: wl} + + cmd := &Command{ + Name: "test-pipe-args", + Runner: "cli", + Mode: "process", + Binary: "echo", + FixedArgs: []string{"-n"}, + Args: []CommandArg{ + {Name: "title", Type: "string"}, + {Name: "type", Type: "string"}, + }, + OutputSchema: "text", + Timeout: "5s", + } + call := CommandCall{Command: "test-pipe-args", Args: map[string]any{}} + p := testPipeline() + + pipe := `{"title": "Fix auth", "type": "task"}` + result, err := runner.Run(context.Background(), &Executor{Logger: testLogger()}, p, 0, cmd, call, pipe) + require.NoError(t, err) + assert.Contains(t, result, "--title=Fix auth") + assert.Contains(t, result, "--type=task") +} + +func TestCLIRunner_ArgsPlannerOverridesPipe(t *testing.T) { + _, wl := setupWhitelist(t, "echo") + runner := &CLIRunner{Whitelist: wl} + + cmd := &Command{ + Name: "test-override", + Runner: "cli", + Mode: "process", + Binary: "echo", + FixedArgs: []string{"-n"}, + Args: []CommandArg{ + {Name: "title", Type: "string"}, + }, + OutputSchema: "text", + Timeout: "5s", + } + call := CommandCall{ + Command: "test-override", + Args: map[string]any{"title": "Override"}, + } + p := testPipeline() + + pipe := `{"title": "From pipe"}` + result, err := runner.Run(context.Background(), &Executor{Logger: testLogger()}, p, 0, cmd, call, pipe) + require.NoError(t, err) + assert.Contains(t, result, "--title=Override") + assert.NotContains(t, result, "From pipe") +} + +func TestCLIRunner_ArgsFromPipe_InvalidJSON(t *testing.T) { + _, wl := setupWhitelist(t, "echo") + runner := &CLIRunner{Whitelist: wl} + + cmd := &Command{ + Name: "test-bad-json", + Runner: "cli", + Mode: "process", + Binary: "echo", + FixedArgs: []string{"-n"}, + Args: []CommandArg{ + {Name: "title", Type: "string"}, + }, + OutputSchema: "text", + Timeout: "5s", + } + call := CommandCall{ + Command: "test-bad-json", + Args: map[string]any{"title": "FromArgs"}, + } + p := testPipeline() + + // Pipe is not valid JSON — should not crash, args from call.Args only. + pipe := "this is not json" + result, err := runner.Run(context.Background(), &Executor{Logger: testLogger()}, p, 0, cmd, call, pipe) + require.NoError(t, err) + assert.Contains(t, result, "--title=FromArgs") +} From fd0d6f5e92626221a20fe2ca355c518be7539496 Mon Sep 17 00:00:00 2001 From: Claude Agento Date: Sat, 18 Apr 2026 15:26:22 -0700 Subject: [PATCH 2/2] fix(daemon): address 3 Copilot findings on pipe-to-args - TODO comment: pipe-derived args bypass ValidateArgs (accepted risk) - Pre-check: skip JSON parse when pipe doesn't start with '{' - Remove dead pipeFields parse in runCompound --- internal/daemon/runner.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/daemon/runner.go b/internal/daemon/runner.go index 3933be3..f718dc4 100644 --- a/internal/daemon/runner.go +++ b/internal/daemon/runner.go @@ -139,7 +139,7 @@ func (r *CLIRunner) Run(ctx context.Context, e *Executor, p *Pipeline, idx int, // Best-effort parse of pipe as JSON so declared args can fall back // to fields from the previous stage's output. var pipeFields map[string]any - if pipe != "" { + if trimmed := strings.TrimSpace(pipe); len(trimmed) > 0 && trimmed[0] == '{' { _ = json.Unmarshal([]byte(pipe), &pipeFields) } @@ -155,6 +155,9 @@ func (r *CLIRunner) Run(ctx context.Context, e *Executor, p *Pipeline, idx int, for _, decl := range cmd.Args { val, ok := call.Args[decl.Name] + // TODO(beadle-vjo): pipe-derived args bypass ValidateArgs type constraints. + // Prior stage schema validation covers this for now. Add runtime validation + // when arg types are enforced at execution time. if !ok && pipeFields != nil { val, ok = pipeFields[decl.Name] } @@ -221,13 +224,6 @@ func (r *CLIRunner) Run(ctx context.Context, e *Executor, p *Pipeline, idx int, // runCompound chains multiple binaries via io.Pipe, running all steps // concurrently under a shared context timeout. func (r *CLIRunner) runCompound(ctx context.Context, e *Executor, cmd *Command, pipe string) (string, error) { - // Best-effort parse of pipe as JSON for future arg interpolation. - var pipeFields map[string]any - if pipe != "" { - _ = json.Unmarshal([]byte(pipe), &pipeFields) - } - _ = pipeFields // reserved for compound arg interpolation - timeout := 30 * time.Second if cmd.Timeout != "" { if d, err := time.ParseDuration(cmd.Timeout); err == nil {