From ad6a4664595a58167671d0da138c46a6d2cecd65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 00:48:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(coredeno):=20Tier=203=20Worker=20isolation?= =?UTF-8?q?=20=E2=80=94=20sandboxed=20module=20loading=20with=20I/O=20brid?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/coredeno/denoclient.go | 14 +- pkg/coredeno/integration_test.go | 124 +++++++++++- pkg/coredeno/runtime/deno.json | 3 +- pkg/coredeno/runtime/main.ts | 7 +- pkg/coredeno/runtime/modules.ts | 192 +++++++++++++++++-- pkg/coredeno/runtime/server.ts | 4 +- pkg/coredeno/runtime/testdata/test-module.ts | 5 + pkg/coredeno/runtime/worker-entry.ts | 79 ++++++++ 8 files changed, 396 insertions(+), 32 deletions(-) create mode 100644 pkg/coredeno/runtime/testdata/test-module.ts create mode 100644 pkg/coredeno/runtime/worker-entry.ts diff --git a/pkg/coredeno/denoclient.go b/pkg/coredeno/denoclient.go index 81b6952..81ecc39 100644 --- a/pkg/coredeno/denoclient.go +++ b/pkg/coredeno/denoclient.go @@ -63,19 +63,27 @@ func (c *DenoClient) call(req map[string]any) (map[string]any, error) { return resp, nil } +// ModulePermissions declares per-module permission scopes for Deno Worker sandboxing. +type ModulePermissions struct { + Read []string `json:"read,omitempty"` + Write []string `json:"write,omitempty"` + Net []string `json:"net,omitempty"` + Run []string `json:"run,omitempty"` +} + // LoadModuleResponse holds the result of a LoadModule call. type LoadModuleResponse struct { Ok bool Error string } -// LoadModule tells Deno to load a module. -func (c *DenoClient) LoadModule(code, entryPoint string, permissions []string) (*LoadModuleResponse, error) { +// LoadModule tells Deno to load a module with the given permissions. +func (c *DenoClient) LoadModule(code, entryPoint string, perms ModulePermissions) (*LoadModuleResponse, error) { resp, err := c.call(map[string]any{ "method": "LoadModule", "code": code, "entry_point": entryPoint, - "permissions": permissions, + "permissions": perms, }) if err != nil { return nil, err diff --git a/pkg/coredeno/integration_test.go b/pkg/coredeno/integration_test.go index b1c515d..da61c8c 100644 --- a/pkg/coredeno/integration_test.go +++ b/pkg/coredeno/integration_test.go @@ -44,6 +44,15 @@ func runtimeEntryPoint(t *testing.T) string { return abs } +// testModulePath returns the absolute path to runtime/testdata/test-module.ts. +func testModulePath(t *testing.T) string { + t.Helper() + abs, err := filepath.Abs("runtime/testdata/test-module.ts") + require.NoError(t, err) + require.FileExists(t, abs) + return abs +} + func TestIntegration_FullBoot_Good(t *testing.T) { denoPath := findDeno(t) @@ -147,7 +156,7 @@ permissions: DenoSocketPath: denoSockPath, AppRoot: tmpDir, StoreDBPath: ":memory:", - SidecarArgs: []string{"run", "-A", entryPoint}, + SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint}, } c, err := core.New() @@ -181,12 +190,20 @@ permissions: // Verify DenoClient is connected require.NotNil(t, svc.DenoClient(), "DenoClient should be connected") - // Test Go → Deno: LoadModule - loadResp, err := svc.DenoClient().LoadModule("test-module", "/modules/test/main.ts", []string{"read", "net"}) + // Test Go → Deno: LoadModule with real Worker + modPath := testModulePath(t) + loadResp, err := svc.DenoClient().LoadModule("test-module", modPath, ModulePermissions{ + Read: []string{filepath.Dir(modPath) + "/"}, + }) require.NoError(t, err) assert.True(t, loadResp.Ok) - // Test Go → Deno: ModuleStatus + // Wait for module to finish loading (async Worker init) + require.Eventually(t, func() bool { + resp, err := svc.DenoClient().ModuleStatus("test-module") + return err == nil && (resp.Status == "RUNNING" || resp.Status == "ERRORED") + }, 5*time.Second, 50*time.Millisecond, "module should finish loading") + statusResp, err := svc.DenoClient().ModuleStatus("test-module") require.NoError(t, err) assert.Equal(t, "test-module", statusResp.Code) @@ -223,3 +240,102 @@ permissions: assert.NoError(t, err) assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") } + +func TestIntegration_Tier3_WorkerIsolation_Good(t *testing.T) { + denoPath := findDeno(t) + + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + denoSockPath := filepath.Join(tmpDir, "deno.sock") + + // Write a manifest + coreDir := filepath.Join(tmpDir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(` +code: tier3-test +name: Tier 3 Test +version: "1.0" +permissions: + read: ["./data/"] +`), 0644)) + + entryPoint := runtimeEntryPoint(t) + modPath := testModulePath(t) + + opts := Options{ + DenoPath: denoPath, + SocketPath: sockPath, + DenoSocketPath: denoSockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint}, + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, err := factory(c) + require.NoError(t, err) + svc := result.(*Service) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify both sockets appeared + require.Eventually(t, func() bool { + _, err := os.Stat(denoSockPath) + return err == nil + }, 10*time.Second, 50*time.Millisecond, "deno socket should appear") + + require.NotNil(t, svc.DenoClient(), "DenoClient should be connected") + + // Load a real module — it writes to store via I/O bridge + loadResp, err := svc.DenoClient().LoadModule("test-mod", modPath, ModulePermissions{ + Read: []string{filepath.Dir(modPath) + "/"}, + }) + require.NoError(t, err) + assert.True(t, loadResp.Ok) + + // Wait for module to reach RUNNING (Worker init + init() completes) + require.Eventually(t, func() bool { + resp, err := svc.DenoClient().ModuleStatus("test-mod") + return err == nil && resp.Status == "RUNNING" + }, 10*time.Second, 100*time.Millisecond, "module should be RUNNING") + + // Verify the module wrote to the store via the I/O bridge + // Module calls: core.storeSet("test-module", "init", "ok") + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + coreClient := pb.NewCoreServiceClient(conn) + + // Poll for the store value — module init is async + require.Eventually(t, func() bool { + resp, err := coreClient.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "test-module", Key: "init", + }) + return err == nil && resp.Found && resp.Value == "ok" + }, 5*time.Second, 100*time.Millisecond, "module should have written to store via I/O bridge") + + // Unload and verify + unloadResp, err := svc.DenoClient().UnloadModule("test-mod") + require.NoError(t, err) + assert.True(t, unloadResp.Ok) + + statusResp, err := svc.DenoClient().ModuleStatus("test-mod") + require.NoError(t, err) + assert.Equal(t, "STOPPED", statusResp.Status) + + // Clean shutdown + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) + assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") +} diff --git a/pkg/coredeno/runtime/deno.json b/pkg/coredeno/runtime/deno.json index 13f798e..95117a4 100644 --- a/pkg/coredeno/runtime/deno.json +++ b/pkg/coredeno/runtime/deno.json @@ -3,5 +3,6 @@ "@grpc/grpc-js": "npm:@grpc/grpc-js@^1.12", "@grpc/proto-loader": "npm:@grpc/proto-loader@^0.7" }, - "nodeModulesDir": "none" + "nodeModulesDir": "none", + "unstable": ["worker-options"] } diff --git a/pkg/coredeno/runtime/main.ts b/pkg/coredeno/runtime/main.ts index ca0aba4..052ba2d 100644 --- a/pkg/coredeno/runtime/main.ts +++ b/pkg/coredeno/runtime/main.ts @@ -82,10 +82,13 @@ let coreClient: CoreClient; console.error("CoreDeno: CoreService client connected"); } -// 4. Signal readiness +// 4. Inject CoreClient into registry for I/O bridge +registry.setCoreClient(coreClient); + +// 5. Signal readiness console.error("CoreDeno: ready"); -// 5. Keep alive until SIGTERM +// 6. Keep alive until SIGTERM const ac = new AbortController(); Deno.addSignalListener("SIGTERM", () => { console.error("CoreDeno: shutting down"); diff --git a/pkg/coredeno/runtime/modules.ts b/pkg/coredeno/runtime/modules.ts index a53bd4b..0feb890 100644 --- a/pkg/coredeno/runtime/modules.ts +++ b/pkg/coredeno/runtime/modules.ts @@ -1,40 +1,189 @@ -// Module registry — tracks loaded modules and their lifecycle status. -// Tier 2: status tracking only. Tier 3 adds real Deno worker isolates. +// 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. -export type ModuleStatus = "UNKNOWN" | "LOADING" | "RUNNING" | "STOPPED" | "ERRORED"; +import type { CoreClient } from "./client.ts"; -// Status enum values matching the proto definition. -export const StatusEnum: Record = { - UNKNOWN: 0, - LOADING: 1, - RUNNING: 2, - STOPPED: 3, - ERRORED: 4, -}; +export type ModuleStatus = + | "UNKNOWN" + | "LOADING" + | "RUNNING" + | "STOPPED" + | "ERRORED"; -export interface Module { +export interface ModulePermissions { + read?: string[]; + write?: string[]; + net?: string[]; + run?: string[]; +} + +interface Module { code: string; entryPoint: string; - permissions: string[]; + permissions: ModulePermissions; status: ModuleStatus; + worker?: Worker; } export class ModuleRegistry { private modules = new Map(); + private coreClient: CoreClient | null = null; + private workerEntryUrl: string; - load(code: string, entryPoint: string, permissions: string[]): void { - this.modules.set(code, { + 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: "RUNNING", - }); - console.error(`CoreDeno: module loaded: ${code}`); + 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, + ): Promise { + 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; @@ -44,7 +193,10 @@ export class ModuleRegistry { return this.modules.get(code)?.status ?? "UNKNOWN"; } - list(): Module[] { - return Array.from(this.modules.values()); + list(): Array<{ code: string; status: ModuleStatus }> { + return Array.from(this.modules.values()).map((m) => ({ + code: m.code, + status: m.status, + })); } } diff --git a/pkg/coredeno/runtime/server.ts b/pkg/coredeno/runtime/server.ts index 81065a2..82afcc6 100644 --- a/pkg/coredeno/runtime/server.ts +++ b/pkg/coredeno/runtime/server.ts @@ -94,7 +94,7 @@ interface RPCRequest { method: string; code?: string; entry_point?: string; - permissions?: string[]; + permissions?: { read?: string[]; write?: string[]; net?: string[]; run?: string[] }; process_id?: string; } @@ -107,7 +107,7 @@ function dispatch( registry.load( req.code ?? "", req.entry_point ?? "", - req.permissions ?? [], + req.permissions ?? {}, ); return { ok: true, error: "" }; } diff --git a/pkg/coredeno/runtime/testdata/test-module.ts b/pkg/coredeno/runtime/testdata/test-module.ts new file mode 100644 index 0000000..c7fd761 --- /dev/null +++ b/pkg/coredeno/runtime/testdata/test-module.ts @@ -0,0 +1,5 @@ +// Test module — writes to store via I/O bridge to prove Workers work. +// Called by integration tests. +export async function init(core: any) { + await core.storeSet("test-module", "init", "ok"); +} diff --git a/pkg/coredeno/runtime/worker-entry.ts b/pkg/coredeno/runtime/worker-entry.ts new file mode 100644 index 0000000..757fabf --- /dev/null +++ b/pkg/coredeno/runtime/worker-entry.ts @@ -0,0 +1,79 @@ +// Worker bootstrap — loaded as entry point for every module Worker. +// Sets up the I/O bridge (postMessage ↔ parent relay), then dynamically +// imports the module and calls its init(core) function. +// +// The parent (ModuleRegistry) injects module_code into all gRPC calls, +// so modules can't spoof their identity. + +// I/O bridge: request/response correlation over postMessage +const pending = new Map(); +let nextId = 0; + +function rpc( + method: string, + params: Record, +): Promise { + return new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + self.postMessage({ type: "rpc", id, method, params }); + }); +} + +// Typed core object passed to module's init() function. +// Each method maps to a CoreService gRPC call relayed through the parent. +const core = { + storeGet(group: string, key: string) { + return rpc("StoreGet", { group, key }); + }, + storeSet(group: string, key: string, value: string) { + return rpc("StoreSet", { group, key, value }); + }, + fileRead(path: string) { + return rpc("FileRead", { path }); + }, + fileWrite(path: string, content: string) { + return rpc("FileWrite", { path, content }); + }, + processStart(command: string, args: string[]) { + return rpc("ProcessStart", { command, args }); + }, + processStop(processId: string) { + return rpc("ProcessStop", { process_id: processId }); + }, +}; + +// Handle messages from parent: RPC responses and load commands +self.addEventListener("message", async (e: MessageEvent) => { + const msg = e.data; + + if (msg.type === "rpc_response") { + const p = pending.get(msg.id); + if (p) { + pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error)); + else p.resolve(msg.result); + } + return; + } + + if (msg.type === "load") { + try { + const mod = await import(msg.url); + if (typeof mod.init === "function") { + await mod.init(core); + } + self.postMessage({ type: "loaded", ok: true }); + } catch (err) { + self.postMessage({ + type: "loaded", + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } + return; + } +}); + +// Signal ready — parent will respond with {type: "load", url: "..."} +self.postMessage({ type: "ready" });