diff --git a/.changeset/doctor-real-paths.md b/.changeset/doctor-real-paths.md new file mode 100644 index 000000000..f6d245eeb --- /dev/null +++ b/.changeset/doctor-real-paths.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +`squad doctor` compares real paths in the `squad_state` bridge check, so a team folder reached through a symlink (macOS `/var` is `/private/var`) no longer fails it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5796a55d7..41efa576a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o - `squad doctor` flags machine-specific launch commands and a missing launcher. Run `squad upgrade` once to fix an existing repository. - **Coordinators wait for a human's sign-off.** When `team.md` or `routing.md` says a human must sign off on work (a release, tag, publish, merge or deploy), the Claude, Kiro, OpenCode, Cursor, Codex and Antigravity coordinators no longer treat the request itself as the sign-off. They name the rule and the human, ask whether you are that human or have their sign-off, go ahead only on an explicit yes, and record it in the decisions inbox. In a non-interactive run they stop at the gate. Seen in a live test: asked to "cut the 1.0.0 release", the Claude coordinator used to bump, tag and run `npm publish` itself. - `squad_state` works when the team folder is reached through a symlink, such as macOS `/tmp` (really `/private/tmp`) or a linked team root. Squad stores the folder's resolved path but was given paths in the original spelling, so it refused reads and writes there with "Path traversal blocked". Paths that really leave the team folder, including through a symlink inside it, are still refused. +- `squad doctor` no longer fails the `squad_state` bridge check when the team folder has two spellings, such as macOS `/var` and `/private/var`. It compares the real paths. - `squad init --client claude` tells you to run `claude --agent squad --mcp-config .mcp.json`, not Copilot. With several hosts it gives one line per host (#2453). - `squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that finds the repository root (the nearest folder with `.git`) and runs only the `.squad/squad.mjs` there, so a launcher in a package's own `.squad/` or above the repository is never run. Run `squad upgrade` to rewrite an existing `.mcp.json`. - `.squad/squad.mjs` finds a `squad` on PATH. It used to skip every PATH entry, so a repository with only a global Squad CLI failed with "no Squad CLI found". Arguments reach the CLI unchanged on Windows (through npm's `.cmd` shims, never cmd.exe) and Linux, and a new CI job runs the launcher tests on both. diff --git a/packages/squad-cli/src/cli/commands/doctor.ts b/packages/squad-cli/src/cli/commands/doctor.ts index 2c74e029a..8df11ad03 100644 --- a/packages/squad-cli/src/cli/commands/doctor.ts +++ b/packages/squad-cli/src/cli/commands/doctor.ts @@ -11,7 +11,7 @@ */ import path from 'node:path'; -import { lstatSync, readFileSync, readdirSync, type Dirent } from 'node:fs'; +import { lstatSync, readFileSync, readdirSync, realpathSync, type Dirent } from 'node:fs'; import { exec, execFileSync } from 'node:child_process'; import { FSStorageProvider, resolveStateBackend, type StateBackendType } from '@bradygaster/squad-sdk'; import { detectHistoricalCastingSchema, readCastingRegistryPair } from '@bradygaster/squad-sdk/casting'; @@ -684,9 +684,12 @@ export async function checkSquadStateBridge(cwd: string): Promise { }; } const reportedTeamDir = path.resolve(teamLine); - const sameTeamDir = process.platform === 'win32' - ? reportedTeamDir.toLowerCase() === expectedTeamDir.toLowerCase() - : reportedTeamDir === expectedTeamDir; + // The bridge reports its team directory with symlinks resolved (macOS /var is /private/var), + // so compare real paths: two spellings of one directory are the same team. + const real = (p: string) => { try { return realpathSync(p); } catch { return p; } }; + const a = real(reportedTeamDir); + const b = real(expectedTeamDir); + const sameTeamDir = process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; if (!sameTeamDir) { return { name, diff --git a/test/cli/export-import.test.ts b/test/cli/export-import.test.ts index c130ccb58..762f7cff3 100644 --- a/test/cli/export-import.test.ts +++ b/test/cli/export-import.test.ts @@ -531,9 +531,9 @@ describe('CLI: export with externalized state (#1396)', () => { const origAppData = process.env['APPDATA']; const origXdgConfig = process.env['XDG_CONFIG_HOME']; const origHome = process.env['HOME']; - const externalBase = process.platform === 'darwin' - ? join(EXT_GLOBAL, 'Library', 'Application Support') - : EXT_GLOBAL; + // macOS honours XDG_CONFIG_HOME before ~/Library/Application Support, and the test harness sets it + // for every test, so redirect it on macOS too rather than HOME. + const externalBase = EXT_GLOBAL; const externalStateDir = join(externalBase, 'squad', 'projects', EXT_PROJECT_KEY); beforeEach(async () => { @@ -547,8 +547,6 @@ describe('CLI: export with externalized state (#1396)', () => { // Point resolveGlobalSquadPath() inside EXT_GLOBAL (not the real user dir) if (process.platform === 'win32') { process.env['APPDATA'] = EXT_GLOBAL; - } else if (process.platform === 'darwin') { - process.env['HOME'] = EXT_GLOBAL; } else { process.env['XDG_CONFIG_HOME'] = EXT_GLOBAL; } diff --git a/test/cli/upstream-source-hardening.test.ts b/test/cli/upstream-source-hardening.test.ts index 377288063..b7ce92aa4 100644 --- a/test/cli/upstream-source-hardening.test.ts +++ b/test/cli/upstream-source-hardening.test.ts @@ -27,7 +27,8 @@ let prevCwd: string; const upstreamJson = () => path.join(tmp, '.squad', 'upstream.json'); beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upstream-hardening-')); + // Resolved (macOS /var is /private/var): the CLI clones into the resolved path. + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upstream-hardening-'))); fs.mkdirSync(path.join(tmp, '.squad'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.squad', 'team.md'), '# t\n'); prevCwd = process.cwd(); diff --git a/test/profile-fs-guard.test.ts b/test/profile-fs-guard.test.ts index 0a27f2489..30b6aeaf6 100644 --- a/test/profile-fs-guard.test.ts +++ b/test/profile-fs-guard.test.ts @@ -22,7 +22,8 @@ describe('fail-closed test profile guard', () => { expect(roots.length).toBeGreaterThan(0); const norm = (p: string) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p)); expect(roots).toContain(norm(realHomeSquad)); - expect(norm(process.env['SQUAD_HOME']!).startsWith(norm(os.tmpdir()).replace(/[A-Z]:/, (m) => m.toLowerCase()).slice(0, 3)), 'SQUAD_HOME must be under the temp dir').toBe(true); + // The guard creates its root under the resolved temp dir (macOS /var is /private/var). + expect(norm(process.env['SQUAD_HOME']!).startsWith(norm(fs.realpathSync(os.tmpdir())).replace(/[A-Z]:/, (m) => m.toLowerCase()).slice(0, 3)), 'SQUAD_HOME must be under the temp dir').toBe(true); expect(roots.some((r) => norm(process.env['SQUAD_HOME']!).startsWith(r))).toBe(false); });