@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +49 -1
  3. package/package.json +4 -4
  4. package/src/environments/js-environments.ts +208 -0
  5. package/src/environments/js-installer.ts +268 -0
  6. package/src/environments/js-revision-files.ts +91 -0
  7. package/src/environments/no-links.ts +132 -0
  8. package/src/environments/project-resolves.ts +54 -0
  9. package/src/environments/revision-store.ts +20 -4
  10. package/src/extension/runtime-factory.ts +9 -0
  11. package/src/index.ts +1 -0
  12. package/src/kernels/js/cell-dispatch.ts +44 -0
  13. package/src/kernels/js/context-manager.ts +23 -27
  14. package/src/kernels/js/host-entries.ts +62 -0
  15. package/src/kernels/js/local-module-loader.ts +17 -6
  16. package/src/kernels/js/worker-core.js +2 -0
  17. package/src/kernels/js/worker-package-resolve.js +153 -0
  18. package/src/kernels/py/host-cell.ts +8 -31
  19. package/src/kernels/sandbox/sandbox-cell.ts +116 -0
  20. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  21. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  22. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  23. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  24. package/src/kernels/shared/host-cell.ts +57 -0
  25. package/src/tool/cell-handler.ts +9 -11
  26. package/src/tool/eval-request.ts +23 -1
  27. package/src/tool/eval-tool-options.ts +3 -0
  28. package/src/tool/eval-tool.ts +16 -4
  29. package/src/tool/load-cell.ts +18 -3
  30. package/src/tool/magic-cell-host.ts +60 -2
  31. package/src/tool/magic-cells.ts +12 -2
  32. package/src/tool/run-eval-cell.ts +30 -3
  33. package/src/tool/types.ts +21 -1
package/CHANGELOG.md CHANGED
@@ -12,6 +12,28 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.10] - 2026-10-05
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - Isolated eval cells: with `sandbox.enabled`, `isolate: true` runs a JavaScript cell in a fresh QuickJS VM with no persistence and no ambient host (only `tools.*`, `print`, `display`), streaming output under a credit window. Off by default; the eval schema is unchanged until the setting is on.
22
+ - A JavaScript cell that is only `%bun add <package ...>` or `%npm add <package ...>` installs packages into a per-session managed environment without restarting the kernel; the next cell imports them by bare name, the project's `package.json` and `node_modules` are untouched, lifecycle scripts never run, and a failed or cancelled install leaves the previous packages active ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
23
+
24
+ ### Changed
25
+
26
+ ### Fixed
27
+
28
+ - An isolated (`isolate: true`) cell whose QuickJS runtime is missing now fails with `eval_isolate_unavailable` before any of its code runs, instead of a module-resolution error that named a host path ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
29
+
30
+ - `%bun add` over a package first installed from a local directory now works when the new archive's top directory is not `package/` (a GitHub-style `<repo>-<sha>.tgz`, or a plain `.tar`): its name is read from the archive's own top-level directory, so the old directory's links are removed before bun installs ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
31
+
32
+ - A nested tool call that is denied inside an `eval` cell (a permission denial or another hook's block) now reaches the cell as the plain denial, without the `Expected parameters:` schema hint that made it read like an argument error; argument failures still get the hint ([#2700](https://github.com/code-yeongyu/senpi/issues/2700)). Thanks to @MoerAI ([#2755](https://github.com/code-yeongyu/senpi/pull/2755)).
33
+ - An `eval` run with an invalid `language` value (for example `"python"`, `""` or `null`) now gets its own error listing the enabled languages, instead of the "run requires language" message meant for an omitted one; `peek` and `stop` still need no language ([#1395](https://github.com/code-yeongyu/senpi/issues/1395)). Thanks to @MoerAI.
34
+
35
+ ### Removed
36
+
15
37
  ## [2026.10.9] - 2026-10-04
16
38
 
17
39
  ### Breaking Changes
package/README.md CHANGED
@@ -67,6 +67,12 @@ Windows fresh-cache bootstrap p99 (30 samples). A stalled or failed start names
67
67
  the last stage and retains the interpreter's diagnostic error. The low-level
68
68
  `PythonKernel.start({ startupTimeoutMs })` override applies per stage.
69
69
 
70
+ Every `eval` run must explicitly select an enabled `language` (`js`, `py`, `rb`,
71
+ or `jl`); there is no default kernel, even when only one language is enabled.
72
+ Omitting `action` means `run`, so it also requires `language`. Control requests
73
+ with `action: "peek"` or `action: "stop"` use `cell_id` and do not require a
74
+ language.
75
+
70
76
  ### Session environment
71
77
 
72
78
  Every kernel starts with the active session's `PI_*` environment — `PI_SESSION_ID`,
@@ -117,6 +123,35 @@ cannot be read fails the cell with a message naming the path as written
117
123
  (`file not found: <path>`, `permission denied: <path>`); files over 8 MiB, and
118
124
  anything that is not a regular file, are refused.
119
125
 
126
+ ### JavaScript packages
127
+
128
+ A JavaScript cell whose only line is `%bun add <package ...>` or `%npm add <package ...>`
129
+ installs packages without restarting the kernel; the next cell imports them by bare name.
130
+ `%bun add` uses bun and `%npm add` uses npm; `environments.js.installer` (`bun`, `npm` or
131
+ `auto`: bun when it is on `PATH`, else npm) applies only where the magic names none, and the
132
+ kernel's own runtime never changes to get one.
133
+
134
+ In the default managed mode packages go into an environment revision under the session's
135
+ artifacts directory, never into the project's `package.json` or `node_modules`. With
136
+ `environments.managedRoot` set, every session using that root shares one revision chain: their
137
+ installs are serialized and each revision carries the packages installed before it.
138
+ A bare import resolves from the session directory's `node_modules` first and then from the
139
+ active revision, using each package's `exports` for `import` (ESM-only packages load), so the
140
+ project's own packages still win, and both win over Senpi's own dependencies (a package neither has may still load from Senpi's install). When a
141
+ newly added package is also in the project's `node_modules`, the result says
142
+ `environment_resolution_conflict` because the project copy resolves first.
143
+
144
+ Each install builds a new revision that carries the previous one forward (its `package.json`,
145
+ lockfile, and from `.npmrc` only `registry`, `@scope:registry`, `strict-ssl`, `ca` and `cafile`, rewritten as a regular file; credentials and every other key are dropped, and `bunfig.toml` is not carried)
146
+ and is published only when the installer succeeds; a failed or cancelled install, or one still
147
+ running when the session closes, leaves the previous revision active. Lifecycle scripts never run
148
+ (`--ignore-scripts`), installer flags are refused because the host picks the destination, and a
149
+ path-like package (`./pkg`, `file:../pkg`) is a path from the session directory. Package names
150
+ are split on whitespace; quoting is not supported. Error and installer text names the session directory as
151
+ `<cwd>`, the revision as `<root>`, the temp directory as `<tmp>`, the home directory as `~`, and an
152
+ absolute package path by its file name only. `%environment project` installs
153
+ into the session directory itself instead.
154
+
120
155
  ### Python packages
121
156
 
122
157
  A Python cell whose only line is `%pip install <requirements>` installs packages
@@ -145,6 +180,19 @@ that project, with concurrent installs serialised by a lock. Setting
145
180
  of `environment_install_failed`, `environment_install_cancelled`,
146
181
  `environment_installer_unavailable` or `environment_resolution_conflict`.
147
182
 
183
+ ### Isolated cells
184
+
185
+ With `sandbox.enabled: true`, a JavaScript call may pass `isolate: true`. The cell then runs in a fresh QuickJS VM (the vendored pi codemode runtime) instead of the persistent kernel:
186
+
187
+ - Nothing persists: globals from the kernel are not visible, and a global set in one isolated cell is gone in the next. `store()` throws `eval_isolate_no_state` and `load()` returns `undefined`.
188
+ - No ambient host: there is no `process`, `require`, file system, network, child processes or timers. The cell's only reach is `tools.<name>` (also `tool.<name>`; every active tool except `eval`, run through the same permission hooks as a direct call) plus `print`, `display`, `text` and `image`.
189
+ - `isolate: true` isolates the JavaScript, not the tools it calls: a tool keeps its normal reach. `tools.bash`, for example, runs a shell with the session's environment and files, under the same permission hooks as any other call.
190
+ - Output streams from the VM in frames under a 256 KiB credit window, so the VM never holds a large item; the host joins an item's frames before adding it to the result, and memory stays bounded by `sandbox.memoryMb`.
191
+ - It is a host entry in the JavaScript queue: it waits for earlier cells, Stop and the run budget end it, and the persistent kernel is never restarted for it.
192
+ - Limits: `sandbox.memoryMb` (default 64) and `sandbox.timeoutSeconds` (default 300). Failures are named: `eval_isolate_memory_limit`, `eval_isolate_timeout`, `eval_isolate_unresolved_promise` (a top-level promise that can never settle) and `eval_isolate_unavailable` (the runtime could not start).
193
+
194
+ While `sandbox.enabled` is `false` (the default) the `isolate` field is absent from the eval schema, and passing it is refused with `eval_isolate_invalid`, as are `isolate` with another language and `isolate` with `reset`.
195
+
148
196
  ## Settings
149
197
 
150
198
  Configuration is loaded in this order:
@@ -218,7 +266,7 @@ Configuration is loaded in this order:
218
266
  | `prompt.advertiseHelpers` | `false` | When `true`, one pointer line to `tool_schema('eval:helpers')` is appended to the eval description. |
219
267
  | `kernelTools.enabled` | `true` | Allows cells to define kernel tools (`tool(fn)`, `@tool`). `false` makes them refuse with `tools_unavailable`. |
220
268
 
221
- The `languages.pyInterpreter`, `environments.*`, `isolation.*`, `sandbox.*`, `prompt.*` and `kernelTools.*` keys are accepted and validated now, with the defaults shown, which match today's behaviour. The effect each of those rows describes takes effect when its feature ships; until then, setting a key changes nothing.
269
+ The `languages.pyInterpreter`, `environments.*`, `isolation.*`, `prompt.*` and `kernelTools.*` keys are accepted and validated now, with the defaults shown, which match today's behaviour. The effect each of those rows describes takes effect when its feature ships; until then, setting a key changes nothing.
222
270
 
223
271
  `SENPI_CODEMODE_PY`, `SENPI_CODEMODE_JS`, `SENPI_CODEMODE_RB`, and
224
272
  `SENPI_CODEMODE_JL` override the corresponding file setting. `1` or `true`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.9",
3
+ "version": "2026.10.10",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -32,15 +32,15 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@babel/parser": "8.0.4",
35
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.9",
35
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10",
36
36
  "quickjs-wasi": "3.6.2",
37
37
  "typebox": "1.3.34"
38
38
  },
39
39
  "peerDependencies": {
40
- "@code-yeongyu/senpi": "2026.10.9"
40
+ "@code-yeongyu/senpi": "2026.10.10"
41
41
  },
42
42
  "devDependencies": {
43
- "@code-yeongyu/senpi": "2026.10.9"
43
+ "@code-yeongyu/senpi": "2026.10.10"
44
44
  },
45
45
  "keywords": [
46
46
  "senpi",
@@ -0,0 +1,208 @@
1
+ import { mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
2
+ import { isAbsolute, join } from "node:path";
3
+ import type { ResolvedCodemodeSettings } from "../config/settings.ts";
4
+ import { withRootLock } from "./install-lock.ts";
5
+ import {
6
+ absoluteSpec,
7
+ type JsInstallerChoice,
8
+ parseJsPackages,
9
+ requestedPackageName,
10
+ resolveJsInstaller,
11
+ runJsInstall,
12
+ withoutHostPaths,
13
+ } from "./js-installer.ts";
14
+ import { carryNpmrcSettings, recordedFileSpecs, seedPackageJson } from "./js-revision-files.ts";
15
+ import { assertBuiltRevisionLinks, assertNoLinksBelow, recordedInstallSources } from "./no-links.ts";
16
+ import { projectResolves } from "./project-resolves.ts";
17
+ import type { EnvironmentMode } from "./py-environment.ts";
18
+ import { EnvironmentError } from "./py-installer.ts";
19
+ import { mkdirPrivate, publishNextRevision, readActiveRevision } from "./revision-store.ts";
20
+
21
+ /** The paths in a revision that the installer writes. */
22
+ const INSTALLER_WRITES = [
23
+ "package.json",
24
+ "package-lock.json",
25
+ "bun.lock",
26
+ "bun.lockb",
27
+ "node_modules",
28
+ ".npmrc",
29
+ "bunfig.toml",
30
+ ];
31
+
32
+ export interface JsInstallReceipt {
33
+ readonly installer: "bun" | "npm";
34
+ readonly mode: EnvironmentMode;
35
+ readonly revision: number | undefined;
36
+ readonly added: readonly string[];
37
+ readonly shadowed: readonly string[];
38
+ }
39
+
40
+ export interface JsEnvironmentsOptions {
41
+ readonly artifactsDir: string;
42
+ readonly cwd: string;
43
+ readonly runtime: string;
44
+ readonly env: NodeJS.ProcessEnv;
45
+ readonly settings: Pick<ResolvedCodemodeSettings, "environments">;
46
+ }
47
+
48
+ export class JsEnvironments {
49
+ readonly #options: JsEnvironmentsOptions;
50
+ #mode: EnvironmentMode = "managed";
51
+ #packageRoot: string | undefined;
52
+
53
+ constructor(options: JsEnvironmentsOptions) {
54
+ this.#options = options;
55
+ }
56
+
57
+ get mode(): EnvironmentMode {
58
+ return this.#mode;
59
+ }
60
+
61
+ get packageRoot(): string | undefined {
62
+ return this.#mode === "managed" ? this.#packageRoot : undefined;
63
+ }
64
+
65
+ async setMode(mode: EnvironmentMode): Promise<string> {
66
+ this.#mode = mode;
67
+ if (mode === "project") return this.#options.cwd;
68
+ this.#packageRoot = (await readActiveRevision(this.#managedBase()))?.dir;
69
+ return this.#managedBase();
70
+ }
71
+
72
+ async install(
73
+ requested: string,
74
+ signal: AbortSignal,
75
+ onOutput?: (stream: "stdout" | "stderr", data: string) => void,
76
+ requestedInstaller?: "bun" | "npm",
77
+ ): Promise<JsInstallReceipt> {
78
+ const environments = this.#options.settings.environments;
79
+ if (environments?.autoProvision === false) {
80
+ throw new EnvironmentError(
81
+ "environment_installer_unavailable",
82
+ "installs are turned off for this project (environments.autoProvision is false)",
83
+ );
84
+ }
85
+ const packages = parseJsPackages(requested).map((spec) => absoluteSpec(spec, this.#options.cwd));
86
+ // `%bun add` and `%npm add` name their installer; the setting applies only where the magic does not.
87
+ const choice: JsInstallerChoice = requestedInstaller ?? environments?.js?.installer ?? "auto";
88
+ const { installer, command } = resolveJsInstaller(choice, this.#options.env);
89
+ let recordedSpecs: readonly string[] = [];
90
+ const run = (root: string, remove?: string) =>
91
+ runJsInstall({
92
+ installer,
93
+ command,
94
+ root,
95
+ packages,
96
+ ...(remove === undefined ? {} : { remove }),
97
+ recordedSpecs,
98
+ cwd: this.#options.cwd,
99
+ env: this.#options.env,
100
+ signal,
101
+ ...(onOutput === undefined ? {} : { onOutput }),
102
+ });
103
+ // The lock and the revision store fail with raw file system errors; they reach the cell redacted.
104
+ const redacted =
105
+ (...roots: string[]) =>
106
+ (error: unknown) => {
107
+ const raw =
108
+ error instanceof EnvironmentError
109
+ ? error.message.slice(error.code.length + 2)
110
+ : String(error instanceof Error ? error.message : error);
111
+ const text = roots.reduce(
112
+ (message, root) => withoutHostPaths(message, { root, cwd: this.#options.cwd, packages, recordedSpecs }),
113
+ raw,
114
+ );
115
+ if (error instanceof EnvironmentError) throw new EnvironmentError(error.code, text);
116
+ // An abort while waiting for the lock or before the publish is a cancel, not a failure.
117
+ if (signal.aborted)
118
+ throw new EnvironmentError("environment_install_cancelled", `the install was cancelled: ${text}`);
119
+ throw new EnvironmentError("environment_install_failed", text);
120
+ };
121
+ if (this.#mode === "project") {
122
+ const before = await dependencyNames(this.#options.cwd);
123
+ const lockRoot = join(this.#options.cwd, ".senpi", "js-packages");
124
+ await mkdir(lockRoot, { recursive: true });
125
+ await withRootLock(lockRoot, async () => await run(this.#options.cwd), signal).catch(redacted(lockRoot));
126
+ const added = (await dependencyNames(this.#options.cwd)).filter((name) => !before.includes(name));
127
+ return { installer, mode: "project", revision: undefined, added, shadowed: [] };
128
+ }
129
+ let added: string[] = [];
130
+ const requestedBase = this.#managedBase();
131
+ const base = await canonicalBase(this.#managedRoot(), requestedBase).catch(redacted(requestedBase));
132
+ const { revision } = await publishNextRevision(
133
+ base,
134
+ async (staging) => {
135
+ await carryNpmrcSettings(staging);
136
+ await rm(join(staging, "bunfig.toml"), { recursive: true, force: true });
137
+ await seedPackageJson(staging);
138
+ // Every path the installer writes must be the revision's own, never a link out of it.
139
+ for (const entry of INSTALLER_WRITES) await assertNoLinksBelow(base, join(staging, entry));
140
+ const before = await dependencyNames(staging);
141
+ recordedSpecs = await recordedFileSpecs(staging);
142
+ // A package whose source changes kind (a directory to a tarball or registry version, or back) is removed
143
+ // first: bun keeps its per-file links into the old directory otherwise (#2758).
144
+ for (const name of await sourceKindChanges(staging, packages)) await run(staging, name);
145
+ await run(staging);
146
+ await assertBuiltRevisionLinks(staging);
147
+ added = (await dependencyNames(staging)).filter((name) => !before.includes(name));
148
+ },
149
+ signal,
150
+ ).catch(redacted(base, requestedBase));
151
+ this.#packageRoot = revision.dir;
152
+ const shadowed = added.filter((name) => projectResolves(this.#options.cwd, name));
153
+ return { installer, mode: "managed", revision: revision.number, added, shadowed };
154
+ }
155
+
156
+ #managedRoot(): string {
157
+ return this.#options.settings.environments?.managedRoot ?? this.#options.artifactsDir;
158
+ }
159
+
160
+ #managedBase(): string {
161
+ return join(this.#managedRoot(), "environments", "js", this.#options.runtime);
162
+ }
163
+ }
164
+
165
+ async function dependencyNames(root: string): Promise<string[]> {
166
+ try {
167
+ const manifest: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
168
+ if (typeof manifest !== "object" || manifest === null || !("dependencies" in manifest)) return [];
169
+ const dependencies = manifest.dependencies;
170
+ return typeof dependencies === "object" && dependencies !== null ? Object.keys(dependencies) : [];
171
+ } catch (error) {
172
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Creates the managed base, checks that nothing below the managed root is a link, and returns its realpath: npm keys
179
+ * a linked package in its lockfile by a path relative to the prefix, and a prefix under a linked directory (macOS
180
+ * `/var` is a link to `/private/var`) gives keys that stop resolving once the staged revision is renamed.
181
+ */
182
+ async function canonicalBase(managedRoot: string, base: string): Promise<string> {
183
+ await assertNoLinksBelow(managedRoot, base);
184
+ await mkdirPrivate(base);
185
+ await assertNoLinksBelow(managedRoot, base);
186
+ return await realpath(base);
187
+ }
188
+
189
+ /** Names among `packages` whose recorded source is a directory and is not the same directory now, or the reverse. */
190
+ export async function sourceKindChanges(revision: string, packages: readonly string[]): Promise<string[]> {
191
+ const installed = new Set(await dependencyNames(revision));
192
+ const sources = await recordedInstallSources(revision);
193
+ const changed: string[] = [];
194
+ for (const spec of packages) {
195
+ const name = await requestedPackageName(spec);
196
+ if (name === undefined || !installed.has(name)) continue;
197
+ const directory = await localDirectory(spec);
198
+ if (directory !== sources.get(name)) changed.push(name);
199
+ }
200
+ return changed;
201
+ }
202
+
203
+ async function localDirectory(spec: string): Promise<string | undefined> {
204
+ const path = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
205
+ if (!isAbsolute(path)) return undefined;
206
+ const resolved = await realpath(path).catch(() => undefined);
207
+ return resolved !== undefined && (await stat(resolved)).isDirectory() ? resolved : undefined;
208
+ }
@@ -0,0 +1,268 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { basename, delimiter, isAbsolute, join, resolve, sep } from "node:path";
6
+ import { EnvironmentError } from "./py-installer.ts";
7
+
8
+ export type JsInstallerChoice = "auto" | "bun" | "npm";
9
+ export type JsInstaller = "bun" | "npm";
10
+
11
+ const STDERR_TAIL_BYTES = 4_096;
12
+
13
+ function onPath(command: string, env: NodeJS.ProcessEnv): string | undefined {
14
+ const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
15
+ const extensions = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
16
+ for (const directory of (env[pathKey] ?? "").split(delimiter)) {
17
+ if (directory === "") continue;
18
+ for (const extension of extensions) {
19
+ const candidate = join(directory, `${command}${extension}`);
20
+ if (existsSync(candidate)) return candidate;
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ export function resolveJsInstaller(
27
+ choice: JsInstallerChoice,
28
+ env: NodeJS.ProcessEnv,
29
+ ): { readonly installer: JsInstaller; readonly command: string } {
30
+ const order: readonly JsInstaller[] = choice === "auto" ? ["bun", "npm"] : [choice];
31
+ for (const installer of order) {
32
+ const command = onPath(installer, env);
33
+ if (command !== undefined) return { installer, command };
34
+ }
35
+ throw new EnvironmentError(
36
+ "environment_installer_unavailable",
37
+ choice === "auto" ? "neither bun nor npm is on PATH" : `${choice} is not on PATH`,
38
+ );
39
+ }
40
+
41
+ export function parseJsPackages(text: string): string[] {
42
+ if (/[\u0000-\u0008\u000e-\u001f\u007f]/.test(text)) {
43
+ throw new EnvironmentError("environment_install_failed", "package names cannot contain control characters");
44
+ }
45
+ const packages = text.split(/\s+/).filter((token) => token !== "");
46
+ const flag = packages.find((token) => token.startsWith("-"));
47
+ if (flag !== undefined) {
48
+ throw new EnvironmentError(
49
+ "environment_install_failed",
50
+ `installer flags are chosen by the host; name packages only (got ${flag})`,
51
+ );
52
+ }
53
+ if (packages.length === 0)
54
+ throw new EnvironmentError("environment_install_failed", "name at least one package to add");
55
+ return packages;
56
+ }
57
+
58
+ /**
59
+ * A path-like spec (`./pkg`, `../x.tgz`, `file:./pkg`) means a path from the session directory; both installers get it
60
+ * absolute, because bun resolves a relative spec from the revision it builds and npm from its own cwd.
61
+ */
62
+ export function absoluteSpec(spec: string, cwd: string): string {
63
+ const file = spec.startsWith("file:") ? spec.slice("file:".length) : undefined;
64
+ const path = file ?? spec;
65
+ const pathLike = path.startsWith("./") || path.startsWith("../") || path === "." || path === "..";
66
+ if (!pathLike) return spec;
67
+ const absolute = resolve(cwd, path);
68
+ return file === undefined ? absolute : `file:${absolute}`;
69
+ }
70
+
71
+ export function jsInstallArgv(installer: JsInstaller, root: string, packages: readonly string[]): string[] {
72
+ return installer === "bun"
73
+ ? ["add", "--ignore-scripts", "--cwd", root, ...packages]
74
+ : ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--prefix", root, ...packages];
75
+ }
76
+
77
+ export function jsRemoveArgv(installer: JsInstaller, root: string, name: string): string[] {
78
+ return installer === "bun"
79
+ ? ["remove", "--ignore-scripts", "--cwd", root, name]
80
+ : ["uninstall", "--ignore-scripts", "--no-audit", "--no-fund", "--prefix", root, name];
81
+ }
82
+
83
+ const REGISTRY_NAME = /^((?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*)(?:@[^/]*)?$/i;
84
+
85
+ /**
86
+ * The package name a spec installs, when it can be known before the install: a local directory's or tarball's own
87
+ * `package.json` name, or the name of a registry spec (`name`, `name@range`, `@scope/name@range`). Undefined for URL
88
+ * and git specs.
89
+ */
90
+ export async function requestedPackageName(spec: string): Promise<string | undefined> {
91
+ const path = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
92
+ if (isAbsolute(path)) {
93
+ try {
94
+ const manifest: unknown = JSON.parse(
95
+ (await stat(path)).isDirectory()
96
+ ? await readFile(join(path, "package.json"), "utf8")
97
+ : await tarballManifest(path),
98
+ );
99
+ return typeof manifest === "object" &&
100
+ manifest !== null &&
101
+ "name" in manifest &&
102
+ typeof manifest.name === "string"
103
+ ? manifest.name
104
+ : undefined;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ return REGISTRY_NAME.exec(spec)?.[1];
110
+ }
111
+
112
+ /**
113
+ * An archive's own `package.json`, read from its single top-level directory as both installers unpack it: npm's
114
+ * `package/`, or a GitHub-style `<repo>-<sha>/`. tar detects the compression itself, so a plain `.tar` works too.
115
+ */
116
+ async function tarballManifest(path: string): Promise<string> {
117
+ const entries = (await tarOutput(["-tf", path])).split("\n").filter((entry) => entry !== "");
118
+ const normalized = (entry: string) => entry.replace(/^(\.\/)+/, "");
119
+ const tops = new Set(entries.map((entry) => normalized(entry).split("/")[0] ?? "").filter((top) => top !== ""));
120
+ // An archive whose entries do not all sit under one directory has no single package to name: judging it by
121
+ // whichever entry comes first could name the wrong one.
122
+ const [top, ...others] = [...tops];
123
+ if (top === undefined || others.length > 0 || top === "." || top === "..") {
124
+ throw new Error("the archive does not hold one top-level directory");
125
+ }
126
+ // Extract by the member's own name: GNU tar matches it exactly, so a leading "./" must be kept.
127
+ const manifest = entries.find((entry) => normalized(entry) === `${top}/package.json`);
128
+ if (manifest === undefined) throw new Error("the archive holds no package.json in its top-level directory");
129
+ return await tarOutput(["-xOf", path, manifest]);
130
+ }
131
+
132
+ function tarOutput(args: readonly string[]): Promise<string> {
133
+ return new Promise((resolveText, reject) => {
134
+ execFile("tar", [...args], { maxBuffer: 64 << 20 }, (error, stdout) =>
135
+ error === null ? resolveText(stdout) : reject(error),
136
+ );
137
+ });
138
+ }
139
+
140
+ export function runJsInstall(input: {
141
+ readonly installer: JsInstaller;
142
+ readonly command: string;
143
+ readonly root: string;
144
+ readonly packages: readonly string[];
145
+ /** Remove this package instead of adding `packages`. */
146
+ readonly remove?: string;
147
+ /** Absolute specs already recorded in the revision; the installer echoes them, so they are redacted too. */
148
+ readonly recordedSpecs?: readonly string[];
149
+ readonly cwd: string;
150
+ readonly env: NodeJS.ProcessEnv;
151
+ readonly signal: AbortSignal;
152
+ readonly onOutput?: (stream: "stdout" | "stderr", data: string) => void;
153
+ }): Promise<void> {
154
+ return new Promise((resolve, reject) => {
155
+ if (input.signal.aborted) {
156
+ reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started"));
157
+ return;
158
+ }
159
+ const argv =
160
+ input.remove === undefined
161
+ ? jsInstallArgv(input.installer, input.root, input.packages)
162
+ : jsRemoveArgv(input.installer, input.root, input.remove);
163
+ const child = spawn(input.command, argv, {
164
+ cwd: input.cwd,
165
+ stdio: ["ignore", "pipe", "pipe"],
166
+ // npm reads `global`/`location` from any .npmrc and the env; a global install would land where the import never looks.
167
+ env: {
168
+ ...input.env,
169
+ npm_config_ignore_scripts: "true",
170
+ npm_config_global: "false",
171
+ npm_config_location: "project",
172
+ },
173
+ detached: process.platform !== "win32",
174
+ });
175
+ let output = "";
176
+ const record = (stream: "stdout" | "stderr") => (data: string) => {
177
+ output = (output + data).slice(-STDERR_TAIL_BYTES);
178
+ input.onOutput?.(stream, withoutHostPaths(data, input));
179
+ };
180
+ child.stdout.setEncoding("utf8").on("data", record("stdout"));
181
+ child.stderr.setEncoding("utf8").on("data", record("stderr"));
182
+ const onAbort = () => {
183
+ if (child.pid !== undefined && process.platform !== "win32") {
184
+ try {
185
+ process.kill(-child.pid, "SIGKILL");
186
+ return;
187
+ } catch {
188
+ // ESRCH (the group is gone) or EPERM (macOS refuses a group whose leader already exited): stop the
189
+ // installer itself. An abort listener must never throw: nothing above it can catch the error.
190
+ }
191
+ }
192
+ child.kill("SIGKILL");
193
+ };
194
+ input.signal.addEventListener("abort", onAbort, { once: true });
195
+ child.once("error", (error) => {
196
+ input.signal.removeEventListener("abort", onAbort);
197
+ reject(
198
+ new EnvironmentError(
199
+ "environment_installer_unavailable",
200
+ `${input.installer}: ${withoutHostPaths(error.message, input)}`,
201
+ ),
202
+ );
203
+ });
204
+ child.once("close", (code, signal) => {
205
+ input.signal.removeEventListener("abort", onAbort);
206
+ if (input.signal.aborted) {
207
+ reject(
208
+ new EnvironmentError(
209
+ "environment_install_cancelled",
210
+ `the install was cancelled${cancelReason(input.signal, input)}; ${input.installer} was stopped`,
211
+ ),
212
+ );
213
+ } else if (code === 0) resolve();
214
+ else {
215
+ reject(
216
+ new EnvironmentError(
217
+ "environment_install_failed",
218
+ withoutHostPaths(output.trim(), input) || `${input.installer} exited with ${code ?? signal}`,
219
+ ),
220
+ );
221
+ }
222
+ });
223
+ });
224
+ }
225
+
226
+ function cancelReason(signal: AbortSignal, input: Parameters<typeof withoutHostPaths>[1]): string {
227
+ const reason: unknown = signal.reason;
228
+ return reason instanceof Error && reason.name !== "AbortError" ? `: ${withoutHostPaths(reason.message, input)}` : "";
229
+ }
230
+
231
+ /** Installer output names the session's own roots; error text says `<root>`/`<cwd>`/`~` instead of absolute paths. */
232
+ export function withoutHostPaths(
233
+ text: string,
234
+ input: {
235
+ readonly root: string;
236
+ readonly cwd: string;
237
+ readonly packages: readonly string[];
238
+ readonly recordedSpecs?: readonly string[];
239
+ },
240
+ ): string {
241
+ const home = homedir();
242
+ // A spec that is an absolute file path names the user's file system; it is shown by its file name only.
243
+ // This install's absolute specs, and every path an earlier install recorded (npm records a relative `file:` path).
244
+ const requested = input.packages
245
+ .map((spec) => (spec.startsWith("file:") ? spec.slice("file:".length) : spec))
246
+ .filter((path) => isAbsolute(path));
247
+ const specPaths = [...requested, ...(input.recordedSpecs ?? [])]
248
+ .sort((a, b) => b.length - a.length)
249
+ .map((path) => [path, `<path>/${basename(path)}`] as const);
250
+ return [
251
+ ...specPaths,
252
+ [input.root, "<root>"],
253
+ [input.cwd, "<cwd>"],
254
+ [realpathOrSelf(tmpdir()), "<tmp>"],
255
+ [tmpdir(), "<tmp>"],
256
+ [home, "~"],
257
+ ]
258
+ .filter(([path]) => path !== "" && path !== sep)
259
+ .reduce((current, [path, label]) => current.replaceAll(path, label), text);
260
+ }
261
+
262
+ function realpathOrSelf(path: string): string {
263
+ try {
264
+ return realpathSync(path);
265
+ } catch {
266
+ return path;
267
+ }
268
+ }