@code-yeongyu/senpi-codemode 2026.10.27-2 → 2026.10.30-2

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 (66) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/package.json +4 -4
  3. package/src/bridge/http-server.ts +18 -5
  4. package/src/bridge/permission-check.ts +76 -0
  5. package/src/bridge/protocol.ts +7 -0
  6. package/src/kernels/jl/kernel.ts +3 -0
  7. package/src/kernels/js/context-manager.ts +26 -8
  8. package/src/kernels/js/group-signal-notice.js +277 -1
  9. package/src/kernels/js/inline-worker-entry.js +1 -23
  10. package/src/kernels/js/inline-worker.ts +7 -2
  11. package/src/kernels/js/interpose-bun.js +220 -0
  12. package/src/kernels/js/interpose-capabilities.js +29 -0
  13. package/src/kernels/js/interpose-code.js +8 -0
  14. package/src/kernels/js/interpose-fs.js +124 -0
  15. package/src/kernels/js/interpose-modules.js +107 -0
  16. package/src/kernels/js/interpose-paths.js +80 -0
  17. package/src/kernels/js/interpose-process.js +97 -0
  18. package/src/kernels/js/interpose-resources.js +59 -0
  19. package/src/kernels/js/interpose-sqlite.js +104 -0
  20. package/src/kernels/js/interpose.js +86 -0
  21. package/src/kernels/js/kernel-contract.ts +31 -0
  22. package/src/kernels/js/kernel-tools-pump.js +4 -2
  23. package/src/kernels/js/local-module-loader.ts +9 -4
  24. package/src/kernels/js/permission-authority.d.mts +10 -0
  25. package/src/kernels/js/permission-authority.mjs +11 -0
  26. package/src/kernels/js/permission-core.md +114 -0
  27. package/src/kernels/js/permission-host.ts +388 -0
  28. package/src/kernels/js/permission-runtime.js +205 -0
  29. package/src/kernels/js/permission-sessions.ts +46 -0
  30. package/src/kernels/js/permission-slots.d.mts +51 -0
  31. package/src/kernels/js/permission-slots.mjs +133 -0
  32. package/src/kernels/js/permission-worker-entry.js +1 -0
  33. package/src/kernels/js/permission-worker-frames.ts +55 -0
  34. package/src/kernels/js/process-tree.js +9 -1
  35. package/src/kernels/js/rewrite-imports.ts +44 -0
  36. package/src/kernels/js/windows-tree-kill.js +10 -2
  37. package/src/kernels/js/worker-core.d.ts +2 -0
  38. package/src/kernels/js/worker-core.js +5 -2
  39. package/src/kernels/js/worker-cwd.js +14 -8
  40. package/src/kernels/js/worker-entry.js +1 -23
  41. package/src/kernels/js/worker-file-helpers.js +58 -0
  42. package/src/kernels/js/worker-host.ts +24 -6
  43. package/src/kernels/js/worker-package-resolve.js +10 -2
  44. package/src/kernels/js/worker-runtime.js +5 -65
  45. package/src/kernels/js/worker-slot.ts +5 -1
  46. package/src/kernels/js/worker-startup.ts +7 -5
  47. package/src/kernels/js/worker-webview.js +17 -16
  48. package/src/kernels/py/interpose.py +765 -0
  49. package/src/kernels/py/kernel-contract.ts +4 -0
  50. package/src/kernels/py/kernel-tools-host.ts +5 -1
  51. package/src/kernels/py/kernel.ts +45 -5
  52. package/src/kernels/py/path_identity.py +162 -0
  53. package/src/kernels/py/path_identity_posix.py +74 -0
  54. package/src/kernels/py/path_identity_windows.py +93 -0
  55. package/src/kernels/py/permission-core.md +103 -0
  56. package/src/kernels/py/permission-host.ts +198 -0
  57. package/src/kernels/py/permission_bridge.py +145 -0
  58. package/src/kernels/py/prelude.py +40 -5
  59. package/src/kernels/py/transport.ts +15 -8
  60. package/src/kernels/rb/kernel.ts +3 -0
  61. package/src/kernels/shared/invocation-authority.ts +59 -0
  62. package/src/kernels/shared/kernel-tools-pump.ts +22 -1
  63. package/src/kernels/shared/path-identity.d.mts +42 -0
  64. package/src/kernels/shared/path-identity.mjs +149 -0
  65. package/src/kernels/shared/subprocess-contract.ts +2 -0
  66. package/src/kernels/shared/subprocess-kernel.ts +28 -0
@@ -0,0 +1,133 @@
1
+ const COUNT = 4;
2
+ const REGION = 16 * 1024;
3
+ const STRIDE = 12 + REGION * 2;
4
+ const FREE = 0, REQUESTED = 1, WRITING = 2, ANSWERED = 3, ABANDONED = 4, INTERRUPTED = 5;
5
+ const encoder = new TextEncoder();
6
+ const decoder = new TextDecoder("utf-8", { fatal: true });
7
+
8
+ export function createPermissionSlots() {
9
+ return new SharedArrayBuffer(COUNT * STRIDE);
10
+ }
11
+
12
+ function slotViews(buffer, index) {
13
+ if (buffer.byteLength !== COUNT * STRIDE || !Number.isInteger(index) || index < 0 || index >= COUNT) {
14
+ throw new Error("Invalid permission slot");
15
+ }
16
+ const offset = index * STRIDE;
17
+ return {
18
+ control: new Int32Array(buffer, offset, 3),
19
+ request: new Uint8Array(buffer, offset + 12, REGION),
20
+ response: new Uint8Array(buffer, offset + 12 + REGION, REGION),
21
+ };
22
+ }
23
+
24
+ export function permissionControl(buffer, index) {
25
+ return Atomics.load(slotViews(buffer, index).control, 0);
26
+ }
27
+
28
+ export function interruptPermissionSlot(buffer, index, observed) {
29
+ if ((observed & 7) !== REQUESTED) return false;
30
+ const { control } = slotViews(buffer, index);
31
+ const won = Atomics.compareExchange(control, 0, observed, (observed & ~7) | INTERRUPTED) === observed;
32
+ if (won) Atomics.notify(control, 0);
33
+ return won;
34
+ }
35
+
36
+ export function interruptPermissionSlots(buffer) {
37
+ for (let index = 0; index < COUNT; index++) interruptPermissionSlot(buffer, index, permissionControl(buffer, index));
38
+ }
39
+
40
+ function encode(region, value) {
41
+ const bytes = encoder.encode(JSON.stringify(value));
42
+ if (bytes.length > region.length) throw new Error("Permission message exceeds slot capacity");
43
+ region.set(bytes);
44
+ return bytes.length;
45
+ }
46
+
47
+ function decode(region, length) {
48
+ if (!Number.isInteger(length) || length < 1 || length > region.length) throw new Error("Invalid permission message size");
49
+ return JSON.parse(decoder.decode(region.subarray(0, length)));
50
+ }
51
+
52
+ export function createPermissionRequester(buffer, hooks) {
53
+ let generation = 0;
54
+ let retired = false;
55
+ return {
56
+ check(request) {
57
+ if (retired || generation >= (hooks.generationLimit ?? 2 ** 28)) {
58
+ if (!retired) { retired = true; hooks.retire?.(); }
59
+ throw new Error("Permission worker generation exhausted");
60
+ }
61
+ generation++;
62
+ for (let index = 0; index < COUNT; index++) {
63
+ const { control, request: region, response } = slotViews(buffer, index);
64
+ const old = Atomics.load(control, 0);
65
+ if (![FREE, ANSWERED, ABANDONED].includes(old & 7)) continue;
66
+ control[1] = encode(region, request);
67
+ const requested = (generation << 3) | REQUESTED;
68
+ if (Atomics.compareExchange(control, 0, old, requested) !== old) throw new Error("Permission slot ownership changed");
69
+ try {
70
+ hooks.wake(index, generation);
71
+ const wait = hooks.wait ?? Atomics.wait;
72
+ const deadline = Date.now() + (hooks.deadlineMs ?? 2000);
73
+ // The host claims the slot before it answers, and that claim does not notify, so a park can
74
+ // end on the claim rather than on the answer. Keep waiting while the slot is still ours or
75
+ // held by the host; only the deadline or a foreign state ends the wait.
76
+ let current = Atomics.load(control, 0);
77
+ while (current === requested || current === ((generation << 3) | WRITING)) {
78
+ const remaining = deadline - Date.now();
79
+ if (remaining <= 0 || wait(control, 0, current, remaining) === "timed-out") break;
80
+ current = Atomics.load(control, 0);
81
+ }
82
+ if (current === ((generation << 3) | ANSWERED)) {
83
+ const result = decode(response, control[2]);
84
+ if (result?.action === "pass") return result;
85
+ if (result?.action === "deny" && validRule(result.rule)) return result;
86
+ throw new Error("Invalid permission answer");
87
+ }
88
+ throw new Error("Permission check interrupted or timed out");
89
+ } finally {
90
+ // A WRITING slot belongs to the host even when the worker's deadline has expired.
91
+ Atomics.compareExchange(control, 0, requested, (generation << 3) | ABANDONED);
92
+ Atomics.compareExchange(control, 0, (generation << 3) | INTERRUPTED, (generation << 3) | ABANDONED);
93
+ }
94
+ }
95
+ throw new Error("No permission slot available");
96
+ },
97
+ };
98
+ }
99
+
100
+ function validRule(rule) {
101
+ return rule !== null && typeof rule === "object" &&
102
+ typeof rule.permission === "string" && typeof rule.pattern === "string" && typeof rule.layer === "string";
103
+ }
104
+
105
+ export function servePermissionSlot(buffer, index, generation, decide, hooks = {}) {
106
+ const { control, request, response } = slotViews(buffer, index);
107
+ const requested = (generation << 3) | REQUESTED;
108
+ // Own the request bytes before reading them; an abandoned slot may otherwise be rewritten mid-decode.
109
+ if (Atomics.compareExchange(control, 0, requested, (generation << 3) | WRITING) !== requested) return Promise.resolve();
110
+ const finish = async (answer) => {
111
+ try {
112
+ control[2] = encode(response, answer);
113
+ } catch {
114
+ control[2] = encode(response, { action: "error" });
115
+ }
116
+ Atomics.compareExchange(control, 0, (generation << 3) | WRITING, (generation << 3) | ANSWERED);
117
+ Atomics.notify(control, 0);
118
+ };
119
+ const evaluate = () => {
120
+ try {
121
+ const result = decide(decode(request, control[1]));
122
+ return result instanceof Promise
123
+ ? result.then(finish, () => finish({ action: "error" }))
124
+ : finish(result);
125
+ } catch {
126
+ return finish({ action: "error" });
127
+ }
128
+ };
129
+ try {
130
+ const pending = hooks.claimed?.();
131
+ return pending ? pending.then(evaluate, () => finish({ action: "error" })) : evaluate();
132
+ } catch { return finish({ action: "error" }); }
133
+ }
@@ -0,0 +1 @@
1
+ import "./interpose.js";
@@ -0,0 +1,55 @@
1
+ import { MessagePort, type Transferable } from "node:worker_threads";
2
+
3
+ // Frames a host-observed cell Worker sends to its MessagePort proxy in the cell (permission-runtime.js).
4
+
5
+ /**
6
+ * Runs inside a host-process listener, so it must not throw. A raw `parentPort` frame carries no transfer
7
+ * list; its received MessagePorts are the only values that cannot be re-cloned, so they are transferred
8
+ * onward as a native Worker would deliver them. A frame that still cannot be forwarded becomes the
9
+ * cell Worker's `messageerror` event.
10
+ */
11
+ export function forwardWorkerMessage(port: MessagePort, value: unknown, transfer: Transferable[]): void {
12
+ const frame = { type: "message", value };
13
+ try {
14
+ port.postMessage(frame, transfer);
15
+ } catch {
16
+ try {
17
+ port.postMessage(frame, [...new Set([...transfer, ...receivedPorts(value)])]);
18
+ } catch (error) {
19
+ const reason = error instanceof Error ? error : new Error(String(error));
20
+ const failure = new Error(`Worker message could not be forwarded (${reason.name}): ${reason.message}`);
21
+ port.postMessage({ type: "messageerror", error: failure });
22
+ }
23
+ }
24
+ }
25
+
26
+ /** Structured clone yields only plain containers, so ports can sit in arrays, objects, Maps and Sets. */
27
+ function receivedPorts(value: unknown): MessagePort[] {
28
+ const ports: MessagePort[] = [];
29
+ const seen = new Set<object>();
30
+ const pending = [value];
31
+ while (pending.length > 0) {
32
+ const next = pending.pop();
33
+ if (next === null || typeof next !== "object" || seen.has(next)) continue;
34
+ seen.add(next);
35
+ if (next instanceof MessagePort) ports.push(next);
36
+ // Push one value at a time: spreading a collection past the engine's argument limit throws RangeError.
37
+ else if (next instanceof Map) for (const [key, entry] of next) pending.push(key, entry);
38
+ else if (next instanceof Set || Array.isArray(next)) for (const entry of next) pending.push(entry);
39
+ else if (Object.getPrototypeOf(next) === Object.prototype)
40
+ for (const entry of Object.values(next)) pending.push(entry);
41
+ }
42
+ return ports;
43
+ }
44
+
45
+ export function workerErrorFrame(value: unknown) {
46
+ const error = value instanceof Error ? value : new Error(String(value));
47
+ return {
48
+ type: "error",
49
+ error,
50
+ message: error.message,
51
+ filename: "fileName" in error ? error.fileName : "sourceURL" in error ? error.sourceURL : "",
52
+ lineno: "lineNumber" in error ? error.lineNumber : "line" in error ? error.line : 0,
53
+ colno: "columnNumber" in error ? error.columnNumber : "column" in error ? error.column : 0,
54
+ };
55
+ }
@@ -10,6 +10,14 @@ const EXIT_POLL_MS = 25;
10
10
  // A hung `ps` must not hold a cell's retirement; empty output then means nothing is signalled.
11
11
  const PS_LOOKUP_TIMEOUT_MS = 2_000;
12
12
  const ZOMBIE_CHECK_INTERVAL_MS = 250;
13
+ let cleanupExecFile = execFile;
14
+ let initializedCleanup = false;
15
+
16
+ export function initializeProcessCleanup(original) {
17
+ if (initializedCleanup) throw new Error("Process cleanup already initialized");
18
+ initializedCleanup = true;
19
+ cleanupExecFile = original;
20
+ }
13
21
 
14
22
  export function isProcessAlive(pid) {
15
23
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -184,7 +192,7 @@ async function withoutZombies(pids) {
184
192
  // `ps -p` exits non-zero when any listed pid is gone but still prints the rest.
185
193
  function ps(args, options = {}) {
186
194
  return new Promise((resolve) => {
187
- execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true, ...options }, (_error, output) =>
195
+ cleanupExecFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true, ...options }, (_error, output) =>
188
196
  resolve(typeof output === "string" ? output : String(output ?? "")),
189
197
  );
190
198
  });
@@ -177,3 +177,47 @@ export function rewriteImports(code: string): string {
177
177
  }
178
178
  return applyEdits(code, edits);
179
179
  }
180
+
181
+ /** Host-side preflight: worker assets stay dependency-free, and direct eval keeps native lexical scope. */
182
+ export function hasDynamicCodeCapability(code: string): boolean {
183
+ if (!/\b(?:eval|Function|plugin)\b/u.test(code)) return false;
184
+ const ast = parseProgram(code);
185
+ if (!ast) return false;
186
+ let unsupported = false;
187
+ walkNodes(ast.program, (node) => {
188
+ if (node.type === "MemberExpression" && !node.value.computed) {
189
+ const owner = nodeFrom(node.value.object);
190
+ const member = nodeFrom(node.value.property);
191
+ const outer = owner && nodeFrom(owner.value.object);
192
+ const inner = owner && nodeFrom(owner.value.property);
193
+ if (
194
+ member?.value.name === "plugin" &&
195
+ (owner?.value.name === "Bun" || (outer?.value.name === "globalThis" && inner?.value.name === "Bun"))
196
+ ) {
197
+ unsupported = true;
198
+ }
199
+ }
200
+ if (node.type !== "CallExpression" && node.type !== "NewExpression") return;
201
+ const callee = nodeFrom(node.value.callee);
202
+ const object = callee && nodeFrom(callee.value.object);
203
+ const property = callee && nodeFrom(callee.value.property);
204
+ const name =
205
+ callee?.type === "Identifier"
206
+ ? callee.value.name
207
+ : callee?.type === "MemberExpression" && !callee.value.computed && object?.value.name === "globalThis"
208
+ ? property?.value.name
209
+ : undefined;
210
+ if (name !== "eval" && name !== "Function") return;
211
+ const args = node.value.arguments;
212
+ if (!Array.isArray(args)) return;
213
+ for (const argument of args) {
214
+ const value = nodeFrom(argument);
215
+ if (value?.type !== "StringLiteral") unsupported = true;
216
+ }
217
+ const body = nodeFrom(args.at(-1));
218
+ if (body?.type === "StringLiteral" && typeof body.value.value === "string") {
219
+ unsupported ||= hasDynamicCodeCapability(body.value.value);
220
+ }
221
+ });
222
+ return unsupported;
223
+ }
@@ -7,6 +7,14 @@ import { execFile } from "node:child_process";
7
7
 
8
8
  const LISTING_TIMEOUT_MS = 5_000;
9
9
  const LISTING_MAX_BUFFER = 16 * 1024 * 1024;
10
+ let cleanupExecFile = execFile;
11
+ let initializedCleanup = false;
12
+
13
+ export function initializeWindowsCleanup(original) {
14
+ if (initializedCleanup) throw new Error("Windows cleanup already initialized");
15
+ initializedCleanup = true;
16
+ cleanupExecFile = original;
17
+ }
10
18
 
11
19
  const ROWS_SCRIPT = `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {
12
20
  $created = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }
@@ -75,7 +83,7 @@ export function windowsTreeKillArgs(rootPid, rows, selfPid = process.pid) {
75
83
 
76
84
  export function listWindowsRows() {
77
85
  return new Promise((resolve) => {
78
- execFile(
86
+ cleanupExecFile(
79
87
  "powershell.exe",
80
88
  ["-NoProfile", "-NonInteractive", "-Command", ROWS_SCRIPT],
81
89
  { timeout: LISTING_TIMEOUT_MS, maxBuffer: LISTING_MAX_BUFFER, windowsHide: true },
@@ -95,6 +103,6 @@ export async function killWindowsTree(pid, listing = listWindowsRows()) {
95
103
  const args = windowsTreeKillArgs(pid, await listing);
96
104
  if (args.length === 0) return;
97
105
  await new Promise((resolve) => {
98
- execFile("taskkill", args, { timeout: LISTING_TIMEOUT_MS, windowsHide: true }, () => resolve());
106
+ cleanupExecFile("taskkill", args, { timeout: LISTING_TIMEOUT_MS, windowsHide: true }, () => resolve());
99
107
  });
100
108
  }
@@ -1,4 +1,5 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { WireAuthority } from "./permission-authority.mjs";
2
3
 
3
4
  export interface WorkerCoreTransport {
4
5
  send(message: KernelToHostMessage): void;
@@ -9,6 +10,7 @@ export interface WorkerCoreTransport {
9
10
  export interface WorkerCoreOptions {
10
11
  cwd: string;
11
12
  parallelPoolWidth: number;
13
+ runAuthority?<T>(token: WireAuthority | undefined, run: () => T | Promise<T>): Promise<T>;
12
14
  }
13
15
 
14
16
  export interface WorkerCoreHandle {
@@ -50,6 +50,7 @@ export function createWorkerCore(transport, options) {
50
50
  getRuntime: () => runtime,
51
51
  emit: (message) => transport.send(message),
52
52
  nestedInvokes,
53
+ runAuthority: options.runAuthority,
53
54
  });
54
55
 
55
56
  function emit(message) {
@@ -72,7 +73,7 @@ export function createWorkerCore(transport, options) {
72
73
  activeCell = cell;
73
74
  rejections.startCell(cell);
74
75
  try {
75
- const run = runInCell(cell, () =>
76
+ const execute = () => runInCell(cell, () =>
76
77
  runtime.run(message.code, message.cellId, {
77
78
  emit: (event) => {
78
79
  if (releasedCellError() === undefined) emit(event);
@@ -80,6 +81,7 @@ export function createWorkerCore(transport, options) {
80
81
  callTool: async (toolName, args) => await callTool(toolName, args),
81
82
  }),
82
83
  );
84
+ const run = options.runAuthority ? options.runAuthority(message.authority, execute, message.code) : execute();
83
85
  const value = await Promise.race([run, release.promise]);
84
86
  rejections.finishCell(cell);
85
87
  emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs), ...memoryReport() });
@@ -174,6 +176,7 @@ export function createWorkerCore(transport, options) {
174
176
  parallelPoolWidth: options.parallelPoolWidth,
175
177
  localRoots: message.connection.localRoots,
176
178
  artifactsDir: message.connection.artifactsDir,
179
+ permissionIO: options.permissionIO,
177
180
  kernelGeneration: message.kernelGeneration ?? 1,
178
181
  hostToolNames: message.hostToolNames ?? [],
179
182
  foreignLanguageNames: message.foreignLanguageNames ?? [],
@@ -265,7 +268,7 @@ function cellInterruptedError(reason) {
265
268
 
266
269
  function bridgeError(error) {
267
270
  if (error instanceof Error) {
268
- return { name: error.name, message: error.message, stack: error.stack, ...(typeof error.code === "string" ? { code: error.code } : {}) };
271
+ return { name: error.name, message: error.message, stack: error.stack, ...(typeof error.code === "string" ? { code: error.code } : {}), ...(error.code === "cell_permission_denied" && error.rule ? { permissionRule: error.rule } : {}) };
269
272
  }
270
273
  return { message: String(error) };
271
274
  }
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
2
2
  import { syncBuiltinESMExports } from "node:module";
3
3
  import { isAbsolute, resolve, sep } from "node:path";
4
4
  import { isMainThread } from "node:worker_threads";
5
+ import { FS_PERMISSION_PATHS } from "./interpose-fs.js";
5
6
 
6
7
  // A worker thread cannot chdir (process.chdir throws ERR_WORKER_UNSUPPORTED_OPERATION under Node
7
8
  // and Bun), so every relative path a cell used resolved against the host process directory: in a
@@ -73,19 +74,22 @@ export function installSessionCwd(cwd, options) {
73
74
  const path = process.getBuiltinModule("node:path");
74
75
  const resolveFromProcess = path.resolve;
75
76
  path.resolve = (...segments) => resolveFromProcess(root, ...segments);
76
- patchFs(process.getBuiltinModule("node:fs"), process.getBuiltinModule("node:fs/promises"), at, root);
77
+ patchFs(process.getBuiltinModule("node:fs"), process.getBuiltinModule("node:fs/promises"), at, root, options?.permissionInterposed);
77
78
  patchChildProcess(process.getBuiltinModule("node:child_process"), at, root);
78
- patchBun(globalThis.Bun, at, root);
79
+ patchBun(globalThis.Bun, at, root, options?.permissionInterposed);
79
80
  syncBuiltinESMExports();
80
81
  }
81
82
 
82
- function patchFs(fs, promises, at, root) {
83
+ function patchFs(fs, promises, at, root, permissionInterposed) {
83
84
  const promiseModules = [...new Set([fs.promises, promises])];
84
85
  for (const [name, indexes] of Object.entries(FS_PATH_ARGS)) {
86
+ if (permissionInterposed && FS_PERMISSION_PATHS.has(name)) continue;
85
87
  replace(fs, name, (fn) => resolvingArgs(fn, indexes, at));
86
- replace(fs, `${name}Sync`, (fn) => resolvingArgs(fn, indexes, at));
88
+ // The early wrapper must see the requested spelling before cwd resolution (#4309).
89
+ if (!(permissionInterposed && name === "writeFile")) replace(fs, `${name}Sync`, (fn) => resolvingArgs(fn, indexes, at));
87
90
  for (const target of promiseModules) replace(target, name, (fn) => resolvingArgs(fn, indexes, at));
88
91
  }
92
+ if (permissionInterposed) return;
89
93
  replace(fs.realpath, "native", (fn) => resolvingArgs(fn, [0], at));
90
94
  replace(fs.realpathSync, "native", (fn) => resolvingArgs(fn, [0], at));
91
95
  for (const target of [fs, ...promiseModules]) {
@@ -105,15 +109,17 @@ function patchChildProcess(childProcess, at, root) {
105
109
  for (const name of CHILD_PROCESS_COMMAND_ARGS) replace(childProcess, name, (fn) => withOptionsCwd(fn, () => 1, root));
106
110
  }
107
111
 
108
- function patchBun(bun, at, root) {
112
+ function patchBun(bun, at, root, permissionInterposed) {
109
113
  if (bun === null || typeof bun !== "object") return;
110
- replace(bun, "file", (fn) => resolvingArgs(fn, [0], at));
111
- replace(bun, "write", (fn) => resolvingArgs(fn, [0], at));
114
+ if (!permissionInterposed) {
115
+ replace(bun, "file", (fn) => resolvingArgs(fn, [0], at));
116
+ replace(bun, "write", (fn) => resolvingArgs(fn, [0], at));
117
+ }
112
118
  replace(bun, "spawn", (fn) => withBunSpawnCwd(fn, root));
113
119
  replace(bun, "spawnSync", (fn) => withBunSpawnCwd(fn, root, false));
114
120
  if (typeof bun.$?.cwd === "function") bun.$.cwd(root);
115
121
  const glob = bun.Glob?.prototype;
116
- if (glob) {
122
+ if (glob && !permissionInterposed) {
117
123
  for (const name of ["scan", "scanSync"]) {
118
124
  replace(glob, name, (fn) =>
119
125
  function scan(options) {
@@ -1,23 +1 @@
1
- import { parentPort, workerData } from "node:worker_threads";
2
- import { createWorkerCore } from "./worker-core.js";
3
-
4
- if (!parentPort) throw new Error("JS kernel worker missing parentPort");
5
-
6
- const transport = {
7
- send(message) {
8
- parentPort.postMessage(message);
9
- },
10
- onMessage(handler) {
11
- parentPort.on("message", handler);
12
- return () => parentPort.off("message", handler);
13
- },
14
- close() {
15
- parentPort.close();
16
- setTimeout(() => process.exit(0), 0);
17
- },
18
- };
19
-
20
- createWorkerCore(transport, {
21
- cwd: workerData.cwd,
22
- parallelPoolWidth: workerData.parallelPoolWidth,
23
- });
1
+ import "./interpose.js";
@@ -0,0 +1,58 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
3
+
4
+ export function createFileHelpers(options, emit) {
5
+ const roots = { ...options.localRoots };
6
+ if (options.artifactsDir && !roots.local) roots.local = join(options.artifactsDir, "local");
7
+ const resolvePath = (raw, operation) => {
8
+ const match = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/iu.exec(raw);
9
+ if (!match) return isAbsolute(raw) ? normalize(raw) : resolve(options.cwd, raw);
10
+ const scheme = match[1].toLowerCase();
11
+ const root = roots[scheme];
12
+ if (!root) throw new Error(`Protocol paths are not supported by ${operation}(): ${raw}`);
13
+ let relative;
14
+ try { relative = decodeURIComponent(match[2].replaceAll("\\", "/")); }
15
+ catch (error) {
16
+ if (error instanceof URIError) throw new Error(`Invalid URL encoding in ${scheme}:// path: ${raw}`);
17
+ throw error;
18
+ }
19
+ if (isAbsolute(relative) || relative.split("/").includes("..")) throw new Error(`Path traversal is not allowed in ${scheme}:// URLs: ${raw}`);
20
+ const rootPath = resolve(root);
21
+ const path = resolve(rootPath, relative);
22
+ if (path !== rootPath && !path.startsWith(`${rootPath}${sep}`)) throw new Error(`${scheme}:// path escapes its root`);
23
+ return path;
24
+ };
25
+ return {
26
+ async read(rawPath, settings) {
27
+ const path = resolvePath(String(rawPath), "read");
28
+ const info = await stat(path);
29
+ if (info.isDirectory()) throw new Error(`Directory paths are not supported by read(): ${path}`);
30
+ let text = options.permissionIO
31
+ ? await options.permissionIO.read(String(rawPath), path) : await readFile(path, "utf8");
32
+ const offset = typeof settings.offset === "number" ? settings.offset : 1;
33
+ const limit = typeof settings.limit === "number" ? settings.limit : undefined;
34
+ if (offset > 1 || limit !== undefined) {
35
+ const start = Math.max(0, offset - 1);
36
+ text = text.split(/\r?\n/u).slice(start, limit === undefined ? undefined : start + limit).join("\n");
37
+ }
38
+ emit({ op: "read", path, bytes: info.size, chars: text.length });
39
+ return text;
40
+ },
41
+ async write(rawPath, content) {
42
+ const path = resolvePath(String(rawPath), "write");
43
+ const data = await writeData(content);
44
+ if (options.permissionIO) await options.permissionIO.write(String(rawPath), path, data);
45
+ else { await mkdir(dirname(path), { recursive: true }); await writeFile(path, data); }
46
+ emit({ op: "write", path, bytes: typeof data === "string" ? Buffer.byteLength(data) : data.byteLength });
47
+ return path;
48
+ },
49
+ };
50
+ }
51
+
52
+ async function writeData(value) {
53
+ if (typeof value === "string" || value instanceof Uint8Array) return value;
54
+ if (value instanceof Blob) return new Uint8Array(await value.arrayBuffer());
55
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
56
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
57
+ throw new TypeError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
58
+ }
@@ -4,6 +4,7 @@ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
4
4
  import { describeExit } from "../shared/kernel-death.ts";
5
5
  import type { WorkerLike } from "./inline-worker.ts";
6
6
  import type { JavaScriptKernelMode } from "./kernel-contract.ts";
7
+ import type { KernelPermissionHost } from "./permission-host.ts";
7
8
 
8
9
  declare global {
9
10
  // Inert outside the gate. Bun does not refresh named builtin constructor exports.
@@ -41,10 +42,16 @@ export function spawnNodeWorker(
41
42
  cwd: string,
42
43
  parallelPoolWidth: number,
43
44
  mode: JavaScriptKernelMode = "worker",
45
+ permissions?: KernelPermissionHost,
44
46
  ): WorkerLike {
45
- const worker = new Worker(url, { workerData: { cwd, parallelPoolWidth } });
47
+ const permission = permissions?.bootstrap();
48
+ const worker = new Worker(url, {
49
+ workerData: { cwd, parallelPoolWidth, permission },
50
+ transferList: permission ? [permission.port] : [],
51
+ });
52
+ const exited = new Promise<void>((resolve) => worker.once("exit", () => resolve()));
46
53
  globalThis.__senpiCodemodeGateObserveResource?.("workers", worker, "exit");
47
- return wrapNodeWorker(worker, mode);
54
+ return wrapNodeWorker(worker, mode, exited, permissions);
48
55
  }
49
56
 
50
57
  export function waitForReady(worker: WorkerLike, signal: AbortSignal): Promise<void> {
@@ -106,13 +113,21 @@ export function crashedResult(
106
113
  return { type: "result", cellId, ok: false, error: bridgeError(error), durationMs };
107
114
  }
108
115
 
109
- function wrapNodeWorker(worker: Worker, mode: JavaScriptKernelMode): WorkerLike {
116
+ function wrapNodeWorker(
117
+ worker: Worker,
118
+ mode: JavaScriptKernelMode,
119
+ exited: Promise<void>,
120
+ permissions?: KernelPermissionHost,
121
+ ): WorkerLike {
110
122
  return {
111
123
  mode,
112
124
  postMessage: (message, transfer) =>
113
- worker.postMessage(message, transfer === undefined ? undefined : [...transfer]),
125
+ worker.postMessage(permissions?.frame(message) ?? message, transfer === undefined ? undefined : [...transfer]),
114
126
  onMessage(handler) {
115
- const listener = (message: KernelToHostMessage): void => handler(message);
127
+ const listener = (message: KernelToHostMessage): void => {
128
+ permissions?.settle(message);
129
+ handler(message);
130
+ };
116
131
  worker.on("message", listener);
117
132
  return () => worker.off("message", listener);
118
133
  },
@@ -133,7 +148,10 @@ function wrapNodeWorker(worker: Worker, mode: JavaScriptKernelMode): WorkerLike
133
148
  };
134
149
  },
135
150
  async terminate() {
136
- await worker.terminate();
151
+ permissions?.revoke();
152
+ const children = permissions?.terminateChildren();
153
+ void worker.terminate();
154
+ await Promise.all([exited, children]);
137
155
  },
138
156
  };
139
157
  }
@@ -9,6 +9,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  // conditions), so ESM-only packages load and the first root that has the package wins.
10
10
  const RESOLVE_PACKAGE = Symbol.for("senpi.kernel.resolvePackage");
11
11
  const CONDITIONS = typeof globalThis.Bun === "object" ? ["bun", "node", "import", "default"] : ["node", "import", "default"];
12
+ let resolverIO = { readFileSync, realpathSync };
13
+ let initializedIO = false;
14
+
15
+ export function initializeResolverIO(io) {
16
+ if (initializedIO) throw new Error("Resolver IO already initialized");
17
+ initializedIO = true;
18
+ resolverIO = { readFileSync: io.readFileSync, realpathSync: io.realpathSync };
19
+ }
12
20
 
13
21
  function isFile(path) {
14
22
  try {
@@ -93,7 +101,7 @@ function legacyFile(path) {
93
101
  function packageEntry(packageDir, subpath, specifier) {
94
102
  let manifest;
95
103
  try {
96
- manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
104
+ manifest = JSON.parse(resolverIO.readFileSync(join(packageDir, "package.json"), "utf8"));
97
105
  } catch {
98
106
  return legacyFile(join(packageDir, subpath === "." ? "index.js" : subpath));
99
107
  }
@@ -139,7 +147,7 @@ export function resolvePackage(specifier, rootUrls) {
139
147
  if (entry === undefined) continue;
140
148
  let real = entry;
141
149
  try {
142
- real = realpathSync(entry);
150
+ real = resolverIO.realpathSync(entry);
143
151
  } catch {
144
152
  // A missing target fails in the import itself, with the runtime's own error.
145
153
  }