From db4576951320b1d7e73c320c8519a4f9a6657012 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:51:24 +0800 Subject: [PATCH] feat(tool-cordis): Node-API traps + fs/web capability routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox deliberately provides no Node API, and now says so instead of letting a bare ReferenceError teach nothing: require, the timers, and fetch are callable traps whose error redirects to the cordis alternative (inject: ['fs'] + ctx.fs, ['web'] + ctx.web, ['bash'] + ctx.bash, ['timer'] + ctx.setTimeout — a fiber effect, unwound on unmount). Only function-shaped globals are trapped; process/Buffer stay undefined so typeof feature probes stay inert. The mount description and the demo persona state the routing rule, and the demo mounts ctx.fs (local provider) and ctx.web (seam + keyless local fetch provider) so agent-built plugins have real capabilities to build on. Live-validated: a model that reached for Node setTimeout self-corrected to inject: ['timer'] in one step and built a working ctx.web fetch tool. --- docs/tool-catalog.md | 2 +- examples/README.md | 2 +- examples/cordis-agent/README.md | 2 +- examples/cordis-agent/composition.md | 9 +++ examples/cordis-agent/cordis.yml | 27 +++++++-- packages/cordis/tool-cordis/src/index.ts | 9 ++- packages/cordis/tool-cordis/src/sandbox.ts | 59 ++++++++++++++++--- .../cordis/tool-cordis/tests/mount.spec.ts | 37 +++++++++++- 8 files changed, 129 insertions(+), 18 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index e77716f736..f0a3fab35d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -136,7 +136,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_mount` -Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — cannot declare inject, uses whatever services are on the parent context, and accessing a service without inject (e.g. ctx.bash) throws; use it only when you need no injected services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form for any plugin that needs bash, llm, sessions, etc. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`; there is no `require`, `process`, `Buffer`, or network. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) The sandbox prevents accidental global pollution, not malice: `ctx` is the real, fully privileged runtime handle. +Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — cannot declare inject, uses whatever services are on the parent context, and accessing a service without inject (e.g. ctx.bash) throws; use it only when you need no injected services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form for any plugin that needs bash, llm, sessions, etc. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) The sandbox prevents accidental global pollution, not malice: `ctx` is the real, fully privileged runtime handle. ```json { diff --git a/examples/README.md b/examples/README.md index c308df73e1..c86f83022a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,7 +21,7 @@ Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a ## cordis-agent -The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. +The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on. Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset RFC](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index ba9365498b..953533d35b 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -1,6 +1,6 @@ # cordis-agent -The self-referential harness demo: the coding-agent spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential harness demo: the coding-agent spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 6822129c26..015bec724e 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -14,6 +14,12 @@ flowchart LR cfg --> plugin_cordis_llm_deepseek plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_cordis_bash + plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_cordis_fs_local + plugin_cordis_web["web
@deepseek-ai/dsh-web"] + cfg --> plugin_cordis_web + plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] + cfg --> plugin_cordis_web_fetch_local plugin_cordis_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-agent"] cfg --> plugin_cordis_stdio_agent plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"] @@ -32,6 +38,9 @@ flowchart LR | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `web` | `@deepseek-ai/dsh-web` | +| `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | | `stdio-agent` | `@deepseek-ai/dsh-stdio-agent` | | `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index b5e08c8325..65d5e6eb36 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -36,6 +36,23 @@ config: timeoutMs: 60000 +# Filesystem service for mounted plugins (ctx.fs) — the local provider only. +# The model-facing read/write/edit tools stay unmounted on purpose: this demo +# is about the agent building its own tools over the services. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +# Web service for mounted plugins (ctx.web): the seam plus the anonymous local +# fetch provider (keyless). No search provider is loaded — ctx.web search +# calls fail loud until a deployment adds one. +- id: web + name: '@deepseek-ai/dsh-web' + +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + # The stdio chat app: the whole spine + front-door cluster, configured for the # self-referential demo driving a pre-created `main` agent. - id: stdio-agent @@ -54,10 +71,12 @@ `events` sections document the service methods, type shapes, and events your plugin code can use), cordis_mount to add a plugin (an event listener, a brand-new tool for yourself, or a service other mounts - inject), cordis_unmount to clean one up. Prefer small single-purpose - plugins, prefer plain notification events over waterfall events unless - you intend to intercept, and unmount what you no longer need. Report - results briefly. + inject), cordis_unmount to clean one up. In mounted code, NEVER use Node + built-ins (require/setTimeout/fetch) — use the runtime's cordis services + via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small + single-purpose plugins, prefer plain notification events over waterfall + events unless you intend to intercept, and unmount what you no longer + need. Report results briefly. # The self-referential cordis toolset (loaded after the app so ctx.tools exists). - id: tool-cordis diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index d4492b4768..de028b384a 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -152,8 +152,13 @@ export function apply(ctx: Context, config: Config): void { + 'Everything registered inside `apply` is cleaned up automatically on unmount. ' + 'Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness ' + 'terminal), `harness.defineTool`, `harness.registerTool`, ' - + '`btoa`, `atob`, `TextEncoder`, `TextDecoder`; ' - + 'there is no `require`, `process`, `Buffer`, or network. ' + + '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. ' + + 'Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, ' + + 'never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect ' + + 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for ' + + 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, ' + + 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, ' + + 'auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. ' + 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). ' + 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a ' + 'trailing `next` callback which MUST be called — returning without `next()` ' diff --git a/packages/cordis/tool-cordis/src/sandbox.ts b/packages/cordis/tool-cordis/src/sandbox.ts index 4d3eb16dfc..add858f3ce 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -1,10 +1,16 @@ /** * The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose * globals are a tagged write-through console, the `harness` registration - * helpers, and the encoding primitives a bare vm context lacks. The sandbox - * guards against ACCIDENTAL global pollution only — it is not a security - * boundary; the `ctx` a mounted plugin's `apply` later receives is the real, - * fully privileged runtime handle, and that is the point of the toolset. + * helpers, the encoding primitives a bare vm context lacks, and callable traps + * over the Node APIs the sandbox deliberately withholds. Capability access is + * routed through cordis services, never Node built-ins: filesystem work goes + * through `ctx.fs`, network through `ctx.web`, processes through `ctx.bash`, + * timers through the `ctx.timer` helpers (fiber effects, unwound on unmount) + * — so everything a mounted plugin does stays inspectable and disposable. The + * sandbox guards against ACCIDENTAL global pollution only — it is not a + * security boundary; the `ctx` a mounted plugin's `apply` later receives is + * the real, fully privileged runtime handle, and that is the point of the + * toolset. * * @module @deepseek-ai/dsh-tool-cordis/sandbox */ @@ -60,19 +66,58 @@ function patchDualRealmInstanceof(sandbox: object): void { patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set }) } +const TIMER_REDIRECT + = 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin ' + + 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically on unmount.' + +/** + * The callable Node APIs the sandbox deliberately disables, each mapped to the + * cordis alternative its trap error names. Only FUNCTION-shaped globals are + * trapped — a data-shaped global like `process` stays `undefined`, because a + * throwing accessor would detonate the common `typeof process` feature probe + * at resolution time. + */ +const NODE_API_REDIRECTS: Record = { + require: + 'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, ' + + '[\'web\'] for HTTP, [\'bash\'] for processes; cordis_inspect what:"api" lists what THIS runtime provides.', + setTimeout: TIMER_REDIRECT, + setInterval: TIMER_REDIRECT, + setImmediate: TIMER_REDIRECT, + clearTimeout: TIMER_REDIRECT, + clearInterval: TIMER_REDIRECT, + fetch: + 'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web ' + + '(see cordis_inspect what:"api" for its methods).', +} + +/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */ +function nodeApiTraps(): Record never> { + const traps: Record never> = {} + for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) { + traps[name] = () => { + throw new Error(`${name} is not available in the mount sandbox — ${redirect}`) + } + } + return traps +} + /** * Build the vm context one `cordis_mount` call evaluates in: the tagged - * console, the `harness` registration helpers, the encoding primitives, and - * the dual-realm `instanceof` patch, already `createContext`-ed. + * console, the `harness` registration helpers, the encoding primitives, the + * Node-API traps, and the dual-realm `instanceof` patch, already + * `createContext`-ed. * @param id - the mount id (`dyn-`), used as the console tag and filename stem. * @returns the contextified sandbox object to pass to {@link evaluateMountCode}. */ export function createSandbox(id: string): object { const sandbox = { + ...nodeApiTraps(), console: taggedConsole(id), harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool }, // Web APIs absent from fresh vm contexts — made available so the model - // can encode/decode base64 without Buffer (which is also absent). + // can encode/decode base64 without Buffer (which is also absent). Host + // closures over Buffer, never Buffer itself. btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'), atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'), TextEncoder, diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 5bdde09b53..03038f818c 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -284,12 +284,12 @@ describe('cordis_mount', () => { expect(retry.isError).toBe(false) }) - it('isolates sandbox globals: no process/require, and globalThis writes do not leak to the host', async () => { + it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => { const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` globalThis.__cordis_tool_leak = 'leaked' - return { name: 'probe-' + typeof process + '-' + typeof require, apply(ctx) {} } + return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} } `, }) expect(result.isError).toBe(false) @@ -297,6 +297,39 @@ describe('cordis_mount', () => { expect((globalThis as Record).__cordis_tool_leak).toBeUndefined() }) + it.each([ + ['require(\'fs\')', 'require is not available in the mount sandbox', 'inject: [\'fs\']'], + ['setTimeout(() => {}, 5)', 'setTimeout is not available in the mount sandbox', 'ctx.setTimeout'], + ['fetch(\'https://example.com\')', 'fetch is not available in the mount sandbox', 'ctx.web'], + ])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => { + const ctx = await setup() + const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` }) + expect(result.isError).toBe(true) + expect(text(result)).toContain(trapMessage) + expect(text(result)).toContain(redirect) + expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + }) + + it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => { + const ctx = await setup() + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const result = await call(ctx, 'cordis_mount', { + code: ` + return { + name: 'ticker', + inject: ['timer'], + apply(ctx) { + ctx.setTimeout(() => console.log('tick'), 10) + }, + } + `, + }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('state: active') + await new Promise(resolve => setTimeout(resolve, 50)) + expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick') + }) + it('provides btoa/atob and the tagged console variants inside the sandbox', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {})