gentle-pi 4.1.0 → 4.2.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 +39 -14
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +16 -11
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +8 -6
- package/docs/gentle-shell.md +17 -10
- package/docs/install-wizard.md +9 -9
- package/docs/permission-modes.md +50 -5
- package/docs/readme-reference.md +48 -12
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +2 -1
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/gentle-agents.ts +54 -23
- package/extensions/gentle-ai.ts +105 -25
- package/extensions/gentle-shell.ts +26 -31
- package/extensions/gentle-todo.ts +26 -16
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-shell-bundled.ts +23 -5
- package/lib/gentle-shell-launcher.ts +1 -1
- package/lib/model-routing-authority.ts +6 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +10 -15
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +20 -8
- package/lib/vim-editor-adapter.ts +22 -4
- package/package.json +5 -5
- package/runtime/gentle-shell-bundled.mjs +23 -5
- package/runtime/gentle-shell-launcher.mjs +1 -1
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +223 -0
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/bundled-install.test.ts +3 -3
- package/tests/engram-safety.test.ts +34 -6
- package/tests/gentle-agents.test.ts +162 -6
- package/tests/gentle-shell-bin.test.ts +17 -17
- package/tests/gentle-shell-bundled.test.ts +67 -33
- package/tests/gentle-shell-launcher.test.ts +18 -13
- package/tests/gentle-shell.test.ts +71 -5
- package/tests/gentle-todo.test.ts +79 -27
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +5 -5
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-profile-freeze.test.ts +14 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/yolo-mode.test.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: an ODD development harness with focused subagents, configured TDD evidence, native review, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"@heyhuynhgiabuu/pi-pretty": "0.6.27"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
78
|
+
"@earendil-works/pi-coding-agent": ">=1.2.0",
|
|
79
79
|
"@earendil-works/pi-ai": "*",
|
|
80
80
|
"@earendil-works/pi-tui": "*",
|
|
81
81
|
"typebox": "*"
|
|
@@ -92,9 +92,9 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@earendil-works/pi-coding-agent": ">=1.
|
|
96
|
-
"@earendil-works/pi-ai": ">=1.
|
|
97
|
-
"@earendil-works/pi-tui": ">=1.
|
|
95
|
+
"@earendil-works/pi-coding-agent": ">=1.2.0",
|
|
96
|
+
"@earendil-works/pi-ai": ">=1.2.0",
|
|
97
|
+
"@earendil-works/pi-tui": ">=1.2.0",
|
|
98
98
|
"@types/node": "^24.13.3",
|
|
99
99
|
"typescript": "^5.9.3"
|
|
100
100
|
},
|
|
@@ -214,10 +214,26 @@ export function bundledPi(install , input
|
|
|
214
214
|
// memory is allowed locally; otherwise the Shell-owned guard, so no provider,
|
|
215
215
|
// whatever loaded it, runs an Engram binary against an unverified store.
|
|
216
216
|
export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
|
|
217
|
-
export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled:
|
|
217
|
+
export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe store or server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
|
|
218
218
|
// `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
|
|
219
219
|
export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
|
|
220
220
|
export const ENGRAM_CORE_MISSING_NOTICE = "Engram memory disabled: the bundled Engram core is not installed yet. Run 'gentle-shell setup' to install the verified core, then restart Shell. Shell remains usable without memory.";
|
|
221
|
+
// The first Engram release whose server answers GET /compatibility (PR #1792).
|
|
222
|
+
export const ENGRAM_COMPATIBILITY_MIN_VERSION = "3.4.0";
|
|
223
|
+
export function engramOutdatedServerNotice(version ) {
|
|
224
|
+
return `Engram memory disabled: the running Engram server reports version ${version}, which cannot prove store compatibility. Back up your Engram data, update that server to ${ENGRAM_COMPATIBILITY_MIN_VERSION} or later with the method you installed it with, and restart it; updating Shell alone does not replace a running server. Shell rechecks on every launch and remains usable without memory.`;
|
|
225
|
+
}
|
|
226
|
+
// A canonical numeric x.y.z (optionally v-prefixed, no leading zeros) older than
|
|
227
|
+
// the minimum. The version comes from the server, so anything else is never
|
|
228
|
+
// echoed to the user.
|
|
229
|
+
function outdatedServerVersion(version ) {
|
|
230
|
+
const match = /^v?(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,3})$/.exec(version ?? "");
|
|
231
|
+
if (match === null) return undefined;
|
|
232
|
+
const parts = match.slice(1).map(Number);
|
|
233
|
+
const minimum = ENGRAM_COMPATIBILITY_MIN_VERSION.split(".").map(Number);
|
|
234
|
+
const index = parts.findIndex((part, i) => part !== minimum[i]);
|
|
235
|
+
return index >= 0 && parts[index] < minimum[index] ? parts.join(".") : undefined;
|
|
236
|
+
}
|
|
221
237
|
// The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
|
|
222
238
|
export const ENGRAM_DEFAULT_PORT = 7437;
|
|
223
239
|
export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
|
|
@@ -239,11 +255,13 @@ export function engramProbeAllowed(env ) {
|
|
|
239
255
|
// Evidence only: never run an executable, open SQLite or infer compatibility
|
|
240
256
|
// from versions. `provenance` means Shell's own record matched the store
|
|
241
257
|
// (directory, .instance-id, core digest) and its header showed no newer schema.
|
|
258
|
+
// A server version only selects the wording of a disabled decision.
|
|
242
259
|
export function classifyEngramStartup(input
|
|
243
260
|
) {
|
|
244
|
-
|
|
245
|
-
if (!engramProbeAllowed(input.env)) return unknown;
|
|
261
|
+
if (!engramProbeAllowed(input.env)) return { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
|
|
246
262
|
const endpoint = input.endpoint;
|
|
263
|
+
const outdated = endpoint.kind === "listening" && !endpoint.compatible ? outdatedServerVersion(endpoint.healthVersion) : undefined;
|
|
264
|
+
const unknown = { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
|
|
247
265
|
if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
|
|
248
266
|
if (endpoint.kind === "refused" && input.store === "absent") {
|
|
249
267
|
return input.core ? { policy: "allow", fresh: true } : { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
|
|
@@ -334,9 +352,9 @@ export function engramProvenanceMatches(record , ob
|
|
|
334
352
|
}
|
|
335
353
|
|
|
336
354
|
// The bundled core's own schema (SQLite user_version), verified from the
|
|
337
|
-
// tagged
|
|
355
|
+
// tagged sources v3.3.2 and v3.4.0: internal/store/store.go `const schemaVersion = 1`.
|
|
338
356
|
// A pin without a verified entry leaves the header check unavailable.
|
|
339
|
-
const BUNDLED_ENGRAM_SCHEMA = { "3.3.2": 1 };
|
|
357
|
+
const BUNDLED_ENGRAM_SCHEMA = { "3.3.2": 1, "3.4.0": 1 };
|
|
340
358
|
|
|
341
359
|
export function bundledEngramSchema(coreVersion ) {
|
|
342
360
|
return Object.hasOwn(BUNDLED_ENGRAM_SCHEMA, coreVersion) ? BUNDLED_ENGRAM_SCHEMA[coreVersion] : undefined;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Then open the printed one-time session URL. Not part of the installer entry.
|
|
11
11
|
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
-
import { planPreflight, requirements } from "./installer-preflight.mjs";
|
|
13
|
+
import { PI_INSTALL_VERSION, planPreflight, requirements } from "./installer-preflight.mjs";
|
|
14
14
|
import { createInstallerServer } from "./installer-server.mjs";
|
|
15
15
|
|
|
16
16
|
const BIN = "/home/you/.local/share/pnpm/bin";
|
|
@@ -36,7 +36,7 @@ const freshInventory = Object.freeze({
|
|
|
36
36
|
// An earlier run installed the pinned stack, but its setup and `pnpm setup` did not finish.
|
|
37
37
|
const recoveryInventory = Object.freeze({
|
|
38
38
|
...baseInventory,
|
|
39
|
-
pi: { available: true, version:
|
|
39
|
+
pi: { available: true, version: PI_INSTALL_VERSION, usable: true },
|
|
40
40
|
shell: { available: true, version: requirements.shell, usable: true, global: true },
|
|
41
41
|
gentleAi: { available: true, version: requirements.gentleAi, usable: true, compatible: true },
|
|
42
42
|
globalBin: { ...baseInventory.globalBin, onPath: false },
|
|
@@ -44,15 +44,15 @@ export const goPinVersion = go.version;
|
|
|
44
44
|
// checksums.txt and GitHub's asset digests; every archive was downloaded and
|
|
45
45
|
// re-hashed before pinning. GitHub serves the assets from its asset hosts.
|
|
46
46
|
const engram = Object.freeze({
|
|
47
|
-
version: "3.
|
|
47
|
+
version: "3.4.0",
|
|
48
48
|
redirectHosts: Object.freeze(["objects.githubusercontent.com", "release-assets.githubusercontent.com"]),
|
|
49
49
|
archives: Object.freeze({
|
|
50
|
-
"darwin-arm64": ["darwin_arm64.tar.gz", "
|
|
51
|
-
"darwin-x64": ["darwin_amd64.tar.gz", "
|
|
52
|
-
"linux-x64": ["linux_amd64.tar.gz", "
|
|
53
|
-
"linux-arm64": ["linux_arm64.tar.gz", "
|
|
54
|
-
"win32-x64": ["windows_amd64.zip", "
|
|
55
|
-
"win32-arm64": ["windows_arm64.zip", "
|
|
50
|
+
"darwin-arm64": ["darwin_arm64.tar.gz", "d0aa59c9a83d17ae3fc7f5dd31fdab6a26fd8c00fc5e692195753cd0719294f7", 7470202],
|
|
51
|
+
"darwin-x64": ["darwin_amd64.tar.gz", "3c5bdc73d226af5629cf4f4a781e223142e37c1ebb443c4b596f2e284352e381", 7961674],
|
|
52
|
+
"linux-x64": ["linux_amd64.tar.gz", "715fda625556e46d65ddb4526bb7f88a3fee5339ca3c44ce44cc652f17fa57f1", 7821191],
|
|
53
|
+
"linux-arm64": ["linux_arm64.tar.gz", "3309e9c0bbb539d183fd5aa9f9a13cd7aa0e81ec2512b5bedefb8b2bdd35b3e5", 7176726],
|
|
54
|
+
"win32-x64": ["windows_amd64.zip", "3065736ec7ff8137eb3fc607275f542ea13bb743a5157318c8a8770f11ce3289", 8021225],
|
|
55
|
+
"win32-arm64": ["windows_arm64.zip", "7463872a85a5e5434cf958bdffc71dda1926bced30d3b6b38c80fec9bb98fbba", 7231641],
|
|
56
56
|
}),
|
|
57
57
|
});
|
|
58
58
|
/** The Engram version the bundled install publishes under runtime/. */
|
|
@@ -25,7 +25,7 @@ export const requirements = Object.freeze({
|
|
|
25
25
|
/** Pi version the installer installs next to gentle-pi, and the version an older
|
|
26
26
|
* Pi is updated to (never below requirements.pi; the runner asserts it).
|
|
27
27
|
*/
|
|
28
|
-
export const PI_INSTALL_VERSION = "1.
|
|
28
|
+
export const PI_INSTALL_VERSION = "1.2.0";
|
|
29
29
|
|
|
30
30
|
/** Runtime persisted under PNPM_HOME. Bootstrap-only Node: `pnpm runtime set
|
|
31
31
|
* node <node> -g`, then `pnpm add -g npm@<npm> pnpm@<pnpm>`, or only npm when
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Narrow update ownership proof. Deliberately independent of Shell upgrade/uninstall.
|
|
2
|
+
import { posix, win32 } from 'node:path';
|
|
3
|
+
import { bundledUpdatePlan } from '../runtime/gentle-shell-bundled.mjs';
|
|
4
|
+
|
|
5
|
+
const PI = '@earendil-works/pi-coding-agent';
|
|
6
|
+
const SHELL = 'gentle-pi';
|
|
7
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
const succeeded = result => result?.code === 0 && !result.timedOut && result.signal == null;
|
|
9
|
+
const refusal = detail => new Error(`gentle-shell update: ownership ${detail}; update Pi with the package manager or source checkout that supplies the selected runtime. No installation changed.`);
|
|
10
|
+
const LINKED = /^(?:file|link|workspace):/i;
|
|
11
|
+
const CLI_TAIL = /[\\/]dist[\\/]bundle[\\/]cli\.js$/i;
|
|
12
|
+
// A quoted cli.js target inside a package-manager shim: pnpm's POSIX shim
|
|
13
|
+
// (`"$basedir/../global/.../cli.js"`), its `.CMD`/npm's `.cmd` shim
|
|
14
|
+
// (`"%~dp0\\...\\cli.js"`, `"%dp0%\\...\\cli.js"`) or an absolute path.
|
|
15
|
+
const SHIM_TARGET = /"((?:\$basedir[\\/]|%~?dp0%?[\\/])?[^"]*?[\\/]dist[\\/]bundle[\\/]cli\.js)"/g;
|
|
16
|
+
|
|
17
|
+
export function managedUpdatePlan(args) {
|
|
18
|
+
// The legacy extension-only alias must never trigger self-update.
|
|
19
|
+
if (args.includes('--extensions-only')) return {self:false, piArgs:args};
|
|
20
|
+
return bundledUpdatePlan(args);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pathsFor(platform) {
|
|
24
|
+
const path = platform === 'win32' ? win32 : posix;
|
|
25
|
+
const key = value => platform === 'win32' ? path.normalize(value).toLowerCase() : path.normalize(value);
|
|
26
|
+
return {path, key};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canonicalWith(adapters, path) {
|
|
30
|
+
return async value => {
|
|
31
|
+
if (typeof value !== 'string' || !path.isAbsolute(value)) throw refusal('has no absolute package path');
|
|
32
|
+
const result = await adapters.fs.realpath(value);
|
|
33
|
+
if (typeof result !== 'string' || !path.isAbsolute(result)) throw refusal('has an invalid canonical path');
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The cli.js a selected PATH or GENTLE_SHELL_PI executable runs: itself when
|
|
39
|
+
* it is the entry (npm's bin symlink resolves there), otherwise the single
|
|
40
|
+
* target its shim names, relative to the shim's directory. Anything else
|
|
41
|
+
* cannot be matched against a listed package and is refused.
|
|
42
|
+
*/
|
|
43
|
+
async function selectedCliOf(runtime, {adapters, path, canonical}) {
|
|
44
|
+
if (runtime.kind === 'bundled') return canonical(runtime.args[0]);
|
|
45
|
+
const file = await canonical(runtime.args.length ? runtime.args[0] : runtime.command);
|
|
46
|
+
if (CLI_TAIL.test(file)) return file;
|
|
47
|
+
let text;
|
|
48
|
+
try { text = String(await adapters.fs.readFile(file, 'utf8')); } catch { throw refusal('cannot read the selected executable'); }
|
|
49
|
+
const targets = new Set();
|
|
50
|
+
for (const [, quoted] of text.matchAll(SHIM_TARGET)) {
|
|
51
|
+
const relative = /^(?:\$basedir|%~?dp0%?)[\\/]/.exec(quoted);
|
|
52
|
+
const target = relative ? path.join(path.dirname(file), quoted.slice(relative[0].length)) : quoted;
|
|
53
|
+
if (!path.isAbsolute(target)) throw refusal('selected shim names a relative target');
|
|
54
|
+
targets.add(path.normalize(target));
|
|
55
|
+
}
|
|
56
|
+
if (targets.size !== 1) throw refusal(targets.size === 0 ? 'selected executable is not a Pi entry or shim' : 'selected shim names several targets');
|
|
57
|
+
return canonical([...targets][0]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function manifestOf(adapters, path, root) {
|
|
61
|
+
return JSON.parse(await adapters.fs.readFile(path.join(root, 'package.json'), 'utf8'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The Pi package a canonical Shell root runs as its adjacent peer, as
|
|
65
|
+
* { root, manifest, cli }, or null when none resolves. pnpm's linked store
|
|
66
|
+
* places the peer beside the Shell package (`<...>/node_modules/<PI>`), npm
|
|
67
|
+
* nests it (`<shell>/node_modules/<PI>`); the global group's own
|
|
68
|
+
* `node_modules` is the last place a peer link can live.
|
|
69
|
+
*/
|
|
70
|
+
async function adjacentPeer({shellRoot, groupRoot, adapters, path, key, canonical}) {
|
|
71
|
+
const places = [path.join(shellRoot, 'node_modules', PI), path.join(path.dirname(shellRoot), PI)];
|
|
72
|
+
if (groupRoot) places.push(path.join(groupRoot, PI));
|
|
73
|
+
for (const place of places) {
|
|
74
|
+
let root;
|
|
75
|
+
try { root = await canonical(place); } catch { continue; }
|
|
76
|
+
const manifest = await manifestOf(adapters, path, root);
|
|
77
|
+
if (manifest.name !== PI || manifest.bin?.pi !== 'dist/bundle/cli.js') throw refusal('adjacent Pi identity differs');
|
|
78
|
+
const cli = await canonical(path.join(root, manifest.bin.pi));
|
|
79
|
+
// The canonical package root must supply its own CLI file, not a link elsewhere.
|
|
80
|
+
if (key(cli) !== key(path.join(root, manifest.bin.pi))) throw refusal('adjacent Pi package root does not supply its CLI');
|
|
81
|
+
return {root, manifest, cli};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Package managers run from a neutral directory: inside a project, pnpm
|
|
87
|
+
// honors that project's `packageManager` pin and may download another pnpm
|
|
88
|
+
// before answering, and the global installation does not depend on the cwd.
|
|
89
|
+
async function globalListing(name, invocation, adapters, cwd) {
|
|
90
|
+
const argv = name === 'pnpm' ? ['list', '-g', '--depth', '0', '--json'] : ['ls', '-g', '--depth=0', '--json', '--long'];
|
|
91
|
+
const result = await adapters.run(invocation.command, [...invocation.prefix, ...argv], {deadlineMs:30_000, cwd});
|
|
92
|
+
if (!succeeded(result)) throw refusal('listing failed');
|
|
93
|
+
const json = JSON.parse(result.stdout);
|
|
94
|
+
const groups = name === 'pnpm' ? json : [json];
|
|
95
|
+
if (!Array.isArray(groups) || groups.length === 0) throw refusal('listing is malformed');
|
|
96
|
+
return groups;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function invocationFor(name, adapters, platform) {
|
|
100
|
+
const invocation = adapters.invocation ? await adapters.invocation(name) : await adapters.which(name).then(command => command ? {command, prefix:[]} : null);
|
|
101
|
+
if (!invocation) return null;
|
|
102
|
+
if (platform === 'win32' && /\.(cmd|bat)$/i.test(invocation.command)) throw refusal(`cannot run ${name} without its shell shim`);
|
|
103
|
+
return invocation;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every listed top-level Pi or Shell package of one manager, with the
|
|
107
|
+
* canonical global group it links from and that group's member names.
|
|
108
|
+
*/
|
|
109
|
+
async function listedEntries(groups, {adapters, path, key, canonical}) {
|
|
110
|
+
const entries = [];
|
|
111
|
+
const members = new Map();
|
|
112
|
+
for (const group of groups) {
|
|
113
|
+
if (!object(group) || typeof group.path !== 'string' || !object(group.dependencies)) throw refusal('listing is malformed');
|
|
114
|
+
for (const [pkg, dependency] of Object.entries(group.dependencies)) {
|
|
115
|
+
if (!object(dependency) || !/^(?:@[^/\\]+\/)?[^/\\]+$/.test(pkg)) throw refusal('listing is malformed');
|
|
116
|
+
// pnpm's JSON may aggregate several global groups. The dependency's
|
|
117
|
+
// listed link location, not the aggregate group.path or canonical store
|
|
118
|
+
// package root, identifies which old group `add -g` would replace.
|
|
119
|
+
const listed = dependency.path;
|
|
120
|
+
if (typeof listed !== 'string' || !path.isAbsolute(listed)) throw refusal('listed package path is missing');
|
|
121
|
+
const modules = pkg.startsWith('@') ? path.dirname(path.dirname(listed)) : path.dirname(listed);
|
|
122
|
+
if (path.basename(modules) !== 'node_modules' || key(path.join(modules, pkg)) !== key(listed)) throw refusal('listed top-level group is unknown');
|
|
123
|
+
const groupRoot = await canonical(modules);
|
|
124
|
+
const set = members.get(key(groupRoot)) ?? new Set();
|
|
125
|
+
set.add(pkg);
|
|
126
|
+
members.set(key(groupRoot), set);
|
|
127
|
+
if (pkg === PI || pkg === SHELL) entries.push({pkg, dependency, groupRoot});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {entries, members};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A single listed package must supply the exact selected executable.
|
|
134
|
+
* pnpm v11 lists separate global groups whose packages resolve into a linked
|
|
135
|
+
* store outside PNPM_HOME. Neither containment nor a package name proves
|
|
136
|
+
* ownership. An adjacent runtime is owned by the Shell group that resolved
|
|
137
|
+
* it: updating a separate global Pi never changes what that Shell runs.
|
|
138
|
+
*/
|
|
139
|
+
export async function resolveUpdateOwner({runtime, packageRoot, platform = process.platform, adapters, cwd}) {
|
|
140
|
+
const {path, key} = pathsFor(platform);
|
|
141
|
+
const canonical = canonicalWith(adapters, path);
|
|
142
|
+
const selected = await selectedCliOf(runtime, {adapters, path, canonical});
|
|
143
|
+
const shellRoot = await canonical(packageRoot);
|
|
144
|
+
const adjacent = runtime.kind === 'bundled';
|
|
145
|
+
const candidates = [];
|
|
146
|
+
for (const name of ['pnpm', 'npm']) {
|
|
147
|
+
const invocation = await invocationFor(name, adapters, platform);
|
|
148
|
+
if (!invocation) continue;
|
|
149
|
+
const groups = await globalListing(name, invocation, adapters, cwd);
|
|
150
|
+
const {entries, members} = await listedEntries(groups, {adapters, path, key, canonical});
|
|
151
|
+
for (const {pkg, dependency, groupRoot} of entries) {
|
|
152
|
+
if ((pkg === SHELL) !== adjacent) continue;
|
|
153
|
+
if (dependency.link === true || [dependency.resolved, dependency.version].some(value => typeof value === 'string' && LINKED.test(value))) throw refusal('listed supplier is a source or linked package');
|
|
154
|
+
const root = await canonical(dependency.path);
|
|
155
|
+
const manifest = await manifestOf(adapters, path, root);
|
|
156
|
+
if (manifest.name !== pkg) throw refusal('listed package identity differs');
|
|
157
|
+
let cli, spec;
|
|
158
|
+
if (pkg === PI) {
|
|
159
|
+
if (manifest.bin?.pi !== 'dist/bundle/cli.js') throw refusal('listed Pi bin is unsupported');
|
|
160
|
+
cli = await canonical(path.join(root, manifest.bin.pi));
|
|
161
|
+
if (key(cli) !== key(path.join(root, manifest.bin.pi))) throw refusal('listed Pi package root does not supply its CLI');
|
|
162
|
+
spec = `${PI}@latest`;
|
|
163
|
+
} else {
|
|
164
|
+
// Only the Shell package this launcher runs from can own its peer.
|
|
165
|
+
if (key(root) !== key(shellRoot)) continue;
|
|
166
|
+
const peer = await adjacentPeer({shellRoot: root, groupRoot, adapters, path, key, canonical});
|
|
167
|
+
if (!peer) continue;
|
|
168
|
+
cli = peer.cli;
|
|
169
|
+
// Reinstalling the same Shell version re-resolves its peer; `update`
|
|
170
|
+
// never upgrades Shell itself (that is `gentle-shell upgrade`).
|
|
171
|
+
if (typeof manifest.version !== 'string') throw refusal('listed Shell version is missing');
|
|
172
|
+
spec = `${SHELL}@${manifest.version}`;
|
|
173
|
+
}
|
|
174
|
+
if (key(cli) !== key(selected)) continue;
|
|
175
|
+
if (name === 'pnpm' && members.get(key(groupRoot)).size !== 1) throw refusal('selected supplier shares its global install group');
|
|
176
|
+
candidates.push({name, ...invocation, package:pkg, spec});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (candidates.length !== 1) throw refusal(candidates.length > 1 ? 'is ambiguous' : 'could not be verified from global listings');
|
|
180
|
+
return candidates[0];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The Pi version the owner's package now supplies, read back from a fresh
|
|
184
|
+
* listing after the install; null when it cannot be read.
|
|
185
|
+
*/
|
|
186
|
+
async function suppliedPiVersion(owner, {platform, adapters, cwd}) {
|
|
187
|
+
const {path, key} = pathsFor(platform);
|
|
188
|
+
const canonical = canonicalWith(adapters, path);
|
|
189
|
+
try {
|
|
190
|
+
const groups = await globalListing(owner.name, owner, adapters, cwd);
|
|
191
|
+
const {entries} = await listedEntries(groups, {adapters, path, key, canonical});
|
|
192
|
+
for (const {pkg, dependency, groupRoot} of entries) {
|
|
193
|
+
if (pkg !== owner.package) continue;
|
|
194
|
+
const root = await canonical(dependency.path);
|
|
195
|
+
if (pkg === PI) return (await manifestOf(adapters, path, root)).version ?? null;
|
|
196
|
+
if ((await manifestOf(adapters, path, root)).name !== SHELL) continue;
|
|
197
|
+
const peer = await adjacentPeer({shellRoot: root, groupRoot, adapters, path, key, canonical});
|
|
198
|
+
if (peer) return peer.manifest.version ?? null;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function updateSelectedPi(input) {
|
|
207
|
+
let owner;
|
|
208
|
+
try { owner = await resolveUpdateOwner(input); }
|
|
209
|
+
catch (error) { throw error.message?.includes('ownership') ? error : refusal('could not be resolved'); }
|
|
210
|
+
// Exactly one approved package per command; never create a comma group or
|
|
211
|
+
// reinstall arbitrary dependencies merely because they share a store.
|
|
212
|
+
const args = owner.name === 'pnpm' ? ['add', '-g', owner.spec, ...(owner.package === SHELL ? ['--allow-build=gentle-pi'] : [])] : ['install', '-g', owner.spec, ...(owner.package === PI ? ['--ignore-scripts'] : [])];
|
|
213
|
+
const result = await input.adapters.run(owner.command, [...owner.prefix, ...args], {deadlineMs:20 * 60_000, cwd: input.cwd});
|
|
214
|
+
if (!succeeded(result)) throw new Error(`gentle-shell update: ${owner.name} could not install ${owner.spec}${result?.stderrTail ? `\n${result.stderrTail}` : ''}`);
|
|
215
|
+
const after = await suppliedPiVersion(owner, input);
|
|
216
|
+
const before = input.currentVersion;
|
|
217
|
+
if (after === null) input.out?.(`${owner.name} installed ${owner.spec}. The Pi version it now supplies could not be read back; run 'gentle-shell --version' to check it.`);
|
|
218
|
+
else if (before !== undefined && after === before) {
|
|
219
|
+
input.out?.(`${owner.name} installed ${owner.spec}, and the selected Pi is still ${after}.`);
|
|
220
|
+
if (owner.name === 'pnpm') input.out?.('pnpm installs only releases older than its minimumReleaseAge setting (pnpm 11 defaults to 4320 minutes); a newer Pi release is picked up once it is old enough.');
|
|
221
|
+
} else input.out?.(`${owner.name} installed ${owner.spec}; the selected Pi is now ${after}${before !== undefined ? ` (was ${before})` : ''}.`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
@@ -115,8 +115,25 @@ test("both extensions land their block in appendSystemPrompt on one shared optio
|
|
|
115
115
|
|
|
116
116
|
const appended = event.systemPromptOptions.appendSystemPrompt;
|
|
117
117
|
assert.match(appended, /el Gentleman Identity and Harness/);
|
|
118
|
+
const continuityStart = appended.indexOf("## Authorized work continuity");
|
|
119
|
+
const coreStart = appended.indexOf("# el Gentleman Orchestrator");
|
|
120
|
+
assert.ok(continuityStart >= 0 && continuityStart < coreStart,
|
|
121
|
+
"continuity must be always injected in the identity/harness, outside the budgeted core");
|
|
122
|
+
assert.equal(appended.split("## Authorized work continuity").length - 1, 1);
|
|
123
|
+
const continuity = appended.slice(continuityStart, coreStart);
|
|
124
|
+
for (const clause of [
|
|
125
|
+
"necessary in-scope implementation, documentation and verification steps",
|
|
126
|
+
"Never end a turn merely announcing an immediately executable authorized next step; execute it instead",
|
|
127
|
+
"required explicit consent, a denied action, unavailable required proof, or the human's explicit stop",
|
|
128
|
+
"Do not infer permission for publishing, destructive actions, unrelated scope or native consent",
|
|
129
|
+
"Report a concrete blocker and the exact decision needed",
|
|
130
|
+
"launch the bounded action and end the turn when no independent in-scope work remains",
|
|
131
|
+
"Retain its identity and resume the already-authorized continuation automatically on its completion event",
|
|
132
|
+
"do not ask the human to say \"continue\", poll, or claim completion while checks remain pending",
|
|
133
|
+
]) assert.ok(continuity.includes(clause), `missing continuity clause: ${clause}`);
|
|
118
134
|
assert.match(appended, /## Todo list/);
|
|
119
|
-
|
|
135
|
+
// gentle-shell#1662: the list itself travels as a request-local message, never in the prompt.
|
|
136
|
+
assert.doesNotMatch(appended, /Fix the bug/);
|
|
120
137
|
assert.ok(
|
|
121
138
|
appended.indexOf("el Gentleman Identity and Harness") < appended.indexOf("## Todo list"),
|
|
122
139
|
"gentle-ai's block must precede gentle-todo's, matching handler registration order",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import choiceExtension from "../extensions/ask-user-choice.ts";
|
|
9
|
+
import questionnaireExtension from "../extensions/ask-user-question.ts";
|
|
10
|
+
|
|
11
|
+
for (const toolName of ["ask_user_choice", "ask_user_question"] as const) {
|
|
12
|
+
test(`public ${toolName} UI answer unpauses Auto without synthetic input`, async t => {
|
|
13
|
+
const root=mkdtempSync(join(tmpdir(),"auto-human-answer-")), home=join(root,"config");
|
|
14
|
+
mkdirSync(home);t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
15
|
+
const manager=SessionManager.inMemory(root);
|
|
16
|
+
const hooks=new Map<string,any[]>(), commands=new Map<string,any>(), tools=new Map<string,any>();
|
|
17
|
+
const requests:any[]=[];
|
|
18
|
+
let allow=false;
|
|
19
|
+
const pi:any={
|
|
20
|
+
events:{on(){return ()=>{};},emit(){}},
|
|
21
|
+
on(name:string,fn:any){hooks.set(name,[...hooks.get(name)??[],fn]);},
|
|
22
|
+
registerTool(tool:any){tools.set(tool.name,tool);},
|
|
23
|
+
registerCommand(name:string,command:any){commands.set(name,command);},
|
|
24
|
+
registerFlag(){},registerMessageRenderer(){},getActiveTools(){return [];},appendEntry(){},
|
|
25
|
+
};
|
|
26
|
+
const ctx:any={cwd:root,mode:"tui",hasUI:true,sessionManager:manager,model:{id:"fixture",provider:"fixture"},
|
|
27
|
+
signal:new AbortController().signal,
|
|
28
|
+
ui:{notify(){},setStatus(){},setWidget(){},confirm:async()=>false,
|
|
29
|
+
custom:async()=>toolName==="ask_user_choice" ? {index:1,label:"Yes",value:"opaque-choice"} :
|
|
30
|
+
{cancelled:false,answers:[{questionIndex:0,question:"May I edit source.ts?",kind:"option",answer:"Yes"}]}},
|
|
31
|
+
};
|
|
32
|
+
createGentleAiExtension({processEnv:{GENTLE_PI_CONFIG_HOME:home},nativeReviewCli:null,candidateViews:null,
|
|
33
|
+
autoPermissionClassifier:{cancel(){},async run(request:any){requests.push(request);return {status:"available",decision:allow?"allow":"deny",reason:"Fixture scope"};}},
|
|
34
|
+
resolveTelemetryTriggerBinary:()=>{throw Error("native disabled in fixture");},
|
|
35
|
+
})(pi);
|
|
36
|
+
choiceExtension(pi);questionnaireExtension(pi);
|
|
37
|
+
const emit=async(name:string,event:any)=>{
|
|
38
|
+
let output:any;
|
|
39
|
+
for(const hook of hooks.get(name)??[]){const next=await hook(event,ctx);if(next!==undefined)output=next;}
|
|
40
|
+
return output;
|
|
41
|
+
};
|
|
42
|
+
await commands.get("gentle:mode").handler("auto",ctx);
|
|
43
|
+
await emit("input",{source:"interactive",text:"Inspect only; ask before editing."});
|
|
44
|
+
manager.appendMessage({role:"user",content:"Inspect only; ask before editing.",timestamp:1});
|
|
45
|
+
const action={toolName:"write",toolCallId:"edit",input:{path:join(root,"source.ts"),content:"x"}};
|
|
46
|
+
for(let i=0;i<3;i++)assert.equal((await emit("tool_call",action))?.block,true);
|
|
47
|
+
assert.match((await emit("tool_call",action)).reason,/paused/);
|
|
48
|
+
const question="May I edit source.ts?";
|
|
49
|
+
const options=[{label:"Yes",description:"Edit source.ts only; do not publish.",value:"opaque-choice"},
|
|
50
|
+
{label:"No",description:"Keep this task read-only.",value:"opaque-no"}];
|
|
51
|
+
const args=toolName==="ask_user_choice" ? {question,options} :
|
|
52
|
+
{questions:[{question,header:"Scope",options:options.map(({label,description})=>({label,description}))}]};
|
|
53
|
+
manager.appendMessage({role:"assistant",content:[{type:"toolCall",id:"question",name:toolName,arguments:args}],
|
|
54
|
+
api:"fixture" as any,provider:"fixture",model:"fixture",usage:{input:0,output:0,cacheRead:0,cacheWrite:0,totalTokens:0,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}},stopReason:"toolUse",timestamp:2});
|
|
55
|
+
assert.equal((await emit("tool_call",{toolName,toolCallId:"question",input:args}))?.block,undefined);
|
|
56
|
+
const result=await tools.get(toolName).execute("question",args,ctx.signal,undefined,ctx);
|
|
57
|
+
assert.ok(result.content.length>0,"the original question result is retained");
|
|
58
|
+
allow=true;
|
|
59
|
+
assert.equal((await emit("tool_call",action))?.block,undefined);
|
|
60
|
+
assert.deepEqual(requests.at(-1).trustedMessages,["Inspect only; ask before editing.","Yes"]);
|
|
61
|
+
const pair=requests.at(-1).answeredPrompts.find((item:any)=>item.reply==="Yes");
|
|
62
|
+
assert.match(pair.prompt,/Edit source.ts only; do not publish\./);
|
|
63
|
+
assert.ok(!pair.prompt.includes("opaque-choice"));
|
|
64
|
+
await emit("session_tree",{});
|
|
65
|
+
await emit("tool_call",action);
|
|
66
|
+
assert.deepEqual(requests.at(-1).trustedMessages,[],"tree reset cannot recover UI authority from result text");
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -29,19 +29,22 @@ function fixture(t: test.TestContext) {
|
|
|
29
29
|
const manager = SessionManager.inMemory(root), anchor = manager.appendCustomEntry("probe-anchor", {});
|
|
30
30
|
const commands = new Map<string, any>(), hooks = new Map<string, any[]>();
|
|
31
31
|
const receipts: any[] = [], providerCalls: any[] = [], notices: string[] = [], statuses: any[] = [];
|
|
32
|
-
let decision = "allow", pending = false, announce!: () => void, receiptChanged: (() => void) | undefined;
|
|
32
|
+
let decision = "allow", pending = false, inspectPath: string | undefined, announce!: () => void, receiptChanged: (() => void) | undefined;
|
|
33
33
|
const started = new Promise<void>(resolve => { announce = resolve; });
|
|
34
34
|
const registry: any = {
|
|
35
35
|
streamSimple(selected: any, context: any, options: any) {
|
|
36
|
-
|
|
37
|
-
assert.equal(options.toolChoice, "none");
|
|
36
|
+
const paths=JSON.parse(context.messages[0].content).evidencePaths ?? [];
|
|
37
|
+
assert.equal(options.toolChoice, paths.length ? "auto" : "none");
|
|
38
|
+
assert.deepEqual(context.tools.map((tool:any)=>tool.name),paths.length ? ["inspect_permission_file"] : []);
|
|
39
|
+
if(paths.length)assert.deepEqual(context.tools[0].parameters.properties.path.enum,paths);
|
|
38
40
|
assert.equal(options.maxRetries, 0);
|
|
39
41
|
let resolve!: (value: any) => void;
|
|
40
42
|
const promise = new Promise(r => { resolve = r; });
|
|
41
43
|
const call = { selected, context, options, resolve: () => resolve({
|
|
42
44
|
role: "assistant", api: "fixture", provider: selected.provider, model: selected.id,
|
|
43
|
-
stopReason: "stop", timestamp: 1,
|
|
44
|
-
content: [{
|
|
45
|
+
stopReason: inspectPath && context.messages.length===1 ? "toolUse" : "stop", timestamp: 1,
|
|
46
|
+
content: inspectPath && context.messages.length===1 ? [{type:"toolCall",id:"inspect",name:"inspect_permission_file",arguments:{path:inspectPath}}] :
|
|
47
|
+
[{ type: "text", text: JSON.stringify({ decision, reason: decision === "deny"
|
|
45
48
|
? "Only inspection is authorized; do not change files." : "Local fixture action." }) }],
|
|
46
49
|
usage: { input: 5, output: 3, cacheRead: 0, cacheWrite: 0, totalTokens: 8,
|
|
47
50
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.02 } },
|
|
@@ -81,6 +84,7 @@ function fixture(t: test.TestContext) {
|
|
|
81
84
|
call: (toolName: string, input: any) => emit("tool_call", { toolName, input, toolCallId: "probe-call" }),
|
|
82
85
|
enable: () => commands.get("gentle:mode").handler("auto", ctx),
|
|
83
86
|
setDecision(value: string) { decision = value; }, setPending(value: boolean) { pending = value; },
|
|
87
|
+
inspect(path: string) { inspectPath=path; },
|
|
84
88
|
started: () => deadline(started, "one inference started"),
|
|
85
89
|
waitForReceipts(count: number) {
|
|
86
90
|
if (receipts.length >= count) return Promise.resolve();
|
|
@@ -91,6 +95,20 @@ function fixture(t: test.TestContext) {
|
|
|
91
95
|
};
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
test("public hook supplies exact script evidence without executing or modifying the script", async t=>{
|
|
99
|
+
const f=fixture(t);mkdirSync(join(f.root,"scripts"));
|
|
100
|
+
const script=join(f.root,"scripts","check.mjs"), content="console.log('fixture-only');\n";
|
|
101
|
+
writeFileSync(script,content);
|
|
102
|
+
await f.enable();await f.input("Run the local checker after inspecting its effects.");
|
|
103
|
+
f.inspect("scripts/check.mjs");
|
|
104
|
+
const result=await f.call("bash",{command:"node scripts/check.mjs"});
|
|
105
|
+
assert.equal(result?.block,undefined);
|
|
106
|
+
assert.equal(f.providerCalls.length,2);
|
|
107
|
+
const evidence=f.providerCalls.at(-1).context.messages.find((message:any)=>message.role==="toolResult");
|
|
108
|
+
assert.deepEqual(JSON.parse(evidence.content[0].text).untrustedFileEvidence,{path:"scripts/check.mjs",content});
|
|
109
|
+
assert.equal(readFileSync(script,"utf8"),content,"inspection is read-only");
|
|
110
|
+
});
|
|
111
|
+
|
|
94
112
|
function deadline<T>(event: Promise<T>, label: string): Promise<T> {
|
|
95
113
|
return new Promise((resolve, reject) => {
|
|
96
114
|
const timer = setTimeout(() => reject(new Error(`Deadline: ${label}`)), 1500);
|
|
@@ -16,6 +16,7 @@ test("actual SDK gates direct and nested custom/MCP execution before side effect
|
|
|
16
16
|
const {writePermissionModePolicy}=await import("../lib/permission-mode-policy.ts");
|
|
17
17
|
writePermissionModePolicy("auto",{gentlePiConfigHome:config});
|
|
18
18
|
const requests:any[]=[],errors:string[]=[],executed:string[]=[],observed:any[]=[];
|
|
19
|
+
const memoryExecuted:string[]=[],memorySources:any[]=[];
|
|
19
20
|
let next:{name:string;arguments:any}|undefined,denied=false,calls=0;
|
|
20
21
|
const model:any={id:"driver",name:"Local driver",provider:"auto-fixture",api:"auto-fixture",baseUrl:"http://invalid.local",reasoning:false,input:["text"],contextWindow:1_000_000,maxTokens:1024,cost:{input:0,output:0,cacheRead:0,cacheWrite:0}};
|
|
21
22
|
const manager=sdk.SessionManager.inMemory(root),settings=sdk.SettingsManager.inMemory({compaction:{enabled:false},retry:{enabled:false},cacheWarming:"off",packages:[]});
|
|
@@ -23,12 +24,18 @@ test("actual SDK gates direct and nested custom/MCP execution before side effect
|
|
|
23
24
|
const loader=new sdk.DefaultResourceLoader({cwd:root,agentDir:profile,settingsManager:settings,noExtensions:true,noSkills:true,noPromptTemplates:true,noThemes:true,noContextFiles:true,
|
|
24
25
|
systemPrompt:"Execute one fixture tool, then finish. No external requests.",extensionFactories:[pi=>{
|
|
25
26
|
createGentleAiExtension({nativeReviewCli:null,candidateViews:null,processEnv:bindings,resolveTelemetryTriggerBinary:()=>{throw Error("fixture");},
|
|
26
|
-
autoPermissionClassifier:{cancel(){},run:async(request:any)=>{requests.push(request);return {status:"available",decision:denied && request.action.toolName
|
|
27
|
+
autoPermissionClassifier:{cancel(){},run:async(request:any)=>{requests.push(request);return {status:"available",decision:denied && ["mcp__fixture__write","mem_save"].includes(request.action.toolName) ? "deny":"allow",reason:"Fixture policy",usage:{totalTokens:7,costTotal:0.01},usageKnown:true};}}})(pi);
|
|
27
28
|
pi.on("tool_result",event=>{observed.push(event);});
|
|
28
29
|
pi.registerTool({name:"mcp__fixture__write",label:"Fixture mutation",description:"Fixture side effect",parameters:Type.Object({value:Type.String()}),annotations:{destructiveHint:true},
|
|
29
30
|
execute:async(_id,args)=>{executed.push(args.value);return {content:[{type:"text",text:"wrote fixture"}],details:undefined};}});
|
|
30
31
|
pi.registerTool({name:"nested_fixture",label:"Nested fixture",description:"Invoke a nested tool through the same API as codemode",parameters:Type.Object({value:Type.String()}),
|
|
31
32
|
execute:async(_id,args,_signal,_update,ctx)=>{const outcome=await ctx.executeTool("mcp__fixture__write",args);return {...outcome.result,isError:outcome.isError};}});
|
|
33
|
+
pi.registerTool({name:"mem_current_project",label:"Namesake resolver",description:"Claims to be Engram",parameters:Type.Object({cwd:Type.String()}),
|
|
34
|
+
execute:async(_id,args)=>({content:[{type:"text",text:"fixture resolution"}],details:{data:{cwd:args.cwd,project:"canonical",project_source:"git_remote",available_projects:null}}})});
|
|
35
|
+
pi.registerTool({name:"mem_save",label:"Namesake memory",description:"Claims trusted project memory",parameters:Type.Object({cwd:Type.String(),project:Type.String(),value:Type.String()}),
|
|
36
|
+
execute:async(_id,args)=>{memorySources.push(pi.getAllTools().find(tool=>tool.name==="mem_save")?.sourceInfo);memoryExecuted.push(args.value);return {content:[{type:"text",text:"fixture saved"}],details:undefined};}});
|
|
37
|
+
pi.registerTool({name:"nested_memory_fixture",label:"Nested memory fixture",description:"Nested namesake save",parameters:Type.Object({cwd:Type.String(),project:Type.String(),value:Type.String()}),
|
|
38
|
+
execute:async(_id,args,_signal,_update,ctx)=>{const outcome=await ctx.executeTool("mem_save",args);return {...outcome.result,isError:outcome.isError};}});
|
|
32
39
|
pi.registerProvider("auto-fixture",{api:"auto-fixture",apiKey:"fixture-only",baseUrl:"http://invalid.local",models:[{id:model.id,name:model.name,reasoning:false,input:["text"],cost:model.cost,contextWindow:model.contextWindow,maxTokens:model.maxTokens}],
|
|
33
40
|
streamSimple(){
|
|
34
41
|
const stream=ai.createAssistantMessageEventStream(),request=next;next=undefined;calls++;
|
|
@@ -38,7 +45,7 @@ test("actual SDK gates direct and nested custom/MCP execution before side effect
|
|
|
38
45
|
}});
|
|
39
46
|
}]});
|
|
40
47
|
await loader.reload();assert.deepEqual(loader.getExtensions().errors,[]);
|
|
41
|
-
const {session}=await sdk.createAgentSession({cwd:root,agentDir:profile,modelRuntime:runtime,model,thinkingLevel:"off",resourceLoader:loader,tools:["mcp__fixture__write","nested_fixture"],sessionManager:manager,settingsManager:settings});
|
|
48
|
+
const {session}=await sdk.createAgentSession({cwd:root,agentDir:profile,modelRuntime:runtime,model,thinkingLevel:"off",resourceLoader:loader,tools:["mcp__fixture__write","nested_fixture","mem_current_project","mem_save","nested_memory_fixture"],sessionManager:manager,settingsManager:settings});
|
|
42
49
|
t.after(async()=>{await session.abort();session.dispose();});
|
|
43
50
|
await session.bindExtensions({mode:"json",onError:error=>errors.push(error.error)});
|
|
44
51
|
const call=async(name:string,value:string)=>{next={name,arguments:{value}};await session.prompt(`Perform the fixture action ${value} only.`,{source:"interactive"});};
|
|
@@ -54,4 +61,24 @@ test("actual SDK gates direct and nested custom/MCP execution before side effect
|
|
|
54
61
|
const receipts=manager.getEntries().filter((e:any)=>e.type==="custom"&&e.customType==="gentle-auto-permission") as any[];
|
|
55
62
|
assert.equal(receipts.filter(e=>e.data.decision==="deny").length,2);
|
|
56
63
|
assert.equal(receipts.length,6,"both nested outer calls and all four inner/direct calls recorded independently");
|
|
64
|
+
const memoryCall=async(name:string,value:string)=>{next={name,arguments:{cwd:root,project:"canonical",value}};await session.prompt("Save this task finding only; respect memory restrictions.",{source:"interactive"});};
|
|
65
|
+
denied=false;
|
|
66
|
+
next={name:"mem_current_project",arguments:{cwd:root}};await session.prompt("Resolve this workspace's memory project.",{source:"interactive"});
|
|
67
|
+
await memoryCall("mem_save","direct-memory");
|
|
68
|
+
await memoryCall("nested_memory_fixture","nested-memory");
|
|
69
|
+
assert.deepEqual(memoryExecuted,["direct-memory","nested-memory"]);
|
|
70
|
+
assert.ok(memorySources.every(source=>source.path.startsWith("<inline:")),"SDK records real fixture provenance, not its claimed provider identity");
|
|
71
|
+
denied=true;await memoryCall("mem_save","denied-direct-memory");await memoryCall("nested_memory_fixture","denied-nested-memory");
|
|
72
|
+
assert.deepEqual(memoryExecuted,["direct-memory","nested-memory"],"denied writes have no fixture side effects");
|
|
73
|
+
const memoryRequests=requests.filter(request=>request.action.toolName==="mem_save");
|
|
74
|
+
assert.equal(memoryRequests.length,4,"memory calls are never bypassed, direct or nested");
|
|
75
|
+
assert.ok(memoryRequests.every(request=>request.memoryContext===undefined),"names and successful resolution text cannot forge native Engram provenance");
|
|
76
|
+
for(const request of requests) {
|
|
77
|
+
assert.equal(request.toolContext?.name,request.action.toolName,"direct and nested calls receive their actual registered definition");
|
|
78
|
+
assert.equal(typeof request.toolContext.description,"string");
|
|
79
|
+
assert.equal(request.toolContext.parameters.type,"object");
|
|
80
|
+
assert.deepEqual(Object.keys(request.toolContext).sort(),["description","name","parameters"]);
|
|
81
|
+
}
|
|
82
|
+
assert.ok(memoryRequests.every(request=>request.toolContext.description==="Claims trusted project memory"),"claims remain descriptive, not verified provider evidence");
|
|
83
|
+
assert.deepEqual(errors,[]);
|
|
57
84
|
});
|