go/pkg/coredeno/runtime/modules.ts
Claude ad6a466459
feat(coredeno): Tier 3 Worker isolation — sandboxed module loading with I/O bridge
Each module now runs in a real Deno Worker with per-module permission
sandboxing. The I/O bridge relays Worker postMessage calls through the
parent to CoreService gRPC, so modules can access store, files, and
processes without direct network/filesystem access.

- Worker bootstrap (worker-entry.ts): sets up RPC bridge, dynamically
  imports module, calls init(core) with typed I/O object
- ModuleRegistry rewritten: creates Workers with Deno permission
  constructor, handles LOADING → RUNNING → STOPPED lifecycle
- Structured ModulePermissions (read/write/net/run) replaces flat
  string array in Go→Deno JSON-RPC
- I/O bridge: Worker postMessage → parent dispatchRPC → CoreClient
  gRPC → response relayed back to Worker
- Test module proves end-to-end: Worker calls core.storeSet() →
  Go verifies value in store

40 unit tests + 3 integration tests (Tier 1 boot + Tier 2 bidir + Tier 3 Worker).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 00:48:16 +00:00

202 lines
5.4 KiB
TypeScript

// Module registry — manages module lifecycle with Deno Worker isolation.
// Each module runs in its own Worker with per-module permission sandboxing.
// I/O bridge relays Worker postMessage calls to CoreService gRPC.
import type { CoreClient } from "./client.ts";
export type ModuleStatus =
| "UNKNOWN"
| "LOADING"
| "RUNNING"
| "STOPPED"
| "ERRORED";
export interface ModulePermissions {
read?: string[];
write?: string[];
net?: string[];
run?: string[];
}
interface Module {
code: string;
entryPoint: string;
permissions: ModulePermissions;
status: ModuleStatus;
worker?: Worker;
}
export class ModuleRegistry {
private modules = new Map<string, Module>();
private coreClient: CoreClient | null = null;
private workerEntryUrl: string;
constructor() {
this.workerEntryUrl = new URL("./worker-entry.ts", import.meta.url).href;
}
setCoreClient(client: CoreClient): void {
this.coreClient = client;
}
load(code: string, entryPoint: string, permissions: ModulePermissions): void {
// Terminate existing worker if reloading
const existing = this.modules.get(code);
if (existing?.worker) {
existing.worker.terminate();
}
const mod: Module = {
code,
entryPoint,
permissions,
status: "LOADING",
};
this.modules.set(code, mod);
// Resolve entry point URL for the module
const moduleUrl =
entryPoint.startsWith("file://") || entryPoint.startsWith("http")
? entryPoint
: "file://" + entryPoint;
// Build read permissions: worker-entry.ts dir + module source + declared reads
const readPerms: string[] = [
new URL(".", import.meta.url).pathname,
];
// Add the module's directory so it can be dynamically imported
if (!entryPoint.startsWith("http")) {
const modPath = entryPoint.startsWith("file://")
? entryPoint.slice(7)
: entryPoint;
// Add the module file's directory
const lastSlash = modPath.lastIndexOf("/");
if (lastSlash > 0) readPerms.push(modPath.slice(0, lastSlash + 1));
else readPerms.push(modPath);
}
if (permissions.read) readPerms.push(...permissions.read);
// Create Worker with permission sandbox
const worker = new Worker(this.workerEntryUrl, {
type: "module",
name: code,
// deno-lint-ignore no-explicit-any
deno: {
permissions: {
read: readPerms,
write: permissions.write ?? [],
net: permissions.net ?? [],
run: permissions.run ?? [],
env: false,
sys: false,
ffi: false,
},
},
} as any);
mod.worker = worker;
// I/O bridge: relay Worker RPC to CoreClient
worker.onmessage = async (e: MessageEvent) => {
const msg = e.data;
if (msg.type === "ready") {
worker.postMessage({ type: "load", url: moduleUrl });
return;
}
if (msg.type === "loaded") {
mod.status = msg.ok ? "RUNNING" : "ERRORED";
if (msg.ok) {
console.error(`CoreDeno: module running: ${code}`);
} else {
console.error(`CoreDeno: module error: ${code}: ${msg.error}`);
}
return;
}
if (msg.type === "rpc" && this.coreClient) {
try {
const result = await this.dispatchRPC(
code,
msg.method,
msg.params,
);
worker.postMessage({ type: "rpc_response", id: msg.id, result });
} catch (err) {
worker.postMessage({
type: "rpc_response",
id: msg.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
};
worker.onerror = (e: ErrorEvent) => {
mod.status = "ERRORED";
console.error(`CoreDeno: worker error: ${code}: ${e.message}`);
};
console.error(`CoreDeno: module loading: ${code}`);
}
private async dispatchRPC(
moduleCode: string,
method: string,
params: Record<string, unknown>,
): Promise<unknown> {
const c = this.coreClient!;
switch (method) {
case "StoreGet":
return c.storeGet(params.group as string, params.key as string);
case "StoreSet":
return c.storeSet(
params.group as string,
params.key as string,
params.value as string,
);
case "FileRead":
return c.fileRead(params.path as string, moduleCode);
case "FileWrite":
return c.fileWrite(
params.path as string,
params.content as string,
moduleCode,
);
case "ProcessStart":
return c.processStart(
params.command as string,
params.args as string[],
moduleCode,
);
case "ProcessStop":
return c.processStop(params.process_id as string);
default:
throw new Error(`unknown RPC method: ${method}`);
}
}
unload(code: string): boolean {
const mod = this.modules.get(code);
if (!mod) return false;
if (mod.worker) {
mod.worker.terminate();
mod.worker = undefined;
}
mod.status = "STOPPED";
console.error(`CoreDeno: module unloaded: ${code}`);
return true;
}
status(code: string): ModuleStatus {
return this.modules.get(code)?.status ?? "UNKNOWN";
}
list(): Array<{ code: string; status: ModuleStatus }> {
return Array.from(this.modules.values()).map((m) => ({
code: m.code,
status: m.status,
}));
}
}