@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.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.
- package/CHANGELOG.md +178 -0
- package/README.md +212 -14
- package/package.json +6 -5
- package/src/bridge/http-server.ts +9 -1
- package/src/bridge/protocol.ts +12 -3
- package/src/bridge/reserved.ts +2 -0
- package/src/bridges/eval-environment-schemas.ts +48 -0
- package/src/bridges/eval-virtual-schemas.ts +12 -4
- package/src/bridges/reserved-dispatch.ts +13 -1
- package/src/config/executable-settings.json +5 -0
- package/src/config/project-trust.ts +51 -0
- package/src/config/settings-overrides.ts +2 -0
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/packages-install.ts +98 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/py-environment.ts +7 -3
- package/src/environments/py-installer.ts +68 -4
- package/src/environments/python-environments.ts +85 -0
- package/src/environments/revision-store.ts +38 -7
- package/src/extension/bridge-tool-call.ts +7 -1
- package/src/extension/cell-kernel-tools.ts +28 -0
- package/src/extension/eval-notifier.ts +13 -2
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/kernel-replacement.ts +20 -0
- package/src/extension/runtime-factory.ts +53 -3
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +26 -4
- package/src/index.ts +4 -2
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/AGENTS.md +2 -0
- package/src/kernels/jl/prelude.jl +6 -1
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/cell-ownership.js +268 -0
- package/src/kernels/js/cell-run-context.js +89 -0
- package/src/kernels/js/context-manager.ts +48 -39
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/kernel-contract.ts +16 -1
- package/src/kernels/js/kernel-memory-bridge.ts +1 -1
- package/src/kernels/js/kernel-memory.ts +79 -0
- package/src/kernels/js/kernel-tools-registry.js +1 -0
- package/src/kernels/js/local-module-loader.ts +21 -5
- package/src/kernels/js/process-drain-scan.d.ts +8 -0
- package/src/kernels/js/process-drain-scan.js +28 -0
- package/src/kernels/js/process-entry.js +466 -0
- package/src/kernels/js/process-memory-host.ts +83 -0
- package/src/kernels/js/process-worker.ts +298 -0
- package/src/kernels/js/rejection-reports.js +61 -0
- package/src/kernels/js/run-queue.ts +7 -0
- package/src/kernels/js/worker-core.js +53 -8
- package/src/kernels/js/worker-cwd.js +5 -2
- package/src/kernels/js/worker-host.ts +9 -2
- package/src/kernels/js/worker-indirect-eval.js +37 -13
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-require.d.ts +19 -0
- package/src/kernels/js/worker-require.js +52 -0
- package/src/kernels/js/worker-runtime.js +45 -9
- package/src/kernels/js/worker-shadow-guard.js +2 -6
- package/src/kernels/js/worker-shell-capture.js +5 -0
- package/src/kernels/js/worker-slot.ts +9 -0
- package/src/kernels/js/worker-startup.ts +49 -1
- package/src/kernels/js/worker-webview.js +15 -1
- package/src/kernels/js/workpool.js +5 -2
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/kernel.ts +8 -2
- package/src/kernels/py/prelude.py +145 -11
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/py/transport.ts +13 -1
- package/src/kernels/rb/workpool.rb +7 -1
- package/src/kernels/sandbox/sandbox-cell.ts +141 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/cell-source-at-start.ts +18 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/kernels/shared/subprocess-process.ts +14 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-handler.ts +13 -12
- package/src/tool/cell-reserved-dispatch.ts +1 -0
- package/src/tool/cell-runtime.ts +4 -2
- package/src/tool/detached-cell-contract.ts +3 -0
- package/src/tool/detached-cell-manager.ts +4 -8
- package/src/tool/detached-cell-notification.ts +11 -63
- package/src/tool/detached-cell-snapshot.ts +17 -0
- package/src/tool/detached-eval-result.ts +13 -7
- package/src/tool/detached-notification-queue.ts +1 -2
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +6 -0
- package/src/tool/eval-tool.ts +17 -4
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/live-headline.ts +60 -0
- package/src/tool/load-cell.ts +88 -0
- package/src/tool/magic-cell-host.ts +123 -0
- package/src/tool/magic-cells.ts +75 -0
- package/src/tool/managed-cell.ts +0 -4
- package/src/tool/render.ts +74 -15
- package/src/tool/run-eval-cell.ts +73 -1
- package/src/tool/runtime-label.ts +2 -1
- package/src/tool/types.ts +50 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const NPMRC_CARRIED_KEY = /^(?:registry|@[^\s=:/]+:registry|strict-ssl|ca|cafile)$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A revision carries forward only the registry settings of its `.npmrc`: `registry`, `@scope:registry`, `strict-ssl`,
|
|
8
|
+
* `ca` and `cafile`. Every other key, credentials included, is dropped by default, and the file is rewritten as a
|
|
9
|
+
* regular file: a symlinked `.npmrc` is replaced, never written through, so the file it pointed at stays untouched.
|
|
10
|
+
*/
|
|
11
|
+
export async function carryNpmrcSettings(root: string): Promise<void> {
|
|
12
|
+
const path = join(root, ".npmrc");
|
|
13
|
+
let text: string;
|
|
14
|
+
try {
|
|
15
|
+
text = await readFile(path, "utf8");
|
|
16
|
+
} catch {
|
|
17
|
+
await rm(path, { recursive: true, force: true });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const kept = text
|
|
21
|
+
.split(/\r\n|\r|\n/)
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => {
|
|
24
|
+
const key = line.split("=", 1)[0]?.trim() ?? "";
|
|
25
|
+
return line.includes("=") && NPMRC_CARRIED_KEY.test(key);
|
|
26
|
+
})
|
|
27
|
+
.flatMap((line) => {
|
|
28
|
+
const carried = withoutUrlCredentials(line);
|
|
29
|
+
return carried === undefined ? [] : [carried];
|
|
30
|
+
});
|
|
31
|
+
await rm(path, { recursive: true, force: true });
|
|
32
|
+
if (kept.length > 0) await writeFile(path, `${kept.join("\n")}\n`, { mode: 0o600, flag: "wx" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The `file:` and absolute path specs a revision's `package.json` already records, as written (npm writes a relative
|
|
37
|
+
* `file:../..` path) and resolved against the revision: the installer echoes them on every later install.
|
|
38
|
+
*/
|
|
39
|
+
export async function recordedFileSpecs(root: string): Promise<string[]> {
|
|
40
|
+
try {
|
|
41
|
+
const manifest: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
42
|
+
if (typeof manifest !== "object" || manifest === null || !("dependencies" in manifest)) return [];
|
|
43
|
+
const dependencies = manifest.dependencies;
|
|
44
|
+
if (typeof dependencies !== "object" || dependencies === null) return [];
|
|
45
|
+
return Object.values(dependencies).flatMap((spec) => {
|
|
46
|
+
if (typeof spec !== "string") return [];
|
|
47
|
+
if (spec.startsWith("file:")) {
|
|
48
|
+
const path = spec.slice("file:".length);
|
|
49
|
+
return isAbsolute(path) ? [path] : [path, resolve(root, path)];
|
|
50
|
+
}
|
|
51
|
+
return isAbsolute(spec) ? [spec] : [];
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A carried `registry`/`@scope:registry` value keeps only its scheme, host and path: user info, a query and a fragment
|
|
60
|
+
* can each carry a credential, so they are cleared with the WHATWG URL parser npm itself uses. The path is kept, since
|
|
61
|
+
* registries are addressed by it, so a registry that embeds a token in its path keeps it in the revision's private
|
|
62
|
+
* (0600, in a 0700 directory) copy. A value that does not parse is dropped, and so is one that names an environment
|
|
63
|
+
* variable (`${...}`): npm expands it at install time, and a parsed copy would be a different URL. Other carried keys
|
|
64
|
+
* pass unchanged.
|
|
65
|
+
*/
|
|
66
|
+
function withoutUrlCredentials(line: string): string | undefined {
|
|
67
|
+
const separator = line.indexOf("=");
|
|
68
|
+
const key = line.slice(0, separator).trim();
|
|
69
|
+
if (!key.endsWith("registry")) return line;
|
|
70
|
+
const raw = line.slice(separator + 1).trim();
|
|
71
|
+
if (raw.includes("${")) return undefined;
|
|
72
|
+
const quote = raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw.endsWith(raw[0]) ? raw[0] : "";
|
|
73
|
+
let url: URL;
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(quote === "" ? raw : raw.slice(1, -1));
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
url.username = "";
|
|
80
|
+
url.password = "";
|
|
81
|
+
url.search = "";
|
|
82
|
+
url.hash = "";
|
|
83
|
+
return `${key}=${quote}${url.href}${quote}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Without its own `package.json`, bun walks up from the revision and installs into the nearest one above it. */
|
|
87
|
+
export async function seedPackageJson(root: string): Promise<void> {
|
|
88
|
+
await writeFile(join(root, "package.json"), '{"private":true}\n', { flag: "wx" }).catch((error: unknown) => {
|
|
89
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, readlink, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { EnvironmentError } from "./py-installer.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every managed-root write goes through here: each component of `path` strictly below `root` that exists must be a
|
|
7
|
+
* real file or directory, never a link, so a write can never land in what a planted link points at. `root` itself
|
|
8
|
+
* may be a link (a user may relocate their whole artifacts directory); nothing below it may.
|
|
9
|
+
*/
|
|
10
|
+
export async function assertNoLinksBelow(root: string, path: string): Promise<void> {
|
|
11
|
+
const below = relative(root, path);
|
|
12
|
+
if (below.startsWith("..") || isAbsolute(below)) {
|
|
13
|
+
throw new EnvironmentError("environment_install_failed", "refusing to write outside the managed environment");
|
|
14
|
+
}
|
|
15
|
+
let current = root;
|
|
16
|
+
for (const part of below.split(sep).filter((segment) => segment !== "")) {
|
|
17
|
+
current = join(current, part);
|
|
18
|
+
const stats = await lstat(current).catch(missing);
|
|
19
|
+
if (stats === undefined) return;
|
|
20
|
+
if (stats.isSymbolicLink()) {
|
|
21
|
+
throw new EnvironmentError(
|
|
22
|
+
"environment_install_failed",
|
|
23
|
+
`${relative(root, current)} is a link; refusing to write through it`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The local directories a revision's `package.json` records as install sources, by package name, as realpaths: npm
|
|
31
|
+
* writes `file:<path relative to the revision>`, bun the absolute path. Only these may be link targets out of the
|
|
32
|
+
* revision, because installing from a directory links the package to it.
|
|
33
|
+
*/
|
|
34
|
+
export async function recordedInstallSources(revision: string): Promise<ReadonlyMap<string, string>> {
|
|
35
|
+
const sources = new Map<string, string>();
|
|
36
|
+
let manifest: unknown;
|
|
37
|
+
try {
|
|
38
|
+
manifest = JSON.parse(await readFile(join(revision, "package.json"), "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return sources;
|
|
41
|
+
}
|
|
42
|
+
if (typeof manifest !== "object" || manifest === null || !("dependencies" in manifest)) return sources;
|
|
43
|
+
const dependencies = manifest.dependencies;
|
|
44
|
+
if (typeof dependencies !== "object" || dependencies === null) return sources;
|
|
45
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
46
|
+
if (typeof spec !== "string") continue;
|
|
47
|
+
const path = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
|
|
48
|
+
if (!isAbsolute(path) && !path.startsWith("./") && !path.startsWith("../")) continue;
|
|
49
|
+
const source = await realpath(resolve(revision, path)).catch(() => undefined);
|
|
50
|
+
if (source !== undefined && (await stat(source)).isDirectory()) sources.set(name, source);
|
|
51
|
+
}
|
|
52
|
+
return sources;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A copy filter. A link inside `tree` may point inside it (relatively: an absolute link would keep pointing at the
|
|
57
|
+
* previous revision from the copy), or out of it only to exactly the matching file of a recorded install source:
|
|
58
|
+
* the link at `node_modules/<name>/<rest>` may resolve to `<source of name>/<rest>` and to nothing else. Whether a
|
|
59
|
+
* target is inside is judged from the link's own text, without following further links.
|
|
60
|
+
*/
|
|
61
|
+
export async function assertLinkStaysInside(
|
|
62
|
+
path: string,
|
|
63
|
+
tree: string,
|
|
64
|
+
sources: ReadonlyMap<string, string>,
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
if (!(await lstat(path)).isSymbolicLink()) return true;
|
|
67
|
+
const target = await readlink(path);
|
|
68
|
+
const resolved = resolve(dirname(path), target);
|
|
69
|
+
const inside = relative(tree, resolved);
|
|
70
|
+
const where = relative(tree, path);
|
|
71
|
+
if (inside === "" || (!inside.startsWith("..") && !isAbsolute(inside))) {
|
|
72
|
+
if (!isAbsolute(target)) return true;
|
|
73
|
+
throw new EnvironmentError(
|
|
74
|
+
"environment_install_failed",
|
|
75
|
+
`${where} is an absolute link into its revision; refusing to build on it`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (await isRecordedSourceLink(where, resolved, sources)) return true;
|
|
79
|
+
throw new EnvironmentError(
|
|
80
|
+
"environment_install_failed",
|
|
81
|
+
`${where} links outside its revision; refusing to build on it`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function isRecordedSourceLink(
|
|
86
|
+
where: string,
|
|
87
|
+
resolved: string,
|
|
88
|
+
sources: ReadonlyMap<string, string>,
|
|
89
|
+
): Promise<boolean> {
|
|
90
|
+
const parts = where.split(sep);
|
|
91
|
+
if (parts[0] !== "node_modules" || parts[1] === undefined) return false;
|
|
92
|
+
const scoped = parts[1].startsWith("@");
|
|
93
|
+
const name = scoped ? `${parts[1]}/${parts[2] ?? ""}` : parts[1];
|
|
94
|
+
const source = sources.get(name);
|
|
95
|
+
if (source === undefined) return false;
|
|
96
|
+
// `source` is a realpath; the expected target is that path plus the link's own path below the package, taken
|
|
97
|
+
// literally, so a file of the source that is itself a link out of it does not count as the source.
|
|
98
|
+
const expected = join(source, ...parts.slice(scoped ? 3 : 2));
|
|
99
|
+
const actual = await realpath(resolved).catch(() => undefined);
|
|
100
|
+
return actual !== undefined && actual === expected;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function missing(error: unknown): undefined {
|
|
104
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Checks every link in a built revision as the next copy will, so an install that leaves a link the next install
|
|
110
|
+
* would refuse (an installer keeping links into a directory the manifest no longer records) is refused now, while
|
|
111
|
+
* the previous revision is still active.
|
|
112
|
+
*/
|
|
113
|
+
export async function assertBuiltRevisionLinks(revision: string): Promise<void> {
|
|
114
|
+
const sources = await recordedInstallSources(revision);
|
|
115
|
+
const walk = async (dir: string): Promise<void> => {
|
|
116
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
117
|
+
const path = join(dir, entry.name);
|
|
118
|
+
if (entry.isSymbolicLink()) {
|
|
119
|
+
await assertLinkStaysInside(path, revision, sources).catch((error: unknown) => {
|
|
120
|
+
const where = relative(revision, path);
|
|
121
|
+
throw error instanceof EnvironmentError
|
|
122
|
+
? new EnvironmentError(
|
|
123
|
+
"environment_install_failed",
|
|
124
|
+
`the install left ${where} linked outside the revision, so nothing was published`,
|
|
125
|
+
)
|
|
126
|
+
: error;
|
|
127
|
+
});
|
|
128
|
+
} else if (entry.isDirectory()) await walk(path);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
await walk(revision);
|
|
132
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { JsEnvironments, JsInstallReceipt } from "./js-environments.ts";
|
|
2
|
+
import type { InstallReceipt } from "./py-environment.ts";
|
|
3
|
+
import { EnvironmentError } from "./py-installer.ts";
|
|
4
|
+
import type { PythonEnvironments } from "./python-environments.ts";
|
|
5
|
+
|
|
6
|
+
export interface PackagesInstallEnvironments {
|
|
7
|
+
readonly python?: PythonEnvironments;
|
|
8
|
+
readonly js?: JsEnvironments;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const PACKAGES_INSTALL_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
12
|
+
|
|
13
|
+
interface PackagesInstallRequest {
|
|
14
|
+
readonly manager: "pip" | "bun" | "npm";
|
|
15
|
+
readonly requirements: readonly string[];
|
|
16
|
+
readonly timeoutSeconds: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function invalid(message: string): EnvironmentError {
|
|
20
|
+
return new EnvironmentError("environment_install_failed", `packages.install(): ${message}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseRequest(args: unknown): PackagesInstallRequest {
|
|
24
|
+
if (typeof args !== "object" || args === null) throw invalid("expected (manager, requirements, {timeout?})");
|
|
25
|
+
const manager = "manager" in args ? args.manager : undefined;
|
|
26
|
+
if (manager !== "pip" && manager !== "bun" && manager !== "npm") {
|
|
27
|
+
throw invalid('manager must be "pip" (Python) or "bun" / "npm" (JavaScript)');
|
|
28
|
+
}
|
|
29
|
+
const raw = "requirements" in args ? args.requirements : undefined;
|
|
30
|
+
const list = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : undefined;
|
|
31
|
+
if (
|
|
32
|
+
list === undefined ||
|
|
33
|
+
list.length === 0 ||
|
|
34
|
+
!list.every((item) => typeof item === "string" && item.trim() !== "")
|
|
35
|
+
) {
|
|
36
|
+
throw invalid("requirements must be a non-empty string or a non-empty list of non-empty strings");
|
|
37
|
+
}
|
|
38
|
+
const timeout = "timeout" in args ? args.timeout : undefined;
|
|
39
|
+
if (timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
|
|
40
|
+
throw invalid("timeout must be a positive number of seconds");
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
manager,
|
|
44
|
+
requirements: list.map((item: string) => item.trim()),
|
|
45
|
+
timeoutSeconds: timeout ?? PACKAGES_INSTALL_DEFAULT_TIMEOUT_SECONDS,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function jsArguments(requirements: readonly string[]): string {
|
|
50
|
+
for (const requirement of requirements) {
|
|
51
|
+
if (/\s/.test(requirement)) throw invalid(`a package spec cannot contain whitespace: ${requirement}`);
|
|
52
|
+
}
|
|
53
|
+
return requirements.join(" ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `packages.install(manager, requirements, {timeout?})`: the in-cell form of `%pip install` / `%bun add` /
|
|
58
|
+
* `%npm add`. It drives the same session environment the magic cells use and returns its receipt; a stop of the
|
|
59
|
+
* owning cell cancels it (environment_install_cancelled), and the timeout fails it with environment_install_timeout.
|
|
60
|
+
* pip receives the requirement list as separate arguments, never re-split. The installer's output is not streamed
|
|
61
|
+
* into the cell: the receipt is the result, and a failure carries the installer's stderr tail.
|
|
62
|
+
*/
|
|
63
|
+
export async function runPackagesInstall(
|
|
64
|
+
args: unknown,
|
|
65
|
+
environments: PackagesInstallEnvironments,
|
|
66
|
+
signal: AbortSignal | undefined,
|
|
67
|
+
): Promise<InstallReceipt | JsInstallReceipt> {
|
|
68
|
+
const request = parseRequest(args);
|
|
69
|
+
const { python, js } = environments;
|
|
70
|
+
const manager = request.manager;
|
|
71
|
+
let install: ((signal: AbortSignal) => Promise<InstallReceipt | JsInstallReceipt>) | undefined;
|
|
72
|
+
if (manager === "pip") {
|
|
73
|
+
if (python !== undefined) install = (signal) => python.install(request.requirements, signal);
|
|
74
|
+
} else if (js !== undefined) {
|
|
75
|
+
install = (signal) => js.install(jsArguments(request.requirements), signal, undefined, manager);
|
|
76
|
+
}
|
|
77
|
+
if (install === undefined) {
|
|
78
|
+
throw new EnvironmentError(
|
|
79
|
+
"environment_installer_unavailable",
|
|
80
|
+
manager === "pip"
|
|
81
|
+
? "pip installs need this session to have a Python interpreter"
|
|
82
|
+
: "bun/npm installs run only in a JavaScript cell",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const timeout = AbortSignal.timeout(request.timeoutSeconds * 1_000);
|
|
86
|
+
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
87
|
+
try {
|
|
88
|
+
return await install(combined);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (timeout.aborted && !(signal?.aborted ?? false)) {
|
|
91
|
+
throw new EnvironmentError(
|
|
92
|
+
"environment_install_timeout",
|
|
93
|
+
`the install did not finish within ${request.timeoutSeconds}s and was stopped`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a bare import of `name` from the session directory resolves in the project before a managed revision: the
|
|
6
|
+
* host-side twin of the kernel resolver (`kernels/js/worker-package-resolve.js` stays plain JavaScript, and a `.ts`
|
|
7
|
+
* module may not import it). Like the resolver it stops at the first `node_modules/<name>` directory up the chain,
|
|
8
|
+
* and that directory wins only when the resolver would take an entry from it.
|
|
9
|
+
*/
|
|
10
|
+
export function projectResolves(cwd: string, name: string): boolean {
|
|
11
|
+
for (let directory = cwd; ; directory = dirname(directory)) {
|
|
12
|
+
const packageDir = join(directory, "node_modules", name);
|
|
13
|
+
if (isDirectory(packageDir)) return hasRootEntry(packageDir);
|
|
14
|
+
if (dirname(directory) === directory) return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hasRootEntry(packageDir: string): boolean {
|
|
19
|
+
let manifest: unknown;
|
|
20
|
+
try {
|
|
21
|
+
manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return legacyFile(join(packageDir, "index.js"));
|
|
24
|
+
}
|
|
25
|
+
// The resolver throws on a null manifest and treats any other non-object as one without fields.
|
|
26
|
+
if (manifest === null) return true;
|
|
27
|
+
if (typeof manifest !== "object") return legacyFile(join(packageDir, "index.js"));
|
|
28
|
+
// With `exports` the resolver commits to this package: it imports the "." target or fails, never falling through.
|
|
29
|
+
if ("exports" in manifest && manifest.exports !== undefined && manifest.exports !== null) return true;
|
|
30
|
+
const moduleEntry =
|
|
31
|
+
"Bun" in globalThis && "module" in manifest && typeof manifest.module === "string" ? manifest.module : undefined;
|
|
32
|
+
const main = "main" in manifest && typeof manifest.main === "string" ? manifest.main : "index.js";
|
|
33
|
+
return legacyFile(join(packageDir, moduleEntry ?? main));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function legacyFile(path: string): boolean {
|
|
37
|
+
return [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, "index.js")].some(isFile);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isFile(path: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(path).isFile();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isDirectory(path: string): boolean {
|
|
49
|
+
try {
|
|
50
|
+
return statSync(path).isDirectory();
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { assertInstalledInRevision, assertNoEditableInstalls } from "./editable-check.ts";
|
|
6
|
-
import { parsePipRequirements, runPipInstall } from "./py-installer.ts";
|
|
6
|
+
import { normalizePipInstall, parsePipRequirements, runPipInstall } from "./py-installer.ts";
|
|
7
7
|
import { publishNextRevision, type Revision } from "./revision-store.ts";
|
|
8
8
|
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
@@ -40,12 +40,16 @@ export async function installPythonPackages(input: {
|
|
|
40
40
|
readonly base: string;
|
|
41
41
|
readonly mode: EnvironmentMode;
|
|
42
42
|
readonly interpreter: string;
|
|
43
|
-
|
|
43
|
+
/** `%pip` argument text, or the requirement list `packages.install()` passes without re-parsing. */
|
|
44
|
+
readonly requirements: string | readonly string[];
|
|
44
45
|
readonly cwd: string;
|
|
45
46
|
readonly signal: AbortSignal;
|
|
46
47
|
readonly onOutput?: (stream: "stdout" | "stderr", data: string) => void;
|
|
47
48
|
}): Promise<InstallReceipt> {
|
|
48
|
-
const requested =
|
|
49
|
+
const requested =
|
|
50
|
+
typeof input.requirements === "string"
|
|
51
|
+
? parsePipRequirements(input.requirements)
|
|
52
|
+
: normalizePipInstall(["install", ...input.requirements]);
|
|
49
53
|
let stdout = "";
|
|
50
54
|
const { revision } = await publishNextRevision(
|
|
51
55
|
input.base,
|