nccgs 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/nccgs-adversarial-reviewer.md +1 -1
- package/.claude/agents/nccgs-engine-programmer.md +1 -1
- package/.claude/agents/nccgs-fast-implementer.md +1 -1
- package/.claude/agents/nccgs-game-designer.md +1 -1
- package/.claude/agents/nccgs-gameplay-programmer.md +1 -1
- package/.claude/agents/nccgs-qa-engineer.md +1 -1
- package/.claude/agents/nccgs-task-scout.md +1 -1
- package/.claude/agents/nccgs-ui-programmer.md +1 -1
- package/.claude/agents/nccgs-unity-build-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-implementer.md +1 -1
- package/.claude/agents/nccgs-unity-systems-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-ui-specialist.md +1 -1
- package/.claude/agents/nccgs-verification-engineer.md +1 -1
- package/.claude/nccgs/MEMORY.md +9 -3
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/nccgs/constitution.md +187 -176
- package/.claude/nccgs/hooks/closure-guard.mjs +2 -0
- package/.claude/nccgs/hooks/unity-transport-guard.mjs +11 -0
- package/.claude/nccgs/pipeline.json +260 -260
- package/.claude/nccgs/protocols/context-packets.md +5 -0
- package/.claude/nccgs/protocols/model-routing.md +46 -46
- package/.claude/nccgs/protocols/review-handoff.md +21 -1
- package/.claude/nccgs/protocols/shared-workflow-context.md +33 -0
- package/.claude/nccgs/protocols/unity-boundary.md +20 -0
- package/.claude/nccgs/routing.generated.md +4 -4
- package/.claude/nccgs/settings.fragment.json +84 -24
- package/.claude/nccgs/studio.json +53 -53
- package/.claude/nccgs/tools/autonomy.mjs +13 -0
- package/.claude/nccgs/tools/command-tokens.mjs +22 -0
- package/.claude/nccgs/tools/compile-policy.mjs +4 -0
- package/.claude/nccgs/tools/configure-models.mjs +4 -0
- package/.claude/nccgs/tools/hook-transport.mjs +34 -0
- package/.claude/nccgs/tools/installation-guard.mjs +55 -0
- package/.claude/nccgs/tools/native-completion.mjs +10 -1
- package/.claude/nccgs/tools/returned-handoff.mjs +11 -2
- package/.claude/nccgs/tools/shared-context-guard.mjs +60 -0
- package/.claude/nccgs/tools/shared-context.mjs +25 -0
- package/.claude/nccgs/tools/tool-guard.mjs +120 -131
- package/.claude/nccgs/tools/unity-transport.mjs +170 -0
- package/.claude/nccgs/workflow-catalog.json +4 -4
- package/CHANGELOG.md +235 -165
- package/CLAUDE.md +10 -8
- package/MEMORY.md +109 -56
- package/README.md +206 -168
- package/UPGRADING.md +130 -112
- package/VERSION +1 -1
- package/docs/ARCHITECTURE.md +131 -131
- package/docs/ASSET-PRODUCTION-GRAPHIFY-PLAN.md +230 -0
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +117 -87
- package/docs/NCCGS-1.5.md +237 -237
- package/docs/PROJECT-POLICY.md +94 -94
- package/docs/RELEASE-STATUS.md +32 -7
- package/docs/UNINSTALL.md +134 -0
- package/docs/WFK17-HOOK-REPAIR.md +71 -0
- package/docs/WORKFLOWS.md +85 -85
- package/package.json +1 -1
- package/scaffold/.nccgs/project.yaml +109 -108
- package/scripts/benchmark-v15.mjs +173 -173
- package/scripts/cli.mjs +11 -2
- package/scripts/doctor.mjs +261 -261
- package/scripts/install.mjs +530 -515
- package/scripts/recover-session.mjs +89 -0
- package/scripts/run.mjs +22 -9
- package/scripts/supervise.mjs +4 -0
- package/scripts/uninstall.mjs +292 -0
- package/scripts/validate.mjs +285 -278
- package/tests/autonomy.test.mjs +126 -113
- package/tests/design-readiness.test.mjs +199 -199
- package/tests/fixtures/compiler-shutdown.cs +67 -64
- package/tests/fixtures/native-provider.mjs +61 -0
- package/tests/framework.test.mjs +740 -740
- package/tests/hook-repair.test.mjs +106 -0
- package/tests/lifecycle-v15.test.mjs +346 -300
- package/tests/native-completion-notices.test.mjs +181 -0
- package/tests/phase1.test.mjs +427 -416
- package/tests/phase2.test.mjs +250 -250
- package/tests/policy-v15.test.mjs +234 -234
- package/tests/returned-handoff-notices.test.mjs +170 -0
- package/tests/runtime-v15.test.mjs +1197 -1191
- package/tests/task-slices.test.mjs +285 -285
- package/tests/uninstall.test.mjs +477 -0
- package/tests/unity-transport.test.mjs +348 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { uninstall } from "../scripts/uninstall.mjs";
|
|
10
|
+
import { launchPlan, run as runClaude } from "../scripts/run.mjs";
|
|
11
|
+
import { recoverSession } from "../scripts/recover-session.mjs";
|
|
12
|
+
import { acquireInstallationLease, assertInstallationAvailable } from "../.claude/nccgs/tools/installation-guard.mjs";
|
|
13
|
+
import { ownerState } from "../.claude/nccgs/tools/process-lock.mjs";
|
|
14
|
+
import { compile } from "../.claude/nccgs/tools/compile-policy.mjs";
|
|
15
|
+
|
|
16
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const hash = value => crypto.createHash("sha256").update(value).digest("hex");
|
|
18
|
+
const read = file => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
19
|
+
function inventory(directory, relative = "") {
|
|
20
|
+
return Object.fromEntries(fs.readdirSync(path.join(directory, relative), { withFileTypes: true }).flatMap(entry => {
|
|
21
|
+
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
22
|
+
if (name.startsWith(".nccgs/uninstall/") || name === ".nccgs/uninstall") return [];
|
|
23
|
+
if (entry.isDirectory()) return Object.entries(inventory(directory, name));
|
|
24
|
+
assert.ok(entry.isFile(), name);
|
|
25
|
+
return [[name, hash(fs.readFileSync(path.join(directory, name)))]];
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
function fixture({ settings, claude } = {}) {
|
|
29
|
+
const parent = fs.realpathSync(os.tmpdir()), directory = fs.mkdtempSync(path.join(parent, "nccgs-uninstall-")), project = path.join(directory, "Unity project Việt");
|
|
30
|
+
fs.mkdirSync(project);
|
|
31
|
+
const at = relative => path.join(project, relative), write = (relative, value) => {
|
|
32
|
+
fs.mkdirSync(path.dirname(at(relative)), { recursive: true }); fs.writeFileSync(at(relative), typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n");
|
|
33
|
+
};
|
|
34
|
+
write("Assets/Game.cs", "// owner's game source\n"); write("Packages/manifest.json", { dependencies: {} }); write("ProjectSettings/ProjectVersion.txt", "m_EditorVersion: 6000.3.17f1\n");
|
|
35
|
+
if (settings) write(".claude/settings.json", settings);
|
|
36
|
+
if (claude !== undefined) write("CLAUDE.md", claude);
|
|
37
|
+
const env = { ...process.env };
|
|
38
|
+
for (const key of ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NCCGS_SUPERVISED_SESSION", "NCCGS_TASK_AUTONOMY", "NCCGS_PROJECT_ROOT"]) delete env[key];
|
|
39
|
+
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args, "--project", project], { cwd: directory, env, windowsHide: true, encoding: "utf8", timeout: 60000, maxBuffer: 5e6 });
|
|
40
|
+
const installed = run("install", "--autonomy", "guarded"); assert.equal(installed.status, 0, installed.stderr + installed.stdout);
|
|
41
|
+
const remove = (...args) => { const r = run("uninstall", ...args); assert.equal(r.status, 0, r.stderr + r.stdout); return JSON.parse(r.stdout); };
|
|
42
|
+
const dispose = () => {
|
|
43
|
+
assert.equal(fs.realpathSync(path.dirname(directory)).toLowerCase(), parent.toLowerCase());
|
|
44
|
+
assert.ok(path.basename(directory).startsWith("nccgs-uninstall-")); assert.ok(!fs.lstatSync(directory).isSymbolicLink());
|
|
45
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
46
|
+
};
|
|
47
|
+
return { directory, project, at, write, run, remove, env, dispose };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("uninstall preview is read-only, preserves game/history, detaches hooks and restores exact bytes", () => {
|
|
51
|
+
const f = fixture({ claude: "# Owner instructions\r\nKeep my game rules.\r\n", settings: { env: { OWNER: "keep" }, permissions: { allow: ["Bash(git status*)"], deny: ["Read(private/**)"] }, hooks: { SessionStart: [{ matcher: "", timeout: 9, hooks: [{ type: "command", command: "echo owner" }] }] } } });
|
|
52
|
+
try {
|
|
53
|
+
f.write(".nccgs/tasks/open.json", { id: "open", closed: null, history: [{ outcome: "failed" }], reviewBudget: { used: 1, limit: 3 } });
|
|
54
|
+
f.write(".nccgs/budgets/open.json", { totalUsd: 12, spentUsd: 7 }); f.write(".nccgs/evidence/original.txt", "negative evidence\n");
|
|
55
|
+
f.write(".claude/skills/owner/SKILL.md", "Owner tool\n");
|
|
56
|
+
const before = inventory(f.project), settings = read(f.at(".claude/settings.json"));
|
|
57
|
+
const preview = f.remove("--dry-run"); assert.equal(preview.status, "PLANNED"); assert.ok(preview.remove.length > 300); assert.deepEqual(inventory(f.project), before);
|
|
58
|
+
const result = f.remove(); assert.equal(result.status, "UNINSTALLED"); assert.ok(!fs.existsSync(f.at(".claude/nccgs/install-state.json")));
|
|
59
|
+
for (const name of Object.keys(before).filter(n => n.startsWith(".nccgs/") || n.startsWith("Assets/") || n.startsWith("Packages/") || n.startsWith("ProjectSettings/") || n.includes("/owner/"))) assert.equal(inventory(f.project)[name], before[name], name);
|
|
60
|
+
const after = read(f.at(".claude/settings.json")); assert.deepEqual(after.permissions, settings.permissions); assert.deepEqual(after.env, settings.env); assert.equal(after.model, settings.model);
|
|
61
|
+
assert.deepEqual(after.hooks.SessionStart, [{ matcher: "", timeout: 9, hooks: [{ type: "command", command: "echo owner" }] }]);
|
|
62
|
+
assert.ok(fs.readFileSync(f.at("CLAUDE.md"), "utf8").startsWith("# Owner instructions\r\nKeep my game rules.\r\n"));
|
|
63
|
+
assert.ok(!fs.readFileSync(f.at("CLAUDE.md"), "utf8").includes("@.claude/nccgs/"));
|
|
64
|
+
const removed = inventory(f.project); assert.equal(f.remove().status, "NOT_INSTALLED"); assert.deepEqual(inventory(f.project), removed);
|
|
65
|
+
assert.equal(f.remove("--rollback", result.id, "--dry-run").status, "ROLLBACK_PREVIEW"); assert.deepEqual(inventory(f.project), removed);
|
|
66
|
+
assert.equal(f.remove("--rollback", result.id).status, "RESTORED"); assert.deepEqual(inventory(f.project), before); assert.equal(f.run("doctor").status, 0);
|
|
67
|
+
} finally { f.dispose(); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("modified managed files and unrelated files survive; optional generated agents are removed", () => {
|
|
71
|
+
const f = fixture();
|
|
72
|
+
try {
|
|
73
|
+
assert.equal(f.run("agents", "--enable", "nccgs-network-programmer").status, 0);
|
|
74
|
+
f.write(".claude/agents/nccgs-gameplay-programmer.md", "My customized worker\n");
|
|
75
|
+
f.write(".claude/skills/review/SKILL.md", "My customized review\n");
|
|
76
|
+
f.write(".claude/nccgs/owner-notes.txt", "Untracked owner file\n");
|
|
77
|
+
const before = inventory(f.project), result = f.remove(); assert.equal(result.status, "UNINSTALLED_WITH_PRESERVED_FILES");
|
|
78
|
+
assert.ok(result.preserved.some(x => x.path === ".claude/agents/nccgs-gameplay-programmer.md"));
|
|
79
|
+
for (const name of [".claude/agents/nccgs-gameplay-programmer.md", ".claude/skills/review/SKILL.md", ".claude/nccgs/owner-notes.txt"]) assert.equal(inventory(f.project)[name], before[name]);
|
|
80
|
+
assert.ok(!fs.existsSync(f.at(".claude/agents/nccgs-network-programmer.md"))); assert.ok(!fs.existsSync(f.at("CLAUDE.md")));
|
|
81
|
+
f.remove("--rollback", result.id); assert.deepEqual(inventory(f.project), before);
|
|
82
|
+
} finally { f.dispose(); }
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("mixed hook groups preserve owner metadata and hooks; customized compound hooks refuse cleanup", () => {
|
|
86
|
+
const f = fixture();
|
|
87
|
+
try {
|
|
88
|
+
const settings = read(f.at(".claude/settings.json")), group = settings.hooks.PreToolUse[0];
|
|
89
|
+
group.ownerMarker = "keep"; group.hooks.push({ type: "command", command: "echo user", timeout: 2 }); f.write(".claude/settings.json", settings);
|
|
90
|
+
const result = f.remove(); const after = read(f.at(".claude/settings.json")); assert.equal(after.hooks.PreToolUse[0].ownerMarker, "keep"); assert.deepEqual(after.hooks.PreToolUse[0].hooks, [{ type: "command", command: "echo user", timeout: 2 }]);
|
|
91
|
+
f.remove("--rollback", result.id);
|
|
92
|
+
settings.hooks.PreToolUse[0].hooks[0].command += " && echo private-owner-step"; f.write(".claude/settings.json", settings);
|
|
93
|
+
const before = inventory(f.project), refused = f.run("uninstall"); assert.notEqual(refused.status, 0); assert.match(refused.stderr, /Customized NCCGS hook/); assert.deepEqual(inventory(f.project), before);
|
|
94
|
+
} finally { f.dispose(); }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("active or uncertain dispatch, verification, run and process ownership block without mutation", () => {
|
|
98
|
+
const f = fixture();
|
|
99
|
+
try {
|
|
100
|
+
const scenarios = [
|
|
101
|
+
[".nccgs/orchestration.json", { dispatches: { one: { id: "one", status: "uncertain" } } }],
|
|
102
|
+
[".nccgs/jobs/one/request.json", { id: "one" }],
|
|
103
|
+
[".nccgs/runs/one/run.json", { status: "blocked", invocations: [{ status: "running", pid: process.pid }] }],
|
|
104
|
+
[".nccgs/runs/one/run.json", { status: "unknown", invocations: [] }],
|
|
105
|
+
[".nccgs/uninstall.lock.recovery", { broken: true }],
|
|
106
|
+
[".nccgs/sessions/one.lock", { schemaVersion: 1, host: os.hostname(), pid: process.pid, token: "owner", phase: "owned", child: null }],
|
|
107
|
+
[".nccgs/orchestration.lock", { broken: true }]
|
|
108
|
+
];
|
|
109
|
+
for (const [relative, value] of scenarios) {
|
|
110
|
+
f.write(relative, value); const before = inventory(f.project);
|
|
111
|
+
for (const args of [[], ["--dry-run"]]) { const r = f.run("uninstall", ...args); assert.notEqual(r.status, 0, relative); assert.deepEqual(inventory(f.project), before, relative); }
|
|
112
|
+
fs.unlinkSync(f.at(relative));
|
|
113
|
+
}
|
|
114
|
+
assert.equal(f.remove().status, "UNINSTALLED");
|
|
115
|
+
} finally { f.dispose(); }
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("unsafe and duplicate manifest paths are rejected before touching source", () => {
|
|
119
|
+
const f = fixture();
|
|
120
|
+
try {
|
|
121
|
+
const file = f.at(".claude/nccgs/install-state.json"), initial = read(file);
|
|
122
|
+
for (const name of ["../outside", "Assets/Game.cs", ".claude/settings.json", ".claude/nccgs/../../Assets/Game.cs", "C:/outside", ".claude/nccgs/con.txt"]) {
|
|
123
|
+
const state = structuredClone(initial); state.files.push({ relative: name, sha256: hash("x") }); f.write(".claude/nccgs/install-state.json", state);
|
|
124
|
+
const before = inventory(f.project); assert.notEqual(f.run("uninstall").status, 0, name); assert.deepEqual(inventory(f.project), before);
|
|
125
|
+
}
|
|
126
|
+
const state = structuredClone(initial); state.files.push(state.files[0]); f.write(".claude/nccgs/install-state.json", state); assert.notEqual(f.run("uninstall").status, 0);
|
|
127
|
+
} finally { f.dispose(); }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("rollback refuses new owner edits and corrupt backups before restoring anything", () => {
|
|
131
|
+
const f = fixture();
|
|
132
|
+
try {
|
|
133
|
+
const result = f.remove(), manifest = read(f.at(result.backup)), first = manifest.files.find(x => x.after === null);
|
|
134
|
+
f.write(first.path, "new owner content\n"); const modified = inventory(f.project);
|
|
135
|
+
assert.notEqual(f.run("uninstall", "--rollback", result.id).status, 0); assert.deepEqual(inventory(f.project), modified);
|
|
136
|
+
fs.unlinkSync(f.at(first.path)); const backup = `.nccgs/uninstall/${result.id}/files/${first.path}`; f.write(backup, "tampered\n");
|
|
137
|
+
const before = inventory(f.project); const rejected = f.run("uninstall", "--rollback", result.id); assert.notEqual(rejected.status, 0); assert.match(rejected.stderr, /Changed rollback backup/); assert.deepEqual(inventory(f.project), before);
|
|
138
|
+
} finally { f.dispose(); }
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("reinstall after uninstall retains existing task history policy and spent budget", () => {
|
|
142
|
+
const f = fixture();
|
|
143
|
+
try {
|
|
144
|
+
f.write(".nccgs/tasks/retained.json", { id: "retained", history: [{ outcome: "failed" }], reviewBudget: { used: 2 } });
|
|
145
|
+
f.write(".nccgs/budgets/retained.json", { totalUsd: 12, spentUsd: 9 });
|
|
146
|
+
const before = inventory(f.project), result = f.remove(); assert.equal(result.status, "UNINSTALLED");
|
|
147
|
+
assert.equal(f.run("install").status, 0); assert.equal(f.run("doctor").status, 0);
|
|
148
|
+
for (const name of [".nccgs/tasks/retained.json", ".nccgs/budgets/retained.json", ".nccgs/project.yaml", ".nccgs/execution.json"]) assert.equal(inventory(f.project)[name], before[name]);
|
|
149
|
+
assert.notEqual(f.run("uninstall", "--rollback", result.id).status, 0, "Reinstall must not be silently overwritten by rollback");
|
|
150
|
+
} finally { f.dispose(); }
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("write failure restores previously removed files and records the failed attempt", () => {
|
|
154
|
+
const f = fixture();
|
|
155
|
+
try {
|
|
156
|
+
const before = inventory(f.project), original = fs.unlinkSync; let count = 0;
|
|
157
|
+
fs.unlinkSync = function(file) { if (String(file).includes(`${path.sep}.claude${path.sep}`) && ++count === 3) throw new Error("synthetic unlink failure"); return original.call(fs, file); };
|
|
158
|
+
try { assert.throws(() => uninstall(f.project), /all planned files restored/); } finally { fs.unlinkSync = original; }
|
|
159
|
+
assert.deepEqual(inventory(f.project), before); const ids = fs.readdirSync(f.at(".nccgs/uninstall")); assert.equal(ids.length, 1);
|
|
160
|
+
const manifest = read(f.at(`.nccgs/uninstall/${ids[0]}/manifest.json`)); assert.equal(manifest.status, "restored"); assert.match(manifest.error, /synthetic unlink failure/);
|
|
161
|
+
} finally { f.dispose(); }
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("interrupted uninstall blocks launch/update and can recover from the preserved receipt", () => {
|
|
165
|
+
const f = fixture();
|
|
166
|
+
try {
|
|
167
|
+
const result = f.remove(), receipt = read(f.at(result.backup)); receipt.status = "applying"; f.write(result.backup, receipt);
|
|
168
|
+
assert.throws(() => launchPlan(["--project", f.project, "--dry-run"], f.env), /Interrupted uninstall/);
|
|
169
|
+
assert.notEqual(f.run("install").status, 0); assert.notEqual(f.run("uninstall").status, 0);
|
|
170
|
+
assert.equal(f.remove("--rollback", result.id).status, "RESTORED"); assert.equal(f.run("doctor").status, 0);
|
|
171
|
+
} finally { f.dispose(); }
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("active removal prevents install and launch; unknown tracked files and line endings stay safe", () => {
|
|
175
|
+
const f = fixture();
|
|
176
|
+
try {
|
|
177
|
+
f.write(".nccgs/uninstall.lock", { schemaVersion: 1, host: os.hostname(), pid: process.pid, token: "active-removal", phase: "owned", child: null });
|
|
178
|
+
const locked = inventory(f.project);
|
|
179
|
+
assert.notEqual(f.run("install").status, 0); assert.notEqual(f.run("run", "--dry-run").status, 0);
|
|
180
|
+
assert.deepEqual(inventory(f.project), locked); fs.unlinkSync(f.at(".nccgs/uninstall.lock"));
|
|
181
|
+
const state = read(f.at(".claude/nccgs/install-state.json"));
|
|
182
|
+
f.write(".claude/owner-secret.txt", "unrelated owner file\n");
|
|
183
|
+
state.files.push({ relative: ".claude/owner-secret.txt", sha256: hash("unrelated owner file\n") }); f.write(".claude/nccgs/install-state.json", state);
|
|
184
|
+
const skill = ".claude/skills/review/SKILL.md";
|
|
185
|
+
f.write(skill, fs.readFileSync(f.at(skill), "utf8").replace(/\r\n/g, "\n").replace(/\n/g, "\r\n"));
|
|
186
|
+
const before = inventory(f.project), result = f.remove();
|
|
187
|
+
assert.equal(result.status, "UNINSTALLED_WITH_PRESERVED_FILES"); assert.ok(!fs.existsSync(f.at(skill)));
|
|
188
|
+
assert.equal(inventory(f.project)[".claude/owner-secret.txt"], before[".claude/owner-secret.txt"]);
|
|
189
|
+
f.remove("--rollback", result.id); assert.deepEqual(inventory(f.project), before);
|
|
190
|
+
} finally { f.dispose(); }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("failed native launch releases its session lease without starting a provider", async () => {
|
|
194
|
+
const f = fixture();
|
|
195
|
+
try {
|
|
196
|
+
await assert.rejects(runClaude(["--project", f.project, "--claude", path.join(f.directory, "absent-claude-binary")]), /Cannot start Claude Code/);
|
|
197
|
+
assert.equal(fs.readdirSync(f.at(".nccgs/sessions")).length, 0); assert.equal(f.remove().status, "UNINSTALLED");
|
|
198
|
+
} finally { f.dispose(); }
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("uninstall completed between planning and session registration cannot start Claude", async () => {
|
|
202
|
+
const f = fixture();
|
|
203
|
+
const original = fs.openSync; let intercepted = false;
|
|
204
|
+
try {
|
|
205
|
+
fs.openSync = function(file, ...args) {
|
|
206
|
+
if (!intercepted && String(file) === f.at(".nccgs/uninstall.lock")) {
|
|
207
|
+
intercepted = true; assert.equal(uninstall(f.project).status, "UNINSTALLED");
|
|
208
|
+
}
|
|
209
|
+
return original.call(fs, file, ...args);
|
|
210
|
+
};
|
|
211
|
+
await assert.rejects(runClaude(["--project", f.project, "--claude", path.join(f.directory, "absent-claude-binary")]), /removed before launch/);
|
|
212
|
+
assert.ok(intercepted); assert.equal(fs.readdirSync(f.at(".nccgs/sessions")).length, 0);
|
|
213
|
+
} finally { fs.openSync = original; f.dispose(); }
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("dead launcher recovery requires explicit tree attestation and archives exact leases without resetting history", () => {
|
|
217
|
+
const f = fixture();
|
|
218
|
+
try {
|
|
219
|
+
const stopped = spawnSync(process.execPath, ["-e", "process.exit(0)"], { windowsHide: true });
|
|
220
|
+
assert.equal(stopped.status, 0); assert.throws(() => process.kill(stopped.pid, 0), error => error.code === "ESRCH");
|
|
221
|
+
f.write(".nccgs/tasks/history.json", { outcome: "partial", reviewBudget: { used: 2 }, history: ["failed"] });
|
|
222
|
+
f.write(".nccgs/budgets/history.json", { spentUsd: 9, totalUsd: 12 });
|
|
223
|
+
const history = inventory(f.project);
|
|
224
|
+
for (const phase of ["running", "spawning"]) {
|
|
225
|
+
const id = crypto.randomUUID(), relative = `.nccgs/sessions/${id}.lock`;
|
|
226
|
+
const record = { schemaVersion: 1, operation: "nccgs-run", host: os.hostname(), pid: stopped.pid, token: crypto.randomUUID(), phase,
|
|
227
|
+
child: phase === "running" ? { pid: stopped.pid, group: false } : null };
|
|
228
|
+
f.write(relative, record); assert.equal(ownerState(record), "unknown");
|
|
229
|
+
const before = inventory(f.project); assert.notEqual(f.run("uninstall").status, 0);
|
|
230
|
+
const preview = recoverSession(f.project, { id, dryRun: true }); assert.equal(preview.descendantTree, "unverified"); assert.deepEqual(inventory(f.project), before);
|
|
231
|
+
assert.throws(() => recoverSession(f.project, { id, expectedSha256: preview.sha256, reason: "I checked the tree" }), /confirm-process-tree-stopped/);
|
|
232
|
+
assert.throws(() => recoverSession(f.project, { id, expectedSha256: "a".repeat(64), confirmStopped: true, reason: "I checked the tree" }), /does not match/);
|
|
233
|
+
const result = recoverSession(f.project, { id, expectedSha256: preview.sha256, confirmStopped: true, reason: "Test-only dead harmless process; no descendants were created." });
|
|
234
|
+
assert.equal(result.status, "SESSION_RECONCILED"); assert.ok(!fs.existsSync(f.at(relative)));
|
|
235
|
+
assert.equal(hash(fs.readFileSync(f.at(result.archivedLease))), before[relative]);
|
|
236
|
+
assert.equal(read(f.at(result.receipt)).attestation.automaticDescendantProof, false);
|
|
237
|
+
assert.equal(read(f.at(result.receipt)).status, "pending");
|
|
238
|
+
assert.equal(read(f.at(result.outcome)).status, "completed");
|
|
239
|
+
assert.equal(read(f.at(result.outcome)).receiptSha256, hash(fs.readFileSync(f.at(result.receipt))));
|
|
240
|
+
assert.equal(read(f.at(result.outcome)).originalSha256, before[relative]);
|
|
241
|
+
assert.equal(ownerState(read(f.at(result.archivedLease))), "unknown", "Recovery does not weaken ownerState");
|
|
242
|
+
}
|
|
243
|
+
for (const relative of [".nccgs/tasks/history.json", ".nccgs/budgets/history.json"]) assert.equal(inventory(f.project)[relative], history[relative]);
|
|
244
|
+
f.write(".nccgs/jobs/unfinished/request.json", { id: "unfinished" }); assert.notEqual(f.run("uninstall").status, 0);
|
|
245
|
+
fs.unlinkSync(f.at(".nccgs/jobs/unfinished/request.json")); assert.equal(f.remove().status, "UNINSTALLED");
|
|
246
|
+
} finally { f.dispose(); }
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("session recovery rejects live owners, live children, foreign or unrelated records and unsafe identities without writes", () => {
|
|
250
|
+
const f = fixture();
|
|
251
|
+
try {
|
|
252
|
+
const stopped = spawnSync(process.execPath, ["-e", "process.exit(0)"], { windowsHide: true });
|
|
253
|
+
const id = crypto.randomUUID(), relative = `.nccgs/sessions/${id}.lock`;
|
|
254
|
+
const base = { schemaVersion: 1, operation: "nccgs-run", host: os.hostname(), pid: stopped.pid, token: crypto.randomUUID(), phase: "running", child: { pid: stopped.pid, group: false } };
|
|
255
|
+
for (const record of [{ ...base, pid: process.pid }, { ...base, child: { pid: process.pid } }, { ...base, host: "another-host" }, { ...base, operation: "verification" }, { ...base, token: "invalid" }]) {
|
|
256
|
+
f.write(relative, record); const before = inventory(f.project);
|
|
257
|
+
assert.throws(() => recoverSession(f.project, { id, expectedSha256: before[relative], confirmStopped: true, reason: "Cannot authorize a live or foreign process" }));
|
|
258
|
+
assert.deepEqual(inventory(f.project), before);
|
|
259
|
+
}
|
|
260
|
+
for (const unsafe of ["../tasks/one", "one", "C:/outside"]) assert.throws(() => recoverSession(f.project, { id: unsafe, dryRun: true }), /UUID/);
|
|
261
|
+
f.write(relative, base); const before = inventory(f.project), original = process.env.CLAUDECODE;
|
|
262
|
+
try { process.env.CLAUDECODE = "1"; assert.throws(() => recoverSession(f.project, { id, dryRun: true }), /external operator/); }
|
|
263
|
+
finally { if (original === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = original; }
|
|
264
|
+
assert.deepEqual(inventory(f.project), before);
|
|
265
|
+
const preview = f.run("recover-session", "--id", id, "--dry-run"); assert.equal(preview.status, 0);
|
|
266
|
+
const result = f.run("recover-session", "--id", id, "--sha256", JSON.parse(preview.stdout).sha256, "--confirm-process-tree-stopped", "--reason", "Test fixture: dead process created no descendants.");
|
|
267
|
+
assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).status, "SESSION_RECONCILED");
|
|
268
|
+
} finally { f.dispose(); }
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("admission contention identifies holders, empty locks and released holders after the first check", () => {
|
|
272
|
+
const f = fixture(), original = fs.openSync, originalRead = fs.readFileSync;
|
|
273
|
+
const gate = ".nccgs/uninstall.lock";
|
|
274
|
+
const owner = { schemaVersion: 1, host: os.hostname(), pid: process.pid, token: crypto.randomUUID(), phase: "owned", child: null };
|
|
275
|
+
try {
|
|
276
|
+
for (const operation of ["installation-admission", "uninstall"]) {
|
|
277
|
+
f.write(gate, { ...owner, operation }); const before = inventory(f.project);
|
|
278
|
+
const expected = operation === "installation-admission" ? /installation admission is busy or unverified/ : /uninstall is active or unverified/;
|
|
279
|
+
assert.throws(() => assertInstallationAvailable(f.project), expected);
|
|
280
|
+
assert.throws(() => acquireInstallationLease(f.project, "test"), expected);
|
|
281
|
+
assert.deepEqual(inventory(f.project), before); fs.unlinkSync(f.at(gate));
|
|
282
|
+
}
|
|
283
|
+
for (const state of ["valid", "empty", "released"]) {
|
|
284
|
+
let raced = false, released = false;
|
|
285
|
+
fs.openSync = function(file, flags, ...args) {
|
|
286
|
+
if (!raced && path.resolve(String(file)) === f.at(gate) && flags === "wx") {
|
|
287
|
+
raced = true; f.write(gate, state === "empty" ? "" : { ...owner, operation: "installation-admission" });
|
|
288
|
+
}
|
|
289
|
+
return original.call(fs, file, flags, ...args);
|
|
290
|
+
};
|
|
291
|
+
fs.readFileSync = function(file, ...args) {
|
|
292
|
+
const value = originalRead.call(fs, file, ...args);
|
|
293
|
+
if (state === "released" && !released && path.resolve(String(file)) === f.at(gate)) {
|
|
294
|
+
released = true; fs.unlinkSync(file);
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
};
|
|
298
|
+
try { assert.throws(() => acquireInstallationLease(f.project, "test"), /installation admission is busy or unverified/); }
|
|
299
|
+
finally { fs.openSync = original; fs.readFileSync = originalRead; }
|
|
300
|
+
assert.ok(raced);
|
|
301
|
+
if (state === "released") { assert.ok(released); assert.ok(!fs.existsSync(f.at(gate))); }
|
|
302
|
+
else { assert.equal(fs.readFileSync(f.at(gate), "utf8"), state === "empty" ? "" : JSON.stringify({ ...owner, operation: "installation-admission" }, null, 2) + "\n"); fs.unlinkSync(f.at(gate)); }
|
|
303
|
+
}
|
|
304
|
+
const lease = acquireInstallationLease(f.project, "test"); lease.release();
|
|
305
|
+
assert.equal(fs.readdirSync(f.at(".nccgs/sessions")).length, 0);
|
|
306
|
+
} finally { fs.openSync = original; fs.readFileSync = originalRead; f.dispose(); }
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("unreadable or changing admission records fail closed with actionable diagnostics and no mutations", () => {
|
|
310
|
+
const f = fixture(), originalRead = fs.readFileSync;
|
|
311
|
+
try {
|
|
312
|
+
for (const relative of [".nccgs/uninstall.lock", ".nccgs/uninstall.lock.recovery"]) {
|
|
313
|
+
for (const contents of ["", "{", "null", "true", "42", "[]", '"invalid"']) {
|
|
314
|
+
f.write(relative, contents); const before = inventory(f.project);
|
|
315
|
+
assert.throws(() => assertInstallationAvailable(f.project), error => !(error instanceof SyntaxError) && /admission is busy or unverified/.test(error.message));
|
|
316
|
+
assert.throws(() => acquireInstallationLease(f.project, "test"), /admission is busy or unverified/);
|
|
317
|
+
assert.deepEqual(inventory(f.project), before); fs.unlinkSync(f.at(relative));
|
|
318
|
+
}
|
|
319
|
+
for (const code of ["EACCES", "ENOENT"]) {
|
|
320
|
+
f.write(relative, "{}"); const before = inventory(f.project);
|
|
321
|
+
fs.readFileSync = function(file, ...args) {
|
|
322
|
+
if (path.resolve(String(file)) === f.at(relative)) { const error = new Error(`injected ${code}`); error.code = code; throw error; }
|
|
323
|
+
return originalRead.call(fs, file, ...args);
|
|
324
|
+
};
|
|
325
|
+
try { assert.throws(() => acquireInstallationLease(f.project, "test"), error => /admission is busy or unverified/.test(error.message) && error.cause?.code === code); }
|
|
326
|
+
finally { fs.readFileSync = originalRead; }
|
|
327
|
+
assert.deepEqual(inventory(f.project), before); fs.unlinkSync(f.at(relative));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} finally { fs.readFileSync = originalRead; f.dispose(); }
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("recovery rename errors preserve the lease and separate failed outcomes from pending intent", () => {
|
|
334
|
+
const f = fixture(), original = fs.renameSync;
|
|
335
|
+
try {
|
|
336
|
+
const stopped = spawnSync(process.execPath, ["-e", "process.exit(0)"], { windowsHide: true }); assert.equal(stopped.status, 0);
|
|
337
|
+
f.write(".nccgs/tasks/history.json", { history: ["failed"], review: "required" });
|
|
338
|
+
f.write(".nccgs/budgets/history.json", { spentUsd: 9, limitUsd: 12 });
|
|
339
|
+
for (const code of ["EPERM", "EBUSY"]) {
|
|
340
|
+
const id = crypto.randomUUID(), relative = `.nccgs/sessions/${id}.lock`;
|
|
341
|
+
f.write(relative, { schemaVersion: 1, operation: "nccgs-run", host: os.hostname(), pid: stopped.pid, token: crypto.randomUUID(), phase: "spawning", child: null });
|
|
342
|
+
const before = inventory(f.project), options = { id, expectedSha256: before[relative], confirmStopped: true, reason: "Test-only stopped process with no descendants." };
|
|
343
|
+
let attempted;
|
|
344
|
+
fs.renameSync = function(from, to) {
|
|
345
|
+
if (path.resolve(String(from)) === f.at(relative)) { attempted = path.dirname(to); const error = new Error(`injected ${code}`); error.code = code; throw error; }
|
|
346
|
+
return original.call(fs, from, to);
|
|
347
|
+
};
|
|
348
|
+
try { assert.throws(() => recoverSession(f.project, options), /Session recovery .* failed: injected/); }
|
|
349
|
+
finally { fs.renameSync = original; }
|
|
350
|
+
assert.ok(attempted); assert.equal(hash(fs.readFileSync(f.at(relative))), before[relative]);
|
|
351
|
+
const receipt = path.join(attempted, "receipt.json"), outcome = path.join(attempted, "outcome.json");
|
|
352
|
+
assert.equal(read(receipt).status, "pending"); assert.equal(read(outcome).status, "failed"); assert.equal(read(outcome).code, code);
|
|
353
|
+
assert.equal(read(outcome).receiptSha256, hash(fs.readFileSync(receipt))); assert.ok(!fs.existsSync(path.join(attempted, "lease.lock")));
|
|
354
|
+
for (const [name, digest] of Object.entries(before)) assert.equal(hash(fs.readFileSync(f.at(name))), digest, name);
|
|
355
|
+
assert.notEqual(f.run("uninstall").status, 0); const failedReceipt = hash(fs.readFileSync(receipt)), failedOutcome = hash(fs.readFileSync(outcome));
|
|
356
|
+
const retry = recoverSession(f.project, options); assert.equal(retry.status, "SESSION_RECONCILED"); assert.notEqual(path.dirname(f.at(retry.receipt)), attempted);
|
|
357
|
+
assert.equal(hash(fs.readFileSync(receipt)), failedReceipt); assert.equal(hash(fs.readFileSync(outcome)), failedOutcome);
|
|
358
|
+
}
|
|
359
|
+
} finally { fs.renameSync = original; f.dispose(); }
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("recovery audit write failures never claim completion before or after the lease moves", () => {
|
|
363
|
+
const f = fixture(), originalWrite = fs.writeFileSync, originalRename = fs.renameSync;
|
|
364
|
+
try {
|
|
365
|
+
const stopped = spawnSync(process.execPath, ["-e", "process.exit(0)"], { windowsHide: true }); assert.equal(stopped.status, 0);
|
|
366
|
+
for (const moveFails of [true, false]) {
|
|
367
|
+
const id = crypto.randomUUID(), relative = `.nccgs/sessions/${id}.lock`;
|
|
368
|
+
f.write(relative, { schemaVersion: 1, operation: "nccgs-run", host: os.hostname(), pid: stopped.pid, token: crypto.randomUUID(), phase: "spawning", child: null });
|
|
369
|
+
const digest = hash(fs.readFileSync(f.at(relative))); let directory;
|
|
370
|
+
fs.writeFileSync = function(file, data, ...args) {
|
|
371
|
+
if (typeof file === "string" && path.basename(file) === "outcome.json") {
|
|
372
|
+
directory = path.dirname(file); const error = new Error("injected outcome write failure"); error.code = "EIO"; throw error;
|
|
373
|
+
}
|
|
374
|
+
return originalWrite.call(fs, file, data, ...args);
|
|
375
|
+
};
|
|
376
|
+
fs.renameSync = function(from, to) {
|
|
377
|
+
if (moveFails && path.resolve(String(from)) === f.at(relative)) throw new Error("injected move failure");
|
|
378
|
+
return originalRename.call(fs, from, to);
|
|
379
|
+
};
|
|
380
|
+
try { assert.throws(() => recoverSession(f.project, { id, expectedSha256: digest, confirmStopped: true, reason: "Test-only stopped process with no descendants." }), moveFails ? /Outcome could not be recorded/ : /lease was archived, but completion could not be recorded/); }
|
|
381
|
+
finally { fs.writeFileSync = originalWrite; fs.renameSync = originalRename; }
|
|
382
|
+
assert.ok(directory); assert.equal(read(path.join(directory, "receipt.json")).status, "pending");
|
|
383
|
+
assert.ok(!fs.existsSync(path.join(directory, "outcome.json"))); assert.ok(!fs.existsSync(f.at(".nccgs/uninstall.lock")));
|
|
384
|
+
assert.equal(fs.existsSync(f.at(relative)), moveFails);
|
|
385
|
+
assert.equal(hash(fs.readFileSync(moveFails ? f.at(relative) : path.join(directory, "lease.lock"))), digest);
|
|
386
|
+
if (moveFails) assert.notEqual(f.run("uninstall").status, 0);
|
|
387
|
+
}
|
|
388
|
+
} finally { fs.writeFileSync = originalWrite; fs.renameSync = originalRename; f.dispose(); }
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("busy empty-directory cleanup returns completed removal, backup id and warnings", () => {
|
|
392
|
+
const f = fixture(), original = fs.rmdirSync; let injected = false;
|
|
393
|
+
try {
|
|
394
|
+
const before = inventory(f.project);
|
|
395
|
+
fs.rmdirSync = function(file, ...args) {
|
|
396
|
+
if (!injected && String(file).startsWith(f.at(".claude") + path.sep)) { injected = true; const error = new Error("busy fixture folder"); error.code = "EBUSY"; throw error; }
|
|
397
|
+
return original.call(fs, file, ...args);
|
|
398
|
+
};
|
|
399
|
+
let result; try { result = uninstall(f.project); } finally { fs.rmdirSync = original; }
|
|
400
|
+
assert.ok(injected); assert.equal(result.status, "UNINSTALLED"); assert.ok(result.id && result.backup);
|
|
401
|
+
assert.ok(result.warnings.some(warning => warning.includes("EBUSY"))); assert.equal(read(f.at(result.backup)).status, "completed");
|
|
402
|
+
f.remove("--rollback", result.id); assert.deepEqual(inventory(f.project), before);
|
|
403
|
+
} finally { fs.rmdirSync = original; f.dispose(); }
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("pre-apply refusal cancels without restoring or stranding launch behind an incomplete receipt", () => {
|
|
407
|
+
const f = fixture(), original = fs.renameSync; let injected = false;
|
|
408
|
+
try {
|
|
409
|
+
const before = inventory(f.project);
|
|
410
|
+
fs.renameSync = function(from, to) {
|
|
411
|
+
const value = original.call(fs, from, to);
|
|
412
|
+
if (!injected && String(to).startsWith(f.at(".nccgs/uninstall") + path.sep) && path.basename(to) === "manifest.json" && read(to).status === "prepared") {
|
|
413
|
+
injected = true; f.write(".nccgs/sessions/legacy-competitor.lock", { schemaVersion: 1, host: os.hostname(), pid: process.pid, token: crypto.randomUUID(), phase: "owned", child: null });
|
|
414
|
+
}
|
|
415
|
+
return value;
|
|
416
|
+
};
|
|
417
|
+
try { assert.throws(() => uninstall(f.project), /cancelled before changing files/); } finally { fs.renameSync = original; }
|
|
418
|
+
assert.ok(injected); const ids = fs.readdirSync(f.at(".nccgs/uninstall")); assert.equal(ids.length, 1);
|
|
419
|
+
assert.equal(read(f.at(`.nccgs/uninstall/${ids[0]}/manifest.json`)).status, "cancelled");
|
|
420
|
+
fs.unlinkSync(f.at(".nccgs/sessions/legacy-competitor.lock")); assert.deepEqual(inventory(f.project), before);
|
|
421
|
+
assert.equal(f.run("run", "--dry-run").status, 0); assert.equal(f.run("agents", "--enable", "nccgs-network-programmer").status, 0);
|
|
422
|
+
} finally { fs.renameSync = original; f.dispose(); }
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("apply and automatic rollback keep admissions closed without weakening process checks", () => {
|
|
426
|
+
const f = fixture(), original = fs.unlinkSync; let injected = false;
|
|
427
|
+
try {
|
|
428
|
+
const before = inventory(f.project);
|
|
429
|
+
fs.unlinkSync = function(file, ...args) {
|
|
430
|
+
const value = original.call(fs, file, ...args);
|
|
431
|
+
if (!injected && String(file).startsWith(f.at(".claude") + path.sep)) {
|
|
432
|
+
injected = true;
|
|
433
|
+
assert.throws(() => acquireInstallationLease(f.project, "nccgs-agents"), /active or unverified/);
|
|
434
|
+
assert.equal(fs.readdirSync(f.at(".nccgs/sessions")).length, 0);
|
|
435
|
+
throw new Error("controlled interruption during apply");
|
|
436
|
+
}
|
|
437
|
+
return value;
|
|
438
|
+
};
|
|
439
|
+
try { assert.throws(() => uninstall(f.project), /all planned files restored/); } finally { fs.unlinkSync = original; }
|
|
440
|
+
assert.ok(injected); assert.deepEqual(inventory(f.project), before);
|
|
441
|
+
const lease = acquireInstallationLease(f.project, "nccgs-agents");
|
|
442
|
+
try { assert.throws(() => uninstall(f.project), /active or unverified process lock/); } finally { lease.release(); }
|
|
443
|
+
assert.deepEqual(inventory(f.project), before);
|
|
444
|
+
} finally { fs.unlinkSync = original; f.dispose(); }
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("agents, migration and policy writers refuse active and interrupted uninstall before changes", () => {
|
|
448
|
+
const f = fixture();
|
|
449
|
+
try {
|
|
450
|
+
const id = crypto.randomUUID(), receipt = `.nccgs/uninstall/${id}/manifest.json`;
|
|
451
|
+
for (const state of ["applying", "failed", "active-lock"]) {
|
|
452
|
+
if (state === "active-lock") f.write(".nccgs/uninstall.lock", { schemaVersion: 1, host: os.hostname(), pid: process.pid, token: crypto.randomUUID(), phase: "owned", child: null });
|
|
453
|
+
else f.write(receipt, { status: state });
|
|
454
|
+
const before = inventory(f.project);
|
|
455
|
+
assert.notEqual(f.run("agents", "--profile", "astra-claude").status, 0);
|
|
456
|
+
assert.notEqual(f.run("migrate-execution", "--to", "standalone").status, 0);
|
|
457
|
+
assert.throws(() => compile(f.project, true), /uninstall/);
|
|
458
|
+
assert.deepEqual(inventory(f.project), before);
|
|
459
|
+
fs.unlinkSync(f.at(state === "active-lock" ? ".nccgs/uninstall.lock" : receipt));
|
|
460
|
+
}
|
|
461
|
+
assert.equal(f.run("agents", "--profile", "astra-claude").status, 0);
|
|
462
|
+
assert.equal(f.run("migrate-execution", "--to", "standalone", "--model-profile", "claude-standalone").status, 0);
|
|
463
|
+
assert.equal(fs.readdirSync(f.at(".nccgs/sessions")).length, 0);
|
|
464
|
+
} finally { f.dispose(); }
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("junction or symlink in a managed path cannot redirect deletion outside the project", t => {
|
|
468
|
+
const f = fixture();
|
|
469
|
+
try {
|
|
470
|
+
const outside = path.join(f.directory, "outside"); fs.mkdirSync(outside); fs.writeFileSync(path.join(outside, "sentinel.txt"), "untouched");
|
|
471
|
+
const link = f.at(".claude/nccgs/trap");
|
|
472
|
+
try { fs.symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir"); }
|
|
473
|
+
catch (error) { if (["EPERM", "EACCES"].includes(error.code)) return t.skip("Host does not permit symlinks"); throw error; }
|
|
474
|
+
const state = read(f.at(".claude/nccgs/install-state.json")); state.files.push({ relative: ".claude/nccgs/trap/sentinel.txt", sha256: hash("untouched") }); f.write(".claude/nccgs/install-state.json", state);
|
|
475
|
+
assert.notEqual(f.run("uninstall").status, 0); assert.equal(fs.readFileSync(path.join(outside, "sentinel.txt"), "utf8"), "untouched"); fs.unlinkSync(link);
|
|
476
|
+
} finally { f.dispose(); }
|
|
477
|
+
});
|