From 7a86c6a126579d3ed43ecde202ced50e93fa461b Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Wed, 10 Jun 2026 10:09:15 -0400 Subject: [PATCH 1/2] chore: harden hook scripts, add Windows/shellcheck/actionlint CI gates Hooks (fail-open contract made uniform): - safety-check.sh, audit-trail.sh, slop-detect.sh: add set -euo pipefail with guarded jq extractions, matching rtk-rewrite.sh convention - post-validate.sh, subagent-stop.sh: guard previously-unguarded jq calls so malformed stdin degrades cleanly instead of killing the hook - slop-detect.sh: fix latent '|| echo 0' double-output bug (grep -c already prints 0 on no-match) that broke integer comparisons - audit-trail.sh: tolerate log-write failures (read-only fs) silently - stop-gate.sh: remove unused HAS_CONFIG (SC2034) CI: - new windows-latest job: build+test src engine and mcpb/launcher on PR (previously Windows breakage only surfaced at release time) - new lint-shell job: shellcheck hooks/*.sh + wrappers - new lint-actions job: actionlint via the author's docker image - new .github/dependabot.yml: weekly gomod (src, mcpb/launcher) + github-actions update groups Go (swallowed errors): - engine.go: surface GetSteps failure from final-report path unless a step error already takes precedence - status.go: cost-query failure renders 'unknown' like showAllSessions - workflow.go: check agent/budget flag lookup errors Verified: go build/vet clean, 441 tests pass, shellcheck clean, hooks_test.sh 75/75. --- .github/dependabot.yml | 25 ++++++++++++++++++++ .github/workflows/ci.yml | 51 ++++++++++++++++++++++++++++++++++++++++ hooks/audit-trail.sh | 27 +++++++++++++-------- hooks/post-validate.sh | 14 ++++++----- hooks/safety-check.sh | 13 ++++++---- hooks/slop-detect.sh | 32 +++++++++++++++---------- hooks/stop-gate.sh | 3 +-- hooks/subagent-stop.sh | 7 ++++-- src/cmd/status.go | 12 +++++++--- src/cmd/workflow.go | 10 ++++++-- src/engine/engine.go | 8 ++++++- 11 files changed, 159 insertions(+), 43 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2ade37c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: /src + schedule: + interval: weekly + groups: + go-deps: + patterns: ["*"] + + - package-ecosystem: gomod + directory: /mcpb/launcher + schedule: + interval: weekly + groups: + go-deps: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92ed7c2..0dc9d4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,57 @@ jobs: exit 1 fi + # Windows gets binaries at release time (engine cross-compile + the + # mcpb launcher), but nothing validated Windows builds on PR — breakage + # only surfaced during the release pipeline. This job fails fast. + build-and-test-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: src/go.mod + cache-dependency-path: src/go.sum + + - name: Build engine + working-directory: src + run: go build ./... + + - name: Test engine + working-directory: src + run: go test ./... -count=1 + + - name: Build MCPB launcher (native Windows) + working-directory: mcpb/launcher + run: go build ./... + + - name: Test MCPB launcher + working-directory: mcpb/launcher + run: go test ./... -count=1 + + # The fresh-install job shellchecks the shipped wrappers; this covers + # the hook layer, which runs on every tool call in every session. + lint-shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: shellcheck hooks and wrappers + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck -S warning hooks/*.sh bin/devkit bin/mcpb-build mcpb/server/devkit + + lint-actions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Run the tool author's image directly rather than a third-party + # wrapper action — one less link in the supply chain. + - name: actionlint + run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:latest -color + hook-smoke-tests: runs-on: ubuntu-latest steps: diff --git a/hooks/audit-trail.sh b/hooks/audit-trail.sh index 556fd6f..267f497 100755 --- a/hooks/audit-trail.sh +++ b/hooks/audit-trail.sh @@ -5,8 +5,12 @@ # Log location: .devkit/audit.log (gitignored) # Format: ISO-8601 timestamp | working directory | command -INPUT=$(cat) -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') +set -euo pipefail + +# Observational hook — jq parse failures degrade to "nothing to log", +# never to a hook error (fail-open contract, see rtk-rewrite.sh). +INPUT=$(cat || true) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true) # Skip if no command [ -z "$COMMAND" ] && exit 0 @@ -32,32 +36,35 @@ LOG_FILE="${LOG_DIR}/audit.log" # the submodule's own .gitignore is the right file to update. INIT_MARKER="${LOG_DIR}/.gitignore-installed" if [[ ! -f "$INIT_MARKER" ]]; then - mkdir -p "$LOG_DIR" + mkdir -p "$LOG_DIR" 2>/dev/null || true if ( set -C; : > "$INIT_MARKER" ) 2>/dev/null; then REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) if [[ -n "$REPO_ROOT" ]]; then GITIGNORE="${REPO_ROOT}/.gitignore" if [[ ! -f "$GITIGNORE" ]] || ! grep -qE '^\.devkit($|/)' "$GITIGNORE" 2>/dev/null; then if [[ -f "$GITIGNORE" ]] && [[ -n "$(tail -c 1 "$GITIGNORE" 2>/dev/null)" ]]; then - printf '\n' >> "$GITIGNORE" + printf '\n' >> "$GITIGNORE" 2>/dev/null || true fi - printf '.devkit/\n' >> "$GITIGNORE" + printf '.devkit/\n' >> "$GITIGNORE" 2>/dev/null || true fi fi fi fi -mkdir -p "$LOG_DIR" +# If the log directory can't be created there is nothing to log into — +# exit clean rather than erroring on every Bash call. +mkdir -p "$LOG_DIR" 2>/dev/null || exit 0 # Truncate command for log (first line only, max 500 chars) CMD_SHORT=$(echo "$COMMAND" | head -1 | cut -c1-500) -# Append timestamped entry -echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $(pwd) | ${CMD_SHORT}" >> "$LOG_FILE" +# Append timestamped entry. Tolerate write failures (read-only fs, +# permissions) — losing one audit line must not error every Bash call. +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $(pwd) | ${CMD_SHORT}" >> "$LOG_FILE" 2>/dev/null || true # Rotate if log exceeds 10k lines -if [ -f "$LOG_FILE" ] && [ "$(wc -l < "$LOG_FILE")" -gt 10000 ]; then - tail -5000 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE" +if [ -f "$LOG_FILE" ] && [ "$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)" -gt 10000 ]; then + { tail -5000 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"; } 2>/dev/null || true fi # Always allow — this hook is observational only diff --git a/hooks/post-validate.sh b/hooks/post-validate.sh index 5476a0e..a831ac9 100755 --- a/hooks/post-validate.sh +++ b/hooks/post-validate.sh @@ -11,11 +11,13 @@ set -euo pipefail -INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') -TOOL_OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty') -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') -CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty') +# jq failures on malformed stdin must not abort the hook under set -e — +# fail open (skip validation) rather than surface a hook error. +INPUT=$(cat || true) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true) +TOOL_OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty' 2>/dev/null || true) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) +CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null || true) # --- Bash: check for suppressed errors --- if [ "$TOOL_NAME" = "Bash" ]; then @@ -35,7 +37,7 @@ fi if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then CHECK_CONTENT="$CONTENT" if [ -z "$CHECK_CONTENT" ]; then - CHECK_CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty') + CHECK_CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty' 2>/dev/null || true) fi if [ -n "$CHECK_CONTENT" ]; then diff --git a/hooks/safety-check.sh b/hooks/safety-check.sh index 5776713..22027d9 100755 --- a/hooks/safety-check.sh +++ b/hooks/safety-check.sh @@ -2,10 +2,15 @@ # devkit safety hook — blocks or prompts on dangerous operations # Runs on PreToolUse for Bash, Edit, and Write tools -INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') +set -euo pipefail + +# jq failures on malformed stdin must not abort the hook — the contract +# is "fail open" (never wedge Claude Code on parse errors), matching the +# guard pattern in rtk-rewrite.sh / security-patterns.sh. +INPUT=$(cat || true) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) # --- Bash: dangerous commands --- if [ "$TOOL_NAME" = "Bash" ]; then diff --git a/hooks/slop-detect.sh b/hooks/slop-detect.sh index 9860991..56b7730 100755 --- a/hooks/slop-detect.sh +++ b/hooks/slop-detect.sh @@ -10,9 +10,12 @@ # PostToolUse hook schema: # { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "string" } } -INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') +set -euo pipefail + +# Fail open on malformed stdin — advisory hook, never a blocker. +INPUT=$(cat || true) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ] || exit 0 [ -z "$FILE_PATH" ] && exit 0 @@ -24,10 +27,13 @@ WARNINGS="" # For JS/TS/Python files, check if documentation blocks outweigh code if echo "$FILE_PATH" | grep -qE '\.(js|jsx|ts|tsx|mjs|cjs)$'; then # Count JSDoc/block comment lines vs code lines - DOC_LINES=$(grep -cE '^\s*(\*|/\*\*|///|\s*\*)' "$FILE_PATH" 2>/dev/null || echo 0) + # NB: grep -c prints "0" itself on no match (exiting 1), so the guard + # must be `|| true` — `|| echo 0` would yield "0\n0" and break the + # integer comparisons below. + DOC_LINES=$(grep -cE '^\s*(\*|/\*\*|///|\s*\*)' "$FILE_PATH" 2>/dev/null || true) TOTAL_LINES=$(wc -l < "$FILE_PATH" 2>/dev/null | tr -d ' ' || echo 1) - BLANK_LINES=$(grep -cE '^\s*$' "$FILE_PATH" 2>/dev/null || echo 0) - CODE_LINES=$(( TOTAL_LINES - DOC_LINES - BLANK_LINES )) + BLANK_LINES=$(grep -cE '^\s*$' "$FILE_PATH" 2>/dev/null || true) + CODE_LINES=$(( ${TOTAL_LINES:-1} - ${DOC_LINES:-0} - ${BLANK_LINES:-0} )) [ "$CODE_LINES" -lt 1 ] && CODE_LINES=1 if [ "$DOC_LINES" -gt 10 ] && [ "$DOC_LINES" -gt "$CODE_LINES" ]; then @@ -36,10 +42,10 @@ if echo "$FILE_PATH" | grep -qE '\.(js|jsx|ts|tsx|mjs|cjs)$'; then fi if echo "$FILE_PATH" | grep -qE '\.py$'; then - DOC_LINES=$(grep -cE '^\s*("""|'\'''\'''\''|#)' "$FILE_PATH" 2>/dev/null || echo 0) + DOC_LINES=$(grep -cE '^\s*("""|'\'''\'''\''|#)' "$FILE_PATH" 2>/dev/null || true) TOTAL_LINES=$(wc -l < "$FILE_PATH" 2>/dev/null | tr -d ' ' || echo 1) - BLANK_LINES=$(grep -cE '^\s*$' "$FILE_PATH" 2>/dev/null || echo 0) - CODE_LINES=$(( TOTAL_LINES - DOC_LINES - BLANK_LINES )) + BLANK_LINES=$(grep -cE '^\s*$' "$FILE_PATH" 2>/dev/null || true) + CODE_LINES=$(( ${TOTAL_LINES:-1} - ${DOC_LINES:-0} - ${BLANK_LINES:-0} )) [ "$CODE_LINES" -lt 1 ] && CODE_LINES=1 if [ "$DOC_LINES" -gt 10 ] && [ "$DOC_LINES" -gt "$CODE_LINES" ]; then @@ -68,10 +74,10 @@ fi # --- Excessive type annotations in JS (not TS) --- if echo "$FILE_PATH" | grep -qE '\.(js|jsx|mjs|cjs)$'; then - JSDOC_TYPES=$(grep -cE '@(param|returns|type|typedef)\s' "$FILE_PATH" 2>/dev/null || echo 0) - FUNCTIONS=$(grep -cE '(function\s|=>|async\s)' "$FILE_PATH" 2>/dev/null || echo 1) - [ "$FUNCTIONS" -lt 1 ] && FUNCTIONS=1 - RATIO=$(( JSDOC_TYPES / FUNCTIONS )) + JSDOC_TYPES=$(grep -cE '@(param|returns|type|typedef)\s' "$FILE_PATH" 2>/dev/null || true) + FUNCTIONS=$(grep -cE '(function\s|=>|async\s)' "$FILE_PATH" 2>/dev/null || true) + [ "${FUNCTIONS:-0}" -lt 1 ] 2>/dev/null && FUNCTIONS=1 + RATIO=$(( ${JSDOC_TYPES:-0} / ${FUNCTIONS:-1} )) if [ "$RATIO" -gt 4 ]; then WARNINGS="${WARNINGS}Excessive JSDoc type annotations in .js file (${JSDOC_TYPES} annotations for ${FUNCTIONS} functions) — consider using TypeScript instead. " fi diff --git a/hooks/stop-gate.sh b/hooks/stop-gate.sh index 352fa74..70e9d5c 100755 --- a/hooks/stop-gate.sh +++ b/hooks/stop-gate.sh @@ -57,7 +57,6 @@ HAS_GO=false HAS_TS=false HAS_RUST=false HAS_PYTHON=false -HAS_CONFIG=false HAS_SQL=false while IFS= read -r file; do @@ -66,7 +65,7 @@ while IFS= read -r file; do *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) HAS_TS=true ;; *.rs) HAS_RUST=true ;; *.py) HAS_PYTHON=true ;; - *.yml|*.yaml|*.json|*.toml|*.ini|*.env*) HAS_CONFIG=true ;; + *.yml|*.yaml|*.json|*.toml|*.ini|*.env*) ;; # config — excluded from domain count, needs no test evidence *.sql|*/migrations/*|*/migrate/*) HAS_SQL=true ;; esac done <<< "$CHANGED_FILES" diff --git a/hooks/subagent-stop.sh b/hooks/subagent-stop.sh index 18f89fe..fb124a3 100755 --- a/hooks/subagent-stop.sh +++ b/hooks/subagent-stop.sh @@ -9,8 +9,11 @@ set -euo pipefail -INPUT=$(cat) -AGENT_OUTPUT=$(echo "$INPUT" | jq -r '.agent_output // empty') +# SubagentStop must always emit a JSON verdict — a jq parse failure under +# set -e would kill the hook with no output. Degrade to empty (which the +# short-output check below converts into a block with a clear reason). +INPUT=$(cat || true) +AGENT_OUTPUT=$(echo "$INPUT" | jq -r '.agent_output // empty' 2>/dev/null || true) # If agent output is empty or very short, block — something went wrong if [ ${#AGENT_OUTPUT} -lt 20 ]; then diff --git a/src/cmd/status.go b/src/cmd/status.go index 5bb39c8..ed55d11 100644 --- a/src/cmd/status.go +++ b/src/cmd/status.go @@ -61,7 +61,13 @@ func showSessionDetail(id string) error { return fmt.Errorf("get steps: %w", err) } - totalCost, _ := db.SessionTotalCost(id) + // Degrade to "unknown" on error, matching showAllSessions — a cost + // query failure should not make session detail unviewable. + totalCost, costErr := db.SessionTotalCost(id) + costStr := fmt.Sprintf("$%.4f", totalCost) + if costErr != nil { + costStr = "unknown" + } fmt.Printf("Session: %s\n", session.ID) fmt.Printf("Workflow: %s\n", session.Workflow) @@ -81,9 +87,9 @@ func showSessionDetail(id string) error { fmt.Printf("Iterations: %d/%d\n", len(steps), session.MaxIterations) } if session.BudgetUSD > 0 { - fmt.Printf("Budget: $%.2f ($%.4f spent)\n", session.BudgetUSD, totalCost) + fmt.Printf("Budget: $%.2f (%s spent)\n", session.BudgetUSD, costStr) } - fmt.Printf("Total cost: $%.4f\n", totalCost) + fmt.Printf("Total cost: %s\n", costStr) if len(steps) > 0 { fmt.Printf("\n%-5s %-10s %-10s %-8s %-8s %s\n", "ITER", "AGENT", "STATUS", "EXIT", "COST", "SUMMARY") diff --git a/src/cmd/workflow.go b/src/cmd/workflow.go index d391381..23d3666 100644 --- a/src/cmd/workflow.go +++ b/src/cmd/workflow.go @@ -58,13 +58,19 @@ var workflowCmd = &cobra.Command{ return fmt.Errorf("parse workflow: %w", err) } - agentName, _ := cmd.Flags().GetString("agent") + agentName, err := cmd.Flags().GetString("agent") + if err != nil { + return fmt.Errorf("agent flag: %w", err) + } runner, err := resolveRunner(agentName) if err != nil { return err } - budget, _ := cmd.Flags().GetFloat64("budget") + budget, err := cmd.Flags().GetFloat64("budget") + if err != nil { + return fmt.Errorf("budget flag: %w", err) + } // CLI flag overrides YAML budget; fall back to YAML if flag not set if budget == 0 && wf.Budget.Limit > 0 { // Convert token budget to rough USD estimate ($0.01 per 1K tokens) diff --git a/src/engine/engine.go b/src/engine/engine.go index 39d0258..7af21ce 100644 --- a/src/engine/engine.go +++ b/src/engine/engine.go @@ -231,7 +231,13 @@ func (e *Engine) RunWorkflow(ctx context.Context, wf *Workflow, cfg RunConfig) ( e.db.UpdateSessionStatus(session.ID, "cancelled") } - allSteps, _ := e.db.GetSteps(session.ID) + // A failed read here still writes the report (with whatever steps we + // got) but must not be silent — surface it unless a step error is + // already being returned, which takes precedence. + allSteps, stepsErr := e.db.GetSteps(session.ID) + if stepsErr != nil && stepErr == nil { + stepErr = fmt.Errorf("loading steps for final report: %w", stepsErr) + } stopReason := "completed" if failed { stopReason = "failed" From b1a9658768c781e0e96357a69fd635e28c88c099 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Wed, 10 Jun 2026 10:16:09 -0400 Subject: [PATCH 2/2] fix(ci): green up the new Windows and actionlint gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first run of build-and-test-windows surfaced two pre-existing issues the gate exists to catch: - mcp tests leaked the server's open devkit.db handle; Windows cannot delete a TempDir containing an open file. setupTestServer and TestNewServer now t.Cleanup(srv.Close) (LIFO — runs before TempDir removal). - TestDBDirectoryPermissions and TestSessionFileMode assert Unix permission bits, which os.Stat does not report on Windows — skip there with rationale. actionlint flagged two pre-existing SC2235 subshell-style nits in release.yml's version-compare condition — converted to brace groups, semantically identical. --- .github/workflows/release.yml | 4 ++-- src/lib/db_test.go | 4 ++++ src/mcp/integration_test.go | 3 +++ src/mcp/server_test.go | 3 +++ src/mcp/tools_test.go | 4 ++++ 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b72e39d..c997684 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,8 +60,8 @@ jobs: TAG_PATCH=$(echo "$LATEST_TAG" | cut -d. -f3) if [ "$P_MAJOR" -gt "$TAG_MAJOR" ] 2>/dev/null || \ - ([ "$P_MAJOR" -eq "$TAG_MAJOR" ] && [ "$P_MINOR" -gt "$TAG_MINOR" ]) 2>/dev/null || \ - ([ "$P_MAJOR" -eq "$TAG_MAJOR" ] && [ "$P_MINOR" -eq "$TAG_MINOR" ] && [ "$P_PATCH" -gt "$TAG_PATCH" ]) 2>/dev/null; then + { [ "$P_MAJOR" -eq "$TAG_MAJOR" ] && [ "$P_MINOR" -gt "$TAG_MINOR" ]; } 2>/dev/null || \ + { [ "$P_MAJOR" -eq "$TAG_MAJOR" ] && [ "$P_MINOR" -eq "$TAG_MINOR" ] && [ "$P_PATCH" -gt "$TAG_PATCH" ]; } 2>/dev/null; then echo "Manual version bump detected: $LATEST_TAG → $PLUGIN_VERSION" echo "version=$PLUGIN_VERSION" >> "$GITHUB_OUTPUT" echo "bumped=manual" >> "$GITHUB_OUTPUT" diff --git a/src/lib/db_test.go b/src/lib/db_test.go index 6860bd4..eaac598 100644 --- a/src/lib/db_test.go +++ b/src/lib/db_test.go @@ -3,6 +3,7 @@ package lib import ( "os" "path/filepath" + "runtime" "testing" ) @@ -314,6 +315,9 @@ func TestEnsureGitignore_EntryMidFile(t *testing.T) { } func TestDBDirectoryPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits are not enforced on Windows — Stat reports 0777") + } dir := t.TempDir() dbDir := filepath.Join(dir, ".devkit") db, err := OpenDB(filepath.Join(dbDir, "devkit.db")) diff --git a/src/mcp/integration_test.go b/src/mcp/integration_test.go index 55ab70a..0d6b068 100644 --- a/src/mcp/integration_test.go +++ b/src/mcp/integration_test.go @@ -37,6 +37,9 @@ func setupTestServer(t *testing.T, workflowYAML string, principlesYAML string) ( if err != nil { t.Fatalf("NewServer: %v", err) } + // Close the DB before TempDir cleanup (LIFO: this runs first) — + // Windows cannot delete a directory containing an open file. + t.Cleanup(func() { srv.Close() }) return srv, dir } diff --git a/src/mcp/server_test.go b/src/mcp/server_test.go index 5e23c4f..3cb1b70 100644 --- a/src/mcp/server_test.go +++ b/src/mcp/server_test.go @@ -18,4 +18,7 @@ func TestNewServer(t *testing.T) { if srv == nil { t.Fatal("server is nil") } + // Close the DB before TempDir cleanup — Windows cannot delete a + // directory containing an open file. + t.Cleanup(func() { srv.Close() }) } diff --git a/src/mcp/tools_test.go b/src/mcp/tools_test.go index d99e24f..5132d31 100644 --- a/src/mcp/tools_test.go +++ b/src/mcp/tools_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "sync" "sync/atomic" @@ -1940,6 +1941,9 @@ func TestUpdateSessionJSONNoChange(t *testing.T) { // TestSessionFileMode verifies session.json is chmod 0600 (may contain // pasted secrets via workflow input/outputs). func TestSessionFileMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits are not enforced on Windows — Stat reports 0666") + } dir := t.TempDir() state := &lib.SessionState{ ID: "mode-test",