gentle-pi 2.3.0 → 2.5.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 +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- 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 +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -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 +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -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 +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- 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 +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -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 +608 -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 +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { PassThrough, Writable } from "node:stream";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
|
|
6
|
+
const REPOSITORY_ID = `sha256:${"a".repeat(64)}`;
|
|
7
|
+
const OTHER_REPOSITORY_ID = `sha256:${"b".repeat(64)}`;
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SYMBOL,
|
|
11
|
+
ChildStandingReviewPermissionClient,
|
|
12
|
+
createChildStandingReviewPermissionClient,
|
|
13
|
+
ParentStandingReviewPermissionBroker,
|
|
14
|
+
} from "../lib/review-session-standing-permission-ipc.ts";
|
|
15
|
+
|
|
16
|
+
function pair() {
|
|
17
|
+
const childToParent = new PassThrough();
|
|
18
|
+
const parentToChild = new PassThrough();
|
|
19
|
+
return {
|
|
20
|
+
child: { readable: parentToChild, writable: childToParent },
|
|
21
|
+
parent: { readable: childToParent, writable: parentToChild },
|
|
22
|
+
close() {
|
|
23
|
+
childToParent.destroy();
|
|
24
|
+
parentToChild.destroy();
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function productionChild(requests: number, authorize: () => boolean, options: { maxRequests?: number; closeChannel?: boolean; withoutChannel?: boolean } = {}): Promise<boolean[]> {
|
|
30
|
+
const moduleUrl = new URL("../lib/review-session-standing-permission-ipc.ts", import.meta.url).href;
|
|
31
|
+
const source = `
|
|
32
|
+
import { createChildStandingReviewPermissionClient } from ${JSON.stringify(moduleUrl)};
|
|
33
|
+
const client = createChildStandingReviewPermissionClient();
|
|
34
|
+
const answers = [];
|
|
35
|
+
for (let index = 0; index < ${requests}; index += 1) answers.push(await client?.requestAuthorization(${JSON.stringify(REPOSITORY_ID)}) ?? false);
|
|
36
|
+
client?.close();
|
|
37
|
+
process.stdout.write(JSON.stringify(answers));
|
|
38
|
+
`;
|
|
39
|
+
const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", source], {
|
|
40
|
+
env: { ...process.env, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" },
|
|
41
|
+
stdio: options.withoutChannel ? ["ignore", "pipe", "pipe"] : ["ignore", "pipe", "pipe", "pipe"],
|
|
42
|
+
});
|
|
43
|
+
const pipe = child.stdio[3];
|
|
44
|
+
const broker = pipe === null || pipe === undefined
|
|
45
|
+
? undefined
|
|
46
|
+
: new ParentStandingReviewPermissionBroker({ readable: pipe, writable: pipe }, authorize, options);
|
|
47
|
+
if (options.closeChannel && pipe !== null && pipe !== undefined) {
|
|
48
|
+
broker?.close();
|
|
49
|
+
pipe.destroy();
|
|
50
|
+
}
|
|
51
|
+
let stdout = "";
|
|
52
|
+
let stderr = "";
|
|
53
|
+
child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
|
|
54
|
+
child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
|
|
55
|
+
return new Promise((resolve, reject) => child.once("exit", (code) => {
|
|
56
|
+
broker?.close();
|
|
57
|
+
if (code !== 0) reject(new Error(`child exited ${code}: ${stderr}`));
|
|
58
|
+
else resolve(JSON.parse(stdout) as boolean[]);
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("the production child fd3 client gets grant, revocation, closure, denial, and request bounds from its parent broker", async () => {
|
|
63
|
+
let calls = 0;
|
|
64
|
+
assert.deepEqual(await productionChild(2, () => ++calls === 1), [true, false], "the parent rechecks authorization for each request");
|
|
65
|
+
assert.deepEqual(await productionChild(1, () => false), [false], "a child without a grant is denied");
|
|
66
|
+
assert.deepEqual(await productionChild(2, () => true, { maxRequests: 1 }), [true, false], "the parent caps requests per child channel");
|
|
67
|
+
assert.deepEqual(await productionChild(1, () => true, { closeChannel: true }), [false], "a closed fd3 channel fails closed without crashing the child");
|
|
68
|
+
assert.deepEqual(await productionChild(1, () => true, { withoutChannel: true }), [false], "a marked normal process without fd3 fails closed without crashing");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("fresh Jiti moduleCache:false reloads share fd3 structurally and reject stale terminal callbacks", async () => {
|
|
72
|
+
const ipcUrl = new URL("../lib/review-session-standing-permission-ipc.ts", import.meta.url).href;
|
|
73
|
+
const extensionUrl = new URL("../extensions/gentle-ai.ts", import.meta.url).href;
|
|
74
|
+
const source = `
|
|
75
|
+
import { createJiti } from "jiti";
|
|
76
|
+
import { fileURLToPath } from "node:url";
|
|
77
|
+
const ipcPath = fileURLToPath(${JSON.stringify(ipcUrl)});
|
|
78
|
+
const extensionPath = fileURLToPath(${JSON.stringify(extensionUrl)});
|
|
79
|
+
const firstLoader = createJiti(import.meta.url, { moduleCache: false });
|
|
80
|
+
const firstExtension = await firstLoader.import(extensionPath, { default: true });
|
|
81
|
+
const firstIpc = await firstLoader.import(ipcPath, { default: false });
|
|
82
|
+
const shutdowns = [];
|
|
83
|
+
const pi = { on(name, handler) { if (name === "session_shutdown") shutdowns.push(handler); }, registerTool() {}, registerCommand() {}, events: { emit() {} } };
|
|
84
|
+
const context = { cwd: process.cwd(), sessionManager: { getSessionId: () => "child" }, ui: { setStatus() {} } };
|
|
85
|
+
firstExtension(pi);
|
|
86
|
+
const staleShutdown = shutdowns.at(-1);
|
|
87
|
+
const client = firstIpc.createChildStandingReviewPermissionClient();
|
|
88
|
+
const answers = [await client?.requestAuthorization(${JSON.stringify(REPOSITORY_ID)}) ?? false];
|
|
89
|
+
staleShutdown({ reason: "reload" }, context);
|
|
90
|
+
const secondLoader = createJiti(import.meta.url, { moduleCache: false });
|
|
91
|
+
const secondExtension = await secondLoader.import(extensionPath, { default: true });
|
|
92
|
+
const secondIpc = await secondLoader.import(ipcPath, { default: false });
|
|
93
|
+
secondExtension(pi);
|
|
94
|
+
const activeShutdown = shutdowns.at(-1);
|
|
95
|
+
answers.push(await secondIpc.createChildStandingReviewPermissionClient()?.requestAuthorization(${JSON.stringify(REPOSITORY_ID)}) ?? false);
|
|
96
|
+
staleShutdown({ reason: "new" }, context);
|
|
97
|
+
answers.push(await client?.requestAuthorization(${JSON.stringify(REPOSITORY_ID)}) ?? false);
|
|
98
|
+
activeShutdown({ reason: "new" }, context);
|
|
99
|
+
answers.push(await client?.requestAuthorization(${JSON.stringify(REPOSITORY_ID)}) ?? false);
|
|
100
|
+
process.stdout.write(JSON.stringify({ answers, classIdentityDiffers: firstIpc.ChildStandingReviewPermissionClient !== secondIpc.ChildStandingReviewPermissionClient }));
|
|
101
|
+
`;
|
|
102
|
+
const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", source], {
|
|
103
|
+
env: { ...process.env, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" },
|
|
104
|
+
stdio: ["ignore", "pipe", "pipe", "pipe"],
|
|
105
|
+
});
|
|
106
|
+
const pipe = child.stdio[3];
|
|
107
|
+
assert.ok(pipe);
|
|
108
|
+
const broker = new ParentStandingReviewPermissionBroker({ readable: pipe, writable: pipe }, () => true);
|
|
109
|
+
let stdout = "";
|
|
110
|
+
let stderr = "";
|
|
111
|
+
child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
|
|
112
|
+
child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
|
|
113
|
+
const result = await new Promise<{ answers: boolean[]; classIdentityDiffers: boolean }>((resolve, reject) => child.once("exit", (code) => {
|
|
114
|
+
broker.close();
|
|
115
|
+
if (code !== 0) reject(new Error(`Jiti reload child exited ${code}: ${stderr}`));
|
|
116
|
+
else resolve(JSON.parse(stdout) as { answers: boolean[]; classIdentityDiffers: boolean });
|
|
117
|
+
}));
|
|
118
|
+
assert.equal(result.classIdentityDiffers, true, "fresh Jiti loaders must re-evaluate the IPC class");
|
|
119
|
+
assert.deepEqual(result.answers, [true, true, true, false], "reload shares one client, stale owners cannot close it, and terminal shutdown closes it");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("an incompatible process registry fails closed rather than replacing ownership", () => {
|
|
123
|
+
const globalRegistry = globalThis as Record<symbol, unknown>;
|
|
124
|
+
const previous = globalRegistry[CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SYMBOL];
|
|
125
|
+
try {
|
|
126
|
+
globalRegistry[CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SYMBOL] = { schema: "wrong" };
|
|
127
|
+
assert.equal(createChildStandingReviewPermissionClient({ GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" }), undefined);
|
|
128
|
+
} finally {
|
|
129
|
+
if (previous === undefined) delete globalRegistry[CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SYMBOL];
|
|
130
|
+
else globalRegistry[CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SYMBOL] = previous;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a package-owned child receives one parent-session authorization only after the parent checks its live task", async () => {
|
|
135
|
+
const channel = pair();
|
|
136
|
+
let live = true;
|
|
137
|
+
const broker = new ParentStandingReviewPermissionBroker(channel.parent, async (repositoryIdentity) => live && repositoryIdentity === REPOSITORY_ID);
|
|
138
|
+
const child = new ChildStandingReviewPermissionClient(channel.child, { timeoutMs: 25 });
|
|
139
|
+
assert.equal(await child.requestAuthorization(REPOSITORY_ID), true);
|
|
140
|
+
assert.equal(await child.requestAuthorization(OTHER_REPOSITORY_ID), false, "the task broker rejects a child target outside its repository");
|
|
141
|
+
assert.equal(await child.requestAuthorization("sha256:malformed"), false, "a malformed identity cannot reach the parent broker");
|
|
142
|
+
live = false;
|
|
143
|
+
assert.equal(await child.requestAuthorization(REPOSITORY_ID), false, "each request rechecks the live parent task and grant");
|
|
144
|
+
child.close();
|
|
145
|
+
broker.close();
|
|
146
|
+
channel.close();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("the parent bounds each child channel before an untrusted request stream can keep asking", async () => {
|
|
150
|
+
const channel = pair();
|
|
151
|
+
const broker = new ParentStandingReviewPermissionBroker(channel.parent, async () => true, { maxRequests: 1 });
|
|
152
|
+
const child = new ChildStandingReviewPermissionClient(channel.child, { timeoutMs: 25 });
|
|
153
|
+
assert.equal(await child.requestAuthorization(REPOSITORY_ID), true);
|
|
154
|
+
assert.equal(await child.requestAuthorization(REPOSITORY_ID), false, "the broker rejects requests beyond its fixed per-task bound");
|
|
155
|
+
child.close();
|
|
156
|
+
broker.close();
|
|
157
|
+
channel.close();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a broken, timed-out, or malformed parent-owned channel fails closed", async () => {
|
|
161
|
+
const channel = pair();
|
|
162
|
+
const broker = new ParentStandingReviewPermissionBroker(channel.parent, async () => true);
|
|
163
|
+
const child = new ChildStandingReviewPermissionClient(channel.child, { timeoutMs: 1 });
|
|
164
|
+
broker.close();
|
|
165
|
+
assert.equal(await child.requestAuthorization(REPOSITORY_ID), false, "a closed broker cannot grant");
|
|
166
|
+
channel.close();
|
|
167
|
+
assert.equal(await child.requestAuthorization(REPOSITORY_ID), false, "an orphaned channel cannot grant");
|
|
168
|
+
child.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("the authorization wire carries no candidate data or provider consent vectors", async () => {
|
|
172
|
+
const channel = pair();
|
|
173
|
+
const seen: string[] = [];
|
|
174
|
+
channel.parent.readable.on("data", (chunk: Buffer) => seen.push(chunk.toString("utf8")));
|
|
175
|
+
const broker = new ParentStandingReviewPermissionBroker(channel.parent, async () => true);
|
|
176
|
+
const child = new ChildStandingReviewPermissionClient(channel.child, { timeoutMs: 25 });
|
|
177
|
+
assert.equal(await child.requestAuthorization(REPOSITORY_ID), true);
|
|
178
|
+
assert.equal(seen.length, 1);
|
|
179
|
+
assert.match(seen[0]!, /"type":"standing-review-permission-request"/);
|
|
180
|
+
assert.match(seen[0]!, new RegExp(REPOSITORY_ID));
|
|
181
|
+
assert.doesNotMatch(seen[0]!, /consent|candidate|lineage|workspace|commonDir|path/i);
|
|
182
|
+
child.close();
|
|
183
|
+
broker.close();
|
|
184
|
+
channel.close();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a child disconnect during a pending parent response cannot crash the broker process", async () => {
|
|
188
|
+
class ChildDisconnectedOutput extends Writable {
|
|
189
|
+
private disconnected = false;
|
|
190
|
+
|
|
191
|
+
disconnect(): void {
|
|
192
|
+
this.disconnected = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_write(_chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
|
196
|
+
callback(this.disconnected ? Object.assign(new Error("child pipe reset"), { code: "ECONNRESET" }) : null);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const childToParent = new PassThrough();
|
|
201
|
+
const parentToChild = new ChildDisconnectedOutput();
|
|
202
|
+
let calls = 0;
|
|
203
|
+
let releaseAuthorization: ((granted: boolean) => void) | undefined;
|
|
204
|
+
let signalAuthorizationStarted: () => void = () => {};
|
|
205
|
+
const authorizationStarted = new Promise<void>((resolve) => { signalAuthorizationStarted = resolve; });
|
|
206
|
+
const broker = new ParentStandingReviewPermissionBroker(
|
|
207
|
+
{ readable: childToParent, writable: parentToChild },
|
|
208
|
+
() => new Promise<boolean>((resolve) => {
|
|
209
|
+
calls += 1;
|
|
210
|
+
releaseAuthorization = resolve;
|
|
211
|
+
signalAuthorizationStarted();
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
let uncaught: Error | undefined;
|
|
215
|
+
const observeUncaught = (error: Error) => { uncaught = error; };
|
|
216
|
+
process.on("uncaughtException", observeUncaught);
|
|
217
|
+
try {
|
|
218
|
+
childToParent.write(`${JSON.stringify({ type: "standing-review-permission-request", id: "disconnect", repositoryIdentity: REPOSITORY_ID })}\n`);
|
|
219
|
+
await authorizationStarted;
|
|
220
|
+
parentToChild.disconnect();
|
|
221
|
+
releaseAuthorization?.(true);
|
|
222
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
223
|
+
childToParent.write(`${JSON.stringify({ type: "standing-review-permission-request", id: "after-disconnect", repositoryIdentity: REPOSITORY_ID })}\n`);
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
225
|
+
assert.equal(calls, 1, "the closed broker does not accept more work after a child pipe error");
|
|
226
|
+
assert.equal(uncaught, undefined, "the parent's late write error is handled by the broker, not process-wide");
|
|
227
|
+
} finally {
|
|
228
|
+
process.off("uncaughtException", observeUncaught);
|
|
229
|
+
broker.close();
|
|
230
|
+
childToParent.destroy();
|
|
231
|
+
parentToChild.destroy();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
createAgentSessionFromServices,
|
|
11
|
+
createAgentSessionRuntime,
|
|
12
|
+
createAgentSessionServices,
|
|
13
|
+
SessionManager,
|
|
14
|
+
type CreateAgentSessionRuntimeFactory,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
captureReviewSessionIdentity,
|
|
18
|
+
grantReviewSessionPermission,
|
|
19
|
+
hasReviewSessionPermission,
|
|
20
|
+
} from "../lib/review-session-standing-permission.ts";
|
|
21
|
+
|
|
22
|
+
const LIFECYCLE_SYMBOL = Symbol.for("gentle-pi.test.review-session-permission-lifecycle");
|
|
23
|
+
|
|
24
|
+
interface LifecycleEvent {
|
|
25
|
+
instance: number;
|
|
26
|
+
type: "start" | "shutdown";
|
|
27
|
+
reason: string;
|
|
28
|
+
manager: object;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface LifecycleState {
|
|
32
|
+
nextInstance: number;
|
|
33
|
+
events: LifecycleEvent[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scratch(t: test.TestContext) {
|
|
37
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-permission-runtime-")));
|
|
38
|
+
t.after(() => {
|
|
39
|
+
try { execFileSync("chmod", ["-R", "u+w", root], { stdio: "ignore" }); } catch { /* best effort */ }
|
|
40
|
+
rmSync(root, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
const cwd = join(root, "repo");
|
|
43
|
+
const agentDir = join(root, "agent");
|
|
44
|
+
const sessionDir = join(root, "sessions");
|
|
45
|
+
mkdirSync(cwd, { recursive: true });
|
|
46
|
+
mkdirSync(join(agentDir, "extensions"), { recursive: true });
|
|
47
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
48
|
+
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
|
|
49
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
|
|
50
|
+
execFileSync("git", ["add", "app.ts"], { cwd, stdio: "ignore" });
|
|
51
|
+
execFileSync("git", ["-c", "user.name=Runtime Test", "-c", "user.email=runtime@example.invalid", "commit", "-m", "base"], { cwd, stdio: "ignore" });
|
|
52
|
+
return { root, cwd: realpathSync(cwd), agentDir, sessionDir };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function testUi(statuses: Array<{ key: string; text?: string }>): ExtensionUIContext {
|
|
56
|
+
return {
|
|
57
|
+
select: async () => undefined,
|
|
58
|
+
confirm: async () => false,
|
|
59
|
+
input: async () => undefined,
|
|
60
|
+
editor: async () => undefined,
|
|
61
|
+
notify() {},
|
|
62
|
+
onTerminalInput: () => () => {},
|
|
63
|
+
setStatus(key, text) { statuses.push({ key, text }); },
|
|
64
|
+
setWorkingMessage() {},
|
|
65
|
+
setWorkingVisible() {},
|
|
66
|
+
setWorkingIndicator() {},
|
|
67
|
+
setHiddenThinkingLabel() {},
|
|
68
|
+
setWidget() {},
|
|
69
|
+
setFooter() {},
|
|
70
|
+
setHeader() {},
|
|
71
|
+
setTitle() {},
|
|
72
|
+
custom: async () => undefined,
|
|
73
|
+
pasteToEditor() {},
|
|
74
|
+
setEditorText() {},
|
|
75
|
+
getEditorText: () => "",
|
|
76
|
+
addAutocompleteProvider() {},
|
|
77
|
+
setEditorComponent() {},
|
|
78
|
+
getEditorComponent: () => undefined,
|
|
79
|
+
get theme() { return { fg: (_color: string, text: string) => text, bold: (text: string) => text } as never; },
|
|
80
|
+
getAllThemes: () => [{ name: "runtime-test", path: undefined } as never],
|
|
81
|
+
getTheme: () => undefined,
|
|
82
|
+
setTheme: () => ({ success: false, error: "not used" }),
|
|
83
|
+
getToolsExpanded: () => false,
|
|
84
|
+
setToolsExpanded() {},
|
|
85
|
+
} as ExtensionUIContext;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function identity(cwd: string, manager: SessionManager) {
|
|
89
|
+
const captured = await captureReviewSessionIdentity({ cwd, mode: "tui", hasUI: true, sessionManager: manager }, {});
|
|
90
|
+
assert.ok(captured);
|
|
91
|
+
return captured;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("actual Pi SDK loader preserves permission only across reload and disposes it on replacement lifecycles", async (t) => {
|
|
95
|
+
const { cwd, agentDir, sessionDir } = scratch(t);
|
|
96
|
+
const state: LifecycleState = { nextInstance: 0, events: [] };
|
|
97
|
+
const globalState = globalThis as Record<symbol, unknown>;
|
|
98
|
+
const previousLifecycle = globalState[LIFECYCLE_SYMBOL];
|
|
99
|
+
globalState[LIFECYCLE_SYMBOL] = state;
|
|
100
|
+
t.after(() => {
|
|
101
|
+
if (previousLifecycle === undefined) delete globalState[LIFECYCLE_SYMBOL];
|
|
102
|
+
else globalState[LIFECYCLE_SYMBOL] = previousLifecycle;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const extensionSource = pathToFileURL(join(import.meta.dirname, "..", "extensions", "gentle-ai.ts")).href;
|
|
106
|
+
const extensionPath = join(agentDir, "extensions", "runtime-permission.ts");
|
|
107
|
+
writeFileSync(extensionPath, `
|
|
108
|
+
import { createGentleAiExtension } from ${JSON.stringify(extensionSource)};
|
|
109
|
+
const lifecycle = globalThis[Symbol.for("gentle-pi.test.review-session-permission-lifecycle")];
|
|
110
|
+
const instance = ++lifecycle.nextInstance;
|
|
111
|
+
export default function (pi) {
|
|
112
|
+
pi.on("session_start", (event, ctx) => lifecycle.events.push({ instance, type: "start", reason: event.reason, manager: ctx.sessionManager }));
|
|
113
|
+
pi.on("session_shutdown", (event, ctx) => lifecycle.events.push({ instance, type: "shutdown", reason: event.reason, manager: ctx.sessionManager }));
|
|
114
|
+
createGentleAiExtension({ nativeReviewCli: null, candidateViews: null, processEnv: {} })(pi);
|
|
115
|
+
}
|
|
116
|
+
`, "utf8");
|
|
117
|
+
|
|
118
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
119
|
+
process.env.GENTLE_PI_AGENT_HOME = agentDir;
|
|
120
|
+
t.after(() => {
|
|
121
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
122
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
123
|
+
});
|
|
124
|
+
const statuses: Array<{ key: string; text?: string }> = [];
|
|
125
|
+
const uiContext = testUi(statuses);
|
|
126
|
+
const bindings = { uiContext, mode: "tui" as const };
|
|
127
|
+
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd: runtimeCwd, sessionManager, sessionStartEvent }) => {
|
|
128
|
+
const services = await createAgentSessionServices({
|
|
129
|
+
cwd: runtimeCwd,
|
|
130
|
+
agentDir,
|
|
131
|
+
resourceLoaderOptions: {
|
|
132
|
+
additionalExtensionPaths: [extensionPath],
|
|
133
|
+
noSkills: true,
|
|
134
|
+
noPromptTemplates: true,
|
|
135
|
+
noThemes: true,
|
|
136
|
+
noContextFiles: true,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, noTools: "all" })),
|
|
141
|
+
services,
|
|
142
|
+
diagnostics: services.diagnostics,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
const initialManager = SessionManager.create(cwd, sessionDir);
|
|
146
|
+
const runtime = await createAgentSessionRuntime(createRuntime, { cwd, agentDir, sessionManager: initialManager });
|
|
147
|
+
runtime.setRebindSession(async (session) => session.bindExtensions(bindings));
|
|
148
|
+
await runtime.session.bindExtensions(bindings);
|
|
149
|
+
const originalSessionFile = runtime.session.sessionFile;
|
|
150
|
+
assert.ok(originalSessionFile);
|
|
151
|
+
const initialIdentity = await identity(cwd, initialManager);
|
|
152
|
+
assert.equal(grantReviewSessionPermission(initialIdentity), true);
|
|
153
|
+
|
|
154
|
+
await runtime.session.reload();
|
|
155
|
+
assert.equal(runtime.session.sessionManager, initialManager, "Pi reload must preserve the exact live SessionManager object");
|
|
156
|
+
assert.equal(hasReviewSessionPermission(initialIdentity), true, "the global Symbol registry reconnects the new extension closure");
|
|
157
|
+
assert.deepEqual(state.events.slice(0, 3).map(({ instance, type, reason }) => ({ instance, type, reason })), [
|
|
158
|
+
{ instance: 1, type: "start", reason: "startup" },
|
|
159
|
+
{ instance: 1, type: "shutdown", reason: "reload" },
|
|
160
|
+
{ instance: 2, type: "start", reason: "reload" },
|
|
161
|
+
]);
|
|
162
|
+
assert.ok(statuses.some(({ text }) => text === "reviews allowed for this session"), "reload startup restores the visible nonblocking status");
|
|
163
|
+
|
|
164
|
+
const firstEntry = initialManager.appendMessage({ role: "user", content: "first", timestamp: Date.now() });
|
|
165
|
+
initialManager.appendMessage({ role: "user", content: "second", timestamp: Date.now() });
|
|
166
|
+
await runtime.session.navigateTree(firstEntry, { summarize: false });
|
|
167
|
+
assert.equal(runtime.session.sessionManager, initialManager);
|
|
168
|
+
assert.equal(hasReviewSessionPermission(initialIdentity), true, "/tree stays inside the same session and retains permission");
|
|
169
|
+
|
|
170
|
+
await runtime.newSession();
|
|
171
|
+
assert.equal(hasReviewSessionPermission(initialIdentity), false);
|
|
172
|
+
const newManager = runtime.session.sessionManager;
|
|
173
|
+
const newIdentity = await identity(cwd, newManager);
|
|
174
|
+
assert.equal(hasReviewSessionPermission(newIdentity), false);
|
|
175
|
+
assert.equal(state.events.some(({ type, reason }) => type === "shutdown" && reason === "new"), true);
|
|
176
|
+
assert.equal(state.events.some(({ type, reason }) => type === "start" && reason === "new"), true);
|
|
177
|
+
|
|
178
|
+
grantReviewSessionPermission(newIdentity);
|
|
179
|
+
await runtime.switchSession(originalSessionFile);
|
|
180
|
+
assert.equal(hasReviewSessionPermission(newIdentity), false);
|
|
181
|
+
const resumedIdentity = await identity(cwd, runtime.session.sessionManager);
|
|
182
|
+
assert.equal(hasReviewSessionPermission(resumedIdentity), false);
|
|
183
|
+
assert.equal(state.events.some(({ type, reason }) => type === "shutdown" && reason === "resume"), true);
|
|
184
|
+
assert.equal(state.events.some(({ type, reason }) => type === "start" && reason === "resume"), true);
|
|
185
|
+
|
|
186
|
+
runtime.session.sessionManager.resetLeaf();
|
|
187
|
+
const resumedEntry = runtime.session.sessionManager.appendMessage({ role: "user", content: "fork point", timestamp: Date.now() });
|
|
188
|
+
grantReviewSessionPermission(resumedIdentity);
|
|
189
|
+
await runtime.fork(resumedEntry);
|
|
190
|
+
assert.equal(hasReviewSessionPermission(resumedIdentity), false);
|
|
191
|
+
const forkedIdentity = await identity(cwd, runtime.session.sessionManager);
|
|
192
|
+
assert.equal(hasReviewSessionPermission(forkedIdentity), false);
|
|
193
|
+
assert.equal(state.events.some(({ type, reason }) => type === "shutdown" && reason === "fork"), true);
|
|
194
|
+
assert.equal(state.events.some(({ type, reason }) => type === "start" && reason === "fork"), true);
|
|
195
|
+
|
|
196
|
+
grantReviewSessionPermission(forkedIdentity);
|
|
197
|
+
await runtime.dispose();
|
|
198
|
+
assert.equal(hasReviewSessionPermission(forkedIdentity), false);
|
|
199
|
+
assert.equal(state.events.some(({ type, reason }) => type === "shutdown" && reason === "quit"), true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a fresh Node process has no standing permission registry authority", async (t) => {
|
|
203
|
+
const { cwd } = scratch(t);
|
|
204
|
+
const moduleUrl = pathToFileURL(join(import.meta.dirname, "..", "lib", "review-session-standing-permission.ts")).href;
|
|
205
|
+
const script = `
|
|
206
|
+
import { hasReviewSessionPermission } from ${JSON.stringify(moduleUrl)};
|
|
207
|
+
const manager = {};
|
|
208
|
+
process.stdout.write(String(hasReviewSessionPermission({ sessionManager: manager, sessionId: "restart", worktreeRoot: ${JSON.stringify(cwd)} })));
|
|
209
|
+
`;
|
|
210
|
+
const output = execFileSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script], { cwd, encoding: "utf8" });
|
|
211
|
+
assert.equal(output, "false");
|
|
212
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA,
|
|
9
|
+
REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL,
|
|
10
|
+
captureReviewSessionIdentity,
|
|
11
|
+
grantReviewSessionPermission,
|
|
12
|
+
hasReviewSessionPermission,
|
|
13
|
+
reviewSessionPermissionEpoch,
|
|
14
|
+
revokeReviewSessionPermission,
|
|
15
|
+
revokeReviewSessionPermissionsForSession,
|
|
16
|
+
type ReviewSessionContext,
|
|
17
|
+
} from "../lib/review-session-standing-permission.ts";
|
|
18
|
+
|
|
19
|
+
function repository(t: test.TestContext): string {
|
|
20
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-session-permission-")));
|
|
21
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
22
|
+
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
|
|
23
|
+
writeFileSync(join(cwd, "README.md"), "test repository\n");
|
|
24
|
+
execFileSync("git", ["add", "README.md"], { cwd, stdio: "ignore" });
|
|
25
|
+
execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-m", "base"], { cwd, stdio: "ignore" });
|
|
26
|
+
return cwd;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function siblingWorktree(t: test.TestContext, parentRoot: string): string {
|
|
30
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-session-permission-worktree-")));
|
|
31
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
32
|
+
execFileSync("git", ["worktree", "add", "--detach", cwd, "HEAD"], { cwd: parentRoot, stdio: "ignore" });
|
|
33
|
+
return cwd;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function context(cwd: string, manager: object, sessionId: string, overrides: Partial<ReviewSessionContext> = {}): ReviewSessionContext {
|
|
37
|
+
return {
|
|
38
|
+
cwd,
|
|
39
|
+
mode: "tui",
|
|
40
|
+
hasUI: true,
|
|
41
|
+
sessionManager: Object.assign(manager, { getSessionId: () => sessionId }),
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("standing permission is process-memory-only and bound to one live manager, session, and Git common directory", async (t) => {
|
|
47
|
+
const firstRoot = repository(t);
|
|
48
|
+
const siblingRoot = siblingWorktree(t, firstRoot);
|
|
49
|
+
const unrelatedRoot = repository(t);
|
|
50
|
+
const manager = {};
|
|
51
|
+
const identity = await captureReviewSessionIdentity(context(firstRoot, manager, "session-a"), {});
|
|
52
|
+
assert.ok(identity);
|
|
53
|
+
assert.equal(identity.worktreeRoot, firstRoot);
|
|
54
|
+
assert.equal(grantReviewSessionPermission(identity), true);
|
|
55
|
+
assert.equal(hasReviewSessionPermission(identity), true);
|
|
56
|
+
|
|
57
|
+
const wrongManager = await captureReviewSessionIdentity(context(firstRoot, {}, "session-a"), {});
|
|
58
|
+
const wrongSession = await captureReviewSessionIdentity(context(firstRoot, manager, "session-b"), {});
|
|
59
|
+
const sibling = await captureReviewSessionIdentity(context(siblingRoot, manager, "session-a"), {});
|
|
60
|
+
const unrelated = await captureReviewSessionIdentity(context(unrelatedRoot, manager, "session-a"), {});
|
|
61
|
+
assert.ok(wrongManager && wrongSession && sibling && unrelated);
|
|
62
|
+
assert.equal(sibling.repositoryIdentity, identity.repositoryIdentity, "sibling worktrees share one common-directory identity");
|
|
63
|
+
assert.notEqual(unrelated.repositoryIdentity, identity.repositoryIdentity, "independent repositories have distinct identities");
|
|
64
|
+
assert.equal(hasReviewSessionPermission(wrongManager), false);
|
|
65
|
+
assert.equal(hasReviewSessionPermission(wrongSession), false);
|
|
66
|
+
assert.equal(hasReviewSessionPermission(sibling), true, "a grant follows sibling worktrees of the same clone");
|
|
67
|
+
assert.equal(hasReviewSessionPermission(unrelated), false, "an unrelated repository requires its own human grant");
|
|
68
|
+
|
|
69
|
+
assert.equal(revokeReviewSessionPermission(identity), true);
|
|
70
|
+
assert.equal(hasReviewSessionPermission(sibling), false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("headless, child, empty-session, and non-Git contexts cannot offer or consume standing permission", async (t) => {
|
|
74
|
+
const root = repository(t);
|
|
75
|
+
const manager = {};
|
|
76
|
+
assert.equal(await captureReviewSessionIdentity(context(root, manager, "session", { mode: "print", hasUI: false }), {}), undefined);
|
|
77
|
+
assert.equal(await captureReviewSessionIdentity(context(root, manager, "session", { mode: "rpc", ui: { getAllThemes: () => [{}] } }), {}), undefined);
|
|
78
|
+
assert.equal(await captureReviewSessionIdentity(context(root, manager, "session", { mode: undefined }), {}), undefined, "an omitted mode with no verified TUI surface fails closed");
|
|
79
|
+
assert.ok(await captureReviewSessionIdentity(context(root, manager, "session", { mode: undefined, ui: { getAllThemes: () => [{}] } }), {}), "Pi 0.85 compatibility contexts prove TUI support through their nonempty theme surface");
|
|
80
|
+
assert.equal(await captureReviewSessionIdentity(context(root, manager, "session"), { GENTLE_PI_AGENTS_CHILD: "1" }), undefined);
|
|
81
|
+
assert.equal(await captureReviewSessionIdentity(context(root, manager, ""), {}), undefined);
|
|
82
|
+
assert.equal(await captureReviewSessionIdentity(context(tmpdir(), manager, "session"), {}), undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("revocation advances an epoch that prevents an older awaited UI from re-granting", async (t) => {
|
|
86
|
+
const root = repository(t);
|
|
87
|
+
const identity = await captureReviewSessionIdentity(context(root, {}, "session"), {});
|
|
88
|
+
assert.ok(identity);
|
|
89
|
+
const epoch = reviewSessionPermissionEpoch(identity);
|
|
90
|
+
assert.equal(epoch, 0);
|
|
91
|
+
revokeReviewSessionPermissionsForSession(identity.sessionManager, identity.sessionId);
|
|
92
|
+
assert.equal(reviewSessionPermissionEpoch(identity), 1);
|
|
93
|
+
assert.equal(grantReviewSessionPermission(identity, epoch), false);
|
|
94
|
+
assert.equal(hasReviewSessionPermission(identity), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("session revocation removes only the exact manager and session partition", async (t) => {
|
|
98
|
+
const root = repository(t);
|
|
99
|
+
const manager = {};
|
|
100
|
+
const first = await captureReviewSessionIdentity(context(root, manager, "first"), {});
|
|
101
|
+
const second = await captureReviewSessionIdentity(context(root, manager, "second"), {});
|
|
102
|
+
assert.ok(first && second);
|
|
103
|
+
grantReviewSessionPermission(first);
|
|
104
|
+
grantReviewSessionPermission(second);
|
|
105
|
+
revokeReviewSessionPermissionsForSession(manager, "first");
|
|
106
|
+
assert.equal(hasReviewSessionPermission(first), false);
|
|
107
|
+
assert.equal(hasReviewSessionPermission(second), true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("an incompatible Symbol.for registry fails closed instead of being replaced", async (t) => {
|
|
111
|
+
const root = repository(t);
|
|
112
|
+
const globalRegistry = globalThis as Record<symbol, unknown>;
|
|
113
|
+
const previous = globalRegistry[REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL];
|
|
114
|
+
globalRegistry[REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL] = {
|
|
115
|
+
schema: `${REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA}-future`,
|
|
116
|
+
permissions: new WeakMap(),
|
|
117
|
+
};
|
|
118
|
+
t.after(() => {
|
|
119
|
+
if (previous === undefined) delete globalRegistry[REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL];
|
|
120
|
+
else globalRegistry[REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL] = previous;
|
|
121
|
+
});
|
|
122
|
+
const identity = await captureReviewSessionIdentity(context(root, {}, "session"), {});
|
|
123
|
+
assert.ok(identity);
|
|
124
|
+
assert.equal(grantReviewSessionPermission(identity), false);
|
|
125
|
+
assert.equal(hasReviewSessionPermission(identity), false);
|
|
126
|
+
});
|
|
@@ -314,6 +314,7 @@ async function run() {
|
|
|
314
314
|
const capture = lastEventPi.tools.get("gentle_review_capture");
|
|
315
315
|
assert.ok(controller, "runtime must register the public status controller");
|
|
316
316
|
assert.ok(capture, "runtime must register the one-slot capture tool");
|
|
317
|
+
assert.equal(lastEventPi.tools.get("gentle_review_capture_group")?.executionMode, "sequential", "runtime must register grouped capture with bounded foreground concurrency");
|
|
317
318
|
assert.equal(controller.parameters.properties.operation.enum.includes("finalize"), false);
|
|
318
319
|
assert.equal(controller.parameters.properties.operation.enum.includes("validate"), false);
|
|
319
320
|
|