@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
|
@@ -2,7 +2,31 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
|
2
2
|
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { EvalKernelRunInput } from "../../tool/types.ts";
|
|
4
4
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
5
|
+
import type { InvocationPermissions } from "../shared/invocation-authority.ts";
|
|
5
6
|
import type { JavaScriptInterruptBounds } from "./interrupt-bounds.ts";
|
|
7
|
+
import type { CellDecision, CellOperation, CellRule } from "./permission-slots.mjs";
|
|
8
|
+
|
|
9
|
+
export interface CellAuthorityToken {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly sessionId: string;
|
|
12
|
+
readonly cwd: string;
|
|
13
|
+
readonly authority: SharedArrayBuffer;
|
|
14
|
+
readonly rules: readonly CellRule[];
|
|
15
|
+
readonly signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type CellDecider = (token: CellAuthorityToken, call: CellOperation) => CellDecision | Promise<CellDecision>;
|
|
19
|
+
|
|
20
|
+
export interface CellPermissionState {
|
|
21
|
+
readonly epoch: number;
|
|
22
|
+
readonly active: boolean;
|
|
23
|
+
readonly rules: readonly CellRule[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RevocationFailedEvent {
|
|
27
|
+
readonly sessionId: string;
|
|
28
|
+
readonly kernelId: string;
|
|
29
|
+
}
|
|
6
30
|
|
|
7
31
|
export type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
|
|
8
32
|
export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
|
|
@@ -20,6 +44,13 @@ export function resolveKernelToolNameSource(names?: KernelToolNameSource): strin
|
|
|
20
44
|
export interface JavaScriptKernelOptions {
|
|
21
45
|
readonly sessionId: string;
|
|
22
46
|
readonly cwd: string;
|
|
47
|
+
readonly permissions?: InvocationPermissions & {
|
|
48
|
+
readonly decide: CellDecider;
|
|
49
|
+
/** Slot wait deadline in ms; production keeps the 2s default. Integration suites raise it above host load. */
|
|
50
|
+
readonly checkDeadlineMs?: number;
|
|
51
|
+
};
|
|
52
|
+
/** Integration marks direct tool admission DEAD too; the kernel host fences cells itself. */
|
|
53
|
+
readonly onRevocationFailed?: (event: RevocationFailedEvent) => void;
|
|
23
54
|
/**
|
|
24
55
|
* Collects the exit status of a retired worker's children once they are killed (#1962): no thread is left to
|
|
25
56
|
* wait on them. Absent, they stay zombies until the host's child reaper or process exit collects them.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { kernelToolCallContext } from "./kernel-tools-context.js";
|
|
2
2
|
import { kernelToolError } from "./kernel-tools-errors.js";
|
|
3
3
|
|
|
4
|
-
export function createKernelToolPump({ getRuntime, emit, nestedInvokes }) {
|
|
4
|
+
export function createKernelToolPump({ getRuntime, emit, nestedInvokes, runAuthority }) {
|
|
5
5
|
async function describe(message) {
|
|
6
6
|
try {
|
|
7
7
|
const runtime = getRuntime();
|
|
@@ -20,7 +20,7 @@ export function createKernelToolPump({ getRuntime, emit, nestedInvokes }) {
|
|
|
20
20
|
try {
|
|
21
21
|
const runtime = getRuntime();
|
|
22
22
|
if (!runtime?.kernelTools) throw kernelToolError("tools_unavailable", "JS runtime not initialized");
|
|
23
|
-
const
|
|
23
|
+
const execute = () => kernelToolCallContext.run(
|
|
24
24
|
// The scope lives only in this store: it is gone once the call settles, and it never
|
|
25
25
|
// reaches the top-level cell context or the next invocation (#1731).
|
|
26
26
|
{
|
|
@@ -42,6 +42,7 @@ export function createKernelToolPump({ getRuntime, emit, nestedInvokes }) {
|
|
|
42
42
|
controller.signal,
|
|
43
43
|
),
|
|
44
44
|
);
|
|
45
|
+
const value = await (runAuthority ? runAuthority(message.authority, execute) : execute());
|
|
45
46
|
emit({ type: "kernel-tool-invoke-reply", requestId: message.requestId, ok: true, value });
|
|
46
47
|
} catch (error) {
|
|
47
48
|
emit({ type: "kernel-tool-invoke-reply", requestId: message.requestId, ok: false, error: pumpError(error) });
|
|
@@ -100,6 +101,7 @@ function pumpError(error) {
|
|
|
100
101
|
stack: error.stack,
|
|
101
102
|
...(typeof error.code === "string" ? { code: error.code } : {}),
|
|
102
103
|
...(isPlainRecord(error.details) ? { details: error.details } : {}),
|
|
104
|
+
...(error.code === "cell_permission_denied" && error.rule ? { details: { permissionRule: error.rule } } : {}),
|
|
103
105
|
};
|
|
104
106
|
}
|
|
105
107
|
return { message: String(error) };
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "../../bridge/reserved.ts";
|
|
12
12
|
import { type KernelPreludePlan, KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
|
|
13
13
|
import type { JavaScriptKernelOptions as BaseJavaScriptKernelOptions } from "./kernel-contract.ts";
|
|
14
|
-
import { rewriteImports } from "./rewrite-imports.ts";
|
|
14
|
+
import { hasDynamicCodeCapability, rewriteImports } from "./rewrite-imports.ts";
|
|
15
15
|
|
|
16
16
|
const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
|
|
17
17
|
|
|
@@ -77,8 +77,10 @@ function loaderPrelude(context: RuntimeModuleContext): string {
|
|
|
77
77
|
"globalThis.__senpi_import__ = async (source, options) => {",
|
|
78
78
|
" const context = globalThis.__senpi_module_context__;",
|
|
79
79
|
" const specifier = String(source);",
|
|
80
|
+
" const permissionImport = globalThis[Symbol.for('senpi.kernel.permissionImport')];",
|
|
81
|
+
" permissionImport?.(specifier, new URL('__cell__.js', context.cwdUrl).href);",
|
|
80
82
|
" const kernelBun = globalThis[Symbol.for('senpi.kernel.bun')];",
|
|
81
|
-
" if (specifier === 'bun' && kernelBun) return { ...
|
|
83
|
+
" if (specifier === 'bun' && kernelBun) return { ...kernelBun, default: kernelBun };",
|
|
82
84
|
// Under Bun a cell's named import (`import { spawn } from 'node:child_process'`) binds the builtin's original
|
|
83
85
|
// export even after the worker patched the module object (worker-cwd.js), so the session cwd and the cell
|
|
84
86
|
// children's own process group (senpi#2995) were skipped. The patched module objects are served instead.
|
|
@@ -106,7 +108,10 @@ function loaderPrelude(context: RuntimeModuleContext): string {
|
|
|
106
108
|
" const urlModule = await import('node:url');",
|
|
107
109
|
" target = urlModule.pathToFileURL(specifier).href;",
|
|
108
110
|
" }",
|
|
109
|
-
|
|
111
|
+
// The import and everything it loads (resolution hooks, module bodies) run under the cell's loading window.
|
|
112
|
+
" const permissionLoad = globalThis[Symbol.for('senpi.kernel.permissionLoad')];",
|
|
113
|
+
" const run = (resolved) => (options === undefined ? import(resolved) : import(resolved, options));",
|
|
114
|
+
" const load = (resolved) => (permissionLoad ? permissionLoad(() => run(resolved)) : run(resolved));",
|
|
110
115
|
" const resolvePackage = globalThis[Symbol.for('senpi.kernel.resolvePackage')];",
|
|
111
116
|
" if (target === specifier && typeof resolvePackage === 'function') {",
|
|
112
117
|
" const resolved = resolvePackage(specifier, [context.cwdUrl, context.packageRootUrl]);",
|
|
@@ -147,6 +152,6 @@ export class LocalModuleLoader {
|
|
|
147
152
|
const cwdUrl = sourceFile === undefined ? this.#cwdUrl : directoryUrl(dirname(sourceFile));
|
|
148
153
|
const base = `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(cwdUrl)}, packageRootUrl: ${JSON.stringify(packageRoot === undefined ? null : directoryUrl(packageRoot))} };`;
|
|
149
154
|
const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
|
|
150
|
-
return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
|
|
155
|
+
return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), dynamicCodeCapability: hasDynamicCodeCapability(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
|
|
151
156
|
}
|
|
152
157
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface WireAuthority {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly authority: SharedArrayBuffer;
|
|
4
|
+
readonly rules: readonly import("./permission-slots.mjs").CellRule[];
|
|
5
|
+
}
|
|
6
|
+
export function authorityDecision(
|
|
7
|
+
current: WireAuthority | undefined,
|
|
8
|
+
bound: readonly WireAuthority[],
|
|
9
|
+
kernel: WireAuthority,
|
|
10
|
+
): "pass" | "check" | "refuse";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function authorityDecision(current, bound, kernel) {
|
|
2
|
+
if (!(Atomics.load(new Int32Array(kernel.authority), 0) & 2)) return "refuse";
|
|
3
|
+
if (current) {
|
|
4
|
+
const word = Atomics.load(new Int32Array(current.authority), 0);
|
|
5
|
+
if (!(word & 2)) return "refuse";
|
|
6
|
+
return word & 1 ? "check" : "pass";
|
|
7
|
+
}
|
|
8
|
+
const authorities = bound.length ? bound : [kernel];
|
|
9
|
+
return authorities.every((token) => (Atomics.load(new Int32Array(token.authority), 0) & 3) === 2)
|
|
10
|
+
? "pass" : "refuse";
|
|
11
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# JS kernel permission core
|
|
2
|
+
|
|
3
|
+
This is the core portion of senpi#4309. It provides the transport and lifetime
|
|
4
|
+
contract for a host `CellDecider`; it does not connect the permission extension.
|
|
5
|
+
|
|
6
|
+
## Host integration
|
|
7
|
+
|
|
8
|
+
`JavaScriptKernelOptions.permissions` supplies a current epoch snapshot and
|
|
9
|
+
`decide(token, call)`. Tokens identify the invoking session and execution cwd.
|
|
10
|
+
The decision is either `pass` or `deny` with a rule's permission, pattern and
|
|
11
|
+
layer. The core does not interpret rule syntax or implement allow/ask precedence.
|
|
12
|
+
|
|
13
|
+
The host calls `revokeSessionKernels(sessionId)` when effective enforcement
|
|
14
|
+
changes. The call kills every authority word synchronously, interrupts parked
|
|
15
|
+
checks and waits for retirement. New cells wait for this completion. An
|
|
16
|
+
abandoned retirement permanently fences cell admission for the session and
|
|
17
|
+
emits `onRevocationFailed({ sessionId, kernelId })`.
|
|
18
|
+
|
|
19
|
+
The integration PR must connect that event to direct-tool admission. Direct
|
|
20
|
+
tools are not fenced by this core alone.
|
|
21
|
+
|
|
22
|
+
The coding-agent dispatcher stamps `options.invoker.sessionId` from its own
|
|
23
|
+
SessionManager. Neither cell data nor tool arguments can override that identity.
|
|
24
|
+
Registered extension event handlers also bind their own host context, rather
|
|
25
|
+
than inheriting a parent task tool's identity when a child emits an event.
|
|
26
|
+
The shared host pump forwards these options to `resolveInvocationToken` before
|
|
27
|
+
posting an invocation frame. Absent or owner identity mints an owner token;
|
|
28
|
+
another session is refused before the body runs if either session enforces,
|
|
29
|
+
with `reason: "cross_session_authority"` and the enforcing session identified.
|
|
30
|
+
The attached `rule` names a deny rule activating that session's enforcement;
|
|
31
|
+
it does not claim that this rule matched an operation inside the unexecuted
|
|
32
|
+
tool body. Otherwise the token identifies the invoker.
|
|
33
|
+
Python applies the same cross-session check before posting to the interpreter.
|
|
34
|
+
|
|
35
|
+
Live kernel owners publish their current permission snapshots to the host-only
|
|
36
|
+
invocation registry. The permission integration supplies `permissions.resolveSession`
|
|
37
|
+
for sessions without a kernel, returning that session's current state and cwd.
|
|
38
|
+
Without integration or a registered kernel, a session has no enforcing state in
|
|
39
|
+
this core and retains the existing inactive behavior. No invoker identity or
|
|
40
|
+
session resolver is accepted by the worker protocol. An active JS invocation
|
|
41
|
+
also binds its destination kernel to the invoker's revocation lifecycle. A
|
|
42
|
+
settled inactive invocation keeps that binding until the worker retires, so
|
|
43
|
+
the invoker's revocation still reaches the continuations it left behind.
|
|
44
|
+
|
|
45
|
+
## Runtime invariants
|
|
46
|
+
|
|
47
|
+
Both worker entries load `interpose.js` first. It captures and patches builtins
|
|
48
|
+
before importing the runtime, including before a package can obtain a static
|
|
49
|
+
builtin binding. Originals stay in module-local closures. The one-time runtime
|
|
50
|
+
initializer does not export a token store or original functions.
|
|
51
|
+
|
|
52
|
+
Operation families are installed by `interpose-fs.js`, `interpose-process.js`,
|
|
53
|
+
`interpose-bun.js` and `interpose-sqlite.js` through the same check closure.
|
|
54
|
+
Arguments are analyzed only on the active path. Direct
|
|
55
|
+
argv becomes shell-quoted command text; a shell invocation uses the text the
|
|
56
|
+
shell actually receives. Requested path spelling is preserved for policy;
|
|
57
|
+
execution still resolves relative paths against the kernel cwd.
|
|
58
|
+
|
|
59
|
+
Each worker owns four bounded shared-memory slots. A generation-tagged CAS
|
|
60
|
+
claims each transition. The host claims WRITING before decoding the request or
|
|
61
|
+
calling the decider, so the requester cannot rewrite bytes the host is reading.
|
|
62
|
+
A timed-out WRITING slot remains host-owned, while unclaimed and interrupted
|
|
63
|
+
requests become ABANDONED. A stale interrupt cannot affect a later generation.
|
|
64
|
+
Checks have a 2000 ms worker-side deadline and fail closed on
|
|
65
|
+
transport, sizing, decoding or authority errors.
|
|
66
|
+
|
|
67
|
+
Authority follows `AsyncLocalStorage` rather than a mutable current-cell global.
|
|
68
|
+
No-token operations pass only when every bound token is alive and inactive;
|
|
69
|
+
idle code consults the kernel word. Settlement kills active tokens, but leaves
|
|
70
|
+
inactive continuations alive without retaining them in the host registry.
|
|
71
|
+
An interrupt or timeout releases tokens the same way. Cancellation still
|
|
72
|
+
kills the invocation token. A dead kernel word overrides
|
|
73
|
+
every token, including inactive tokens from settled runs. A token's death
|
|
74
|
+
takes precedence over its inactive bit.
|
|
75
|
+
|
|
76
|
+
Cell-created global Workers use a private MessagePort proxy. The host constructs
|
|
77
|
+
the instrumented worker and observes its exit directly, including nested
|
|
78
|
+
workers. The proxy forwards messages and transfer lists; each child gets its
|
|
79
|
+
own slot pool and the parent's shared authority. Retirement waits for observed
|
|
80
|
+
exit, not merely a termination request or a DEAD word. Inactive Workers use the
|
|
81
|
+
kernel lifetime, so later cells can use them or create them from a continuation.
|
|
82
|
+
User workerData is restored before user imports and preloads. User frames,
|
|
83
|
+
including primitives and objects resembling transport frames, remain data;
|
|
84
|
+
internal transfer envelopes carry a per-Worker key. MessagePorts transferred
|
|
85
|
+
through a raw `parentPort` frame are transferred on to the cell; a frame the
|
|
86
|
+
host cannot forward becomes a `messageerror` event and never throws in the
|
|
87
|
+
host listener. Error events carry the error
|
|
88
|
+
and its available source coordinates, and observed exit dispatches close.
|
|
89
|
+
|
|
90
|
+
## Scope boundary
|
|
91
|
+
|
|
92
|
+
PR 3 extends the operation table without changing authority or slot messages.
|
|
93
|
+
Resource tables retain each open's path and access modes; locally granted
|
|
94
|
+
operations still check authority liveness before using a resource. Engine
|
|
95
|
+
package-resolution reads and process retirement use one-time, closure-private
|
|
96
|
+
originals, rather than gaining an exemption for an installation path.
|
|
97
|
+
|
|
98
|
+
The module plugin observes resolution only and lets the native loader handle
|
|
99
|
+
attributes. Cell import and require routes also check relative and absolute
|
|
100
|
+
paths. Dynamic-code preflight runs in the host AST preparer so workers remain
|
|
101
|
+
dependency-free and direct eval retains lexical scope.
|
|
102
|
+
|
|
103
|
+
Native capability residual: Bun 1.4.4 makes both the global `Bun` binding and `Bun.plugin`
|
|
104
|
+
non-writable and non-configurable. The cell-local binding can guard plugin calls;
|
|
105
|
+
native libraries' plugin calls and non-literal dynamic evaluation remain available.
|
|
106
|
+
The native-loader regression matrix verifies that code reached through these routes
|
|
107
|
+
still encounters the enforced synchronous/asynchronous spawn and write checks.
|
|
108
|
+
The engine adds no onLoad transform. Final acceptance of this named residual
|
|
109
|
+
remains with the lead.
|
|
110
|
+
This core is not a sandbox and does not claim read completeness.
|
|
111
|
+
|
|
112
|
+
Known limitation: in natively loaded dependencies, Bun.plugin registration and non-literal eval are not refused (Bun 1.4.4 makes Bun.plugin immutable); code loaded that way still runs under the enforced spawn and write checks; tracked in #4380.
|
|
113
|
+
|
|
114
|
+
Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380.
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { MessageChannel, type MessagePort, type Transferable, Worker, type WorkerOptions } from "node:worker_threads";
|
|
2
|
+
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
|
+
import {
|
|
4
|
+
invocationSession,
|
|
5
|
+
type InvocationSession,
|
|
6
|
+
registerInvocationSession,
|
|
7
|
+
} from "../shared/invocation-authority.ts";
|
|
8
|
+
import type { WorkerRetirement } from "./interrupt-bounds.ts";
|
|
9
|
+
import type { CellAuthorityToken, JavaScriptKernelOptions } from "./kernel-contract.ts";
|
|
10
|
+
import type { KernelToolsInvokeOptions, KernelToolsInvokeRequest } from "./kernel-tools-types.ts";
|
|
11
|
+
import { type PermissionSession, registerPermissionKernel, unregisterPermissionKernel } from "./permission-sessions.ts";
|
|
12
|
+
import { forwardWorkerMessage, workerErrorFrame } from "./permission-worker-frames.ts";
|
|
13
|
+
import {
|
|
14
|
+
createPermissionSlots,
|
|
15
|
+
interruptPermissionSlots,
|
|
16
|
+
type PermissionRequest,
|
|
17
|
+
servePermissionSlot,
|
|
18
|
+
} from "./permission-slots.mjs";
|
|
19
|
+
|
|
20
|
+
export { revokeSessionKernels } from "./permission-sessions.ts";
|
|
21
|
+
|
|
22
|
+
type WorkerCommand =
|
|
23
|
+
| { type: "check"; slot: number; generation: number }
|
|
24
|
+
| { type: "retire" }
|
|
25
|
+
| {
|
|
26
|
+
type: "spawn";
|
|
27
|
+
script: string;
|
|
28
|
+
token?: string;
|
|
29
|
+
port: MessagePort;
|
|
30
|
+
options: WorkerOptions & { preload?: string | string[] };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class KernelPermissionHost {
|
|
34
|
+
readonly kernelId = crypto.randomUUID();
|
|
35
|
+
readonly #options: JavaScriptKernelOptions;
|
|
36
|
+
readonly #session: PermissionSession;
|
|
37
|
+
readonly #retire: () => Promise<WorkerRetirement>;
|
|
38
|
+
readonly #tokens = new Map<string, CellAuthorityToken>();
|
|
39
|
+
readonly #runs = new Map<string, CellAuthorityToken>();
|
|
40
|
+
readonly #invocations = new Map<string, CellAuthorityToken>();
|
|
41
|
+
readonly #invokerBindings = new Map<string, number>();
|
|
42
|
+
readonly #retained = new Map<string, Set<CellAuthorityToken>>();
|
|
43
|
+
readonly #pools = new Set<SharedArrayBuffer>();
|
|
44
|
+
readonly #ports = new Set<MessagePort>();
|
|
45
|
+
readonly #children = new Map<Worker, Promise<void>>();
|
|
46
|
+
readonly #releaseSession: () => void;
|
|
47
|
+
#kernel: CellAuthorityToken;
|
|
48
|
+
#revoked = false;
|
|
49
|
+
#retiring?: Promise<void>;
|
|
50
|
+
|
|
51
|
+
constructor(options: JavaScriptKernelOptions, retire: () => Promise<WorkerRetirement>) {
|
|
52
|
+
this.#options = options;
|
|
53
|
+
this.#retire = retire;
|
|
54
|
+
this.#kernel = this.#mint();
|
|
55
|
+
this.#session = registerPermissionKernel(options.sessionId, this);
|
|
56
|
+
this.#releaseSession = registerInvocationSession(options.sessionId, () => this.#snapshot());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get blocked(): boolean {
|
|
60
|
+
return this.#revoked || this.#session.dead || this.#session.pending !== undefined || this.#retiring !== undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async admit(): Promise<void> {
|
|
64
|
+
await this.#session.pending;
|
|
65
|
+
await this.#retiring;
|
|
66
|
+
this.assertSessionLive();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
assertSessionLive(): void {
|
|
70
|
+
if (this.#session.dead) throw new Error("Permission authority unavailable; restart the session");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#snapshot(): InvocationSession {
|
|
74
|
+
return {
|
|
75
|
+
cwd: this.#options.cwd,
|
|
76
|
+
state: this.#options.permissions?.state() ?? { epoch: 0, active: false, rules: [] },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#mint(
|
|
81
|
+
signal?: AbortSignal,
|
|
82
|
+
session = { sessionId: this.#options.sessionId, ...this.#snapshot() },
|
|
83
|
+
): CellAuthorityToken {
|
|
84
|
+
const { state } = session;
|
|
85
|
+
if (!Number.isInteger(state.epoch) || state.epoch < 0 || state.epoch >= 2 ** 29)
|
|
86
|
+
throw new Error("Invalid permission epoch");
|
|
87
|
+
const authority = new SharedArrayBuffer(4);
|
|
88
|
+
Atomics.store(new Int32Array(authority), 0, (state.epoch << 2) | 2 | Number(state.active));
|
|
89
|
+
return {
|
|
90
|
+
id: crypto.randomUUID(),
|
|
91
|
+
sessionId: session.sessionId,
|
|
92
|
+
cwd: session.cwd,
|
|
93
|
+
authority,
|
|
94
|
+
rules: state.rules,
|
|
95
|
+
signal,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolve host-only caller identity before admitting any invocation frame to the worker. */
|
|
100
|
+
resolveInvocationToken(
|
|
101
|
+
_request: KernelToolsInvokeRequest,
|
|
102
|
+
options?: AbortSignal | KernelToolsInvokeOptions,
|
|
103
|
+
): CellAuthorityToken {
|
|
104
|
+
const signal = options && ("aborted" in options ? options : options.signal);
|
|
105
|
+
const session = invocationSession(
|
|
106
|
+
this.#options.sessionId,
|
|
107
|
+
this.#snapshot(),
|
|
108
|
+
options,
|
|
109
|
+
this.#options.permissions?.resolveSession,
|
|
110
|
+
);
|
|
111
|
+
const token = this.#mint(signal, session);
|
|
112
|
+
if (session.sessionId !== this.#options.sessionId) {
|
|
113
|
+
const binding = registerPermissionKernel(session.sessionId, this);
|
|
114
|
+
if (binding.dead || binding.pending) {
|
|
115
|
+
if (!this.#invokerBindings.has(session.sessionId)) unregisterPermissionKernel(session.sessionId, this);
|
|
116
|
+
throw new Error("Permission authority unavailable");
|
|
117
|
+
}
|
|
118
|
+
this.#invokerBindings.set(session.sessionId, (this.#invokerBindings.get(session.sessionId) ?? 0) + 1);
|
|
119
|
+
}
|
|
120
|
+
this.#tokens.set(token.id, token);
|
|
121
|
+
return token;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
invocation(request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions): () => void {
|
|
125
|
+
const token = this.resolveInvocationToken(request, options);
|
|
126
|
+
this.#invocations.set(request.call_id, token);
|
|
127
|
+
const cancel = () => this.#kill(token);
|
|
128
|
+
token.signal?.addEventListener("abort", cancel, { once: true });
|
|
129
|
+
if (token.signal?.aborted) cancel();
|
|
130
|
+
return () => {
|
|
131
|
+
token.signal?.removeEventListener("abort", cancel);
|
|
132
|
+
this.#invocations.delete(request.call_id);
|
|
133
|
+
this.#release(token);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
frame(
|
|
138
|
+
message: HostToKernelMessage,
|
|
139
|
+
): HostToKernelMessage & { authority?: ReturnType<KernelPermissionHost["wireToken"]> } {
|
|
140
|
+
let token: CellAuthorityToken | undefined;
|
|
141
|
+
if (message.type === "run") {
|
|
142
|
+
token = this.#mint();
|
|
143
|
+
this.#tokens.set(token.id, token);
|
|
144
|
+
this.#runs.set(message.cellId, token);
|
|
145
|
+
} else if (message.type === "kernel-tool-invoke") {
|
|
146
|
+
token = this.#invocations.get(message.call_id);
|
|
147
|
+
} else if (message.type === "interrupt" || message.type === "close") {
|
|
148
|
+
// An interrupt (including a timeout) ends work like settlement: inactive continuations survive.
|
|
149
|
+
for (const token of this.#tokens.values()) {
|
|
150
|
+
if (message.type === "interrupt") this.#release(token);
|
|
151
|
+
else this.#kill(token);
|
|
152
|
+
}
|
|
153
|
+
if (message.type === "close") this.#endRetained();
|
|
154
|
+
for (const pool of this.#pools) interruptPermissionSlots(pool);
|
|
155
|
+
}
|
|
156
|
+
return token ? { ...message, authority: this.wireToken(token) } : message;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
settle(message: KernelToHostMessage): void {
|
|
160
|
+
if (message.type !== "result") return;
|
|
161
|
+
const token = this.#runs.get(message.cellId);
|
|
162
|
+
this.#runs.delete(message.cellId);
|
|
163
|
+
if (token) this.#release(token);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#release(token: CellAuthorityToken): void {
|
|
167
|
+
if (!this.#tokens.delete(token.id)) return;
|
|
168
|
+
// Settled no-deny work keeps its async resources; the kernel word still revokes them. A live
|
|
169
|
+
// inactive token is retained rather than forgotten, and keeps its invoker binding for as long as it
|
|
170
|
+
// is alive, so that session's revocation still reaches the continuation it left behind. Nothing
|
|
171
|
+
// reports when that continuation actually finishes, so the binding ends with the token, which is
|
|
172
|
+
// what closing the kernel does; it does not end earlier.
|
|
173
|
+
const word = Atomics.load(new Int32Array(token.authority), 0);
|
|
174
|
+
if (word & 2 && !(word & 1)) {
|
|
175
|
+
if (this.#invokerBindings.has(token.sessionId)) {
|
|
176
|
+
const held = this.#retained.get(token.sessionId) ?? new Set<CellAuthorityToken>();
|
|
177
|
+
held.add(token);
|
|
178
|
+
this.#retained.set(token.sessionId, held);
|
|
179
|
+
}
|
|
180
|
+
this.#sweepRetained(token.sessionId);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.#kill(token);
|
|
184
|
+
this.#unbind(token.sessionId);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A retained token holds its own binding and gives it back once that token is dead, not before. */
|
|
188
|
+
#sweepRetained(sessionId: string): void {
|
|
189
|
+
const held = this.#retained.get(sessionId);
|
|
190
|
+
if (!held) return;
|
|
191
|
+
for (const token of held) {
|
|
192
|
+
if (Atomics.load(new Int32Array(token.authority), 0) & 2) continue;
|
|
193
|
+
held.delete(token);
|
|
194
|
+
this.#unbind(sessionId);
|
|
195
|
+
}
|
|
196
|
+
if (held.size === 0) this.#retained.delete(sessionId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Closing the kernel ends every continuation it was still holding open for an invoker. */
|
|
200
|
+
#endRetained(): void {
|
|
201
|
+
for (const [sessionId, held] of this.#retained) {
|
|
202
|
+
for (const token of held) this.#kill(token);
|
|
203
|
+
this.#sweepRetained(sessionId);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#unbind(sessionId: string): void {
|
|
208
|
+
const count = this.#invokerBindings.get(sessionId);
|
|
209
|
+
if (count === undefined) return;
|
|
210
|
+
if (count > 1) this.#invokerBindings.set(sessionId, count - 1);
|
|
211
|
+
else {
|
|
212
|
+
this.#invokerBindings.delete(sessionId);
|
|
213
|
+
unregisterPermissionKernel(sessionId, this);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Worker retirement ends every continuation, so no invoker revocation needs to reach this kernel. */
|
|
218
|
+
#unbindInvokers(): void {
|
|
219
|
+
for (const sessionId of this.#invokerBindings.keys()) unregisterPermissionKernel(sessionId, this);
|
|
220
|
+
this.#invokerBindings.clear();
|
|
221
|
+
this.#retained.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#kill(token: CellAuthorityToken): void {
|
|
225
|
+
Atomics.and(new Int32Array(token.authority), 0, ~2);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
wireToken(token: CellAuthorityToken) {
|
|
229
|
+
return { id: token.id, authority: token.authority, rules: token.rules };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
bootstrap() {
|
|
233
|
+
if (this.#revoked) {
|
|
234
|
+
this.#kernel = this.#mint();
|
|
235
|
+
this.#revoked = false;
|
|
236
|
+
}
|
|
237
|
+
return this.#channel();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#channel(token?: CellAuthorityToken) {
|
|
241
|
+
const slots = createPermissionSlots();
|
|
242
|
+
const { port1, port2 } = new MessageChannel();
|
|
243
|
+
this.#pools.add(slots);
|
|
244
|
+
this.#ports.add(port1);
|
|
245
|
+
port1.on("message", (message: WorkerCommand) => {
|
|
246
|
+
if (message.type === "check") {
|
|
247
|
+
void servePermissionSlot(slots, message.slot, message.generation, (request) => this.#decide(request));
|
|
248
|
+
} else if (message.type === "retire") {
|
|
249
|
+
this.revoke();
|
|
250
|
+
void this.retire();
|
|
251
|
+
} else if (message.type === "spawn") {
|
|
252
|
+
this.#spawnChild(message);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
port1.on("close", () => {
|
|
256
|
+
this.#pools.delete(slots);
|
|
257
|
+
this.#ports.delete(port1);
|
|
258
|
+
});
|
|
259
|
+
return {
|
|
260
|
+
port: port2,
|
|
261
|
+
slots,
|
|
262
|
+
kernel: this.wireToken(this.#kernel),
|
|
263
|
+
deadlineMs: this.#options.permissions?.checkDeadlineMs,
|
|
264
|
+
token: token && this.wireToken(token),
|
|
265
|
+
cwd: this.#options.cwd,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async #decide(request: PermissionRequest) {
|
|
270
|
+
const token = this.#tokens.get(request.token);
|
|
271
|
+
if (!token || this.#revoked || this.#session.dead || !(Atomics.load(new Int32Array(token.authority), 0) & 2)) {
|
|
272
|
+
throw new Error("Permission authority unavailable");
|
|
273
|
+
}
|
|
274
|
+
const decide = this.#options.permissions?.decide;
|
|
275
|
+
if (!decide) throw new Error("Cell decider unavailable");
|
|
276
|
+
const answer = await decide(token, request.call);
|
|
277
|
+
if (!(Atomics.load(new Int32Array(token.authority), 0) & 2)) throw new Error("Permission authority revoked");
|
|
278
|
+
return answer;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#spawnChild(message: Extract<WorkerCommand, { type: "spawn" }>): void {
|
|
282
|
+
const token = message.token ? this.#tokens.get(message.token) : undefined;
|
|
283
|
+
if (this.blocked || (message.token && !token)) {
|
|
284
|
+
message.port.close();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const permission = this.#channel(token);
|
|
288
|
+
const messageKey = crypto.randomUUID();
|
|
289
|
+
const { preload, ...workerOptions } = message.options;
|
|
290
|
+
let worker: Worker;
|
|
291
|
+
try {
|
|
292
|
+
worker = new Worker(new URL("./permission-worker-entry.js", import.meta.url), {
|
|
293
|
+
...workerOptions,
|
|
294
|
+
workerData: {
|
|
295
|
+
permission: { ...permission, messageKey },
|
|
296
|
+
enforced: token !== undefined,
|
|
297
|
+
script: message.script,
|
|
298
|
+
preload,
|
|
299
|
+
userWorkerData: workerOptions.workerData,
|
|
300
|
+
},
|
|
301
|
+
transferList: [permission.port],
|
|
302
|
+
});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
permission.port.close();
|
|
305
|
+
message.port.postMessage(workerErrorFrame(error));
|
|
306
|
+
message.port.postMessage({ type: "exit" });
|
|
307
|
+
message.port.close();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const exited = new Promise<void>((resolve) =>
|
|
311
|
+
worker.once("exit", () => {
|
|
312
|
+
message.port.postMessage({ type: "exit" });
|
|
313
|
+
message.port.close();
|
|
314
|
+
this.#children.delete(worker);
|
|
315
|
+
resolve();
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
this.#children.set(worker, exited);
|
|
319
|
+
worker.on("error", (error: Error) => message.port.postMessage(workerErrorFrame(error)));
|
|
320
|
+
worker.on("online", () => message.port.postMessage({ type: "open" }));
|
|
321
|
+
worker.on("message", (frame: unknown) => {
|
|
322
|
+
if (
|
|
323
|
+
frame !== null &&
|
|
324
|
+
typeof frame === "object" &&
|
|
325
|
+
"key" in frame &&
|
|
326
|
+
frame.key === messageKey &&
|
|
327
|
+
"value" in frame &&
|
|
328
|
+
"transfer" in frame &&
|
|
329
|
+
Array.isArray(frame.transfer)
|
|
330
|
+
) {
|
|
331
|
+
forwardWorkerMessage(message.port, frame.value, frame.transfer);
|
|
332
|
+
} else forwardWorkerMessage(message.port, frame, []);
|
|
333
|
+
});
|
|
334
|
+
message.port.on("message", (frame: { type: string; value?: unknown; transfer?: Transferable[] }) => {
|
|
335
|
+
if (frame.type === "terminate") void worker.terminate();
|
|
336
|
+
else if (frame.type === "message") worker.postMessage(frame.value, frame.transfer);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
revoke(): void {
|
|
341
|
+
this.#revoked = true;
|
|
342
|
+
this.#kill(this.#kernel);
|
|
343
|
+
for (const token of this.#tokens.values()) this.#kill(token);
|
|
344
|
+
for (const pool of this.#pools) interruptPermissionSlots(pool);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
retire(): Promise<void> {
|
|
348
|
+
if (this.#retiring) return this.#retiring;
|
|
349
|
+
const retirement = (async () => {
|
|
350
|
+
let outcome: WorkerRetirement;
|
|
351
|
+
try {
|
|
352
|
+
outcome = await this.#retire();
|
|
353
|
+
} catch {
|
|
354
|
+
outcome = "abandoned";
|
|
355
|
+
}
|
|
356
|
+
if (outcome === "abandoned") this.#failed();
|
|
357
|
+
})();
|
|
358
|
+
this.#retiring = retirement;
|
|
359
|
+
void retirement
|
|
360
|
+
.finally(() => {
|
|
361
|
+
if (this.#retiring === retirement) this.#retiring = undefined;
|
|
362
|
+
})
|
|
363
|
+
.catch(() => {});
|
|
364
|
+
return retirement;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#failed(): void {
|
|
368
|
+
this.#session.dead = true;
|
|
369
|
+
this.#options.onRevocationFailed?.({ sessionId: this.#options.sessionId, kernelId: this.kernelId });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async terminateChildren(): Promise<void> {
|
|
373
|
+
const children = [...this.#children];
|
|
374
|
+
for (const [worker] of children) void worker.terminate();
|
|
375
|
+
await Promise.all(children.map(([, exited]) => exited));
|
|
376
|
+
for (const port of this.#ports) port.close();
|
|
377
|
+
for (const token of this.#tokens.values()) this.#release(token);
|
|
378
|
+
this.#unbindInvokers();
|
|
379
|
+
this.#runs.clear();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
dispose(): void {
|
|
383
|
+
this.revoke();
|
|
384
|
+
this.#releaseSession();
|
|
385
|
+
this.#unbindInvokers();
|
|
386
|
+
unregisterPermissionKernel(this.#options.sessionId, this);
|
|
387
|
+
}
|
|
388
|
+
}
|