gentle-pi 2.4.0 → 2.6.0
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/README.md +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
+
import childProcess from "node:child_process";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import { syncBuiltinESMExports } from "node:module";
|
|
6
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { tmpdir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
9
10
|
import test from "node:test";
|
|
10
11
|
import { gzipSync } from "node:zlib";
|
|
11
12
|
import {
|
|
12
13
|
CandidateViewRegistry,
|
|
13
14
|
CandidateViewError,
|
|
15
|
+
hasExpectedExecutableBits,
|
|
14
16
|
type CandidateGitExecutor,
|
|
15
17
|
createCandidateView,
|
|
16
18
|
decodeCandidateContextManifest,
|
|
@@ -18,7 +20,9 @@ import {
|
|
|
18
20
|
digestChangedPathManifest,
|
|
19
21
|
injectReviewCandidateView,
|
|
20
22
|
readCandidateContextManifestPage,
|
|
23
|
+
type NativeCandidateProjectionDescriptor,
|
|
21
24
|
} from "../lib/review-candidate-view.ts";
|
|
25
|
+
import { assertCandidateOwnerParent, assertTrustedWindowsOwner, prepareCandidateOwnerParent, setWindowsAclAuthorityForTesting, validatePrivateWindowsDacl, validatePrivateWindowsOwner, WindowsDaclValidationError, WindowsOwnerValidationError } from "../lib/review-candidate-view-owner.ts";
|
|
22
26
|
|
|
23
27
|
function git(cwd: string, ...arguments_: string[]): string {
|
|
24
28
|
return execFileSync("git", arguments_, { cwd, encoding: "utf8" }).trim();
|
|
@@ -26,7 +30,10 @@ function git(cwd: string, ...arguments_: string[]): string {
|
|
|
26
30
|
|
|
27
31
|
function repository(t: test.TestContext): string {
|
|
28
32
|
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-candidate-view-"));
|
|
29
|
-
t.after(() =>
|
|
33
|
+
t.after(() => {
|
|
34
|
+
if (process.platform !== "win32") execFileSync("chmod", ["-R", "u+rwx", cwd]);
|
|
35
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
36
|
+
});
|
|
30
37
|
git(cwd, "init", "-b", "main");
|
|
31
38
|
writeFileSync(join(cwd, "tracked.txt"), "base\n");
|
|
32
39
|
git(cwd, "add", "tracked.txt");
|
|
@@ -34,6 +41,529 @@ function repository(t: test.TestContext): string {
|
|
|
34
41
|
return cwd;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
function ownerMarker(root: string): string { return `${root}.owner.json`; }
|
|
45
|
+
|
|
46
|
+
function mockWindowsAcl(t: test.TestContext): void {
|
|
47
|
+
setWindowsAclAuthorityForTesting(() => {});
|
|
48
|
+
t.after(() => setWindowsAclAuthorityForTesting());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function orphanFixture(t: test.TestContext) {
|
|
52
|
+
const cwd = repository(t);
|
|
53
|
+
const registry = new CandidateViewRegistry();
|
|
54
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
55
|
+
const marker = ownerMarker(view.root);
|
|
56
|
+
const owner = JSON.parse(readFileSync(marker, "utf8"));
|
|
57
|
+
owner.pid = 2147483000;
|
|
58
|
+
writeFileSync(marker, JSON.stringify(owner));
|
|
59
|
+
return { cwd, registry, view, marker, owner };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mockOwnerProbe(t: test.TestContext, code?: string): void {
|
|
63
|
+
t.mock.method(process, "kill", () => {
|
|
64
|
+
if (code) throw Object.assign(new Error("fixture probe"), { code });
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("candidate ownership is private and durable before worktree add; ordinary cleanup removes its marker", (t) => {
|
|
70
|
+
const cwd = repository(t);
|
|
71
|
+
let observed = false;
|
|
72
|
+
let syncs = 0;
|
|
73
|
+
const fsync = fs.fsyncSync;
|
|
74
|
+
t.mock.method(fs, "fsyncSync", (fd: number) => { syncs++; fsync(fd); });
|
|
75
|
+
syncBuiltinESMExports();
|
|
76
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
77
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
78
|
+
if (args[0] === "worktree" && args[1] === "add") {
|
|
79
|
+
const root = args[args.length - 2]!;
|
|
80
|
+
const marker = ownerMarker(root);
|
|
81
|
+
const owner = JSON.parse(readFileSync(marker, "utf8"));
|
|
82
|
+
assert.equal(owner.root, root);
|
|
83
|
+
assert.equal(owner.pid, process.pid);
|
|
84
|
+
assert.match(owner.token, /^[0-9a-f-]{36}$/);
|
|
85
|
+
if (process.platform !== "win32") assert.equal(lstatSync(marker).mode & 0o777, 0o600);
|
|
86
|
+
assert.equal(existsSync(root), false);
|
|
87
|
+
assert.ok(syncs >= (process.platform === "win32" ? 1 : 4), "marker and every newly created ancestor must be durable before add");
|
|
88
|
+
observed = true;
|
|
89
|
+
}
|
|
90
|
+
return execFileSync(file, args, options);
|
|
91
|
+
});
|
|
92
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
93
|
+
assert.equal(observed, true);
|
|
94
|
+
view.cleanup();
|
|
95
|
+
assert.equal(existsSync(view.root), false);
|
|
96
|
+
assert.equal(existsSync(ownerMarker(view.root)), false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("private candidate owner accepts safe Windows artifacts", (t) => {
|
|
100
|
+
mockWindowsAcl(t);
|
|
101
|
+
const view = new CandidateViewRegistry(undefined, "win32").create({ contributorRoot: repository(t) });
|
|
102
|
+
try {
|
|
103
|
+
assert.equal(lstatSync(ownerMarker(view.root)).isFile(), true);
|
|
104
|
+
assert.doesNotThrow(() => view.verify());
|
|
105
|
+
} finally {
|
|
106
|
+
view.cleanup();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("private candidate owner accepts a canonical Windows worktree registration spelling", { skip: process.platform !== "win32" }, (t) => {
|
|
111
|
+
let root: string | undefined;
|
|
112
|
+
const registry = new CandidateViewRegistry((file, arguments_, options) => {
|
|
113
|
+
const output = execFileSync(file, arguments_, options);
|
|
114
|
+
if (root !== undefined && arguments_[0] === "worktree" && arguments_[1] === "list" && typeof output === "string") {
|
|
115
|
+
return output.replace(`worktree ${root}\0`, `worktree \\\\?\\${root}\0`);
|
|
116
|
+
}
|
|
117
|
+
return output;
|
|
118
|
+
}, "win32");
|
|
119
|
+
const view = registry.create({ contributorRoot: repository(t) });
|
|
120
|
+
root = view.root;
|
|
121
|
+
view.cleanup();
|
|
122
|
+
assert.equal(existsSync(root), false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("private candidate owner rejects an untrusted conditional Windows allow ACE", () => {
|
|
126
|
+
assert.throws(
|
|
127
|
+
() => validatePrivateWindowsDacl("D:P(A;OICI;FA;;;S-1-5-21-1)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(XA;OICI;FA;;;S-1-5-21-2)", "S-1-5-21-1", true),
|
|
128
|
+
(error: unknown) => error instanceof WindowsDaclValidationError && error.reason === "ace-type" && !error.message.includes("S-1-5-21-2"),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("private candidate owner reports bounded Windows DACL mismatch reasons", () => {
|
|
133
|
+
const dacl = "D:P(A;OICI;FA;;;S-1-5-21-1)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)";
|
|
134
|
+
for (const [reason, invalid] of [["ace-flags", dacl.replace("OICI", "OI")], ["ace-rights", dacl.replace("FA", "FR")], ["ace-reserved-fields", dacl.replace(";;;S-1-5-21-1", ";object;;S-1-5-21-1")], ["ace-trustee", dacl.replace("S-1-5-21-1", "S-1-5-21-2")], ["ace-duplicate", `${dacl}(A;OICI;FA;;;SY)`], ["ace-count", dacl.replace("(A;OICI;FA;;;BA)", "")]] as const) {
|
|
135
|
+
assert.throws(() => validatePrivateWindowsDacl(invalid, "S-1-5-21-1", true), (error: unknown) => error instanceof WindowsDaclValidationError && error.reason === reason && error.message === `Windows DACL validation failed: ${reason}${reason === "ace-trustee" ? " (sid)" : ""}`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("private candidate owner reports bounded Windows DACL mismatch reasons for trustees", () => {
|
|
140
|
+
const dacl = "D:P(A;OICI;FA;;;S-1-5-21-1)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)";
|
|
141
|
+
for (const [trustee, classification] of [["OW", "OW"], ["CO", "CO"], ["BU", "BU"], ["AU", "AU"], ["WD", "WD"], ["LA", "LA"], ["S-1-5-21-999-888-777-666", "sid"], ["unknown", "other"], ["", "missing"]] as const) {
|
|
142
|
+
assert.throws(() => validatePrivateWindowsDacl(dacl.replace("S-1-5-21-1", trustee), "S-1-5-21-1", true), (error: unknown) => error instanceof WindowsDaclValidationError && error.reason === "ace-trustee" && error.trustee === classification && error.message === `Windows DACL validation failed: ace-trustee (${classification})` && (trustee === classification || trustee === "" || !error.message.includes(trustee)));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("private candidate owner accepts LA for the exact local Administrator SID", () => {
|
|
147
|
+
assert.doesNotThrow(() => validatePrivateWindowsDacl("D:P(A;OICI;FA;;;LA)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "S-1-5-21-1-2-3-500", true, "S-1-5-21-1-2-3-500"));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("private candidate owner rejects LA for a non-local RID-500 current user when the local Administrator differs", () => {
|
|
151
|
+
assert.throws(() => validatePrivateWindowsDacl("D:P(A;OICI;FA;;;LA)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "S-1-5-21-1-2-3-500", true, "S-1-5-21-4-5-6-500"), (error: unknown) => error instanceof WindowsDaclValidationError && error.reason === "ace-trustee" && error.trustee === "LA");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("private candidate owner rejects raw and LA local Administrator grants as duplicates", () => {
|
|
155
|
+
assert.throws(() => validatePrivateWindowsDacl("D:P(A;OICI;FA;;;S-1-5-21-1-2-3-500)(A;OICI;FA;;;LA)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "S-1-5-21-1-2-3-500", true, "S-1-5-21-1-2-3-500"), (error: unknown) => error instanceof WindowsDaclValidationError && error.reason === "ace-duplicate");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("private candidate owner fails closed when local Administrator resolution is unavailable", () => {
|
|
159
|
+
assert.throws(() => validatePrivateWindowsDacl("D:P(A;OICI;FA;;;LA)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "S-1-5-21-1-2-3-500", true), (error: unknown) => error instanceof WindowsDaclValidationError && error.reason === "ace-trustee" && error.trustee === "LA");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("private candidate owner accepts only trusted Windows owners", () => {
|
|
163
|
+
for (const owner of ["S-1-5-21-1-2-3-1001", "S-1-5-18", "S-1-5-32-544"]) assert.doesNotThrow(() => validatePrivateWindowsOwner(owner, "S-1-5-21-1-2-3-1001"));
|
|
164
|
+
assert.throws(() => validatePrivateWindowsOwner("S-1-5-21-4-5-6-1002", "S-1-5-21-1-2-3-1001"), (error: unknown) => error instanceof WindowsOwnerValidationError && !error.message.includes("S-1-5-21-4-5-6-1002"));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("private candidate owner fails closed when Windows owner resolution is unavailable", () => {
|
|
168
|
+
assert.throws(() => validatePrivateWindowsOwner(undefined, "S-1-5-21-1-2-3-1001"), WindowsOwnerValidationError);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("private candidate owner rejects a different Windows owner at the ancestor replacement boundary", (t) => {
|
|
172
|
+
const cwd = repository(t), commonDir = join(cwd, ".git"), control = join(commonDir, "gentle-ai"), parent = join(control, "candidate-views");
|
|
173
|
+
mkdirSync(parent, { recursive: true });
|
|
174
|
+
setWindowsAclAuthorityForTesting((path) => {
|
|
175
|
+
if (path === control) validatePrivateWindowsOwner("S-1-5-21-4-5-6-1002", "S-1-5-21-1-2-3-1001");
|
|
176
|
+
});
|
|
177
|
+
t.after(() => setWindowsAclAuthorityForTesting());
|
|
178
|
+
assert.throws(() => prepareCandidateOwnerParent(commonDir, "win32"), (error: unknown) => error instanceof WindowsOwnerValidationError && error.owner === "sid");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("private candidate owner revalidates every Windows ancestor replacement boundary", (t) => {
|
|
182
|
+
const cwd = repository(t), commonDir = join(cwd, ".git"), control = join(commonDir, "gentle-ai"), parent = join(control, "candidate-views");
|
|
183
|
+
mkdirSync(parent, { recursive: true });
|
|
184
|
+
const checked: string[] = [];
|
|
185
|
+
setWindowsAclAuthorityForTesting((path) => checked.push(path));
|
|
186
|
+
t.after(() => setWindowsAclAuthorityForTesting());
|
|
187
|
+
assert.equal(assertCandidateOwnerParent(commonDir, "win32"), parent);
|
|
188
|
+
assert.deepEqual(checked, [commonDir, control, parent]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("private candidate owner rejects a controlled foreign Windows owner", { skip: process.platform !== "win32" }, (t) => {
|
|
192
|
+
const path = mkdtempSync(join(tmpdir(), "gentle-pi-foreign-owner-"));
|
|
193
|
+
t.after(() => rmSync(path, { recursive: true, force: true }));
|
|
194
|
+
try {
|
|
195
|
+
execFileSync(realpathSync.native("\\\\?\\GLOBALROOT\\SystemRoot\\System32\\icacls.exe"), [path, "/setowner", "*S-1-5-32-545"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
196
|
+
} catch {
|
|
197
|
+
if (process.env.GITHUB_ACTIONS === "true" || process.env.CI === "true") assert.fail("Windows CI could not assign BUILTIN\\Users as the controlled temporary directory owner");
|
|
198
|
+
t.skip("Current Windows token cannot assign BUILTIN\\Users as the controlled temporary directory owner");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
assert.throws(() => assertTrustedWindowsOwner(path, "directory"), (error: unknown) => error instanceof WindowsOwnerValidationError && error.owner === "sid");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("private candidate owner reads trusted Windows directory and marker file owners", { skip: process.platform !== "win32" }, (t) => {
|
|
205
|
+
const cwd = repository(t), commonDir = join(cwd, ".git"), parent = join(commonDir, "gentle-ai", "candidate-views");
|
|
206
|
+
const view = new CandidateViewRegistry().create({ contributorRoot: cwd });
|
|
207
|
+
try {
|
|
208
|
+
assert.doesNotThrow(() => assertTrustedWindowsOwner(parent, "directory"));
|
|
209
|
+
assert.doesNotThrow(() => assertTrustedWindowsOwner(ownerMarker(view.root), "file"));
|
|
210
|
+
assert.doesNotThrow(() => view.verify());
|
|
211
|
+
} finally {
|
|
212
|
+
view.cleanup();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("private candidate owner removes unrelated explicit Windows grants during enforcement", { skip: process.platform !== "win32" }, (t) => {
|
|
217
|
+
const cwd = repository(t), commonDir = join(cwd, ".git"), parent = join(commonDir, "gentle-ai", "candidate-views");
|
|
218
|
+
const initial = new CandidateViewRegistry().create({ contributorRoot: cwd });
|
|
219
|
+
initial.cleanup();
|
|
220
|
+
const system = process.env.SystemRoot!;
|
|
221
|
+
execFileSync(`${system}\\System32\\icacls.exe`, [parent, "/grant", "*S-1-5-32-545:(OI)(CI)F"], { encoding: "utf8" });
|
|
222
|
+
const archive = ".candidate-owner-acl.txt";
|
|
223
|
+
try {
|
|
224
|
+
execFileSync(`${system}\\System32\\icacls.exe`, [parent, "/save", archive, "/c"], { cwd: parent, encoding: "utf8" });
|
|
225
|
+
const before = readFileSync(join(parent, archive), "utf16le").match(/D:[^\r\n]+/)?.[0];
|
|
226
|
+
assert.ok(before);
|
|
227
|
+
assert.match(before, /;;;(?:BU|S-1-5-32-545)\)/i);
|
|
228
|
+
assert.doesNotThrow(() => prepareCandidateOwnerParent(commonDir));
|
|
229
|
+
execFileSync(`${system}\\System32\\icacls.exe`, [parent, "/save", archive, "/c"], { cwd: parent, encoding: "utf8" });
|
|
230
|
+
const after = readFileSync(join(parent, archive), "utf16le").match(/D:[^\r\n]+/)?.[0];
|
|
231
|
+
assert.ok(after);
|
|
232
|
+
assert.doesNotMatch(after, /;;;(?:BU|S-1-5-32-545)\)/i);
|
|
233
|
+
} finally {
|
|
234
|
+
rmSync(join(parent, archive), { force: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("candidate contributor root accepts canonical Windows Git path spelling", { skip: process.platform !== "win32" }, (t) => {
|
|
239
|
+
const contributorRoot = repository(t);
|
|
240
|
+
let useCanonicalSpelling = false;
|
|
241
|
+
const registry = new CandidateViewRegistry((file, arguments_, options) => {
|
|
242
|
+
const output = execFileSync(file, arguments_, options);
|
|
243
|
+
if (!useCanonicalSpelling || typeof output !== "string" || arguments_[0] !== "rev-parse") return output;
|
|
244
|
+
if (arguments_[1] === "--show-toplevel") return output.toUpperCase();
|
|
245
|
+
if (arguments_[1] === "--git-common-dir") return resolve(contributorRoot, output).toUpperCase();
|
|
246
|
+
return output;
|
|
247
|
+
}, "win32");
|
|
248
|
+
mockWindowsAcl(t);
|
|
249
|
+
const view = registry.create({ contributorRoot });
|
|
250
|
+
try {
|
|
251
|
+
useCanonicalSpelling = true;
|
|
252
|
+
assert.doesNotThrow(() => view.verify());
|
|
253
|
+
} finally {
|
|
254
|
+
view.cleanup();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("private candidate owner ignores a spoofed SystemRoot when invoking Windows ACL tools", { skip: process.platform !== "win32" }, (t) => {
|
|
259
|
+
const originalSystemRoot = process.env.SystemRoot;
|
|
260
|
+
const spoofedSystemRoot = join(tmpdir(), "gentle-pi-spoofed-SystemRoot");
|
|
261
|
+
process.env.SystemRoot = spoofedSystemRoot;
|
|
262
|
+
t.after(() => {
|
|
263
|
+
if (originalSystemRoot === undefined) delete process.env.SystemRoot;
|
|
264
|
+
else process.env.SystemRoot = originalSystemRoot;
|
|
265
|
+
});
|
|
266
|
+
const execFile = childProcess.execFileSync;
|
|
267
|
+
const aclExecutables: string[] = [];
|
|
268
|
+
t.mock.method(childProcess, "execFileSync", (file, arguments_, options) => {
|
|
269
|
+
if (typeof file === "string" && /\\(?:whoami|icacls)\.exe$/i.test(file)) {
|
|
270
|
+
aclExecutables.push(file);
|
|
271
|
+
assert.equal(file.toLowerCase().startsWith(spoofedSystemRoot.toLowerCase()), false);
|
|
272
|
+
}
|
|
273
|
+
return execFile(file, arguments_, options);
|
|
274
|
+
});
|
|
275
|
+
syncBuiltinESMExports();
|
|
276
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
277
|
+
const view = new CandidateViewRegistry().create({ contributorRoot: repository(t) });
|
|
278
|
+
try {
|
|
279
|
+
view.verify();
|
|
280
|
+
assert.ok(aclExecutables.length > 0);
|
|
281
|
+
} finally {
|
|
282
|
+
view.cleanup();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("private candidate owner reads back a restrictive Windows DACL before worktree materialization", { skip: process.platform !== "win32" }, (t) => {
|
|
287
|
+
const system = process.env.SystemRoot!;
|
|
288
|
+
const view = new CandidateViewRegistry().create({ contributorRoot: repository(t) });
|
|
289
|
+
try {
|
|
290
|
+
const parent = join(view.root, ".."), archive = ".candidate-owner-acl.txt";
|
|
291
|
+
execFileSync(`${system}\\System32\\icacls.exe`, [parent, "/save", archive, "/c"], { cwd: parent, encoding: "utf8" });
|
|
292
|
+
const sddl = readFileSync(join(parent, archive), "utf16le");
|
|
293
|
+
rmSync(join(parent, archive));
|
|
294
|
+
const dacl = sddl.match(/D:[^\r\n]+/)?.[0];
|
|
295
|
+
assert.ok(dacl);
|
|
296
|
+
assert.doesNotThrow(() => view.verify());
|
|
297
|
+
} finally {
|
|
298
|
+
view.cleanup();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
function publicationFailure(t: test.TestContext, phase: "write" | "fsync" | "directory"): void {
|
|
303
|
+
mockWindowsAcl(t);
|
|
304
|
+
if (phase === "directory") {
|
|
305
|
+
const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
306
|
+
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
|
|
307
|
+
t.after(() => Object.defineProperty(process, "platform", descriptor));
|
|
308
|
+
}
|
|
309
|
+
const cwd = repository(t), parent = join(cwd, ".git", "gentle-ai", "candidate-views");
|
|
310
|
+
let adds = 0;
|
|
311
|
+
if (phase === "write") t.mock.method(fs, "writeFileSync", () => { throw new Error("fixture marker write failure"); });
|
|
312
|
+
if (phase === "fsync") t.mock.method(fs, "fsyncSync", () => { throw new Error("fixture marker fsync failure"); });
|
|
313
|
+
if (phase === "directory") {
|
|
314
|
+
const fsync = fs.fsyncSync;
|
|
315
|
+
let syncs = 0;
|
|
316
|
+
t.mock.method(fs, "fsyncSync", (fd: number) => {
|
|
317
|
+
if (++syncs === 1) return fsync(fd);
|
|
318
|
+
throw new Error("fixture marker directory sync failure");
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
syncBuiltinESMExports();
|
|
322
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
323
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
324
|
+
if (args[0] === "worktree" && args[1] === "add") adds++;
|
|
325
|
+
return execFileSync(file, args, options);
|
|
326
|
+
}, "win32");
|
|
327
|
+
assert.throws(
|
|
328
|
+
() => registry.create({ contributorRoot: cwd }),
|
|
329
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "candidate-owner-preparation-failed",
|
|
330
|
+
);
|
|
331
|
+
assert.equal(adds, 0);
|
|
332
|
+
assert.deepEqual(readdirSync(parent).filter((name) => name.endsWith(".owner.json") || name.endsWith(".reaper-lock")), []);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const [phase, label] of [["write", "marker write"], ["fsync", "marker fsync"], ["directory", "post-marker directory sync"]] as const) {
|
|
336
|
+
test(`candidate owner publication rolls back after ${label} failure`, (t) => publicationFailure(t, phase));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
test("candidate owner publication never removes a replaced marker", (t) => {
|
|
340
|
+
mockWindowsAcl(t);
|
|
341
|
+
const cwd = repository(t), parent = join(cwd, ".git", "gentle-ai", "candidate-views");
|
|
342
|
+
const write = fs.writeFileSync;
|
|
343
|
+
t.mock.method(fs, "fsyncSync", () => {
|
|
344
|
+
const marker = readdirSync(parent).find((name) => name.endsWith(".owner.json"));
|
|
345
|
+
assert.ok(marker);
|
|
346
|
+
renameSync(join(parent, marker!), join(parent, `${marker}.saved`));
|
|
347
|
+
write(join(parent, marker!), "replacement", { mode: 0o600 });
|
|
348
|
+
throw new Error("fixture marker fsync failure after replacement");
|
|
349
|
+
});
|
|
350
|
+
syncBuiltinESMExports();
|
|
351
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
352
|
+
let adds = 0;
|
|
353
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
354
|
+
if (args[0] === "worktree" && args[1] === "add") adds++;
|
|
355
|
+
return execFileSync(file, args, options);
|
|
356
|
+
}, "win32");
|
|
357
|
+
assert.throws(() => registry.create({ contributorRoot: cwd }), CandidateViewError);
|
|
358
|
+
assert.equal(adds, 0);
|
|
359
|
+
const marker = readdirSync(parent).find((name) => name.endsWith(".owner.json"));
|
|
360
|
+
assert.ok(marker);
|
|
361
|
+
assert.equal(readFileSync(join(parent, marker!), "utf8"), "replacement");
|
|
362
|
+
assert.equal(readdirSync(parent).some((name) => name.endsWith(".reaper-lock")), false);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("candidate owner rejects an absent POSIX getuid before worktree creation", { skip: process.platform === "win32" }, (t) => {
|
|
366
|
+
const originalGetuid = process.getuid;
|
|
367
|
+
Object.defineProperty(process, "getuid", { configurable: true, value: undefined });
|
|
368
|
+
t.after(() => Object.defineProperty(process, "getuid", { configurable: true, value: originalGetuid }));
|
|
369
|
+
assert.throws(
|
|
370
|
+
() => new CandidateViewRegistry().create({ contributorRoot: repository(t) }),
|
|
371
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "candidate-owner-preparation-failed",
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("candidate owner rejects a POSIX UID mismatch before worktree creation", { skip: process.platform === "win32" }, (t) => {
|
|
376
|
+
const uid = process.getuid?.();
|
|
377
|
+
assert.equal(typeof uid, "number");
|
|
378
|
+
const originalGetuid = process.getuid;
|
|
379
|
+
Object.defineProperty(process, "getuid", { configurable: true, value: () => uid! + 1 });
|
|
380
|
+
t.after(() => Object.defineProperty(process, "getuid", { configurable: true, value: originalGetuid }));
|
|
381
|
+
assert.throws(
|
|
382
|
+
() => new CandidateViewRegistry().create({ contributorRoot: repository(t) }),
|
|
383
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "candidate-owner-preparation-failed",
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("marked registered definitively dead candidate is reclaimed before another materialization", (t) => {
|
|
388
|
+
const { cwd, view, marker } = orphanFixture(t);
|
|
389
|
+
mockOwnerProbe(t, "ESRCH");
|
|
390
|
+
const next = new CandidateViewRegistry().create({ contributorRoot: cwd });
|
|
391
|
+
assert.equal(existsSync(view.root), false);
|
|
392
|
+
assert.equal(existsSync(marker), false);
|
|
393
|
+
assert.ok(!git(cwd, "worktree", "list", "--porcelain").includes(view.root));
|
|
394
|
+
next.cleanup();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
for (const scenario of ["self", "live", "EPERM", "unknown", "malformed", "foreign-host", "unknown-host", "invalid-pid", "extra-key", "legacy", "unregistered", "root-symlink", "marker-symlink", "parent-symlink", "escape", "unknown-lock", "public-marker", "locked", "backlink", "admin-symlink", "missing-root"] as const) {
|
|
398
|
+
test(`orphan sweep preserves ${scenario} ownership`, (t) => {
|
|
399
|
+
const { cwd, view, marker, owner } = orphanFixture(t);
|
|
400
|
+
mockOwnerProbe(t, scenario === "live" ? undefined : scenario === "EPERM" ? "EPERM" : scenario === "unknown" ? "EIO" : "ESRCH");
|
|
401
|
+
if (scenario === "self") owner.pid = process.pid;
|
|
402
|
+
if (scenario === "foreign-host") owner.host = "another-host";
|
|
403
|
+
if (scenario === "unknown-host") owner.host = null;
|
|
404
|
+
if (scenario === "invalid-pid") owner.pid = -1;
|
|
405
|
+
if (scenario === "extra-key") owner.extra = true;
|
|
406
|
+
if (scenario === "escape") owner.root = cwd;
|
|
407
|
+
writeFileSync(marker, scenario === "malformed" ? "{" : JSON.stringify(owner));
|
|
408
|
+
if (scenario === "legacy") rmSync(marker);
|
|
409
|
+
if (scenario === "public-marker") chmodSync(marker, 0o644);
|
|
410
|
+
if (scenario === "locked") git(cwd, "worktree", "lock", view.root);
|
|
411
|
+
if (scenario === "backlink" || scenario === "admin-symlink") {
|
|
412
|
+
const admin = readFileSync(join(view.root, ".git"), "utf8").slice(8).trim();
|
|
413
|
+
if (scenario === "backlink") writeFileSync(join(admin, "gitdir"), `${cwd}/.git\n`);
|
|
414
|
+
else { renameSync(admin, `${admin}.saved`); symlinkSync(`${admin}.saved`, admin); }
|
|
415
|
+
}
|
|
416
|
+
if (scenario === "missing-root") renameSync(view.root, `${view.root}.saved`);
|
|
417
|
+
if (scenario === "unknown-lock") writeFileSync(`${view.root}.reaper-lock`, "unknown");
|
|
418
|
+
if (scenario === "unregistered") {
|
|
419
|
+
execFileSync("chmod", ["-R", "u+rwx", view.root]);
|
|
420
|
+
git(cwd, "worktree", "remove", "--force", view.root);
|
|
421
|
+
mkdirSync(view.root);
|
|
422
|
+
writeFileSync(join(view.root, "preserve.txt"), "unregistered");
|
|
423
|
+
}
|
|
424
|
+
if (scenario === "root-symlink") { renameSync(view.root, `${view.root}.saved`); symlinkSync(`${view.root}.saved`, view.root); }
|
|
425
|
+
if (scenario === "marker-symlink") { renameSync(marker, `${marker}.saved`); symlinkSync(`${marker}.saved`, marker); }
|
|
426
|
+
if (scenario === "parent-symlink") {
|
|
427
|
+
const parent = join(view.root, "..");
|
|
428
|
+
renameSync(parent, `${parent}.saved`);
|
|
429
|
+
symlinkSync(`${parent}.saved`, parent);
|
|
430
|
+
}
|
|
431
|
+
new CandidateViewRegistry().sweepOrphans(cwd);
|
|
432
|
+
assert.equal(existsSync(view.root), scenario !== "missing-root");
|
|
433
|
+
assert.equal(existsSync(marker), scenario !== "legacy");
|
|
434
|
+
assert.equal(readFileSync(join(cwd, "tracked.txt"), "utf8"), "base\n");
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
test("reaper lock excludes concurrent sweeps and rechecks marker identity before removal", (t) => {
|
|
439
|
+
const { cwd, view, marker, owner } = orphanFixture(t);
|
|
440
|
+
mockOwnerProbe(t, "ESRCH");
|
|
441
|
+
let lists = 0;
|
|
442
|
+
const racing = new CandidateViewRegistry((file, args, options) => {
|
|
443
|
+
if (args.includes("list")) {
|
|
444
|
+
lists++;
|
|
445
|
+
new CandidateViewRegistry().sweepOrphans(cwd);
|
|
446
|
+
if (lists === 2) writeFileSync(marker, JSON.stringify({ ...owner, token: "00000000-0000-4000-8000-000000000000" }));
|
|
447
|
+
}
|
|
448
|
+
return execFileSync(file, args, options);
|
|
449
|
+
});
|
|
450
|
+
racing.sweepOrphans(cwd);
|
|
451
|
+
assert.ok(lists >= 2);
|
|
452
|
+
assert.equal(existsSync(view.root), true);
|
|
453
|
+
assert.equal(existsSync(`${view.root}.reaper-lock`), false);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("failed Git removal preserves owned candidate and marker without recursive fallback", (t) => {
|
|
457
|
+
const cwd = repository(t);
|
|
458
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
459
|
+
if (args.includes("remove")) throw new Error("fixture removal failure");
|
|
460
|
+
return execFileSync(file, args, options);
|
|
461
|
+
});
|
|
462
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
463
|
+
assert.throws(() => view.cleanup());
|
|
464
|
+
assert.equal(existsSync(view.root), true);
|
|
465
|
+
assert.equal(existsSync(ownerMarker(view.root)), true);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("failed creation retains a recoverable marker when Git removal also fails", (t) => {
|
|
469
|
+
const cwd = repository(t);
|
|
470
|
+
let root = "";
|
|
471
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
472
|
+
if (args[0] === "worktree" && args[1] === "add") root = args[args.length - 2]!;
|
|
473
|
+
if (args.includes("remove") || (args[0] === "read-tree" && options.cwd === root)) throw new Error("fixture failure");
|
|
474
|
+
return execFileSync(file, args, options);
|
|
475
|
+
});
|
|
476
|
+
assert.throws(() => registry.create({ contributorRoot: cwd }));
|
|
477
|
+
assert.equal(existsSync(root), true);
|
|
478
|
+
const owner = JSON.parse(readFileSync(ownerMarker(root), "utf8"));
|
|
479
|
+
writeFileSync(ownerMarker(root), JSON.stringify({ ...owner, pid: 2147483000 }));
|
|
480
|
+
mockOwnerProbe(t, "ESRCH");
|
|
481
|
+
new CandidateViewRegistry().sweepOrphans(cwd);
|
|
482
|
+
assert.equal(existsSync(root), false);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("materialization rejects a symlinked owner ancestor without creating an external store", (t) => {
|
|
486
|
+
const cwd = repository(t);
|
|
487
|
+
const outside = join(cwd, "outside");
|
|
488
|
+
mkdirSync(outside);
|
|
489
|
+
symlinkSync(outside, join(cwd, ".git", "gentle-ai"));
|
|
490
|
+
assert.throws(() => new CandidateViewRegistry().create({ contributorRoot: cwd }));
|
|
491
|
+
assert.equal(existsSync(join(outside, "candidate-views")), false);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("owner fsync failure prevents worktree registration", (t) => {
|
|
495
|
+
const cwd = repository(t);
|
|
496
|
+
let adds = 0;
|
|
497
|
+
t.mock.method(fs, "fsyncSync", () => { throw new Error("fixture fsync failure"); });
|
|
498
|
+
syncBuiltinESMExports();
|
|
499
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
500
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
501
|
+
if (args[0] === "worktree" && args[1] === "add") adds++;
|
|
502
|
+
return execFileSync(file, args, options);
|
|
503
|
+
});
|
|
504
|
+
assert.throws(
|
|
505
|
+
() => registry.create({ contributorRoot: cwd }),
|
|
506
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "candidate-owner-preparation-failed" && error.cause instanceof Error && /fsync/.test(error.cause.message),
|
|
507
|
+
);
|
|
508
|
+
assert.equal(adds, 0);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("ordinary cleanup preserves mismatched owner and cleanupAll continues other records", (t) => {
|
|
512
|
+
const cwd = repository(t);
|
|
513
|
+
const registry = new CandidateViewRegistry();
|
|
514
|
+
const first = registry.create({ contributorRoot: cwd });
|
|
515
|
+
const second = registry.create({ contributorRoot: cwd });
|
|
516
|
+
const marker = ownerMarker(first.root);
|
|
517
|
+
const bytes = readFileSync(marker, "utf8");
|
|
518
|
+
writeFileSync(marker, JSON.stringify({ ...JSON.parse(bytes), token: "00000000-0000-4000-8000-000000000000" }));
|
|
519
|
+
registry.cleanupAll();
|
|
520
|
+
assert.equal(existsSync(first.root), true);
|
|
521
|
+
assert.equal(existsSync(marker), true);
|
|
522
|
+
assert.equal(existsSync(second.root), false);
|
|
523
|
+
writeFileSync(marker, bytes);
|
|
524
|
+
registry.cleanupAll();
|
|
525
|
+
assert.equal(existsSync(first.root), false);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
for (const race of ["root", "registration", "lock"] as const) {
|
|
529
|
+
test(`orphan reaper preserves a ${race} replacement at the Git race boundary`, (t) => {
|
|
530
|
+
const { cwd, view, marker } = orphanFixture(t);
|
|
531
|
+
mockOwnerProbe(t, "ESRCH");
|
|
532
|
+
let lists = 0;
|
|
533
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
534
|
+
const result = execFileSync(file, args, options);
|
|
535
|
+
if (args.includes("list") && ++lists === 2) {
|
|
536
|
+
if (race === "root") { renameSync(view.root, `${view.root}.saved`); mkdirSync(view.root); }
|
|
537
|
+
if (race === "registration") return String(result).replace(`worktree ${view.root}`, `worktree ${view.root}.other`);
|
|
538
|
+
if (race === "lock") writeFileSync(`${view.root}.reaper-lock`, "replacement");
|
|
539
|
+
}
|
|
540
|
+
return result;
|
|
541
|
+
});
|
|
542
|
+
registry.sweepOrphans(cwd);
|
|
543
|
+
assert.equal(existsSync(view.root), true);
|
|
544
|
+
assert.equal(existsSync(marker), true);
|
|
545
|
+
if (race === "lock") assert.equal(readFileSync(`${view.root}.reaper-lock`, "utf8"), "replacement");
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
test("ordinary cleanup never unlinks a replaced sidecar after Git removal", (t) => {
|
|
550
|
+
const cwd = repository(t);
|
|
551
|
+
let marker = "";
|
|
552
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
553
|
+
const result = execFileSync(file, args, options);
|
|
554
|
+
if (args[0] === "worktree" && args[1] === "remove") {
|
|
555
|
+
const bytes = readFileSync(marker);
|
|
556
|
+
renameSync(marker, `${marker}.saved`);
|
|
557
|
+
writeFileSync(marker, bytes, { mode: 0o600 });
|
|
558
|
+
}
|
|
559
|
+
return result;
|
|
560
|
+
});
|
|
561
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
562
|
+
marker = ownerMarker(view.root);
|
|
563
|
+
assert.throws(() => view.cleanup(), /ownership changed/i);
|
|
564
|
+
assert.equal(existsSync(marker), true);
|
|
565
|
+
});
|
|
566
|
+
|
|
37
567
|
function compactCandidateContextManifest(task: string): { encoded: string; sha256: string } {
|
|
38
568
|
const match = /Frozen changed scope manifest \(gzip\+base64url\): `([A-Za-z0-9_-]+)`\.\nFrozen changed scope manifest SHA-256: `([0-9a-f]{64})`\./.exec(task);
|
|
39
569
|
assert.ok(match, "expected a compact candidate context manifest");
|
|
@@ -379,6 +909,50 @@ test("candidate view preserves staged additions with explicit intended-untracked
|
|
|
379
909
|
}
|
|
380
910
|
});
|
|
381
911
|
|
|
912
|
+
test("candidate view detects a same-stat tracked rewrite with selected untracked content when trustctime is disabled", (t) => {
|
|
913
|
+
const contributorRoot = repository(t);
|
|
914
|
+
const tracked = join(contributorRoot, "tracked.txt");
|
|
915
|
+
const index = join(contributorRoot, ".git", "index");
|
|
916
|
+
// A fractional timestamp exposes Date-based timestamp restoration, which can
|
|
917
|
+
// collapse the private index onto the live index's racy-clean boundary.
|
|
918
|
+
const fixedTimestampSeconds = 946_684_800.654_321;
|
|
919
|
+
git(contributorRoot, "config", "core.trustctime", "false");
|
|
920
|
+
writeFileSync(tracked, "fixed\n");
|
|
921
|
+
utimesSync(tracked, fixedTimestampSeconds, fixedTimestampSeconds);
|
|
922
|
+
git(contributorRoot, "add", "tracked.txt");
|
|
923
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "fixed timestamp base");
|
|
924
|
+
writeFileSync(tracked, "other\n");
|
|
925
|
+
utimesSync(tracked, fixedTimestampSeconds, fixedTimestampSeconds);
|
|
926
|
+
utimesSync(index, fixedTimestampSeconds, fixedTimestampSeconds);
|
|
927
|
+
const liveIndexMtimeNs = lstatSync(index, { bigint: true }).mtimeNs;
|
|
928
|
+
if (liveIndexMtimeNs % 1_000_000n === 0n) t.diagnostic("filesystem does not retain sub-millisecond index mtimes; retaining the same-stat rewrite assertion");
|
|
929
|
+
else assert.notEqual(liveIndexMtimeNs % 1_000_000_000n, 0n, "sub-millisecond filesystem must retain the requested non-whole-second index mtime");
|
|
930
|
+
writeFileSync(join(contributorRoot, "selected.txt"), "selected\n");
|
|
931
|
+
|
|
932
|
+
let privateIndexMtimeNs: bigint | undefined;
|
|
933
|
+
const registry = new CandidateViewRegistry((file, arguments_, options) => {
|
|
934
|
+
if (arguments_[0] === "add" && privateIndexMtimeNs === undefined) {
|
|
935
|
+
const privateIndexPath = options.env?.GIT_INDEX_FILE;
|
|
936
|
+
assert.equal(typeof privateIndexPath, "string", "private candidate index must be set before Git refreshes tracked entries");
|
|
937
|
+
privateIndexMtimeNs = lstatSync(privateIndexPath, { bigint: true }).mtimeNs;
|
|
938
|
+
}
|
|
939
|
+
return execFileSync(file, arguments_, options);
|
|
940
|
+
});
|
|
941
|
+
const view = registry.create({ contributorRoot, intendedUntracked: ["selected.txt"] });
|
|
942
|
+
try {
|
|
943
|
+
assert.notEqual(privateIndexMtimeNs, undefined, "private index timestamp must be observed before Git add");
|
|
944
|
+
assert.ok(privateIndexMtimeNs! < liveIndexMtimeNs, "private index must be strictly earlier than the live index");
|
|
945
|
+
assert.ok(liveIndexMtimeNs - privateIndexMtimeNs! >= 1_000_000_000n, "private index must leave a bounded racy-clean safety interval");
|
|
946
|
+
assert.deepEqual(view.intendedUntracked, ["selected.txt"]);
|
|
947
|
+
assert.deepEqual(view.paths, ["selected.txt", "tracked.txt"]);
|
|
948
|
+
assert.notEqual(view.candidateTree, view.baseTree);
|
|
949
|
+
assert.equal(readFileSync(join(view.root, "tracked.txt"), "utf8"), "other\n");
|
|
950
|
+
assert.equal(readFileSync(join(view.root, "selected.txt"), "utf8"), "selected\n");
|
|
951
|
+
} finally {
|
|
952
|
+
view.cleanup();
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
|
|
382
956
|
test("candidate view skips a shared index that disappears during stat or copy", (t) => {
|
|
383
957
|
const contributorRoot = repository(t);
|
|
384
958
|
writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
|
|
@@ -490,6 +1064,76 @@ test("candidate registry isolates replay, projection, current, and cleanup state
|
|
|
490
1064
|
registry.cleanupTerminal("same-lineage", "approved", rootA);
|
|
491
1065
|
});
|
|
492
1066
|
|
|
1067
|
+
// ga#4085 / ga#4050: after a valid `granted` consent, a retried native START
|
|
1068
|
+
// for a lineage this controller already bound (native reports it "resumed")
|
|
1069
|
+
// re-materializes a fresh, content-identical candidate view under a new
|
|
1070
|
+
// token -- for example after a transport hiccup between START and the
|
|
1071
|
+
// follow-up STATUS call. Rebinding that duplicate to the same lineage used to
|
|
1072
|
+
// fail closed with "candidate view lineage binding is missing or ambiguous"
|
|
1073
|
+
// even though nothing was actually ambiguous. It must reuse the already-bound
|
|
1074
|
+
// view instead of failing the retry.
|
|
1075
|
+
test("candidate registry rebinds a retried native START for an already-bound lineage instead of failing closed", (t) => {
|
|
1076
|
+
const contributorRoot = repository(t);
|
|
1077
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
|
|
1078
|
+
const registry = new CandidateViewRegistry();
|
|
1079
|
+
t.after(() => registry.cleanupAll());
|
|
1080
|
+
const first = registry.create({ contributorRoot });
|
|
1081
|
+
registry.bindCurrent({ token: first.token, lineageId: "retry-lineage", selectedLenses: ["review-risk"] });
|
|
1082
|
+
assert.equal(registry.hasCurrentBinding(contributorRoot), true);
|
|
1083
|
+
|
|
1084
|
+
const retry = registry.create({ contributorRoot });
|
|
1085
|
+
assert.notEqual(retry.token, first.token, "the retry must materialize a distinct physical worktree/token");
|
|
1086
|
+
assert.doesNotThrow(() => registry.bindCurrent({ token: retry.token, lineageId: "retry-lineage", selectedLenses: ["review-risk"] }));
|
|
1087
|
+
|
|
1088
|
+
// The already-bound view stays current; the redundant duplicate is discarded.
|
|
1089
|
+
assert.equal(registry.resolveForLens("retry-lineage", "review-risk", contributorRoot).root, first.root);
|
|
1090
|
+
assert.equal(registry.resolveCurrentForLens("review-risk", contributorRoot).root, first.root);
|
|
1091
|
+
assert.equal(existsSync(retry.root), false, "the redundant duplicate candidate view must be cleaned up");
|
|
1092
|
+
registry.cleanupTerminal("retry-lineage", "approved", contributorRoot);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test("candidate registry still fails closed rebinding the same lineage to genuinely different content", (t) => {
|
|
1096
|
+
const contributorRoot = repository(t);
|
|
1097
|
+
const registry = new CandidateViewRegistry();
|
|
1098
|
+
t.after(() => registry.cleanupAll());
|
|
1099
|
+
const first = registry.create({ contributorRoot });
|
|
1100
|
+
registry.bindCurrent({ token: first.token, lineageId: "conflict-lineage", selectedLenses: ["review-risk"] });
|
|
1101
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "different content\n");
|
|
1102
|
+
const conflicting = registry.create({ contributorRoot });
|
|
1103
|
+
assert.notEqual(conflicting.candidateTree, first.candidateTree);
|
|
1104
|
+
assert.throws(
|
|
1105
|
+
() => registry.bindCurrent({ token: conflicting.token, lineageId: "conflict-lineage", selectedLenses: ["review-risk"] }),
|
|
1106
|
+
CandidateViewError,
|
|
1107
|
+
);
|
|
1108
|
+
registry.cleanup(conflicting.token);
|
|
1109
|
+
registry.cleanupTerminal("conflict-lineage", "approved", contributorRoot);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
// gentle-pi#323: `createOrReuse` reuses whatever view a replay key maps to,
|
|
1113
|
+
// with no awareness of live candidate content -- a content-independent key
|
|
1114
|
+
// reuses a stale view even after the candidate content it was frozen from
|
|
1115
|
+
// has changed. The fix lives at the START call site (extensions/gentle-ai.ts),
|
|
1116
|
+
// which now folds the current candidate tree into the replay key; this test
|
|
1117
|
+
// documents both the registry's plain-key reuse contract and that folding
|
|
1118
|
+
// content identity into the key produces a fresh view once content changes.
|
|
1119
|
+
test("candidate registry reuses a replay-keyed view verbatim regardless of live content, so callers must fold content identity into the key", (t) => {
|
|
1120
|
+
const registry = new CandidateViewRegistry();
|
|
1121
|
+
t.after(() => registry.cleanupAll());
|
|
1122
|
+
const contributorRoot = repository(t);
|
|
1123
|
+
const contentIndependentKey = "cwd-and-lineage-only";
|
|
1124
|
+
const first = registry.createOrReuse({ contributorRoot, replayKey: contentIndependentKey });
|
|
1125
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate two\n");
|
|
1126
|
+
const staleReuse = registry.createOrReuse({ contributorRoot, replayKey: contentIndependentKey });
|
|
1127
|
+
assert.equal(staleReuse.token, first.token, "a content-independent replay key reuses the stale view");
|
|
1128
|
+
const freshForCurrentContent = registry.create({ contributorRoot });
|
|
1129
|
+
assert.notEqual(staleReuse.candidateTree, freshForCurrentContent.candidateTree, "the stale reused view no longer reflects live candidate content");
|
|
1130
|
+
const contentScopedKey = `${contentIndependentKey}${freshForCurrentContent.candidateTree}`;
|
|
1131
|
+
const rekeyed = registry.createOrReuse({ contributorRoot, replayKey: contentScopedKey });
|
|
1132
|
+
assert.notEqual(rekeyed.token, staleReuse.token, "folding candidate content into the replay key mints a fresh view once content changes");
|
|
1133
|
+
assert.equal(rekeyed.candidateTree, freshForCurrentContent.candidateTree);
|
|
1134
|
+
registry.cleanup(freshForCurrentContent.token);
|
|
1135
|
+
});
|
|
1136
|
+
|
|
493
1137
|
test("candidate registry rejects a lineage target whose authorized symlink was replaced", (t) => {
|
|
494
1138
|
const root = repository(t);
|
|
495
1139
|
const replacement = repository(t);
|
|
@@ -546,14 +1190,15 @@ test("candidate view cleanup is confined and idempotent", (t) => {
|
|
|
546
1190
|
assert.equal(lstatSync(view.root, { throwIfNoEntry: false }), undefined);
|
|
547
1191
|
});
|
|
548
1192
|
|
|
549
|
-
test("candidate cleanup
|
|
1193
|
+
test("candidate cleanup preserves a root when Git reports success without deleting it", (t) => {
|
|
550
1194
|
const registry = new CandidateViewRegistry((file, arguments_, options) =>
|
|
551
1195
|
arguments_[0] === "worktree" && arguments_[1] === "remove" ? "" : execFileSync(file, arguments_, options));
|
|
552
1196
|
const view = registry.create({ contributorRoot: repository(t) });
|
|
553
1197
|
assert.equal(lstatSync(view.root).mode & 0o222, 0);
|
|
554
|
-
view.cleanup();
|
|
555
|
-
view.cleanup();
|
|
556
|
-
assert.equal(
|
|
1198
|
+
assert.throws(() => view.cleanup(), /incomplete/);
|
|
1199
|
+
assert.throws(() => view.cleanup(), /incomplete/);
|
|
1200
|
+
assert.equal(existsSync(view.root), true);
|
|
1201
|
+
assert.equal(existsSync(ownerMarker(view.root)), true);
|
|
557
1202
|
});
|
|
558
1203
|
|
|
559
1204
|
test("corrected views stay within frozen scope and replace projections only when promoted", (t) => {
|
|
@@ -711,6 +1356,32 @@ test("candidate view verifies unchanged tree entries even when they are absent f
|
|
|
711
1356
|
}
|
|
712
1357
|
});
|
|
713
1358
|
|
|
1359
|
+
test("candidate executable-mode validation accepts a readonly Git executable on Windows and rejects it on POSIX", () => {
|
|
1360
|
+
assert.equal(hasExpectedExecutableBits(0o444, "100755", "win32"), true);
|
|
1361
|
+
assert.equal(hasExpectedExecutableBits(0o444, "100755", "linux"), false);
|
|
1362
|
+
assert.equal(hasExpectedExecutableBits(0o555, "100755", "linux"), true);
|
|
1363
|
+
assert.equal(hasExpectedExecutableBits(0o555, "100644", "win32"), false);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
test("candidate registry forwards explicit Windows mode validation to view verification", (t) => {
|
|
1367
|
+
mockWindowsAcl(t);
|
|
1368
|
+
const contributorRoot = repository(t);
|
|
1369
|
+
writeFileSync(join(contributorRoot, "unchanged-executable.sh"), "#!/bin/sh\necho base\n");
|
|
1370
|
+
git(contributorRoot, "add", "unchanged-executable.sh");
|
|
1371
|
+
git(contributorRoot, "update-index", "--chmod=+x", "unchanged-executable.sh");
|
|
1372
|
+
chmodSync(join(contributorRoot, "unchanged-executable.sh"), 0o755);
|
|
1373
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "executable base");
|
|
1374
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "changed\n");
|
|
1375
|
+
const view = new CandidateViewRegistry(undefined, "win32").createOrReuse({ contributorRoot, baseRef: "HEAD" });
|
|
1376
|
+
try {
|
|
1377
|
+
assert.deepEqual(view.paths, ["tracked.txt"]);
|
|
1378
|
+
chmodSync(view.root, 0o755); chmodSync(join(view.root, "unchanged-executable.sh"), 0o444); chmodSync(view.root, 0o555);
|
|
1379
|
+
assert.doesNotThrow(() => view.verify());
|
|
1380
|
+
} finally {
|
|
1381
|
+
view.cleanup();
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
|
|
714
1385
|
test("candidate view compacts an oversized non-ASCII scope losslessly and deterministically", (t) => {
|
|
715
1386
|
const contributorRoot = repository(t);
|
|
716
1387
|
writeFileSync(join(contributorRoot, "deleted.txt"), "delete me\n");
|
|
@@ -1925,3 +2596,76 @@ test("candidate view cleans up a partially registered unborn fallback worktree w
|
|
|
1925
2596
|
const leftover = existsSync(parent) ? readdirSync(parent).filter((entry) => lstatSync(join(parent, entry)).isDirectory()) : [];
|
|
1926
2597
|
assert.deepEqual(leftover, [], "no candidate directory remains after a partial unborn fallback failure");
|
|
1927
2598
|
});
|
|
2599
|
+
|
|
2600
|
+
function stagedFinalizeDescriptor(cwd: string, path: string, content: string): NativeCandidateProjectionDescriptor {
|
|
2601
|
+
writeFileSync(join(cwd, path), content);
|
|
2602
|
+
git(cwd, "add", path);
|
|
2603
|
+
const baseTree = git(cwd, "rev-parse", "HEAD^{tree}");
|
|
2604
|
+
const currentCandidateTree = git(cwd, "write-tree");
|
|
2605
|
+
return { baseTree, currentCandidateTree, paths: [path], intendedUntracked: [], projection: "staged" };
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function candidateViewWorktreeCount(cwd: string): number {
|
|
2609
|
+
return git(cwd, "worktree", "list").split("\n").filter((line) => line.includes("candidate-views")).length;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
test("gentle-pi#185: a forecast-restored FINALIZE worktree does not leak across the next restore for the same lineage", (t) => {
|
|
2613
|
+
// gentle-pi#185: `restoreForFinalizeFromNative` materializes a real Git
|
|
2614
|
+
// worktree to restore a lineage's FINALIZE binding from the native frozen
|
|
2615
|
+
// projection. A forecast-only FINALIZE after a process restart uses this
|
|
2616
|
+
// exact path, but its outcome is non-terminal, so `cleanupTerminal` (which
|
|
2617
|
+
// only fires for "approved"/"escalated") never runs for it. Before the fix,
|
|
2618
|
+
// `restoreForFinalizeFromNative` never cleared the stale binding it had
|
|
2619
|
+
// just created, so restoring the same lineage's FINALIZE binding again
|
|
2620
|
+
// (the lineage's later approved FINALIZE, or another restart-time
|
|
2621
|
+
// forecast) threw "native frozen projection is invalid or already
|
|
2622
|
+
// restored" from `restoreProjectionFromNative` — leaving the first
|
|
2623
|
+
// worktree registered forever and breaking the very finalize that should
|
|
2624
|
+
// have succeeded.
|
|
2625
|
+
const cwd = repository(t);
|
|
2626
|
+
const registry = new CandidateViewRegistry();
|
|
2627
|
+
const lineageId = "forecast-lineage-185";
|
|
2628
|
+
const descriptor = stagedFinalizeDescriptor(cwd, "tracked.txt", "forecast change\n");
|
|
2629
|
+
|
|
2630
|
+
assert.equal(candidateViewWorktreeCount(cwd), 0, "no candidate worktree exists before any restore");
|
|
2631
|
+
|
|
2632
|
+
// Reproduces the leak symptom: a forecast-only FINALIZE restore
|
|
2633
|
+
// materializes a worktree that is still registered with Git once the
|
|
2634
|
+
// (non-terminal) forecast finalize returns.
|
|
2635
|
+
registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor);
|
|
2636
|
+
assert.equal(candidateViewWorktreeCount(cwd), 1, "the forecast restore leaves its worktree registered with Git after it returns");
|
|
2637
|
+
|
|
2638
|
+
// A later restore for the same lineage (its approved FINALIZE, or another
|
|
2639
|
+
// restart-time forecast) must clean up the stale worktree and replace it
|
|
2640
|
+
// deterministically instead of throwing or leaking a parallel sibling.
|
|
2641
|
+
assert.doesNotThrow(
|
|
2642
|
+
() => registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor),
|
|
2643
|
+
"restoring the same lineage's FINALIZE binding again must not fail on the stale binding it left behind",
|
|
2644
|
+
);
|
|
2645
|
+
assert.equal(candidateViewWorktreeCount(cwd), 1, "the second restore must replace the first worktree, never accumulate a parallel sibling");
|
|
2646
|
+
|
|
2647
|
+
// The subsequent approved FINALIZE's terminal cleanup must still work for
|
|
2648
|
+
// the worktree that is actually registered after the restore.
|
|
2649
|
+
registry.cleanupTerminal(lineageId, "approved", cwd);
|
|
2650
|
+
assert.equal(candidateViewWorktreeCount(cwd), 0, "approved cleanup removes the restored worktree, leaving no candidate worktree registered");
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
test("gentle-pi#185 review correction: a restore whose materialization fails leaves the previous binding intact", (t) => {
|
|
2654
|
+
const cwd = repository(t);
|
|
2655
|
+
const lineageId = "forecast-lineage-185-rollback";
|
|
2656
|
+
const descriptor = stagedFinalizeDescriptor(cwd, "tracked.txt", "forecast change\n");
|
|
2657
|
+
let worktreeAdds = 0;
|
|
2658
|
+
const executor: CandidateGitExecutor = (file, args, options) => {
|
|
2659
|
+
if (args[0] === "worktree" && args[1] === "add" && ++worktreeAdds === 2) throw Object.assign(new Error("simulated worktree add failure"), { status: 128 });
|
|
2660
|
+
return execFileSync(file, args, options);
|
|
2661
|
+
};
|
|
2662
|
+
const registry = new CandidateViewRegistry(executor);
|
|
2663
|
+
const first = registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor);
|
|
2664
|
+
assert.equal(candidateViewWorktreeCount(cwd), 1);
|
|
2665
|
+
let failure: unknown;
|
|
2666
|
+
try { registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor); } catch (error) { failure = error; }
|
|
2667
|
+
assert.equal((failure as CandidateViewError)?.reason, "candidate-view-git-failure", "a failed materialization must propagate, not silently succeed");
|
|
2668
|
+
assert.equal(candidateViewWorktreeCount(cwd), 1, "the previously registered worktree must survive the failed restore");
|
|
2669
|
+
assert.equal(registry.resolveForFinalize(lineageId, cwd).token, first.token, "the lineage must still resolve to its original worktree");
|
|
2670
|
+
registry.cleanupTerminal(lineageId, "approved", cwd);
|
|
2671
|
+
});
|