gentle-pi 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -16,12 +16,16 @@ import {
|
|
|
16
16
|
bundledUpdatePlan,
|
|
17
17
|
parseBundledUpgradeArgs,
|
|
18
18
|
withBundledNpmCommand,
|
|
19
|
+
withoutBundledNpmCommand,
|
|
20
|
+
withoutMissingBundledNpmCommand,
|
|
19
21
|
resolveBundledInstall,
|
|
20
22
|
setBundledInstallForTesting,
|
|
21
23
|
type BundledFs,
|
|
24
|
+
isBundledNpmCommand,
|
|
22
25
|
type BundledPi,
|
|
23
26
|
} from "../lib/gentle-shell-bundled.ts";
|
|
24
27
|
import { artifactFor, engramPinVersion } from "../scripts/installer-downloads.mjs";
|
|
28
|
+
import { bundledNpmCommand, prefixLayout } from "../scripts/bundled-install.mjs";
|
|
25
29
|
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
26
30
|
import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
|
|
27
31
|
|
|
@@ -128,7 +132,7 @@ test("bundledPi fails with the version, the pinned Pi and the way to repair it",
|
|
|
128
132
|
assert.equal(bundledPi(install, { platform: "linux", fs: outside }).ok, false);
|
|
129
133
|
});
|
|
130
134
|
|
|
131
|
-
const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.
|
|
135
|
+
const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.5.2/engram" };
|
|
132
136
|
|
|
133
137
|
test("bundledChildEnv keeps the user's environment and adds only the bundled Pi, the prefix and Engram, no Pi override and no Pi update check", () => {
|
|
134
138
|
const user = { PATH: "/home/ana/.nvm/bin:/usr/bin", NODE_OPTIONS: "--max-old-space-size=4096", npm_config_registry: "https://npm.example", npm_config_prefix: "/home/ana/.npm-global",
|
|
@@ -140,7 +144,7 @@ test("bundledChildEnv keeps the user's environment and adds only the bundled Pi,
|
|
|
140
144
|
// folder from the prefix, and Gentle AI 4.0.0 reads that variable as a parent.
|
|
141
145
|
assert.deepEqual(env, { PATH: user.PATH, NODE_OPTIONS: user.NODE_OPTIONS, npm_config_registry: user.npm_config_registry, npm_config_prefix: user.npm_config_prefix,
|
|
142
146
|
GENTLE_PI_CONFIG_HOME: user.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_PI: "/g/node /g/cli.js", PI_SKIP_VERSION_CHECK: "1", GENTLE_SHELL_PREFIX: "/g",
|
|
143
|
-
ENGRAM_BIN: "/g/runtime/engram-3.
|
|
147
|
+
ENGRAM_BIN: "/g/runtime/engram-3.5.2/engram" });
|
|
144
148
|
// The subagent runner splits that variable on whitespace: a spaced path falls
|
|
145
149
|
// back to its own default, the running node and cli.js.
|
|
146
150
|
const spaced = bundledChildEnv({ GENTLE_PI_AGENTS_PI: "pi" }, { node: "C:\\Users\\Ana Li\\node.exe", cli: "C:\\cli.js", ...ISOLATION });
|
|
@@ -174,6 +178,83 @@ test("withBundledNpmCommand sets only npmCommand and leaves an already bundled s
|
|
|
174
178
|
for (const malformed of ["{", "[]", "null"]) assert.deepEqual(withBundledNpmCommand(malformed, command), { status: "malformed" });
|
|
175
179
|
});
|
|
176
180
|
|
|
181
|
+
// #2469: the npmCommand a bundled self-uninstall left behind names a node.exe
|
|
182
|
+
// the uninstall removed, so Pi package installs fail with ENOENT until it is
|
|
183
|
+
// gone; a command naming nothing inside the removed prefix stays.
|
|
184
|
+
test("withoutBundledNpmCommand removes the key when an entry names the removed prefix and keeps the rest", () => {
|
|
185
|
+
const text = '{\n "theme": "gentle",\n "npmCommand": ["/p/runtime/node.exe", "--update-notifier=false"]\n}\n';
|
|
186
|
+
const removed = withoutBundledNpmCommand(text, (path) => path === "/p/runtime/node.exe");
|
|
187
|
+
assert.ok(removed.status === "removed", removed.status);
|
|
188
|
+
assert.deepEqual(JSON.parse(removed.text), { theme: "gentle" });
|
|
189
|
+
assert.match(removed.text, /^\{\n {4}"theme"/);
|
|
190
|
+
assert.ok(removed.text.endsWith("}\n"));
|
|
191
|
+
assert.deepEqual(withoutBundledNpmCommand(undefined, () => true), { status: "unchanged" });
|
|
192
|
+
assert.deepEqual(withoutBundledNpmCommand('{"npmCommand":"npm"}', () => true), { status: "unchanged" });
|
|
193
|
+
assert.deepEqual(withoutBundledNpmCommand('{"npmCommand":["node"]}', () => true), { status: "unchanged" });
|
|
194
|
+
assert.deepEqual(withoutBundledNpmCommand('{"npmCommand":["/other/node"]}', () => false), { status: "unchanged" });
|
|
195
|
+
assert.deepEqual(withoutBundledNpmCommand('{"theme":"gentle"}', () => true), { status: "unchanged" });
|
|
196
|
+
for (const malformed of ["{", "[]", "null"]) assert.deepEqual(withoutBundledNpmCommand(malformed, () => true), { status: "malformed" });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("withoutMissingBundledNpmCommand removes only a bundled npmCommand whose executable is an absolute path that is gone", () => {
|
|
200
|
+
const text = '{\n "theme": "gentle",\n "npmCommand": ["/p/runtime/node-24.21.0/bin/node", "--userconfig=/p/pnpm/config/npmrc", "--globalconfig=/p/npm/npmrc", "--cache=/p/npm/cache", "--update-notifier=false"]\n}\n';
|
|
201
|
+
const removed = withoutMissingBundledNpmCommand(text, (path) => path !== "/p/runtime/node-24.21.0/bin/node");
|
|
202
|
+
assert.ok(removed.status === "removed", removed.status);
|
|
203
|
+
assert.deepEqual(JSON.parse(removed.text), { theme: "gentle" });
|
|
204
|
+
// An executable that exists, a PATH command, a non-array value, no key, no file.
|
|
205
|
+
assert.deepEqual(withoutMissingBundledNpmCommand(text, () => true), { status: "unchanged" });
|
|
206
|
+
assert.deepEqual(withoutMissingBundledNpmCommand('{"npmCommand":["npm"]}', () => false), { status: "unchanged" });
|
|
207
|
+
assert.deepEqual(withoutMissingBundledNpmCommand('{"npmCommand":"npm"}', () => false), { status: "unchanged" });
|
|
208
|
+
assert.deepEqual(withoutMissingBundledNpmCommand('{"theme":"gentle"}', () => false), { status: "unchanged" });
|
|
209
|
+
assert.deepEqual(withoutMissingBundledNpmCommand(undefined, () => false), { status: "unchanged" });
|
|
210
|
+
for (const malformed of ["{", "[]", "null"]) assert.deepEqual(withoutMissingBundledNpmCommand(malformed, () => false), { status: "malformed" });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// A missing program alone is not proof the command is ours: a user's
|
|
214
|
+
// npmCommand on an offline drive must survive the launcher repair, so only the
|
|
215
|
+
// shape a bundled install writes is ever dropped (PR #2472 review).
|
|
216
|
+
test("withoutMissingBundledNpmCommand never drops a user's npmCommand, even with a missing program", () => {
|
|
217
|
+
// The posix form the launcher sees on this platform is dropped. The Windows
|
|
218
|
+
// form is covered by isBundledNpmCommand: the launcher's isAbsolute is
|
|
219
|
+
// platform-specific, so a C:\ path is only absolute on Windows.
|
|
220
|
+
const posix = "/home/ana/.gentle-shell";
|
|
221
|
+
const flags = (root: string, sep: string) => [`--userconfig=${root}${sep}pnpm${sep}config${sep}npmrc`, `--globalconfig=${root}${sep}npm${sep}npmrc`, `--cache=${root}${sep}npm${sep}cache`];
|
|
222
|
+
const bundledForm = [`${posix}/runtime/node-24.21.0/bin/node`, `${posix}/runtime/node-24.21.0/lib/node_modules/npm/bin/npm-cli.js`, ...flags(posix, "/"), `--prefix=${posix}/npm/prefix`, "--update-notifier=false"];
|
|
223
|
+
{
|
|
224
|
+
const command = bundledForm;
|
|
225
|
+
const text = JSON.stringify({ theme: "gentle", npmCommand: command });
|
|
226
|
+
const removed = withoutMissingBundledNpmCommand(text, () => false);
|
|
227
|
+
assert.ok(removed.status === "removed", `${command[0]}: ${removed.status}`);
|
|
228
|
+
}
|
|
229
|
+
// What a user writes is never ours, even with a missing program.
|
|
230
|
+
const userCommands: unknown[][] = [
|
|
231
|
+
["npm"],
|
|
232
|
+
["C:\\tools\\node.exe", "C:\\tools\\npm-cli.js"],
|
|
233
|
+
["C:\\Users\\ana\\AppData\\Local\\gentle-shell\\runtime\\node-24.21.0\\node.exe", "npm-cli.js"],
|
|
234
|
+
["/opt/runtime/node-20.0.0/bin/node", "/opt/npm-cli.js", "--update-notifier=false"],
|
|
235
|
+
];
|
|
236
|
+
for (const command of userCommands) {
|
|
237
|
+
const text = JSON.stringify({ theme: "gentle", npmCommand: command });
|
|
238
|
+
assert.deepEqual(withoutMissingBundledNpmCommand(text, () => false), { status: "unchanged" }, JSON.stringify(command));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("isBundledNpmCommand recognizes only the npmCommand a bundled install writes, in every released form", () => {
|
|
243
|
+
const win = "C:\\Users\\ana\\AppData\\Local\\gentle-shell";
|
|
244
|
+
const posix = "/home/ana/.gentle-shell";
|
|
245
|
+
const flags = (root: string, sep: string) => [`--userconfig=${root}${sep}pnpm${sep}config${sep}npmrc`, `--globalconfig=${root}${sep}npm${sep}npmrc`, `--cache=${root}${sep}npm${sep}cache`];
|
|
246
|
+
// What the installer writes today on each platform, and its two released forms.
|
|
247
|
+
assert.equal(isBundledNpmCommand(bundledNpmCommand(prefixLayout({ platform: "win32", home: "C:\\Users\\ana", root: win }))), true);
|
|
248
|
+
assert.equal(isBundledNpmCommand(bundledNpmCommand(prefixLayout({ platform: "darwin", home: "/home/ana" }))), true);
|
|
249
|
+
assert.equal(isBundledNpmCommand([`${win}\\runtime\\node-24.21.0\\node.exe`, `${win}\\runtime\\node-24.21.0\\gentle-shell\\npm`, ...flags(win, "\\"), "--update-notifier=false"]), true);
|
|
250
|
+
assert.equal(isBundledNpmCommand([`${posix}/runtime/node-24.21.0/bin/node`, `${posix}/runtime/node-24.21.0/lib/node_modules/npm/bin/npm-cli.js`, ...flags(posix, "/"), `--prefix=${posix}/npm/prefix`, "--update-notifier=false"]), true);
|
|
251
|
+
// What a user writes is never ours, even with a missing program.
|
|
252
|
+
for (const command of [["npm"], ["C:\\tools\\node.exe", "C:\\tools\\npm-cli.js"], [`${win}\\runtime\\node-24.21.0\\node.exe`, "npm-cli.js"],
|
|
253
|
+
["/opt/runtime/node-20.0.0/bin/node", "/opt/npm-cli.js", "--update-notifier=false"]]) {
|
|
254
|
+
assert.equal(isBundledNpmCommand(command), false, JSON.stringify(command));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
177
258
|
test("bundledUpdatePlan redirects Pi's own update and keeps the extension updates", () => {
|
|
178
259
|
assert.deepEqual(bundledUpdatePlan(["update"]), { self: true });
|
|
179
260
|
assert.deepEqual(bundledUpdatePlan(["update", "--self"]), { self: true });
|
|
@@ -193,6 +274,18 @@ test("bundledUpdatePlan redirects Pi's own update and keeps the extension update
|
|
|
193
274
|
}
|
|
194
275
|
});
|
|
195
276
|
|
|
277
|
+
test("bundledUpdatePlan marks Pi's own update with --latest and keeps every other --latest form passing through", () => {
|
|
278
|
+
for (const args of [["update", "--latest"], ["update", "pi", "--latest"], ["update", "--latest", "self"], ["update", "--self", "--latest"]]) {
|
|
279
|
+
assert.deepEqual(bundledUpdatePlan(args), { self: true, latest: true }, args.join(" "));
|
|
280
|
+
}
|
|
281
|
+
assert.deepEqual(bundledUpdatePlan(["update", "--all", "--latest"]), { self: true, latest: true, piArgs: ["update", "--extensions"] });
|
|
282
|
+
assert.deepEqual(bundledUpdatePlan(["update", "--latest", "--self", "--extensions", "-a"]), { self: true, latest: true, piArgs: ["update", "--extensions", "-a"] });
|
|
283
|
+
for (const passthrough of [["update", "--extensions", "--latest"], ["update", "npm:x", "--latest"], ["update", "--models", "--latest"], ["update", "--all", "--self", "--latest"],
|
|
284
|
+
["update", "pi", "npm:x", "--latest"], ["update", "--latest", "--bogus"], ["update", "--help", "--latest"]]) {
|
|
285
|
+
assert.deepEqual(bundledUpdatePlan(passthrough), { self: false, piArgs: passthrough }, passthrough.join(" "));
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
196
289
|
test("bundledUpdateNext still updates extensions when the upgrade did not complete", () => {
|
|
197
290
|
assert.deepEqual(bundledUpdateNext({ self: false, piArgs: ["update", "npm:x"] }, undefined), { piArgs: ["update", "npm:x"] });
|
|
198
291
|
assert.deepEqual(bundledUpdateNext({ self: true }, 0), { exitCode: 0 });
|
|
@@ -202,6 +295,9 @@ test("bundledUpdateNext still updates extensions when the upgrade did not comple
|
|
|
202
295
|
const failed = bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 1);
|
|
203
296
|
assert.deepEqual("piArgs" in failed ? failed.piArgs : [], ["update", "--extensions"]);
|
|
204
297
|
assert.match("note" in failed ? String(failed.note) : "", /upgrade did not complete.*extensions/);
|
|
298
|
+
const latest = bundledUpdateNext({ self: true, latest: true, piArgs: ["update", "--extensions"] }, 1);
|
|
299
|
+
assert.match("note" in latest ? String(latest.note) : "", /latest Pi update did not complete.*extensions/);
|
|
300
|
+
assert.deepEqual(bundledUpdateNext({ self: true, latest: true }, 1), { exitCode: 1 });
|
|
205
301
|
});
|
|
206
302
|
|
|
207
303
|
test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => {
|
|
@@ -209,6 +305,11 @@ test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell
|
|
|
209
305
|
assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
|
|
210
306
|
assert.match(interactive, /Running 'gentle-shell upgrade'/);
|
|
211
307
|
assert.match(bundledUpdateNotice({ piVersion: "1.2.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/);
|
|
308
|
+
for (const interactive of [true, false]) {
|
|
309
|
+
const lines = bundledUpdateNotice({ piVersion: "1.2.0", interactive });
|
|
310
|
+
assert.equal(lines[0], "Pi ships pinned with Gentle Shell (Pi 1.2.0): a newer Pi arrives with a Gentle Shell release.");
|
|
311
|
+
assert.equal(lines.at(-1), "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.");
|
|
312
|
+
}
|
|
212
313
|
});
|
|
213
314
|
|
|
214
315
|
test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => {
|
|
@@ -235,48 +336,56 @@ const posixHost = process.platform === "win32" ? "the bundled prefix fixture use
|
|
|
235
336
|
const SHELL = JSON.parse(readFileSync(join(repository, "package.json"), "utf8")).version;
|
|
236
337
|
const FIXTURE_ID = `${SHELL}-1.2.0`;
|
|
237
338
|
const FAKE_PI = [
|
|
238
|
-
"import {
|
|
339
|
+
"import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
|
|
239
340
|
"const args = process.argv.slice(2);",
|
|
240
341
|
"if (args[0] === 'install' && args[1] === 'npm:gentle-engram@0.4.2' && process.env.FIXTURE_INSTALL_CAPABILITY !== '0') {",
|
|
241
342
|
" const home = process.env.PI_CODING_AGENT_DIR; const metadata = join(home, 'npm/node_modules/gentle-engram/package.json'); mkdirSync(dirname(metadata), {recursive:true});",
|
|
242
|
-
" writeFileSync(metadata, JSON.stringify({name:'gentle-engram',version:'0.4.2',engram:{startupPolicy:1
|
|
343
|
+
" writeFileSync(metadata, JSON.stringify({name:'gentle-engram',version:'0.4.2',engram:{startupPolicy:1}}));",
|
|
243
344
|
" const path = join(home,'settings.json'); let settings={}; try {settings=JSON.parse(readFileSync(path,'utf8'));} catch {}",
|
|
244
345
|
" if (process.env.FIXTURE_INSTALL_CAPABILITY !== 'metadata-only') { const packages = settings.packages ?? []; const index = packages.findIndex(p => /^npm:gentle-engram(?:@|$)/.test(typeof p === 'string' ? p : p.source));",
|
|
245
346
|
" if (index < 0) packages.push(args[1]); else packages[index] = typeof packages[index] === 'string' ? args[1] : {...packages[index], source:args[1]}; settings.packages = packages; writeFileSync(path, JSON.stringify(settings)); }",
|
|
246
347
|
"}",
|
|
247
348
|
"if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
|
|
248
|
-
// Stands in for gentle-engram creating the store through the bundled core during the session.
|
|
249
|
-
"if (process.env.FIXTURE_CREATE_STORE && args[0] !== 'install' && args[0] !== 'list') {",
|
|
250
|
-
" const data = join(process.env.HOME, '.engram');",
|
|
251
|
-
" if (process.env.ENGRAM_BIN?.includes('engram-guard') !== true) { mkdirSync(data, {recursive:true});",
|
|
252
|
-
" if (!existsSync(join(data, '.instance-id'))) writeFileSync(join(data, '.instance-id'), process.env.FIXTURE_CREATE_STORE + '\\n');",
|
|
253
|
-
" const header = Buffer.alloc(4096); header.write('SQLite format 3\\0', 0, 'latin1'); header.writeUInt16BE(4096, 16); header[18] = 2; header[19] = 2;",
|
|
254
|
-
" writeFileSync(join(data, 'engram.db'), header); writeFileSync(join(data, 'engram.db-wal'), '');",
|
|
255
|
-
" }",
|
|
256
|
-
"}",
|
|
257
349
|
"const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'npm_config_registry', 'NODE_OPTIONS', 'PI_CODING_AGENT_DIR',",
|
|
258
350
|
"\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_SERVER_MODE', 'ENGRAM_SOCKET', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK'];",
|
|
259
|
-
"
|
|
260
|
-
"const provenance = (() => { try { return JSON.parse(readFileSync(record, 'utf8')); } catch { return null; } })();",
|
|
261
|
-
"console.log(JSON.stringify({ cli: process.argv[1], args, provenance, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));",
|
|
262
|
-
// Stands in for a crash, kill or power loss of the launcher before its exit handler runs.
|
|
263
|
-
"if (process.env.FIXTURE_KILL_LAUNCHER && args[0] !== 'install') process.kill(process.ppid, 'SIGKILL');",
|
|
351
|
+
"console.log(JSON.stringify({ cli: process.argv[1], args, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));",
|
|
264
352
|
].join("\n");
|
|
353
|
+
const READY_COMPATIBILITY = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
|
|
354
|
+
instance_id: "0123456789abcdef0123456789abcdef", runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
|
|
355
|
+
// The bundled core: `serve` logs its invocation (FIXTURE_SERVE_LOG) and, like
|
|
356
|
+
// Engram 3.5.2, answers the ready /compatibility contract on a port it publishes
|
|
357
|
+
// in FIXTURE_ENGRAM_PORT_FILE, where the redirected default endpoint finds it.
|
|
358
|
+
// FIXTURE_SERVE=exit fails like a lost bind; race fails after starting the
|
|
359
|
+
// server a concurrent launch would. Every other command only prints a line.
|
|
265
360
|
const FIXTURE_CORE = [
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
'
|
|
361
|
+
`#!${process.execPath}`,
|
|
362
|
+
"const { appendFileSync, renameSync, writeFileSync } = require('node:fs'); const { spawn } = require('node:child_process');",
|
|
363
|
+
"const args = process.argv.slice(2);",
|
|
364
|
+
"if (args[0] !== 'serve') { console.log('engram'); process.exit(0); }",
|
|
365
|
+
"const mode = process.env.FIXTURE_SERVE ?? 'ready';",
|
|
366
|
+
"const pick = ['HOME', 'GH_TOKEN', 'GITHUB_TOKEN', 'ENGRAM_NO_UPDATE_CHECK', 'ENGRAM_URL', 'ENGRAM_BIN', 'ENGRAM_HTTP_TOKEN', 'ENGRAM_STARTUP_POLICY'];",
|
|
367
|
+
"appendFileSync(process.env.FIXTURE_SERVE_LOG, JSON.stringify({ args, mode, cwd: process.cwd(), pid: process.pid, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }) + '\\n');",
|
|
368
|
+
"if (mode === 'exit') process.exit(1);",
|
|
369
|
+
"if (mode === 'race') { spawn(process.execPath, [process.argv[1], 'serve'], { env: { ...process.env, FIXTURE_SERVE: 'late' }, detached: true, stdio: 'ignore' }).unref(); process.exit(1); }",
|
|
370
|
+
`const ready = ${JSON.stringify(JSON.stringify(READY_COMPATIBILITY))};`,
|
|
371
|
+
"setTimeout(() => require('node:http').createServer((request, response) => {",
|
|
372
|
+
" response.writeHead(request.url === '/compatibility' ? 200 : 404, { 'content-type': 'application/json' }); response.end(request.url === '/compatibility' ? ready : '{}');",
|
|
373
|
+
"}).listen(0, '127.0.0.1', function () { const file = process.env.FIXTURE_ENGRAM_PORT_FILE; writeFileSync(file + '.tmp', String(this.address().port)); renameSync(file + '.tmp', file); }),",
|
|
374
|
+
" mode === 'late' ? 300 : 0);",
|
|
375
|
+
"setTimeout(() => process.exit(0), 60000);",
|
|
274
376
|
"",
|
|
275
377
|
].join("\n");
|
|
276
378
|
|
|
277
379
|
function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
|
|
278
380
|
const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-shell-bundled-")));
|
|
279
|
-
|
|
381
|
+
const serveLog = join(base, "engram-serve.log");
|
|
382
|
+
const serves = (): Array<{ args: string[]; mode: string; cwd: string; pid: number; env: Record<string, string | undefined> }> =>
|
|
383
|
+
existsSync(serveLog) ? readFileSync(serveLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [];
|
|
384
|
+
// The detached servers outlive their launcher by design: stop them with the fixture.
|
|
385
|
+
t.after(() => {
|
|
386
|
+
for (const { pid } of serves()) try { process.kill(pid); } catch { /* Already exited. */ }
|
|
387
|
+
rmSync(base, { recursive: true, force: true });
|
|
388
|
+
});
|
|
280
389
|
const home = join(base, "home");
|
|
281
390
|
const root = join(home, ".gentle-shell");
|
|
282
391
|
const version = join(root, "versions", FIXTURE_ID);
|
|
@@ -339,33 +448,38 @@ function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
|
|
|
339
448
|
mkdirSync(userBin);
|
|
340
449
|
writeFileSync(join(userBin, "pi"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 });
|
|
341
450
|
// No test contacts the actual default Engram endpoint: connections to 7437
|
|
342
|
-
// are redirected to a
|
|
343
|
-
// server
|
|
451
|
+
// are redirected to a local fixture server (FIXTURE_ENGRAM_PORT), to the
|
|
452
|
+
// server the bundled core's `serve` published (FIXTURE_ENGRAM_PORT_FILE), or
|
|
453
|
+
// else to a closed ephemeral port (refused). FIXTURE_NO_PROBE makes any probe fail the test.
|
|
344
454
|
const network = join(base, "network.mjs");
|
|
345
|
-
|
|
455
|
+
const portFile = join(base, "engram-port");
|
|
456
|
+
writeFileSync(network, `import net from 'node:net'; import { readFileSync } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
|
|
346
457
|
const original = net.createConnection;
|
|
347
458
|
const probe = net.createServer(); await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
|
|
348
459
|
const closed = probe.address().port; await new Promise((resolve) => probe.close(resolve));
|
|
349
460
|
net.createConnection = (options, ...rest) => {
|
|
350
461
|
if (Number(options?.port ?? options) !== 7437) return original(options, ...rest);
|
|
351
462
|
if (process.env.FIXTURE_NO_PROBE === '1') { process.stderr.write('unexpected Engram probe\\n'); process.exit(97); }
|
|
352
|
-
|
|
463
|
+
const served = () => { try { return readFileSync(process.env.FIXTURE_ENGRAM_PORT_FILE, 'utf8'); } catch { return ''; } };
|
|
464
|
+
return original({ ...options, port: Number(process.env.FIXTURE_ENGRAM_PORT || served() || closed) }, ...rest);
|
|
353
465
|
};
|
|
354
466
|
syncBuiltinESMExports();`);
|
|
355
467
|
const metadata = join(home, ".gentle-shell", "agent", "npm", "node_modules", "gentle-engram", "package.json");
|
|
356
468
|
mkdirSync(dirname(metadata), { recursive: true });
|
|
357
469
|
writeFileSync(metadata, JSON.stringify({ name: "gentle-engram", version: "0.4.2", engram: { startupPolicy: 1 } }));
|
|
358
470
|
const env = { HOME: home, PATH: `${userBin}:/usr/bin:/bin`, GENTLE_SHELL_PI: join(userBin, "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1",
|
|
359
|
-
NODE_OPTIONS: `--no-warnings --import=${network} --import=${registerHooks}`, npm_config_registry: "https://npm.example"
|
|
471
|
+
NODE_OPTIONS: `--no-warnings --import=${network} --import=${registerHooks}`, npm_config_registry: "https://npm.example",
|
|
472
|
+
FIXTURE_SERVE_LOG: serveLog, FIXTURE_ENGRAM_PORT_FILE: portFile };
|
|
360
473
|
const entry = join(root, "current", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs");
|
|
361
474
|
const run = (args: string[], extra: Record<string, string> = {}) => spawnSync(process.execPath, [entry, ...args], { env: { ...env, ...extra }, encoding: "utf8", input: "" });
|
|
362
|
-
return { root, home, userBin, version, piDir, npmCli, engram, metadata, network, guard, run, env, entry: join(shell, "bin", "gentle-shell.mjs") };
|
|
475
|
+
return { root, home, userBin, version, piDir, npmCli, engram, metadata, network, guard, run, env, serves, portFile, entry: join(shell, "bin", "gentle-shell.mjs") };
|
|
363
476
|
}
|
|
364
477
|
|
|
365
478
|
const STORE_ID = "0123456789abcdef0123456789abcdef";
|
|
366
479
|
const OTHER_STORE_ID = "fedcba9876543210fedcba9876543210";
|
|
480
|
+
const DEFAULT_URL = "http://127.0.0.1:7437";
|
|
367
481
|
const lastLaunch = (result: { stdout: string }) => JSON.parse(result.stdout.trim().split("\n").at(-1)!);
|
|
368
|
-
const memoryOf = (result: { stdout: string }) => [lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN];
|
|
482
|
+
const memoryOf = (result: { stdout: string }) => [lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN, lastLaunch(result).env.ENGRAM_URL];
|
|
369
483
|
const provenancePath = (prefix: { root: string }) => join(prefix.root, "config", "engram-provenance.json");
|
|
370
484
|
const sha256File = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
371
485
|
function writeHeader(path: string, userVersion: number, magic = "SQLite format 3\0") {
|
|
@@ -390,164 +504,141 @@ async function engramServer(t: TestContext, routes: Record<string, [number, unkn
|
|
|
390
504
|
return new Promise((resolve) => child.stdout.once("data", (chunk) => resolve(String(chunk).trim())));
|
|
391
505
|
}
|
|
392
506
|
|
|
393
|
-
|
|
507
|
+
// Whether the server a launch started still answers after the launcher exited.
|
|
508
|
+
async function stillServing(prefix: ReturnType<typeof bundledPrefix>): Promise<boolean> {
|
|
509
|
+
const response = await fetch(`http://127.0.0.1:${readFileSync(prefix.portFile, "utf8")}/compatibility`);
|
|
510
|
+
return response.ok && (await response.json()).status === "ready";
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
test("with nothing listening Shell starts the bundled core's shared server detached, with a clean environment, and Pi reuses it", { skip: posixHost }, async (t) => {
|
|
394
514
|
const prefix = bundledPrefix(t);
|
|
395
|
-
const
|
|
396
|
-
const result = prefix.run([], { FIXTURE_PROVISION_ENV: provisioning, GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token" });
|
|
515
|
+
const result = prefix.run([], { GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token", ENGRAM_HTTP_TOKEN: "fixture-http" });
|
|
397
516
|
assert.equal(result.status, 0, result.stderr);
|
|
398
|
-
|
|
399
|
-
assert.
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
assert.deepEqual(
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
assert.deepEqual([
|
|
517
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
|
|
518
|
+
assert.doesNotMatch(result.stderr, /memory disabled/);
|
|
519
|
+
const [serve, ...others] = prefix.serves();
|
|
520
|
+
assert.deepEqual(others, []);
|
|
521
|
+
// Engram's own defaults: no port, data directory or URL chosen for it.
|
|
522
|
+
assert.deepEqual([serve.args, serve.cwd, serve.env.HOME], [["serve"], prefix.home, prefix.home]);
|
|
523
|
+
// S8: no update check, no GitHub tokens and no inherited Engram settings; Pi keeps the user's environment.
|
|
524
|
+
assert.deepEqual([serve.env.ENGRAM_NO_UPDATE_CHECK, serve.env.GH_TOKEN, serve.env.GITHUB_TOKEN, serve.env.ENGRAM_HTTP_TOKEN, serve.env.ENGRAM_URL, serve.env.ENGRAM_BIN],
|
|
525
|
+
["1", undefined, undefined, undefined, undefined, undefined]);
|
|
526
|
+
assert.deepEqual([lastLaunch(result).env.GH_TOKEN, lastLaunch(result).env.ENGRAM_NO_UPDATE_CHECK], ["fixture-token", undefined]);
|
|
527
|
+
// Detached: it outlives the launcher and Pi, and the next launch reuses it without starting another.
|
|
528
|
+
assert.equal(await stillServing(prefix), true);
|
|
529
|
+
const second = prefix.run([]);
|
|
530
|
+
assert.equal(second.status, 0, second.stderr);
|
|
531
|
+
assert.deepEqual(memoryOf(second), ["allow", prefix.guard, DEFAULT_URL]);
|
|
532
|
+
assert.equal(prefix.serves().length, 1);
|
|
408
533
|
});
|
|
409
534
|
|
|
410
|
-
test("a
|
|
535
|
+
test("a server that fails to start or never reports ready disables memory for that launch and Shell still starts Pi", { skip: posixHost }, (t) => {
|
|
411
536
|
const prefix = bundledPrefix(t);
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
assert.deepEqual(
|
|
415
|
-
assert.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
mkdirSync(data);
|
|
419
|
-
writeFileSync(join(data, ".instance-id"), `${OTHER_STORE_ID}\n`);
|
|
420
|
-
writeHeader(join(data, "engram.db"), 1);
|
|
421
|
-
const foreign = prefix.run([]);
|
|
422
|
-
assert.deepEqual(memoryOf(foreign), ["disabled", prefix.guard]);
|
|
423
|
-
assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
|
|
424
|
-
});
|
|
425
|
-
test("a later launch reuses Shell's own store, including a stale WAL header, without rewriting provenance", { skip: posixHost }, (t) => {
|
|
426
|
-
const prefix = bundledPrefix(t);
|
|
427
|
-
assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0);
|
|
428
|
-
const recorded = readFileSync(provenancePath(prefix), "utf8");
|
|
429
|
-
for (const userVersion of [0, 1]) {
|
|
430
|
-
writeHeader(join(prefix.home, ".engram", "engram.db"), userVersion);
|
|
431
|
-
const second = prefix.run([]);
|
|
432
|
-
assert.equal(second.status, 0, second.stderr);
|
|
433
|
-
const seen = lastLaunch(second);
|
|
434
|
-
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["allow", prefix.engram, undefined], String(userVersion));
|
|
435
|
-
}
|
|
436
|
-
assert.equal(readFileSync(provenancePath(prefix), "utf8"), recorded);
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
test("provenance mismatches disable memory behind the guard: instance, core digest, newer or malformed header", { skip: posixHost }, (t) => {
|
|
440
|
-
const mismatches: Array<[string, (prefix: ReturnType<typeof bundledPrefix>) => void]> = [
|
|
441
|
-
["instance", (prefix) => writeFileSync(join(prefix.home, ".engram", ".instance-id"), `${OTHER_STORE_ID}\n`)],
|
|
442
|
-
["invalid identity", (prefix) => writeFileSync(join(prefix.home, ".engram", ".instance-id"), "not-an-id\n")],
|
|
443
|
-
["core digest", (prefix) => writeFileSync(prefix.engram, "#!/bin/sh\necho upgraded engram\n", { mode: 0o700 })],
|
|
444
|
-
["newer schema", (prefix) => writeHeader(join(prefix.home, ".engram", "engram.db"), 2)],
|
|
445
|
-
["malformed header", (prefix) => writeHeader(join(prefix.home, ".engram", "engram.db"), 1, "not sqlite at all")],
|
|
446
|
-
["symlinked database", (prefix) => {
|
|
447
|
-
const db = join(prefix.home, ".engram", "engram.db");
|
|
448
|
-
renameSync(db, `${db}.real`);
|
|
449
|
-
symlinkSync(`${db}.real`, db);
|
|
450
|
-
}],
|
|
451
|
-
["corrupt provenance", (prefix) => writeFileSync(provenancePath(prefix), "{")],
|
|
452
|
-
];
|
|
453
|
-
for (const [name, change] of mismatches) {
|
|
454
|
-
const prefix = bundledPrefix(t);
|
|
455
|
-
assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0, name);
|
|
456
|
-
change(prefix);
|
|
457
|
-
const result = prefix.run([]);
|
|
458
|
-
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
459
|
-
const seen = lastLaunch(result);
|
|
460
|
-
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard], name);
|
|
461
|
-
assert.match(result.stderr, name === "core digest" ? /memory disabled.*bundled core changed.*memory migrate --confirm-quiescent/s : /memory disabled.*could not verify.*compatibility.*Back up/s, name);
|
|
462
|
-
}
|
|
537
|
+
const result = prefix.run([], { FIXTURE_SERVE: "exit" });
|
|
538
|
+
assert.equal(result.status, 0, result.stderr);
|
|
539
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
540
|
+
assert.match(result.stderr, /memory disabled.*could not start the shared Engram server/s);
|
|
541
|
+
assert.equal(lastLaunch(result).cli.endsWith(join("dist", "bundle", "cli.js")), true);
|
|
542
|
+
assert.equal(prefix.serves().length, 1);
|
|
463
543
|
});
|
|
464
544
|
|
|
465
|
-
|
|
545
|
+
test("concurrent starts converge: a failed bind followed by a ready /compatibility counts as started", { skip: posixHost }, (t) => {
|
|
466
546
|
const prefix = bundledPrefix(t);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
writeFileSync(prefix.engram, `#!${process.execPath}\nimport {cpSync,writeFileSync} from 'node:fs';\nconst args=process.argv.slice(2);\nif(args[0]==='--help'){console.log(${JSON.stringify(outcome === "unsupported" ? "Commands: compatibility" : " migrate-copy --data-dir <absolute-source> --output-dir <new-absolute-destination> --confirm-quiescent --json\n serve --session-owned Private ephemeral HTTP server")});process.exit(0);}\nwriteFileSync(${JSON.stringify(invocation)},JSON.stringify({args,token:process.env.GH_TOKEN,cloud:process.env.ENGRAM_CLOUD_TOKEN}));\nif(args[0]!=='migrate-copy') process.exit(2);\nconst source=args[args.indexOf('--data-dir')+1],output=args[args.indexOf('--output-dir')+1];\nif(${JSON.stringify(outcome)}==='failure'){process.stderr.write('migration rejected\\n');process.exit(1);}\ncpSync(source,output,{recursive:true});\nconsole.log(JSON.stringify({source_dir:source,output_dir:output,instance_id:${JSON.stringify(outcome === "wrong-identity" ? OTHER_STORE_ID : STORE_ID)},migration_completed:true}));\n`, { mode: 0o700 });
|
|
474
|
-
return { ...prefix, source, originalRecord, sourceBytes, invocation };
|
|
475
|
-
}
|
|
547
|
+
const result = prefix.run([], { FIXTURE_SERVE: "race" });
|
|
548
|
+
assert.equal(result.status, 0, result.stderr);
|
|
549
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
|
|
550
|
+
assert.doesNotMatch(result.stderr, /memory disabled/);
|
|
551
|
+
assert.deepEqual(prefix.serves().map(({ mode }) => mode), ["race", "late"]);
|
|
552
|
+
});
|
|
476
553
|
|
|
477
|
-
test("bundled
|
|
554
|
+
test("bundled memory never requires or selects a session-owned server, with the 0.4.2 companion and an explicit mode kept", { skip: posixHost }, (t) => {
|
|
478
555
|
const prefix = bundledPrefix(t);
|
|
479
|
-
const result = prefix.run([]
|
|
556
|
+
const result = prefix.run([]);
|
|
480
557
|
assert.equal(result.status, 0, result.stderr);
|
|
481
|
-
assert.deepEqual(memoryOf(result), ["allow", prefix.
|
|
482
|
-
|
|
483
|
-
assert.
|
|
484
|
-
assert.
|
|
485
|
-
assert.equal(seen.env.ENGRAM_SOCKET, undefined);
|
|
486
|
-
assert.equal(seen.provenance.instanceId, STORE_ID);
|
|
558
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
|
|
559
|
+
assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, undefined);
|
|
560
|
+
assert.doesNotMatch(result.stderr, /session-owned/);
|
|
561
|
+
assert.deepEqual(JSON.parse(readFileSync(prefix.metadata, "utf8")).engram, { startupPolicy: 1 });
|
|
487
562
|
});
|
|
488
563
|
|
|
489
|
-
test("
|
|
490
|
-
for (const
|
|
564
|
+
test("an explicit Engram server mode is a selection: kept, never probed or started, memory disabled", { skip: posixHost }, (t) => {
|
|
565
|
+
for (const mode of ["session-owned", "bogus"]) {
|
|
491
566
|
const prefix = bundledPrefix(t);
|
|
492
|
-
const result = prefix.run([], {
|
|
567
|
+
const result = prefix.run([], { ENGRAM_SERVER_MODE: mode, FIXTURE_NO_PROBE: "1" });
|
|
493
568
|
assert.equal(result.status, 0, result.stderr);
|
|
494
|
-
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
495
|
-
assert.
|
|
496
|
-
assert.
|
|
497
|
-
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
569
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined], mode);
|
|
570
|
+
assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, mode);
|
|
571
|
+
assert.deepEqual(prefix.serves(), [], mode);
|
|
498
572
|
}
|
|
499
573
|
});
|
|
500
574
|
|
|
501
|
-
test("
|
|
575
|
+
test("a mixed local and npm companion selection is preserved and memory still uses the shared server", { skip: posixHost }, (t) => {
|
|
502
576
|
const prefix = bundledPrefix(t);
|
|
503
577
|
const local = join(prefix.home, "legacy-companion");
|
|
504
578
|
mkdirSync(local);
|
|
505
579
|
writeFileSync(join(local, "package.json"), JSON.stringify({ name: "gentle-engram", engram: { startupPolicy: 1 } }));
|
|
506
|
-
writeFileSync(prefix.metadata, JSON.stringify({ name: "gentle-engram", engram: { startupPolicy: 1, sessionOwned: 1 } }));
|
|
507
580
|
const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json");
|
|
508
581
|
const selected = JSON.stringify({ packages: [local, "npm:gentle-engram@0.4.2"] });
|
|
509
582
|
writeFileSync(settings, selected);
|
|
510
|
-
const result = prefix.run([]
|
|
583
|
+
const result = prefix.run([]);
|
|
511
584
|
assert.equal(result.status, 0, result.stderr);
|
|
512
|
-
assert.deepEqual(memoryOf(result), ["
|
|
513
|
-
assert.match(result.stderr, /session-owned.*supporting published/s);
|
|
585
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
|
|
514
586
|
assert.deepEqual(JSON.parse(readFileSync(settings, "utf8")).packages, JSON.parse(selected).packages);
|
|
515
|
-
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
516
|
-
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
517
587
|
});
|
|
518
588
|
|
|
519
|
-
test("
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
589
|
+
test("an existing store, including one the global CLI created, starts the shared server and Shell never touches it", { skip: posixHost }, (t) => {
|
|
590
|
+
const prefix = bundledPrefix(t);
|
|
591
|
+
const data = join(prefix.home, ".engram");
|
|
592
|
+
mkdirSync(data);
|
|
593
|
+
writeFileSync(join(data, ".instance-id"), `${OTHER_STORE_ID}\n`);
|
|
594
|
+
for (const userVersion of [0, 1]) {
|
|
595
|
+
writeHeader(join(data, "engram.db"), userVersion);
|
|
596
|
+
const before = readFileSync(join(data, "engram.db"));
|
|
597
|
+
const result = prefix.run([]);
|
|
523
598
|
assert.equal(result.status, 0, result.stderr);
|
|
524
|
-
assert.deepEqual(memoryOf(result), ["
|
|
525
|
-
assert.
|
|
526
|
-
assert.match(result.stderr, /explicit ENGRAM_SERVER_MODE.*unset.*session-owned/s);
|
|
527
|
-
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
528
|
-
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
599
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL], String(userVersion));
|
|
600
|
+
assert.deepEqual(readFileSync(join(data, "engram.db")), before);
|
|
529
601
|
}
|
|
602
|
+
assert.deepEqual(readdirSync(data).sort(), [".instance-id", "engram.db"]);
|
|
603
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
530
604
|
});
|
|
531
605
|
|
|
532
|
-
test("
|
|
606
|
+
test("a store a newer Engram migrated keeps the server stopped and memory disabled", { skip: posixHost }, (t) => {
|
|
533
607
|
const prefix = bundledPrefix(t);
|
|
534
|
-
const
|
|
608
|
+
const data = join(prefix.home, ".engram");
|
|
609
|
+
mkdirSync(data);
|
|
610
|
+
writeHeader(join(data, "engram.db"), 2);
|
|
611
|
+
const result = prefix.run([]);
|
|
535
612
|
assert.equal(result.status, 0, result.stderr);
|
|
536
|
-
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
537
|
-
assert.
|
|
538
|
-
assert.
|
|
613
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
614
|
+
assert.match(result.stderr, /memory disabled.*newer Engram/s);
|
|
615
|
+
assert.deepEqual(prefix.serves(), []);
|
|
539
616
|
});
|
|
540
617
|
|
|
541
|
-
|
|
542
|
-
const prefix =
|
|
543
|
-
|
|
618
|
+
function migrationFixture(t: TestContext, outcome = "success") {
|
|
619
|
+
const prefix = bundledPrefix(t);
|
|
620
|
+
// A store an earlier Shell provisioned and recorded with the bundled core.
|
|
621
|
+
const source = join(prefix.home, ".engram");
|
|
622
|
+
mkdirSync(source);
|
|
623
|
+
writeFileSync(join(source, ".instance-id"), `${STORE_ID}\n`);
|
|
624
|
+
writeHeader(join(source, "engram.db"), 1);
|
|
625
|
+
writeFileSync(join(source, "config.json"), '{"preserved":true}\n');
|
|
626
|
+
mkdirSync(dirname(provenancePath(prefix)), { recursive: true });
|
|
627
|
+
writeFileSync(provenancePath(prefix), `${JSON.stringify({ schema: 1, dataDir: source, instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) }, null, 2)}\n`);
|
|
628
|
+
const originalRecord = readFileSync(provenancePath(prefix), "utf8");
|
|
629
|
+
const sourceBytes = readFileSync(join(source, "engram.db"));
|
|
630
|
+
const invocation = join(prefix.home, "migration-invoked");
|
|
631
|
+
writeFileSync(prefix.engram, `#!${process.execPath}\nimport {cpSync,writeFileSync} from 'node:fs';\nconst args=process.argv.slice(2);\nif(args[0]==='--help'){console.log(${JSON.stringify(outcome === "unsupported" ? "Commands: compatibility" : " migrate-copy --data-dir <absolute-source> --output-dir <new-absolute-destination> --confirm-quiescent --json")});process.exit(0);}\nwriteFileSync(${JSON.stringify(invocation)},JSON.stringify({args,token:process.env.GH_TOKEN,cloud:process.env.ENGRAM_CLOUD_TOKEN}));\nif(args[0]!=='migrate-copy') process.exit(2);\nconst source=args[args.indexOf('--data-dir')+1],output=args[args.indexOf('--output-dir')+1];\nif(${JSON.stringify(outcome)}==='failure'){process.stderr.write('migration rejected\\n');process.exit(1);}\ncpSync(source,output,{recursive:true});\nconsole.log(JSON.stringify({source_dir:source,output_dir:output,instance_id:${JSON.stringify(outcome === "wrong-identity" ? OTHER_STORE_ID : STORE_ID)},migration_completed:true}));\n`, { mode: 0o700 });
|
|
632
|
+
return { ...prefix, source, originalRecord, sourceBytes, invocation };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
test("an explicit Engram socket remains untouched and is never probed", { skip: posixHost }, (t) => {
|
|
636
|
+
const prefix = bundledPrefix(t);
|
|
637
|
+
const result = prefix.run([], { ENGRAM_SOCKET: "/fixture/selected.sock", FIXTURE_NO_PROBE: "1" });
|
|
544
638
|
assert.equal(result.status, 0, result.stderr);
|
|
545
|
-
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
546
|
-
assert.
|
|
547
|
-
assert.
|
|
548
|
-
assert.equal(existsSync(prefix.invocation), false);
|
|
549
|
-
assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
|
|
550
|
-
assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
|
|
639
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
640
|
+
assert.equal(lastLaunch(result).env.ENGRAM_SOCKET, "/fixture/selected.sock");
|
|
641
|
+
assert.deepEqual(prefix.serves(), []);
|
|
551
642
|
});
|
|
552
643
|
|
|
553
644
|
test("memory migrate explicitly promotes a verified copy and retains the original store", { skip: posixHost }, (t) => {
|
|
@@ -568,7 +659,6 @@ test("memory migrate explicitly promotes a verified copy and retains the origina
|
|
|
568
659
|
assert.equal(invoked.token, undefined);
|
|
569
660
|
assert.equal(invoked.cloud, undefined);
|
|
570
661
|
assert.equal(existsSync(join(prefix.root, "config", "engram-migration.json")), false);
|
|
571
|
-
assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
|
|
572
662
|
});
|
|
573
663
|
|
|
574
664
|
test("memory migrate rejects missing confirmation and malformed flags without invoking Engram", { skip: posixHost }, (t) => {
|
|
@@ -608,18 +698,17 @@ test("memory migrate refuses provider failure and mismatched evidence without ch
|
|
|
608
698
|
}
|
|
609
699
|
});
|
|
610
700
|
|
|
611
|
-
test("an interrupted memory promotion
|
|
701
|
+
test("an interrupted memory promotion keeps the server stopped until recovery", { skip: posixHost }, (t) => {
|
|
612
702
|
const prefix = bundledPrefix(t);
|
|
613
703
|
mkdirSync(join(prefix.root, "config"), { recursive: true });
|
|
614
704
|
const marker = join(prefix.root, "config", "engram-migration.json");
|
|
615
705
|
writeFileSync(marker, '{"interrupted":true}\n');
|
|
616
706
|
const result = prefix.run([], { FIXTURE_NO_PROBE: "1" });
|
|
617
707
|
assert.equal(result.status, 0, result.stderr);
|
|
618
|
-
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
708
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
619
709
|
assert.match(result.stderr, /incomplete memory migration/);
|
|
620
|
-
assert.
|
|
710
|
+
assert.deepEqual(prefix.serves(), []);
|
|
621
711
|
assert.equal(readFileSync(marker, "utf8"), '{"interrupted":true}\n');
|
|
622
|
-
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
623
712
|
});
|
|
624
713
|
|
|
625
714
|
test("public help explains compatibility evidence without promising update recovery", { skip: posixHost }, (t) => {
|
|
@@ -629,41 +718,21 @@ test("public help explains compatibility evidence without promising update recov
|
|
|
629
718
|
assert.equal(result.stderr, "");
|
|
630
719
|
assert.match(result.stdout, /Matching versions or updating alone do not prove compatibility/);
|
|
631
720
|
assert.match(result.stdout, /\/compatibility/);
|
|
632
|
-
assert.
|
|
721
|
+
assert.match(result.stdout, /shares the global Engram server.*nothing listens.*engram serve/s);
|
|
722
|
+
assert.doesNotMatch(result.stdout, /update Engram, restart|Shell-provisioned|session-owned/);
|
|
633
723
|
});
|
|
634
724
|
|
|
635
|
-
test("a
|
|
725
|
+
test("a listener without the ready contract is never replaced, even reporting the bundled core version", { skip: posixHost }, async (t) => {
|
|
636
726
|
const prefix = bundledPrefix(t);
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
writeFileSync(join(data, ".instance-id"), `${STORE_ID}\n`);
|
|
640
|
-
writeHeader(join(data, "engram.db"), 1);
|
|
641
|
-
const result = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
|
|
727
|
+
const port = await engramServer(t, { "/health": [200, { status: "ok", instance_id: STORE_ID, version: engramPinVersion }] });
|
|
728
|
+
const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
|
|
642
729
|
assert.equal(result.status, 0, result.stderr);
|
|
643
|
-
assert.deepEqual(
|
|
644
|
-
assert.
|
|
730
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
731
|
+
assert.match(result.stderr, /memory disabled.*could not verify/s);
|
|
732
|
+
assert.deepEqual(prefix.serves(), []);
|
|
645
733
|
});
|
|
646
734
|
|
|
647
|
-
test("a
|
|
648
|
-
const cases: Array<[string, unknown, string]> = [
|
|
649
|
-
["match", { status: "ok", instance_id: STORE_ID, version: engramPinVersion }, "allow"],
|
|
650
|
-
["other instance", { status: "ok", instance_id: OTHER_STORE_ID, version: engramPinVersion }, "disabled"],
|
|
651
|
-
["other version", { status: "ok", instance_id: STORE_ID, version: "9.9.9" }, "disabled"],
|
|
652
|
-
];
|
|
653
|
-
for (const [name, health, policy] of cases) {
|
|
654
|
-
const prefix = bundledPrefix(t);
|
|
655
|
-
assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0, name);
|
|
656
|
-
const port = await engramServer(t, { "/health": [200, health] });
|
|
657
|
-
const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
|
|
658
|
-
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
659
|
-
const seen = lastLaunch(result);
|
|
660
|
-
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL],
|
|
661
|
-
[policy, prefix.guard, policy === "allow" ? "http://127.0.0.1:7437" : undefined], name);
|
|
662
|
-
assert.equal(seen.env.ENGRAM_SERVER_MODE, undefined, name);
|
|
663
|
-
}
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
test("a verified /compatibility server is reused through an explicit URL and the guard, without provenance", { skip: posixHost }, async (t) => {
|
|
735
|
+
test("a verified /compatibility server is reused unchanged through an explicit URL and the guard", { skip: posixHost }, async (t) => {
|
|
667
736
|
const ready = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
|
|
668
737
|
instance_id: STORE_ID, runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
|
|
669
738
|
const prefix = bundledPrefix(t);
|
|
@@ -675,11 +744,12 @@ test("a verified /compatibility server is reused through an explicit URL and the
|
|
|
675
744
|
assert.equal(result.status, 0, result.stderr);
|
|
676
745
|
const seen = lastLaunch(result);
|
|
677
746
|
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_URL, seen.env.ENGRAM_BIN], ["allow", "http://127.0.0.1:7437", prefix.guard]);
|
|
678
|
-
assert.
|
|
747
|
+
assert.deepEqual(prefix.serves(), []);
|
|
679
748
|
const unready = bundledPrefix(t);
|
|
680
749
|
mkdirSync(join(unready.home, ".engram"));
|
|
681
750
|
const notReady = await engramServer(t, { "/compatibility": [200, { ...ready, status: "unknown", active_reuse_authorized: false }] });
|
|
682
751
|
assert.equal(lastLaunch(unready.run([], { FIXTURE_ENGRAM_PORT: notReady })).env.ENGRAM_STARTUP_POLICY, "disabled");
|
|
752
|
+
assert.deepEqual(unready.serves(), []);
|
|
683
753
|
});
|
|
684
754
|
|
|
685
755
|
test("a foreign store behind a 3.3.2 server stays guarded and the launch names the server version to update", { skip: posixHost }, async (t) => {
|
|
@@ -696,34 +766,10 @@ test("a foreign store behind a 3.3.2 server stays guarded and the launch names t
|
|
|
696
766
|
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
|
|
697
767
|
assert.match(result.stderr, /memory disabled.*reports version 3\.3\.2.*update that server to 3\.4\.0 or later/s);
|
|
698
768
|
assert.deepEqual(readFileSync(join(data, "engram.db")), before);
|
|
699
|
-
assert.
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
test("a fresh launch whose launcher is killed during Pi already has finalized provenance, and the next launch allows memory", { skip: posixHost }, (t) => {
|
|
703
|
-
const prefix = bundledPrefix(t);
|
|
704
|
-
const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID, FIXTURE_KILL_LAUNCHER: "1" });
|
|
705
|
-
assert.equal(first.signal, "SIGKILL");
|
|
706
|
-
assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
|
|
707
|
-
assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
|
|
708
|
-
});
|
|
709
|
-
test("a failed identity provisioning disables memory for that launch and records nothing", { skip: posixHost }, (t) => {
|
|
710
|
-
const cases: Array<[string, Record<string, string>, (prefix: ReturnType<typeof bundledPrefix>) => void]> = [
|
|
711
|
-
["instance-id failure", { FIXTURE_INSTANCE_ID_FAIL: "1" }, () => {}],
|
|
712
|
-
["invalid identity file", { FIXTURE_INSTANCE_ID: "not-an-id" }, () => {}],
|
|
713
|
-
["provenance write failure", {}, (prefix) => mkdirSync(provenancePath(prefix), { recursive: true })],
|
|
714
|
-
];
|
|
715
|
-
for (const [name, extra, prepare] of cases) {
|
|
716
|
-
const prefix = bundledPrefix(t);
|
|
717
|
-
prepare(prefix);
|
|
718
|
-
const result = prefix.run([], extra);
|
|
719
|
-
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
720
|
-
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard], name);
|
|
721
|
-
assert.match(result.stderr, /could not provision/, name);
|
|
722
|
-
assert.equal(lastLaunch(result).provenance, null, name);
|
|
723
|
-
}
|
|
769
|
+
assert.deepEqual(prefix.serves(), []);
|
|
724
770
|
});
|
|
725
771
|
|
|
726
|
-
test("--version and forwarded Pi version or help flags never probe Engram, install the companion or print memory notices", { skip: posixHost }, (t) => {
|
|
772
|
+
test("--version and forwarded Pi version or help flags never probe or start Engram, install the companion or print memory notices", { skip: posixHost }, (t) => {
|
|
727
773
|
const prefix = bundledPrefix(t);
|
|
728
774
|
writeFileSync(prefix.metadata, '{"name":"gentle-engram","version":"0.4.1"}');
|
|
729
775
|
mkdirSync(join(prefix.home, ".engram"));
|
|
@@ -737,7 +783,7 @@ test("--version and forwarded Pi version or help flags never probe Engram, insta
|
|
|
737
783
|
assert.equal(readFileSync(settings, "utf8"), before, args.join(" "));
|
|
738
784
|
assert.equal(JSON.parse(readFileSync(prefix.metadata, "utf8")).version, "0.4.1", args.join(" "));
|
|
739
785
|
}
|
|
740
|
-
assert.equal(
|
|
786
|
+
assert.equal(prefix.serves().length, 1);
|
|
741
787
|
});
|
|
742
788
|
|
|
743
789
|
test("-v or --help as a prompt or an option value never switches memory off", { skip: posixHost }, (t) => {
|
|
@@ -745,8 +791,8 @@ test("-v or --help as a prompt or an option value never switches memory off", {
|
|
|
745
791
|
const prefix = bundledPrefix(t);
|
|
746
792
|
const result = prefix.run(args);
|
|
747
793
|
assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`);
|
|
748
|
-
assert.deepEqual(memoryOf(result), ["allow", prefix.
|
|
749
|
-
assert.equal(
|
|
794
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL], args.join(" "));
|
|
795
|
+
assert.equal(prefix.serves().length, 1, args.join(" "));
|
|
750
796
|
}
|
|
751
797
|
});
|
|
752
798
|
test("a missing bundled core uses the guard, never an engram on PATH", { skip: posixHost }, (t) => {
|
|
@@ -771,20 +817,23 @@ function countingGentleAi(prefix: ReturnType<typeof bundledPrefix>) {
|
|
|
771
817
|
return { bin, runs: () => (existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean).length : 0) };
|
|
772
818
|
}
|
|
773
819
|
|
|
774
|
-
test("setup on a fresh machine publishes the bundled core; the next launch
|
|
820
|
+
test("setup on a fresh machine publishes the bundled core; the next launch starts the shared server and later launches reuse it", { skip: posixHost }, (t) => {
|
|
775
821
|
const prefix = bundledPrefix(t, { core: false });
|
|
776
822
|
const installer = countingGentleAi(prefix);
|
|
777
823
|
const setup = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: installer.bin });
|
|
778
824
|
assert.equal(setup.status, 0, setup.stderr);
|
|
779
825
|
assert.equal(installer.runs(), 1);
|
|
780
826
|
assert.equal(existsSync(prefix.engram), true);
|
|
781
|
-
|
|
827
|
+
// Setup itself never starts a server.
|
|
828
|
+
assert.deepEqual(prefix.serves(), []);
|
|
829
|
+
const first = prefix.run([]);
|
|
782
830
|
assert.equal(first.status, 0, first.stderr);
|
|
783
|
-
assert.deepEqual(
|
|
784
|
-
assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
|
|
831
|
+
assert.deepEqual(memoryOf(first), ["allow", prefix.guard, DEFAULT_URL]);
|
|
785
832
|
const second = prefix.run([]);
|
|
786
833
|
assert.equal(second.status, 0, second.stderr);
|
|
787
|
-
assert.deepEqual(
|
|
834
|
+
assert.deepEqual(memoryOf(second), ["allow", prefix.guard, DEFAULT_URL]);
|
|
835
|
+
assert.equal(prefix.serves().length, 1);
|
|
836
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
788
837
|
});
|
|
789
838
|
|
|
790
839
|
test("an explicit ENGRAM_URL alone keeps the verified core, or the guard without it, as ENGRAM_BIN", { skip: posixHost }, (t) => {
|
|
@@ -808,12 +857,13 @@ test("a failed companion check after setup is a warning, not a setup failure", {
|
|
|
808
857
|
assert.equal(setup.status, 0, setup.stderr);
|
|
809
858
|
assert.equal(installer.runs(), 1);
|
|
810
859
|
assert.match(setup.stderr, /startupPolicy/);
|
|
811
|
-
|
|
860
|
+
// The guard, not the companion check, protects memory: the shared server is still reused.
|
|
861
|
+
assert.deepEqual(memoryOf(prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" })), ["allow", prefix.guard, DEFAULT_URL]);
|
|
812
862
|
assert.deepEqual(readdirSync(data), ["engram.db"]);
|
|
813
863
|
assert.equal(readFileSync(join(data, "engram.db"), "utf8"), "untouched database sentinel");
|
|
814
864
|
});
|
|
815
865
|
|
|
816
|
-
test("setup with
|
|
866
|
+
test("setup with an existing store runs the installer like the baseline and the launch reuses the shared server behind the guard", { skip: posixHost }, (t) => {
|
|
817
867
|
const prefix = bundledPrefix(t, { core: false });
|
|
818
868
|
const data = join(prefix.home, ".engram");
|
|
819
869
|
mkdirSync(data);
|
|
@@ -823,13 +873,11 @@ test("setup with a foreign existing store runs the installer like the baseline,
|
|
|
823
873
|
assert.equal(setup.status, 0, setup.stderr);
|
|
824
874
|
assert.equal(installer.runs(), 1);
|
|
825
875
|
assert.equal(existsSync(prefix.engram), true);
|
|
826
|
-
assert.
|
|
827
|
-
assert.doesNotMatch(setup.stderr, /update Engram and restart/);
|
|
828
|
-
assert.doesNotMatch(setup.stderr, /deferred/);
|
|
876
|
+
assert.doesNotMatch(setup.stderr, /memory disabled|deferred/);
|
|
829
877
|
const guarded = prefix.run([]);
|
|
830
878
|
assert.equal(guarded.status, 0, guarded.stderr);
|
|
831
879
|
const env = lastLaunch(guarded).env;
|
|
832
|
-
assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["
|
|
880
|
+
assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["allow", prefix.guard, DEFAULT_URL]);
|
|
833
881
|
// Subagents receive the same environment (GENTLE_PI_AGENTS_PI runs the same Pi with it).
|
|
834
882
|
assert.equal(env.GENTLE_PI_AGENTS_PI.endsWith(join("dist", "bundle", "cli.js")), true);
|
|
835
883
|
const provider = spawnSync(env.ENGRAM_BIN, ["instance-id"], { env: { ...prefix.env, ...env }, encoding: "utf8" });
|
|
@@ -844,15 +892,14 @@ test("setup with a foreign existing store runs the installer like the baseline,
|
|
|
844
892
|
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
845
893
|
});
|
|
846
894
|
|
|
847
|
-
test("a gentle-pi version change
|
|
895
|
+
test("a gentle-pi version change reruns setup once and keeps memory on the shared server", { skip: posixHost }, (t) => {
|
|
848
896
|
const prefix = bundledPrefix(t, { core: false });
|
|
849
897
|
const installer = countingGentleAi(prefix);
|
|
850
898
|
const auto = { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: installer.bin };
|
|
851
|
-
const first = prefix.run([],
|
|
899
|
+
const first = prefix.run([], auto);
|
|
852
900
|
assert.equal(first.status, 0, first.stderr);
|
|
853
901
|
assert.equal(installer.runs(), 1);
|
|
854
|
-
assert.deepEqual(
|
|
855
|
-
assert.equal(existsSync(provenancePath(prefix)), true);
|
|
902
|
+
assert.deepEqual(memoryOf(first), ["allow", prefix.guard, DEFAULT_URL]);
|
|
856
903
|
assert.equal(prefix.run([], auto).status, 0);
|
|
857
904
|
assert.equal(installer.runs(), 1);
|
|
858
905
|
const manifest = join(dirname(dirname(prefix.entry)), "package.json");
|
|
@@ -861,7 +908,8 @@ test("a gentle-pi version change re-provisions Shell's own store once and keeps
|
|
|
861
908
|
assert.equal(changed.status, 0, changed.stderr);
|
|
862
909
|
assert.equal(installer.runs(), 2);
|
|
863
910
|
assert.doesNotMatch(changed.stderr, /automatic setup failed|deferred/);
|
|
864
|
-
assert.deepEqual(
|
|
911
|
+
assert.deepEqual(memoryOf(changed), ["allow", prefix.guard, DEFAULT_URL]);
|
|
912
|
+
assert.equal(prefix.serves().length, 1);
|
|
865
913
|
assert.equal(prefix.run([], auto).status, 0);
|
|
866
914
|
assert.equal(installer.runs(), 2);
|
|
867
915
|
});
|
|
@@ -882,9 +930,7 @@ test("capability readback also requires Pi to declare the selected pinned compan
|
|
|
882
930
|
const result = prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "metadata-only" });
|
|
883
931
|
assert.equal(result.status, 0, result.stderr);
|
|
884
932
|
assert.match(result.stderr, /startupPolicy/);
|
|
885
|
-
|
|
886
|
-
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
887
|
-
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
933
|
+
assert.equal(lastLaunch(result).env.ENGRAM_BIN, prefix.guard);
|
|
888
934
|
});
|
|
889
935
|
|
|
890
936
|
test("pinning preserves resource filters and unrelated settings and does not reinstall next launch", { skip: posixHost }, (t) => {
|
|
@@ -900,16 +946,19 @@ test("pinning preserves resource filters and unrelated settings and does not rei
|
|
|
900
946
|
assert.doesNotMatch(prefix.run([]).stderr, /installing policy-capable companion/);
|
|
901
947
|
});
|
|
902
948
|
|
|
903
|
-
test("final startup rechecks
|
|
949
|
+
test("final startup rechecks a store a newer Engram migrated during automatic setup and never starts a server on it", { skip: posixHost }, (t) => {
|
|
904
950
|
const prefix = bundledPrefix(t);
|
|
905
951
|
const fake = join(prefix.userBin, "gentle-ai");
|
|
906
|
-
|
|
952
|
+
const db = join(prefix.home, ".engram", "engram.db");
|
|
953
|
+
writeFileSync(fake, `#!${process.execPath}\nconst { mkdirSync, writeFileSync } = require('node:fs');\nmkdirSync(${JSON.stringify(dirname(db))}, { recursive: true });\n` +
|
|
954
|
+
`const header = Buffer.alloc(4096); header.write('SQLite format 3\\0', 0, 'latin1'); header.writeUInt16BE(4096, 16); header[18] = 2; header[19] = 2; header.writeUInt32BE(2, 60);\n` +
|
|
955
|
+
`writeFileSync(${JSON.stringify(db)}, header);\n`, { mode: 0o755 });
|
|
907
956
|
const result = prefix.run([], { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: fake });
|
|
908
957
|
assert.equal(result.status, 0, result.stderr);
|
|
909
|
-
|
|
910
|
-
assert.deepEqual(
|
|
911
|
-
assert.
|
|
912
|
-
assert.
|
|
958
|
+
assert.equal(existsSync(db), true);
|
|
959
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
960
|
+
assert.match(result.stderr, /memory disabled.*newer Engram/s);
|
|
961
|
+
assert.deepEqual(prefix.serves(), []);
|
|
913
962
|
});
|
|
914
963
|
|
|
915
964
|
test("explicit endpoints and invalid policies never trigger availability probes or override executables", { skip: posixHost }, (t) => {
|
|
@@ -923,6 +972,7 @@ test("explicit endpoints and invalid policies never trigger availability probes
|
|
|
923
972
|
const policyOnly = prefix.run([], { FIXTURE_NO_PROBE: "1", ENGRAM_STARTUP_POLICY: "invalid" });
|
|
924
973
|
assert.equal(policyOnly.status, 0, policyOnly.stderr);
|
|
925
974
|
assert.equal(lastLaunch(policyOnly).env.ENGRAM_BIN, prefix.guard);
|
|
975
|
+
assert.deepEqual(prefix.serves(), []);
|
|
926
976
|
});
|
|
927
977
|
|
|
928
978
|
test("startup offer precedes Pi, not-now preserves config and omit suppresses just that release", { skip: posixHost }, (t) => {
|
|
@@ -939,15 +989,36 @@ test("startup offer precedes Pi, not-now preserves config and omit suppresses ju
|
|
|
939
989
|
env: { ...prefix.env, NODE_OPTIONS: `${prefix.env.NODE_OPTIONS} --import=data:text/javascript,${encodeURIComponent(preload(latest))}` },
|
|
940
990
|
encoding: "utf8", input, timeout: 10000,
|
|
941
991
|
});
|
|
992
|
+
// A recorded lookup answers the next launch without the network, so these
|
|
993
|
+
// runs simulate later days by aging the cache back past the TTL; the live
|
|
994
|
+
// lookup then records its own answer again.
|
|
995
|
+
const ageCache = () => {
|
|
996
|
+
const parsed = JSON.parse(readFileSync(config, "utf8"));
|
|
997
|
+
const latest = typeof parsed.startupReleaseCheck?.latest === "string" ? parsed.startupReleaseCheck.latest : "999.0.0";
|
|
998
|
+
parsed.startupReleaseCheck = { checkedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), latest };
|
|
999
|
+
writeFileSync(config, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
1000
|
+
};
|
|
942
1001
|
const skipped = run("999.0.0", "2\r");
|
|
943
1002
|
assert.equal(skipped.status, 0, skipped.stderr);
|
|
944
1003
|
assert.match(skipped.stdout, /Gentle Shell update available/);
|
|
945
1004
|
assert.ok(skipped.stdout.indexOf("Gentle Shell update available") < skipped.stdout.indexOf('"cli":'));
|
|
946
|
-
|
|
1005
|
+
// A not-now choice still records no decision, but the live lookup it paid
|
|
1006
|
+
// for lands in the config as the cache tomorrow's launch reuses.
|
|
1007
|
+
const afterSkipped = JSON.parse(readFileSync(config, "utf8"));
|
|
1008
|
+
assert.equal(afterSkipped.other, "preserved");
|
|
1009
|
+
assert.equal(afterSkipped.skippedStartupRelease, undefined);
|
|
1010
|
+
assert.equal(afterSkipped.startupReleaseCheck.latest, "999.0.0");
|
|
1011
|
+
assert.ok(!Number.isNaN(Date.parse(afterSkipped.startupReleaseCheck.checkedAt)));
|
|
1012
|
+
ageCache();
|
|
947
1013
|
const omitted = run("999.0.0", "3\r");
|
|
948
1014
|
assert.equal(omitted.status, 0, omitted.stderr);
|
|
949
|
-
|
|
1015
|
+
const afterOmit = JSON.parse(readFileSync(config, "utf8"));
|
|
1016
|
+
assert.equal(afterOmit.other, "preserved");
|
|
1017
|
+
assert.equal(afterOmit.skippedStartupRelease, "999.0.0");
|
|
1018
|
+
assert.equal(afterOmit.startupReleaseCheck.latest, "999.0.0");
|
|
1019
|
+
ageCache();
|
|
950
1020
|
assert.doesNotMatch(run("999.0.0", "").stdout, /Gentle Shell update available/);
|
|
1021
|
+
ageCache();
|
|
951
1022
|
assert.match(run("999.0.1", "2\r").stdout, /Gentle Shell update available/);
|
|
952
1023
|
const command = run("999.0.1", "", ["list"]);
|
|
953
1024
|
assert.equal(command.status, 0, command.stderr);
|
|
@@ -973,11 +1044,13 @@ test("the bundled launcher runs only its version's Pi, with the user's environme
|
|
|
973
1044
|
assert.equal(seen.env.NODE_OPTIONS, prefix.env.NODE_OPTIONS);
|
|
974
1045
|
assert.equal(seen.env.npm_config_registry, "https://npm.example");
|
|
975
1046
|
assert.equal(seen.env.npm_config_prefix, undefined);
|
|
976
|
-
// S9: gentle-pi's config folder inside the prefix, the
|
|
977
|
-
// gentle-engram, and none of Gentle AI's own variables.
|
|
1047
|
+
// S9: gentle-pi's config folder inside the prefix, the shared server's URL
|
|
1048
|
+
// behind the guard for gentle-engram, and none of Gentle AI's own variables.
|
|
1049
|
+
// A Pi subcommand never starts the server.
|
|
978
1050
|
assert.equal(seen.env.GENTLE_SHELL_PREFIX, prefix.root);
|
|
979
1051
|
assert.equal(seen.env.GENTLE_PI_CONFIG_HOME, undefined);
|
|
980
|
-
assert.
|
|
1052
|
+
assert.deepEqual([seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], [prefix.guard, DEFAULT_URL]);
|
|
1053
|
+
assert.deepEqual(prefix.serves(), []);
|
|
981
1054
|
for (const name of ["GENTLE_AI_HOME", "GENTLE_AI_PI_CONFIG_DIR", "GENTLE_AI_ENGRAM_BIN", "GENTLE_AI_NO_SELF_UPDATE"]) assert.equal(seen.env[name], undefined, name);
|
|
982
1055
|
// Pi's own npm is ours, configured only from the prefix, through the isolated home's settings.
|
|
983
1056
|
const settings = JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8"));
|