gentle-pi 4.3.0 → 4.4.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 +8 -5
- 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 +101 -151
- 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 +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- 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 +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- 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 +2 -0
- 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 +123 -49
- package/lib/gentle-shell-launcher.ts +9 -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/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-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- 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 +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -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/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- 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 +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- 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 +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -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 +183 -8
- 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-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/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- 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 +99 -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/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-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- 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 +50 -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/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 });
|
|
@@ -235,48 +316,56 @@ const posixHost = process.platform === "win32" ? "the bundled prefix fixture use
|
|
|
235
316
|
const SHELL = JSON.parse(readFileSync(join(repository, "package.json"), "utf8")).version;
|
|
236
317
|
const FIXTURE_ID = `${SHELL}-1.2.0`;
|
|
237
318
|
const FAKE_PI = [
|
|
238
|
-
"import {
|
|
319
|
+
"import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
|
|
239
320
|
"const args = process.argv.slice(2);",
|
|
240
321
|
"if (args[0] === 'install' && args[1] === 'npm:gentle-engram@0.4.2' && process.env.FIXTURE_INSTALL_CAPABILITY !== '0') {",
|
|
241
322
|
" 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
|
|
323
|
+
" writeFileSync(metadata, JSON.stringify({name:'gentle-engram',version:'0.4.2',engram:{startupPolicy:1}}));",
|
|
243
324
|
" const path = join(home,'settings.json'); let settings={}; try {settings=JSON.parse(readFileSync(path,'utf8'));} catch {}",
|
|
244
325
|
" 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
326
|
" 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
327
|
"}",
|
|
247
328
|
"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
329
|
"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
330
|
"\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');",
|
|
331
|
+
"console.log(JSON.stringify({ cli: process.argv[1], args, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));",
|
|
264
332
|
].join("\n");
|
|
333
|
+
const READY_COMPATIBILITY = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
|
|
334
|
+
instance_id: "0123456789abcdef0123456789abcdef", runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
|
|
335
|
+
// The bundled core: `serve` logs its invocation (FIXTURE_SERVE_LOG) and, like
|
|
336
|
+
// Engram 3.5.2, answers the ready /compatibility contract on a port it publishes
|
|
337
|
+
// in FIXTURE_ENGRAM_PORT_FILE, where the redirected default endpoint finds it.
|
|
338
|
+
// FIXTURE_SERVE=exit fails like a lost bind; race fails after starting the
|
|
339
|
+
// server a concurrent launch would. Every other command only prints a line.
|
|
265
340
|
const FIXTURE_CORE = [
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
'
|
|
341
|
+
`#!${process.execPath}`,
|
|
342
|
+
"const { appendFileSync, renameSync, writeFileSync } = require('node:fs'); const { spawn } = require('node:child_process');",
|
|
343
|
+
"const args = process.argv.slice(2);",
|
|
344
|
+
"if (args[0] !== 'serve') { console.log('engram'); process.exit(0); }",
|
|
345
|
+
"const mode = process.env.FIXTURE_SERVE ?? 'ready';",
|
|
346
|
+
"const pick = ['HOME', 'GH_TOKEN', 'GITHUB_TOKEN', 'ENGRAM_NO_UPDATE_CHECK', 'ENGRAM_URL', 'ENGRAM_BIN', 'ENGRAM_HTTP_TOKEN', 'ENGRAM_STARTUP_POLICY'];",
|
|
347
|
+
"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');",
|
|
348
|
+
"if (mode === 'exit') process.exit(1);",
|
|
349
|
+
"if (mode === 'race') { spawn(process.execPath, [process.argv[1], 'serve'], { env: { ...process.env, FIXTURE_SERVE: 'late' }, detached: true, stdio: 'ignore' }).unref(); process.exit(1); }",
|
|
350
|
+
`const ready = ${JSON.stringify(JSON.stringify(READY_COMPATIBILITY))};`,
|
|
351
|
+
"setTimeout(() => require('node:http').createServer((request, response) => {",
|
|
352
|
+
" response.writeHead(request.url === '/compatibility' ? 200 : 404, { 'content-type': 'application/json' }); response.end(request.url === '/compatibility' ? ready : '{}');",
|
|
353
|
+
"}).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); }),",
|
|
354
|
+
" mode === 'late' ? 300 : 0);",
|
|
355
|
+
"setTimeout(() => process.exit(0), 60000);",
|
|
274
356
|
"",
|
|
275
357
|
].join("\n");
|
|
276
358
|
|
|
277
359
|
function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
|
|
278
360
|
const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-shell-bundled-")));
|
|
279
|
-
|
|
361
|
+
const serveLog = join(base, "engram-serve.log");
|
|
362
|
+
const serves = (): Array<{ args: string[]; mode: string; cwd: string; pid: number; env: Record<string, string | undefined> }> =>
|
|
363
|
+
existsSync(serveLog) ? readFileSync(serveLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [];
|
|
364
|
+
// The detached servers outlive their launcher by design: stop them with the fixture.
|
|
365
|
+
t.after(() => {
|
|
366
|
+
for (const { pid } of serves()) try { process.kill(pid); } catch { /* Already exited. */ }
|
|
367
|
+
rmSync(base, { recursive: true, force: true });
|
|
368
|
+
});
|
|
280
369
|
const home = join(base, "home");
|
|
281
370
|
const root = join(home, ".gentle-shell");
|
|
282
371
|
const version = join(root, "versions", FIXTURE_ID);
|
|
@@ -339,33 +428,38 @@ function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
|
|
|
339
428
|
mkdirSync(userBin);
|
|
340
429
|
writeFileSync(join(userBin, "pi"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 });
|
|
341
430
|
// No test contacts the actual default Engram endpoint: connections to 7437
|
|
342
|
-
// are redirected to a
|
|
343
|
-
// server
|
|
431
|
+
// are redirected to a local fixture server (FIXTURE_ENGRAM_PORT), to the
|
|
432
|
+
// server the bundled core's `serve` published (FIXTURE_ENGRAM_PORT_FILE), or
|
|
433
|
+
// else to a closed ephemeral port (refused). FIXTURE_NO_PROBE makes any probe fail the test.
|
|
344
434
|
const network = join(base, "network.mjs");
|
|
345
|
-
|
|
435
|
+
const portFile = join(base, "engram-port");
|
|
436
|
+
writeFileSync(network, `import net from 'node:net'; import { readFileSync } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
|
|
346
437
|
const original = net.createConnection;
|
|
347
438
|
const probe = net.createServer(); await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
|
|
348
439
|
const closed = probe.address().port; await new Promise((resolve) => probe.close(resolve));
|
|
349
440
|
net.createConnection = (options, ...rest) => {
|
|
350
441
|
if (Number(options?.port ?? options) !== 7437) return original(options, ...rest);
|
|
351
442
|
if (process.env.FIXTURE_NO_PROBE === '1') { process.stderr.write('unexpected Engram probe\\n'); process.exit(97); }
|
|
352
|
-
|
|
443
|
+
const served = () => { try { return readFileSync(process.env.FIXTURE_ENGRAM_PORT_FILE, 'utf8'); } catch { return ''; } };
|
|
444
|
+
return original({ ...options, port: Number(process.env.FIXTURE_ENGRAM_PORT || served() || closed) }, ...rest);
|
|
353
445
|
};
|
|
354
446
|
syncBuiltinESMExports();`);
|
|
355
447
|
const metadata = join(home, ".gentle-shell", "agent", "npm", "node_modules", "gentle-engram", "package.json");
|
|
356
448
|
mkdirSync(dirname(metadata), { recursive: true });
|
|
357
449
|
writeFileSync(metadata, JSON.stringify({ name: "gentle-engram", version: "0.4.2", engram: { startupPolicy: 1 } }));
|
|
358
450
|
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"
|
|
451
|
+
NODE_OPTIONS: `--no-warnings --import=${network} --import=${registerHooks}`, npm_config_registry: "https://npm.example",
|
|
452
|
+
FIXTURE_SERVE_LOG: serveLog, FIXTURE_ENGRAM_PORT_FILE: portFile };
|
|
360
453
|
const entry = join(root, "current", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs");
|
|
361
454
|
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") };
|
|
455
|
+
return { root, home, userBin, version, piDir, npmCli, engram, metadata, network, guard, run, env, serves, portFile, entry: join(shell, "bin", "gentle-shell.mjs") };
|
|
363
456
|
}
|
|
364
457
|
|
|
365
458
|
const STORE_ID = "0123456789abcdef0123456789abcdef";
|
|
366
459
|
const OTHER_STORE_ID = "fedcba9876543210fedcba9876543210";
|
|
460
|
+
const DEFAULT_URL = "http://127.0.0.1:7437";
|
|
367
461
|
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];
|
|
462
|
+
const memoryOf = (result: { stdout: string }) => [lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN, lastLaunch(result).env.ENGRAM_URL];
|
|
369
463
|
const provenancePath = (prefix: { root: string }) => join(prefix.root, "config", "engram-provenance.json");
|
|
370
464
|
const sha256File = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
371
465
|
function writeHeader(path: string, userVersion: number, magic = "SQLite format 3\0") {
|
|
@@ -390,164 +484,141 @@ async function engramServer(t: TestContext, routes: Record<string, [number, unkn
|
|
|
390
484
|
return new Promise((resolve) => child.stdout.once("data", (chunk) => resolve(String(chunk).trim())));
|
|
391
485
|
}
|
|
392
486
|
|
|
393
|
-
|
|
487
|
+
// Whether the server a launch started still answers after the launcher exited.
|
|
488
|
+
async function stillServing(prefix: ReturnType<typeof bundledPrefix>): Promise<boolean> {
|
|
489
|
+
const response = await fetch(`http://127.0.0.1:${readFileSync(prefix.portFile, "utf8")}/compatibility`);
|
|
490
|
+
return response.ok && (await response.json()).status === "ready";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
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
494
|
const prefix = bundledPrefix(t);
|
|
395
|
-
const
|
|
396
|
-
const result = prefix.run([], { FIXTURE_PROVISION_ENV: provisioning, GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token" });
|
|
495
|
+
const result = prefix.run([], { GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token", ENGRAM_HTTP_TOKEN: "fixture-http" });
|
|
397
496
|
assert.equal(result.status, 0, result.stderr);
|
|
398
|
-
|
|
399
|
-
assert.
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
assert.deepEqual(
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
assert.deepEqual([
|
|
497
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
|
|
498
|
+
assert.doesNotMatch(result.stderr, /memory disabled/);
|
|
499
|
+
const [serve, ...others] = prefix.serves();
|
|
500
|
+
assert.deepEqual(others, []);
|
|
501
|
+
// Engram's own defaults: no port, data directory or URL chosen for it.
|
|
502
|
+
assert.deepEqual([serve.args, serve.cwd, serve.env.HOME], [["serve"], prefix.home, prefix.home]);
|
|
503
|
+
// S8: no update check, no GitHub tokens and no inherited Engram settings; Pi keeps the user's environment.
|
|
504
|
+
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],
|
|
505
|
+
["1", undefined, undefined, undefined, undefined, undefined]);
|
|
506
|
+
assert.deepEqual([lastLaunch(result).env.GH_TOKEN, lastLaunch(result).env.ENGRAM_NO_UPDATE_CHECK], ["fixture-token", undefined]);
|
|
507
|
+
// Detached: it outlives the launcher and Pi, and the next launch reuses it without starting another.
|
|
508
|
+
assert.equal(await stillServing(prefix), true);
|
|
509
|
+
const second = prefix.run([]);
|
|
510
|
+
assert.equal(second.status, 0, second.stderr);
|
|
511
|
+
assert.deepEqual(memoryOf(second), ["allow", prefix.guard, DEFAULT_URL]);
|
|
512
|
+
assert.equal(prefix.serves().length, 1);
|
|
408
513
|
});
|
|
409
514
|
|
|
410
|
-
test("a
|
|
411
|
-
const prefix = bundledPrefix(t);
|
|
412
|
-
assert.equal(prefix.run([]).status, 0);
|
|
413
|
-
const data = join(prefix.home, ".engram");
|
|
414
|
-
assert.deepEqual(readdirSync(data).sort(), [".instance-id"]);
|
|
415
|
-
assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
|
|
416
|
-
// Another Engram client replaces the shared directory with its own store.
|
|
417
|
-
rmSync(data, { recursive: true });
|
|
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) => {
|
|
515
|
+
test("a server that fails to start or never reports ready disables memory for that launch and Shell still starts Pi", { skip: posixHost }, (t) => {
|
|
426
516
|
const prefix = bundledPrefix(t);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
}
|
|
517
|
+
const result = prefix.run([], { FIXTURE_SERVE: "exit" });
|
|
518
|
+
assert.equal(result.status, 0, result.stderr);
|
|
519
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
520
|
+
assert.match(result.stderr, /memory disabled.*could not start the shared Engram server/s);
|
|
521
|
+
assert.equal(lastLaunch(result).cli.endsWith(join("dist", "bundle", "cli.js")), true);
|
|
522
|
+
assert.equal(prefix.serves().length, 1);
|
|
463
523
|
});
|
|
464
524
|
|
|
465
|
-
|
|
525
|
+
test("concurrent starts converge: a failed bind followed by a ready /compatibility counts as started", { skip: posixHost }, (t) => {
|
|
466
526
|
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
|
-
}
|
|
527
|
+
const result = prefix.run([], { FIXTURE_SERVE: "race" });
|
|
528
|
+
assert.equal(result.status, 0, result.stderr);
|
|
529
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
|
|
530
|
+
assert.doesNotMatch(result.stderr, /memory disabled/);
|
|
531
|
+
assert.deepEqual(prefix.serves().map(({ mode }) => mode), ["race", "late"]);
|
|
532
|
+
});
|
|
476
533
|
|
|
477
|
-
test("bundled
|
|
534
|
+
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
535
|
const prefix = bundledPrefix(t);
|
|
479
|
-
const result = prefix.run([]
|
|
536
|
+
const result = prefix.run([]);
|
|
480
537
|
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);
|
|
538
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
|
|
539
|
+
assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, undefined);
|
|
540
|
+
assert.doesNotMatch(result.stderr, /session-owned/);
|
|
541
|
+
assert.deepEqual(JSON.parse(readFileSync(prefix.metadata, "utf8")).engram, { startupPolicy: 1 });
|
|
487
542
|
});
|
|
488
543
|
|
|
489
|
-
test("
|
|
490
|
-
for (const
|
|
544
|
+
test("an explicit Engram server mode is a selection: kept, never probed or started, memory disabled", { skip: posixHost }, (t) => {
|
|
545
|
+
for (const mode of ["session-owned", "bogus"]) {
|
|
491
546
|
const prefix = bundledPrefix(t);
|
|
492
|
-
const result = prefix.run([], {
|
|
547
|
+
const result = prefix.run([], { ENGRAM_SERVER_MODE: mode, FIXTURE_NO_PROBE: "1" });
|
|
493
548
|
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);
|
|
549
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined], mode);
|
|
550
|
+
assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, mode);
|
|
551
|
+
assert.deepEqual(prefix.serves(), [], mode);
|
|
498
552
|
}
|
|
499
553
|
});
|
|
500
554
|
|
|
501
|
-
test("
|
|
555
|
+
test("a mixed local and npm companion selection is preserved and memory still uses the shared server", { skip: posixHost }, (t) => {
|
|
502
556
|
const prefix = bundledPrefix(t);
|
|
503
557
|
const local = join(prefix.home, "legacy-companion");
|
|
504
558
|
mkdirSync(local);
|
|
505
559
|
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
560
|
const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json");
|
|
508
561
|
const selected = JSON.stringify({ packages: [local, "npm:gentle-engram@0.4.2"] });
|
|
509
562
|
writeFileSync(settings, selected);
|
|
510
|
-
const result = prefix.run([]
|
|
563
|
+
const result = prefix.run([]);
|
|
511
564
|
assert.equal(result.status, 0, result.stderr);
|
|
512
|
-
assert.deepEqual(memoryOf(result), ["
|
|
513
|
-
assert.match(result.stderr, /session-owned.*supporting published/s);
|
|
565
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
|
|
514
566
|
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
567
|
});
|
|
518
568
|
|
|
519
|
-
test("
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
569
|
+
test("an existing store, including one the global CLI created, starts the shared server and Shell never touches it", { skip: posixHost }, (t) => {
|
|
570
|
+
const prefix = bundledPrefix(t);
|
|
571
|
+
const data = join(prefix.home, ".engram");
|
|
572
|
+
mkdirSync(data);
|
|
573
|
+
writeFileSync(join(data, ".instance-id"), `${OTHER_STORE_ID}\n`);
|
|
574
|
+
for (const userVersion of [0, 1]) {
|
|
575
|
+
writeHeader(join(data, "engram.db"), userVersion);
|
|
576
|
+
const before = readFileSync(join(data, "engram.db"));
|
|
577
|
+
const result = prefix.run([]);
|
|
523
578
|
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);
|
|
579
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL], String(userVersion));
|
|
580
|
+
assert.deepEqual(readFileSync(join(data, "engram.db")), before);
|
|
529
581
|
}
|
|
582
|
+
assert.deepEqual(readdirSync(data).sort(), [".instance-id", "engram.db"]);
|
|
583
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
530
584
|
});
|
|
531
585
|
|
|
532
|
-
test("
|
|
586
|
+
test("a store a newer Engram migrated keeps the server stopped and memory disabled", { skip: posixHost }, (t) => {
|
|
533
587
|
const prefix = bundledPrefix(t);
|
|
534
|
-
const
|
|
588
|
+
const data = join(prefix.home, ".engram");
|
|
589
|
+
mkdirSync(data);
|
|
590
|
+
writeHeader(join(data, "engram.db"), 2);
|
|
591
|
+
const result = prefix.run([]);
|
|
535
592
|
assert.equal(result.status, 0, result.stderr);
|
|
536
|
-
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
537
|
-
assert.
|
|
538
|
-
assert.
|
|
593
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
594
|
+
assert.match(result.stderr, /memory disabled.*newer Engram/s);
|
|
595
|
+
assert.deepEqual(prefix.serves(), []);
|
|
539
596
|
});
|
|
540
597
|
|
|
541
|
-
|
|
542
|
-
const prefix =
|
|
543
|
-
|
|
598
|
+
function migrationFixture(t: TestContext, outcome = "success") {
|
|
599
|
+
const prefix = bundledPrefix(t);
|
|
600
|
+
// A store an earlier Shell provisioned and recorded with the bundled core.
|
|
601
|
+
const source = join(prefix.home, ".engram");
|
|
602
|
+
mkdirSync(source);
|
|
603
|
+
writeFileSync(join(source, ".instance-id"), `${STORE_ID}\n`);
|
|
604
|
+
writeHeader(join(source, "engram.db"), 1);
|
|
605
|
+
writeFileSync(join(source, "config.json"), '{"preserved":true}\n');
|
|
606
|
+
mkdirSync(dirname(provenancePath(prefix)), { recursive: true });
|
|
607
|
+
writeFileSync(provenancePath(prefix), `${JSON.stringify({ schema: 1, dataDir: source, instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) }, null, 2)}\n`);
|
|
608
|
+
const originalRecord = readFileSync(provenancePath(prefix), "utf8");
|
|
609
|
+
const sourceBytes = readFileSync(join(source, "engram.db"));
|
|
610
|
+
const invocation = join(prefix.home, "migration-invoked");
|
|
611
|
+
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 });
|
|
612
|
+
return { ...prefix, source, originalRecord, sourceBytes, invocation };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
test("an explicit Engram socket remains untouched and is never probed", { skip: posixHost }, (t) => {
|
|
616
|
+
const prefix = bundledPrefix(t);
|
|
617
|
+
const result = prefix.run([], { ENGRAM_SOCKET: "/fixture/selected.sock", FIXTURE_NO_PROBE: "1" });
|
|
544
618
|
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);
|
|
619
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
620
|
+
assert.equal(lastLaunch(result).env.ENGRAM_SOCKET, "/fixture/selected.sock");
|
|
621
|
+
assert.deepEqual(prefix.serves(), []);
|
|
551
622
|
});
|
|
552
623
|
|
|
553
624
|
test("memory migrate explicitly promotes a verified copy and retains the original store", { skip: posixHost }, (t) => {
|
|
@@ -568,7 +639,6 @@ test("memory migrate explicitly promotes a verified copy and retains the origina
|
|
|
568
639
|
assert.equal(invoked.token, undefined);
|
|
569
640
|
assert.equal(invoked.cloud, undefined);
|
|
570
641
|
assert.equal(existsSync(join(prefix.root, "config", "engram-migration.json")), false);
|
|
571
|
-
assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
|
|
572
642
|
});
|
|
573
643
|
|
|
574
644
|
test("memory migrate rejects missing confirmation and malformed flags without invoking Engram", { skip: posixHost }, (t) => {
|
|
@@ -608,18 +678,17 @@ test("memory migrate refuses provider failure and mismatched evidence without ch
|
|
|
608
678
|
}
|
|
609
679
|
});
|
|
610
680
|
|
|
611
|
-
test("an interrupted memory promotion
|
|
681
|
+
test("an interrupted memory promotion keeps the server stopped until recovery", { skip: posixHost }, (t) => {
|
|
612
682
|
const prefix = bundledPrefix(t);
|
|
613
683
|
mkdirSync(join(prefix.root, "config"), { recursive: true });
|
|
614
684
|
const marker = join(prefix.root, "config", "engram-migration.json");
|
|
615
685
|
writeFileSync(marker, '{"interrupted":true}\n');
|
|
616
686
|
const result = prefix.run([], { FIXTURE_NO_PROBE: "1" });
|
|
617
687
|
assert.equal(result.status, 0, result.stderr);
|
|
618
|
-
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
688
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
619
689
|
assert.match(result.stderr, /incomplete memory migration/);
|
|
620
|
-
assert.
|
|
690
|
+
assert.deepEqual(prefix.serves(), []);
|
|
621
691
|
assert.equal(readFileSync(marker, "utf8"), '{"interrupted":true}\n');
|
|
622
|
-
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
623
692
|
});
|
|
624
693
|
|
|
625
694
|
test("public help explains compatibility evidence without promising update recovery", { skip: posixHost }, (t) => {
|
|
@@ -629,41 +698,21 @@ test("public help explains compatibility evidence without promising update recov
|
|
|
629
698
|
assert.equal(result.stderr, "");
|
|
630
699
|
assert.match(result.stdout, /Matching versions or updating alone do not prove compatibility/);
|
|
631
700
|
assert.match(result.stdout, /\/compatibility/);
|
|
632
|
-
assert.
|
|
701
|
+
assert.match(result.stdout, /shares the global Engram server.*nothing listens.*engram serve/s);
|
|
702
|
+
assert.doesNotMatch(result.stdout, /update Engram, restart|Shell-provisioned|session-owned/);
|
|
633
703
|
});
|
|
634
704
|
|
|
635
|
-
test("a
|
|
705
|
+
test("a listener without the ready contract is never replaced, even reporting the bundled core version", { skip: posixHost }, async (t) => {
|
|
636
706
|
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 });
|
|
707
|
+
const port = await engramServer(t, { "/health": [200, { status: "ok", instance_id: STORE_ID, version: engramPinVersion }] });
|
|
708
|
+
const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
|
|
642
709
|
assert.equal(result.status, 0, result.stderr);
|
|
643
|
-
assert.deepEqual(
|
|
644
|
-
assert.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
test("a listening server is reused for Shell's store only with the recorded /health instance and the exact core version", { skip: posixHost }, async (t) => {
|
|
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
|
-
}
|
|
710
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
711
|
+
assert.match(result.stderr, /memory disabled.*could not verify/s);
|
|
712
|
+
assert.deepEqual(prefix.serves(), []);
|
|
664
713
|
});
|
|
665
714
|
|
|
666
|
-
test("a verified /compatibility server is reused through an explicit URL and the guard
|
|
715
|
+
test("a verified /compatibility server is reused unchanged through an explicit URL and the guard", { skip: posixHost }, async (t) => {
|
|
667
716
|
const ready = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
|
|
668
717
|
instance_id: STORE_ID, runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
|
|
669
718
|
const prefix = bundledPrefix(t);
|
|
@@ -675,11 +724,12 @@ test("a verified /compatibility server is reused through an explicit URL and the
|
|
|
675
724
|
assert.equal(result.status, 0, result.stderr);
|
|
676
725
|
const seen = lastLaunch(result);
|
|
677
726
|
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.
|
|
727
|
+
assert.deepEqual(prefix.serves(), []);
|
|
679
728
|
const unready = bundledPrefix(t);
|
|
680
729
|
mkdirSync(join(unready.home, ".engram"));
|
|
681
730
|
const notReady = await engramServer(t, { "/compatibility": [200, { ...ready, status: "unknown", active_reuse_authorized: false }] });
|
|
682
731
|
assert.equal(lastLaunch(unready.run([], { FIXTURE_ENGRAM_PORT: notReady })).env.ENGRAM_STARTUP_POLICY, "disabled");
|
|
732
|
+
assert.deepEqual(unready.serves(), []);
|
|
683
733
|
});
|
|
684
734
|
|
|
685
735
|
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 +746,10 @@ test("a foreign store behind a 3.3.2 server stays guarded and the launch names t
|
|
|
696
746
|
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
|
|
697
747
|
assert.match(result.stderr, /memory disabled.*reports version 3\.3\.2.*update that server to 3\.4\.0 or later/s);
|
|
698
748
|
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
|
-
}
|
|
749
|
+
assert.deepEqual(prefix.serves(), []);
|
|
724
750
|
});
|
|
725
751
|
|
|
726
|
-
test("--version and forwarded Pi version or help flags never probe Engram, install the companion or print memory notices", { skip: posixHost }, (t) => {
|
|
752
|
+
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
753
|
const prefix = bundledPrefix(t);
|
|
728
754
|
writeFileSync(prefix.metadata, '{"name":"gentle-engram","version":"0.4.1"}');
|
|
729
755
|
mkdirSync(join(prefix.home, ".engram"));
|
|
@@ -737,7 +763,7 @@ test("--version and forwarded Pi version or help flags never probe Engram, insta
|
|
|
737
763
|
assert.equal(readFileSync(settings, "utf8"), before, args.join(" "));
|
|
738
764
|
assert.equal(JSON.parse(readFileSync(prefix.metadata, "utf8")).version, "0.4.1", args.join(" "));
|
|
739
765
|
}
|
|
740
|
-
assert.equal(
|
|
766
|
+
assert.equal(prefix.serves().length, 1);
|
|
741
767
|
});
|
|
742
768
|
|
|
743
769
|
test("-v or --help as a prompt or an option value never switches memory off", { skip: posixHost }, (t) => {
|
|
@@ -745,8 +771,8 @@ test("-v or --help as a prompt or an option value never switches memory off", {
|
|
|
745
771
|
const prefix = bundledPrefix(t);
|
|
746
772
|
const result = prefix.run(args);
|
|
747
773
|
assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`);
|
|
748
|
-
assert.deepEqual(memoryOf(result), ["allow", prefix.
|
|
749
|
-
assert.equal(
|
|
774
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL], args.join(" "));
|
|
775
|
+
assert.equal(prefix.serves().length, 1, args.join(" "));
|
|
750
776
|
}
|
|
751
777
|
});
|
|
752
778
|
test("a missing bundled core uses the guard, never an engram on PATH", { skip: posixHost }, (t) => {
|
|
@@ -771,20 +797,23 @@ function countingGentleAi(prefix: ReturnType<typeof bundledPrefix>) {
|
|
|
771
797
|
return { bin, runs: () => (existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean).length : 0) };
|
|
772
798
|
}
|
|
773
799
|
|
|
774
|
-
test("setup on a fresh machine publishes the bundled core; the next launch
|
|
800
|
+
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
801
|
const prefix = bundledPrefix(t, { core: false });
|
|
776
802
|
const installer = countingGentleAi(prefix);
|
|
777
803
|
const setup = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: installer.bin });
|
|
778
804
|
assert.equal(setup.status, 0, setup.stderr);
|
|
779
805
|
assert.equal(installer.runs(), 1);
|
|
780
806
|
assert.equal(existsSync(prefix.engram), true);
|
|
781
|
-
|
|
807
|
+
// Setup itself never starts a server.
|
|
808
|
+
assert.deepEqual(prefix.serves(), []);
|
|
809
|
+
const first = prefix.run([]);
|
|
782
810
|
assert.equal(first.status, 0, first.stderr);
|
|
783
|
-
assert.deepEqual(
|
|
784
|
-
assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
|
|
811
|
+
assert.deepEqual(memoryOf(first), ["allow", prefix.guard, DEFAULT_URL]);
|
|
785
812
|
const second = prefix.run([]);
|
|
786
813
|
assert.equal(second.status, 0, second.stderr);
|
|
787
|
-
assert.deepEqual(
|
|
814
|
+
assert.deepEqual(memoryOf(second), ["allow", prefix.guard, DEFAULT_URL]);
|
|
815
|
+
assert.equal(prefix.serves().length, 1);
|
|
816
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
788
817
|
});
|
|
789
818
|
|
|
790
819
|
test("an explicit ENGRAM_URL alone keeps the verified core, or the guard without it, as ENGRAM_BIN", { skip: posixHost }, (t) => {
|
|
@@ -808,12 +837,13 @@ test("a failed companion check after setup is a warning, not a setup failure", {
|
|
|
808
837
|
assert.equal(setup.status, 0, setup.stderr);
|
|
809
838
|
assert.equal(installer.runs(), 1);
|
|
810
839
|
assert.match(setup.stderr, /startupPolicy/);
|
|
811
|
-
|
|
840
|
+
// The guard, not the companion check, protects memory: the shared server is still reused.
|
|
841
|
+
assert.deepEqual(memoryOf(prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" })), ["allow", prefix.guard, DEFAULT_URL]);
|
|
812
842
|
assert.deepEqual(readdirSync(data), ["engram.db"]);
|
|
813
843
|
assert.equal(readFileSync(join(data, "engram.db"), "utf8"), "untouched database sentinel");
|
|
814
844
|
});
|
|
815
845
|
|
|
816
|
-
test("setup with
|
|
846
|
+
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
847
|
const prefix = bundledPrefix(t, { core: false });
|
|
818
848
|
const data = join(prefix.home, ".engram");
|
|
819
849
|
mkdirSync(data);
|
|
@@ -823,13 +853,11 @@ test("setup with a foreign existing store runs the installer like the baseline,
|
|
|
823
853
|
assert.equal(setup.status, 0, setup.stderr);
|
|
824
854
|
assert.equal(installer.runs(), 1);
|
|
825
855
|
assert.equal(existsSync(prefix.engram), true);
|
|
826
|
-
assert.
|
|
827
|
-
assert.doesNotMatch(setup.stderr, /update Engram and restart/);
|
|
828
|
-
assert.doesNotMatch(setup.stderr, /deferred/);
|
|
856
|
+
assert.doesNotMatch(setup.stderr, /memory disabled|deferred/);
|
|
829
857
|
const guarded = prefix.run([]);
|
|
830
858
|
assert.equal(guarded.status, 0, guarded.stderr);
|
|
831
859
|
const env = lastLaunch(guarded).env;
|
|
832
|
-
assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["
|
|
860
|
+
assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["allow", prefix.guard, DEFAULT_URL]);
|
|
833
861
|
// Subagents receive the same environment (GENTLE_PI_AGENTS_PI runs the same Pi with it).
|
|
834
862
|
assert.equal(env.GENTLE_PI_AGENTS_PI.endsWith(join("dist", "bundle", "cli.js")), true);
|
|
835
863
|
const provider = spawnSync(env.ENGRAM_BIN, ["instance-id"], { env: { ...prefix.env, ...env }, encoding: "utf8" });
|
|
@@ -844,15 +872,14 @@ test("setup with a foreign existing store runs the installer like the baseline,
|
|
|
844
872
|
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
845
873
|
});
|
|
846
874
|
|
|
847
|
-
test("a gentle-pi version change
|
|
875
|
+
test("a gentle-pi version change reruns setup once and keeps memory on the shared server", { skip: posixHost }, (t) => {
|
|
848
876
|
const prefix = bundledPrefix(t, { core: false });
|
|
849
877
|
const installer = countingGentleAi(prefix);
|
|
850
878
|
const auto = { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: installer.bin };
|
|
851
|
-
const first = prefix.run([],
|
|
879
|
+
const first = prefix.run([], auto);
|
|
852
880
|
assert.equal(first.status, 0, first.stderr);
|
|
853
881
|
assert.equal(installer.runs(), 1);
|
|
854
|
-
assert.deepEqual(
|
|
855
|
-
assert.equal(existsSync(provenancePath(prefix)), true);
|
|
882
|
+
assert.deepEqual(memoryOf(first), ["allow", prefix.guard, DEFAULT_URL]);
|
|
856
883
|
assert.equal(prefix.run([], auto).status, 0);
|
|
857
884
|
assert.equal(installer.runs(), 1);
|
|
858
885
|
const manifest = join(dirname(dirname(prefix.entry)), "package.json");
|
|
@@ -861,7 +888,8 @@ test("a gentle-pi version change re-provisions Shell's own store once and keeps
|
|
|
861
888
|
assert.equal(changed.status, 0, changed.stderr);
|
|
862
889
|
assert.equal(installer.runs(), 2);
|
|
863
890
|
assert.doesNotMatch(changed.stderr, /automatic setup failed|deferred/);
|
|
864
|
-
assert.deepEqual(
|
|
891
|
+
assert.deepEqual(memoryOf(changed), ["allow", prefix.guard, DEFAULT_URL]);
|
|
892
|
+
assert.equal(prefix.serves().length, 1);
|
|
865
893
|
assert.equal(prefix.run([], auto).status, 0);
|
|
866
894
|
assert.equal(installer.runs(), 2);
|
|
867
895
|
});
|
|
@@ -882,9 +910,7 @@ test("capability readback also requires Pi to declare the selected pinned compan
|
|
|
882
910
|
const result = prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "metadata-only" });
|
|
883
911
|
assert.equal(result.status, 0, result.stderr);
|
|
884
912
|
assert.match(result.stderr, /startupPolicy/);
|
|
885
|
-
|
|
886
|
-
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
887
|
-
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
913
|
+
assert.equal(lastLaunch(result).env.ENGRAM_BIN, prefix.guard);
|
|
888
914
|
});
|
|
889
915
|
|
|
890
916
|
test("pinning preserves resource filters and unrelated settings and does not reinstall next launch", { skip: posixHost }, (t) => {
|
|
@@ -900,16 +926,19 @@ test("pinning preserves resource filters and unrelated settings and does not rei
|
|
|
900
926
|
assert.doesNotMatch(prefix.run([]).stderr, /installing policy-capable companion/);
|
|
901
927
|
});
|
|
902
928
|
|
|
903
|
-
test("final startup rechecks
|
|
929
|
+
test("final startup rechecks a store a newer Engram migrated during automatic setup and never starts a server on it", { skip: posixHost }, (t) => {
|
|
904
930
|
const prefix = bundledPrefix(t);
|
|
905
931
|
const fake = join(prefix.userBin, "gentle-ai");
|
|
906
|
-
|
|
932
|
+
const db = join(prefix.home, ".engram", "engram.db");
|
|
933
|
+
writeFileSync(fake, `#!${process.execPath}\nconst { mkdirSync, writeFileSync } = require('node:fs');\nmkdirSync(${JSON.stringify(dirname(db))}, { recursive: true });\n` +
|
|
934
|
+
`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` +
|
|
935
|
+
`writeFileSync(${JSON.stringify(db)}, header);\n`, { mode: 0o755 });
|
|
907
936
|
const result = prefix.run([], { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: fake });
|
|
908
937
|
assert.equal(result.status, 0, result.stderr);
|
|
909
|
-
|
|
910
|
-
assert.deepEqual(
|
|
911
|
-
assert.
|
|
912
|
-
assert.
|
|
938
|
+
assert.equal(existsSync(db), true);
|
|
939
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
|
|
940
|
+
assert.match(result.stderr, /memory disabled.*newer Engram/s);
|
|
941
|
+
assert.deepEqual(prefix.serves(), []);
|
|
913
942
|
});
|
|
914
943
|
|
|
915
944
|
test("explicit endpoints and invalid policies never trigger availability probes or override executables", { skip: posixHost }, (t) => {
|
|
@@ -923,6 +952,7 @@ test("explicit endpoints and invalid policies never trigger availability probes
|
|
|
923
952
|
const policyOnly = prefix.run([], { FIXTURE_NO_PROBE: "1", ENGRAM_STARTUP_POLICY: "invalid" });
|
|
924
953
|
assert.equal(policyOnly.status, 0, policyOnly.stderr);
|
|
925
954
|
assert.equal(lastLaunch(policyOnly).env.ENGRAM_BIN, prefix.guard);
|
|
955
|
+
assert.deepEqual(prefix.serves(), []);
|
|
926
956
|
});
|
|
927
957
|
|
|
928
958
|
test("startup offer precedes Pi, not-now preserves config and omit suppresses just that release", { skip: posixHost }, (t) => {
|
|
@@ -939,15 +969,36 @@ test("startup offer precedes Pi, not-now preserves config and omit suppresses ju
|
|
|
939
969
|
env: { ...prefix.env, NODE_OPTIONS: `${prefix.env.NODE_OPTIONS} --import=data:text/javascript,${encodeURIComponent(preload(latest))}` },
|
|
940
970
|
encoding: "utf8", input, timeout: 10000,
|
|
941
971
|
});
|
|
972
|
+
// A recorded lookup answers the next launch without the network, so these
|
|
973
|
+
// runs simulate later days by aging the cache back past the TTL; the live
|
|
974
|
+
// lookup then records its own answer again.
|
|
975
|
+
const ageCache = () => {
|
|
976
|
+
const parsed = JSON.parse(readFileSync(config, "utf8"));
|
|
977
|
+
const latest = typeof parsed.startupReleaseCheck?.latest === "string" ? parsed.startupReleaseCheck.latest : "999.0.0";
|
|
978
|
+
parsed.startupReleaseCheck = { checkedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), latest };
|
|
979
|
+
writeFileSync(config, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
980
|
+
};
|
|
942
981
|
const skipped = run("999.0.0", "2\r");
|
|
943
982
|
assert.equal(skipped.status, 0, skipped.stderr);
|
|
944
983
|
assert.match(skipped.stdout, /Gentle Shell update available/);
|
|
945
984
|
assert.ok(skipped.stdout.indexOf("Gentle Shell update available") < skipped.stdout.indexOf('"cli":'));
|
|
946
|
-
|
|
985
|
+
// A not-now choice still records no decision, but the live lookup it paid
|
|
986
|
+
// for lands in the config as the cache tomorrow's launch reuses.
|
|
987
|
+
const afterSkipped = JSON.parse(readFileSync(config, "utf8"));
|
|
988
|
+
assert.equal(afterSkipped.other, "preserved");
|
|
989
|
+
assert.equal(afterSkipped.skippedStartupRelease, undefined);
|
|
990
|
+
assert.equal(afterSkipped.startupReleaseCheck.latest, "999.0.0");
|
|
991
|
+
assert.ok(!Number.isNaN(Date.parse(afterSkipped.startupReleaseCheck.checkedAt)));
|
|
992
|
+
ageCache();
|
|
947
993
|
const omitted = run("999.0.0", "3\r");
|
|
948
994
|
assert.equal(omitted.status, 0, omitted.stderr);
|
|
949
|
-
|
|
995
|
+
const afterOmit = JSON.parse(readFileSync(config, "utf8"));
|
|
996
|
+
assert.equal(afterOmit.other, "preserved");
|
|
997
|
+
assert.equal(afterOmit.skippedStartupRelease, "999.0.0");
|
|
998
|
+
assert.equal(afterOmit.startupReleaseCheck.latest, "999.0.0");
|
|
999
|
+
ageCache();
|
|
950
1000
|
assert.doesNotMatch(run("999.0.0", "").stdout, /Gentle Shell update available/);
|
|
1001
|
+
ageCache();
|
|
951
1002
|
assert.match(run("999.0.1", "2\r").stdout, /Gentle Shell update available/);
|
|
952
1003
|
const command = run("999.0.1", "", ["list"]);
|
|
953
1004
|
assert.equal(command.status, 0, command.stderr);
|
|
@@ -973,11 +1024,13 @@ test("the bundled launcher runs only its version's Pi, with the user's environme
|
|
|
973
1024
|
assert.equal(seen.env.NODE_OPTIONS, prefix.env.NODE_OPTIONS);
|
|
974
1025
|
assert.equal(seen.env.npm_config_registry, "https://npm.example");
|
|
975
1026
|
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.
|
|
1027
|
+
// S9: gentle-pi's config folder inside the prefix, the shared server's URL
|
|
1028
|
+
// behind the guard for gentle-engram, and none of Gentle AI's own variables.
|
|
1029
|
+
// A Pi subcommand never starts the server.
|
|
978
1030
|
assert.equal(seen.env.GENTLE_SHELL_PREFIX, prefix.root);
|
|
979
1031
|
assert.equal(seen.env.GENTLE_PI_CONFIG_HOME, undefined);
|
|
980
|
-
assert.
|
|
1032
|
+
assert.deepEqual([seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], [prefix.guard, DEFAULT_URL]);
|
|
1033
|
+
assert.deepEqual(prefix.serves(), []);
|
|
981
1034
|
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
1035
|
// Pi's own npm is ours, configured only from the prefix, through the isolated home's settings.
|
|
983
1036
|
const settings = JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8"));
|