* test(#3333): fold the runtime & install surface fix-* cluster — Wave 1 Folds 11 legacy tests/fix-*.test.cjs regression files into their module's main test suite: 6 folded into existing suites (host-integration-descriptors, effort-surface-axis, trae-imperative-reference, hermes-skills-migration, gsd-agent-isolation-guard), 5 renamed to become the module's sole suite (cursor-hook-workspace-roots, cursor-subagent-isolation, lint-compiled-artifact-sync, hooks-commonjs-marker, shared-hooks-dir-resolution). All 195 test() blocks preserved with zero drops; lint-test-file-count.cjs and eslint remain clean. No production code changed. Wave 1 of 7 in #3315 (H3 of epic #3053). * test(#3333): replace try/finally with t.after() in isolation-guard tests CONTRIBUTING.md bans try/finally inside test bodies (masks failures, not an approved pattern). The fold in the prior commit carried 27 instances forward verbatim from the deleted fix-3045-dispatch-isolation-resolver.test.cjs into an otherwise-clean file. Converts each to the approved per-test t.after() cleanup pattern — same cleanup call, registered instead of finally-wrapped. No assertion, fixture, or test-name change; test( count unchanged at 50. Found by the Standards review pass on Wave 1 (#3333, H3 of epic #3053). * fix(#3333): restore raw NUL byte mangled by the fold in hermes-skills-migration.test.cjs The prior fold commit copied fix-2284-hermes-agent-delegate-task-projection's "collision-robust" test via a text-based Read/Write pipeline, which silently turned a raw NUL byte (0x00) embedded in two string literals into a regular space character. That corrupted the test's actual purpose (proving a NUL byte survives a string-rewrite operation untouched) and produced a genuine gsd-test failure: `24 !== 1` for `out.split(' ').length`, because splitting on a space finds every space in the sentence instead of the single NUL byte the test meant to isolate. Root-caused by diffing the raw bytes (via `git cat-file blob` + `cat -v`) between the pre-fold source and the folded target — confirmed exactly two bytes differ. Restored via a byte-precise patch (latin1 round-trip) touching only those two lines; test( count and every other byte unchanged. * fix(#3333): use \x00 escape sequence instead of a raw NUL byte in test fixture The prior commit restored a byte-exact raw NUL byte matching the original fix-2284 source, and the production function (applyClaudeCodeBrandSwap) was confirmed correct in a standalone repro. But the same raw byte still failed through gsd-test's remote pipeline. Root cause is upstream of gsd-core: some step in that transfer path does not carry a raw 0x00 byte through untouched. A raw embedded NUL byte was never necessary here — `\x00` as a 4-character escape sequence in the source text produces the identical runtime character (U+0000) without ever putting a raw byte in the tracked file, sidestepping any byte-oriented transfer step. Applied at both call sites (the fixture string and the split() delimiter). No behavior change; test( count unchanged at 76. * fix(#3333): harden copyWithPathReplacement against a source file vanishing mid-copy (TOCTOU) Surfaced by this PR's own gsd-test run: tests/install-minimal-hooks.test.cjs and tests/opencode-command-dir-plural.test.cjs intermittently crashed with ENOENT reading gsd-core/workflows/zzz-e5-drift-fixture.md. Root cause is unrelated to test-file consolidation — tests/planning-prompt-drift.test.cjs writes that fixture directly into the real, shared gsd-core/workflows/ tree (main() hardcodes its scan root to the real repo) and deletes it in t.after(); copyWithPathReplacement's readdirSync-then-read loop has no protection against the listed file vanishing before it gets there, so a concurrently-running install path can crash entirely on what is otherwise a completely benign race. Fixed by skipping (not crashing on) a listed entry that no longer exists by the time the loop reaches it. Added a regression test that deterministically reproduces the race (readdirSync snapshot still lists the file; it is deleted immediately after) and proves both outcomes: no throw, and the vanished entry's destination is never partially written. Per CLAUDE.md's no-defer rule, a defect surfaced while verifying this PR is fixed inline rather than deferred — this overrides one-concern-per-PR. * fix(#3333): fix third NUL-byte-mangled occurrence missed by prior fix passes The fold originally mangled three raw-NUL-byte occurrences to spaces, not two — the earlier byte-restore and escape-sequence commits both only targeted the fixture string and the split() delimiter, missing out.includes('[ ]') a few lines below (should read out.includes('[\x00]')). A remote gsd-test run kept failing on this exact assertion even after both prior fixes, which is what surfaced the miss. Verified via a standalone repro using the file's real (not retyped) fixture content: all six assertions in the collision-robust test now pass. Zero raw NUL bytes remain in the file; test( count unchanged at 76. * chore(#3333): add changeset for the copyWithPathReplacement TOCTOU fix Fixed-type fragment for the production defect fixed inline in this PR (bin/install.js's copyWithPathReplacement). Exempt from docs/ requirements per CONTRIBUTING.md (only Added/Changed/Deprecated/Removed require it). * chore(#3333): backfill changeset PR number (pr:0 -> pr:3341) --------- Co-authored-by: sim <sim@local>
877 lines
41 KiB
JavaScript
877 lines
41 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* gsd-agent-isolation-guard.js — Agent-dispatch isolation guard (#3045)
|
|
*
|
|
* Seam: hooks/gsd-agent-isolation-guard.js (PreToolUse hook, spawned with a
|
|
* JSON payload on stdin, exactly as every runtime bus invokes it).
|
|
*
|
|
* Defect: `gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md`
|
|
* resolves dispatch isolation correctly, then relies on PROSE ("substitute
|
|
* $HARNESS_FLAG's value... on Claude Code it is literally isolation=\"worktree\"")
|
|
* to get it into the model-authored `Agent()` call. Nothing verified the
|
|
* substitution happened, so an executor could silently dispatch into the
|
|
* user's primary checkout. This hook enforces the invariant structurally.
|
|
*
|
|
* Matrix source: .gsd/bug/fix-3045-agent-dispatch-isolation-guard/50-test-matrix.md
|
|
* Part 1, rows 1-12. Every row below is annotated with its row number.
|
|
*
|
|
* Two implementation notes that diverge from a literal reading of the design
|
|
* (both intentional, both explained where they're tested):
|
|
*
|
|
* - Rows 8 and 12 ("config unreadable" / "config read times out") collapse
|
|
* to the SAME code path in the real implementation: resolveIsolationState
|
|
* resolves entirely via synchronous, in-process fs reads and require()
|
|
* calls — no subprocess is spawned (the guard prefers reading config
|
|
* directly, per the design's own preference), so there is no literal
|
|
* wall-clock timeout to simulate. Both rows are exercised here via two
|
|
* DIFFERENT real, deterministic, cross-platform-safe failure conditions
|
|
* that both land in the guard's single "cannot verify" catch: row 8 uses
|
|
* `.planning/config.json` being a DIRECTORY (fs.readFileSync → EISDIR),
|
|
* row 12 uses a syntactically invalid config.json (JSON.parse throws).
|
|
* Neither is a chmod/permission trick (CLAUDE.md's cross-platform IO
|
|
* injection rule) — both are real, deterministic file-type/content
|
|
* conditions that behave identically on macOS/Linux/Windows.
|
|
*
|
|
* - Runtime selection for rows 6/7 (orchestrator-worktree / none) uses the
|
|
* real capability-registry.cjs shipped alongside the hook, selected via
|
|
* GSD_RUNTIME (the same precedence resolveIsolationState implements):
|
|
* codex → orchestrator-worktree, windsurf → none. No fixture/mock
|
|
* registry is substituted — this is the real hook reading its real
|
|
* sibling data file, per the "drive the real hook entry point" mandate.
|
|
*/
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fc = require('./helpers/fast-check-setup.cjs');
|
|
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
const { toLegacyResult, gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const { createTempDir, createTempProject, runGsdTools, cleanup } = require('./helpers.cjs');
|
|
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS, readSentinel } = require('../hooks/lib/isolation-sentinel.js');
|
|
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
|
|
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js');
|
|
|
|
/**
|
|
* Write a #3045 dispatch-isolation sentinel under `dir` (mirrors what
|
|
* `gsd-tools.cjs record-dispatch-isolation` writes). `writtenAt` defaults to
|
|
* "now" (fresh); pass an explicit past timestamp to construct a stale one.
|
|
*/
|
|
function writeSentinel(dir, { isolation, harnessFlag = null, phase = null, plan = null, writtenAt = Date.now() }) {
|
|
const p = path.join(dir, SENTINEL_RELATIVE_PATH);
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(p, JSON.stringify({ isolation, harness_flag: harnessFlag, phase, plan, written_at: writtenAt }));
|
|
}
|
|
|
|
/**
|
|
* Run the hook with a given payload against a given cwd.
|
|
* GSD_RUNTIME is deleted by default so ambient environment can never leak a
|
|
* runtime override into a test that expects the config.json `runtime` key
|
|
* (or the 'claude' default) to be used instead.
|
|
*/
|
|
function runHook(payload, cwd, extraEnv = {}) {
|
|
const env = { ...process.env };
|
|
delete env.GSD_RUNTIME;
|
|
Object.assign(env, extraEnv);
|
|
// Production code resolves the home directory via `os.homedir()` (correct,
|
|
// cross-platform), which on Windows reads `USERPROFILE`, not `HOME` —
|
|
// `os.homedir()` never honors `HOME` there. Tests below override `HOME` to
|
|
// redirect `os.homedir()` hermetically; mirror the override onto
|
|
// `USERPROFILE` too so that redirection actually takes effect on Windows
|
|
// instead of silently leaking the real CI runner's profile directory.
|
|
if ('HOME' in extraEnv) env.USERPROFILE = extraEnv.HOME;
|
|
const r = runHookSeam(HOOK_PATH, [], {
|
|
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
|
cwd,
|
|
env,
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
});
|
|
return toLegacyResult(r);
|
|
}
|
|
|
|
/**
|
|
* #3045 follow-up (folded from tests/fix-3045-dispatch-isolation-resolver.test.cjs,
|
|
* #3333 wave 1): sentinel-file path/read helpers for the WRITE-side coverage
|
|
* below, which drives the real `gsd-tools.cjs query dispatch-isolation` CLI
|
|
* (routeDispatchIsolation) rather than the guard hook.
|
|
*/
|
|
function sentinelFile(dir) {
|
|
return path.join(dir, SENTINEL_RELATIVE_PATH);
|
|
}
|
|
|
|
function readSentinelRaw(dir) {
|
|
return JSON.parse(fs.readFileSync(sentinelFile(dir), 'utf-8'));
|
|
}
|
|
|
|
function agentPayload(overrides = {}) {
|
|
return {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Agent',
|
|
tool_input: { subagent_type: 'gsd-executor', ...(overrides.tool_input || {}) },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mkProject(prefix) {
|
|
const dir = createTempDir(prefix);
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function writeConfig(dir, content) {
|
|
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), content);
|
|
}
|
|
|
|
describe('gsd-agent-isolation-guard.js: applicability matrix (#3045)', () => {
|
|
let harnessProject; // GSD project resolving to harness-worktree (claude)
|
|
let orchestratorProject; // resolves to orchestrator-worktree (codex)
|
|
let noneProject; // resolves to none (windsurf)
|
|
let noGsdProject; // not a GSD project at all
|
|
let unreadableConfigProject; // config.json is a directory (EISDIR)
|
|
let corruptConfigProject; // config.json is invalid JSON
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-aig-harness-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
|
|
orchestratorProject = mkProject('gsd-aig-orch-');
|
|
writeConfig(orchestratorProject, JSON.stringify({}));
|
|
|
|
noneProject = mkProject('gsd-aig-none-');
|
|
writeConfig(noneProject, JSON.stringify({}));
|
|
|
|
noGsdProject = createTempDir('gsd-aig-nogsd-');
|
|
|
|
unreadableConfigProject = mkProject('gsd-aig-unreadable-');
|
|
// #3050 lesson: force a genuine, cross-platform-safe read failure by
|
|
// making the config path a DIRECTORY instead of a file — fs.readFileSync
|
|
// throws EISDIR deterministically on macOS/Linux/Windows. NOT a
|
|
// chmod/permission trick (CLAUDE.md's IO-failure-injection rule).
|
|
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- removing a single fixture FILE (not a temp dir teardown) to replace it with a directory; helpers.cleanup() tears down whole temp dirs and isn't the right tool here
|
|
fs.rmSync(path.join(unreadableConfigProject, '.planning', 'config.json'), { force: true });
|
|
fs.mkdirSync(path.join(unreadableConfigProject, '.planning', 'config.json'));
|
|
|
|
corruptConfigProject = mkProject('gsd-aig-corrupt-');
|
|
writeConfig(corruptConfigProject, '{ this is not valid json');
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
cleanup(orchestratorProject);
|
|
cleanup(noneProject);
|
|
cleanup(noGsdProject);
|
|
cleanup(unreadableConfigProject);
|
|
cleanup(corruptConfigProject);
|
|
});
|
|
|
|
test('row 1: absent isolation param, harness-worktree, GSD project -> DENY', () => {
|
|
const r = runHook(agentPayload(), harnessProject);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.equal(out.decision, 'block');
|
|
assert.match(out.reason, /harness-worktree/);
|
|
assert.match(out.reason, /isolation="worktree"/);
|
|
assert.equal(r.stderr, out.reason, 'stderr must carry the same reason (Kimi reads stderr on exit 2)');
|
|
});
|
|
|
|
test('row 2: isolation="worktree" present -> allow', () => {
|
|
const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } }), harnessProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('row 3: isolation="" (empty) -> DENY', () => {
|
|
const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: '' } }), harnessProject);
|
|
assert.equal(r.status, 2);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('row 4: isolation="none" -> DENY', () => {
|
|
const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'none' } }), harnessProject);
|
|
assert.equal(r.status, 2);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('row 5: subagent_type=gsd-code-reviewer (not an executor) -> allow', () => {
|
|
const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-code-reviewer' } }), harnessProject);
|
|
assert.equal(r.status, 0);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('row 6: resolved mode orchestrator-worktree -> allow (different path)', () => {
|
|
const r = runHook(agentPayload(), orchestratorProject, { GSD_RUNTIME: 'codex' });
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('row 7: resolved mode none -> allow', () => {
|
|
const r = runHook(agentPayload(), noneProject, { GSD_RUNTIME: 'windsurf' });
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('row 8: config unreadable (EISDIR) + GSD project present -> DENY, distinct reason', () => {
|
|
const r = runHook(agentPayload(), unreadableConfigProject);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.equal(out.decision, 'block');
|
|
assert.match(out.reason, /could not read or resolve/i);
|
|
assert.match(out.reason, /#3050/);
|
|
});
|
|
|
|
test('row 9: no GSD project (.planning/config.json absent) -> allow, inert', () => {
|
|
const r = runHook(agentPayload(), noGsdProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('row 10: wrong tool (Bash) -> allow', () => {
|
|
const r = runHook({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo hi' } }, harnessProject);
|
|
assert.equal(r.status, 0);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('row 11a: subagent_type absent -> allow, must not throw', () => {
|
|
const r = runHook(agentPayload({ tool_input: {} }), harnessProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(r.stderr, '', 'must not crash or log a stack trace');
|
|
});
|
|
|
|
test('row 11b: subagent_type malformed (non-string, e.g. array) -> allow, must not throw', () => {
|
|
const r = runHook(agentPayload({ tool_input: { subagent_type: ['gsd-executor'] } }), harnessProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(r.stderr, '');
|
|
});
|
|
|
|
test('row 11c: tool_input entirely absent -> allow, must not throw', () => {
|
|
const r = runHook({ hook_event_name: 'PreToolUse', tool_name: 'Agent' }, harnessProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('row 11d: payload is not JSON at all -> allow, must not throw', () => {
|
|
const r = runHook('not json {{{', harnessProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('row 11e: payload is JSON null -> allow, must not throw', () => {
|
|
const r = runHook('null', harnessProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('row 12: config read fails via corrupt JSON (stands in for "times out" — see file header) -> DENY', () => {
|
|
const r = runHook(agentPayload(), corruptConfigProject);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.equal(out.decision, 'block');
|
|
assert.match(out.reason, /could not read or resolve/i);
|
|
});
|
|
|
|
test('reason names the exact parameter to add (self-correction requirement)', () => {
|
|
const r = runHook(agentPayload(), harnessProject);
|
|
assert.equal(r.status, 2);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.match(out.reason, /Add isolation="worktree" to the Agent\(\) call/);
|
|
});
|
|
});
|
|
|
|
describe('gsd-agent-isolation-guard.js: property — deny iff isolation param != "worktree" (harness-worktree project)', () => {
|
|
let harnessProject;
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-aig-prop-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
});
|
|
|
|
test('for any string value, dispatch is blocked unless the value is exactly "worktree"', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.string(),
|
|
(isolationValue) => {
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: isolationValue } }),
|
|
harnessProject
|
|
);
|
|
const expectBlocked = isolationValue !== 'worktree';
|
|
const actualBlocked = r.status === 2;
|
|
assert.equal(
|
|
actualBlocked, expectBlocked,
|
|
`isolation=${JSON.stringify(isolationValue)} expected ${expectBlocked ? 'blocked' : 'allowed'}, got status ${r.status}, stdout: ${r.stdout}`
|
|
);
|
|
}
|
|
),
|
|
{ numRuns: 30 } // each sample spawns the hook process — bound the cost
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('gsd-agent-isolation-guard.js: #3045 BLOCKER regression — sentinel is authoritative over registry capability', () => {
|
|
// These rows pin the exact defect the isolated code review flagged as a
|
|
// BLOCKER: the guard used to key enforcement on the REGISTRY's
|
|
// dispatch.isolation (a host CAPABILITY — "this host CAN isolate"), not
|
|
// the workflow's resolved per-dispatch ISOLATION ("this dispatch SHOULD be
|
|
// isolated"). Sequential ISOLATION=none legitimately happens even on a
|
|
// harness-worktree-capable host. Every row below uses `harnessProject`
|
|
// (registry resolves to harness-worktree for runtime 'claude') so a FAIL
|
|
// here proves the sentinel is actually consulted, not merely coincidental
|
|
// with what the registry alone would already allow.
|
|
let harnessProject;
|
|
let useWorktreesFalseProject;
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-aig-sentinel-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
|
|
useWorktreesFalseProject = mkProject('gsd-aig-uwf-');
|
|
writeConfig(useWorktreesFalseProject, JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: false } }));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
cleanup(useWorktreesFalseProject);
|
|
});
|
|
|
|
test('sentinel says isolation=none -> ALLOW even though registry resolves harness-worktree (the BLOCKER)', (t) => {
|
|
writeSentinel(harnessProject, { isolation: 'none' });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(agentPayload(), harnessProject); // no isolation param on the dispatch
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('sentinel says isolation=orchestrator-worktree -> ALLOW', (t) => {
|
|
writeSentinel(harnessProject, { isolation: 'orchestrator-worktree' });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(agentPayload(), harnessProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('sentinel says isolation=harness-worktree + dispatch missing the flag -> DENY', (t) => {
|
|
writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(agentPayload(), harnessProject);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('sentinel says isolation=harness-worktree + dispatch carries the flag -> ALLOW', (t) => {
|
|
writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } }),
|
|
harnessProject
|
|
);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('STALE sentinel (older than SENTINEL_STALE_MS) is ignored -> falls back to registry (DENY, harness-worktree still applies)', (t) => {
|
|
// The stale sentinel LIES (says none) — proving the fallback re-derives
|
|
// from the registry instead of trusting it is exactly the point.
|
|
writeSentinel(harnessProject, { isolation: 'none', writtenAt: Date.now() - (SENTINEL_STALE_MS + 60000) });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(agentPayload(), harnessProject);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('MALFORMED sentinel (invalid JSON) is treated as stale, never fatal -> falls back to registry (DENY)', (t) => {
|
|
const sentinelPath = path.join(harnessProject, SENTINEL_RELATIVE_PATH);
|
|
fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
|
|
fs.writeFileSync(sentinelPath, '{ this is not valid json');
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(agentPayload(), harnessProject);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
assert.equal(r.stderr.length > 0, true, 'must not crash — a clean block reason, not a stack trace');
|
|
});
|
|
|
|
test('no sentinel + workflow.use_worktrees=false -> ALLOW (project-level opt-out, case (a) from the BLOCKER)', () => {
|
|
const r = runHook(agentPayload(), useWorktreesFalseProject);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('no sentinel + workflow.use_worktrees absent + registry harness-worktree -> DENY (conservative fallback still enforces)', () => {
|
|
const r = runHook(agentPayload(), harnessProject);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('tool_name="Task" behaves identically to "Agent" (#3045 MAJOR 1)', () => {
|
|
const r = runHook(
|
|
{ hook_event_name: 'PreToolUse', tool_name: 'Task', tool_input: { subagent_type: 'gsd-executor' } },
|
|
harnessProject
|
|
);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('tool_name="Task" with isolation="worktree" present -> allow, same as "Agent"', () => {
|
|
const r = runHook(
|
|
{ hook_event_name: 'PreToolUse', tool_name: 'Task', tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } },
|
|
harnessProject
|
|
);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
|
});
|
|
});
|
|
|
|
describe('gsd-agent-isolation-guard.js: #3045 MAJOR 2 — undeterminable runtime does not demand the Claude kwarg', () => {
|
|
let unconfiguredProject;
|
|
|
|
before(() => {
|
|
unconfiguredProject = mkProject('gsd-aig-unconfigured-');
|
|
// No `runtime` key at all — mirrors gsd-core/templates/config.json,
|
|
// which ships every new project's scaffold WITHOUT one. GSD_RUNTIME is
|
|
// deleted by runHook(), so this project has NO explicit runtime signal.
|
|
writeConfig(unconfiguredProject, JSON.stringify({}));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(unconfiguredProject);
|
|
});
|
|
|
|
test('no GSD_RUNTIME override, no config.json runtime key, no ~/.gsd/defaults.json runtime -> ALLOW (cannot-determine degrades to inert, not a guessed "claude" demand)', () => {
|
|
// #3045 BLOCKER 2 fix: resolveRuntimeIdentity now ALSO reads
|
|
// ~/.gsd/defaults.json as a confidence signal. That makes this test's
|
|
// outcome environment-dependent unless HOME is pinned to a directory with
|
|
// no defaults.json (the project fixture dir itself has none) — otherwise
|
|
// a developer machine that ever installed GSD for a non-Claude runtime
|
|
// would have ~/.gsd/defaults.json's real `runtime` leak in here and
|
|
// silently flip this test's expectation depending on who runs it.
|
|
const r = runHook(agentPayload(), unconfiguredProject, { HOME: unconfiguredProject });
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
});
|
|
|
|
describe('gsd-agent-isolation-guard.js: #3045 BLOCKER 2 — default-install fail-open (isolated two-review finding)', () => {
|
|
let harnessProject; // registry resolves harness-worktree (claude), no defaults.json runtime
|
|
let unconfiguredHarnessProject; // same, but with NO explicit runtime signal at all
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-aig-b2-harness-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
|
|
unconfiguredHarnessProject = mkProject('gsd-aig-b2-unconfigured-');
|
|
// Mirrors gsd-core/templates/config.json exactly: no `runtime` key.
|
|
writeConfig(unconfiguredHarnessProject, JSON.stringify({}));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
cleanup(unconfiguredHarnessProject);
|
|
});
|
|
|
|
test('part A: fresh sentinel confirms harness-worktree but carries NO harness_flag, and the runtime is not confidently resolvable -> DENY (was ALLOW pre-fix)', (t) => {
|
|
// This is the exact BLOCKER 2 regression: previously this branch fell
|
|
// through to the "not confident -> none" degrade and ALLOWED the
|
|
// dispatch to run unisolated, on the DEFAULT-INSTALL path (no `runtime`
|
|
// key in config.json — gsd-core/templates/config.json's shipped shape —
|
|
// and no ~/.gsd/defaults.json runtime either).
|
|
writeSentinel(unconfiguredHarnessProject, { isolation: 'harness-worktree', harnessFlag: null });
|
|
t.after(() => cleanup(path.join(unconfiguredHarnessProject, '.gsd')));
|
|
const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: unconfiguredHarnessProject });
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — must DENY, not silently allow`);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.equal(out.decision, 'block');
|
|
assert.match(out.reason, /cannot verify|harness_flag/i);
|
|
});
|
|
|
|
test('part B: ~/.gsd/defaults.json runtime (installer-persisted, #2395) is now a confident signal — makes the default install enforce', (t) => {
|
|
// No sentinel at all here — pure conservative-fallback path. Before this
|
|
// fix, an unconfigured project (no config.json runtime key, the COMMON
|
|
// scaffold shape) always fell back to 'none'/allow regardless of what the
|
|
// machine actually has installed. After the fix, a `runtime` persisted to
|
|
// ~/.gsd/defaults.json (which bin/install.js's writeNonClaudeDefaults
|
|
// already writes for every non-Claude install) is read as confidently as
|
|
// GSD_RUNTIME or config.json's own key.
|
|
const home = mkProject('gsd-aig-b2-home-');
|
|
t.after(() => cleanup(home));
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'claude' }));
|
|
|
|
const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: home });
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — defaults.json runtime must be enforced`);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('part B (negative control): a project WITH its own config.json runtime key still wins over defaults.json', (t) => {
|
|
const home = mkProject('gsd-aig-b2-home2-');
|
|
t.after(() => cleanup(home));
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
// defaults.json says a runtime with NO harness-worktree capability;
|
|
// config.json's own `runtime: claude` must take precedence.
|
|
fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'windsurf' }));
|
|
|
|
const r = runHook(agentPayload(), harnessProject, { HOME: home });
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
});
|
|
|
|
describe('gsd-agent-isolation-guard.js: #3045 SECURITY F2 — sentinel bound to phase/plan, mismatch is "no applicable sentinel"', () => {
|
|
let harnessProject;
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-aig-f2-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
});
|
|
|
|
test('a fresh "none" sentinel for a DIFFERENT phase than this dispatch is not applied — falls through to conservative fallback and DENIES', (t) => {
|
|
// Sentinel legitimately recorded 'none' for phase 1 (e.g. a submodule
|
|
// degrade). This dispatch's own description names phase 2 — the guard
|
|
// must not reuse phase 1's stale-but-fresh "none" to authorize it.
|
|
writeSentinel(harnessProject, { isolation: 'none', phase: '1', plan: 'plan-a' });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'Execute plan plan-b of phase 2' } }),
|
|
harnessProject,
|
|
);
|
|
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — mismatched sentinel must not silently allow`);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('a fresh sentinel for the SAME phase/plan as this dispatch is applied normally (positive control)', (t) => {
|
|
writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'Execute plan plan-b of phase 2' } }),
|
|
harnessProject,
|
|
);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('a dispatch whose description does not match the expected shape does not itself trigger a mismatch (best-effort extraction)', (t) => {
|
|
writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'some other free-form text' } }),
|
|
harnessProject,
|
|
);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
});
|
|
});
|
|
|
|
describe('gsd-agent-isolation-guard.js: #3045 MAJOR — clock seam boundary coverage (in-process, no subprocess wall-clock race)', () => {
|
|
const guardModule = require('../hooks/gsd-agent-isolation-guard.js');
|
|
|
|
let harnessProject;
|
|
let savedGsdRuntime;
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-aig-clock-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
// These tests call resolveIsolationState() directly, in-process (not via
|
|
// runHook's subprocess, which already strips GSD_RUNTIME) — guard against
|
|
// ambient env leakage from the CURRENT test process (repo hermeticity
|
|
// rule: an ambient GSD_ env var must never redirect a test's outcome).
|
|
savedGsdRuntime = process.env.GSD_RUNTIME;
|
|
delete process.env.GSD_RUNTIME;
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
|
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
|
});
|
|
|
|
function fixedClock(nowMs) {
|
|
return { now: () => nowMs };
|
|
}
|
|
|
|
test('sentinel exactly at SENTINEL_STALE_MS - 1 is still FRESH (trusted)', (t) => {
|
|
const writtenAt = 1_000_000;
|
|
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS - 1) });
|
|
assert.equal(state.isolation, 'none', 'still within the trust window — must use the sentinel, not the registry fallback');
|
|
});
|
|
|
|
test('sentinel exactly AT SENTINEL_STALE_MS is STALE (age > threshold is the only fresh condition)', (t) => {
|
|
const writtenAt = 1_000_000;
|
|
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) });
|
|
// Registry fallback for this project resolves harness-worktree (claude,
|
|
// no workflow.use_worktrees:false) — proves the sentinel's 'none' was
|
|
// NOT trusted at exactly the boundary.
|
|
assert.equal(state.isolation, 'harness-worktree');
|
|
});
|
|
|
|
test('sentinel at SENTINEL_STALE_MS + 1 is STALE', (t) => {
|
|
const writtenAt = 1_000_000;
|
|
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) });
|
|
assert.equal(state.isolation, 'harness-worktree');
|
|
});
|
|
});
|
|
|
|
// Folded from tests/fix-3045-dispatch-isolation-resolver.test.cjs (#3333 wave
|
|
// 1, test-only consolidation — no behavior change). These describe blocks
|
|
// cover the sentinel WRITE side: `gsd-tools.cjs query dispatch-isolation`
|
|
// (routeDispatchIsolation) persists the resolved isolation decision as an
|
|
// unconditional side effect of resolving it, and `record-dispatch-isolation`
|
|
// (routeRecordDispatchIsolation) is the explicit fallback/testable primitive
|
|
// sharing the same atomic-write implementation. Every test here drives the
|
|
// REAL gsd-tools.cjs CLI (via runGsdTools) and asserts on the sentinel file
|
|
// it actually wrote, parsed as JSON.
|
|
describe('#3045 CORE REDESIGN — dispatch-isolation records as an unconditional side effect', () => {
|
|
test('a plain --raw query with no explicit isolation-record verb still writes the sentinel', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'precondition: no sentinel yet');
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--phase', '7'],
|
|
dir,
|
|
{ GSD_RUNTIME: 'claude', HOME: dir },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
assert.equal(result.output.trim(), 'harness-worktree');
|
|
|
|
const sentinel = readSentinelRaw(dir);
|
|
assert.equal(sentinel.isolation, 'harness-worktree');
|
|
assert.equal(sentinel.harness_flag, 'isolation="worktree"');
|
|
assert.equal(sentinel.phase, '7');
|
|
assert.equal(sentinel.plan, null);
|
|
assert.equal(typeof sentinel.written_at, 'number');
|
|
});
|
|
|
|
test('--json output and the recorded sentinel agree on isolation + harnessFlag', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--json', '--phase', '3', '--plan', 'plan-b'],
|
|
dir,
|
|
{ GSD_RUNTIME: 'claude', HOME: dir },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
const parsed = JSON.parse(result.output);
|
|
const sentinel = readSentinelRaw(dir);
|
|
assert.equal(sentinel.isolation, parsed.isolation);
|
|
assert.equal(sentinel.harness_flag, parsed.harnessFlag);
|
|
assert.equal(sentinel.phase, '3');
|
|
assert.equal(sentinel.plan, 'plan-b');
|
|
});
|
|
|
|
test('--force-isolation none overrides a naturally-resolved harness-worktree host and clears harnessFlag', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--phase', '4', '--force-isolation', 'none'],
|
|
dir,
|
|
{ GSD_RUNTIME: 'claude', HOME: dir },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
// routeDispatchIsolation's own stdout still reflects the FORCED value.
|
|
assert.equal(result.output.trim(), 'none');
|
|
|
|
const sentinel = readSentinelRaw(dir);
|
|
assert.equal(sentinel.isolation, 'none');
|
|
assert.equal(sentinel.harness_flag, null);
|
|
});
|
|
|
|
test('an invalid --force-isolation value is ignored, not applied', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--force-isolation', 'bogus-mode'],
|
|
dir,
|
|
{ GSD_RUNTIME: 'claude', HOME: dir },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
assert.equal(result.output.trim(), 'harness-worktree');
|
|
assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree');
|
|
});
|
|
|
|
test('#3045 BLOCKER 1 — a later, plan-scoped call overwrites an earlier phase-only sentinel atomically', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
// Phase-level resolve (as the "Resolve ISOLATION" step performs it).
|
|
runGsdTools(['query', 'dispatch-isolation', '--raw', '--phase', '9'], dir, { GSD_RUNTIME: 'claude', HOME: dir });
|
|
assert.equal(readSentinelRaw(dir).plan, null);
|
|
|
|
// Per-plan gate degrades THIS plan to sequential (submodule intersection).
|
|
const r = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--phase', '9', '--plan', 'plan-sub', '--force-isolation', 'none'],
|
|
dir,
|
|
{ GSD_RUNTIME: 'claude', HOME: dir },
|
|
);
|
|
assert.equal(r.success, true, r.error);
|
|
|
|
const sentinel = readSentinelRaw(dir);
|
|
assert.equal(sentinel.isolation, 'none', 'the plan-scoped degrade must win over the stale phase-level record');
|
|
assert.equal(sentinel.plan, 'plan-sub');
|
|
assert.equal(sentinel.phase, '9');
|
|
});
|
|
|
|
test('the sentinel round-trips through the real reader (hooks/lib/isolation-sentinel.js)', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--phase', '2', '--plan', 'p1'],
|
|
dir,
|
|
{ GSD_RUNTIME: 'claude', HOME: dir },
|
|
);
|
|
const read = readSentinel(dir);
|
|
assert.equal(read.present, true);
|
|
assert.equal(read.stale, false);
|
|
assert.equal(read.malformed, false);
|
|
assert.equal(read.isolation, 'harness-worktree');
|
|
assert.equal(read.harnessFlag, 'isolation="worktree"');
|
|
assert.equal(read.phase, '2');
|
|
assert.equal(read.plan, 'p1');
|
|
});
|
|
});
|
|
|
|
describe('#3045 MAJOR — --harness-flag can now accept a bare CLI-flag value (Cursor real registry value + generalized parsing)', () => {
|
|
test('record-dispatch-isolation --harness-flag=--worktree persists the REAL cursor registry value verbatim', (t) => {
|
|
const cursorFlag = runtimes.cursor.runtime.harnessIsolationFlag;
|
|
assert.equal(cursorFlag, '--worktree', 'precondition: registry shape assumed by this test');
|
|
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', `--harness-flag=${cursorFlag}`, '--phase', '1'],
|
|
dir,
|
|
{ HOME: dir },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
const sentinel = readSentinelRaw(dir);
|
|
assert.equal(sentinel.harness_flag, cursorFlag);
|
|
});
|
|
|
|
test('record-dispatch-isolation --harness-flag=<bare-flag> persists ANY bare-CLI-flag-shaped value verbatim (parser is not Cursor-specific)', (t) => {
|
|
// A prior draft of this test asserted `runtimes.windsurf.runtime.harnessIsolationFlag
|
|
// === '--worktree'`, assuming Windsurf's registry entry mirrors Cursor's.
|
|
// It does not: Windsurf's `hostIntegration.dispatch.isolation` is 'none'
|
|
// and it declares NO `harnessIsolationFlag` at all — per ADR-1239
|
|
// (docs/adr/1239-gsd-embeddable-orchestration-engine.md:247,250),
|
|
// `pi`/`zcode`/`windsurf` "genuinely cannot benefit and correctly stay
|
|
// none" because they lack named/concurrent subagent dispatch, so there is
|
|
// no per-dispatch isolation flag for Windsurf to record. That was a wrong
|
|
// test expectation (a fabricated registry precondition), not a production
|
|
// defect — corrected here to prove the `--harness-flag=<value>` parser
|
|
// generalizes to any bare-CLI-flag-shaped value, not merely Cursor's
|
|
// specific '--worktree' string (which the sub-test above already pins).
|
|
assert.equal(
|
|
runtimes.windsurf.runtime.harnessIsolationFlag,
|
|
undefined,
|
|
'precondition: windsurf declares no harnessIsolationFlag (isolation: "none", ADR-1239)',
|
|
);
|
|
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag=--isolated', '--phase', '1'],
|
|
dir,
|
|
{ HOME: dir },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
assert.equal(readSentinelRaw(dir).harness_flag, '--isolated');
|
|
});
|
|
|
|
test('the legacy space-separated form still rejects a value that looks like another flag (unchanged, regression pin)', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag', '--worktree', '--phase', '1'],
|
|
dir,
|
|
{ HOME: dir },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
assert.equal(readSentinelRaw(dir).harness_flag, null, 'space form must not swallow a value shaped like a flag');
|
|
});
|
|
|
|
test('record-dispatch-isolation still errors with usage text when --isolation is missing', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(['query', 'record-dispatch-isolation'], dir, { HOME: dir });
|
|
assert.equal(result.success, false);
|
|
assert.match(result.error, /Usage: record-dispatch-isolation/);
|
|
});
|
|
|
|
test('record-dispatch-isolation accepts --plan and records it', (t) => {
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'record-dispatch-isolation', '--isolation', 'none', '--phase', '5', '--plan', 'plan-x'],
|
|
dir,
|
|
{ HOME: dir },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
const sentinel = readSentinelRaw(dir);
|
|
assert.equal(sentinel.isolation, 'none');
|
|
assert.equal(sentinel.phase, '5');
|
|
assert.equal(sentinel.plan, 'plan-x');
|
|
});
|
|
});
|
|
|
|
describe('#3045 MINOR — writer/reader sentinel path derivation now agrees for a linked worktree without its own .planning/', () => {
|
|
function git(args, cwd) {
|
|
gitOrThrow(args, { cwd });
|
|
}
|
|
|
|
test('a sentinel written from a linked worktree (via --cwd) is found by readSentinel() called with that SAME worktree path', (t) => {
|
|
const mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-main-'));
|
|
const wtParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-wtparent-'));
|
|
t.after(() => {
|
|
cleanup(mainRepo);
|
|
cleanup(wtParent);
|
|
});
|
|
git(['init'], mainRepo);
|
|
git(['config', 'user.email', 'test@test.com'], mainRepo);
|
|
git(['config', 'user.name', 'Test'], mainRepo);
|
|
git(['config', 'commit.gpgsign', 'false'], mainRepo);
|
|
fs.writeFileSync(path.join(mainRepo, 'README.md'), 'placeholder\n');
|
|
git(['add', '-A'], mainRepo);
|
|
git(['commit', '-m', 'initial commit'], mainRepo);
|
|
|
|
// .planning/ is created AFTER the commit — uncommitted/untracked, the
|
|
// documented shape where a linked worktree does NOT get its own copy
|
|
// (git worktree only checks out tracked files).
|
|
fs.mkdirSync(path.join(mainRepo, '.planning'));
|
|
fs.writeFileSync(path.join(mainRepo, '.planning', 'config.json'), JSON.stringify({}));
|
|
|
|
const linked = path.join(wtParent, 'linked');
|
|
git(['worktree', 'add', linked, '-b', 'gsd-3045-minor-branch'], mainRepo);
|
|
assert.equal(fs.existsSync(path.join(linked, '.planning')), false, 'precondition: linked worktree has no own .planning/');
|
|
|
|
// Write FROM the linked worktree path — mirrors an orchestrator
|
|
// running in a linked worktree calling `dispatch-isolation`.
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--cwd', linked, '--phase', '1'],
|
|
mainRepo,
|
|
{ GSD_RUNTIME: 'claude', HOME: mainRepo },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
|
|
// The writer resolved up to the MAIN worktree (findProjectRoot(resolveMainWorktreeCwd(...))) —
|
|
// the sentinel must NOT exist at the linked worktree's own (nonexistent) .gsd/.
|
|
assert.equal(fs.existsSync(sentinelFile(linked)), false, 'writer must not have written under the linked worktree itself');
|
|
assert.equal(fs.existsSync(sentinelFile(mainRepo)), true, 'writer must have resolved up to the main worktree');
|
|
|
|
// The READER, given the raw linked-worktree cwd (exactly what a guard
|
|
// hook receives as data.cwd / workspace_roots[i]), must derive the SAME
|
|
// root the writer did and find the sentinel — this is the MINOR fix.
|
|
const read = readSentinel(linked);
|
|
assert.equal(read.present, true, 'reader must resolve the linked worktree up to the main worktree, same as the writer');
|
|
assert.equal(read.stale, false);
|
|
assert.equal(read.isolation, 'harness-worktree');
|
|
});
|
|
});
|