From 3f7eb7f09be5c8b738605ba1c7dc488039be6257 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:57:40 +0000 Subject: [PATCH 01/22] feat(gitagent): verified substrate harness and protocol ref/envelope core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the SPEC-git-agent-protocol §1 verification harness as hack/gitagent_empirical.sh: quarantine env leakage and the --local-env-vars omission of GIT_QUARANTINE_PATH (R1.1), push-option survival and the advertisePushOptions default (R1.2), quarantined-tree materialization and the relative work-tree trap (R1.3/H18), and the no-copy relay (R1.4). A colocated test reruns it against the installed git so a git upgrade that shifts any of these behaviours fails loudly. On git 2.43.0 the relative-work-tree probe materializes instead of silently no-opping; the harness records the divergence and the implementation absolutizes regardless. pkg/gitagent gains the pure-data core: ref naming and parsing for refs/captain/tasks//{dispatch,control,result,verdict}/ and the agent branch, task-id and attempt validation, separator-aware namespace containment (R8.3/H11), and the push-option control envelope with strict decode and envelope↔ref agreement checks (R4.1). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- .gitignore | 5 + hack/gitagent_empirical.sh | 250 ++++++++++++++++++++++++++ pkg/gitagent/envelope.go | 215 ++++++++++++++++++++++ pkg/gitagent/envelope_ginkgo_test.go | 135 ++++++++++++++ pkg/gitagent/gitagent_suite_test.go | 13 ++ pkg/gitagent/refs.go | 167 +++++++++++++++++ pkg/gitagent/refs_ginkgo_test.go | 95 ++++++++++ pkg/gitagent/substrate_ginkgo_test.go | 28 +++ 8 files changed, 908 insertions(+) create mode 100755 hack/gitagent_empirical.sh create mode 100644 pkg/gitagent/envelope.go create mode 100644 pkg/gitagent/envelope_ginkgo_test.go create mode 100644 pkg/gitagent/gitagent_suite_test.go create mode 100644 pkg/gitagent/refs.go create mode 100644 pkg/gitagent/refs_ginkgo_test.go create mode 100644 pkg/gitagent/substrate_ginkgo_test.go diff --git a/.gitignore b/.gitignore index 8389c11e..5d6c32b9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,12 @@ dist/ pkg/cli/webapp/dist/* !pkg/cli/webapp/dist/.gitkeep .grite/ +# hack/ is local scratch space, except the git-agent substrate harness the +# protocol tests rerun on every git version (SPEC-git-agent-protocol §1). hack/ +!hack/ +hack/* +!hack/gitagent_empirical.sh .ok/ .okignore .ginkgo/ diff --git a/hack/gitagent_empirical.sh b/hack/gitagent_empirical.sh new file mode 100755 index 00000000..ebbd7929 --- /dev/null +++ b/hack/gitagent_empirical.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +# gitagent_empirical.sh — verifies the git behaviours the git-agent protocol +# (SPEC-git-agent-protocol §1) relies on, against the git on PATH. +# +# The four properties: +# 1.1 receive-pack quarantine env leaks into hook descendants, and +# `rev-parse --local-env-vars` does NOT list GIT_QUARANTINE_PATH, +# so the githooks(5) scrub idiom leaves it set (R1.1). +# 1.2 push options survive byte-identical under receive.advertisePushOptions, +# and a push with options fails against a receiver that has not +# advertised them (R1.2). +# 1.3 read-tree + checkout-index materializes a quarantined tree when the +# work tree is absolute; the relative form is a trap (R1.3, H18). +# 1.4 a relay push from inside pre-receive succeeds once GIT_QUARANTINE_PATH +# alone is unset, with the inherited object directories retained (R1.4). +# +# Output is TAP-like; exit status is non-zero if any check fails. +# Rerun this before trusting the protocol on a new git version. + +set -u + +ROOT="$(mktemp -d "${TMPDIR:-/tmp}/gitagent-empirical.XXXXXX")" +trap 'rm -rf "$ROOT"' EXIT + +N=0 +FAIL=0 + +ok() { + N=$((N + 1)) + echo "ok $N - $1" +} + +not_ok() { + N=$((N + 1)) + FAIL=$((FAIL + 1)) + echo "not ok $N - $1" +} + +# assert +assert() { + if [ "$1" -eq 0 ]; then ok "$2"; else not_ok "$2"; fi +} + +g() { + git -c user.name=captain -c user.email=captain@localhost \ + -c init.defaultBranch=main -c protocol.file.allow=always "$@" +} + +# mkclient — working repo with a three-file commit (one nested path). +mkclient() { + g init -q "$1" + mkdir -p "$1/pkg/deep" + echo alpha >"$1/alpha.txt" + echo beta >"$1/pkg/beta.txt" + echo gamma >"$1/pkg/deep/gamma.txt" + g -C "$1" add -A + g -C "$1" commit -q -m seed +} + +echo "# git version: $(git version)" + +# --- 1.1 quarantine leak + --local-env-vars omission -------------------------- + +r11="$ROOT/r11" +client11="$ROOT/client11" +unrelated="$ROOT/unrelated" +out11="$ROOT/out11" +mkdir -p "$out11" +g init -q --bare "$r11" +mkclient "$client11" +mkclient "$unrelated" +unrelated_head="$(g -C "$unrelated" rev-parse HEAD)" + +cat >"$r11/hooks/pre-receive" </dev/null +printf '%s' "\${GIT_QUARANTINE_PATH:-}" >"$out11/quarantine_path" +sh -c "cd '$unrelated' && git update-ref refs/heads/probe-leak $unrelated_head" \ + 2>"$out11/leak_err" +echo \$? >"$out11/leak_rc" +sh -c "unset GIT_QUARANTINE_PATH GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE \ + GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; \ + cd '$unrelated' && git update-ref refs/heads/probe-scrubbed $unrelated_head" \ + 2>"$out11/scrub_err" +echo \$? >"$out11/scrub_rc" +git rev-parse --local-env-vars >"$out11/local_env_vars" +exit 0 +EOF +chmod +x "$r11/hooks/pre-receive" + +g -C "$client11" push -q "$r11" HEAD:refs/heads/main 2>"$out11/push_err" +assert $? "1.1 push driving the quarantine probe succeeds" + +test -s "$out11/quarantine_path" +assert $? "1.1 GIT_QUARANTINE_PATH is set in the pre-receive environment" + +test "$(cat "$out11/leak_rc" 2>/dev/null)" = "128" +assert $? "1.1 inherited env breaks a descendant git in an unrelated repo (rc=128)" + +test "$(cat "$out11/scrub_rc" 2>/dev/null)" = "0" && + g -C "$unrelated" rev-parse -q --verify refs/heads/probe-scrubbed >/dev/null +assert $? "1.1 scrubbing the R1.1 variable list makes the same command succeed" + +! grep -q GIT_QUARANTINE_PATH "$out11/local_env_vars" +assert $? "1.1 rev-parse --local-env-vars omits GIT_QUARANTINE_PATH" + +# --- 1.2 push options --------------------------------------------------------- + +r12="$ROOT/r12" +client12="$ROOT/client12" +out12="$ROOT/out12" +mkdir -p "$out12" +g init -q --bare "$r12" +g -C "$r12" config receive.advertisePushOptions true +mkclient "$client12" + +cat >"$r12/hooks/pre-receive" </dev/null +printf '%s' "\${GIT_PUSH_OPTION_COUNT:-}" >"$out12/count" +printf '%s' "\${GIT_PUSH_OPTION_0:-}" >"$out12/opt0" +printf '%s' "\${GIT_PUSH_OPTION_1:-}" >"$out12/opt1" +exit 0 +EOF +chmod +x "$r12/hooks/pre-receive" + +g -C "$client12" push -q \ + --push-option=captain-envelope-v1 --push-option=attempt=2 \ + "$r12" HEAD:refs/heads/main 2>"$out12/push_err" +assert $? "1.2 push with options succeeds when advertised" + +test "$(cat "$out12/count")" = "2" && + test "$(cat "$out12/opt0")" = "captain-envelope-v1" && + test "$(cat "$out12/opt1")" = "attempt=2" +assert $? "1.2 both options arrive byte-identical in pre-receive" + +r12b="$ROOT/r12b" +g init -q --bare "$r12b" # receive.advertisePushOptions left at the false default +if g -C "$client12" push -q --push-option=x "$r12b" HEAD:refs/heads/main \ + 2>"$out12/noadv_err"; then + not_ok "1.2 push with options fails against a non-advertising receiver" +else + if g -C "$r12b" rev-parse -q --verify refs/heads/main >/dev/null; then + not_ok "1.2 rejected options push leaves no ref behind" + else + ok "1.2 push with options fails outright when not advertised" + fi +fi + +# --- 1.3 materialization ------------------------------------------------------ + +r13="$ROOT/r13" +client13="$ROOT/client13" +out13="$ROOT/out13" +abswt="$ROOT/abswt" +mkdir -p "$out13" "$abswt" +g init -q --bare "$r13" +mkclient "$client13" + +cat >"$r13/hooks/pre-receive" <"$out13/expected" + +idx="$out13/idx-abs" +GIT_INDEX_FILE="\$idx" git read-tree "\$new" && + GIT_INDEX_FILE="\$idx" GIT_WORK_TREE="$abswt" git checkout-index -a -f +echo \$? >"$out13/abs_rc" +find "$abswt" -type f | wc -l | tr -d ' ' >"$out13/abs_count" + +mkdir -p relwt +idxrel="$out13/idx-rel" +GIT_INDEX_FILE="\$idxrel" git read-tree "\$new" && + GIT_INDEX_FILE="\$idxrel" GIT_WORK_TREE=relwt git checkout-index -a -f +echo \$? >"$out13/rel_rc" +find relwt -type f | wc -l | tr -d ' ' >"$out13/rel_count" +exit 0 +EOF +chmod +x "$r13/hooks/pre-receive" + +g -C "$client13" push -q "$r13" HEAD:refs/heads/main 2>"$out13/push_err" +assert $? "1.3 push driving the materialization probe succeeds" + +expected="$(tr -d ' ' <"$out13/expected")" +test "$(cat "$out13/abs_rc")" = "0" && + test -n "$expected" && test "$expected" -gt 0 && + test "$(cat "$out13/abs_count")" = "$expected" +assert $? "1.3 absolute work-tree materializes the full quarantined tree ($expected files)" + +rel_rc="$(cat "$out13/rel_rc" 2>/dev/null)" +rel_count="$(cat "$out13/rel_count" 2>/dev/null)" +if [ "$rel_rc" = "0" ] && [ "$rel_count" = "0" ]; then + ok "1.3 relative work-tree writes nothing while exiting 0 (H18 confirmed)" +elif [ "$rel_rc" != "0" ]; then + ok "1.3 relative work-tree fails visibly on this git (rc=$rel_rc; safe, H18 moot)" +else + # It materialized where the naive reading expects. Still not a failure for + # the protocol (which always absolutizes) but flag the substrate change. + ok "1.3 relative work-tree materialized $rel_count files on this git # H18 behaviour differs; absolutizing remains correct" +fi + +# --- 1.4 relay from inside pre-receive --------------------------------------- + +sidecar="$ROOT/sidecar" +upstream="$ROOT/upstream" +client14="$ROOT/client14" +out14="$ROOT/out14" +mkdir -p "$out14" +g init -q --bare "$sidecar" +g init -q --bare "$upstream" +mkclient "$client14" + +cat >"$sidecar/hooks/pre-receive" <"$out14/naive_err" +echo \$? >"$out14/naive_rc" +env -u GIT_QUARANTINE_PATH \ + git push "$upstream" "\$new:refs/heads/relay-scrubbed" \ + 2>"$out14/scrubbed_err" +echo \$? >"$out14/scrubbed_rc" +exit 0 +EOF +chmod +x "$sidecar/hooks/pre-receive" + +g -C "$client14" push -q "$sidecar" HEAD:refs/heads/main 2>"$out14/push_err" +assert $? "1.4 push driving the relay probe succeeds" + +test "$(cat "$out14/naive_rc")" != "0" && + grep -qi quarantine "$out14/naive_err" +assert $? "1.4 naive relay is refused by the upstream quarantine guard" + +! g -C "$upstream" rev-parse -q --verify refs/heads/relay-quarantined >/dev/null +assert $? "1.4 refused relay left no ref upstream" + +pushed="$(g -C "$client14" rev-parse HEAD)" +test "$(cat "$out14/scrubbed_rc")" = "0" && + test "$(g -C "$upstream" rev-parse refs/heads/relay-scrubbed 2>/dev/null)" = "$pushed" +assert $? "1.4 unsetting GIT_QUARANTINE_PATH alone lets the relay through" + +# ------------------------------------------------------------------------------ + +echo "1..$N" +if [ "$FAIL" -ne 0 ]; then + echo "# $FAIL of $N checks failed" + exit 1 +fi +echo "# all $N checks passed" diff --git a/pkg/gitagent/envelope.go b/pkg/gitagent/envelope.go new file mode 100644 index 00000000..5f1861db --- /dev/null +++ b/pkg/gitagent/envelope.go @@ -0,0 +1,215 @@ +// The control envelope rides on push options (§4): option 0 is the version +// tag, the remainder are key=value pairs. Envelope values are never read from +// commit trailers — trailers are heuristically extracted and forgeable (R4.2). +package gitagent + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const ( + // EnvelopeVersionTag is push option 0 for protocol v1. + EnvelopeVersionTag = "captain-envelope-v1" + + // ProtocolVersion is the version this implementation speaks. + ProtocolVersion = 1 + + // MaxHookDepth bounds hook-recursion depth (R5.4/H15). Depth 0 is the top + // level; a prompt hook increments it per nesting level. + MaxHookDepth = 4 + + // maxPushOptions bounds how many options a decoder will look at. + maxPushOptions = 16 +) + +// RelayMode selects how the sidecar reports the supervisor's verdict (§6.4). +type RelayMode string + +const ( + // RelaySync blocks the agent's push until the supervisor has decided. It + // is the default and MUST be supported (R6.8). + RelaySync RelayMode = "sync" + // RelayAsync accepts after hook set #1 and reports out-of-band. It MUST + // NOT be the default (R6.8). + RelayAsync RelayMode = "async" +) + +// oidRe accepts SHA-1 or SHA-256 object names. +var oidRe = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`) + +// ValidateOID checks that s is a full hex object id. +func ValidateOID(s string) error { + if !oidRe.MatchString(s) { + return fmt.Errorf("%q is not a full object id", s) + } + return nil +} + +// Envelope is the §4 control envelope. +type Envelope struct { + Version int `json:"v"` + Task string `json:"task"` + Attempt int `json:"attempt"` + Base string `json:"base"` // supervisor HEAD OID at dispatch (R10.1) + Depth int `json:"depth"` // hook-recursion depth, 0 at top level + Agent string `json:"agent,omitempty"` // dispatch only: target agent + Relay RelayMode `json:"relay,omitempty"` // dispatch only +} + +// Validate checks every field an envelope always carries. Agent and Relay are +// dispatch-only and validated when present. +func (e Envelope) Validate() error { + if e.Version != ProtocolVersion { + return fmt.Errorf("unsupported envelope version %d (implementation speaks %d)", e.Version, ProtocolVersion) + } + if err := ValidateTaskID(e.Task); err != nil { + return err + } + if e.Attempt < 1 || e.Attempt > MaxAttempt { + return fmt.Errorf("attempt %d out of range [1,%d]", e.Attempt, MaxAttempt) + } + if err := ValidateOID(e.Base); err != nil { + return fmt.Errorf("base: %w", err) + } + if e.Depth < 0 || e.Depth > MaxHookDepth { + return fmt.Errorf("depth %d out of range [0,%d]", e.Depth, MaxHookDepth) + } + if e.Agent != "" { + if err := ValidateTaskID(e.Agent); err != nil { + return fmt.Errorf("agent: %w", err) + } + } + switch e.Relay { + case "", RelaySync, RelayAsync: + default: + return fmt.Errorf("relay %q must be %q or %q", e.Relay, RelaySync, RelayAsync) + } + return nil +} + +// Encode renders the envelope as push options: the version tag followed by +// key=value pairs. It validates first so a malformed envelope never leaves +// the process. +func (e Envelope) Encode() ([]string, error) { + if e.Version == 0 { + e.Version = ProtocolVersion + } + if err := e.Validate(); err != nil { + return nil, err + } + opts := []string{ + EnvelopeVersionTag, + "task=" + e.Task, + "attempt=" + strconv.Itoa(e.Attempt), + "base=" + e.Base, + "depth=" + strconv.Itoa(e.Depth), + } + if e.Agent != "" { + opts = append(opts, "agent="+e.Agent) + } + if e.Relay != "" { + opts = append(opts, "relay="+string(e.Relay)) + } + return opts, nil +} + +// DecodeEnvelope parses push options into an Envelope. Absent envelope, an +// unknown version tag, and unknown keys are all errors: a receiver rejects +// what it does not understand (R4.1). +func DecodeEnvelope(opts []string) (Envelope, error) { + if len(opts) == 0 { + return Envelope{}, fmt.Errorf("push carries no envelope (no push options)") + } + if len(opts) > maxPushOptions { + return Envelope{}, fmt.Errorf("push carries %d options, more than the %d the protocol allows", len(opts), maxPushOptions) + } + if opts[0] != EnvelopeVersionTag { + return Envelope{}, fmt.Errorf("push option 0 is %q, not the version tag %q", opts[0], EnvelopeVersionTag) + } + e := Envelope{Version: ProtocolVersion} + seen := map[string]bool{} + for _, opt := range opts[1:] { + key, value, found := strings.Cut(opt, "=") + if !found || value == "" { + return Envelope{}, fmt.Errorf("push option %q is not key=value", opt) + } + if seen[key] { + return Envelope{}, fmt.Errorf("push option key %q repeated", key) + } + seen[key] = true + if err := e.setField(key, value); err != nil { + return Envelope{}, err + } + } + for _, required := range []string{"task", "attempt", "base", "depth"} { + if !seen[required] { + return Envelope{}, fmt.Errorf("envelope is missing required key %q", required) + } + } + if err := e.Validate(); err != nil { + return Envelope{}, err + } + return e, nil +} + +func (e *Envelope) setField(key, value string) error { + switch key { + case "task": + e.Task = value + case "attempt": + n, err := ParseAttempt(value) + if err != nil { + return err + } + e.Attempt = n + case "base": + e.Base = value + case "depth": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("depth %q is not an integer", value) + } + e.Depth = n + case "agent": + e.Agent = value + case "relay": + e.Relay = RelayMode(value) + default: + return fmt.Errorf("unknown envelope key %q", key) + } + return nil +} + +// EnvelopeFromEnv decodes the envelope receive-pack exposes to hooks via +// GIT_PUSH_OPTION_COUNT / GIT_PUSH_OPTION_ (§1.2). getenv is injectable +// for tests; pass os.Getenv in hooks. +func EnvelopeFromEnv(getenv func(string) string) (Envelope, error) { + countStr := getenv("GIT_PUSH_OPTION_COUNT") + if countStr == "" { + return Envelope{}, fmt.Errorf("push carries no envelope (GIT_PUSH_OPTION_COUNT unset; is receive.advertisePushOptions on?)") + } + count, err := strconv.Atoi(countStr) + if err != nil || count < 0 || count > maxPushOptions { + return Envelope{}, fmt.Errorf("GIT_PUSH_OPTION_COUNT %q out of range [0,%d]", countStr, maxPushOptions) + } + opts := make([]string, 0, count) + for i := 0; i < count; i++ { + opts = append(opts, getenv("GIT_PUSH_OPTION_"+strconv.Itoa(i))) + } + return DecodeEnvelope(opts) +} + +// MatchesRef enforces envelope↔ref agreement (R4.1): a receiver rejects a +// push whose task or attempt disagree with the ref being written. +func (e Envelope) MatchesRef(info RefInfo) error { + if e.Task != info.Task { + return fmt.Errorf("envelope task %q disagrees with ref task %q", e.Task, info.Task) + } + if e.Attempt != info.Attempt { + return fmt.Errorf("envelope attempt %d disagrees with ref attempt %d", e.Attempt, info.Attempt) + } + return nil +} diff --git a/pkg/gitagent/envelope_ginkgo_test.go b/pkg/gitagent/envelope_ginkgo_test.go new file mode 100644 index 00000000..aa6c0001 --- /dev/null +++ b/pkg/gitagent/envelope_ginkgo_test.go @@ -0,0 +1,135 @@ +package gitagent_test + +import ( + "strconv" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +const testOID = "0123456789abcdef0123456789abcdef01234567" + +func validEnvelope() gitagent.Envelope { + return gitagent.Envelope{ + Version: gitagent.ProtocolVersion, + Task: "my-task", + Attempt: 2, + Base: testOID, + Depth: 0, + } +} + +var _ = Describe("envelope encode/decode", func() { + It("round-trips a dispatch envelope", func() { + e := validEnvelope() + e.Agent = "worker-01" + e.Relay = gitagent.RelaySync + opts, err := e.Encode() + Expect(err).NotTo(HaveOccurred()) + Expect(opts[0]).To(Equal(gitagent.EnvelopeVersionTag)) + decoded, err := gitagent.DecodeEnvelope(opts) + Expect(err).NotTo(HaveOccurred()) + Expect(decoded).To(Equal(e)) + }) + + It("round-trips a minimal envelope", func() { + e := validEnvelope() + opts, err := e.Encode() + Expect(err).NotTo(HaveOccurred()) + decoded, err := gitagent.DecodeEnvelope(opts) + Expect(err).NotTo(HaveOccurred()) + Expect(decoded).To(Equal(e)) + }) + + It("rejects a missing envelope", func() { + _, err := gitagent.DecodeEnvelope(nil) + Expect(err).To(HaveOccurred()) + }) + + It("rejects an unknown version tag (R4.1)", func() { + opts, _ := validEnvelope().Encode() + opts[0] = "captain-envelope-v2" + _, err := gitagent.DecodeEnvelope(opts) + Expect(err).To(MatchError(ContainSubstring("version tag"))) + }) + + It("rejects unknown keys, repeats, and non key=value options", func() { + base, _ := validEnvelope().Encode() + for _, extra := range []string{"evil=1", "task=my-task", "notkeyvalue", "task="} { + opts := append(append([]string{}, base...), extra) + _, err := gitagent.DecodeEnvelope(opts) + Expect(err).To(HaveOccurred(), "option %q", extra) + } + }) + + It("rejects a missing required key", func() { + for _, drop := range []string{"task=", "attempt=", "base=", "depth="} { + opts, _ := validEnvelope().Encode() + kept := opts[:0:0] + for _, o := range opts { + if !strings.HasPrefix(o, drop) { + kept = append(kept, o) + } + } + _, err := gitagent.DecodeEnvelope(kept) + Expect(err).To(HaveOccurred(), "dropped %q", drop) + } + }) + + It("rejects invalid field values", func() { + bad := []gitagent.Envelope{} + e := validEnvelope() + e.Task = "Bad Task" + bad = append(bad, e) + e = validEnvelope() + e.Attempt = 0 + bad = append(bad, e) + e = validEnvelope() + e.Base = "not-an-oid" + bad = append(bad, e) + e = validEnvelope() + e.Depth = gitagent.MaxHookDepth + 1 + bad = append(bad, e) + e = validEnvelope() + e.Relay = "eventually" + bad = append(bad, e) + for i, envelope := range bad { + _, err := envelope.Encode() + Expect(err).To(HaveOccurred(), "case %d", i) + } + }) +}) + +var _ = Describe("envelope from hook environment", func() { + It("reads GIT_PUSH_OPTION_COUNT and the numbered options", func() { + opts, err := validEnvelope().Encode() + Expect(err).NotTo(HaveOccurred()) + env := map[string]string{"GIT_PUSH_OPTION_COUNT": strconv.Itoa(len(opts))} + for i, o := range opts { + env["GIT_PUSH_OPTION_"+strconv.Itoa(i)] = o + } + decoded, err := gitagent.EnvelopeFromEnv(func(k string) string { return env[k] }) + Expect(err).NotTo(HaveOccurred()) + Expect(decoded).To(Equal(validEnvelope())) + }) + + It("names advertisePushOptions when the count is unset", func() { + _, err := gitagent.EnvelopeFromEnv(func(string) string { return "" }) + Expect(err).To(MatchError(ContainSubstring("advertisePushOptions"))) + }) +}) + +var _ = Describe("envelope↔ref agreement (R4.1)", func() { + It("accepts matching task and attempt", func() { + info := gitagent.RefInfo{Task: "my-task", Kind: gitagent.RefResult, Attempt: 2} + Expect(validEnvelope().MatchesRef(info)).To(Succeed()) + }) + + It("rejects disagreement on either field", func() { + Expect(validEnvelope().MatchesRef(gitagent.RefInfo{Task: "other", Kind: gitagent.RefResult, Attempt: 2})).NotTo(Succeed()) + Expect(validEnvelope().MatchesRef(gitagent.RefInfo{Task: "my-task", Kind: gitagent.RefResult, Attempt: 3})).NotTo(Succeed()) + }) +}) diff --git a/pkg/gitagent/gitagent_suite_test.go b/pkg/gitagent/gitagent_suite_test.go new file mode 100644 index 00000000..8388c3bf --- /dev/null +++ b/pkg/gitagent/gitagent_suite_test.go @@ -0,0 +1,13 @@ +package gitagent_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGitAgent(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "GitAgent Suite") +} diff --git a/pkg/gitagent/refs.go b/pkg/gitagent/refs.go new file mode 100644 index 00000000..ead682c4 --- /dev/null +++ b/pkg/gitagent/refs.go @@ -0,0 +1,167 @@ +// Package gitagent implements the git-agent protocol v1: dispatching a unit +// of work to a coding agent on another machine and vetting the result using +// nothing but git (issue #40, SPEC-git-agent-protocol). +// +// This file is the ref layer — pure data, no git invocation. The two hops are +// deliberately asymmetric: the agent pushes an ordinary branch +// (refs/heads/captain/), while the sidecar↔supervisor hop is an +// append-only audit trail under refs/captain/tasks/. +package gitagent + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const ( + // taskRefPrefix roots the machine-to-machine audit refs. These land in a + // mailbox repo, never the user's working repository (R2.1). + taskRefPrefix = "refs/captain/tasks/" + + // agentBranchPrefix roots the agent↔sidecar hop: a plain branch the agent + // pushes with no protocol awareness (R3.1). + agentBranchPrefix = "refs/heads/captain/" + + // MaxAttempt bounds attempt numbers well above any real retry budget so a + // hostile ref name cannot drive integer growth. + MaxAttempt = 999999 +) + +// RefKind names the four protocol ref roles under a task namespace. +type RefKind string + +const ( + RefDispatch RefKind = "dispatch" // code: supervisor's snapshot, parent = base + RefControl RefKind = "control" // control: task.json / hooks.json / policy.json + RefResult RefKind = "result" // code: the agent's work, parent = dispatch + RefVerdict RefKind = "verdict" // control: verdict.json + log +) + +var refKinds = map[RefKind]bool{ + RefDispatch: true, + RefControl: true, + RefResult: true, + RefVerdict: true, +} + +var ( + taskIDRe = regexp.MustCompile(`^[a-z0-9-]{1,64}$`) + // Attempts are positive decimals with no leading zeros (§3.2). + attemptRe = regexp.MustCompile(`^[1-9][0-9]{0,5}$`) +) + +// ValidateTaskID enforces the §3.2 task-id shape: ^[a-z0-9-]{1,64}$. +func ValidateTaskID(task string) error { + if !taskIDRe.MatchString(task) { + return fmt.Errorf("task id %q must match %s", task, taskIDRe) + } + return nil +} + +// ParseAttempt parses an attempt number: a positive decimal integer with no +// leading zeros, bounded by MaxAttempt. +func ParseAttempt(s string) (int, error) { + if !attemptRe.MatchString(s) { + return 0, fmt.Errorf("attempt %q must be a positive integer with no leading zeros", s) + } + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > MaxAttempt { + return 0, fmt.Errorf("attempt %q out of range [1,%d]", s, MaxAttempt) + } + return n, nil +} + +// RefInfo is a parsed protocol ref. +type RefInfo struct { + Task string + Kind RefKind + Attempt int +} + +// TaskRef builds refs/captain/tasks///, validating each +// component. +func TaskRef(task string, kind RefKind, attempt int) (string, error) { + if err := ValidateTaskID(task); err != nil { + return "", err + } + if !refKinds[kind] { + return "", fmt.Errorf("unknown protocol ref kind %q", kind) + } + if attempt < 1 || attempt > MaxAttempt { + return "", fmt.Errorf("attempt %d out of range [1,%d]", attempt, MaxAttempt) + } + return taskRefPrefix + task + "/" + string(kind) + "/" + strconv.Itoa(attempt), nil +} + +// DispatchRef, ControlRef, ResultRef and VerdictRef are the four TaskRef +// specializations. +func DispatchRef(task string, attempt int) (string, error) { + return TaskRef(task, RefDispatch, attempt) +} + +// ControlRef builds the control ref for an attempt. +func ControlRef(task string, attempt int) (string, error) { return TaskRef(task, RefControl, attempt) } + +// ResultRef builds the result ref for an attempt. +func ResultRef(task string, attempt int) (string, error) { return TaskRef(task, RefResult, attempt) } + +// VerdictRef builds the verdict ref for an attempt. +func VerdictRef(task string, attempt int) (string, error) { return TaskRef(task, RefVerdict, attempt) } + +// AgentBranch builds refs/heads/captain/, the ordinary branch the agent +// pushes (R3.1). +func AgentBranch(task string) (string, error) { + if err := ValidateTaskID(task); err != nil { + return "", err + } + return agentBranchPrefix + task, nil +} + +// IsProtocolRef reports whether ref lies under refs/captain/tasks/. +func IsProtocolRef(ref string) bool { + return strings.HasPrefix(ref, taskRefPrefix) +} + +// ParseTaskRef parses a refs/captain/tasks/// name, +// rejecting anything malformed. +func ParseTaskRef(ref string) (RefInfo, error) { + rest, found := strings.CutPrefix(ref, taskRefPrefix) + if !found { + return RefInfo{}, fmt.Errorf("ref %q is not a protocol ref (missing %s)", ref, taskRefPrefix) + } + parts := strings.Split(rest, "/") + if len(parts) != 3 { + return RefInfo{}, fmt.Errorf("ref %q must be %s//", ref, taskRefPrefix) + } + if err := ValidateTaskID(parts[0]); err != nil { + return RefInfo{}, fmt.Errorf("ref %q: %w", ref, err) + } + kind := RefKind(parts[1]) + if !refKinds[kind] { + return RefInfo{}, fmt.Errorf("ref %q: unknown protocol ref kind %q", ref, parts[1]) + } + attempt, err := ParseAttempt(parts[2]) + if err != nil { + return RefInfo{}, fmt.Errorf("ref %q: %w", ref, err) + } + return RefInfo{Task: parts[0], Kind: kind, Attempt: attempt}, nil +} + +// TaskNamespace returns the ref namespace owned by a task: +// refs/captain/tasks/ (no trailing separator). +func TaskNamespace(task string) string { + return taskRefPrefix + task +} + +// NamespaceContains reports whether ref lies inside namespace. The comparison +// appends the separator before matching (R8.3): bare prefix matching would let +// agent "a" write agent "ab"'s namespace (H11). +func NamespaceContains(namespace, ref string) bool { + namespace = strings.TrimSuffix(namespace, "/") + if namespace == "" { + return false + } + return strings.HasPrefix(ref, namespace+"/") +} diff --git a/pkg/gitagent/refs_ginkgo_test.go b/pkg/gitagent/refs_ginkgo_test.go new file mode 100644 index 00000000..ef742a04 --- /dev/null +++ b/pkg/gitagent/refs_ginkgo_test.go @@ -0,0 +1,95 @@ +package gitagent_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +var _ = Describe("task ids", func() { + It("accepts the documented shape", func() { + Expect(gitagent.ValidateTaskID("01jb-refactor-store")).To(Succeed()) + Expect(gitagent.ValidateTaskID("a")).To(Succeed()) + }) + + It("rejects everything outside ^[a-z0-9-]{1,64}$", func() { + for _, bad := range []string{"", "UPPER", "has space", "dot.dot", "a/b", "..", "-" + string(make([]byte, 64))} { + Expect(gitagent.ValidateTaskID(bad)).NotTo(Succeed(), "task id %q", bad) + } + long := "" + for range 65 { + long += "a" + } + Expect(gitagent.ValidateTaskID(long)).NotTo(Succeed()) + }) +}) + +var _ = Describe("attempts", func() { + It("parses positive decimals without leading zeros", func() { + n, err := gitagent.ParseAttempt("1") + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(1)) + n, err = gitagent.ParseAttempt("42") + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(42)) + }) + + It("rejects zero, leading zeros, negatives and junk", func() { + for _, bad := range []string{"0", "01", "-1", "", "1x", "0x1", "1.0", "9999999"} { + _, err := gitagent.ParseAttempt(bad) + Expect(err).To(HaveOccurred(), "attempt %q", bad) + } + }) +}) + +var _ = Describe("protocol refs", func() { + It("round-trips all four kinds", func() { + for _, kind := range []gitagent.RefKind{gitagent.RefDispatch, gitagent.RefControl, gitagent.RefResult, gitagent.RefVerdict} { + ref, err := gitagent.TaskRef("my-task", kind, 3) + Expect(err).NotTo(HaveOccurred()) + Expect(ref).To(Equal("refs/captain/tasks/my-task/" + string(kind) + "/3")) + info, err := gitagent.ParseTaskRef(ref) + Expect(err).NotTo(HaveOccurred()) + Expect(info).To(Equal(gitagent.RefInfo{Task: "my-task", Kind: kind, Attempt: 3})) + } + }) + + It("rejects malformed refs", func() { + for _, bad := range []string{ + "refs/heads/main", + "refs/captain/tasks/my-task", + "refs/captain/tasks/my-task/dispatch", + "refs/captain/tasks/my-task/dispatch/0", + "refs/captain/tasks/my-task/dispatch/01", + "refs/captain/tasks/my-task/unknown/1", + "refs/captain/tasks/My-Task/dispatch/1", + "refs/captain/tasks/my-task/dispatch/1/extra", + } { + _, err := gitagent.ParseTaskRef(bad) + Expect(err).To(HaveOccurred(), "ref %q", bad) + } + }) + + It("builds the agent branch", func() { + ref, err := gitagent.AgentBranch("my-task") + Expect(err).NotTo(HaveOccurred()) + Expect(ref).To(Equal("refs/heads/captain/my-task")) + _, err = gitagent.AgentBranch("Bad Task") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("namespace containment (R8.3/H11)", func() { + It("appends the separator before matching", func() { + ns := gitagent.TaskNamespace("a") + Expect(gitagent.NamespaceContains(ns, "refs/captain/tasks/a/result/1")).To(BeTrue()) + // agent "a" must not reach agent "ab"'s namespace + Expect(gitagent.NamespaceContains(ns, "refs/captain/tasks/ab/result/1")).To(BeFalse()) + Expect(gitagent.NamespaceContains(ns, "refs/captain/tasks/a")).To(BeFalse()) + }) + + It("rejects an empty namespace", func() { + Expect(gitagent.NamespaceContains("", "refs/captain/tasks/a/result/1")).To(BeFalse()) + }) +}) diff --git a/pkg/gitagent/substrate_ginkgo_test.go b/pkg/gitagent/substrate_ginkgo_test.go new file mode 100644 index 00000000..39d5c024 --- /dev/null +++ b/pkg/gitagent/substrate_ginkgo_test.go @@ -0,0 +1,28 @@ +package gitagent_test + +import ( + "os/exec" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The protocol's git assumptions (SPEC §1) are re-verified against the +// installed git on every run, so a git upgrade that changes quarantine, +// push-option or materialization behaviour fails loudly here rather than +// silently corrupting a dispatch. +var _ = Describe("verified substrate", func() { + It("passes the empirical harness against the installed git", func() { + if _, err := exec.LookPath("git"); err != nil { + Skip("git not installed") + } + if _, err := exec.LookPath("bash"); err != nil { + Skip("bash not installed") + } + script, err := filepath.Abs(filepath.Join("..", "..", "hack", "gitagent_empirical.sh")) + Expect(err).NotTo(HaveOccurred()) + out, err := exec.Command("bash", script).CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "harness output:\n%s", out) + }) +}) From d4a70a0557f5389469242aa171f16973cff5001c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:10:02 +0000 Subject: [PATCH 02/22] feat(gitagent): dispatch snapshot from an explicit path set, audited first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TakeSnapshot captures the supervisor's dirty worktree as a commit parented on HEAD, built against a throwaway index: read-tree of the base, then update-index --index-info records for exactly the dirty paths — never read-tree + add --all, which stages mass deletions under sparse-checkout and skip-worktree (R6.1/H4, covered by a sparse fixture test). Blobs are hashed with --no-filters and every git call pins core.autocrlf=false, core.eol=lf and core.attributesFile=/dev/null, so the bytes committed are the bytes on disk even under a hostile text attribute (R6.2). Dispatch refuses loudly — never degrades — on LFS-filtered paths, required clean/smudge filters, dirty submodules, unmerged entries and policy caps (H5), and policy path globs bound what a snapshot may carry. The A4.3 fidelity fixture (modifications, staged and unstaged deletions, a rename, exec bits, symlinks, nested/odd-named untracked files, CRLF bytes) gates the snapshot, and a companion audit pins commons-db's Checkout.Dirty behaviour: deletions, renames, exec bits, symlinks and untracked files round-trip; the skip-worktree silent drop is asserted as a documented upstream gap so a commons-db release that fixes it fails the audit and triggers a re-review (A4.2). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- go.mod | 2 +- pkg/gitagent/git.go | 72 +++++ pkg/gitagent/scrub.go | 78 +++++ pkg/gitagent/snapshot.go | 333 +++++++++++++++++++++ pkg/gitagent/snapshot_audit_ginkgo_test.go | 93 ++++++ pkg/gitagent/snapshot_ginkgo_test.go | 256 ++++++++++++++++ pkg/gitagent/status.go | 58 ++++ 7 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 pkg/gitagent/git.go create mode 100644 pkg/gitagent/scrub.go create mode 100644 pkg/gitagent/snapshot.go create mode 100644 pkg/gitagent/snapshot_audit_ginkgo_test.go create mode 100644 pkg/gitagent/snapshot_ginkgo_test.go create mode 100644 pkg/gitagent/status.go diff --git a/go.mod b/go.mod index 50d3bfb0..e8ac6baf 100644 --- a/go.mod +++ b/go.mod @@ -127,7 +127,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/buger/jsonparser v1.1.2 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/pkg/gitagent/git.go b/pkg/gitagent/git.go new file mode 100644 index 00000000..893e08d7 --- /dev/null +++ b/pkg/gitagent/git.go @@ -0,0 +1,72 @@ +package gitagent + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" +) + +// normalizationArgs pin every git invocation this package makes to byte +// fidelity (R6.2): no CRLF conversion and no user/global attribute file, so +// the bytes committed are the bytes on disk regardless of host config. +var normalizationArgs = []string{ + "-c", "core.autocrlf=false", + "-c", "core.eol=lf", + "-c", "core.attributesFile=/dev/null", +} + +// runGit executes `git ` in dir with the given environment and +// returns trimmed stdout, failing loud with stderr context on any error. +func runGit(ctx context.Context, dir string, env []string, args ...string) (string, error) { + return runGitIn(ctx, dir, env, nil, args...) +} + +// runGitIn is runGit with a stdin stream, for plumbing commands that read +// their payload from stdin (hash-object --stdin, update-index --index-info). +func runGitIn(ctx context.Context, dir string, env []string, stdin io.Reader, args ...string) (string, error) { + out, err := runGitRaw(ctx, dir, env, stdin, args...) + return strings.TrimSpace(out), err +} + +// runGitRaw returns stdout verbatim — status/ls-files -z records start with +// significant bytes (a leading space is a status code) that trimming would +// corrupt. +func runGitRaw(ctx context.Context, dir string, env []string, stdin io.Reader, args ...string) (string, error) { + full := append(append([]string{}, normalizationArgs...), args...) + cmd := exec.CommandContext(ctx, "git", full...) + cmd.Dir = dir + cmd.Env = env + cmd.Stdin = stdin + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +// gitExitCode runs git and returns its exit code, for commands whose non-zero +// exit is an answer rather than a failure. +func gitExitCode(ctx context.Context, dir string, env []string, args ...string) (int, string, error) { + full := append(append([]string{}, normalizationArgs...), args...) + cmd := exec.CommandContext(ctx, "git", full...) + cmd.Dir = dir + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return 0, stdout.String(), nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), stdout.String(), nil + } + return -1, "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) +} diff --git a/pkg/gitagent/scrub.go b/pkg/gitagent/scrub.go new file mode 100644 index 00000000..67d4c974 --- /dev/null +++ b/pkg/gitagent/scrub.go @@ -0,0 +1,78 @@ +package gitagent + +import "strings" + +// receivePackEnv are the variables receive-pack injects that redirect any +// descendant git at another repository. GIT_QUARANTINE_PATH is listed by name +// because `git rev-parse --local-env-vars` omits it (verified, §1.1), so the +// githooks(5) scrub idiom leaves it set — the exact trap R1.1 exists for. +var receivePackEnv = []string{ + "GIT_QUARANTINE_PATH", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", +} + +// ScrubGitEnv returns env without any receive-pack repository redirection and +// without the push-option variables, for hook subprocesses (R1.1). +func ScrubGitEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + if scrubbedGitVar(name) { + continue + } + out = append(out, kv) + } + return out +} + +func scrubbedGitVar(name string) bool { + if strings.HasPrefix(name, "GIT_PUSH_OPTION_") { + return true + } + for _, v := range receivePackEnv { + if name == v { + return true + } + } + return false +} + +// RelayEnv returns env with only GIT_QUARANTINE_PATH removed. The relay push +// from inside pre-receive must keep the inherited object directories so the +// quarantined objects stay readable, and must not copy them (R1.4, verified +// §1.4). +func RelayEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + if name, _, _ := strings.Cut(kv, "="); name == "GIT_QUARANTINE_PATH" { + continue + } + out = append(out, kv) + } + return out +} + +// envWith returns env with the given KEY=VALUE pairs appended, each replacing +// any existing entry for its key. +func envWith(env []string, pairs ...string) []string { + out := make([]string, 0, len(env)+len(pairs)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + replaced := false + for _, p := range pairs { + if pname, _, _ := strings.Cut(p, "="); pname == name { + replaced = true + break + } + } + if !replaced { + out = append(out, kv) + } + } + return append(out, pairs...) +} diff --git a/pkg/gitagent/snapshot.go b/pkg/gitagent/snapshot.go new file mode 100644 index 00000000..3831bd29 --- /dev/null +++ b/pkg/gitagent/snapshot.go @@ -0,0 +1,333 @@ +// The dispatch snapshot (§6.1): the supervisor's dirty worktree captured as a +// commit parented on HEAD, built from an explicit path set against a +// temporary index — never `read-tree HEAD` + `add --all`, which stages mass +// deletions under sparse-checkout or skip-worktree (R6.1/H4). Blobs are +// hashed with --no-filters so no clean filter, CRLF rule or attribute can +// change the bytes (R6.2); anything that cannot round-trip byte-exact — LFS, +// required filters, dirty submodules — refuses loudly instead (H5). +package gitagent + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" +) + +// Default snapshot caps, applied when the policy leaves a cap zero. +const ( + DefaultSnapshotMaxFiles = 5000 + DefaultSnapshotMaxFileSize = int64(32 << 20) + DefaultSnapshotMaxTotalSize = int64(256 << 20) +) + +const zeroOID = "0000000000000000000000000000000000000000" + +// SnapshotPolicy bounds what a dispatch snapshot may carry. +type SnapshotPolicy struct { + // Paths are doublestar globs; a leading ! denies. With any non-negated + // pattern present, a path must match one to be included. + Paths []string + MaxFiles int + MaxFileSize int64 + MaxTotalSize int64 +} + +// Snapshot is the result of TakeSnapshot: a commit whose tree is the dirty +// worktree state and whose parent is the base the supervisor dispatched from. +type Snapshot struct { + Commit string + Tree string + Base string + Paths []string // repo-relative dirty paths the snapshot applied +} + +// TakeSnapshot captures repoDir's dirty worktree as a commit parented on +// HEAD. The worktree, index and repo are left untouched. +func TakeSnapshot(ctx context.Context, repoDir string, policy SnapshotPolicy) (*Snapshot, error) { + dir, err := filepath.Abs(repoDir) + if err != nil { + return nil, err + } + env := ScrubGitEnv(os.Environ()) + base, err := runGit(ctx, dir, env, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return nil, fmt.Errorf("dispatch requires a repository with at least one commit: %w", err) + } + entries, err := statusZ(ctx, dir, env) + if err != nil { + return nil, err + } + for _, e := range entries { + if e.Unmerged() { + return nil, fmt.Errorf("snapshot refused: %q is unmerged; resolve the conflict before dispatching", e.Path) + } + } + all := make([]string, 0, len(entries)) + for _, e := range entries { + all = append(all, e.Path) + } + paths, err := filterPolicyPaths(all, policy.Paths) + if err != nil { + return nil, err + } + if err := refuseSnapshotHazards(ctx, dir, env, paths); err != nil { + return nil, err + } + if err := enforceSnapshotCaps(dir, paths, policy); err != nil { + return nil, err + } + tree, err := buildSnapshotTree(ctx, dir, env, base, paths) + if err != nil { + return nil, err + } + commit, err := commitSnapshotTree(ctx, dir, env, tree, base) + if err != nil { + return nil, err + } + return &Snapshot{Commit: commit, Tree: tree, Base: base, Paths: paths}, nil +} + +// filterPolicyPaths applies allow/deny globs. Exclusion is the point of a +// path policy — an excluded dirty path stays at its base version. +func filterPolicyPaths(paths, patterns []string) ([]string, error) { + if len(patterns) == 0 { + return paths, nil + } + var allows, denies []string + for _, p := range patterns { + pattern, negated := strings.CutPrefix(p, "!") + if !doublestar.ValidatePattern(pattern) { + return nil, fmt.Errorf("invalid policy path pattern %q", p) + } + if negated { + denies = append(denies, pattern) + } else { + allows = append(allows, pattern) + } + } + matchAny := func(patterns []string, path string) bool { + for _, p := range patterns { + if ok, _ := doublestar.Match(p, path); ok { + return true + } + } + return false + } + var out []string + for _, path := range paths { + if matchAny(denies, path) { + continue + } + if len(allows) > 0 && !matchAny(allows, path) { + continue + } + out = append(out, path) + } + return out, nil +} + +// refuseSnapshotHazards aborts on anything that round-trips incorrectly and +// silently (H5): LFS-filtered paths, required clean/smudge filters, and dirty +// submodules. +func refuseSnapshotHazards(ctx context.Context, dir string, env []string, paths []string) error { + if err := refuseLFS(ctx, dir, env, paths); err != nil { + return err + } + code, out, err := gitExitCode(ctx, dir, env, "config", "--get-regexp", `^filter\..*\.required$`) + if err != nil { + return err + } + if code == 0 { + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if strings.HasSuffix(strings.TrimSpace(line), "true") { + return fmt.Errorf("snapshot refused: required clean/smudge filter declared (%s); it cannot round-trip byte-exact", strings.Fields(line)[0]) + } + } + } + return refuseDirtySubmodules(ctx, dir, env, paths) +} + +// refuseLFS rejects a snapshot when any candidate path resolves the lfs +// filter, or when any in-repo .gitattributes declares it at all — equivalent +// to "`git lfs ls-files` is non-empty" without requiring the lfs binary. +func refuseLFS(ctx context.Context, dir string, env []string, paths []string) error { + if len(paths) > 0 { + args := append([]string{"check-attr", "-z", "filter", "--"}, paths...) + out, err := runGitRaw(ctx, dir, env, nil, args...) + if err != nil { + return err + } + fields := strings.Split(out, "\x00") + for i := 0; i+2 < len(fields); i += 3 { + if fields[i+2] == "lfs" { + return fmt.Errorf("snapshot refused: %q is LFS-tracked; LFS pointers do not round-trip (H5)", fields[i]) + } + } + } + out, err := runGitRaw(ctx, dir, env, nil, + "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ".gitattributes", ":(glob)**/.gitattributes") + if err != nil { + return err + } + for _, attrFile := range strings.Split(out, "\x00") { + if attrFile == "" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, attrFile)) + if err != nil { + continue // deleted attributes file cannot declare anything + } + if strings.Contains(string(data), "filter=lfs") { + return fmt.Errorf("snapshot refused: %s declares filter=lfs; dispatch does not support LFS repositories (H5)", attrFile) + } + } + return nil +} + +// refuseDirtySubmodules rejects when a dirty path is a gitlink: a submodule's +// content cannot travel in the snapshot, so its dirt would be silently lost. +func refuseDirtySubmodules(ctx context.Context, dir string, env []string, paths []string) error { + out, err := runGitRaw(ctx, dir, env, nil, "ls-files", "-z", "--stage") + if err != nil { + return err + } + gitlinks := map[string]bool{} + for _, record := range strings.Split(out, "\x00") { + if !strings.HasPrefix(record, "160000 ") { + continue + } + if _, path, found := strings.Cut(record, "\t"); found { + gitlinks[path] = true + } + } + for _, p := range paths { + if gitlinks[p] { + return fmt.Errorf("snapshot refused: submodule %q is dirty; commit or clean it before dispatching (H5)", p) + } + } + return nil +} + +func enforceSnapshotCaps(dir string, paths []string, policy SnapshotPolicy) error { + maxFiles := policy.MaxFiles + if maxFiles == 0 { + maxFiles = DefaultSnapshotMaxFiles + } + maxFile := policy.MaxFileSize + if maxFile == 0 { + maxFile = DefaultSnapshotMaxFileSize + } + maxTotal := policy.MaxTotalSize + if maxTotal == 0 { + maxTotal = DefaultSnapshotMaxTotalSize + } + if len(paths) > maxFiles { + return fmt.Errorf("snapshot refused: %d dirty paths exceed the %d-file cap", len(paths), maxFiles) + } + var total int64 + for _, p := range paths { + fi, err := os.Lstat(filepath.Join(dir, p)) + if err != nil { + continue // a deletion has no size + } + if fi.Mode().IsRegular() && fi.Size() > maxFile { + return fmt.Errorf("snapshot refused: %q is %d bytes, over the %d-byte cap", p, fi.Size(), maxFile) + } + total += fi.Size() + if total > maxTotal { + return fmt.Errorf("snapshot refused: dirty state exceeds the %d-byte total cap", maxTotal) + } + } + return nil +} + +// buildSnapshotTree stages exactly paths over base in a throwaway index and +// returns the written tree. +func buildSnapshotTree(ctx context.Context, dir string, env []string, base string, paths []string) (string, error) { + idxDir, err := os.MkdirTemp("", "captain-gitagent-") + if err != nil { + return "", err + } + defer os.RemoveAll(idxDir) + ienv := envWith(env, "GIT_INDEX_FILE="+filepath.Join(idxDir, "index")) + if _, err := runGit(ctx, dir, ienv, "read-tree", base); err != nil { + return "", err + } + var info bytes.Buffer + for _, p := range paths { + line, err := indexEntry(ctx, dir, env, p) + if err != nil { + return "", err + } + info.WriteString(line) + info.WriteByte(0) + } + if info.Len() > 0 { + if _, err := runGitIn(ctx, dir, ienv, &info, "update-index", "-z", "--index-info"); err != nil { + return "", err + } + } + return runGit(ctx, dir, ienv, "write-tree") +} + +// indexEntry renders one `update-index --index-info` record for the path's +// on-disk state: mode 0 removes a deleted path, symlinks hash their target, +// and regular files are hashed with --no-filters so the blob is byte-exact. +func indexEntry(ctx context.Context, dir string, env []string, path string) (string, error) { + full := filepath.Join(dir, path) + fi, err := os.Lstat(full) + if errors.Is(err, fs.ErrNotExist) { + return "0 " + zeroOID + "\t" + path, nil + } + if err != nil { + return "", err + } + switch { + case fi.Mode()&fs.ModeSymlink != 0: + target, err := os.Readlink(full) + if err != nil { + return "", err + } + oid, err := runGitIn(ctx, dir, env, strings.NewReader(target), "hash-object", "-w", "--no-filters", "--stdin") + if err != nil { + return "", err + } + return "120000 " + oid + "\t" + path, nil + case fi.Mode().IsRegular(): + f, err := os.Open(full) + if err != nil { + return "", err + } + defer f.Close() + oid, err := runGitIn(ctx, dir, env, f, "hash-object", "-w", "--no-filters", "--stdin") + if err != nil { + return "", err + } + mode := "100644" + if fi.Mode()&0o111 != 0 { + mode = "100755" + } + return mode + " " + oid + "\t" + path, nil + default: + return "", fmt.Errorf("snapshot refused: %q is a %s, which git cannot carry", path, fi.Mode().Type()) + } +} + +func commitSnapshotTree(ctx context.Context, dir string, env []string, tree, base string) (string, error) { + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + return runGitIn(ctx, dir, cenv, + strings.NewReader("captain dispatch snapshot\n"), + "commit-tree", tree, "-p", base) +} diff --git a/pkg/gitagent/snapshot_audit_ginkgo_test.go b/pkg/gitagent/snapshot_audit_ginkgo_test.go new file mode 100644 index 00000000..237eedc7 --- /dev/null +++ b/pkg/gitagent/snapshot_audit_ginkgo_test.go @@ -0,0 +1,93 @@ +package gitagent_test + +import ( + "context" + "os" + "path/filepath" + + dbcontext "github.com/flanksource/commons-db/context" + "github.com/flanksource/commons-db/shell" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// A4.3 audit of commons-db's dirty-state mechanism (shell.Checkout.Dirty → +// applyDirtyState), which issue #39 §4 names as the dispatch-snapshot +// substrate. The dispatch snapshot does NOT use it — TakeSnapshot builds the +// commit from git plumbing directly — but Spec.Setup.Checkout.Dirty remains a +// user-facing surface, so this suite pins what round-trips and what does not. +// Failures here after a commons-db upgrade mean upstream behaviour changed: +// re-audit before trusting it. Known gaps (filed upstream rather than forked, +// A4.2): skip-worktree edits are silently dropped, CRLF normalization is not +// pinned, LFS pointers pass through silently, and dirtyFiles mangles unusual +// paths. +var _ = Describe("commons-db dirty-state audit (A4.3)", func() { + prepare := func(src string) (*shell.SetupResult, error) { + return shell.Prepare(dbcontext.NewContext(context.Background()), &shell.Setup{ + BaseDir: GinkgoT().TempDir(), + Checkout: &shell.Checkout{ + Path: src, + Dirty: &shell.Dirty{Stash: shell.StashAll}, + Worktree: &shell.Worktree{Mode: shell.WorktreeNew, Prefix: "captain-audit"}, + }, + }) + } + + It("round-trips staged deletions, renames, exec bits, symlinks and untracked files", func() { + src := newFidelityRepo() + // The fidelity fixture arms core.autocrlf=true + a text attribute; + // commons-db does not pin normalization (a known gap), so drop the + // CRLF tripwires to audit the rest in isolation. + gitT(src, "config", "core.autocrlf", "false") + Expect(os.Remove(filepath.Join(src, ".gitattributes"))).To(Succeed()) + + res, err := prepare(src) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + if res.Cleanup != nil { + _ = res.Cleanup() + } + }) + wt := res.Cwd + Expect(wt).NotTo(Equal(src)) + + Expect(os.ReadFile(filepath.Join(wt, "mod.txt"))).To(Equal([]byte("v2\n"))) + Expect(filepath.Join(wt, "del-staged.txt")).NotTo(BeAnExistingFile()) + Expect(filepath.Join(wt, "ren-old.txt")).NotTo(BeAnExistingFile()) + Expect(os.ReadFile(filepath.Join(wt, "ren-new.txt"))).To(Equal([]byte("renamed v2\n"))) + info, err := os.Stat(filepath.Join(wt, "exec.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode()&0o111).NotTo(BeZero(), "exec bit lost in transit") + target, err := os.Readlink(filepath.Join(wt, "link.txt")) + Expect(err).NotTo(HaveOccurred()) + Expect(target).To(Equal("mod.txt")) + Expect(os.ReadFile(filepath.Join(wt, "newdir/new.txt"))).To(Equal([]byte("brand new\n"))) + Expect(os.ReadFile(filepath.Join(wt, "with space.txt"))).To(Equal([]byte("spaced\n"))) + }) + + It("silently drops skip-worktree edits — the pinned upstream gap", func() { + src := GinkgoT().TempDir() + gitT(src, "init", "-q") + writeFileT(src, "hidden.txt", "committed\n") + writeFileT(src, "visible.txt", "v1\n") + gitT(src, "add", "-A") + gitT(src, "commit", "-q", "-m", "base") + writeFileT(src, "hidden.txt", "local edit git cannot see\n") + gitT(src, "update-index", "--skip-worktree", "hidden.txt") + writeFileT(src, "visible.txt", "v2\n") + + res, err := prepare(src) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + if res.Cleanup != nil { + _ = res.Cleanup() + } + }) + Expect(os.ReadFile(filepath.Join(res.Cwd, "visible.txt"))).To(Equal([]byte("v2\n"))) + // Documented loss: `git diff` does not report skip-worktree paths, so + // the local edit never reaches the worktree. When an upstream release + // starts carrying it, this assertion breaks — re-audit then. + Expect(os.ReadFile(filepath.Join(res.Cwd, "hidden.txt"))).To(Equal([]byte("committed\n")), + "commons-db now carries skip-worktree edits; update the audit and the upstream-gap list") + }) +}) diff --git a/pkg/gitagent/snapshot_ginkgo_test.go b/pkg/gitagent/snapshot_ginkgo_test.go new file mode 100644 index 00000000..ec91f40e --- /dev/null +++ b/pkg/gitagent/snapshot_ginkgo_test.go @@ -0,0 +1,256 @@ +package gitagent_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +// gitT runs git in dir with a pinned identity, failing the spec on error. +func gitT(dir string, args ...string) string { + GinkgoHelper() + full := append([]string{ + "-c", "user.name=test", "-c", "user.email=test@localhost", + "-c", "init.defaultBranch=main", "-c", "protocol.file.allow=always", + }, args...) + cmd := exec.Command("git", full...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "git %v:\n%s", args, out) + return strings.TrimSpace(string(out)) +} + +func writeFileT(dir, path, content string) { + GinkgoHelper() + full := filepath.Join(dir, path) + Expect(os.MkdirAll(filepath.Dir(full), 0o755)).To(Succeed()) + Expect(os.WriteFile(full, []byte(content), 0o644)).To(Succeed()) +} + +// blob returns the byte content of path in the given tree-ish, or "" if absent. +func blob(dir, treeish, path string) string { + cmd := exec.Command("git", "cat-file", "blob", treeish+":"+path) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + return string(out) +} + +// treeMode returns the mode of path in the tree-ish, or "" if absent. +func treeMode(dir, treeish, path string) string { + cmd := exec.Command("git", "ls-tree", treeish, "--", path) + cmd.Dir = dir + out, _ := cmd.Output() + fields := strings.Fields(string(out)) + if len(fields) < 1 { + return "" + } + return fields[0] +} + +// newFidelityRepo builds the A4.3 fixture: a repo whose dirty state covers +// modifications, staged and unstaged deletions, a rename, an exec-bit flip, a +// symlink, nested and odd-named untracked files, and CRLF bytes under a text +// attribute plus core.autocrlf=true. +func newFidelityRepo() string { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "keep.txt", "keep\n") + writeFileT(dir, "mod.txt", "v1\n") + writeFileT(dir, "del-staged.txt", "doomed\n") + writeFileT(dir, "del-unstaged.txt", "doomed too\n") + writeFileT(dir, "ren-old.txt", "rename me\n") + writeFileT(dir, "exec.sh", "#!/bin/sh\n") + writeFileT(dir, "sub/nested.txt", "nested\n") + writeFileT(dir, "crlf.txt", "a\nb\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + + writeFileT(dir, "mod.txt", "v2\n") + gitT(dir, "rm", "-q", "del-staged.txt") + Expect(os.Remove(filepath.Join(dir, "del-unstaged.txt"))).To(Succeed()) + gitT(dir, "mv", "ren-old.txt", "ren-new.txt") + writeFileT(dir, "ren-new.txt", "renamed v2\n") + Expect(os.Chmod(filepath.Join(dir, "exec.sh"), 0o755)).To(Succeed()) + Expect(os.Symlink("mod.txt", filepath.Join(dir, "link.txt"))).To(Succeed()) + writeFileT(dir, "newdir/new.txt", "brand new\n") + writeFileT(dir, "with space.txt", "spaced\n") + // CRLF bytes on disk, with every conversion knob armed against them. + Expect(os.WriteFile(filepath.Join(dir, "crlf.txt"), []byte("a\r\nb\r\n"), 0o644)).To(Succeed()) + writeFileT(dir, ".gitattributes", "*.txt text\n") + gitT(dir, "config", "core.autocrlf", "true") + return dir +} + +var _ = Describe("dispatch snapshot", func() { + ctx := context.Background() + + It("captures the dirty worktree byte-exactly and leaves the repo untouched", func() { + dir := newFidelityRepo() + head := gitT(dir, "rev-parse", "HEAD") + statusBefore := gitT(dir, "status", "--porcelain") + + snap, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + Expect(snap.Base).To(Equal(head)) + Expect(gitT(dir, "rev-parse", snap.Commit+"^")).To(Equal(head)) + Expect(gitT(dir, "rev-parse", snap.Commit+"^{tree}")).To(Equal(snap.Tree)) + + Expect(blob(dir, snap.Commit, "mod.txt")).To(Equal("v2\n")) + Expect(treeMode(dir, snap.Commit, "del-staged.txt")).To(BeEmpty()) + Expect(treeMode(dir, snap.Commit, "del-unstaged.txt")).To(BeEmpty()) + Expect(treeMode(dir, snap.Commit, "ren-old.txt")).To(BeEmpty()) + Expect(blob(dir, snap.Commit, "ren-new.txt")).To(Equal("renamed v2\n")) + Expect(treeMode(dir, snap.Commit, "exec.sh")).To(Equal("100755")) + Expect(treeMode(dir, snap.Commit, "link.txt")).To(Equal("120000")) + Expect(blob(dir, snap.Commit, "link.txt")).To(Equal("mod.txt")) + Expect(blob(dir, snap.Commit, "newdir/new.txt")).To(Equal("brand new\n")) + Expect(blob(dir, snap.Commit, "with space.txt")).To(Equal("spaced\n")) + // R6.2: the CRLF bytes on disk are the bytes in the snapshot, despite + // core.autocrlf=true and the `*.txt text` attribute. + Expect(blob(dir, snap.Commit, "crlf.txt")).To(Equal("a\r\nb\r\n")) + // An untouched file keeps its base blob. + Expect(blob(dir, snap.Commit, "keep.txt")).To(Equal("keep\n")) + + // The snapshot must not disturb the repo: same HEAD, same dirt. + Expect(gitT(dir, "rev-parse", "HEAD")).To(Equal(head)) + Expect(gitT(dir, "status", "--porcelain")).To(Equal(statusBefore)) + }) + + It("does not stage deletions under sparse-checkout (R6.1/H4)", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "top.txt", "top\n") + writeFileT(dir, "vendor/dep.txt", "vendored\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + vendorBlob := gitT(dir, "rev-parse", "HEAD:vendor/dep.txt") + gitT(dir, "sparse-checkout", "set", "--no-cone", "/*", "!vendor/") + Expect(filepath.Join(dir, "vendor", "dep.txt")).NotTo(BeAnExistingFile()) + + writeFileT(dir, "top.txt", "top v2\n") + snap, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + Expect(blob(dir, snap.Commit, "top.txt")).To(Equal("top v2\n")) + Expect(gitT(dir, "rev-parse", snap.Commit+":vendor/dep.txt")).To(Equal(vendorBlob)) + }) + + It("snapshots a clean worktree as the base tree", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "a.txt", "a\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + snap, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + Expect(snap.Paths).To(BeEmpty()) + Expect(snap.Tree).To(Equal(gitT(dir, "rev-parse", "HEAD^{tree}"))) + }) + + It("applies policy path globs, denies winning over allows", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "seed.txt", "seed\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + writeFileT(dir, "pkg/a.go", "package a\n") + writeFileT(dir, "pkg/key.pem", "SECRET\n") + writeFileT(dir, "docs/readme.md", "docs\n") + + snap, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{ + Paths: []string{"pkg/**", "!**/*.pem"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(snap.Paths).To(ConsistOf("pkg/a.go")) + Expect(blob(dir, snap.Commit, "pkg/a.go")).To(Equal("package a\n")) + Expect(treeMode(dir, snap.Commit, "pkg/key.pem")).To(BeEmpty()) + Expect(treeMode(dir, snap.Commit, "docs/readme.md")).To(BeEmpty()) + }) + + It("refuses LFS, required filters, dirty submodules and unmerged paths (H5)", func() { + lfs := GinkgoT().TempDir() + gitT(lfs, "init", "-q") + writeFileT(lfs, "seed.txt", "seed\n") + gitT(lfs, "add", "-A") + gitT(lfs, "commit", "-q", "-m", "base") + writeFileT(lfs, ".gitattributes", "*.bin filter=lfs diff=lfs merge=lfs -text\n") + writeFileT(lfs, "data.bin", "not really lfs\n") + _, err := gitagent.TakeSnapshot(ctx, lfs, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("LFS"))) + + reqf := GinkgoT().TempDir() + gitT(reqf, "init", "-q") + writeFileT(reqf, "seed.txt", "seed\n") + gitT(reqf, "add", "-A") + gitT(reqf, "commit", "-q", "-m", "base") + gitT(reqf, "config", "filter.crypt.required", "true") + writeFileT(reqf, "dirty.txt", "dirty\n") + _, err = gitagent.TakeSnapshot(ctx, reqf, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("required clean/smudge filter"))) + + sub := GinkgoT().TempDir() + gitT(sub, "init", "-q") + writeFileT(sub, "inner.txt", "inner\n") + gitT(sub, "add", "-A") + gitT(sub, "commit", "-q", "-m", "sub base") + super := GinkgoT().TempDir() + gitT(super, "init", "-q") + writeFileT(super, "seed.txt", "seed\n") + gitT(super, "add", "-A") + gitT(super, "commit", "-q", "-m", "base") + gitT(super, "submodule", "add", sub, "submod") + gitT(super, "commit", "-q", "-m", "add submodule") + writeFileT(super, "submod/inner.txt", "modified inner\n") + _, err = gitagent.TakeSnapshot(ctx, super, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("submodule"))) + + conflict := GinkgoT().TempDir() + gitT(conflict, "init", "-q") + writeFileT(conflict, "c.txt", "base\n") + gitT(conflict, "add", "-A") + gitT(conflict, "commit", "-q", "-m", "base") + gitT(conflict, "checkout", "-q", "-b", "side") + writeFileT(conflict, "c.txt", "side\n") + gitT(conflict, "commit", "-q", "-am", "side") + gitT(conflict, "checkout", "-q", "main") + writeFileT(conflict, "c.txt", "main\n") + gitT(conflict, "commit", "-q", "-am", "main") + cmd := exec.Command("git", "merge", "side") + cmd.Dir = conflict + _ = cmd.Run() // expected to conflict + _, err = gitagent.TakeSnapshot(ctx, conflict, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("unmerged"))) + }) + + It("enforces snapshot caps", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + writeFileT(dir, "seed.txt", "seed\n") + gitT(dir, "add", "-A") + gitT(dir, "commit", "-q", "-m", "base") + writeFileT(dir, "a.txt", "a\n") + writeFileT(dir, "b.txt", "b\n") + + _, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{MaxFiles: 1}) + Expect(err).To(MatchError(ContainSubstring("cap"))) + + _, err = gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{MaxFileSize: 1}) + Expect(err).To(MatchError(ContainSubstring("cap"))) + }) + + It("refuses a repository with no commits", func() { + dir := GinkgoT().TempDir() + gitT(dir, "init", "-q") + _, err := gitagent.TakeSnapshot(ctx, dir, gitagent.SnapshotPolicy{}) + Expect(err).To(MatchError(ContainSubstring("at least one commit"))) + }) +}) diff --git a/pkg/gitagent/status.go b/pkg/gitagent/status.go new file mode 100644 index 00000000..7fad69d4 --- /dev/null +++ b/pkg/gitagent/status.go @@ -0,0 +1,58 @@ +package gitagent + +import ( + "context" + "fmt" + "strings" +) + +// StatusEntry is one record of `git status --porcelain -z`. +type StatusEntry struct { + X, Y byte // staged / worktree status codes + Path string // repo-relative, verbatim (the -z format does not quote) +} + +// Unmerged reports whether the entry is in a conflict state. +func (e StatusEntry) Unmerged() bool { + return e.X == 'U' || e.Y == 'U' || + (e.X == 'D' && e.Y == 'D') || (e.X == 'A' && e.Y == 'A') +} + +// statusZ lists every dirty path in dir. --no-renames keeps records +// single-path (a staged rename arrives as its A and D halves, which is +// exactly the final state a tree-level snapshot needs), and -z emits paths +// verbatim where the human format would quote and escape them. +func statusZ(ctx context.Context, dir string, env []string) ([]StatusEntry, error) { + out, err := runGitRaw(ctx, dir, env, nil, + "status", "--porcelain", "-z", "--untracked-files=all", "--no-renames") + if err != nil { + return nil, err + } + return parseStatusZ(out) +} + +func parseStatusZ(out string) ([]StatusEntry, error) { + fields := strings.Split(out, "\x00") + var entries []StatusEntry + for i := 0; i < len(fields); i++ { + record := fields[i] + if record == "" { + continue + } + if len(record) < 4 || record[2] != ' ' { + return nil, fmt.Errorf("unparseable git status record %q", record) + } + entry := StatusEntry{X: record[0], Y: record[1], Path: record[3:]} + entries = append(entries, entry) + // Defensive: rename/copy records carry a second, source-path field. + // --no-renames should prevent them, but skipping one silently would + // misattribute the next record. + if entry.X == 'R' || entry.X == 'C' || entry.Y == 'R' || entry.Y == 'C' { + if i+1 < len(fields) && fields[i+1] != "" { + i++ + entries = append(entries, StatusEntry{X: entry.X, Y: entry.Y, Path: fields[i]}) + } + } + } + return entries, nil +} From b70ba84118e990cf4a3d6b5f4bb9acf4402bfd90 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:16:48 +0000 Subject: [PATCH 03/22] feat(gitagent): receivers, task state and the pure-data admission tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InitMailbox/InitSidecar create the two receiving repos with the mandated R2.2 config — advertisePushOptions, fsckObjects, denyDeletes, autogc off, full ref logging and a finite maxInputSize — and the mailbox shares the real repository's object store via objects/info/alternates so protocol refs never touch the user's working repo (R2.1/H8). Hook shims exec the captain binary with stdin and env flowing through, refuse to clobber foreign hooks, and re-install idempotently. Admit is the sub-second pre-receive tier (§6.2 step 7): protocol ref shape, create-only with deletes and updates refused (R3.2), the dispatch+control / result+control atomic pairing (R3.4), envelope↔ref agreement (R4.1), separator-aware namespace and per-agent task ownership (R8.3/H11), attempt caps, fast-forward-only agent branches, and content gates that need no materialized tree: policy path globs, secret-shaped names via the exported commit.LooksSecret (A5.4), and blob size caps read from object metadata. Control payloads travel as parentless commits, never bare trees (R3.3). Receiver-side task state lives under /captain/, outside the object store, so it survives a rejected push (R6.9 groundwork). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- pkg/gitagent/admit.go | 329 +++++++++++++++++++++++++++ pkg/gitagent/admit_ginkgo_test.go | 264 +++++++++++++++++++++ pkg/gitagent/control.go | 66 ++++++ pkg/gitagent/hookshim.go | 53 +++++ pkg/gitagent/receiver.go | 103 +++++++++ pkg/gitagent/receiver_ginkgo_test.go | 87 +++++++ pkg/gitagent/state.go | 71 ++++++ 7 files changed, 973 insertions(+) create mode 100644 pkg/gitagent/admit.go create mode 100644 pkg/gitagent/admit_ginkgo_test.go create mode 100644 pkg/gitagent/control.go create mode 100644 pkg/gitagent/hookshim.go create mode 100644 pkg/gitagent/receiver.go create mode 100644 pkg/gitagent/receiver_ginkgo_test.go create mode 100644 pkg/gitagent/state.go diff --git a/pkg/gitagent/admit.go b/pkg/gitagent/admit.go new file mode 100644 index 00000000..2ae3d0cc --- /dev/null +++ b/pkg/gitagent/admit.go @@ -0,0 +1,329 @@ +// Admission (§6.2 step 7): the sub-second, pure-data tier of pre-receive. +// Everything here is checkable from ref names, the envelope, task state and +// object metadata — no tree is materialized and no hook is run. +package gitagent + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + + "github.com/flanksource/captain/pkg/ai/agent/commit" +) + +// RefUpdate is one "old new ref" line of the pre-receive stdin. +type RefUpdate struct { + Old, New, Ref string +} + +// IsCreate reports whether the update creates the ref. +func (u RefUpdate) IsCreate() bool { return u.Old == zeroOID } + +// IsDelete reports whether the update deletes the ref. +func (u RefUpdate) IsDelete() bool { return u.New == zeroOID } + +// ParseRefUpdates reads pre-receive stdin lines. +func ParseRefUpdates(r io.Reader) ([]RefUpdate, error) { + var updates []RefUpdate + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 3 { + return nil, fmt.Errorf("unparseable ref update line %q", line) + } + if err := ValidateOID(fields[0]); err != nil { + return nil, fmt.Errorf("ref update %q: old: %w", line, err) + } + if err := ValidateOID(fields[1]); err != nil { + return nil, fmt.Errorf("ref update %q: new: %w", line, err) + } + updates = append(updates, RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]}) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return updates, nil +} + +// AdmitRequest carries everything the admission tier may consult. +type AdmitRequest struct { + Repo string + Role ReceiverRole + Agent string // authenticated agent identity; "" for an unauthenticated local push + Updates []RefUpdate + Envelope *Envelope // nil when the push carried none (an agent's bare push) + Env []string // hook environment — keeps quarantine object dirs readable +} + +// Admit accepts or rejects a push. The error message is the rejection reason +// shown to the pusher. +func Admit(ctx context.Context, req AdmitRequest) error { + if len(req.Updates) == 0 { + return fmt.Errorf("push updates no refs") + } + protocol := map[string]RefInfo{} + for _, u := range req.Updates { + switch { + case IsProtocolRef(u.Ref): + info, err := admitProtocolRef(req, u) + if err != nil { + return err + } + protocol[u.Ref] = info + case req.Role == RoleSidecar && strings.HasPrefix(u.Ref, agentBranchPrefix): + if err := admitAgentBranch(ctx, req, u); err != nil { + return err + } + default: + return fmt.Errorf("ref %s is outside the protocol namespaces this receiver accepts", u.Ref) + } + } + if err := requireAtomicPairs(protocol); err != nil { + return err + } + for ref, info := range protocol { + if err := admitCodeContent(ctx, req, refUpdateFor(req.Updates, ref), info); err != nil { + return err + } + } + return nil +} + +// admitProtocolRef checks the pure-name properties of one protocol ref +// update: shape, create-only (R3.2), role allowlist, envelope agreement +// (R4.1), and namespace ownership (R8.3). +func admitProtocolRef(req AdmitRequest, u RefUpdate) (RefInfo, error) { + info, err := ParseTaskRef(u.Ref) + if err != nil { + return RefInfo{}, err + } + if u.IsDelete() { + return RefInfo{}, fmt.Errorf("ref %s: protocol refs cannot be deleted (R3.2)", u.Ref) + } + if !u.IsCreate() { + return RefInfo{}, fmt.Errorf("ref %s already exists; every protocol ref push is a create (R3.2)", u.Ref) + } + allowed := map[ReceiverRole][]RefKind{ + RoleSidecar: {RefDispatch, RefControl}, + RoleMailbox: {RefResult, RefControl}, + }[req.Role] + if !containsKind(allowed, info.Kind) { + return RefInfo{}, fmt.Errorf("ref %s: a %s does not accept %s refs over the wire", u.Ref, req.Role, info.Kind) + } + if req.Envelope == nil { + return RefInfo{}, fmt.Errorf("ref %s: protocol ref pushes require the control envelope in push options (R4.1)", u.Ref) + } + if err := req.Envelope.MatchesRef(info); err != nil { + return RefInfo{}, err + } + if !NamespaceContains(TaskNamespace(info.Task), u.Ref) { + return RefInfo{}, fmt.Errorf("ref %s escapes its task namespace", u.Ref) + } + if req.Agent != "" { + st, ok, err := LoadTaskState(req.Repo, info.Task) + if err != nil { + return RefInfo{}, err + } + if !ok { + return RefInfo{}, fmt.Errorf("task %s was never dispatched here", info.Task) + } + if st.Agent != req.Agent { + return RefInfo{}, fmt.Errorf("agent %q cannot write task %s, which belongs to agent %q (R8.3)", req.Agent, info.Task, st.Agent) + } + if st.Policy.MaxAttempts > 0 && info.Attempt > st.Policy.MaxAttempts { + return RefInfo{}, fmt.Errorf("attempt %d exceeds the task's maxAttempts %d", info.Attempt, st.Policy.MaxAttempts) + } + } + return info, nil +} + +// admitAgentBranch checks an agent's bare push to refs/heads/captain/: +// the task must have been dispatched here, deletes are refused, and updates +// must be fast-forward. +func admitAgentBranch(ctx context.Context, req AdmitRequest, u RefUpdate) error { + task := strings.TrimPrefix(u.Ref, agentBranchPrefix) + if err := ValidateTaskID(task); err != nil { + return fmt.Errorf("ref %s: %w", u.Ref, err) + } + if u.IsDelete() { + return fmt.Errorf("ref %s: the task branch cannot be deleted", u.Ref) + } + st, ok, err := LoadTaskState(req.Repo, task) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("task %s was never dispatched to this sidecar", task) + } + if !u.IsCreate() { + code, _, err := gitExitCode(ctx, req.Repo, req.Env, "merge-base", "--is-ancestor", u.Old, u.New) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("ref %s: non-fast-forward push rejected; fetch and rebase onto your branch tip", u.Ref) + } + } + return admitContent(ctx, req, st, st.DispatchCommit, u.New) +} + +// requireAtomicPairs enforces R3.4: a code ref and its control ref travel in +// one atomic push, or not at all. +func requireAtomicPairs(protocol map[string]RefInfo) error { + if len(protocol) == 0 { + return nil + } + type key struct { + task string + attempt int + } + kinds := map[key]map[RefKind]bool{} + for _, info := range protocol { + k := key{info.Task, info.Attempt} + if kinds[k] == nil { + kinds[k] = map[RefKind]bool{} + } + kinds[k][info.Kind] = true + } + for k, present := range kinds { + code := present[RefDispatch] || present[RefResult] + if code && !present[RefControl] { + return fmt.Errorf("task %s attempt %d: a code ref without its control ref is unprocessable (R3.4)", k.task, k.attempt) + } + if present[RefControl] && !code { + return fmt.Errorf("task %s attempt %d: a control ref must travel with its code ref (R3.4)", k.task, k.attempt) + } + } + return nil +} + +// admitCodeContent runs the content checks that need object access: result +// parentage, blob caps and name gates. Control refs carry no worktree code. +func admitCodeContent(ctx context.Context, req AdmitRequest, u RefUpdate, info RefInfo) error { + if info.Kind != RefResult { + return nil + } + st, ok, err := LoadTaskState(req.Repo, info.Task) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("task %s was never dispatched here", info.Task) + } + parents, err := runGit(ctx, req.Repo, req.Env, "rev-list", "--parents", "-n", "1", u.New) + if err != nil { + return err + } + fields := strings.Fields(parents) + if len(fields) != 2 || fields[1] != st.DispatchCommit { + return fmt.Errorf("result %s must be parented on its dispatch %s", u.New, st.DispatchCommit) + } + return admitContent(ctx, req, st, st.DispatchCommit, u.New) +} + +// admitContent applies the pure-data content gates over old..new: path +// policy, secret-shaped names, and blob size caps. It stats nothing on disk — +// the tree is not materialized at this tier. +func admitContent(ctx context.Context, req AdmitRequest, st *TaskState, from, to string) error { + out, err := runGitRaw(ctx, req.Repo, req.Env, nil, + "diff-tree", "-r", "-z", "--name-only", "--no-commit-id", "--no-renames", from, to) + if err != nil { + return err + } + var paths []string + for _, p := range strings.Split(out, "\x00") { + if p != "" { + paths = append(paths, p) + } + } + kept, err := filterPolicyPaths(paths, st.Policy.Paths) + if err != nil { + return err + } + if len(kept) != len(paths) { + denied := diffPaths(paths, kept) + return fmt.Errorf("gate:path-denied %s", strings.Join(denied, " ")) + } + for _, p := range paths { + if commit.LooksSecret(p) { + return fmt.Errorf("gate:secret-name %s looks like a credential; the push is rejected (A5.4)", p) + } + } + return admitBlobCaps(ctx, req, st, to) +} + +func admitBlobCaps(ctx context.Context, req AdmitRequest, st *TaskState, tip string) error { + maxBlob := st.Policy.MaxBlobSize + if maxBlob == 0 { + maxBlob = DefaultSnapshotMaxFileSize + } + objects, err := runGitRaw(ctx, req.Repo, req.Env, nil, + "rev-list", "--objects", tip, "--not", "--all", "--alternate-refs") + if err != nil { + return err + } + var oids strings.Builder + for _, line := range strings.Split(objects, "\n") { + if fields := strings.Fields(line); len(fields) > 0 { + oids.WriteString(fields[0]) + oids.WriteByte('\n') + } + } + if oids.Len() == 0 { + return nil + } + sizes, err := runGitIn(ctx, req.Repo, req.Env, strings.NewReader(oids.String()), + "cat-file", "--batch-check=%(objecttype) %(objectsize) %(objectname)") + if err != nil { + return err + } + for _, line := range strings.Split(sizes, "\n") { + fields := strings.Fields(line) + if len(fields) == 3 && fields[0] == "blob" { + var size int64 + fmt.Sscanf(fields[1], "%d", &size) + if size > maxBlob { + return fmt.Errorf("gate:blob-size object %s is %d bytes, over the %d-byte cap", fields[2], size, maxBlob) + } + } + } + return nil +} + +func containsKind(kinds []RefKind, k RefKind) bool { + for _, kind := range kinds { + if kind == k { + return true + } + } + return false +} + +func refUpdateFor(updates []RefUpdate, ref string) RefUpdate { + for _, u := range updates { + if u.Ref == ref { + return u + } + } + return RefUpdate{} +} + +func diffPaths(all, kept []string) []string { + keep := map[string]bool{} + for _, p := range kept { + keep[p] = true + } + var out []string + for _, p := range all { + if !keep[p] { + out = append(out, p) + } + } + return out +} diff --git a/pkg/gitagent/admit_ginkgo_test.go b/pkg/gitagent/admit_ginkgo_test.go new file mode 100644 index 00000000..cc70aa2a --- /dev/null +++ b/pkg/gitagent/admit_ginkgo_test.go @@ -0,0 +1,264 @@ +package gitagent_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +const zeroOID40 = "0000000000000000000000000000000000000000" + +// admitFixture builds a supervisor repo with a snapshot, a control commit, +// and an initialized sidecar holding both (pushed without admission, so the +// tests can drive Admit directly against real objects). +type admitFixture struct { + super, sidecar string + snap *gitagent.Snapshot + control string + env []string +} + +func newAdmitFixture(ctx context.Context) *admitFixture { + super := GinkgoT().TempDir() + gitT(super, "init", "-q") + writeFileT(super, "src/main.go", "package main\n") + gitT(super, "add", "-A") + gitT(super, "commit", "-q", "-m", "base") + writeFileT(super, "src/dirty.go", "package main // dirty\n") + + snap, err := gitagent.TakeSnapshot(ctx, super, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + control, err := gitagent.BuildControlCommit(ctx, super, map[string][]byte{ + gitagent.ControlTaskFile: []byte(`{"prompt":"do the thing"}`), + gitagent.ControlHooksFile: []byte(`{}`), + gitagent.ControlPolicyFile: []byte(`{}`), + }) + Expect(err).NotTo(HaveOccurred()) + + sidecar := filepath.Join(GinkgoT().TempDir(), "repo.git") + Expect(gitagent.InitSidecar(ctx, sidecar)).To(Succeed()) + gitT(super, "push", "-q", sidecar, + snap.Commit+":refs/captain/tasks/t-1/dispatch/1", + control+":refs/captain/tasks/t-1/control/1") + return &admitFixture{super: super, sidecar: sidecar, snap: snap, control: control, env: os.Environ()} +} + +func (f *admitFixture) envelope() *gitagent.Envelope { + return &gitagent.Envelope{ + Version: gitagent.ProtocolVersion, + Task: "t-1", + Attempt: 1, + Base: f.snap.Base, + Depth: 0, + Agent: "worker-1", + Relay: gitagent.RelaySync, + } +} + +func (f *admitFixture) dispatchUpdates() []gitagent.RefUpdate { + return []gitagent.RefUpdate{ + {Old: zeroOID40, New: f.snap.Commit, Ref: "refs/captain/tasks/t-1/dispatch/1"}, + {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/1"}, + } +} + +// newCommitOn writes files on top of parent in repo and returns the commit, +// leaving the objects in the repo with no ref pointing at them (as quarantine +// would). +func newCommitOn(ctx context.Context, repo, parent string, files map[string]string) string { + GinkgoHelper() + work := GinkgoT().TempDir() + gitT(repo, "worktree", "add", "-q", "--detach", work, parent) + defer gitT(repo, "worktree", "remove", "--force", work) + for path, content := range files { + writeFileT(work, path, content) + } + gitT(work, "add", "-A") + gitT(work, "commit", "-q", "-m", "agent work") + return gitT(work, "rev-parse", "HEAD") +} + +var _ = Describe("admission", func() { + ctx := context.Background() + + It("parses pre-receive stdin", func() { + updates, err := gitagent.ParseRefUpdates(strings.NewReader( + zeroOID40 + " " + strings.Repeat("a", 40) + " refs/captain/tasks/t-1/dispatch/1\n")) + Expect(err).NotTo(HaveOccurred()) + Expect(updates).To(HaveLen(1)) + Expect(updates[0].IsCreate()).To(BeTrue()) + Expect(updates[0].IsDelete()).To(BeFalse()) + + _, err = gitagent.ParseRefUpdates(strings.NewReader("garbage\n")) + Expect(err).To(HaveOccurred()) + }) + + It("admits a well-formed dispatch pair on the sidecar", func() { + f := newAdmitFixture(ctx) + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: f.dispatchUpdates(), Envelope: f.envelope(), Env: f.env, + })).To(Succeed()) + }) + + It("rejects deletes, updates to existing refs, and unpaired refs (R3.2/R3.4)", func() { + f := newAdmitFixture(ctx) + + del := f.dispatchUpdates() + del[0].New = zeroOID40 + err := gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: del, Envelope: f.envelope(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("cannot be deleted"))) + + upd := f.dispatchUpdates() + upd[0].Old = f.snap.Base + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: upd, Envelope: f.envelope(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("every protocol ref push is a create"))) + + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates()[:1], Envelope: f.envelope(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("R3.4"))) + }) + + It("rejects role mismatches, missing envelopes and envelope disagreement (R4.1)", func() { + f := newAdmitFixture(ctx) + + err := gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleMailbox, Updates: f.dispatchUpdates(), Envelope: f.envelope(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("does not accept dispatch"))) + + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates(), Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("require the control envelope"))) + + wrong := f.envelope() + wrong.Attempt = 2 + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates(), Envelope: wrong, Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("disagrees"))) + + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: f.snap.Commit, Ref: "refs/heads/main"}}, + Envelope: f.envelope(), Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("outside the protocol namespaces"))) + }) + + Describe("agent branch pushes on the sidecar", func() { + It("requires a dispatched task and fast-forward updates", func() { + f := newAdmitFixture(ctx) + work := newCommitOn(ctx, f.sidecar, f.snap.Commit, map[string]string{"src/fix.go": "package main // fix\n"}) + + err := gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: work, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("never dispatched"))) + + Expect(gitagent.SaveTaskState(f.sidecar, &gitagent.TaskState{ + Task: "t-1", Agent: "worker-1", Base: f.snap.Base, DispatchCommit: f.snap.Commit, + })).To(Succeed()) + + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: work, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + })).To(Succeed()) + + // A push rewinding to the dispatch tip is not a fast-forward. + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: work, New: f.snap.Commit, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("non-fast-forward"))) + }) + + It("applies the content gates to agent work", func() { + f := newAdmitFixture(ctx) + Expect(gitagent.SaveTaskState(f.sidecar, &gitagent.TaskState{ + Task: "t-1", Agent: "worker-1", Base: f.snap.Base, DispatchCommit: f.snap.Commit, + Policy: gitagent.Policy{Paths: []string{"src/**"}, MaxBlobSize: 64}, + })).To(Succeed()) + + secret := newCommitOn(ctx, f.sidecar, f.snap.Commit, map[string]string{"src/.env": "TOKEN=x\n"}) + err := gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: secret, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("gate:secret-name"))) + + outside := newCommitOn(ctx, f.sidecar, f.snap.Commit, map[string]string{"docs/notes.md": "notes\n"}) + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: outside, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("gate:path-denied"))) + + big := newCommitOn(ctx, f.sidecar, f.snap.Commit, map[string]string{"src/big.go": strings.Repeat("x", 128) + "\n"}) + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: big, Ref: "refs/heads/captain/t-1"}}, + Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("gate:blob-size"))) + }) + }) + + Describe("result admission on the mailbox", func() { + It("enforces parentage, agent namespace and attempt caps", func() { + f := newAdmitFixture(ctx) + mailbox := filepath.Join(GinkgoT().TempDir(), "mailbox.git") + Expect(gitagent.InitMailbox(ctx, mailbox, f.super)).To(Succeed()) + Expect(gitagent.SaveTaskState(mailbox, &gitagent.TaskState{ + Task: "t-1", Agent: "worker-1", Base: f.snap.Base, DispatchCommit: f.snap.Commit, + Policy: gitagent.Policy{MaxAttempts: 2}, + })).To(Succeed()) + + result := newCommitOn(ctx, f.super, f.snap.Commit, map[string]string{"src/fix.go": "package main // fix\n"}) + resultUpdates := []gitagent.RefUpdate{ + {Old: zeroOID40, New: result, Ref: "refs/captain/tasks/t-1/result/1"}, + {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/1"}, + } + + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", + Updates: resultUpdates, Envelope: f.envelope(), Env: f.env, + })).To(Succeed()) + + err := gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-2", + Updates: resultUpdates, Envelope: f.envelope(), Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("belongs to agent"))) + + orphan := newCommitOn(ctx, f.super, f.snap.Base, map[string]string{"src/fix.go": "package main // fix\n"}) + orphanUpdates := []gitagent.RefUpdate{ + {Old: zeroOID40, New: orphan, Ref: "refs/captain/tasks/t-1/result/1"}, + {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/1"}, + } + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", + Updates: orphanUpdates, Envelope: f.envelope(), Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("parented on its dispatch"))) + + over := f.envelope() + over.Attempt = 3 + overUpdates := []gitagent.RefUpdate{ + {Old: zeroOID40, New: result, Ref: "refs/captain/tasks/t-1/result/3"}, + {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/3"}, + } + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", + Updates: overUpdates, Envelope: over, Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("maxAttempts"))) + }) + }) +}) diff --git a/pkg/gitagent/control.go b/pkg/gitagent/control.go new file mode 100644 index 00000000..87dad426 --- /dev/null +++ b/pkg/gitagent/control.go @@ -0,0 +1,66 @@ +package gitagent + +import ( + "context" + "fmt" + "os" + "sort" + "strings" +) + +// Control payload file names (§4). +const ( + ControlTaskFile = "task.json" + ControlHooksFile = "hooks.json" + ControlPolicyFile = "policy.json" +) + +// BuildControlCommit writes payloads as a flat tree and wraps it in a +// parentless commit. Control refs point at commits, never bare trees — a +// tree-tipped ref trips gc, bitmap and fsck paths (R3.3). +func BuildControlCommit(ctx context.Context, repoDir string, payloads map[string][]byte) (string, error) { + if len(payloads) == 0 { + return "", fmt.Errorf("a control commit needs at least one payload") + } + env := ScrubGitEnv(os.Environ()) + names := make([]string, 0, len(payloads)) + for name := range payloads { + if name == "" || strings.ContainsAny(name, "/\x00") { + return "", fmt.Errorf("control payload name %q must be a bare file name", name) + } + names = append(names, name) + } + sort.Strings(names) + var tree strings.Builder + for _, name := range names { + oid, err := runGitIn(ctx, repoDir, env, strings.NewReader(string(payloads[name])), + "hash-object", "-w", "--no-filters", "--stdin") + if err != nil { + return "", err + } + fmt.Fprintf(&tree, "100644 blob %s\t%s\n", oid, name) + } + treeOID, err := runGitIn(ctx, repoDir, env, strings.NewReader(tree.String()), "mktree") + if err != nil { + return "", err + } + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + return runGitIn(ctx, repoDir, cenv, + strings.NewReader("captain control envelope payloads\n"), + "commit-tree", treeOID) +} + +// ReadControlPayload reads one payload file from a control commit's tree, +// readable in pre-receive through the quarantine object directories. +func ReadControlPayload(ctx context.Context, repoDir string, env []string, controlCommit, name string) ([]byte, error) { + out, err := runGitRaw(ctx, repoDir, env, nil, "cat-file", "blob", controlCommit+":"+name) + if err != nil { + return nil, err + } + return []byte(out), nil +} diff --git a/pkg/gitagent/hookshim.go b/pkg/gitagent/hookshim.go new file mode 100644 index 00000000..2ebef296 --- /dev/null +++ b/pkg/gitagent/hookshim.go @@ -0,0 +1,53 @@ +package gitagent + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const hookShimMarker = "# installed by captain sandbox git-agent" + +// InstallHookShims writes pre-receive and post-receive shims into the repo's +// hooks directory, each exec'ing the captain binary's hook subcommand with +// stdin and environment flowing through untouched. Re-running is idempotent: +// an identical shim is left alone, a stale one is rewritten, and a foreign +// hook is refused rather than silently replaced. +func InstallHookShims(repoPath, captainBin string, role ReceiverRole) error { + bin, err := filepath.Abs(captainBin) + if err != nil { + return err + } + repo, err := filepath.Abs(repoPath) + if err != nil { + return err + } + for _, hook := range []string{"pre-receive", "post-receive"} { + shim := fmt.Sprintf(`#!/bin/sh +%s +exec %q sandbox git-agent hook %s --repo %q --role %q +`, hookShimMarker, bin, hook, repo, string(role)) + target := filepath.Join(repo, "hooks", hook) + existing, err := os.ReadFile(target) + switch { + case err == nil && string(existing) == shim: + continue + case err == nil && !containsMarker(existing): + return fmt.Errorf("%s already has a %s hook not installed by captain; remove it first", repo, hook) + case err != nil && !os.IsNotExist(err): + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := writeFileAtomic(target, []byte(shim), 0o755); err != nil { + return err + } + } + return nil +} + +func containsMarker(content []byte) bool { + return strings.Contains(string(content), hookShimMarker) +} diff --git a/pkg/gitagent/receiver.go b/pkg/gitagent/receiver.go new file mode 100644 index 00000000..3736b745 --- /dev/null +++ b/pkg/gitagent/receiver.go @@ -0,0 +1,103 @@ +// Receiver repositories: the supervisor-side mailbox and the agent-side +// sidecar bare repo. Both carry the mandated config (R2.2); the mailbox +// additionally shares the real repository's object store via alternates so +// protocol refs never pollute the user's working repo (R2.1/H8). +package gitagent + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" +) + +// ReceiverRole distinguishes the two admission tiers. +type ReceiverRole string + +const ( + RoleMailbox ReceiverRole = "mailbox" + RoleSidecar ReceiverRole = "sidecar" +) + +// DefaultMaxInputSize caps a single incoming pack (R2.2/H10). +const DefaultMaxInputSize = int64(512 << 20) + +// receiverConfig is the R2.2 block every receiving repo MUST carry. +func receiverConfig(maxInputSize int64) [][2]string { + return [][2]string{ + {"receive.advertisePushOptions", "true"}, + {"receive.fsckObjects", "true"}, + {"receive.denyDeletes", "true"}, + {"receive.autogc", "false"}, + {"core.logAllRefUpdates", "always"}, + {"receive.maxInputSize", strconv.FormatInt(maxInputSize, 10)}, + } +} + +// InitMailbox creates (or re-configures — every step is idempotent) the bare +// mailbox repo at path, sharing objects with the real repository at realRepo +// via objects/info/alternates. +func InitMailbox(ctx context.Context, path, realRepo string) error { + if err := initReceiver(ctx, path); err != nil { + return err + } + realGitDir, err := runGit(ctx, realRepo, ScrubGitEnv(os.Environ()), "rev-parse", "--absolute-git-dir") + if err != nil { + return fmt.Errorf("mailbox: resolving the real repository: %w", err) + } + objects := filepath.Join(realGitDir, "objects") + if _, err := os.Stat(objects); err != nil { + return fmt.Errorf("mailbox: real repository object store %s: %w", objects, err) + } + alternates := filepath.Join(path, "objects", "info", "alternates") + if err := os.MkdirAll(filepath.Dir(alternates), 0o755); err != nil { + return err + } + return writeFileAtomic(alternates, []byte(objects+"\n"), 0o644) +} + +// InitSidecar creates the bare sidecar repo at path. +func InitSidecar(ctx context.Context, path string) error { + return initReceiver(ctx, path) +} + +func initReceiver(ctx context.Context, path string) error { + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + env := ScrubGitEnv(os.Environ()) + if _, err := runGit(ctx, path, env, "init", "--quiet", "--bare"); err != nil { + return err + } + for _, kv := range receiverConfig(DefaultMaxInputSize) { + if _, err := runGit(ctx, path, env, "config", kv[0], kv[1]); err != nil { + return err + } + } + return os.MkdirAll(filepath.Join(path, "captain"), 0o755) +} + +// writeFileAtomic writes via a sibling temp file and rename, so a reader +// never observes a partial file. +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") + if err != nil { + return err + } + name := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(name) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(name) + return err + } + if err := os.Chmod(name, mode); err != nil { + os.Remove(name) + return err + } + return os.Rename(name, path) +} diff --git a/pkg/gitagent/receiver_ginkgo_test.go b/pkg/gitagent/receiver_ginkgo_test.go new file mode 100644 index 00000000..cf9a304b --- /dev/null +++ b/pkg/gitagent/receiver_ginkgo_test.go @@ -0,0 +1,87 @@ +package gitagent_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +var _ = Describe("receiver repositories", func() { + ctx := context.Background() + + It("configures a sidecar with the R2.2 block, idempotently", func() { + path := filepath.Join(GinkgoT().TempDir(), "repo.git") + Expect(gitagent.InitSidecar(ctx, path)).To(Succeed()) + Expect(gitagent.InitSidecar(ctx, path)).To(Succeed(), "re-running must be safe") + + for key, want := range map[string]string{ + "receive.advertisepushoptions": "true", + "receive.fsckobjects": "true", + "receive.denydeletes": "true", + "receive.autogc": "false", + "core.logallrefupdates": "always", + } { + Expect(gitT(path, "config", key)).To(Equal(want), key) + } + Expect(gitT(path, "config", "receive.maxinputsize")).NotTo(Equal("0"), "maxInputSize must be finite") + }) + + It("shares the real repository's objects with the mailbox via alternates", func() { + real := GinkgoT().TempDir() + gitT(real, "init", "-q") + writeFileT(real, "a.txt", "a\n") + gitT(real, "add", "-A") + gitT(real, "commit", "-q", "-m", "base") + head := gitT(real, "rev-parse", "HEAD") + + mailbox := filepath.Join(GinkgoT().TempDir(), "mailbox.git") + Expect(gitagent.InitMailbox(ctx, mailbox, real)).To(Succeed()) + Expect(gitagent.InitMailbox(ctx, mailbox, real)).To(Succeed()) + + alternates, err := os.ReadFile(filepath.Join(mailbox, "objects", "info", "alternates")) + Expect(err).NotTo(HaveOccurred()) + Expect(strings.TrimSpace(string(alternates))).To(HaveSuffix(filepath.Join(".git", "objects"))) + // The real repo's commits are readable without any copy. + Expect(gitT(mailbox, "cat-file", "-t", head)).To(Equal("commit")) + }) +}) + +var _ = Describe("hook shims", func() { + It("installs idempotent pre/post-receive shims and refuses to clobber foreign hooks", func() { + ctx := context.Background() + repo := filepath.Join(GinkgoT().TempDir(), "repo.git") + Expect(gitagent.InitSidecar(ctx, repo)).To(Succeed()) + + Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", gitagent.RoleSidecar)).To(Succeed()) + Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", gitagent.RoleSidecar)).To(Succeed()) + + for _, hook := range []string{"pre-receive", "post-receive"} { + path := filepath.Join(repo, "hooks", hook) + info, err := os.Stat(path) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode() & 0o111).NotTo(BeZero()) + content, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("/usr/local/bin/captain")) + Expect(string(content)).To(ContainSubstring("--role \"sidecar\"")) + } + + // A rebinned captain updates the shim in place. + Expect(gitagent.InstallHookShims(repo, "/opt/captain", gitagent.RoleSidecar)).To(Succeed()) + content, err := os.ReadFile(filepath.Join(repo, "hooks", "pre-receive")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("/opt/captain")) + + // A hook captain did not install is never overwritten. + foreign := filepath.Join(repo, "hooks", "pre-receive") + Expect(os.WriteFile(foreign, []byte("#!/bin/sh\nexit 0\n"), 0o755)).To(Succeed()) + err = gitagent.InstallHookShims(repo, "/opt/captain", gitagent.RoleSidecar) + Expect(err).To(MatchError(ContainSubstring("not installed by captain"))) + }) +}) diff --git a/pkg/gitagent/state.go b/pkg/gitagent/state.go new file mode 100644 index 00000000..1a8a06c8 --- /dev/null +++ b/pkg/gitagent/state.go @@ -0,0 +1,71 @@ +// Receiver-side task state, kept under /captain/ — outside the object +// store, because a rejected push must leave zero new objects and refs while +// the verdict still has to survive (R6.9). +package gitagent + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Policy is the policy.json payload referenced by a control ref (§4). +type Policy struct { + Paths []string `json:"paths,omitempty"` + MaxAttempts int `json:"maxAttempts,omitempty"` + MaxBlobSize int64 `json:"maxBlobSize,omitempty"` +} + +// TaskState records what a receiver knows about a dispatched task. +type TaskState struct { + Task string `json:"task"` + Agent string `json:"agent,omitempty"` + Base string `json:"base"` + DispatchCommit string `json:"dispatchCommit"` + Attempts int `json:"attempts"` // highest attempt seen + Relay RelayMode `json:"relay,omitempty"` + Policy Policy `json:"policy"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func taskStateDir(repo, task string) string { + return filepath.Join(repo, "captain", "tasks", task) +} + +// LoadTaskState reads a task's state; ok is false when the task is unknown. +func LoadTaskState(repo, task string) (*TaskState, bool, error) { + if err := ValidateTaskID(task); err != nil { + return nil, false, err + } + data, err := os.ReadFile(filepath.Join(taskStateDir(repo, task), "state.json")) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var st TaskState + if err := json.Unmarshal(data, &st); err != nil { + return nil, false, fmt.Errorf("task %s state: %w", task, err) + } + return &st, true, nil +} + +// SaveTaskState persists st atomically. +func SaveTaskState(repo string, st *TaskState) error { + if err := ValidateTaskID(st.Task); err != nil { + return err + } + dir := taskStateDir(repo, st.Task) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + st.UpdatedAt = time.Now().UTC() + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + return writeFileAtomic(filepath.Join(dir, "state.json"), append(data, '\n'), 0o644) +} From 7b99c379fa72ffb305543b4b50a40eee3755ae20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:25:53 +0000 Subject: [PATCH 04/22] feat(gitagent): materialization, sandboxed hook sets, verdicts and feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materialize runs read-tree + checkout-index against an absolutized destination — never git archive (R6.5/H9) — and asserts the tree is non-empty with a file count matching ls-tree before any hook may see it (R1.3/H18): exit 0 from checkout-index is not proof of materialization. A .git path component refuses to materialize as defense in depth behind receive.fsckObjects. RunHookSet executes an api.Workflow as one tier's single-verdict chain over the materialized tree, reusing the exact builders the local run path uses (A5.1): commit gates, then CmdVerifier exec hooks, then LLM-judge prompt hooks, stopping at the first failure (R5.1). Exec hooks gain the confinement seam — CmdVerifier now carries Env and a Wrap func mirroring api.CommandWrapper — and a hook set with exec hooks but no wrap refuses to run rather than exec agent-authored commands on the host (R5.2/H1). Prompt hooks are depth-bounded (R5.4/H15) and run against an injected provider; tests judge through a stub with no live model call. Every failure folds into a verdict whose error status rejects (R7.5), persisted outside git keyed by (task, attempt) before pre-receive exits non-zero (R6.9). Feedback renders the §7 wire format: CR-free (R7.1), one greppable captain-json line that degrades before it can blow the budget (R7.2), a 64 KiB cap with an explicit truncation marker and full-log pointer (R7.3), and a keepalive ticker for long hooks (R7.4). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- pkg/ai/agent/verify/verify.go | 36 +++- pkg/gitagent/feedback.go | 152 +++++++++++++++++ pkg/gitagent/hookset.go | 166 ++++++++++++++++++ pkg/gitagent/hookset_ginkgo_test.go | 252 ++++++++++++++++++++++++++++ pkg/gitagent/materialize.go | 110 ++++++++++++ pkg/gitagent/verdict.go | 89 ++++++++++ 6 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 pkg/gitagent/feedback.go create mode 100644 pkg/gitagent/hookset.go create mode 100644 pkg/gitagent/hookset_ginkgo_test.go create mode 100644 pkg/gitagent/materialize.go create mode 100644 pkg/gitagent/verdict.go diff --git a/pkg/ai/agent/verify/verify.go b/pkg/ai/agent/verify/verify.go index cf1081b0..3852094d 100644 --- a/pkg/ai/agent/verify/verify.go +++ b/pkg/ai/agent/verify/verify.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "os" "os/exec" "strings" "sync" @@ -46,6 +47,11 @@ func New(name string, v Verifier) *Plugin { return &Plugin{name: name, v: v} } func (p *Plugin) Name() string { return p.name } +// Verifier exposes the wrapped Verifier so a caller that runs checks outside +// an agent loop — the git-agent receive path — can drive it directly with its +// own cwd and changed set instead of an agent.HookContext. +func (p *Plugin) Verifier() Verifier { return p.v } + func (p *Plugin) Verify(hc *agent.HookContext) (agent.VerifyResult, error) { ws := hc.Workspace() var changed []string @@ -95,10 +101,18 @@ type CmdVerifier struct { Cmd string Args []string PerFile bool - FeedbackTail int // max bytes of output fed back; 0 ⇒ 4096 - Timeout time.Duration // wall-clock bound; 0 ⇒ DefaultCmdTimeout + FeedbackTail int // max bytes of output fed back; 0 ⇒ 4096 + Timeout time.Duration // wall-clock bound; 0 ⇒ DefaultCmdTimeout + Env []string // command environment; nil ⇒ inherit the process's + Wrap CommandWrapFunc // optional confinement seam; see CommandWrapFunc } +// CommandWrapFunc rewrites a command for confined execution. It mirrors +// api.CommandWrapper's Wrap signature so a resolved sandbox adapter plugs in +// directly — hook inputs are untrusted, so a receive path must never exec +// them bare on the host (issue #40 R5.2). +type CommandWrapFunc func(ctx context.Context, cmd string, args, env []string) (string, []string, []string, error) + func (c *CmdVerifier) Verify(ctx context.Context, cwd string, changed []string) (Verdict, error) { args := append([]string(nil), c.Args...) if c.PerFile { @@ -120,8 +134,24 @@ func (c *CmdVerifier) Verify(ctx context.Context, cwd string, changed []string) } output := &tailBuffer{max: tail} - cmd := exec.CommandContext(runCtx, c.Cmd, args...) + command, cmdArgs, env := c.Cmd, args, c.Env + if c.Wrap != nil { + wrapEnv := env + if wrapEnv == nil { + wrapEnv = os.Environ() + } + var err error + command, cmdArgs, env, err = c.Wrap(ctx, command, cmdArgs, wrapEnv) + if err != nil { + return Verdict{}, fmt.Errorf("wrapping %s for sandboxed execution: %w", c.Cmd, err) + } + } + + cmd := exec.CommandContext(runCtx, command, cmdArgs...) cmd.Dir = cwd + if env != nil { + cmd.Env = env + } cmd.Stdout, cmd.Stderr = output, output // Own process group, and cancellation kills the group: signalling only the // pid leaves a hook's children running after their parent is dead. diff --git a/pkg/gitagent/feedback.go b/pkg/gitagent/feedback.go new file mode 100644 index 00000000..586569e1 --- /dev/null +++ b/pkg/gitagent/feedback.go @@ -0,0 +1,152 @@ +// The feedback wire format (§7): everything the agent sees arrives through +// the sideband, prefixed `remote:` by its client. CR would be eaten as a +// progress-line terminator (R7.1); the JSON summary is one greppable line +// (R7.2); the block is capped with an explicit truncation marker (R7.3); and +// long hooks emit keepalive traffic so intermediaries don't drop the +// connection (R7.4). +package gitagent + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +// MaxFeedbackBytes caps the feedback block (R7.3). +const MaxFeedbackBytes = 64 << 10 + +// maxFindingFeedback bounds one finding's feedback in both the human block +// and the JSON line, so the line stays a line and the block stays cappable. +const maxFindingFeedback = 8 << 10 + +// FormatFeedback renders the §7 block. fullLogPath, when non-empty, is named +// in the truncation marker so a capped block still points at the whole story. +func FormatFeedback(v TierVerdict, fullLogPath string) (string, error) { + v = boundFindings(v) + jsonLine, err := feedbackJSONLine(v) + if err != nil { + return "", err + } + var b strings.Builder + verb := strings.ToUpper(string(v.Status)) + fmt.Fprintf(&b, "captain: %s task %s attempt %d (%s)\n\n", verb, v.Task, v.Attempt, v.Tier) + for _, f := range v.Findings { + mark := "✗" + fmt.Fprintf(&b, "%s %s", mark, f.Hook) + if f.Path != "" { + fmt.Fprintf(&b, " %s", f.Path) + } + if f.Message != "" { + fmt.Fprintf(&b, " %s", f.Message) + } + b.WriteByte('\n') + for _, line := range strings.Split(strings.TrimSpace(f.Feedback), "\n") { + if line != "" { + fmt.Fprintf(&b, " %s\n", line) + } + } + } + human := sanitizeCR(b.String()) + budget := MaxFeedbackBytes - len(jsonLine) - 256 // headroom for the marker + if budget < 0 { + budget = 0 + } + if len(human) > budget { + marker := "\n[feedback truncated" + if fullLogPath != "" { + marker += "; full log: " + fullLogPath + } + marker += "]\n" + human = human[:budget] + marker + } + return human + "\n" + jsonLine + "\n", nil +} + +// boundFindings truncates each finding's feedback to maxFindingFeedback, +// copying so the caller's verdict (and the persisted verdict.json) keeps the +// full text. +func boundFindings(v TierVerdict) TierVerdict { + if len(v.Findings) == 0 { + return v + } + findings := make([]Finding, len(v.Findings)) + copy(findings, v.Findings) + for i := range findings { + if len(findings[i].Feedback) > maxFindingFeedback { + findings[i].Feedback = findings[i].Feedback[:maxFindingFeedback] + "\n[finding feedback truncated]" + } + } + v.Findings = findings + return v +} + +// feedbackJSONLine renders the single-line machine summary (R7.2). When the +// full form would eat the 64 KiB budget, the summary degrades — feedback +// bodies drop out and the findings list is capped — because the human block +// and the retained log carry the detail; the line must stay recoverable. +func feedbackJSONLine(v TierVerdict) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + if len(data) > MaxFeedbackBytes/2 { + slim := v + findings := slim.Findings + if len(findings) > 32 { + findings = findings[:32] + } + slimFindings := make([]Finding, len(findings)) + copy(slimFindings, findings) + for i := range slimFindings { + slimFindings[i].Feedback = "" + } + slim.Findings = slimFindings + if data, err = json.Marshal(slim); err != nil { + return "", err + } + } + return "captain-json: " + sanitizeCR(string(data)), nil +} + +// sanitizeCR strips carriage returns: the sideband demuxer consumes CR as a +// progress-line terminator and the text after it is lost (R7.1). +func sanitizeCR(s string) string { + return strings.ReplaceAll(s, "\r", "") +} + +// WriteFeedback renders v onto w (pre-receive stderr → the pusher's sideband). +func WriteFeedback(w io.Writer, v TierVerdict, fullLogPath string) error { + block, err := FormatFeedback(v, fullLogPath) + if err != nil { + return err + } + _, err = io.WriteString(w, block) + return err +} + +// StartProgress emits a keepalive line to w every interval until the returned +// stop function is called (R7.4). Without sideband traffic during a long +// hook, intermediaries drop the connection and the agent sees a transport +// error instead of a verdict. +func StartProgress(w io.Writer, label string, interval time.Duration) (stop func()) { + if interval <= 0 { + interval = 30 * time.Second + } + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + start := time.Now() + for { + select { + case <-done: + return + case <-ticker.C: + fmt.Fprintf(w, "captain: %s still running (%ds)\n", label, int(time.Since(start).Seconds())) + } + } + }() + return func() { close(done) } +} diff --git a/pkg/gitagent/hookset.go b/pkg/gitagent/hookset.go new file mode 100644 index 00000000..5a4d6b6f --- /dev/null +++ b/pkg/gitagent/hookset.go @@ -0,0 +1,166 @@ +// Hook-set execution (§5): an api.Workflow run as a single-verdict check +// chain over a materialized tree. Order is commit gates, then exec verifiers, +// then prompt judges; the chain stops at the first failure and that hook's +// output is the verdict's feedback (R5.1). Exec hooks are agent-authored +// input and MUST run confined — a wrap-command sandbox is required, not +// optional (R5.2/H1). Nothing here returns a Go error: every failure mode +// folds into the verdict, and an indeterminate verdict rejects (R7.5). +package gitagent + +import ( + "context" + "fmt" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent" + agentcommit "github.com/flanksource/captain/pkg/ai/agent/commit" + "github.com/flanksource/captain/pkg/ai/agent/verify" + "github.com/flanksource/captain/pkg/api" +) + +// DefaultHookTimeout bounds one hook's wall clock inside a blocked push +// (R5.5); tighter than verify.DefaultCmdTimeout because a push is waiting. +const DefaultHookTimeout = 5 * time.Minute + +// HookWorkspace is the materialized tree a hook set runs against. +type HookWorkspace struct { + Dir string // absolute path of the materialized tree + Changed []string // repo-relative paths the push changed +} + +// HookSetOptions configures one tier's hook-set run. +type HookSetOptions struct { + Workflow *api.Workflow + Tier string // "sidecar" | "supervisor" + Task string + Attempt int + Depth int // envelope depth of the push being vetted (R5.4/H15) + Judge ai.Provider // provider for prompt hooks; nil forbids them + Wrap verify.CommandWrapFunc + Env []string // scrubbed environment for exec hooks (R1.1) + Timeout time.Duration +} + +// RunHookSet executes the workflow's checks and renders the tier's verdict. +func RunHookSet(ctx context.Context, ws HookWorkspace, opts HookSetOptions) TierVerdict { + verdict := TierVerdict{ + V: ProtocolVersion, + Task: opts.Task, + Attempt: opts.Attempt, + Status: StatusAccepted, + Tier: opts.Tier, + } + wf := opts.Workflow + if wf == nil { + return verdict + } + if finding, failed := runCommitGates(ws, wf); failed { + verdict.Status = StatusRejected + verdict.Findings = append(verdict.Findings, finding) + return verdict + } + plugins, errFinding := buildHookPlugins(wf, opts) + if errFinding != nil { + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, *errFinding) + return verdict + } + changed := ws.Changed + if verify.ScopeForWorkflow(wf) != agent.ScopeChanged { + changed = nil // whole-tree semantics, matching verify.Plugin + } + for _, p := range plugins { + vd, err := p.Verifier().Verify(ctx, ws.Dir, changed) + if err != nil { + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, Finding{ + Hook: p.Name(), Kind: hookKind(p.Name()), + Message: fmt.Sprintf("hook could not reach a verdict: %v", err), + }) + return verdict + } + if !vd.OK { + verdict.Status = StatusRejected + verdict.Findings = append(verdict.Findings, Finding{ + Hook: p.Name(), Kind: hookKind(p.Name()), + Message: vd.Reason, Feedback: vd.Feedback, + }) + return verdict + } + } + return verdict +} + +// runCommitGates applies each declared commit policy's content gates over the +// changed paths (the receive-side analogue of commit.Hook's pre-commit check). +func runCommitGates(ws HookWorkspace, wf *api.Workflow) (Finding, bool) { + for _, c := range wf.Commits { + if err := agentcommit.CheckGates(ws.Dir, c.EffectiveGates(), 0, ws.Changed); err != nil { + return Finding{ + Hook: "gate:commit", Kind: "commit", + Message: err.Error(), + }, true + } + } + return Finding{}, false +} + +// buildHookPlugins assembles exec and prompt verifiers via the same builders +// the local run path uses (A5.1: one hook machinery), confining every exec +// hook and bounding prompt-hook recursion. +func buildHookPlugins(wf *api.Workflow, opts HookSetOptions) ([]*verify.Plugin, *Finding) { + var plugins []*verify.Plugin + execHooks := verify.HooksForWorkflow(wf) + if len(execHooks) > 0 && opts.Wrap == nil { + return nil, &Finding{ + Hook: "hookset", Kind: "exec", + Message: "exec hooks require a wrap-command sandbox; refusing to run agent-authored commands on the host (R5.2)", + } + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = DefaultHookTimeout + } + for _, h := range execHooks { + p, ok := h.(*verify.Plugin) + if !ok { + return nil, &Finding{Hook: "hookset", Kind: "exec", Message: fmt.Sprintf("unexpected hook type %T", h)} + } + if cv, ok := p.Verifier().(*verify.CmdVerifier); ok { + cv.Env = opts.Env + cv.Wrap = opts.Wrap + cv.Timeout = timeout + } + plugins = append(plugins, p) + } + if wf.Verify != nil && len(wf.Verify.Prompts) > 0 && opts.Depth+1 > MaxHookDepth { + return nil, &Finding{ + Hook: "hookset", Kind: "prompt", + Message: fmt.Sprintf("prompt hooks at depth %d exceed the recursion bound %d (R5.4/H15)", opts.Depth+1, MaxHookDepth), + } + } + judgeHooks, err := verify.PromptHooksForWorkflow(wf, opts.Judge) + if err != nil { + return nil, &Finding{Hook: "hookset", Kind: "prompt", Message: err.Error()} + } + for _, h := range judgeHooks { + p, ok := h.(*verify.Plugin) + if !ok { + return nil, &Finding{Hook: "hookset", Kind: "prompt", Message: fmt.Sprintf("unexpected hook type %T", h)} + } + plugins = append(plugins, p) + } + return plugins, nil +} + +func hookKind(name string) string { + switch { + case len(name) > 6 && name[:6] == "judge:": + return "prompt" + case len(name) > 7 && name[:7] == "verify:": + return "exec" + default: + return "exec" + } +} diff --git a/pkg/gitagent/hookset_ginkgo_test.go b/pkg/gitagent/hookset_ginkgo_test.go new file mode 100644 index 00000000..4a58445d --- /dev/null +++ b/pkg/gitagent/hookset_ginkgo_test.go @@ -0,0 +1,252 @@ +package gitagent_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" +) + +// identityWrap satisfies the mandatory confinement seam for tests without a +// real sandbox runtime. +func identityWrap(_ context.Context, cmd string, args, env []string) (string, []string, []string, error) { + return cmd, args, env, nil +} + +// judgeStub is an ai.Provider that returns a fixed judge verdict with no live +// model call. +type judgeStub struct { + verdict string + judged int +} + +func (p *judgeStub) Execute(_ context.Context, req ai.Request) (*ai.Response, error) { + p.judged++ + if req.Prompt.Schema != nil { + // A real provider unmarshals the model's JSON into the structured + // output target; the stub does the same with its canned verdict. + if err := json.Unmarshal([]byte(p.verdict), req.Prompt.Schema); err != nil { + return nil, err + } + } + return &ai.Response{Text: p.verdict}, nil +} +func (p *judgeStub) GetModel() string { return "stub" } +func (p *judgeStub) GetBackend() api.Backend { return api.BackendAnthropic } + +var _ = Describe("materialization", func() { + ctx := context.Background() + + It("materializes the full tree into an absolute destination and counts it (R1.3)", func() { + f := newAdmitFixture(ctx) + dst := filepath.Join(GinkgoT().TempDir(), "tree") + count, err := gitagent.Materialize(ctx, f.super, os.Environ(), f.snap.Commit, dst) + Expect(err).NotTo(HaveOccurred()) + Expect(count).To(Equal(2)) // src/main.go + src/dirty.go + Expect(os.ReadFile(filepath.Join(dst, "src", "dirty.go"))).To(Equal([]byte("package main // dirty\n"))) + }) + + It("detects an empty or short materialization instead of passing (H18)", func() { + Expect(gitagent.AssertMaterialized(0, 3)).To(MatchError(ContainSubstring("H18"))) + Expect(gitagent.AssertMaterialized(2, 3)).To(MatchError(ContainSubstring("H18"))) + Expect(gitagent.AssertMaterialized(3, 0)).To(MatchError(ContainSubstring("H18"))) + Expect(gitagent.AssertMaterialized(3, 3)).To(Succeed()) + }) + + It("refuses a tree containing a .git path component (H9)", func() { + Expect(gitagent.RejectDotGitComponents([]string{"a/b.txt", ".git/config"})).To(MatchError(ContainSubstring("H9"))) + Expect(gitagent.RejectDotGitComponents([]string{"a/.GIT/hook"})).To(MatchError(ContainSubstring("H9"))) + Expect(gitagent.RejectDotGitComponents([]string{"a/gitty/.gitignore"})).To(Succeed()) + }) +}) + +var _ = Describe("hook sets", func() { + ctx := context.Background() + + ws := func() gitagent.HookWorkspace { + dir := GinkgoT().TempDir() + writeFileT(dir, "main.go", "package main\n") + return gitagent.HookWorkspace{Dir: dir, Changed: []string{"main.go"}} + } + + It("accepts an empty workflow and a passing chain", func() { + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{Task: "t-1", Attempt: 1, Tier: "sidecar"}) + Expect(v.Status).To(Equal(gitagent.StatusAccepted)) + + v = gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"true"}}}, + Wrap: identityWrap, + }) + Expect(v.Status).To(Equal(gitagent.StatusAccepted)) + Expect(v.Rejects()).To(BeFalse()) + }) + + It("stops at the first failing hook and carries its feedback (R5.1)", func() { + w := ws() + marker := filepath.Join(w.Dir, "second-ran") + v := gitagent.RunHookSet(ctx, w, gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{ + "echo boom-detail && false", + "touch " + marker, + }}}, + Wrap: identityWrap, + }) + Expect(v.Status).To(Equal(gitagent.StatusRejected)) + Expect(v.Findings).To(HaveLen(1)) + Expect(v.Findings[0].Feedback).To(ContainSubstring("boom-detail")) + Expect(marker).NotTo(BeAnExistingFile(), "the chain must stop at the first failure") + }) + + It("refuses to run exec hooks without a confinement wrap (R5.2)", func() { + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"true"}}}, + }) + Expect(v.Status).To(Equal(gitagent.StatusError)) + Expect(v.Rejects()).To(BeTrue(), "an indeterminate verdict rejects (R7.5)") + Expect(v.Findings[0].Message).To(ContainSubstring("R5.2")) + }) + + It("kills a hook that overruns its timeout and reports error status", func() { + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"sleep 30"}}}, + Wrap: identityWrap, + Timeout: 200 * time.Millisecond, + }) + Expect(v.Status).To(Equal(gitagent.StatusRejected)) + Expect(v.Findings[0].Message).To(ContainSubstring("timed out")) + }) + + It("runs prompt hooks through a mock provider with no live model call", func() { + promptPath := filepath.Join(GinkgoT().TempDir(), "review.prompt") + Expect(os.WriteFile(promptPath, []byte("{{role \"user\"}}\nJudge {{cwd}}."), 0o644)).To(Succeed()) + + reject := &judgeStub{verdict: `{"ok":false,"reason":"style","feedback":"rename Baz"}`} + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "supervisor", + Workflow: &api.Workflow{Verify: &api.Verify{Prompts: []string{promptPath}}}, + Judge: reject, + }) + Expect(v.Status).To(Equal(gitagent.StatusRejected)) + Expect(reject.judged).To(Equal(1)) + Expect(v.Findings[0].Kind).To(Equal("prompt")) + Expect(v.Findings[0].Feedback).To(ContainSubstring("rename Baz")) + + accept := &judgeStub{verdict: `{"ok":true,"reason":"fine","feedback":""}`} + v = gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "supervisor", + Workflow: &api.Workflow{Verify: &api.Verify{Prompts: []string{promptPath}}}, + Judge: accept, + }) + Expect(v.Status).To(Equal(gitagent.StatusAccepted)) + }) + + It("errors on prompts with no judge, and bounds recursion depth (R5.4)", func() { + promptPath := filepath.Join(GinkgoT().TempDir(), "review.prompt") + Expect(os.WriteFile(promptPath, []byte("{{role \"user\"}}\nJudge {{cwd}}."), 0o644)).To(Succeed()) + wf := &api.Workflow{Verify: &api.Verify{Prompts: []string{promptPath}}} + + v := gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{Task: "t-1", Attempt: 1, Workflow: wf}) + Expect(v.Status).To(Equal(gitagent.StatusError)) + Expect(v.Findings[0].Message).To(ContainSubstring("no provider")) + + v = gitagent.RunHookSet(ctx, ws(), gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Workflow: wf, + Judge: &judgeStub{verdict: `{"ok":true}`}, Depth: gitagent.MaxHookDepth, + }) + Expect(v.Status).To(Equal(gitagent.StatusError)) + Expect(v.Findings[0].Message).To(ContainSubstring("H15")) + }) + + It("applies commit gates to changed paths", func() { + w := ws() + writeFileT(w.Dir, ".env", "TOKEN=x\n") + w.Changed = append(w.Changed, ".env") + v := gitagent.RunHookSet(ctx, w, gitagent.HookSetOptions{ + Task: "t-1", Attempt: 1, Tier: "sidecar", + Workflow: &api.Workflow{Commits: []api.Commit{{}}}, + }) + Expect(v.Status).To(Equal(gitagent.StatusRejected)) + Expect(v.Findings[0].Kind).To(Equal("commit")) + Expect(v.Findings[0].Message).To(ContainSubstring(".env")) + }) +}) + +var _ = Describe("verdict persistence (R6.9)", func() { + It("round-trips a verdict keyed by task and attempt", func() { + repo := GinkgoT().TempDir() + v := gitagent.TierVerdict{ + V: 1, Task: "t-1", Attempt: 2, Status: gitagent.StatusRejected, Tier: "sidecar", + } + Expect(gitagent.SaveVerdict(repo, v)).To(Succeed()) + loaded, ok, err := gitagent.LoadVerdict(repo, "t-1", 2) + Expect(err).NotTo(HaveOccurred()) + Expect(ok).To(BeTrue()) + Expect(loaded.Status).To(Equal(gitagent.StatusRejected)) + + _, ok, err = gitagent.LoadVerdict(repo, "t-1", 3) + Expect(err).NotTo(HaveOccurred()) + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("feedback wire format (§7)", func() { + verdict := gitagent.TierVerdict{ + V: 1, Task: "t-1", Attempt: 2, Status: gitagent.StatusRejected, Tier: "sidecar", + Findings: []gitagent.Finding{ + {Hook: "gate:path-denied", Kind: "commit", Path: ".env", Message: "path denied by policy"}, + {Hook: "verify:make lint", Kind: "exec", Feedback: "pkg/foo/bar.go:12:2: undefined: Baz\r\nsecond line"}, + }, + } + + It("renders the header, findings and a single captain-json line without CR", func() { + block, err := gitagent.FormatFeedback(verdict, "") + Expect(err).NotTo(HaveOccurred()) + Expect(block).To(ContainSubstring("captain: REJECTED task t-1 attempt 2 (sidecar)")) + Expect(block).To(ContainSubstring("✗ gate:path-denied")) + Expect(block).To(ContainSubstring("undefined: Baz")) + Expect(block).NotTo(ContainSubstring("\r"), "CR is eaten by the sideband demuxer (R7.1)") + + var jsonLine string + for _, line := range strings.Split(block, "\n") { + if strings.HasPrefix(line, "captain-json: ") { + Expect(jsonLine).To(BeEmpty(), "exactly one captain-json line (R7.2)") + jsonLine = strings.TrimPrefix(line, "captain-json: ") + } + } + Expect(jsonLine).NotTo(BeEmpty()) + var decoded gitagent.TierVerdict + Expect(json.Unmarshal([]byte(jsonLine), &decoded)).To(Succeed()) + Expect(decoded.Status).To(Equal(gitagent.StatusRejected)) + Expect(decoded.Findings).To(HaveLen(2)) + }) + + It("caps the block at 64 KiB with a marker and log pointer (R7.3)", func() { + big := verdict + big.Findings = nil + for range 24 { + // Each finding's feedback is individually bounded, so overflow the + // block with many findings rather than one enormous one. + big.Findings = append(big.Findings, gitagent.Finding{ + Hook: "verify:test", Kind: "exec", Feedback: strings.Repeat("x", 9<<10), + }) + } + block, err := gitagent.FormatFeedback(big, "/var/log/captain/t-1.log") + Expect(err).NotTo(HaveOccurred()) + Expect(len(block)).To(BeNumerically("<=", gitagent.MaxFeedbackBytes)) + Expect(block).To(ContainSubstring("[feedback truncated; full log: /var/log/captain/t-1.log]")) + Expect(block).To(ContainSubstring("captain-json: "), "the JSON summary survives truncation") + }) +}) diff --git a/pkg/gitagent/materialize.go b/pkg/gitagent/materialize.go new file mode 100644 index 00000000..3436d0f1 --- /dev/null +++ b/pkg/gitagent/materialize.go @@ -0,0 +1,110 @@ +// Materialization (§1.3, R1.3/H18): read-tree + checkout-index against an +// absolute destination, never git archive (R6.5/H9), with the non-empty and +// file-count assertions that turn the silent-false-accept trap into a loud +// failure. Exit status 0 from checkout-index is not proof of materialization. +package gitagent + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// Materialize checks out commitOID's full tree into dst and returns the file +// count. env should be the hook environment so quarantined objects stay +// readable; every path is absolutized before use (R1.3). +func Materialize(ctx context.Context, repoDir string, env []string, commitOID, dst string) (int, error) { + dstAbs, err := filepath.Abs(dst) + if err != nil { + return 0, err + } + if err := os.MkdirAll(dstAbs, 0o755); err != nil { + return 0, err + } + names, err := treeEntryNames(ctx, repoDir, env, commitOID) + if err != nil { + return 0, err + } + if err := RejectDotGitComponents(names); err != nil { + return 0, err + } + idxDir, err := os.MkdirTemp("", "captain-gitagent-") + if err != nil { + return 0, err + } + defer os.RemoveAll(idxDir) + ienv := envWith(env, "GIT_INDEX_FILE="+filepath.Join(idxDir, "index")) + if _, err := runGit(ctx, repoDir, ienv, "read-tree", commitOID); err != nil { + return 0, err + } + if _, err := runGit(ctx, repoDir, ienv, "checkout-index", "-a", "-f", "--prefix="+dstAbs+string(os.PathSeparator)); err != nil { + return 0, err + } + count, err := countMaterialized(dstAbs) + if err != nil { + return 0, err + } + if err := AssertMaterialized(count, len(names)); err != nil { + return 0, fmt.Errorf("%w (destination %s)", err, dstAbs) + } + return count, nil +} + +func treeEntryNames(ctx context.Context, repoDir string, env []string, commitOID string) ([]string, error) { + out, err := runGitRaw(ctx, repoDir, env, nil, "ls-tree", "-r", "-z", "--name-only", commitOID) + if err != nil { + return nil, err + } + var names []string + for _, name := range strings.Split(out, "\x00") { + if name != "" { + names = append(names, name) + } + } + return names, nil +} + +// RejectDotGitComponents refuses any path with a .git component (H9 defense +// in depth — receive.fsckObjects already rejects such trees on the wire). +func RejectDotGitComponents(paths []string) error { + for _, p := range paths { + for _, component := range strings.Split(p, "/") { + if strings.EqualFold(component, ".git") { + return fmt.Errorf("tree contains a .git path component (%q); refusing to materialize (H9)", p) + } + } + } + return nil +} + +// AssertMaterialized is the H18 guard: an empty or short materialization is a +// silent false-accept, not a success. +func AssertMaterialized(got, expected int) error { + if expected == 0 { + return fmt.Errorf("refusing to verify an empty tree (H18)") + } + if got == 0 { + return fmt.Errorf("materialization wrote nothing despite checkout-index exiting 0 (H18)") + } + if got != expected { + return fmt.Errorf("materialized %d files but the tree holds %d (H18)", got, expected) + } + return nil +} + +func countMaterialized(dir string) (int, error) { + count := 0 + err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + count++ + } + return nil + }) + return count, err +} diff --git a/pkg/gitagent/verdict.go b/pkg/gitagent/verdict.go new file mode 100644 index 00000000..a305bd49 --- /dev/null +++ b/pkg/gitagent/verdict.go @@ -0,0 +1,89 @@ +// Verdicts (§7.1): the machine record of one tier's decision. A verdict is +// persisted outside git before pre-receive exits non-zero, because quarantine +// discards every object of a rejected push (R6.9). +package gitagent + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" +) + +// VerdictStatus is accepted | rejected | error. error means the tier could +// not reach a verdict — and an indeterminate verdict rejects (R7.5). +type VerdictStatus string + +const ( + StatusAccepted VerdictStatus = "accepted" + StatusRejected VerdictStatus = "rejected" + StatusError VerdictStatus = "error" +) + +// Finding is one hook's contribution to a verdict. +type Finding struct { + Hook string `json:"hook"` + Kind string `json:"kind"` // exec | commit | prompt | fixture + Path string `json:"path,omitempty"` + Message string `json:"message,omitempty"` + Feedback string `json:"feedback,omitempty"` +} + +// TierVerdict is the verdict.json payload. +type TierVerdict struct { + V int `json:"v"` + Task string `json:"task"` + Attempt int `json:"attempt"` + Status VerdictStatus `json:"status"` + Tier string `json:"tier"` // "sidecar" | "supervisor" + Findings []Finding `json:"findings,omitempty"` +} + +// Rejects reports whether the push must be refused. Only an explicit accept +// passes: error is indeterminate and indeterminate rejects (R7.5). +func (v TierVerdict) Rejects() bool { return v.Status != StatusAccepted } + +func verdictPath(repo, task string, attempt int) string { + return filepath.Join(taskStateDir(repo, task), "verdicts", strconv.Itoa(attempt)+".json") +} + +// SaveVerdict persists v under /captain/tasks//verdicts/, keyed +// by attempt, atomically — the out-of-band record a rejected push leaves +// behind and the channel async relay reports through (R6.9). +func SaveVerdict(repo string, v TierVerdict) error { + if err := ValidateTaskID(v.Task); err != nil { + return err + } + if v.Attempt < 1 { + return fmt.Errorf("verdict attempt %d must be positive", v.Attempt) + } + path := verdictPath(repo, v.Task, v.Attempt) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return writeFileAtomic(path, append(data, '\n'), 0o644) +} + +// LoadVerdict reads a persisted verdict; ok is false when none exists. +func LoadVerdict(repo, task string, attempt int) (*TierVerdict, bool, error) { + if err := ValidateTaskID(task); err != nil { + return nil, false, err + } + data, err := os.ReadFile(verdictPath(repo, task, attempt)) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var v TierVerdict + if err := json.Unmarshal(data, &v); err != nil { + return nil, false, fmt.Errorf("verdict %s/%d: %w", task, attempt, err) + } + return &v, true, nil +} From 1e50aedaddd1af31b6af9fd65c9b5cdcd7cfdd57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:38:39 +0000 Subject: [PATCH 05/22] feat(gitagent): SSH receive endpoint, enrollment and the CLI group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An embedded gliderlabs/ssh server speaks git-receive-pack and the enrollment exchange, nothing else — upload-pack is refused by name so a shared endpoint cannot leak task namespaces or user branches (R2.3/H11). Keys authenticate; authorization maps the SHA256 fingerprint to an enrolled agent through an AgentDirectory consulted per handshake, so revocation takes effect for the next connection (R8.5). Client-supplied repo paths are containment-checked after resolution, rejecting .. traversal (R8.4/H13) — the three gavel-serve defects issue #39 §9 records are each corrected. receive-pack runs with the agent identity and receiver role injected for the hook shims. Enrollment issues a single-use short-TTL join token, never a key (R8.2): the agent generates its ed25519 pair on first start, presents the token over a host-fingerprint-pinned connection (no trust on first use), and the supervisor binds the fingerprint and burns the token — replay fails, and burning an expired token persists through the flocked captainconfig.Update (A3.4). A minimal GIT_SSH_COMMAND client rides the same transport so dispatch and relay pushes need no system ssh binary. captain sandbox git-agent add|list|revoke|serve land under the existing sandbox group; add/revoke stay MCP-excluded via ^sandbox (A7.3), add prints the join command and supports --dry-run with every mutation spelled out (A7.2), and serve prunes orphaned worktrees at startup (R10.3). The push test drives a real git push through the served endpoint using the test binary as the ssh client. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- cmd/captain/main.go | 21 +++ go.mod | 4 +- pkg/cli/gitagent.go | 201 ++++++++++++++++++++++++++ pkg/cli/gitagent_directory.go | 116 +++++++++++++++ pkg/cli/gitagent_serve.go | 82 +++++++++++ pkg/cli/gitagent_test.go | 140 +++++++++++++++++++ pkg/gitagent/enroll.go | 105 ++++++++++++++ pkg/gitagent/gitagent_suite_test.go | 12 ++ pkg/gitagent/keys.go | 58 ++++++++ pkg/gitagent/receiver.go | 21 +++ pkg/gitagent/server.go | 201 ++++++++++++++++++++++++++ pkg/gitagent/server_ginkgo_test.go | 209 ++++++++++++++++++++++++++++ pkg/gitagent/sshclient.go | 123 ++++++++++++++++ 13 files changed, 1292 insertions(+), 1 deletion(-) create mode 100644 pkg/cli/gitagent.go create mode 100644 pkg/cli/gitagent_directory.go create mode 100644 pkg/cli/gitagent_serve.go create mode 100644 pkg/cli/gitagent_test.go create mode 100644 pkg/gitagent/enroll.go create mode 100644 pkg/gitagent/keys.go create mode 100644 pkg/gitagent/server.go create mode 100644 pkg/gitagent/server_ginkgo_test.go create mode 100644 pkg/gitagent/sshclient.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 171734c7..537495b9 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -6,6 +6,7 @@ import ( "reflect" "github.com/flanksource/captain/pkg/cli" + "github.com/flanksource/captain/pkg/gitagent" "github.com/flanksource/clicky" "github.com/flanksource/clicky/flags" "github.com/flanksource/clicky/mcp" @@ -123,6 +124,26 @@ func main() { clicky.AddNamedCommand("generate", sandboxCmd, cli.SRTGenerateOptions{}, cli.RunSRTGenerate).Short = "Generate sandbox-runtime config" clicky.AddNamedCommand("presets", sandboxCmd, cli.SandboxPresetsOptions{}, cli.RunSandboxPresets).Short = "List available sandbox-runtime presets" + gitAgentCmd := &cobra.Command{ + Use: "git-agent", + Short: "Enroll and serve remote git-agent sandboxes (SPEC-git-agent-protocol)", + } + sandboxCmd.AddCommand(gitAgentCmd) + clicky.AddNamedCommand("add", gitAgentCmd, cli.GitAgentAddOptions{}, cli.RunGitAgentAdd).Short = "Enroll a new agent: mint a join token and print the join command" + clicky.AddNamedCommand("list", gitAgentCmd, cli.GitAgentListOptions{}, cli.RunGitAgentList).Short = "List enrolled agents and pending enrollments" + clicky.AddNamedCommand("revoke", gitAgentCmd, cli.GitAgentRevokeOptions{}, cli.RunGitAgentRevoke).Short = "Revoke an enrolled agent's key" + clicky.AddNamedCommandWithContext("serve", gitAgentCmd, cli.GitAgentServeOptions{}, cli.RunGitAgentServe).Short = "Run the receive endpoint on this host (agent sidecar or supervisor mailbox)" + gitAgentCmd.AddCommand(&cobra.Command{ + Use: "ssh", + Hidden: true, + Short: "Internal: GIT_SSH_COMMAND transport for dispatch and relay pushes", + DisableFlagParsing: true, + RunE: func(_ *cobra.Command, args []string) error { + os.Exit(gitagent.SSHClientMain(args)) + return nil + }, + }) + aiCmd := &cobra.Command{ Use: "ai", Short: "AI provider commands", diff --git a/go.mod b/go.mod index e8ac6baf..82bff7e2 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( require ( github.com/flanksource/commons-db v0.1.26 + github.com/gliderlabs/ssh v0.3.8 github.com/pelletier/go-toml/v2 v2.4.3 ) @@ -48,6 +49,7 @@ require ( ariga.io/atlas v0.38.0 // indirect cloud.google.com/go/cloudsqlconn v1.22.1 // indirect github.com/agext/levenshtein v1.2.1 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect @@ -344,7 +346,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect gocloud.dev v0.43.0 // indirect - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.53.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/pkg/cli/gitagent.go b/pkg/cli/gitagent.go new file mode 100644 index 00000000..026932a1 --- /dev/null +++ b/pkg/cli/gitagent.go @@ -0,0 +1,201 @@ +// The captain sandbox git-agent command group (issue #39 §7): enrollment and +// the receive endpoint. `add` and `revoke` stay excluded from MCP exposure +// via the existing ^sandbox pattern (A7.3). +package cli + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky" +) + +// gitAgentKeysDir anchors key material beside the config file: with the +// default ~/.captain.yaml this is ~/.captain/sandbox, and tests that redirect +// the config path get an isolated keys dir for free. +func gitAgentKeysDir() (string, error) { + path, err := captainconfig.Path() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(path), ".captain", "sandbox"), nil +} + +type GitAgentAddOptions struct { + Name string `args:"true" help:"Name for the agent being enrolled"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Endpoint string `flag:"endpoint" help:"ssh://host:port the new agent will join through (defaults to the backend's url)"` + DryRun bool `flag:"dry-run" help:"Print every intended mutation without touching anything" short:"n"` +} + +// GitAgentAddResult is the enrollment hand-off. The token is single-use and +// short-TTL; a private key is never printed (R8.2/A7.1). +type GitAgentAddResult struct { + Backend string `json:"backend" pretty:"label=Backend"` + Agent string `json:"agent" pretty:"label=Agent"` + Expires time.Time `json:"expires" pretty:"label=Token expires"` + HostFingerprint string `json:"hostFingerprint" pretty:"label=Host key"` + JoinCommand string `json:"joinCommand" pretty:"label=Join command"` +} + +func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { + if err := gitagent.ValidateTaskID(opts.Name); err != nil { + return nil, fmt.Errorf("agent name: %w", err) + } + keysDir, err := gitAgentKeysDir() + if err != nil { + return nil, err + } + hostKeyPath := filepath.Join(keysDir, "host_ed25519") + endpoint := opts.Endpoint + if endpoint == "" { + endpoint = gitAgentBackendEndpoint(opts.Backend) + } + if opts.DryRun { + clicky.Printf("[dry-run] would ensure host key at %s\n", hostKeyPath) + clicky.Printf("[dry-run] would mint a single-use join token (TTL %s) for agent %q\n", gitagent.JoinTokenTTL, opts.Name) + clicky.Printf("[dry-run] would record the pending enrollment under sandbox.backends.%s in %s\n", opts.Backend, configPathForDisplay()) + clicky.Printf("[dry-run] would print the join command for endpoint %s\n", endpoint) + return nil, nil + } + _, hostFP, err := gitagent.EnsureKeyPair(hostKeyPath) + if err != nil { + return nil, err + } + token, hash, err := gitagent.MintJoinToken() + if err != nil { + return nil, err + } + expires := time.Now().UTC().Add(gitagent.JoinTokenTTL) + err = captainconfig.Update(func(cfg *captainconfig.Config) error { + backend := ensureGitAgentBackend(cfg, opts.Backend) + pending, _ := backend.Options["pending"].(map[string]any) + if pending == nil { + pending = map[string]any{} + } + pending[hash] = map[string]any{ + "agent": opts.Name, + "expires": expires.Format(time.RFC3339), + } + backend.Options["pending"] = pending + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + }) + if err != nil { + return nil, err + } + join := fmt.Sprintf( + "captain sandbox git-agent serve --join %s --supervisor %s --host-fingerprint %s", + token, endpoint, hostFP) + return GitAgentAddResult{ + Backend: opts.Backend, + Agent: opts.Name, + Expires: expires, + HostFingerprint: hostFP, + JoinCommand: join, + }, nil +} + +type GitAgentListOptions struct { + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` +} + +type GitAgentListEntry struct { + Name string `json:"name" pretty:"label=Name"` + Fingerprint string `json:"fingerprint,omitempty" pretty:"label=Fingerprint"` + AddedAt string `json:"addedAt,omitempty" pretty:"label=Added"` + Status string `json:"status" pretty:"label=Status"` +} + +func RunGitAgentList(opts GitAgentListOptions) (any, error) { + cfg, _, err := captainconfig.Load() + if err != nil { + return nil, err + } + backend, ok := cfg.Sandbox.Backends[opts.Backend] + if !ok { + return []GitAgentListEntry{}, nil + } + var entries []GitAgentListEntry + agents, _ := backend.Options["agents"].(map[string]any) + for name, v := range agents { + entry := GitAgentListEntry{Name: name, Status: "enrolled"} + if m, ok := v.(map[string]any); ok { + entry.Fingerprint, _ = m["fingerprint"].(string) + entry.AddedAt, _ = m["addedAt"].(string) + } + entries = append(entries, entry) + } + pending, _ := backend.Options["pending"].(map[string]any) + for _, v := range pending { + if m, ok := v.(map[string]any); ok { + name, _ := m["agent"].(string) + expires, _ := m["expires"].(string) + entries = append(entries, GitAgentListEntry{Name: name, Status: "pending until " + expires}) + } + } + return entries, nil +} + +type GitAgentRevokeOptions struct { + Name string `args:"true" help:"Enrolled agent name to revoke"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + DryRun bool `flag:"dry-run" help:"Print the intended mutation without touching anything" short:"n"` +} + +func RunGitAgentRevoke(opts GitAgentRevokeOptions) (any, error) { + if opts.DryRun { + clicky.Printf("[dry-run] would remove agent %q from sandbox.backends.%s.agents in %s\n", + opts.Name, opts.Backend, configPathForDisplay()) + return nil, nil + } + var fingerprint string + err := captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, ok := cfg.Sandbox.Backends[opts.Backend] + if !ok { + return fmt.Errorf("backend %q has no enrolled agents", opts.Backend) + } + agents, _ := backend.Options["agents"].(map[string]any) + entry, ok := agents[opts.Name].(map[string]any) + if !ok { + return fmt.Errorf("agent %q is not enrolled in backend %q", opts.Name, opts.Backend) + } + fingerprint, _ = entry["fingerprint"].(string) + delete(agents, opts.Name) + if len(agents) == 0 { + delete(backend.Options, "agents") + } + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + }) + if err != nil { + return nil, err + } + // Effective for connections established after now (R8.5): the server + // consults the config per handshake. + clicky.Printf("revoked %s (%s)\n", opts.Name, fingerprint) + return nil, nil +} + +func gitAgentBackendEndpoint(backend string) string { + cfg, _, err := captainconfig.Load() + if err == nil { + if b, ok := cfg.Sandbox.Backends[backend]; ok { + if url, _ := b.Options["url"].(string); url != "" { + return url + } + } + } + return "ssh://:7422" +} + +func configPathForDisplay() string { + path, err := captainconfig.Path() + if err != nil { + return "~/.captain.yaml" + } + return path +} diff --git a/pkg/cli/gitagent_directory.go b/pkg/cli/gitagent_directory.go new file mode 100644 index 00000000..135f6264 --- /dev/null +++ b/pkg/cli/gitagent_directory.go @@ -0,0 +1,116 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/flanksource/captain/pkg/api/registry" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +// gitAgentDirectory implements gitagent.AgentDirectory over the sandbox +// backend's options block in ~/.captain.yaml. Every read loads the file fresh +// so a revocation takes effect for the next connection (R8.5), and every +// mutation goes through the flocked captainconfig.Update (A3.4) so a token +// burn is atomic. +type gitAgentDirectory struct { + backend string +} + +func (d gitAgentDirectory) AgentByFingerprint(fingerprint string) (string, bool) { + cfg, _, err := captainconfig.Load() + if err != nil { + return "", false + } + backend, ok := cfg.Sandbox.Backends[d.backend] + if !ok { + return "", false + } + agents, _ := backend.Options["agents"].(map[string]any) + for name, v := range agents { + if entry, ok := v.(map[string]any); ok && entry["fingerprint"] == fingerprint { + return name, true + } + } + return "", false +} + +func (d gitAgentDirectory) ConsumeJoinToken(token string) (string, error) { + hash := gitagent.HashJoinToken(token) + var agentName string + var refusal error + // The refusal travels outside the Update callback: an error returned from + // the callback aborts the write, and burning an expired or malformed + // token must persist. + err := captainconfig.Update(func(cfg *captainconfig.Config) error { + refusal = fmt.Errorf("join token is unknown or already used") + backend, ok := cfg.Sandbox.Backends[d.backend] + if !ok { + return nil + } + pending, _ := backend.Options["pending"].(map[string]any) + entry, ok := pending[hash].(map[string]any) + if !ok { + return nil + } + // Burn before inspecting: a malformed entry must not stay redeemable. + delete(pending, hash) + if len(pending) == 0 { + delete(backend.Options, "pending") + } + cfg.Sandbox.Backends[d.backend] = backend + expires, _ := entry["expires"].(string) + if t, err := time.Parse(time.RFC3339, expires); err != nil || time.Now().After(t) { + refusal = fmt.Errorf("join token has expired; mint a new one with `captain sandbox git-agent add`") + return nil + } + name, _ := entry["agent"].(string) + if name == "" { + refusal = fmt.Errorf("join token has no agent recorded") + return nil + } + agentName = name + refusal = nil + return nil + }) + if err != nil { + return "", err + } + return agentName, refusal +} + +func (d gitAgentDirectory) RecordAgentKey(name, fingerprint string) error { + return captainconfig.Update(func(cfg *captainconfig.Config) error { + backend := ensureGitAgentBackend(cfg, d.backend) + agents, _ := backend.Options["agents"].(map[string]any) + if agents == nil { + agents = map[string]any{} + } + agents[name] = map[string]any{ + "fingerprint": fingerprint, + "addedAt": time.Now().UTC().Format(time.RFC3339), + } + backend.Options["agents"] = agents + cfg.Sandbox.Backends[d.backend] = backend + return nil + }) +} + +// ensureGitAgentBackend returns the named backend, creating a git-agent one +// (with an initialized Options map) when absent so `add` works on a fresh +// config. +func ensureGitAgentBackend(cfg *captainconfig.Config, name string) captainconfig.SandboxBackend { + if cfg.Sandbox.Backends == nil { + cfg.Sandbox.Backends = map[string]captainconfig.SandboxBackend{} + } + backend, ok := cfg.Sandbox.Backends[name] + if !ok { + backend = captainconfig.SandboxBackend{Kind: string(registry.SandboxGitAgent)} + } + if backend.Options == nil { + backend.Options = map[string]any{} + } + cfg.Sandbox.Backends[name] = backend + return backend +} diff --git a/pkg/cli/gitagent_serve.go b/pkg/cli/gitagent_serve.go new file mode 100644 index 00000000..d92307dc --- /dev/null +++ b/pkg/cli/gitagent_serve.go @@ -0,0 +1,82 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky" +) + +type GitAgentServeOptions struct { + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Listen string `flag:"listen" help:"Address to serve git-receive-pack on" default:":7422"` + Root string `flag:"root" help:"Directory of receivable repos (default /repos)"` + Role string `flag:"role" help:"Receiver role: sidecar or mailbox" default:"sidecar"` + Join string `flag:"join" help:"Single-use join token printed by 'captain sandbox git-agent add'"` + Supervisor string `flag:"supervisor" help:"ssh://host:port of the supervisor to enroll with"` + HostFingerprint string `flag:"host-fingerprint" help:"Pinned SHA256 fingerprint of the supervisor's host key"` +} + +// RunGitAgentServe runs the receive endpoint on this host, optionally +// enrolling with a supervisor first. The agent keypair is generated locally +// on first start and its private half never leaves this machine (R8.2). +func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, error) { + role := gitagent.ReceiverRole(opts.Role) + if role != gitagent.RoleSidecar && role != gitagent.RoleMailbox { + return nil, fmt.Errorf("--role must be %q or %q", gitagent.RoleSidecar, gitagent.RoleMailbox) + } + keysDir, err := gitAgentKeysDir() + if err != nil { + return nil, err + } + if opts.Join != "" { + if opts.Supervisor == "" { + return nil, fmt.Errorf("--join requires --supervisor ssh://host:port") + } + signer, fp, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, "agent_ed25519")) + if err != nil { + return nil, err + } + confirmation, err := gitagent.Enroll(ctx, opts.Supervisor, opts.Join, opts.HostFingerprint, signer) + if err != nil { + return nil, err + } + clicky.Printf("%s\n", confirmation) + clicky.Printf("this agent's key fingerprint: %s\n", fp) + } + root := opts.Root + if root == "" { + root = filepath.Join(keysDir, "repos") + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, err + } + // Reclaim worktrees orphaned by a crashed hook (R10.3). + gitagent.PruneWorktrees(ctx, root) + hostKey, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, "host_ed25519")) + if err != nil { + return nil, err + } + server, err := gitagent.NewServer(gitagent.ServerConfig{ + Listen: opts.Listen, + Root: root, + Role: role, + HostKey: hostKey, + Directory: gitAgentDirectory{backend: opts.Backend}, + }) + if err != nil { + return nil, err + } + clicky.Printf("captain git-agent %s serving %s on %s (host key %s)\n", role, root, opts.Listen, hostFP) + go func() { + <-ctx.Done() + _ = server.Close() + }() + if err := server.ListenAndServe(); err != nil && ctx.Err() == nil { + return nil, err + } + return nil, nil +} diff --git a/pkg/cli/gitagent_test.go b/pkg/cli/gitagent_test.go new file mode 100644 index 00000000..8627f477 --- /dev/null +++ b/pkg/cli/gitagent_test.go @@ -0,0 +1,140 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +func isolatedConfig(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".captain.yaml") + captainconfig.SetPathForTesting(path) + t.Cleanup(func() { captainconfig.SetPathForTesting("") }) + return path +} + +func TestGitAgentAddMintsSingleUseToken(t *testing.T) { + path := isolatedConfig(t) + + res, err := RunGitAgentAdd(GitAgentAddOptions{Name: "worker-1", Backend: "git-agent"}) + if err != nil { + t.Fatal(err) + } + add, ok := res.(GitAgentAddResult) + if !ok { + t.Fatalf("result = %T", res) + } + if add.HostFingerprint == "" || !strings.Contains(add.JoinCommand, "--join ") { + t.Fatalf("join hand-off incomplete: %+v", add) + } + if !strings.Contains(add.JoinCommand, "--host-fingerprint "+add.HostFingerprint) { + t.Fatalf("join command must pin the host key: %s", add.JoinCommand) + } + if time.Until(add.Expires) > gitagent.JoinTokenTTL { + t.Fatalf("token TTL too long: %s", add.Expires) + } + + // The raw token never lands in the config file — only its hash (R8.2). + token := strings.Fields(strings.SplitAfter(add.JoinCommand, "--join ")[1])[0] + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), token) { + t.Fatal("the raw join token must not be persisted") + } + if !strings.Contains(string(raw), gitagent.HashJoinToken(token)) { + t.Fatal("the token hash must be persisted as pending") + } + + // Consume: valid once, burned after (R8.2). + dir := gitAgentDirectory{backend: "git-agent"} + name, err := dir.ConsumeJoinToken(token) + if err != nil || name != "worker-1" { + t.Fatalf("consume = %q, %v", name, err) + } + if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "already used") { + t.Fatalf("replay must fail, got %v", err) + } +} + +func TestGitAgentExpiredTokenRefused(t *testing.T) { + isolatedConfig(t) + token, hash, err := gitagent.MintJoinToken() + if err != nil { + t.Fatal(err) + } + err = captainconfig.Update(func(cfg *captainconfig.Config) error { + backend := ensureGitAgentBackend(cfg, "git-agent") + backend.Options["pending"] = map[string]any{ + hash: map[string]any{ + "agent": "worker-1", + "expires": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), + }, + } + cfg.Sandbox.Backends["git-agent"] = backend + return nil + }) + if err != nil { + t.Fatal(err) + } + dir := gitAgentDirectory{backend: "git-agent"} + if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expired token must fail, got %v", err) + } + // And expiry burns it: a retry is unknown, not expired. + if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "already used") { + t.Fatalf("expired token must burn, got %v", err) + } +} + +func TestGitAgentEnrollListRevoke(t *testing.T) { + isolatedConfig(t) + dir := gitAgentDirectory{backend: "git-agent"} + if err := dir.RecordAgentKey("worker-1", "SHA256:abc"); err != nil { + t.Fatal(err) + } + if name, ok := dir.AgentByFingerprint("SHA256:abc"); !ok || name != "worker-1" { + t.Fatalf("lookup = %q, %v", name, ok) + } + + res, err := RunGitAgentList(GitAgentListOptions{Backend: "git-agent"}) + if err != nil { + t.Fatal(err) + } + entries := res.([]GitAgentListEntry) + if len(entries) != 1 || entries[0].Name != "worker-1" || entries[0].Status != "enrolled" { + t.Fatalf("entries = %+v", entries) + } + + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent"}); err != nil { + t.Fatal(err) + } + // Revocation is effective for lookups after it (R8.5). + if _, ok := dir.AgentByFingerprint("SHA256:abc"); ok { + t.Fatal("revoked fingerprint must be refused") + } + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent"}); err == nil { + t.Fatal("revoking an unknown agent must error") + } +} + +func TestGitAgentAddDryRunTouchesNothing(t *testing.T) { + path := isolatedConfig(t) + if _, err := RunGitAgentAdd(GitAgentAddOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("dry-run must not write the config, stat err = %v", err) + } + keysDir := filepath.Join(filepath.Dir(path), ".captain", "sandbox") + if _, err := os.Stat(keysDir); !os.IsNotExist(err) { + t.Fatalf("dry-run must not create key material, stat err = %v", err) + } +} diff --git a/pkg/gitagent/enroll.go b/pkg/gitagent/enroll.go new file mode 100644 index 00000000..90253e49 --- /dev/null +++ b/pkg/gitagent/enroll.go @@ -0,0 +1,105 @@ +// Enrollment (§8): a single-use, short-TTL join token authorizes exactly one +// key registration and is then burned. The private key never leaves the agent +// host (R8.2). +package gitagent + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net" + "net/url" + "strings" + "time" + + gossh "golang.org/x/crypto/ssh" +) + +// JoinTokenTTL bounds how long a minted token stays redeemable. +const JoinTokenTTL = 15 * time.Minute + +// MintJoinToken returns a fresh token and its storage hash. Only the hash is +// persisted, so a leaked config file does not leak redeemable tokens. +func MintJoinToken() (token, hash string, err error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + token = base64.RawURLEncoding.EncodeToString(raw) + return token, HashJoinToken(token), nil +} + +// HashJoinToken maps a presented token onto its storage hash. +func HashJoinToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// Enroll dials the supervisor endpoint, presents the join token, and returns +// the server's confirmation line. The host key is verified against the +// fingerprint printed by `git-agent add` — never trusted on first use. +func Enroll(ctx context.Context, endpoint, token, hostFingerprint string, signer gossh.Signer) (string, error) { + if strings.TrimSpace(hostFingerprint) == "" { + return "", fmt.Errorf("enrollment requires the supervisor's host-key fingerprint (printed by `captain sandbox git-agent add`)") + } + addr, user, err := splitSSHEndpoint(endpoint) + if err != nil { + return "", err + } + config := &gossh.ClientConfig{ + User: user, + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: func(_ string, _ net.Addr, key gossh.PublicKey) error { + if got := gossh.FingerprintSHA256(key); got != hostFingerprint { + return fmt.Errorf("supervisor host key %s does not match the pinned %s", got, hostFingerprint) + } + return nil + }, + Timeout: 30 * time.Second, + } + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return "", err + } + c, chans, reqs, err := gossh.NewClientConn(conn, addr, config) + if err != nil { + conn.Close() + return "", err + } + client := gossh.NewClient(c, chans, reqs) + defer client.Close() + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + out, err := session.CombinedOutput(EnrollCommand + " " + token) + if err != nil { + return "", fmt.Errorf("enrollment refused: %s", strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +// splitSSHEndpoint accepts ssh://[user@]host[:port] or host[:port]. +func splitSSHEndpoint(endpoint string) (addr, user string, err error) { + user = "captain" + target := endpoint + if strings.Contains(endpoint, "://") { + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "ssh" || u.Host == "" { + return "", "", fmt.Errorf("endpoint %q must be ssh://[user@]host[:port]", endpoint) + } + if u.User != nil && u.User.Username() != "" { + user = u.User.Username() + } + target = u.Host + } + if !strings.Contains(target, ":") { + target += ":22" + } + return target, user, nil +} diff --git a/pkg/gitagent/gitagent_suite_test.go b/pkg/gitagent/gitagent_suite_test.go index 8388c3bf..d0e534ad 100644 --- a/pkg/gitagent/gitagent_suite_test.go +++ b/pkg/gitagent/gitagent_suite_test.go @@ -1,12 +1,24 @@ package gitagent_test import ( + "os" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" ) +// TestMain doubles the test binary as the GIT_SSH_COMMAND client, so push +// tests exercise the production transport with no system ssh installed. +func TestMain(m *testing.M) { + if os.Getenv("CAPTAIN_TEST_SSH_CLIENT") == "1" { + os.Exit(gitagent.SSHClientMain(os.Args[1:])) + } + os.Exit(m.Run()) +} + func TestGitAgent(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "GitAgent Suite") diff --git a/pkg/gitagent/keys.go b/pkg/gitagent/keys.go new file mode 100644 index 00000000..a8b592bb --- /dev/null +++ b/pkg/gitagent/keys.go @@ -0,0 +1,58 @@ +// Key material (§8): each agent authenticates with its own keypair, generated +// where it will live and never transiting a terminal, clipboard or log (R8.2). +package gitagent + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + gossh "golang.org/x/crypto/ssh" +) + +// EnsureKeyPair loads the OpenSSH private key at path, generating an ed25519 +// pair (0600, parent 0700) on first use. It returns the signer and the +// SHA256: fingerprint of the public half. +func EnsureKeyPair(path string) (gossh.Signer, string, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return generateKeyPair(path) + } + if err != nil { + return nil, "", err + } + signer, err := gossh.ParsePrivateKey(data) + if err != nil { + return nil, "", fmt.Errorf("parse key %s: %w", path, err) + } + return signer, gossh.FingerprintSHA256(signer.PublicKey()), nil +} + +func generateKeyPair(path string) (gossh.Signer, string, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, "", err + } + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, "", err + } + block, err := gossh.MarshalPrivateKey(priv, "captain-gitagent") + if err != nil { + return nil, "", err + } + if err := writeFileAtomic(path, pem.EncodeToMemory(block), 0o600); err != nil { + return nil, "", err + } + signer, err := gossh.NewSignerFromKey(priv) + if err != nil { + return nil, "", err + } + pub := gossh.MarshalAuthorizedKey(signer.PublicKey()) + if err := writeFileAtomic(path+".pub", pub, 0o644); err != nil { + return nil, "", err + } + return signer, gossh.FingerprintSHA256(signer.PublicKey()), nil +} diff --git a/pkg/gitagent/receiver.go b/pkg/gitagent/receiver.go index 3736b745..bcb2bf9b 100644 --- a/pkg/gitagent/receiver.go +++ b/pkg/gitagent/receiver.go @@ -78,6 +78,27 @@ func initReceiver(ctx context.Context, path string) error { return os.MkdirAll(filepath.Join(path, "captain"), 0o755) } +// PruneWorktrees runs `git worktree prune` in every bare repo directly under +// root, reclaiming worktrees orphaned by a crashed hook (R10.3). Failures are +// deliberately non-fatal: pruning is hygiene, not a serving precondition. +func PruneWorktrees(ctx context.Context, root string) { + entries, err := os.ReadDir(root) + if err != nil { + return + } + env := ScrubGitEnv(os.Environ()) + for _, e := range entries { + if !e.IsDir() { + continue + } + repo := filepath.Join(root, e.Name()) + if _, err := os.Stat(filepath.Join(repo, "HEAD")); err != nil { + continue + } + _, _ = runGit(ctx, repo, env, "worktree", "prune") + } +} + // writeFileAtomic writes via a sibling temp file and rename, so a reader // never observes a partial file. func writeFileAtomic(path string, data []byte, mode os.FileMode) error { diff --git a/pkg/gitagent/server.go b/pkg/gitagent/server.go new file mode 100644 index 00000000..922bbcff --- /dev/null +++ b/pkg/gitagent/server.go @@ -0,0 +1,201 @@ +// The embedded SSH endpoint (§2/§8): it speaks git-receive-pack and the +// enrollment exchange, nothing else. upload-pack is never served — a shared +// upload-pack leaks every task namespace to every enrolled agent (R2.3/H11). +// +// The three gavel-serve defects issue #39 §9 records are corrected here: keys +// are authorized against an enrollment directory instead of accept-all, the +// vetting hooks live in pre-receive where rejection is possible, and the repo +// path is containment-checked rather than merely unquoted. +package gitagent + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gliderlabs/ssh" + gossh "golang.org/x/crypto/ssh" +) + +// Env vars the server injects into receive-pack so the hook shims know who is +// pushing and which admission tier they are. +const ( + EnvAgentName = "CAPTAIN_GITAGENT_AGENT" + EnvRole = "CAPTAIN_GITAGENT_ROLE" +) + +// EnrollCommand is the single non-git exec verb the server accepts. +const EnrollCommand = "captain-enroll" + +// AgentDirectory is the server's authorization source. Implementations read +// live state on every call so revocation takes effect for new connections +// (R8.5) and a join token can be burned atomically (R8.2). +type AgentDirectory interface { + // AgentByFingerprint maps an SSH public-key SHA256 fingerprint to an + // enrolled agent name. + AgentByFingerprint(fingerprint string) (string, bool) + // ConsumeJoinToken validates and burns a single-use join token, returning + // the agent name it enrolls. + ConsumeJoinToken(token string) (string, error) + // RecordAgentKey binds a fingerprint to an enrolled agent. + RecordAgentKey(name, fingerprint string) error +} + +// ServerConfig configures one receive endpoint. +type ServerConfig struct { + Listen string + Root string // directory whose repos may be pushed to + Role ReceiverRole + HostKey gossh.Signer + Directory AgentDirectory +} + +// NewServer builds the SSH server. The caller owns ListenAndServe/Serve and +// Close. +func NewServer(cfg ServerConfig) (*ssh.Server, error) { + if cfg.HostKey == nil || cfg.Directory == nil || cfg.Root == "" { + return nil, fmt.Errorf("git-agent server needs a host key, an agent directory and a repo root") + } + root, err := filepath.Abs(cfg.Root) + if err != nil { + return nil, err + } + server := &ssh.Server{ + Addr: cfg.Listen, + // The key handler only proves possession and records the fingerprint; + // authorization is per-command below, because an unenrolled key must + // still be able to present a join token (R8.2). + PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { + ctx.SetValue("fingerprint", gossh.FingerprintSHA256(key)) + return true + }, + Handler: func(s ssh.Session) { + handleSession(s, root, cfg) + }, + } + server.AddHostKey(cfg.HostKey) + return server, nil +} + +func handleSession(s ssh.Session, root string, cfg ServerConfig) { + fingerprint, _ := s.Context().Value("fingerprint").(string) + cmd := s.Command() + if len(cmd) == 0 { + fmt.Fprintln(s.Stderr(), "captain: interactive sessions are not served") + _ = s.Exit(1) + return + } + switch cmd[0] { + case EnrollCommand: + handleEnroll(s, cfg, fingerprint, cmd) + case "git-receive-pack", "git", "git-upload-pack", "git-upload-archive": + handleGit(s, root, cfg, fingerprint, cmd) + default: + fmt.Fprintf(s.Stderr(), "captain: command %q is not served\n", cmd[0]) + _ = s.Exit(1) + } +} + +func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []string) { + if len(cmd) != 2 || strings.TrimSpace(cmd[1]) == "" { + fmt.Fprintln(s.Stderr(), "captain: usage: captain-enroll ") + _ = s.Exit(1) + return + } + name, err := cfg.Directory.ConsumeJoinToken(strings.TrimSpace(cmd[1])) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: enrollment refused: %v\n", err) + _ = s.Exit(1) + return + } + if err := cfg.Directory.RecordAgentKey(name, fingerprint); err != nil { + fmt.Fprintf(s.Stderr(), "captain: enrollment failed: %v\n", err) + _ = s.Exit(1) + return + } + fmt.Fprintf(s, "enrolled %s %s\n", name, fingerprint) + _ = s.Exit(0) +} + +func handleGit(s ssh.Session, root string, cfg ServerConfig, fingerprint string, cmd []string) { + repoArg, err := parseReceivePack(cmd) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: %v\n", err) + _ = s.Exit(1) + return + } + agent, ok := cfg.Directory.AgentByFingerprint(fingerprint) + if !ok { + fmt.Fprintf(s.Stderr(), "captain: key %s is not enrolled\n", fingerprint) + _ = s.Exit(1) + return + } + repo, err := ResolveRepoPath(root, repoArg) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: %v\n", err) + _ = s.Exit(1) + return + } + proc := exec.CommandContext(s.Context(), "git", "receive-pack", repo) + proc.Env = envWith(os.Environ(), EnvAgentName+"="+agent, EnvRole+"="+string(cfg.Role)) + proc.Stdin = s + proc.Stdout = s + proc.Stderr = s.Stderr() + if err := proc.Run(); err != nil { + if exit, ok := err.(*exec.ExitError); ok { + _ = s.Exit(exit.ExitCode()) + return + } + fmt.Fprintf(s.Stderr(), "captain: receive-pack: %v\n", err) + _ = s.Exit(1) + return + } + _ = s.Exit(0) +} + +// parseReceivePack accepts only the receive-pack command forms and returns +// the repo argument. upload-pack is refused by name (R2.3). +func parseReceivePack(cmd []string) (string, error) { + switch cmd[0] { + case "git-receive-pack": + if len(cmd) != 2 { + return "", fmt.Errorf("git-receive-pack takes exactly one repository argument") + } + return cmd[1], nil + case "git": + if len(cmd) != 3 || cmd[1] != "receive-pack" { + return "", fmt.Errorf("only `git receive-pack ` is served") + } + return cmd[2], nil + default: + return "", fmt.Errorf("%s is not served: this endpoint speaks git-receive-pack only (R2.3)", cmd[0]) + } +} + +// ResolveRepoPath maps a client-supplied repo path onto a repo under root, +// confirming containment after resolving — stripping quotes and leading +// slashes is insufficient because `..` traversal escapes (R8.4/H13). +func ResolveRepoPath(root, arg string) (string, error) { + cleaned := strings.TrimSpace(arg) + cleaned = strings.Trim(cleaned, "'\"") + cleaned = strings.TrimPrefix(cleaned, "/") + if cleaned == "" { + return "", fmt.Errorf("empty repository path") + } + joined := filepath.Join(root, filepath.FromSlash(cleaned)) + resolved, err := filepath.EvalSymlinks(joined) + if err != nil { + return "", fmt.Errorf("repository %q not found", cleaned) + } + rootResolved, err := filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + rel, err := filepath.Rel(rootResolved, resolved) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("repository path %q escapes the served root (H13)", arg) + } + return resolved, nil +} diff --git a/pkg/gitagent/server_ginkgo_test.go b/pkg/gitagent/server_ginkgo_test.go new file mode 100644 index 00000000..43e1ad75 --- /dev/null +++ b/pkg/gitagent/server_ginkgo_test.go @@ -0,0 +1,209 @@ +package gitagent_test + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gossh "golang.org/x/crypto/ssh" + + "github.com/flanksource/captain/pkg/gitagent" +) + +// memoryDirectory is an in-process AgentDirectory for server tests. +type memoryDirectory struct { + mu sync.Mutex + agents map[string]string // fingerprint → name + pending map[string]string // token hash → name +} + +func (d *memoryDirectory) AgentByFingerprint(fp string) (string, bool) { + d.mu.Lock() + defer d.mu.Unlock() + name, ok := d.agents[fp] + return name, ok +} + +func (d *memoryDirectory) ConsumeJoinToken(token string) (string, error) { + d.mu.Lock() + defer d.mu.Unlock() + name, ok := d.pending[gitagent.HashJoinToken(token)] + if !ok { + return "", fmt.Errorf("join token is unknown or already used") + } + delete(d.pending, gitagent.HashJoinToken(token)) + return name, nil +} + +func (d *memoryDirectory) RecordAgentKey(name, fp string) error { + d.mu.Lock() + defer d.mu.Unlock() + d.agents[fp] = name + return nil +} + +// startTestServer serves root on a loopback port and returns its address and +// host fingerprint. +func startTestServer(dir *memoryDirectory, root string, role gitagent.ReceiverRole) (addr, hostFP string) { + GinkgoHelper() + keys := GinkgoT().TempDir() + hostKey, fp, err := gitagent.EnsureKeyPair(filepath.Join(keys, "host_ed25519")) + Expect(err).NotTo(HaveOccurred()) + server, err := gitagent.NewServer(gitagent.ServerConfig{ + Root: root, Role: role, HostKey: hostKey, Directory: dir, + }) + Expect(err).NotTo(HaveOccurred()) + listener, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + go func() { _ = server.Serve(listener) }() + DeferCleanup(func() { _ = server.Close() }) + return listener.Addr().String(), fp +} + +func newClientKey() (gossh.Signer, string, string) { + GinkgoHelper() + keyPath := filepath.Join(GinkgoT().TempDir(), "agent_ed25519") + signer, fp, err := gitagent.EnsureKeyPair(keyPath) + Expect(err).NotTo(HaveOccurred()) + return signer, fp, keyPath +} + +func sshExec(addr string, signer gossh.Signer, command string) (string, error) { + config := &gossh.ClientConfig{ + User: "captain", + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + } + client, err := gossh.Dial("tcp", addr, config) + if err != nil { + return "", err + } + defer client.Close() + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + out, err := session.CombinedOutput(command) + return string(out), err +} + +var _ = Describe("the git-agent SSH endpoint", func() { + ctx := context.Background() + + It("serves receive-pack to enrolled keys and completes a real push", func() { + root := GinkgoT().TempDir() + Expect(gitagent.InitSidecar(ctx, filepath.Join(root, "repo.git"))).To(Succeed()) + + dir := &memoryDirectory{agents: map[string]string{}, pending: map[string]string{}} + _, fp, keyPath := newClientKey() + dir.agents[fp] = "worker-1" + addr, hostFP := startTestServer(dir, root, gitagent.RoleSidecar) + + src := GinkgoT().TempDir() + gitT(src, "init", "-q") + writeFileT(src, "a.txt", "a\n") + gitT(src, "add", "-A") + gitT(src, "commit", "-q", "-m", "base") + + // The test binary doubles as GIT_SSH_COMMAND (see TestMain), so this + // is the production transport end to end with no system ssh. + exe, err := os.Executable() + Expect(err).NotTo(HaveOccurred()) + host, port, err := net.SplitHostPort(addr) + Expect(err).NotTo(HaveOccurred()) + push := exec.Command("git", "push", "-q", + fmt.Sprintf("ssh://captain@%s:%s/repo.git", host, port), "HEAD:refs/heads/pushed") + push.Dir = src + push.Env = append(os.Environ(), + "GIT_SSH_COMMAND="+exe, + "GIT_SSH_VARIANT=ssh", // an unknown command defaults to "simple", which cannot pass -p + "CAPTAIN_TEST_SSH_CLIENT=1", + gitagent.EnvSSHKey+"="+keyPath, + gitagent.EnvSSHHostFingerprint+"="+hostFP, + ) + out, err := push.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "push output:\n%s", out) + + Expect(gitT(filepath.Join(root, "repo.git"), "rev-parse", "refs/heads/pushed")). + To(Equal(gitT(src, "rev-parse", "HEAD"))) + }) + + It("refuses unknown keys, upload-pack, and path traversal", func() { + root := GinkgoT().TempDir() + Expect(gitagent.InitSidecar(ctx, filepath.Join(root, "repo.git"))).To(Succeed()) + dir := &memoryDirectory{agents: map[string]string{}, pending: map[string]string{}} + enrolled, fp, _ := newClientKey() + dir.agents[fp] = "worker-1" + stranger, _, _ := newClientKey() + addr, _ := startTestServer(dir, root, gitagent.RoleSidecar) + + out, err := sshExec(addr, stranger, "git-receive-pack 'repo.git'") + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("not enrolled")) + + out, err = sshExec(addr, enrolled, "git-upload-pack 'repo.git'") + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("git-receive-pack only")) + + out, err = sshExec(addr, enrolled, "git-receive-pack '../repo.git'") + Expect(err).To(HaveOccurred()) + Expect(out).NotTo(BeEmpty()) + + out, err = sshExec(addr, enrolled, "rm -rf /") + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("not served")) + }) + + It("enrolls through a single-use join token and refuses replay (R8.2)", func() { + root := GinkgoT().TempDir() + dir := &memoryDirectory{agents: map[string]string{}, pending: map[string]string{}} + token, hash, err := gitagent.MintJoinToken() + Expect(err).NotTo(HaveOccurred()) + dir.pending[hash] = "worker-2" + addr, hostFP := startTestServer(dir, root, gitagent.RoleMailbox) + + signer, fp, _ := newClientKey() + confirmation, err := gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer) + Expect(err).NotTo(HaveOccurred()) + Expect(confirmation).To(ContainSubstring("enrolled worker-2")) + name, ok := dir.AgentByFingerprint(fp) + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("worker-2")) + + // Replay fails: the token burned. + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer) + Expect(err).To(MatchError(ContainSubstring("already used"))) + + // A wrong host fingerprint is refused before the token is offered. + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "SHA256:bogus", signer) + Expect(err).To(HaveOccurred()) + + // An empty fingerprint never trusts on first use. + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "", signer) + Expect(err).To(MatchError(ContainSubstring("host-key fingerprint"))) + }) + + It("resolves repo paths strictly within the root (R8.4/H13)", func() { + root := GinkgoT().TempDir() + Expect(gitagent.InitSidecar(ctx, filepath.Join(root, "repo.git"))).To(Succeed()) + + resolved, err := gitagent.ResolveRepoPath(root, "/repo.git") + Expect(err).NotTo(HaveOccurred()) + Expect(resolved).To(HaveSuffix("repo.git")) + + _, err = gitagent.ResolveRepoPath(root, "'/repo.git'") + Expect(err).NotTo(HaveOccurred(), "quoted forms are unquoted") + + for _, evil := range []string{"../outside.git", "/../outside.git", "a/../../outside.git", ""} { + _, err := gitagent.ResolveRepoPath(root, evil) + Expect(err).To(HaveOccurred(), "path %q must be rejected", evil) + } + }) +}) diff --git a/pkg/gitagent/sshclient.go b/pkg/gitagent/sshclient.go new file mode 100644 index 00000000..cd9ab140 --- /dev/null +++ b/pkg/gitagent/sshclient.go @@ -0,0 +1,123 @@ +// A minimal GIT_SSH_COMMAND implementation. Dispatch and relay pushes run +// git with GIT_SSH_COMMAND pointing at `captain sandbox git-agent ssh`, so no +// system ssh client is needed, the key never leaves captain-managed paths, +// and the server's host key is verified against a pinned fingerprint — the +// endpoint name is never trusted as identity. +package gitagent + +import ( + "fmt" + "io" + "net" + "os" + "strings" + + gossh "golang.org/x/crypto/ssh" +) + +// Environment variables the client reads; flags would be mangled by git's +// shell-splitting of GIT_SSH_COMMAND. +const ( + EnvSSHKey = "CAPTAIN_SSH_KEY" + EnvSSHHostFingerprint = "CAPTAIN_SSH_HOST_FINGERPRINT" + EnvSSHUser = "CAPTAIN_SSH_USER" +) + +// SSHClientMain speaks git's ssh-command contract: argv is +// `[-4|-6] [-p port] [--] [user@]host command...`. It returns the process +// exit code, propagating the remote command's. +func SSHClientMain(args []string) int { + code, err := runSSHClient(args, os.Stdin, os.Stdout, os.Stderr) + if err != nil { + fmt.Fprintf(os.Stderr, "captain-ssh: %v\n", err) + if code == 0 { + code = 255 + } + } + return code +} + +func runSSHClient(args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + host, port, command, err := parseSSHArgs(args) + if err != nil { + return 255, err + } + keyPath := os.Getenv(EnvSSHKey) + pinned := os.Getenv(EnvSSHHostFingerprint) + if keyPath == "" || pinned == "" { + return 255, fmt.Errorf("%s and %s must be set", EnvSSHKey, EnvSSHHostFingerprint) + } + user := os.Getenv(EnvSSHUser) + if user == "" { + user = "captain" + } + if at := strings.LastIndex(host, "@"); at >= 0 { + user, host = host[:at], host[at+1:] + } + signer, _, err := EnsureKeyPair(keyPath) + if err != nil { + return 255, err + } + config := &gossh.ClientConfig{ + User: user, + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: func(_ string, _ net.Addr, key gossh.PublicKey) error { + if got := gossh.FingerprintSHA256(key); got != pinned { + return fmt.Errorf("host key %s does not match the pinned %s", got, pinned) + } + return nil + }, + } + client, err := gossh.Dial("tcp", net.JoinHostPort(host, port), config) + if err != nil { + return 255, err + } + defer client.Close() + session, err := client.NewSession() + if err != nil { + return 255, err + } + defer session.Close() + session.Stdin = stdin + session.Stdout = stdout + session.Stderr = stderr + if err := session.Run(command); err != nil { + if exitErr, ok := err.(*gossh.ExitError); ok { + return exitErr.ExitStatus(), nil + } + return 255, err + } + return 0, nil +} + +func parseSSHArgs(args []string) (host, port, command string, err error) { + port = "22" + i := 0 +loop: + for i < len(args) { + switch args[i] { + case "-p": + if i+1 >= len(args) { + return "", "", "", fmt.Errorf("-p needs a port argument") + } + port = args[i+1] + i += 2 + case "-4", "-6": + i++ + case "--": + i++ + break loop + default: + break loop + } + } + if i >= len(args) { + return "", "", "", fmt.Errorf("usage: [-p port] host command...") + } + host = args[i] + command = strings.Join(args[i+1:], " ") + if strings.TrimSpace(command) == "" { + return "", "", "", fmt.Errorf("no remote command given (interactive sessions are not supported)") + } + return host, port, command, nil +} From 2f2ee7d5aef31f799b8cc5465849a2c77b5e6f0f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 18:06:11 +0000 Subject: [PATCH 06/22] =?UTF-8?q?feat(gitagent):=20the=20full=20dispatch?= =?UTF-8?q?=E2=86=92vet=E2=86=92relay=E2=86=92integrate=20loop,=20wired=20?= =?UTF-8?q?to=20the=20run=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch (§6.1) snapshots the dirty worktree, records the audit refs and task state in the local mailbox, and pushes dispatch+control atomically to the sidecar with the envelope on push options, over captain's own GIT_SSH_COMMAND transport. The sidecar's post-receive — where quarantine has ended (R6.4/H2) — records state, creates the task branch, clones the agent's workspace with branch and upstream set so a bare git commit + git push suffices (R3.1/H17), materializes task.json outside the worktree, and launches the agent fully detached so the dispatch push returns promptly (R6.3/H12). A submit runs the whole chain inside one blocking push (§6.2): sidecar admission, materialized tier-1 hook set, then the nested relay — inside pre-receive, where rejection is still possible (R6.6/H16) — pushing the squashed result plus the original control commit with quarantine unset and object dirs kept (R1.4). A non-zero upstream exit rejects the agent's push (R6.7); the supervisor's sideband streams back through the sidecar's stderr. Attempts are consumed per submit, so a retry after rejection is attempt n+1 bounded by maxAttempts (§6.3). Acceptance integrates three-way against the envelope's base — HEAD may have moved (R10.1) — onto a captain/ branch, reports conflicts instead of auto-resolving them (R10.2), and writes the verdict ref and file. Run-path wiring: the git-agent adapter implements RemoteExecutor plus the isolate-workspace and egress-proxy markers, construction-time verifiers cover both capabilities, the resolveSandboxSelection guard admits the kind, SandboxRef.Agent/Policy thread through SandboxConfig, and buildProvider routes a remote-exec selection around local setup and provider construction entirely — refusing to combine with a setup checkout. The §12 conformance suite drives the real loop over loopback SSH and local repos, with the test binary standing in for the captain binary in both shims and transport. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- cmd/captain/main.go | 3 + pkg/api/sandbox.go | 13 + pkg/api/sandbox_ginkgo_test.go | 19 +- pkg/api/sandbox_registry.go | 13 + pkg/cli/ai.go | 11 +- pkg/cli/ai_prompt_file.go | 4 +- pkg/cli/ai_sandbox.go | 18 +- pkg/cli/ai_sandbox_remote.go | 57 +++ pkg/cli/ai_sandbox_test.go | 11 +- pkg/cli/gitagent_hook.go | 121 ++++++ pkg/cli/gitagent_serve.go | 49 +++ pkg/gitagent/admit.go | 26 +- pkg/gitagent/admit_ginkgo_test.go | 15 +- pkg/gitagent/conformance_ginkgo_test.go | 284 ++++++++++++++ pkg/gitagent/control.go | 10 +- pkg/gitagent/dispatch.go | 248 ++++++++++++ pkg/gitagent/git.go | 18 +- pkg/gitagent/gitagent_suite_test.go | 10 +- pkg/gitagent/hookmain.go | 490 ++++++++++++++++++++++++ pkg/gitagent/integrate.go | 78 ++++ pkg/gitagent/materialize.go | 6 +- pkg/gitagent/relay.go | 81 ++++ pkg/gitagent/state.go | 3 +- pkg/gitagent/workspace.go | 94 +++++ pkg/sandbox/adapter/gitagent.go | 183 +++++++++ 25 files changed, 1833 insertions(+), 32 deletions(-) create mode 100644 pkg/cli/ai_sandbox_remote.go create mode 100644 pkg/cli/gitagent_hook.go create mode 100644 pkg/gitagent/conformance_ginkgo_test.go create mode 100644 pkg/gitagent/dispatch.go create mode 100644 pkg/gitagent/hookmain.go create mode 100644 pkg/gitagent/integrate.go create mode 100644 pkg/gitagent/relay.go create mode 100644 pkg/gitagent/workspace.go create mode 100644 pkg/sandbox/adapter/gitagent.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 537495b9..617db0a4 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -133,6 +133,9 @@ func main() { clicky.AddNamedCommand("list", gitAgentCmd, cli.GitAgentListOptions{}, cli.RunGitAgentList).Short = "List enrolled agents and pending enrollments" clicky.AddNamedCommand("revoke", gitAgentCmd, cli.GitAgentRevokeOptions{}, cli.RunGitAgentRevoke).Short = "Revoke an enrolled agent's key" clicky.AddNamedCommandWithContext("serve", gitAgentCmd, cli.GitAgentServeOptions{}, cli.RunGitAgentServe).Short = "Run the receive endpoint on this host (agent sidecar or supervisor mailbox)" + hookLeaf := clicky.AddNamedCommandWithContext("hook", gitAgentCmd, cli.GitAgentHookOptions{}, cli.RunGitAgentHook) + hookLeaf.Short = "Internal: receive-hook entrypoint invoked by the installed shims" + hookLeaf.Hidden = true gitAgentCmd.AddCommand(&cobra.Command{ Use: "ssh", Hidden: true, diff --git a/pkg/api/sandbox.go b/pkg/api/sandbox.go index a17dfd9e..315e19ec 100644 --- a/pkg/api/sandbox.go +++ b/pkg/api/sandbox.go @@ -90,6 +90,19 @@ type RemoteExecutor interface { Execute(ctx context.Context, spec Spec) (*Response, error) } +// WorkspaceIsolating marks an adapter whose run happens in its own working +// tree, so selecting it must not be combined with another workspace isolator +// (a --worktree run or a setup checkout). +type WorkspaceIsolating interface { + IsolatesWorkspace() bool +} + +// EgressProxied marks an adapter whose sandbox never holds a real credential: +// placeholders are substituted outside it by an egress proxy. +type EgressProxied interface { + ProvidesEgressProxy() bool +} + // SandboxAs resolves a capability from a sandbox, walking SandboxUnwrapper // chains so a capability is found behind decorators. Mirrors ProviderAs. func SandboxAs[T any](sandbox Sandbox) (T, bool) { diff --git a/pkg/api/sandbox_ginkgo_test.go b/pkg/api/sandbox_ginkgo_test.go index c1d0791b..a3819106 100644 --- a/pkg/api/sandbox_ginkgo_test.go +++ b/pkg/api/sandbox_ginkgo_test.go @@ -103,7 +103,7 @@ var _ = Describe("NewSandbox", func() { descriptor, ok := api.SandboxFor(api.SandboxSRT) Expect(ok).To(BeTrue()) original := append([]api.SandboxCapability(nil), descriptor.Capabilities...) - descriptor.Capabilities = append(descriptor.Capabilities, api.CapabilityEgressProxy) + descriptor.Capabilities = append(descriptor.Capabilities, api.SandboxCapability("future-capability")) DeferCleanup(func() { descriptor.Capabilities = original }) api.RegisterSandbox(api.SandboxSRT, func(cfg api.SandboxConfig) (api.Sandbox, error) { return wrappingSandboxStub{sandboxStub{kind: api.SandboxSRT}}, nil @@ -111,7 +111,22 @@ var _ = Describe("NewSandbox", func() { _, err := api.NewSandbox(api.SandboxConfig{Kind: api.SandboxSRT}) - Expect(err).To(MatchError(ContainSubstring(`capability "egress-proxy" but no construction-time verifier`))) + Expect(err).To(MatchError(ContainSubstring(`capability "future-capability" but no construction-time verifier`))) + }) + + It("rejects an adapter that fails a declared capability's verifier", func() { + descriptor, ok := api.SandboxFor(api.SandboxSRT) + Expect(ok).To(BeTrue()) + original := append([]api.SandboxCapability(nil), descriptor.Capabilities...) + descriptor.Capabilities = append(descriptor.Capabilities, api.CapabilityEgressProxy) + DeferCleanup(func() { descriptor.Capabilities = original }) + api.RegisterSandbox(api.SandboxSRT, func(cfg api.SandboxConfig) (api.Sandbox, error) { + return wrappingSandboxStub{sandboxStub{kind: api.SandboxSRT}}, nil // no EgressProxied + }) + + _, err := api.NewSandbox(api.SandboxConfig{Kind: api.SandboxSRT}) + + Expect(err).To(MatchError(ContainSubstring(`declares capability "egress-proxy" but its adapter does not implement it`))) }) It("accepts an adapter that implements its declared capabilities", func() { diff --git a/pkg/api/sandbox_registry.go b/pkg/api/sandbox_registry.go index 077a341b..f03feb92 100644 --- a/pkg/api/sandbox_registry.go +++ b/pkg/api/sandbox_registry.go @@ -19,6 +19,11 @@ type SandboxConfig struct { // Options carries the kind-specific settings verbatim. Each adapter decodes // its own; an unknown key is the adapter's error to raise, not this layer's. Options map[string]any `json:"options,omitempty" yaml:"options,omitempty"` + // Agent pins one enrolled agent of a git-agent backend, from + // SandboxRef.Agent. Empty lets the adapter choose. + Agent string `json:"agent,omitempty" yaml:"agent,omitempty"` + // Policy is the per-run override from SandboxRef.Policy. + Policy *SandboxPolicy `json:"policy,omitempty" yaml:"policy,omitempty"` } // SandboxFactory constructs a Sandbox from a SandboxConfig. @@ -87,6 +92,14 @@ func NewSandbox(cfg SandboxConfig) (Sandbox, error) { var sandboxCapabilityChecks = map[SandboxCapability]func(Sandbox) bool{ CapabilityWrapCommand: func(s Sandbox) bool { _, ok := SandboxAs[CommandWrapper](s); return ok }, CapabilityRemoteExec: func(s Sandbox) bool { _, ok := SandboxAs[RemoteExecutor](s); return ok }, + CapabilityIsolateWorkspace: func(s Sandbox) bool { + iso, ok := SandboxAs[WorkspaceIsolating](s) + return ok && iso.IsolatesWorkspace() + }, + CapabilityEgressProxy: func(s Sandbox) bool { + proxy, ok := SandboxAs[EgressProxied](s) + return ok && proxy.ProvidesEgressProxy() + }, } func verifySandboxCapabilities(descriptor *SandboxDescriptor, sandbox Sandbox) error { diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index de6c8ed8..93389f01 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -129,7 +129,7 @@ func (o AIProviderOptions) ToConfig() (ai.Config, error) { APIKey: o.APIKey, APIURL: strings.TrimSpace(o.APIURL), Sandbox: sandbox.Kind == registry.SandboxSRT, - SandboxSelection: sandboxSelectionConfig(sandbox), + SandboxSelection: sandboxSelectionConfig(sandbox, nil), NoCache: o.NoCache || saved.NoCache, SchemaRepair: schemaRepairConfig(savedCfg.Prompts.SchemaRepair), }, nil @@ -455,6 +455,15 @@ func buildProvider(ctx context.Context, req *ai.Request, cfg ai.Config) (ai.Prov if req.NoCache { cfg.NoCache = true } + // A remote-executing sandbox replaces provider execution wholesale: the + // run happens on another machine, so no local setup checkout, no CLI + // process, no streaming. Resolved before setup.Apply because the sandbox + // is itself a workspace isolator — a local checkout would double-isolate. + if remote, err := remoteExecProviderFor(req, cfg); err != nil { + return nil, cleanup, err + } else if remote != nil { + return remote, cleanup, nil + } prepared, err := setup.Apply(ctx, req, "") if err != nil { return nil, cleanup, err diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go index 541328de..839ab745 100644 --- a/pkg/cli/ai_prompt_file.go +++ b/pkg/cli/ai_prompt_file.go @@ -202,7 +202,9 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque // so it overwrites rather than ORs with baseCfg: an explicit "none" must be // able to turn an inherited srt selection OFF. cfg.Sandbox = sandbox.Kind == registry.SandboxSRT - cfg.SandboxSelection = sandboxSelectionConfig(sandbox) + // req.Sandbox is the winning ref (frontmatter, or the flag's override + // recorded above), so its agent pin and policy ride along. + cfg.SandboxSelection = sandboxSelectionConfig(sandbox, req.Sandbox) cfg.NoCache = req.NoCache return req, cfg, nil } diff --git a/pkg/cli/ai_sandbox.go b/pkg/cli/ai_sandbox.go index c9716b39..f76d3492 100644 --- a/pkg/cli/ai_sandbox.go +++ b/pkg/cli/ai_sandbox.go @@ -26,12 +26,12 @@ func resolveSandboxSelection(flagSelector string, ref *api.SandboxRef, defaults return captainconfig.SandboxSelection{}, err } switch selection.Kind { - case registry.SandboxNone, registry.SandboxSRT, registry.SandboxContainer: + case registry.SandboxNone, registry.SandboxSRT, registry.SandboxContainer, registry.SandboxGitAgent: return selection, nil default: return captainconfig.SandboxSelection{}, fmt.Errorf( - "sandbox kind %q is not wired to execution yet (supported today: %s, %s, %s)", - selection.Kind, registry.SandboxNone, registry.SandboxSRT, registry.SandboxContainer) + "sandbox kind %q is not wired to execution yet (supported today: %s, %s, %s, %s)", + selection.Kind, registry.SandboxNone, registry.SandboxSRT, registry.SandboxContainer, registry.SandboxGitAgent) } } @@ -51,10 +51,16 @@ func sandboxForcedMode(kind registry.SandboxKind) registry.RuntimeMode { // sandboxSelectionConfig projects a resolved selection onto the runtime // config. "none" stays nil, so an unsandboxed run carries no selection at all -// and the exec seam's nil check keeps its meaning. -func sandboxSelectionConfig(selection captainconfig.SandboxSelection) *api.SandboxConfig { +// and the exec seam's nil check keeps its meaning. ref, when present, carries +// the per-prompt agent pin and policy override (git-agent). +func sandboxSelectionConfig(selection captainconfig.SandboxSelection, ref *api.SandboxRef) *api.SandboxConfig { if selection.Kind == registry.SandboxNone { return nil } - return &api.SandboxConfig{Kind: selection.Kind, Name: selection.Name, Options: selection.Options} + cfg := &api.SandboxConfig{Kind: selection.Kind, Name: selection.Name, Options: selection.Options} + if ref != nil { + cfg.Agent = ref.Agent + cfg.Policy = ref.Policy + } + return cfg } diff --git a/pkg/cli/ai_sandbox_remote.go b/pkg/cli/ai_sandbox_remote.go new file mode 100644 index 00000000..ea693000 --- /dev/null +++ b/pkg/cli/ai_sandbox_remote.go @@ -0,0 +1,57 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" +) + +// remoteExecProviderFor returns a provider backed by the resolved sandbox's +// RemoteExecutor when the selection has that capability, nil otherwise. This +// is the run-path branch for whole-run relocation (git-agent): it sits above +// provider construction because the adapter replaces execution, not the argv. +func remoteExecProviderFor(req *ai.Request, cfg ai.Config) (ai.Provider, error) { + selection := cfg.SandboxSelection + if selection == nil { + return nil, nil + } + descriptor, ok := registry.SandboxFor(selection.Kind) + if !ok || !descriptor.Has(registry.CapabilityRemoteExec) { + return nil, nil + } + sandbox, err := api.NewSandbox(*selection) + if err != nil { + return nil, err + } + executor, ok := api.SandboxAs[api.RemoteExecutor](sandbox) + if !ok { + _ = sandbox.Close() + return nil, fmt.Errorf("sandbox %q declares remote execution but provides none", selection.Kind) + } + if _, err := sandbox.Prepare(context.Background(), req); err != nil { + _ = sandbox.Close() + return nil, err + } + return &remoteExecProvider{executor: executor, sandbox: sandbox, model: cfg.Model.Name, backend: cfg.Model.Backend}, nil +} + +// remoteExecProvider adapts a RemoteExecutor to ai.Provider. Streaming is +// deliberately absent — the run happens elsewhere and comes back whole; the +// buffered workflow path synthesizes events from Execute. +type remoteExecProvider struct { + executor api.RemoteExecutor + sandbox api.Sandbox + model string + backend api.Backend +} + +func (p *remoteExecProvider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { + defer p.sandbox.Close() + return p.executor.Execute(ctx, req) +} + +func (p *remoteExecProvider) GetModel() string { return p.model } +func (p *remoteExecProvider) GetBackend() api.Backend { return p.backend } diff --git a/pkg/cli/ai_sandbox_test.go b/pkg/cli/ai_sandbox_test.go index 65e60cb2..77707587 100644 --- a/pkg/cli/ai_sandbox_test.go +++ b/pkg/cli/ai_sandbox_test.go @@ -58,10 +58,13 @@ func TestResolveSandboxSelection_Precedence(t *testing.T) { } }) - t.Run("an unwired kind fails loud instead of running unsandboxed", func(t *testing.T) { - _, err := resolveSandboxSelection("git-agent", nil, defaults) - if err == nil || !strings.Contains(err.Error(), "not wired to execution yet") { - t.Fatalf("err = %v", err) + t.Run("git-agent resolves now that remote execution is wired", func(t *testing.T) { + got, err := resolveSandboxSelection("git-agent", nil, defaults) + if err != nil { + t.Fatal(err) + } + if got.Kind != registry.SandboxGitAgent { + t.Fatalf("kind = %q, want git-agent", got.Kind) } }) diff --git a/pkg/cli/gitagent_hook.go b/pkg/cli/gitagent_hook.go new file mode 100644 index 00000000..f1f1e965 --- /dev/null +++ b/pkg/cli/gitagent_hook.go @@ -0,0 +1,121 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "gopkg.in/yaml.v3" +) + +type GitAgentHookOptions struct { + Hook string `args:"true" help:"pre-receive or post-receive"` + Repo string `flag:"repo" help:"Receiving bare repository"` + Role string `flag:"role" help:"Receiver role: sidecar or mailbox"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` +} + +// RunGitAgentHook is the shim entrypoint: admission, hook sets, relay and +// integration, with the runtime assembled from the backend's config block. +func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) { + runtime, err := hookRuntimeFromConfig(opts.Backend) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return nil, err + } + wrap, err := gitagent.ResolveHookWrap(runtime.HookSandbox) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return nil, err + } + host := gitagent.HookHost{Runtime: runtime, Wrap: wrap} + role := gitagent.ReceiverRole(opts.Role) + switch opts.Hook { + case "pre-receive": + return nil, gitagent.RunPreReceive(ctx, opts.Repo, role, host, os.Stdin, os.Stderr) + case "post-receive": + return nil, gitagent.RunPostReceive(ctx, opts.Repo, role, host, os.Stdin) + default: + return nil, fmt.Errorf("unknown hook %q", opts.Hook) + } +} + +// hookRuntimeFromConfig assembles the receiver runtime from the backend's +// options block: the two hook-set workflows, the confinement sandbox for exec +// hooks, the agent launch command, the integration target, and the relay +// endpoint recorded at enrollment. +func hookRuntimeFromConfig(backendName string) (gitagent.HookRuntime, error) { + var rt gitagent.HookRuntime + cfg, _, err := captainconfig.Load() + if err != nil { + return rt, err + } + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return rt, nil // no config: admission still runs, hook sets are empty + } + if hooks, ok := backend.Options["hooks"].(map[string]any); ok { + if rt.SidecarWorkflow, err = decodeWorkflow(hooks["sidecar"]); err != nil { + return rt, fmt.Errorf("hooks.sidecar: %w", err) + } + if rt.SupervisorWorkflow, err = decodeWorkflow(hooks["supervisor"]); err != nil { + return rt, fmt.Errorf("hooks.supervisor: %w", err) + } + } + rt.HookSandbox, _ = backend.Options["hookSandbox"].(string) + rt.AgentCommand, _ = backend.Options["agentCommand"].(string) + rt.RealRepo, _ = backend.Options["repo"].(string) + if supervisor, ok := backend.Options["supervisor"].(map[string]any); ok { + url, _ := supervisor["url"].(string) + hostFP, _ := supervisor["hostFingerprint"].(string) + keysDir, err := gitAgentKeysDir() + if err != nil { + return rt, err + } + exe, err := os.Executable() + if err != nil { + return rt, err + } + rt.Relay = gitagent.RelayTarget{ + URL: url, + HostFingerprint: hostFP, + KeyPath: filepath.Join(keysDir, "agent_ed25519"), + SSHCommand: exe + " sandbox git-agent ssh", + } + } + return rt, nil +} + +// decodeWorkflow converts a YAML-decoded options value into an api.Workflow +// via a JSON round-trip, so the receiver runs the exact schema the local run +// path declares (A5.1). +func decodeWorkflow(v any) (*api.Workflow, error) { + if v == nil { + return nil, nil + } + raw, err := yaml.Marshal(v) + if err != nil { + return nil, err + } + var normalized any + if err := yaml.Unmarshal(raw, &normalized); err != nil { + return nil, err + } + data, err := json.Marshal(normalized) + if err != nil { + return nil, err + } + var wf api.Workflow + if err := json.Unmarshal(data, &wf); err != nil { + return nil, err + } + if err := wf.Validate(); err != nil { + return nil, err + } + return &wf, nil +} diff --git a/pkg/cli/gitagent_serve.go b/pkg/cli/gitagent_serve.go index d92307dc..5d5ae408 100644 --- a/pkg/cli/gitagent_serve.go +++ b/pkg/cli/gitagent_serve.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/flanksource/captain/pkg/captainconfig" "github.com/flanksource/captain/pkg/gitagent" "github.com/flanksource/clicky" ) @@ -46,6 +47,19 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro } clicky.Printf("%s\n", confirmation) clicky.Printf("this agent's key fingerprint: %s\n", fp) + // Record where the relay pushes go (A3.4: through the flocked Update). + err = captainconfig.Update(func(cfg *captainconfig.Config) error { + backend := ensureGitAgentBackend(cfg, opts.Backend) + backend.Options["supervisor"] = map[string]any{ + "url": opts.Supervisor, + "hostFingerprint": opts.HostFingerprint, + } + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + }) + if err != nil { + return nil, err + } } root := opts.Root if root == "" { @@ -56,6 +70,9 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro } // Reclaim worktrees orphaned by a crashed hook (R10.3). gitagent.PruneWorktrees(ctx, root) + if err := ensureServedRepos(ctx, root, role); err != nil { + return nil, err + } hostKey, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, "host_ed25519")) if err != nil { return nil, err @@ -80,3 +97,35 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro } return nil, nil } + +// ensureServedRepos creates the role's default repository and (re-)installs +// the hook shims on every repo under root, so an upgraded captain binary +// repoints the shims at itself. +func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverRole) error { + if role == gitagent.RoleSidecar { + if err := gitagent.InitSidecar(ctx, filepath.Join(root, "repo.git")); err != nil { + return err + } + } + exe, err := os.Executable() + if err != nil { + return err + } + entries, err := os.ReadDir(root) + if err != nil { + return err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + repo := filepath.Join(root, e.Name()) + if _, err := os.Stat(filepath.Join(repo, "HEAD")); err != nil { + continue + } + if err := gitagent.InstallHookShims(repo, exe, role); err != nil { + return err + } + } + return nil +} diff --git a/pkg/gitagent/admit.go b/pkg/gitagent/admit.go index 2ae3d0cc..8dd50005 100644 --- a/pkg/gitagent/admit.go +++ b/pkg/gitagent/admit.go @@ -84,7 +84,7 @@ func Admit(ctx context.Context, req AdmitRequest) error { return fmt.Errorf("ref %s is outside the protocol namespaces this receiver accepts", u.Ref) } } - if err := requireAtomicPairs(protocol); err != nil { + if err := requireAtomicPairs(ctx, req, protocol); err != nil { return err } for ref, info := range protocol { @@ -125,7 +125,9 @@ func admitProtocolRef(req AdmitRequest, u RefUpdate) (RefInfo, error) { if !NamespaceContains(TaskNamespace(info.Task), u.Ref) { return RefInfo{}, fmt.Errorf("ref %s escapes its task namespace", u.Ref) } - if req.Agent != "" { + // Task ownership binds on the mailbox, where results arrive from enrolled + // agents; a sidecar's dispatch refs are what CREATE the task there. + if req.Role == RoleMailbox && req.Agent != "" { st, ok, err := LoadTaskState(req.Repo, info.Task) if err != nil { return RefInfo{}, err @@ -173,9 +175,12 @@ func admitAgentBranch(ctx context.Context, req AdmitRequest, u RefUpdate) error return admitContent(ctx, req, st, st.DispatchCommit, u.New) } -// requireAtomicPairs enforces R3.4: a code ref and its control ref travel in -// one atomic push, or not at all. -func requireAtomicPairs(protocol map[string]RefInfo) error { +// requireAtomicPairs enforces R3.4: a code ref is unprocessable without its +// control ref. The control may travel in the same atomic push or already be +// present at the receiver — git filters an up-to-date control out of a push, +// so attempt 1's relay legitimately arrives result-only when the supervisor +// audit-wrote the identical control commit at dispatch. +func requireAtomicPairs(ctx context.Context, req AdmitRequest, protocol map[string]RefInfo) error { if len(protocol) == 0 { return nil } @@ -193,7 +198,7 @@ func requireAtomicPairs(protocol map[string]RefInfo) error { } for k, present := range kinds { code := present[RefDispatch] || present[RefResult] - if code && !present[RefControl] { + if code && !present[RefControl] && !controlRefExists(ctx, req, k.task, k.attempt) { return fmt.Errorf("task %s attempt %d: a code ref without its control ref is unprocessable (R3.4)", k.task, k.attempt) } if present[RefControl] && !code { @@ -203,6 +208,15 @@ func requireAtomicPairs(protocol map[string]RefInfo) error { return nil } +func controlRefExists(ctx context.Context, req AdmitRequest, task string, attempt int) bool { + ref, err := ControlRef(task, attempt) + if err != nil { + return false + } + code, _, err := gitExitCode(ctx, req.Repo, req.Env, "rev-parse", "--verify", "--quiet", ref) + return err == nil && code == 0 +} + // admitCodeContent runs the content checks that need object access: result // parentage, blob caps and name gates. Control refs carry no worktree code. func admitCodeContent(ctx context.Context, req AdmitRequest, u RefUpdate, info RefInfo) error { diff --git a/pkg/gitagent/admit_ginkgo_test.go b/pkg/gitagent/admit_ginkgo_test.go index cc70aa2a..b786c220 100644 --- a/pkg/gitagent/admit_ginkgo_test.go +++ b/pkg/gitagent/admit_ginkgo_test.go @@ -34,7 +34,7 @@ func newAdmitFixture(ctx context.Context) *admitFixture { snap, err := gitagent.TakeSnapshot(ctx, super, gitagent.SnapshotPolicy{}) Expect(err).NotTo(HaveOccurred()) - control, err := gitagent.BuildControlCommit(ctx, super, map[string][]byte{ + control, err := gitagent.BuildControlCommit(ctx, super, nil, map[string][]byte{ gitagent.ControlTaskFile: []byte(`{"prompt":"do the thing"}`), gitagent.ControlHooksFile: []byte(`{}`), gitagent.ControlPolicyFile: []byte(`{}`), @@ -120,7 +120,18 @@ var _ = Describe("admission", func() { err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: upd, Envelope: f.envelope(), Env: f.env}) Expect(err).To(MatchError(ContainSubstring("every protocol ref push is a create"))) - err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates()[:1], Envelope: f.envelope(), Env: f.env}) + // A code ref alone is unprocessable — unless the receiver already + // holds that attempt's control ref, which the fixture's task does. + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates()[:1], Envelope: f.envelope(), Env: f.env})). + To(Succeed(), "an existing control ref satisfies the pairing") + + fresh := f.envelope() + fresh.Task = "t-2" + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: f.sidecar, Role: gitagent.RoleSidecar, + Updates: []gitagent.RefUpdate{{Old: zeroOID40, New: f.snap.Commit, Ref: "refs/captain/tasks/t-2/dispatch/1"}}, + Envelope: fresh, Env: f.env, + }) Expect(err).To(MatchError(ContainSubstring("R3.4"))) }) diff --git a/pkg/gitagent/conformance_ginkgo_test.go b/pkg/gitagent/conformance_ginkgo_test.go new file mode 100644 index 00000000..1ccf2fef --- /dev/null +++ b/pkg/gitagent/conformance_ginkgo_test.go @@ -0,0 +1,284 @@ +package gitagent_test + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" +) + +// conformanceWorld is the §12 topology built from local repos and loopback +// SSH: a supervisor host (real repo + mailbox + serve) and an agent host +// (sidecar repo + serve), with the test binary standing in for the captain +// binary in both the shims and the ssh transport. +type conformanceWorld struct { + superRepo string + mailbox string + sidecar string + sidecarURL string + dispatch gitagent.DispatchRequest + workdir string // the agent's clone, present after dispatch +} + +// testShim writes a hook shim exec'ing the test binary with the hook env +// armed and the ssh env disarmed (each invocation re-arms its own). +func installTestShims(repo, role, runtimeCfg string) { + GinkgoHelper() + exe, err := os.Executable() + Expect(err).NotTo(HaveOccurred()) + for _, hook := range []string{"pre-receive", "post-receive"} { + shim := fmt.Sprintf("#!/bin/sh\nCAPTAIN_TEST_HOOK=1 CAPTAIN_TEST_SSH_CLIENT= exec %q %s %q %q %q\n", + exe, hook, repo, role, runtimeCfg) + path := filepath.Join(repo, "hooks", hook) + Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed()) + Expect(os.WriteFile(path, []byte(shim), 0o755)).To(Succeed()) + } +} + +func writeRuntime(dir string, rt gitagent.HookRuntime) string { + GinkgoHelper() + data, err := json.Marshal(rt) + Expect(err).NotTo(HaveOccurred()) + path := filepath.Join(dir, "runtime.json") + Expect(os.WriteFile(path, data, 0o644)).To(Succeed()) + return path +} + +// testSSHCommand is the GIT_SSH_COMMAND for pushes in the conformance world: +// the test binary with its ssh persona armed and the hook persona disarmed. +func testSSHCommand() string { + exe, err := os.Executable() + Expect(err).NotTo(HaveOccurred()) + return "env CAPTAIN_TEST_SSH_CLIENT=1 CAPTAIN_TEST_HOOK= " + exe +} + +// newConformanceWorld wires the full topology. sidecarWF and supervisorWF are +// the two tier hook sets; agentCommand is launched detached on dispatch. +func newConformanceWorld(ctx context.Context, sidecarWF, supervisorWF *api.Workflow, agentCommand string) *conformanceWorld { + GinkgoHelper() + w := &conformanceWorld{} + + // Supervisor host: real repo with dirty state, mailbox beside it. + supRoot := GinkgoT().TempDir() + w.superRepo = filepath.Join(supRoot, "project") + Expect(os.MkdirAll(w.superRepo, 0o755)).To(Succeed()) + gitT(w.superRepo, "init", "-q") + writeFileT(w.superRepo, "pkg/main.go", "package main\n") + writeFileT(w.superRepo, "docs/readme.md", "readme\n") + gitT(w.superRepo, "add", "-A") + gitT(w.superRepo, "commit", "-q", "-m", "base") + writeFileT(w.superRepo, "pkg/dirty.go", "package main // dirty\n") + w.mailbox = filepath.Join(supRoot, "mailbox.git") + Expect(gitagent.InitMailbox(ctx, w.mailbox, w.superRepo)).To(Succeed()) + + // Keys: one per party. + supKey := filepath.Join(GinkgoT().TempDir(), "supervisor_ed25519") + _, supFP, err := gitagent.EnsureKeyPair(supKey) + Expect(err).NotTo(HaveOccurred()) + agentKey := filepath.Join(GinkgoT().TempDir(), "agent_ed25519") + _, agentFP, err := gitagent.EnsureKeyPair(agentKey) + Expect(err).NotTo(HaveOccurred()) + + // Supervisor mailbox endpoint, accepting the enrolled agent's key. + supDir := &memoryDirectory{agents: map[string]string{agentFP: "worker-1"}, pending: map[string]string{}} + supAddr, supHostFP := startTestServer(supDir, supRoot, gitagent.RoleMailbox) + supHost, supPort, err := net.SplitHostPort(supAddr) + Expect(err).NotTo(HaveOccurred()) + + // Agent host: sidecar repo + endpoint accepting the supervisor's key. + agentRoot := GinkgoT().TempDir() + w.sidecar = filepath.Join(agentRoot, "repo.git") + Expect(gitagent.InitSidecar(ctx, w.sidecar)).To(Succeed()) + sideDir := &memoryDirectory{agents: map[string]string{supFP: "supervisor"}, pending: map[string]string{}} + sideAddr, sideHostFP := startTestServer(sideDir, agentRoot, gitagent.RoleSidecar) + sideHost, sidePort, err := net.SplitHostPort(sideAddr) + Expect(err).NotTo(HaveOccurred()) + w.sidecarURL = fmt.Sprintf("ssh://captain@%s:%s/repo.git", sideHost, sidePort) + + // Hook runtimes and shims on both receivers. + sidecarRT := writeRuntime(GinkgoT().TempDir(), gitagent.HookRuntime{ + SidecarWorkflow: sidecarWF, + HookSandbox: "test-identity", + AgentCommand: agentCommand, + Relay: gitagent.RelayTarget{ + URL: fmt.Sprintf("ssh://captain@%s:%s/mailbox.git", supHost, supPort), + HostFingerprint: supHostFP, + KeyPath: agentKey, + SSHCommand: testSSHCommand(), + }, + }) + installTestShims(w.sidecar, "sidecar", sidecarRT) + mailboxRT := writeRuntime(GinkgoT().TempDir(), gitagent.HookRuntime{ + SupervisorWorkflow: supervisorWF, + HookSandbox: "test-identity", + RealRepo: w.superRepo, + }) + installTestShims(w.mailbox, "mailbox", mailboxRT) + + w.dispatch = gitagent.DispatchRequest{ + RepoDir: w.superRepo, + MailboxPath: w.mailbox, + Agent: "worker-1", + SidecarURL: w.sidecarURL, + SidecarHostFP: sideHostFP, + KeyPath: supKey, + SSHCommand: testSSHCommand(), + Policy: gitagent.Policy{MaxAttempts: 5}, + } + return w +} + +// agentPush commits the given files in the agent's clone and pushes bare, +// returning the push's combined output and error. +func (w *conformanceWorld) agentPush(files map[string]string) (string, error) { + GinkgoHelper() + for path, content := range files { + writeFileT(w.workdir, path, content) + } + gitT(w.workdir, "add", "-A") + gitT(w.workdir, "commit", "-q", "-m", "agent work", "--allow-empty") + push := exec.Command("git", "push") + push.Dir = w.workdir + out, err := push.CombinedOutput() + return string(out), err +} + +func (w *conformanceWorld) dispatchTask(ctx context.Context) *gitagent.DispatchResult { + GinkgoHelper() + result, err := gitagent.Dispatch(ctx, w.dispatch) + Expect(err).NotTo(HaveOccurred()) + w.workdir = filepath.Join(w.sidecar, "captain", "tasks", result.Task, "worktree") + return result +} + +var _ = Describe("protocol conformance (§12)", Serial, func() { + ctx := context.Background() + + It("completes a full cycle from a vanilla clone with bare git commands (H17)", func() { + w := newConformanceWorld(ctx, + &api.Workflow{Verify: &api.Verify{Commands: []string{"test -f pkg/ok.txt"}}}, + &api.Workflow{Verify: &api.Verify{Commands: []string{"grep -q good pkg/ok.txt"}}}, + "") + result := w.dispatchTask(ctx) + Expect(w.workdir).To(BeADirectory(), "post-receive must set up the agent workspace") + Expect(os.ReadFile(filepath.Join(w.workdir, "pkg", "dirty.go"))). + To(Equal([]byte("package main // dirty\n")), "the dirty worktree travelled") + Expect(filepath.Join(w.sidecar, "captain", "tasks", result.Task, "task.json")). + To(BeAnExistingFile(), "task.json lands outside the worktree") + Expect(filepath.Join(w.workdir, "task.json")).NotTo(BeAnExistingFile()) + + // HEAD moves on the supervisor mid-task: integration must use the + // envelope's base, not the moved HEAD (R10.1). + writeFileT(w.superRepo, "docs/readme.md", "readme v2\n") + gitT(w.superRepo, "commit", "-q", "-am", "moved after dispatch") + + out, err := w.agentPush(map[string]string{"pkg/ok.txt": "good\n"}) + Expect(err).NotTo(HaveOccurred(), "push output:\n%s", out) + Expect(out).To(ContainSubstring("captain: ACCEPTED")) + + verdict, err := gitagent.AwaitOutcome(ctx, w.mailbox, result.Task, 30*time.Second) + Expect(err).NotTo(HaveOccurred()) + Expect(verdict.Status).To(Equal(gitagent.StatusAccepted)) + + // Result + control landed in the mailbox; the verdict ref exists. + Expect(gitT(w.mailbox, "rev-parse", "refs/captain/tasks/"+result.Task+"/result/1")).NotTo(BeEmpty()) + Expect(gitT(w.mailbox, "rev-parse", "refs/captain/tasks/"+result.Task+"/verdict/1")).NotTo(BeEmpty()) + + // Integration: the captain/ branch carries BOTH the agent's file + // and the supervisor's post-dispatch commit. + branch := "captain/" + result.Task + Expect(blob(w.superRepo, branch, "pkg/ok.txt")).To(Equal("good\n")) + Expect(blob(w.superRepo, branch, "docs/readme.md")).To(Equal("readme v2\n")) + Expect(blob(w.superRepo, branch, "pkg/dirty.go")).To(Equal("package main // dirty\n")) + }) + + It("rejects at tier 1 with feedback, never contacting the supervisor, and recovers on retry (§6.3)", func() { + w := newConformanceWorld(ctx, + &api.Workflow{Verify: &api.Verify{Commands: []string{"test -f pkg/ok.txt"}}}, + nil, "") + result := w.dispatchTask(ctx) + + refsBefore := gitT(w.sidecar, "for-each-ref") + out, err := w.agentPush(map[string]string{"pkg/other.txt": "not the file\n"}) + Expect(err).To(HaveOccurred(), "push must be rejected:\n%s", out) + Expect(out).To(ContainSubstring("captain: REJECTED"), "tier-1 feedback reaches the pusher") + Expect(out).To(ContainSubstring("verify:test -f pkg/ok.txt")) + Expect(out).To(ContainSubstring("captain-json: ")) + + // The supervisor was never contacted: no result refs, no verdicts. + Expect(gitT(w.mailbox, "for-each-ref", "refs/captain/tasks/"+result.Task+"/result")).To(BeEmpty()) + _, found, err := gitagent.LoadVerdict(w.mailbox, result.Task, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse()) + + // Zero new refs at the sidecar; the remote task branch did not advance. + Expect(gitT(w.sidecar, "for-each-ref")).To(Equal(refsBefore)) + + // The rejection persisted out-of-band at the rejecting tier (R6.9). + verdict, found, err := gitagent.LoadVerdict(w.sidecar, result.Task, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(verdict.Status).To(Equal(gitagent.StatusRejected)) + + // Rejection is not termination: fix and push again as attempt 2. + out, err = w.agentPush(map[string]string{"pkg/ok.txt": "fixed\n"}) + Expect(err).NotTo(HaveOccurred(), "retry output:\n%s", out) + Expect(out).To(ContainSubstring("captain: ACCEPTED")) + Expect(gitT(w.mailbox, "rev-parse", "refs/captain/tasks/"+result.Task+"/result/2")).NotTo(BeEmpty()) + }) + + It("relays tier-2 rejection text down through the sidecar (H16)", func() { + w := newConformanceWorld(ctx, + nil, + &api.Workflow{Verify: &api.Verify{Commands: []string{"grep -q good pkg/ok.txt"}}}, + "") + result := w.dispatchTask(ctx) + + out, err := w.agentPush(map[string]string{"pkg/ok.txt": "bad\n"}) + Expect(err).To(HaveOccurred(), "push must be rejected:\n%s", out) + Expect(out).To(ContainSubstring("verify:grep -q good pkg/ok.txt"), "the supervisor's feedback travelled the sideband chain") + + // The supervisor's rejection persisted at its tier (R6.9); quarantine + // left no result ref behind. + verdict, found, err := gitagent.LoadVerdict(w.mailbox, result.Task, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(verdict.Status).To(Equal(gitagent.StatusRejected)) + Expect(verdict.Tier).To(Equal("supervisor")) + Expect(gitT(w.mailbox, "for-each-ref", "refs/captain/tasks/"+result.Task+"/result")).To(BeEmpty()) + + // The agent's local branch stays intact and ahead. + Expect(gitT(w.workdir, "rev-list", "--count", "origin/captain/"+result.Task+"..HEAD")).To(Equal("1")) + }) + + It("returns from dispatch promptly while the agent keeps running (H12)", func() { + w := newConformanceWorld(ctx, nil, nil, "echo started > agent-marker.txt && sleep 30") + start := time.Now() + result := w.dispatchTask(ctx) + Expect(time.Since(start)).To(BeNumerically("<", 15*time.Second), + "the dispatch push must not wait for the agent") + marker := filepath.Join(w.workdir, "agent-marker.txt") + Eventually(func() error { _, err := os.Stat(marker); return err }, "10s", "200ms").Should(Succeed(), + "the detached agent runs on after the push returned") + Expect(strings.TrimSpace(string(mustRead(marker)))).To(Equal("started")) + _ = result + }) +}) + +func mustRead(path string) []byte { + GinkgoHelper() + data, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + return data +} diff --git a/pkg/gitagent/control.go b/pkg/gitagent/control.go index 87dad426..a267b4b7 100644 --- a/pkg/gitagent/control.go +++ b/pkg/gitagent/control.go @@ -17,12 +17,16 @@ const ( // BuildControlCommit writes payloads as a flat tree and wraps it in a // parentless commit. Control refs point at commits, never bare trees — a -// tree-tipped ref trips gc, bitmap and fsck paths (R3.3). -func BuildControlCommit(ctx context.Context, repoDir string, payloads map[string][]byte) (string, error) { +// tree-tipped ref trips gc, bitmap and fsck paths (R3.3). env matters: built +// with a hook environment the objects land in quarantine and are discarded +// with a rejected push; nil means the process environment, scrubbed. +func BuildControlCommit(ctx context.Context, repoDir string, env []string, payloads map[string][]byte) (string, error) { if len(payloads) == 0 { return "", fmt.Errorf("a control commit needs at least one payload") } - env := ScrubGitEnv(os.Environ()) + if env == nil { + env = ScrubGitEnv(os.Environ()) + } names := make([]string, 0, len(payloads)) for name := range payloads { if name == "" || strings.ContainsAny(name, "/\x00") { diff --git a/pkg/gitagent/dispatch.go b/pkg/gitagent/dispatch.go new file mode 100644 index 00000000..86e902f0 --- /dev/null +++ b/pkg/gitagent/dispatch.go @@ -0,0 +1,248 @@ +// Dispatch (§6.1): snapshot the supervisor's dirty worktree, record the task +// in the local mailbox, and push dispatch+control atomically to the agent's +// sidecar with the envelope riding on push options. +package gitagent + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "time" +) + +// TaskPayload is task.json: what the agent is asked to do. It is materialized +// outside the agent's worktree so it is not itself submittable (§4). +type TaskPayload struct { + Prompt string `json:"prompt"` + System string `json:"system,omitempty"` + Model string `json:"model,omitempty"` +} + +// DispatchRequest carries one task hand-off. +type DispatchRequest struct { + RepoDir string + MailboxPath string + Task string // generated when empty + Agent string + SidecarURL string // ssh://host:port/repo.git + SidecarHostFP string + KeyPath string + SSHCommand string // GIT_SSH_COMMAND; "" ⇒ this binary's git-agent ssh transport + Relay RelayMode + Policy Policy + TaskPayload TaskPayload + HooksJSON []byte // pre-serialized hooks.json (may be nil) +} + +// DispatchResult reports the pushed hand-off. +type DispatchResult struct { + Task string + Attempt int + Snapshot *Snapshot + Control string +} + +// Dispatch performs §6.1 steps 1–3. The sidecar's admission and agent launch +// happen inside the push; the push returning is the proof of hand-off (H12). +func Dispatch(ctx context.Context, req DispatchRequest) (*DispatchResult, error) { + task := req.Task + if task == "" { + var err error + if task, err = NewTaskID(); err != nil { + return nil, err + } + } + if err := ValidateTaskID(task); err != nil { + return nil, err + } + if req.Relay == "" { + req.Relay = RelaySync + } + snapshot, err := TakeSnapshot(ctx, req.RepoDir, SnapshotPolicy{ + Paths: req.Policy.Paths, MaxFileSize: req.Policy.MaxBlobSize, + }) + if err != nil { + return nil, err + } + control, err := buildDispatchControl(ctx, req, snapshot) + if err != nil { + return nil, err + } + if err := recordDispatch(ctx, req, task, snapshot, control); err != nil { + return nil, err + } + if err := pushDispatch(ctx, req, task, snapshot, control); err != nil { + return nil, err + } + return &DispatchResult{Task: task, Attempt: 1, Snapshot: snapshot, Control: control}, nil +} + +// NewTaskID mints a task id inside the §3.2 shape. +func NewTaskID() (string, error) { + raw := make([]byte, 6) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return "t-" + hex.EncodeToString(raw), nil +} + +func buildDispatchControl(ctx context.Context, req DispatchRequest, snapshot *Snapshot) (string, error) { + taskJSON, err := json.Marshal(req.TaskPayload) + if err != nil { + return "", err + } + policyJSON, err := json.Marshal(req.Policy) + if err != nil { + return "", err + } + hooksJSON := req.HooksJSON + if len(hooksJSON) == 0 { + hooksJSON = []byte("{}") + } + return BuildControlCommit(ctx, req.RepoDir, nil, map[string][]byte{ + ControlTaskFile: taskJSON, + ControlHooksFile: hooksJSON, + ControlPolicyFile: policyJSON, + }) +} + +// recordDispatch writes the audit refs and task state into the local mailbox +// (R2.1): local update-refs, since the mailbox shares the real repository's +// objects through alternates. +func recordDispatch(ctx context.Context, req DispatchRequest, task string, snapshot *Snapshot, control string) error { + if err := InitMailbox(ctx, req.MailboxPath, req.RepoDir); err != nil { + return err + } + env := ScrubGitEnv(os.Environ()) + dispatchRef, err := DispatchRef(task, 1) + if err != nil { + return err + } + controlRef, err := ControlRef(task, 1) + if err != nil { + return err + } + if _, err := runGit(ctx, req.MailboxPath, env, "update-ref", dispatchRef, snapshot.Commit); err != nil { + return err + } + if _, err := runGit(ctx, req.MailboxPath, env, "update-ref", controlRef, control); err != nil { + return err + } + return SaveTaskState(req.MailboxPath, &TaskState{ + Task: task, + Agent: req.Agent, + Base: snapshot.Base, + DispatchCommit: snapshot.Commit, + Relay: req.Relay, + Policy: req.Policy, + }) +} + +func pushDispatch(ctx context.Context, req DispatchRequest, task string, snapshot *Snapshot, control string) error { + dispatchRef, err := DispatchRef(task, 1) + if err != nil { + return err + } + controlRef, err := ControlRef(task, 1) + if err != nil { + return err + } + envelope := Envelope{ + Version: ProtocolVersion, + Task: task, + Attempt: 1, + Base: snapshot.Base, + Depth: 0, + Agent: req.Agent, + Relay: req.Relay, + } + opts, err := envelope.Encode() + if err != nil { + return err + } + args := []string{"push", "--atomic"} + for _, o := range opts { + args = append(args, "--push-option="+o) + } + args = append(args, req.SidecarURL, + snapshot.Commit+":"+dispatchRef, + control+":"+controlRef, + ) + pairs, err := transportPairs(req.SSHCommand, req.KeyPath, req.SidecarHostFP) + if err != nil { + return err + } + env := envWith(ScrubGitEnv(os.Environ()), pairs...) + if _, err := runGit(ctx, req.RepoDir, env, args...); err != nil { + return fmt.Errorf("dispatch push: %w", err) + } + return nil +} + +// transportPairs builds the env for a push riding captain's GIT_SSH_COMMAND +// transport: no system ssh, key from a captain-managed path, host key pinned +// by fingerprint. An empty sshCommand means this binary's own transport. +func transportPairs(sshCommand, keyPath, hostFingerprint string) ([]string, error) { + if sshCommand == "" { + exe, err := os.Executable() + if err != nil { + return nil, err + } + sshCommand = exe + " sandbox git-agent ssh" + } + return []string{ + "GIT_SSH_COMMAND=" + sshCommand, + "GIT_SSH_VARIANT=ssh", // an unrecognized command defaults to "simple", which cannot pass -p + EnvSSHKey + "=" + keyPath, + EnvSSHHostFingerprint + "=" + hostFingerprint, + }, nil +} + +// AwaitOutcome polls the mailbox for the task's final verdict: the first +// accepted one, or a rejection on the task's last permitted attempt, or a +// timeout. Rejected non-final attempts keep waiting — rejection is not +// termination (§6.3). +func AwaitOutcome(ctx context.Context, mailbox, task string, timeout time.Duration) (*TierVerdict, error) { + if timeout <= 0 { + timeout = time.Hour + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + tick := time.NewTicker(500 * time.Millisecond) + defer tick.Stop() + for { + st, ok, err := LoadTaskState(mailbox, task) + if err != nil { + return nil, err + } + maxAttempts := 0 + if ok { + maxAttempts = st.Policy.MaxAttempts + } + for attempt := 1; attempt <= MaxAttempt; attempt++ { + v, found, err := LoadVerdict(mailbox, task, attempt) + if err != nil { + return nil, err + } + if !found { + break + } + if v.Status == StatusAccepted { + return v, nil + } + if maxAttempts > 0 && attempt >= maxAttempts { + return v, nil + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-deadline.C: + return nil, fmt.Errorf("task %s: no final verdict within %s", task, timeout) + case <-tick.C: + } + } +} diff --git a/pkg/gitagent/git.go b/pkg/gitagent/git.go index 893e08d7..325406ca 100644 --- a/pkg/gitagent/git.go +++ b/pkg/gitagent/git.go @@ -53,13 +53,25 @@ func runGitRaw(ctx context.Context, dir string, env []string, stdin io.Reader, a // gitExitCode runs git and returns its exit code, for commands whose non-zero // exit is an answer rather than a failure. func gitExitCode(ctx context.Context, dir string, env []string, args ...string) (int, string, error) { + var stderr bytes.Buffer + code, out, err := gitExitCodeStderr(ctx, dir, env, &stderr, args...) + if err != nil { + return code, out, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return code, out, nil +} + +// gitExitCodeStderr is gitExitCode with stderr streamed live to the given +// writer — the relay uses it to pass the upstream's sideband straight through +// to the blocked pusher (R6.6). +func gitExitCodeStderr(ctx context.Context, dir string, env []string, stderr io.Writer, args ...string) (int, string, error) { full := append(append([]string{}, normalizationArgs...), args...) cmd := exec.CommandContext(ctx, "git", full...) cmd.Dir = dir cmd.Env = env - var stdout, stderr bytes.Buffer + var stdout bytes.Buffer cmd.Stdout = &stdout - cmd.Stderr = &stderr + cmd.Stderr = stderr err := cmd.Run() if err == nil { return 0, stdout.String(), nil @@ -68,5 +80,5 @@ func gitExitCode(ctx context.Context, dir string, env []string, args ...string) if errors.As(err, &exitErr) { return exitErr.ExitCode(), stdout.String(), nil } - return -1, "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + return -1, "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) } diff --git a/pkg/gitagent/gitagent_suite_test.go b/pkg/gitagent/gitagent_suite_test.go index d0e534ad..98daa4fa 100644 --- a/pkg/gitagent/gitagent_suite_test.go +++ b/pkg/gitagent/gitagent_suite_test.go @@ -10,12 +10,18 @@ import ( "github.com/flanksource/captain/pkg/gitagent" ) -// TestMain doubles the test binary as the GIT_SSH_COMMAND client, so push -// tests exercise the production transport with no system ssh installed. +// TestMain triples the test binary as the GIT_SSH_COMMAND client and as the +// receive-hook shim, so the conformance suite exercises the production +// transport and hook entrypoints with no captain binary installed. The ssh +// branch is checked first: a relay push inside a hook process sets the ssh +// variable explicitly while the hook variable is still inherited. func TestMain(m *testing.M) { if os.Getenv("CAPTAIN_TEST_SSH_CLIENT") == "1" { os.Exit(gitagent.SSHClientMain(os.Args[1:])) } + if os.Getenv("CAPTAIN_TEST_HOOK") == "1" { + os.Exit(gitagent.HookMain(os.Args[1:])) + } os.Exit(m.Run()) } diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go new file mode 100644 index 00000000..3a636533 --- /dev/null +++ b/pkg/gitagent/hookmain.go @@ -0,0 +1,490 @@ +// The receive-hook entrypoints the shims exec (§6.2). pre-receive is the only +// place a push can be rejected, so admission, hook set execution and the +// nested relay all live there (R6.6); post-receive owns everything that needs +// quarantine to have ended — workspace setup, agent launch, integration +// (R6.4). +package gitagent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent/verify" + "github.com/flanksource/captain/pkg/api" +) + +// HookRuntime is the serializable part of a receiver's hook configuration. +type HookRuntime struct { + SidecarWorkflow *api.Workflow `json:"sidecarWorkflow,omitempty"` + SupervisorWorkflow *api.Workflow `json:"supervisorWorkflow,omitempty"` + // HookSandbox names the wrap-command sandbox confining exec hooks (R5.2): + // srt or container. The value test-identity is accepted only inside a Go + // test binary and cannot activate in production. + HookSandbox string `json:"hookSandbox,omitempty"` + AgentCommand string `json:"agentCommand,omitempty"` + RealRepo string `json:"realRepo,omitempty"` // mailbox: integration target + Relay RelayTarget `json:"relay,omitempty"` // sidecar: the supervisor mailbox +} + +// HookHost is a runtime plus the process-local collaborators a hook set needs. +type HookHost struct { + Runtime HookRuntime + Judge ai.Provider + Wrap verify.CommandWrapFunc + Timeout time.Duration +} + +// LoadHookRuntime reads a HookRuntime JSON file; an empty path is an empty +// runtime. +func LoadHookRuntime(path string) (HookRuntime, error) { + var rt HookRuntime + if path == "" { + return rt, nil + } + data, err := os.ReadFile(path) + if err != nil { + return rt, err + } + if err := json.Unmarshal(data, &rt); err != nil { + return rt, fmt.Errorf("hook runtime %s: %w", path, err) + } + return rt, nil +} + +// ResolveHookWrap maps HookRuntime.HookSandbox onto a confinement func via +// the sandbox registry. Empty means none — RunHookSet then refuses exec hooks +// rather than running them bare (R5.2). +func ResolveHookWrap(name string) (verify.CommandWrapFunc, error) { + switch name { + case "": + return nil, nil + case "test-identity": + if !testing.Testing() { + return nil, fmt.Errorf("hookSandbox test-identity is only available inside a test binary (R5.2)") + } + return func(_ context.Context, cmd string, args, env []string) (string, []string, []string, error) { + return cmd, args, env, nil + }, nil + } + kind, ok := api.ParseSandboxKind(name) + if !ok { + return nil, fmt.Errorf("unknown hook sandbox kind %q", name) + } + sandbox, err := api.NewSandbox(api.SandboxConfig{Kind: kind}) + if err != nil { + return nil, err + } + wrapper, ok := api.SandboxAs[api.CommandWrapper](sandbox) + if !ok { + return nil, fmt.Errorf("hook sandbox %q provides no command wrapper; exec hooks cannot run confined (R5.2)", name) + } + return wrapper.Wrap, nil +} + +// HookMain is the shim entrypoint: args are +// [runtime-config.json]. +func HookMain(args []string) int { + if len(args) < 3 || len(args) > 4 { + fmt.Fprintln(os.Stderr, "captain: usage: hook [runtime.json]") + return 1 + } + hook, repo, role := args[0], args[1], args[2] + cfgPath := "" + if len(args) == 4 { + cfgPath = args[3] + } + runtime, err := LoadHookRuntime(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return 1 + } + wrap, err := ResolveHookWrap(runtime.HookSandbox) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return 1 + } + host := HookHost{Runtime: runtime, Wrap: wrap} + ctx := context.Background() + switch hook { + case "pre-receive": + if err := RunPreReceive(ctx, repo, ReceiverRole(role), host, os.Stdin, os.Stderr); err != nil { + return 1 + } + return 0 + case "post-receive": + if err := RunPostReceive(ctx, repo, ReceiverRole(role), host, os.Stdin); err != nil { + // post-receive exit status cannot reject; still surface the problem. + fmt.Fprintf(os.Stderr, "captain: post-receive: %v\n", err) + return 1 + } + return 0 + default: + fmt.Fprintf(os.Stderr, "captain: unknown hook %q\n", hook) + return 1 + } +} + +// RunPreReceive is the admission + vetting + relay tier. A non-nil error +// means the push is rejected; the reason has already been written to +// sideband. +func RunPreReceive(ctx context.Context, repo string, role ReceiverRole, host HookHost, stdin io.Reader, sideband io.Writer) error { + updates, err := ParseRefUpdates(stdin) + if err != nil { + fmt.Fprintf(sideband, "captain: %v\n", err) + return err + } + envelope := envelopeFromHookEnv() + req := AdmitRequest{Repo: repo, Role: role, Agent: os.Getenv(EnvAgentName), Updates: updates, Envelope: envelope, Env: os.Environ()} + if err := Admit(ctx, req); err != nil { + fmt.Fprintf(sideband, "captain: REJECTED (admission)\ncaptain: %v\n", err) + return err + } + if role == RoleSidecar { + return sidecarPreReceive(ctx, repo, host, updates, sideband) + } + return mailboxPreReceive(ctx, repo, host, updates, envelope, sideband) +} + +func envelopeFromHookEnv() *Envelope { + envelope, err := EnvelopeFromEnv(os.Getenv) + if err != nil { + return nil // an agent's bare push carries none; Admit enforces per-ref + } + return &envelope +} + +// sidecarPreReceive vets an agent's submit. Dispatch pushes (protocol refs +// only) were fully admitted above and need no hook set — theirs runs on +// submit. +func sidecarPreReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, sideband io.Writer) error { + branchUpdate, task, ok := singleAgentBranchUpdate(updates) + if !ok { + return nil + } + st, found, err := LoadTaskState(repo, task) + if err != nil || !found { + fmt.Fprintf(sideband, "captain: task state missing for %s\n", task) + return fmt.Errorf("task state missing for %s", task) + } + // An attempt is consumed per submit, rejected or not (§6.3): the retry + // after a rejection is attempt n+1. + attempt := st.Attempts + 1 + if st.Policy.MaxAttempts > 0 && attempt > st.Policy.MaxAttempts { + verdict := TierVerdict{ + V: ProtocolVersion, Task: task, Attempt: attempt, Tier: string(RoleSidecar), Status: StatusRejected, + Findings: []Finding{{Hook: "gate:max-attempts", Kind: "commit", + Message: fmt.Sprintf("attempt %d exceeds the task's maxAttempts %d", attempt, st.Policy.MaxAttempts)}}, + } + return rejectWithVerdict(repo, verdict, sideband) + } + st.Attempts = attempt + if err := SaveTaskState(repo, st); err != nil { + fmt.Fprintf(sideband, "captain: %v\n", err) + return err + } + verdict := vetTree(ctx, repo, vetRequest{ + host: host, workflow: host.Runtime.SidecarWorkflow, tier: string(RoleSidecar), + task: task, attempt: attempt, depth: 0, + from: st.DispatchCommit, to: branchUpdate.New, + }) + if verdict.Rejects() { + return rejectWithVerdict(repo, verdict, sideband) + } + if host.Runtime.Relay.URL != "" { + if err := relayUpward(ctx, repo, host, st, attempt, branchUpdate.New, sideband); err != nil { + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "relay", Kind: "exec", Message: err.Error(), + }) + return rejectWithVerdict(repo, verdict, sideband) + } + } + if err := SaveVerdict(repo, verdict); err != nil { + fmt.Fprintf(sideband, "captain: %v\n", err) + return err + } + return WriteFeedback(sideband, verdict, "") +} + +// relayUpward pushes the squashed result plus the ORIGINAL dispatch control +// commit at the attempt's control ref. On attempt 1 the mailbox already holds +// that exact commit from the supervisor's audit write, so git filters the +// control update as up-to-date; on a retry it is a fresh create — either way +// R3.2's create-only holds and the R3.4 pairing invariant is preserved. +func relayUpward(ctx context.Context, repo string, host HookHost, st *TaskState, attempt int, tip string, sideband io.Writer) error { + hookEnv := os.Environ() + result, err := BuildResultCommit(ctx, repo, hookEnv, tip, st.DispatchCommit) + if err != nil { + return err + } + control := st.ControlCommit + if control == "" { + return fmt.Errorf("task %s has no recorded control commit; cannot relay", st.Task) + } + envelope := Envelope{ + Version: ProtocolVersion, Task: st.Task, Attempt: attempt, + Base: st.Base, Depth: 0, Agent: st.Agent, Relay: st.Relay, + } + return Relay(ctx, repo, hookEnv, host.Runtime.Relay, envelope, result, control, sideband) +} + +// mailboxPreReceive runs hook set #2 over an arriving result (§6.2 step 10). +// The accept-path verdict is written by post-receive after integration; only +// a rejection must persist here, before the non-zero exit (R6.9). +func mailboxPreReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, envelope *Envelope, sideband io.Writer) error { + resultUpdate, info, ok := singleResultUpdate(updates) + if !ok { + return nil + } + st, found, err := LoadTaskState(repo, info.Task) + if err != nil || !found { + fmt.Fprintf(sideband, "captain: task state missing for %s\n", info.Task) + return fmt.Errorf("task state missing for %s", info.Task) + } + depth := 0 + if envelope != nil { + depth = envelope.Depth + } + verdict := vetTree(ctx, repo, vetRequest{ + host: host, workflow: host.Runtime.SupervisorWorkflow, tier: "supervisor", + task: info.Task, attempt: info.Attempt, depth: depth, + from: st.DispatchCommit, to: resultUpdate.New, + }) + if verdict.Rejects() { + return rejectWithVerdict(repo, verdict, sideband) + } + return nil +} + +type vetRequest struct { + host HookHost + workflow *api.Workflow + tier string + task string + attempt int + depth int + from, to string +} + +// vetTree materializes the pushed tree and runs one tier's hook set over it. +// Every failure mode folds into the verdict (R7.5). +func vetTree(ctx context.Context, repo string, req vetRequest) TierVerdict { + verdict := TierVerdict{V: ProtocolVersion, Task: req.task, Attempt: req.attempt, Tier: req.tier, Status: StatusError} + dir, err := os.MkdirTemp("", "captain-vet-") + if err != nil { + verdict.Findings = append(verdict.Findings, Finding{Hook: "materialize", Kind: "exec", Message: err.Error()}) + return verdict + } + defer os.RemoveAll(dir) + hookEnv := os.Environ() + if _, err := Materialize(ctx, repo, hookEnv, req.to, dir); err != nil { + verdict.Findings = append(verdict.Findings, Finding{Hook: "materialize", Kind: "exec", Message: err.Error()}) + return verdict + } + changed, err := changedPathsBetween(ctx, repo, hookEnv, req.from, req.to) + if err != nil { + verdict.Findings = append(verdict.Findings, Finding{Hook: "materialize", Kind: "exec", Message: err.Error()}) + return verdict + } + stop := StartProgress(os.Stderr, req.tier+" hooks", 30*time.Second) + defer stop() + return RunHookSet(ctx, HookWorkspace{Dir: dir, Changed: changed}, HookSetOptions{ + Workflow: req.workflow, + Tier: req.tier, + Task: req.task, + Attempt: req.attempt, + Depth: req.depth, + Judge: req.host.Judge, + Wrap: req.host.Wrap, + Env: ScrubGitEnv(hookEnv), + Timeout: req.host.Timeout, + }) +} + +// rejectWithVerdict persists the verdict out-of-band before the non-zero exit +// (R6.9), then writes the sideband feedback block (§7). +func rejectWithVerdict(repo string, verdict TierVerdict, sideband io.Writer) error { + logPath := verdictPath(repo, verdict.Task, verdict.Attempt) + if err := SaveVerdict(repo, verdict); err != nil { + fmt.Fprintf(sideband, "captain: persisting verdict: %v\n", err) + } + _ = WriteFeedback(sideband, verdict, logPath) + return fmt.Errorf("push rejected: task %s attempt %d (%s)", verdict.Task, verdict.Attempt, verdict.Tier) +} + +// RunPostReceive owns the work that is only legal once quarantine has ended +// (R6.4): sidecar workspace setup and agent launch on dispatch, supervisor +// integration and the verdict ref on an accepted result. +func RunPostReceive(ctx context.Context, repo string, role ReceiverRole, host HookHost, stdin io.Reader) error { + updates, err := ParseRefUpdates(stdin) + if err != nil { + return err + } + envelope := envelopeFromHookEnv() + if role == RoleSidecar { + return sidecarPostReceive(ctx, repo, host, updates, envelope) + } + return mailboxPostReceive(ctx, repo, host, updates, envelope) +} + +func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, envelope *Envelope) error { + for _, u := range updates { + if !IsProtocolRef(u.Ref) { + continue + } + info, err := ParseTaskRef(u.Ref) + if err != nil || info.Kind != RefDispatch { + continue + } + if envelope == nil { + return fmt.Errorf("dispatch ref %s arrived without an envelope", u.Ref) + } + policy, taskPayload, controlCommit := loadDispatchPayloads(ctx, repo, updates, info) + if err := SaveTaskState(repo, &TaskState{ + Task: info.Task, Agent: envelope.Agent, Base: envelope.Base, + DispatchCommit: u.New, ControlCommit: controlCommit, + Relay: envelope.Relay, Policy: policy, + }); err != nil { + return err + } + workdir, err := SetupAgentWorkspace(ctx, repo, info.Task, u.New) + if err != nil { + return err + } + taskFile, err := WriteTaskFile(repo, info.Task, taskPayload) + if err != nil { + return err + } + if err := LaunchAgent(repo, info.Task, workdir, taskFile, host.Runtime.AgentCommand); err != nil { + return err + } + } + return nil +} + +// loadDispatchPayloads reads policy.json and task.json from the control +// commit that travelled with the dispatch. Absent payloads fall back to +// defaults — the dispatch is still processable (only the envelope is +// mandatory). +func loadDispatchPayloads(ctx context.Context, repo string, updates []RefUpdate, dispatch RefInfo) (Policy, []byte, string) { + var policy Policy + taskPayload := []byte("{}") + controlRef, err := ControlRef(dispatch.Task, dispatch.Attempt) + if err != nil { + return policy, taskPayload, "" + } + control := refUpdateFor(updates, controlRef) + if control.New == "" || control.New == zeroOID { + return policy, taskPayload, "" + } + env := os.Environ() + if raw, err := ReadControlPayload(ctx, repo, env, control.New, ControlPolicyFile); err == nil { + _ = json.Unmarshal(raw, &policy) + } + if raw, err := ReadControlPayload(ctx, repo, env, control.New, ControlTaskFile); err == nil && len(raw) > 0 { + taskPayload = raw + } + return policy, taskPayload, control.New +} + +func mailboxPostReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, envelope *Envelope) error { + resultUpdate, info, ok := singleResultUpdate(updates) + if !ok { + return nil + } + st, found, err := LoadTaskState(repo, info.Task) + if err != nil || !found { + return fmt.Errorf("task state missing for %s", info.Task) + } + base := st.Base + if envelope != nil { + base = envelope.Base // R10.1: the base recorded in the envelope + } + verdict := TierVerdict{V: ProtocolVersion, Task: info.Task, Attempt: info.Attempt, Tier: "supervisor", Status: StatusAccepted} + if host.Runtime.RealRepo != "" { + integration, err := Integrate(ctx, host.Runtime.RealRepo, repo, info.Task, info.Attempt, base, resultUpdate.New) + if err != nil { + return err + } + if integration.Conflict != "" { + // The work was accepted; only its integration needs a human. The + // conflict is reported, never auto-resolved (R10.2). + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "integrate", Kind: "commit", Message: integration.Conflict, + }) + } else { + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "integrate", Kind: "commit", + Message: "merged onto " + integration.Branch, Path: integration.Branch, + }) + } + } + st.Attempts = info.Attempt + if err := SaveTaskState(repo, st); err != nil { + return err + } + if err := SaveVerdict(repo, verdict); err != nil { + return err + } + return writeVerdictRef(ctx, repo, verdict) +} + +// writeVerdictRef records the verdict as a control commit on +// refs/captain/tasks//verdict/ (§3.2). +func writeVerdictRef(ctx context.Context, repo string, verdict TierVerdict) error { + ref, err := VerdictRef(verdict.Task, verdict.Attempt) + if err != nil { + return err + } + payload, err := json.MarshalIndent(verdict, "", " ") + if err != nil { + return err + } + commit, err := BuildControlCommit(ctx, repo, nil, map[string][]byte{"verdict.json": payload}) + if err != nil { + return err + } + _, err = runGit(ctx, repo, os.Environ(), "update-ref", ref, commit) + return err +} + +func singleAgentBranchUpdate(updates []RefUpdate) (RefUpdate, string, bool) { + for _, u := range updates { + if strings.HasPrefix(u.Ref, agentBranchPrefix) { + return u, strings.TrimPrefix(u.Ref, agentBranchPrefix), true + } + } + return RefUpdate{}, "", false +} + +func singleResultUpdate(updates []RefUpdate) (RefUpdate, RefInfo, bool) { + for _, u := range updates { + if info, err := ParseTaskRef(u.Ref); err == nil && info.Kind == RefResult { + return u, info, true + } + } + return RefUpdate{}, RefInfo{}, false +} + +// changedPathsBetween lists the paths differing between two commits. +func changedPathsBetween(ctx context.Context, repo string, env []string, from, to string) ([]string, error) { + out, err := runGitRaw(ctx, repo, env, nil, + "diff-tree", "-r", "-z", "--name-only", "--no-commit-id", "--no-renames", from, to) + if err != nil { + return nil, err + } + var paths []string + for _, p := range strings.Split(out, "\x00") { + if p != "" { + paths = append(paths, p) + } + } + return paths, nil +} diff --git a/pkg/gitagent/integrate.go b/pkg/gitagent/integrate.go new file mode 100644 index 00000000..8283a684 --- /dev/null +++ b/pkg/gitagent/integrate.go @@ -0,0 +1,78 @@ +// Integration (§10): an accepted result merges three-way against the base +// recorded in the envelope — never against current HEAD, which may have moved +// during the task (R10.1). Conflicts become structured feedback; nothing is +// auto-resolved (R10.2). +package gitagent + +import ( + "context" + "fmt" + "os" + "strings" +) + +// IntegrationResult reports where an accepted result landed. +type IntegrationResult struct { + Branch string // captain/ in the real repository + Commit string // the merge commit the branch points at + Conflict string // non-empty when the merge conflicted; Branch is then unset +} + +// Integrate merges result into the real repository's HEAD using base as the +// merge base and parks the outcome on refs/heads/captain/, leaving the +// user's worktree and current branch untouched. mailbox is fetched first: the +// result's objects arrived there, and the real repository has no alternates +// pointing back (R2.1 keeps that arrow one-way). +func Integrate(ctx context.Context, realRepo, mailbox, task string, attempt int, base, result string) (*IntegrationResult, error) { + branch, err := AgentBranch(task) + if err != nil { + return nil, err + } + env := ScrubGitEnv(os.Environ()) + resultRef, err := ResultRef(task, attempt) + if err != nil { + return nil, err + } + if _, err := runGit(ctx, realRepo, env, "fetch", "--quiet", "--no-write-fetch-head", mailbox, resultRef); err != nil { + return nil, err + } + head, err := runGit(ctx, realRepo, env, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return nil, err + } + code, out, err := gitExitCode(ctx, realRepo, env, + "merge-tree", "--write-tree", "--merge-base="+base, head, result) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(out), "\n") + switch { + case code == 1: + // Content conflicts: the first line is the conflict-markered tree, + // the rest describes the conflicted paths. + detail := "merge conflict" + if len(lines) > 1 { + detail = "merge conflict in: " + strings.Join(lines[1:], ", ") + } + return &IntegrationResult{Conflict: detail}, nil + case code != 0 || len(lines) == 0: + return nil, fmt.Errorf("merge-tree failed (exit %d): %s", code, strings.TrimSpace(out)) + } + tree := strings.TrimSpace(lines[0]) + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + merge, err := runGitIn(ctx, realRepo, cenv, + strings.NewReader(fmt.Sprintf("captain: integrate task %s\n", task)), + "commit-tree", tree, "-p", head, "-p", result) + if err != nil { + return nil, err + } + if _, err := runGit(ctx, realRepo, env, "update-ref", branch, merge); err != nil { + return nil, err + } + return &IntegrationResult{Branch: branch, Commit: merge}, nil +} diff --git a/pkg/gitagent/materialize.go b/pkg/gitagent/materialize.go index 3436d0f1..e7948072 100644 --- a/pkg/gitagent/materialize.go +++ b/pkg/gitagent/materialize.go @@ -40,7 +40,11 @@ func Materialize(ctx context.Context, repoDir string, env []string, commitOID, d if _, err := runGit(ctx, repoDir, ienv, "read-tree", commitOID); err != nil { return 0, err } - if _, err := runGit(ctx, repoDir, ienv, "checkout-index", "-a", "-f", "--prefix="+dstAbs+string(os.PathSeparator)); err != nil { + // The --work-tree + core.bare=false form is the one verified to write a + // full tree from a bare receiver during pre-receive (§1.3); --prefix + // refuses to run without a work tree there. + if _, err := runGit(ctx, repoDir, ienv, + "-c", "core.bare=false", "--work-tree="+dstAbs, "checkout-index", "-a", "-f"); err != nil { return 0, err } count, err := countMaterialized(dstAbs) diff --git a/pkg/gitagent/relay.go b/pkg/gitagent/relay.go new file mode 100644 index 00000000..f2a6a75b --- /dev/null +++ b/pkg/gitagent/relay.go @@ -0,0 +1,81 @@ +// The nested relay (§6.2 step 9): still inside the sidecar's pre-receive, the +// vetted work is pushed onward to the supervisor's mailbox and the upstream +// sideband streams back out through the sidecar's own stderr. The relay lives +// in pre-receive because post-receive can no longer reject (R6.6/H16); it +// unsets GIT_QUARANTINE_PATH and keeps the inherited object directories, so +// no object is ever copied out of quarantine (R1.4). +package gitagent + +import ( + "context" + "fmt" + "io" + "strings" +) + +// RelayTarget is where and how the sidecar reaches the supervisor mailbox. +type RelayTarget struct { + URL string `json:"url"` + HostFingerprint string `json:"hostFingerprint"` + KeyPath string `json:"keyPath"` + SSHCommand string `json:"sshCommand,omitempty"` // "" ⇒ this binary's transport +} + +// BuildResultCommit squashes the agent's branch tip into the single result +// commit the protocol requires: tree = the tip's tree, parent = the dispatch +// commit (§3.2). Written through the hook environment, so in pre-receive the +// new object lands in quarantine and is discarded with a rejected push. +func BuildResultCommit(ctx context.Context, repo string, env []string, tip, dispatchCommit string) (string, error) { + tree, err := runGit(ctx, repo, env, "rev-parse", tip+"^{tree}") + if err != nil { + return "", err + } + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + return runGitIn(ctx, repo, cenv, + strings.NewReader("captain result\n"), + "commit-tree", tree, "-p", dispatchCommit) +} + +// Relay pushes result+control atomically to the mailbox, streaming the +// upstream's stderr through sideband. A non-zero upstream exit is the +// caller's signal to reject the agent's push (R6.7). +func Relay(ctx context.Context, repo string, hookEnv []string, target RelayTarget, envelope Envelope, result, control string, sideband io.Writer) error { + resultRef, err := ResultRef(envelope.Task, envelope.Attempt) + if err != nil { + return err + } + controlRef, err := ControlRef(envelope.Task, envelope.Attempt) + if err != nil { + return err + } + opts, err := envelope.Encode() + if err != nil { + return err + } + args := []string{"push", "--atomic"} + for _, o := range opts { + args = append(args, "--push-option="+o) + } + args = append(args, target.URL, result+":"+resultRef, control+":"+controlRef) + + pairs, err := transportPairs(target.SSHCommand, target.KeyPath, target.HostFingerprint) + if err != nil { + return err + } + // R1.4: unset only GIT_QUARANTINE_PATH; the object-directory variables + // stay so the quarantined objects remain readable for the outbound pack. + env := envWith(RelayEnv(hookEnv), pairs...) + code, out, err := gitExitCodeStderr(ctx, repo, env, sideband, args...) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("supervisor rejected attempt %d (exit %d)%s", envelope.Attempt, code, strings.TrimSpace(out)) + } + return nil +} diff --git a/pkg/gitagent/state.go b/pkg/gitagent/state.go index 1a8a06c8..fad88651 100644 --- a/pkg/gitagent/state.go +++ b/pkg/gitagent/state.go @@ -24,7 +24,8 @@ type TaskState struct { Agent string `json:"agent,omitempty"` Base string `json:"base"` DispatchCommit string `json:"dispatchCommit"` - Attempts int `json:"attempts"` // highest attempt seen + ControlCommit string `json:"controlCommit,omitempty"` // the dispatched control payloads + Attempts int `json:"attempts"` // highest attempt seen Relay RelayMode `json:"relay,omitempty"` Policy Policy `json:"policy"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/pkg/gitagent/workspace.go b/pkg/gitagent/workspace.go new file mode 100644 index 00000000..f7e7e3e2 --- /dev/null +++ b/pkg/gitagent/workspace.go @@ -0,0 +1,94 @@ +// Sidecar workspace setup (§6.1 steps 4–5, run from post-receive where +// quarantine has ended — R6.4/H2): the agent gets an ordinary clone with a +// branch and upstream so a bare `git commit` + `git push` is sufficient +// (R3.1/H17), task.json lands outside the worktree, and the agent process is +// launched fully detached so the dispatch push returns promptly (R6.3/H12). +package gitagent + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +// SetupAgentWorkspace creates the task branch in the sidecar repo and clones +// it (object store shared) into /captain/tasks//worktree. A clone +// rather than a linked worktree keeps the agent's unaccepted commits in the +// agent's own object store: a rejected push leaves the sidecar repo clean. +func SetupAgentWorkspace(ctx context.Context, sidecarRepo, task, dispatchCommit string) (string, error) { + branch, err := AgentBranch(task) + if err != nil { + return "", err + } + env := ScrubGitEnv(os.Environ()) + if _, err := runGit(ctx, sidecarRepo, env, "update-ref", branch, dispatchCommit); err != nil { + return "", err + } + workdir := filepath.Join(taskStateDir(sidecarRepo, task), "worktree") + if _, err := os.Stat(workdir); err == nil { + return workdir, nil // re-dispatch onto an existing workspace is a no-op + } + branchName := "captain/" + task + if _, err := runGit(ctx, filepath.Dir(workdir), env, + "clone", "--quiet", "--shared", "--branch", branchName, sidecarRepo, workdir); err != nil { + return "", err + } + // Pin the agent's identity so a bare `git commit` needs no global config. + for _, kv := range [][2]string{{"user.name", "captain-agent"}, {"user.email", "agent@captain.local"}} { + if _, err := runGit(ctx, workdir, env, "config", kv[0], kv[1]); err != nil { + return "", err + } + } + return workdir, nil +} + +// WriteTaskFile materializes task.json outside the worktree (§4). +func WriteTaskFile(sidecarRepo, task string, payload []byte) (string, error) { + dir := taskStateDir(sidecarRepo, task) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + path := filepath.Join(dir, ControlTaskFile) + return path, writeFileAtomic(path, payload, 0o644) +} + +// LaunchAgent starts command (a shell line) fully detached: its own session, +// stdio redirected to files. A child inheriting the hook's stdout keeps the +// sideband pipe open and receive-pack waits for EOF — the dispatch push would +// hang for the agent's lifetime (R6.3/H12). An empty command is a no-op: the +// workspace is ready for a human or an externally-managed agent. +func LaunchAgent(sidecarRepo, task, workdir, taskFile, command string) error { + if command == "" { + return nil + } + dir := taskStateDir(sidecarRepo, task) + stdout, err := os.OpenFile(filepath.Join(dir, "agent.stdout.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer stdout.Close() + stderr, err := os.OpenFile(filepath.Join(dir, "agent.stderr.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer stderr.Close() + cmd := exec.Command("sh", "-c", command) + cmd.Dir = workdir + cmd.Env = envWith(ScrubGitEnv(os.Environ()), + "CAPTAIN_TASK="+task, + "CAPTAIN_TASK_FILE="+taskFile, + ) + cmd.Stdin = nil + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("launching agent: %w", err) + } + // Detach: the hook must not wait. Release drops our handle; the process + // reparents to init when the hook exits. + return cmd.Process.Release() +} diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go new file mode 100644 index 00000000..2f8b9788 --- /dev/null +++ b/pkg/sandbox/adapter/gitagent.go @@ -0,0 +1,183 @@ +// The git-agent adapter: whole-run relocation over the git-agent protocol. +// Execute snapshots the supervisor's dirty worktree, dispatches it to an +// enrolled agent's sidecar, and blocks until the two-tier vet loop produces a +// final verdict, which comes back as the run's response. +package adapter + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "time" + + "github.com/flanksource/captain/pkg/ai/agent/setup" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +// GitAgent constructs the remote-execution adapter. Backend options carry the +// enrollment map and transport endpoints; nothing here is read from the spec +// except the work itself. +func GitAgent(cfg api.SandboxConfig) (api.Sandbox, error) { + return &gitAgentSandbox{cfg: cfg}, nil +} + +func init() { + api.RegisterSandbox(api.SandboxGitAgent, GitAgent) +} + +type gitAgentSandbox struct { + cfg api.SandboxConfig +} + +func (g *gitAgentSandbox) Kind() api.SandboxKind { return api.SandboxGitAgent } + +func (g *gitAgentSandbox) Prepare(_ context.Context, _ *api.Spec) (*api.SandboxSession, error) { + return &api.SandboxSession{}, nil +} + +func (g *gitAgentSandbox) Close() error { return nil } + +// IsolatesWorkspace: the run happens in the agent's own worktree, so pairing +// with --worktree or a setup checkout must be refused, never doubled. +func (g *gitAgentSandbox) IsolatesWorkspace() bool { return true } + +// ProvidesEgressProxy: the dispatch sandbox holds placeholders, not +// credentials; the sidecar proxy substitutes on the way out. +func (g *gitAgentSandbox) ProvidesEgressProxy() bool { return true } + +func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Response, error) { + if spec.Setup != nil && setup.Relocates(spec.Setup.Checkout) { + return nil, fmt.Errorf("sandbox git-agent relocates the run; it cannot be combined with a setup checkout (register exactly one isolator)") + } + target, err := g.resolveTarget() + if err != nil { + return nil, err + } + repoDir := spec.Cwd() + hooksJSON, _ := json.Marshal(map[string]any{ + "sidecar": g.cfg.Options["hooks"], + "supervisor": nil, + }) + prompt := "" + system := "" + if spec.Prompt.User != "" { + prompt = spec.Prompt.User + system = spec.Prompt.System + } + dispatch, err := gitagent.Dispatch(ctx, gitagent.DispatchRequest{ + RepoDir: repoDir, + MailboxPath: target.mailbox, + Agent: target.agent, + SidecarURL: target.url, + SidecarHostFP: target.hostFingerprint, + KeyPath: target.keyPath, + Relay: target.relay, + Policy: target.policy, + TaskPayload: gitagent.TaskPayload{Prompt: prompt, System: system, Model: spec.Name}, + HooksJSON: hooksJSON, + }) + if err != nil { + return nil, err + } + verdict, err := gitagent.AwaitOutcome(ctx, target.mailbox, dispatch.Task, target.waitTimeout) + if err != nil { + return nil, fmt.Errorf("task %s dispatched but not concluded: %w", dispatch.Task, err) + } + return gitAgentResponse(dispatch.Task, verdict), nil +} + +func gitAgentResponse(task string, verdict *gitagent.TierVerdict) *api.Response { + text := fmt.Sprintf("git-agent task %s attempt %d: %s", task, verdict.Attempt, verdict.Status) + for _, f := range verdict.Findings { + text += "\n" + f.Hook + if f.Message != "" { + text += ": " + f.Message + } + } + resp := &api.Response{Text: text, StructuredData: verdict} + for _, f := range verdict.Findings { + if f.Hook == "integrate" && f.Path != "" { + resp.Workspace = &api.Workspace{Branch: f.Path} + } + } + return resp +} + +type gitAgentTarget struct { + agent string + url string + hostFingerprint string + keyPath string + mailbox string + relay gitagent.RelayMode + policy gitagent.Policy + waitTimeout time.Duration +} + +// resolveTarget picks the enrolled agent — pinned by the spec's sandbox.agent, +// or the sole enrolled one — and assembles transport details from options. +func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { + opts := g.cfg.Options + agents, _ := opts["agents"].(map[string]any) + name := g.cfg.Agent + if name == "" { + if len(agents) == 1 { + for only := range agents { + name = only + } + } else { + return nil, fmt.Errorf("backend %q has %d enrolled agents; pin one with sandbox.agent", g.cfg.Name, len(agents)) + } + } + entry, _ := agents[name].(map[string]any) + if entry == nil { + return nil, fmt.Errorf("agent %q is not enrolled in backend %q", name, g.cfg.Name) + } + url, _ := entry["url"].(string) + hostFP, _ := entry["hostFingerprint"].(string) + if url == "" || hostFP == "" { + return nil, fmt.Errorf("agent %q needs url and hostFingerprint recorded (its sidecar endpoint and host key)", name) + } + keysDir, err := gitAgentKeysDir() + if err != nil { + return nil, err + } + target := &gitAgentTarget{ + agent: name, + url: url, + hostFingerprint: hostFP, + keyPath: stringOption(opts, "key", filepath.Join(keysDir, "supervisor_ed25519")), + mailbox: stringOption(opts, "mailbox", filepath.Join(keysDir, "mailbox.git")), + relay: gitagent.RelayMode(stringOption(opts, "relay", string(gitagent.RelaySync))), + waitTimeout: time.Hour, + } + if raw, ok := opts["waitTimeout"].(string); ok { + if d, err := time.ParseDuration(raw); err == nil { + target.waitTimeout = d + } + } + if g.cfg.Policy != nil { + target.policy = gitagent.Policy{Paths: g.cfg.Policy.Paths, MaxAttempts: g.cfg.Policy.MaxAttempts} + } + return target, nil +} + +func stringOption(opts map[string]any, key, fallback string) string { + if v, ok := opts[key].(string); ok && v != "" { + return v + } + return fallback +} + +// gitAgentKeysDir anchors key material and the default mailbox beside the +// captain config file, matching the CLI's layout. +func gitAgentKeysDir() (string, error) { + path, err := captainconfig.Path() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(path), ".captain", "sandbox"), nil +} From 5892378e0d8e0bdce22f263fe2f1d1d706a18e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 18:10:05 +0000 Subject: [PATCH 07/22] =?UTF-8?q?feat(gitagent):=20egress=20credential=20p?= =?UTF-8?q?roxy=20=E2=80=94=20placeholders=20out,=20values=20never=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox holds captain-placeholder--
stand-ins; the proxy substitutes the real value only when the placeholder appears in exactly the granted header for the granted destination. A placeholder anywhere else — another header, the URL, the body, another host — is rejected and logged, never stripped and forwarded: the appearance is an exfiltration attempt and silently continuing would hide it (R9.2). Destinations are deny-by-default, grants are scoped by method and path prefix because a host allowlist alone still permits POST /gists (R9.6/H7), CONNECT is refused so every request stays inspectable, DNS is resolved by the proxy itself with TLS validating the granted name — never the sandbox-controlled Host or SNI (R9.1) — an unresolvable credential fails the request rather than forwarding the placeholder (R9.4), and every decision is audited without ever logging a value (R9.5). Grant headers are types.EnvVar (A4.1); the static resolver covers inline values and store-backed resolvers plug in behind the same seam. TokenResult gains its Placeholder and a PlaceholderEnv projection, so a sandbox environment can be built that provably never carried the real credential (issue #39 §6.2). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- pkg/gitagent/proxy/grants.go | 148 +++++++++++++++++++ pkg/gitagent/proxy/proxy.go | 221 ++++++++++++++++++++++++++++ pkg/gitagent/proxy/proxy_test.go | 243 +++++++++++++++++++++++++++++++ pkg/sandbox/tokens.go | 20 +++ pkg/sandbox/tokens_test.go | 25 ++++ 5 files changed, 657 insertions(+) create mode 100644 pkg/gitagent/proxy/grants.go create mode 100644 pkg/gitagent/proxy/proxy.go create mode 100644 pkg/gitagent/proxy/proxy_test.go create mode 100644 pkg/sandbox/tokens_test.go diff --git a/pkg/gitagent/proxy/grants.go b/pkg/gitagent/proxy/grants.go new file mode 100644 index 00000000..6ed5ad9b --- /dev/null +++ b/pkg/gitagent/proxy/grants.go @@ -0,0 +1,148 @@ +// Package proxy is the egress credential proxy (SPEC-git-agent-protocol §9): +// the sandbox never holds a real credential, only a placeholder; the proxy +// substitutes the real value on the way out, and only when the placeholder +// appears in exactly the granted header for the granted destination. A +// placeholder anywhere else is an exfiltration attempt and rejects loudly +// (R9.2). The proxy protects the credential's value, not its capability — +// grants are scoped by method and path prefix because a host-level allowlist +// still permits POST /gists (R9.6/H7). +package proxy + +import ( + "fmt" + "net/url" + "strings" + + "github.com/flanksource/commons-db/types" +) + +// PlaceholderPrefix starts every placeholder the sandbox sees. +const PlaceholderPrefix = "captain-placeholder-" + +// HeaderGrant names one header that may carry a credential, and where its +// real value comes from. The value is a types.EnvVar (A4.1): static, or +// resolved from a secret store at request time — the proxy never learns +// anything about storage. +type HeaderGrant struct { + Name string `json:"name" yaml:"name"` + Value types.EnvVar `json:"valueFrom,omitempty" yaml:"valueFrom,omitempty"` +} + +// Grant is one destination the sandbox may reach: host, methods, path +// prefixes, and the headers that may carry credentials to it. Destinations +// are deny-by-default — no grant, no request (R9.3; the network layer must +// additionally block non-HTTP egress, which no proxy can see). +type Grant struct { + Name string `json:"name" yaml:"name"` // stable id; part of the placeholder + URL string `json:"url" yaml:"url"` // scheme://host[:port] + Methods []string `json:"methods,omitempty" yaml:"methods,omitempty"` + Paths []string `json:"paths,omitempty" yaml:"paths,omitempty"` + Headers []HeaderGrant `json:"headers,omitempty" yaml:"headers,omitempty"` +} + +// Placeholder returns the sandbox-visible stand-in for one granted header. +func (g Grant) Placeholder(header string) string { + return PlaceholderPrefix + g.Name + "-" + strings.ToLower(header) +} + +// Validate checks the grant's shape. +func (g Grant) Validate() error { + if g.Name == "" || strings.ContainsAny(g.Name, " /") { + return fmt.Errorf("grant name %q must be a bare identifier", g.Name) + } + u, err := url.Parse(g.URL) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("grant %s: url %q must be http(s)://host[:port]", g.Name, g.URL) + } + if u.Path != "" && u.Path != "/" { + return fmt.Errorf("grant %s: scope paths belong in paths, not the url", g.Name) + } + for _, h := range g.Headers { + if strings.TrimSpace(h.Name) == "" { + return fmt.Errorf("grant %s: header grant with no name", g.Name) + } + } + return nil +} + +func (g Grant) host() string { + u, err := url.Parse(g.URL) + if err != nil { + return "" + } + host := u.Host + if u.Port() == "" { + if u.Scheme == "https" { + host += ":443" + } else { + host += ":80" + } + } + return host +} + +func (g Grant) scheme() string { + u, err := url.Parse(g.URL) + if err != nil { + return "https" + } + return u.Scheme +} + +// AllowsMethod reports whether the grant permits the method; an empty list +// permits none — scope is mandatory, not advisory (R9.6). +func (g Grant) AllowsMethod(method string) bool { + for _, m := range g.Methods { + if strings.EqualFold(m, method) { + return true + } + } + return false +} + +// AllowsPath reports whether the path lies under a granted prefix. +func (g Grant) AllowsPath(path string) bool { + for _, p := range g.Paths { + if strings.HasPrefix(path, p) { + return true + } + } + return false +} + +// grantedHeader returns the header grant for name, if any. +func (g Grant) grantedHeader(name string) (HeaderGrant, bool) { + for _, h := range g.Headers { + if strings.EqualFold(h.Name, name) { + return h, true + } + } + return HeaderGrant{}, false +} + +// SandboxEnv renders the placeholder environment for a set of grants: one +// _
entry per granted header, all placeholders, no values. +func SandboxEnv(grants []Grant) []string { + var env []string + for _, g := range grants { + for _, h := range g.Headers { + key := strings.ToUpper(strings.ReplaceAll(g.Name+"_"+h.Name, "-", "_")) + env = append(env, key+"="+g.Placeholder(h.Name)) + } + } + return env +} + +// Resolver turns a header grant's EnvVar into the real value at request +// time. StaticResolver handles inline values; richer resolvers plug in +// secret stores. +type Resolver func(types.EnvVar) (string, error) + +// StaticResolver resolves only inline `value:` credentials, failing loudly on +// anything it cannot resolve — the placeholder is never forwarded (R9.4). +func StaticResolver(v types.EnvVar) (string, error) { + if v.ValueStatic != "" { + return v.ValueStatic, nil + } + return "", fmt.Errorf("credential is not statically resolvable (valueFrom requires a store-backed resolver)") +} diff --git a/pkg/gitagent/proxy/proxy.go b/pkg/gitagent/proxy/proxy.go new file mode 100644 index 00000000..414e7469 --- /dev/null +++ b/pkg/gitagent/proxy/proxy.go @@ -0,0 +1,221 @@ +package proxy + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +// maxScannedBody bounds how much request body is scanned for misplaced +// placeholders. Response bodies are deliberately not scanned: response-body +// redaction is telemetry, not a control (§9 non-goal). +const maxScannedBody = 1 << 20 + +// Decision is one audit record. Values are never logged (R9.5). +type Decision struct { + Time time.Time + Method string + Destination string + Header string // the substituted or offending header, when relevant + Verdict string // forwarded | rejected | error + Reason string +} + +// Proxy is the egress credential proxy: an HTTP forward proxy that +// substitutes placeholders in exactly the granted position and rejects them +// anywhere else. CONNECT is refused — tunneled TLS would blind the proxy, and +// the sandbox has no legitimate need to hide its requests from it. +type Proxy struct { + Grants []Grant + Resolve Resolver + // Audit receives every decision; nil discards. Never receives a value. + Audit func(Decision) + // Dialer resolves and dials upstream. The proxy resolves DNS itself and + // the TLS layer validates the certificate against the granted host name — + // never the sandbox-controlled Host header or SNI (R9.1). + Dialer *net.Dialer +} + +func (p *Proxy) audit(d Decision) { + d.Time = time.Now() + if p.Audit != nil { + p.Audit(d) + } +} + +func (p *Proxy) resolve(v HeaderGrant) (string, error) { + resolver := p.Resolve + if resolver == nil { + resolver = StaticResolver + } + return resolver(v.Value) +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + p.audit(Decision{Method: r.Method, Destination: r.Host, Verdict: "rejected", Reason: "CONNECT is not served; requests must be inspectable"}) + http.Error(w, "captain-proxy: CONNECT is not served", http.StatusForbidden) + return + } + if !r.URL.IsAbs() { + http.Error(w, "captain-proxy: absolute-form proxy requests only", http.StatusBadRequest) + return + } + grant, ok := p.grantFor(r.URL.Hostname(), r.URL.Port(), r.URL.Scheme) + if !ok { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "destination not granted (deny by default)"}) + http.Error(w, "captain-proxy: destination not granted", http.StatusForbidden) + return + } + if reason := p.findMisplacedPlaceholder(r, grant); reason != "" { + // Rejected and logged, never stripped-and-forwarded: the appearance + // is an exfiltration attempt and silently continuing hides it (R9.2). + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: reason}) + http.Error(w, "captain-proxy: "+reason, http.StatusForbidden) + return + } + if !grant.AllowsMethod(r.Method) || !grant.AllowsPath(r.URL.Path) { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "method or path outside the grant's scope (R9.6)"}) + http.Error(w, "captain-proxy: method or path outside the grant's scope", http.StatusForbidden) + return + } + substituted, err := p.substitute(r, grant) + if err != nil { + // A credential that fails to resolve fails the request loudly; the + // placeholder is never forwarded upstream (R9.4). + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "error", Reason: err.Error()}) + http.Error(w, "captain-proxy: credential resolution failed", http.StatusBadGateway) + return + } + p.forward(w, r, grant, substituted) +} + +// grantFor matches a destination against the grant table. +func (p *Proxy) grantFor(host, port, scheme string) (Grant, bool) { + if port == "" { + if scheme == "https" { + port = "443" + } else { + port = "80" + } + } + for _, g := range p.Grants { + if g.host() == net.JoinHostPort(host, port) && g.scheme() == scheme { + return g, true + } + } + return Grant{}, false +} + +// findMisplacedPlaceholder scans headers, URL and a bounded body prefix for +// any placeholder that is not this grant's placeholder in this grant's +// header. It returns the rejection reason, or "". +func (p *Proxy) findMisplacedPlaceholder(r *http.Request, grant Grant) string { + if strings.Contains(r.URL.String(), PlaceholderPrefix) { + return "credential placeholder in the URL is an exfiltration attempt (R9.2)" + } + for name, values := range r.Header { + for _, value := range values { + if !strings.Contains(value, PlaceholderPrefix) { + continue + } + granted, ok := grant.grantedHeader(name) + if !ok || value != grant.Placeholder(granted.Name) { + return fmt.Sprintf("credential placeholder in header %s is not granted for this destination (R9.2)", name) + } + } + } + if r.Body != nil { + prefix := make([]byte, maxScannedBody) + n, _ := io.ReadFull(r.Body, prefix) + body := prefix[:n] + rest := r.Body + r.Body = readCloser{io.MultiReader(strings.NewReader(string(body)), rest), rest} + if strings.Contains(string(body), PlaceholderPrefix) { + return "credential placeholder in the request body is an exfiltration attempt (R9.2)" + } + } + return "" +} + +// substitute swaps each granted header's placeholder for its resolved value, +// returning the headers it substituted. +func (p *Proxy) substitute(r *http.Request, grant Grant) ([]string, error) { + var substituted []string + for _, h := range grant.Headers { + value := r.Header.Get(h.Name) + if value != grant.Placeholder(h.Name) { + continue // header absent or carrying the caller's own value + } + real, err := p.resolve(h) + if err != nil { + return nil, fmt.Errorf("header %s: %w", h.Name, err) + } + r.Header.Set(h.Name, real) + substituted = append(substituted, h.Name) + } + return substituted, nil +} + +// forward relays the request upstream, resolving DNS itself and letting TLS +// validate against the granted host name (R9.1). +func (p *Proxy) forward(w http.ResponseWriter, r *http.Request, grant Grant, substituted []string) { + dialer := p.Dialer + if dialer == nil { + dialer = &net.Dialer{Timeout: 30 * time.Second} + } + transport := &http.Transport{ + Proxy: nil, // never chain through environment proxies + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // addr is the granted host:port (the URL was matched against the + // grant). Resolve it ourselves rather than trusting anything the + // sandbox controls as identity. + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil || len(ips) == 0 { + return nil, fmt.Errorf("resolving %s: %w", host, err) + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0], port)) + }, + } + defer transport.CloseIdleConnections() + + out := r.Clone(r.Context()) + out.RequestURI = "" + out.URL.Scheme = grant.scheme() + out.URL.Host = strings.TrimSuffix(strings.TrimSuffix(grant.host(), ":443"), ":80") + out.Host = out.URL.Host + + resp, err := transport.RoundTrip(out) + if err != nil { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "error", Reason: "upstream: " + err.Error()}) + http.Error(w, "captain-proxy: upstream unreachable", http.StatusBadGateway) + return + } + defer resp.Body.Close() + for _, name := range substituted { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Header: name, Verdict: "forwarded", Reason: "placeholder substituted"}) + } + if len(substituted) == 0 { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "forwarded", Reason: "no credential involved"}) + } + for name, values := range resp.Header { + for _, v := range values { + w.Header().Add(name, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +type readCloser struct { + io.Reader + io.Closer +} diff --git a/pkg/gitagent/proxy/proxy_test.go b/pkg/gitagent/proxy/proxy_test.go new file mode 100644 index 00000000..4baaa4ab --- /dev/null +++ b/pkg/gitagent/proxy/proxy_test.go @@ -0,0 +1,243 @@ +package proxy + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "github.com/flanksource/commons-db/types" +) + +// world spins an upstream recording server, a proxy granting it, and a client +// routed through the proxy. +type world struct { + upstream *httptest.Server + proxy *httptest.Server + client *http.Client + grant Grant + mu sync.Mutex + requests []*http.Request + headers []http.Header + decisions []Decision +} + +func newWorld(t *testing.T, secret string) *world { + t.Helper() + w := &world{} + w.upstream = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + w.mu.Lock() + w.requests = append(w.requests, r.Clone(r.Context())) + w.headers = append(w.headers, r.Header.Clone()) + w.mu.Unlock() + rw.Header().Set("X-Upstream", "yes") + rw.WriteHeader(200) + _, _ = rw.Write([]byte("upstream says hi")) + })) + t.Cleanup(w.upstream.Close) + + w.grant = Grant{ + Name: "gh", + URL: w.upstream.URL, + Methods: []string{"GET", "POST"}, + Paths: []string{"/repos/acme/"}, + Headers: []HeaderGrant{{Name: "Authorization", Value: types.EnvVar{ValueStatic: secret}}}, + } + if err := w.grant.Validate(); err != nil { + t.Fatal(err) + } + p := &Proxy{ + Grants: []Grant{w.grant}, + Audit: func(d Decision) { + w.mu.Lock() + w.decisions = append(w.decisions, d) + w.mu.Unlock() + }, + } + w.proxy = httptest.NewServer(p) + t.Cleanup(w.proxy.Close) + + proxyURL, _ := url.Parse(w.proxy.URL) + w.client = &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} + return w +} + +func (w *world) do(t *testing.T, method, target string, headers map[string]string, body string) *http.Response { + t.Helper() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req, err := http.NewRequest(method, target, reader) + if err != nil { + t.Fatal(err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := w.client.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +func (w *world) upstreamHeaderValues(name string) []string { + w.mu.Lock() + defer w.mu.Unlock() + var values []string + for _, h := range w.headers { + values = append(values, h.Get(name)) + } + return values +} + +const secret = "ghp_real_secret_value" + +func TestSubstitutesOnlyInTheGrantedPosition(t *testing.T) { + w := newWorld(t, secret) + placeholder := w.grant.Placeholder("Authorization") + + resp := w.do(t, "GET", w.upstream.URL+"/repos/acme/captain", map[string]string{"Authorization": placeholder}, "") + if resp.StatusCode != 200 { + t.Fatalf("status = %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "upstream says hi" { + t.Fatalf("body = %q", body) + } + values := w.upstreamHeaderValues("Authorization") + if len(values) != 1 || values[0] != secret { + t.Fatalf("upstream saw Authorization = %v, want the real value", values) + } +} + +func TestPlaceholderInNonGrantedHeaderIsRejectedNotStripped(t *testing.T) { + w := newWorld(t, secret) + placeholder := w.grant.Placeholder("Authorization") + + resp := w.do(t, "GET", w.upstream.URL+"/repos/acme/captain", + map[string]string{"X-Exfil": placeholder}, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", resp.StatusCode) + } + if n := len(w.upstreamHeaderValues("X-Exfil")); n != 0 { + t.Fatalf("upstream was contacted %d times; a rejected request must never be forwarded", n) + } + found := false + for _, d := range w.decisions { + if d.Verdict == "rejected" && strings.Contains(d.Reason, "R9.2") { + found = true + if strings.Contains(d.Reason, secret) || strings.Contains(d.Reason, placeholder) { + t.Fatal("the audit log must not carry values") + } + } + } + if !found { + t.Fatal("the rejection must be logged (R9.5)") + } +} + +func TestPlaceholderInBodyOrURLIsRejected(t *testing.T) { + w := newWorld(t, secret) + placeholder := w.grant.Placeholder("Authorization") + + resp := w.do(t, "POST", w.upstream.URL+"/repos/acme/captain", nil, `{"note":"`+placeholder+`"}`) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("body placeholder: status = %d, want 403", resp.StatusCode) + } + resp = w.do(t, "GET", w.upstream.URL+"/repos/acme/captain?x="+placeholder, nil, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("url placeholder: status = %d, want 403", resp.StatusCode) + } + if len(w.requests) != 0 { + t.Fatal("nothing may reach upstream") + } +} + +func TestNonGrantedDestinationIsDenied(t *testing.T) { + w := newWorld(t, secret) + other := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(200) + })) + defer other.Close() + + // Including with the placeholder: granted for host A, sent to host B. + resp := w.do(t, "GET", other.URL+"/anything", + map[string]string{"Authorization": w.grant.Placeholder("Authorization")}, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (deny by default)", resp.StatusCode) + } +} + +func TestScopeByMethodAndPath(t *testing.T) { + w := newWorld(t, secret) + resp := w.do(t, "DELETE", w.upstream.URL+"/repos/acme/captain", nil, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("method outside scope: status = %d, want 403 (R9.6)", resp.StatusCode) + } + resp = w.do(t, "POST", w.upstream.URL+"/gists", nil, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("path outside scope: status = %d, want 403 (R9.6/H7)", resp.StatusCode) + } +} + +func TestUnresolvableCredentialFailsTheRequest(t *testing.T) { + w := newWorld(t, secret) + w.grant.Headers[0].Value = types.EnvVar{} // nothing to resolve + p := &Proxy{Grants: []Grant{w.grant}} + broken := httptest.NewServer(p) + defer broken.Close() + proxyURL, _ := url.Parse(broken.URL) + client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} + + req, _ := http.NewRequest("GET", w.upstream.URL+"/repos/acme/captain", nil) + req.Header.Set("Authorization", w.grant.Placeholder("Authorization")) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status = %d, want 502 (R9.4)", resp.StatusCode) + } + for _, v := range w.upstreamHeaderValues("Authorization") { + if strings.Contains(v, PlaceholderPrefix) { + t.Fatal("the placeholder must never be forwarded upstream (R9.4)") + } + } +} + +func TestConnectIsRefused(t *testing.T) { + w := newWorld(t, secret) + req, _ := http.NewRequest(http.MethodConnect, w.proxy.URL, nil) + req.URL.Opaque = "example.com:443" + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("CONNECT: status = %d, want 403", resp.StatusCode) + } +} + +func TestSandboxEnvHoldsOnlyPlaceholders(t *testing.T) { + w := newWorld(t, secret) + env := SandboxEnv([]Grant{w.grant}) + if len(env) != 1 { + t.Fatalf("env = %v", env) + } + if env[0] != "GH_AUTHORIZATION="+w.grant.Placeholder("Authorization") { + t.Fatalf("env = %v", env) + } + for _, kv := range env { + if strings.Contains(kv, secret) { + t.Fatal("the real secret must be absent from the sandbox environment") + } + } +} diff --git a/pkg/sandbox/tokens.go b/pkg/sandbox/tokens.go index 687a6ac2..b63e989d 100644 --- a/pkg/sandbox/tokens.go +++ b/pkg/sandbox/tokens.go @@ -51,6 +51,26 @@ type TokenResult struct { EnvVars map[string]string WritePaths []string Expiry time.Time + // Placeholder, when set, is what the sandbox environment carries instead + // of the real values: an egress proxy (pkg/gitagent/proxy) substitutes it + // at the granted header on the way out, so the credential never enters + // the sandbox (issue #39 §6.2, SPEC-git-agent-protocol §9). + Placeholder string +} + +// PlaceholderEnv returns EnvVars with every value replaced by the +// placeholder, for building a sandbox environment that holds no credential. +// It returns nil when no placeholder is set — the caller must not fall back +// to real values by accident. +func (t TokenResult) PlaceholderEnv() map[string]string { + if t.Placeholder == "" { + return nil + } + env := make(map[string]string, len(t.EnvVars)) + for k := range t.EnvVars { + env[k] = t.Placeholder + } + return env } type TokenManager struct { diff --git a/pkg/sandbox/tokens_test.go b/pkg/sandbox/tokens_test.go new file mode 100644 index 00000000..29d19e15 --- /dev/null +++ b/pkg/sandbox/tokens_test.go @@ -0,0 +1,25 @@ +package sandbox + +import "testing" + +func TestPlaceholderEnvNeverLeaksValues(t *testing.T) { + result := TokenResult{ + Provider: "github", + EnvVars: map[string]string{"GITHUB_TOKEN": "ghp_real", "GH_TOKEN": "ghp_real"}, + Placeholder: "captain-placeholder-gh-authorization", + } + env := result.PlaceholderEnv() + if len(env) != 2 { + t.Fatalf("env = %v", env) + } + for k, v := range env { + if v != result.Placeholder { + t.Fatalf("%s = %q, want the placeholder", k, v) + } + } + + // Without a placeholder there is no safe environment to hand out. + if env := (TokenResult{EnvVars: map[string]string{"X": "y"}}).PlaceholderEnv(); env != nil { + t.Fatalf("env = %v, want nil so callers cannot fall back to real values", env) + } +} From 0a693735023b37e7d8dc6adecf083c29f4e9019b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:25:27 +0000 Subject: [PATCH 08/22] fix(gitagent): make the advertised cycle work through the public CLI Testing found the protocol sound but the product unusable: enrollment produced a topology no dispatch could use. The conformance suite could not see it because it wires its topology programmatically, so this adds end-to-end coverage that drives the compiled binary with a real supervisor and agent in separate processes and separate homes. Enrollment is now bidirectional, because trust is. The agent sends its endpoint and host key; the supervisor records both alongside the key (dispatch had nothing to connect to), and hands back its dispatch-key fingerprint and mailbox path. The agent authorizes that key so the supervisor's push is accepted (it previously was not) and composes a relay URL that carries a repository path (it previously did not). When no endpoint is advertised the supervisor derives one from the connection's source address and the agent's listen port, with --advertise as the override for NAT. serve gains the mailbox half: --role mailbox --repo creates the mailbox where dispatch writes, shares the real repository's objects, installs mailbox-role hooks and records the integration target. Previously only a sidecar repository was ever created, so a relayed result met a receiver that rejects result refs by construction. Two defects sat behind those. The GIT_SSH_COMMAND client mistook git's own "-o SendEnv=..." option for the hostname, failing every dispatch and relay push with a DNS lookup of the option itself. And the receive hooks resolved their configuration through the home directory, which for a co-located agent belongs to whoever pushed rather than to the receiver, so they silently loaded no hook sets and no relay target. Hook shims now carry their config path explicitly, and captainconfig grows SetPath for processes that cannot trust an ambient home. Also: the git-agent group prints its own help and setup steps instead of inheriting the parent's, list emits an empty array rather than null for an empty roster, revoke returns a result document rather than prose followed by null, and an enrollment carrying no reachable endpoint is refused at enrollment time, where it is actionable, instead of at dispatch time. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- cmd/captain/main.go | 9 + pkg/captainconfig/config.go | 12 +- pkg/cli/ai_sandbox_remote_test.go | 58 +++ pkg/cli/gitagent.go | 105 +++++- pkg/cli/gitagent_directory.go | 23 +- pkg/cli/gitagent_e2e_test.go | 544 +++++++++++++++++++++++++++ pkg/cli/gitagent_hook.go | 5 + pkg/cli/gitagent_serve.go | 206 +++++++--- pkg/cli/gitagent_test.go | 5 +- pkg/cli/srt.go | 4 +- pkg/gitagent/enroll.go | 124 +++++- pkg/gitagent/hookshim.go | 19 +- pkg/gitagent/receiver_ginkgo_test.go | 11 +- pkg/gitagent/server.go | 84 ++++- pkg/gitagent/server_ginkgo_test.go | 53 ++- pkg/gitagent/sshclient.go | 51 ++- pkg/gitagent/sshclient_test.go | 65 ++++ pkg/sandbox/adapter/gitagent.go | 23 +- 18 files changed, 1280 insertions(+), 121 deletions(-) create mode 100644 pkg/cli/ai_sandbox_remote_test.go create mode 100644 pkg/cli/gitagent_e2e_test.go create mode 100644 pkg/gitagent/sshclient_test.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 617db0a4..5b25251e 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -128,6 +128,15 @@ func main() { Use: "git-agent", Short: "Enroll and serve remote git-agent sandboxes (SPEC-git-agent-protocol)", } + // Without its own help func this inherits the parent's, which prints the + // sandbox generate/presets help and makes this group undiscoverable. + gitAgentCmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + if c == gitAgentCmd { + fmt.Fprint(os.Stderr, cli.GitAgentHelp().ANSI()) + return + } + fmt.Fprint(os.Stderr, c.UsageString()) + }) sandboxCmd.AddCommand(gitAgentCmd) clicky.AddNamedCommand("add", gitAgentCmd, cli.GitAgentAddOptions{}, cli.RunGitAgentAdd).Short = "Enroll a new agent: mint a join token and print the join command" clicky.AddNamedCommand("list", gitAgentCmd, cli.GitAgentListOptions{}, cli.RunGitAgentList).Short = "List enrolled agents and pending enrollments" diff --git a/pkg/captainconfig/config.go b/pkg/captainconfig/config.go index e9eedfd1..8b8b6020 100644 --- a/pkg/captainconfig/config.go +++ b/pkg/captainconfig/config.go @@ -236,13 +236,19 @@ type SchemaRepairDefaults struct { Prompt string `yaml:"prompt,omitempty"` } -// pathOverride lets tests redirect Path() to a temp directory without touching -// $HOME. Empty string means "use os.UserHomeDir". +// pathOverride redirects Path() away from $HOME. Empty string means +// "use os.UserHomeDir". var pathOverride string +// SetPath redirects Path() to an explicit config file. It exists for +// processes that cannot rely on $HOME: a git receive hook runs as a child of +// whoever pushed, so its ambient home is the pusher's, not the one the +// receiver was configured under. +func SetPath(p string) { pathOverride = p } + // SetPathForTesting redirects Path() to the given absolute file path. Tests // must call it with t.Cleanup(func() { SetPathForTesting("") }). -func SetPathForTesting(p string) { pathOverride = p } +func SetPathForTesting(p string) { SetPath(p) } // Path returns the absolute path to the captain config file. Currently fixed // at ~/.captain.yaml; the location is intentionally not configurable via env diff --git a/pkg/cli/ai_sandbox_remote_test.go b/pkg/cli/ai_sandbox_remote_test.go new file mode 100644 index 00000000..f75fc011 --- /dev/null +++ b/pkg/cli/ai_sandbox_remote_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" +) + +// A remote-executing selection must replace provider execution. Without this +// the run silently falls through to the model provider and the sandbox is a +// no-op — which is indistinguishable from success until a dispatch never +// arrives. +func TestRemoteExecProviderForRoutesGitAgent(t *testing.T) { + req := &ai.Request{} + cfg := ai.Config{ + SandboxSelection: &api.SandboxConfig{ + Kind: registry.SandboxGitAgent, + Name: "git-agent", + Options: map[string]any{ + "agents": map[string]any{ + "worker-01": map[string]any{ + "fingerprint": "SHA256:key", + "url": "ssh://captain@127.0.0.1:7502/repo.git", + "hostFingerprint": "SHA256:host", + }, + }, + }, + }, + } + provider, err := remoteExecProviderFor(req, cfg) + if err != nil { + t.Fatalf("remoteExecProviderFor: %v", err) + } + if provider == nil { + t.Fatal("a git-agent selection must produce a remote-executing provider, not fall through to the model") + } + if _, isRemote := provider.(*remoteExecProvider); !isRemote { + t.Fatalf("provider = %T, want *remoteExecProvider", provider) + } +} + +func TestRemoteExecProviderForLeavesLocalSandboxesAlone(t *testing.T) { + for _, selection := range []*api.SandboxConfig{ + nil, + {Kind: registry.SandboxSRT}, + {Kind: registry.SandboxContainer}, + } { + provider, err := remoteExecProviderFor(&ai.Request{}, ai.Config{SandboxSelection: selection}) + if err != nil { + t.Fatalf("selection %v: %v", selection, err) + } + if provider != nil { + t.Fatalf("selection %v produced %T; only remote-exec kinds replace the provider", selection, provider) + } + } +} diff --git a/pkg/cli/gitagent.go b/pkg/cli/gitagent.go index 026932a1..ecf873b1 100644 --- a/pkg/cli/gitagent.go +++ b/pkg/cli/gitagent.go @@ -11,6 +11,7 @@ import ( "github.com/flanksource/captain/pkg/captainconfig" "github.com/flanksource/captain/pkg/gitagent" "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" ) // gitAgentKeysDir anchors key material beside the config file: with the @@ -24,6 +25,61 @@ func gitAgentKeysDir() (string, error) { return filepath.Join(filepath.Dir(path), ".captain", "sandbox"), nil } +// The fixed layout every git-agent host uses, so enrollment and dispatch agree +// on where key material and repositories live without configuration. +const ( + hostKeyName = "host_ed25519" // this endpoint's SSH host key + dispatchKeyName = "supervisor_ed25519" // the supervisor's client key + agentKeyName = "agent_ed25519" // the agent's client key + servedReposDir = "repos" // served root, under the keys dir + MailboxRepoName = "mailbox.git" // the supervisor's mailbox, under the root + SidecarRepoName = "repo.git" // the agent's sidecar repo, under the root + supervisorAgentID = "supervisor" // the supervisor's identity on a sidecar +) + +func gitAgentServedRoot() (string, error) { + keysDir, err := gitAgentKeysDir() + if err != nil { + return "", err + } + return filepath.Join(keysDir, servedReposDir), nil +} + +// gitAgentMailboxPath is where dispatch writes and the supervisor's endpoint +// serves. Keeping them the same path is what makes a relay reachable. +func gitAgentMailboxPath() (string, error) { + root, err := gitAgentServedRoot() + if err != nil { + return "", err + } + return filepath.Join(root, MailboxRepoName), nil +} + +// GitAgentHelp documents the group and the two-host setup, because the order +// of the steps is the part that is not guessable from the flags. +func GitAgentHelp() api.Textable { + return clicky.Text("Remote git-agent sandboxes", "font-bold text-blue-400").NewLine().NewLine(). + AddText("A supervisor dispatches work to a coding agent on another machine; the agent", "text-gray-400").NewLine(). + AddText("runs only `git commit` and `git push`. Work is vetted at both ends before it", "text-gray-400").NewLine(). + AddText("is integrated.", "text-gray-400").NewLine().NewLine(). + AddText("Commands:", "font-bold text-blue-400").NewLine(). + AddText(" captain sandbox git-agent serve", "text-green-400"). + AddText(" — run a receive endpoint (this host)", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent add", "text-green-400"). + AddText(" — enroll an agent, print its join command", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent list", "text-green-400"). + AddText(" — enrolled agents and pending enrollments", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent revoke", "text-green-400"). + AddText(" — refuse an agent's key from now on", "text-gray-500").NewLine().NewLine(). + AddText("Setting up (supervisor first, then the agent host):", "font-bold text-blue-400").NewLine(). + AddText(" 1. supervisor: captain sandbox git-agent serve --role mailbox --repo /path/to/repo", "text-green-400").NewLine(). + AddText(" 2. supervisor: captain sandbox git-agent add worker-01 --endpoint ssh://:7422", "text-green-400").NewLine(). + AddText(" 3. agent host: run the printed join command (it enrolls, then serves)", "text-green-400").NewLine(). + AddText(" 4. supervisor: captain ai prompt ./task.prompt --sandbox git-agent", "text-green-400").NewLine().NewLine(). + AddText("Step 3 establishes trust in both directions: the supervisor learns the agent's", "text-gray-400").NewLine(). + AddText("endpoint and host key, and the agent authorizes the supervisor's dispatch key.", "text-gray-400").NewLine() +} + type GitAgentAddOptions struct { Name string `args:"true" help:"Name for the agent being enrolled"` Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` @@ -38,6 +94,7 @@ type GitAgentAddResult struct { Agent string `json:"agent" pretty:"label=Agent"` Expires time.Time `json:"expires" pretty:"label=Token expires"` HostFingerprint string `json:"hostFingerprint" pretty:"label=Host key"` + DispatchKey string `json:"dispatchKey" pretty:"label=Dispatch key"` JoinCommand string `json:"joinCommand" pretty:"label=Join command"` } @@ -49,13 +106,15 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { if err != nil { return nil, err } - hostKeyPath := filepath.Join(keysDir, "host_ed25519") + hostKeyPath := filepath.Join(keysDir, hostKeyName) + dispatchKeyPath := filepath.Join(keysDir, dispatchKeyName) endpoint := opts.Endpoint if endpoint == "" { endpoint = gitAgentBackendEndpoint(opts.Backend) } if opts.DryRun { clicky.Printf("[dry-run] would ensure host key at %s\n", hostKeyPath) + clicky.Printf("[dry-run] would ensure dispatch key at %s\n", dispatchKeyPath) clicky.Printf("[dry-run] would mint a single-use join token (TTL %s) for agent %q\n", gitagent.JoinTokenTTL, opts.Name) clicky.Printf("[dry-run] would record the pending enrollment under sandbox.backends.%s in %s\n", opts.Backend, configPathForDisplay()) clicky.Printf("[dry-run] would print the join command for endpoint %s\n", endpoint) @@ -65,6 +124,12 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { if err != nil { return nil, err } + // The dispatch key is what the agent must authorize for the supervisor's + // push to be accepted, so it has to exist before the join is offered. + _, dispatchFP, err := gitagent.EnsureKeyPair(dispatchKeyPath) + if err != nil { + return nil, err + } token, hash, err := gitagent.MintJoinToken() if err != nil { return nil, err @@ -81,6 +146,7 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { "expires": expires.Format(time.RFC3339), } backend.Options["pending"] = pending + backend.Options["dispatchKey"] = dispatchFP cfg.Sandbox.Backends[opts.Backend] = backend return nil }) @@ -95,6 +161,7 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { Agent: opts.Name, Expires: expires, HostFingerprint: hostFP, + DispatchKey: dispatchFP, JoinCommand: join, }, nil } @@ -106,32 +173,36 @@ type GitAgentListOptions struct { type GitAgentListEntry struct { Name string `json:"name" pretty:"label=Name"` Fingerprint string `json:"fingerprint,omitempty" pretty:"label=Fingerprint"` + URL string `json:"url,omitempty" pretty:"label=Endpoint"` AddedAt string `json:"addedAt,omitempty" pretty:"label=Added"` Status string `json:"status" pretty:"label=Status"` } +// RunGitAgentList always returns a slice — an empty roster renders as [] in +// JSON rather than null, so a consumer can iterate it unconditionally. func RunGitAgentList(opts GitAgentListOptions) (any, error) { + entries := []GitAgentListEntry{} cfg, _, err := captainconfig.Load() if err != nil { return nil, err } backend, ok := cfg.Sandbox.Backends[opts.Backend] if !ok { - return []GitAgentListEntry{}, nil + return entries, nil } - var entries []GitAgentListEntry agents, _ := backend.Options["agents"].(map[string]any) - for name, v := range agents { + for _, name := range sortedKeys(agents) { entry := GitAgentListEntry{Name: name, Status: "enrolled"} - if m, ok := v.(map[string]any); ok { + if m, ok := agents[name].(map[string]any); ok { entry.Fingerprint, _ = m["fingerprint"].(string) + entry.URL, _ = m["url"].(string) entry.AddedAt, _ = m["addedAt"].(string) } entries = append(entries, entry) } pending, _ := backend.Options["pending"].(map[string]any) - for _, v := range pending { - if m, ok := v.(map[string]any); ok { + for _, hash := range sortedKeys(pending) { + if m, ok := pending[hash].(map[string]any); ok { name, _ := m["agent"].(string) expires, _ := m["expires"].(string) entries = append(entries, GitAgentListEntry{Name: name, Status: "pending until " + expires}) @@ -146,11 +217,21 @@ type GitAgentRevokeOptions struct { DryRun bool `flag:"dry-run" help:"Print the intended mutation without touching anything" short:"n"` } +// GitAgentRevokeResult reports the revocation, so --format json emits a +// single well-formed document rather than prose. +type GitAgentRevokeResult struct { + Backend string `json:"backend" pretty:"label=Backend"` + Agent string `json:"agent" pretty:"label=Agent"` + Fingerprint string `json:"fingerprint,omitempty" pretty:"label=Fingerprint"` + Revoked bool `json:"revoked" pretty:"label=Revoked"` + DryRun bool `json:"dryRun,omitempty" pretty:"label=Dry Run"` +} + func RunGitAgentRevoke(opts GitAgentRevokeOptions) (any, error) { if opts.DryRun { clicky.Printf("[dry-run] would remove agent %q from sandbox.backends.%s.agents in %s\n", opts.Name, opts.Backend, configPathForDisplay()) - return nil, nil + return GitAgentRevokeResult{Backend: opts.Backend, Agent: opts.Name, DryRun: true}, nil } var fingerprint string err := captainconfig.Update(func(cfg *captainconfig.Config) error { @@ -176,8 +257,12 @@ func RunGitAgentRevoke(opts GitAgentRevokeOptions) (any, error) { } // Effective for connections established after now (R8.5): the server // consults the config per handshake. - clicky.Printf("revoked %s (%s)\n", opts.Name, fingerprint) - return nil, nil + return GitAgentRevokeResult{ + Backend: opts.Backend, + Agent: opts.Name, + Fingerprint: fingerprint, + Revoked: true, + }, nil } func gitAgentBackendEndpoint(backend string) string { diff --git a/pkg/cli/gitagent_directory.go b/pkg/cli/gitagent_directory.go index 135f6264..ed027695 100644 --- a/pkg/cli/gitagent_directory.go +++ b/pkg/cli/gitagent_directory.go @@ -9,6 +9,9 @@ import ( "github.com/flanksource/captain/pkg/gitagent" ) +// gitAgentDirectory satisfies the server's authorization source. +var _ gitagent.AgentDirectory = gitAgentDirectory{} + // gitAgentDirectory implements gitagent.AgentDirectory over the sandbox // backend's options block in ~/.captain.yaml. Every read loads the file fresh // so a revocation takes effect for the next connection (R8.5), and every @@ -80,16 +83,28 @@ func (d gitAgentDirectory) ConsumeJoinToken(token string) (string, error) { return agentName, refusal } -func (d gitAgentDirectory) RecordAgentKey(name, fingerprint string) error { +// RecordAgent stores everything a dispatch to this agent needs: its client +// key, its endpoint, and the host key to pin when pushing there. Recording +// only the key would leave an enrollment that looks complete but cannot be +// dispatched to. +func (d gitAgentDirectory) RecordAgent(e gitagent.AgentEnrollment) error { + if e.URL == "" { + return fmt.Errorf("agent %q advertised no endpoint; rerun its serve with --advertise ssh://host:port", e.Name) + } + if e.HostFingerprint == "" { + return fmt.Errorf("agent %q advertised no host key fingerprint; its dispatch could not be verified", e.Name) + } return captainconfig.Update(func(cfg *captainconfig.Config) error { backend := ensureGitAgentBackend(cfg, d.backend) agents, _ := backend.Options["agents"].(map[string]any) if agents == nil { agents = map[string]any{} } - agents[name] = map[string]any{ - "fingerprint": fingerprint, - "addedAt": time.Now().UTC().Format(time.RFC3339), + agents[e.Name] = map[string]any{ + "fingerprint": e.Fingerprint, + "url": e.URL, + "hostFingerprint": e.HostFingerprint, + "addedAt": time.Now().UTC().Format(time.RFC3339), } backend.Options["agents"] = agents cfg.Sandbox.Backends[d.backend] = backend diff --git a/pkg/cli/gitagent_e2e_test.go b/pkg/cli/gitagent_e2e_test.go new file mode 100644 index 00000000..0a1d70d7 --- /dev/null +++ b/pkg/cli/gitagent_e2e_test.go @@ -0,0 +1,544 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// lockedBuffer collects a background process's output safely across the +// goroutine that waits on it. +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func netDial(addr string) (net.Conn, error) { + return net.DialTimeout("tcp", addr, 200*time.Millisecond) +} + +// freeLocalPort reserves a loopback port and releases it for immediate reuse. +func freeLocalPort(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + _, port, err := net.SplitHostPort(l.Addr().String()) + if err != nil { + t.Fatal(err) + } + return port +} + +// End-to-end coverage through the compiled binary, with each host in its own +// process and its own HOME. It exists because the protocol conformance suite +// wires its topology programmatically, and because captainconfig's path is +// process-global: neither can see a broken enrollment, a missing endpoint +// record, or a server exposing the wrong role — each of which leaves a working +// protocol attached to a product that cannot complete a cycle. + +var ( + captainBuildOnce sync.Once + captainBinPath string + captainBuildErr error +) + +// captainBinary builds cmd/captain once per test binary. +func captainBinary(t *testing.T) string { + t.Helper() + captainBuildOnce.Do(func() { + dir, err := os.MkdirTemp("", "captain-e2e-bin-") + if err != nil { + captainBuildErr = err + return + } + out := filepath.Join(dir, "captain") + cmd := exec.Command("go", "build", "-o", out, "./cmd/captain") + cmd.Dir = moduleRoot(t) + if combined, err := cmd.CombinedOutput(); err != nil { + captainBuildErr = fmt.Errorf("go build: %w\n%s", err, combined) + return + } + captainBinPath = out + }) + if captainBuildErr != nil { + t.Fatal(captainBuildErr) + } + return captainBinPath +} + +// moduleRoot is the repository root relative to this package. +func moduleRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + return root +} + +// host is one machine in the topology: an isolated HOME and the binary. +type host struct { + t *testing.T + home string + bin string +} + +func newHost(t *testing.T) *host { + t.Helper() + return &host{t: t, home: t.TempDir(), bin: captainBinary(t)} +} + +func (h *host) env() []string { + return append(os.Environ(), "HOME="+h.home, "CAPTAIN_SESSION_DB_URL=off") +} + +// run executes a captain command to completion. +func (h *host) run(args ...string) (string, error) { + h.t.Helper() + cmd := exec.Command(h.bin, args...) + cmd.Env = h.env() + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (h *host) mustRun(args ...string) string { + h.t.Helper() + out, err := h.run(args...) + if err != nil { + h.t.Fatalf("captain %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + return out +} + +// serve starts a receive endpoint in the background and waits for it to +// accept, failing with the process's own output if it exits first. +func (h *host) serve(port string, args ...string) { + h.t.Helper() + full := append([]string{"sandbox", "git-agent", "serve", "--listen", "127.0.0.1:" + port}, args...) + cmd := exec.Command(h.bin, full...) + cmd.Env = h.env() + var out lockedBuffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Start(); err != nil { + h.t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + h.t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-done + }) + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if conn, err := netDial("127.0.0.1:" + port); err == nil { + conn.Close() + return + } + select { + case err := <-done: + h.t.Fatalf("serve exited before listening (%v):\n%s", err, out.String()) + case <-time.After(100 * time.Millisecond): + } + } + h.t.Fatalf("serve never began listening:\n%s", out.String()) +} + +func (h *host) configBytes() string { + h.t.Helper() + data, err := os.ReadFile(filepath.Join(h.home, ".captain.yaml")) + if err != nil { + h.t.Fatalf("reading %s: %v", filepath.Join(h.home, ".captain.yaml"), err) + } + return string(data) +} + +// gitIn runs git in dir with a pinned identity. +func gitIn(t *testing.T, dir string, args ...string) string { + t.Helper() + full := append([]string{ + "-c", "user.name=test", "-c", "user.email=test@localhost", + "-c", "init.defaultBranch=main", + }, args...) + cmd := exec.Command("git", full...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v:\n%s", args, out) + } + return strings.TrimSpace(string(out)) +} + +func writeAt(t *testing.T, dir, path, content string) { + t.Helper() + full := filepath.Join(dir, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// newRepo creates a seeded git repository. +func newRepo(t *testing.T) string { + t.Helper() + repo := filepath.Join(t.TempDir(), "project") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatal(err) + } + gitIn(t, repo, "init", "-q") + writeAt(t, repo, "pkg/main.go", "package main\n") + gitIn(t, repo, "add", "-A") + gitIn(t, repo, "commit", "-q", "-m", "base") + return repo +} + +// addResult is the JSON `add --format json` emits. +type addResult struct { + Agent string `json:"agent"` + HostFingerprint string `json:"hostFingerprint"` + DispatchKey string `json:"dispatchKey"` + JoinCommand string `json:"joinCommand"` +} + +// enrollPair brings up a supervisor and an agent using only public commands, +// exactly as the setup documentation instructs, and returns both hosts. +func enrollPair(t *testing.T) (supervisor, agent *host, repo, agentPort string, add addResult) { + t.Helper() + supervisor, agent = newHost(t), newHost(t) + repo = newRepo(t) + + supPort := freeLocalPort(t) + supervisor.serve(supPort, "--role", "mailbox", "--repo", repo) + + out := supervisor.mustRun("sandbox", "git-agent", "add", "worker-01", + "--endpoint", "ssh://127.0.0.1:"+supPort, "--format", "json") + if err := json.Unmarshal([]byte(firstJSONDocument(out)), &add); err != nil { + t.Fatalf("add --format json is not a JSON document: %v\n%s", err, out) + } + if add.JoinCommand == "" || add.DispatchKey == "" { + t.Fatalf("add did not report a join command and dispatch key: %+v", add) + } + + // Run the printed join command verbatim, only redirecting the listen + // address — an operator changes nothing else. + joinArgs := parseJoin(t, add.JoinCommand) + agentPort = freeLocalPort(t) + agent.serve(agentPort, joinArgs...) + return supervisor, agent, repo, agentPort, add +} + +// parseJoin turns the printed join command into serve arguments. +func parseJoin(t *testing.T, join string) []string { + t.Helper() + fields := strings.Fields(join) + idx := -1 + for i, f := range fields { + if f == "serve" { + idx = i + 1 + break + } + } + if idx < 0 { + t.Fatalf("join command does not invoke serve: %q", join) + } + args := fields[idx:] + for _, required := range []string{"--join", "--supervisor", "--host-fingerprint"} { + if !containsArg(args, required) { + t.Fatalf("join command lacks %s, so an operator cannot complete enrollment: %q", required, join) + } + } + return args +} + +func containsArg(args []string, name string) bool { + for _, a := range args { + if a == name { + return true + } + } + return false +} + +// TestEnrollmentProducesADispatchableTopology is the regression test for the +// setup blockers: after only `add` on the supervisor and the printed join +// command on the agent, dispatch must be possible with no hand-edited config. +func TestEnrollmentProducesADispatchableTopology(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary") + } + supervisor, agent, repo, agentPort, add := enrollPair(t) + + // The supervisor must hold a dispatchable record: key, endpoint and host + // key. Recording only the key is what made dispatch impossible. + listed := supervisor.mustRun("sandbox", "git-agent", "list", "--format", "json") + var entries []struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + URL string `json:"url"` + Status string `json:"status"` + } + if err := json.Unmarshal([]byte(firstJSONDocument(listed)), &entries); err != nil { + t.Fatalf("list --format json is not a JSON document: %v\n%s", err, listed) + } + var worker *struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + URL string `json:"url"` + Status string `json:"status"` + } + for i := range entries { + if entries[i].Name == "worker-01" && entries[i].Status == "enrolled" { + worker = &entries[i] + } + } + if worker == nil { + t.Fatalf("worker-01 is not enrolled: %s", listed) + } + if worker.Fingerprint == "" || worker.URL == "" { + t.Fatalf("enrollment recorded no key or endpoint: %+v", worker) + } + if !strings.Contains(worker.URL, agentPort) || !strings.HasSuffix(worker.URL, SidecarRepoName) { + t.Fatalf("recorded endpoint %q must address the agent's port and repository", worker.URL) + } + if !strings.Contains(supervisor.configBytes(), "hostFingerprint") { + t.Fatalf("the supervisor recorded no host key to pin when dispatching:\n%s", supervisor.configBytes()) + } + + // The agent must have authorized the supervisor's dispatch key, and + // recorded a relay URL carrying a repository path. + agentCfg := agent.configBytes() + if !strings.Contains(agentCfg, add.DispatchKey) { + t.Fatalf("the agent did not authorize the supervisor's dispatch key %s:\n%s", add.DispatchKey, agentCfg) + } + if !strings.Contains(agentCfg, MailboxRepoName) { + t.Fatalf("the agent's relay URL carries no repository path:\n%s", agentCfg) + } + + // The supervisor must serve a mailbox, at the path the relay names, with + // mailbox-role hooks and objects shared with the real repository. + mailbox := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir, MailboxRepoName) + if _, err := os.Stat(filepath.Join(mailbox, "HEAD")); err != nil { + t.Fatalf("no mailbox repository where the relay points: %v", err) + } + shim, err := os.ReadFile(filepath.Join(mailbox, "hooks", "pre-receive")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(shim), "mailbox") { + t.Fatalf("mailbox hooks run with the wrong role:\n%s", shim) + } + alternates, err := os.ReadFile(filepath.Join(mailbox, "objects", "info", "alternates")) + if err != nil { + t.Fatalf("the mailbox does not share the repository's objects: %v", err) + } + if !strings.Contains(string(alternates), repo) { + t.Fatalf("mailbox alternates %q do not point at %s", alternates, repo) + } +} + +// TestFullCycleThroughTheCLI is the §12 baseline check at product level: with +// nothing but the documented commands, a dispatch reaches the agent, the agent +// completes it with `git commit` and a bare `git push`, the result relays to +// the supervisor, and accepted work is integrated. Every setup blocker this +// suite exists for shows up here as a hang or a missing ref. +func TestFullCycleThroughTheCLI(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary and runs two endpoints") + } + supervisor, agent, repo, _, _ := enrollPair(t) + + // A dirty supervisor worktree must travel with the dispatch. + writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nAdd a greeting.\n") + writeAt(t, repo, "pkg/main.go", "package main\n\n// dirty\n") + + // Dispatch blocks until the verdict (relay=sync), so it runs alongside the + // agent's work. + dispatch := exec.Command(supervisor.bin, "ai", "prompt", "./task.prompt", "--sandbox", "git-agent") + dispatch.Dir = repo + dispatch.Env = supervisor.env() + var dispatchOut lockedBuffer + dispatch.Stdout, dispatch.Stderr = &dispatchOut, &dispatchOut + if err := dispatch.Start(); err != nil { + t.Fatal(err) + } + dispatchDone := make(chan error, 1) + go func() { dispatchDone <- dispatch.Wait() }() + t.Cleanup(func() { + if dispatch.Process != nil { + _ = dispatch.Process.Kill() + } + }) + + // The agent's workspace is an ordinary worktree with a branch and upstream. + tasksDir := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + var worktree, taskID string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) && worktree == "" { + entries, _ := os.ReadDir(tasksDir) + for _, e := range entries { + candidate := filepath.Join(tasksDir, e.Name(), "worktree") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + worktree, taskID = candidate, e.Name() + } + } + select { + case err := <-dispatchDone: + t.Fatalf("dispatch exited before creating a workspace (%v):\n%s", err, dispatchOut.String()) + case <-time.After(250 * time.Millisecond): + } + } + if worktree == "" { + t.Fatalf("no agent workspace appeared:\n%s", dispatchOut.String()) + } + if got := gitIn(t, worktree, "rev-parse", "--abbrev-ref", "@{u}"); !strings.HasSuffix(got, taskID) { + t.Fatalf("upstream = %q; a bare `git push` needs one (H17)", got) + } + // The supervisor's uncommitted edit travelled. + body, err := os.ReadFile(filepath.Join(worktree, "pkg", "main.go")) + if err != nil || !strings.Contains(string(body), "// dirty") { + t.Fatalf("the dispatch did not carry the dirty worktree: %q (%v)", body, err) + } + + // The agent uses ordinary git and nothing else. + writeAt(t, worktree, "pkg/main.go", string(body)+"\nfunc Greet() string { return \"hi\" }\n") + gitIn(t, worktree, "add", "-A") + gitIn(t, worktree, "commit", "-q", "-m", "add greeting") + push := exec.Command("git", "push") + push.Dir = worktree + pushOut, err := push.CombinedOutput() + if err != nil { + t.Fatalf("the agent's bare push failed: %v\n%s", err, pushOut) + } + if !strings.Contains(string(pushOut), "ACCEPTED") { + t.Fatalf("push was not accepted:\n%s", pushOut) + } + + select { + case err := <-dispatchDone: + if err != nil { + t.Fatalf("dispatch failed: %v\n%s", err, dispatchOut.String()) + } + case <-time.After(90 * time.Second): + t.Fatalf("dispatch never concluded after the push:\n%s", dispatchOut.String()) + } + if !strings.Contains(dispatchOut.String(), "accepted") { + t.Fatalf("dispatch did not report acceptance:\n%s", dispatchOut.String()) + } + + // The supervisor holds the result and verdict refs, and integrated the work + // onto a branch without touching the user's checkout. + mailbox := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir, MailboxRepoName) + refs := gitIn(t, mailbox, "for-each-ref", "--format=%(refname)") + for _, want := range []string{"/result/1", "/verdict/1"} { + if !strings.Contains(refs, taskID+want) { + t.Fatalf("mailbox is missing %s%s:\n%s", taskID, want, refs) + } + } + integrated := gitIn(t, repo, "show", "captain/"+taskID+":pkg/main.go") + if !strings.Contains(integrated, "func Greet()") || !strings.Contains(integrated, "// dirty") { + t.Fatalf("integration lost the agent's work or the dispatched state:\n%s", integrated) + } + if branch := gitIn(t, repo, "rev-parse", "--abbrev-ref", "HEAD"); strings.HasPrefix(branch, "captain/") { + t.Fatalf("integration switched the user's checkout to %s", branch) + } +} + +// TestJoinTokenIsSingleUse pins that the bidirectional exchange keeps the +// single-use property (R8.2). +func TestJoinTokenIsSingleUse(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary") + } + _, _, _, _, add := enrollPair(t) + + replay := newHost(t) + args := append([]string{"sandbox", "git-agent", "serve", "--listen", "127.0.0.1:" + freeLocalPort(t)}, + parseJoin(t, add.JoinCommand)...) + out, err := replay.run(args...) + if err == nil { + t.Fatalf("token replay must be refused:\n%s", out) + } + if !strings.Contains(out, "already used") { + t.Fatalf("replay refusal must name the cause:\n%s", out) + } +} + +// TestGitAgentHelpDocumentsItsOwnCommands covers the reported discoverability +// defect: the group inherited the parent's help and advertised the wrong +// commands. +func TestGitAgentHelpDocumentsItsOwnCommands(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary") + } + h := newHost(t) + out, _ := h.run("sandbox", "git-agent", "--help") + for _, want := range []string{"serve", "add", "list", "revoke"} { + if !strings.Contains(out, want) { + t.Fatalf("git-agent help does not mention %q:\n%s", want, out) + } + } + if strings.Contains(out, "sandbox-runtime presets") { + t.Fatalf("git-agent help is the parent's:\n%s", out) + } + for _, sub := range []string{"add", "serve", "list", "revoke"} { + out, _ := h.run("sandbox", "git-agent", sub, "--help") + if strings.Contains(out, "sandbox-runtime presets") { + t.Fatalf("git-agent %s --help is the parent's:\n%s", sub, out) + } + } +} + +// TestListEmitsAnArrayWhenEmpty covers the reported JSON defect. +func TestListEmitsAnArrayWhenEmpty(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary") + } + h := newHost(t) + out := h.mustRun("sandbox", "git-agent", "list", "--format", "json") + doc := firstJSONDocument(out) + if strings.TrimSpace(doc) != "[]" { + t.Fatalf("an empty roster must encode as [], got %q\n%s", doc, out) + } +} + +// firstJSONDocument extracts the JSON value from command output that may also +// carry log lines. +func firstJSONDocument(out string) string { + trimmed := strings.TrimSpace(out) + for _, open := range []string{"[", "{"} { + if i := strings.Index(trimmed, open); i >= 0 { + candidate := trimmed[i:] + var probe any + if json.Unmarshal([]byte(candidate), &probe) == nil { + return candidate + } + } + } + return trimmed +} diff --git a/pkg/cli/gitagent_hook.go b/pkg/cli/gitagent_hook.go index f1f1e965..a39a3d85 100644 --- a/pkg/cli/gitagent_hook.go +++ b/pkg/cli/gitagent_hook.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" @@ -18,11 +19,15 @@ type GitAgentHookOptions struct { Repo string `flag:"repo" help:"Receiving bare repository"` Role string `flag:"role" help:"Receiver role: sidecar or mailbox"` Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Config string `flag:"config" help:"Config file to read; hooks cannot rely on $HOME, which belongs to whoever pushed"` } // RunGitAgentHook is the shim entrypoint: admission, hook sets, relay and // integration, with the runtime assembled from the backend's config block. func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) { + if strings.TrimSpace(opts.Config) != "" { + captainconfig.SetPath(opts.Config) + } runtime, err := hookRuntimeFromConfig(opts.Backend) if err != nil { fmt.Fprintf(os.Stderr, "captain: %v\n", err) diff --git a/pkg/cli/gitagent_serve.go b/pkg/cli/gitagent_serve.go index 5d5ae408..55a7244c 100644 --- a/pkg/cli/gitagent_serve.go +++ b/pkg/cli/gitagent_serve.go @@ -3,8 +3,10 @@ package cli import ( "context" "fmt" + "net" "os" "path/filepath" + "strings" "github.com/flanksource/captain/pkg/captainconfig" "github.com/flanksource/captain/pkg/gitagent" @@ -15,7 +17,9 @@ type GitAgentServeOptions struct { Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` Listen string `flag:"listen" help:"Address to serve git-receive-pack on" default:":7422"` Root string `flag:"root" help:"Directory of receivable repos (default /repos)"` - Role string `flag:"role" help:"Receiver role: sidecar or mailbox" default:"sidecar"` + Role string `flag:"role" help:"Receiver role: sidecar (runs beside a coding agent) or mailbox (the supervisor's receiver)" default:"sidecar"` + Repo string `flag:"repo" help:"mailbox role: the real repository accepted work is integrated into"` + Advertise string `flag:"advertise" help:"sidecar role: ssh://host:port the supervisor should dispatch to (default: the address the supervisor sees)"` Join string `flag:"join" help:"Single-use join token printed by 'captain sandbox git-agent add'"` Supervisor string `flag:"supervisor" help:"ssh://host:port of the supervisor to enroll with"` HostFingerprint string `flag:"host-fingerprint" help:"Pinned SHA256 fingerprint of the supervisor's host key"` @@ -33,61 +37,50 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro if err != nil { return nil, err } - if opts.Join != "" { - if opts.Supervisor == "" { - return nil, fmt.Errorf("--join requires --supervisor ssh://host:port") - } - signer, fp, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, "agent_ed25519")) - if err != nil { - return nil, err - } - confirmation, err := gitagent.Enroll(ctx, opts.Supervisor, opts.Join, opts.HostFingerprint, signer) - if err != nil { + root := opts.Root + if root == "" { + if root, err = gitAgentServedRoot(); err != nil { return nil, err } - clicky.Printf("%s\n", confirmation) - clicky.Printf("this agent's key fingerprint: %s\n", fp) - // Record where the relay pushes go (A3.4: through the flocked Update). - err = captainconfig.Update(func(cfg *captainconfig.Config) error { - backend := ensureGitAgentBackend(cfg, opts.Backend) - backend.Options["supervisor"] = map[string]any{ - "url": opts.Supervisor, - "hostFingerprint": opts.HostFingerprint, - } - cfg.Sandbox.Backends[opts.Backend] = backend - return nil - }) - if err != nil { + } + if opts.Join != "" { + if err := joinSupervisor(ctx, opts, keysDir, root); err != nil { return nil, err } } - root := opts.Root - if root == "" { - root = filepath.Join(keysDir, "repos") - } if err := os.MkdirAll(root, 0o755); err != nil { return nil, err } // Reclaim worktrees orphaned by a crashed hook (R10.3). gitagent.PruneWorktrees(ctx, root) - if err := ensureServedRepos(ctx, root, role); err != nil { + if err := ensureServedRepos(ctx, root, role, opts); err != nil { + return nil, err + } + hostKey, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) + if err != nil { return nil, err } - hostKey, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, "host_ed25519")) + offer, err := enrollmentOffer(role, keysDir) if err != nil { return nil, err } server, err := gitagent.NewServer(gitagent.ServerConfig{ - Listen: opts.Listen, - Root: root, - Role: role, - HostKey: hostKey, - Directory: gitAgentDirectory{backend: opts.Backend}, + Listen: opts.Listen, + Root: root, + Role: role, + HostKey: hostKey, + Directory: gitAgentDirectory{backend: opts.Backend}, + Offer: offer, + AgentRepoPath: SidecarRepoName, }) if err != nil { return nil, err } - clicky.Printf("captain git-agent %s serving %s on %s (host key %s)\n", role, root, opts.Listen, hostFP) + clicky.Printf("captain git-agent %s serving %s on %s\n", role, root, opts.Listen) + clicky.Printf(" host key: %s\n", hostFP) + if role == gitagent.RoleMailbox { + clicky.Printf(" enroll an agent with: captain sandbox git-agent add --endpoint ssh://:\n") + } go func() { <-ctx.Done() _ = server.Close() @@ -98,12 +91,122 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro return nil, nil } -// ensureServedRepos creates the role's default repository and (re-)installs -// the hook shims on every repo under root, so an upgraded captain binary -// repoints the shims at itself. -func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverRole) error { - if role == gitagent.RoleSidecar { - if err := gitagent.InitSidecar(ctx, filepath.Join(root, "repo.git")); err != nil { +// enrollmentOffer is what this endpoint hands a joining agent. Only a mailbox +// has anything to offer: its dispatch key, so the agent can authorize the +// supervisor's push, and its mailbox path, so the agent can relay back. +func enrollmentOffer(role gitagent.ReceiverRole, keysDir string) (gitagent.EnrollmentOffer, error) { + if role != gitagent.RoleMailbox { + return gitagent.EnrollmentOffer{}, nil + } + _, dispatchFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, dispatchKeyName)) + if err != nil { + return gitagent.EnrollmentOffer{}, err + } + return gitagent.EnrollmentOffer{DispatchKey: dispatchFP, MailboxPath: MailboxRepoName}, nil +} + +// joinSupervisor performs the enrollment exchange and records both directions +// of trust: the supervisor's dispatch key is authorized locally so its push is +// accepted, and its mailbox URL is recorded so the relay knows where to go. +func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir, root string) error { + if opts.Supervisor == "" { + return fmt.Errorf("--join requires --supervisor ssh://host:port") + } + signer, fp, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, agentKeyName)) + if err != nil { + return err + } + // The supervisor must be able to verify this endpoint's host key when it + // dispatches, so the key has to exist before we advertise its fingerprint. + _, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) + if err != nil { + return err + } + _, port, err := net.SplitHostPort(opts.Listen) + if err != nil { + return fmt.Errorf("--listen %q must be [host]:port: %w", opts.Listen, err) + } + resp, err := gitagent.Enroll(ctx, opts.Supervisor, opts.Join, opts.HostFingerprint, signer, gitagent.EnrollRequest{ + AdvertiseURL: advertiseURL(opts.Advertise), + ListenPort: port, + HostFingerprint: hostFP, + }) + if err != nil { + return err + } + mailboxURL, err := gitagent.MailboxURL(opts.Supervisor, resp.MailboxPath) + if err != nil { + return err + } + err = captainconfig.Update(func(cfg *captainconfig.Config) error { + backend := ensureGitAgentBackend(cfg, opts.Backend) + // Where the relay pushes, and the host key to pin when it does. + backend.Options["supervisor"] = map[string]any{ + "url": mailboxURL, + "hostFingerprint": opts.HostFingerprint, + } + // Authorize the supervisor's dispatch key so its push is accepted + // here — the direction a one-way enrollment leaves broken. + agents, _ := backend.Options["agents"].(map[string]any) + if agents == nil { + agents = map[string]any{} + } + agents[supervisorAgentID] = map[string]any{"fingerprint": resp.DispatchKey} + backend.Options["agents"] = agents + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + }) + if err != nil { + return err + } + clicky.Printf("enrolled as %s\n", resp.Agent) + clicky.Printf(" this agent's key: %s\n", fp) + clicky.Printf(" this endpoint's host key: %s\n", hostFP) + clicky.Printf(" relays to: %s\n", mailboxURL) + clicky.Printf(" authorized supervisor key: %s\n", resp.DispatchKey) + return nil +} + +// advertiseURL normalizes an operator-supplied endpoint, appending the sidecar +// repository path when only a host:port was given. +func advertiseURL(raw string) string { + advertise := strings.TrimSpace(raw) + if advertise == "" { + return "" + } + if !strings.Contains(advertise, "://") { + advertise = "ssh://" + advertise + } + if trimmed := strings.TrimSuffix(advertise, "/"); !strings.Contains(strings.TrimPrefix(trimmed, "ssh://"), "/") { + advertise = trimmed + "/" + SidecarRepoName + } + return advertise +} + +// ensureServedRepos creates the role's repository and (re-)installs the hook +// shims on every repo under root, so an upgraded captain binary repoints the +// shims at itself. +func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverRole, opts GitAgentServeOptions) error { + switch role { + case gitagent.RoleSidecar: + if err := gitagent.InitSidecar(ctx, filepath.Join(root, SidecarRepoName)); err != nil { + return err + } + case gitagent.RoleMailbox: + repo := strings.TrimSpace(opts.Repo) + if repo == "" { + return fmt.Errorf("--role mailbox requires --repo : the repository accepted work is integrated into") + } + abs, err := filepath.Abs(repo) + if err != nil { + return err + } + // The mailbox must be the same path dispatch writes to, or a relayed + // result would land somewhere the supervisor never reads. + if err := gitagent.InitMailbox(ctx, filepath.Join(root, MailboxRepoName), abs); err != nil { + return err + } + if err := recordMailboxRepo(opts.Backend, abs); err != nil { return err } } @@ -111,6 +214,12 @@ func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverR if err != nil { return err } + // Bake the config path into the shims: a hook inherits the pusher's + // environment, and a co-located agent pushes from its own shell. + configPath, err := captainconfig.Path() + if err != nil { + return err + } entries, err := os.ReadDir(root) if err != nil { return err @@ -123,9 +232,20 @@ func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverR if _, err := os.Stat(filepath.Join(repo, "HEAD")); err != nil { continue } - if err := gitagent.InstallHookShims(repo, exe, role); err != nil { + if err := gitagent.InstallHookShims(repo, exe, configPath, role); err != nil { return err } } return nil } + +// recordMailboxRepo tells the mailbox's receive hooks which repository to +// integrate accepted work into. +func recordMailboxRepo(backendName, repo string) error { + return captainconfig.Update(func(cfg *captainconfig.Config) error { + backend := ensureGitAgentBackend(cfg, backendName) + backend.Options["repo"] = repo + cfg.Sandbox.Backends[backendName] = backend + return nil + }) +} diff --git a/pkg/cli/gitagent_test.go b/pkg/cli/gitagent_test.go index 8627f477..a9384faf 100644 --- a/pkg/cli/gitagent_test.go +++ b/pkg/cli/gitagent_test.go @@ -97,7 +97,10 @@ func TestGitAgentExpiredTokenRefused(t *testing.T) { func TestGitAgentEnrollListRevoke(t *testing.T) { isolatedConfig(t) dir := gitAgentDirectory{backend: "git-agent"} - if err := dir.RecordAgentKey("worker-1", "SHA256:abc"); err != nil { + if err := dir.RecordAgent(gitagent.AgentEnrollment{ + Name: "worker-1", Fingerprint: "SHA256:abc", + URL: "ssh://127.0.0.1:7422/repo.git", HostFingerprint: "SHA256:host", + }); err != nil { t.Fatal(err) } if name, ok := dir.AgentByFingerprint("SHA256:abc"); !ok || name != "worker-1" { diff --git a/pkg/cli/srt.go b/pkg/cli/srt.go index 498361a2..06ac4dc6 100644 --- a/pkg/cli/srt.go +++ b/pkg/cli/srt.go @@ -490,7 +490,9 @@ func mergeStringSlices(a, b []string) []string { return sortedKeys(seen) } -func sortedKeys(m map[string]bool) []string { +// sortedKeys is generic over the value type so set-shaped maps and decoded +// config maps share one helper. +func sortedKeys[V any](m map[string]V) []string { result := make([]string, 0, len(m)) for k := range m { result = append(result, k) diff --git a/pkg/gitagent/enroll.go b/pkg/gitagent/enroll.go index 90253e49..9cd67574 100644 --- a/pkg/gitagent/enroll.go +++ b/pkg/gitagent/enroll.go @@ -1,6 +1,11 @@ // Enrollment (§8): a single-use, short-TTL join token authorizes exactly one // key registration and is then burned. The private key never leaves the agent // host (R8.2). +// +// The exchange is bidirectional because trust is: the supervisor dispatches TO +// the sidecar and the sidecar relays TO the mailbox, so each side must learn +// the other's endpoint and authorize the other's key. A one-way enrollment +// leaves a topology that looks configured but cannot complete a cycle. package gitagent import ( @@ -9,6 +14,8 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "encoding/json" + "errors" "fmt" "net" "net/url" @@ -21,6 +28,49 @@ import ( // JoinTokenTTL bounds how long a minted token stays redeemable. const JoinTokenTTL = 15 * time.Minute +// EnrollRequest is what a joining agent tells the supervisor about itself. +type EnrollRequest struct { + // AdvertiseURL is the sidecar endpoint the supervisor should dispatch to. + // When empty the supervisor derives it from the connection's source + // address and ListenPort, which is right on a flat network and wrong + // behind NAT — hence the explicit override. + AdvertiseURL string `json:"advertiseUrl,omitempty"` + // ListenPort is the port the agent's own receive endpoint listens on. + ListenPort string `json:"listenPort,omitempty"` + // HostFingerprint is the agent endpoint's host key, for the supervisor to + // pin when it dispatches. + HostFingerprint string `json:"hostFingerprint"` +} + +// EnrollResponse is what the supervisor hands back so the agent can complete +// the reverse direction without manual configuration. +type EnrollResponse struct { + Agent string `json:"agent"` + // DispatchKey is the supervisor's client-key fingerprint. The agent + // authorizes it locally so the supervisor's dispatch push is accepted. + DispatchKey string `json:"dispatchKey"` + // MailboxPath is the mailbox repository's path under the supervisor's + // served root. The agent joins it onto the endpoint it already dialed, so + // the supervisor never has to know its own reachable hostname. + MailboxPath string `json:"mailboxPath"` +} + +// EnrollmentOffer is the supervisor-side half of the exchange, supplied to +// the server by whatever runs it. +type EnrollmentOffer struct { + DispatchKey string + MailboxPath string +} + +// AgentEnrollment is one recorded agent: its key, its endpoint, and the host +// key to pin when dispatching there. +type AgentEnrollment struct { + Name string + Fingerprint string + URL string + HostFingerprint string +} + // MintJoinToken returns a fresh token and its storage hash. Only the hash is // persisted, so a leaked config file does not leak redeemable tokens. func MintJoinToken() (token, hash string, err error) { @@ -38,16 +88,17 @@ func HashJoinToken(token string) string { return hex.EncodeToString(sum[:]) } -// Enroll dials the supervisor endpoint, presents the join token, and returns -// the server's confirmation line. The host key is verified against the -// fingerprint printed by `git-agent add` — never trusted on first use. -func Enroll(ctx context.Context, endpoint, token, hostFingerprint string, signer gossh.Signer) (string, error) { +// Enroll dials the supervisor endpoint, presents the join token along with +// this agent's endpoint details, and returns what the supervisor offered +// back. The host key is verified against the fingerprint printed by +// `git-agent add` — never trusted on first use. +func Enroll(ctx context.Context, endpoint, token, hostFingerprint string, signer gossh.Signer, req EnrollRequest) (*EnrollResponse, error) { if strings.TrimSpace(hostFingerprint) == "" { - return "", fmt.Errorf("enrollment requires the supervisor's host-key fingerprint (printed by `captain sandbox git-agent add`)") + return nil, fmt.Errorf("enrollment requires the supervisor's host-key fingerprint (printed by `captain sandbox git-agent add`)") } addr, user, err := splitSSHEndpoint(endpoint) if err != nil { - return "", err + return nil, err } config := &gossh.ClientConfig{ User: user, @@ -63,25 +114,67 @@ func Enroll(ctx context.Context, endpoint, token, hostFingerprint string, signer dialer := net.Dialer{} conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { - return "", err + return nil, err } c, chans, reqs, err := gossh.NewClientConn(conn, addr, config) if err != nil { conn.Close() - return "", err + return nil, err } client := gossh.NewClient(c, chans, reqs) defer client.Close() session, err := client.NewSession() if err != nil { - return "", err + return nil, err } defer session.Close() - out, err := session.CombinedOutput(EnrollCommand + " " + token) + payload, err := json.Marshal(req) + if err != nil { + return nil, err + } + command := EnrollCommand + " " + token + " " + base64.RawURLEncoding.EncodeToString(payload) + // stderr is captured separately: the server explains a refusal there, and + // folding it into stdout would corrupt the JSON response. + var refusal strings.Builder + session.Stderr = &refusal + out, err := session.Output(command) if err != nil { - return "", fmt.Errorf("enrollment refused: %s", strings.TrimSpace(string(out))) + return nil, fmt.Errorf("enrollment refused: %s", enrollFailureDetail(err, []byte(refusal.String()))) } - return strings.TrimSpace(string(out)), nil + var resp EnrollResponse + if err := json.Unmarshal(out, &resp); err != nil { + return nil, fmt.Errorf("unparseable enrollment response %q: %w", strings.TrimSpace(string(out)), err) + } + if resp.Agent == "" || resp.DispatchKey == "" { + return nil, fmt.Errorf("enrollment response is missing the agent name or the supervisor's dispatch key") + } + return &resp, nil +} + +// enrollFailureDetail prefers the server's own explanation over the bare exit +// status, so a refusal reads as its reason. +func enrollFailureDetail(err error, out []byte) string { + if detail := strings.TrimSpace(string(out)); detail != "" { + return detail + } + var exitErr *gossh.ExitError + if errors.As(err, &exitErr) && strings.TrimSpace(exitErr.Msg()) != "" { + return strings.TrimSpace(exitErr.Msg()) + } + return err.Error() +} + +// MailboxURL joins the supervisor endpoint the agent dialed with the mailbox +// path the supervisor offered. +func MailboxURL(endpoint, mailboxPath string) (string, error) { + if strings.TrimSpace(mailboxPath) == "" { + return "", fmt.Errorf("the supervisor offered no mailbox path") + } + addr, user, err := splitSSHEndpoint(endpoint) + if err != nil { + return "", err + } + return "ssh://" + user + "@" + addr + "/" + strings.TrimPrefix(mailboxPath, "/"), nil } // splitSSHEndpoint accepts ssh://[user@]host[:port] or host[:port]. @@ -98,8 +191,11 @@ func splitSSHEndpoint(endpoint string) (addr, user string, err error) { } target = u.Host } - if !strings.Contains(target, ":") { - target += ":22" + if at := strings.LastIndex(target, "@"); at >= 0 { + user, target = target[:at], target[at+1:] + } + if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { + target = net.JoinHostPort(target, "22") } return target, user, nil } diff --git a/pkg/gitagent/hookshim.go b/pkg/gitagent/hookshim.go index 2ebef296..02d608e4 100644 --- a/pkg/gitagent/hookshim.go +++ b/pkg/gitagent/hookshim.go @@ -14,7 +14,12 @@ const hookShimMarker = "# installed by captain sandbox git-agent" // stdin and environment flowing through untouched. Re-running is idempotent: // an identical shim is left alone, a stale one is rewritten, and a foreign // hook is refused rather than silently replaced. -func InstallHookShims(repoPath, captainBin string, role ReceiverRole) error { +// +// configPath is baked in rather than left to $HOME: a hook runs as a child of +// whoever pushed — for a co-located agent that is the agent's own shell — so +// an ambient home would silently load the wrong configuration and skip the +// hook sets and the relay entirely. +func InstallHookShims(repoPath, captainBin, configPath string, role ReceiverRole) error { bin, err := filepath.Abs(captainBin) if err != nil { return err @@ -23,11 +28,19 @@ func InstallHookShims(repoPath, captainBin string, role ReceiverRole) error { if err != nil { return err } + config := "" + if strings.TrimSpace(configPath) != "" { + abs, err := filepath.Abs(configPath) + if err != nil { + return err + } + config = fmt.Sprintf(" --config %q", abs) + } for _, hook := range []string{"pre-receive", "post-receive"} { shim := fmt.Sprintf(`#!/bin/sh %s -exec %q sandbox git-agent hook %s --repo %q --role %q -`, hookShimMarker, bin, hook, repo, string(role)) +exec %q sandbox git-agent hook %s --repo %q --role %q%s +`, hookShimMarker, bin, hook, repo, string(role), config) target := filepath.Join(repo, "hooks", hook) existing, err := os.ReadFile(target) switch { diff --git a/pkg/gitagent/receiver_ginkgo_test.go b/pkg/gitagent/receiver_ginkgo_test.go index cf9a304b..5007197f 100644 --- a/pkg/gitagent/receiver_ginkgo_test.go +++ b/pkg/gitagent/receiver_ginkgo_test.go @@ -58,8 +58,8 @@ var _ = Describe("hook shims", func() { repo := filepath.Join(GinkgoT().TempDir(), "repo.git") Expect(gitagent.InitSidecar(ctx, repo)).To(Succeed()) - Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", gitagent.RoleSidecar)).To(Succeed()) - Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", gitagent.RoleSidecar)).To(Succeed()) + Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar)).To(Succeed()) + Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar)).To(Succeed()) for _, hook := range []string{"pre-receive", "post-receive"} { path := filepath.Join(repo, "hooks", hook) @@ -70,10 +70,13 @@ var _ = Describe("hook shims", func() { Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("/usr/local/bin/captain")) Expect(string(content)).To(ContainSubstring("--role \"sidecar\"")) + // A hook runs as a child of whoever pushed, so its config path is + // baked in rather than resolved from an ambient $HOME. + Expect(string(content)).To(ContainSubstring("--config \"/home/agent/.captain.yaml\"")) } // A rebinned captain updates the shim in place. - Expect(gitagent.InstallHookShims(repo, "/opt/captain", gitagent.RoleSidecar)).To(Succeed()) + Expect(gitagent.InstallHookShims(repo, "/opt/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar)).To(Succeed()) content, err := os.ReadFile(filepath.Join(repo, "hooks", "pre-receive")) Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("/opt/captain")) @@ -81,7 +84,7 @@ var _ = Describe("hook shims", func() { // A hook captain did not install is never overwritten. foreign := filepath.Join(repo, "hooks", "pre-receive") Expect(os.WriteFile(foreign, []byte("#!/bin/sh\nexit 0\n"), 0o755)).To(Succeed()) - err = gitagent.InstallHookShims(repo, "/opt/captain", gitagent.RoleSidecar) + err = gitagent.InstallHookShims(repo, "/opt/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar) Expect(err).To(MatchError(ContainSubstring("not installed by captain"))) }) }) diff --git a/pkg/gitagent/server.go b/pkg/gitagent/server.go index 922bbcff..a57ee5c6 100644 --- a/pkg/gitagent/server.go +++ b/pkg/gitagent/server.go @@ -9,7 +9,10 @@ package gitagent import ( + "encoding/base64" + "encoding/json" "fmt" + "net" "os" "os/exec" "path/filepath" @@ -39,8 +42,9 @@ type AgentDirectory interface { // ConsumeJoinToken validates and burns a single-use join token, returning // the agent name it enrolls. ConsumeJoinToken(token string) (string, error) - // RecordAgentKey binds a fingerprint to an enrolled agent. - RecordAgentKey(name, fingerprint string) error + // RecordAgent binds a key, an endpoint and a host key to an enrolled + // agent — everything a dispatch to it needs. + RecordAgent(AgentEnrollment) error } // ServerConfig configures one receive endpoint. @@ -50,6 +54,13 @@ type ServerConfig struct { Role ReceiverRole HostKey gossh.Signer Directory AgentDirectory + // Offer is what this endpoint hands back to a joining agent so the agent + // can complete the reverse direction of trust. A mailbox that leaves it + // empty enrolls agents it can dispatch to but that cannot relay back. + Offer EnrollmentOffer + // AgentRepoPath is the repository path an enrolled agent serves, used to + // derive its dispatch URL when the agent advertises none. + AgentRepoPath string } // NewServer builds the SSH server. The caller owns ListenAndServe/Serve and @@ -98,9 +109,18 @@ func handleSession(s ssh.Session, root string, cfg ServerConfig) { } } +// handleEnroll completes both directions of the exchange: it records the +// agent's key and endpoint, and hands back the supervisor's dispatch key and +// mailbox path so the agent can authorize the reverse push. func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []string) { - if len(cmd) != 2 || strings.TrimSpace(cmd[1]) == "" { - fmt.Fprintln(s.Stderr(), "captain: usage: captain-enroll ") + if len(cmd) < 2 || strings.TrimSpace(cmd[1]) == "" { + fmt.Fprintln(s.Stderr(), "captain: usage: captain-enroll [request]") + _ = s.Exit(1) + return + } + req, err := decodeEnrollRequest(cmd) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: %v\n", err) _ = s.Exit(1) return } @@ -110,15 +130,67 @@ func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []str _ = s.Exit(1) return } - if err := cfg.Directory.RecordAgentKey(name, fingerprint); err != nil { + enrollment := AgentEnrollment{ + Name: name, + Fingerprint: fingerprint, + URL: agentDispatchURL(req, s.RemoteAddr(), cfg.AgentRepoPath), + HostFingerprint: strings.TrimSpace(req.HostFingerprint), + } + if err := cfg.Directory.RecordAgent(enrollment); err != nil { fmt.Fprintf(s.Stderr(), "captain: enrollment failed: %v\n", err) _ = s.Exit(1) return } - fmt.Fprintf(s, "enrolled %s %s\n", name, fingerprint) + resp, err := json.Marshal(EnrollResponse{ + Agent: name, + DispatchKey: cfg.Offer.DispatchKey, + MailboxPath: cfg.Offer.MailboxPath, + }) + if err != nil { + fmt.Fprintf(s.Stderr(), "captain: %v\n", err) + _ = s.Exit(1) + return + } + fmt.Fprintln(s, string(resp)) _ = s.Exit(0) } +func decodeEnrollRequest(cmd []string) (EnrollRequest, error) { + var req EnrollRequest + if len(cmd) < 3 || strings.TrimSpace(cmd[2]) == "" { + return req, nil // an older client sent no details; derive what we can + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(cmd[2])) + if err != nil { + return req, fmt.Errorf("unparseable enrollment request: %w", err) + } + if err := json.Unmarshal(raw, &req); err != nil { + return req, fmt.Errorf("unparseable enrollment request: %w", err) + } + return req, nil +} + +// agentDispatchURL resolves where the supervisor will dispatch to: the URL the +// agent advertised, or — on a flat network where the source address is the +// agent's real address — one derived from the connection plus its listen port. +func agentDispatchURL(req EnrollRequest, remote net.Addr, repoPath string) string { + if url := strings.TrimSpace(req.AdvertiseURL); url != "" { + return url + } + port := strings.TrimSpace(req.ListenPort) + if port == "" || remote == nil { + return "" + } + host, _, err := net.SplitHostPort(remote.String()) + if err != nil { + return "" + } + if repoPath == "" { + repoPath = "repo.git" + } + return "ssh://captain@" + net.JoinHostPort(host, port) + "/" + strings.TrimPrefix(repoPath, "/") +} + func handleGit(s ssh.Session, root string, cfg ServerConfig, fingerprint string, cmd []string) { repoArg, err := parseReceivePack(cmd) if err != nil { diff --git a/pkg/gitagent/server_ginkgo_test.go b/pkg/gitagent/server_ginkgo_test.go index 43e1ad75..aff12888 100644 --- a/pkg/gitagent/server_ginkgo_test.go +++ b/pkg/gitagent/server_ginkgo_test.go @@ -18,9 +18,10 @@ import ( // memoryDirectory is an in-process AgentDirectory for server tests. type memoryDirectory struct { - mu sync.Mutex - agents map[string]string // fingerprint → name - pending map[string]string // token hash → name + mu sync.Mutex + agents map[string]string // fingerprint → name + pending map[string]string // token hash → name + enrollments map[string]gitagent.AgentEnrollment } func (d *memoryDirectory) AgentByFingerprint(fp string) (string, bool) { @@ -41,22 +42,32 @@ func (d *memoryDirectory) ConsumeJoinToken(token string) (string, error) { return name, nil } -func (d *memoryDirectory) RecordAgentKey(name, fp string) error { +func (d *memoryDirectory) RecordAgent(e gitagent.AgentEnrollment) error { d.mu.Lock() defer d.mu.Unlock() - d.agents[fp] = name + d.agents[e.Fingerprint] = e.Name + if d.enrollments == nil { + d.enrollments = map[string]gitagent.AgentEnrollment{} + } + d.enrollments[e.Name] = e return nil } // startTestServer serves root on a loopback port and returns its address and // host fingerprint. func startTestServer(dir *memoryDirectory, root string, role gitagent.ReceiverRole) (addr, hostFP string) { + GinkgoHelper() + return startTestServerWithOffer(dir, root, role, gitagent.EnrollmentOffer{}) +} + +func startTestServerWithOffer(dir *memoryDirectory, root string, role gitagent.ReceiverRole, offer gitagent.EnrollmentOffer) (addr, hostFP string) { GinkgoHelper() keys := GinkgoT().TempDir() hostKey, fp, err := gitagent.EnsureKeyPair(filepath.Join(keys, "host_ed25519")) Expect(err).NotTo(HaveOccurred()) server, err := gitagent.NewServer(gitagent.ServerConfig{ Root: root, Role: role, HostKey: hostKey, Directory: dir, + Offer: offer, AgentRepoPath: "repo.git", }) Expect(err).NotTo(HaveOccurred()) listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -161,32 +172,46 @@ var _ = Describe("the git-agent SSH endpoint", func() { Expect(out).To(ContainSubstring("not served")) }) - It("enrolls through a single-use join token and refuses replay (R8.2)", func() { + It("enrolls both directions through a single-use join token and refuses replay (R8.2)", func() { root := GinkgoT().TempDir() dir := &memoryDirectory{agents: map[string]string{}, pending: map[string]string{}} token, hash, err := gitagent.MintJoinToken() Expect(err).NotTo(HaveOccurred()) dir.pending[hash] = "worker-2" - addr, hostFP := startTestServer(dir, root, gitagent.RoleMailbox) + addr, hostFP := startTestServerWithOffer(dir, root, gitagent.RoleMailbox, + gitagent.EnrollmentOffer{DispatchKey: "SHA256:dispatch", MailboxPath: "mailbox.git"}) signer, fp, _ := newClientKey() - confirmation, err := gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer) + request := gitagent.EnrollRequest{ListenPort: "7502", HostFingerprint: "SHA256:agenthost"} + resp, err := gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer, request) Expect(err).NotTo(HaveOccurred()) - Expect(confirmation).To(ContainSubstring("enrolled worker-2")) - name, ok := dir.AgentByFingerprint(fp) + Expect(resp.Agent).To(Equal("worker-2")) + // The reverse direction: what the agent needs to accept a dispatch and + // to reach the mailbox. + Expect(resp.DispatchKey).To(Equal("SHA256:dispatch")) + Expect(resp.MailboxPath).To(Equal("mailbox.git")) + + // The supervisor recorded an endpoint it can actually dispatch to. + enrolled, ok := dir.enrollments["worker-2"] Expect(ok).To(BeTrue()) - Expect(name).To(Equal("worker-2")) + Expect(enrolled.Fingerprint).To(Equal(fp)) + Expect(enrolled.HostFingerprint).To(Equal("SHA256:agenthost")) + Expect(enrolled.URL).To(ContainSubstring(":7502/repo.git")) + + mailboxURL, err := gitagent.MailboxURL("ssh://"+addr, resp.MailboxPath) + Expect(err).NotTo(HaveOccurred()) + Expect(mailboxURL).To(HaveSuffix("/mailbox.git")) // Replay fails: the token burned. - _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer) + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer, request) Expect(err).To(MatchError(ContainSubstring("already used"))) // A wrong host fingerprint is refused before the token is offered. - _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "SHA256:bogus", signer) + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "SHA256:bogus", signer, request) Expect(err).To(HaveOccurred()) // An empty fingerprint never trusts on first use. - _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "", signer) + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "", signer, request) Expect(err).To(MatchError(ContainSubstring("host-key fingerprint"))) }) diff --git a/pkg/gitagent/sshclient.go b/pkg/gitagent/sshclient.go index cd9ab140..7a35e0ff 100644 --- a/pkg/gitagent/sshclient.go +++ b/pkg/gitagent/sshclient.go @@ -90,29 +90,54 @@ func runSSHClient(args []string, stdin io.Reader, stdout, stderr io.Writer) (int return 0, nil } +// valueFlags are the ssh(1) options git may pass that consume the following +// argument. Anything else beginning with - is treated as a standalone switch, +// so an option we do not model cannot be mistaken for the hostname. +var valueFlags = map[string]bool{ + "-o": true, "-p": true, "-i": true, "-l": true, "-F": true, "-c": true, + "-m": true, "-b": true, "-e": true, "-w": true, "-J": true, "-S": true, + "-D": true, "-L": true, "-R": true, "-W": true, "-Q": true, "-B": true, + "-E": true, "-I": true, +} + +// parseSSHArgs implements git's ssh-command contract: +// `[options] [--] [user@]host command...`. git invokes it with options of its +// own — notably `-o SendEnv=GIT_PROTOCOL` — so options must be skipped +// generically rather than by an allowlist of the ones we care about. func parseSSHArgs(args []string) (host, port, command string, err error) { port = "22" i := 0 -loop: for i < len(args) { - switch args[i] { - case "-p": + arg := args[i] + if arg == "--" { + i++ + break + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + break + } + // Joined forms (-p2222, -oFoo=bar) carry their value inline. + if len(arg) > 2 && valueFlags[arg[:2]] { + if arg[:2] == "-p" { + port = arg[2:] + } + i++ + continue + } + if valueFlags[arg] { if i+1 >= len(args) { - return "", "", "", fmt.Errorf("-p needs a port argument") + return "", "", "", fmt.Errorf("%s needs an argument", arg) + } + if arg == "-p" { + port = args[i+1] } - port = args[i+1] i += 2 - case "-4", "-6": - i++ - case "--": - i++ - break loop - default: - break loop + continue } + i++ // a standalone switch such as -4, -6, -q, -T } if i >= len(args) { - return "", "", "", fmt.Errorf("usage: [-p port] host command...") + return "", "", "", fmt.Errorf("usage: [options] [user@]host command...") } host = args[i] command = strings.Join(args[i+1:], " ") diff --git a/pkg/gitagent/sshclient_test.go b/pkg/gitagent/sshclient_test.go new file mode 100644 index 00000000..8f031128 --- /dev/null +++ b/pkg/gitagent/sshclient_test.go @@ -0,0 +1,65 @@ +package gitagent + +import "testing" + +// git invokes GIT_SSH_COMMAND with options of its own — `-o SendEnv=…` in +// particular. Mistaking one for the hostname makes every dispatch and relay +// push fail with a DNS lookup of the option itself. +func TestParseSSHArgs(t *testing.T) { + cases := []struct { + name string + args []string + host string + port string + command string + wantFail bool + }{ + { + name: "git's own invocation", + args: []string{"-o", "SendEnv=GIT_PROTOCOL", "-p", "7502", "captain@127.0.0.1", "git-receive-pack", "'/repo.git'"}, + host: "captain@127.0.0.1", port: "7502", + command: "git-receive-pack '/repo.git'", + }, + { + name: "no options", + args: []string{"host", "git-receive-pack", "'r.git'"}, + host: "host", port: "22", command: "git-receive-pack 'r.git'", + }, + { + name: "joined option values", + args: []string{"-p2222", "-oBatchMode=yes", "host", "cmd"}, + host: "host", port: "2222", command: "cmd", + }, + { + name: "standalone switches and a separator", + args: []string{"-4", "-q", "--", "host", "cmd"}, + host: "host", port: "22", command: "cmd", + }, + { + name: "unmodelled option does not become the host", + args: []string{"-i", "/tmp/key", "-T", "host", "cmd"}, + host: "host", port: "22", command: "cmd", + }, + {name: "no command", args: []string{"host"}, wantFail: true}, + {name: "nothing", args: nil, wantFail: true}, + {name: "dangling option value", args: []string{"-o"}, wantFail: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + host, port, command, err := parseSSHArgs(tc.args) + if tc.wantFail { + if err == nil { + t.Fatalf("want an error, got host=%q port=%q command=%q", host, port, command) + } + return + } + if err != nil { + t.Fatal(err) + } + if host != tc.host || port != tc.port || command != tc.command { + t.Fatalf("host=%q port=%q command=%q, want %q %q %q", + host, port, command, tc.host, tc.port, tc.command) + } + }) + } +} diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go index 2f8b9788..4b82acff 100644 --- a/pkg/sandbox/adapter/gitagent.go +++ b/pkg/sandbox/adapter/gitagent.go @@ -139,7 +139,10 @@ func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { url, _ := entry["url"].(string) hostFP, _ := entry["hostFingerprint"].(string) if url == "" || hostFP == "" { - return nil, fmt.Errorf("agent %q needs url and hostFingerprint recorded (its sidecar endpoint and host key)", name) + return nil, fmt.Errorf( + "agent %q has no endpoint recorded: it enrolled before advertising one. "+ + "Re-enroll it (captain sandbox git-agent add %s) so its serve reports its URL and host key", + name, name) } keysDir, err := gitAgentKeysDir() if err != nil { @@ -149,10 +152,12 @@ func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { agent: name, url: url, hostFingerprint: hostFP, - keyPath: stringOption(opts, "key", filepath.Join(keysDir, "supervisor_ed25519")), - mailbox: stringOption(opts, "mailbox", filepath.Join(keysDir, "mailbox.git")), - relay: gitagent.RelayMode(stringOption(opts, "relay", string(gitagent.RelaySync))), - waitTimeout: time.Hour, + keyPath: stringOption(opts, "key", filepath.Join(keysDir, dispatchKeyFile)), + // The mailbox must be the path the supervisor's endpoint serves, or a + // relayed result lands where nothing reads it. + mailbox: stringOption(opts, "mailbox", filepath.Join(keysDir, servedReposDir, mailboxRepoName)), + relay: gitagent.RelayMode(stringOption(opts, "relay", string(gitagent.RelaySync))), + waitTimeout: time.Hour, } if raw, ok := opts["waitTimeout"].(string); ok { if d, err := time.ParseDuration(raw); err == nil { @@ -172,6 +177,14 @@ func stringOption(opts map[string]any, key, fallback string) string { return fallback } +// The on-disk layout shared with the CLI (pkg/cli/gitagent.go). Duplicated as +// constants rather than imported because pkg/cli imports this package. +const ( + dispatchKeyFile = "supervisor_ed25519" + servedReposDir = "repos" + mailboxRepoName = "mailbox.git" +) + // gitAgentKeysDir anchors key material and the default mailbox beside the // captain config file, matching the CLI's layout. func gitAgentKeysDir() (string, error) { From d9c2605897f334179df2b80b5433ef46a2ee3c33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 09:04:22 +0000 Subject: [PATCH 09/22] fix(gitagent): size a relocated run's deadline for the remote agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dispatch to a git-agent sandbox failed at exactly two minutes with "dispatched but not concluded: context deadline exceeded". The dispatch had succeeded; what expired was the run's own deadline. A relocated run blocks while a remote coding agent does real work, but it inherited the request timeout meant for a model call, so it killed work that was still progressing and reported it as a failure. The run now sizes its deadline for where the work happens: when the resolved sandbox declares remote execution and no timeout was declared, it uses the backend's wait budget (waitTimeout, default one hour) — the same value the adapter already waits on, so the two can no longer disagree. An explicit --timeout or budget.timeout still wins, and local sandboxes are untouched. Applied on the direct, stream, workflow and batch paths so no entry point keeps the old default. Behind it sat a second defect: --timeout carried default:"120s", and that default was folded into the request ahead of the prompt file, so a frontmatter budget.timeout was silently overridden and could never take effect. The flag now defaults to empty and the 120s fallback lives in one place, which both restores frontmatter precedence and makes "the user asked for a timeout" distinguishable from "nobody did". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- pkg/cli/ai.go | 4 +-- pkg/cli/ai_sandbox_remote.go | 39 ++++++++++++++++++++++---- pkg/cli/ai_sandbox_remote_test.go | 46 +++++++++++++++++++++++++++++++ pkg/cli/prompt_batch_run.go | 4 +-- pkg/cli/prompt_entity.go | 2 +- pkg/cli/prompt_run.go | 6 ++-- pkg/sandbox/adapter/gitagent.go | 25 +++++++++++++---- 7 files changed, 106 insertions(+), 20 deletions(-) diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 93389f01..5b9ab75c 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -214,7 +214,7 @@ type AIPromptOptions struct { Var []string `flag:"var" help:"Template variable key=value (repeatable)" short:"V"` Attach []string `flag:"attach" help:"Attach a local path or URL (repeatable; RFC 4180 comma-separated values allowed)" short:"A"` MultiModels []string `flag:"multi-models" help:"Run prompt once per runtime selector in parallel, e.g. cli:sonnet-5,cmux:opus (repeatable; comma-separated allowed)" short:"M"` - Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` + Timeout string `flag:"timeout" help:"Request timeout (default 120s; a relocating sandbox waits for the remote agent instead)"` NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text to stdout"` } @@ -335,7 +335,7 @@ func (o AIPromptOptions) ToRequest() (ai.Request, error) { } func executePromptRequest(parent context.Context, req ai.Request, cfg ai.Config, timeout time.Duration, noStream bool) (any, error) { - ctx, cancel := runContext(parent, req, timeout) + ctx, cancel := runContext(parent, req, remoteAwareTimeout(req, cfg, timeout)) defer cancel() if err := preparePromptAttachments(ctx, &req, cfg); err != nil { return nil, err diff --git a/pkg/cli/ai_sandbox_remote.go b/pkg/cli/ai_sandbox_remote.go index ea693000..feb3b804 100644 --- a/pkg/cli/ai_sandbox_remote.go +++ b/pkg/cli/ai_sandbox_remote.go @@ -3,25 +3,52 @@ package cli import ( "context" "fmt" + "strings" + "time" "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/api/registry" + "github.com/flanksource/captain/pkg/sandbox/adapter" ) +// relocatesRun reports whether the selection replaces provider execution with +// a run on another machine. +func relocatesRun(cfg ai.Config) bool { + if cfg.SandboxSelection == nil { + return false + } + descriptor, ok := registry.SandboxFor(cfg.SandboxSelection.Kind) + return ok && descriptor.Has(registry.CapabilityRemoteExec) +} + +// remoteAwareTimeout sizes the run's deadline for where the work happens. A +// relocated run blocks on a remote agent for as long as that agent takes, so +// the local request default — sized for a model call — would kill a dispatch +// that is still progressing and report it as a failure. An explicitly declared +// timeout (--timeout or budget.timeout) always wins. +func remoteAwareTimeout(req ai.Request, cfg ai.Config, timeout time.Duration) time.Duration { + if strings.TrimSpace(req.Budget.Timeout) != "" || !relocatesRun(cfg) { + return timeout + } + return adapter.WaitTimeout(cfg.SandboxSelection.Options) +} + +// renderedTimeout is remoteAwareTimeout for an already-rendered prompt, used +// by the stream and batch paths that size their own deadline. +func renderedTimeout(rendered PromptRenderResult) time.Duration { + return remoteAwareTimeout(rendered.Input, rendered.Config, runtimeTimeout(rendered.Input.Budget.Timeout)) +} + // remoteExecProviderFor returns a provider backed by the resolved sandbox's // RemoteExecutor when the selection has that capability, nil otherwise. This // is the run-path branch for whole-run relocation (git-agent): it sits above // provider construction because the adapter replaces execution, not the argv. func remoteExecProviderFor(req *ai.Request, cfg ai.Config) (ai.Provider, error) { - selection := cfg.SandboxSelection - if selection == nil { - return nil, nil - } - descriptor, ok := registry.SandboxFor(selection.Kind) - if !ok || !descriptor.Has(registry.CapabilityRemoteExec) { + if !relocatesRun(cfg) { return nil, nil } + selection := cfg.SandboxSelection sandbox, err := api.NewSandbox(*selection) if err != nil { return nil, err diff --git a/pkg/cli/ai_sandbox_remote_test.go b/pkg/cli/ai_sandbox_remote_test.go index f75fc011..2af5a135 100644 --- a/pkg/cli/ai_sandbox_remote_test.go +++ b/pkg/cli/ai_sandbox_remote_test.go @@ -2,10 +2,12 @@ package cli import ( "testing" + "time" "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/api/registry" + "github.com/flanksource/captain/pkg/sandbox/adapter" ) // A remote-executing selection must replace provider execution. Without this @@ -41,6 +43,50 @@ func TestRemoteExecProviderForRoutesGitAgent(t *testing.T) { } } +// A relocated run waits on a remote agent doing real work. Inheriting the +// local request default kills a dispatch that is still progressing and reports +// it as a failure ("dispatched but not concluded: context deadline exceeded"). +func TestRemoteAwareTimeout(t *testing.T) { + remote := ai.Config{SandboxSelection: &api.SandboxConfig{Kind: registry.SandboxGitAgent}} + local := ai.Config{SandboxSelection: &api.SandboxConfig{Kind: registry.SandboxSRT}} + const requestDefault = 120 * time.Second + + t.Run("a relocated run waits for the agent, not the model", func(t *testing.T) { + got := remoteAwareTimeout(ai.Request{}, remote, requestDefault) + if got != adapter.DefaultWaitTimeout { + t.Fatalf("timeout = %s, want the remote wait budget %s", got, adapter.DefaultWaitTimeout) + } + }) + + t.Run("the backend's waitTimeout is honoured", func(t *testing.T) { + scoped := ai.Config{SandboxSelection: &api.SandboxConfig{ + Kind: registry.SandboxGitAgent, + Options: map[string]any{"waitTimeout": "15m"}, + }} + if got := remoteAwareTimeout(ai.Request{}, scoped, requestDefault); got != 15*time.Minute { + t.Fatalf("timeout = %s, want 15m", got) + } + }) + + t.Run("an explicit timeout always wins", func(t *testing.T) { + req := ai.Request{} + req.Budget.Timeout = "45s" + // runContext applies the declared budget itself; the point here is that + // the remote default does not override an explicit choice. + if got := remoteAwareTimeout(req, remote, requestDefault); got != requestDefault { + t.Fatalf("timeout = %s, want the caller's %s left untouched", got, requestDefault) + } + }) + + t.Run("local sandboxes keep the request default", func(t *testing.T) { + for name, cfg := range map[string]ai.Config{"srt": local, "none": {}} { + if got := remoteAwareTimeout(ai.Request{}, cfg, requestDefault); got != requestDefault { + t.Fatalf("%s: timeout = %s, want %s", name, got, requestDefault) + } + } + }) +} + func TestRemoteExecProviderForLeavesLocalSandboxesAlone(t *testing.T) { for _, selection := range []*api.SandboxConfig{ nil, diff --git a/pkg/cli/prompt_batch_run.go b/pkg/cli/prompt_batch_run.go index 6fedab8b..28e315f3 100644 --- a/pkg/cli/prompt_batch_run.go +++ b/pkg/cli/prompt_batch_run.go @@ -52,11 +52,11 @@ func launchAsyncBatch(ctx context.Context, id string, rendered PromptRenderResul } handles[i] = group.Add(runtimeSelector(run.Runtime), func(_ flanksourceContext.Context, t *task.Task) (PromptRunSummary, error) { if chat { - chatSession := newChatSession(runID, variant, runtimeTimeout(variant.Input.Budget.Timeout), stream, binding) + chatSession := newChatSession(runID, variant, renderedTimeout(variant), stream, binding) promptChats.register(chatSession) return chatSession.run(t) } - summary, runErr := runPromptStream(t, variant, runtimeTimeout(variant.Input.Budget.Timeout), runID, stream, binding) + summary, runErr := runPromptStream(t, variant, renderedTimeout(variant), runID, stream, binding) if runErr != nil { persistPromptRun(context.WithoutCancel(t.Context()), promptRunRecordInput{ Rendered: variant, RunID: runID, Binding: binding, diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index 154d32f4..41ed6db5 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -131,7 +131,7 @@ type PromptActionFlags struct { Attach []string `flag:"attach" help:"Attach a local path or URL (repeatable; RFC 4180 comma-separated values allowed)" short:"A"` Vars string `flag:"vars" help:"JSON object of template variables (HTTP callers)"` MultiModels []string `flag:"multi-models" help:"Run prompt once per runtime selector in parallel, e.g. cli:sonnet-5,cmux:opus (repeatable; comma-separated allowed)" short:"M"` - Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` + Timeout string `flag:"timeout" help:"Request timeout (default 120s; a relocating sandbox waits for the remote agent instead)"` NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text (CLI)"` } diff --git a/pkg/cli/prompt_run.go b/pkg/cli/prompt_run.go index 5c87edfd..5ddd9d45 100644 --- a/pkg/cli/prompt_run.go +++ b/pkg/cli/prompt_run.go @@ -93,7 +93,7 @@ var executePromptRequestFunc = executePromptRequest func launchAsyncRun(id string, rendered PromptRenderResult, chat bool) PromptRunResult { runID := uuid.NewString() stream := promptRuns.create(runID) - timeout := runtimeTimeout(rendered.Input.Budget.Timeout) + timeout := renderedTimeout(rendered) capabilities := chatCapabilitiesForBackend(rendered.Backend) stream.setRun(PromptRunFrame{ RunID: runID, Status: "running", Chat: chat, Model: rendered.Model, @@ -156,7 +156,7 @@ func executeSyncRunSingleDirect(ctx context.Context, t *task.Task, rendered Prom if workflowConfigured(rendered.Input.Workflow) { return executeSyncWorkflowRun(t, rendered, opts.NoStream, binding) } - out, err := executePromptRequestFunc(ctx, rendered.Input, rendered.Config, runtimeTimeout(rendered.Input.Budget.Timeout), opts.NoStream) + out, err := executePromptRequestFunc(ctx, rendered.Input, rendered.Config, renderedTimeout(rendered), opts.NoStream) if err != nil { return PromptRunResult{}, err } @@ -192,7 +192,7 @@ func executeSyncWorkflowRun(t *task.Task, rendered PromptRenderResult, noStream // loop only runs under `captain serve` — deregister so embedders don't // accumulate finished runs. defer promptRuns.remove(runID) - timeout := runtimeTimeout(rendered.Input.Budget.Timeout) + timeout := renderedTimeout(rendered) var summary PromptRunSummary var err error if noStream { diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go index 4b82acff..f8b68cf0 100644 --- a/pkg/sandbox/adapter/gitagent.go +++ b/pkg/sandbox/adapter/gitagent.go @@ -157,12 +157,7 @@ func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { // relayed result lands where nothing reads it. mailbox: stringOption(opts, "mailbox", filepath.Join(keysDir, servedReposDir, mailboxRepoName)), relay: gitagent.RelayMode(stringOption(opts, "relay", string(gitagent.RelaySync))), - waitTimeout: time.Hour, - } - if raw, ok := opts["waitTimeout"].(string); ok { - if d, err := time.ParseDuration(raw); err == nil { - target.waitTimeout = d - } + waitTimeout: WaitTimeout(opts), } if g.cfg.Policy != nil { target.policy = gitagent.Policy{Paths: g.cfg.Policy.Paths, MaxAttempts: g.cfg.Policy.MaxAttempts} @@ -185,6 +180,24 @@ const ( mailboxRepoName = "mailbox.git" ) +// DefaultWaitTimeout bounds how long a dispatch waits for its verdict. A +// relocating sandbox blocks on a remote agent doing real work, so this is +// sized for that rather than for a model call. +const DefaultWaitTimeout = time.Hour + +// WaitTimeout reads the backend's waitTimeout option, falling back to +// DefaultWaitTimeout. Exported so the run path can size its own deadline to +// match: a shorter outer timeout would kill the dispatch mid-flight and report +// a failure for work that is still running. +func WaitTimeout(options map[string]any) time.Duration { + if raw, ok := options["waitTimeout"].(string); ok { + if d, err := time.ParseDuration(raw); err == nil && d > 0 { + return d + } + } + return DefaultWaitTimeout +} + // gitAgentKeysDir anchors key material and the default mailbox beside the // captain config file, matching the CLI's layout. func gitAgentKeysDir() (string, error) { From 4e771d12809e6d9a3acf55cc897cb900b0ea3f0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 09:54:38 +0000 Subject: [PATCH 10/22] fix(gitagent): dispatch a task that something actually works on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advertised add -> join -> ai prompt flow prepared a worktree and then went quiet. LaunchAgent treated an empty agentCommand as a no-op, enrollment never set one, and --model only travelled as metadata, so a dispatch waited out its whole budget for work that was never started. Nothing in the suite could see it: the end-to-end test drove the agent by hand, which is precisely the step meant to be automatic. A sidecar now launches captain itself when the backend configures no agent. The new run-task subcommand reads the dispatched task.json, runs the prompt in the prepared worktree with the sandbox pinned to none (it is already the relocated run; resolving a relocating sandbox here would dispatch onward, H15), then performs the agent's half of the protocol: stage, commit, push. The resolved backend now travels beside the model in task.json, so the agent runs the runtime the supervisor selected rather than re-resolving the name against its own defaults — which is what made --model cli:codex look like it selected nothing. An empty agentCommand is now an error rather than a silent no-op, since launching nothing is indistinguishable from an agent still thinking. Opting out is spelled agentCommand: none, and records in the task directory why nothing ran and where to push from. Tests are split so neither can hide the other: one proves a dispatch completes with no human touching the worktree (scripted agent, no credentials needed), one proves an unconfigured backend still launches the default agent, and one pins that the default command is captain's own run-task. The manual-push cycle test keeps its protocol coverage but now opts out explicitly and says in its name what it does not prove. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xfz36FVah9mBujveWyKRze --- cmd/captain/main.go | 3 + pkg/cli/gitagent.go | 8 +- pkg/cli/gitagent_e2e_test.go | 205 +++++++++++++++++++++++++++++++- pkg/cli/gitagent_hook.go | 17 ++- pkg/cli/gitagent_runtask.go | 140 ++++++++++++++++++++++ pkg/gitagent/dispatch.go | 31 +++++ pkg/gitagent/hookmain.go | 20 +++- pkg/gitagent/workspace.go | 25 +++- pkg/gitagent/workspace_test.go | 66 ++++++++++ pkg/sandbox/adapter/gitagent.go | 10 +- 10 files changed, 510 insertions(+), 15 deletions(-) create mode 100644 pkg/cli/gitagent_runtask.go create mode 100644 pkg/gitagent/workspace_test.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 5b25251e..892deaf5 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -145,6 +145,9 @@ func main() { hookLeaf := clicky.AddNamedCommandWithContext("hook", gitAgentCmd, cli.GitAgentHookOptions{}, cli.RunGitAgentHook) hookLeaf.Short = "Internal: receive-hook entrypoint invoked by the installed shims" hookLeaf.Hidden = true + runTaskLeaf := clicky.AddNamedCommandWithContext("run-task", gitAgentCmd, cli.GitAgentRunTaskOptions{}, cli.RunGitAgentRunTask) + runTaskLeaf.Short = "Internal: work a dispatched task in its worktree, then commit and push" + runTaskLeaf.Hidden = true gitAgentCmd.AddCommand(&cobra.Command{ Use: "ssh", Hidden: true, diff --git a/pkg/cli/gitagent.go b/pkg/cli/gitagent.go index ecf873b1..77ca04fc 100644 --- a/pkg/cli/gitagent.go +++ b/pkg/cli/gitagent.go @@ -77,7 +77,13 @@ func GitAgentHelp() api.Textable { AddText(" 3. agent host: run the printed join command (it enrolls, then serves)", "text-green-400").NewLine(). AddText(" 4. supervisor: captain ai prompt ./task.prompt --sandbox git-agent", "text-green-400").NewLine().NewLine(). AddText("Step 3 establishes trust in both directions: the supervisor learns the agent's", "text-gray-400").NewLine(). - AddText("endpoint and host key, and the agent authorizes the supervisor's dispatch key.", "text-gray-400").NewLine() + AddText("endpoint and host key, and the agent authorizes the supervisor's dispatch key.", "text-gray-400").NewLine().NewLine(). + AddText("The agent:", "font-bold text-blue-400").NewLine(). + AddText(" By default the sidecar runs captain itself on the dispatched prompt, then", "text-gray-400").NewLine(). + AddText(" commits and pushes. Set sandbox.backends..agentCommand to run your own", "text-gray-400").NewLine(). + AddText(" agent instead, or ", "text-gray-400"). + AddText("agentCommand: none", "text-green-400"). + AddText(" to prepare the worktree and stop.", "text-gray-400").NewLine() } type GitAgentAddOptions struct { diff --git a/pkg/cli/gitagent_e2e_test.go b/pkg/cli/gitagent_e2e_test.go index 0a1d70d7..5d5d05ee 100644 --- a/pkg/cli/gitagent_e2e_test.go +++ b/pkg/cli/gitagent_e2e_test.go @@ -12,6 +12,9 @@ import ( "sync" "testing" "time" + + "github.com/flanksource/captain/pkg/gitagent" + "gopkg.in/yaml.v3" ) // lockedBuffer collects a background process's output safely across the @@ -177,6 +180,35 @@ func (h *host) configBytes() string { return string(data) } +// setBackendOption edits one option under sandbox.backends.git-agent in this +// host's config, the way an operator would. +func (h *host) setBackendOption(t *testing.T, key, value string) { + t.Helper() + path := filepath.Join(h.home, ".captain.yaml") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + sandbox, _ := cfg["sandbox"].(map[string]any) + backends, _ := sandbox["backends"].(map[string]any) + backend, _ := backends["git-agent"].(map[string]any) + if backend == nil { + t.Fatalf("no git-agent backend in %s:\n%s", path, data) + } + backend[key] = value + out, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } +} + // gitIn runs git in dir with a pinned identity. func gitIn(t *testing.T, dir string, args ...string) string { t.Helper() @@ -362,16 +394,21 @@ func TestEnrollmentProducesADispatchableTopology(t *testing.T) { } } -// TestFullCycleThroughTheCLI is the §12 baseline check at product level: with -// nothing but the documented commands, a dispatch reaches the agent, the agent -// completes it with `git commit` and a bare `git push`, the result relays to -// the supervisor, and accepted work is integrated. Every setup blocker this -// suite exists for shows up here as a hang or a missing ref. -func TestFullCycleThroughTheCLI(t *testing.T) { +// TestFullCycleWithAManualAgent covers the protocol half of the cycle: a +// dispatch reaches the agent, work pushed with ordinary git relays to the +// supervisor, and accepted work is integrated. +// +// It drives the agent by hand deliberately, which is exactly what it does NOT +// prove: that a dispatch launches anything. TestDispatchLaunchesAnAgent covers +// that, and the two must stay separate — a manual push in this test once +// masked a launch path that never ran. +func TestFullCycleWithAManualAgent(t *testing.T) { if testing.Short() { t.Skip("builds the captain binary and runs two endpoints") } supervisor, agent, repo, _, _ := enrollPair(t) + // Opt out of the launcher: this test drives the agent itself. + agent.setBackendOption(t, "agentCommand", gitagent.NoAgentCommand) // A dirty supervisor worktree must travel with the dispatch. writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nAdd a greeting.\n") @@ -542,3 +579,159 @@ func firstJSONDocument(out string) string { } return trimmed } + +// TestDispatchLaunchesAnAgent is the test the manual-push cycle test cannot +// be: it proves a dispatch starts work that completes on its own, with no +// human touching the worktree at any point. +// +// The agent is a scripted stand-in rather than a real model, so the +// launch → work → push → relay → integrate chain is exercised without needing +// credentials. What it does not cover is the UNCONFIGURED default — that is +// TestUnconfiguredDispatchLaunchesTheDefaultAgent below, plus +// TestDefaultAgentCommandRunsCaptain for the command itself. +func TestDispatchLaunchesAnAgent(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary and runs two endpoints") + } + supervisor, agent, repo, _, _ := enrollPair(t) + + // A scripted agent: edit, commit, push — the same three steps run-task + // performs after the model call. + agent.setBackendOption(t, "agentCommand", + `echo 'func Greet() string { return "hi" }' >> pkg/main.go `+ + `&& git add -A && git commit -q -m "captain: $CAPTAIN_TASK" && git push`) + + writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nAdd a greeting.\n") + writeAt(t, repo, "pkg/main.go", "package main\n\n// dirty\n") + + // No manual step anywhere below: dispatch, and wait for it to conclude. + dispatch := exec.Command(supervisor.bin, "ai", "prompt", "./task.prompt", + "--sandbox", "git-agent", "--timeout", "3m") + dispatch.Dir = repo + dispatch.Env = supervisor.env() + out, err := dispatch.CombinedOutput() + if err != nil { + t.Fatalf("the dispatch did not conclude on its own: %v\n%s\n%s", err, out, agentLogs(t, agent)) + } + if !strings.Contains(string(out), "accepted") { + t.Fatalf("dispatch did not report acceptance:\n%s\n%s", out, agentLogs(t, agent)) + } + + // The agent's work reached the supervisor and was integrated, with no + // human touching the worktree. + mailbox := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir, MailboxRepoName) + refs := gitIn(t, mailbox, "for-each-ref", "--format=%(refname)") + if !strings.Contains(refs, "/result/1") || !strings.Contains(refs, "/verdict/1") { + t.Fatalf("no result/verdict refs; the launched agent never submitted:\n%s", refs) + } + branch := "" + for _, line := range strings.Split(gitIn(t, repo, "branch", "--format=%(refname:short)"), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "captain/") { + branch = strings.TrimSpace(line) + } + } + if branch == "" { + t.Fatalf("accepted work was not integrated:\n%s", gitIn(t, repo, "branch")) + } + integrated := gitIn(t, repo, "show", branch+":pkg/main.go") + if !strings.Contains(integrated, "func Greet()") || !strings.Contains(integrated, "// dirty") { + t.Fatalf("integration lost the agent's work or the dispatched state:\n%s", integrated) + } +} + +// agentLogs returns whatever the detached agent wrote, which is the only +// diagnosis available when a dispatch fails to conclude. +func agentLogs(t *testing.T, agent *host) string { + t.Helper() + tasks := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + entries, err := os.ReadDir(tasks) + if err != nil { + return "no task directory: " + err.Error() + } + var b strings.Builder + for _, e := range entries { + for _, name := range []string{"agent.stdout.log", "agent.stderr.log"} { + if data, err := os.ReadFile(filepath.Join(tasks, e.Name(), name)); err == nil { + fmt.Fprintf(&b, "--- %s/%s ---\n%s\n", e.Name(), name, data) + } + } + } + if b.Len() == 0 { + return "the agent wrote no logs (was anything launched?)" + } + return b.String() +} + +// TestDefaultAgentCommandRunsCaptain pins what an unconfigured sidecar +// launches: this binary, working the task in place. An empty default is the +// defect this whole pair of tests exists to prevent. +func TestDefaultAgentCommandRunsCaptain(t *testing.T) { + command := DefaultAgentCommand("/usr/local/bin/captain", "/srv/repo.git", "t-1", "/home/agent/.captain.yaml") + for _, want := range []string{ + `"/usr/local/bin/captain"`, "sandbox git-agent run-task", + `--repo "/srv/repo.git"`, `--task "t-1"`, `--config "/home/agent/.captain.yaml"`, + } { + if !strings.Contains(command, want) { + t.Fatalf("default agent command %q lacks %q", command, want) + } + } + if strings.TrimSpace(DefaultAgentCommand("/bin/captain", "/r.git", "t-1", "")) == "" { + t.Fatal("the default must never be empty; LaunchAgent would refuse and the dispatch would hang") + } +} + +// TestUnconfiguredDispatchLaunchesTheDefaultAgent closes the gap the other +// tests leave: a backend that configures no agentCommand at all must still +// launch a real agent. It cannot assert the task completes — captain's default +// agent calls a model, which needs credentials this suite does not have — so +// it asserts the launch itself, which is precisely what used to be missing. +func TestUnconfiguredDispatchLaunchesTheDefaultAgent(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary and runs two endpoints") + } + supervisor, agent, repo, _, _ := enrollPair(t) + if strings.Contains(agent.configBytes(), "agentCommand") { + t.Fatalf("this test requires an unconfigured backend:\n%s", agent.configBytes()) + } + + writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nAdd a greeting.\n") + + // A short budget: the point is what got launched, not what it produced. + dispatch := exec.Command(supervisor.bin, "ai", "prompt", "./task.prompt", + "--sandbox", "git-agent", "--timeout", "45s") + dispatch.Dir = repo + dispatch.Env = supervisor.env() + var out lockedBuffer + dispatch.Stdout, dispatch.Stderr = &out, &out + if err := dispatch.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- dispatch.Wait() }() + t.Cleanup(func() { + if dispatch.Process != nil { + _ = dispatch.Process.Kill() + } + }) + + // An agent log appearing at all is the assertion: the sidecar launched + // something rather than preparing a workspace and going quiet. + tasks := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + deadline := time.Now().Add(60 * time.Second) + launched := "" + for time.Now().Before(deadline) && launched == "" { + entries, _ := os.ReadDir(tasks) + for _, e := range entries { + for _, name := range []string{"agent.stdout.log", "agent.stderr.log"} { + if data, err := os.ReadFile(filepath.Join(tasks, e.Name(), name)); err == nil && len(data) > 0 { + launched = string(data) + } + } + } + time.Sleep(250 * time.Millisecond) + } + if launched == "" { + t.Fatalf("an unconfigured backend launched nothing; the dispatch would wait out its whole budget in silence\ndispatch output:\n%s", out.String()) + } + t.Logf("default agent produced:\n%s", launched) +} diff --git a/pkg/cli/gitagent_hook.go b/pkg/cli/gitagent_hook.go index a39a3d85..116477da 100644 --- a/pkg/cli/gitagent_hook.go +++ b/pkg/cli/gitagent_hook.go @@ -38,7 +38,22 @@ func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) fmt.Fprintf(os.Stderr, "captain: %v\n", err) return nil, err } - host := gitagent.HookHost{Runtime: runtime, Wrap: wrap} + exe, err := os.Executable() + if err != nil { + return nil, err + } + configPath := strings.TrimSpace(opts.Config) + host := gitagent.HookHost{ + Runtime: runtime, + Wrap: wrap, + // With no agentCommand configured, the sidecar still launches a real + // agent: this binary, working the task in the prepared worktree. The + // alternative — launching nothing — leaves the supervisor waiting out + // its whole budget on work that never started. + DefaultAgentCommand: func(repo, task string) string { + return DefaultAgentCommand(exe, repo, task, configPath) + }, + } role := gitagent.ReceiverRole(opts.Role) switch opts.Hook { case "pre-receive": diff --git a/pkg/cli/gitagent_runtask.go b/pkg/cli/gitagent_runtask.go new file mode 100644 index 00000000..771494b1 --- /dev/null +++ b/pkg/cli/gitagent_runtask.go @@ -0,0 +1,140 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/aiflags" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +// The default coding agent a sidecar launches. It is captain driving whichever +// CLI runtime the supervisor resolved, in the prepared worktree, followed by +// the two commands the protocol asks of an agent: commit and push. +// +// This exists because the alternative — leaving agentCommand empty and telling +// operators to write their own — makes the advertised `add → join → ai prompt` +// flow silently wait forever on a task nothing ever started. +type GitAgentRunTaskOptions struct { + Repo string `flag:"repo" help:"The sidecar's bare repository"` + Task string `flag:"task" help:"Task id to work on"` + Config string `flag:"config" help:"Config file to read; a detached agent cannot rely on $HOME"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` +} + +// RunGitAgentRunTask performs one task end to end on the agent host: read the +// dispatched prompt, run it in the worktree, then commit and push. Its output +// is the agent log, so every failure is reported there rather than to a +// terminal nobody is watching. +func RunGitAgentRunTask(ctx context.Context, opts GitAgentRunTaskOptions) (any, error) { + if strings.TrimSpace(opts.Config) != "" { + captainconfig.SetPath(opts.Config) + } + worktree, taskFile, err := gitagent.TaskPaths(opts.Repo, opts.Task) + if err != nil { + return nil, err + } + payload, err := gitagent.LoadTaskPayload(taskFile) + if err != nil { + return nil, err + } + if err := runTaskPrompt(ctx, worktree, payload); err != nil { + return nil, fmt.Errorf("running the dispatched prompt: %w", err) + } + if err := submitWork(ctx, worktree, opts.Task); err != nil { + return nil, err + } + return nil, nil +} + +// runTaskPrompt executes the dispatched prompt in the worktree. The sandbox is +// pinned to none: this process IS the relocated run, so resolving a relocating +// sandbox here would dispatch the task to another agent, and so on (H15). +func runTaskPrompt(ctx context.Context, worktree string, payload gitagent.TaskPayload) error { + providerOpts := AIProviderOptions{ + ModelFlags: aiflags.ModelFlags{Model: payload.Model, Backend: payload.Backend}, + Sandbox: "none", + } + cfg, err := providerOpts.ToConfig() + if err != nil { + return err + } + var req ai.Request + req.Prompt.User = payload.Prompt + req.Prompt.System = payload.System + req.Model = cfg.Model + req.SetCwd(worktree) + // Editing is the point: a coding agent that cannot write files produces an + // empty result and an unexplained silence on the supervisor. + req.Permissions.Mode = api.PermissionAcceptEdits + + if _, err := executePromptRequestFunc(ctx, req, cfg, renderedTimeout(PromptRenderResult{Input: req, Config: cfg}), true); err != nil { + return err + } + return nil +} + +// submitWork performs the agent's half of the protocol: stage everything the +// run produced, commit, and push. A run that changed nothing is reported as +// such rather than pushed as an empty success. +func submitWork(ctx context.Context, worktree, task string) error { + if err := git(ctx, worktree, "add", "-A"); err != nil { + return err + } + staged, err := hasStagedChanges(ctx, worktree) + if err != nil { + return err + } + if !staged { + return fmt.Errorf("the agent produced no changes for task %s; nothing to submit", task) + } + if err := git(ctx, worktree, "commit", "-m", "captain: "+task); err != nil { + return err + } + // The push carries the work through both hook tiers and blocks until the + // verdict, so its output is the agent's most important log line. + return git(ctx, worktree, "push") +} + +func git(ctx context.Context, dir string, args ...string) error { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return nil +} + +// hasStagedChanges reports whether the index differs from HEAD. `diff --cached +// --quiet` exits 1 when it does, which is the answer rather than a failure. +func hasStagedChanges(ctx context.Context, dir string) (bool, error) { + cmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--quiet") + cmd.Dir = dir + err := cmd.Run() + if err == nil { + return false, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return true, nil + } + return false, fmt.Errorf("git diff --cached: %w", err) +} + +// DefaultAgentCommand is the command a sidecar launches when the backend +// declares none: this binary, working the task in place. +func DefaultAgentCommand(captainBin, repo, task, configPath string) string { + command := fmt.Sprintf("%q sandbox git-agent run-task --repo %q --task %q", captainBin, repo, task) + if strings.TrimSpace(configPath) != "" { + command += fmt.Sprintf(" --config %q", configPath) + } + return command +} diff --git a/pkg/gitagent/dispatch.go b/pkg/gitagent/dispatch.go index 86e902f0..8b84ea2e 100644 --- a/pkg/gitagent/dispatch.go +++ b/pkg/gitagent/dispatch.go @@ -10,6 +10,8 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "strings" "time" ) @@ -19,6 +21,35 @@ type TaskPayload struct { Prompt string `json:"prompt"` System string `json:"system,omitempty"` Model string `json:"model,omitempty"` + // Backend records which runtime the supervisor resolved, so the agent runs + // the coding agent that was actually selected rather than re-resolving the + // model name against its own defaults. + Backend string `json:"backend,omitempty"` +} + +// LoadTaskPayload reads a materialized task.json. +func LoadTaskPayload(path string) (TaskPayload, error) { + var payload TaskPayload + data, err := os.ReadFile(path) + if err != nil { + return payload, err + } + if err := json.Unmarshal(data, &payload); err != nil { + return payload, fmt.Errorf("task file %s: %w", path, err) + } + if strings.TrimSpace(payload.Prompt) == "" { + return payload, fmt.Errorf("task file %s carries no prompt", path) + } + return payload, nil +} + +// TaskPaths locates a task's materialized inputs on a sidecar. +func TaskPaths(sidecarRepo, task string) (worktree, taskFile string, err error) { + if err := ValidateTaskID(task); err != nil { + return "", "", err + } + dir := taskStateDir(sidecarRepo, task) + return filepath.Join(dir, "worktree"), filepath.Join(dir, ControlTaskFile), nil } // DispatchRequest carries one task hand-off. diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go index 3a636533..35472fc9 100644 --- a/pkg/gitagent/hookmain.go +++ b/pkg/gitagent/hookmain.go @@ -39,6 +39,24 @@ type HookHost struct { Judge ai.Provider Wrap verify.CommandWrapFunc Timeout time.Duration + // DefaultAgentCommand supplies the command to launch when the backend + // declares no agentCommand. Without it a dispatch would prepare a + // workspace nothing ever works on, and the supervisor would wait out its + // whole budget in silence. The task id is only known here, at + // post-receive, which is why this is a builder rather than a string. + DefaultAgentCommand func(repo, task string) string +} + +// agentCommandFor resolves what to launch for a task: the configured command, +// else the host's default. +func (h HookHost) agentCommandFor(repo, task string) string { + if command := strings.TrimSpace(h.Runtime.AgentCommand); command != "" { + return command + } + if h.DefaultAgentCommand != nil { + return h.DefaultAgentCommand(repo, task) + } + return "" } // LoadHookRuntime reads a HookRuntime JSON file; an empty path is an empty @@ -362,7 +380,7 @@ func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates if err != nil { return err } - if err := LaunchAgent(repo, info.Task, workdir, taskFile, host.Runtime.AgentCommand); err != nil { + if err := LaunchAgent(repo, info.Task, workdir, taskFile, host.agentCommandFor(repo, info.Task)); err != nil { return err } } diff --git a/pkg/gitagent/workspace.go b/pkg/gitagent/workspace.go index f7e7e3e2..a7eaad48 100644 --- a/pkg/gitagent/workspace.go +++ b/pkg/gitagent/workspace.go @@ -11,9 +11,15 @@ import ( "os" "os/exec" "path/filepath" + "strings" "syscall" ) +// NoAgentCommand opts a sidecar out of launching anything, leaving the +// prepared worktree for a human. It is spelled explicitly so that "no agent +// ran" is always a choice on the record rather than an empty config field. +const NoAgentCommand = "none" + // SetupAgentWorkspace creates the task branch in the sidecar repo and clones // it (object store shared) into /captain/tasks//worktree. A clone // rather than a linked worktree keeps the agent's unaccepted commits in the @@ -58,13 +64,24 @@ func WriteTaskFile(sidecarRepo, task string, payload []byte) (string, error) { // LaunchAgent starts command (a shell line) fully detached: its own session, // stdio redirected to files. A child inheriting the hook's stdout keeps the // sideband pipe open and receive-pack waits for EOF — the dispatch push would -// hang for the agent's lifetime (R6.3/H12). An empty command is a no-op: the -// workspace is ready for a human or an externally-managed agent. +// hang for the agent's lifetime (R6.3/H12). +// +// An empty command is an error, not a no-op. Nothing would run, nothing would +// push, and the dispatch would wait out its whole budget for work that was +// never started — a silence indistinguishable from an agent still thinking. +// Callers that genuinely want a bare workspace pass NoAgentCommand. func LaunchAgent(sidecarRepo, task, workdir, taskFile, command string) error { - if command == "" { - return nil + if strings.TrimSpace(command) == "" { + return fmt.Errorf("task %s: no agent command configured; nothing would run and the dispatch would wait for work that never started", task) } dir := taskStateDir(sidecarRepo, task) + if command == NoAgentCommand { + // Explicitly hand the workspace to a human: record why nothing ran so + // a waiting dispatch is diagnosable from the task directory. + return writeFileAtomic(filepath.Join(dir, "agent.stdout.log"), + []byte("agentCommand is "+NoAgentCommand+": the worktree is prepared but no agent was launched.\n"+ + "Commit and push from "+workdir+" to complete this task.\n"), 0o644) + } stdout, err := os.OpenFile(filepath.Join(dir, "agent.stdout.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return err diff --git a/pkg/gitagent/workspace_test.go b/pkg/gitagent/workspace_test.go new file mode 100644 index 00000000..85c66984 --- /dev/null +++ b/pkg/gitagent/workspace_test.go @@ -0,0 +1,66 @@ +package gitagent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A dispatch that launches nothing leaves the supervisor waiting out its whole +// budget on work that never started — a silence indistinguishable from an +// agent still thinking. Empty must therefore be an error, and "no agent" must +// be spelled explicitly. +func TestLaunchAgentRefusesAnEmptyCommand(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(taskStateDir(repo, "t-1"), 0o755); err != nil { + t.Fatal(err) + } + for _, command := range []string{"", " "} { + err := LaunchAgent(repo, "t-1", filepath.Join(repo, "wt"), "task.json", command) + if err == nil || !strings.Contains(err.Error(), "no agent command") { + t.Fatalf("command %q: err = %v, want a refusal", command, err) + } + } +} + +func TestLaunchAgentRecordsAnExplicitOptOut(t *testing.T) { + repo := t.TempDir() + dir := taskStateDir(repo, "t-1") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + workdir := filepath.Join(repo, "wt") + if err := LaunchAgent(repo, "t-1", workdir, "task.json", NoAgentCommand); err != nil { + t.Fatal(err) + } + log, err := os.ReadFile(filepath.Join(dir, "agent.stdout.log")) + if err != nil { + t.Fatalf("an opt-out must leave a diagnosable record: %v", err) + } + if !strings.Contains(string(log), workdir) { + t.Fatalf("the record must name the worktree to push from: %s", log) + } +} + +// A backend that configures no agentCommand still gets a real agent, supplied +// by the host. Falling through to empty is what made the advertised flow wait +// forever. +func TestAgentCommandForFallsBackToTheHostDefault(t *testing.T) { + host := HookHost{DefaultAgentCommand: func(repo, task string) string { + return "captain run-task " + repo + " " + task + }} + if got := host.agentCommandFor("/repo.git", "t-1"); got != "captain run-task /repo.git t-1" { + t.Fatalf("agentCommandFor = %q", got) + } + + host.Runtime.AgentCommand = "my-own-agent" + if got := host.agentCommandFor("/repo.git", "t-1"); got != "my-own-agent" { + t.Fatalf("a configured command must win, got %q", got) + } + + bare := HookHost{} + if got := bare.agentCommandFor("/repo.git", "t-1"); got != "" { + t.Fatalf("with no default and no config the result is empty (LaunchAgent then refuses), got %q", got) + } +} diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go index f8b68cf0..9d302134 100644 --- a/pkg/sandbox/adapter/gitagent.go +++ b/pkg/sandbox/adapter/gitagent.go @@ -76,8 +76,14 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp KeyPath: target.keyPath, Relay: target.relay, Policy: target.policy, - TaskPayload: gitagent.TaskPayload{Prompt: prompt, System: system, Model: spec.Name}, - HooksJSON: hooksJSON, + // The resolved backend travels with the model: the agent must run the + // runtime the supervisor selected, not re-resolve the name against its + // own defaults and quietly pick a different one. + TaskPayload: gitagent.TaskPayload{ + Prompt: prompt, System: system, + Model: spec.Name, Backend: string(spec.Backend), + }, + HooksJSON: hooksJSON, }) if err != nil { return nil, err From 615fed05246494d1e4e32cabcf70329d4707df39 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 5 Aug 2026 16:03:53 +0545 Subject: [PATCH 11/22] fix(gitagent): let remote agents edit and detach Map edit-capable permission modes to Codex workspace-write so run-task can create files. Mark inherited receive-pack descriptors close-on-exec so detached agents do not keep dispatch pushes open. --- pkg/api/cli_options.go | 2 ++ pkg/api/cli_options_test.go | 2 ++ pkg/gitagent/workspace.go | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/pkg/api/cli_options.go b/pkg/api/cli_options.go index cc036b36..060ade05 100644 --- a/pkg/api/cli_options.go +++ b/pkg/api/cli_options.go @@ -111,6 +111,8 @@ func CodexSafety(p Permissions) (CodexSandbox, CodexApprovalPolicy) { switch { case p.Mode == PermissionBypass: return CodexSandboxDangerFull, CodexApprovalNever + case p.Mode == PermissionAcceptEdits, p.Mode == PermissionAuto: + return CodexSandboxWorkspaceWrite, CodexApprovalOnRequest case p.HasPreset(PresetEdit) && p.Mode == "": return CodexSandboxWorkspaceWrite, CodexApprovalOnRequest default: diff --git a/pkg/api/cli_options_test.go b/pkg/api/cli_options_test.go index 5c7e043c..6f41b1ba 100644 --- a/pkg/api/cli_options_test.go +++ b/pkg/api/cli_options_test.go @@ -63,6 +63,8 @@ func TestCodexSafety(t *testing.T) { wantApproval CodexApprovalPolicy }{ {"bypass is full access", Permissions{Mode: PermissionBypass}, CodexSandboxDangerFull, CodexApprovalNever}, + {"accept edits is workspace write", Permissions{Mode: PermissionAcceptEdits}, CodexSandboxWorkspaceWrite, CodexApprovalOnRequest}, + {"auto is workspace write", Permissions{Mode: PermissionAuto}, CodexSandboxWorkspaceWrite, CodexApprovalOnRequest}, {"edit preset is workspace write", Permissions{Presets: []Preset{PresetEdit}}, CodexSandboxWorkspaceWrite, CodexApprovalOnRequest}, {"default is read-only", Permissions{}, CodexSandboxReadOnly, CodexApprovalOnRequest}, {"edit preset with explicit mode stays read-only", Permissions{Mode: PermissionDefault, Presets: []Preset{PresetEdit}}, CodexSandboxReadOnly, CodexApprovalOnRequest}, diff --git a/pkg/gitagent/workspace.go b/pkg/gitagent/workspace.go index a7eaad48..a5ccece0 100644 --- a/pkg/gitagent/workspace.go +++ b/pkg/gitagent/workspace.go @@ -11,8 +11,11 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" + + "golang.org/x/sys/unix" ) // NoAgentCommand opts a sidecar out of launching anything, leaving the @@ -102,6 +105,9 @@ func LaunchAgent(sidecarRepo, task, workdir, taskFile, command string) error { cmd.Stdout = stdout cmd.Stderr = stderr cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := markInheritedDescriptorsCloseOnExec(); err != nil { + return err + } if err := cmd.Start(); err != nil { return fmt.Errorf("launching agent: %w", err) } @@ -109,3 +115,23 @@ func LaunchAgent(sidecarRepo, task, workdir, taskFile, command string) error { // reparents to init when the hook exits. return cmd.Process.Release() } + +// markInheritedDescriptorsCloseOnExec prevents receive-pack's sideband pipes +// from surviving in the detached agent and keeping the dispatch push open. +func markInheritedDescriptorsCloseOnExec() error { + closeRangeErr := unix.CloseRange(3, ^uint(0), unix.CLOSE_RANGE_CLOEXEC) + if closeRangeErr == nil { + return nil + } + entries, readErr := os.ReadDir("/proc/self/fd") + if readErr != nil { + return fmt.Errorf("marking inherited descriptors close-on-exec: close_range: %v; /proc/self/fd: %w", closeRangeErr, readErr) + } + for _, entry := range entries { + fd, parseErr := strconv.Atoi(entry.Name()) + if parseErr == nil && fd >= 3 { + syscall.CloseOnExec(fd) + } + } + return nil +} From 80c8f4f701fadcd55adba2ce0b41e865681318e5 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 5 Aug 2026 18:51:20 +0545 Subject: [PATCH 12/22] fix(gitagent): harden remote task protocol Remote runs could lose sandbox metadata, inherit the local model timeout, and bypass provider middleware. Receiver, SSH, and proxy edge cases could also hang work, lose concurrent state, or route credentialed requests outside the exact grant. Preserve remote selection and deadlines, supply hook judges, serialize durable state and key writes, harden Git/SSH transport and enrollment, and enforce canonical proxy scopes with reusable bounded transports. Add focused protocol and concurrency regressions. --- pkg/captainconfig/config.go | 3 + pkg/cli/ai.go | 8 +- pkg/cli/ai_prompt_file.go | 11 +- pkg/cli/ai_prompt_file_test.go | 25 ++++ pkg/cli/ai_sandbox_remote.go | 22 +++- pkg/cli/ai_sandbox_remote_test.go | 50 +++++++ pkg/cli/gitagent.go | 39 ++++-- pkg/cli/gitagent_directory.go | 13 +- pkg/cli/gitagent_e2e_test.go | 3 + pkg/cli/gitagent_hook.go | 47 ++++++- pkg/cli/gitagent_runtask.go | 15 +-- pkg/cli/gitagent_runtask_test.go | 33 +++++ pkg/cli/gitagent_serve.go | 23 +++- pkg/cli/gitagent_test.go | 34 ++++- pkg/gitagent/admit.go | 11 +- pkg/gitagent/conformance_ginkgo_test.go | 3 +- pkg/gitagent/control.go | 2 +- pkg/gitagent/dispatch.go | 18 ++- pkg/gitagent/enroll.go | 6 +- pkg/gitagent/envelope.go | 5 + pkg/gitagent/git.go | 8 +- pkg/gitagent/hookmain.go | 46 +++++-- pkg/gitagent/hookset_ginkgo_test.go | 9 ++ pkg/gitagent/keys.go | 24 +++- pkg/gitagent/materialize.go | 20 ++- pkg/gitagent/proxy/grants.go | 20 ++- pkg/gitagent/proxy/proxy.go | 134 +++++++++++++------ pkg/gitagent/proxy/proxy_test.go | 69 +++++++++- pkg/gitagent/receiver.go | 47 ++++++- pkg/gitagent/regression_test.go | 167 ++++++++++++++++++++++++ pkg/gitagent/snapshot.go | 11 +- pkg/gitagent/sshclient.go | 4 +- pkg/gitagent/state.go | 46 ++++++- pkg/gitagent/workspace.go | 1 + pkg/sandbox/adapter/gitagent.go | 38 +++++- pkg/sandbox/adapter/gitagent_test.go | 16 +++ 36 files changed, 898 insertions(+), 133 deletions(-) create mode 100644 pkg/cli/gitagent_runtask_test.go create mode 100644 pkg/gitagent/regression_test.go create mode 100644 pkg/sandbox/adapter/gitagent_test.go diff --git a/pkg/captainconfig/config.go b/pkg/captainconfig/config.go index 8b8b6020..7d0c9f2f 100644 --- a/pkg/captainconfig/config.go +++ b/pkg/captainconfig/config.go @@ -244,6 +244,9 @@ var pathOverride string // processes that cannot rely on $HOME: a git receive hook runs as a child of // whoever pushed, so its ambient home is the pusher's, not the one the // receiver was configured under. +// +// Call SetPath during process startup, before concurrent calls to Path. The +// override is process-global and intentionally unsynchronized. func SetPath(p string) { pathOverride = p } // SetPathForTesting redirects Path() to the given absolute file path. Tests diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 5b9ab75c..2a9a0717 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -346,6 +346,7 @@ func executePromptRequest(parent context.Context, req ai.Request, cfg ai.Config, return nil, err } defer cleanup() + defer closeProvider(p) if streamer, ok := p.(ai.StreamingProvider); ok && !noStream && !req.Prompt.HasSchema() { return runStreaming(ctx, streamer, req) @@ -462,7 +463,12 @@ func buildProvider(ctx context.Context, req *ai.Request, cfg ai.Config) (ai.Prov if remote, err := remoteExecProviderFor(req, cfg); err != nil { return nil, cleanup, err } else if remote != nil { - return remote, cleanup, nil + wrapped, err := middleware.Wrap(remote, middleware.WithLogging(), middleware.WithSchemaValidation(cfg)) + if err != nil { + closeProvider(remote) + return nil, cleanup, err + } + return bufferedOnlyProvider{Provider: wrapped}, cleanup, nil } prepared, err := setup.Apply(ctx, req, "") if err != nil { diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go index 839ab745..71d3787e 100644 --- a/pkg/cli/ai_prompt_file.go +++ b/pkg/cli/ai_prompt_file.go @@ -187,9 +187,16 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque } // Record the winning sandbox on the request when the flag overrode it, so the - // serialized spec carries the choice the run was actually made with. + // serialized spec carries the choice the run was actually made with. The + // selector only replaces the backend; an independently declared agent and + // policy remain part of the request. if selector := o.SandboxSelector(); selector != "" { - req.Sandbox = &api.SandboxRef{Backend: selector} + ref := api.SandboxRef{Backend: selector} + if base.Sandbox != nil { + ref.Agent = base.Sandbox.Agent + ref.Policy = base.Sandbox.Policy + } + req.Sandbox = &ref } // Config mirrors the resolved model + budget; runtime-only knobs from CLI+saved. diff --git a/pkg/cli/ai_prompt_file_test.go b/pkg/cli/ai_prompt_file_test.go index 1d03de4e..89e8091b 100644 --- a/pkg/cli/ai_prompt_file_test.go +++ b/pkg/cli/ai_prompt_file_test.go @@ -196,6 +196,31 @@ func TestOverlayCLI_SandboxNoneClearsInherited(t *testing.T) { } } +func TestOverlayCLI_SandboxFlagPreservesFrontmatterAgentAndPolicy(t *testing.T) { + isolateSavedAI(t) + base := baseFileReq() + base.Sandbox = &api.SandboxRef{ + Backend: "old-pool", + Agent: "worker-01", + Policy: &api.SandboxPolicy{Paths: []string{"pkg/**"}, MaxAttempts: 3}, + } + opts := AIPromptOptions{AIRuntimeOptions: AIRuntimeOptions{AIProviderOptions: AIProviderOptions{Sandbox: "git-agent"}}} + + req, cfg, err := overlayCLI(base, ai.Config{}, opts) + if err != nil { + t.Fatal(err) + } + if req.Sandbox == nil || req.Sandbox.Backend != "git-agent" || req.Sandbox.Agent != "worker-01" { + t.Fatalf("request sandbox = %#v", req.Sandbox) + } + if req.Sandbox.Policy == nil || req.Sandbox.Policy.MaxAttempts != 3 { + t.Fatalf("request policy = %#v", req.Sandbox.Policy) + } + if cfg.SandboxSelection == nil || cfg.SandboxSelection.Agent != "worker-01" || cfg.SandboxSelection.Policy != req.Sandbox.Policy { + t.Fatalf("config sandbox = %#v", cfg.SandboxSelection) + } +} + // --api-url is what points a run at a `captain ai mock` endpoint, so it has to // survive the overlay; a prompt file may pin its own endpoint, and the flag wins. func TestOverlayCLI_APIURLFlagBeatsFrontmatter(t *testing.T) { diff --git a/pkg/cli/ai_sandbox_remote.go b/pkg/cli/ai_sandbox_remote.go index feb3b804..320ee947 100644 --- a/pkg/cli/ai_sandbox_remote.go +++ b/pkg/cli/ai_sandbox_remote.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" "time" "github.com/flanksource/captain/pkg/ai" @@ -73,12 +74,31 @@ type remoteExecProvider struct { sandbox api.Sandbox model string backend api.Backend + close sync.Once + closeErr error } +// bufferedOnlyProvider preserves a provider's buffered capability after +// middleware wrapping. Middleware supports streaming when its inner provider +// does, but its wrapper methods must not advertise streaming for a remote run. +type bufferedOnlyProvider struct{ ai.Provider } + +func (p bufferedOnlyProvider) Unwrap() ai.Provider { return p.Provider } + func (p *remoteExecProvider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { - defer p.sandbox.Close() return p.executor.Execute(ctx, req) } func (p *remoteExecProvider) GetModel() string { return p.model } func (p *remoteExecProvider) GetBackend() api.Backend { return p.backend } + +// Close releases the prepared remote sandbox. It is idempotent because both +// provider setup failures and execution completion can reach this boundary. +func (p *remoteExecProvider) Close() error { + p.close.Do(func() { + if p.sandbox != nil { + p.closeErr = p.sandbox.Close() + } + }) + return p.closeErr +} diff --git a/pkg/cli/ai_sandbox_remote_test.go b/pkg/cli/ai_sandbox_remote_test.go index 2af5a135..88f42f1a 100644 --- a/pkg/cli/ai_sandbox_remote_test.go +++ b/pkg/cli/ai_sandbox_remote_test.go @@ -1,6 +1,8 @@ package cli import ( + "context" + "errors" "testing" "time" @@ -10,6 +12,20 @@ import ( "github.com/flanksource/captain/pkg/sandbox/adapter" ) +type remoteSandboxStub struct{ closes int } + +func (s *remoteSandboxStub) Kind() api.SandboxKind { return registry.SandboxGitAgent } +func (s *remoteSandboxStub) Prepare(context.Context, *api.Spec) (*api.SandboxSession, error) { + return &api.SandboxSession{}, nil +} +func (s *remoteSandboxStub) Close() error { s.closes++; return nil } + +type remoteExecutorStub struct{ err error } + +func (s remoteExecutorStub) Execute(context.Context, api.Spec) (*api.Response, error) { + return nil, s.err +} + // A remote-executing selection must replace provider execution. Without this // the run silently falls through to the model provider and the sandbox is a // no-op — which is indistinguishable from success until a dispatch never @@ -43,6 +59,23 @@ func TestRemoteExecProviderForRoutesGitAgent(t *testing.T) { } } +func TestBuildProviderWrapsRemoteWithoutAdvertisingStreaming(t *testing.T) { + req := &ai.Request{} + cfg := ai.Config{SandboxSelection: &api.SandboxConfig{Kind: registry.SandboxGitAgent}} + provider, cleanup, err := buildProvider(context.Background(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer cleanup() + defer closeProvider(provider) + if _, ok := provider.(ai.StreamingProvider); ok { + t.Fatalf("wrapped remote provider %T advertises unsupported streaming", provider) + } + if _, ok := api.ProviderAs[*remoteExecProvider](provider); !ok { + t.Fatalf("wrapped provider %T hides the remote provider", provider) + } +} + // A relocated run waits on a remote agent doing real work. Inheriting the // local request default kills a dispatch that is still progressing and reports // it as a failure ("dispatched but not concluded: context deadline exceeded"). @@ -102,3 +135,20 @@ func TestRemoteExecProviderForLeavesLocalSandboxesAlone(t *testing.T) { } } } + +func TestRemoteExecProviderCloseIsIdempotentAfterFailure(t *testing.T) { + sandbox := &remoteSandboxStub{} + provider := &remoteExecProvider{executor: remoteExecutorStub{err: errors.New("failed")}, sandbox: sandbox} + if _, err := provider.Execute(context.Background(), ai.Request{}); err == nil { + t.Fatal("Execute returned nil error") + } + if err := provider.Close(); err != nil { + t.Fatal(err) + } + if err := provider.Close(); err != nil { + t.Fatal(err) + } + if sandbox.closes != 1 { + t.Fatalf("sandbox closed %d times, want once", sandbox.closes) + } +} diff --git a/pkg/cli/gitagent.go b/pkg/cli/gitagent.go index 77ca04fc..ee152543 100644 --- a/pkg/cli/gitagent.go +++ b/pkg/cli/gitagent.go @@ -102,6 +102,7 @@ type GitAgentAddResult struct { HostFingerprint string `json:"hostFingerprint" pretty:"label=Host key"` DispatchKey string `json:"dispatchKey" pretty:"label=Dispatch key"` JoinCommand string `json:"joinCommand" pretty:"label=Join command"` + DryRun bool `json:"dryRun,omitempty" pretty:"label=Dry Run"` } func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { @@ -124,7 +125,7 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { clicky.Printf("[dry-run] would mint a single-use join token (TTL %s) for agent %q\n", gitagent.JoinTokenTTL, opts.Name) clicky.Printf("[dry-run] would record the pending enrollment under sandbox.backends.%s in %s\n", opts.Backend, configPathForDisplay()) clicky.Printf("[dry-run] would print the join command for endpoint %s\n", endpoint) - return nil, nil + return GitAgentAddResult{Backend: opts.Backend, Agent: opts.Name, DryRun: true}, nil } _, hostFP, err := gitagent.EnsureKeyPair(hostKeyPath) if err != nil { @@ -142,7 +143,10 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { } expires := time.Now().UTC().Add(gitagent.JoinTokenTTL) err = captainconfig.Update(func(cfg *captainconfig.Config) error { - backend := ensureGitAgentBackend(cfg, opts.Backend) + backend, err := ensureGitAgentBackend(cfg, opts.Backend) + if err != nil { + return err + } pending, _ := backend.Options["pending"].(map[string]any) if pending == nil { pending = map[string]any{} @@ -235,21 +239,25 @@ type GitAgentRevokeResult struct { func RunGitAgentRevoke(opts GitAgentRevokeOptions) (any, error) { if opts.DryRun { + cfg, _, err := captainconfig.Load() + if err != nil { + return nil, err + } + if _, err := enrolledAgent(cfg, opts.Backend, opts.Name); err != nil { + return nil, err + } clicky.Printf("[dry-run] would remove agent %q from sandbox.backends.%s.agents in %s\n", opts.Name, opts.Backend, configPathForDisplay()) return GitAgentRevokeResult{Backend: opts.Backend, Agent: opts.Name, DryRun: true}, nil } var fingerprint string err := captainconfig.Update(func(cfg *captainconfig.Config) error { - backend, ok := cfg.Sandbox.Backends[opts.Backend] - if !ok { - return fmt.Errorf("backend %q has no enrolled agents", opts.Backend) + entry, err := enrolledAgent(*cfg, opts.Backend, opts.Name) + if err != nil { + return err } + backend := cfg.Sandbox.Backends[opts.Backend] agents, _ := backend.Options["agents"].(map[string]any) - entry, ok := agents[opts.Name].(map[string]any) - if !ok { - return fmt.Errorf("agent %q is not enrolled in backend %q", opts.Name, opts.Backend) - } fingerprint, _ = entry["fingerprint"].(string) delete(agents, opts.Name) if len(agents) == 0 { @@ -271,6 +279,19 @@ func RunGitAgentRevoke(opts GitAgentRevokeOptions) (any, error) { }, nil } +func enrolledAgent(cfg captainconfig.Config, backendName, agentName string) (map[string]any, error) { + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return nil, fmt.Errorf("backend %q has no enrolled agents", backendName) + } + agents, _ := backend.Options["agents"].(map[string]any) + entry, ok := agents[agentName].(map[string]any) + if !ok { + return nil, fmt.Errorf("agent %q is not enrolled in backend %q", agentName, backendName) + } + return entry, nil +} + func gitAgentBackendEndpoint(backend string) string { cfg, _, err := captainconfig.Load() if err == nil { diff --git a/pkg/cli/gitagent_directory.go b/pkg/cli/gitagent_directory.go index ed027695..47b9d246 100644 --- a/pkg/cli/gitagent_directory.go +++ b/pkg/cli/gitagent_directory.go @@ -95,7 +95,10 @@ func (d gitAgentDirectory) RecordAgent(e gitagent.AgentEnrollment) error { return fmt.Errorf("agent %q advertised no host key fingerprint; its dispatch could not be verified", e.Name) } return captainconfig.Update(func(cfg *captainconfig.Config) error { - backend := ensureGitAgentBackend(cfg, d.backend) + backend, err := ensureGitAgentBackend(cfg, d.backend) + if err != nil { + return err + } agents, _ := backend.Options["agents"].(map[string]any) if agents == nil { agents = map[string]any{} @@ -115,17 +118,21 @@ func (d gitAgentDirectory) RecordAgent(e gitagent.AgentEnrollment) error { // ensureGitAgentBackend returns the named backend, creating a git-agent one // (with an initialized Options map) when absent so `add` works on a fresh // config. -func ensureGitAgentBackend(cfg *captainconfig.Config, name string) captainconfig.SandboxBackend { +func ensureGitAgentBackend(cfg *captainconfig.Config, name string) (captainconfig.SandboxBackend, error) { if cfg.Sandbox.Backends == nil { cfg.Sandbox.Backends = map[string]captainconfig.SandboxBackend{} } backend, ok := cfg.Sandbox.Backends[name] if !ok { backend = captainconfig.SandboxBackend{Kind: string(registry.SandboxGitAgent)} + } else if backend.Kind != "" && backend.Kind != string(registry.SandboxGitAgent) { + return backend, fmt.Errorf("backend %q is kind %q, not %s", name, backend.Kind, registry.SandboxGitAgent) + } else if backend.Kind == "" { + backend.Kind = string(registry.SandboxGitAgent) } if backend.Options == nil { backend.Options = map[string]any{} } cfg.Sandbox.Backends[name] = backend - return backend + return backend, nil } diff --git a/pkg/cli/gitagent_e2e_test.go b/pkg/cli/gitagent_e2e_test.go index 5d5d05ee..9e4a6c7f 100644 --- a/pkg/cli/gitagent_e2e_test.go +++ b/pkg/cli/gitagent_e2e_test.go @@ -444,6 +444,9 @@ func TestFullCycleWithAManualAgent(t *testing.T) { worktree, taskID = candidate, e.Name() } } + if worktree != "" { + break + } select { case err := <-dispatchDone: t.Fatalf("dispatch exited before creating a workspace (%v):\n%s", err, dispatchOut.String()) diff --git a/pkg/cli/gitagent_hook.go b/pkg/cli/gitagent_hook.go index 116477da..b5c5344d 100644 --- a/pkg/cli/gitagent_hook.go +++ b/pkg/cli/gitagent_hook.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "encoding/json" "fmt" @@ -8,6 +9,8 @@ import ( "path/filepath" "strings" + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/middleware" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" "github.com/flanksource/captain/pkg/gitagent" @@ -38,6 +41,15 @@ func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) fmt.Fprintf(os.Stderr, "captain: %v\n", err) return nil, err } + var judge ai.Provider + if opts.Hook == "pre-receive" { + judge, err = hookJudgeProvider(runtime) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return nil, err + } + } + defer closeProvider(judge) exe, err := os.Executable() if err != nil { return nil, err @@ -45,6 +57,7 @@ func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) configPath := strings.TrimSpace(opts.Config) host := gitagent.HookHost{ Runtime: runtime, + Judge: judge, Wrap: wrap, // With no agentCommand configured, the sidecar still launches a real // agent: this binary, working the task in the prepared worktree. The @@ -65,6 +78,32 @@ func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) } } +// hookJudgeProvider builds the local provider used by receiver-side prompt +// checks. It pins sandboxing to none because the hook is already executing at +// the remote receiver; selecting git-agent here would recursively dispatch. +func hookJudgeProvider(runtime gitagent.HookRuntime) (ai.Provider, error) { + if !runtime.RequiresJudge() { + return nil, nil + } + cfg, err := (AIProviderOptions{Sandbox: "none"}).ToConfig() + if err != nil { + return nil, fmt.Errorf("configure hook judge: %w", err) + } + if strings.TrimSpace(cfg.Model.Name) == "" { + return nil, fmt.Errorf("verify prompts declared but no model is configured for the hook judge") + } + provider, err := ai.NewProvider(cfg) + if err != nil { + return nil, fmt.Errorf("create hook judge: %w", err) + } + wrapped, err := middleware.Wrap(provider, middleware.WithLogging(), middleware.WithSchemaValidation(cfg)) + if err != nil { + closeProvider(provider) + return nil, fmt.Errorf("wrap hook judge: %w", err) + } + return wrapped, nil +} + // hookRuntimeFromConfig assembles the receiver runtime from the backend's // options block: the two hook-set workflows, the confinement sandbox for exec // hooks, the agent launch command, the integration target, and the relay @@ -104,8 +143,8 @@ func hookRuntimeFromConfig(backendName string) (gitagent.HookRuntime, error) { rt.Relay = gitagent.RelayTarget{ URL: url, HostFingerprint: hostFP, - KeyPath: filepath.Join(keysDir, "agent_ed25519"), - SSHCommand: exe + " sandbox git-agent ssh", + KeyPath: filepath.Join(keysDir, agentKeyName), + SSHCommand: gitagent.SSHTransportCommand(exe), } } return rt, nil @@ -131,7 +170,9 @@ func decodeWorkflow(v any) (*api.Workflow, error) { return nil, err } var wf api.Workflow - if err := json.Unmarshal(data, &wf); err != nil { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&wf); err != nil { return nil, err } if err := wf.Validate(); err != nil { diff --git a/pkg/cli/gitagent_runtask.go b/pkg/cli/gitagent_runtask.go index 771494b1..5e5cbf4e 100644 --- a/pkg/cli/gitagent_runtask.go +++ b/pkg/cli/gitagent_runtask.go @@ -15,13 +15,7 @@ import ( "github.com/flanksource/captain/pkg/gitagent" ) -// The default coding agent a sidecar launches. It is captain driving whichever -// CLI runtime the supervisor resolved, in the prepared worktree, followed by -// the two commands the protocol asks of an agent: commit and push. -// -// This exists because the alternative — leaving agentCommand empty and telling -// operators to write their own — makes the advertised `add → join → ai prompt` -// flow silently wait forever on a task nothing ever started. +// GitAgentRunTaskOptions configures the detached sidecar task runner. type GitAgentRunTaskOptions struct { Repo string `flag:"repo" help:"The sidecar's bare repository"` Task string `flag:"task" help:"Task id to work on"` @@ -70,6 +64,7 @@ func runTaskPrompt(ctx context.Context, worktree string, payload gitagent.TaskPa req.Prompt.User = payload.Prompt req.Prompt.System = payload.System req.Model = cfg.Model + req.Budget.Timeout = payload.Timeout req.SetCwd(worktree) // Editing is the point: a coding agent that cannot write files produces an // empty result and an unexplained silence on the supervisor. @@ -129,8 +124,10 @@ func hasStagedChanges(ctx context.Context, dir string) (bool, error) { return false, fmt.Errorf("git diff --cached: %w", err) } -// DefaultAgentCommand is the command a sidecar launches when the backend -// declares none: this binary, working the task in place. +// DefaultAgentCommand is the coding agent a sidecar launches when the backend +// declares none: Captain drives the supervisor-selected CLI runtime in the +// prepared worktree, then commits and pushes the result. A concrete default +// keeps an unconfigured sidecar from silently leaving dispatched work idle. func DefaultAgentCommand(captainBin, repo, task, configPath string) string { command := fmt.Sprintf("%q sandbox git-agent run-task --repo %q --task %q", captainBin, repo, task) if strings.TrimSpace(configPath) != "" { diff --git a/pkg/cli/gitagent_runtask_test.go b/pkg/cli/gitagent_runtask_test.go new file mode 100644 index 00000000..1c9297dc --- /dev/null +++ b/pkg/cli/gitagent_runtask_test.go @@ -0,0 +1,33 @@ +package cli + +import ( + "context" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" +) + +func TestRunTaskPromptCarriesSupervisorTimeout(t *testing.T) { + isolateSavedAI(t) + original := executePromptRequestFunc + t.Cleanup(func() { executePromptRequestFunc = original }) + + var captured ai.Request + executePromptRequestFunc = func(_ context.Context, req ai.Request, _ ai.Config, _ time.Duration, _ bool) (any, error) { + captured = req + return nil, nil + } + payload := gitagent.TaskPayload{ + Prompt: "make a change", Model: "gpt-5.6-sol", + Backend: string(api.BackendCodexCLI), Timeout: "17m", + } + if err := runTaskPrompt(context.Background(), t.TempDir(), payload); err != nil { + t.Fatal(err) + } + if captured.Budget.Timeout != "17m" { + t.Fatalf("timeout = %q, want 17m", captured.Budget.Timeout) + } +} diff --git a/pkg/cli/gitagent_serve.go b/pkg/cli/gitagent_serve.go index 55a7244c..b6331a1b 100644 --- a/pkg/cli/gitagent_serve.go +++ b/pkg/cli/gitagent_serve.go @@ -126,6 +126,15 @@ func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir, roo if err != nil { return fmt.Errorf("--listen %q must be [host]:port: %w", opts.Listen, err) } + // Verify the local half can be persisted before consuming the supervisor's + // single-use token. The exchange cannot be transactional across hosts, but + // this catches path, permission, and backend-kind failures up front. + if err := captainconfig.Update(func(cfg *captainconfig.Config) error { + _, err := ensureGitAgentBackend(cfg, opts.Backend) + return err + }); err != nil { + return fmt.Errorf("prepare local enrollment config: %w", err) + } resp, err := gitagent.Enroll(ctx, opts.Supervisor, opts.Join, opts.HostFingerprint, signer, gitagent.EnrollRequest{ AdvertiseURL: advertiseURL(opts.Advertise), ListenPort: port, @@ -139,11 +148,14 @@ func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir, roo return err } err = captainconfig.Update(func(cfg *captainconfig.Config) error { - backend := ensureGitAgentBackend(cfg, opts.Backend) + backend, err := ensureGitAgentBackend(cfg, opts.Backend) + if err != nil { + return err + } // Where the relay pushes, and the host key to pin when it does. backend.Options["supervisor"] = map[string]any{ "url": mailboxURL, - "hostFingerprint": opts.HostFingerprint, + "hostFingerprint": strings.TrimSpace(opts.HostFingerprint), } // Authorize the supervisor's dispatch key so its push is accepted // here — the direction a one-way enrollment leaves broken. @@ -157,7 +169,7 @@ func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir, roo return nil }) if err != nil { - return err + return fmt.Errorf("supervisor enrolled agent %q, but the local relay config could not be saved; mint a new join token and retry after fixing the config: %w", resp.Agent, err) } clicky.Printf("enrolled as %s\n", resp.Agent) clicky.Printf(" this agent's key: %s\n", fp) @@ -243,7 +255,10 @@ func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverR // integrate accepted work into. func recordMailboxRepo(backendName, repo string) error { return captainconfig.Update(func(cfg *captainconfig.Config) error { - backend := ensureGitAgentBackend(cfg, backendName) + backend, err := ensureGitAgentBackend(cfg, backendName) + if err != nil { + return err + } backend.Options["repo"] = repo cfg.Sandbox.Backends[backendName] = backend return nil diff --git a/pkg/cli/gitagent_test.go b/pkg/cli/gitagent_test.go index a9384faf..d2ec0410 100644 --- a/pkg/cli/gitagent_test.go +++ b/pkg/cli/gitagent_test.go @@ -71,7 +71,10 @@ func TestGitAgentExpiredTokenRefused(t *testing.T) { t.Fatal(err) } err = captainconfig.Update(func(cfg *captainconfig.Config) error { - backend := ensureGitAgentBackend(cfg, "git-agent") + backend, err := ensureGitAgentBackend(cfg, "git-agent") + if err != nil { + return err + } backend.Options["pending"] = map[string]any{ hash: map[string]any{ "agent": "worker-1", @@ -115,6 +118,9 @@ func TestGitAgentEnrollListRevoke(t *testing.T) { if len(entries) != 1 || entries[0].Name != "worker-1" || entries[0].Status != "enrolled" { t.Fatalf("entries = %+v", entries) } + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}); err != nil { + t.Fatalf("dry-run existing agent: %v", err) + } if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent"}); err != nil { t.Fatal(err) @@ -126,13 +132,20 @@ func TestGitAgentEnrollListRevoke(t *testing.T) { if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent"}); err == nil { t.Fatal("revoking an unknown agent must error") } + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}); err == nil { + t.Fatal("dry-run revoking an unknown agent must error") + } } func TestGitAgentAddDryRunTouchesNothing(t *testing.T) { path := isolatedConfig(t) - if _, err := RunGitAgentAdd(GitAgentAddOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}); err != nil { + result, err := RunGitAgentAdd(GitAgentAddOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}) + if err != nil { t.Fatal(err) } + if add, ok := result.(GitAgentAddResult); !ok || !add.DryRun { + t.Fatalf("dry-run result = %#v", result) + } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("dry-run must not write the config, stat err = %v", err) } @@ -141,3 +154,20 @@ func TestGitAgentAddDryRunTouchesNothing(t *testing.T) { t.Fatalf("dry-run must not create key material, stat err = %v", err) } } + +func TestEnsureGitAgentBackendRejectsDifferentKind(t *testing.T) { + cfg := captainconfig.Config{} + cfg.Sandbox.Backends = map[string]captainconfig.SandboxBackend{ + "shared": {Kind: "srt"}, + } + if _, err := ensureGitAgentBackend(&cfg, "shared"); err == nil || !strings.Contains(err.Error(), "not git-agent") { + t.Fatalf("error = %v", err) + } +} + +func TestDecodeWorkflowRejectsUnknownFields(t *testing.T) { + _, err := decodeWorkflow(map[string]any{"verify": map[string]any{"promtps": []string{"judge.prompt"}}}) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("error = %v", err) + } +} diff --git a/pkg/gitagent/admit.go b/pkg/gitagent/admit.go index 8dd50005..80308949 100644 --- a/pkg/gitagent/admit.go +++ b/pkg/gitagent/admit.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "io" + "strconv" "strings" "github.com/flanksource/captain/pkg/ai/agent/commit" @@ -19,10 +20,10 @@ type RefUpdate struct { } // IsCreate reports whether the update creates the ref. -func (u RefUpdate) IsCreate() bool { return u.Old == zeroOID } +func (u RefUpdate) IsCreate() bool { return isZeroOID(u.Old) } // IsDelete reports whether the update deletes the ref. -func (u RefUpdate) IsDelete() bool { return u.New == zeroOID } +func (u RefUpdate) IsDelete() bool { return isZeroOID(u.New) } // ParseRefUpdates reads pre-receive stdin lines. func ParseRefUpdates(r io.Reader) ([]RefUpdate, error) { @@ -300,8 +301,10 @@ func admitBlobCaps(ctx context.Context, req AdmitRequest, st *TaskState, tip str for _, line := range strings.Split(sizes, "\n") { fields := strings.Fields(line) if len(fields) == 3 && fields[0] == "blob" { - var size int64 - fmt.Sscanf(fields[1], "%d", &size) + size, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return fmt.Errorf("gate:blob-size cannot read the size of object %s: %w", fields[2], err) + } if size > maxBlob { return fmt.Errorf("gate:blob-size object %s is %d bytes, over the %d-byte cap", fields[2], size, maxBlob) } diff --git a/pkg/gitagent/conformance_ginkgo_test.go b/pkg/gitagent/conformance_ginkgo_test.go index 1ccf2fef..aa970801 100644 --- a/pkg/gitagent/conformance_ginkgo_test.go +++ b/pkg/gitagent/conformance_ginkgo_test.go @@ -58,9 +58,10 @@ func writeRuntime(dir string, rt gitagent.HookRuntime) string { // testSSHCommand is the GIT_SSH_COMMAND for pushes in the conformance world: // the test binary with its ssh persona armed and the hook persona disarmed. func testSSHCommand() string { + GinkgoHelper() exe, err := os.Executable() Expect(err).NotTo(HaveOccurred()) - return "env CAPTAIN_TEST_SSH_CLIENT=1 CAPTAIN_TEST_HOOK= " + exe + return fmt.Sprintf("env CAPTAIN_TEST_SSH_CLIENT=1 CAPTAIN_TEST_HOOK= %q", exe) } // newConformanceWorld wires the full topology. sidecarWF and supervisorWF are diff --git a/pkg/gitagent/control.go b/pkg/gitagent/control.go index a267b4b7..72495767 100644 --- a/pkg/gitagent/control.go +++ b/pkg/gitagent/control.go @@ -29,7 +29,7 @@ func BuildControlCommit(ctx context.Context, repoDir string, env []string, paylo } names := make([]string, 0, len(payloads)) for name := range payloads { - if name == "" || strings.ContainsAny(name, "/\x00") { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") { return "", fmt.Errorf("control payload name %q must be a bare file name", name) } names = append(names, name) diff --git a/pkg/gitagent/dispatch.go b/pkg/gitagent/dispatch.go index 8b84ea2e..ff98dd07 100644 --- a/pkg/gitagent/dispatch.go +++ b/pkg/gitagent/dispatch.go @@ -25,6 +25,9 @@ type TaskPayload struct { // the coding agent that was actually selected rather than re-resolving the // model name against its own defaults. Backend string `json:"backend,omitempty"` + // Timeout is the supervisor's effective deadline. The relocated runner + // must not fall back to the shorter local model-call default. + Timeout string `json:"timeout,omitempty"` } // LoadTaskPayload reads a materialized task.json. @@ -156,10 +159,9 @@ func recordDispatch(ctx context.Context, req DispatchRequest, task string, snaps if err != nil { return err } - if _, err := runGit(ctx, req.MailboxPath, env, "update-ref", dispatchRef, snapshot.Commit); err != nil { - return err - } - if _, err := runGit(ctx, req.MailboxPath, env, "update-ref", controlRef, control); err != nil { + updates := fmt.Sprintf("start\ncreate %s %s\ncreate %s %s\nprepare\ncommit\n", + dispatchRef, snapshot.Commit, controlRef, control) + if _, err := runGitIn(ctx, req.MailboxPath, env, strings.NewReader(updates), "update-ref", "--stdin"); err != nil { return err } return SaveTaskState(req.MailboxPath, &TaskState{ @@ -222,7 +224,7 @@ func transportPairs(sshCommand, keyPath, hostFingerprint string) ([]string, erro if err != nil { return nil, err } - sshCommand = exe + " sandbox git-agent ssh" + sshCommand = SSHTransportCommand(exe) } return []string{ "GIT_SSH_COMMAND=" + sshCommand, @@ -232,6 +234,12 @@ func transportPairs(sshCommand, keyPath, hostFingerprint string) ([]string, erro }, nil } +// SSHTransportCommand renders this binary's SSH transport as a shell-safe +// GIT_SSH_COMMAND value. Git evaluates the value through a shell. +func SSHTransportCommand(executable string) string { + return "'" + strings.ReplaceAll(executable, "'", "'\"'\"'") + "' sandbox git-agent ssh" +} + // AwaitOutcome polls the mailbox for the task's final verdict: the first // accepted one, or a rejection on the task's last permitted attempt, or a // timeout. Rejected non-final attempts keep waiting — rejection is not diff --git a/pkg/gitagent/enroll.go b/pkg/gitagent/enroll.go index 9cd67574..fee149a4 100644 --- a/pkg/gitagent/enroll.go +++ b/pkg/gitagent/enroll.go @@ -93,7 +93,8 @@ func HashJoinToken(token string) string { // back. The host key is verified against the fingerprint printed by // `git-agent add` — never trusted on first use. func Enroll(ctx context.Context, endpoint, token, hostFingerprint string, signer gossh.Signer, req EnrollRequest) (*EnrollResponse, error) { - if strings.TrimSpace(hostFingerprint) == "" { + hostFingerprint = strings.TrimSpace(hostFingerprint) + if hostFingerprint == "" { return nil, fmt.Errorf("enrollment requires the supervisor's host-key fingerprint (printed by `captain sandbox git-agent add`)") } addr, user, err := splitSSHEndpoint(endpoint) @@ -195,7 +196,8 @@ func splitSSHEndpoint(endpoint string) (addr, user string, err error) { user, target = target[:at], target[at+1:] } if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { - target = net.JoinHostPort(target, "22") + host := strings.TrimSuffix(strings.TrimPrefix(target, "["), "]") + target = net.JoinHostPort(host, "22") } return target, user, nil } diff --git a/pkg/gitagent/envelope.go b/pkg/gitagent/envelope.go index 5f1861db..63b95940 100644 --- a/pkg/gitagent/envelope.go +++ b/pkg/gitagent/envelope.go @@ -1,6 +1,7 @@ // The control envelope rides on push options (§4): option 0 is the version // tag, the remainder are key=value pairs. Envelope values are never read from // commit trailers — trailers are heuristically extracted and forgeable (R4.2). + package gitagent import ( @@ -48,6 +49,10 @@ func ValidateOID(s string) error { return nil } +func isZeroOID(oid string) bool { + return (len(oid) == 40 || len(oid) == 64) && strings.Trim(oid, "0") == "" +} + // Envelope is the §4 control envelope. type Envelope struct { Version int `json:"v"` diff --git a/pkg/gitagent/git.go b/pkg/gitagent/git.go index 325406ca..f43dc77d 100644 --- a/pkg/gitagent/git.go +++ b/pkg/gitagent/git.go @@ -78,7 +78,13 @@ func gitExitCodeStderr(ctx context.Context, dir string, env []string, stderr io. } var exitErr *exec.ExitError if errors.As(err, &exitErr) { - return exitErr.ExitCode(), stdout.String(), nil + if code := exitErr.ExitCode(); code >= 0 { + return code, stdout.String(), nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return -1, stdout.String(), ctxErr + } + return -1, stdout.String(), fmt.Errorf("git %s terminated by signal: %w", strings.Join(args, " "), err) } return -1, "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) } diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go index 35472fc9..4abb96a2 100644 --- a/pkg/gitagent/hookmain.go +++ b/pkg/gitagent/hookmain.go @@ -33,6 +33,16 @@ type HookRuntime struct { Relay RelayTarget `json:"relay,omitempty"` // sidecar: the supervisor mailbox } +// RequiresJudge reports whether either receiver tier declares prompt checks. +func (r HookRuntime) RequiresJudge() bool { + for _, workflow := range []*api.Workflow{r.SidecarWorkflow, r.SupervisorWorkflow} { + if workflow != nil && workflow.Verify != nil && len(workflow.Verify.Prompts) > 0 { + return true + } + } + return false +} + // HookHost is a runtime plus the process-local collaborators a hook set needs. type HookHost struct { Runtime HookRuntime @@ -123,6 +133,10 @@ func HookMain(args []string) int { fmt.Fprintf(os.Stderr, "captain: %v\n", err) return 1 } + if runtime.RequiresJudge() { + fmt.Fprintln(os.Stderr, "captain: verify prompts declared but the standalone hook shim has no provider; use `captain sandbox git-agent hook`") + return 1 + } wrap, err := ResolveHookWrap(runtime.HookSandbox) if err != nil { fmt.Fprintf(os.Stderr, "captain: %v\n", err) @@ -186,14 +200,25 @@ func sidecarPreReceive(ctx context.Context, repo string, host HookHost, updates if !ok { return nil } - st, found, err := LoadTaskState(repo, task) - if err != nil || !found { + var attempt int + st, err := UpdateTaskState(repo, task, func(current *TaskState) (bool, error) { + attempt = current.Attempts + 1 + if current.Policy.MaxAttempts > 0 && attempt > current.Policy.MaxAttempts { + return false, nil + } + current.Attempts = attempt + return true, nil + }) + if err != nil { + fmt.Fprintf(sideband, "captain: %v\n", err) + return err + } + if st == nil { fmt.Fprintf(sideband, "captain: task state missing for %s\n", task) return fmt.Errorf("task state missing for %s", task) } // An attempt is consumed per submit, rejected or not (§6.3): the retry // after a rejection is attempt n+1. - attempt := st.Attempts + 1 if st.Policy.MaxAttempts > 0 && attempt > st.Policy.MaxAttempts { verdict := TierVerdict{ V: ProtocolVersion, Task: task, Attempt: attempt, Tier: string(RoleSidecar), Status: StatusRejected, @@ -202,11 +227,6 @@ func sidecarPreReceive(ctx context.Context, repo string, host HookHost, updates } return rejectWithVerdict(repo, verdict, sideband) } - st.Attempts = attempt - if err := SaveTaskState(repo, st); err != nil { - fmt.Fprintf(sideband, "captain: %v\n", err) - return err - } verdict := vetTree(ctx, repo, vetRequest{ host: host, workflow: host.Runtime.SidecarWorkflow, tier: string(RoleSidecar), task: task, attempt: attempt, depth: 0, @@ -399,7 +419,7 @@ func loadDispatchPayloads(ctx context.Context, repo string, updates []RefUpdate, return policy, taskPayload, "" } control := refUpdateFor(updates, controlRef) - if control.New == "" || control.New == zeroOID { + if control.New == "" || isZeroOID(control.New) { return policy, taskPayload, "" } env := os.Environ() @@ -444,8 +464,12 @@ func mailboxPostReceive(ctx context.Context, repo string, host HookHost, updates }) } } - st.Attempts = info.Attempt - if err := SaveTaskState(repo, st); err != nil { + if _, err := UpdateTaskState(repo, info.Task, func(current *TaskState) (bool, error) { + if info.Attempt > current.Attempts { + current.Attempts = info.Attempt + } + return true, nil + }); err != nil { return err } if err := SaveVerdict(repo, verdict); err != nil { diff --git a/pkg/gitagent/hookset_ginkgo_test.go b/pkg/gitagent/hookset_ginkgo_test.go index 4a58445d..fc12be74 100644 --- a/pkg/gitagent/hookset_ginkgo_test.go +++ b/pkg/gitagent/hookset_ginkgo_test.go @@ -55,6 +55,15 @@ var _ = Describe("materialization", func() { Expect(os.ReadFile(filepath.Join(dst, "src", "dirty.go"))).To(Equal([]byte("package main // dirty\n"))) }) + It("ignores unrelated files already present in the destination", func() { + f := newAdmitFixture(ctx) + dst := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(dst, "stale.txt"), []byte("stale"), 0o644)).To(Succeed()) + count, err := gitagent.Materialize(ctx, f.super, os.Environ(), f.snap.Commit, dst) + Expect(err).NotTo(HaveOccurred()) + Expect(count).To(Equal(2)) + }) + It("detects an empty or short materialization instead of passing (H18)", func() { Expect(gitagent.AssertMaterialized(0, 3)).To(MatchError(ContainSubstring("H18"))) Expect(gitagent.AssertMaterialized(2, 3)).To(MatchError(ContainSubstring("H18"))) diff --git a/pkg/gitagent/keys.go b/pkg/gitagent/keys.go index a8b592bb..d5866775 100644 --- a/pkg/gitagent/keys.go +++ b/pkg/gitagent/keys.go @@ -19,7 +19,29 @@ import ( func EnsureKeyPair(path string) (gossh.Signer, string, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { - return generateKeyPair(path) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, "", err + } + var signer gossh.Signer + var fingerprint string + err := withFileLock(path+".lock", 0o600, func() error { + data, readErr := os.ReadFile(path) + if readErr == nil { + var parseErr error + signer, parseErr = gossh.ParsePrivateKey(data) + if parseErr != nil { + return fmt.Errorf("parse key %s: %w", path, parseErr) + } + fingerprint = gossh.FingerprintSHA256(signer.PublicKey()) + return nil + } + if !os.IsNotExist(readErr) { + return readErr + } + signer, fingerprint, readErr = generateKeyPair(path) + return readErr + }) + return signer, fingerprint, err } if err != nil { return nil, "", err diff --git a/pkg/gitagent/materialize.go b/pkg/gitagent/materialize.go index e7948072..6bfcb0a5 100644 --- a/pkg/gitagent/materialize.go +++ b/pkg/gitagent/materialize.go @@ -7,7 +7,6 @@ package gitagent import ( "context" "fmt" - "io/fs" "os" "path/filepath" "strings" @@ -47,7 +46,7 @@ func Materialize(ctx context.Context, repoDir string, env []string, commitOID, d "-c", "core.bare=false", "--work-tree="+dstAbs, "checkout-index", "-a", "-f"); err != nil { return 0, err } - count, err := countMaterialized(dstAbs) + count, err := countMaterialized(dstAbs, names) if err != nil { return 0, err } @@ -99,16 +98,15 @@ func AssertMaterialized(got, expected int) error { return nil } -func countMaterialized(dir string) (int, error) { - count := 0 - err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { +func countMaterialized(dir string, expected []string) (int, error) { + for _, name := range expected { + info, err := os.Lstat(filepath.Join(dir, filepath.FromSlash(name))) if err != nil { - return err + return 0, fmt.Errorf("materialized path %q: %w", name, err) } - if !d.IsDir() { - count++ + if info.IsDir() { + return 0, fmt.Errorf("materialized path %q is a directory, not a tree entry", name) } - return nil - }) - return count, err + } + return len(expected), nil } diff --git a/pkg/gitagent/proxy/grants.go b/pkg/gitagent/proxy/grants.go index 6ed5ad9b..b7cb9db8 100644 --- a/pkg/gitagent/proxy/grants.go +++ b/pkg/gitagent/proxy/grants.go @@ -11,6 +11,7 @@ package proxy import ( "fmt" "net/url" + "path" "strings" "github.com/flanksource/commons-db/types" @@ -81,6 +82,14 @@ func (g Grant) host() string { return host } +func (g Grant) rawHost() string { + u, err := url.Parse(g.URL) + if err != nil { + return "" + } + return u.Host +} + func (g Grant) scheme() string { u, err := url.Parse(g.URL) if err != nil { @@ -103,13 +112,22 @@ func (g Grant) AllowsMethod(method string) bool { // AllowsPath reports whether the path lies under a granted prefix. func (g Grant) AllowsPath(path string) bool { for _, p := range g.Paths { - if strings.HasPrefix(path, p) { + prefix := pathpkgClean(p) + request := pathpkgClean(path) + if prefix == "/" || request == prefix || strings.HasPrefix(request, prefix+"/") { return true } } return false } +func pathpkgClean(value string) string { + if !strings.HasPrefix(value, "/") { + value = "/" + value + } + return path.Clean(value) +} + // grantedHeader returns the header grant for name, if any. func (g Grant) grantedHeader(name string) (HeaderGrant, bool) { for _, h := range g.Headers { diff --git a/pkg/gitagent/proxy/proxy.go b/pkg/gitagent/proxy/proxy.go index 414e7469..20c66848 100644 --- a/pkg/gitagent/proxy/proxy.go +++ b/pkg/gitagent/proxy/proxy.go @@ -1,12 +1,15 @@ package proxy import ( + "bytes" "context" "fmt" "io" "net" "net/http" + "path" "strings" + "sync" "time" ) @@ -38,6 +41,8 @@ type Proxy struct { // the TLS layer validates the certificate against the granted host name — // never the sandbox-controlled Host header or SNI (R9.1). Dialer *net.Dialer + once sync.Once + rt *http.Transport } func (p *Proxy) audit(d Decision) { @@ -62,15 +67,28 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if !r.URL.IsAbs() { + p.audit(Decision{Method: r.Method, Destination: r.Host, Verdict: "rejected", Reason: "not an absolute-form proxy request"}) http.Error(w, "captain-proxy: absolute-form proxy requests only", http.StatusBadRequest) return } - grant, ok := p.grantFor(r.URL.Hostname(), r.URL.Port(), r.URL.Scheme) - if !ok { + canonicalPath, canonical := canonicalRequestPath(r.URL.Path) + if !canonical { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "request path is not canonical (R9.6)"}) + http.Error(w, "captain-proxy: request path is not canonical", http.StatusForbidden) + return + } + grants := p.grantsFor(r.URL.Hostname(), r.URL.Port(), r.URL.Scheme) + if len(grants) == 0 { p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "destination not granted (deny by default)"}) http.Error(w, "captain-proxy: destination not granted", http.StatusForbidden) return } + grant, ok := scopedGrantFor(grants, r, canonicalPath) + if !ok { + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "method or path outside the grant's scope (R9.6)"}) + http.Error(w, "captain-proxy: method or path outside the grant's scope", http.StatusForbidden) + return + } if reason := p.findMisplacedPlaceholder(r, grant); reason != "" { // Rejected and logged, never stripped-and-forwarded: the appearance // is an exfiltration attempt and silently continuing hides it (R9.2). @@ -78,11 +96,6 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "captain-proxy: "+reason, http.StatusForbidden) return } - if !grant.AllowsMethod(r.Method) || !grant.AllowsPath(r.URL.Path) { - p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: "method or path outside the grant's scope (R9.6)"}) - http.Error(w, "captain-proxy: method or path outside the grant's scope", http.StatusForbidden) - return - } substituted, err := p.substitute(r, grant) if err != nil { // A credential that fails to resolve fails the request loudly; the @@ -94,8 +107,9 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { p.forward(w, r, grant, substituted) } -// grantFor matches a destination against the grant table. -func (p *Proxy) grantFor(host, port, scheme string) (Grant, bool) { +// grantsFor returns every grant for a destination. Scope selection happens +// separately so one destination can carry independent capabilities. +func (p *Proxy) grantsFor(host, port, scheme string) []Grant { if port == "" { if scheme == "https" { port = "443" @@ -103,14 +117,46 @@ func (p *Proxy) grantFor(host, port, scheme string) (Grant, bool) { port = "80" } } + var matches []Grant for _, g := range p.Grants { if g.host() == net.JoinHostPort(host, port) && g.scheme() == scheme { - return g, true + matches = append(matches, g) + } + } + return matches +} + +func scopedGrantFor(grants []Grant, r *http.Request, requestPath string) (Grant, bool) { + var scoped []Grant + for _, grant := range grants { + if grant.AllowsMethod(r.Method) && grant.AllowsPath(requestPath) { + scoped = append(scoped, grant) } } + for _, grant := range scoped { + for _, header := range grant.Headers { + if r.Header.Get(header.Name) == grant.Placeholder(header.Name) { + return grant, true + } + } + } + if len(scoped) > 0 { + return scoped[0], true + } return Grant{}, false } +func canonicalRequestPath(requestPath string) (string, bool) { + if requestPath == "" { + requestPath = "/" + } + cleaned := path.Clean(requestPath) + if strings.HasSuffix(requestPath, "/") && cleaned != "/" { + cleaned += "/" + } + return cleaned, cleaned == requestPath +} + // findMisplacedPlaceholder scans headers, URL and a bounded body prefix for // any placeholder that is not this grant's placeholder in this grant's // header. It returns the rejection reason, or "". @@ -130,12 +176,12 @@ func (p *Proxy) findMisplacedPlaceholder(r *http.Request, grant Grant) string { } } if r.Body != nil { - prefix := make([]byte, maxScannedBody) - n, _ := io.ReadFull(r.Body, prefix) - body := prefix[:n] + var scanned bytes.Buffer + _, _ = scanned.ReadFrom(io.LimitReader(r.Body, maxScannedBody)) + body := scanned.Bytes() rest := r.Body - r.Body = readCloser{io.MultiReader(strings.NewReader(string(body)), rest), rest} - if strings.Contains(string(body), PlaceholderPrefix) { + r.Body = readCloser{io.MultiReader(bytes.NewReader(body), rest), rest} + if bytes.Contains(body, []byte(PlaceholderPrefix)) { return "credential placeholder in the request body is an exfiltration attempt (R9.2)" } } @@ -164,33 +210,12 @@ func (p *Proxy) substitute(r *http.Request, grant Grant) ([]string, error) { // forward relays the request upstream, resolving DNS itself and letting TLS // validate against the granted host name (R9.1). func (p *Proxy) forward(w http.ResponseWriter, r *http.Request, grant Grant, substituted []string) { - dialer := p.Dialer - if dialer == nil { - dialer = &net.Dialer{Timeout: 30 * time.Second} - } - transport := &http.Transport{ - Proxy: nil, // never chain through environment proxies - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - // addr is the granted host:port (the URL was matched against the - // grant). Resolve it ourselves rather than trusting anything the - // sandbox controls as identity. - host, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - ips, err := net.DefaultResolver.LookupHost(ctx, host) - if err != nil || len(ips) == 0 { - return nil, fmt.Errorf("resolving %s: %w", host, err) - } - return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0], port)) - }, - } - defer transport.CloseIdleConnections() + transport := p.transport() out := r.Clone(r.Context()) out.RequestURI = "" out.URL.Scheme = grant.scheme() - out.URL.Host = strings.TrimSuffix(strings.TrimSuffix(grant.host(), ":443"), ":80") + out.URL.Host = grant.rawHost() out.Host = out.URL.Host resp, err := transport.RoundTrip(out) @@ -215,6 +240,39 @@ func (p *Proxy) forward(w http.ResponseWriter, r *http.Request, grant Grant, sub _, _ = io.Copy(w, resp.Body) } +func (p *Proxy) transport() *http.Transport { + p.once.Do(func() { + dialer := p.Dialer + if dialer == nil { + dialer = &net.Dialer{Timeout: 30 * time.Second} + } + p.rt = &http.Transport{ + Proxy: nil, // never chain through environment proxies + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, + IdleConnTimeout: 90 * time.Second, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // addr is the granted host:port (the URL was matched against the + // grant). Resolve it ourselves rather than trusting anything the + // sandbox controls as identity. + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolving %s: %w", host, err) + } + if len(ips) == 0 { + return nil, fmt.Errorf("resolving %s: no addresses", host) + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0], port)) + }, + } + }) + return p.rt +} + type readCloser struct { io.Reader io.Closer diff --git a/pkg/gitagent/proxy/proxy_test.go b/pkg/gitagent/proxy/proxy_test.go index 4baaa4ab..7e5a7e02 100644 --- a/pkg/gitagent/proxy/proxy_test.go +++ b/pkg/gitagent/proxy/proxy_test.go @@ -96,6 +96,18 @@ func (w *world) upstreamHeaderValues(name string) []string { return values } +func (w *world) auditDecisions() []Decision { + w.mu.Lock() + defer w.mu.Unlock() + return append([]Decision(nil), w.decisions...) +} + +func (w *world) upstreamRequestCount() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.requests) +} + const secret = "ghp_real_secret_value" func TestSubstitutesOnlyInTheGrantedPosition(t *testing.T) { @@ -129,7 +141,7 @@ func TestPlaceholderInNonGrantedHeaderIsRejectedNotStripped(t *testing.T) { t.Fatalf("upstream was contacted %d times; a rejected request must never be forwarded", n) } found := false - for _, d := range w.decisions { + for _, d := range w.auditDecisions() { if d.Verdict == "rejected" && strings.Contains(d.Reason, "R9.2") { found = true if strings.Contains(d.Reason, secret) || strings.Contains(d.Reason, placeholder) { @@ -154,7 +166,7 @@ func TestPlaceholderInBodyOrURLIsRejected(t *testing.T) { if resp.StatusCode != http.StatusForbidden { t.Fatalf("url placeholder: status = %d, want 403", resp.StatusCode) } - if len(w.requests) != 0 { + if w.upstreamRequestCount() != 0 { t.Fatal("nothing may reach upstream") } } @@ -184,12 +196,19 @@ func TestScopeByMethodAndPath(t *testing.T) { if resp.StatusCode != http.StatusForbidden { t.Fatalf("path outside scope: status = %d, want 403 (R9.6/H7)", resp.StatusCode) } + req := httptest.NewRequest("POST", w.upstream.URL+"/repos/acme/../gists", nil) + recorder := httptest.NewRecorder() + (&Proxy{Grants: []Grant{w.grant}}).ServeHTTP(recorder, req) + if recorder.Code != http.StatusForbidden { + t.Fatalf("dot-segment escape: status = %d, want 403", recorder.Code) + } } func TestUnresolvableCredentialFailsTheRequest(t *testing.T) { w := newWorld(t, secret) - w.grant.Headers[0].Value = types.EnvVar{} // nothing to resolve - p := &Proxy{Grants: []Grant{w.grant}} + unresolvable := w.grant + unresolvable.Headers = []HeaderGrant{{Name: "Authorization"}} + p := &Proxy{Grants: []Grant{unresolvable}} broken := httptest.NewServer(p) defer broken.Close() proxyURL, _ := url.Parse(broken.URL) @@ -212,6 +231,48 @@ func TestUnresolvableCredentialFailsTheRequest(t *testing.T) { } } +func TestLaterGrantForSameDestinationCanAuthorize(t *testing.T) { + w := newWorld(t, secret) + otherScope := w.grant + otherScope.Name = "other" + otherScope.Paths = []string{"/other/"} + p := &Proxy{Grants: []Grant{otherScope, w.grant}} + server := httptest.NewServer(p) + defer server.Close() + proxyURL, _ := url.Parse(server.URL) + client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} + req, _ := http.NewRequest("GET", w.upstream.URL+"/repos/acme/captain", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestGrantRawHostPreservesExplicitCrossSchemePort(t *testing.T) { + for _, test := range []struct { + url string + host string + }{ + {"http://example.com:443", "example.com:443"}, + {"https://example.com:80", "example.com:80"}, + } { + if got := (Grant{URL: test.url}).rawHost(); got != test.host { + t.Fatalf("rawHost(%q) = %q, want %q", test.url, got, test.host) + } + } +} + +func TestProxyReusesTransport(t *testing.T) { + p := &Proxy{} + if first, second := p.transport(), p.transport(); first != second { + t.Fatal("transport was rebuilt") + } +} + func TestConnectIsRefused(t *testing.T) { w := newWorld(t, secret) req, _ := http.NewRequest(http.MethodConnect, w.proxy.URL, nil) diff --git a/pkg/gitagent/receiver.go b/pkg/gitagent/receiver.go index bcb2bf9b..52a53b30 100644 --- a/pkg/gitagent/receiver.go +++ b/pkg/gitagent/receiver.go @@ -2,6 +2,7 @@ // sidecar bare repo. Both carry the mandated config (R2.2); the mailbox // additionally shares the real repository's object store via alternates so // protocol refs never pollute the user's working repo (R2.1/H8). + package gitagent import ( @@ -10,6 +11,8 @@ import ( "os" "path/filepath" "strconv" + + "golang.org/x/sys/unix" ) // ReceiverRole distinguishes the two admission tiers. @@ -107,18 +110,50 @@ func writeFileAtomic(path string, data []byte, mode os.FileMode) error { return err } name := tmp.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(name) + } + }() if _, err := tmp.Write(data); err != nil { - tmp.Close() - os.Remove(name) + _ = tmp.Close() + return err + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() return err } if err := tmp.Close(); err != nil { - os.Remove(name) return err } - if err := os.Chmod(name, mode); err != nil { - os.Remove(name) + if err := os.Rename(name, path); err != nil { + return err + } + committed = true + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +// withFileLock serializes a durable state transition across hook and CLI +// processes. The caller creates the parent directory before entering. +func withFileLock(path string, mode os.FileMode, fn func() error) error { + lock, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, mode) + if err != nil { + return err + } + defer lock.Close() + if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil { return err } - return os.Rename(name, path) + defer func() { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) }() + return fn() } diff --git a/pkg/gitagent/regression_test.go b/pkg/gitagent/regression_test.go new file mode 100644 index 00000000..cadd153f --- /dev/null +++ b/pkg/gitagent/regression_test.go @@ -0,0 +1,167 @@ +package gitagent + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + + gossh "golang.org/x/crypto/ssh" +) + +func TestSplitSSHEndpointBracketedIPv6DefaultsPort(t *testing.T) { + addr, user, err := splitSSHEndpoint("ssh://operator@[::1]") + if err != nil { + t.Fatal(err) + } + if addr != "[::1]:22" || user != "operator" { + t.Fatalf("endpoint = %q, %q", addr, user) + } +} + +func TestUpdateTaskStateSerializesConcurrentWriters(t *testing.T) { + repo := t.TempDir() + if err := SaveTaskState(repo, &TaskState{Task: "t-lock"}); err != nil { + t.Fatal(err) + } + const writers = 24 + var wg sync.WaitGroup + errs := make(chan error, writers) + for range writers { + wg.Add(1) + go func() { + defer wg.Done() + _, err := UpdateTaskState(repo, "t-lock", func(st *TaskState) (bool, error) { + st.Attempts++ + return true, nil + }) + errs <- err + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + state, ok, err := LoadTaskState(repo, "t-lock") + if err != nil || !ok { + t.Fatalf("load state: ok=%v err=%v", ok, err) + } + if state.Attempts != writers { + t.Fatalf("attempts = %d, want %d", state.Attempts, writers) + } +} + +func TestEnsureKeyPairConcurrentCreationReturnsStoredKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent_ed25519") + const callers = 8 + type result struct { + fingerprint string + err error + } + results := make(chan result, callers) + var wg sync.WaitGroup + for range callers { + wg.Add(1) + go func() { + defer wg.Done() + _, fingerprint, err := EnsureKeyPair(path) + results <- result{fingerprint: fingerprint, err: err} + }() + } + wg.Wait() + close(results) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + signer, err := gossh.ParsePrivateKey(data) + if err != nil { + t.Fatal(err) + } + want := gossh.FingerprintSHA256(signer.PublicKey()) + for result := range results { + if result.err != nil || result.fingerprint != want { + t.Fatalf("result = %#v, stored fingerprint = %q", result, want) + } + } +} + +func TestRefUpdateRecognizesSHA256NullOID(t *testing.T) { + null := "0000000000000000000000000000000000000000000000000000000000000000" + if !(RefUpdate{Old: null}).IsCreate() || !(RefUpdate{New: null}).IsDelete() { + t.Fatal("64-character null OID was not recognized") + } +} + +func TestSSHTransportCommandQuotesExecutable(t *testing.T) { + got := SSHTransportCommand("/tmp/captain build/captain's") + want := "'/tmp/captain build/captain'\"'\"'s' sandbox git-agent ssh" + if got != want { + t.Fatalf("command = %q, want %q", got, want) + } +} + +func TestWriteFileAtomicCleansTempAfterRenameFailure(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "state.json") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + if err := writeFileAtomic(target, []byte("state"), 0o644); err == nil { + t.Fatal("writeFileAtomic succeeded with a directory target") + } + matches, err := filepath.Glob(filepath.Join(parent, ".state.json-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("temporary files remain: %v", matches) + } +} + +func TestRecordDispatchCreatesAuditRefsAtomically(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + env := ScrubGitEnv(os.Environ()) + for _, args := range [][]string{ + {"init", "--quiet"}, + {"-c", "user.name=test", "-c", "user.email=test@localhost", "commit", "--allow-empty", "-m", "base"}, + } { + if _, err := runGit(ctx, repo, env, args...); err != nil { + t.Fatal(err) + } + } + snapshot, err := TakeSnapshot(ctx, repo, SnapshotPolicy{}) + if err != nil { + t.Fatal(err) + } + control, err := BuildControlCommit(ctx, repo, nil, map[string][]byte{ControlTaskFile: []byte(`{"prompt":"test"}`)}) + if err != nil { + t.Fatal(err) + } + mailbox := filepath.Join(t.TempDir(), "mailbox.git") + if err := InitMailbox(ctx, mailbox, repo); err != nil { + t.Fatal(err) + } + controlRef, _ := ControlRef("t-atomic", 1) + if _, err := runGit(ctx, mailbox, env, "update-ref", controlRef, control); err != nil { + t.Fatal(err) + } + req := DispatchRequest{RepoDir: repo, MailboxPath: mailbox, Task: "t-atomic"} + if err := recordDispatch(ctx, req, "t-atomic", snapshot, control); err == nil { + t.Fatal("recordDispatch succeeded despite an existing control ref") + } + dispatchRef, _ := DispatchRef("t-atomic", 1) + code, _, err := gitExitCode(ctx, mailbox, env, "show-ref", "--verify", "--quiet", dispatchRef) + if err != nil { + t.Fatal(err) + } + if code == 0 { + t.Fatalf("dispatch ref %s was left behind after transaction failure", dispatchRef) + } +} diff --git a/pkg/gitagent/snapshot.go b/pkg/gitagent/snapshot.go index 3831bd29..296c321b 100644 --- a/pkg/gitagent/snapshot.go +++ b/pkg/gitagent/snapshot.go @@ -27,8 +27,6 @@ const ( DefaultSnapshotMaxTotalSize = int64(256 << 20) ) -const zeroOID = "0000000000000000000000000000000000000000" - // SnapshotPolicy bounds what a dispatch snapshot may carry. type SnapshotPolicy struct { // Paths are doublestar globs; a leading ! denies. With any non-negated @@ -262,7 +260,7 @@ func buildSnapshotTree(ctx context.Context, dir string, env []string, base strin } var info bytes.Buffer for _, p := range paths { - line, err := indexEntry(ctx, dir, env, p) + line, err := indexEntry(ctx, dir, env, p, len(base)) if err != nil { return "", err } @@ -280,11 +278,14 @@ func buildSnapshotTree(ctx context.Context, dir string, env []string, base strin // indexEntry renders one `update-index --index-info` record for the path's // on-disk state: mode 0 removes a deleted path, symlinks hash their target, // and regular files are hashed with --no-filters so the blob is byte-exact. -func indexEntry(ctx context.Context, dir string, env []string, path string) (string, error) { +func indexEntry(ctx context.Context, dir string, env []string, path string, oidLength int) (string, error) { full := filepath.Join(dir, path) fi, err := os.Lstat(full) if errors.Is(err, fs.ErrNotExist) { - return "0 " + zeroOID + "\t" + path, nil + if oidLength != 40 && oidLength != 64 { + return "", fmt.Errorf("snapshot refused: unsupported object id length %d", oidLength) + } + return "0 " + strings.Repeat("0", oidLength) + "\t" + path, nil } if err != nil { return "", err diff --git a/pkg/gitagent/sshclient.go b/pkg/gitagent/sshclient.go index 7a35e0ff..bc769e15 100644 --- a/pkg/gitagent/sshclient.go +++ b/pkg/gitagent/sshclient.go @@ -11,6 +11,7 @@ import ( "net" "os" "strings" + "time" gossh "golang.org/x/crypto/ssh" ) @@ -67,6 +68,7 @@ func runSSHClient(args []string, stdin io.Reader, stdout, stderr io.Writer) (int } return nil }, + Timeout: 30 * time.Second, } client, err := gossh.Dial("tcp", net.JoinHostPort(host, port), config) if err != nil { @@ -137,7 +139,7 @@ func parseSSHArgs(args []string) (host, port, command string, err error) { i++ // a standalone switch such as -4, -6, -q, -T } if i >= len(args) { - return "", "", "", fmt.Errorf("usage: [options] [user@]host command...") + return "", "", "", fmt.Errorf("usage: [options] [user@]host command") } host = args[i] command = strings.Join(args[i+1:], " ") diff --git a/pkg/gitagent/state.go b/pkg/gitagent/state.go index fad88651..b12ac62b 100644 --- a/pkg/gitagent/state.go +++ b/pkg/gitagent/state.go @@ -1,6 +1,7 @@ // Receiver-side task state, kept under /captain/ — outside the object // store, because a rejected push must leave zero new objects and refs while // the verdict still has to survive (R6.9). + package gitagent import ( @@ -40,6 +41,10 @@ func LoadTaskState(repo, task string) (*TaskState, bool, error) { if err := ValidateTaskID(task); err != nil { return nil, false, err } + return loadTaskStateUnlocked(repo, task) +} + +func loadTaskStateUnlocked(repo, task string) (*TaskState, bool, error) { data, err := os.ReadFile(filepath.Join(taskStateDir(repo, task), "state.json")) if os.IsNotExist(err) { return nil, false, nil @@ -63,10 +68,49 @@ func SaveTaskState(repo string, st *TaskState) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } + return withFileLock(filepath.Join(dir, "state.lock"), 0o600, func() error { + return saveTaskStateUnlocked(repo, st) + }) +} + +// UpdateTaskState holds the task lock across load, mutation, and save. The +// callback returns false when it inspected state but intentionally made no +// durable change. +func UpdateTaskState(repo, task string, update func(*TaskState) (bool, error)) (*TaskState, error) { + if err := ValidateTaskID(task); err != nil { + return nil, err + } + dir := taskStateDir(repo, task) + if _, err := os.Stat(dir); err != nil { + return nil, err + } + var result *TaskState + err := withFileLock(filepath.Join(dir, "state.lock"), 0o600, func() error { + st, ok, err := loadTaskStateUnlocked(repo, task) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("task %s state is missing", task) + } + save, err := update(st) + if err != nil { + return err + } + result = st + if !save { + return nil + } + return saveTaskStateUnlocked(repo, st) + }) + return result, err +} + +func saveTaskStateUnlocked(repo string, st *TaskState) error { st.UpdatedAt = time.Now().UTC() data, err := json.MarshalIndent(st, "", " ") if err != nil { return err } - return writeFileAtomic(filepath.Join(dir, "state.json"), append(data, '\n'), 0o644) + return writeFileAtomic(filepath.Join(taskStateDir(repo, st.Task), "state.json"), append(data, '\n'), 0o644) } diff --git a/pkg/gitagent/workspace.go b/pkg/gitagent/workspace.go index a5ccece0..bd67cc6c 100644 --- a/pkg/gitagent/workspace.go +++ b/pkg/gitagent/workspace.go @@ -3,6 +3,7 @@ // branch and upstream so a bare `git commit` + `git push` is sufficient // (R3.1/H17), task.json lands outside the worktree, and the agent process is // launched fully detached so the dispatch push returns promptly (R6.3/H12). + package gitagent import ( diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go index 9d302134..573ff94d 100644 --- a/pkg/sandbox/adapter/gitagent.go +++ b/pkg/sandbox/adapter/gitagent.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "strings" "time" "github.com/flanksource/captain/pkg/ai/agent/setup" @@ -21,6 +22,9 @@ import ( // enrollment map and transport endpoints; nothing here is read from the spec // except the work itself. func GitAgent(cfg api.SandboxConfig) (api.Sandbox, error) { + if _, err := configuredWaitTimeout(cfg.Options); err != nil { + return nil, err + } return &gitAgentSandbox{cfg: cfg}, nil } @@ -67,6 +71,10 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp prompt = spec.Prompt.User system = spec.Prompt.System } + timeout := strings.TrimSpace(spec.Budget.Timeout) + if timeout == "" { + timeout = target.waitTimeout.String() + } dispatch, err := gitagent.Dispatch(ctx, gitagent.DispatchRequest{ RepoDir: repoDir, MailboxPath: target.mailbox, @@ -81,7 +89,7 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp // own defaults and quietly pick a different one. TaskPayload: gitagent.TaskPayload{ Prompt: prompt, System: system, - Model: spec.Name, Backend: string(spec.Backend), + Model: spec.Name, Backend: string(spec.Backend), Timeout: timeout, }, HooksJSON: hooksJSON, }) @@ -196,12 +204,30 @@ const DefaultWaitTimeout = time.Hour // match: a shorter outer timeout would kill the dispatch mid-flight and report // a failure for work that is still running. func WaitTimeout(options map[string]any) time.Duration { - if raw, ok := options["waitTimeout"].(string); ok { - if d, err := time.ParseDuration(raw); err == nil && d > 0 { - return d - } + timeout, err := configuredWaitTimeout(options) + if err != nil { + return DefaultWaitTimeout + } + return timeout +} + +func configuredWaitTimeout(options map[string]any) (time.Duration, error) { + value, exists := options["waitTimeout"] + if !exists { + return DefaultWaitTimeout, nil + } + raw, ok := value.(string) + if !ok { + return 0, fmt.Errorf("waitTimeout must be a duration string, got %T", value) + } + timeout, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("waitTimeout %q: %w", raw, err) + } + if timeout <= 0 { + return 0, fmt.Errorf("waitTimeout must be positive, got %q", raw) } - return DefaultWaitTimeout + return timeout, nil } // gitAgentKeysDir anchors key material and the default mailbox beside the diff --git a/pkg/sandbox/adapter/gitagent_test.go b/pkg/sandbox/adapter/gitagent_test.go new file mode 100644 index 00000000..394b1e61 --- /dev/null +++ b/pkg/sandbox/adapter/gitagent_test.go @@ -0,0 +1,16 @@ +package adapter + +import ( + "testing" + + "github.com/flanksource/captain/pkg/api" +) + +func TestGitAgentRejectsInvalidWaitTimeout(t *testing.T) { + for _, value := range []any{"fifteen", "0s", 15} { + _, err := GitAgent(api.SandboxConfig{Options: map[string]any{"waitTimeout": value}}) + if err == nil { + t.Fatalf("waitTimeout %#v was accepted", value) + } + } +} From deaca74c496066fdd056a669f9402ce5b1c550a8 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 5 Aug 2026 14:30:06 +0000 Subject: [PATCH 13/22] fix(gitagent): complete portable remote task cycle Amp-Thread-ID: https://ampcode.com/threads/T-019fd21e-b226-70bc-b705-dfbbfd091e4d Co-authored-by: Aditya Thebe --- hack/gitagent_empirical.sh | 6 ++++++ pkg/cli/gitagent_e2e_test.go | 4 ++++ pkg/cli/sandbox.go | 4 +++- pkg/gitagent/admit.go | 7 +++++++ pkg/gitagent/admit_ginkgo_test.go | 8 ++++++++ pkg/gitagent/hookmain.go | 13 ++++++------- pkg/gitagent/integrate.go | 28 +++++++++++++++++++++------- pkg/gitagent/receiver.go | 10 ++++++++++ pkg/gitagent/receiver_ginkgo_test.go | 2 ++ 9 files changed, 67 insertions(+), 15 deletions(-) diff --git a/hack/gitagent_empirical.sh b/hack/gitagent_empirical.sh index ebbd7929..ed650fad 100755 --- a/hack/gitagent_empirical.sh +++ b/hack/gitagent_empirical.sh @@ -19,6 +19,12 @@ set -u +# The harness installs repository-local hooks in disposable repositories. +# Ignore host-level hook redirection so a user's core.hooksPath cannot make +# the probes silently skip those hooks and report false substrate failures. +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_NOSYSTEM=1 + ROOT="$(mktemp -d "${TMPDIR:-/tmp}/gitagent-empirical.XXXXXX")" trap 'rm -rf "$ROOT"' EXIT diff --git a/pkg/cli/gitagent_e2e_test.go b/pkg/cli/gitagent_e2e_test.go index 9e4a6c7f..b26ad00f 100644 --- a/pkg/cli/gitagent_e2e_test.go +++ b/pkg/cli/gitagent_e2e_test.go @@ -537,6 +537,10 @@ func TestGitAgentHelpDocumentsItsOwnCommands(t *testing.T) { t.Skip("builds the captain binary") } h := newHost(t) + parent, _ := h.run("sandbox", "--help") + if !strings.Contains(parent, "git-agent") { + t.Fatalf("sandbox help does not advertise git-agent:\n%s", parent) + } out, _ := h.run("sandbox", "git-agent", "--help") for _, want := range []string{"serve", "add", "list", "revoke"} { if !strings.Contains(out, want) { diff --git a/pkg/cli/sandbox.go b/pkg/cli/sandbox.go index f9710545..4eafb04e 100644 --- a/pkg/cli/sandbox.go +++ b/pkg/cli/sandbox.go @@ -21,7 +21,9 @@ func SandboxHelp() api.Text { AddText(" captain sandbox generate", "text-green-400"). AddText(" — generate sandbox-runtime config", "text-gray-500").NewLine(). AddText(" captain sandbox presets", "text-green-400"). - AddText(" — list available presets with details", "text-gray-500").NewLine().NewLine(). + AddText(" — list available presets with details", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent", "text-green-400"). + AddText(" — enroll and serve remote coding agents", "text-gray-500").NewLine().NewLine(). AddText("See also:", "font-bold text-blue-400").NewLine(). AddText(" captain container", "text-green-400"). AddText(" — build container images with preset support", "text-gray-500").NewLine() diff --git a/pkg/gitagent/admit.go b/pkg/gitagent/admit.go index 80308949..1f2310d8 100644 --- a/pkg/gitagent/admit.go +++ b/pkg/gitagent/admit.go @@ -231,6 +231,13 @@ func admitCodeContent(ctx context.Context, req AdmitRequest, u RefUpdate, info R if !ok { return fmt.Errorf("task %s was never dispatched here", info.Task) } + if req.Envelope == nil || req.Envelope.Base != st.Base { + got := "" + if req.Envelope != nil { + got = req.Envelope.Base + } + return fmt.Errorf("task %s: envelope base %s does not match dispatched base %s", info.Task, got, st.Base) + } parents, err := runGit(ctx, req.Repo, req.Env, "rev-list", "--parents", "-n", "1", u.New) if err != nil { return err diff --git a/pkg/gitagent/admit_ginkgo_test.go b/pkg/gitagent/admit_ginkgo_test.go index b786c220..3367bad3 100644 --- a/pkg/gitagent/admit_ginkgo_test.go +++ b/pkg/gitagent/admit_ginkgo_test.go @@ -242,7 +242,15 @@ var _ = Describe("admission", func() { Updates: resultUpdates, Envelope: f.envelope(), Env: f.env, })).To(Succeed()) + forgedBase := f.envelope() + forgedBase.Base = result err := gitagent.Admit(ctx, gitagent.AdmitRequest{ + Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", + Updates: resultUpdates, Envelope: forgedBase, Env: f.env, + }) + Expect(err).To(MatchError(ContainSubstring("does not match dispatched base"))) + + err = gitagent.Admit(ctx, gitagent.AdmitRequest{ Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-2", Updates: resultUpdates, Envelope: f.envelope(), Env: f.env, }) diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go index 4abb96a2..18c0fa28 100644 --- a/pkg/gitagent/hookmain.go +++ b/pkg/gitagent/hookmain.go @@ -369,7 +369,7 @@ func RunPostReceive(ctx context.Context, repo string, role ReceiverRole, host Ho if role == RoleSidecar { return sidecarPostReceive(ctx, repo, host, updates, envelope) } - return mailboxPostReceive(ctx, repo, host, updates, envelope) + return mailboxPostReceive(ctx, repo, host, updates) } func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, envelope *Envelope) error { @@ -432,7 +432,7 @@ func loadDispatchPayloads(ctx context.Context, repo string, updates []RefUpdate, return policy, taskPayload, control.New } -func mailboxPostReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate, envelope *Envelope) error { +func mailboxPostReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate) error { resultUpdate, info, ok := singleResultUpdate(updates) if !ok { return nil @@ -441,13 +441,12 @@ func mailboxPostReceive(ctx context.Context, repo string, host HookHost, updates if err != nil || !found { return fmt.Errorf("task state missing for %s", info.Task) } - base := st.Base - if envelope != nil { - base = envelope.Base // R10.1: the base recorded in the envelope - } verdict := TierVerdict{V: ProtocolVersion, Task: info.Task, Attempt: info.Attempt, Tier: "supervisor", Status: StatusAccepted} if host.Runtime.RealRepo != "" { - integration, err := Integrate(ctx, host.Runtime.RealRepo, repo, info.Task, info.Attempt, base, resultUpdate.New) + // Admission already proved that the envelope agrees with this durable + // state. Keep the authoritative dispatch record as the sole source for + // the three-way merge base (R10.1). + integration, err := Integrate(ctx, host.Runtime.RealRepo, repo, info.Task, info.Attempt, st.Base, resultUpdate.New) if err != nil { return err } diff --git a/pkg/gitagent/integrate.go b/pkg/gitagent/integrate.go index 8283a684..fccbb3cd 100644 --- a/pkg/gitagent/integrate.go +++ b/pkg/gitagent/integrate.go @@ -40,8 +40,28 @@ func Integrate(ctx context.Context, realRepo, mailbox, task string, attempt int, if err != nil { return nil, err } + // Older supported Git releases do not have merge-tree's --merge-base + // option. Give the current HEAD tree a synthetic parent at the envelope's + // recorded base instead: merge-tree then computes that exact base from the + // graph while leaving the user's real history and checkout untouched. + headTree, err := runGit(ctx, realRepo, env, "rev-parse", "--verify", head+"^{tree}") + if err != nil { + return nil, err + } + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + mergeHead, err := runGitIn(ctx, realRepo, cenv, + strings.NewReader("captain: integration merge input\n"), + "commit-tree", headTree, "-p", base) + if err != nil { + return nil, err + } code, out, err := gitExitCode(ctx, realRepo, env, - "merge-tree", "--write-tree", "--merge-base="+base, head, result) + "merge-tree", "--write-tree", mergeHead, result) if err != nil { return nil, err } @@ -59,12 +79,6 @@ func Integrate(ctx context.Context, realRepo, mailbox, task string, attempt int, return nil, fmt.Errorf("merge-tree failed (exit %d): %s", code, strings.TrimSpace(out)) } tree := strings.TrimSpace(lines[0]) - cenv := envWith(env, - "GIT_AUTHOR_NAME=captain", - "GIT_AUTHOR_EMAIL=captain@localhost", - "GIT_COMMITTER_NAME=captain", - "GIT_COMMITTER_EMAIL=captain@localhost", - ) merge, err := runGitIn(ctx, realRepo, cenv, strings.NewReader(fmt.Sprintf("captain: integrate task %s\n", task)), "commit-tree", tree, "-p", head, "-p", result) diff --git a/pkg/gitagent/receiver.go b/pkg/gitagent/receiver.go index 52a53b30..d262c0fd 100644 --- a/pkg/gitagent/receiver.go +++ b/pkg/gitagent/receiver.go @@ -73,6 +73,16 @@ func initReceiver(ctx context.Context, path string) error { if _, err := runGit(ctx, path, env, "init", "--quiet", "--bare"); err != nil { return err } + // A receiver must always run the shims installed in its own hooks + // directory. Without a repository-local override, the pusher's global + // core.hooksPath can silently bypass admission, vetting, and relay. + hooksPath, err := filepath.Abs(filepath.Join(path, "hooks")) + if err != nil { + return err + } + if _, err := runGit(ctx, path, env, "config", "core.hooksPath", hooksPath); err != nil { + return err + } for _, kv := range receiverConfig(DefaultMaxInputSize) { if _, err := runGit(ctx, path, env, "config", kv[0], kv[1]); err != nil { return err diff --git a/pkg/gitagent/receiver_ginkgo_test.go b/pkg/gitagent/receiver_ginkgo_test.go index 5007197f..4f586681 100644 --- a/pkg/gitagent/receiver_ginkgo_test.go +++ b/pkg/gitagent/receiver_ginkgo_test.go @@ -29,6 +29,8 @@ var _ = Describe("receiver repositories", func() { } { Expect(gitT(path, "config", key)).To(Equal(want), key) } + Expect(gitT(path, "config", "core.hookspath")).To(Equal(filepath.Join(path, "hooks")), + "a pusher's global core.hooksPath must not bypass receiver hooks") Expect(gitT(path, "config", "receive.maxinputsize")).NotTo(Equal("0"), "maxInputSize must be finite") }) From 4013f6dfc4c3d6bf1d3501a39cf6cbbef1120a54 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 6 Aug 2026 10:39:43 +0545 Subject: [PATCH 14/22] fix(gitagent): enforce hooks and reject merge conflicts Task hooks were serialized at the wrong level and never loaded from the control payload, so failing checks could be silently accepted. Integration also detected conflicts in post-receive, after receive-pack had already reported success. Preserve and validate tiered hook sets in task state, use them during receive vetting, and preflight three-way integration while result objects remain quarantined. Reject conflicts before ref acceptance and fail closed if HEAD changes before post-receive. Add protocol and adapter regressions. --- pkg/gitagent/conformance_ginkgo_test.go | 43 +++++++++++++ pkg/gitagent/control.go | 40 ++++++++++++ pkg/gitagent/dispatch.go | 10 ++- pkg/gitagent/hookmain.go | 84 +++++++++++++++++++------ pkg/gitagent/integrate.go | 68 +++++++++++++++----- pkg/gitagent/regression_test.go | 2 +- pkg/gitagent/state.go | 1 + pkg/sandbox/adapter/gitagent.go | 25 ++++++-- pkg/sandbox/adapter/gitagent_test.go | 32 ++++++++++ 9 files changed, 263 insertions(+), 42 deletions(-) diff --git a/pkg/gitagent/conformance_ginkgo_test.go b/pkg/gitagent/conformance_ginkgo_test.go index aa970801..d2b55039 100644 --- a/pkg/gitagent/conformance_ginkgo_test.go +++ b/pkg/gitagent/conformance_ginkgo_test.go @@ -239,6 +239,28 @@ var _ = Describe("protocol conformance (§12)", Serial, func() { Expect(gitT(w.mailbox, "rev-parse", "refs/captain/tasks/"+result.Task+"/result/2")).NotTo(BeEmpty()) }) + It("enforces the sidecar hooks carried by the dispatch control payload", func() { + w := newConformanceWorld(ctx, nil, nil, "") + hooks, err := json.Marshal(gitagent.HookSets{ + Sidecar: &api.Workflow{Verify: &api.Verify{Commands: []string{"false"}}}, + }) + Expect(err).NotTo(HaveOccurred()) + w.dispatch.HooksJSON = hooks + result := w.dispatchTask(ctx) + + state, found, err := gitagent.LoadTaskState(w.sidecar, result.Task) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(state.Hooks).NotTo(BeNil()) + Expect(state.Hooks.Sidecar.Verify.Commands).To(Equal([]string{"false"})) + + out, err := w.agentPush(map[string]string{"pkg/result.txt": "must not land\n"}) + Expect(err).To(HaveOccurred(), "task-carried false hook must reject:\n%s", out) + Expect(out).To(ContainSubstring("verify:false")) + Expect(out).To(ContainSubstring("captain: REJECTED")) + Expect(gitT(w.mailbox, "for-each-ref", "refs/captain/tasks/"+result.Task+"/result")).To(BeEmpty()) + }) + It("relays tier-2 rejection text down through the sidecar (H16)", func() { w := newConformanceWorld(ctx, nil, @@ -263,6 +285,27 @@ var _ = Describe("protocol conformance (§12)", Serial, func() { Expect(gitT(w.workdir, "rev-list", "--count", "origin/captain/"+result.Task+"..HEAD")).To(Equal("1")) }) + It("rejects a moved-HEAD integration conflict before accepting the agent push (R10.2)", func() { + w := newConformanceWorld(ctx, nil, nil, "") + result := w.dispatchTask(ctx) + + writeFileT(w.superRepo, "docs/readme.md", "supervisor version\n") + gitT(w.superRepo, "commit", "-q", "-am", "move supervisor head") + + out, err := w.agentPush(map[string]string{"docs/readme.md": "agent version\n"}) + Expect(err).To(HaveOccurred(), "conflicting push must be rejected:\n%s", out) + Expect(out).To(ContainSubstring("merge conflict")) + Expect(out).To(ContainSubstring("captain: REJECTED")) + + verdict, found, err := gitagent.LoadVerdict(w.mailbox, result.Task, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(verdict.Status).To(Equal(gitagent.StatusRejected)) + Expect(gitT(w.mailbox, "for-each-ref", "refs/captain/tasks/"+result.Task+"/result")).To(BeEmpty()) + Expect(gitT(w.superRepo, "for-each-ref", "refs/heads/captain/"+result.Task)).To(BeEmpty()) + Expect(gitT(w.workdir, "rev-list", "--count", "origin/captain/"+result.Task+"..HEAD")).To(Equal("1")) + }) + It("returns from dispatch promptly while the agent keeps running (H12)", func() { w := newConformanceWorld(ctx, nil, nil, "echo started > agent-marker.txt && sleep 30") start := time.Now() diff --git a/pkg/gitagent/control.go b/pkg/gitagent/control.go index 72495767..d69d2de1 100644 --- a/pkg/gitagent/control.go +++ b/pkg/gitagent/control.go @@ -1,11 +1,16 @@ package gitagent import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "os" "sort" "strings" + + "github.com/flanksource/captain/pkg/api" ) // Control payload file names (§4). @@ -15,6 +20,41 @@ const ( ControlPolicyFile = "policy.json" ) +// HookSets is the hooks.json wire payload. The supervisor chooses both tiers; +// receivers persist this value with task state so retries vet the same policy. +type HookSets struct { + Sidecar *api.Workflow `json:"sidecar,omitempty"` + Supervisor *api.Workflow `json:"supervisor,omitempty"` +} + +// DecodeHookSets validates a hooks.json payload before it becomes durable task +// policy. Unknown fields fail closed instead of silently disabling a tier. +func DecodeHookSets(data []byte) (*HookSets, error) { + if len(bytes.TrimSpace(data)) == 0 { + data = []byte("{}") + } + var hooks HookSets + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&hooks); err != nil { + return nil, fmt.Errorf("decode hooks.json: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("multiple JSON values") + } + return nil, fmt.Errorf("decode hooks.json: %w", err) + } + for tier, workflow := range map[string]*api.Workflow{ + "sidecar": hooks.Sidecar, "supervisor": hooks.Supervisor, + } { + if err := workflow.Validate(); err != nil { + return nil, fmt.Errorf("hooks.%s: %w", tier, err) + } + } + return &hooks, nil +} + // BuildControlCommit writes payloads as a flat tree and wraps it in a // parentless commit. Control refs point at commits, never bare trees — a // tree-tipped ref trips gc, bitmap and fsck paths (R3.3). env matters: built diff --git a/pkg/gitagent/dispatch.go b/pkg/gitagent/dispatch.go index ff98dd07..35ba9dc1 100644 --- a/pkg/gitagent/dispatch.go +++ b/pkg/gitagent/dispatch.go @@ -95,6 +95,10 @@ func Dispatch(ctx context.Context, req DispatchRequest) (*DispatchResult, error) if req.Relay == "" { req.Relay = RelaySync } + hooks, err := DecodeHookSets(req.HooksJSON) + if err != nil { + return nil, err + } snapshot, err := TakeSnapshot(ctx, req.RepoDir, SnapshotPolicy{ Paths: req.Policy.Paths, MaxFileSize: req.Policy.MaxBlobSize, }) @@ -105,7 +109,7 @@ func Dispatch(ctx context.Context, req DispatchRequest) (*DispatchResult, error) if err != nil { return nil, err } - if err := recordDispatch(ctx, req, task, snapshot, control); err != nil { + if err := recordDispatch(ctx, req, task, snapshot, control, hooks); err != nil { return nil, err } if err := pushDispatch(ctx, req, task, snapshot, control); err != nil { @@ -146,7 +150,7 @@ func buildDispatchControl(ctx context.Context, req DispatchRequest, snapshot *Sn // recordDispatch writes the audit refs and task state into the local mailbox // (R2.1): local update-refs, since the mailbox shares the real repository's // objects through alternates. -func recordDispatch(ctx context.Context, req DispatchRequest, task string, snapshot *Snapshot, control string) error { +func recordDispatch(ctx context.Context, req DispatchRequest, task string, snapshot *Snapshot, control string, hooks *HookSets) error { if err := InitMailbox(ctx, req.MailboxPath, req.RepoDir); err != nil { return err } @@ -169,8 +173,10 @@ func recordDispatch(ctx context.Context, req DispatchRequest, task string, snaps Agent: req.Agent, Base: snapshot.Base, DispatchCommit: snapshot.Commit, + ControlCommit: control, Relay: req.Relay, Policy: req.Policy, + Hooks: hooks, }) } diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go index 18c0fa28..0430bc77 100644 --- a/pkg/gitagent/hookmain.go +++ b/pkg/gitagent/hookmain.go @@ -227,8 +227,12 @@ func sidecarPreReceive(ctx context.Context, repo string, host HookHost, updates } return rejectWithVerdict(repo, verdict, sideband) } + workflow := host.Runtime.SidecarWorkflow + if st.Hooks != nil && st.Hooks.Sidecar != nil { + workflow = st.Hooks.Sidecar + } verdict := vetTree(ctx, repo, vetRequest{ - host: host, workflow: host.Runtime.SidecarWorkflow, tier: string(RoleSidecar), + host: host, workflow: workflow, tier: string(RoleSidecar), task: task, attempt: attempt, depth: 0, from: st.DispatchCommit, to: branchUpdate.New, }) @@ -290,14 +294,35 @@ func mailboxPreReceive(ctx context.Context, repo string, host HookHost, updates if envelope != nil { depth = envelope.Depth } + workflow := host.Runtime.SupervisorWorkflow + if st.Hooks != nil && st.Hooks.Supervisor != nil { + workflow = st.Hooks.Supervisor + } verdict := vetTree(ctx, repo, vetRequest{ - host: host, workflow: host.Runtime.SupervisorWorkflow, tier: "supervisor", + host: host, workflow: workflow, tier: "supervisor", task: info.Task, attempt: info.Attempt, depth: depth, from: st.DispatchCommit, to: resultUpdate.New, }) if verdict.Rejects() { return rejectWithVerdict(repo, verdict, sideband) } + if host.Runtime.RealRepo != "" { + conflict, err := checkIntegration(ctx, host.Runtime.RealRepo, repo, st.Base, resultUpdate.New, os.Environ()) + if err != nil { + verdict.Status = StatusError + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "integrate", Kind: "commit", Message: "could not evaluate integration: " + err.Error(), + }) + return rejectWithVerdict(repo, verdict, sideband) + } + if conflict != "" { + verdict.Status = StatusRejected + verdict.Findings = append(verdict.Findings, Finding{ + Hook: "integrate", Kind: "commit", Message: conflict, + }) + return rejectWithVerdict(repo, verdict, sideband) + } + } return nil } @@ -384,11 +409,14 @@ func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates if envelope == nil { return fmt.Errorf("dispatch ref %s arrived without an envelope", u.Ref) } - policy, taskPayload, controlCommit := loadDispatchPayloads(ctx, repo, updates, info) + payloads, err := loadDispatchPayloads(ctx, repo, updates, info) + if err != nil { + return err + } if err := SaveTaskState(repo, &TaskState{ Task: info.Task, Agent: envelope.Agent, Base: envelope.Base, - DispatchCommit: u.New, ControlCommit: controlCommit, - Relay: envelope.Relay, Policy: policy, + DispatchCommit: u.New, ControlCommit: payloads.controlCommit, + Relay: envelope.Relay, Policy: payloads.policy, Hooks: payloads.hooks, }); err != nil { return err } @@ -396,7 +424,7 @@ func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates if err != nil { return err } - taskFile, err := WriteTaskFile(repo, info.Task, taskPayload) + taskFile, err := WriteTaskFile(repo, info.Task, payloads.task) if err != nil { return err } @@ -407,29 +435,43 @@ func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates return nil } -// loadDispatchPayloads reads policy.json and task.json from the control -// commit that travelled with the dispatch. Absent payloads fall back to -// defaults — the dispatch is still processable (only the envelope is -// mandatory). -func loadDispatchPayloads(ctx context.Context, repo string, updates []RefUpdate, dispatch RefInfo) (Policy, []byte, string) { - var policy Policy - taskPayload := []byte("{}") +type dispatchPayloads struct { + policy Policy + task []byte + hooks *HookSets + controlCommit string +} + +// loadDispatchPayloads reads the task policy carried by the paired control +// commit. Hooks become durable receiver state; consulting only local receiver +// config would let the dispatched vetting policy disappear at the sidecar. +func loadDispatchPayloads(ctx context.Context, repo string, updates []RefUpdate, dispatch RefInfo) (dispatchPayloads, error) { + payloads := dispatchPayloads{task: []byte("{}")} controlRef, err := ControlRef(dispatch.Task, dispatch.Attempt) if err != nil { - return policy, taskPayload, "" + return payloads, err } control := refUpdateFor(updates, controlRef) if control.New == "" || isZeroOID(control.New) { - return policy, taskPayload, "" + return payloads, fmt.Errorf("dispatch %s has no paired control commit", dispatch.Task) } + payloads.controlCommit = control.New env := os.Environ() if raw, err := ReadControlPayload(ctx, repo, env, control.New, ControlPolicyFile); err == nil { - _ = json.Unmarshal(raw, &policy) + if err := json.Unmarshal(raw, &payloads.policy); err != nil { + return payloads, fmt.Errorf("decode %s: %w", ControlPolicyFile, err) + } } if raw, err := ReadControlPayload(ctx, repo, env, control.New, ControlTaskFile); err == nil && len(raw) > 0 { - taskPayload = raw + payloads.task = raw + } + if raw, err := ReadControlPayload(ctx, repo, env, control.New, ControlHooksFile); err == nil { + payloads.hooks, err = DecodeHookSets(raw) + if err != nil { + return payloads, err + } } - return policy, taskPayload, control.New + return payloads, nil } func mailboxPostReceive(ctx context.Context, repo string, host HookHost, updates []RefUpdate) error { @@ -451,8 +493,10 @@ func mailboxPostReceive(ctx context.Context, repo string, host HookHost, updates return err } if integration.Conflict != "" { - // The work was accepted; only its integration needs a human. The - // conflict is reported, never auto-resolved (R10.2). + // Pre-receive normally rejects this. If HEAD changed in the narrow + // gap before post-receive, fail the verdict closed instead of + // claiming that unintegrated work was accepted. + verdict.Status = StatusError verdict.Findings = append(verdict.Findings, Finding{ Hook: "integrate", Kind: "commit", Message: integration.Conflict, }) diff --git a/pkg/gitagent/integrate.go b/pkg/gitagent/integrate.go index fccbb3cd..530d6de2 100644 --- a/pkg/gitagent/integrate.go +++ b/pkg/gitagent/integrate.go @@ -18,6 +18,25 @@ type IntegrationResult struct { Conflict string // non-empty when the merge conflicted; Branch is then unset } +type integrationPlan struct { + head, tree, conflict string +} + +// checkIntegration evaluates the merge while the result is still quarantined. +// The mailbox can see both the quarantine and the real repository's objects, +// so pre-receive can reject a conflict before git reports push success. +func checkIntegration(ctx context.Context, realRepo, mailbox, base, result string, env []string) (string, error) { + head, err := runGit(ctx, realRepo, ScrubGitEnv(env), "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return "", err + } + plan, err := prepareIntegration(ctx, mailbox, env, head, base, result) + if err != nil { + return "", err + } + return plan.conflict, nil +} + // Integrate merges result into the real repository's HEAD using base as the // merge base and parks the outcome on refs/heads/captain/, leaving the // user's worktree and current branch untouched. mailbox is fetched first: the @@ -40,11 +59,40 @@ func Integrate(ctx context.Context, realRepo, mailbox, task string, attempt int, if err != nil { return nil, err } + plan, err := prepareIntegration(ctx, realRepo, env, head, base, result) + if err != nil { + return nil, err + } + if plan.conflict != "" { + return &IntegrationResult{Conflict: plan.conflict}, nil + } + cenv := envWith(env, + "GIT_AUTHOR_NAME=captain", + "GIT_AUTHOR_EMAIL=captain@localhost", + "GIT_COMMITTER_NAME=captain", + "GIT_COMMITTER_EMAIL=captain@localhost", + ) + merge, err := runGitIn(ctx, realRepo, cenv, + strings.NewReader(fmt.Sprintf("captain: integrate task %s\n", task)), + "commit-tree", plan.tree, "-p", plan.head, "-p", result) + if err != nil { + return nil, err + } + if _, err := runGit(ctx, realRepo, env, "update-ref", branch, merge); err != nil { + return nil, err + } + return &IntegrationResult{Branch: branch, Commit: merge}, nil +} + +// prepareIntegration computes a three-way merge tree without updating refs. +// objectRepo must be able to read head, base, and result; during pre-receive +// that is the mailbox with its quarantine environment intact. +func prepareIntegration(ctx context.Context, objectRepo string, env []string, head, base, result string) (*integrationPlan, error) { // Older supported Git releases do not have merge-tree's --merge-base // option. Give the current HEAD tree a synthetic parent at the envelope's // recorded base instead: merge-tree then computes that exact base from the // graph while leaving the user's real history and checkout untouched. - headTree, err := runGit(ctx, realRepo, env, "rev-parse", "--verify", head+"^{tree}") + headTree, err := runGit(ctx, objectRepo, env, "rev-parse", "--verify", head+"^{tree}") if err != nil { return nil, err } @@ -54,13 +102,13 @@ func Integrate(ctx context.Context, realRepo, mailbox, task string, attempt int, "GIT_COMMITTER_NAME=captain", "GIT_COMMITTER_EMAIL=captain@localhost", ) - mergeHead, err := runGitIn(ctx, realRepo, cenv, + mergeHead, err := runGitIn(ctx, objectRepo, cenv, strings.NewReader("captain: integration merge input\n"), "commit-tree", headTree, "-p", base) if err != nil { return nil, err } - code, out, err := gitExitCode(ctx, realRepo, env, + code, out, err := gitExitCode(ctx, objectRepo, env, "merge-tree", "--write-tree", mergeHead, result) if err != nil { return nil, err @@ -74,19 +122,9 @@ func Integrate(ctx context.Context, realRepo, mailbox, task string, attempt int, if len(lines) > 1 { detail = "merge conflict in: " + strings.Join(lines[1:], ", ") } - return &IntegrationResult{Conflict: detail}, nil + return &integrationPlan{head: head, conflict: detail}, nil case code != 0 || len(lines) == 0: return nil, fmt.Errorf("merge-tree failed (exit %d): %s", code, strings.TrimSpace(out)) } - tree := strings.TrimSpace(lines[0]) - merge, err := runGitIn(ctx, realRepo, cenv, - strings.NewReader(fmt.Sprintf("captain: integrate task %s\n", task)), - "commit-tree", tree, "-p", head, "-p", result) - if err != nil { - return nil, err - } - if _, err := runGit(ctx, realRepo, env, "update-ref", branch, merge); err != nil { - return nil, err - } - return &IntegrationResult{Branch: branch, Commit: merge}, nil + return &integrationPlan{head: head, tree: strings.TrimSpace(lines[0])}, nil } diff --git a/pkg/gitagent/regression_test.go b/pkg/gitagent/regression_test.go index cadd153f..667aeac2 100644 --- a/pkg/gitagent/regression_test.go +++ b/pkg/gitagent/regression_test.go @@ -153,7 +153,7 @@ func TestRecordDispatchCreatesAuditRefsAtomically(t *testing.T) { t.Fatal(err) } req := DispatchRequest{RepoDir: repo, MailboxPath: mailbox, Task: "t-atomic"} - if err := recordDispatch(ctx, req, "t-atomic", snapshot, control); err == nil { + if err := recordDispatch(ctx, req, "t-atomic", snapshot, control, &HookSets{}); err == nil { t.Fatal("recordDispatch succeeded despite an existing control ref") } dispatchRef, _ := DispatchRef("t-atomic", 1) diff --git a/pkg/gitagent/state.go b/pkg/gitagent/state.go index b12ac62b..8b00874b 100644 --- a/pkg/gitagent/state.go +++ b/pkg/gitagent/state.go @@ -29,6 +29,7 @@ type TaskState struct { Attempts int `json:"attempts"` // highest attempt seen Relay RelayMode `json:"relay,omitempty"` Policy Policy `json:"policy"` + Hooks *HookSets `json:"hooks,omitempty"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go index 573ff94d..91a3bd81 100644 --- a/pkg/sandbox/adapter/gitagent.go +++ b/pkg/sandbox/adapter/gitagent.go @@ -61,10 +61,10 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp return nil, err } repoDir := spec.Cwd() - hooksJSON, _ := json.Marshal(map[string]any{ - "sidecar": g.cfg.Options["hooks"], - "supervisor": nil, - }) + hooksJSON, err := hookSetsJSON(g.cfg.Options) + if err != nil { + return nil, err + } prompt := "" system := "" if spec.Prompt.User != "" { @@ -103,6 +103,23 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp return gitAgentResponse(dispatch.Task, verdict), nil } +// hookSetsJSON preserves the sidecar/supervisor split declared by the backend +// and validates it against the protocol payload before dispatch. +func hookSetsJSON(options map[string]any) ([]byte, error) { + value := any(map[string]any{}) + if configured, ok := options["hooks"]; ok { + value = configured + } + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode git-agent hooks: %w", err) + } + if _, err := gitagent.DecodeHookSets(data); err != nil { + return nil, err + } + return data, nil +} + func gitAgentResponse(task string, verdict *gitagent.TierVerdict) *api.Response { text := fmt.Sprintf("git-agent task %s attempt %d: %s", task, verdict.Attempt, verdict.Status) for _, f := range verdict.Findings { diff --git a/pkg/sandbox/adapter/gitagent_test.go b/pkg/sandbox/adapter/gitagent_test.go index 394b1e61..ecdd1a80 100644 --- a/pkg/sandbox/adapter/gitagent_test.go +++ b/pkg/sandbox/adapter/gitagent_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" ) func TestGitAgentRejectsInvalidWaitTimeout(t *testing.T) { @@ -14,3 +15,34 @@ func TestGitAgentRejectsInvalidWaitTimeout(t *testing.T) { } } } + +func TestHookSetsJSONPreservesConfiguredTiers(t *testing.T) { + data, err := hookSetsJSON(map[string]any{ + "hooks": map[string]any{ + "sidecar": map[string]any{"verify": map[string]any{"commands": []any{"false"}}}, + "supervisor": map[string]any{"verify": map[string]any{"commands": []any{"make lint"}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + hooks, err := gitagent.DecodeHookSets(data) + if err != nil { + t.Fatal(err) + } + if got := hooks.Sidecar.Verify.Commands; len(got) != 1 || got[0] != "false" { + t.Fatalf("sidecar commands = %#v", got) + } + if got := hooks.Supervisor.Verify.Commands; len(got) != 1 || got[0] != "make lint" { + t.Fatalf("supervisor commands = %#v", got) + } +} + +func TestHookSetsJSONRejectsAWorkflowAtTheWrongLevel(t *testing.T) { + _, err := hookSetsJSON(map[string]any{ + "hooks": map[string]any{"verify": map[string]any{"commands": []any{"false"}}}, + }) + if err == nil { + t.Fatal("hooks.verify was accepted; hooks must name sidecar or supervisor") + } +} From bf68f550f6213bf5cecf29c8b0f0e7605235f34b Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 07:38:37 +0000 Subject: [PATCH 15/22] fix(gitagent): preserve supervisor relay verdict Amp-Thread-ID: https://ampcode.com/threads/T-019fd5cc-e03d-7056-b868-774c78b47738 Co-authored-by: Aditya Thebe --- pkg/gitagent/conformance_ginkgo_test.go | 4 ++ pkg/gitagent/hookmain.go | 8 +++ pkg/gitagent/relay.go | 70 ++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/pkg/gitagent/conformance_ginkgo_test.go b/pkg/gitagent/conformance_ginkgo_test.go index d2b55039..a003a366 100644 --- a/pkg/gitagent/conformance_ginkgo_test.go +++ b/pkg/gitagent/conformance_ginkgo_test.go @@ -271,6 +271,10 @@ var _ = Describe("protocol conformance (§12)", Serial, func() { out, err := w.agentPush(map[string]string{"pkg/ok.txt": "bad\n"}) Expect(err).To(HaveOccurred(), "push must be rejected:\n%s", out) Expect(out).To(ContainSubstring("verify:grep -q good pkg/ok.txt"), "the supervisor's feedback travelled the sideband chain") + Expect(out).NotTo(ContainSubstring("remote: remote:"), "the relay removes the inner git transport prefix") + Expect(strings.Count(out, "captain-json: ")).To(Equal(1), "the supervisor verdict remains the only machine summary") + Expect(out).To(ContainSubstring(`"status":"rejected","tier":"supervisor"`)) + Expect(out).NotTo(ContainSubstring(`"hook":"relay"`), "a supervisor rejection is not a sidecar transport error") // The supervisor's rejection persisted at its tier (R6.9); quarantine // left no result ref behind. diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go index 0430bc77..6e33b790 100644 --- a/pkg/gitagent/hookmain.go +++ b/pkg/gitagent/hookmain.go @@ -8,6 +8,7 @@ package gitagent import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -241,6 +242,13 @@ func sidecarPreReceive(ctx context.Context, repo string, host HookHost, updates } if host.Runtime.Relay.URL != "" { if err := relayUpward(ctx, repo, host, st, attempt, branchUpdate.New, sideband); err != nil { + var rejected *upstreamRejectedError + if errors.As(err, &rejected) { + // The supervisor already persisted and rendered the authoritative + // verdict. Return non-zero to reject this push without replacing it + // with a second, generic sidecar relay-error verdict. + return err + } verdict.Status = StatusError verdict.Findings = append(verdict.Findings, Finding{ Hook: "relay", Kind: "exec", Message: err.Error(), diff --git a/pkg/gitagent/relay.go b/pkg/gitagent/relay.go index f2a6a75b..299caa08 100644 --- a/pkg/gitagent/relay.go +++ b/pkg/gitagent/relay.go @@ -8,6 +8,7 @@ package gitagent import ( "context" + "encoding/json" "fmt" "io" "strings" @@ -21,6 +22,66 @@ type RelayTarget struct { SSHCommand string `json:"sshCommand,omitempty"` // "" ⇒ this binary's transport } +// upstreamRejectedError distinguishes a supervisor verdict from a failure to +// obtain one. The supervisor has already persisted and rendered this verdict; +// the sidecar only needs the error to reject its own still-blocked push. +type upstreamRejectedError struct { + verdict TierVerdict +} + +func (e *upstreamRejectedError) Error() string { + return fmt.Sprintf("supervisor rejected task %s attempt %d (%s)", + e.verdict.Task, e.verdict.Attempt, e.verdict.Status) +} + +// relayFeedbackWriter removes the inner git client's "remote: " transport +// prefix before forwarding through the outer receive-pack. It also retains +// the structured supervisor verdict so an ordinary rejection is not +// misclassified as a sidecar transport error. +type relayFeedbackWriter struct { + dst io.Writer + pending string + verdict *TierVerdict +} + +func (w *relayFeedbackWriter) Write(p []byte) (int, error) { + w.pending += string(p) + for { + i := strings.IndexByte(w.pending, '\n') + if i < 0 { + break + } + line := w.pending[:i+1] + w.pending = w.pending[i+1:] + if err := w.writeLine(line); err != nil { + return 0, err + } + } + return len(p), nil +} + +func (w *relayFeedbackWriter) flush() error { + if w.pending == "" { + return nil + } + line := w.pending + w.pending = "" + return w.writeLine(line) +} + +func (w *relayFeedbackWriter) writeLine(line string) error { + line = strings.TrimPrefix(line, "remote: ") + candidate := strings.TrimSpace(line) + if raw, ok := strings.CutPrefix(candidate, "captain-json: "); ok { + var verdict TierVerdict + if json.Unmarshal([]byte(raw), &verdict) == nil && verdict.Tier == "supervisor" { + w.verdict = &verdict + } + } + _, err := io.WriteString(w.dst, line) + return err +} + // BuildResultCommit squashes the agent's branch tip into the single result // commit the protocol requires: tree = the tip's tree, parent = the dispatch // commit (§3.2). Written through the hook environment, so in pre-receive the @@ -70,11 +131,18 @@ func Relay(ctx context.Context, repo string, hookEnv []string, target RelayTarge // R1.4: unset only GIT_QUARANTINE_PATH; the object-directory variables // stay so the quarantined objects remain readable for the outbound pack. env := envWith(RelayEnv(hookEnv), pairs...) - code, out, err := gitExitCodeStderr(ctx, repo, env, sideband, args...) + feedback := &relayFeedbackWriter{dst: sideband} + code, out, err := gitExitCodeStderr(ctx, repo, env, feedback, args...) + if flushErr := feedback.flush(); err == nil && flushErr != nil { + err = flushErr + } if err != nil { return err } if code != 0 { + if feedback.verdict != nil && feedback.verdict.Rejects() { + return &upstreamRejectedError{verdict: *feedback.verdict} + } return fmt.Errorf("supervisor rejected attempt %d (exit %d)%s", envelope.Attempt, code, strings.TrimSpace(out)) } return nil From abe567ab917f07e30617b832f72da2d2c858adff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 08:57:33 +0000 Subject: [PATCH 16/22] fix(gitagent): confine exec hooks to their materialized workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exec hooks declared as shell strings never ran under hookSandbox: srt — the adapter's per-CLI switch rejected "sh" before sandbox-runtime started. Allowing it naively would have been worse: the hook path never called Prepare, so the filesystem policy fell back to the hook process's working directory — the bare receiving repository, whose hooks/ dir the mandatory deny scan does not cover — making the receive shims themselves writable from inside the sandbox. The SRT adapter gains an explicit hook profile, selected at construction (api.SandboxProfileHook), never inferred from the wrapped argv: writes are confined to the materialized tree plus a private scratch directory that becomes the run's TMPDIR and HOME (host /tmp stays read-only), network isolation is on with every domain denied, no credential env is passed through, and reads of provider state, captain's own config and key material, and the receiving repository are hidden on top of the host-credential deny list. Prepare with no workspace fails closed. ResolveHookWrap now returns a per-workspace factory instead of a bare wrap func: the confining sandbox is built after materialization, against the tree it confines, and closed once the hook set has run. Unknown kinds and wrapper-less adapters still fail at hook startup. Two boundary leaks are closed alongside: CmdVerifier no longer widens a wrapper's nil env into full process inheritance (the pre-wrap env is the boundary), and hooks now receive an allowlisted environment (HookExecEnv) rather than a scrubbed copy of the host environ, so ambient provider credentials cannot reach agent-authored commands and R1.1's git scrub falls out of the allowlist (issue #40 R5.2). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168pEfMnMsDZRcvX6D1ynMY --- pkg/ai/agent/verify/cmd_hardening_test.go | 24 +++ pkg/ai/agent/verify/verify.go | 7 + pkg/api/sandbox_registry.go | 16 ++ pkg/cli/gitagent_hook.go | 4 +- pkg/gitagent/hookmain.go | 86 ++++++++-- pkg/gitagent/hookset.go | 2 +- pkg/gitagent/hookwrap_test.go | 154 ++++++++++++++++++ pkg/gitagent/scrub.go | 26 +++ pkg/sandbox/adapter/srt.go | 189 +++++++++++++++++++--- pkg/sandbox/adapter/srt_test.go | 184 +++++++++++++++++++++ 10 files changed, 648 insertions(+), 44 deletions(-) create mode 100644 pkg/gitagent/hookwrap_test.go diff --git a/pkg/ai/agent/verify/cmd_hardening_test.go b/pkg/ai/agent/verify/cmd_hardening_test.go index 6c7c108e..154d11c0 100644 --- a/pkg/ai/agent/verify/cmd_hardening_test.go +++ b/pkg/ai/agent/verify/cmd_hardening_test.go @@ -2,6 +2,7 @@ package verify import ( "context" + "os" "strings" "testing" "time" @@ -62,6 +63,29 @@ func TestCmdVerifier_StartFailureFeedsBackTheError(t *testing.T) { assert.Contains(t, verdict.Feedback, "captain-no-such-binary") } +// A wrapper that returns no environment must leave the pre-wrap boundary in +// place, not fall through to full process inheritance: the git-agent hook path +// hands a deliberately reduced Env to Wrap, and inheriting the process +// environment would silently expose every ambient credential to an +// agent-authored command (issue #40). +func TestCmdVerifier_WrapWithNilEnvKeepsTheDeclaredBoundary(t *testing.T) { + t.Setenv("CAPTAIN_TEST_AMBIENT_SECRET", "leaked") + + v := &CmdVerifier{ + Cmd: "sh", + Args: []string{"-c", `test -z "$CAPTAIN_TEST_AMBIENT_SECRET" && test "$MARKER" = ok`}, + Env: []string{"PATH=" + os.Getenv("PATH"), "MARKER=ok"}, + Wrap: func(_ context.Context, cmd string, args, _ []string) (string, []string, []string, error) { + return cmd, args, nil, nil // a wrapper that supplies no environment + }, + } + + verdict, err := v.Verify(context.Background(), t.TempDir(), nil) + + require.NoError(t, err) + assert.True(t, verdict.OK, "declared env must reach the command and the ambient secret must not: %s", verdict.Feedback) +} + // A parent deadline shorter than the verifier's own Timeout is the RUN's // cancellation, not the command's: it must come back as an error, never as a // verdict blaming the command for "timing out after ". diff --git a/pkg/ai/agent/verify/verify.go b/pkg/ai/agent/verify/verify.go index 3852094d..d33039bb 100644 --- a/pkg/ai/agent/verify/verify.go +++ b/pkg/ai/agent/verify/verify.go @@ -145,6 +145,13 @@ func (c *CmdVerifier) Verify(ctx context.Context, cwd string, changed []string) if err != nil { return Verdict{}, fmt.Errorf("wrapping %s for sandboxed execution: %w", c.Cmd, err) } + if env == nil { + // A wrapper that supplies no environment keeps the pre-wrap + // boundary. Leaving env nil here would hand the wrapped process + // the full inherited environment — silently widening a caller's + // deliberately reduced Env (the git-agent hook path, issue #40). + env = wrapEnv + } } cmd := exec.CommandContext(runCtx, command, cmdArgs...) diff --git a/pkg/api/sandbox_registry.go b/pkg/api/sandbox_registry.go index f03feb92..53bd4364 100644 --- a/pkg/api/sandbox_registry.go +++ b/pkg/api/sandbox_registry.go @@ -26,6 +26,22 @@ type SandboxConfig struct { Policy *SandboxPolicy `json:"policy,omitempty" yaml:"policy,omitempty"` } +// Options keys shared between the git-agent hook resolver and the adapters it +// constructs. They live here because the resolver (pkg/gitagent) cannot import +// the adapters (pkg/sandbox/adapter imports pkg/gitagent). +const ( + // SandboxOptionProfile selects a policy profile within one adapter kind. + SandboxOptionProfile = "profile" + // SandboxProfileHook is the generic exec-hook profile: the wrapped command + // is untrusted, agent-authored repository code, so it gets its prepared + // workspace and nothing else — no network, no provider credentials or + // state, no host credentials (issue #40 R5.2). + SandboxProfileHook = "hook" + // SandboxOptionDenyRead carries extra deny-read paths ([]string) the + // profile must hide — for hooks, the receiving repository itself. + SandboxOptionDenyRead = "denyRead" +) + // SandboxFactory constructs a Sandbox from a SandboxConfig. type SandboxFactory func(cfg SandboxConfig) (Sandbox, error) diff --git a/pkg/cli/gitagent_hook.go b/pkg/cli/gitagent_hook.go index b5c5344d..040cd96a 100644 --- a/pkg/cli/gitagent_hook.go +++ b/pkg/cli/gitagent_hook.go @@ -36,7 +36,7 @@ func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) fmt.Fprintf(os.Stderr, "captain: %v\n", err) return nil, err } - wrap, err := gitagent.ResolveHookWrap(runtime.HookSandbox) + wrapFor, err := gitagent.ResolveHookWrap(runtime.HookSandbox, opts.Repo) if err != nil { fmt.Fprintf(os.Stderr, "captain: %v\n", err) return nil, err @@ -58,7 +58,7 @@ func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) host := gitagent.HookHost{ Runtime: runtime, Judge: judge, - Wrap: wrap, + WrapFor: wrapFor, // With no agentCommand configured, the sidecar still launches a real // agent: this binary, working the task in the prepared worktree. The // alternative — launching nothing — leaves the supervisor waiting out diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go index 6e33b790..3a836813 100644 --- a/pkg/gitagent/hookmain.go +++ b/pkg/gitagent/hookmain.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "testing" "time" @@ -48,7 +49,7 @@ func (r HookRuntime) RequiresJudge() bool { type HookHost struct { Runtime HookRuntime Judge ai.Provider - Wrap verify.CommandWrapFunc + WrapFor HookWrapFactory Timeout time.Duration // DefaultAgentCommand supplies the command to launch when the backend // declares no agentCommand. Without it a dispatch would prepare a @@ -87,10 +88,21 @@ func LoadHookRuntime(path string) (HookRuntime, error) { return rt, nil } -// ResolveHookWrap maps HookRuntime.HookSandbox onto a confinement func via -// the sandbox registry. Empty means none — RunHookSet then refuses exec hooks -// rather than running them bare (R5.2). -func ResolveHookWrap(name string) (verify.CommandWrapFunc, error) { +// HookWrapFactory builds a confinement wrapper scoped to one materialized +// workspace. The sandbox's filesystem policy depends on the tree it confines, +// and that tree only exists once a push is being vetted — so hooks resolve a +// factory up front and construct the wrapper per vet, after materialization. +// The returned close releases the sandbox; callers must invoke it once the +// hook set has run. +type HookWrapFactory func(ctx context.Context, dir string) (verify.CommandWrapFunc, func() error, error) + +// ResolveHookWrap maps HookRuntime.HookSandbox onto a per-workspace +// confinement factory via the sandbox registry. Empty means none — RunHookSet +// then refuses exec hooks rather than running them bare (R5.2). repo is the +// receiving repository, which the hook policy must hide from the confined +// command. Unknown kinds and adapters without a command wrapper fail here, at +// hook startup, not mid-vet. +func ResolveHookWrap(name, repo string) (HookWrapFactory, error) { switch name { case "": return nil, nil @@ -98,23 +110,52 @@ func ResolveHookWrap(name string) (verify.CommandWrapFunc, error) { if !testing.Testing() { return nil, fmt.Errorf("hookSandbox test-identity is only available inside a test binary (R5.2)") } - return func(_ context.Context, cmd string, args, env []string) (string, []string, []string, error) { - return cmd, args, env, nil + return func(_ context.Context, _ string) (verify.CommandWrapFunc, func() error, error) { + wrap := func(_ context.Context, cmd string, args, env []string) (string, []string, []string, error) { + return cmd, args, env, nil + } + return wrap, func() error { return nil }, nil }, nil } kind, ok := api.ParseSandboxKind(name) if !ok { return nil, fmt.Errorf("unknown hook sandbox kind %q", name) } - sandbox, err := api.NewSandbox(api.SandboxConfig{Kind: kind}) + cfg := api.SandboxConfig{Kind: kind, Options: map[string]any{ + api.SandboxOptionProfile: api.SandboxProfileHook, + }} + if abs, err := filepath.Abs(repo); err == nil && repo != "" { + cfg.Options[api.SandboxOptionDenyRead] = []string{abs} + } + // Probe once at resolve time so a kind that cannot confine commands is a + // startup error rather than a per-push surprise. + probe, err := api.NewSandbox(cfg) if err != nil { return nil, err } - wrapper, ok := api.SandboxAs[api.CommandWrapper](sandbox) - if !ok { + _, canWrap := api.SandboxAs[api.CommandWrapper](probe) + _ = probe.Close() + if !canWrap { return nil, fmt.Errorf("hook sandbox %q provides no command wrapper; exec hooks cannot run confined (R5.2)", name) } - return wrapper.Wrap, nil + return func(ctx context.Context, dir string) (verify.CommandWrapFunc, func() error, error) { + sandbox, err := api.NewSandbox(cfg) + if err != nil { + return nil, nil, err + } + spec := &api.Spec{} + spec.SetCwd(dir) + if _, err := sandbox.Prepare(ctx, spec); err != nil { + _ = sandbox.Close() + return nil, nil, err + } + wrapper, ok := api.SandboxAs[api.CommandWrapper](sandbox) + if !ok { + _ = sandbox.Close() + return nil, nil, fmt.Errorf("hook sandbox %q provides no command wrapper; exec hooks cannot run confined (R5.2)", name) + } + return wrapper.Wrap, sandbox.Close, nil + }, nil } // HookMain is the shim entrypoint: args are @@ -138,12 +179,12 @@ func HookMain(args []string) int { fmt.Fprintln(os.Stderr, "captain: verify prompts declared but the standalone hook shim has no provider; use `captain sandbox git-agent hook`") return 1 } - wrap, err := ResolveHookWrap(runtime.HookSandbox) + wrapFor, err := ResolveHookWrap(runtime.HookSandbox, repo) if err != nil { fmt.Fprintf(os.Stderr, "captain: %v\n", err) return 1 } - host := HookHost{Runtime: runtime, Wrap: wrap} + host := HookHost{Runtime: runtime, WrapFor: wrapFor} ctx := context.Background() switch hook { case "pre-receive": @@ -364,6 +405,21 @@ func vetTree(ctx context.Context, repo string, req vetRequest) TierVerdict { verdict.Findings = append(verdict.Findings, Finding{Hook: "materialize", Kind: "exec", Message: err.Error()}) return verdict } + // The confinement wrapper is built here, against the tree it will confine: + // only now does the workspace exist, and a sandbox whose filesystem policy + // was computed for any other directory would confine the wrong thing. A + // factory failure is fail-closed (R5.2): status error, and error rejects. + var wrap verify.CommandWrapFunc + if req.host.WrapFor != nil && len(verify.HooksForWorkflow(req.workflow)) > 0 { + wrapped, closeWrap, err := req.host.WrapFor(ctx, dir) + if err != nil { + verdict.Findings = append(verdict.Findings, Finding{Hook: "hookset", Kind: "exec", + Message: "hook sandbox could not confine the workspace: " + err.Error()}) + return verdict + } + defer func() { _ = closeWrap() }() + wrap = wrapped + } stop := StartProgress(os.Stderr, req.tier+" hooks", 30*time.Second) defer stop() return RunHookSet(ctx, HookWorkspace{Dir: dir, Changed: changed}, HookSetOptions{ @@ -373,8 +429,8 @@ func vetTree(ctx context.Context, repo string, req vetRequest) TierVerdict { Attempt: req.attempt, Depth: req.depth, Judge: req.host.Judge, - Wrap: req.host.Wrap, - Env: ScrubGitEnv(hookEnv), + Wrap: wrap, + Env: HookExecEnv(hookEnv), Timeout: req.host.Timeout, }) } diff --git a/pkg/gitagent/hookset.go b/pkg/gitagent/hookset.go index 5a4d6b6f..9d1d4ca4 100644 --- a/pkg/gitagent/hookset.go +++ b/pkg/gitagent/hookset.go @@ -38,7 +38,7 @@ type HookSetOptions struct { Depth int // envelope depth of the push being vetted (R5.4/H15) Judge ai.Provider // provider for prompt hooks; nil forbids them Wrap verify.CommandWrapFunc - Env []string // scrubbed environment for exec hooks (R1.1) + Env []string // allowlisted environment for exec hooks (R1.1, HookExecEnv) Timeout time.Duration } diff --git a/pkg/gitagent/hookwrap_test.go b/pkg/gitagent/hookwrap_test.go new file mode 100644 index 00000000..fec2a893 --- /dev/null +++ b/pkg/gitagent/hookwrap_test.go @@ -0,0 +1,154 @@ +package gitagent + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai/agent/verify" + "github.com/flanksource/captain/pkg/api" +) + +func TestHookExecEnv(t *testing.T) { + got := HookExecEnv([]string{ + "PATH=/usr/bin", + "HOME=/home/u", + "LANG=C.UTF-8", + "LC_ALL=C", + "GIT_DIR=.", + "GIT_QUARANTINE_PATH=/q", + "ANTHROPIC_API_KEY=sk-secret", + "OPENAI_API_KEY=sk-secret", + "AWS_SECRET_ACCESS_KEY=secret", + "CAPTAIN_TASK=t-1", + }) + want := []string{"PATH=/usr/bin", "HOME=/home/u", "LANG=C.UTF-8", "LC_ALL=C"} + if len(got) != len(want) { + t.Fatalf("HookExecEnv = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("HookExecEnv = %v, want %v", got, want) + } + } +} + +func TestResolveHookWrap_EmptyMeansNone(t *testing.T) { + factory, err := ResolveHookWrap("", t.TempDir()) + if err != nil || factory != nil { + t.Fatalf("ResolveHookWrap(\"\") = %v, %v; want nil factory, nil error", factory, err) + } +} + +func TestResolveHookWrap_UnknownKindFailsAtResolveTime(t *testing.T) { + if _, err := ResolveHookWrap("no-such-sandbox", t.TempDir()); err == nil { + t.Fatal("unknown hook sandbox kind must fail at resolve time") + } +} + +// vetTreeFixture builds a repo with two commits and returns their OIDs, so +// vetTree has a real from→to range to materialize. +func vetTreeFixture(t *testing.T) (repo, from, to string) { + t.Helper() + ctx := context.Background() + repo = t.TempDir() + env := os.Environ() + mustGit := func(args ...string) string { + out, err := runGit(ctx, repo, env, args...) + if err != nil { + t.Fatalf("git %v: %v", args, err) + } + return strings.TrimSpace(out) + } + mustGit("init", "-q") + mustGit("config", "user.name", "t") + mustGit("config", "user.email", "t@example.com") + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit("add", "-A") + mustGit("commit", "-q", "-m", "one") + from = mustGit("rev-parse", "HEAD") + if err := os.WriteFile(filepath.Join(repo, "b.txt"), []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit("add", "-A") + mustGit("commit", "-q", "-m", "two") + to = mustGit("rev-parse", "HEAD") + return repo, from, to +} + +// A hook-sandbox factory that cannot confine the workspace must fail the vet +// closed: status error, and error rejects (R5.2/R7.5). +func TestVetTreeFailsClosedWhenHookSandboxFails(t *testing.T) { + repo, from, to := vetTreeFixture(t) + host := HookHost{WrapFor: func(context.Context, string) (verify.CommandWrapFunc, func() error, error) { + return nil, nil, errors.New("no confinement available") + }} + verdict := vetTree(context.Background(), repo, vetRequest{ + host: host, + workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"true"}}}, + tier: "sidecar", task: "t-1", attempt: 1, + from: from, to: to, + }) + if verdict.Status != StatusError || !verdict.Rejects() { + t.Fatalf("verdict = %+v, want a rejecting error status", verdict) + } + if len(verdict.Findings) == 0 || !strings.Contains(verdict.Findings[0].Message, "no confinement available") { + t.Fatalf("findings = %+v, want the sandbox failure surfaced", verdict.Findings) + } +} + +// The wrapper must be constructed against the materialized tree — the hook +// command observing the pushed files proves the confinement target and the +// execution directory agree — and the factory's close must run. +func TestVetTreeBuildsTheWrapperForTheMaterializedTree(t *testing.T) { + repo, from, to := vetTreeFixture(t) + var confined string + closed := false + host := HookHost{WrapFor: func(_ context.Context, dir string) (verify.CommandWrapFunc, func() error, error) { + confined = dir + wrap := func(_ context.Context, cmd string, args, env []string) (string, []string, []string, error) { + return cmd, args, env, nil + } + return wrap, func() error { closed = true; return nil }, nil + }} + verdict := vetTree(context.Background(), repo, vetRequest{ + host: host, + workflow: &api.Workflow{Verify: &api.Verify{Commands: []string{"test -f a.txt && test -f b.txt"}}}, + tier: "sidecar", task: "t-1", attempt: 1, + from: from, to: to, + }) + if verdict.Status != StatusAccepted { + t.Fatalf("verdict = %+v, want accepted", verdict) + } + if confined == "" { + t.Fatal("the factory never saw a workspace directory") + } + if !closed { + t.Fatal("the factory's close must run after the hook set") + } +} + +// Prompt-only workflows must not construct a sandbox at all: there is no exec +// hook to confine, and a hook sandbox failure would reject pushes that run no +// commands. +func TestVetTreeSkipsTheSandboxWithoutExecHooks(t *testing.T) { + repo, from, to := vetTreeFixture(t) + host := HookHost{WrapFor: func(context.Context, string) (verify.CommandWrapFunc, func() error, error) { + t.Fatal("no sandbox may be constructed for a workflow without exec hooks") + return nil, nil, nil + }} + verdict := vetTree(context.Background(), repo, vetRequest{ + host: host, + workflow: &api.Workflow{}, + tier: "sidecar", task: "t-1", attempt: 1, + from: from, to: to, + }) + if verdict.Status != StatusAccepted { + t.Fatalf("verdict = %+v, want accepted", verdict) + } +} diff --git a/pkg/gitagent/scrub.go b/pkg/gitagent/scrub.go index 67d4c974..27c960e9 100644 --- a/pkg/gitagent/scrub.go +++ b/pkg/gitagent/scrub.go @@ -42,6 +42,32 @@ func scrubbedGitVar(name string) bool { return false } +// hookExecEnvAllowed are the variables an exec hook may inherit by exact name. +// PATH so declared commands resolve; HOME and TMPDIR because tools need them +// (a sandboxing wrapper may re-point both at a private scratch directory); the +// rest is locale and terminal plumbing. Everything else — provider +// credentials, host tokens, the receive-pack git context — stays out. +var hookExecEnvAllowed = map[string]struct{}{ + "PATH": {}, "HOME": {}, "TMPDIR": {}, "LANG": {}, "TZ": {}, + "TERM": {}, "USER": {}, "LOGNAME": {}, "SHELL": {}, +} + +// HookExecEnv reduces env to the allowlist an exec hook may see. Hooks run +// agent-authored commands, so their environment is a boundary, not an +// inheritance: R1.1's git scrub falls out of the allowlist (no GIT_* survives) +// and no ambient credential — provider API keys included — can reach the hook +// (issue #40). +func HookExecEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + if _, ok := hookExecEnvAllowed[name]; ok || strings.HasPrefix(name, "LC_") { + out = append(out, kv) + } + } + return out +} + // RelayEnv returns env with only GIT_QUARANTINE_PATH removed. The relay push // from inside pre-receive must keep the inherited object directories so the // quarantined objects stay readable, and must not copy them (R1.4, verified diff --git a/pkg/sandbox/adapter/srt.go b/pkg/sandbox/adapter/srt.go index 0c011d08..a513a37b 100644 --- a/pkg/sandbox/adapter/srt.go +++ b/pkg/sandbox/adapter/srt.go @@ -11,6 +11,7 @@ import ( "time" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" sandboxruntime "github.com/flanksource/sandbox-runtime/sandbox" ) @@ -27,18 +28,42 @@ var NewSRTRuntime = func(ctx context.Context, cfg sandboxruntime.Config) (Runtim return sandboxruntime.New(ctx, cfg) } -// srtSandbox confines the agent CLI with sandbox-runtime (filesystem and -// network policy). The confinement is constructed per wrapped command, because -// its policy depends on which CLI is being run. +// srtSandbox confines a process with sandbox-runtime (filesystem and network +// policy). It carries one of two policies, chosen at construction: the per-CLI +// provider policy (which API domains, credentials and state directory that CLI +// needs), or the generic exec-hook policy (untrusted repository code gets its +// prepared workspace and nothing else). The confinement is constructed per +// wrapped command, because its policy depends on what is being run. type srtSandbox struct { cwd string + // hook selects the generic exec-hook profile (api.SandboxProfileHook). + // Hook policy is a construction-time choice, never inferred from the + // wrapped argv: keying it off the binary name would hand agent-authored + // code a provider policy — credentials included — the day a hook invokes + // a supported CLI. + hook bool + // hookDenyRead are extra paths the hook profile must hide (the receiving + // repository), from api.SandboxOptionDenyRead. + hookDenyRead []string + // scratch is the hook run's private writable directory: its TMPDIR and + // HOME, created by Prepare and removed by Close. It exists so the policy + // need not allow host /tmp wholesale, where concurrent runs' trees live. + scratch string + mu sync.Mutex runtimes []Runtime } // SRT is the SandboxFactory for the sandbox-runtime adapter. -func SRT(api.SandboxConfig) (api.Sandbox, error) { return &srtSandbox{}, nil } +func SRT(cfg api.SandboxConfig) (api.Sandbox, error) { + s := &srtSandbox{} + if profile, _ := cfg.Options[api.SandboxOptionProfile].(string); profile == api.SandboxProfileHook { + s.hook = true + s.hookDenyRead = stringSliceOption(cfg.Options, api.SandboxOptionDenyRead) + } + return s, nil +} func init() { api.RegisterSandbox(api.SandboxSRT, SRT) } @@ -46,11 +71,30 @@ func (s *srtSandbox) Kind() api.SandboxKind { return api.SandboxSRT } func (s *srtSandbox) Prepare(_ context.Context, spec *api.Spec) (*api.SandboxSession, error) { s.cwd = spec.Cwd() + if s.hook { + // The hook profile confines to an explicit workspace or not at all: a + // cwd fallback here would build the policy for whatever directory the + // receive hook happens to run in — the bare receiving repository. + if s.cwd == "" { + return nil, fmt.Errorf("srt hook sandbox requires an explicit workspace; refusing to confine to the process working directory") + } + scratch, err := os.MkdirTemp("", "captain-hook-scratch-") + if err != nil { + return nil, fmt.Errorf("create hook scratch directory: %w", err) + } + s.scratch = scratch + } return &api.SandboxSession{}, nil } func (s *srtSandbox) Wrap(ctx context.Context, command string, args, env []string) (string, []string, []string, error) { - cfg, err := srtConfigFor(command, s.cwd) + var cfg sandboxruntime.Config + var err error + if s.hook { + cfg, err = srtHookConfigFor(s.cwd, s.scratch, s.hookDenyRead) + } else { + cfg, err = srtConfigFor(command, s.cwd) + } if err != nil { return "", nil, nil, err } @@ -68,12 +112,21 @@ func (s *srtSandbox) Wrap(ctx context.Context, command string, args, env []strin } // When the runtime supplies its own environment, the request-declared // variables are appended so they survive; when it supplies none, nil is - // returned and the exec seam falls back to the full resolved environment, - // which already contains them. + // returned and the exec seam falls back to its own environment boundary. wrappedEnv := cmd.Env if len(wrappedEnv) > 0 { wrappedEnv = append(wrappedEnv, env...) } + if s.hook { + // The hook environment is explicit, never inherited: the caller's env + // is the whole boundary, and the run's TMPDIR and HOME move into the + // scratch directory so tools have somewhere writable that is not the + // tree under verification and not host /tmp. + if wrappedEnv == nil { + wrappedEnv = append([]string(nil), env...) + } + wrappedEnv = append(wrappedEnv, "TMPDIR="+s.scratch, "HOME="+s.scratch) + } return cmd.Args[0], cmd.Args[1:], wrappedEnv, nil } @@ -90,6 +143,12 @@ func (s *srtSandbox) Close() error { } cancel() } + if s.scratch != "" { + if err := os.RemoveAll(s.scratch); err != nil { + errs = append(errs, err) + } + s.scratch = "" + } return errors.Join(errs...) } @@ -135,26 +194,104 @@ func srtConfigFor(command, cwd string) (sandboxruntime.Config, error) { }, Filesystem: sandboxruntime.FilesystemConfig{ AllowWrite: append([]string{absoluteCwd, "/tmp"}, statePaths...), - DenyRead: []string{ - filepath.Join(home, ".ssh"), - filepath.Join(home, ".aws"), - filepath.Join(home, ".azure"), - filepath.Join(home, ".config", "gcloud"), - filepath.Join(home, ".kube"), - filepath.Join(home, ".netrc"), - filepath.Join(home, ".git-credentials"), - filepath.Join(home, ".config", "gh"), - filepath.Join(home, ".docker", "config.json"), - filepath.Join(home, ".npmrc"), - filepath.Join(home, ".pypirc"), - filepath.Join(home, ".docker", "run", "docker.sock"), - "/var/run/docker.sock", - "/run/docker.sock", - "/run/containerd/containerd.sock", - "/run/podman/podman.sock", - }, - DenyWrite: []string{}, + DenyRead: hostCredentialDenyRead(home), + DenyWrite: []string{}, }, PassthroughEnv: passthroughEnv, }, nil } + +// srtHookConfigFor builds the generic exec-hook confinement. The wrapped +// command is agent-authored repository code (issue #40 R5.2), so the policy is +// the inverse of the CLI ones: write access to the materialized workspace and +// the run's scratch directory only (not host /tmp, where other runs' trees +// live), network denied entirely, no credential env passthrough, and reads of +// provider state, captain's own key material and the receiving repository +// hidden on top of the host-credential list. Both directories must already +// exist and be non-empty paths — sandbox-runtime silently skips missing +// AllowWrite entries, which here would mean an unwritable workspace, so this +// fails closed instead. +func srtHookConfigFor(workspace, scratch string, extraDenyRead []string) (sandboxruntime.Config, error) { + if workspace == "" || scratch == "" { + return sandboxruntime.Config{}, fmt.Errorf("srt hook sandbox has no prepared workspace; Prepare must run with the materialized tree before Wrap") + } + absoluteWorkspace, err := filepath.Abs(workspace) + if err != nil { + return sandboxruntime.Config{}, fmt.Errorf("resolve hook workspace %q: %w", workspace, err) + } + home, err := os.UserHomeDir() + if err != nil { + return sandboxruntime.Config{}, fmt.Errorf("resolve sandbox home directory: %w", err) + } + denyRead := append(hostCredentialDenyRead(home), + // Provider state and credentials the CLI policies deliberately allow. + filepath.Join(home, ".claude"), + filepath.Join(home, ".claude.json"), + filepath.Join(home, ".codex"), + filepath.Join(home, ".gemini"), + ) + // Captain's own configuration (which may embed provider keys) and its + // git-agent key material and served repositories. + if configPath, err := captainconfig.Path(); err == nil { + denyRead = append(denyRead, configPath, filepath.Join(filepath.Dir(configPath), ".captain")) + } + for _, path := range extraDenyRead { + if abs, err := filepath.Abs(path); err == nil { + denyRead = append(denyRead, abs) + } + } + + return sandboxruntime.Config{ + Network: sandboxruntime.NetworkConfig{ + // Non-nil and empty: network isolation is ON and every domain is + // denied. nil would mean "no network restriction" instead. + AllowedDomains: []string{}, + DeniedDomains: []string{}, + }, + Filesystem: sandboxruntime.FilesystemConfig{ + AllowWrite: []string{absoluteWorkspace, scratch}, + DenyRead: denyRead, + DenyWrite: []string{}, + }, + }, nil +} + +// hostCredentialDenyRead is the host credential material no sandboxed process +// may read, shared by every SRT policy: SSH, cloud, git, container and +// package-manager credentials, plus container runtime sockets. +func hostCredentialDenyRead(home string) []string { + return []string{ + filepath.Join(home, ".ssh"), + filepath.Join(home, ".aws"), + filepath.Join(home, ".azure"), + filepath.Join(home, ".config", "gcloud"), + filepath.Join(home, ".kube"), + filepath.Join(home, ".netrc"), + filepath.Join(home, ".git-credentials"), + filepath.Join(home, ".config", "gh"), + filepath.Join(home, ".docker", "config.json"), + filepath.Join(home, ".npmrc"), + filepath.Join(home, ".pypirc"), + filepath.Join(home, ".docker", "run", "docker.sock"), + "/var/run/docker.sock", + "/run/docker.sock", + "/run/containerd/containerd.sock", + "/run/podman/podman.sock", + } +} + +func stringSliceOption(options map[string]any, key string) []string { + switch value := options[key].(type) { + case []string: + return value + case []any: + var out []string + for _, item := range value { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} diff --git a/pkg/sandbox/adapter/srt_test.go b/pkg/sandbox/adapter/srt_test.go index 95bff458..26ff9569 100644 --- a/pkg/sandbox/adapter/srt_test.go +++ b/pkg/sandbox/adapter/srt_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/gitagent" sandboxruntime "github.com/flanksource/sandbox-runtime/sandbox" ) @@ -130,3 +131,186 @@ func specWithCwd(cwd string) *api.Spec { spec.SetCwd(cwd) return spec } + +func hookSandboxConfig(denyRead ...string) api.SandboxConfig { + options := map[string]any{api.SandboxOptionProfile: api.SandboxProfileHook} + if len(denyRead) > 0 { + options[api.SandboxOptionDenyRead] = denyRead + } + return api.SandboxConfig{Kind: api.SandboxSRT, Options: options} +} + +// The hook profile is the boundary for agent-authored commands (issue #40 +// R5.2): writes confined to the prepared workspace plus a private scratch dir, +// network denied entirely, no credential passthrough, and provider state +// hidden on top of the host-credential list. +func TestSRTAdapter_HookProfile(t *testing.T) { + original := NewSRTRuntime + t.Cleanup(func() { NewSRTRuntime = original }) + fake := &fakeRuntime{} + NewSRTRuntime = func(_ context.Context, cfg sandboxruntime.Config) (Runtime, error) { + fake.gotConfig = cfg + return fake, nil + } + + repo := t.TempDir() + sandbox, err := api.NewSandbox(hookSandboxConfig(repo)) + if err != nil { + t.Fatal(err) + } + workspace := t.TempDir() + if _, err := sandbox.Prepare(context.Background(), specWithCwd(workspace)); err != nil { + t.Fatal(err) + } + wrapper, _ := api.SandboxAs[api.CommandWrapper](sandbox) + command, args, env, err := wrapper.Wrap(context.Background(), "sh", []string{"-c", "make lint"}, []string{"PATH=/usr/bin"}) + if err != nil { + t.Fatal(err) + } + if command != "srt-wrapper" || len(args) != 3 || args[0] != "sh" || args[1] != "-c" || args[2] != "make lint" { + t.Fatalf("wrapped argv = %q %v", command, args) + } + + cfg := fake.gotConfig + if cfg.Network.AllowedDomains == nil || len(cfg.Network.AllowedDomains) != 0 { + t.Fatalf("allowed domains = %#v, want non-nil empty (network isolation on, everything denied)", cfg.Network.AllowedDomains) + } + if len(cfg.Filesystem.AllowWrite) != 2 || cfg.Filesystem.AllowWrite[0] != workspace { + t.Fatalf("allowWrite = %v, want exactly [workspace, scratch]", cfg.Filesystem.AllowWrite) + } + scratch := cfg.Filesystem.AllowWrite[1] + if !strings.Contains(scratch, "captain-hook-scratch-") { + t.Fatalf("second allowWrite = %q, want the run's scratch directory", scratch) + } + if info, err := os.Stat(scratch); err != nil || !info.IsDir() { + t.Fatalf("scratch %q must exist before wrap (missing AllowWrite paths are silently skipped): %v", scratch, err) + } + for _, path := range cfg.Filesystem.AllowWrite { + if path == "/tmp" { + t.Fatal("hook policy must not allow host /tmp, where other runs' trees live") + } + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + filepath.Join(home, ".ssh"), + filepath.Join(home, ".claude"), + filepath.Join(home, ".claude.json"), + filepath.Join(home, ".codex"), + filepath.Join(home, ".gemini"), + repo, + } { + found := false + for _, got := range cfg.Filesystem.DenyRead { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("denyRead %v missing %q", cfg.Filesystem.DenyRead, want) + } + } + if len(cfg.PassthroughEnv) != 0 { + t.Fatalf("passthroughEnv = %v, want none for hooks", cfg.PassthroughEnv) + } + + // The environment is explicit: the runtime's env, the declared env, and + // TMPDIR/HOME re-pointed at the scratch directory. + want := []string{"SRT=1", "PATH=/usr/bin", "TMPDIR=" + scratch, "HOME=" + scratch} + if !reflect.DeepEqual(env, want) { + t.Fatalf("wrapped env = %v, want %v", env, want) + } + + if err := sandbox.Close(); err != nil { + t.Fatal(err) + } + if !fake.closed { + t.Fatal("Close must close the live runtime") + } + if _, err := os.Stat(scratch); !os.IsNotExist(err) { + t.Fatalf("Close must remove the scratch directory, stat err = %v", err) + } +} + +// Without Prepare there is no workspace to confine to, and falling back to the +// process working directory would build the policy for the bare receiving +// repository — the exact directory hooks must never touch. +func TestSRTAdapter_HookProfileFailsClosedWithoutWorkspace(t *testing.T) { + original := NewSRTRuntime + t.Cleanup(func() { NewSRTRuntime = original }) + NewSRTRuntime = func(_ context.Context, _ sandboxruntime.Config) (Runtime, error) { + t.Fatal("no runtime may be constructed without a prepared workspace") + return nil, nil + } + + sandbox, err := api.NewSandbox(hookSandboxConfig()) + if err != nil { + t.Fatal(err) + } + if _, err := sandbox.Prepare(context.Background(), &api.Spec{}); err == nil { + t.Fatal("Prepare with no cwd must fail closed for the hook profile") + } + + wrapper, _ := api.SandboxAs[api.CommandWrapper](sandbox) + if _, _, _, err := wrapper.Wrap(context.Background(), "sh", []string{"-c", "true"}, nil); err == nil { + t.Fatal("Wrap before a successful Prepare must fail closed") + } +} + +// The none adapter is registered here but confines nothing; resolving it for +// hooks must fail at resolve time rather than let exec hooks run bare (R5.2). +func TestResolveHookWrap_NoneIsRefused(t *testing.T) { + if _, err := gitagent.ResolveHookWrap("none", t.TempDir()); err == nil || + !strings.Contains(err.Error(), "no command wrapper") { + t.Fatalf("hookSandbox none must be refused loudly, got err = %v", err) + } +} + +// The regression this guards: ResolveHookWrap used to hand out a wrapper with +// no Prepare call at all, so the SRT policy was computed for the hook +// process's working directory instead of the materialized tree. +func TestResolveHookWrap_SRTConfinesTheMaterializedTree(t *testing.T) { + original := NewSRTRuntime + t.Cleanup(func() { NewSRTRuntime = original }) + fake := &fakeRuntime{} + NewSRTRuntime = func(_ context.Context, cfg sandboxruntime.Config) (Runtime, error) { + fake.gotConfig = cfg + return fake, nil + } + + repo := t.TempDir() + factory, err := gitagent.ResolveHookWrap("srt", repo) + if err != nil { + t.Fatal(err) + } + tree := t.TempDir() + wrap, closeWrap, err := factory(context.Background(), tree) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := wrap(context.Background(), "sh", []string{"-c", "true"}, []string{"PATH=/usr/bin"}); err != nil { + t.Fatal(err) + } + if got := fake.gotConfig.Filesystem.AllowWrite[0]; got != tree { + t.Fatalf("confinement workspace = %q, want the materialized tree %q", got, tree) + } + found := false + for _, path := range fake.gotConfig.Filesystem.DenyRead { + if path == repo { + found = true + break + } + } + if !found { + t.Fatalf("denyRead %v must hide the receiving repository %q", fake.gotConfig.Filesystem.DenyRead, repo) + } + if err := closeWrap(); err != nil { + t.Fatal(err) + } + if !fake.closed { + t.Fatal("the factory's close must close the live runtime") + } +} From cf0d9ca079a5a179857b31769f8c1846eba254b6 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 6 Aug 2026 20:19:56 +0545 Subject: [PATCH 17/22] feat(gitagent): stream agent task logs from serve Detached agent runs wrote normal Captain output only to task log files, leaving the long-running sidecar terminal silent while work was executing. Tail new task output from the serve process and add task, attempt, verdict, submission, failure, and duration lifecycle messages. Preserve detached receive-pack semantics and avoid replaying historical logs after restart. --- pkg/cli/gitagent_e2e_test.go | 31 ++++- pkg/cli/gitagent_runtask.go | 15 ++- pkg/cli/gitagent_serve.go | 6 + pkg/cli/gitagent_task_logs.go | 208 +++++++++++++++++++++++++++++ pkg/cli/gitagent_task_logs_test.go | 99 ++++++++++++++ 5 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 pkg/cli/gitagent_task_logs.go create mode 100644 pkg/cli/gitagent_task_logs_test.go diff --git a/pkg/cli/gitagent_e2e_test.go b/pkg/cli/gitagent_e2e_test.go index b26ad00f..a0680740 100644 --- a/pkg/cli/gitagent_e2e_test.go +++ b/pkg/cli/gitagent_e2e_test.go @@ -104,9 +104,10 @@ func moduleRoot(t *testing.T) string { // host is one machine in the topology: an isolated HOME and the binary. type host struct { - t *testing.T - home string - bin string + t *testing.T + home string + bin string + serveOut *lockedBuffer } func newHost(t *testing.T) *host { @@ -144,6 +145,7 @@ func (h *host) serve(port string, args ...string) { cmd := exec.Command(h.bin, full...) cmd.Env = h.env() var out lockedBuffer + h.serveOut = &out cmd.Stdout, cmd.Stderr = &out, &out if err := cmd.Start(); err != nil { h.t.Fatal(err) @@ -171,6 +173,13 @@ func (h *host) serve(port string, args ...string) { h.t.Fatalf("serve never began listening:\n%s", out.String()) } +func (h *host) serveLogs() string { + if h.serveOut == nil { + return "" + } + return h.serveOut.String() +} + func (h *host) configBytes() string { h.t.Helper() data, err := os.ReadFile(filepath.Join(h.home, ".captain.yaml")) @@ -644,6 +653,15 @@ func TestDispatchLaunchesAnAgent(t *testing.T) { if !strings.Contains(integrated, "func Greet()") || !strings.Contains(integrated, "// dirty") { t.Fatalf("integration lost the agent's work or the dispatched state:\n%s", integrated) } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + logs := agent.serveLogs() + if strings.Contains(logs, "git-agent task") && strings.Contains(logs, "received") && strings.Contains(logs, "accepted at sidecar") { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("agent serve did not report the task lifecycle:\n%s", agent.serveLogs()) } // agentLogs returns whatever the detached agent wrote, which is the only @@ -741,4 +759,11 @@ func TestUnconfiguredDispatchLaunchesTheDefaultAgent(t *testing.T) { t.Fatalf("an unconfigured backend launched nothing; the dispatch would wait out its whole budget in silence\ndispatch output:\n%s", out.String()) } t.Logf("default agent produced:\n%s", launched) + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && !strings.Contains(agent.serveLogs(), "starting") { + time.Sleep(50 * time.Millisecond) + } + if logs := agent.serveLogs(); !strings.Contains(logs, "git-agent task") || !strings.Contains(logs, "starting") { + t.Fatalf("agent serve did not stream the default Captain run log:\n%s", logs) + } } diff --git a/pkg/cli/gitagent_runtask.go b/pkg/cli/gitagent_runtask.go index 5e5cbf4e..ce3519b2 100644 --- a/pkg/cli/gitagent_runtask.go +++ b/pkg/cli/gitagent_runtask.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "strings" + "time" "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/aiflags" @@ -27,7 +28,13 @@ type GitAgentRunTaskOptions struct { // dispatched prompt, run it in the worktree, then commit and push. Its output // is the agent log, so every failure is reported there rather than to a // terminal nobody is watching. -func RunGitAgentRunTask(ctx context.Context, opts GitAgentRunTaskOptions) (any, error) { +func RunGitAgentRunTask(ctx context.Context, opts GitAgentRunTaskOptions) (_ any, runErr error) { + started := time.Now() + defer func() { + if runErr != nil { + log.Errorf("git-agent task %s failed after %s: %v", opts.Task, time.Since(started).Round(time.Millisecond), runErr) + } + }() if strings.TrimSpace(opts.Config) != "" { captainconfig.SetPath(opts.Config) } @@ -39,12 +46,15 @@ func RunGitAgentRunTask(ctx context.Context, opts GitAgentRunTaskOptions) (any, if err != nil { return nil, err } + log.Infof("git-agent task %s starting %s:%s in %s", opts.Task, payload.Backend, payload.Model, worktree) if err := runTaskPrompt(ctx, worktree, payload); err != nil { return nil, fmt.Errorf("running the dispatched prompt: %w", err) } + log.Infof("git-agent task %s agent finished after %s; preparing submission", opts.Task, time.Since(started).Round(time.Millisecond)) if err := submitWork(ctx, worktree, opts.Task); err != nil { return nil, err } + log.Infof("git-agent task %s accepted after %s", opts.Task, time.Since(started).Round(time.Millisecond)) return nil, nil } @@ -80,6 +90,7 @@ func runTaskPrompt(ctx context.Context, worktree string, payload gitagent.TaskPa // run produced, commit, and push. A run that changed nothing is reported as // such rather than pushed as an empty success. func submitWork(ctx context.Context, worktree, task string) error { + log.Infof("git-agent task %s staging workspace changes", task) if err := git(ctx, worktree, "add", "-A"); err != nil { return err } @@ -90,11 +101,13 @@ func submitWork(ctx context.Context, worktree, task string) error { if !staged { return fmt.Errorf("the agent produced no changes for task %s; nothing to submit", task) } + log.Infof("git-agent task %s committing workspace changes", task) if err := git(ctx, worktree, "commit", "-m", "captain: "+task); err != nil { return err } // The push carries the work through both hook tiers and blocks until the // verdict, so its output is the agent's most important log line. + log.Infof("git-agent task %s pushing for sidecar and supervisor verification", task) return git(ctx, worktree, "push") } diff --git a/pkg/cli/gitagent_serve.go b/pkg/cli/gitagent_serve.go index b6331a1b..a31cca73 100644 --- a/pkg/cli/gitagent_serve.go +++ b/pkg/cli/gitagent_serve.go @@ -80,6 +80,12 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro clicky.Printf(" host key: %s\n", hostFP) if role == gitagent.RoleMailbox { clicky.Printf(" enroll an agent with: captain sandbox git-agent add --endpoint ssh://:\n") + } else { + monitor := newAgentTaskLogMonitor(filepath.Join(root, SidecarRepoName), os.Stdout, os.Stderr, log.Infof) + if err := monitor.prime(); err != nil { + log.Warnf("git-agent task log monitor: %v", err) + } + go monitor.run(ctx) } go func() { <-ctx.Done() diff --git a/pkg/cli/gitagent_task_logs.go b/pkg/cli/gitagent_task_logs.go new file mode 100644 index 00000000..dd6ae5ec --- /dev/null +++ b/pkg/cli/gitagent_task_logs.go @@ -0,0 +1,208 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/flanksource/captain/pkg/gitagent" +) + +const agentTaskLogPollInterval = 200 * time.Millisecond + +// agentTaskLogMonitor bridges detached task output back to the long-running +// sidecar terminal. It tails files rather than inheriting receive-pack's file +// descriptors, which would keep the dispatch push open for the agent's life. +type agentTaskLogMonitor struct { + repo string + stdout io.Writer + stderr io.Writer + notify func(string, ...any) + interval time.Duration + tasks map[string]*observedAgentTask +} + +type observedAgentTask struct { + offsets map[string]int64 + attempts int + verdicts map[int]bool +} + +func newAgentTaskLogMonitor(repo string, stdout, stderr io.Writer, notify func(string, ...any)) *agentTaskLogMonitor { + return &agentTaskLogMonitor{ + repo: repo, stdout: stdout, stderr: stderr, notify: notify, + interval: agentTaskLogPollInterval, + tasks: map[string]*observedAgentTask{}, + } +} + +func (m *agentTaskLogMonitor) prime() error { + return m.scan(false) +} + +func (m *agentTaskLogMonitor) run(ctx context.Context) { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.scan(true); err != nil { + log.Warnf("git-agent task log monitor: %v", err) + } + } + } +} + +func (m *agentTaskLogMonitor) scan(reportNew bool) error { + root := filepath.Join(m.repo, "captain", "tasks") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if err := m.scanTask(entry.Name(), reportNew); err != nil { + return fmt.Errorf("task %s: %w", entry.Name(), err) + } + } + return nil +} + +func (m *agentTaskLogMonitor) scanTask(task string, reportNew bool) error { + observed, exists := m.tasks[task] + if !exists { + observed = &observedAgentTask{offsets: map[string]int64{}, verdicts: map[int]bool{}} + m.tasks[task] = observed + if !reportNew { + return m.seedTask(task, observed) + } + m.notify("git-agent task %s received; workspace=%s", task, filepath.Join(m.repo, "captain", "tasks", task, "worktree")) + } + dir := filepath.Join(m.repo, "captain", "tasks", task) + for _, stream := range []struct { + name string + writer io.Writer + }{{"agent.stdout.log", m.stdout}, {"agent.stderr.log", m.stderr}} { + offset, err := copyAppended(filepath.Join(dir, stream.name), observed.offsets[stream.name], stream.writer) + if err != nil { + return err + } + observed.offsets[stream.name] = offset + } + state, found, err := gitagent.LoadTaskState(m.repo, task) + if err != nil { + return err + } + if found && state.Attempts > observed.attempts { + for attempt := observed.attempts + 1; attempt <= state.Attempts; attempt++ { + m.notify("git-agent task %s submit attempt %d", task, attempt) + } + observed.attempts = state.Attempts + } + return m.scanVerdicts(task, observed) +} + +func (m *agentTaskLogMonitor) seedTask(task string, observed *observedAgentTask) error { + dir := filepath.Join(m.repo, "captain", "tasks", task) + for _, name := range []string{"agent.stdout.log", "agent.stderr.log"} { + if info, err := os.Stat(filepath.Join(dir, name)); err == nil { + observed.offsets[name] = info.Size() + } else if !os.IsNotExist(err) { + return err + } + } + if state, found, err := gitagent.LoadTaskState(m.repo, task); err != nil { + return err + } else if found { + observed.attempts = state.Attempts + } + entries, err := os.ReadDir(filepath.Join(dir, "verdicts")) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + for _, entry := range entries { + if attempt, ok := verdictAttempt(entry.Name()); ok { + observed.verdicts[attempt] = true + } + } + return nil +} + +func (m *agentTaskLogMonitor) scanVerdicts(task string, observed *observedAgentTask) error { + dir := filepath.Join(m.repo, "captain", "tasks", task, "verdicts") + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + for _, entry := range entries { + attempt, ok := verdictAttempt(entry.Name()) + if !ok || observed.verdicts[attempt] { + continue + } + verdict, found, err := gitagent.LoadVerdict(m.repo, task, attempt) + if err != nil { + return err + } + if !found { + continue + } + message := "" + if len(verdict.Findings) > 0 && verdict.Findings[0].Message != "" { + message = ": " + verdict.Findings[0].Message + } + m.notify("git-agent task %s attempt %d %s at %s%s", task, attempt, verdict.Status, verdict.Tier, message) + observed.verdicts[attempt] = true + } + return nil +} + +func verdictAttempt(name string) (int, bool) { + if filepath.Ext(name) != ".json" { + return 0, false + } + attempt, err := strconv.Atoi(strings.TrimSuffix(name, ".json")) + return attempt, err == nil && attempt > 0 +} + +func copyAppended(path string, offset int64, dst io.Writer) (int64, error) { + file, err := os.Open(path) + if os.IsNotExist(err) { + return offset, nil + } + if err != nil { + return offset, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return offset, err + } + if info.Size() < offset { + offset = 0 + } + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return offset, err + } + written, err := io.Copy(dst, file) + return offset + written, err +} diff --git a/pkg/cli/gitagent_task_logs_test.go b/pkg/cli/gitagent_task_logs_test.go new file mode 100644 index 00000000..973d1490 --- /dev/null +++ b/pkg/cli/gitagent_task_logs_test.go @@ -0,0 +1,99 @@ +package cli + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/gitagent" +) + +func TestAgentTaskLogMonitorStreamsOnlyNewTaskActivity(t *testing.T) { + repo := t.TempDir() + oldTask := "t-old" + if err := gitagent.SaveTaskState(repo, &gitagent.TaskState{Task: oldTask}); err != nil { + t.Fatal(err) + } + writeAgentLog(t, repo, oldTask, "agent.stderr.log", "old output\n") + + var stdout, stderr bytes.Buffer + var notices []string + monitor := newAgentTaskLogMonitor(repo, &stdout, &stderr, func(format string, args ...any) { + notices = append(notices, formatMessage(format, args...)) + }) + if err := monitor.prime(); err != nil { + t.Fatal(err) + } + if stdout.Len() != 0 || stderr.Len() != 0 || len(notices) != 0 { + t.Fatalf("prime replayed old activity: stdout=%q stderr=%q notices=%v", stdout.String(), stderr.String(), notices) + } + + task := "t-new" + if err := gitagent.SaveTaskState(repo, &gitagent.TaskState{Task: task}); err != nil { + t.Fatal(err) + } + writeAgentLog(t, repo, task, "agent.stdout.log", "model output\n") + writeAgentLog(t, repo, task, "agent.stderr.log", "normal captain log\n") + if err := monitor.scan(true); err != nil { + t.Fatal(err) + } + if stdout.String() != "model output\n" || stderr.String() != "normal captain log\n" { + t.Fatalf("new task output was not streamed: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + + writeAgentLog(t, repo, task, "agent.stderr.log", "next line\n") + if _, err := gitagent.UpdateTaskState(repo, task, func(state *gitagent.TaskState) (bool, error) { + state.Attempts = 1 + return true, nil + }); err != nil { + t.Fatal(err) + } + if err := gitagent.SaveVerdict(repo, gitagent.TierVerdict{ + V: 1, Task: task, Attempt: 1, Tier: "sidecar", Status: gitagent.StatusAccepted, + }); err != nil { + t.Fatal(err) + } + if err := monitor.scan(true); err != nil { + t.Fatal(err) + } + if err := monitor.scan(true); err != nil { + t.Fatal(err) + } + + if stderr.String() != "normal captain log\nnext line\n" { + t.Fatalf("tail replayed or lost bytes: %q", stderr.String()) + } + joined := strings.Join(notices, "\n") + for _, want := range []string{ + "git-agent task t-new received", + "git-agent task t-new submit attempt 1", + "git-agent task t-new attempt 1 accepted at sidecar", + } { + if strings.Count(joined, want) != 1 { + t.Fatalf("notices %q contain %q %d times, want once", joined, want, strings.Count(joined, want)) + } + } +} + +func writeAgentLog(t *testing.T, repo, task, name, text string) { + t.Helper() + dir := filepath.Join(repo, "captain", "tasks", task) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + file, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if _, err := file.WriteString(text); err != nil { + t.Fatal(err) + } +} + +func formatMessage(format string, args ...any) string { + return fmt.Sprintf(format, args...) +} From fa11050bc431e045216f49d37e96b4f3fa4d8dc2 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 7 Aug 2026 00:01:00 +0545 Subject: [PATCH 18/22] feat(gitagent): route tasks through repository mailboxes A single global mailbox was rebound to whichever repository dispatched most recently. Existing task refs then resolved through the wrong object alternate, poisoning later admissions and limiting each supervisor endpoint to one worktree. Derive an immutable mailbox per canonical repository, carry its opaque route through dispatch and relay state, and resolve integration from the mailbox-local binding. Keep synthetic dispatch objects in the mailbox and scope blob admission to the current task range. --- pkg/captainconfig/sandbox_test.go | 2 +- pkg/cli/gitagent.go | 13 +- pkg/cli/gitagent_e2e_test.go | 112 +++++++++++---- pkg/cli/gitagent_hook.go | 21 ++- pkg/cli/gitagent_serve.go | 76 +++++----- pkg/gitagent/admit.go | 9 +- pkg/gitagent/admit_ginkgo_test.go | 12 ++ pkg/gitagent/conformance_ginkgo_test.go | 21 +-- pkg/gitagent/dispatch.go | 25 ++++ pkg/gitagent/enroll.go | 13 +- pkg/gitagent/envelope.go | 23 +++- pkg/gitagent/envelope_ginkgo_test.go | 4 + pkg/gitagent/hookmain.go | 12 +- pkg/gitagent/hookshim.go | 17 ++- pkg/gitagent/mailbox.go | 176 ++++++++++++++++++++++++ pkg/gitagent/receiver.go | 28 ++-- pkg/gitagent/receiver_ginkgo_test.go | 36 ++++- pkg/gitagent/regression_test.go | 45 ++++++ pkg/gitagent/relay.go | 11 +- pkg/gitagent/server.go | 8 +- pkg/gitagent/server_ginkgo_test.go | 15 +- pkg/gitagent/state.go | 1 + pkg/sandbox/adapter/gitagent.go | 30 +++- 23 files changed, 547 insertions(+), 163 deletions(-) create mode 100644 pkg/gitagent/mailbox.go diff --git a/pkg/captainconfig/sandbox_test.go b/pkg/captainconfig/sandbox_test.go index fc6e493f..4789f2d0 100644 --- a/pkg/captainconfig/sandbox_test.go +++ b/pkg/captainconfig/sandbox_test.go @@ -80,7 +80,7 @@ backends: prod-pool: kind: git-agent relay: sync - mailbox: ~/.captain/sandbox/mailbox.git + mailboxRoot: ~/.captain/sandbox/repos local-docker: kind: container presets: [golang, git] diff --git a/pkg/cli/gitagent.go b/pkg/cli/gitagent.go index ee152543..b8274f28 100644 --- a/pkg/cli/gitagent.go +++ b/pkg/cli/gitagent.go @@ -32,7 +32,6 @@ const ( dispatchKeyName = "supervisor_ed25519" // the supervisor's client key agentKeyName = "agent_ed25519" // the agent's client key servedReposDir = "repos" // served root, under the keys dir - MailboxRepoName = "mailbox.git" // the supervisor's mailbox, under the root SidecarRepoName = "repo.git" // the agent's sidecar repo, under the root supervisorAgentID = "supervisor" // the supervisor's identity on a sidecar ) @@ -45,16 +44,6 @@ func gitAgentServedRoot() (string, error) { return filepath.Join(keysDir, servedReposDir), nil } -// gitAgentMailboxPath is where dispatch writes and the supervisor's endpoint -// serves. Keeping them the same path is what makes a relay reachable. -func gitAgentMailboxPath() (string, error) { - root, err := gitAgentServedRoot() - if err != nil { - return "", err - } - return filepath.Join(root, MailboxRepoName), nil -} - // GitAgentHelp documents the group and the two-host setup, because the order // of the steps is the part that is not guessable from the flags. func GitAgentHelp() api.Textable { @@ -72,7 +61,7 @@ func GitAgentHelp() api.Textable { AddText(" captain sandbox git-agent revoke", "text-green-400"). AddText(" — refuse an agent's key from now on", "text-gray-500").NewLine().NewLine(). AddText("Setting up (supervisor first, then the agent host):", "font-bold text-blue-400").NewLine(). - AddText(" 1. supervisor: captain sandbox git-agent serve --role mailbox --repo /path/to/repo", "text-green-400").NewLine(). + AddText(" 1. supervisor: captain sandbox git-agent serve --role mailbox", "text-green-400").NewLine(). AddText(" 2. supervisor: captain sandbox git-agent add worker-01 --endpoint ssh://:7422", "text-green-400").NewLine(). AddText(" 3. agent host: run the printed join command (it enrolls, then serves)", "text-green-400").NewLine(). AddText(" 4. supervisor: captain ai prompt ./task.prompt --sandbox git-agent", "text-green-400").NewLine().NewLine(). diff --git a/pkg/cli/gitagent_e2e_test.go b/pkg/cli/gitagent_e2e_test.go index a0680740..d1c0769d 100644 --- a/pkg/cli/gitagent_e2e_test.go +++ b/pkg/cli/gitagent_e2e_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "encoding/json" "fmt" "net" @@ -259,6 +260,16 @@ func newRepo(t *testing.T) string { return repo } +func mailboxPathForRepo(t *testing.T, supervisor *host, repo string) string { + t.Helper() + root := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir) + mailbox, err := gitagent.MailboxForRepository(context.Background(), root, repo) + if err != nil { + t.Fatal(err) + } + return mailbox.Path +} + // addResult is the JSON `add --format json` emits. type addResult struct { Agent string `json:"agent"` @@ -275,7 +286,7 @@ func enrollPair(t *testing.T) (supervisor, agent *host, repo, agentPort string, repo = newRepo(t) supPort := freeLocalPort(t) - supervisor.serve(supPort, "--role", "mailbox", "--repo", repo) + supervisor.serve(supPort, "--role", "mailbox") out := supervisor.mustRun("sandbox", "git-agent", "add", "worker-01", "--endpoint", "ssh://127.0.0.1:"+supPort, "--format", "json") @@ -370,36 +381,22 @@ func TestEnrollmentProducesADispatchableTopology(t *testing.T) { if !strings.Contains(supervisor.configBytes(), "hostFingerprint") { t.Fatalf("the supervisor recorded no host key to pin when dispatching:\n%s", supervisor.configBytes()) } + if !strings.Contains(supervisor.configBytes(), "mailboxRoot") { + t.Fatalf("the supervisor endpoint recorded no root for lazy mailboxes:\n%s", supervisor.configBytes()) + } - // The agent must have authorized the supervisor's dispatch key, and - // recorded a relay URL carrying a repository path. + // The agent authorizes the supervisor once and retains only its stable + // endpoint. A mailbox path is selected later for each dispatched repository. agentCfg := agent.configBytes() if !strings.Contains(agentCfg, add.DispatchKey) { t.Fatalf("the agent did not authorize the supervisor's dispatch key %s:\n%s", add.DispatchKey, agentCfg) } - if !strings.Contains(agentCfg, MailboxRepoName) { - t.Fatalf("the agent's relay URL carries no repository path:\n%s", agentCfg) - } - - // The supervisor must serve a mailbox, at the path the relay names, with - // mailbox-role hooks and objects shared with the real repository. - mailbox := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir, MailboxRepoName) - if _, err := os.Stat(filepath.Join(mailbox, "HEAD")); err != nil { - t.Fatalf("no mailbox repository where the relay points: %v", err) - } - shim, err := os.ReadFile(filepath.Join(mailbox, "hooks", "pre-receive")) - if err != nil { - t.Fatal(err) + if !strings.Contains(agentCfg, "ssh://127.0.0.1:") { + t.Fatalf("the agent recorded no supervisor endpoint:\n%s", agentCfg) } - if !strings.Contains(string(shim), "mailbox") { - t.Fatalf("mailbox hooks run with the wrong role:\n%s", shim) - } - alternates, err := os.ReadFile(filepath.Join(mailbox, "objects", "info", "alternates")) - if err != nil { - t.Fatalf("the mailbox does not share the repository's objects: %v", err) - } - if !strings.Contains(string(alternates), repo) { - t.Fatalf("mailbox alternates %q do not point at %s", alternates, repo) + mailbox := mailboxPathForRepo(t, supervisor, repo) + if _, err := os.Stat(mailbox); !os.IsNotExist(err) { + t.Fatalf("repository mailbox must be created lazily at dispatch, stat error = %v", err) } } @@ -502,7 +499,7 @@ func TestFullCycleWithAManualAgent(t *testing.T) { // The supervisor holds the result and verdict refs, and integrated the work // onto a branch without touching the user's checkout. - mailbox := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir, MailboxRepoName) + mailbox := mailboxPathForRepo(t, supervisor, repo) refs := gitIn(t, mailbox, "for-each-ref", "--format=%(refname)") for _, want := range []string{"/result/1", "/verdict/1"} { if !strings.Contains(refs, taskID+want) { @@ -564,6 +561,9 @@ func TestGitAgentHelpDocumentsItsOwnCommands(t *testing.T) { if strings.Contains(out, "sandbox-runtime presets") { t.Fatalf("git-agent %s --help is the parent's:\n%s", sub, out) } + if sub == "serve" && strings.Contains(out, "--repo string") { + t.Fatalf("the long-running endpoint must not be repository-bound:\n%s", out) + } } } @@ -635,7 +635,7 @@ func TestDispatchLaunchesAnAgent(t *testing.T) { // The agent's work reached the supervisor and was integrated, with no // human touching the worktree. - mailbox := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir, MailboxRepoName) + mailbox := mailboxPathForRepo(t, supervisor, repo) refs := gitIn(t, mailbox, "for-each-ref", "--format=%(refname)") if !strings.Contains(refs, "/result/1") || !strings.Contains(refs, "/verdict/1") { t.Fatalf("no result/verdict refs; the launched agent never submitted:\n%s", refs) @@ -664,6 +664,64 @@ func TestDispatchLaunchesAnAgent(t *testing.T) { t.Fatalf("agent serve did not report the task lifecycle:\n%s", agent.serveLogs()) } +// TestOneEndpointRoutesTwoRepositories proves service lifetime is independent +// of repository lifetime: one enrollment and one listener own isolated +// mailboxes while the shared sidecar executes both tasks. +func TestOneEndpointRoutesTwoRepositories(t *testing.T) { + if testing.Short() { + t.Skip("builds the captain binary and runs two endpoints") + } + supervisor, agent, repoA, _, _ := enrollPair(t) + repoB := newRepo(t) // same basename, different canonical path + agent.setBackendOption(t, "agentCommand", + `echo "// completed $CAPTAIN_TASK" >> pkg/main.go `+ + `&& git add -A && git commit -q -m "captain: $CAPTAIN_TASK" && git push`) + + run := func(repo string) string { + t.Helper() + writeAt(t, repo, "task.prompt", "---\nsandbox: git-agent\n---\n{{role \"user\"}}\nComplete this task.\n") + cmd := exec.Command(supervisor.bin, "ai", "prompt", "./task.prompt", + "--sandbox", "git-agent", "--timeout", "3m") + cmd.Dir, cmd.Env = repo, supervisor.env() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("dispatch from %s failed: %v\n%s\n%s", repo, err, out, agentLogs(t, agent)) + } + branches := strings.Fields(gitIn(t, repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/captain")) + if len(branches) != 1 { + t.Fatalf("integration branches in %s = %v", repo, branches) + } + return strings.TrimPrefix(branches[0], "captain/") + } + + taskA := run(repoA) + taskB := run(repoB) + if taskA == taskB { + t.Fatalf("two dispatches reused task id %s", taskA) + } + mailboxA := mailboxPathForRepo(t, supervisor, repoA) + mailboxB := mailboxPathForRepo(t, supervisor, repoB) + if mailboxA == mailboxB { + t.Fatalf("same-named repositories share mailbox %s", mailboxA) + } + for _, tc := range []struct { + mailbox, ownTask, otherTask, repo string + }{{mailboxA, taskA, taskB, repoA}, {mailboxB, taskB, taskA, repoB}} { + refs := gitIn(t, tc.mailbox, "for-each-ref", "--format=%(refname)") + if !strings.Contains(refs, tc.ownTask+"/result/1") || strings.Contains(refs, tc.otherTask) { + t.Fatalf("mailbox %s has crossed task namespaces:\n%s", tc.mailbox, refs) + } + binding, err := gitagent.LoadMailboxBinding(tc.mailbox) + if err != nil || binding.Repository != tc.repo { + t.Fatalf("mailbox %s binding = %+v, %v; want %s", tc.mailbox, binding, err, tc.repo) + } + shim, err := os.ReadFile(filepath.Join(tc.mailbox, "hooks", "pre-receive")) + if err != nil || !strings.Contains(string(shim), `--role "mailbox"`) { + t.Fatalf("mailbox %s has no mailbox hook: %v\n%s", tc.mailbox, err, shim) + } + } +} + // agentLogs returns whatever the detached agent wrote, which is the only // diagnosis available when a dispatch fails to conclude. func agentLogs(t *testing.T, agent *host) string { diff --git a/pkg/cli/gitagent_hook.go b/pkg/cli/gitagent_hook.go index 040cd96a..33a993c6 100644 --- a/pkg/cli/gitagent_hook.go +++ b/pkg/cli/gitagent_hook.go @@ -31,11 +31,23 @@ func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) if strings.TrimSpace(opts.Config) != "" { captainconfig.SetPath(opts.Config) } + role := gitagent.ReceiverRole(opts.Role) + if role != gitagent.RoleSidecar && role != gitagent.RoleMailbox { + return nil, fmt.Errorf("unknown receiver role %q", opts.Role) + } runtime, err := hookRuntimeFromConfig(opts.Backend) if err != nil { fmt.Fprintf(os.Stderr, "captain: %v\n", err) return nil, err } + if role == gitagent.RoleMailbox { + binding, err := gitagent.LoadMailboxBinding(opts.Repo) + if err != nil { + fmt.Fprintf(os.Stderr, "captain: %v\n", err) + return nil, err + } + runtime.RealRepo = binding.Repository + } wrapFor, err := gitagent.ResolveHookWrap(runtime.HookSandbox, opts.Repo) if err != nil { fmt.Fprintf(os.Stderr, "captain: %v\n", err) @@ -67,7 +79,6 @@ func RunGitAgentHook(ctx context.Context, opts GitAgentHookOptions) (any, error) return DefaultAgentCommand(exe, repo, task, configPath) }, } - role := gitagent.ReceiverRole(opts.Role) switch opts.Hook { case "pre-receive": return nil, gitagent.RunPreReceive(ctx, opts.Repo, role, host, os.Stdin, os.Stderr) @@ -104,10 +115,9 @@ func hookJudgeProvider(runtime gitagent.HookRuntime) (ai.Provider, error) { return wrapped, nil } -// hookRuntimeFromConfig assembles the receiver runtime from the backend's -// options block: the two hook-set workflows, the confinement sandbox for exec -// hooks, the agent launch command, the integration target, and the relay -// endpoint recorded at enrollment. +// hookRuntimeFromConfig assembles host-wide receiver settings from the +// backend. Repository integration is mailbox-local and is resolved separately +// from the binding beside that mailbox's task state. func hookRuntimeFromConfig(backendName string) (gitagent.HookRuntime, error) { var rt gitagent.HookRuntime cfg, _, err := captainconfig.Load() @@ -128,7 +138,6 @@ func hookRuntimeFromConfig(backendName string) (gitagent.HookRuntime, error) { } rt.HookSandbox, _ = backend.Options["hookSandbox"].(string) rt.AgentCommand, _ = backend.Options["agentCommand"].(string) - rt.RealRepo, _ = backend.Options["repo"].(string) if supervisor, ok := backend.Options["supervisor"].(map[string]any); ok { url, _ := supervisor["url"].(string) hostFP, _ := supervisor["hostFingerprint"].(string) diff --git a/pkg/cli/gitagent_serve.go b/pkg/cli/gitagent_serve.go index a31cca73..7f0cb1a5 100644 --- a/pkg/cli/gitagent_serve.go +++ b/pkg/cli/gitagent_serve.go @@ -18,7 +18,6 @@ type GitAgentServeOptions struct { Listen string `flag:"listen" help:"Address to serve git-receive-pack on" default:":7422"` Root string `flag:"root" help:"Directory of receivable repos (default /repos)"` Role string `flag:"role" help:"Receiver role: sidecar (runs beside a coding agent) or mailbox (the supervisor's receiver)" default:"sidecar"` - Repo string `flag:"repo" help:"mailbox role: the real repository accepted work is integrated into"` Advertise string `flag:"advertise" help:"sidecar role: ssh://host:port the supervisor should dispatch to (default: the address the supervisor sees)"` Join string `flag:"join" help:"Single-use join token printed by 'captain sandbox git-agent add'"` Supervisor string `flag:"supervisor" help:"ssh://host:port of the supervisor to enroll with"` @@ -43,8 +42,12 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro return nil, err } } + root, err = filepath.Abs(root) + if err != nil { + return nil, err + } if opts.Join != "" { - if err := joinSupervisor(ctx, opts, keysDir, root); err != nil { + if err := joinSupervisor(ctx, opts, keysDir); err != nil { return nil, err } } @@ -98,8 +101,8 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro } // enrollmentOffer is what this endpoint hands a joining agent. Only a mailbox -// has anything to offer: its dispatch key, so the agent can authorize the -// supervisor's push, and its mailbox path, so the agent can relay back. +// has a dispatch key for the agent to authorize; task-specific mailbox routes +// arrive later in authenticated dispatch envelopes. func enrollmentOffer(role gitagent.ReceiverRole, keysDir string) (gitagent.EnrollmentOffer, error) { if role != gitagent.RoleMailbox { return gitagent.EnrollmentOffer{}, nil @@ -108,13 +111,13 @@ func enrollmentOffer(role gitagent.ReceiverRole, keysDir string) (gitagent.Enrol if err != nil { return gitagent.EnrollmentOffer{}, err } - return gitagent.EnrollmentOffer{DispatchKey: dispatchFP, MailboxPath: MailboxRepoName}, nil + return gitagent.EnrollmentOffer{DispatchKey: dispatchFP}, nil } // joinSupervisor performs the enrollment exchange and records both directions -// of trust: the supervisor's dispatch key is authorized locally so its push is -// accepted, and its mailbox URL is recorded so the relay knows where to go. -func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir, root string) error { +// of trust: the supervisor's dispatch key is authorized locally, and its base +// endpoint is retained for task-specific result relays. +func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir string) error { if opts.Supervisor == "" { return fmt.Errorf("--join requires --supervisor ssh://host:port") } @@ -149,18 +152,15 @@ func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir, roo if err != nil { return err } - mailboxURL, err := gitagent.MailboxURL(opts.Supervisor, resp.MailboxPath) - if err != nil { - return err - } err = captainconfig.Update(func(cfg *captainconfig.Config) error { backend, err := ensureGitAgentBackend(cfg, opts.Backend) if err != nil { return err } - // Where the relay pushes, and the host key to pin when it does. + // The task supplies its repository-specific mailbox route; enrollment + // records only the stable supervisor endpoint and host identity. backend.Options["supervisor"] = map[string]any{ - "url": mailboxURL, + "url": strings.TrimSuffix(opts.Supervisor, "/"), "hostFingerprint": strings.TrimSpace(opts.HostFingerprint), } // Authorize the supervisor's dispatch key so its push is accepted @@ -180,7 +180,7 @@ func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir, roo clicky.Printf("enrolled as %s\n", resp.Agent) clicky.Printf(" this agent's key: %s\n", fp) clicky.Printf(" this endpoint's host key: %s\n", hostFP) - clicky.Printf(" relays to: %s\n", mailboxURL) + clicky.Printf(" relays to: %s/\n", strings.TrimSuffix(opts.Supervisor, "/")) clicky.Printf(" authorized supervisor key: %s\n", resp.DispatchKey) return nil } @@ -211,20 +211,10 @@ func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverR return err } case gitagent.RoleMailbox: - repo := strings.TrimSpace(opts.Repo) - if repo == "" { - return fmt.Errorf("--role mailbox requires --repo : the repository accepted work is integrated into") - } - abs, err := filepath.Abs(repo) - if err != nil { - return err - } - // The mailbox must be the same path dispatch writes to, or a relayed - // result would land somewhere the supervisor never reads. - if err := gitagent.InitMailbox(ctx, filepath.Join(root, MailboxRepoName), abs); err != nil { + if err := os.MkdirAll(filepath.Join(root, gitagent.MailboxesDir), 0o755); err != nil { return err } - if err := recordMailboxRepo(opts.Backend, abs); err != nil { + if err := recordMailboxRoot(opts.Backend, root); err != nil { return err } } @@ -238,34 +228,40 @@ func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverR if err != nil { return err } - entries, err := os.ReadDir(root) - if err != nil { - return err - } - for _, e := range entries { - if !e.IsDir() { - continue + var repos []string + if role == gitagent.RoleSidecar { + repos = append(repos, filepath.Join(root, SidecarRepoName)) + } else { + entries, err := os.ReadDir(filepath.Join(root, gitagent.MailboxesDir)) + if err != nil { + return err } - repo := filepath.Join(root, e.Name()) + for _, e := range entries { + if e.IsDir() { + repos = append(repos, filepath.Join(root, gitagent.MailboxesDir, e.Name())) + } + } + } + for _, repo := range repos { if _, err := os.Stat(filepath.Join(repo, "HEAD")); err != nil { continue } - if err := gitagent.InstallHookShims(repo, exe, configPath, role); err != nil { + if err := gitagent.InstallHookShims(repo, exe, configPath, role, opts.Backend); err != nil { return err } } return nil } -// recordMailboxRepo tells the mailbox's receive hooks which repository to -// integrate accepted work into. -func recordMailboxRepo(backendName, repo string) error { +// recordMailboxRoot lets dispatch processes create repository-specific +// mailboxes under the same root served by this long-running endpoint. +func recordMailboxRoot(backendName, root string) error { return captainconfig.Update(func(cfg *captainconfig.Config) error { backend, err := ensureGitAgentBackend(cfg, backendName) if err != nil { return err } - backend.Options["repo"] = repo + backend.Options["mailboxRoot"] = root cfg.Sandbox.Backends[backendName] = backend return nil }) diff --git a/pkg/gitagent/admit.go b/pkg/gitagent/admit.go index 1f2310d8..082b719d 100644 --- a/pkg/gitagent/admit.go +++ b/pkg/gitagent/admit.go @@ -123,6 +123,9 @@ func admitProtocolRef(req AdmitRequest, u RefUpdate) (RefInfo, error) { if err := req.Envelope.MatchesRef(info); err != nil { return RefInfo{}, err } + if req.Role == RoleSidecar && info.Kind == RefDispatch && req.Envelope.Mailbox == "" { + return RefInfo{}, fmt.Errorf("ref %s: dispatch envelope carries no mailbox route", u.Ref) + } if !NamespaceContains(TaskNamespace(info.Task), u.Ref) { return RefInfo{}, fmt.Errorf("ref %s escapes its task namespace", u.Ref) } @@ -277,16 +280,16 @@ func admitContent(ctx context.Context, req AdmitRequest, st *TaskState, from, to return fmt.Errorf("gate:secret-name %s looks like a credential; the push is rejected (A5.4)", p) } } - return admitBlobCaps(ctx, req, st, to) + return admitBlobCaps(ctx, req, st, from, to) } -func admitBlobCaps(ctx context.Context, req AdmitRequest, st *TaskState, tip string) error { +func admitBlobCaps(ctx context.Context, req AdmitRequest, st *TaskState, from, tip string) error { maxBlob := st.Policy.MaxBlobSize if maxBlob == 0 { maxBlob = DefaultSnapshotMaxFileSize } objects, err := runGitRaw(ctx, req.Repo, req.Env, nil, - "rev-list", "--objects", tip, "--not", "--all", "--alternate-refs") + "rev-list", "--objects", tip, "--not", from) if err != nil { return err } diff --git a/pkg/gitagent/admit_ginkgo_test.go b/pkg/gitagent/admit_ginkgo_test.go index 3367bad3..05430782 100644 --- a/pkg/gitagent/admit_ginkgo_test.go +++ b/pkg/gitagent/admit_ginkgo_test.go @@ -58,6 +58,7 @@ func (f *admitFixture) envelope() *gitagent.Envelope { Depth: 0, Agent: "worker-1", Relay: gitagent.RelaySync, + Mailbox: "mailboxes/" + strings.Repeat("a", 64) + ".git", } } @@ -144,6 +145,11 @@ var _ = Describe("admission", func() { err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates(), Env: f.env}) Expect(err).To(MatchError(ContainSubstring("require the control envelope"))) + unroutable := f.envelope() + unroutable.Mailbox = "" + err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates(), Envelope: unroutable, Env: f.env}) + Expect(err).To(MatchError(ContainSubstring("no mailbox route"))) + wrong := f.envelope() wrong.Attempt = 2 err = gitagent.Admit(ctx, gitagent.AdmitRequest{Repo: f.sidecar, Role: gitagent.RoleSidecar, Updates: f.dispatchUpdates(), Envelope: wrong, Env: f.env}) @@ -237,6 +243,12 @@ var _ = Describe("admission", func() { {Old: zeroOID40, New: f.control, Ref: "refs/captain/tasks/t-1/control/1"}, } + // Admission must stay inside this task's dispatch..result range. A + // damaged historical namespace cannot reject an unrelated result. + broken := filepath.Join(mailbox, "refs", "captain", "tasks", "t-stale", "control", "1") + Expect(os.MkdirAll(filepath.Dir(broken), 0o755)).To(Succeed()) + Expect(os.WriteFile(broken, []byte(strings.Repeat("f", 40)+"\n"), 0o644)).To(Succeed()) + Expect(gitagent.Admit(ctx, gitagent.AdmitRequest{ Repo: mailbox, Role: gitagent.RoleMailbox, Agent: "worker-1", Updates: resultUpdates, Envelope: f.envelope(), Env: f.env, diff --git a/pkg/gitagent/conformance_ginkgo_test.go b/pkg/gitagent/conformance_ginkgo_test.go index a003a366..356871ec 100644 --- a/pkg/gitagent/conformance_ginkgo_test.go +++ b/pkg/gitagent/conformance_ginkgo_test.go @@ -23,12 +23,13 @@ import ( // (sidecar repo + serve), with the test binary standing in for the captain // binary in both the shims and the ssh transport. type conformanceWorld struct { - superRepo string - mailbox string - sidecar string - sidecarURL string - dispatch gitagent.DispatchRequest - workdir string // the agent's clone, present after dispatch + superRepo string + mailbox string + mailboxRoute string + sidecar string + sidecarURL string + dispatch gitagent.DispatchRequest + workdir string // the agent's clone, present after dispatch } // testShim writes a hook shim exec'ing the test binary with the hook env @@ -80,8 +81,9 @@ func newConformanceWorld(ctx context.Context, sidecarWF, supervisorWF *api.Workf gitT(w.superRepo, "add", "-A") gitT(w.superRepo, "commit", "-q", "-m", "base") writeFileT(w.superRepo, "pkg/dirty.go", "package main // dirty\n") - w.mailbox = filepath.Join(supRoot, "mailbox.git") - Expect(gitagent.InitMailbox(ctx, w.mailbox, w.superRepo)).To(Succeed()) + mailbox, err := gitagent.EnsureMailbox(ctx, supRoot, w.superRepo) + Expect(err).NotTo(HaveOccurred()) + w.mailbox, w.mailboxRoute = mailbox.Path, mailbox.Route // Keys: one per party. supKey := filepath.Join(GinkgoT().TempDir(), "supervisor_ed25519") @@ -113,7 +115,7 @@ func newConformanceWorld(ctx context.Context, sidecarWF, supervisorWF *api.Workf HookSandbox: "test-identity", AgentCommand: agentCommand, Relay: gitagent.RelayTarget{ - URL: fmt.Sprintf("ssh://captain@%s:%s/mailbox.git", supHost, supPort), + URL: fmt.Sprintf("ssh://captain@%s:%s", supHost, supPort), HostFingerprint: supHostFP, KeyPath: agentKey, SSHCommand: testSSHCommand(), @@ -130,6 +132,7 @@ func newConformanceWorld(ctx context.Context, sidecarWF, supervisorWF *api.Workf w.dispatch = gitagent.DispatchRequest{ RepoDir: w.superRepo, MailboxPath: w.mailbox, + MailboxRoute: w.mailboxRoute, Agent: "worker-1", SidecarURL: w.sidecarURL, SidecarHostFP: sideHostFP, diff --git a/pkg/gitagent/dispatch.go b/pkg/gitagent/dispatch.go index 35ba9dc1..245d6afa 100644 --- a/pkg/gitagent/dispatch.go +++ b/pkg/gitagent/dispatch.go @@ -59,6 +59,7 @@ func TaskPaths(sidecarRepo, task string) (worktree, taskFile string, err error) type DispatchRequest struct { RepoDir string MailboxPath string + MailboxRoute string // opaque path under the supervisor's served root Task string // generated when empty Agent string SidecarURL string // ssh://host:port/repo.git @@ -92,6 +93,9 @@ func Dispatch(ctx context.Context, req DispatchRequest) (*DispatchResult, error) if err := ValidateTaskID(task); err != nil { return nil, err } + if err := ValidateMailboxRoute(req.MailboxRoute); err != nil { + return nil, err + } if req.Relay == "" { req.Relay = RelaySync } @@ -154,6 +158,9 @@ func recordDispatch(ctx context.Context, req DispatchRequest, task string, snaps if err := InitMailbox(ctx, req.MailboxPath, req.RepoDir); err != nil { return err } + if err := copyDispatchObjects(ctx, req.RepoDir, req.MailboxPath, snapshot, control); err != nil { + return err + } env := ScrubGitEnv(os.Environ()) dispatchRef, err := DispatchRef(task, 1) if err != nil { @@ -175,11 +182,28 @@ func recordDispatch(ctx context.Context, req DispatchRequest, task string, snaps DispatchCommit: snapshot.Commit, ControlCommit: control, Relay: req.Relay, + Mailbox: req.MailboxRoute, Policy: req.Policy, Hooks: hooks, }) } +// copyDispatchObjects gives the mailbox ownership of synthetic objects that +// have no ref in the real repository. The base history remains borrowed via +// alternates, while source-repository GC can no longer prune dispatch/control. +func copyDispatchObjects(ctx context.Context, source, mailbox string, snapshot *Snapshot, control string) error { + packDir := filepath.Join(mailbox, "objects", "pack") + if err := os.MkdirAll(packDir, 0o755); err != nil { + return err + } + revisions := strings.NewReader(snapshot.Commit + "\n" + control + "\n^" + snapshot.Base + "\n") + if _, err := runGitIn(ctx, source, ScrubGitEnv(os.Environ()), revisions, + "pack-objects", "--revs", filepath.Join(packDir, "pack")); err != nil { + return fmt.Errorf("storing dispatch objects in mailbox: %w", err) + } + return nil +} + func pushDispatch(ctx context.Context, req DispatchRequest, task string, snapshot *Snapshot, control string) error { dispatchRef, err := DispatchRef(task, 1) if err != nil { @@ -197,6 +221,7 @@ func pushDispatch(ctx context.Context, req DispatchRequest, task string, snapsho Depth: 0, Agent: req.Agent, Relay: req.Relay, + Mailbox: req.MailboxRoute, } opts, err := envelope.Encode() if err != nil { diff --git a/pkg/gitagent/enroll.go b/pkg/gitagent/enroll.go index fee149a4..2362da4e 100644 --- a/pkg/gitagent/enroll.go +++ b/pkg/gitagent/enroll.go @@ -49,17 +49,12 @@ type EnrollResponse struct { // DispatchKey is the supervisor's client-key fingerprint. The agent // authorizes it locally so the supervisor's dispatch push is accepted. DispatchKey string `json:"dispatchKey"` - // MailboxPath is the mailbox repository's path under the supervisor's - // served root. The agent joins it onto the endpoint it already dialed, so - // the supervisor never has to know its own reachable hostname. - MailboxPath string `json:"mailboxPath"` } // EnrollmentOffer is the supervisor-side half of the exchange, supplied to // the server by whatever runs it. type EnrollmentOffer struct { DispatchKey string - MailboxPath string } // AgentEnrollment is one recorded agent: its key, its endpoint, and the host @@ -165,11 +160,11 @@ func enrollFailureDetail(err error, out []byte) string { return err.Error() } -// MailboxURL joins the supervisor endpoint the agent dialed with the mailbox -// path the supervisor offered. +// MailboxURL joins the enrolled supervisor endpoint with the opaque route +// carried by a dispatch. func MailboxURL(endpoint, mailboxPath string) (string, error) { - if strings.TrimSpace(mailboxPath) == "" { - return "", fmt.Errorf("the supervisor offered no mailbox path") + if err := ValidateMailboxRoute(mailboxPath); err != nil { + return "", err } addr, user, err := splitSSHEndpoint(endpoint) if err != nil { diff --git a/pkg/gitagent/envelope.go b/pkg/gitagent/envelope.go index 63b95940..6a93f0ef 100644 --- a/pkg/gitagent/envelope.go +++ b/pkg/gitagent/envelope.go @@ -58,14 +58,15 @@ type Envelope struct { Version int `json:"v"` Task string `json:"task"` Attempt int `json:"attempt"` - Base string `json:"base"` // supervisor HEAD OID at dispatch (R10.1) - Depth int `json:"depth"` // hook-recursion depth, 0 at top level - Agent string `json:"agent,omitempty"` // dispatch only: target agent - Relay RelayMode `json:"relay,omitempty"` // dispatch only + Base string `json:"base"` // supervisor HEAD OID at dispatch (R10.1) + Depth int `json:"depth"` // hook-recursion depth, 0 at top level + Agent string `json:"agent,omitempty"` // dispatch only: target agent + Relay RelayMode `json:"relay,omitempty"` // dispatch only + Mailbox string `json:"mailbox,omitempty"` // dispatch only: opaque supervisor route } -// Validate checks every field an envelope always carries. Agent and Relay are -// dispatch-only and validated when present. +// Validate checks every field an envelope always carries. Agent, Relay, and +// Mailbox are dispatch-only and validated when present. func (e Envelope) Validate() error { if e.Version != ProtocolVersion { return fmt.Errorf("unsupported envelope version %d (implementation speaks %d)", e.Version, ProtocolVersion) @@ -92,6 +93,11 @@ func (e Envelope) Validate() error { default: return fmt.Errorf("relay %q must be %q or %q", e.Relay, RelaySync, RelayAsync) } + if e.Mailbox != "" { + if err := ValidateMailboxRoute(e.Mailbox); err != nil { + return err + } + } return nil } @@ -118,6 +124,9 @@ func (e Envelope) Encode() ([]string, error) { if e.Relay != "" { opts = append(opts, "relay="+string(e.Relay)) } + if e.Mailbox != "" { + opts = append(opts, "mailbox="+e.Mailbox) + } return opts, nil } @@ -182,6 +191,8 @@ func (e *Envelope) setField(key, value string) error { e.Agent = value case "relay": e.Relay = RelayMode(value) + case "mailbox": + e.Mailbox = value default: return fmt.Errorf("unknown envelope key %q", key) } diff --git a/pkg/gitagent/envelope_ginkgo_test.go b/pkg/gitagent/envelope_ginkgo_test.go index aa6c0001..3acbf4aa 100644 --- a/pkg/gitagent/envelope_ginkgo_test.go +++ b/pkg/gitagent/envelope_ginkgo_test.go @@ -27,6 +27,7 @@ var _ = Describe("envelope encode/decode", func() { e := validEnvelope() e.Agent = "worker-01" e.Relay = gitagent.RelaySync + e.Mailbox = "mailboxes/" + strings.Repeat("a", 64) + ".git" opts, err := e.Encode() Expect(err).NotTo(HaveOccurred()) Expect(opts[0]).To(Equal(gitagent.EnvelopeVersionTag)) @@ -96,6 +97,9 @@ var _ = Describe("envelope encode/decode", func() { e = validEnvelope() e.Relay = "eventually" bad = append(bad, e) + e = validEnvelope() + e.Mailbox = "../other.git" + bad = append(bad, e) for i, envelope := range bad { _, err := envelope.Encode() Expect(err).To(HaveOccurred(), "case %d", i) diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go index 3a836813..26009f3b 100644 --- a/pkg/gitagent/hookmain.go +++ b/pkg/gitagent/hookmain.go @@ -31,8 +31,8 @@ type HookRuntime struct { // test binary and cannot activate in production. HookSandbox string `json:"hookSandbox,omitempty"` AgentCommand string `json:"agentCommand,omitempty"` - RealRepo string `json:"realRepo,omitempty"` // mailbox: integration target - Relay RelayTarget `json:"relay,omitempty"` // sidecar: the supervisor mailbox + RealRepo string `json:"realRepo,omitempty"` // mailbox-local integration target + Relay RelayTarget `json:"relay,omitempty"` // sidecar: supervisor base endpoint } // RequiresJudge reports whether either receiver tier declares prompt checks. @@ -319,11 +319,14 @@ func relayUpward(ctx context.Context, repo string, host HookHost, st *TaskState, if control == "" { return fmt.Errorf("task %s has no recorded control commit; cannot relay", st.Task) } + if err := ValidateMailboxRoute(st.Mailbox); err != nil { + return fmt.Errorf("task %s has no usable mailbox route: %w", st.Task, err) + } envelope := Envelope{ Version: ProtocolVersion, Task: st.Task, Attempt: attempt, Base: st.Base, Depth: 0, Agent: st.Agent, Relay: st.Relay, } - return Relay(ctx, repo, hookEnv, host.Runtime.Relay, envelope, result, control, sideband) + return Relay(ctx, repo, hookEnv, host.Runtime.Relay, st.Mailbox, envelope, result, control, sideband) } // mailboxPreReceive runs hook set #2 over an arriving result (§6.2 step 10). @@ -480,7 +483,8 @@ func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates if err := SaveTaskState(repo, &TaskState{ Task: info.Task, Agent: envelope.Agent, Base: envelope.Base, DispatchCommit: u.New, ControlCommit: payloads.controlCommit, - Relay: envelope.Relay, Policy: payloads.policy, Hooks: payloads.hooks, + Relay: envelope.Relay, Mailbox: envelope.Mailbox, + Policy: payloads.policy, Hooks: payloads.hooks, }); err != nil { return err } diff --git a/pkg/gitagent/hookshim.go b/pkg/gitagent/hookshim.go index 02d608e4..ac47582a 100644 --- a/pkg/gitagent/hookshim.go +++ b/pkg/gitagent/hookshim.go @@ -15,11 +15,10 @@ const hookShimMarker = "# installed by captain sandbox git-agent" // an identical shim is left alone, a stale one is rewritten, and a foreign // hook is refused rather than silently replaced. // -// configPath is baked in rather than left to $HOME: a hook runs as a child of -// whoever pushed — for a co-located agent that is the agent's own shell — so -// an ambient home would silently load the wrong configuration and skip the -// hook sets and the relay entirely. -func InstallHookShims(repoPath, captainBin, configPath string, role ReceiverRole) error { +// configPath and backend are baked in rather than left to process defaults: a +// hook runs as a child of whoever pushed, whose ambient HOME and selected +// backend may differ from the receiver that installed it. +func InstallHookShims(repoPath, captainBin, configPath string, role ReceiverRole, backend string) error { bin, err := filepath.Abs(captainBin) if err != nil { return err @@ -36,11 +35,15 @@ func InstallHookShims(repoPath, captainBin, configPath string, role ReceiverRole } config = fmt.Sprintf(" --config %q", abs) } + backendArg := "" + if strings.TrimSpace(backend) != "" { + backendArg = fmt.Sprintf(" --backend %q", strings.TrimSpace(backend)) + } for _, hook := range []string{"pre-receive", "post-receive"} { shim := fmt.Sprintf(`#!/bin/sh %s -exec %q sandbox git-agent hook %s --repo %q --role %q%s -`, hookShimMarker, bin, hook, repo, string(role), config) +exec %q sandbox git-agent hook %s --repo %q --role %q%s%s +`, hookShimMarker, bin, hook, repo, string(role), backendArg, config) target := filepath.Join(repo, "hooks", hook) existing, err := os.ReadFile(target) switch { diff --git a/pkg/gitagent/mailbox.go b/pkg/gitagent/mailbox.go new file mode 100644 index 00000000..99fabd2a --- /dev/null +++ b/pkg/gitagent/mailbox.go @@ -0,0 +1,176 @@ +// Repository-scoped supervisor mailboxes. One receive endpoint hosts many +// bare repositories, while each mailbox has one immutable local integration +// target and one opaque route safe to send to a sidecar. +package gitagent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +const ( + // MailboxesDir is the repository namespace served by a supervisor endpoint. + MailboxesDir = "mailboxes" + + mailboxBindingVersion = 1 + mailboxBindingFile = "repository.json" +) + +var mailboxRouteRe = regexp.MustCompile(`^mailboxes/[0-9a-f]{64}\.git$`) + +// Mailbox identifies the repository-specific receiver used by one supervisor +// worktree. Route is safe to send over the wire; Repository remains local. +type Mailbox struct { + Path string + Route string + Repository string +} + +// MailboxBinding is the durable, supervisor-local association between a bare +// mailbox and the worktree into which accepted results are integrated. +type MailboxBinding struct { + Version int `json:"version"` + Repository string `json:"repository"` +} + +// ValidateMailboxRoute limits a relayed repository path to Captain's opaque +// mailbox namespace. The route is still containment-checked by the SSH server. +func ValidateMailboxRoute(route string) error { + if !mailboxRouteRe.MatchString(route) { + return fmt.Errorf("mailbox route %q must match %s", route, mailboxRouteRe) + } + return nil +} + +// MailboxForRepository derives a stable receiver path from the canonical +// worktree root. Repositories with the same basename remain distinct. +func MailboxForRepository(ctx context.Context, servedRoot, repoDir string) (Mailbox, error) { + repository, err := canonicalRepository(ctx, repoDir) + if err != nil { + return Mailbox{}, err + } + root, err := filepath.Abs(servedRoot) + if err != nil { + return Mailbox{}, err + } + sum := sha256.Sum256([]byte(repository)) + route := path.Join(MailboxesDir, hex.EncodeToString(sum[:])+".git") + return Mailbox{ + Path: filepath.Join(root, filepath.FromSlash(route)), + Route: route, + Repository: repository, + }, nil +} + +// EnsureMailbox creates or verifies the repository-specific mailbox. A +// mailbox binding is immutable: changing it would leave existing refs backed +// by a different object store and make unrelated future pushes fail. +func EnsureMailbox(ctx context.Context, servedRoot, repoDir string) (Mailbox, error) { + mailbox, err := MailboxForRepository(ctx, servedRoot, repoDir) + if err != nil { + return Mailbox{}, err + } + if err := os.MkdirAll(filepath.Dir(mailbox.Path), 0o755); err != nil { + return Mailbox{}, err + } + if err := InitMailbox(ctx, mailbox.Path, mailbox.Repository); err != nil { + return Mailbox{}, err + } + return mailbox, nil +} + +// LoadMailboxBinding reads the integration target owned by a mailbox hook. +func LoadMailboxBinding(mailboxPath string) (MailboxBinding, error) { + data, err := os.ReadFile(filepath.Join(mailboxPath, "captain", mailboxBindingFile)) + if err != nil { + return MailboxBinding{}, fmt.Errorf("mailbox binding: %w", err) + } + var binding MailboxBinding + if err := json.Unmarshal(data, &binding); err != nil { + return MailboxBinding{}, fmt.Errorf("mailbox binding: %w", err) + } + if binding.Version != mailboxBindingVersion || !filepath.IsAbs(binding.Repository) { + return MailboxBinding{}, fmt.Errorf("mailbox binding has version %d and repository %q", binding.Version, binding.Repository) + } + return binding, nil +} + +// bindMailbox commits the repository association and alternates path under one +// lock. A partial first initialization can be completed, never redirected. +func bindMailbox(mailboxPath, repository, objects string) error { + return withFileLock(filepath.Join(mailboxPath, "captain", "mailbox.lock"), 0o600, func() error { + bindingPath := filepath.Join(mailboxPath, "captain", mailboxBindingFile) + binding, err := LoadMailboxBinding(mailboxPath) + bindingExists := err == nil + switch { + case bindingExists && binding.Repository != repository: + return fmt.Errorf("mailbox %s is bound to %s; cannot rebind it to %s", mailboxPath, binding.Repository, repository) + case err != nil && !errors.Is(err, os.ErrNotExist): + return err + } + + alternates := filepath.Join(mailboxPath, "objects", "info", "alternates") + if data, readErr := os.ReadFile(alternates); readErr == nil { + current := strings.TrimSpace(string(data)) + if current != objects { + return fmt.Errorf("mailbox %s uses object store %s; cannot rebind it to %s", mailboxPath, current, objects) + } + } else if !os.IsNotExist(readErr) { + return readErr + } + if err := os.MkdirAll(filepath.Dir(alternates), 0o755); err != nil { + return err + } + if err := writeFileAtomic(alternates, []byte(objects+"\n"), 0o644); err != nil { + return err + } + if bindingExists { + return nil + } + data, marshalErr := json.MarshalIndent(MailboxBinding{ + Version: mailboxBindingVersion, Repository: repository, + }, "", " ") + if marshalErr != nil { + return marshalErr + } + return writeFileAtomic(bindingPath, append(data, '\n'), 0o644) + }) +} + +func canonicalRepository(ctx context.Context, repoDir string) (string, error) { + root, err := runGit(ctx, repoDir, ScrubGitEnv(os.Environ()), "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("mailbox: resolving repository root: %w", err) + } + root, err = filepath.Abs(root) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(root) + if err != nil { + return "", fmt.Errorf("mailbox: resolving repository path %s: %w", root, err) + } + return filepath.Clean(resolved), nil +} + +func repositoryObjects(ctx context.Context, repository string) (string, error) { + gitDir, err := runGit(ctx, repository, ScrubGitEnv(os.Environ()), + "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return "", fmt.Errorf("mailbox: resolving the real repository: %w", err) + } + objects := filepath.Join(gitDir, "objects") + if _, err := os.Stat(objects); err != nil { + return "", fmt.Errorf("mailbox: real repository object store %s: %w", objects, err) + } + return objects, nil +} diff --git a/pkg/gitagent/receiver.go b/pkg/gitagent/receiver.go index d262c0fd..4e20e1f6 100644 --- a/pkg/gitagent/receiver.go +++ b/pkg/gitagent/receiver.go @@ -7,7 +7,6 @@ package gitagent import ( "context" - "fmt" "os" "path/filepath" "strconv" @@ -38,26 +37,27 @@ func receiverConfig(maxInputSize int64) [][2]string { } } -// InitMailbox creates (or re-configures — every step is idempotent) the bare -// mailbox repo at path, sharing objects with the real repository at realRepo -// via objects/info/alternates. +// InitMailbox creates a bare mailbox bound to one canonical worktree. Repeated +// initialization is safe only for that same worktree; rebinding is refused so +// existing refs never lose the object store that backs them. func InitMailbox(ctx context.Context, path, realRepo string) error { - if err := initReceiver(ctx, path); err != nil { + repository, err := canonicalRepository(ctx, realRepo) + if err != nil { return err } - realGitDir, err := runGit(ctx, realRepo, ScrubGitEnv(os.Environ()), "rev-parse", "--absolute-git-dir") + objects, err := repositoryObjects(ctx, repository) if err != nil { - return fmt.Errorf("mailbox: resolving the real repository: %w", err) - } - objects := filepath.Join(realGitDir, "objects") - if _, err := os.Stat(objects); err != nil { - return fmt.Errorf("mailbox: real repository object store %s: %w", objects, err) + return err } - alternates := filepath.Join(path, "objects", "info", "alternates") - if err := os.MkdirAll(filepath.Dir(alternates), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(path, "captain"), 0o755); err != nil { return err } - return writeFileAtomic(alternates, []byte(objects+"\n"), 0o644) + return withFileLock(filepath.Join(path, "captain", "init.lock"), 0o600, func() error { + if err := initReceiver(ctx, path); err != nil { + return err + } + return bindMailbox(path, repository, objects) + }) } // InitSidecar creates the bare sidecar repo at path. diff --git a/pkg/gitagent/receiver_ginkgo_test.go b/pkg/gitagent/receiver_ginkgo_test.go index 4f586681..d1865161 100644 --- a/pkg/gitagent/receiver_ginkgo_test.go +++ b/pkg/gitagent/receiver_ginkgo_test.go @@ -34,6 +34,33 @@ var _ = Describe("receiver repositories", func() { Expect(gitT(path, "config", "receive.maxinputsize")).NotTo(Equal("0"), "maxInputSize must be finite") }) + It("namespaces mailboxes by canonical repository and refuses rebinding", func() { + servedRoot := GinkgoT().TempDir() + makeRepo := func(parent string) string { + repo := filepath.Join(parent, "project") + Expect(os.MkdirAll(repo, 0o755)).To(Succeed()) + gitT(repo, "init", "-q") + writeFileT(repo, "a.txt", repo+"\n") + gitT(repo, "add", "-A") + gitT(repo, "commit", "-q", "-m", "base") + return repo + } + repoA := makeRepo(GinkgoT().TempDir()) + repoB := makeRepo(GinkgoT().TempDir()) + mailboxA, err := gitagent.EnsureMailbox(ctx, servedRoot, repoA) + Expect(err).NotTo(HaveOccurred()) + mailboxB, err := gitagent.EnsureMailbox(ctx, servedRoot, repoB) + Expect(err).NotTo(HaveOccurred()) + Expect(mailboxA.Route).NotTo(Equal(mailboxB.Route), "same basenames must not collide") + Expect(mailboxA.Route).To(HavePrefix(gitagent.MailboxesDir + "/")) + + binding, err := gitagent.LoadMailboxBinding(mailboxA.Path) + Expect(err).NotTo(HaveOccurred()) + Expect(binding.Repository).To(Equal(repoA)) + Expect(gitagent.InitMailbox(ctx, mailboxA.Path, repoB)). + To(MatchError(ContainSubstring("cannot rebind"))) + }) + It("shares the real repository's objects with the mailbox via alternates", func() { real := GinkgoT().TempDir() gitT(real, "init", "-q") @@ -60,8 +87,8 @@ var _ = Describe("hook shims", func() { repo := filepath.Join(GinkgoT().TempDir(), "repo.git") Expect(gitagent.InitSidecar(ctx, repo)).To(Succeed()) - Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar)).To(Succeed()) - Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar)).To(Succeed()) + Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar, "worker-pool")).To(Succeed()) + Expect(gitagent.InstallHookShims(repo, "/usr/local/bin/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar, "worker-pool")).To(Succeed()) for _, hook := range []string{"pre-receive", "post-receive"} { path := filepath.Join(repo, "hooks", hook) @@ -72,13 +99,14 @@ var _ = Describe("hook shims", func() { Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("/usr/local/bin/captain")) Expect(string(content)).To(ContainSubstring("--role \"sidecar\"")) + Expect(string(content)).To(ContainSubstring("--backend \"worker-pool\"")) // A hook runs as a child of whoever pushed, so its config path is // baked in rather than resolved from an ambient $HOME. Expect(string(content)).To(ContainSubstring("--config \"/home/agent/.captain.yaml\"")) } // A rebinned captain updates the shim in place. - Expect(gitagent.InstallHookShims(repo, "/opt/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar)).To(Succeed()) + Expect(gitagent.InstallHookShims(repo, "/opt/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar, "worker-pool")).To(Succeed()) content, err := os.ReadFile(filepath.Join(repo, "hooks", "pre-receive")) Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("/opt/captain")) @@ -86,7 +114,7 @@ var _ = Describe("hook shims", func() { // A hook captain did not install is never overwritten. foreign := filepath.Join(repo, "hooks", "pre-receive") Expect(os.WriteFile(foreign, []byte("#!/bin/sh\nexit 0\n"), 0o755)).To(Succeed()) - err = gitagent.InstallHookShims(repo, "/opt/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar) + err = gitagent.InstallHookShims(repo, "/opt/captain", "/home/agent/.captain.yaml", gitagent.RoleSidecar, "worker-pool") Expect(err).To(MatchError(ContainSubstring("not installed by captain"))) }) }) diff --git a/pkg/gitagent/regression_test.go b/pkg/gitagent/regression_test.go index 667aeac2..268180b3 100644 --- a/pkg/gitagent/regression_test.go +++ b/pkg/gitagent/regression_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "sync" "testing" @@ -124,6 +125,50 @@ func TestWriteFileAtomicCleansTempAfterRenameFailure(t *testing.T) { } } +func TestMailboxOwnsSyntheticDispatchObjects(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + env := ScrubGitEnv(os.Environ()) + if _, err := runGit(ctx, repo, env, "init", "--quiet"); err != nil { + t.Fatal(err) + } + if _, err := runGit(ctx, repo, env, + "-c", "user.name=test", "-c", "user.email=test@localhost", + "commit", "--allow-empty", "-m", "base"); err != nil { + t.Fatal(err) + } + snapshot, err := TakeSnapshot(ctx, repo, SnapshotPolicy{}) + if err != nil { + t.Fatal(err) + } + control, err := BuildControlCommit(ctx, repo, nil, map[string][]byte{ + ControlTaskFile: []byte(`{"prompt":"test"}`), + }) + if err != nil { + t.Fatal(err) + } + mailbox := filepath.Join(t.TempDir(), "mailbox.git") + req := DispatchRequest{ + RepoDir: repo, MailboxPath: mailbox, + MailboxRoute: "mailboxes/" + strings.Repeat("a", 64) + ".git", + } + if err := recordDispatch(ctx, req, "t-durable", snapshot, control, &HookSets{}); err != nil { + t.Fatal(err) + } + if _, err := runGit(ctx, repo, env, "prune", "--expire", "now"); err != nil { + t.Fatal(err) + } + for oid, want := range map[string]string{snapshot.Commit: "commit", control: "commit"} { + if _, err := runGit(ctx, repo, env, "cat-file", "-e", oid); err == nil { + t.Fatalf("synthetic object %s unexpectedly survived source prune", oid) + } + got, err := runGit(ctx, mailbox, env, "cat-file", "-t", oid) + if err != nil || got != want { + t.Fatalf("mailbox object %s = %q, %v; want %s", oid, got, err, want) + } + } +} + func TestRecordDispatchCreatesAuditRefsAtomically(t *testing.T) { ctx := context.Background() repo := t.TempDir() diff --git a/pkg/gitagent/relay.go b/pkg/gitagent/relay.go index 299caa08..18679109 100644 --- a/pkg/gitagent/relay.go +++ b/pkg/gitagent/relay.go @@ -14,7 +14,8 @@ import ( "strings" ) -// RelayTarget is where and how the sidecar reaches the supervisor mailbox. +// RelayTarget is where and how the sidecar reaches the supervisor endpoint. +// The repository-specific mailbox route comes from trusted dispatch state. type RelayTarget struct { URL string `json:"url"` HostFingerprint string `json:"hostFingerprint"` @@ -105,7 +106,11 @@ func BuildResultCommit(ctx context.Context, repo string, env []string, tip, disp // Relay pushes result+control atomically to the mailbox, streaming the // upstream's stderr through sideband. A non-zero upstream exit is the // caller's signal to reject the agent's push (R6.7). -func Relay(ctx context.Context, repo string, hookEnv []string, target RelayTarget, envelope Envelope, result, control string, sideband io.Writer) error { +func Relay(ctx context.Context, repo string, hookEnv []string, target RelayTarget, mailboxRoute string, envelope Envelope, result, control string, sideband io.Writer) error { + mailboxURL, err := MailboxURL(target.URL, mailboxRoute) + if err != nil { + return err + } resultRef, err := ResultRef(envelope.Task, envelope.Attempt) if err != nil { return err @@ -122,7 +127,7 @@ func Relay(ctx context.Context, repo string, hookEnv []string, target RelayTarge for _, o := range opts { args = append(args, "--push-option="+o) } - args = append(args, target.URL, result+":"+resultRef, control+":"+controlRef) + args = append(args, mailboxURL, result+":"+resultRef, control+":"+controlRef) pairs, err := transportPairs(target.SSHCommand, target.KeyPath, target.HostFingerprint) if err != nil { diff --git a/pkg/gitagent/server.go b/pkg/gitagent/server.go index a57ee5c6..de08bb59 100644 --- a/pkg/gitagent/server.go +++ b/pkg/gitagent/server.go @@ -55,8 +55,7 @@ type ServerConfig struct { HostKey gossh.Signer Directory AgentDirectory // Offer is what this endpoint hands back to a joining agent so the agent - // can complete the reverse direction of trust. A mailbox that leaves it - // empty enrolls agents it can dispatch to but that cannot relay back. + // can complete the reverse direction of trust. Offer EnrollmentOffer // AgentRepoPath is the repository path an enrolled agent serves, used to // derive its dispatch URL when the agent advertises none. @@ -110,8 +109,8 @@ func handleSession(s ssh.Session, root string, cfg ServerConfig) { } // handleEnroll completes both directions of the exchange: it records the -// agent's key and endpoint, and hands back the supervisor's dispatch key and -// mailbox path so the agent can authorize the reverse push. +// agent's key and endpoint, then returns the supervisor dispatch key needed to +// authorize later task-specific reverse pushes. func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []string) { if len(cmd) < 2 || strings.TrimSpace(cmd[1]) == "" { fmt.Fprintln(s.Stderr(), "captain: usage: captain-enroll [request]") @@ -144,7 +143,6 @@ func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []str resp, err := json.Marshal(EnrollResponse{ Agent: name, DispatchKey: cfg.Offer.DispatchKey, - MailboxPath: cfg.Offer.MailboxPath, }) if err != nil { fmt.Fprintf(s.Stderr(), "captain: %v\n", err) diff --git a/pkg/gitagent/server_ginkgo_test.go b/pkg/gitagent/server_ginkgo_test.go index aff12888..0cef98e7 100644 --- a/pkg/gitagent/server_ginkgo_test.go +++ b/pkg/gitagent/server_ginkgo_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" . "github.com/onsi/ginkgo/v2" @@ -179,17 +180,16 @@ var _ = Describe("the git-agent SSH endpoint", func() { Expect(err).NotTo(HaveOccurred()) dir.pending[hash] = "worker-2" addr, hostFP := startTestServerWithOffer(dir, root, gitagent.RoleMailbox, - gitagent.EnrollmentOffer{DispatchKey: "SHA256:dispatch", MailboxPath: "mailbox.git"}) + gitagent.EnrollmentOffer{DispatchKey: "SHA256:dispatch"}) signer, fp, _ := newClientKey() request := gitagent.EnrollRequest{ListenPort: "7502", HostFingerprint: "SHA256:agenthost"} resp, err := gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer, request) Expect(err).NotTo(HaveOccurred()) Expect(resp.Agent).To(Equal("worker-2")) - // The reverse direction: what the agent needs to accept a dispatch and - // to reach the mailbox. + // The reverse direction: what the agent needs to accept a dispatch. + // Repository-specific mailbox routing arrives with each task. Expect(resp.DispatchKey).To(Equal("SHA256:dispatch")) - Expect(resp.MailboxPath).To(Equal("mailbox.git")) // The supervisor recorded an endpoint it can actually dispatch to. enrolled, ok := dir.enrollments["worker-2"] @@ -198,9 +198,12 @@ var _ = Describe("the git-agent SSH endpoint", func() { Expect(enrolled.HostFingerprint).To(Equal("SHA256:agenthost")) Expect(enrolled.URL).To(ContainSubstring(":7502/repo.git")) - mailboxURL, err := gitagent.MailboxURL("ssh://"+addr, resp.MailboxPath) + route := "mailboxes/" + strings.Repeat("a", 64) + ".git" + mailboxURL, err := gitagent.MailboxURL("ssh://"+addr, route) Expect(err).NotTo(HaveOccurred()) - Expect(mailboxURL).To(HaveSuffix("/mailbox.git")) + Expect(mailboxURL).To(HaveSuffix("/" + route)) + _, err = gitagent.MailboxURL("ssh://"+addr, "../other.git") + Expect(err).To(MatchError(ContainSubstring("mailbox route"))) // Replay fails: the token burned. _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer, request) diff --git a/pkg/gitagent/state.go b/pkg/gitagent/state.go index 8b00874b..51aaa400 100644 --- a/pkg/gitagent/state.go +++ b/pkg/gitagent/state.go @@ -28,6 +28,7 @@ type TaskState struct { ControlCommit string `json:"controlCommit,omitempty"` // the dispatched control payloads Attempts int `json:"attempts"` // highest attempt seen Relay RelayMode `json:"relay,omitempty"` + Mailbox string `json:"mailbox,omitempty"` // opaque route chosen by the supervisor Policy Policy `json:"policy"` Hooks *HookSets `json:"hooks,omitempty"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go index 91a3bd81..c4276ffd 100644 --- a/pkg/sandbox/adapter/gitagent.go +++ b/pkg/sandbox/adapter/gitagent.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "os" "path/filepath" "strings" "time" @@ -61,6 +62,21 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp return nil, err } repoDir := spec.Cwd() + mailbox, err := gitagent.EnsureMailbox(ctx, target.mailboxRoot, repoDir) + if err != nil { + return nil, err + } + exe, err := os.Executable() + if err != nil { + return nil, err + } + configPath, err := captainconfig.Path() + if err != nil { + return nil, err + } + if err := gitagent.InstallHookShims(mailbox.Path, exe, configPath, gitagent.RoleMailbox, g.cfg.Name); err != nil { + return nil, err + } hooksJSON, err := hookSetsJSON(g.cfg.Options) if err != nil { return nil, err @@ -77,7 +93,8 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp } dispatch, err := gitagent.Dispatch(ctx, gitagent.DispatchRequest{ RepoDir: repoDir, - MailboxPath: target.mailbox, + MailboxPath: mailbox.Path, + MailboxRoute: mailbox.Route, Agent: target.agent, SidecarURL: target.url, SidecarHostFP: target.hostFingerprint, @@ -96,7 +113,7 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp if err != nil { return nil, err } - verdict, err := gitagent.AwaitOutcome(ctx, target.mailbox, dispatch.Task, target.waitTimeout) + verdict, err := gitagent.AwaitOutcome(ctx, mailbox.Path, dispatch.Task, target.waitTimeout) if err != nil { return nil, fmt.Errorf("task %s dispatched but not concluded: %w", dispatch.Task, err) } @@ -142,7 +159,7 @@ type gitAgentTarget struct { url string hostFingerprint string keyPath string - mailbox string + mailboxRoot string relay gitagent.RelayMode policy gitagent.Policy waitTimeout time.Duration @@ -184,9 +201,9 @@ func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { url: url, hostFingerprint: hostFP, keyPath: stringOption(opts, "key", filepath.Join(keysDir, dispatchKeyFile)), - // The mailbox must be the path the supervisor's endpoint serves, or a - // relayed result lands where nothing reads it. - mailbox: stringOption(opts, "mailbox", filepath.Join(keysDir, servedReposDir, mailboxRepoName)), + // The long-running endpoint serves this root; each dispatch derives a + // repository-specific mailbox beneath it from the request working tree. + mailboxRoot: stringOption(opts, "mailboxRoot", filepath.Join(keysDir, servedReposDir)), relay: gitagent.RelayMode(stringOption(opts, "relay", string(gitagent.RelaySync))), waitTimeout: WaitTimeout(opts), } @@ -208,7 +225,6 @@ func stringOption(opts map[string]any, key, fallback string) string { const ( dispatchKeyFile = "supervisor_ed25519" servedReposDir = "repos" - mailboxRepoName = "mailbox.git" ) // DefaultWaitTimeout bounds how long a dispatch waits for its verdict. A From 32a65d5b873680262a75971b06ed80571d68650d Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 7 Aug 2026 11:31:36 +0545 Subject: [PATCH 19/22] fix(gitagent): address remote protocol review findings Remote tasks lost the supervisor's effort selection and committed under a generic identity. Over-limit retries also reused attempt numbers, credential grants could resolve over cleartext HTTP, and an unterminated relay stream could grow without bound. Carry effort into the relocated run and commit metadata, persist every submit attempt, reject credential-bearing HTTP grants before resolution, and cap relay line buffering with a single truncation notice. Add focused regressions for each path. --- pkg/cli/gitagent_runtask.go | 5 +- pkg/cli/gitagent_runtask_test.go | 7 ++- pkg/gitagent/dispatch.go | 10 ++-- pkg/gitagent/hookmain.go | 15 ++++-- pkg/gitagent/proxy/proxy.go | 6 +++ pkg/gitagent/proxy/proxy_test.go | 91 ++++++++++++++++++++------------ pkg/gitagent/regression_test.go | 27 ++++++++++ pkg/gitagent/relay.go | 64 ++++++++++++++++++---- pkg/gitagent/relay_test.go | 59 +++++++++++++++++++++ pkg/gitagent/workspace.go | 8 +-- pkg/gitagent/workspace_test.go | 37 +++++++++++++ pkg/sandbox/adapter/gitagent.go | 2 +- 12 files changed, 270 insertions(+), 61 deletions(-) create mode 100644 pkg/gitagent/relay_test.go diff --git a/pkg/cli/gitagent_runtask.go b/pkg/cli/gitagent_runtask.go index ce3519b2..1e51c4e5 100644 --- a/pkg/cli/gitagent_runtask.go +++ b/pkg/cli/gitagent_runtask.go @@ -46,7 +46,8 @@ func RunGitAgentRunTask(ctx context.Context, opts GitAgentRunTaskOptions) (_ any if err != nil { return nil, err } - log.Infof("git-agent task %s starting %s:%s in %s", opts.Task, payload.Backend, payload.Model, worktree) + identity := ai.LogIdentity(api.Backend(payload.Backend), payload.Model, payload.Effort) + log.Infof("git-agent task %s starting %s in %s", opts.Task, identity, worktree) if err := runTaskPrompt(ctx, worktree, payload); err != nil { return nil, fmt.Errorf("running the dispatched prompt: %w", err) } @@ -63,7 +64,7 @@ func RunGitAgentRunTask(ctx context.Context, opts GitAgentRunTaskOptions) (_ any // sandbox here would dispatch the task to another agent, and so on (H15). func runTaskPrompt(ctx context.Context, worktree string, payload gitagent.TaskPayload) error { providerOpts := AIProviderOptions{ - ModelFlags: aiflags.ModelFlags{Model: payload.Model, Backend: payload.Backend}, + ModelFlags: aiflags.ModelFlags{Model: payload.Model, Backend: payload.Backend, Effort: string(payload.Effort)}, Sandbox: "none", } cfg, err := providerOpts.ToConfig() diff --git a/pkg/cli/gitagent_runtask_test.go b/pkg/cli/gitagent_runtask_test.go index 1c9297dc..c94b9f00 100644 --- a/pkg/cli/gitagent_runtask_test.go +++ b/pkg/cli/gitagent_runtask_test.go @@ -10,7 +10,7 @@ import ( "github.com/flanksource/captain/pkg/gitagent" ) -func TestRunTaskPromptCarriesSupervisorTimeout(t *testing.T) { +func TestRunTaskPromptCarriesSupervisorRuntime(t *testing.T) { isolateSavedAI(t) original := executePromptRequestFunc t.Cleanup(func() { executePromptRequestFunc = original }) @@ -22,7 +22,7 @@ func TestRunTaskPromptCarriesSupervisorTimeout(t *testing.T) { } payload := gitagent.TaskPayload{ Prompt: "make a change", Model: "gpt-5.6-sol", - Backend: string(api.BackendCodexCLI), Timeout: "17m", + Backend: string(api.BackendCodexCLI), Effort: api.EffortHigh, Timeout: "17m", } if err := runTaskPrompt(context.Background(), t.TempDir(), payload); err != nil { t.Fatal(err) @@ -30,4 +30,7 @@ func TestRunTaskPromptCarriesSupervisorTimeout(t *testing.T) { if captured.Budget.Timeout != "17m" { t.Fatalf("timeout = %q, want 17m", captured.Budget.Timeout) } + if captured.Effort != api.EffortHigh { + t.Fatalf("effort = %q, want high", captured.Effort) + } } diff --git a/pkg/gitagent/dispatch.go b/pkg/gitagent/dispatch.go index 245d6afa..91c8cf9b 100644 --- a/pkg/gitagent/dispatch.go +++ b/pkg/gitagent/dispatch.go @@ -13,6 +13,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/flanksource/captain/pkg/api" ) // TaskPayload is task.json: what the agent is asked to do. It is materialized @@ -21,10 +23,10 @@ type TaskPayload struct { Prompt string `json:"prompt"` System string `json:"system,omitempty"` Model string `json:"model,omitempty"` - // Backend records which runtime the supervisor resolved, so the agent runs - // the coding agent that was actually selected rather than re-resolving the - // model name against its own defaults. - Backend string `json:"backend,omitempty"` + // Backend and Effort record the runtime the supervisor resolved, so the + // agent does not re-resolve the model against its own defaults. + Backend string `json:"backend,omitempty"` + Effort api.Effort `json:"effort,omitempty"` // Timeout is the supervisor's effective deadline. The relocated runner // must not fall back to the shorter local model-call default. Timeout string `json:"timeout,omitempty"` diff --git a/pkg/gitagent/hookmain.go b/pkg/gitagent/hookmain.go index 26009f3b..95513129 100644 --- a/pkg/gitagent/hookmain.go +++ b/pkg/gitagent/hookmain.go @@ -245,9 +245,6 @@ func sidecarPreReceive(ctx context.Context, repo string, host HookHost, updates var attempt int st, err := UpdateTaskState(repo, task, func(current *TaskState) (bool, error) { attempt = current.Attempts + 1 - if current.Policy.MaxAttempts > 0 && attempt > current.Policy.MaxAttempts { - return false, nil - } current.Attempts = attempt return true, nil }) @@ -488,7 +485,7 @@ func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates }); err != nil { return err } - workdir, err := SetupAgentWorkspace(ctx, repo, info.Task, u.New) + workdir, err := SetupAgentWorkspace(ctx, repo, info.Task, u.New, taskRuntimeIdentity(payloads.task)) if err != nil { return err } @@ -503,6 +500,16 @@ func sidecarPostReceive(ctx context.Context, repo string, host HookHost, updates return nil } +// taskRuntimeIdentity derives commit provenance from task.json while keeping +// legacy or human-driven payloads usable with the generic agent name. +func taskRuntimeIdentity(data []byte) string { + var payload TaskPayload + if err := json.Unmarshal(data, &payload); err != nil || payload.Backend == "" || payload.Model == "" { + return "captain-agent" + } + return ai.LogIdentity(api.Backend(payload.Backend), payload.Model, payload.Effort) +} + type dispatchPayloads struct { policy Policy task []byte diff --git a/pkg/gitagent/proxy/proxy.go b/pkg/gitagent/proxy/proxy.go index 20c66848..74823d86 100644 --- a/pkg/gitagent/proxy/proxy.go +++ b/pkg/gitagent/proxy/proxy.go @@ -96,6 +96,12 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "captain-proxy: "+reason, http.StatusForbidden) return } + if len(grant.Headers) > 0 && grant.scheme() != "https" { + reason := "credential-bearing grants require HTTPS" + p.audit(Decision{Method: r.Method, Destination: r.URL.Host, Verdict: "rejected", Reason: reason}) + http.Error(w, "captain-proxy: "+reason, http.StatusForbidden) + return + } substituted, err := p.substitute(r, grant) if err != nil { // A credential that fails to resolve fails the request loudly; the diff --git a/pkg/gitagent/proxy/proxy_test.go b/pkg/gitagent/proxy/proxy_test.go index 7e5a7e02..6f8813a5 100644 --- a/pkg/gitagent/proxy/proxy_test.go +++ b/pkg/gitagent/proxy/proxy_test.go @@ -4,7 +4,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "strings" "sync" "testing" @@ -12,12 +11,13 @@ import ( "github.com/flanksource/commons-db/types" ) -// world spins an upstream recording server, a proxy granting it, and a client -// routed through the proxy. +// world spins a TLS upstream and a proxy granting it. Requests invoke the +// handler in absolute form because ordinary HTTP clients tunnel TLS with +// CONNECT, which this inspectable proxy deliberately refuses. type world struct { upstream *httptest.Server proxy *httptest.Server - client *http.Client + handler *Proxy grant Grant mu sync.Mutex requests []*http.Request @@ -28,7 +28,7 @@ type world struct { func newWorld(t *testing.T, secret string) *world { t.Helper() w := &world{} - w.upstream = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + w.upstream = httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { w.mu.Lock() w.requests = append(w.requests, r.Clone(r.Context())) w.headers = append(w.headers, r.Header.Clone()) @@ -57,11 +57,13 @@ func newWorld(t *testing.T, secret string) *world { w.mu.Unlock() }, } + trusted := w.upstream.Client().Transport.(*http.Transport).Clone() + trusted.Proxy = nil + p.once.Do(func() { p.rt = trusted }) + w.handler = p w.proxy = httptest.NewServer(p) t.Cleanup(w.proxy.Close) - - proxyURL, _ := url.Parse(w.proxy.URL) - w.client = &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} + t.Cleanup(trusted.CloseIdleConnections) return w } @@ -78,10 +80,9 @@ func (w *world) do(t *testing.T, method, target string, headers map[string]strin for k, v := range headers { req.Header.Set(k, v) } - resp, err := w.client.Do(req) - if err != nil { - t.Fatal(err) - } + recorder := httptest.NewRecorder() + w.handler.ServeHTTP(recorder, req) + resp := recorder.Result() t.Cleanup(func() { resp.Body.Close() }) return resp } @@ -128,6 +129,44 @@ func TestSubstitutesOnlyInTheGrantedPosition(t *testing.T) { } } +func TestCredentialBearingHTTPGrantIsRejectedBeforeResolution(t *testing.T) { + hits := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + hits++ + rw.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + grant := Grant{ + Name: "cleartext", URL: upstream.URL, + Methods: []string{"GET"}, Paths: []string{"/repos/"}, + Headers: []HeaderGrant{{Name: "Authorization", Value: types.EnvVar{ValueStatic: secret}}}, + } + resolved := false + var decisions []Decision + p := &Proxy{ + Grants: []Grant{grant}, + Resolve: func(types.EnvVar) (string, error) { + resolved = true + return secret, nil + }, + Audit: func(d Decision) { decisions = append(decisions, d) }, + } + req, _ := http.NewRequest("GET", upstream.URL+"/repos/acme", nil) + req.Header.Set("Authorization", grant.Placeholder("Authorization")) + recorder := httptest.NewRecorder() + p.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", recorder.Code) + } + if hits != 0 || resolved { + t.Fatalf("upstream hits = %d, resolved = %v; cleartext credentials must not leave the proxy", hits, resolved) + } + if len(decisions) != 1 || decisions[0].Verdict != "rejected" || !strings.Contains(decisions[0].Reason, "HTTPS") { + t.Fatalf("decisions = %+v", decisions) + } +} + func TestPlaceholderInNonGrantedHeaderIsRejectedNotStripped(t *testing.T) { w := newWorld(t, secret) placeholder := w.grant.Placeholder("Authorization") @@ -208,19 +247,10 @@ func TestUnresolvableCredentialFailsTheRequest(t *testing.T) { w := newWorld(t, secret) unresolvable := w.grant unresolvable.Headers = []HeaderGrant{{Name: "Authorization"}} - p := &Proxy{Grants: []Grant{unresolvable}} - broken := httptest.NewServer(p) - defer broken.Close() - proxyURL, _ := url.Parse(broken.URL) - client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} + w.handler.Grants = []Grant{unresolvable} - req, _ := http.NewRequest("GET", w.upstream.URL+"/repos/acme/captain", nil) - req.Header.Set("Authorization", w.grant.Placeholder("Authorization")) - resp, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() + resp := w.do(t, "GET", w.upstream.URL+"/repos/acme/captain", + map[string]string{"Authorization": w.grant.Placeholder("Authorization")}, "") if resp.StatusCode != http.StatusBadGateway { t.Fatalf("status = %d, want 502 (R9.4)", resp.StatusCode) } @@ -236,17 +266,8 @@ func TestLaterGrantForSameDestinationCanAuthorize(t *testing.T) { otherScope := w.grant otherScope.Name = "other" otherScope.Paths = []string{"/other/"} - p := &Proxy{Grants: []Grant{otherScope, w.grant}} - server := httptest.NewServer(p) - defer server.Close() - proxyURL, _ := url.Parse(server.URL) - client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} - req, _ := http.NewRequest("GET", w.upstream.URL+"/repos/acme/captain", nil) - resp, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() + w.handler.Grants = []Grant{otherScope, w.grant} + resp := w.do(t, "GET", w.upstream.URL+"/repos/acme/captain", nil, "") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } diff --git a/pkg/gitagent/regression_test.go b/pkg/gitagent/regression_test.go index 268180b3..e775e6bd 100644 --- a/pkg/gitagent/regression_test.go +++ b/pkg/gitagent/regression_test.go @@ -21,6 +21,33 @@ func TestSplitSSHEndpointBracketedIPv6DefaultsPort(t *testing.T) { } } +func TestOverLimitSubmissionsAdvanceAttempt(t *testing.T) { + repo := t.TempDir() + if err := SaveTaskState(repo, &TaskState{ + Task: "t-limit", Attempts: 1, Policy: Policy{MaxAttempts: 1}, + }); err != nil { + t.Fatal(err) + } + updates := []RefUpdate{{Ref: agentBranchPrefix + "t-limit"}} + for want := 2; want <= 3; want++ { + var sideband strings.Builder + if err := sidecarPreReceive(context.Background(), repo, HookHost{}, updates, &sideband); err == nil { + t.Fatalf("attempt %d was accepted", want) + } + verdict, found, err := LoadVerdict(repo, "t-limit", want) + if err != nil || !found || verdict.Attempt != want { + t.Fatalf("attempt %d verdict = %+v, found=%v, err=%v", want, verdict, found, err) + } + } + state, found, err := LoadTaskState(repo, "t-limit") + if err != nil || !found { + t.Fatalf("load state: found=%v err=%v", found, err) + } + if state.Attempts != 3 { + t.Fatalf("attempts = %d, want 3", state.Attempts) + } +} + func TestUpdateTaskStateSerializesConcurrentWriters(t *testing.T) { repo := t.TempDir() if err := SaveTaskState(repo, &TaskState{Task: "t-lock"}); err != nil { diff --git a/pkg/gitagent/relay.go b/pkg/gitagent/relay.go index 18679109..77e244dc 100644 --- a/pkg/gitagent/relay.go +++ b/pkg/gitagent/relay.go @@ -7,6 +7,7 @@ package gitagent import ( + "bytes" "context" "encoding/json" "fmt" @@ -35,30 +36,73 @@ func (e *upstreamRejectedError) Error() string { e.verdict.Task, e.verdict.Attempt, e.verdict.Status) } +const relayFeedbackTruncation = "captain: relay feedback truncated\n" + // relayFeedbackWriter removes the inner git client's "remote: " transport // prefix before forwarding through the outer receive-pack. It also retains // the structured supervisor verdict so an ordinary rejection is not // misclassified as a sidecar transport error. type relayFeedbackWriter struct { - dst io.Writer - pending string - verdict *TierVerdict + dst io.Writer + pending string + verdict *TierVerdict + dropping bool + truncated bool } func (w *relayFeedbackWriter) Write(p []byte) (int, error) { - w.pending += string(p) - for { - i := strings.IndexByte(w.pending, '\n') + n := len(p) + for len(p) > 0 { + if w.dropping { + i := bytes.IndexByte(p, '\n') + if i < 0 { + return n, nil + } + p = p[i+1:] + w.dropping = false + continue + } + + i := bytes.IndexByte(p, '\n') if i < 0 { - break + if len(p) > MaxFeedbackBytes-len(w.pending) { + w.pending = "" + w.dropping = true + if err := w.writeTruncation(); err != nil { + return 0, err + } + return n, nil + } + w.pending += string(p) + return n, nil + } + + fragment := p[:i+1] + p = p[i+1:] + if len(fragment) > MaxFeedbackBytes-len(w.pending) { + w.pending = "" + if err := w.writeTruncation(); err != nil { + return 0, err + } + continue } - line := w.pending[:i+1] - w.pending = w.pending[i+1:] + w.pending += string(fragment) + line := w.pending + w.pending = "" if err := w.writeLine(line); err != nil { return 0, err } } - return len(p), nil + return n, nil +} + +func (w *relayFeedbackWriter) writeTruncation() error { + if w.truncated { + return nil + } + w.truncated = true + _, err := io.WriteString(w.dst, relayFeedbackTruncation) + return err } func (w *relayFeedbackWriter) flush() error { diff --git a/pkg/gitagent/relay_test.go b/pkg/gitagent/relay_test.go new file mode 100644 index 00000000..eb174082 --- /dev/null +++ b/pkg/gitagent/relay_test.go @@ -0,0 +1,59 @@ +package gitagent + +import ( + "strings" + "testing" +) + +func TestRelayFeedbackWriterPreservesLinesAndSupervisorVerdict(t *testing.T) { + var dst strings.Builder + writer := &relayFeedbackWriter{dst: &dst} + if _, err := writer.Write([]byte("remote: first")); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(" line\nremote: captain-json: {\"v\":1,\"task\":\"t-1\",\"attempt\":2,\"status\":\"rejected\",\"tier\":\"supervisor\"}\nremote: tail")); err != nil { + t.Fatal(err) + } + if err := writer.flush(); err != nil { + t.Fatal(err) + } + if got := dst.String(); !strings.Contains(got, "first line\n") || !strings.HasSuffix(got, "tail") { + t.Fatalf("feedback = %q", got) + } + if writer.verdict == nil || writer.verdict.Task != "t-1" || writer.verdict.Attempt != 2 { + t.Fatalf("verdict = %+v", writer.verdict) + } +} + +func TestRelayFeedbackWriterBoundsUnterminatedLines(t *testing.T) { + var dst strings.Builder + writer := &relayFeedbackWriter{dst: &dst} + if _, err := writer.Write([]byte("remote: " + strings.Repeat("x", MaxFeedbackBytes/2))); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(strings.Repeat("x", MaxFeedbackBytes))); err != nil { + t.Fatal(err) + } + if len(writer.pending) > MaxFeedbackBytes { + t.Fatalf("pending grew to %d bytes", len(writer.pending)) + } + if _, err := writer.Write([]byte("discarded\nremote: after\n")); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(strings.Repeat("y", MaxFeedbackBytes+1) + "\n")); err != nil { + t.Fatal(err) + } + if err := writer.flush(); err != nil { + t.Fatal(err) + } + got := dst.String() + if strings.Count(got, relayFeedbackTruncation) != 1 { + t.Fatalf("truncation count = %d, feedback = %q", strings.Count(got, relayFeedbackTruncation), got) + } + if !strings.Contains(got, "after\n") { + t.Fatalf("normal line after truncation was lost: %q", got) + } + if strings.Contains(got, strings.Repeat("x", 32)) || strings.Contains(got, strings.Repeat("y", 32)) { + t.Fatalf("oversized line content was forwarded: %q", got) + } +} diff --git a/pkg/gitagent/workspace.go b/pkg/gitagent/workspace.go index bd67cc6c..a4b6197d 100644 --- a/pkg/gitagent/workspace.go +++ b/pkg/gitagent/workspace.go @@ -28,7 +28,8 @@ const NoAgentCommand = "none" // it (object store shared) into /captain/tasks//worktree. A clone // rather than a linked worktree keeps the agent's unaccepted commits in the // agent's own object store: a rejected push leaves the sidecar repo clean. -func SetupAgentWorkspace(ctx context.Context, sidecarRepo, task, dispatchCommit string) (string, error) { +// runtimeIdentity becomes the author name on the agent's ordinary commits. +func SetupAgentWorkspace(ctx context.Context, sidecarRepo, task, dispatchCommit, runtimeIdentity string) (string, error) { branch, err := AgentBranch(task) if err != nil { return "", err @@ -46,8 +47,9 @@ func SetupAgentWorkspace(ctx context.Context, sidecarRepo, task, dispatchCommit "clone", "--quiet", "--shared", "--branch", branchName, sidecarRepo, workdir); err != nil { return "", err } - // Pin the agent's identity so a bare `git commit` needs no global config. - for _, kv := range [][2]string{{"user.name", "captain-agent"}, {"user.email", "agent@captain.local"}} { + // Pin the selected runtime so a bare `git commit` needs no global config + // and still records which model and effort produced it. + for _, kv := range [][2]string{{"user.name", runtimeIdentity}, {"user.email", "agent@captain.local"}} { if _, err := runGit(ctx, workdir, env, "config", kv[0], kv[1]); err != nil { return "", err } diff --git a/pkg/gitagent/workspace_test.go b/pkg/gitagent/workspace_test.go index 85c66984..fc17829b 100644 --- a/pkg/gitagent/workspace_test.go +++ b/pkg/gitagent/workspace_test.go @@ -1,12 +1,49 @@ package gitagent import ( + "context" "os" "path/filepath" "strings" "testing" ) +func TestTaskRuntimeIdentityUsesModelAndEffort(t *testing.T) { + got := taskRuntimeIdentity([]byte(`{"model":"gpt-5.6-sol","backend":"codex-agent","effort":"high"}`)) + if got != "agent:gpt-5.6-sol:high" { + t.Fatalf("identity = %q", got) + } + if got := taskRuntimeIdentity([]byte(`{}`)); got != "captain-agent" { + t.Fatalf("legacy identity = %q", got) + } +} + +func TestSetupAgentWorkspacePinsRuntimeIdentity(t *testing.T) { + ctx := context.Background() + repo := filepath.Join(t.TempDir(), "sidecar.git") + if err := InitSidecar(ctx, repo); err != nil { + t.Fatal(err) + } + commit, err := BuildControlCommit(ctx, repo, nil, map[string][]byte{"seed.txt": []byte("seed\n")}) + if err != nil { + t.Fatal(err) + } + if err := SaveTaskState(repo, &TaskState{Task: "t-identity"}); err != nil { + t.Fatal(err) + } + workdir, err := SetupAgentWorkspace(ctx, repo, "t-identity", commit, "agent:gpt-5.6-sol:high") + if err != nil { + t.Fatal(err) + } + env := ScrubGitEnv(os.Environ()) + if got, err := runGit(ctx, workdir, env, "config", "user.name"); err != nil || got != "agent:gpt-5.6-sol:high" { + t.Fatalf("user.name = %q, %v", got, err) + } + if got, err := runGit(ctx, workdir, env, "config", "user.email"); err != nil || got != "agent@captain.local" { + t.Fatalf("user.email = %q, %v", got, err) + } +} + // A dispatch that launches nothing leaves the supervisor waiting out its whole // budget on work that never started — a silence indistinguishable from an // agent still thinking. Empty must therefore be an error, and "no agent" must diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go index c4276ffd..c95aa5f9 100644 --- a/pkg/sandbox/adapter/gitagent.go +++ b/pkg/sandbox/adapter/gitagent.go @@ -106,7 +106,7 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp // own defaults and quietly pick a different one. TaskPayload: gitagent.TaskPayload{ Prompt: prompt, System: system, - Model: spec.Name, Backend: string(spec.Backend), Timeout: timeout, + Model: spec.Name, Backend: string(spec.Backend), Effort: spec.Effort, Timeout: timeout, }, HooksJSON: hooksJSON, }) From a11fc5449de4de5066521879502e0abd05232bc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 05:19:36 +0000 Subject: [PATCH 20/22] fix(gitagent): refuse required filters only when a path resolves them A required clean/smudge filter was refused whenever any config scope declared one, but git-lfs registers filter.lfs.required=true machine-wide (GitHub runners ship it in the system gitconfig), so every snapshot on such a host refused regardless of whether the repository used LFS. Check the filter attribute actually resolved by each candidate path instead, and refuse only when that filter is required. The H5 spec's conflict setup also ran bare git merge, which needs a committer identity the runner does not have; pin one so the unmerged case exercises the refusal rather than dying at merge time. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GMGGZyjPLZx2JPK7fC1DQ2 --- pkg/gitagent/snapshot.go | 49 +++++++++++++++++----------- pkg/gitagent/snapshot_ginkgo_test.go | 11 ++++++- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/pkg/gitagent/snapshot.go b/pkg/gitagent/snapshot.go index 296c321b..aea436ad 100644 --- a/pkg/gitagent/snapshot.go +++ b/pkg/gitagent/snapshot.go @@ -132,40 +132,51 @@ func filterPolicyPaths(paths, patterns []string) ([]string, error) { } // refuseSnapshotHazards aborts on anything that round-trips incorrectly and -// silently (H5): LFS-filtered paths, required clean/smudge filters, and dirty -// submodules. +// silently (H5): LFS-filtered paths, required clean/smudge filters resolved +// by a snapshot path, and dirty submodules. func refuseSnapshotHazards(ctx context.Context, dir string, env []string, paths []string) error { - if err := refuseLFS(ctx, dir, env, paths); err != nil { + if err := refuseFilters(ctx, dir, env, paths); err != nil { return err } - code, out, err := gitExitCode(ctx, dir, env, "config", "--get-regexp", `^filter\..*\.required$`) - if err != nil { - return err - } - if code == 0 { - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - if strings.HasSuffix(strings.TrimSpace(line), "true") { - return fmt.Errorf("snapshot refused: required clean/smudge filter declared (%s); it cannot round-trip byte-exact", strings.Fields(line)[0]) - } - } - } return refuseDirtySubmodules(ctx, dir, env, paths) } -// refuseLFS rejects a snapshot when any candidate path resolves the lfs -// filter, or when any in-repo .gitattributes declares it at all — equivalent -// to "`git lfs ls-files` is non-empty" without requiring the lfs binary. -func refuseLFS(ctx context.Context, dir string, env []string, paths []string) error { +// refuseFilters rejects a snapshot when a candidate path resolves the lfs +// filter or any filter declared required in config: materializing would +// smudge the byte-exact blob on the far side. The declaration alone is +// harmless — git-lfs installs filter.lfs.required=true machine-wide, so a +// path must actually resolve the filter through an attribute before the +// hazard exists. In-repo .gitattributes declaring filter=lfs refuses even +// with no dirty path — equivalent to "`git lfs ls-files` is non-empty" +// without requiring the lfs binary. +func refuseFilters(ctx context.Context, dir string, env []string, paths []string) error { if len(paths) > 0 { args := append([]string{"check-attr", "-z", "filter", "--"}, paths...) out, err := runGitRaw(ctx, dir, env, nil, args...) if err != nil { return err } + required := map[string]bool{} fields := strings.Split(out, "\x00") for i := 0; i+2 < len(fields); i += 3 { - if fields[i+2] == "lfs" { + name := fields[i+2] + switch name { + case "lfs": return fmt.Errorf("snapshot refused: %q is LFS-tracked; LFS pointers do not round-trip (H5)", fields[i]) + case "unspecified", "unset", "set": + continue + } + must, seen := required[name] + if !seen { + code, val, err := gitExitCode(ctx, dir, env, "config", "--get", "--type=bool", "filter."+name+".required") + if err != nil { + return err + } + must = code == 0 && strings.TrimSpace(val) == "true" + required[name] = must + } + if must { + return fmt.Errorf("snapshot refused: %q resolves the required clean/smudge filter %q; it cannot round-trip byte-exact", fields[i], name) } } } diff --git a/pkg/gitagent/snapshot_ginkgo_test.go b/pkg/gitagent/snapshot_ginkgo_test.go index ec91f40e..ea44e012 100644 --- a/pkg/gitagent/snapshot_ginkgo_test.go +++ b/pkg/gitagent/snapshot_ginkgo_test.go @@ -193,7 +193,14 @@ var _ = Describe("dispatch snapshot", func() { gitT(reqf, "add", "-A") gitT(reqf, "commit", "-q", "-m", "base") gitT(reqf, "config", "filter.crypt.required", "true") + gitT(reqf, "config", "filter.crypt.clean", "cat") + gitT(reqf, "config", "filter.crypt.smudge", "cat") writeFileT(reqf, "dirty.txt", "dirty\n") + // The bare declaration must not refuse: git-lfs installs one + // machine-wide, so refusing on it would refuse every repository. + _, err = gitagent.TakeSnapshot(ctx, reqf, gitagent.SnapshotPolicy{}) + Expect(err).NotTo(HaveOccurred()) + writeFileT(reqf, ".gitattributes", "*.txt filter=crypt\n") _, err = gitagent.TakeSnapshot(ctx, reqf, gitagent.SnapshotPolicy{}) Expect(err).To(MatchError(ContainSubstring("required clean/smudge filter"))) @@ -224,7 +231,9 @@ var _ = Describe("dispatch snapshot", func() { gitT(conflict, "checkout", "-q", "main") writeFileT(conflict, "c.txt", "main\n") gitT(conflict, "commit", "-q", "-am", "main") - cmd := exec.Command("git", "merge", "side") + cmd := exec.Command("git", + "-c", "user.name=test", "-c", "user.email=test@localhost", + "merge", "side") cmd.Dir = conflict _ = cmd.Run() // expected to conflict _, err = gitagent.TakeSnapshot(ctx, conflict, gitagent.SnapshotPolicy{}) From 7142e6449e8540be4dc03b0bf5f2cf8652fca749 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 05:53:43 +0000 Subject: [PATCH 21/22] fix(cli): order disabled-selections persist+install under one mutex Two concurrent PUTs to /api/captain/ai/disabled could commit to disk in one order and install the process-wide set in the other, leaving the runtime disabled set diverged from ~/.captain.yaml until the next restart. Hold a mutex across the pair so the last write wins in both places consistently. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GMGGZyjPLZx2JPK7fC1DQ2 --- pkg/cli/serve_disabled.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/cli/serve_disabled.go b/pkg/cli/serve_disabled.go index 133a31cd..a05c236a 100644 --- a/pkg/cli/serve_disabled.go +++ b/pkg/cli/serve_disabled.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strings" + "sync" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" @@ -64,6 +65,11 @@ func handleDisabledSelections(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusUnprocessableEntity) return } + // The persist+install pair must commit in the same order for every writer: + // unlocked, two concurrent PUTs can reach disk in one order and install + // process-wide in the other, leaving the runtime set diverged from the file. + disabledWriteMu.Lock() + defer disabledWriteMu.Unlock() if err := captainconfig.Update(func(cfg *captainconfig.Config) error { cfg.AI.Disabled = selections return nil @@ -77,6 +83,8 @@ func handleDisabledSelections(w http.ResponseWriter, r *http.Request) { writeConfigurationJSON(w, disabledSelectionsRequest(selections)) } +var disabledWriteMu sync.Mutex + // normalizeTokens trims, drops blanks and de-duplicates case-insensitively. // Enum axes are canonicalized to lower case; model ids keep the case they were // written in, since they are only ever displayed back and matched insensitively. From 679fa0b82d0ef7e2edc6767071d96bd5b47c98cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 06:00:02 +0000 Subject: [PATCH 22/22] fix(aichat): deflake tool-approval resume against in-flight suspension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt run reaches its waiting state from the event pipeline before the suspending stream persists the trailing assistant message, so an approval resolved from a session poll could land in that window: the resolution had already consumed the approval durably, then the resume failed with 'does not end with the suspended turn' and the session stranded. Wait a bounded interval for the seed message instead — the run's durable approval state guarantees the write is committed or imminent. The lifecycle test had the mirror-image bug: its readiness poll could drain the chat result channel inside a failed Eventually iteration, and the later receive then timed out on an empty channel. Consume the result at most once. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GMGGZyjPLZx2JPK7fC1DQ2 --- .../aimock_lifecycle_integration_test.go | 22 +++++-- pkg/aichat/approval_execution.go | 58 ++++++++++++++----- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/pkg/aichat/aimock_lifecycle_integration_test.go b/pkg/aichat/aimock_lifecycle_integration_test.go index 94f1b7f7..a95ad0d1 100644 --- a/pkg/aichat/aimock_lifecycle_integration_test.go +++ b/pkg/aichat/aimock_lifecycle_integration_test.go @@ -330,15 +330,26 @@ func runApprovalFlow( "user-"+strings.ToLower(verb), verb+" the account update") }() var pending session.Session + // The chat result is consumed at most once across poll retries: a value + // drained inside a failed Eventually iteration would otherwise be lost and + // the final receive below would wait on an already-empty channel. + var chat httpResult + chatReceived := false Eventually(func(g Gomega) { pending = getLifecycleSession(ctx, client, baseURL, thread.ID) if len(pending.Requests) == 0 { - select { - case chat := <-chatResult: + if !chatReceived { + select { + case chat = <-chatResult: + chatReceived = true + default: + } + } + if chatReceived { g.Expect(chat.err).NotTo(HaveOccurred()) g.Expect(chat.status).To(Equal(http.StatusOK), string(chat.body)) g.Expect(pending.Requests).To(HaveLen(1), string(chat.body)) - default: + } else { g.Expect(pending.Requests).To(HaveLen(1), lifecycleRequestsJSON(mockServer.Requests())) } return @@ -361,8 +372,9 @@ func runApprovalFlow( g.Expect(completed.Turns[0].Status).To(Equal(string(database.TurnStatusEnded)), lifecycleRequestsJSON(mockServer.Requests())) }).WithTimeout(30 * time.Second).Should(Succeed()) - var chat httpResult - Eventually(chatResult).WithTimeout(30 * time.Second).Should(Receive(&chat)) + if !chatReceived { + Eventually(chatResult).WithTimeout(30 * time.Second).Should(Receive(&chat)) + } Expect(chat.err).NotTo(HaveOccurred()) Expect(chat.status).To(Equal(http.StatusOK), string(chat.body)) conflict := postLifecycleJSON(ctx, client, http.MethodPost, diff --git a/pkg/aichat/approval_execution.go b/pkg/aichat/approval_execution.go index fdc50326..578b29e2 100644 --- a/pkg/aichat/approval_execution.go +++ b/pkg/aichat/approval_execution.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" aitools "github.com/flanksource/captain/pkg/ai/tools" "github.com/flanksource/captain/pkg/api" @@ -53,24 +54,13 @@ func (s *Service) resumeToolApproval(ctx context.Context, threadID string, conti return fmt.Errorf("backend %q does not support caller tools", provider.GetBackend()) } } - store, err := s.threads(ctx) + seed, err := s.awaitSuspendedSeed(ctx, threadID, execution.TurnID()) if err != nil { return err } - thread, err := store.Get(ctx, threadID) - if err != nil { - return err - } - if len(thread.Messages) == 0 { - return fmt.Errorf("captain chat session %s has no suspended assistant message", threadID) - } - seed := thread.Messages[len(thread.Messages)-1] - if !strings.EqualFold(seed.Role, string(api.RoleAssistant)) || seed.TurnID != execution.TurnID() { - return fmt.Errorf("captain chat session %s does not end with the suspended turn %s", threadID, execution.TurnID()) - } request := ChatRequest{ ID: threadID, ThreadID: threadID, Trigger: "submit-message", MessageID: seed.ID, - Messages: []UIMessage{seed}, ToolApproval: continuation.Spec.ToolApproval, + Messages: []UIMessage{*seed}, ToolApproval: continuation.Spec.ToolApproval, } streamContext, cancel := context.WithCancel(ctx) defer cancel() @@ -97,3 +87,45 @@ func (s *Service) resumeToolApproval(ctx context.Context, threadID string, conti } return nil } + +// suspendedSeedWait bounds how long an approval resolution waits for the +// suspending turn's assistant message to land in the thread store. +const suspendedSeedWait = 5 * time.Second + +// awaitSuspendedSeed returns the thread's trailing assistant message for the +// suspended turn. The prompt run reaches its waiting state from the event +// pipeline before the suspending stream persists that message on its final +// unwind, so an approval resolved from a session poll can arrive while the +// write is still in flight. The run's durable approval state guarantees the +// message is committed or imminent — wait it out instead of failing a +// resolution that has already consumed the approval. +func (s *Service) awaitSuspendedSeed(ctx context.Context, threadID, turnID string) (*UIMessage, error) { + store, err := s.threads(ctx) + if err != nil { + return nil, err + } + deadline := time.Now().Add(suspendedSeedWait) + for { + thread, err := store.Get(ctx, threadID) + if err != nil { + return nil, err + } + if count := len(thread.Messages); count > 0 { + seed := thread.Messages[count-1] + if strings.EqualFold(seed.Role, string(api.RoleAssistant)) && seed.TurnID == turnID { + return &seed, nil + } + } + if time.Now().After(deadline) { + if len(thread.Messages) == 0 { + return nil, fmt.Errorf("captain chat session %s has no suspended assistant message", threadID) + } + return nil, fmt.Errorf("captain chat session %s does not end with the suspended turn %s", threadID, turnID) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +}