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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,13 +434,15 @@ Closing summary (24 PRs merged across the bulletproofing pass):
51. ~~Prompt file 0o644 → 0o600 (registry.go + tmux_runner.go)~~ — DONE (this commit). Prompt content carrying DSNs / WAVE_CONTEXT / acceptance criteria no longer readable by non-owner users on a shared dispatch host.
52. **YAML pipe/semicolon caveat** — DOCUMENTED. `ValidateConfigShellCommand` blocks command substitution but deliberately allows `|`, `;`, `&&` for legitimate multi-step QA commands. An operator who copy-pastes a malicious vxd.yaml can still chain `; curl evil` — this is a documented operator trust boundary, not an oversight. The blocklist is one of three layers; the others are: (a) commands run only when the operator explicitly invokes a requirement that triggers QA, (b) the dashboard auth gate prevents remote requirement submission.
53. ~~Errcheck cleanup + lint job blocking~~ — DONE. `golangci-lint` now reports **0 issues** across the project (`-default standard`, ~5 minute timeout). 44 silent event-store / projection-store `Append`/`Project` failures across `internal/cli` + `internal/engine` now log with full story-ID context; 15 dangerous `f.Write`/`db.Exec`/artifact-store sites now return wrapped errors; best-effort cleanup sites carry explicit `_ =` discards with one-line rationale; `.golangci.yml` excludes benign noise (`fmt.Fprint*` to stdout, `(io.Closer).Close`, HTTP body close, tabwriter Flush) and widens the test-file exemption to cover all linters. The `lint` job in `.github/workflows/ci.yml` lost its `continue-on-error: true` — it is now a blocking gate.
56. ~~CLI IO-seam refactor + coverage uplift~~ — DONE. Introduced `auditDirOverride` (improve.go) and `stateDirOverride` (autoresearch.go) package-level test seams so the production code path still reads CWD / `VXD_STATE_DIR` / `$HOME` but tests can swap directories with no `chdir`. Removed the older `withChdir` helper from `improve_commands_test.go` (chdir broke parallel-test isolation). New test files: `autoresearch_io_test.go` (loadConfigForAutoresearch / openEventStore / countWinsLosses), `backup_logs_test.go` (runBackup end-to-end + runLogs missing-file & stream paths), `db_helpers_test.go` (findDBByNameOrID, isTerminal, dbProviderFor, dockerProviderFor failure paths), `db_subcommands_test.go` (every `vxd db <sub>` CLI command drives its early-error path with devdb disabled), `devdb_provider_test.go` (newDevDBProvider covers null/docker/ghost/unknown branches), `resume_helpers_test.go` (pickRuntime priority + fallback, newDevDBLifecycle disabled/docker/bad-ghost paths, runDevDBOrphanRecovery no-op guard, runResume entry-point wiring), `extra_commands_test.go` (Execute, autoresearch stop/evolve/start/hypotheses/status), `req_helpers_test.go` (buildPlanningClient routing per provider). cli coverage: **58.3% → 72.9%** (+14.6 points). Falls short of 80% target; remaining gap requires fakes for docker/gh/claude CLI, tracked as a separate follow-up.

55. ~~Sanitize prompt-injection pattern expansion + Unicode normalisation~~ — DONE. `internal/sanitize/sanitize.go` grew from 10 to 56 substring patterns across 9 attack families (override/disregard, role/identity coercion, authority spoofing, output coercion, memory poisoning, action coercion, exfiltration, jailbreak labels, chat-template tags). `normaliseForInjectionMatch` now strips zero-width characters (`U+00AD`, `U+200B-U+200F`, `U+202A-U+202E`, `U+2060-U+206F`, `U+FEFF`) before scanning, defeating the `ig<ZWSP>nore previous instructions` bypass. New `MatchInjectionPattern` returns the canonical pattern that fired so post-mortems can distinguish a roleplay-coercion hit from a chat-template-tag hit. 56 positive cases + 6 negative + 6 zero-width-bypass + 3 whitespace-collapse + 3 `MatchInjectionPattern` tests pin the boundary.

54. ~~Coverage roadmap (3 of 4 packages over 80%)~~ — DONE. `internal/state` 78.2% → **86.8%**, `internal/config` 73.6% → **91.5%**, `internal/improve` 72.6% → **80.2%**, `internal/cli` 58.3% → **66.2%**. New test files: `projection_coverage_test.go` (8 zero-cov projection handlers), `autoresearch_validate_test.go` (full validation matrix), `opportunities_coverage_test.go` + `implementer_coverage_test.go` + `audit_coverage_test.go` + `feedback_weekly_coverage_test.go`, `autoresearch_helpers_test.go` + `improve_helpers_test.go` + `improve_commands_test.go` + `gc_helpers_test.go` + `logs_test.go`. cli stops at 66.2% because the remaining gap is structural — cobra `RunE` functions that read globals (`auditDir()` reads CWD, `defaultStateDir()` reads HOME); raising further needs an IO-seam refactor, not more test code.

### Still open (tracked, not security-blocking)

- Coverage roadmap (continued): `internal/cli` at 66.2% — the remaining gap to 80% is dominated by cobra `RunE` functions whose globals (`auditDir()` reads CWD, `defaultStateDir()` reads HOME) make tests structural — likely a refactor (extract IO seam) rather than more test code.
- Coverage roadmap (continued): `internal/cli` at 72.9% after the IO-seam refactor (was 66.2%, target 80%). Remaining gap is dominated by cobra `RunE` functions whose downstream calls require live Docker / `gh` / Claude CLI dependencies. Closing it needs either fakes that mock those subsystems or running tests inside a fully-provisioned environment — neither is a fits-in-one-PR refactor.
29. **Ephemeral DBs for agents** — COMPLETE as of 2026-05-22. SHIPPED:
- SP1+SP3 (foundation + Docker provider)
- SP4 (executor wiring, Lifecycle injection, orphan recovery, SLA-breach release, preflight checks)
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/autoresearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,15 @@ func openEventStore(cmd *cobra.Command) (state.EventStore, func(), error) {
return store, func() { _ = store.Close() }, nil
}

// stateDirOverride lets tests inject a known state directory without
// touching the environment. Production code path checks VXD_STATE_DIR
// then falls back to $HOME/.vxd when this is empty.
var stateDirOverride string

func defaultStateDir() string {
if stateDirOverride != "" {
return stateDirOverride
}
if v := os.Getenv("VXD_STATE_DIR"); v != "" {
return v
}
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/autoresearch_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,15 @@ func emptyConfig() config.Config {
return config.Config{}
}

func TestDefaultStateDir_HonoursOverride(t *testing.T) {
prev := stateDirOverride
stateDirOverride = "/tmp/test-state-override"
defer func() { stateDirOverride = prev }()
if got := defaultStateDir(); got != "/tmp/test-state-override" {
t.Errorf("override ignored: got %q", got)
}
}

func TestDefaultStateDir_FallsBackToHome(t *testing.T) {
prev, had := os.LookupEnv("VXD_STATE_DIR")
t.Cleanup(func() {
Expand Down
138 changes: 138 additions & 0 deletions internal/cli/autoresearch_io_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cli

import (
"os"
"path/filepath"
"testing"

"github.com/spf13/cobra"
"github.com/tzone85/vortex-dispatch/internal/autoresearch"
)

// withStateDir installs a fresh override for defaultStateDir for the
// duration of the test. Avoids touching $HOME or VXD_STATE_DIR.
func withStateDir(t *testing.T, dir string) {
t.Helper()
prev := stateDirOverride
stateDirOverride = dir
t.Cleanup(func() { stateDirOverride = prev })
}

// newCmdWithConfigFlag returns a bare cobra.Command with the
// --config flag wired so loadConfigForAutoresearch can read it.
func newCmdWithConfigFlag() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("config", "", "")
return cmd
}

func TestLoadConfigForAutoresearch_MissingFile(t *testing.T) {
cmd := newCmdWithConfigFlag()
if err := cmd.Flags().Set("config", "/no/such/vxd.yaml"); err != nil {
t.Fatalf("set flag: %v", err)
}
if _, err := loadConfigForAutoresearch(cmd); err == nil {
t.Error("expected error for missing config file")
}
}

func TestLoadConfigForAutoresearch_ParsesValidFile(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "vxd.yaml")
yaml := `workspace:
state_dir: /tmp/state
backend: sqlite
models:
tech_lead:
provider: anthropic
model: claude-opus-4-20250514
senior:
provider: anthropic
model: claude-sonnet-4-20250514
junior:
provider: anthropic
model: claude-haiku-4-5-20251001
`
if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil {
t.Fatalf("write cfg: %v", err)
}
cmd := newCmdWithConfigFlag()
if err := cmd.Flags().Set("config", cfgPath); err != nil {
t.Fatalf("set flag: %v", err)
}
cfg, err := loadConfigForAutoresearch(cmd)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Workspace.StateDir != "/tmp/state" {
t.Errorf("config not parsed; got StateDir=%q", cfg.Workspace.StateDir)
}
}

func TestLoadConfigForAutoresearch_InvalidYAML(t *testing.T) {
// Force a parse error — YAML that has a leading tab triggers the
// "found character that cannot start any token" diagnostic.
cfgPath := filepath.Join(t.TempDir(), "vxd.yaml")
if err := os.WriteFile(cfgPath, []byte("workspace:\n\t\tstate_dir: bad"), 0o600); err != nil {
t.Fatalf("write cfg: %v", err)
}
cmd := newCmdWithConfigFlag()
if err := cmd.Flags().Set("config", cfgPath); err != nil {
t.Fatalf("set flag: %v", err)
}
if _, err := loadConfigForAutoresearch(cmd); err == nil {
t.Error("expected parse error for invalid YAML")
}
}

func TestOpenEventStore_CreatesFileStore(t *testing.T) {
dir := t.TempDir()
withStateDir(t, dir)

store, closer, err := openEventStore(nil)
if err != nil {
t.Fatalf("open: %v", err)
}
defer closer()

if store == nil {
t.Fatal("expected non-nil event store")
}
// File should have been created.
if _, err := os.Stat(filepath.Join(dir, "events.jsonl")); err != nil {
t.Errorf("events.jsonl not created: %v", err)
}
}

func TestOpenEventStore_FailsForUnwritableParent(t *testing.T) {
// Point at a path whose parent is a regular file — FileStore.NewFileStore
// will fail trying to create the events.jsonl beneath it.
dir := t.TempDir()
blocker := filepath.Join(dir, "blocker")
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
withStateDir(t, filepath.Join(blocker, "sub"))

_, _, err := openEventStore(nil)
if err == nil {
t.Error("expected error opening event store under regular file")
}
}

func TestCountWinsLosses_EmptyBank(t *testing.T) {
// HypothesisBank with an empty event store returns zero/zero.
dir := t.TempDir()
withStateDir(t, dir)

store, closer, err := openEventStore(nil)
if err != nil {
t.Fatalf("open: %v", err)
}
defer closer()

bank := autoresearch.NewHypothesisBank(store)
wins, losses := countWinsLosses(bank, "any-repo")
if wins != 0 || losses != 0 {
t.Errorf("got wins=%d losses=%d, want 0/0 on empty bank", wins, losses)
}
}
129 changes: 129 additions & 0 deletions internal/cli/backup_logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cli

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

// seedVxdYaml writes a minimal config to t.TempDir() that points state at
// stateDir and disables all live integrations. Returns the config file
// path so tests can pass it via --config.
func seedVxdYaml(t *testing.T, stateDir string) string {
t.Helper()
cfgPath := filepath.Join(t.TempDir(), "vxd.yaml")
yaml := "workspace:\n" +
" state_dir: " + stateDir + "\n" +
" backend: sqlite\n" +
"models:\n" +
" tech_lead:\n" +
" provider: anthropic\n" +
" model: claude-opus-4-20250514\n" +
" senior:\n" +
" provider: anthropic\n" +
" model: claude-sonnet-4-20250514\n" +
" junior:\n" +
" provider: anthropic\n" +
" model: claude-haiku-4-5-20251001\n"
if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil {
t.Fatalf("write cfg: %v", err)
}
return cfgPath
}

func TestRunBackup_CreatesArchive(t *testing.T) {
stateDir := t.TempDir()
cfgPath := seedVxdYaml(t, stateDir)
outDir := t.TempDir()

cmd := newBackupCmd()
cmd.Flags().String("config", "", "")
cmd.Flags().String("project", "default", "")
if err := cmd.Flags().Set("config", cfgPath); err != nil {
t.Fatalf("set config: %v", err)
}
if err := cmd.Flags().Set("output", outDir); err != nil {
t.Fatalf("set output: %v", err)
}
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)

if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}

entries, _ := os.ReadDir(outDir)
found := false
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tar.gz") {
found = true
break
}
}
if !found {
t.Errorf("expected .tar.gz archive in %s, got: %+v", outDir, entries)
}
if !strings.Contains(out.String(), "Backup created") {
t.Errorf("expected 'Backup created' in stdout, got: %s", out.String())
}
}

func TestRunLogs_MissingLogFile(t *testing.T) {
stateDir := t.TempDir()
cfgPath := seedVxdYaml(t, stateDir)

cmd := newLogsCmd()
cmd.Flags().String("config", "", "")
cmd.Flags().String("project", "default", "")
if err := cmd.Flags().Set("config", cfgPath); err != nil {
t.Fatalf("set config: %v", err)
}
cmd.SetArgs([]string{"REQ-DOES-NOT-EXIST"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})

err := cmd.Execute()
if err == nil {
t.Error("expected error for missing log file")
}
if !strings.Contains(err.Error(), "no log file") {
t.Errorf("expected 'no log file' in error, got: %v", err)
}
}

func TestRunLogs_StreamsLogContents(t *testing.T) {
stateDir := t.TempDir()
cfgPath := seedVxdYaml(t, stateDir)

// Walk the loadStores flow: project dir is <stateDir>/projects/default.
projectDir := filepath.Join(stateDir, "projects", "default")
if err := os.MkdirAll(filepath.Join(projectDir, "logs"), 0o755); err != nil {
t.Fatalf("mkdir logs: %v", err)
}
logPath := filepath.Join(projectDir, "logs", "req-REQ-X.log")
body := "monitor: story 1 dispatched\nmonitor: story 1 merged\n"
if err := os.WriteFile(logPath, []byte(body), 0o600); err != nil {
t.Fatalf("write log: %v", err)
}

cmd := newLogsCmd()
cmd.Flags().String("config", "", "")
cmd.Flags().String("project", "default", "")
if err := cmd.Flags().Set("config", cfgPath); err != nil {
t.Fatalf("set config: %v", err)
}
cmd.SetArgs([]string{"REQ-X"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)

if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(out.String(), "story 1 merged") {
t.Errorf("log not streamed; got: %s", out.String())
}
}
Loading
Loading