From 6e8e778e5106a6007002c6e363e4305caa474ac6 Mon Sep 17 00:00:00 2001 From: packetloss404 Date: Sat, 5 Sep 2026 10:07:43 -0500 Subject: [PATCH 1/6] ci: pin Go 1.26.8 1.26.3 was the pin from the initial workflow and five patch releases have shipped since. The toolchain that builds the release binaries should not be the stale one. Both workflows read the same GO_VERSION key, so they move together. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dfa72a..e3c6b9f 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 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 From fe9d3f52f2146320819c1c8abadb3e7f4043ee46 Mon Sep 17 00:00:00 2001 From: packetloss404 Date: Sat, 5 Sep 2026 10:10:17 -0500 Subject: [PATCH 2/6] tools: collect_agent_results stops claiming an approval gate it never had RequiresApproval returned true for the foreground tool, but every built-in profile lists collect_agent_results in permissions.readOnlyTool, and that check allows the call before RequiresApproval is ever consulted. The gate did not exist. Two sources described the tool and they disagreed. This aligns the code with the behaviour the policy actually implements and with docs/advanced-guide.md, which already documents collection as automatic with an explicit session rule as the way to re-add a gate. No decision changes for any profile: the read-only branch was already returning allow first. The existing test asserted the unreachable intent rather than the behaviour, so it is replaced by one that pins the answer for both the foreground and background constructions. Co-Authored-By: Claude Opus 5 --- internal/tools/collect_agent_results.go | 14 +++++++++----- internal/tools/spawn_agent_test.go | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) 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) + } } } From bd9547be0186747ea8c33d6a53b0dd9f07575128 Mon Sep 17 00:00:00 2001 From: packetloss404 Date: Sat, 5 Sep 2026 10:15:30 -0500 Subject: [PATCH 3/6] session: prune undo backups that no run can reach The undo stack is in-memory and reset whenever a BackupManager is constructed, so a backup tree left by a session that has already exited cannot be restored from by anything. Nothing read those files again; they only accumulated under ~/.packetcode/backups until someone noticed the disk. BackupManager.Cleanup existed but had no production caller. PruneBackups removes trees whose newest write is older than the retention window, once per start, before anything writes new ones. Age is judged by the newest mtime anywhere in the tree rather than the session directory's own: backups mirror the source tree, so an active session's top directory can look stale while a file three levels down was just written. The tree belonging to the session that is starting is never a candidate, because a resumed session is routinely older than the window. Pruning is best effort and silent. A tree that cannot be walked or removed is kept, since failing to reclaim disk is not a reason to fail a startup, and only names that pass the session-id check are joined onto the base, so a candidate path cannot escape the backups directory. Two keys configure it. backup_retention_days defaults to 14 and treats a nonsensical value as unset, matching every other [behavior] cap and reported by doctor; backup_prune_disabled is the off switch, matching the loop_detection_disabled and post_edit_diagnostics_disabled idiom rather than overloading the sign of a count. Co-Authored-By: Claude Opus 5 --- cmd/packetcode/backup_retention_test.go | 31 ++++++ cmd/packetcode/runtime.go | 21 ++++ docs/configuration.md | 9 ++ internal/config/config.go | 8 ++ internal/config/validate.go | 3 + internal/config/validate_test.go | 18 ++++ internal/session/backup.go | 83 +++++++++++++++ internal/session/backup_prune_test.go | 133 ++++++++++++++++++++++++ 8 files changed, 306 insertions(+) create mode 100644 cmd/packetcode/backup_retention_test.go create mode 100644 internal/session/backup_prune_test.go 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) +} From b1dd6fb9804fcc18b2ce1460f7de1216d537a6ce Mon Sep 17 00:00:00 2001 From: packetloss404 Date: Sat, 5 Sep 2026 10:16:48 -0500 Subject: [PATCH 4/6] ci: run the end-to-end smoke on all three runners The smoke job proved the binary starts. It did not exercise a single turn of the agent loop, so every behaviour the audit hardened -- credential resolution, the fail-closed approval path, the dotenv secret refusal, the compound-command deny floor -- was covered only by unit tests and by a script nobody ran. smoke.sh drives the real loop against the stdlib-only stub provider on loopback. It needs no credentials, no network and no new dependencies, and it exits nonzero when any assertion fails, so it gates rather than reports. bash is named explicitly because the Windows runner would otherwise pick PowerShell. The Makefile comment said to add smoke-e2e to `ci` once the script had run green on all three runners. This change is what produces that evidence, so the target moves at the same time and the merge is gated on the result. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 9 +++++++++ Makefile | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c6b9f..f6e89aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/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/ From 46d4d5e64e636f7d7d19c4fb7744e80085a366ae Mon Sep 17 00:00:00 2001 From: packetloss404 Date: Sat, 5 Sep 2026 10:19:41 -0500 Subject: [PATCH 5/6] docs: record what the day-31 backlog turned into Four of the eight items are done, one is partly answered, and three stay open. The list said what was predicted; it now says what the work actually involved, including the correction that CI moved to Go 1.26.8 rather than the 1.26.6 the entry named, because 1.26.8 was the current patch release when the change was made. K-02 and K-07 are left out of the branch on purpose and the entries say why: one decides who can build the project, the other moves a security boundary and needs its own review. The generator and the markdown carry the same list, so both move together. Co-Authored-By: Claude Opus 5 --- build_qa_workbook.py | 36 +++++++++++++++++++-------------- workbook.md | 48 ++++++++++++++++++++++++++++++-------------- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/build_qa_workbook.py b/build_qa_workbook.py index 6c70b04..7910c29 100644 --- a/build_qa_workbook.py +++ b/build_qa_workbook.py @@ -720,22 +720,28 @@ 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. ci.yml and release.yml pin Go 1.26.8, not the 1.26.6 this list " + "first named: 1.26.8 was current when the change was made, and is past the " + "version that clears the nine reachable stdlib advisories."), + ("K-02", "OPEN. Decide U-03. If yes, apply docs/audit/patches/P10b-*.patch and " + "update the Go version in README.md and HANDOFF.md. 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/workbook.md b/workbook.md index 712ce2d..bbb3159 100644 --- a/workbook.md +++ b/workbook.md @@ -319,18 +319,36 @@ 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. `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, and it is past the version the audit identified as clearing the nine + reachable stdlib advisories. No code change. +2. **K-02** Open. Decide U-03. If yes, apply `docs/audit/patches/P10b-*.patch` + and update the Go version in `README.md` and `HANDOFF.md`. Left out of the + branch deliberately: raising the language floor to 1.26 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. From ce80cd66ba1bd35ba2807b2b7fd121feda540f6a Mon Sep 17 00:00:00 2001 From: packetloss404 Date: Sat, 5 Sep 2026 10:29:10 -0500 Subject: [PATCH 6/6] docs: record what CI measured for K-01 and K-02 The first CI run on this branch turned two predictions into numbers. K-01: govulncheck reports 15 reachable vulnerabilities on main at Go 1.26.3, 8 of them in the standard library, and 7 on this branch at 1.26.8 with none in the standard library. The pin cleared 8 stdlib advisories, not the 9 the audit predicted. K-02: the 7 that remain are all x/crypto v0.43.0, every one reached through internal/computers/ssh_backend.go, so vulncheck fails on main and on every branch until the decision is made. It also splits in two, which the audit did not know: five are fixed in v0.52.0 behind a go 1.25.0 floor, and only the remaining two need v0.56.0 and a go 1.26.0 floor. A one-minor raise buys five of seven, which is a cheaper option than the prepared patch and worth having on the table before deciding. Co-Authored-By: Claude Opus 5 --- build_qa_workbook.py | 18 ++++++++++++------ workbook.md | 30 ++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/build_qa_workbook.py b/build_qa_workbook.py index 7910c29..bbde1a1 100644 --- a/build_qa_workbook.py +++ b/build_qa_workbook.py @@ -720,12 +720,18 @@ def bug_log() -> list: def day31() -> list: rows = [ - ("K-01", "DONE. ci.yml and release.yml pin Go 1.26.8, not the 1.26.6 this list " - "first named: 1.26.8 was current when the change was made, and is past the " - "version that clears the nine reachable stdlib advisories."), - ("K-02", "OPEN. Decide U-03. If yes, apply docs/audit/patches/P10b-*.patch and " - "update the Go version in README.md and HANDOFF.md. Raising the language " - "floor decides who can build the project."), + ("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."), diff --git a/workbook.md b/workbook.md index bbb3159..182c469 100644 --- a/workbook.md +++ b/workbook.md @@ -324,14 +324,28 @@ 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. `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, and it is past the version the audit identified as clearing the nine - reachable stdlib advisories. No code change. -2. **K-02** Open. Decide U-03. If yes, apply `docs/audit/patches/P10b-*.patch` - and update the Go version in `README.md` and `HANDOFF.md`. Left out of the - branch deliberately: raising the language floor to 1.26 decides who can - build the project, which is not an audit call to make. +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