@code-yeongyu/senpi-codemode 2026.10.27-2 → 2026.10.30-1
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 +34 -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/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,205 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { MessageChannel, parentPort } from "node:worker_threads";
|
|
4
|
+
import { authorityDecision } from "./permission-authority.mjs";
|
|
5
|
+
import { createPermissionRequester } from "./permission-slots.mjs";
|
|
6
|
+
import { createCodePermissionGuard } from "./interpose-code.js";
|
|
7
|
+
|
|
8
|
+
let initialized = false;
|
|
9
|
+
|
|
10
|
+
export async function initializePermissionRuntime(install, workerData, engine) {
|
|
11
|
+
if (initialized) throw new Error("Permission runtime already initialized");
|
|
12
|
+
initialized = true;
|
|
13
|
+
const context = new AsyncLocalStorage();
|
|
14
|
+
const bound = new Map();
|
|
15
|
+
const data = workerData.permission;
|
|
16
|
+
// The bootstrap transport is host authority, not user-visible worker data.
|
|
17
|
+
delete workerData.permission;
|
|
18
|
+
const inactiveWord = new SharedArrayBuffer(4);
|
|
19
|
+
Atomics.store(new Int32Array(inactiveWord), 0, 2);
|
|
20
|
+
const kernel = data?.kernel ?? { id: "", authority: inactiveWord, rules: [] };
|
|
21
|
+
const requester = data && createPermissionRequester(data.slots, {
|
|
22
|
+
wake: (slot, generation) => data.port.postMessage({ type: "check", slot, generation }),
|
|
23
|
+
retire: () => data.port.postMessage({ type: "retire" }),
|
|
24
|
+
deadlineMs: data.deadlineMs,
|
|
25
|
+
});
|
|
26
|
+
const refusal = (reason, rule) => {
|
|
27
|
+
const error = new Error(`${reason}${rule ? ` (${rule.permission}: ${rule.pattern}, ${rule.layer})` : ""}`);
|
|
28
|
+
error.name = "CellPermissionDeniedError";
|
|
29
|
+
error.code = "cell_permission_denied";
|
|
30
|
+
error.rule = rule;
|
|
31
|
+
return error;
|
|
32
|
+
};
|
|
33
|
+
// Resolution hooks and module top-level bodies run outside the importing cell's async context. A
|
|
34
|
+
// token that is importing opens a LOADING WINDOW; an out-of-context operation is attributed to that
|
|
35
|
+
// token only while it is the sole loader, bound, active and alive, and no other bound token is active
|
|
36
|
+
// (design 6.4 / N4). Any other shape keeps today's refusal: never a pass that was a refusal before.
|
|
37
|
+
const loading = new Map();
|
|
38
|
+
const activeBit = (entry) => Atomics.load(new Int32Array(entry.authority), 0) & 1;
|
|
39
|
+
const aliveBit = (entry) => Atomics.load(new Int32Array(entry.authority), 0) & 2;
|
|
40
|
+
const load = async (thunk) => {
|
|
41
|
+
const token = context.getStore();
|
|
42
|
+
if (!token) return thunk();
|
|
43
|
+
const entry = loading.get(token.id);
|
|
44
|
+
if (entry) entry.depth += 1;
|
|
45
|
+
else loading.set(token.id, { token, depth: 1 });
|
|
46
|
+
try {
|
|
47
|
+
return await thunk();
|
|
48
|
+
} finally {
|
|
49
|
+
const current = loading.get(token.id);
|
|
50
|
+
if (current && (current.depth -= 1) === 0) loading.delete(token.id);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
Object.defineProperty(load, "pending", { value: () => loading.size });
|
|
54
|
+
Object.defineProperty(globalThis, Symbol.for("senpi.kernel.permissionLoad"), { value: load });
|
|
55
|
+
const soleLoader = () => {
|
|
56
|
+
if (loading.size !== 1) return undefined;
|
|
57
|
+
const [{ token }] = loading.values();
|
|
58
|
+
if (!bound.has(token.id) || !activeBit(token) || !aliveBit(token)) return undefined;
|
|
59
|
+
for (const other of bound.values()) if (other.id !== token.id && activeBit(other)) return undefined;
|
|
60
|
+
return token;
|
|
61
|
+
};
|
|
62
|
+
const authorize = (operation) => {
|
|
63
|
+
const token = context.getStore() ?? soleLoader();
|
|
64
|
+
const disposition = authorityDecision(token, token ? [] : [...bound.values()], kernel);
|
|
65
|
+
if (disposition === "pass") return;
|
|
66
|
+
const rule = token?.rules[0] ?? [...bound.values()].find((entry) => entry.rules.length)?.rules[0] ?? kernel.rules[0];
|
|
67
|
+
if (disposition === "refuse" || !requester || !token) throw refusal("Permission authority unavailable", rule);
|
|
68
|
+
try {
|
|
69
|
+
const { call, unsupported, granted } = operation();
|
|
70
|
+
if (unsupported) throw refusal("Operation cannot be checked", rule);
|
|
71
|
+
if (granted) return;
|
|
72
|
+
const result = requester.check({ token: token.id, call });
|
|
73
|
+
// Revocation may have raced an already-published answer.
|
|
74
|
+
if (!(Atomics.load(new Int32Array(token.authority), 0) & 2)) throw new Error("Permission authority revoked");
|
|
75
|
+
if (result.action === "deny") throw refusal("Operation denied", result.rule);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error?.code === "cell_permission_denied") throw error;
|
|
78
|
+
throw refusal(error instanceof Error ? error.message : "Permission check failed", rule);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
install(authorize);
|
|
82
|
+
const codeGuard = createCodePermissionGuard(authorize);
|
|
83
|
+
const runAuthority = async (token, run, source) => {
|
|
84
|
+
if (!token && authorityDecision(undefined, [...bound.values()], kernel) !== "pass") {
|
|
85
|
+
throw refusal("Permission authority unavailable", kernel.rules[0]);
|
|
86
|
+
}
|
|
87
|
+
if (token) bound.set(token.id, token);
|
|
88
|
+
try { return await context.run(token, () => { codeGuard.scan(source); return run(); }); }
|
|
89
|
+
finally { if (token) bound.delete(token.id); }
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Only a configured kernel replaces the constructor; the entry already put a forwarder on the module
|
|
93
|
+
// before its ESM namespace existed, and this class is what it constructs.
|
|
94
|
+
if (data && globalThis.Worker) {
|
|
95
|
+
engine.installGuardedWorker(class Worker extends EventTarget {
|
|
96
|
+
#port;
|
|
97
|
+
onmessage = null;
|
|
98
|
+
onerror = null;
|
|
99
|
+
onopen = null;
|
|
100
|
+
onclose = null;
|
|
101
|
+
onmessageerror = null;
|
|
102
|
+
constructor(script, options = {}) {
|
|
103
|
+
super();
|
|
104
|
+
const token = context.getStore();
|
|
105
|
+
if (authorityDecision(token, [...bound.values()], kernel) === "refuse") throw refusal("Permission authority unavailable", token?.rules[0] ?? kernel.rules[0]);
|
|
106
|
+
const { port1, port2 } = new MessageChannel();
|
|
107
|
+
this.#port = port1;
|
|
108
|
+
port1.on("message", (frame) => {
|
|
109
|
+
if (frame.type === "message") {
|
|
110
|
+
const event = new MessageEvent("message", { data: frame.value });
|
|
111
|
+
if (frame.value === undefined) Object.defineProperty(event, "data", { value: undefined });
|
|
112
|
+
this.onmessage?.(event);
|
|
113
|
+
this.dispatchEvent(event);
|
|
114
|
+
} else if (frame.type === "error") {
|
|
115
|
+
const event = new ErrorEvent("error", {
|
|
116
|
+
message: frame.message, error: frame.error,
|
|
117
|
+
filename: frame.filename, lineno: frame.lineno, colno: frame.colno,
|
|
118
|
+
});
|
|
119
|
+
this.onerror?.(event);
|
|
120
|
+
this.dispatchEvent(event);
|
|
121
|
+
} else if (frame.type === "messageerror") {
|
|
122
|
+
const event = new MessageEvent("messageerror", { data: frame.error });
|
|
123
|
+
this.onmessageerror?.(event);
|
|
124
|
+
this.dispatchEvent(event);
|
|
125
|
+
} else if (frame.type === "open") {
|
|
126
|
+
const event = new Event("open");
|
|
127
|
+
this.onopen?.(event);
|
|
128
|
+
this.dispatchEvent(event);
|
|
129
|
+
} else if (frame.type === "exit") {
|
|
130
|
+
port1.close();
|
|
131
|
+
const event = new Event("close");
|
|
132
|
+
this.onclose?.(event);
|
|
133
|
+
this.dispatchEvent(event);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
port1.on("messageerror", (error) => {
|
|
137
|
+
const event = new MessageEvent("messageerror", { data: error });
|
|
138
|
+
this.onmessageerror?.(event);
|
|
139
|
+
this.dispatchEvent(event);
|
|
140
|
+
});
|
|
141
|
+
const href = new URL(String(script), pathToFileURL(`${process.cwd()}/`)).href;
|
|
142
|
+
// Inactive resources belong to the kernel lifetime, not the completed run's registry entry.
|
|
143
|
+
const activeToken = token && (Atomics.load(new Int32Array(token.authority), 0) & 1) ? token.id : undefined;
|
|
144
|
+
data.port.postMessage({ type: "spawn", script: href, token: activeToken, port: port2, options }, [port2]);
|
|
145
|
+
}
|
|
146
|
+
postMessage(value, options = []) {
|
|
147
|
+
const transfer = Array.isArray(options) ? options : options.transfer ?? [];
|
|
148
|
+
this.#port.postMessage({ type: "message", value, transfer }, transfer);
|
|
149
|
+
}
|
|
150
|
+
terminate() { this.#port.postMessage({ type: "terminate" }); }
|
|
151
|
+
ref() { this.#port.ref(); return this; }
|
|
152
|
+
unref() { this.#port.unref(); return this; }
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (workerData.script) {
|
|
157
|
+
const { installSessionCwd } = await import("./worker-cwd.js");
|
|
158
|
+
installSessionCwd(data.cwd, { permissionInterposed: true });
|
|
159
|
+
globalThis.postMessage = (value, options = []) => {
|
|
160
|
+
const transfer = Array.isArray(options) ? options : options.transfer ?? [];
|
|
161
|
+
parentPort.postMessage({ key: data.messageKey, value, transfer }, transfer);
|
|
162
|
+
};
|
|
163
|
+
globalThis.close = () => parentPort.close();
|
|
164
|
+
const events = new EventTarget();
|
|
165
|
+
globalThis.addEventListener = events.addEventListener.bind(events);
|
|
166
|
+
globalThis.removeEventListener = events.removeEventListener.bind(events);
|
|
167
|
+
parentPort.on("message", (value) => context.run(data.token, () => {
|
|
168
|
+
const event = new MessageEvent("message", { data: value });
|
|
169
|
+
if (value === undefined) Object.defineProperty(event, "data", { value: undefined });
|
|
170
|
+
globalThis.onmessage?.(event);
|
|
171
|
+
events.dispatchEvent(event);
|
|
172
|
+
}));
|
|
173
|
+
engine.activate();
|
|
174
|
+
await runAuthority(data.token, async () => {
|
|
175
|
+
// User preloads still precede the script, but never precede interposition.
|
|
176
|
+
const preloads = typeof workerData.preload === "string" ? [workerData.preload] : workerData.preload ?? [];
|
|
177
|
+
const require = process.getBuiltinModule("node:module").createRequire(pathToFileURL(`${data.cwd}/__preload__.js`));
|
|
178
|
+
const { isAbsolute } = process.getBuiltinModule("node:path");
|
|
179
|
+
for (const preload of preloads) {
|
|
180
|
+
const resolved = /^[a-z][a-z\d+.-]*:/iu.test(preload) && !isAbsolute(preload) ? preload : require.resolve(preload);
|
|
181
|
+
await load(() => import(isAbsolute(resolved) ? pathToFileURL(resolved).href : resolved));
|
|
182
|
+
}
|
|
183
|
+
await load(() => import(workerData.script));
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const { createWorkerCore } = await import("./worker-core.js");
|
|
188
|
+
const { initializeResolverIO } = await import("./worker-package-resolve.js");
|
|
189
|
+
initializeResolverIO(engine.resolverIO);
|
|
190
|
+
const { initializeProcessCleanup } = await import("./process-tree.js");
|
|
191
|
+
const { initializeWindowsCleanup } = await import("./windows-tree-kill.js");
|
|
192
|
+
initializeProcessCleanup(engine.cleanupExecFile);
|
|
193
|
+
initializeWindowsCleanup(engine.cleanupExecFile);
|
|
194
|
+
createWorkerCore({
|
|
195
|
+
send: (message) => parentPort.postMessage(message),
|
|
196
|
+
onMessage(handler) { parentPort.on("message", handler); return () => parentPort.off("message", handler); },
|
|
197
|
+
close() { parentPort.close(); setTimeout(() => process.exit(0), 0); },
|
|
198
|
+
}, {
|
|
199
|
+
cwd: workerData.cwd,
|
|
200
|
+
parallelPoolWidth: workerData.parallelPoolWidth,
|
|
201
|
+
cwdInstallOptions: { permissionInterposed: true },
|
|
202
|
+
runAuthority,
|
|
203
|
+
permissionIO: engine.permissionIO,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
interface SessionKernel {
|
|
2
|
+
revoke(): void;
|
|
3
|
+
retire(): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface PermissionSession {
|
|
7
|
+
dead: boolean;
|
|
8
|
+
pending?: Promise<void>;
|
|
9
|
+
readonly kernels: Set<SessionKernel>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const sessions = new Map<string, PermissionSession>();
|
|
13
|
+
|
|
14
|
+
export function registerPermissionKernel(sessionId: string, kernel: SessionKernel): PermissionSession {
|
|
15
|
+
let session = sessions.get(sessionId);
|
|
16
|
+
if (!session) {
|
|
17
|
+
session = { dead: false, kernels: new Set() };
|
|
18
|
+
sessions.set(sessionId, session);
|
|
19
|
+
}
|
|
20
|
+
session.kernels.add(kernel);
|
|
21
|
+
return session;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function unregisterPermissionKernel(sessionId: string, kernel: SessionKernel): void {
|
|
25
|
+
const session = sessions.get(sessionId);
|
|
26
|
+
if (!session) return;
|
|
27
|
+
session.kernels.delete(kernel);
|
|
28
|
+
if (!session.dead && !session.pending && session.kernels.size === 0) sessions.delete(sessionId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** All words go DEAD synchronously, before retirement or another admission can run. */
|
|
32
|
+
export function revokeSessionKernels(sessionId: string): Promise<void> {
|
|
33
|
+
const session = sessions.get(sessionId);
|
|
34
|
+
if (!session) return Promise.resolve();
|
|
35
|
+
if (session.pending) return session.pending;
|
|
36
|
+
const kernels = [...session.kernels];
|
|
37
|
+
for (const kernel of kernels) kernel.revoke();
|
|
38
|
+
const pending = Promise.all(kernels.map((kernel) => kernel.retire())).then(() => {});
|
|
39
|
+
session.pending = pending;
|
|
40
|
+
void pending
|
|
41
|
+
.finally(() => {
|
|
42
|
+
if (session.pending === pending) session.pending = undefined;
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {});
|
|
45
|
+
return pending;
|
|
46
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface CellRule {
|
|
2
|
+
readonly permission: string;
|
|
3
|
+
readonly pattern: string;
|
|
4
|
+
readonly layer: string;
|
|
5
|
+
}
|
|
6
|
+
export interface CellCanonicalPath {
|
|
7
|
+
/** PATH_IDENTITY_VERSION of the kernel that computed it; the host ignores any other version. */
|
|
8
|
+
readonly v: number;
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly foldCase: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type CellOperation = {
|
|
13
|
+
readonly tool: "bash" | "read" | "write" | "ls";
|
|
14
|
+
readonly input: {
|
|
15
|
+
readonly command?: string;
|
|
16
|
+
/** The path as written by the cell, byte-for-byte. */
|
|
17
|
+
readonly path?: string;
|
|
18
|
+
/** The absolute string the operation hands to the runtime. */
|
|
19
|
+
readonly executedPath?: string;
|
|
20
|
+
readonly canonical?: CellCanonicalPath;
|
|
21
|
+
/** Error code when the kernel could not canonicalize the executed path. */
|
|
22
|
+
readonly unresolvable?: string;
|
|
23
|
+
};
|
|
24
|
+
readonly cwd?: string;
|
|
25
|
+
};
|
|
26
|
+
export type CellDecision = { readonly action: "pass" } | { readonly action: "deny"; readonly rule: CellRule };
|
|
27
|
+
export interface PermissionRequest {
|
|
28
|
+
readonly token: string;
|
|
29
|
+
readonly call: CellOperation;
|
|
30
|
+
}
|
|
31
|
+
export function createPermissionSlots(): SharedArrayBuffer;
|
|
32
|
+
export function permissionControl(buffer: SharedArrayBuffer, index: number): number;
|
|
33
|
+
export function interruptPermissionSlot(buffer: SharedArrayBuffer, index: number, observed: number): boolean;
|
|
34
|
+
export function interruptPermissionSlots(buffer: SharedArrayBuffer): void;
|
|
35
|
+
export function createPermissionRequester(
|
|
36
|
+
buffer: SharedArrayBuffer,
|
|
37
|
+
hooks: {
|
|
38
|
+
wake(index: number, generation: number): void;
|
|
39
|
+
wait?: typeof Atomics.wait;
|
|
40
|
+
generationLimit?: number;
|
|
41
|
+
deadlineMs?: number;
|
|
42
|
+
retire?(): void;
|
|
43
|
+
},
|
|
44
|
+
): { check(request: PermissionRequest): CellDecision };
|
|
45
|
+
export function servePermissionSlot(
|
|
46
|
+
buffer: SharedArrayBuffer,
|
|
47
|
+
index: number,
|
|
48
|
+
generation: number,
|
|
49
|
+
decide: (request: PermissionRequest) => CellDecision | Promise<CellDecision>,
|
|
50
|
+
hooks?: { claimed?(): void | Promise<void> },
|
|
51
|
+
): Promise<void>;
|
|
@@ -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
|
}
|