Skip to content

fix(sdk): wire FSStorageProvider rootDir in resolveSquadState, fix teamRoot='.' sentinel - #1666

Closed
omercangumus wants to merge 1 commit into
bradygaster:devfrom
omercangumus:omercangumus/1555-fs-storage-rootdir
Closed

fix(sdk): wire FSStorageProvider rootDir in resolveSquadState, fix teamRoot='.' sentinel#1666
omercangumus wants to merge 1 commit into
bradygaster:devfrom
omercangumus:omercangumus/1555-fs-storage-rootdir

Conversation

@omercangumus

Copy link
Copy Markdown
Contributor

What

resolveSquadState() now constructs the local-backend FSStorageProvider with rootDir set, and resolveSquadPaths() treats config.teamRoot === '.' as local mode instead of remote mode.

Why

Fixes #1555

Two independent defects in the same path, both live on dev, both hit by squad_decide/squad_state_* (the MCP state tools wired up in state-mcp.ts):

  1. resolveSquadState() built the local backend's storage as new FSStorageProvider() — no rootDir. FSStorageProvider's traversal guard is if (!this.rootDir) return filePath; — an unset rootDir makes it a no-op, so it never validated anything. Every squad_state_*/squad_decide write just trusted whatever path got built upstream instead of confining it to .squad/.
  2. resolveSquadPaths()'s remote-mode check is if (config && config.teamRoot). squad externalize writes teamRoot: '.' into config.json as its "no separate team root" marker (externalize.ts:99) — but '.' is truthy, so this took the remote branch and computed teamDir = path.resolve(projectRoot, '.'), which is just projectRoot — one directory above .squad/, not .squad/ itself.

Once (1) is fixed on its own, (2) turns into a live path-traversal rejection instead of a silent misplacement: state-mcp.ts builds ToolRegistry(context.paths.teamDir, ...), every tool does path.join(this.squadRoot, key), and with teamDir one level too high, those paths land outside the now-enforced rootDirsquad_decide starts throwing Path traversal blocked: [team-root]\decisions\inbox\<file>.md. Fixing both together is what actually gets state writes back to .squad/decisions/inbox/ on an externalized project instead of either silently misplacing them or hard-failing.

How

  • resolution.ts resolveSquadState(): new FSStorageProvider(paths.teamDir) — matches what every local-backend caller already builds its paths against (e.g. state-mcp.ts passes context.paths.teamDir as ToolRegistry's squadRoot).
  • resolution.ts resolveSquadPaths(): if (config && config.teamRoot && config.teamRoot !== '.')'.' now falls through to the existing local-mode branch (teamDir: projectDir), which is what it was always meant to mean.

Testing

Added a #1555 regression: rootDir wiring + teamRoot=. sentinel block to test/state-backend.test.ts:

  • teamRoot: '.' (the externalize.ts marker) resolves mode: 'local' and teamDir === .squad/, not the parent directory
  • local-backend storage now rejects a path that escapes teamDir (Path traversal blocked) and still round-trips a path inside it — proves rootDir is actually wired, not just present
  • an end-to-end run through ToolRegistry built the same way state-mcp.ts builds it (new ToolRegistry(ctx.paths.teamDir, undefined, ctx.storage)), calling the real squad_decide tool handler, asserting the decision file lands in .squad/decisions/inbox/ and not one level up in the repo root
npx vitest run test/state-backend.test.ts
 Test Files  1 passed (1)
      Tests  147 passed (147)

Also ran the broader set of suites that touch resolveSquadPaths/resolveSquadState (dual-root resolver, effective-squad-dir, external-state, state-mcp, ensure-squad-path-dual, rc, copilot, export-import, extract, notes-promote, cast, consult, upgrade-state-backend, watch-notes-promote, mcp-spec-init, npm-registry-fallback):

Test Files  3 failed | 14 passed (17)
     Tests  3 failed | 343 passed (346)

All 3 failures (copilot.test.ts, extract.test.ts, rc.test.ts, each module exports run* function) are the same pre-existing cold dynamic-import 5s timeout as watch.test.ts hits on this box — confirmed by rerunning just those 3 files with --testTimeout=20000: all 58 tests pass. Not caused by this change.

cd packages/squad-sdk && npm run build then cd packages/squad-cli && npm run build (package-local, no root prebuild churn) — clean. tsc --noEmit clean on both packages. eslint on both changed files: 0 errors, resolution.ts's 43 warnings are the same pre-existing n/no-sync categories the file already had (0 new), state-backend.test.ts has 0.

Diff hygiene: git diff vs git diff -w identical, git diff --check clean — resolution.ts had drifted to CRLF on disk despite an LF-committed blob (same box quirk as usual), normalized back to LF and reverified against git show HEAD:<path> before staging.


⚠️ Quick Check

  • Changeset added: .changeset/1555-fs-storage-rootdir.md (patch @bradygaster/squad-sdk)

PR Readiness Checklist

Branch & Commit

  • Branch created from dev (upstream, fresh fetch)
  • Branch is up to date with dev
  • Verified diff contains only intended changes (1 source + 1 test file + changeset)
  • PR is not in draft mode
  • Commit history is clean (single commit)

Build & Test

  • npm run build (per-package) passes
  • npm test — targeted suite 147/147; broader 16-suite regression sweep 343/346 (3 pre-existing unrelated failures, proven)
  • tsc --noEmit clean
  • eslint — 0 errors, pre-existing warning categories only

Changeset

  • Changeset added (sdk patch)

Docs

  • N/A — internal correctness fix, no public API surface changed (resolveSquadState/resolveSquadPaths signatures unchanged)

Exports

  • N/A — no new modules or exports

Breaking Changes

None. Both changes make existing, documented semantics (rootDir confinement; teamRoot: '.' meaning "no separate team root") actually hold — no signature or behavior change for any caller that wasn't already relying on the broken behavior.

Waivers

None.

…amRoot='.' sentinel

Fixes bradygaster#1555

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

🟡 Impact Analysis — PR #1666

Risk tier: 🟡 MEDIUM

📊 Summary

Metric Count
Files changed 3
Files added 1
Files modified 2
Files deleted 0
Modules touched 3

🎯 Risk Factors

  • 3 files changed (≤5 → LOW)
  • 3 modules touched (2-4 → MEDIUM)

📦 Modules Affected

root (1 file)
  • .changeset/1555-fs-storage-rootdir.md
squad-sdk (1 file)
  • packages/squad-sdk/src/resolution.ts
tests (1 file)
  • test/state-backend.test.ts

This report is generated automatically for every PR. See #733 for details.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

🛫 PR Readiness Check

ℹ️ This comment updates on each push. Last checked: commit bbf97e1

PR Scope: 📦🔧 Mixed (product + infrastructure)

⚠️ 2 item(s) to address before review

Status Check Details
Single commit 1 commit — clean history
Not in draft Ready for review
Branch up to date Up to date with dev
Copilot review No Copilot review yet — it may still be processing
Changeset present Changeset file found
Scope clean No .squad/ or docs/proposals/ files
No merge conflicts No merge conflicts
Copilot threads resolved No Copilot review threads
CI passing 8 check(s) still running

Files Changed (3 files, +61 −3)

File +/−
.changeset/1555-fs-storage-rootdir.md +5 −0
packages/squad-sdk/src/resolution.ts +10 −2
test/state-backend.test.ts +46 −1

Total: +61 −3


This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.

@omercangumus

Copy link
Copy Markdown
Contributor Author

same CI failure as #1665 — pre-existing, unrelated: test/cli/upgrade.test.ts > ensureCastingDefaults scaffolds registry.json, policy.json, history.json expects casting_policy_version: '1.1', template's on 1.2 (#1643). that's #1661. this branch is 3 files (resolution.ts, its test, changeset), nothing touches casting templates. 7373/7374 tests pass.

@github-actions

Copy link
Copy Markdown
Contributor

👋 Friendly nudge — this PR has had no activity for 8 days.

What needs attention:

  • 🔴 1 CI check(s) failing: test. Fix these first.
  • 👀 No approving reviews yet. Request a review from a teammate.
  • ⬇️ 83 commits behind dev. Rebase to pick up latest changes.

If this PR is abandoned, please close it. If it's blocked on something external, leave a comment so the team knows.
This is an automated check that runs on weekdays. It won't nudge the same PR more than once per week.

@bradygaster

Copy link
Copy Markdown
Owner

Closing as a duplicate of #1695 — the diffs are byte-identical (same 3 files, same +61/-3 change to resolution.ts, state-backend.test.ts, and the 1555-fs-storage-rootdir changeset). #1695 is rebased on current dev and is the one being merged. Thanks @omercangumus — the fix itself is landing, just via the other PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FSStorageProvider constructed without rootDir in resolveSquadState() — state writes land in repo root instead of .squad/

2 participants