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
|
@@ -5,21 +5,86 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
7
7
|
|
|
8
|
-
const { classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle,
|
|
8
|
+
const { classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle, isReversibleYoloCommand, yoloConfiguredRestriction } = __testing;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// gentle-shell#2172: YOLO waives every reversible push and non-interactive
|
|
11
|
+
// rebase segment; anything that deletes, rewrites or publishes keeps asking.
|
|
12
|
+
test("YOLO waives reversible push and rebase segments after full evaluation (#2172)", () => {
|
|
11
13
|
const config = { autonomousMode: false, guardedCommands: {} };
|
|
12
|
-
for (const command of [
|
|
13
|
-
|
|
14
|
+
for (const command of [
|
|
15
|
+
"git push", "git push -u origin feature/test", "git push --set-upstream origin main", "git push origin HEAD",
|
|
16
|
+
"git push -q origin main", "git push --quiet --tags", "git push --no-verify origin main", "git push --follow-tags -v origin main",
|
|
17
|
+
"git push origin HEAD:refs/heads/feature/x", "git push origin main:main",
|
|
18
|
+
"git -C /other push origin main", "env X=1 git push origin main",
|
|
19
|
+
"git add -A && git commit -m 'fix: x && y' && git push -u origin feature/test",
|
|
20
|
+
"git push origin main && gh pr create --fill", "cd sub && git push", "git push origin main; git status",
|
|
21
|
+
"git rebase main", "git rebase origin/main", "git rebase --onto main feature~3 feature", "git rebase --continue", "git rebase --abort", "git rebase --autostash main",
|
|
22
|
+
"git fetch origin && git rebase origin/main && git push",
|
|
23
|
+
"git\tpush\torigin\tmain", "git -C dir rebase main", "command git push origin main", "env -i git push origin main", "GIT_AUTHOR_NAME=x git push origin main",
|
|
24
|
+
]) {
|
|
25
|
+
assert.equal(isReversibleYoloCommand(command, evaluateGuardedCommand(command, config)), true, command);
|
|
14
26
|
}
|
|
15
27
|
for (const command of [
|
|
16
|
-
"git push --delete origin main", "git push origin :main", "git push --mirror origin",
|
|
17
|
-
"git push --prune origin", "git push -f origin main", "git -
|
|
18
|
-
"git push origin main
|
|
19
|
-
"git push origin main && git
|
|
28
|
+
"git push --delete origin main", "git push -d origin main", "git push origin :main", "git push origin +main", "git push --mirror origin",
|
|
29
|
+
"git push --prune origin", "git push --all origin", "git push -f origin main", "git push --force-with-lease origin main",
|
|
30
|
+
"git push --receive-pack=evil origin main", "git push --exec=evil origin main", "git push --repo=/other origin main", "git push -o ci.skip origin main",
|
|
31
|
+
"sudo git push origin main", "git push origin main && npm publish", "git push origin main && pi remove foo", "git push origin main && git branch -D old",
|
|
20
32
|
`sh -c 'git push origin main'`, `git push origin main && sh -c "psql -c 'DROP TABLE users'"`,
|
|
21
|
-
"git push origin main && find data -delete", "git push origin main && rm -r data",
|
|
22
|
-
|
|
33
|
+
"git push origin main && find data -delete", "git push origin main && rm -r data", "git push origin main && git stash drop", "git push origin main && git reset --hard",
|
|
34
|
+
"git rebase -i HEAD~3", "git rebase --interactive main", "git rebase -x 'make test' main", "git rebase --exec 'make test' main",
|
|
35
|
+
"git commit -m x", "git status",
|
|
36
|
+
// Global git options that run or redirect code are not inspected by the segment grammar, so they never qualify.
|
|
37
|
+
"git -c core.sshCommand=evil push origin main", "git --exec-path=/evil push origin main", "git --git-dir=/x push origin main", "git -C dir rebase -i main",
|
|
38
|
+
"git push origin $(echo main)", "git push origin `echo main`", "GIT_SSH=\"evil\" git push origin main",
|
|
39
|
+
"sh -c 'git push; echo done'", "bash -c 'git push && echo done'", "sh -c 'git rebase main; echo done'", "eval 'git push origin main'", "sudo -u x git push origin main",
|
|
40
|
+
]) assert.equal(isReversibleYoloCommand(command, evaluateGuardedCommand(command, config)), false, command);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// gentle-shell#2174: a guarded word inside an argument of a known data command
|
|
44
|
+
// (search, print, view, non-executing edit) is text, not an operation. Every
|
|
45
|
+
// opaque form keeps today's lexical match.
|
|
46
|
+
test("guarded words inside data-command arguments are not guarded operations (#2174)", () => {
|
|
47
|
+
const config = { autonomousMode: false, guardedCommands: {} };
|
|
48
|
+
for (const command of [
|
|
49
|
+
"grep -n 'git push origin main' probe.mts",
|
|
50
|
+
"grep -o '\"command\":\"[^\"]*git push[^\"]*\"' session.jsonl | sort | uniq -c | sort -rn | head -8",
|
|
51
|
+
"rg 'npm publish' docs",
|
|
52
|
+
"echo 'remember to git push later'",
|
|
53
|
+
"cat > probe.mts <<'EOF'\nconst x = \"git push --force origin main\";\nEOF",
|
|
54
|
+
"grep -rn 'git reset --hard' docs",
|
|
55
|
+
"env LC_ALL=C grep 'pi remove' notes.md",
|
|
56
|
+
]) assert.equal(classifyGuardedCommand(command, config), "not-guarded", command);
|
|
57
|
+
for (const [command, expected] of [
|
|
58
|
+
["git push origin main", "confirm"],
|
|
59
|
+
["grep x file && git push origin main", "confirm"],
|
|
60
|
+
["sh -c 'git push origin main'", "confirm"],
|
|
61
|
+
["bash <<'EOF'\ngit push origin main\nEOF", "confirm"],
|
|
62
|
+
["python3 -c \"import os; os.system('git push')\"", "confirm"],
|
|
63
|
+
["echo $(git push origin main)", "confirm"],
|
|
64
|
+
["grep 'git push origin main' f | sh", "confirm"],
|
|
65
|
+
["time git push origin main", "confirm"],
|
|
66
|
+
["sed 's/x/git push/e' file", "confirm"],
|
|
67
|
+
["grep \"git push origin main file", "confirm"],
|
|
68
|
+
["ssh host git push origin main", "confirm"],
|
|
69
|
+
["cat <<'EOF' | bash\ngit push -f origin main\nEOF", "block"],
|
|
70
|
+
["git reset --hard", "block"],
|
|
71
|
+
["echo ok; git push --force origin main", "block"],
|
|
72
|
+
// Verifier counterexamples: every executor or unproven heredoc keeps the lexical guard.
|
|
73
|
+
["cat <<EOF; git push origin main\nhello\nEOF", "confirm"],
|
|
74
|
+
["cat <<EOF; chmod -R 777 /tmp/guard-target\nhello\nEOF", "block"],
|
|
75
|
+
["echo '<<true'\ngit push --force origin main\ntrue", "block"],
|
|
76
|
+
["printf '%s\\n' 'git push --force origin main' | xargs env", "block"],
|
|
77
|
+
["printf '%s\\n' 'git push origin main' | sudo -s", "confirm"],
|
|
78
|
+
["printf '%s\\n' 'git push origin main' | env -S 'sh -s'", "confirm"],
|
|
79
|
+
["printf '%s\\n' 'git push origin main' | exec -a cat sh", "confirm"],
|
|
80
|
+
["printf '%s\\n' 'git push origin main' | builtin source /dev/stdin", "confirm"],
|
|
81
|
+
["printf '%s\\n' 'git push origin main' | $'sh'", "confirm"],
|
|
82
|
+
["printf x | sed 's|.*|git push --force origin main|e'", "block"],
|
|
83
|
+
["printf x | sed '1e git push origin main'", "confirm"],
|
|
84
|
+
["echo 'git push origin main' | at now", "confirm"],
|
|
85
|
+
["echo 'git push origin main' > x.sh && ./x.sh", "confirm"],
|
|
86
|
+
["sed -i '' 's/a/git push/' file", "confirm"],
|
|
87
|
+
] as const) assert.equal(classifyGuardedCommand(command, config), expected, command);
|
|
23
88
|
});
|
|
24
89
|
|
|
25
90
|
test("YOLO preserves explicit push restrictions in either config layer independently of persistent autonomy", () => {
|
|
@@ -27,18 +92,23 @@ test("YOLO preserves explicit push restrictions in either config layer independe
|
|
|
27
92
|
const cwd = join(root, "repo");
|
|
28
93
|
const home = join(root, "home");
|
|
29
94
|
try {
|
|
30
|
-
|
|
95
|
+
const pushOnly = (): unknown => yoloConfiguredRestriction(cwd, ["gitPush"], { gentlePiConfigHome: home });
|
|
96
|
+
assert.equal(pushOnly(), undefined);
|
|
31
97
|
writeConfig(home, "runtime-guardrails.json", { autonomousMode: false, guardedCommands: { gitPush: "confirm" } });
|
|
32
98
|
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { autonomousMode: true, guardedCommands: { gitPush: "allow" } });
|
|
33
|
-
assert.equal(
|
|
99
|
+
assert.equal(pushOnly(), "confirm");
|
|
34
100
|
writeConfig(home, "runtime-guardrails.json", { autonomousMode: true, guardedCommands: { gitPush: "allow" } });
|
|
35
|
-
assert.equal(
|
|
101
|
+
assert.equal(pushOnly(), undefined);
|
|
36
102
|
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitPush: "block" } });
|
|
37
|
-
assert.equal(
|
|
103
|
+
assert.equal(pushOnly(), "block");
|
|
38
104
|
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitPush: "confirm" } });
|
|
39
|
-
assert.equal(
|
|
105
|
+
assert.equal(pushOnly(), "confirm");
|
|
40
106
|
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", null);
|
|
41
|
-
assert.equal(
|
|
107
|
+
assert.equal(pushOnly(), "confirm");
|
|
108
|
+
// gentle-shell#2172: a rebase rule restricts only commands that rebase.
|
|
109
|
+
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitRebase: "block" } });
|
|
110
|
+
assert.equal(pushOnly(), undefined, "a rebase rule does not restrict a push-only command");
|
|
111
|
+
assert.equal(yoloConfiguredRestriction(cwd, ["gitPush", "gitRebase"], { gentlePiConfigHome: home }), "block");
|
|
42
112
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
43
113
|
});
|
|
44
114
|
|
|
@@ -17,7 +17,7 @@ const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) =
|
|
|
17
17
|
const NODE = "24.21.0";
|
|
18
18
|
const PNPM = "11.1.1";
|
|
19
19
|
const NPM = "11.19.0";
|
|
20
|
-
const ENGRAM = "3.
|
|
20
|
+
const ENGRAM = "3.4.0";
|
|
21
21
|
|
|
22
22
|
// Every temporary home this file creates is removed when the file's tests end.
|
|
23
23
|
const roots: string[] = [];
|
|
@@ -373,8 +373,8 @@ posixTest("ensureEngram downloads nothing when the prefix is missing", async ()
|
|
|
373
373
|
});
|
|
374
374
|
|
|
375
375
|
test("engramExecutable takes only the top-level engram from the tarball and engram.exe from the zip, regular files only", () => {
|
|
376
|
-
const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.
|
|
377
|
-
const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.
|
|
376
|
+
const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_linux_amd64.tar.gz" };
|
|
377
|
+
const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_windows_amd64.zip" };
|
|
378
378
|
assert.deepEqual(engramExecutable(engramArchive, tarball), engramBytes);
|
|
379
379
|
const exe = Buffer.from("MZ engram");
|
|
380
380
|
assert.deepEqual(engramExecutable(zip([["tools/cloud-sync-projects.ps1", Buffer.from("x")], ["engram.exe", exe]]), zipped), exe);
|
|
@@ -16,7 +16,7 @@ const OTHER_ID = "fedcba9876543210fedcba9876543210";
|
|
|
16
16
|
const READY = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
|
|
17
17
|
instance_id: ID, runtime_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", capability: "startup-verified-store-v1" };
|
|
18
18
|
const REFUSED: shell.EngramProbe = { kind: "refused" };
|
|
19
|
-
const CORE = "3.
|
|
19
|
+
const CORE = "3.4.0";
|
|
20
20
|
const listening = (compatible: boolean, healthInstanceId?: string, healthVersion = CORE): shell.EngramProbe =>
|
|
21
21
|
({ kind: "listening", compatible, healthInstanceId, healthVersion: healthInstanceId === undefined ? undefined : healthVersion });
|
|
22
22
|
|
|
@@ -44,11 +44,36 @@ test("existing, unreadable or foreign stores without matching provenance are dis
|
|
|
44
44
|
for (const store of ["present", "unknown"] as const) {
|
|
45
45
|
const result = classify({ store });
|
|
46
46
|
assert.equal(result.policy, "disabled", store);
|
|
47
|
-
assert.match(result.notice!, /Back up
|
|
47
|
+
assert.match(result.notice!, /could not verify.*compatibility.*Back up/is);
|
|
48
|
+
assert.doesNotMatch(result.notice!, /update Engram and restart/i);
|
|
48
49
|
}
|
|
49
50
|
assert.equal(classify({ store: "present", endpoint: listening(false, ID) }).policy, "disabled");
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
test("a running server older than the first compatibility-capable core is named in the notice, without changing the decision", () => {
|
|
54
|
+
for (const version of ["3.3.2", "v3.3.2", "2.9.0"]) {
|
|
55
|
+
const result = classify({ store: "present", endpoint: listening(false, OTHER_ID, version) });
|
|
56
|
+
assert.equal(result.policy, "disabled", version);
|
|
57
|
+
assert.equal(result.url, undefined, version);
|
|
58
|
+
assert.match(result.notice!, new RegExp(`reports version ${version.replace(/^v/, "").replaceAll(".", "\\.")}`), version);
|
|
59
|
+
assert.match(result.notice!, /update that server to 3\.4\.0 or later.*restart it/s, version);
|
|
60
|
+
assert.match(result.notice!, /Back up/, version);
|
|
61
|
+
}
|
|
62
|
+
// Shell's own store behind an older server gets the same guidance and stays disabled.
|
|
63
|
+
const own = classify({ store: "present", provenance: { instanceId: ID }, endpoint: listening(false, ID, "3.3.2") });
|
|
64
|
+
assert.equal(own.policy, "disabled");
|
|
65
|
+
assert.match(own.notice!, /reports version 3\.3\.2/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a current, unparsable or hostile server version keeps the generic notice and is never echoed", () => {
|
|
69
|
+
for (const version of ["3.4.0", "3.10.0", "4.0.0", "dev", "", "3.3.2\u001b[31m", "3.3.2 update now", "03.3.2", "3.03.2", "3.3.02", "v03.3.2"]) {
|
|
70
|
+
const result = classify({ store: "present", endpoint: listening(false, OTHER_ID, version) });
|
|
71
|
+
assert.equal(result.policy, "disabled", JSON.stringify(version));
|
|
72
|
+
assert.equal(result.notice, shell.ENGRAM_RECOVERY_NOTICE, JSON.stringify(version));
|
|
73
|
+
}
|
|
74
|
+
assert.equal(classify({ store: "present", endpoint: listening(false) }).notice, shell.ENGRAM_RECOVERY_NOTICE);
|
|
75
|
+
});
|
|
76
|
+
|
|
52
77
|
test("explicit selections and invalid policies are disabled without depending on probes", () => {
|
|
53
78
|
for (const key of ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_DATABASE_URL"]) {
|
|
54
79
|
assert.equal(classify({ env: { [key]: "chosen" }, endpoint: listening(true) }).policy, "disabled", key);
|
|
@@ -74,7 +99,7 @@ test("Shell provenance allows its own store only for the same instance and the s
|
|
|
74
99
|
assert.deepEqual(classify({ store: "present", provenance }), { policy: "allow" });
|
|
75
100
|
assert.deepEqual(classify({ store: "present", provenance, endpoint: listening(false, ID) }), { policy: "allow" });
|
|
76
101
|
assert.equal(classify({ store: "present", provenance, endpoint: listening(false, OTHER_ID) }).policy, "disabled");
|
|
77
|
-
for (const version of ["3.3.3", "v3.
|
|
102
|
+
for (const version of ["3.3.3", "v3.4.0", "dev", ""]) {
|
|
78
103
|
assert.equal(classify({ store: "present", provenance, endpoint: listening(false, ID, version) }).policy, "disabled", version);
|
|
79
104
|
}
|
|
80
105
|
assert.equal(classify({ store: "present", provenance, endpoint: listening(false) }).policy, "disabled");
|
|
@@ -149,6 +174,7 @@ test("provenance matches only the same directory, instance and core digest", ()
|
|
|
149
174
|
test("the header is a newer-schema tripwire against the verified bundled schema, never a compatibility proof", () => {
|
|
150
175
|
// v3.3.2:internal/store/store.go declares schemaVersion = 1.
|
|
151
176
|
assert.equal(shell.bundledEngramSchema("3.3.2"), 1);
|
|
177
|
+
assert.equal(shell.bundledEngramSchema("3.4.0"), 1);
|
|
152
178
|
assert.equal(shell.bundledEngramSchema("3.3.3"), undefined);
|
|
153
179
|
for (const userVersion of [0, 1]) assert.equal(shell.engramHeaderAllows(userVersion, 1), true, String(userVersion));
|
|
154
180
|
assert.equal(shell.engramHeaderAllows(2, 1), false);
|
|
@@ -215,7 +241,7 @@ test("probe reports refused for a closed port and unknown for a silent listener"
|
|
|
215
241
|
assert.ok(Date.now() - started < 2000);
|
|
216
242
|
});
|
|
217
243
|
|
|
218
|
-
const base = { node: "/node", cli: "/pi", prefix: "/prefix", engram: "/prefix/runtime/engram-3.
|
|
244
|
+
const base = { node: "/node", cli: "/pi", prefix: "/prefix", engram: "/prefix/runtime/engram-3.4.0/engram", guard: GUARD };
|
|
219
245
|
|
|
220
246
|
test("disabled memory without explicit selections points ENGRAM_BIN at the guard and keeps the policy", () => {
|
|
221
247
|
const memory = classify({ store: "present" });
|
|
@@ -274,8 +300,10 @@ test("the guard fails non-zero, reports the notice and leaves Engram data byte-i
|
|
|
274
300
|
assert.match(result.stderr, /Back up first\. \$\(touch injected\)/);
|
|
275
301
|
}
|
|
276
302
|
const fallback = spawnSync(GUARD, ["instance-id"], { env: clean({ HOME: root }), encoding: "utf8" });
|
|
277
|
-
assert.
|
|
278
|
-
assert.
|
|
303
|
+
assert.equal(fallback.status, 78);
|
|
304
|
+
assert.equal(fallback.stdout, "");
|
|
305
|
+
assert.match(fallback.stderr, /could not verify.*compatibility/i);
|
|
306
|
+
assert.doesNotMatch(fallback.stderr, /update Engram, restart/i);
|
|
279
307
|
assert.equal(dataSnapshot(data), before);
|
|
280
308
|
assert.deepEqual(readdirSync(root).sort(), [".engram"]);
|
|
281
309
|
});
|
|
@@ -30,6 +30,7 @@ import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
|
|
|
30
30
|
import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts";
|
|
31
31
|
import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
|
|
32
32
|
import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
33
|
+
import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
|
|
33
34
|
import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
|
|
34
35
|
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
35
36
|
// The card style defaults to float; these assertions pin the outlined (neon)
|
|
@@ -92,6 +93,9 @@ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-")));
|
|
|
92
93
|
const activeSessionTeardowns = new Set<() => Promise<void>>();
|
|
93
94
|
const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown()));
|
|
94
95
|
afterEach(stopActiveSessions);
|
|
96
|
+
// Every fixture session is "s1": the inherited-profile freeze is process state
|
|
97
|
+
// keyed by session id, so each test starts from an unfrozen session.
|
|
98
|
+
afterEach(() => resetFrozenInheritedProfilesForTesting());
|
|
95
99
|
// subagent_run's default mode now reads the background-subagents policy
|
|
96
100
|
// in-process (gentle-pi#background-subagents-default-mode), which falls
|
|
97
101
|
// back to the real ~/.pi/gentle-ai/background-subagents.json when
|
|
@@ -353,7 +357,9 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
|
|
|
353
357
|
pi: { command: "pi", args: [] },
|
|
354
358
|
home,
|
|
355
359
|
resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
|
|
356
|
-
|
|
360
|
+
// The launch reads the global active profile from the config home; keep it
|
|
361
|
+
// on the scratch directory instead of the developer's own profiles.json.
|
|
362
|
+
env: { PATH: "/bin", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME },
|
|
357
363
|
sessionTransport: inertSessionTransport,
|
|
358
364
|
},
|
|
359
365
|
};
|
|
@@ -1223,7 +1229,7 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
|
|
|
1223
1229
|
return { stdout: JSON.stringify({ schema: "gentle-ai.telemetry-policy/v1", operation: "policy", enabled: true,
|
|
1224
1230
|
source: "state", reason: "enabled" }), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
1225
1231
|
} };
|
|
1226
|
-
const env: NodeJS.ProcessEnv = {};
|
|
1232
|
+
const env: NodeJS.ProcessEnv = { GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
|
|
1227
1233
|
const profile = join(root, `metrics-${boundary}`);
|
|
1228
1234
|
mkdirSync(join(profile, "agents"), { recursive: true });
|
|
1229
1235
|
writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
|
|
@@ -2227,6 +2233,8 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2227
2233
|
try {
|
|
2228
2234
|
const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
|
|
2229
2235
|
const h = fakePi();
|
|
2236
|
+
// The launch reads the global active profile; keep it on the scratch config home.
|
|
2237
|
+
env = { ...env, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
|
|
2230
2238
|
gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
|
|
2231
2239
|
const { ctx } = fakeContext();
|
|
2232
2240
|
(ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
|
|
@@ -2258,7 +2266,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2258
2266
|
assert.equal(captured[index]?.command, "/fixture/pi");
|
|
2259
2267
|
assert.deepEqual(captured[index]?.args, args);
|
|
2260
2268
|
assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
|
|
2261
|
-
assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
|
|
2269
|
+
assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
|
|
2262
2270
|
assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
|
|
2263
2271
|
assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
|
|
2264
2272
|
assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
|
|
@@ -2387,6 +2395,9 @@ test("foreign clone tool requires consent before queueing and never enters paren
|
|
|
2387
2395
|
await tick();
|
|
2388
2396
|
assert.deepEqual(spawned, [foreign]);
|
|
2389
2397
|
assert.equal((runnerRun.mock.calls[0]?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, undefined, "foreign child must not receive parent review permission channel");
|
|
2398
|
+
// The parent session froze "no profile" (no pin, no global active), so the
|
|
2399
|
+
// foreign launch keeps the foreign repository's own pin (gentle-shell#1064
|
|
2400
|
+
// 3b-ii). Dropping `foreignRepository` from the launch wiring breaks this.
|
|
2390
2401
|
assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/foreign-model:minimal");
|
|
2391
2402
|
assert.equal((result.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2392
2403
|
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
@@ -2437,6 +2448,34 @@ test("foreign clone tool requires consent before queueing and never enters paren
|
|
|
2437
2448
|
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2438
2449
|
});
|
|
2439
2450
|
|
|
2451
|
+
test("a frozen session profile routes a foreign repository launch over that repository's pin", async (t) => {
|
|
2452
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-frozen-")));
|
|
2453
|
+
t.after(() => rmSync(fixture, TEST_DIR_REMOVAL));
|
|
2454
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2455
|
+
mkdirSync(template);
|
|
2456
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2457
|
+
const configHome = join(fixture, "config");
|
|
2458
|
+
mkdirSync(configHome);
|
|
2459
|
+
writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "session", profiles: {
|
|
2460
|
+
session: { explore: { model: "openai/session-model", thinking: "low" } },
|
|
2461
|
+
pinned: { explore: { model: "openai/foreign-model", thinking: "minimal" } },
|
|
2462
|
+
} }));
|
|
2463
|
+
mkdirSync(join(foreign, ".git", "gentle-ai"));
|
|
2464
|
+
writeFileSync(join(foreign, ".git", "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "pinned" }));
|
|
2465
|
+
const h = fakePi(), runtime = deps();
|
|
2466
|
+
runtime.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: configHome };
|
|
2467
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2468
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2469
|
+
const { ctx } = fakeContext();
|
|
2470
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2471
|
+
await h.fire("session_start", ctx);
|
|
2472
|
+
t.after(async () => { await h.fire("session_shutdown", ctx); await tick(); });
|
|
2473
|
+
assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "session", "the parent froze the global active profile");
|
|
2474
|
+
await h.tools.get("subagent_run")!.execute("foreign-frozen", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2475
|
+
await tick();
|
|
2476
|
+
assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/session-model:low", "the session profile wins over the foreign pin");
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2440
2479
|
test("foreign child Changes require successful target-bound tool evidence, never model claims or sibling writes", async () => {
|
|
2441
2480
|
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-changes-")));
|
|
2442
2481
|
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), sibling = join(fixture, "sibling"), template = join(fixture, "template");
|
|
@@ -2826,7 +2865,8 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
|
|
|
2826
2865
|
Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => scenario === "model" || model === "bad" ? undefined : { provider: "offline", id: model } } });
|
|
2827
2866
|
ctx.sessionManager.getCwd = () => project;
|
|
2828
2867
|
ctx.sessionManager.getEntries = () => h.entries as never;
|
|
2829
|
-
|
|
2868
|
+
// The repository declaration exists before the session starts: the session
|
|
2869
|
+
// freezes it at session_start (gentle-shell#1064 slice 3b-ii).
|
|
2830
2870
|
if (scenario === "profile-model" || scenario === "profile-valid") {
|
|
2831
2871
|
mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
|
|
2832
2872
|
writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "invalid" }));
|
|
@@ -2834,6 +2874,7 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
|
|
|
2834
2874
|
mkdirSync(config, { recursive: true });
|
|
2835
2875
|
writeFileSync(join(config, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { invalid: { worker: { model: scenario === "profile-valid" ? "offline/pinned-good" : "offline/bad" } } } }));
|
|
2836
2876
|
}
|
|
2877
|
+
await h.fire("session_start", ctx);
|
|
2837
2878
|
let calls = 0;
|
|
2838
2879
|
const abort = new AbortController();
|
|
2839
2880
|
const unbind = scenario === "missing" ? () => {} : bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
|
|
@@ -3095,8 +3136,8 @@ function pinFixture(name: string) {
|
|
|
3095
3136
|
declarationPath,
|
|
3096
3137
|
writePin: (profile: string) => writePinText(localPinPath, profile),
|
|
3097
3138
|
writeDeclaration: (profile: string) => writePinText(declarationPath, profile),
|
|
3098
|
-
writeStore: (profiles: Record<string, unknown
|
|
3099
|
-
writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles }, null, 2)}\n`);
|
|
3139
|
+
writeStore: (profiles: Record<string, unknown>, active?: string) => {
|
|
3140
|
+
writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, ...(active === undefined ? {} : { active }), profiles }, null, 2)}\n`);
|
|
3100
3141
|
},
|
|
3101
3142
|
};
|
|
3102
3143
|
}
|
|
@@ -3222,6 +3263,121 @@ test("a queued launch keeps the session routing frozen across a rebind", async t
|
|
|
3222
3263
|
}
|
|
3223
3264
|
});
|
|
3224
3265
|
|
|
3266
|
+
// gentle-shell#1064 slice 3b-ii: a parent session without an explicit Enter
|
|
3267
|
+
// binding freezes p → P → G at session_start; later default changes only show
|
|
3268
|
+
// a drift notice once per change. GENTLE_PI_PROFILE_FOLLOW=1 keeps the live
|
|
3269
|
+
// resolution.
|
|
3270
|
+
async function launchSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
|
|
3271
|
+
const harness = deps();
|
|
3272
|
+
harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
|
|
3273
|
+
harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
|
|
3274
|
+
const { pi, tools, fire } = fakePi();
|
|
3275
|
+
gentleAgents(pi, {}, harness.deps);
|
|
3276
|
+
const { ctx, dialogs } = fakeContext();
|
|
3277
|
+
await fire("session_start", ctx);
|
|
3278
|
+
let count = 0;
|
|
3279
|
+
return {
|
|
3280
|
+
dialogs,
|
|
3281
|
+
driftNotices: () => dialogs.filter((dialog) => dialog.includes("the default profile changed")),
|
|
3282
|
+
launch: async () => {
|
|
3283
|
+
await tools.get("subagent_run")!.execute(`drift-${count}`, { agent: "explore", task: `Map ${count}`, mode: "background" }, undefined, undefined, ctx);
|
|
3284
|
+
await tick();
|
|
3285
|
+
const args = harness.spawned[count];
|
|
3286
|
+
// Settle the child so the next launch is not held by the concurrency cap.
|
|
3287
|
+
const child = harness.children[count++]!;
|
|
3288
|
+
child.emit({ type: "agent_end", messages: [] });
|
|
3289
|
+
child.emit({ type: "agent_settled" });
|
|
3290
|
+
child.exit(0);
|
|
3291
|
+
await tick();
|
|
3292
|
+
return args[args.indexOf("--model") + 1];
|
|
3293
|
+
},
|
|
3294
|
+
shutdown: async () => {
|
|
3295
|
+
await fire("session_shutdown", ctx);
|
|
3296
|
+
await tick();
|
|
3297
|
+
},
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
const DRIFT_PROFILES = {
|
|
3302
|
+
frontier: { explore: { model: "openai/alpha", thinking: "minimal" } },
|
|
3303
|
+
local: { explore: { model: "openai/beta" } },
|
|
3304
|
+
};
|
|
3305
|
+
|
|
3306
|
+
test("session_start freezes the inherited pin: a later pin change does not move the open session", async t => {
|
|
3307
|
+
const base = pinFixture("freeze-pin");
|
|
3308
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3309
|
+
base.writePin("frontier");
|
|
3310
|
+
const session = await launchSession(base);
|
|
3311
|
+
t.after(session.shutdown);
|
|
3312
|
+
assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "frontier", "frozen at session_start, before any launch");
|
|
3313
|
+
base.writePin("local");
|
|
3314
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3315
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3316
|
+
assert.deepEqual(session.driftNotices(), [
|
|
3317
|
+
'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
|
|
3318
|
+
], "one notice for one change, across two launches");
|
|
3319
|
+
base.writeStore(DRIFT_PROFILES, "local");
|
|
3320
|
+
base.writeDeclaration("local");
|
|
3321
|
+
rmSync(base.localPinPath);
|
|
3322
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3323
|
+
assert.equal(session.driftNotices().length, 1, "the same default reached through another layer is not a new change");
|
|
3324
|
+
});
|
|
3325
|
+
|
|
3326
|
+
test("an unpinned session routes through the global active profile and freezes it", async t => {
|
|
3327
|
+
const base = pinFixture("freeze-global");
|
|
3328
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3329
|
+
const session = await launchSession(base);
|
|
3330
|
+
t.after(session.shutdown);
|
|
3331
|
+
base.writeStore(DRIFT_PROFILES, "local");
|
|
3332
|
+
assert.equal(await session.launch(), "openai/alpha:minimal", "the global active profile replaces the materialized routing");
|
|
3333
|
+
assert.equal(session.driftNotices().length, 1);
|
|
3334
|
+
assert.match(session.driftNotices()[0]!, /changed to "local" \(global\), this session keeps "frontier" \(global\)/);
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3337
|
+
test("a session with no profile anywhere keeps today's routing even after a pin appears", async t => {
|
|
3338
|
+
const base = pinFixture("freeze-none");
|
|
3339
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3340
|
+
const session = await launchSession(base);
|
|
3341
|
+
t.after(session.shutdown);
|
|
3342
|
+
assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low", "today's materialized routing");
|
|
3343
|
+
base.writePin("frontier");
|
|
3344
|
+
assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low");
|
|
3345
|
+
assert.deepEqual(session.driftNotices(), [
|
|
3346
|
+
'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.',
|
|
3347
|
+
]);
|
|
3348
|
+
});
|
|
3349
|
+
|
|
3350
|
+
test("an explicit Enter binding wins over the frozen profile and never shows the drift notice", async t => {
|
|
3351
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3352
|
+
const base = pinFixture("freeze-explicit");
|
|
3353
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3354
|
+
base.writePin("frontier");
|
|
3355
|
+
const session = await launchSession(base);
|
|
3356
|
+
t.after(session.shutdown);
|
|
3357
|
+
bindSessionProfile("s1", "local", { explore: { model: "openai/beta" } });
|
|
3358
|
+
base.writePin("local");
|
|
3359
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3360
|
+
rmSync(base.localPinPath);
|
|
3361
|
+
assert.equal(await session.launch(), "openai/beta:high");
|
|
3362
|
+
assert.deepEqual(session.driftNotices(), []);
|
|
3363
|
+
});
|
|
3364
|
+
|
|
3365
|
+
test("follow mode re-resolves the defaults on every launch without a notice; Enter makes it explicit", async t => {
|
|
3366
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3367
|
+
const base = pinFixture("follow");
|
|
3368
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3369
|
+
base.writePin("frontier");
|
|
3370
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3371
|
+
t.after(session.shutdown);
|
|
3372
|
+
assert.equal(readFrozenInheritedProfile("s1"), undefined, "follow never freezes");
|
|
3373
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3374
|
+
base.writePin("local");
|
|
3375
|
+
assert.equal(await session.launch(), "openai/beta:high");
|
|
3376
|
+
bindSessionProfile("s1", "frontier", { explore: { model: "openai/alpha", thinking: "minimal" } });
|
|
3377
|
+
assert.equal(await session.launch(), "openai/alpha:minimal", "the explicit binding stops following");
|
|
3378
|
+
assert.deepEqual(session.driftNotices(), []);
|
|
3379
|
+
});
|
|
3380
|
+
|
|
3225
3381
|
test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
|
|
3226
3382
|
assert.equal(agentsEnabled({}), true);
|
|
3227
3383
|
assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
|
|
@@ -80,10 +80,10 @@ function standaloneLauncher(t: test.TestContext) {
|
|
|
80
80
|
|
|
81
81
|
test("a genuinely absent adjacent peer falls back to PATH", (t) => {
|
|
82
82
|
const f = standaloneLauncher(t);
|
|
83
|
-
writePathPiScript(f.root, "
|
|
83
|
+
writePathPiScript(f.root, "1.2.1");
|
|
84
84
|
const result = spawnSync(process.execPath, [f.launcher, "--version"], { env: f.env, encoding: "utf8" });
|
|
85
85
|
assert.equal(result.status, 0, result.stderr);
|
|
86
|
-
assert.match(result.stdout, /pi
|
|
86
|
+
assert.match(result.stdout, /pi 1\.2\.1/);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
test("malformed adjacent metadata never silently falls back to PATH; env override still wins", (t) => {
|
|
@@ -91,8 +91,8 @@ test("malformed adjacent metadata never silently falls back to PATH; env overrid
|
|
|
91
91
|
const peer = join(f.root, "node_modules", "@earendil-works", "pi-coding-agent");
|
|
92
92
|
mkdirSync(join(peer, "dist", "bundle"), { recursive: true });
|
|
93
93
|
writeFileSync(join(peer, "dist", "index.js"), "");
|
|
94
|
-
writePiScript(join(peer, "dist", "bundle", "cli.js"), "
|
|
95
|
-
const fallback = writePathPiScript(f.root, "
|
|
94
|
+
writePiScript(join(peer, "dist", "bundle", "cli.js"), "1.2.0");
|
|
95
|
+
const fallback = writePathPiScript(f.root, "1.2.1");
|
|
96
96
|
const base = { name: "@earendil-works/pi-coding-agent", type: "module", exports: { ".": { import: "./dist/index.js" } }, bin: { pi: "dist/bundle/cli.js" } };
|
|
97
97
|
for (const metadata of [{ ...base, name: "impostor" }, { ...base, bin: { pi: "../outside.js" } }, { ...base, bin: {} }, "malformed"]) {
|
|
98
98
|
writeFileSync(join(peer, "package.json"), metadata === "malformed" ? "{" : JSON.stringify(metadata));
|
|
@@ -103,7 +103,7 @@ test("malformed adjacent metadata never silently falls back to PATH; env overrid
|
|
|
103
103
|
writeFileSync(join(peer, "package.json"), JSON.stringify({ ...base, name: "impostor" }));
|
|
104
104
|
const override = spawnSync(process.execPath, [f.launcher, "--version"], { env: { ...f.env, GENTLE_SHELL_PI: fallback }, encoding: "utf8" });
|
|
105
105
|
assert.equal(override.status, 0, override.stderr);
|
|
106
|
-
assert.match(override.stdout, /pi
|
|
106
|
+
assert.match(override.stdout, /pi 1\.2\.1/);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
// The launcher's own gentle-pi version, exactly as `ownPackageVersion()` in
|
|
@@ -127,7 +127,7 @@ function fixture(t: test.TestContext) {
|
|
|
127
127
|
const home = join(root, "home");
|
|
128
128
|
mkdirSync(home, { recursive: true });
|
|
129
129
|
const piScript = join(root, "fake-pi.mjs");
|
|
130
|
-
writePiScript(piScript, "
|
|
130
|
+
writePiScript(piScript, "1.2.0");
|
|
131
131
|
const gentleShellHome = join(root, "gentle-shell-home");
|
|
132
132
|
const env: NodeJS.ProcessEnv = {
|
|
133
133
|
...process.env,
|
|
@@ -496,7 +496,7 @@ function writeGentleAiScriptDeclaringGentlePi(path: string, extraSources: string
|
|
|
496
496
|
// the invocation), this models what a real `pi remove` does to settings.json
|
|
497
497
|
// closely enough to prove a later plain launch sees the declaration gone and
|
|
498
498
|
// resumes the launcher's own injection.
|
|
499
|
-
function writePiScriptEditingSettingsOnRemove(path: string, version = "
|
|
499
|
+
function writePiScriptEditingSettingsOnRemove(path: string, version = "1.2.0") {
|
|
500
500
|
writeFileSync(
|
|
501
501
|
path,
|
|
502
502
|
[
|
|
@@ -821,7 +821,7 @@ test("a too-old pi exits 1 naming both versions", (t) => {
|
|
|
821
821
|
const result = run(f.env, []);
|
|
822
822
|
assert.equal(result.status, 1);
|
|
823
823
|
assert.match(result.stderr, /0\.80\.0/);
|
|
824
|
-
assert.match(result.stderr, /
|
|
824
|
+
assert.match(result.stderr, /1\.2\.0/);
|
|
825
825
|
});
|
|
826
826
|
|
|
827
827
|
test("--version prints three lines", (t) => {
|
|
@@ -831,7 +831,7 @@ test("--version prints three lines", (t) => {
|
|
|
831
831
|
const lines = result.stdout.trim().split("\n");
|
|
832
832
|
assert.equal(lines.length, 3);
|
|
833
833
|
assert.match(lines[0], /^gentle-shell /);
|
|
834
|
-
assert.match(lines[1], /^pi
|
|
834
|
+
assert.match(lines[1], /^pi 1\.2\.0$/);
|
|
835
835
|
assert.match(lines[2], /^home isolated /);
|
|
836
836
|
});
|
|
837
837
|
|
|
@@ -1278,7 +1278,7 @@ test("gentle-shell setup propagates a non-zero pi remove exit code with an actio
|
|
|
1278
1278
|
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1279
1279
|
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
1280
1280
|
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
1281
|
-
writePiScript(failingPiScript, "
|
|
1281
|
+
writePiScript(failingPiScript, "1.2.0", 7);
|
|
1282
1282
|
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
1283
1283
|
|
|
1284
1284
|
const result = run(env, ["setup"]);
|
|
@@ -1299,7 +1299,7 @@ test("gentle-shell setup --home <dir> includes --home <dir> in the failing-remov
|
|
|
1299
1299
|
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1300
1300
|
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
1301
1301
|
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
1302
|
-
writePiScript(failingPiScript, "
|
|
1302
|
+
writePiScript(failingPiScript, "1.2.0", 7);
|
|
1303
1303
|
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
1304
1304
|
|
|
1305
1305
|
const result = run(env, ["--home", target, "setup"]);
|
|
@@ -1323,7 +1323,7 @@ test("gentle-shell setup shell-quotes a --home path containing a space in the fa
|
|
|
1323
1323
|
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1324
1324
|
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
1325
1325
|
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
1326
|
-
writePiScript(failingPiScript, "
|
|
1326
|
+
writePiScript(failingPiScript, "1.2.0", 7);
|
|
1327
1327
|
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
1328
1328
|
|
|
1329
1329
|
const result = run(env, ["--home", target, "setup"]);
|
|
@@ -1865,7 +1865,7 @@ test("setup gives Gentle AI a `pi` on its PATH that runs the same Pi gentle-shel
|
|
|
1865
1865
|
|
|
1866
1866
|
const result = run(env, ["setup"]);
|
|
1867
1867
|
assert.equal(result.status, 0, result.stderr);
|
|
1868
|
-
assert.deepEqual(JSON.parse(readFileSync(probe, "utf8")), { status: 0, error: null, stdout: "
|
|
1868
|
+
assert.deepEqual(JSON.parse(readFileSync(probe, "utf8")), { status: 0, error: null, stdout: "1.2.0\n" });
|
|
1869
1869
|
// The launcher is temporary: nothing is left behind in the temp folder it used.
|
|
1870
1870
|
assert.deepEqual(readdirSync(temp), []);
|
|
1871
1871
|
});
|
|
@@ -2171,7 +2171,7 @@ test("a hung 'pi remove' during post-install cleanup is killed after the timeout
|
|
|
2171
2171
|
[
|
|
2172
2172
|
"#!/usr/bin/env node",
|
|
2173
2173
|
"const args = process.argv.slice(2);",
|
|
2174
|
-
'if (args.includes("--version")) { console.log("
|
|
2174
|
+
'if (args.includes("--version")) { console.log("1.2.0"); process.exit(0); }',
|
|
2175
2175
|
"if (args[0] === 'remove') { await new Promise((resolve) => setTimeout(resolve, 60000)); process.exit(0); }",
|
|
2176
2176
|
"console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
|
|
2177
2177
|
"process.exit(0);",
|
|
@@ -3186,7 +3186,7 @@ function writeHandoffPiScript(path: string, exitCode = 0) {
|
|
|
3186
3186
|
"#!/usr/bin/env node",
|
|
3187
3187
|
"const { writeFileSync } = require('node:fs');",
|
|
3188
3188
|
"const args = process.argv.slice(2);",
|
|
3189
|
-
"if (args.includes('--version')) { console.log('
|
|
3189
|
+
"if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
|
|
3190
3190
|
"const handoff = process.env.GENTLE_SHELL_RESUME_HANDOFF;",
|
|
3191
3191
|
"if (handoff) writeFileSync(handoff, JSON.stringify({ sessionId: 'abc' }));",
|
|
3192
3192
|
"if (process.env.PI_STUB_PRINT_ENV) console.log(JSON.stringify({ args, handoff }));",
|
|
@@ -3287,7 +3287,7 @@ function writeWaitingPiScript(path: string) {
|
|
|
3287
3287
|
[
|
|
3288
3288
|
"#!/usr/bin/env node",
|
|
3289
3289
|
"const { writeFileSync } = require('node:fs');",
|
|
3290
|
-
"if (process.argv.includes('--version')) { console.log('
|
|
3290
|
+
"if (process.argv.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
|
|
3291
3291
|
"writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc' }));",
|
|
3292
3292
|
"const quit = () => { process.stdout.write('To resume this session: pi --session abc\\n'); process.exit(0); };",
|
|
3293
3293
|
"process.on('SIGHUP', quit);",
|
|
@@ -3329,7 +3329,7 @@ test("on a TTY a cross-project session resumes by its session file", { skip: !ha
|
|
|
3329
3329
|
[
|
|
3330
3330
|
"#!/usr/bin/env node",
|
|
3331
3331
|
"const { writeFileSync } = require('node:fs');",
|
|
3332
|
-
"if (process.argv.includes('--version')) { console.log('
|
|
3332
|
+
"if (process.argv.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
|
|
3333
3333
|
`writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc', sessionFile: ${JSON.stringify(sessionFile)} }));`,
|
|
3334
3334
|
"process.stdout.write('To resume this session: pi --session abc\\n');",
|
|
3335
3335
|
"",
|