Skip to content

fix(sdk): keep a linked team's state on the team repo's git backend - #2351

Open
joslat wants to merge 1 commit into
github:devfrom
joslat:joslat/2320-linked-team-git-backend
Open

joslat wants to merge 1 commit into
github:devfrom
joslat:joslat/2320-linked-team-git-backend

Conversation

@joslat

@joslat joslat commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

What

A linked project on the orphan or two-layer state backend can use squad_state again. The git backend runs in the team repo, and the storage adapter is rooted at the team's .squad. If the team root isn't in a git repository, resolution fails with a message that says so.

Why

Closes #2320

resolveSquadState ran the git backend in the project repo and rooted StateBackendStorageAdapter at the project's .squad, while state-mcp roots every tool at the team's .squad. Every memory.*, squad_state_write and squad_state_read call therefore failed with "path is outside squadDir". As Tamir confirmed on the issue, the team repo owns .squad/, so its state belongs on the team repo's squad-state branch.

How

  • resolution.ts, resolveSquadState.
    • In remote (linked) mode with a configured orphan or two-layer backend, repoRoot becomes the team repo's git top level (git rev-parse --show-toplevel from the team's .squad, then from the team root).
    • If neither is in a git repository, it throws: "The linked team at … uses the 'orphan' state backend, which keeps state on a branch of the team repo, but the team root is not in a git repository. Initialize git in the team repo, or set "stateBackend" to "local" in …/config.json." Before, it failed on every call instead.
    • The adapter is rooted at paths.teamSquadDir, the same root the local backend (FSStorageProvider) and every tool already use. Outside linked mode teamSquadDir equals projectDir, so nothing changes there.
    • repoRoot in the returned context is now the repo the backend uses, which is what its doc comment describes. None of the five callers in the CLI reads it.
  • state-backend.ts. The config parsing in resolveStateBackend moves into configuredStateBackend(), so resolveSquadState can check the type before creating the backend. It's not exported from the package index, and resolveStateBackend behaves the same.

What stays the same:

  • Linked projects on the local backend: same behaviour.
  • Projects that aren't linked: same behaviour on every backend.
  • The backend type still comes from the project's config.json. I left that alone on purpose; there's a related question about it below.

Related question, not changed here: squad link writes only teamRoot and projectKey to the project's config.json, never stateBackend. If the team repo itself runs on orphan (set in the team's own config.json), a project linked with squad link therefore resolves to local and writes straight into the team repo's working tree instead of its squad-state branch. Should a linked project take the team's stateBackend when its own config doesn't set one? I can open a separate issue for that.

Testing

New tests in test/cli/state-mcp.test.ts, through a real createStateMcpSession from the project dir:

  • linked project with orphan and with two-layer:
    • squad_state_write, squad_state_read and memory.write succeed;
    • the team repo gets refs/heads/squad-state (for orphan, the written key is on it);
    • the project repo gets no squad-state branch and no notes;
    • the team's working tree stays clean;
  • linked project with orphan whose team root isn't in a git repository: resolveSquadState throws the message above. This one runs under the system temp dir, since anywhere inside the checkout git rev-parse finds the Squad repo itself.

Results:

  • With the source change reverted, and the SDK dist rebuilt from it (the CLI imports the SDK from dist), all 3 new tests fail. With the change, they pass.
  • cli/state-mcp and state-backend: 164 passed.
  • Every test file that touches state resolution, backends, linked teams or the memory store (81 files): 2,428 passed. The failures there were in 13 files. Run on their own, those 13 give the same 4 failures on dev and on this branch (a Windows symlink test in plugin-extensibility and render timeouts in repl-ux and hostile-integration); the rest were timeouts from running 81 files at once.
  • npm run lint: clean.

⚠️ Quick Check

  • If SDK/CLI source files changed: changeset added (.changeset/linked-team-git-backend.md, sdk patch)

PR Readiness Checklist

Branch & Commit

  • Branch created from dev (not main)
  • Branch is up to date with dev (c3f54c2)
  • Verified diff contains only intended changes (5 files)
  • PR is not in draft mode
  • Commit history is clean

Build & Test

  • npm run lint passes
  • Relevant tests pass
  • No file overlap with any open PR

Docs

  • features/state-backends.md: a short "Linked Projects" note (state stays with the team repo, and the error when the team root isn't in a git repository)

Breaking Changes

None for working setups. A linked project on a git backend whose team root isn't in a git repository now gets a clear error when it starts, instead of a failure on every state call.

Waivers

None.

In a linked project on the orphan or two-layer backend, resolveSquadState
ran the git backend in the project repo and rooted the storage adapter at
the project's .squad, while every squad_state tool builds paths under the
team's .squad. Each memory.*, squad_state_write and squad_state_read call
failed with "path is outside squadDir".

The team repo owns the state:

- in linked mode with a git backend, the backend runs in the team repo's
  git top level, and resolution fails with a clear message when the team
  root is not in a git repository;
- the adapter is rooted at the team's .squad, the root the local backend
  and the tools already use (unchanged outside linked mode);
- the config parsing in resolveStateBackend moves to
  configuredStateBackend() so the type can be checked first.

Closes github#2320
@github-actions

Copy link
Copy Markdown
Contributor

🟡 Impact Analysis — PR #2351

Risk tier: 🟡 MEDIUM

📊 Summary

Metric Count
Files changed 5
Files added 1
Files modified 4
Files deleted 0
Modules touched 4

🎯 Risk Factors

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

📦 Modules Affected

docs (1 file)
  • docs/src/content/docs/features/state-backends.md
root (1 file)
  • .changeset/linked-team-git-backend.md
squad-sdk (2 files)
  • packages/squad-sdk/src/resolution.ts
  • packages/squad-sdk/src/state-backend.ts
tests (1 file)
  • test/cli/state-mcp.test.ts

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

@github-actions

Copy link
Copy Markdown
Contributor

🛫 PR Readiness Check

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

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 15 check(s) still running

Files Changed (5 files, +127 −9)

File +/−
.changeset/linked-team-git-backend.md +5 −0
docs/src/content/docs/features/state-backends.md +2 −0
packages/squad-sdk/src/resolution.ts +37 −4
packages/squad-sdk/src/state-backend.ts +16 −3
test/cli/state-mcp.test.ts +67 −2

Total: +127 −9


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

@tamirdresher

Copy link
Copy Markdown
Collaborator

Thanks Jose, this is the right fix direction and the storage adapter part is solid (roots at teamSquadDir, normalizes Windows separators and case, rejects outside paths and dot segments, keeps the root delete guard). One boundary issue we need closed before merging, found in our security pass on 0ef7eb3:

In packages/squad-sdk/src/resolution.ts around line 975, gitTopLevel(paths.teamSquadDir) ?? gitTopLevel(paths.teamDir) accepts any git repository that merely contains the configured team directory. It does not prove that the top level it found is the linked team's own repo. Since teamRoot comes straight from .squad/config.json and can be an absolute path, contain .., or point through a symlink, a team directory with no .git of its own that sits under some other repository resolves successfully and the orphan or two layer backend then writes state refs into that outer repo. We reproduced it: a project linked to a non git team dir nested under another repo returned resolved true, backend orphan, with the outer ancestor selected. The current test only places the non git team under the temp dir where there is no ancestor repo, so it does not catch this.

What we need:

  1. Canonicalize the configured team target and the selected git top level (realpath, Windows case and drive handling).
  2. Require the git top level to equal the intended team repository root (worktrees included), not just contain it.
  3. Fail closed on absolute, traversal or symlink configs that resolve to a different repo boundary, unless an explicit trusted link record names that exact canonical root.
  4. Regression tests: non git team nested inside another repo, .. traversal, absolute path, symlink or junction escape, Windows drive and case normalization, and the root delete rejection still holding.

Not a question of intent, just a boundary we have to hold since this path decides where state gets written. Happy to review again as soon as it is pushed.

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

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

What needs attention:

  • 👀 No approving reviews yet. Request a review from a teammate.
  • ⬇️ 19 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.

This branch has not been deployed

No deployments
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.

squad_state fails on every call in a linked team with the orphan or two-layer backend

2 participants