@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.
- package/CHANGELOG.md +49 -0
- package/package.json +4 -4
- package/src/bridge/http-server.ts +18 -5
- package/src/bridge/permission-check.ts +76 -0
- package/src/bridge/protocol.ts +7 -0
- package/src/kernels/jl/kernel.ts +3 -0
- package/src/kernels/js/context-manager.ts +26 -8
- package/src/kernels/js/group-signal-notice.js +277 -1
- package/src/kernels/js/inline-worker-entry.js +1 -23
- package/src/kernels/js/inline-worker.ts +7 -2
- package/src/kernels/js/interpose-bun.js +220 -0
- package/src/kernels/js/interpose-capabilities.js +29 -0
- package/src/kernels/js/interpose-code.js +8 -0
- package/src/kernels/js/interpose-fs.js +124 -0
- package/src/kernels/js/interpose-modules.js +107 -0
- package/src/kernels/js/interpose-paths.js +80 -0
- package/src/kernels/js/interpose-process.js +97 -0
- package/src/kernels/js/interpose-resources.js +59 -0
- package/src/kernels/js/interpose-sqlite.js +104 -0
- package/src/kernels/js/interpose.js +86 -0
- package/src/kernels/js/kernel-contract.ts +31 -0
- package/src/kernels/js/kernel-tools-pump.js +4 -2
- package/src/kernels/js/local-module-loader.ts +9 -4
- package/src/kernels/js/permission-authority.d.mts +10 -0
- package/src/kernels/js/permission-authority.mjs +11 -0
- package/src/kernels/js/permission-core.md +114 -0
- package/src/kernels/js/permission-host.ts +388 -0
- package/src/kernels/js/permission-runtime.js +205 -0
- package/src/kernels/js/permission-sessions.ts +46 -0
- package/src/kernels/js/permission-slots.d.mts +51 -0
- package/src/kernels/js/permission-slots.mjs +133 -0
- package/src/kernels/js/permission-worker-entry.js +1 -0
- package/src/kernels/js/permission-worker-frames.ts +55 -0
- package/src/kernels/js/process-tree.js +9 -1
- package/src/kernels/js/rewrite-imports.ts +44 -0
- package/src/kernels/js/windows-tree-kill.js +10 -2
- package/src/kernels/js/worker-core.d.ts +2 -0
- package/src/kernels/js/worker-core.js +5 -2
- package/src/kernels/js/worker-cwd.js +14 -8
- package/src/kernels/js/worker-entry.js +1 -23
- package/src/kernels/js/worker-file-helpers.js +58 -0
- package/src/kernels/js/worker-host.ts +24 -6
- package/src/kernels/js/worker-package-resolve.js +10 -2
- package/src/kernels/js/worker-runtime.js +5 -65
- package/src/kernels/js/worker-slot.ts +5 -1
- package/src/kernels/js/worker-startup.ts +7 -5
- package/src/kernels/js/worker-webview.js +17 -16
- package/src/kernels/py/interpose.py +765 -0
- package/src/kernels/py/kernel-contract.ts +4 -0
- package/src/kernels/py/kernel-tools-host.ts +5 -1
- package/src/kernels/py/kernel.ts +45 -5
- package/src/kernels/py/path_identity.py +162 -0
- package/src/kernels/py/path_identity_posix.py +74 -0
- package/src/kernels/py/path_identity_windows.py +93 -0
- package/src/kernels/py/permission-core.md +103 -0
- package/src/kernels/py/permission-host.ts +198 -0
- package/src/kernels/py/permission_bridge.py +145 -0
- package/src/kernels/py/prelude.py +40 -5
- package/src/kernels/py/transport.ts +15 -8
- package/src/kernels/rb/kernel.ts +3 -0
- package/src/kernels/shared/invocation-authority.ts +59 -0
- package/src/kernels/shared/kernel-tools-pump.ts +22 -1
- package/src/kernels/shared/path-identity.d.mts +42 -0
- package/src/kernels/shared/path-identity.mjs +149 -0
- package/src/kernels/shared/subprocess-contract.ts +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 =>
|
|
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
|
-
|
|
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
|
}
|