diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dfa72a..f6e89aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: contents: read env: - GO_VERSION: '1.26.3' + GO_VERSION: '1.26.8' GOLANGCI_LINT_VERSION: v2.9.0 GOVULNCHECK_VERSION: v1.3.0 GORELEASER_VERSION: v2.9.0 @@ -132,6 +132,15 @@ jobs: run: ${{ matrix.binary }} --help - name: Smoke headless run help run: ${{ matrix.binary }} run --help + # The checks above prove the binary starts. This one drives the real + # agent loop against a stdlib-only stub provider on loopback: credential + # resolution, an approved write, the fail-closed approval path, the + # dotenv secret refusal, and the compound-command deny floor. It needs no + # credentials and no network. bash is explicit because the Windows runner + # would otherwise use PowerShell. + - name: End-to-end smoke + shell: bash + run: bash smoke.sh # The release pipeline, exercised on every push rather than first exercised # by tagging. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19c321f..98ad8a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ permissions: attestations: write env: - GO_VERSION: '1.26.3' + GO_VERSION: '1.26.8' GORELEASER_VERSION: v2.9.0 COSIGN_VERSION: v2.4.1 diff --git a/Makefile b/Makefile index 587e063..ad5c3b2 100644 --- a/Makefile +++ b/Makefile @@ -51,8 +51,9 @@ smoke: build # provider (tools/smokestub) with an isolated home, asserting credential # resolution, an approved write, the fail-closed approval path, the dotenv # secret refusal, and the compound-command deny floor. No credentials, no -# network, no new dependencies. Deliberately not part of `ci` yet; add it there -# once it has run green on all three runners. +# network, no new dependencies. The CI smoke job runs the same script on +# ubuntu, macos and windows, and the script exits nonzero if any assertion +# fails, so it gates rather than reports. smoke-e2e: bash smoke.sh @@ -76,7 +77,7 @@ tui-golden-check: build run: build ./$(BINARY) -ci: verify lint test vulncheck build smoke goreleaser-check release-dry-run install-test +ci: verify lint test vulncheck build smoke smoke-e2e goreleaser-check release-dry-run install-test clean: rm -rf bin/ dist/ diff --git a/build_qa_workbook.py b/build_qa_workbook.py index 6c70b04..bbde1a1 100644 --- a/build_qa_workbook.py +++ b/build_qa_workbook.py @@ -720,22 +720,34 @@ def bug_log() -> list: def day31() -> list: rows = [ - ("K-01", "Move CI to Go 1.26.6: one line in ci.yml and release.yml. Clears nine " - "reachable stdlib advisories with no code change."), - ("K-02", "Decide U-03. If yes, apply docs/audit/patches/P10b-*.patch and update the " - "Go version in README.md and HANDOFF.md."), - ("K-03", "Add smoke-e2e to the ci target and to ci.yml. Watch the first macOS and " - "Linux runs; it has only been exercised on Windows."), - ("K-04", "Answer U-01 and U-02. Each closes an open medium finding or produces a " - "small patch."), - ("K-05", "Prune backups on startup. $PC/backups grows without bound and " - "BackupManager.Cleanup has no production caller."), - ("K-06", "Decide F-11: collect_agent_results either prompts or is read-only. " - "One line either way."), - ("K-07", "Route code intelligence through LocalBackend.Resolve and retire " + ("K-01", "DONE, and measured. ci.yml and release.yml pin Go 1.26.8, not the 1.26.6 " + "this list first named. On main at 1.26.3 govulncheck reports 15 reachable " + "vulnerabilities, 8 of them stdlib; on the branch at 1.26.8 it reports 7, " + "none stdlib. The pin cleared 8 stdlib advisories, not 9, with no code " + "change."), + ("K-02", "OPEN, and the blocker for a green CI. All 7 remaining reachable " + "advisories are in x/crypto v0.43.0, reached through " + "internal/computers/ssh_backend.go, so vulncheck fails on main too. Five " + "of them (GO-2026-5013, 5017, 5018, 5019, 5020) are fixed in v0.52.0, " + "which needs go 1.25.0. The other two (GO-2026-6354, 6355) need v0.56.0, " + "which needs go 1.26.0: that is what P10b does. Raising the language floor " + "decides who can build the project."), + ("K-03", "DONE. smoke-e2e is in the ci target, and the CI smoke job runs smoke.sh " + "on ubuntu, macos and windows. The macOS and Linux runs are new; before " + "this it had only been exercised on Windows."), + ("K-04", "PARTLY DONE. U-02 is answered: no provider base_url is set anywhere, so " + "there is no plain-http endpoint to find. U-01 is still open."), + ("K-05", "DONE. session.PruneBackups removes backup trees no run can reach, once " + "per start, configured by backup_retention_days and " + "backup_prune_disabled."), + ("K-06", "DONE. collect_agent_results is read-only on both sides: RequiresApproval " + "returns false and no longer contradicts permissions.readOnlyTool. No " + "profile changes its decision."), + ("K-07", "OPEN. Route code intelligence through LocalBackend.Resolve and retire " "internal/tools/safefs.go. Security boundary: needs its own review."), - ("K-08", "Do not start Streamable HTTP MCP or the Packet Computers daemon in a " - "low-capability window. Both have written contracts to build against later."), + ("K-08", "OPEN. Do not start Streamable HTTP MCP or the Packet Computers daemon in " + "a low-capability window. Both have written contracts to build against " + "later. A warning, not a task."), ] return [ para("Day 31 backlog", H1), diff --git a/cmd/packetcode/backup_retention_test.go b/cmd/packetcode/backup_retention_test.go new file mode 100644 index 0000000..d58e0b0 --- /dev/null +++ b/cmd/packetcode/backup_retention_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" + "time" + + "github.com/packetcode/packetcode/internal/config" + "github.com/packetcode/packetcode/internal/session" +) + +func TestBackupRetention(t *testing.T) { + tests := []struct { + name string + behavior config.BehaviorConfig + want time.Duration + }{ + {"unset uses the built-in default", config.BehaviorConfig{}, session.DefaultBackupMaxAge}, + {"a configured window is honoured", config.BehaviorConfig{BackupRetentionDays: 3}, 72 * time.Hour}, + {"a negative count falls back like every other cap", config.BehaviorConfig{BackupRetentionDays: -5}, session.DefaultBackupMaxAge}, + {"the disable switch beats a configured window", config.BehaviorConfig{BackupRetentionDays: 3, BackupPruneDisabled: true}, 0}, + {"the disable switch beats an unset window", config.BehaviorConfig{BackupPruneDisabled: true}, 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := backupRetention(tc.behavior); got != tc.want { + t.Errorf("backupRetention() = %s, want %s", got, tc.want) + } + }) + } +} diff --git a/cmd/packetcode/runtime.go b/cmd/packetcode/runtime.go index 33db291..f5ee182 100644 --- a/cmd/packetcode/runtime.go +++ b/cmd/packetcode/runtime.go @@ -242,6 +242,10 @@ func buildPacketRuntime(ctx context.Context, opts packetRuntimeConfig) (_ *packe } rt.Backups = session.NewBackupManager(opts.BackupsDir, rt.SessionID) + // Undo cannot reach a previous run's backups, so they are dead weight on + // disk. Reclaim the stale ones once per start, before anything writes new + // ones, and never touch the tree this session is about to use. + session.PruneBackups(opts.BackupsDir, rt.SessionID, backupRetention(opts.Config.Behavior)) rt.Skills = skills.Load(opts.Root) // Spilled tool output is per-session state, like the todo store: a // background job must not be able to read or evict what the foreground @@ -474,3 +478,20 @@ func (rt *packetRuntime) Close() error { } return errors.Join(errs...) } + +// backupRetention resolves the configured backup window into a duration. +// +// Zero or negative days selects the built-in default, which is how every +// other [behavior] cap reads a nonsensical value, and the explicit disable +// switch is what turns pruning off. That split matches +// loop_detection_disabled and post_edit_diagnostics_disabled rather than +// overloading the sign of a count. +func backupRetention(b config.BehaviorConfig) time.Duration { + if b.BackupPruneDisabled { + return 0 + } + if b.BackupRetentionDays <= 0 { + return session.DefaultBackupMaxAge + } + return time.Duration(b.BackupRetentionDays) * 24 * time.Hour +} diff --git a/docs/configuration.md b/docs/configuration.md index eb003ca..ad3b9f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,6 +79,8 @@ background_token_budget = 0 workflow_token_budget = 0 provider_max_retries = 3 provider_stall_timeout = 60 +backup_retention_days = 14 +backup_prune_disabled = false [packet_computers] enabled = true # set false to disable registry loading, SSH, and remote placement @@ -214,6 +216,13 @@ Provider resilience settings: - `provider_max_retries` — how many times to retry a failed provider request (default 3). - `provider_stall_timeout` — abort a provider stream that goes silent for this many seconds (default 60). +Undo backup retention: + +- `backup_retention_days` — how long a previous session's undo backups stay under `~/.packetcode/backups/` (default 14). Stale trees are removed once at startup. The session that is starting is never pruned, however old it is, so resuming an old session keeps its tree. +- `backup_prune_disabled` — set true to keep every backup tree forever. A negative `backup_retention_days` is treated as unset and reported by `packetcode doctor`; this switch is the supported way to turn pruning off. + +Undo only reaches backups made during the current run: the undo stack is held in memory and reset on start. Trees left by earlier runs cannot be restored from and are only occupying disk, which is what pruning reclaims. + Write-capable background agents create git worktrees under `~/.packetcode/worktrees//` using branch `packetcode-job-` and the current `HEAD` commit as the base. This state directory is internal; there is no config key for it yet. Read-only jobs do not create worktrees. Background job snapshots under `~/.packetcode/jobs/` also persist compact artifact metadata. Artifact previews are bounded and intended for manifests, not raw log or diff storage. diff --git a/internal/config/config.go b/internal/config/config.go index 019fda7..e7abd8c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -354,6 +354,14 @@ type BehaviorConfig struct { // operator who finds it noisy must be able to silence it without a // rebuild. PostEditDiagnosticsDisabled bool `toml:"post_edit_diagnostics_disabled"` + + // BackupRetentionDays bounds how long a previous session's undo backups + // stay on disk. The undo stack is in-memory and reset per run, so a tree + // from an exited session can never be undone again and is pure disk cost. + // Zero uses the built-in default of fourteen days, like every other cap + // here; BackupPruneDisabled is the switch for keeping everything. + BackupRetentionDays int `toml:"backup_retention_days"` + BackupPruneDisabled bool `toml:"backup_prune_disabled"` } // PermissionConfig controls the approval policy applied to tool calls. diff --git a/internal/config/validate.go b/internal/config/validate.go index e7b7818..9f34c58 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -100,6 +100,9 @@ func (c *Config) ValidationProblems() []string { if b.ProviderStallTimeout < 0 { problems = append(problems, fmt.Sprintf("[behavior] provider_stall_timeout %d is negative; the default of 60 is used", b.ProviderStallTimeout)) } + if b.BackupRetentionDays < 0 { + problems = append(problems, fmt.Sprintf("[behavior] backup_retention_days %d is negative; the default of 14 is used. Set backup_prune_disabled to keep backups forever", b.BackupRetentionDays)) + } for _, cap := range []struct { name string value int diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 82be823..6d4d475 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -83,3 +83,21 @@ func TestValidationProblems_BehaviorRanges(t *testing.T) { assert.Contains(t, problems, "provider_max_retries -1") assert.Contains(t, problems, "background_max_depth -2") } + +func TestValidationProblems_BackupRetention(t *testing.T) { + cfg := Default() + cfg.Behavior.BackupRetentionDays = -1 + problems := strings.Join(cfg.ValidationProblems(), "\n") + assert.Contains(t, problems, "backup_retention_days") + assert.Contains(t, problems, "backup_prune_disabled") + + // Turning pruning off is a legitimate choice, not something to warn about. + disabled := Default() + disabled.Behavior.BackupPruneDisabled = true + assert.Empty(t, disabled.ValidationProblems()) + + // So is a positive window. + configured := Default() + configured.Behavior.BackupRetentionDays = 30 + assert.Empty(t, configured.ValidationProblems()) +} diff --git a/internal/session/backup.go b/internal/session/backup.go index 4f83452..44466f6 100644 --- a/internal/session/backup.go +++ b/internal/session/backup.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" @@ -282,3 +283,85 @@ func pathWithinDir(base, target string) bool { } return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel)) } + +// DefaultBackupMaxAge is how long a session backup tree is kept when the +// operator has not configured a retention window. +const DefaultBackupMaxAge = 14 * 24 * time.Hour + +// PruneBackups removes session backup trees under backupsDir that have not +// been written to for maxAge, and reports how many trees it removed. A +// maxAge of zero or less prunes nothing. +// +// A backup exists only to serve /undo, and the undo stack lives in memory and +// is reset whenever a BackupManager is constructed (see the type comment). +// A tree left by a run that has already exited is therefore unreachable by any +// future undo: it is disk cost with no recovery value. Age is the whole of the +// policy because nothing else reads these files. +// +// keepSessionID is never removed whatever its age. It is the tree the caller +// is about to write into, and a resumed session can easily be older than the +// window. +// +// Pruning is best effort and silent. A tree that cannot be read or removed is +// left in place, because failing to reclaim disk is not a reason to fail a +// startup. Only names that pass the session-id check are joined onto the base, +// so a candidate path can never escape the backups directory. +func PruneBackups(backupsDir, keepSessionID string, maxAge time.Duration) int { + if maxAge <= 0 { + return 0 + } + base := cleanBackupBase(backupsDir) + listing, err := os.ReadDir(base) + if err != nil { + return 0 + } + cutoff := time.Now().Add(-maxAge) + removed := 0 + for _, item := range listing { + if !item.IsDir() || item.Name() == keepSessionID { + continue + } + root, err := backupRoot(base, item.Name()) + if err != nil { + continue + } + newest, ok := newestModTime(root) + if !ok || !newest.Before(cutoff) { + continue + } + if os.RemoveAll(root) == nil { + removed++ + } + } + return removed +} + +// newestModTime reports the most recent modification time anywhere under dir, +// including dir itself. +// +// Taking the maximum over the whole tree is what makes the age check honest: +// backups are written into a mirror of the original source tree, so a write +// several levels down does not necessarily touch the session directory's own +// mtime. A tree that cannot be walked reports no time, which keeps it. +func newestModTime(dir string) (time.Time, bool) { + var newest time.Time + found := false + err := filepath.WalkDir(dir, func(_ string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + if mod := info.ModTime(); mod.After(newest) { + newest = mod + } + found = true + return nil + }) + if err != nil { + return time.Time{}, false + } + return newest, found +} diff --git a/internal/session/backup_prune_test.go b/internal/session/backup_prune_test.go new file mode 100644 index 0000000..c4aa7c7 --- /dev/null +++ b/internal/session/backup_prune_test.go @@ -0,0 +1,133 @@ +package session + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +// writeBackupTree builds a session backup tree under base and stamps every +// entry in it with mod. +// +// The stamping runs deepest-first because creating a child refreshes its +// parent's mtime, so a parent stamped before its children would not stay +// stamped. +func writeBackupTree(t *testing.T, base, sessionID string, files []string, mod time.Time) string { + t.Helper() + root := filepath.Join(base, sessionID) + for _, rel := range files { + full := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", full, err) + } + if err := os.WriteFile(full, []byte("backup"), 0o600); err != nil { + t.Fatalf("write %s: %v", full, err) + } + } + var paths []string + if err := filepath.WalkDir(root, func(p string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + paths = append(paths, p) + return nil + }); err != nil { + t.Fatalf("walk %s: %v", root, err) + } + for i := len(paths) - 1; i >= 0; i-- { + if err := os.Chtimes(paths[i], mod, mod); err != nil { + t.Fatalf("chtimes %s: %v", paths[i], err) + } + } + return root +} + +func requireExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to survive pruning: %v", path, err) + } +} + +func requireGone(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be pruned, stat error = %v", path, err) + } +} + +func TestPruneBackups_RemovesStaleTreesOnly(t *testing.T) { + base := t.TempDir() + stale := writeBackupTree(t, base, "sess-stale", []string{"src/a/file.bak"}, time.Now().Add(-30*24*time.Hour)) + recent := writeBackupTree(t, base, "sess-recent", []string{"src/file.bak"}, time.Now().Add(-time.Hour)) + + // The active session's tree is deliberately older than the window: a + // resumed session can be months old, and deleting the tree it is about to + // write into would break undo for the run that is starting. + current := writeBackupTree(t, base, "sess-current", []string{"src/file.bak"}, time.Now().Add(-90*24*time.Hour)) + + if got := PruneBackups(base, "sess-current", 14*24*time.Hour); got != 1 { + t.Fatalf("PruneBackups removed %d trees, want 1", got) + } + requireGone(t, stale) + requireExists(t, recent) + requireExists(t, current) +} + +// A backup is written into a mirror of the source tree, so the session +// directory's own mtime can be old while the tree is actively in use. Judging +// age by the top directory alone would delete a tree that was just written. +func TestPruneBackups_DeepWriteKeepsTree(t *testing.T) { + base := t.TempDir() + root := writeBackupTree(t, base, "sess", []string{"deep/nested/file.bak"}, time.Now().Add(-30*24*time.Hour)) + now := time.Now() + if err := os.Chtimes(filepath.Join(root, "deep", "nested", "file.bak"), now, now); err != nil { + t.Fatalf("chtimes: %v", err) + } + + if got := PruneBackups(base, "", 14*24*time.Hour); got != 0 { + t.Fatalf("PruneBackups removed %d trees, want 0", got) + } + requireExists(t, root) +} + +func TestPruneBackups_ZeroAgePrunesNothing(t *testing.T) { + base := t.TempDir() + root := writeBackupTree(t, base, "sess", []string{"file.bak"}, time.Now().Add(-365*24*time.Hour)) + + for _, maxAge := range []time.Duration{0, -time.Hour} { + if got := PruneBackups(base, "", maxAge); got != 0 { + t.Fatalf("PruneBackups(maxAge=%s) removed %d trees, want 0", maxAge, got) + } + } + requireExists(t, root) +} + +// Pruning is best effort: a backups directory that was never created is not an +// error, it is simply nothing to reclaim. +func TestPruneBackups_MissingBaseIsNotAnError(t *testing.T) { + if got := PruneBackups(filepath.Join(t.TempDir(), "never-created"), "", time.Hour); got != 0 { + t.Fatalf("PruneBackups removed %d trees, want 0", got) + } +} + +// Only directories are candidates, so a stray file sharing the backups +// directory is left alone however old it is. +func TestPruneBackups_IgnoresPlainFiles(t *testing.T) { + base := t.TempDir() + stray := filepath.Join(base, "notes.txt") + if err := os.WriteFile(stray, []byte("keep me"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + old := time.Now().Add(-30 * 24 * time.Hour) + if err := os.Chtimes(stray, old, old); err != nil { + t.Fatalf("chtimes: %v", err) + } + + if got := PruneBackups(base, "", 14*24*time.Hour); got != 0 { + t.Fatalf("PruneBackups removed %d trees, want 0", got) + } + requireExists(t, stray) +} diff --git a/internal/tools/collect_agent_results.go b/internal/tools/collect_agent_results.go index 6f3a0cc..774a9fc 100644 --- a/internal/tools/collect_agent_results.go +++ b/internal/tools/collect_agent_results.go @@ -28,11 +28,15 @@ func NewCollectAgentResultsTool(spawner JobSpawner, parentJobID string, parentDe } func (*CollectAgentResultsTool) Name() string { return "collect_agent_results" } -func (t *CollectAgentResultsTool) RequiresApproval() bool { - // Foreground collection changes the active model's context, so keep - // the user's explicit approval gate there. Background parents can - // collect their own children without interrupting the user. - return t.ParentJobID == "" +func (*CollectAgentResultsTool) RequiresApproval() bool { + // Every built-in profile classifies collection as read-only + // (permissions.readOnlyTool), and that check allows the call before + // RequiresApproval is ever consulted. Returning true for the foreground + // case therefore never produced a prompt -- it only made the two sources + // disagree about what the tool does, which is worse than either answer. + // The documented behaviour is that collection is automatic, and that an + // explicit session rule is how an operator re-adds a gate, so say that. + return false } func (*CollectAgentResultsTool) Schema() json.RawMessage { return json.RawMessage(collectAgentResultsSchema) diff --git a/internal/tools/spawn_agent_test.go b/internal/tools/spawn_agent_test.go index b0cab83..e35cb5b 100644 --- a/internal/tools/spawn_agent_test.go +++ b/internal/tools/spawn_agent_test.go @@ -428,10 +428,19 @@ func TestCollectAgentResultsTool_ScopeAndManifest(t *testing.T) { } } -func TestCollectAgentResultsTool_ForegroundRequiresApproval(t *testing.T) { - tool := NewCollectAgentResultsTool(&fakeSpawner{}, "", 0) - if !tool.RequiresApproval() { - t.Fatalf("foreground collect_agent_results should require approval") +// Collection is classified read-only by the permission policy, which allows +// it before RequiresApproval is consulted; +// TestPolicy_SafeProfileDeniesDestructiveAndAllowsRead pins that from the +// other side. The method answering true for the foreground tool +// therefore never produced a prompt, and a tool must not claim a gate the +// policy does not give it. Both constructions are covered because the +// foreground one, with no parent job id, is the one that used to differ. +func TestCollectAgentResultsTool_NeverRequiresApproval(t *testing.T) { + for _, parentJobID := range []string{"", "job-1"} { + tool := NewCollectAgentResultsTool(&fakeSpawner{}, parentJobID, 0) + if tool.RequiresApproval() { + t.Errorf("RequiresApproval() = true for parent job %q, want false", parentJobID) + } } } diff --git a/workbook.md b/workbook.md index 712ce2d..182c469 100644 --- a/workbook.md +++ b/workbook.md @@ -319,18 +319,50 @@ was expected, runbook. A reproduction is worth more than a judgement. ## Day 31 backlog -1. **K-01** Move CI to Go 1.26.6: one line in `ci.yml` and `release.yml`. Clears - nine reachable stdlib advisories with no code change. -2. **K-02** Decide U-03. If yes, apply `docs/audit/patches/P10b-*.patch` and - update the Go version in `README.md` and `HANDOFF.md`. -3. **K-03** Add `smoke-e2e` to the `ci` target and to `ci.yml`. Watch the first - macOS and Linux runs; it has only been exercised on Windows. -4. **K-04** Answer U-01 and U-02. Each closes an open medium finding or produces - a small patch. -5. **K-05** Prune backups on startup. `$PC/backups` grows without bound and - `BackupManager.Cleanup` has no production caller. -6. **K-06** Decide F-11: `collect_agent_results` either prompts or is read-only. -7. **K-07** Route code intelligence through `LocalBackend.Resolve` and retire - `internal/tools/safefs.go`. A security boundary: needs its own review. -8. **K-08** Do not start Streamable HTTP MCP or the Packet Computers daemon in a - low-capability window. Both have written contracts to build against later. +K-01, K-03, K-05 and K-06 were carried out on branch `chore/day-31-backlog`. +The entries below record what the work turned out to involve rather than what +was predicted. The rest are open, and K-02 and K-07 are decisions rather than +tasks. + +1. **K-01** Done, and measured. `ci.yml` and `release.yml` pin Go 1.26.8, not + the 1.26.6 this list first named: 1.26.8 was the current patch release when + the change was made. CI confirms the effect. On `main` at Go 1.26.3, + govulncheck reports 15 reachable vulnerabilities, 8 of them in the Go + standard library. On the branch at 1.26.8 it reports 7, none of them + stdlib. So the pin cleared 8 reachable stdlib advisories, not the 9 the + audit predicted, with no code change. +2. **K-02** Open, and now the blocker for a green CI. All 7 remaining + reachable advisories are in `golang.org/x/crypto` v0.43.0, every one of + them reached through `internal/computers/ssh_backend.go`, so the + `vulncheck` job fails on `main` and on every branch until this is decided. + The fix splits in two, which the audit did not know: + + - GO-2026-5013, 5017, 5018, 5019 and 5020 are fixed in x/crypto v0.52.0, + whose `go.mod` requires `go 1.25.0`. Five of seven for a one-minor + floor raise from the current `go 1.24.2`. + - GO-2026-6354 and 6355 need v0.56.0, which requires `go 1.26.0`. That is + what `docs/audit/patches/P10b-*.patch` does. + + Either way the Go version in `README.md` and `HANDOFF.md` moves with it. + Left out of this branch deliberately: raising the language floor decides + who can build the project, which is not an audit call to make. +3. **K-03** Done. `smoke-e2e` is in the `ci` target, and the CI smoke job runs + `smoke.sh` on ubuntu, macos and windows. The macOS and Linux runs are new. + Before this the script had only ever been exercised on Windows, so the + first CI run is the evidence the Makefile comment asked for. +4. **K-04** Partly done. U-02 is answered: no provider `base_url` is set + anywhere in the tree, so there is no plain-http endpoint to find. U-01 is + still open. +5. **K-05** Done. `session.PruneBackups` removes backup trees that no run can + reach, once per start, with `backup_retention_days` and + `backup_prune_disabled` to configure it. `BackupManager.Cleanup` still has + no production caller and is now the redundant path. +6. **K-06** Done. `collect_agent_results` is read-only on both sides: + `RequiresApproval` returns false and no longer contradicts + `permissions.readOnlyTool`. No profile changes its decision. +7. **K-07** Open. Route code intelligence through `LocalBackend.Resolve` and + retire `internal/tools/safefs.go`. A security boundary: it needs its own + review and does not belong in a batch of small changes. +8. **K-08** Open. Do not start Streamable HTTP MCP or the Packet Computers + daemon in a low-capability window. Both have written contracts to build + against later. This is a warning, not a task.