From 74b4a1c23ffb7dfac7b5c83cb412922ec2c277f1 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 4 Aug 2026 20:57:14 -0300 Subject: [PATCH 01/11] feat(console): redact worker-declared secrets at every raw display exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An injected function-trigger renderer can hide a capability inside its own card and still leak it: the card's `raw json` tab renders `message.input` / `message.output` verbatim, the copy buttons put the same value on the clipboard, an assistant-turn copy re-serializes every call's arguments, and the trace span tabs show the same payload from the other side. Adds `redactRaw?(value: unknown): unknown` to the `FunctionTriggerRenderer` contract: the claiming renderer declares how to redact, and the host applies it once at each of those exits. It runs inside the host's render, so it must be pure and total; a throw is fenced and fails CLOSED — the pane shows a placeholder rather than the raw value. Trace-side tabs get the same treatment, and `SpanPanel.redaction-coverage.test.ts` enforces a closed world: every tab either wires a redactor or carries a written reason why it cannot leak. Needed by any worker whose function arguments carry a capability — the first is code-runner, whose `runtime_id` addresses a live microVM. --- console/SKILL.md | 37 +++ .../web/src/components/chat/MessageList.tsx | 12 +- .../function-trigger/FunctionTriggerCard.tsx | 127 ++++++--- .../function-trigger/redact-raw.test.tsx | 255 ++++++++++++++++++ .../function-trigger/renderer-registry.tsx | 55 +++- .../web/src/lib/function-trigger-copy.test.ts | 77 +++++- console/web/src/lib/function-trigger-copy.ts | 28 +- .../TracesV2/components/SpanBaggageTab.tsx | 13 + .../components/SpanErrorsTab.test.tsx | 92 +++++++ .../TracesV2/components/SpanErrorsTab.tsx | 26 +- .../TracesV2/components/SpanLinksTab.tsx | 10 + .../TracesV2/components/SpanLogsTab.test.tsx | 74 +++++ .../pages/TracesV2/components/SpanLogsTab.tsx | 14 +- .../TracesV2/components/SpanOtelLogsTab.tsx | 17 ++ .../SpanPanel.redaction-coverage.test.ts | 135 ++++++++++ .../pages/TracesV2/components/SpanPanel.tsx | 27 +- .../TracesV2/components/SpanTagsTab.test.tsx | 99 +++++++ .../pages/TracesV2/components/SpanTagsTab.tsx | 31 ++- .../lib/functionTriggerFromSpan.test.ts | 74 +++++ .../TracesV2/lib/functionTriggerFromSpan.ts | 24 ++ .../TracesV2/lib/redactAttributes.test.ts | 42 +++ .../pages/TracesV2/lib/redactAttributes.ts | 37 +++ console/web/src/types/injectable-ui.ts | 18 ++ docs/sops/injectable-console-ui.md | 29 ++ packages/console-ui/index.d.ts | 20 ++ 25 files changed, 1305 insertions(+), 68 deletions(-) create mode 100644 console/web/src/components/function-trigger/redact-raw.test.tsx create mode 100644 console/web/src/pages/TracesV2/components/SpanErrorsTab.test.tsx create mode 100644 console/web/src/pages/TracesV2/components/SpanLogsTab.test.tsx create mode 100644 console/web/src/pages/TracesV2/components/SpanPanel.redaction-coverage.test.ts create mode 100644 console/web/src/pages/TracesV2/components/SpanTagsTab.test.tsx create mode 100644 console/web/src/pages/TracesV2/lib/redactAttributes.test.ts create mode 100644 console/web/src/pages/TracesV2/lib/redactAttributes.ts diff --git a/console/SKILL.md b/console/SKILL.md index 7e9c65adf..bc4e6f443 100644 --- a/console/SKILL.md +++ b/console/SKILL.md @@ -286,6 +286,7 @@ interface FunctionTriggerRenderer { tryRenderRunning?(message: FunctionTriggerMessage): React.ReactNode | null tryRenderPreview?(message: FunctionTriggerMessage): React.ReactNode | null FunctionIdLabel?: React.ComponentType<{ functionId: string }> + redactRaw?(value: unknown): unknown } ``` @@ -296,6 +297,42 @@ errors and everything else keep the default cards. Renderer callbacks are fenced: a throwing `isMatch` counts as no-match, a throwing `tryRender` degrades to an error chip, never a broken feed. +#### `redactRaw` — your card is not the only exit + +However your card renders a call, the settled card also mounts a **`raw +json` tab** showing `input` and `output` verbatim, each with a copy button. +So hiding a secret inside your own rendering does not contain it: it is one +click away in the raw tab and on the clipboard. + +`redactRaw` lets you declare what is secret and have the console apply it. +For a message your `isMatch` claims, the console passes the request and the +response through it **before the raw panes render and before the copy button +builds its text** (first claiming renderer that declares it wins). Keep the +knowledge of what a secret looks like in your worker — the console never +learns your patterns. + +```ts +redactRaw: (value) => deepReplace(value, SECRET_PATTERN, mask) +``` + +Rules: + +- Deep-walk the value. Secrets hide in nested arrays, in captured log lines, + in error messages, and in object **keys**, not just in the obvious field. + Preserve shape (objects, arrays, strings, numbers, booleans, `null`, + `undefined`) — the value is not always an object: `FunctionTriggerCard` + calls `redactRaw(undefined)` on every running/pending card (no `output` + yet) and hands it a bare top-level string for a double-encoded payload. + Guard against cycles so a self-referential value cannot hang the console. +- Pure and total: never mutate the argument, never do I/O, never throw. It + runs inside the card's render. +- It is fenced and **fails closed**: if it throws, the pane and the clipboard + get `[redaction failed — value withheld]`, not the raw value. A bug in your + redactor costs the raw view, never the secret. +- It is display hygiene for the chat surface, not access control: the payload + still travelled over the wire and still sits in the trace store, and a full + session export is verbatim by design. + ### `host.configForms.register(configurationId, component)` Replace the schema-generated form for one configuration entry on the Workers diff --git a/console/web/src/components/chat/MessageList.tsx b/console/web/src/components/chat/MessageList.tsx index 11a4111bc..2d5e26426 100644 --- a/console/web/src/components/chat/MessageList.tsx +++ b/console/web/src/components/chat/MessageList.tsx @@ -1,5 +1,9 @@ import { type ReactNode, useEffect, useMemo, useRef } from 'react' import { resultEnvelope } from '@/components/function-trigger/FunctionTriggerCard' +import { + rawRedactor, + useFunctionTriggerRenderers, +} from '@/components/function-trigger/renderer-registry' import type { FilesystemAccessAction } from '@/components/permissions/FilesystemAccessPrompt' import type { SessionTriggerInfo } from '@/lib/backend/triggers' import { useConversationsCtxOptional } from '@/lib/conversations-context' @@ -190,6 +194,12 @@ export function MessageList({ () => resolveRegistrations(messages, triggersById), [messages, triggersById], ) + // Same registry `FunctionTriggerCard` uses for its own raw pane: an + // assistant-turn copy serializes each call's arguments the same way the + // call's own card does, so a worker's `redactRaw` (e.g. code-runner's + // runtime_id) has to cover this exit too — see function-trigger-copy.ts. + const renderers = useFunctionTriggerRenderers() + const redactFor = (functionId: string) => rawRedactor(renderers, functionId) // Read optionally so isolated renders (Storybook) still work without the // ConversationsProvider; the empty state falls back to `ready` there. @@ -266,7 +276,7 @@ export function MessageList({ m.role === 'assistant' ? fcallsByAssistant.get(m.id) : undefined const copyText = m.role === 'assistant' && (m.content || calls?.length) - ? () => assistantCopyText(m.content, calls ?? []) + ? () => assistantCopyText(m.content, calls ?? [], redactFor) : undefined // A call that directly follows another call belongs to the same // burst of agent activity — pull it up against its predecessor so diff --git a/console/web/src/components/function-trigger/FunctionTriggerCard.tsx b/console/web/src/components/function-trigger/FunctionTriggerCard.tsx index 14b464f87..f1dd71211 100644 --- a/console/web/src/components/function-trigger/FunctionTriggerCard.tsx +++ b/console/web/src/components/function-trigger/FunctionTriggerCard.tsx @@ -1,8 +1,9 @@ import { Check, Copy, X } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { CopyMessageButton } from '@/components/chat/CopyMessageButton' import { firstNonNull, + rawRedactor, useFunctionTriggerRenderers, } from '@/components/function-trigger/renderer-registry' import { AlwaysAllowButton } from '@/components/permissions/AlwaysAllowButton' @@ -238,14 +239,33 @@ export function FunctionTriggerCard({ }: FunctionTriggerCardProps) { const pending = !!message.pendingApproval const running = !!message.running + // Registry-dispatched custom panes: injected renderers first, then the + // first-party families, then the JSON fallback below. First non-null + // wins; null falls through. + const renderers = useFunctionTriggerRenderers() + // The raw request/response as this card is allowed to show them. An + // injected renderer that claims this function id may declare `redactRaw` + // (a runtime id is a capability, so code-runner does) — apply it + // ONCE here, then use `rawInput`/`rawOutput` everywhere below: every pane + // derives both its body and its copy text from the value it is handed, so + // redacting at the source covers the clipboard too. Card LOGIC keeps + // reading `message.*` — redaction is a display concern, not a semantic one. + // Memoized because `redactRaw` deep-walks the payload and this runs for + // every card of a claimed function id, collapsed ones included. + const { rawInput, rawOutput } = useMemo(() => { + const redact = rawRedactor(renderers, message.functionId) + return redact + ? { rawInput: redact(message.input), rawOutput: redact(message.output) } + : { rawInput: message.input, rawOutput: message.output } + }, [renderers, message.functionId, message.input, message.output]) // Raw in-flight arguments tail (`_streaming`, injected by the harness // while a call's arguments are still forming) — rendered as a live pane. const streamingTail = running && - message.input && - typeof message.input === 'object' && - typeof (message.input as { _streaming?: unknown })._streaming === 'string' - ? (message.input as { _streaming: string })._streaming + rawInput && + typeof rawInput === 'object' && + typeof (rawInput as { _streaming?: unknown })._streaming === 'string' + ? (rawInput as { _streaming: string })._streaming : undefined const filesystemAccess = pending ? message.filesystemAccess : undefined const [open, setOpen] = useState(!!defaultOpen || pending) @@ -255,10 +275,6 @@ export function FunctionTriggerCard({ >(null) const [submitError, setSubmitError] = useState(null) - // Registry-dispatched custom panes: injected renderers first, then the - // first-party families, then the JSON fallback below. First non-null - // wins; null falls through. - const renderers = useFunctionTriggerRenderers() const customPreview = firstNonNull( renderers, (r) => r.tryRenderPreview?.(message) ?? null, @@ -313,7 +329,11 @@ export function FunctionTriggerCard({ const ran = !isDeniedOutput(message.output) && (message.output !== undefined || typeof message.durationMs === 'number') - const preview = argsPreview(message.input) + // `rawInput`, not `message.input`: the collapsed header digests the request + // args inline, so it is a display exit like the raw pane and the clipboard — + // a claimed card's `redactRaw` has to cover it or a secret shows up in the + // one line that renders without anyone expanding the card. + const preview = argsPreview(rawInput) return (
{customPreview}
) : showRequestPaneAbove ? ( - + ) : null} {running && !pending ? ( streamingTail !== undefined ? ( @@ -434,7 +454,7 @@ export function FunctionTriggerCard({ ) : hasCustomTerminal ? (
{customTerminal}
) : ( - + ) ) : null} {!pending && !running ? ( @@ -450,14 +470,14 @@ export function FunctionTriggerCard({ {customTerminal} - - + + ) : ( <> - - + + ) ) : null} @@ -651,12 +671,18 @@ function StreamingArgsPane({ text }: { text: string }) { ) } -function ValuePane({ label, value, bordered }: ValuePaneProps) { - const empty = isEmptyValue(value) - const primitive = !empty && isPrimitive(value) - const single = !empty && !primitive ? singlePrimitiveField(value) : null - const envelope = - !empty && !primitive && !single ? resultEnvelope(value) : null +/** + * The non-envelope rendering of a value: its body text, the header hints, and + * whether the body is highlighted JSON. One derivation, shared by the pane's + * body and by its copy button (`paneCopyText`) — the two can never disagree. + */ +function plainPane(value: unknown): { + body: string + hints: string[] + json: boolean +} { + const primitive = isPrimitive(value) + const single = primitive ? null : singlePrimitiveField(value) // A string payload that is itself JSON (double-encoded): render the parsed // structure instead of an escaped one-liner, and say so in the header. const embedded = @@ -665,6 +691,40 @@ function ValuePane({ label, value, bordered }: ValuePaneProps) { : single && typeof single.value === 'string' ? parseEmbeddedJson(single.value) : undefined + const body = + embedded !== undefined + ? formatJson(embedded) + : primitive + ? formatPrimitive(value) + : single + ? formatPrimitive(single.value) + : formatJson(value) + return { + body, + hints: [ + ...(single ? [single.key] : []), + ...(embedded !== undefined ? ['json string'] : []), + ], + json: embedded !== undefined || !(primitive || single), + } +} + +/** + * The EXACT text a pane's copy button puts on the clipboard for `value`. + * Both `ValuePane` branches route through this, so the value a pane is handed + * bounds everything that can leave it — redact the value (see `rawRedactor`) + * and the clipboard is redacted with it. A pane that renders `· empty` shows + * no copy button, so its return value is then unused. + */ +export function paneCopyText(value: unknown): string { + // The envelope pane drops text blocks that merely re-serialize `details`, + // so its copy is the whole value rather than the deduplicated rendering. + return resultEnvelope(value) ? formatJson(value) : plainPane(value).body +} + +function ValuePane({ label, value, bordered }: ValuePaneProps) { + const empty = isEmptyValue(value) + const envelope = empty ? null : resultEnvelope(value) if (empty) { return ( @@ -705,7 +765,7 @@ function ValuePane({ label, value, bordered }: ValuePaneProps) { return ( @@ -730,35 +790,22 @@ function ValuePane({ label, value, bordered }: ValuePaneProps) { ) } - const body = - embedded !== undefined - ? formatJson(embedded) - : primitive - ? formatPrimitive(value) - : single - ? formatPrimitive(single.value) - : formatJson(value) - const hints = [ - ...(single ? [single.key] : []), - ...(embedded !== undefined ? ['json string'] : []), - ] + const { body, hints, json } = plainPane(value) return ( - {embedded !== undefined ? ( + {json ? ( - ) : primitive || single ? ( + ) : (
           {body}
         
- ) : ( - )}
) diff --git a/console/web/src/components/function-trigger/redact-raw.test.tsx b/console/web/src/components/function-trigger/redact-raw.test.tsx new file mode 100644 index 000000000..f818d6d7d --- /dev/null +++ b/console/web/src/components/function-trigger/redact-raw.test.tsx @@ -0,0 +1,255 @@ +/** + * `redactRaw` — an injected renderer's declaration of what the card's RAW + * exits may show (types/injectable-ui.ts). + * + * The card always mounts a `raw json` tab rendering `message.input` / + * `message.output`, with a copy button beside each pane, so a renderer that + * redacts a capability inside its own card has NOT contained it. Both exits + * are pinned here: what the panes render (server-rendered HTML) and what the + * copy button copies (`paneCopyText` — the single derivation `ValuePane` + * hands `PaneShell`; console/web has no DOM in unit tests, so the clipboard + * is pinned at that seam rather than by clicking). + * + * `@/lib/ui-slots` is mocked because `useSyncExternalStore` would hand back + * the (always empty) server snapshot under `renderToStaticMarkup`; the + * fencing, `rawRedactor` and the panes below it are the real code. `Tabs` is + * mocked to a passthrough because Radix renders only the ACTIVE tab — and + * the leaking pane is the inactive one. + */ + +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RegisteredRenderer } from '@/lib/ui-slots' +import type { FunctionTriggerMessage } from '@/types/chat' +import type { FunctionTriggerRenderer } from '@/types/injectable-ui' +import { FunctionTriggerCard, paneCopyText } from './FunctionTriggerCard' +import { + functionTriggerRenderers, + RAW_REDACTION_FAILED, + rawRedactor, +} from './renderer-registry' + +const { injected } = vi.hoisted(() => ({ + injected: [] as RegisteredRenderer[], +})) + +vi.mock('@/lib/ui-slots', () => ({ + useExtRenderers: () => injected, +})) + +vi.mock('@/components/ui/Tabs', () => ({ + Tabs: ({ children }: { children?: React.ReactNode }) =>
{children}
, + TabsList: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + TabsTrigger: ({ children }: { children?: React.ReactNode }) => ( + + ), + TabsContent: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})) + +const SECRET = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' +const MASKED = 'rt-3f9a…' +const FN = 'code-runner::eval' + +/** A worker-shaped redactor: every string, object keys included. */ +function maskDeep(value: unknown): unknown { + if (typeof value === 'string') return value.replaceAll(SECRET, MASKED) + if (value === null || typeof value !== 'object') return value + if (Array.isArray(value)) return value.map(maskDeep) + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [ + maskDeep(k), + maskDeep(v), + ]), + ) +} + +function register(renderer: Partial) { + injected.push({ + scope: 'code-runner', + path: 'code-runner/page.js', + renderer: { + id: 'code-runner/page.js#test', + isMatch: (functionId) => functionId === FN, + tryRender: () => null, + ...renderer, + }, + }) +} + +function message( + extra: Partial = {}, +): FunctionTriggerMessage { + return { + id: 'm1', + role: 'function-trigger', + functionId: FN, + createdAt: 0, + input: { runtime_id: SECRET, code: `// ran in ${SECRET}` }, + output: { registered: [`code-runner::${SECRET}::foo`] }, + durationMs: 12, + ...extra, + } +} + +function html(extra: Partial = {}): string { + return renderToStaticMarkup( + , + ) +} + +/** + * What the card's two copy buttons would put on the clipboard: the same + * resolution the card does (`rawRedactor` over the live dispatch list), then + * the pane's own copy-text derivation. + */ +function copyTexts(extra: Partial = {}): string[] { + const m = message(extra) + const redact = rawRedactor(functionTriggerRenderers(injected), m.functionId) + return [m.input, m.output].map((v) => paneCopyText(redact ? redact(v) : v)) +} + +beforeEach(() => { + injected.length = 0 +}) +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('a renderer that declares redactRaw', () => { + beforeEach(() => { + register({ redactRaw: maskDeep }) + }) + + it('redacts both panes of the default (no custom terminal) card', () => { + const out = html() + expect(out).not.toContain(SECRET) + expect(out).toContain(MASKED) + }) + + it('redacts both panes of the raw json tab behind a custom terminal', () => { + injected.length = 0 + // A card that renders none of the payload: the raw tab is exactly the + // exposure this test exists for. + register({ + redactRaw: maskDeep, + tryRender: () => custom terminal, + }) + const out = html() + expect(out).toContain('custom terminal') + expect(out).toContain('raw json') + expect(out).not.toContain(SECRET) + expect(out).toContain(MASKED) + }) + + it('redacts the in-flight response pane and the streaming args tail', () => { + expect(html({ running: true })).not.toContain(SECRET) + const streaming = html({ + running: true, + output: undefined, + input: { _streaming: `{"runtime_id":"${SECRET}"` }, + }) + expect(streaming).not.toContain(SECRET) + expect(streaming).toContain(MASKED) + }) + + it('redacts the pending-approval request pane', () => { + const out = html({ + pendingApproval: true, + output: undefined, + durationMs: undefined, + }) + expect(out).not.toContain(SECRET) + expect(out).toContain(MASKED) + }) + + it('redacts what the copy buttons copy', () => { + const copied = copyTexts() + expect(copied).toHaveLength(2) + for (const text of copied) { + expect(text).not.toContain(SECRET) + expect(text).toContain(MASKED) + } + }) + + it('redacts the copy of a result envelope, whose copy text is the whole value', () => { + const [, response] = copyTexts({ + output: { + content: [{ type: 'text', text: `ran in ${SECRET}` }], + details: { runtime_id: SECRET }, + }, + }) + expect(response).not.toContain(SECRET) + expect(response).toContain(MASKED) + }) +}) + +describe('without a matching redactRaw', () => { + // These are the positive controls: they prove the payload's secret reaches + // the HTML (and the clipboard) verbatim through the very same code path, + // so the `not.toContain` assertions above are not vacuous. + it('leaves the raw panes untouched when the renderer declares none', () => { + register({}) + expect(html()).toContain(SECRET) + expect(copyTexts()[0]).toContain(SECRET) + }) + + it('leaves a message the renderer does not claim untouched', () => { + register({ redactRaw: maskDeep }) + expect(html({ functionId: 'shell::run' })).toContain(SECRET) + }) + + it('is untouched when nothing is injected at all', () => { + expect(html()).toContain(SECRET) + }) +}) + +describe('a throwing redactRaw', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + register({ + redactRaw: () => { + throw new Error('boom') + }, + }) + }) + + it('fails closed: the placeholder renders, never the raw value', () => { + const out = html() + expect(out).not.toContain(SECRET) + expect(out).toContain('redaction failed') + }) + + it('fails closed on the copy path too', () => { + for (const text of copyTexts()) { + expect(text).not.toContain(SECRET) + expect(text).toBe(RAW_REDACTION_FAILED) + } + }) +}) + +describe('rawRedactor', () => { + it('picks the first renderer that both claims the id and declares redactRaw', () => { + const renderers: FunctionTriggerRenderer[] = [ + { id: 'a', isMatch: () => true, tryRender: () => null }, + { + id: 'b', + isMatch: (f) => f === FN, + tryRender: () => null, + redactRaw: () => 'b', + }, + { + id: 'c', + isMatch: () => true, + tryRender: () => null, + redactRaw: () => 'c', + }, + ] + expect(rawRedactor(renderers, FN)?.({})).toBe('b') + expect(rawRedactor(renderers, 'other::fn')?.({})).toBe('c') + expect(rawRedactor(renderers.slice(0, 1), FN)).toBeUndefined() + }) +}) diff --git a/console/web/src/components/function-trigger/renderer-registry.tsx b/console/web/src/components/function-trigger/renderer-registry.tsx index 104ed5718..86b115673 100644 --- a/console/web/src/components/function-trigger/renderer-registry.tsx +++ b/console/web/src/components/function-trigger/renderer-registry.tsx @@ -146,6 +146,17 @@ export const FIRST_PARTY_RENDERERS: readonly FunctionTriggerRenderer[] = [ }, ] +/** + * What a fenced `redactRaw` returns when the injected implementation throws. + * + * Fails CLOSED on purpose: `redactRaw` exists to keep a capability out of the + * raw pane and off the clipboard, so the usual "degrade to the untouched + * value" fallback would hand over exactly what it was declared to hide. A + * broken redactor costs the operator the raw view (the card, the terminal tab + * and the trace are all still there), never the secret. + */ +export const RAW_REDACTION_FAILED = '[redaction failed — value withheld]' + /** * Fence one injected renderer: a throw inside `tryRender*` (called during * the host card's render, outside any boundary) degrades to a chip, and the @@ -195,19 +206,55 @@ function fenceInjected(entry: RegisteredRenderer): FunctionTriggerRenderer { : undefined, FunctionIdLabel: renderer.FunctionIdLabel, primaryTabLabel: renderer.primaryTabLabel, + redactRaw: renderer.redactRaw + ? (value: unknown) => { + try { + return renderer.redactRaw?.(value) + } catch (error) { + console.error(`[iii-ui] redactRaw of ${renderer.id} threw`, error) + return RAW_REDACTION_FAILED + } + } + : undefined, } } +/** The dispatch order for a set of injected registrations (fenced first). */ +export function functionTriggerRenderers( + injected: readonly RegisteredRenderer[], +): readonly FunctionTriggerRenderer[] { + return [...injected.map(fenceInjected), ...FIRST_PARTY_RENDERERS] +} + /** * The live dispatch order: injected renderers (fenced) first, then the * first-party families. Re-computes exactly when a script (re)registers. */ export function useFunctionTriggerRenderers(): readonly FunctionTriggerRenderer[] { const injected = useExtRenderers() - return useMemo( - () => [...injected.map(fenceInjected), ...FIRST_PARTY_RENDERERS], - [injected], - ) + return useMemo(() => functionTriggerRenderers(injected), [injected]) +} + +/** + * The redactor for one message's raw request/response: the first renderer + * that both declares `redactRaw` and claims `functionId`, or `undefined` when + * none does (the overwhelmingly common case — first-party families never + * declare it, so the check short-circuits before any `isMatch` call). + * + * The console deliberately knows nothing about what a worker's secrets look + * like; it only knows that the worker claiming these ids gets to filter what + * the raw pane shows and what its copy button copies. + */ +export function rawRedactor( + renderers: readonly FunctionTriggerRenderer[], + functionId: string, +): ((value: unknown) => unknown) | undefined { + for (const renderer of renderers) { + if (renderer.redactRaw && renderer.isMatch(functionId)) { + return (value) => renderer.redactRaw?.(value) + } + } + return undefined } export function firstNonNull( diff --git a/console/web/src/lib/function-trigger-copy.test.ts b/console/web/src/lib/function-trigger-copy.test.ts index eb193b225..d0f2ef73c 100644 --- a/console/web/src/lib/function-trigger-copy.test.ts +++ b/console/web/src/lib/function-trigger-copy.test.ts @@ -64,6 +64,25 @@ describe('functionTriggerToText', () => { 'ƒ shell::exec', ) }) + + it('runs the input through `redact` before serializing, when given', () => { + const SECRET = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' + const call = fcall({ + functionId: 'code-runner::eval', + input: { runtime_id: SECRET, code: '1+1' }, + }) + const redact = (v: unknown) => + JSON.parse(JSON.stringify(v).replaceAll(SECRET, 'rt-3f9a…')) + const text = functionTriggerToText(call, redact) + expect(text).not.toContain(SECRET) + expect(text).toContain('rt-3f9a…') + }) + + it('is untouched by an absent redact function (the common case)', () => { + expect(functionTriggerToText(fcall())).toBe( + functionTriggerToText(fcall(), undefined), + ) + }) }) describe('assistantCopyText', () => { @@ -90,6 +109,57 @@ describe('assistantCopyText', () => { it('drops the leading blank line when the message has no prose', () => { expect(assistantCopyText('', [fcall({ input: {} })])).toBe('ƒ shell::exec') }) + + describe('redactFor', () => { + const SECRET = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' + const MASKED = 'rt-3f9a…' + const mask = (v: unknown) => + JSON.parse(JSON.stringify(v).replaceAll(SECRET, MASKED)) + /** Only claims code-runner::eval — proves dispatch is per-call, not global. */ + const redactFor = (functionId: string) => + functionId === 'code-runner::eval' ? mask : undefined + + it('redacts a claimed call and leaves an unclaimed one untouched', () => { + const text = assistantCopyText( + 'ran it', + [ + fcall({ + id: 'c1', + functionId: 'code-runner::eval', + input: { runtime_id: SECRET, code: '1+1' }, + }), + fcall({ + id: 'c2', + functionId: 'shell::exec', + input: { command: `echo ${SECRET}` }, + }), + ], + redactFor, + ) + // The claimed call's secret never appears in the eval block… + expect(text).toContain(MASKED) + // …but the unclaimed shell call is untouched (no redactor claims it) — + // proves dispatch is per-functionId, not a blanket scrub. + expect(text).toContain(SECRET) + }) + + it('never redacts the assistant prose itself', () => { + const proseWithLookalike = `see ${SECRET}` + const text = assistantCopyText( + proseWithLookalike, + [fcall({ functionId: 'shell::exec', input: {} })], + () => mask, + ) + expect(text.startsWith(proseWithLookalike)).toBe(true) + }) + + it('is the same as calling with no redactFor at all when every call is unclaimed', () => { + const calls = [fcall({ functionId: 'shell::exec' })] + expect(assistantCopyText('hi', calls, () => undefined)).toBe( + assistantCopyText('hi', calls), + ) + }) + }) }) describe('functionTriggersByAssistant', () => { @@ -145,10 +215,9 @@ describe('functionTriggersByAssistant', () => { const c1 = fcall({ id: 'c1' }) const a1 = assistant({ id: 'a1' }) const c2 = fcall({ id: 'c2' }) - expect(functionTriggersByAssistant([user(), c1, a1, c2]).get('a1')).toEqual([ - c1, - c2, - ]) + expect(functionTriggersByAssistant([user(), c1, a1, c2]).get('a1')).toEqual( + [c1, c2], + ) }) it('prefers trailing attribution between two assistants', () => { diff --git a/console/web/src/lib/function-trigger-copy.ts b/console/web/src/lib/function-trigger-copy.ts index 36fa289fa..dd1bdba6c 100644 --- a/console/web/src/lib/function-trigger-copy.ts +++ b/console/web/src/lib/function-trigger-copy.ts @@ -29,23 +29,43 @@ function formatJson(v: unknown): string { * One function call as copyable plain text: `ƒ ` and its arguments. The * call is what the model emitted; the tool result (output) is copyable from * the call card itself and is deliberately left out of the message-level copy. + * + * `redact`, when given, is applied to `m.input` before it is serialized — + * the same per-function redactor the call's own card applies to its raw + * pane (see `rawRedactor` in renderer-registry.tsx). This module has no + * business knowing about the renderer registry, so it takes an + * already-resolved redact function as a plain parameter rather than + * importing a module-level singleton — keeps it pure and testable with a + * bare mock. */ -export function functionTriggerToText(m: FunctionTriggerMessage): string { - if (isEmptyInput(m.input)) return `ƒ ${m.functionId}` - return `ƒ ${m.functionId}\n${formatJson(m.input)}` +export function functionTriggerToText( + m: FunctionTriggerMessage, + redact?: (value: unknown) => unknown, +): string { + const input = redact ? redact(m.input) : m.input + if (isEmptyInput(input)) return `ƒ ${m.functionId}` + return `ƒ ${m.functionId}\n${formatJson(input)}` } /** * Copy payload for an assistant turn: its prose followed by every function * call it made, blank-line separated. With no calls the prose is returned * unchanged, so callers can build this unconditionally. + * + * `redactFor`, when given, maps a call's function id to its redactor (the + * call site threads in `(functionId) => rawRedactor(renderers, functionId)` + * — see MessageList.tsx). Only the calls' arguments run through it; the + * assistant's own prose never carries a worker's capability. */ export function assistantCopyText( content: string, calls: readonly FunctionTriggerMessage[], + redactFor?: (functionId: string) => ((value: unknown) => unknown) | undefined, ): string { if (calls.length === 0) return content - const callText = calls.map(functionTriggerToText).join('\n\n') + const callText = calls + .map((m) => functionTriggerToText(m, redactFor?.(m.functionId))) + .join('\n\n') return content ? `${content}\n\n${callText}` : callText } diff --git a/console/web/src/pages/TracesV2/components/SpanBaggageTab.tsx b/console/web/src/pages/TracesV2/components/SpanBaggageTab.tsx index a4b1085f9..ff5041554 100644 --- a/console/web/src/pages/TracesV2/components/SpanBaggageTab.tsx +++ b/console/web/src/pages/TracesV2/components/SpanBaggageTab.tsx @@ -4,6 +4,19 @@ import { EmptyState } from '@/components/ui/EmptyState' import type { VisualizationSpan } from '../lib/traceTransform' import { useCopyToClipboard } from '../lib/traceUtils' +/** + * No `redact` prop, deliberately: code-runner never touches the OTel baggage + * API (`grep -ri baggage code-runner/src` — zero hits in the crate). + * Baggage is CALLER-set routing/identity + * context copied onto every span in its scope (iii-helpers' + * `BaggageSpanProcessor` — turn identity, trace tags, `iii.function.id`), + * propagated top-down from the engine/harness; a worker's own internal + * capability (code-runner's `runtime_id`) never flows the + * other way into it. If a future worker ever starts stamping baggage, + * revisit this — see `SpanPanel.redaction-coverage.test.ts`, which enforces + * that every tab has either a `redact` wiring or a written reason like + * this one. + */ interface SpanBaggageTabProps { span: VisualizationSpan } diff --git a/console/web/src/pages/TracesV2/components/SpanErrorsTab.test.tsx b/console/web/src/pages/TracesV2/components/SpanErrorsTab.test.tsx new file mode 100644 index 000000000..cfc24e642 --- /dev/null +++ b/console/web/src/pages/TracesV2/components/SpanErrorsTab.test.tsx @@ -0,0 +1,92 @@ +/** + * Closes the fourth sibling-tab instance of console-UI review finding #1: + * SpanErrorsTab reads `exception.message`/`exception.stacktrace` off the + * SAME `exception` event `functionTriggerFromSpan.ts`'s `exceptionOutput()` + * reads to build the info tab's redacted error output, renders them + * verbatim, and has its own ungated stack-trace copy button. + * + * `renderToStaticMarkup`, no jsdom — see redact-raw.test.tsx. The + * click-to-copy value is pinned the same way SpanTagsTab.test.tsx pins it: + * composing the pure redaction step with what the copy handler is handed, + * since a DOM click can't be simulated here. + */ + +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { redactValue } from '../lib/redactAttributes' +import type { VisualizationSpan } from '../lib/traceTransform' +import { SpanErrorsTab } from './SpanErrorsTab' + +const SECRET = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' +const MASKED = 'rt-3f9a…' + +function maskDeep(value: unknown): unknown { + if (typeof value === 'string') return value.replaceAll(SECRET, MASKED) + if (value === null || typeof value !== 'object') return value + if (Array.isArray(value)) return value.map(maskDeep) + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [ + maskDeep(k), + maskDeep(v), + ]), + ) +} + +function errorSpan(): VisualizationSpan { + return { + name: 'execute code-runner::eval', + span_id: 's-1', + trace_id: 't-1', + duration_ms: 12, + status: 'error', + depth: 0, + start_percent: 0, + width_percent: 100, + attributes: {}, + events: [ + { + name: 'exception', + timestamp_unix_nano: 1, + attributes: { + 'exception.type': 'NamespaceDenied', + 'exception.message': `registered id "x" must start with this runtime's namespace "code-runner::${SECRET}::"`, + 'exception.stacktrace': `at eval (code-runner::${SECRET}::hello)`, + }, + }, + ], + links: [], + pending: false, + } +} + +const SPAN = errorSpan() + +describe('SpanErrorsTab redaction', () => { + it('redacts the exception message, type context, and stack trace when redact is given', () => { + const html = renderToStaticMarkup( + , + ) + expect(html).not.toContain(SECRET) + expect(html).toContain(MASKED) + }) + + it('leaves the exception untouched without a redact prop (positive control)', () => { + const html = renderToStaticMarkup() + expect(html).toContain(SECRET) + }) +}) + +const RAW_STACK = SPAN.events[0].attributes['exception.stacktrace'] as string + +describe('the stack-trace copy value (pinned without simulating a click)', () => { + it('is redacted: the value copyStackTrace is handed is already redacted', () => { + const copied = redactValue(RAW_STACK, maskDeep) as string + expect(copied).not.toContain(SECRET) + expect(copied).toContain(MASKED) + }) + + it('positive control: the same value leaks without a redactor', () => { + expect(redactValue(RAW_STACK)).toBe(RAW_STACK) + expect(RAW_STACK).toContain(SECRET) + }) +}) diff --git a/console/web/src/pages/TracesV2/components/SpanErrorsTab.tsx b/console/web/src/pages/TracesV2/components/SpanErrorsTab.tsx index a1d106f8c..d82fa9289 100644 --- a/console/web/src/pages/TracesV2/components/SpanErrorsTab.tsx +++ b/console/web/src/pages/TracesV2/components/SpanErrorsTab.tsx @@ -1,14 +1,23 @@ import { AlertCircle, CheckCircle2, Copy } from 'lucide-react' import { useMemo } from 'react' import { EmptyState } from '@/components/ui/EmptyState' +import { redactValue } from '../lib/redactAttributes' import type { VisualizationSpan } from '../lib/traceTransform' import { useCopyToClipboard } from '../lib/traceUtils' interface SpanErrorsTabProps { span: VisualizationSpan + /** + * The span's function-trigger redactor (`spanRawRedactor` in + * functionTriggerFromSpan.ts). `exception.message`/`exception.stacktrace` + * read off the SAME `exception` event `functionTriggerFromSpan.ts`'s + * `exceptionOutput()` reads to build the info tab's redacted error output + * — one tab-click away here otherwise, same as the tags/logs tabs. + */ + redact?: (value: unknown) => unknown } -export function SpanErrorsTab({ span }: SpanErrorsTabProps) { +export function SpanErrorsTab({ span, redact }: SpanErrorsTabProps) { const { copiedKey, copy } = useCopyToClipboard() const exceptionEvent = span.events?.find( (e) => e.name === 'exception' || e.name?.startsWith('exception'), @@ -26,9 +35,18 @@ export function SpanErrorsTab({ span }: SpanErrorsTabProps) { const exceptionStacktrace = (span.attributes?.['exception.stacktrace'] ?? eventAttrs['exception.stacktrace']) as string | undefined - const displayMessage = errorMessage || exceptionMessage - const displayType = errorType || exceptionType - const displayStack = errorStack || exceptionStacktrace + // Redacted ONCE here — both the render below and the stack-trace copy + // button read from these, so they cannot disagree about what is safe. + const displayMessage = redactValue( + errorMessage || exceptionMessage, + redact, + ) as string | undefined + const displayType = redactValue(errorType || exceptionType, redact) as + | string + | undefined + const displayStack = redactValue(errorStack || exceptionStacktrace, redact) as + | string + | undefined // Stack-trace lines are static for a given span and may legitimately // repeat (e.g., recursive frames). Stamp each line with a position- diff --git a/console/web/src/pages/TracesV2/components/SpanLinksTab.tsx b/console/web/src/pages/TracesV2/components/SpanLinksTab.tsx index 1691512b6..d26b35856 100644 --- a/console/web/src/pages/TracesV2/components/SpanLinksTab.tsx +++ b/console/web/src/pages/TracesV2/components/SpanLinksTab.tsx @@ -3,6 +3,16 @@ import { useMemo } from 'react' import { EmptyState } from '@/components/ui/EmptyState' import type { VisualizationSpan } from '../lib/traceTransform' +/** + * No `redact` prop, deliberately: this tab renders `link.trace_id`/ + * `link.span_id` (truncated OTel identifiers, not secrets) and an + * attribute COUNT badge — never a link attribute's actual value. There is + * no code path here that could print a payload-derived string, so there is + * nothing for a redactor to intercept. If a future change starts rendering + * `link.attributes` values, add `redact` then — see + * `SpanPanel.redaction-coverage.test.ts`, which enforces that every tab has + * either a `redact` wiring or a written reason like this one. + */ interface SpanLinksTabProps { span: VisualizationSpan onNavigateToTrace?: (traceId: string) => void diff --git a/console/web/src/pages/TracesV2/components/SpanLogsTab.test.tsx b/console/web/src/pages/TracesV2/components/SpanLogsTab.test.tsx new file mode 100644 index 000000000..a6809858f --- /dev/null +++ b/console/web/src/pages/TracesV2/components/SpanLogsTab.test.tsx @@ -0,0 +1,74 @@ +/** + * Closes console-UI review finding #1 for the EVENTS tab: it pretty-prints + * every event attribute — `iii.payload.json` (the iii-sdk auto-capture + * payload) included — verbatim into a `
`. The info tab's card redacts
+ * the identical payload; this tab did not.
+ *
+ * `renderToStaticMarkup`, no jsdom — see redact-raw.test.tsx.
+ */
+
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import type { VisualizationSpan } from '../lib/traceTransform'
+import { SpanLogsTab } from './SpanLogsTab'
+
+const SECRET = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077'
+const MASKED = 'rt-3f9a…'
+
+function maskDeep(value: unknown): unknown {
+  if (typeof value === 'string') return value.replaceAll(SECRET, MASKED)
+  if (value === null || typeof value !== 'object') return value
+  if (Array.isArray(value)) return value.map(maskDeep)
+  return Object.fromEntries(
+    Object.entries(value as Record).map(([k, v]) => [
+      maskDeep(k),
+      maskDeep(v),
+    ]),
+  )
+}
+
+function spanWithPayloadEvent(payloadJson: string): VisualizationSpan {
+  return {
+    name: 'execute code-runner::eval',
+    span_id: 's-1',
+    trace_id: 't-1',
+    duration_ms: 12,
+    status: 'ok',
+    depth: 0,
+    start_percent: 0,
+    width_percent: 100,
+    attributes: {},
+    events: [
+      {
+        name: 'iii.payload',
+        timestamp_unix_nano: 1,
+        attributes: { 'iii.payload.json': payloadJson },
+      },
+    ],
+    links: [],
+    pending: false,
+  }
+}
+
+const SPAN = spanWithPayloadEvent(
+  JSON.stringify({ runtime_id: SECRET, code: '1+1' }),
+)
+
+describe('SpanLogsTab redaction', () => {
+  it('redacts the iii.payload.json event attribute pretty-printed into the 
', () => {
+    const html = renderToStaticMarkup(
+      ,
+    )
+    expect(html).not.toContain(SECRET)
+    expect(html).toContain(MASKED)
+    // Still pretty-printed JSON, not degraded to a single-line fallback —
+    // the regex substitution preserves the surrounding string's syntax.
+    // (renderToStaticMarkup HTML-escapes quotes in text content.)
+    expect(html).toContain('"code"')
+  })
+
+  it('leaves the event untouched without a redact prop (positive control)', () => {
+    const html = renderToStaticMarkup()
+    expect(html).toContain(SECRET)
+  })
+})
diff --git a/console/web/src/pages/TracesV2/components/SpanLogsTab.tsx b/console/web/src/pages/TracesV2/components/SpanLogsTab.tsx
index 96a31a585..95fc3f6cb 100644
--- a/console/web/src/pages/TracesV2/components/SpanLogsTab.tsx
+++ b/console/web/src/pages/TracesV2/components/SpanLogsTab.tsx
@@ -1,15 +1,23 @@
 import { Clock } from 'lucide-react'
 import { EmptyState } from '@/components/ui/EmptyState'
 import { formatPossibleJson } from '../lib/formatPossibleJson'
+import { redactAttributeEntries } from '../lib/redactAttributes'
 import type { VisualizationSpan } from '../lib/traceTransform'
 import { toMs } from '../lib/traceTransform'
 import { formatRelative, formatTimestamp } from '../lib/traceUtils'
 
 interface SpanLogsTabProps {
   span: VisualizationSpan
+  /**
+   * The span's function-trigger redactor (`spanRawRedactor` in
+   * functionTriggerFromSpan.ts). Event attributes — `iii.payload.json`
+   * chief among them — pretty-print into a `
` below; that is the same
+   * payload the info tab's card redacts, one tab-click away otherwise.
+   */
+  redact?: (value: unknown) => unknown
 }
 
-export function SpanLogsTab({ span }: SpanLogsTabProps) {
+export function SpanLogsTab({ span, redact }: SpanLogsTabProps) {
   const sortedEvents = [...(span.events || [])].sort(
     (a, b) => a.timestamp_unix_nano - b.timestamp_unix_nano,
   )
@@ -35,9 +43,7 @@ export function SpanLogsTab({ span }: SpanLogsTabProps) {
         const offsetMs = eventMs - firstEventMs
         const isException =
           event.name === 'exception' || event.name?.startsWith('exception')
-        const attrEntries = event.attributes
-          ? Object.entries(event.attributes)
-          : []
+        const attrEntries = redactAttributeEntries(event.attributes, redact)
 
         return (
           
() + for (const m of PANEL_SRC.matchAll(/<(Span\w*Tab)\b/g)) names.add(m[1]) + return [...names].sort() +} + +/** The full `` opening-tag text, so a prop check can't + * accidentally match a DIFFERENT tab's props elsewhere in the file. */ +function openingTagOf(componentName: string): string { + const start = PANEL_SRC.indexOf(`<${componentName}`) + if (start === -1) return '' + const end = PANEL_SRC.indexOf('>', start) + return end === -1 ? '' : PANEL_SRC.slice(start, end + 1) +} + +type Disposition = + /** Must receive `redact={redact}` straight from SpanPanel. */ + | { kind: 'redact-prop' } + /** Doesn't take `redact` from SpanPanel because it redacts itself — the + * file named here must say so and must mention redaction. */ + | { kind: 'self-redacted'; file: string; mustMention: string } + /** A runtime_id cannot reach this tab at all — the file named here must + * carry the reasoning, not just this test. */ + | { kind: 'exempt'; file: string; mustMention: string } + +/** + * One entry per tab SpanPanel is allowed to render. Keep this in the same + * order as SpanPanel.tsx's TabsContent list so a diff against that file is + * easy to eyeball. + */ +const TAB_DISPOSITIONS: Record = { + SpanInfoTab: { + kind: 'self-redacted', + // The info tab's FunctionTriggerCard resolves its own redactor via + // rawRedactor — covered by redact-raw.test.tsx, not this file. + file: 'SpanInfoTab.tsx', + mustMention: 'FunctionTriggerCard', + }, + SpanTagsTab: { kind: 'redact-prop' }, + SpanLogsTab: { kind: 'redact-prop' }, + SpanErrorsTab: { kind: 'redact-prop' }, + SpanOtelLogsTab: { + kind: 'exempt', + file: 'SpanOtelLogsTab.tsx', + mustMention: 'redact', + }, + SpanBaggageTab: { + kind: 'exempt', + file: 'SpanBaggageTab.tsx', + mustMention: 'redact', + }, + SpanLinksTab: { + kind: 'exempt', + file: 'SpanLinksTab.tsx', + mustMention: 'redact', + }, +} + +describe('every tab SpanPanel renders has a redaction disposition', () => { + it('has no tab that is neither listed here nor wired to redact', () => { + const found = tabsRenderedByPanel() + const known = new Set(Object.keys(TAB_DISPOSITIONS)) + const unlisted = found.filter((name) => !known.has(name)) + expect( + unlisted, + `SpanPanel.tsx renders ${unlisted.join(', ')} but SpanPanel.redaction-coverage.test.ts ` + + `doesn't know it. Either thread redact={redact} into it, or add a written reason ` + + `in its own file for why a runtime_id can't reach it — then add it to TAB_DISPOSITIONS.`, + ).toEqual([]) + }) + + it('has no stale entry for a tab SpanPanel no longer renders', () => { + const found = new Set(tabsRenderedByPanel()) + const stale = Object.keys(TAB_DISPOSITIONS).filter( + (name) => !found.has(name), + ) + expect( + stale, + `TAB_DISPOSITIONS lists ${stale.join(', ')}, which SpanPanel.tsx no longer renders — ` + + `update this guard so it can't hide a real gap behind a dead entry.`, + ).toEqual([]) + }) + + for (const [name, disposition] of Object.entries(TAB_DISPOSITIONS)) { + if (disposition.kind === 'redact-prop') { + it(`${name}: SpanPanel passes redact={redact} to it`, () => { + const tag = openingTagOf(name) + expect(tag, `<${name} ...> not found in SpanPanel.tsx`).not.toBe('') + expect( + /redact=\{redact\}/.test(tag), + `<${name} ...> in SpanPanel.tsx does not pass redact={redact}:\n${tag}`, + ).toBe(true) + }) + } else { + it(`${name}: ${disposition.kind === 'exempt' ? 'exemption' : 'self-redaction'} is written in ${disposition.file}, not just this test`, () => { + const src = readFileSync(join(DIR, disposition.file), 'utf8') + expect( + new RegExp(disposition.mustMention, 'i').test(src), + `${disposition.file} has no visible reasoning about "${disposition.mustMention}" — ` + + `a disposition asserted only in this test, not in the component's own file, is a ` + + `silent gap the next reader won't see.`, + ).toBe(true) + }) + } + } +}) diff --git a/console/web/src/pages/TracesV2/components/SpanPanel.tsx b/console/web/src/pages/TracesV2/components/SpanPanel.tsx index 2efea96a8..e6d47d174 100644 --- a/console/web/src/pages/TracesV2/components/SpanPanel.tsx +++ b/console/web/src/pages/TracesV2/components/SpanPanel.tsx @@ -1,11 +1,13 @@ import { useQuery } from '@tanstack/react-query' import { ArrowUp, Clock, Copy, Layers, X, Zap } from 'lucide-react' import { useEffect, useMemo } from 'react' +import { useFunctionTriggerRenderers } from '@/components/function-trigger/renderer-registry' import { Button } from '@/components/ui/Button' import { StatusDot } from '@/components/ui/StatusDot' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs' import { cn } from '@/lib/utils' import { fetchOtelLogs } from '../api/otel-logs' +import { spanRawRedactor } from '../lib/functionTriggerFromSpan' import type { VisualizationSpan, WaterfallData } from '../lib/traceTransform' import { formatDuration, @@ -70,6 +72,25 @@ export function SpanPanel({ return { parentSpan, childSpans, selfTime, childDuration } }, [span, traceData]) + // All spans of the trace, for resolving a nested span's owning function — + // same ancestor-chain lookup `SpanInfoTab` uses to build the info card. + const spansById = useMemo( + () => new Map((traceData?.spans ?? []).map((s) => [s.span_id, s] as const)), + [traceData], + ) + const renderers = useFunctionTriggerRenderers() + // Every tab below that reads `span.attributes`/`span.events` renders the + // SAME data the info tab's card is built from (functionTriggerFromSpan.ts) + // — the identical redactor has to cover them (tags, logs, errors), or the + // runtime_id the info card hides is one tab-click away. The remaining tabs + // (otel-logs, baggage, links) are exempt for a written reason at their own + // definition — see `SpanPanel.redaction-coverage.test.ts`, which fails + // loudly if a tab is ever added here without one or the other. + const redact = useMemo( + () => (span ? spanRawRedactor(span, spansById, renderers) : undefined), + [span, spansById, renderers], + ) + const { data: logsData } = useQuery({ queryKey: ['span-otel-logs', span?.trace_id, span?.span_id], queryFn: () => @@ -272,13 +293,13 @@ export function SpanPanel({ - + - + - + diff --git a/console/web/src/pages/TracesV2/components/SpanTagsTab.test.tsx b/console/web/src/pages/TracesV2/components/SpanTagsTab.test.tsx new file mode 100644 index 000000000..a30928da4 --- /dev/null +++ b/console/web/src/pages/TracesV2/components/SpanTagsTab.test.tsx @@ -0,0 +1,99 @@ +/** + * Closes console-UI review finding #1 for the ATTRIBUTES tab: it renders + * `tool.arguments` (and every other span attribute) and makes each row + * click-to-copy. The info tab's card redacts the identical payload + * (functionTriggerFromSpan.ts reads the same sources); this tab did not, + * so the runtime_id the card hid was one tab-click away. + * + * Render assertions go through `renderToStaticMarkup` (no jsdom/testing + * -library — see redact-raw.test.tsx for the established pattern). The + * click-to-copy value can't be proven by simulating a click without jsdom, + * so it is pinned at the same seam that test file uses: the pure function + * (`attributeCopyText`) fed the exact value `redactAttributeEntries` + * already redacted — the same `value` the row's `onClick` closes over. + */ + +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { redactAttributeEntries } from '../lib/redactAttributes' +import type { VisualizationSpan } from '../lib/traceTransform' +import { attributeCopyText, SpanTagsTab } from './SpanTagsTab' + +const SECRET = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' +const MASKED = 'rt-3f9a…' + +/** A worker-shaped redactor, matching code-runner/code-runner's shape. */ +function maskDeep(value: unknown): unknown { + if (typeof value === 'string') return value.replaceAll(SECRET, MASKED) + if (value === null || typeof value !== 'object') return value + if (Array.isArray(value)) return value.map(maskDeep) + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [ + maskDeep(k), + maskDeep(v), + ]), + ) +} + +function span(attributes: Record): VisualizationSpan { + return { + name: 'execute code-runner::eval', + span_id: 's-1', + trace_id: 't-1', + duration_ms: 12, + status: 'ok', + depth: 0, + start_percent: 0, + width_percent: 100, + attributes, + events: [], + links: [], + pending: false, + } +} + +const SPAN = span({ + 'tool.arguments': JSON.stringify({ runtime_id: SECRET, code: '1+1' }), +}) + +describe('SpanTagsTab redaction', () => { + it('redacts an attribute value when redact is given', () => { + const html = renderToStaticMarkup( + , + ) + expect(html).not.toContain(SECRET) + expect(html).toContain(MASKED) + }) + + it('leaves attributes untouched without a redact prop (positive control)', () => { + // Proves the assertions above are not vacuous: the same payload really + // does reach the HTML verbatim through this exact code path when + // nothing claims it. + const html = renderToStaticMarkup() + expect(html).toContain(SECRET) + }) + + it('leaves an unrelated attribute value untouched even when redact is given', () => { + const html = renderToStaticMarkup( + , + ) + expect(html).toContain('internal') + }) +}) + +describe('the click-to-copy value (pinned without simulating a click)', () => { + it('is redacted: attributeCopyText fed the value redactAttributeEntries produced', () => { + const [[key, value]] = redactAttributeEntries(SPAN.attributes, maskDeep) + const copied = attributeCopyText(key, value) + expect(copied).not.toContain(SECRET) + expect(copied).toContain(MASKED) + }) + + it('positive control: the same composition leaks without a redactor', () => { + const [[key, value]] = redactAttributeEntries(SPAN.attributes) + expect(attributeCopyText(key, value)).toContain(SECRET) + }) +}) diff --git a/console/web/src/pages/TracesV2/components/SpanTagsTab.tsx b/console/web/src/pages/TracesV2/components/SpanTagsTab.tsx index da60489ea..7b517b525 100644 --- a/console/web/src/pages/TracesV2/components/SpanTagsTab.tsx +++ b/console/web/src/pages/TracesV2/components/SpanTagsTab.tsx @@ -1,11 +1,30 @@ import { ChevronRight, Copy, Search } from 'lucide-react' import { useMemo, useState } from 'react' import { EmptyState } from '@/components/ui/EmptyState' +import { redactAttributeEntries } from '../lib/redactAttributes' import type { VisualizationSpan } from '../lib/traceTransform' import { useCopyToClipboard } from '../lib/traceUtils' interface SpanTagsTabProps { span: VisualizationSpan + /** + * The span's function-trigger redactor (`spanRawRedactor` in + * functionTriggerFromSpan.ts), when the info tab's card has one. Attribute + * values (`tool.arguments` among them) run through it before they render + * OR are copied — the identical payload the info tab's card already hides + * is one tab-click away here otherwise. + */ + redact?: (value: unknown) => unknown +} + +/** + * The exact text one attribute row's copy button puts on the clipboard. + * Pinned as its own function so the redaction that already ran (see + * `entries` below) is provably what reaches the clipboard, without needing + * to simulate a click — console/web's tests stay jsdom-free. + */ +export function attributeCopyText(key: string, value: unknown): string { + return `${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}` } const NAMESPACE_LABELS: Record = { @@ -37,13 +56,18 @@ interface AttributeGroup { entries: [string, unknown][] } -export function SpanTagsTab({ span }: SpanTagsTabProps) { +export function SpanTagsTab({ span, redact }: SpanTagsTabProps) { const [searchQuery, setSearchQuery] = useState('') const { copiedKey, copy } = useCopyToClipboard() const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const attributes = span.attributes || {} - const entries = useMemo(() => Object.entries(attributes), [attributes]) + // Redacted ONCE here — every render below and the copy button both read + // from this, so they cannot disagree about what is safe to show. + const entries = useMemo( + () => redactAttributeEntries(attributes, redact), + [attributes, redact], + ) const filteredEntries = useMemo(() => { return entries.filter(([key, value]) => { @@ -88,8 +112,7 @@ export function SpanTagsTab({ span }: SpanTagsTabProps) { }, [filteredEntries]) const copyToClipboard = (key: string, value: unknown) => { - const text = `${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}` - copy(key, text) + copy(key, attributeCopyText(key, value)) } const toggleGroup = (namespace: string) => { diff --git a/console/web/src/pages/TracesV2/lib/functionTriggerFromSpan.test.ts b/console/web/src/pages/TracesV2/lib/functionTriggerFromSpan.test.ts index 06485c051..8ad9f09e8 100644 --- a/console/web/src/pages/TracesV2/lib/functionTriggerFromSpan.test.ts +++ b/console/web/src/pages/TracesV2/lib/functionTriggerFromSpan.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' +import type { FunctionTriggerRenderer } from '@/types/injectable-ui' import { functionTriggerFromSpan, spanFunctionId, + spanRawRedactor, } from './functionTriggerFromSpan' import type { VisualizationSpan } from './traceTransform' @@ -238,3 +240,75 @@ describe('functionTriggerFromSpan', () => { expect(call?.identityInherited).toBeUndefined() }) }) + +/** + * `spanRawRedactor` — the redactor `SpanTagsTab`/`SpanLogsTab` apply to a + * span's attributes/events (console-UI review finding #1: those two sibling + * tabs rendered the identical payload the info tab's card already redacts). + * Resolved the same way this file's `functionTriggerFromSpan` resolves the + * info card's function id, so a claimed redactor there is guaranteed to be + * found here too. + */ +describe('spanRawRedactor', () => { + function renderer( + overrides: Partial, + ): FunctionTriggerRenderer { + return { + id: 'test/renderer', + isMatch: () => false, + tryRender: () => null, + ...overrides, + } + } + + it('resolves the redactor of the renderer claiming the span’s own explicit function id', () => { + const span = vis({ + attributes: { 'faas.invoked_name': 'code-runner::eval' }, + }) + const renderers = [ + renderer({ + isMatch: (id) => id === 'code-runner::eval', + redactRaw: () => 'redacted', + }), + ] + expect(spanRawRedactor(span, undefined, renderers)?.({})).toBe('redacted') + }) + + it('resolves through the ancestor chain, same as spanFunctionId', () => { + const trigger = vis({ + span_id: 'trigger', + attributes: { function_id: 'code-runner::eval' }, + }) + const inner = vis({ + span_id: 'inner', + parent_span_id: 'trigger', + name: 'HTTP POST', + }) + const renderers = [ + renderer({ + isMatch: (id) => id === 'code-runner::eval', + redactRaw: () => 'redacted', + }), + ] + expect(spanRawRedactor(inner, byId(trigger, inner), renderers)?.({})).toBe( + 'redacted', + ) + }) + + it('is undefined when the span resolves no function id at all', () => { + const span = vis({ attributes: {} }) // no faas.invoked_name, no baggage + const renderers = [renderer({ isMatch: () => true, redactRaw: () => 'x' })] + expect(spanRawRedactor(span, undefined, renderers)).toBeUndefined() + }) + + it('is undefined when a function id resolves but no renderer claims it', () => { + const span = vis({ attributes: { 'faas.invoked_name': 'shell::exec' } }) + const renderers = [ + renderer({ + isMatch: (id) => id === 'code-runner::eval', + redactRaw: () => 'x', + }), + ] + expect(spanRawRedactor(span, undefined, renderers)).toBeUndefined() + }) +}) diff --git a/console/web/src/pages/TracesV2/lib/functionTriggerFromSpan.ts b/console/web/src/pages/TracesV2/lib/functionTriggerFromSpan.ts index 350c2b59c..9987211c1 100644 --- a/console/web/src/pages/TracesV2/lib/functionTriggerFromSpan.ts +++ b/console/web/src/pages/TracesV2/lib/functionTriggerFromSpan.ts @@ -24,7 +24,9 @@ * event for an error output. */ +import { rawRedactor } from '@/components/function-trigger/renderer-registry' import type { FunctionTriggerMessage } from '@/types/chat' +import type { FunctionTriggerRenderer } from '@/types/injectable-ui' import type { VisualizationSpan } from './traceTransform' const PAYLOAD_ATTR = 'iii.payload.json' @@ -226,3 +228,25 @@ export function functionTriggerFromSpan( ...(inherited ? { identityInherited: true } : {}), } } + +/** + * The redactor `SpanTagsTab`/`SpanLogsTab` should apply to a span's own + * attributes/events, resolved the SAME way `functionTriggerFromSpan` above + * resolves the info tab's card — `spanFunctionId`, not the narrower + * `identityInherited` gating this function applies before deciding whether + * to SHOW a card at all. That gate is about the info tab's card; it is not + * a reason to under-redact a sibling tab that always renders regardless. + * + * `undefined` when `spanFunctionId` resolves no function id (the span is + * not part of any function invocation) or when no injected renderer's + * `redactRaw` claims it — the overwhelmingly common case, matching + * `rawRedactor`'s own contract. + */ +export function spanRawRedactor( + span: VisualizationSpan, + spansById: Map | undefined, + renderers: readonly FunctionTriggerRenderer[], +): ((value: unknown) => unknown) | undefined { + const functionId = spanFunctionId(span, spansById) + return functionId ? rawRedactor(renderers, functionId) : undefined +} diff --git a/console/web/src/pages/TracesV2/lib/redactAttributes.test.ts b/console/web/src/pages/TracesV2/lib/redactAttributes.test.ts new file mode 100644 index 000000000..cfbfacc12 --- /dev/null +++ b/console/web/src/pages/TracesV2/lib/redactAttributes.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { redactAttributeEntries } from './redactAttributes' + +const SECRET = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' +const MASKED = 'rt-3f9a…' + +/** A worker-shaped redactor, matching the code-runner/code-runner shape. */ +function mask(value: unknown): unknown { + if (typeof value === 'string') return value.replaceAll(SECRET, MASKED) + if (value === null || typeof value !== 'object') return value + if (Array.isArray(value)) return value.map(mask) + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [ + mask(k), + mask(v), + ]), + ) +} + +describe('redactAttributeEntries', () => { + it('runs every value through redact, keys untouched', () => { + const out = redactAttributeEntries( + { 'tool.arguments': { runtime_id: SECRET }, 'span.kind': 'internal' }, + mask, + ) + expect(out).toEqual([ + ['tool.arguments', { runtime_id: MASKED }], + ['span.kind', 'internal'], + ]) + }) + + it('passes values through unchanged when redact is absent', () => { + const attrs = { 'tool.arguments': { runtime_id: SECRET } } + expect(redactAttributeEntries(attrs)).toEqual([ + ['tool.arguments', { runtime_id: SECRET }], + ]) + }) + + it('treats missing attributes as empty', () => { + expect(redactAttributeEntries(undefined, mask)).toEqual([]) + }) +}) diff --git a/console/web/src/pages/TracesV2/lib/redactAttributes.ts b/console/web/src/pages/TracesV2/lib/redactAttributes.ts new file mode 100644 index 000000000..7ee8dd087 --- /dev/null +++ b/console/web/src/pages/TracesV2/lib/redactAttributes.ts @@ -0,0 +1,37 @@ +/** + * `redact`, applied to one value — the identity function when `redact` is + * absent (nothing claims the span's function id; see `spanRawRedactor` in + * `functionTriggerFromSpan.ts`). The primitive both helpers below, and any + * tab reading a single ad-hoc field (`SpanErrorsTab`'s message/type/stack) + * rather than a whole attribute bag, build on — one seam, so render and copy + * can never disagree about what is safe to show. + */ +export function redactValue( + value: unknown, + redact?: (value: unknown) => unknown, +): unknown { + return redact ? redact(value) : value +} + +/** + * `Object.entries(attributes)` with `redact` applied to each VALUE — the + * single seam `SpanTagsTab` and `SpanLogsTab` route both their rendered text + * and (for tags) their click-to-copy value through, so the two exits cannot + * disagree about what is safe to show. + * + * Both tabs render the same span data `functionTriggerFromSpan.ts` reads to + * build the info tab's `FunctionTriggerCard` (`iii.payload.json` event + * attributes, `tool.arguments`) — so whenever that card's raw pane has a + * redactor, these siblings need the identical one applied to their own + * copies of the same data. `redact` is `undefined` when nothing claims the + * span's function id, in which case every value passes through unchanged. + */ +export function redactAttributeEntries( + attributes: Record | undefined, + redact?: (value: unknown) => unknown, +): [string, unknown][] { + return Object.entries(attributes ?? {}).map(([key, value]) => [ + key, + redactValue(value, redact), + ]) +} diff --git a/console/web/src/types/injectable-ui.ts b/console/web/src/types/injectable-ui.ts index d282f5bf7..af1e592b4 100644 --- a/console/web/src/types/injectable-ui.ts +++ b/console/web/src/types/injectable-ui.ts @@ -83,6 +83,24 @@ export interface FunctionTriggerRenderer { tryRenderPreview?(message: FunctionTriggerMessage): React.ReactNode | null FunctionIdLabel?: React.ComponentType<{ functionId: string }> primaryTabLabel?: string + /** + * Redact the raw request/response before the card DISPLAYS OR COPIES it — + * the `raw json` tab renders `message.input` / `message.output` verbatim + * and its copy button copies the same value, which a renderer's own card + * cannot contain. The console applies this to both exits (see + * `rawRedactor` in components/function-trigger/renderer-registry.tsx); + * what counts as secret stays the worker's to declare, never the host's. + * + * Consulted only for messages this renderer's `isMatch` claims (first + * claiming renderer that declares it wins), once for the request and once + * for the response. + * + * Receives an arbitrary JSON-ish value and returns the redacted copy. Must + * be pure and total: no mutation, no I/O, no throw for any shape (cycles + * included). Called during the card's render and fenced — a throw fails + * CLOSED to a placeholder, never back to the raw value. + */ + redactRaw?(value: unknown): unknown } export type JsonValue = diff --git a/docs/sops/injectable-console-ui.md b/docs/sops/injectable-console-ui.md index be5dcadae..aa495bca3 100644 --- a/docs/sops/injectable-console-ui.md +++ b/docs/sops/injectable-console-ui.md @@ -262,6 +262,7 @@ interface FunctionTriggerRenderer { tryRenderRunning?(message: FunctionTriggerMessage): React.ReactNode | null tryRenderPreview?(message: FunctionTriggerMessage): React.ReactNode | null FunctionIdLabel?: React.ComponentType<{ functionId: string }> + redactRaw?(value: unknown): unknown } ``` @@ -274,6 +275,34 @@ function ids) and let errors and everything else keep the default cards. Renderer callbacks are fenced: a throwing `isMatch` counts as no-match, a throwing `tryRender` degrades to an error chip, never a broken feed. +#### `redactRaw` — your card is not the only exit + +Whatever your card draws, the settled card **always** mounts a `raw json` tab +that renders `message.input` / `message.output` verbatim, with a copy button +per pane. If your rendering redacts a secret (a capability id, a token, a +path), the raw tab and its clipboard hand it over anyway — one click away. + +`redactRaw` is how the WORKER declares what is secret and the CONSOLE applies +it: for a message your `isMatch` claims, the card runs the request and the +response through it **before rendering the raw panes and before building the +text the copy button copies** (the first claiming renderer that declares it +wins). The console stays ignorant of what your secrets look like — no worker +pattern belongs in shared console code. + +- It gets an arbitrary JSON-ish value (object, array, string, number, + boolean, `null`, `undefined`) and returns the redacted copy. Deep-walk it: + ids hide in nested arrays, in log lines, in error messages, and in object + KEYS. `code-runner/ui/src/lib/shared.tsx` (`redactRuntimeIdsDeep`) is the + reference implementation — a shape-preserving walk with cycle protection. +- Pure and total: never mutate the input, never do I/O, never throw. +- It runs during the host card's render, so it is fenced — and fails + **closed**: a throw renders `[redaction failed — value withheld]` in place + of the value, in the pane and on the clipboard. Degrading to the raw value + would surrender exactly what the method exists to protect. +- It covers the card's raw panes only. A session export dumps the transcript + verbatim by design; do not treat `redactRaw` as an access control — the + payload still crosses the wire and lands in the trace store. + ### `host.configForms.register(configurationId, component)` Replace the schema-generated form for one configuration entry on the Workers diff --git a/packages/console-ui/index.d.ts b/packages/console-ui/index.d.ts index c685a2c4f..0ae9c34cd 100644 --- a/packages/console-ui/index.d.ts +++ b/packages/console-ui/index.d.ts @@ -88,6 +88,26 @@ export interface FunctionTriggerRenderer { tryRenderPreview?(message: FunctionTriggerMessage): React.ReactNode | null FunctionIdLabel?: React.ComponentType<{ functionId: string }> primaryTabLabel?: string + /** + * Redact the raw request/response before the console DISPLAYS OR COPIES + * it. The card's `raw json` tab renders `message.input` / `message.output` + * verbatim and its copy button puts the same value on the clipboard, so a + * card that hides a secret in its own rendering has not contained it — + * declare the secret here and the host applies it at both exits. + * + * Consulted only for messages this renderer's `isMatch` claims (the first + * claiming renderer that declares it wins), once for the request and once + * for the response. + * + * Receives an arbitrary JSON-ish value (object, array, string, number, + * boolean, `null`, or `undefined` — whatever rode on the wire) and returns + * the redacted copy. Must be PURE and TOTAL: never mutate the input, never + * do I/O, never throw for any shape (cycles included). It runs inside the + * host card's render; a throw is fenced and fails CLOSED — the pane shows + * a "redaction failed" placeholder rather than the raw value, so a bug + * here costs you the view, never the secret. + */ + redactRaw?(value: unknown): unknown } export type JsonValue = From b875e556a1f3b72481911356ed5da251c0ead81b Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 4 Aug 2026 20:57:27 -0300 Subject: [PATCH 02/11] feat(code-runner): run Node.js and Python in iii-sandbox microVMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worker that executes nothing itself: every eval and every registered handler call becomes `sandbox::*` calls to the iii-sandbox daemon, on the `node` and `python` preset images. That buys Python, npm/pip, and a real OS per call, and keeps the host filesystem untouched. Three functions, split by lifetime: - `code-runner::eval` is one-shot — `sandbox::run` boots, runs, and stops the VM in a single call, returning no `runtime_id` because nothing survives to address. `keep: true` mints one; passing an existing `runtime_id` reuses that VM and leaves it running. - `code-runner::register_function` is persistent: it creates one runtime per `(namespace, lang)` and publishes the source as a bus function. - `code-runner::teardown` takes a `runtime_id` or a whole namespace. A `runtime_id` is a capability — it addresses a live VM — so it never reaches a caller that does not already hold it: the error types redact it, and the injected console UI declares `redactRaw` so the card, its raw pane, and the clipboard are covered too. The handler-to-runner protocol frames results with a per-call sentinel carried in a stdin envelope, never argv. The sentinel is a framing device, not a security boundary — the handler loads into the runner's own process and can intercept stdout to forge a frame; the doc comment says so plainly. Missing iii-sandbox is not fatal: the worker warns at boot and keeps serving, failing each call with a clear message. --- .github/release-workers.yaml | 1 + README.md | 1 + code-runner/Cargo.lock | 2287 +++++++++++++++ code-runner/Cargo.toml | 38 + code-runner/README.md | 178 ++ code-runner/build.rs | 179 ++ code-runner/config.yaml | 3 + code-runner/iii.worker.yaml | 13 + code-runner/src/config.rs | 173 ++ code-runner/src/engine.rs | 355 +++ code-runner/src/error.rs | 357 +++ code-runner/src/functions/eval.rs | 152 + code-runner/src/functions/inject_guidance.rs | 179 ++ code-runner/src/functions/mod.rs | 212 ++ code-runner/src/functions/register.rs | 69 + code-runner/src/functions/teardown.rs | 100 + code-runner/src/lib.rs | 14 + code-runner/src/main.rs | 132 + code-runner/src/manager.rs | 2525 +++++++++++++++++ code-runner/src/manifest.rs | 53 + code-runner/src/runner.rs | 337 +++ code-runner/src/ui.rs | 119 + code-runner/tests/golden/runners/run.mjs | 50 + code-runner/tests/golden/runners/run.py | 54 + .../golden/schemas/code-runner.eval.json | 106 + .../schemas/code-runner.inject-guidance.json | 56 + .../code-runner.register_function.json | 66 + .../golden/schemas/code-runner.teardown.json | 61 + code-runner/tests/integration.rs | 464 +++ code-runner/tests/manifest.rs | 16 + code-runner/tests/runner_exec.rs | 379 +++ code-runner/tests/schemas.rs | 82 + code-runner/tests/support/mod.rs | 118 + code-runner/ui/build.mjs | 37 + code-runner/ui/package.json | 23 + code-runner/ui/page.tsx | 31 + .../function-trigger-message/eval.test.tsx | 412 +++ .../ui/src/function-trigger-message/eval.tsx | 418 +++ .../ui/src/function-trigger-message/index.tsx | 52 + .../redact-runtime-ids.test.tsx | 271 ++ .../register-function.test.tsx | 424 +++ .../register-function.tsx | 494 ++++ .../teardown.test.tsx | 282 ++ .../src/function-trigger-message/teardown.tsx | 227 ++ code-runner/ui/src/lib/shared.test.tsx | 202 ++ code-runner/ui/src/lib/shared.tsx | 523 ++++ code-runner/ui/styles.css | 326 +++ code-runner/ui/tsconfig.json | 14 + pnpm-lock.yaml | 28 + pnpm-workspace.yaml | 1 + 50 files changed, 12694 insertions(+) create mode 100644 code-runner/Cargo.lock create mode 100644 code-runner/Cargo.toml create mode 100644 code-runner/README.md create mode 100644 code-runner/build.rs create mode 100644 code-runner/config.yaml create mode 100644 code-runner/iii.worker.yaml create mode 100644 code-runner/src/config.rs create mode 100644 code-runner/src/engine.rs create mode 100644 code-runner/src/error.rs create mode 100644 code-runner/src/functions/eval.rs create mode 100644 code-runner/src/functions/inject_guidance.rs create mode 100644 code-runner/src/functions/mod.rs create mode 100644 code-runner/src/functions/register.rs create mode 100644 code-runner/src/functions/teardown.rs create mode 100644 code-runner/src/lib.rs create mode 100644 code-runner/src/main.rs create mode 100644 code-runner/src/manager.rs create mode 100644 code-runner/src/manifest.rs create mode 100644 code-runner/src/runner.rs create mode 100644 code-runner/src/ui.rs create mode 100644 code-runner/tests/golden/runners/run.mjs create mode 100644 code-runner/tests/golden/runners/run.py create mode 100644 code-runner/tests/golden/schemas/code-runner.eval.json create mode 100644 code-runner/tests/golden/schemas/code-runner.inject-guidance.json create mode 100644 code-runner/tests/golden/schemas/code-runner.register_function.json create mode 100644 code-runner/tests/golden/schemas/code-runner.teardown.json create mode 100644 code-runner/tests/integration.rs create mode 100644 code-runner/tests/manifest.rs create mode 100644 code-runner/tests/runner_exec.rs create mode 100644 code-runner/tests/schemas.rs create mode 100644 code-runner/tests/support/mod.rs create mode 100644 code-runner/ui/build.mjs create mode 100644 code-runner/ui/package.json create mode 100644 code-runner/ui/page.tsx create mode 100644 code-runner/ui/src/function-trigger-message/eval.test.tsx create mode 100644 code-runner/ui/src/function-trigger-message/eval.tsx create mode 100644 code-runner/ui/src/function-trigger-message/index.tsx create mode 100644 code-runner/ui/src/function-trigger-message/redact-runtime-ids.test.tsx create mode 100644 code-runner/ui/src/function-trigger-message/register-function.test.tsx create mode 100644 code-runner/ui/src/function-trigger-message/register-function.tsx create mode 100644 code-runner/ui/src/function-trigger-message/teardown.test.tsx create mode 100644 code-runner/ui/src/function-trigger-message/teardown.tsx create mode 100644 code-runner/ui/src/lib/shared.test.tsx create mode 100644 code-runner/ui/src/lib/shared.tsx create mode 100644 code-runner/ui/styles.css create mode 100644 code-runner/ui/tsconfig.json diff --git a/.github/release-workers.yaml b/.github/release-workers.yaml index 54eb512e8..959c16a7f 100644 --- a/.github/release-workers.yaml +++ b/.github/release-workers.yaml @@ -93,6 +93,7 @@ standard_workers: - pubsub - queue - rbac-proxy + - code-runner - session-manager - slack - telegram-bot diff --git a/README.md b/README.md index bfdf1777f..01b6ba50f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ npx skills add iii-hq/iii --all | [`computer`](computer/) | Rust | Full-desktop computer use — start a session on this machine, a sandboxed desktop, or a remote one, screenshot it, click and type by coordinate, and stream the live screen into the console. | | [`worktree`](worktree/) | Rust | Git worktree lifecycle for parallel agents — `worktree::*` mint, claim, and track isolated worktrees per repo, emit six lifecycle trigger types, and land branches back through a per-repo FIFO queue (rebase, test gate, ff-only merge). | | [`github`](github/) | Rust | GitHub CLI (`gh`) as an iii worker — typed `github::pr/issue/repo/run/workflow/release/search::*` functions plus `github::exec` argv passthrough and `github::api` for any GitHub REST endpoint. | +| [`code-runner`](code-runner/) | Rust | Run Node.js and Python in iii-sandbox microVMs — eval code, register bus functions from working source, and tear down runtimes on demand. | | [`openwiki`](openwiki/) | Node | Source-grounded markdown wiki for any git repository — a lead agent plans the index and writer sub-agents store cited pages via `openwiki::write-page`, with router and heuristic fallback tiers, incremental refresh from git diffs on a per-wiki cron schedule, and a browser UI + JSON API under `/openwiki`. | | [`pdf`](pdf/) | Rust | Read PDFs locally — `pdf::classify` routes text-based versus scanned in tens of milliseconds and names the pages that still need OCR, `pdf::to-markdown` converts with headings, lists and tables intact, and `pdf::extract-items` / `::extract-regions` expose positions and the text inside a box. Ships a console page. | diff --git a/code-runner/Cargo.lock b/code-runner/Cargo.lock new file mode 100644 index 000000000..d98572ea1 --- /dev/null +++ b/code-runner/Cargo.lock @@ -0,0 +1,2287 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "code-runner" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "futures", + "iii-console-ui", + "iii-helpers", + "iii-sdk", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "which", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "iii-helpers" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d84d5c149ae4404365a79feca28aa66f6a7dbed56423b4b8c4e2421e0b5add" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dd060fddcc9153b0dd07c038a14cf172ce15ce1d4edb98155563ed55b2caba" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-helpers", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.5", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/code-runner/Cargo.toml b/code-runner/Cargo.toml new file mode 100644 index 000000000..9cbe3a95c --- /dev/null +++ b/code-runner/Cargo.toml @@ -0,0 +1,38 @@ +[workspace] + +[package] +name = "code-runner" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "code-runner" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +iii-sdk = "=0.21.6" +iii-helpers = "=0.21.6" +# Worker-side injectable console UI (content function + console:script/style +# triggers + hot-reload watcher) — direct link, never published. +iii-console-ui = { path = "../crates/console-ui" } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "signal", "time"] } +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +# Must stay on the same schemars major as iii-sdk so derived schemas match +# what RegisterFunction emits at registration time. +schemars = "0.8" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive", "env"] } +uuid = { version = "1", features = ["v4"] } +base64 = "0.22" + +[dev-dependencies] +which = "8" diff --git a/code-runner/README.md b/code-runner/README.md new file mode 100644 index 000000000..807d2f8ea --- /dev/null +++ b/code-runner/README.md @@ -0,0 +1,178 @@ +# code-runner + +Run Node.js and Python in isolated microVMs: iterate on code with +`code-runner::eval`, publish working functions to the bus with +`code-runner::register_function`, clean up with `code-runner::teardown`. + +code-runner delegates every execution to the +[iii-sandbox daemon](https://workers.iii.dev/workers/iii-sandbox) +(`sandbox::*` triggers) rather than running an in-process interpreter — you +get Python, npm/pip, and a real OS per call, at the cost of heavier runtimes. +code-runner itself executes nothing and touches no host filesystem. + +## Install + +```bash +iii worker add code-runner +``` + +code-runner delegates every execution to the iii-sandbox daemon, so install +that too: + +```bash +iii worker add iii-sandbox +``` + +Missing iii-sandbox is not fatal — code-runner warns loudly at boot and +keeps serving; every call fails with a clear message until you add it. + +## Prerequisites + +- Hardware virtualization on the engine host (`/dev/kvm` on Linux, Apple + Silicon on macOS) — the iii-sandbox daemon's requirement, inherited. + +## Evaluating code + +`eval` is **one-shot by default**: it boots a VM, runs your code, returns +the result, and destroys the VM before the response is even sent. Nothing +persists — no files, no installed packages — and the response carries no +`runtime_id`, because there is nothing left to address. + +```bash +iii trigger code-runner::eval lang=python code='print(2+2)' +# → { "stdout": "4\n", "stderr": "", "exit_code": 0, "success": true, "duration_ms": … } +``` + +Pass `keep=true` to leave the VM running instead. The response's +`runtime_id` then addresses it — treat it as a secret — and is the +capability `code-runner::teardown` needs to stop it: + +```bash +iii trigger code-runner::eval lang=python code='print(2+2)' keep=true +# → { "runtime_id": "rt-…", "stdout": "4\n", … } + +# run more code in the SAME runtime (same filesystem, fresh process) +iii trigger code-runner::eval runtime_id=rt-… code='print(open("/tmp/x").read())' +``` + +Passing `runtime_id` back in a later eval reuses that VM: **variables do +not survive between evals; files and installed packages do.** A runtime +you hold via `runtime_id` is never auto-stopped — you own it until you +tear it down (`code-runner::teardown`) or its idle TTL reaps it. A failing +script is a response (`success: false`, `stderr`), not an error — errors +are reserved for infrastructure (unknown runtime, expired VM, timeouts, +capacity). + +**`network` needs an existing runtime.** Outbound network +(`npm install` / `pip install`) can only ever be enabled on a runtime's +*own* creation — and neither the one-shot path nor `keep: true` can create +one with network, because both run through the daemon's `sandbox::run`, +which has no network flag at all. Passing `network: true` without an +explicit `runtime_id` is therefore refused outright (`invalid_request`), +not silently ignored. `network: true` is still accepted, and still +ignored, when reusing an existing runtime by `runtime_id` — that runtime's +network was fixed when it was created. + +## Registering a function + +`register_function` needs **no `runtime_id`**. Give it `function_id`, +`source`, `lang`, and an optional `description`; code-runner keeps one +persistent runtime per **namespace** (the first segment of the id — +`app::greet` claims `app::`) and language, creating it on the namespace's +first registration and reusing it for every later one in the same +namespace and `lang`: + +```bash +iii trigger code-runner::register_function \ + function_id=my-app::double \ + lang=python \ + description='Double a number. Payload: { n: number }.' \ + source='def handler(payload): + return {"doubled": payload["n"] * 2}' + +iii trigger my-app::double n=21 +# → { "doubled": 42 } +``` + +`source` must define `handler(payload)` in `lang`. The first registered id +in a namespace claims it; later ids there must share it — AND share its +`lang`, since a runtime is single-language. Each call runs in a fresh +interpreter process inside the namespace's runtime at the configured +`default_timeout_ms`; anything the handler prints goes to code-runner's +debug log, and the caller receives exactly what `handler` returned, +JSON-serialized. + +The runtime backing a namespace is entirely an implementation detail — you +never see or manage its `runtime_id`. It carries no network access (there +is no `network` field on this request either). + +## Teardown and expiry + +Pass **exactly one** of `runtime_id` (a kept eval's runtime) or +`namespace` (every runtime — one per language — backing a +`register_function` namespace): + +```bash +iii trigger code-runner::teardown runtime_id=rt-… +# → { "runtime_id": "rt-…", "torn_down": true, "unregistered": [] } + +iii trigger code-runner::teardown namespace=my-app +# → { "namespace": "my-app::", "torn_down": true, "unregistered": ["my-app::double"] } +``` + +Passing both, or neither, is refused (`invalid_request`) with a message +naming which one to use. Tearing down a namespace destroys every runtime +backing it (one per language it was used in) and unregisters every +function any of them had published — exactly as tearing down a single +kept-eval runtime unregisters that runtime's functions. + +Idle runtimes are reaped by the iii-sandbox daemon after `idle_ttl_secs` +(default 900; any eval or call resets the clock). A reaped runtime +surfaces as `code-runner::expired` on its next use, its bus functions are +unregistered, and its id is forgotten — for a kept-eval runtime, eval again +with `keep: true` (or one-shot, if persistence is no longer needed) to +boot a fresh one; for a namespace runtime, the next `register_function` in +that namespace boots a fresh one automatically. There is no auto-respawn: a +revived VM would have lost its filesystem (installed packages included), +and a half-working function is worse than an honest error. + +**Unregistration is lazy.** A reaped runtime's bus functions are not +removed the moment the TTL passes — they are unregistered only when +something next calls into the dead runtime and gets the `expired` outcome +above. Until then, a stale runtime's functions still show up in the +catalog (e.g. `engine::functions::info`) even though they can no longer be +invoked. + +**Restarting code-runner also invalidates every outstanding `runtime_id`** +and every namespace binding — all state is in-process — but with a +**different** error: `code-runner::runtime_not_found`, not `expired`, +since the new process has no record of the old id at all (and a +`namespace` teardown against a namespace with no live runtime gets the +same code). The orphaned microVM itself is unaffected by the restart and +keeps running in the iii-sandbox daemon until its own idle TTL reaps it; +there is no drain-on-shutdown. + +## Configuration + +```yaml +default_timeout_ms: 5000 # per eval and per handler invocation when unspecified +max_timeout_ms: 30000 # ceiling a request's timeout_ms is clamped to +idle_ttl_secs: 900 # passed to sandbox::create; the daemon reaps idle VMs +``` + +Images (`node`, `python`), CPU/memory caps, sandbox concurrency, and the +image allowlist are the iii-sandbox daemon's configuration — code-runner +deliberately duplicates none of them; a daemon-side refusal (e.g. capacity) +maps to `code-runner::capacity`. + +## Errors + +| code | meaning | +|---|---| +| `code-runner::invalid_request` | malformed field, wrong lang, namespace violation, id already taken, `network: true` with no runtime to honor it, `teardown` given both or neither of `runtime_id`/`namespace` | +| `code-runner::runtime_not_found` | unknown `runtime_id`, or a `namespace` teardown naming one with no live runtime | +| `code-runner::expired` | the runtime's idle VM was reaped; retry the call that discovered it (a fresh `eval`, or `register_function` again) | +| `code-runner::capacity` | the daemon refused a new sandbox (its concurrency/image caps) | +| `code-runner::timeout` | the eval or call blew its deadline | +| `code-runner::handler_error` | the handler threw, or returned non-JSON-serializable data | +| `code-runner::engine` | anything else from the bus or daemon, diagnostic passed through | diff --git a/code-runner/build.rs b/code-runner/build.rs new file mode 100644 index 000000000..63aa6755f --- /dev/null +++ b/code-runner/build.rs @@ -0,0 +1,179 @@ +//! Build script for the `code-runner` worker. +//! +//! 1. Forwards the build-time target triple to the binary as `env!("TARGET")` +//! (used by `manifest.rs` for the registry `supported_targets` field). +//! 2. Ensures the injected console UI assets exist: `src/ui.rs` embeds +//! `ui/dist/page.js` and `ui/dist/styles.css` via `include_str!`, so if +//! either is missing or stale we run `pnpm install && pnpm build` inside +//! `ui/` first (the `state` worker's precedent). Set `SKIP_UI_BUILD=1` to +//! use the existing `ui/dist/` outputs as-is. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); + + // `dist/` itself is not listed: include_str! reads it directly, and + // listing it would rebuild-loop on our own output. + println!("cargo:rerun-if-changed=ui/page.tsx"); + println!("cargo:rerun-if-changed=ui/styles.css"); + println!("cargo:rerun-if-changed=ui/src"); + println!("cargo:rerun-if-changed=ui/build.mjs"); + println!("cargo:rerun-if-changed=ui/package.json"); + // The lockfile lives at the workers-repo root (pnpm workspace: the ui + // project links @iii-dev/console-ui from packages/console-ui). + println!("cargo:rerun-if-changed=../pnpm-lock.yaml"); + println!("cargo:rerun-if-changed=ui/tsconfig.json"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let dist_assets = [ + ui_dir.join("dist").join("page.js"), + ui_dir.join("dist").join("styles.css"), + ]; + + if dist_assets + .iter() + .all(|a| a.exists() && dist_is_fresh(a, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + for asset in &dist_assets { + if !asset.exists() { + panic!( + "SKIP_UI_BUILD set but {} is missing — build the UI manually \ + (cd ui && pnpm install && pnpm build) or unset the env var", + asset.display() + ); + } + } + return; + } + + let pnpm = locate_pnpm(); + + let status = Command::new(&pnpm) + .args(["install"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| { + panic!( + "failed to spawn `pnpm install` in {}: {e}", + ui_dir.display() + ) + }); + if !status.success() { + panic!("`pnpm install` exited with {status} — see logs above"); + } + + let status = Command::new(&pnpm) + .args(["build"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| panic!("failed to spawn `pnpm build` in {}: {e}", ui_dir.display())); + if !status.success() { + panic!("`pnpm build` exited with {status} — see logs above"); + } + + for asset in &dist_assets { + if !asset.exists() { + panic!( + "`pnpm build` finished but {} is still missing — check the esbuild \ + output above", + asset.display() + ); + } + } +} + +/// `true` when the built asset is at least as new as every source that +/// contributes to it. Conservative: any I/O failure forces a rebuild. +fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool { + let Ok(dist_mtime) = dist_asset.metadata().and_then(|m| m.modified()) else { + return false; + }; + + let watched_files = [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ui_dir.join("tsconfig.json"), + ]; + for f in watched_files.iter() { + if !f.exists() { + continue; + } + let Ok(m) = f.metadata().and_then(|m| m.modified()) else { + return false; + }; + if m > dist_mtime { + return false; + } + } + + for dir in [ui_dir.join("src")] { + if dir.exists() && !subtree_older_than(&dir, dist_mtime) { + return false; + } + } + + true +} + +fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool { + let Ok(read) = std::fs::read_dir(root) else { + return false; + }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + return false; + }; + if meta.is_dir() { + if !subtree_older_than(&path, ceiling) { + return false; + } + } else { + let Ok(m) = meta.modified() else { + return false; + }; + if m > ceiling { + return false; + } + } + } + true +} + +fn locate_pnpm() -> PathBuf { + if let Ok(explicit) = std::env::var("PNPM") { + return PathBuf::from(explicit); + } + let candidates = if cfg!(windows) { + ["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice() + } else { + ["pnpm"].as_slice() + }; + let path = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path) { + for name in candidates { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } + } + } + panic!( + "pnpm not found on PATH — install Node + pnpm, or set SKIP_UI_BUILD=1 \ + after building the UI manually with `cd ui && pnpm install && pnpm build`" + ); +} diff --git a/code-runner/config.yaml b/code-runner/config.yaml new file mode 100644 index 000000000..dc85d7a42 --- /dev/null +++ b/code-runner/config.yaml @@ -0,0 +1,3 @@ +default_timeout_ms: 5000 # per eval and per handler invocation when unspecified +max_timeout_ms: 30000 # ceiling a request's timeout_ms is clamped to +idle_ttl_secs: 900 # passed to sandbox::create; the daemon reaps idle VMs diff --git a/code-runner/iii.worker.yaml b/code-runner/iii.worker.yaml new file mode 100644 index 000000000..10252848b --- /dev/null +++ b/code-runner/iii.worker.yaml @@ -0,0 +1,13 @@ +iii: v1 +name: code-runner +language: rust +deploy: binary +manifest: Cargo.toml +bin: code-runner +tags: [nodejs, python, eval, sandbox, microvm] +description: Run Node.js and Python in iii-sandbox microVMs — eval code, register bus functions from working source, and tear down runtimes on demand. + +# code-runner has no V8 (or any other platform-restricted) +# dependency — everything it links (iii-sdk, tokio, serde, schemars, clap, +# uuid, base64…) builds cleanly on every default triple. No `targets:` +# restriction needed; the release matrix fans out to all default triples. diff --git a/code-runner/src/config.rs b/code-runner/src/config.rs new file mode 100644 index 000000000..aa2032776 --- /dev/null +++ b/code-runner/src/config.rs @@ -0,0 +1,173 @@ +use std::time::Duration; + +use anyhow::Result; +use serde::Deserialize; + +/// Operator-facing limits. Every field has a `serde(default)` so an empty or +/// partial `config.yaml` still yields a fully-populated struct. Image +/// allowlists, CPU/memory caps, and sandbox concurrency are the iii-sandbox +/// daemon's config, deliberately not duplicated here. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct CodeRunnerConfig { + #[serde(default = "default_default_timeout_ms")] + pub default_timeout_ms: u64, + #[serde(default = "default_max_timeout_ms")] + pub max_timeout_ms: u64, + /// Passed to `sandbox::create` as `idle_timeout_secs`. The daemon's idle + /// reaper is the ONLY reaper — code-runner runs no sweep of its own; a + /// reaped VM surfaces as `code-runner::expired` on the next call. + #[serde(default = "default_idle_ttl_secs")] + pub idle_ttl_secs: u64, +} + +fn default_default_timeout_ms() -> u64 { + 5_000 +} +fn default_max_timeout_ms() -> u64 { + 30_000 +} +fn default_idle_ttl_secs() -> u64 { + 900 +} + +/// Floor for `effective_idle_ttl_secs`. The daemon itself enforces no +/// minimum on `idle_timeout_secs`, so an operator-set 0 or 1 would make +/// every runtime reapable almost as soon as it's created — in the worst +/// case, before the runner plant's own `sandbox::fs::write` lands right +/// after `sandbox::create` returns (see `RuntimeManager::create`'s +/// `map_failure` doc). `FS_TIMEOUT_MS`'s scale (30s) is the daemon-side +/// deadline that same plant call gets; a runtime that can't outlive its own +/// creation sequence under normal conditions is a config bug, not a choice. +const MIN_IDLE_TTL_SECS: u64 = 30; + +impl Default for CodeRunnerConfig { + fn default() -> Self { + Self { + default_timeout_ms: default_default_timeout_ms(), + max_timeout_ms: default_max_timeout_ms(), + idle_ttl_secs: default_idle_ttl_secs(), + } + } +} + +impl CodeRunnerConfig { + /// Resolve a request's `timeout_ms`: absent falls back to the configured + /// default, present is clamped to `max_timeout_ms`. Zero is treated as + /// absent rather than as "expire immediately", which would make every + /// call fail in a way that reads like a bug. + pub fn clamp_timeout(&self, requested: Option) -> Duration { + let ms = match requested { + None | Some(0) => self.default_timeout_ms, + Some(v) => v.min(self.max_timeout_ms), + }; + Duration::from_millis(ms) + } + + /// The `idle_timeout_secs` value actually sent to `sandbox::create`: + /// `idle_ttl_secs` floored at `MIN_IDLE_TTL_SECS`. Floors, not the raw + /// field — `config.yaml` round-trip tests below check the field as + /// written; only the value that reaches the daemon is protected. + pub fn effective_idle_ttl_secs(&self) -> u64 { + self.idle_ttl_secs.max(MIN_IDLE_TTL_SECS) + } +} + +pub fn load_config(path: &str) -> Result { + let contents = std::fs::read_to_string(path)?; + let cfg: CodeRunnerConfig = serde_yaml::from_str(&contents)?; + Ok(cfg) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn defaults_from_empty_yaml() { + let cfg: CodeRunnerConfig = serde_yaml::from_str("{}").unwrap(); + assert_eq!(cfg, CodeRunnerConfig::default()); + } + + #[test] + fn custom_yaml_overrides_each_field() { + let cfg: CodeRunnerConfig = serde_yaml::from_str( + "default_timeout_ms: 100\nmax_timeout_ms: 200\nidle_ttl_secs: 5\n", + ) + .unwrap(); + assert_eq!( + cfg, + CodeRunnerConfig { + default_timeout_ms: 100, + max_timeout_ms: 200, + idle_ttl_secs: 5, + } + ); + } + + #[test] + fn partial_yaml_keeps_other_defaults() { + let cfg: CodeRunnerConfig = serde_yaml::from_str("idle_ttl_secs: 60").unwrap(); + assert_eq!(cfg.idle_ttl_secs, 60); + assert_eq!(cfg.default_timeout_ms, default_default_timeout_ms()); + } + + #[test] + fn clamp_timeout_uses_default_when_absent_or_zero() { + let cfg = CodeRunnerConfig::default(); + assert_eq!(cfg.clamp_timeout(None), Duration::from_millis(5_000)); + assert_eq!(cfg.clamp_timeout(Some(0)), Duration::from_millis(5_000)); + } + + #[test] + fn clamp_timeout_caps_at_max() { + let cfg = CodeRunnerConfig::default(); + assert_eq!(cfg.clamp_timeout(Some(1_000)), Duration::from_millis(1_000)); + assert_eq!( + cfg.clamp_timeout(Some(999_999)), + Duration::from_millis(30_000) + ); + } + + /// Residual finding (final review, second pass): the daemon enforces no + /// floor on `idle_timeout_secs`, so an operator-set 0 or 1 would make a + /// runtime reapable almost immediately — in the worst case, before the + /// runner plant's own `fs::write` lands right after `sandbox::create` + /// returns. Only the EFFECTIVE value is floored; the raw field is not + /// mutated, so config.yaml round-trips (below) still see what was + /// actually written. + #[test] + fn effective_idle_ttl_secs_has_a_floor() { + let below_floor = CodeRunnerConfig { + idle_ttl_secs: 0, + ..CodeRunnerConfig::default() + }; + assert_eq!(below_floor.effective_idle_ttl_secs(), 30); + + let just_one = CodeRunnerConfig { + idle_ttl_secs: 1, + ..CodeRunnerConfig::default() + }; + assert_eq!(just_one.effective_idle_ttl_secs(), 30); + + let above_floor = CodeRunnerConfig { + idle_ttl_secs: 60, + ..CodeRunnerConfig::default() + }; + assert_eq!( + above_floor.effective_idle_ttl_secs(), + 60, + "above the floor, unchanged" + ); + assert_eq!( + above_floor.idle_ttl_secs, 60, + "the raw field is never mutated" + ); + } + + #[test] + fn committed_config_yaml_matches_struct_defaults() { + let cfg = load_config("config.yaml").expect("committed config.yaml parses"); + assert_eq!(cfg, CodeRunnerConfig::default()); + } +} diff --git a/code-runner/src/engine.rs b/code-runner/src/engine.rs new file mode 100644 index 000000000..0edb585dc --- /dev/null +++ b/code-runner/src/engine.rs @@ -0,0 +1,355 @@ +//! The single seam between this worker and the iii bus. +//! +//! Everything that talks to the engine goes through [`Engine`] — the +//! `sandbox::*` calls out AND the dynamic function registrations in — so the +//! manager is testable without a live engine. The production implementation +//! is [`IIIEngine`]; tests use `FakeEngine`. Ported from node-engine's seam, +//! minus its per-runtime worker connections (no register_worker in v1). + +use std::sync::Arc; + +use futures::future::BoxFuture; +use iii_sdk::errors::Error; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::{IIIClient, RegisterFunction}; +use serde_json::Value; + +pub type CallResult = Result; + +/// A registered function exposed to the bus. Dynamic registrations are +/// `Value`-in / `Value`-out by nature: the schema lives in the handler +/// source inside the VM, not in Rust types. +pub type ProxyHandler = Arc BoxFuture<'static, CallResult> + Send + Sync>; + +pub type UnregisterFn = Box; + +pub trait Engine: Send + Sync + 'static { + /// Invoke any engine function. Unrestricted by design — the deployment's + /// `iii-permissions.yaml` is the gate. + fn call( + &self, + fn_id: String, + payload: Value, + timeout_ms: u64, + ) -> BoxFuture<'static, CallResult>; + + /// Publish a dynamically-created function. The returned closure removes it. + fn register( + &self, + fn_id: String, + description: Option, + handler: ProxyHandler, + ) -> UnregisterFn; +} + +/// Stands in when a caller registers without a description — a registration +/// with no description at all is indistinguishable from a missing function in +/// the catalog, which is worse than a generic line. +pub const DEFAULT_DYNAMIC_DESC: &str = + "Registered at runtime by code-runner; the handler runs inside an iii-sandbox microVM."; + +pub struct IIIEngine { + iii: Arc, +} + +impl IIIEngine { + pub fn new(iii: Arc) -> Self { + Self { iii } + } +} + +impl Engine for IIIEngine { + fn call( + &self, + fn_id: String, + payload: Value, + timeout_ms: u64, + ) -> BoxFuture<'static, CallResult> { + let iii = self.iii.clone(); + Box::pin(async move { + iii.trigger(TriggerRequest { + function_id: fn_id, + payload, + action: None, + timeout_ms: Some(timeout_ms), + }) + .await + .map_err(|e| e.to_string()) + }) + } + + fn register( + &self, + fn_id: String, + description: Option, + handler: ProxyHandler, + ) -> UnregisterFn { + let desc = description.unwrap_or_else(|| DEFAULT_DYNAMIC_DESC.to_string()); + let function_ref = self.iii.register_function( + &fn_id, + RegisterFunction::new_async(move |req: Value| { + let handler = handler.clone(); + async move { handler(req).await.map_err(Error::Handler) } + }) + .description(desc), + ); + Box::new(move || function_ref.unregister()) + } +} + +/// What each id was published with: `(id, description, handler)`. +#[cfg(test)] +type RegisteredHandlers = Arc, ProxyHandler)>>>; + +/// Per-id computed responders — the response is built from the request +/// payload rather than canned. +#[cfg(test)] +type Responders = std::sync::Mutex< + std::collections::HashMap CallResult + Send + Sync>>, +>; + +#[cfg(test)] +#[derive(Default)] +pub struct FakeEngine { + responses: std::sync::Mutex>, + /// Per-id queue of responses, indexed by how many times that id has + /// already been called (pinned at the last entry once exhausted) — models + /// an answer that changes across calls, e.g. an exec that succeeds once + /// and then reports the sandbox gone. + sequenced_responses: + std::sync::Mutex, usize)>>, + calls: std::sync::Mutex>, + /// Computed responders, checked FIRST: the response is built from the + /// request payload. The manager generates a random sentinel per exec, so + /// a canned response cannot contain it — only a responder that reads the + /// sentinel out of the exec args can produce matching stdout. + responders: Responders, + /// `Arc` so the `'static` unregister closure can remove its own entry — a + /// fake whose unregister only counted would let a test assert "torn-down + /// functions are uncallable" and pass without teardown removing anything. + handlers: RegisteredHandlers, + unregisters: Arc, +} + +#[cfg(test)] +impl FakeEngine { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn with_response(&self, fn_id: &str, result: CallResult) { + self.responses + .lock() + .unwrap() + .insert(fn_id.to_string(), result); + } + + /// Queue `results` for `fn_id`, one per call. Once exhausted, later calls + /// keep getting the LAST entry rather than falling through — a queue that + /// quietly stopped answering would make callers look timed out. + pub fn with_response_sequence(&self, fn_id: &str, results: Vec) { + assert!(!results.is_empty(), "an empty sequence answers nothing"); + self.sequenced_responses + .lock() + .unwrap() + .insert(fn_id.to_string(), (results, 0)); + } + + /// Compute the response for `fn_id` from each request's payload. + /// Takes precedence over `with_response`/`with_response_sequence`. + pub fn with_responder( + &self, + fn_id: &str, + f: impl Fn(&Value) -> CallResult + Send + Sync + 'static, + ) { + self.responders + .lock() + .unwrap() + .insert(fn_id.to_string(), Arc::new(f)); + } + + pub fn calls(&self) -> Vec<(String, Value)> { + self.calls.lock().unwrap().clone() + } + + pub fn registered_ids(&self) -> Vec { + self.handlers + .lock() + .unwrap() + .iter() + .map(|(id, _, _)| id.clone()) + .collect() + } + + /// What each id was published with — the fake's only view of the + /// description reaching the bus, so a test can prove it is not dropped. + pub fn registered_descriptions(&self) -> Vec<(String, Option)> { + self.handlers + .lock() + .unwrap() + .iter() + .map(|(id, desc, _)| (id.clone(), desc.clone())) + .collect() + } + + pub fn unregister_count(&self) -> usize { + self.unregisters.load(std::sync::atomic::Ordering::SeqCst) + } + + /// Drive a registered proxy the way the engine would. + pub async fn invoke(&self, fn_id: &str, payload: Value) -> CallResult { + let handler = self + .handlers + .lock() + .unwrap() + .iter() + .find(|(id, _, _)| id == fn_id) + .map(|(_, _, h)| h.clone()); + match handler { + Some(h) => h(payload).await, + None => Err(format!("no such registered function: {fn_id}")), + } + } +} + +#[cfg(test)] +impl Engine for FakeEngine { + fn call( + &self, + fn_id: String, + payload: Value, + _timeout_ms: u64, + ) -> BoxFuture<'static, CallResult> { + self.calls + .lock() + .unwrap() + .push((fn_id.clone(), payload.clone())); + + if let Some(f) = self.responders.lock().unwrap().get(&fn_id).cloned() { + let out = f(&payload); + return Box::pin(async move { out }); + } + + { + let mut sequenced = self.sequenced_responses.lock().unwrap(); + if let Some((seq, next)) = sequenced.get_mut(&fn_id) { + let i = (*next).min(seq.len() - 1); + *next += 1; + let out = seq[i].clone(); + return Box::pin(async move { out }); + } + } + + let out = self + .responses + .lock() + .unwrap() + .get(&fn_id) + .cloned() + .unwrap_or_else(|| Err(format!("no such function: {fn_id}"))); + Box::pin(async move { out }) + } + + fn register( + &self, + fn_id: String, + description: Option, + handler: ProxyHandler, + ) -> UnregisterFn { + self.handlers + .lock() + .unwrap() + .push((fn_id.clone(), description, handler)); + let counter = self.unregisters.clone(); + let handlers = self.handlers.clone(); + Box::new(move || { + // Remove, not just count: the fake must be able to show that an + // unregistered id is genuinely gone. + handlers.lock().unwrap().retain(|(id, _, _)| *id != fn_id); + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn fake_records_calls_and_returns_canned_responses() { + let fake = FakeEngine::new(); + fake.with_response("sandbox::create", Ok(json!({ "sandbox_id": "sb-1" }))); + let out = fake + .call("sandbox::create".into(), json!({ "image": "node" }), 1_000) + .await; + assert_eq!(out, Ok(json!({ "sandbox_id": "sb-1" }))); + assert_eq!( + fake.calls(), + vec![("sandbox::create".to_string(), json!({ "image": "node" }))] + ); + } + + #[tokio::test] + async fn fake_returns_error_for_unconfigured_ids() { + let fake = FakeEngine::new(); + let out = fake.call("nope::missing".into(), json!({}), 1_000).await; + assert_eq!(out, Err("no such function: nope::missing".to_string())); + } + + /// A sequence answers in order and pins at its last entry — this is what + /// expiry tests lean on (exec succeeds once, then the sandbox is gone). + #[tokio::test] + async fn fake_sequences_answers_and_pins_the_last() { + let fake = FakeEngine::new(); + fake.with_response_sequence( + "sandbox::exec", + vec![Ok(json!({ "exit_code": 0 })), Err("gone".into())], + ); + assert!(fake + .call("sandbox::exec".into(), json!({}), 1) + .await + .is_ok()); + assert!(fake + .call("sandbox::exec".into(), json!({}), 1) + .await + .is_err()); + assert!( + fake.call("sandbox::exec".into(), json!({}), 1) + .await + .is_err(), + "pinned at the last entry, not falling through" + ); + } + + #[tokio::test] + async fn fake_responder_computes_the_answer_from_the_payload() { + let fake = FakeEngine::new(); + fake.with_response("sandbox::exec", Ok(json!("canned, must lose"))); + fake.with_responder("sandbox::exec", |payload| { + Ok(json!({ "echoed_cmd": payload["cmd"] })) + }); + let out = fake + .call("sandbox::exec".into(), json!({ "cmd": "node" }), 1) + .await; + assert_eq!(out, Ok(json!({ "echoed_cmd": "node" }))); + } + + #[tokio::test] + async fn fake_register_exposes_the_handler_and_counts_unregisters() { + let fake = FakeEngine::new(); + let handler: ProxyHandler = Arc::new(|p: serde_json::Value| { + Box::pin(async move { Ok(json!({ "echo": p })) }) as BoxFuture<'static, CallResult> + }); + let un = fake.register("ns::hello".into(), None, handler); + assert_eq!(fake.registered_ids(), vec!["ns::hello".to_string()]); + assert_eq!( + fake.invoke("ns::hello", json!({ "a": 1 })).await, + Ok(json!({ "echo": { "a": 1 } })) + ); + un(); + assert_eq!(fake.unregister_count(), 1); + assert!(fake.registered_ids().is_empty()); + assert!(fake.invoke("ns::hello", json!({ "a": 1 })).await.is_err()); + } +} diff --git a/code-runner/src/error.rs b/code-runner/src/error.rs new file mode 100644 index 000000000..c273a5270 --- /dev/null +++ b/code-runner/src/error.rs @@ -0,0 +1,357 @@ +//! The worker's error taxonomy, plus the classifier that turns iii-sandbox +//! wire errors (S-codes embedded as JSON in the message) into code-runner +//! terms. Every variant maps to a stable `code-runner::` wire +//! code; handlers convert into the SDK error so the engine surfaces +//! `code: message` to callers. + +use iii_sdk::errors::Error; + +/// Deliberate exception to the redaction convention (same as +/// `NodeEngineError`): this type `Display`s runtime ids to the holder — the +/// caller who already supplied them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CodeRunnerError { + InvalidRequest(String), + RuntimeNotFound(String), + /// `code-runner::teardown namespace=…` naming a namespace with no + /// runtime backing it. Same wire code as `RuntimeNotFound` (both mean + /// "nothing addressable by this"), a distinct variant only because the + /// message wording differs — `RuntimeNotFound`'s hardcodes "runtime_id". + NamespaceNotFound(String), + /// The backing VM was reaped or stopped. By the time the caller sees + /// this, the runtime's bus functions are unregistered and the record is + /// gone — re-create the runtime. + Expired(String), + /// The daemon refused a new sandbox (its `max_concurrent_sandboxes` or a + /// per-image cap — code-runner keeps no cap of its own). + Capacity(String), + /// The exec blew its in-daemon deadline. + Timeout, + /// The handler threw, or returned something JSON cannot represent. + HandlerError(String), + /// Anything else from the bus or the daemon, passed through. + Engine(String), +} + +impl CodeRunnerError { + pub fn code(&self) -> &'static str { + match self { + Self::InvalidRequest(_) => "code-runner::invalid_request", + Self::RuntimeNotFound(_) | Self::NamespaceNotFound(_) => { + "code-runner::runtime_not_found" + } + Self::Expired(_) => "code-runner::expired", + Self::Capacity(_) => "code-runner::capacity", + Self::Timeout => "code-runner::timeout", + Self::HandlerError(_) => "code-runner::handler_error", + Self::Engine(_) => "code-runner::engine", + } + } + + fn message(&self) -> String { + match self { + Self::InvalidRequest(m) => m.clone(), + Self::RuntimeNotFound(id) => format!("unknown runtime_id {id}"), + Self::NamespaceNotFound(ns) => format!( + "no runtime is registered for namespace {ns:?}; register a function in it \ + first, or pass a runtime_id instead" + ), + Self::Expired(id) => format!( + "runtime {id} expired: its idle VM was reaped and its functions \ + unregistered — call eval again without this runtime_id to boot a fresh one \ + (its filesystem starts empty)" + ), + Self::Capacity(m) => m.clone(), + Self::Timeout => "execution exceeded its deadline".into(), + Self::HandlerError(m) => m.clone(), + Self::Engine(m) => m.clone(), + } + } +} + +impl std::fmt::Display for CodeRunnerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code(), self.message()) + } +} + +impl std::error::Error for CodeRunnerError {} + +impl From for Error { + fn from(e: CodeRunnerError) -> Self { + Error::Handler(e.to_string()) + } +} + +/// How a `sandbox::*` call failed, in code-runner terms. `Gone` (not +/// `Expired`) because only the manager knows which runtime_id to name and +/// which record to clean up. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SandboxFailure { + /// S002/S004 — the sandbox no longer exists. + Gone, + /// S200 — the exec blew its in-daemon deadline. + Timeout, + /// S400 — a daemon capacity bound; the message says which. + Capacity(String), + /// Everything else, diagnostic preserved. + Other(String), +} + +/// The daemon returns errors as JSON embedded in `error.message` +/// (`{type, code, message, docs_url, fix, retryable}` — see the iii-sandbox +/// README's "Error responses" section), and the bus wraps that in its own +/// framing. Scan to the first `{` and stream-parse ONE JSON value, tolerating +/// trailing text; anything that does not yield an object with a string +/// `"code"` classifies as `Other(raw)`, untouched. +pub fn classify_sandbox_error(raw: &str) -> SandboxFailure { + let detail = raw.find('{').and_then(|start| { + serde_json::Deserializer::from_str(&raw[start..]) + .into_iter::() + .next()? + .ok() + }); + let Some(v) = detail else { + return SandboxFailure::Other(raw.to_string()); + }; + let Some(code) = v.get("code").and_then(|c| c.as_str()) else { + return SandboxFailure::Other(raw.to_string()); + }; + let message = v + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("") + .to_string(); + match code { + "S002" | "S004" => SandboxFailure::Gone, + "S200" => SandboxFailure::Timeout, + "S400" => SandboxFailure::Capacity(message), + // S003 (ConcurrentExec) is the daemon's own guard against two execs + // racing one sandbox — reachable despite our own `exec_lock` because + // the bus trigger deadline (timeout_ms + margin) outlives the + // daemon's in-daemon exec deadline (timeout_ms): a daemon slower + // than that margin can leave `exec_in_progress` true after + // code-runner already released its lock. The daemon's own message + // embeds `sandbox_id` ("concurrent exec on sandbox {id}: …"), and + // `sandbox_id` must never reach a caller (see the module doc and + // `RuntimeRecord`'s no-`Debug` note) — a caller holding it could + // drive `sandbox::*` directly, bypassing this worker's mutex and + // teardown accounting entirely. Fixed, id-free, actionable text + // instead of passing the daemon's message through. + "S003" => SandboxFailure::Other( + "S003: an exec is already in flight on this runtime; retry".to_string(), + ), + _ => SandboxFailure::Other(format!("{code}: {message}")), + } +} + +/// How a raw `engine::functions::info` probe error should be read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbeOutcome { + /// The target id genuinely does not exist — free to claim. + Free, + /// The error does not tell us whether `target` is free. Either it is not + /// a "not found"-shaped error at all (e.g. FORBIDDEN), or — the case + /// this exists to catch — it IS "not found"-shaped but names the PROBE + /// function itself (`engine::functions::info`) rather than `target`, + /// which means this engine cannot dispatch the probe at all (an older + /// engine, or one missing the builtin). That says nothing about whether + /// `target` is free, so it must never be read as "free". + Inconclusive, +} + +/// Classify a raw `engine::functions::info` error against the `target` +/// function id that was probed. Mirrors the disambiguation in +/// `iii/engine/src/cli_trigger/help.rs`'s `fetch_function_info`: a +/// "not found" naming the probe itself means the DISPATCHER couldn't find +/// `engine::functions::info`, not that `target` is absent. +pub fn classify_probe_error(raw: &str, target: &str) -> ProbeOutcome { + let lower = raw.to_lowercase(); + let looks_not_found = lower.contains("not_found") || lower.contains("not found"); + if !looks_not_found { + return ProbeOutcome::Inconclusive; + } + let names_target = lower.contains(&target.to_lowercase()); + let names_probe_itself = lower.contains("engine::functions::info"); + if names_probe_itself && !names_target { + return ProbeOutcome::Inconclusive; + } + ProbeOutcome::Free +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_are_stable_wire_strings() { + assert_eq!( + CodeRunnerError::InvalidRequest("x".into()).code(), + "code-runner::invalid_request" + ); + assert_eq!( + CodeRunnerError::RuntimeNotFound("r".into()).code(), + "code-runner::runtime_not_found" + ); + assert_eq!( + CodeRunnerError::Expired("r".into()).code(), + "code-runner::expired" + ); + assert_eq!( + CodeRunnerError::Capacity("full".into()).code(), + "code-runner::capacity" + ); + assert_eq!(CodeRunnerError::Timeout.code(), "code-runner::timeout"); + assert_eq!( + CodeRunnerError::HandlerError("x".into()).code(), + "code-runner::handler_error" + ); + assert_eq!( + CodeRunnerError::Engine("x".into()).code(), + "code-runner::engine" + ); + } + + #[test] + fn display_is_code_colon_message() { + let e = CodeRunnerError::RuntimeNotFound("rt-7".into()); + assert_eq!( + e.to_string(), + "code-runner::runtime_not_found: unknown runtime_id rt-7" + ); + } + + #[test] + fn converts_into_sdk_handler_error_preserving_code() { + let sdk: iii_sdk::errors::Error = CodeRunnerError::Timeout.into(); + assert!(sdk.to_string().contains("code-runner::timeout")); + } + + /// The daemon embeds `{type, code, message, …}` JSON inside the error + /// string, and the bus wraps it in its own framing. The classifier must + /// find and parse it through that wrapping. + #[test] + fn classifies_wrapped_sandbox_errors_by_s_code() { + let wrap = |json: &str| format!("remote error (invocation_failed): handler error: {json}"); + let gone = wrap( + r#"{"type":"SandboxNotFound","code":"S002","message":"no sandbox with that id","docs_url":"https://x/#S002","fix":null,"retryable":false}"#, + ); + assert_eq!(classify_sandbox_error(&gone), SandboxFailure::Gone); + + let stopped = + wrap(r#"{"type":"SandboxStopped","code":"S004","message":"reaped","retryable":false}"#); + assert_eq!(classify_sandbox_error(&stopped), SandboxFailure::Gone); + + let timeout = + wrap(r#"{"type":"ExecTimeout","code":"S200","message":"deadline","retryable":false}"#); + assert_eq!(classify_sandbox_error(&timeout), SandboxFailure::Timeout); + + let full = wrap( + r#"{"type":"ResourceLimit","code":"S400","message":"max_concurrent_sandboxes reached","retryable":true}"#, + ); + assert_eq!( + classify_sandbox_error(&full), + SandboxFailure::Capacity("max_concurrent_sandboxes reached".into()) + ); + } + + /// MUST FIX 3 (final review): S003's raw daemon message embeds + /// `sandbox_id` ("concurrent exec on sandbox {id}: …" — see + /// `iii-worker/src/sandbox_daemon/errors.rs`'s `ConcurrentExec` Display). + /// `sandbox_id` must never reach a caller (see the module doc's design + /// invariant), so the classifier must swap in fixed, id-free text rather + /// than passing the daemon's own message through — the redaction on the + /// ERROR path, not just `Debug`. + #[test] + fn s003_classifies_with_fixed_id_free_text() { + let sandbox_id = "11111111-2222-3333-4444-555555555555"; + let wrap = |json: &str| format!("remote error (invocation_failed): handler error: {json}"); + let raw = wrap(&format!( + r#"{{"type":"validation","code":"S003","message":"concurrent exec on sandbox {sandbox_id}: an exec is already in flight. Exec is serialized one-at-a-time per sandbox","docs_url":"https://x/#S003","fix":null,"retryable":false}}"# + )); + match classify_sandbox_error(&raw) { + SandboxFailure::Other(msg) => { + assert!( + !msg.contains(sandbox_id), + "the sandbox_id leaked into the classified message: {msg}" + ); + assert!(msg.contains("S003"), "{msg}"); + assert!(msg.contains("retry"), "message should be actionable: {msg}"); + } + other => panic!("expected Other, got {other:?}"), + } + } + + /// Unknown S-codes and boot failures pass through with code + message — + /// the daemon's diagnostic (e.g. the S300 stderr tail) must reach the + /// caller, not be swallowed. + #[test] + fn unknown_codes_pass_through_with_their_diagnostic() { + let raw = r#"handler error: {"type":"VmBootFailed","code":"S300","message":"no /dev/kvm: stderr tail here","retryable":false}"#; + match classify_sandbox_error(raw) { + SandboxFailure::Other(msg) => { + assert!(msg.contains("S300"), "{msg}"); + assert!(msg.contains("stderr tail here"), "{msg}"); + } + other => panic!("expected Other, got {other:?}"), + } + } + + /// A non-sandbox error (no embedded JSON, or JSON without a code) is not + /// mangled — the raw string comes back verbatim. + #[test] + fn non_sandbox_errors_pass_through_verbatim() { + for raw in [ + "connection refused", + "no such function: sandbox::exec", + r#"weird {"not":"a sandbox error"} text"#, + ] { + assert_eq!( + classify_sandbox_error(raw), + SandboxFailure::Other(raw.to_string()), + "{raw}" + ); + } + } + + /// The ordinary case: the engine dispatched the probe fine and the + /// TARGET id genuinely isn't registered. + #[test] + fn probe_not_found_naming_the_target_is_free() { + let raw = "remote error (NOT_FOUND): Function 'app::greet' is not registered."; + assert_eq!(classify_probe_error(raw, "app::greet"), ProbeOutcome::Free); + } + + /// MUST FIX 2 (final review): a "not found" naming the PROBE function + /// itself — `engine::functions::info` — means this engine cannot + /// dispatch the probe at all (an older engine, or one missing the + /// builtin). That says nothing about whether the target id is free, so + /// this must NOT classify as `Free`. Before this fix, the old + /// lowercase-substring matcher could not tell this apart from the + /// "target genuinely absent" case above — both contain "not found" — so + /// it treated an unprobeable engine as "id is free" and let + /// `RuntimeManager::publish` register over a live production function on + /// the bus with no error to either worker. This is the failing-open + /// direction this test exists to catch. + #[test] + fn probe_not_found_naming_the_probe_itself_is_inconclusive() { + let raw = "remote error (function_not_found): Function engine::functions::info not found"; + assert_eq!( + classify_probe_error(raw, "app::greet"), + ProbeOutcome::Inconclusive, + "an engine that cannot dispatch the probe itself must never be read as \ + 'the target id is free'" + ); + } + + /// A non-"not found" error (RBAC denial, transport failure, …) is + /// unverifiable and must fail closed, same as before this fix. + #[test] + fn probe_non_not_found_error_is_inconclusive() { + let raw = "remote error: FORBIDDEN: rbac denies functions.info"; + assert_eq!( + classify_probe_error(raw, "app::greet"), + ProbeOutcome::Inconclusive + ); + } +} diff --git a/code-runner/src/functions/eval.rs b/code-runner/src/functions/eval.rs new file mode 100644 index 000000000..bf2b9b2f6 --- /dev/null +++ b/code-runner/src/functions/eval.rs @@ -0,0 +1,152 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::runner::Lang; + +#[derive(Deserialize, JsonSchema)] +pub struct EvalRequest { + /// Source run as a whole file by a fresh interpreter process. Variables + /// do NOT survive between evals; whether files and installed packages + /// do depends on the path below. + pub code: String, + /// Evaluate in a SPECIFIC runtime, sharing its filesystem: the write and + /// the run land in that VM, and it is NOT stopped afterwards — you own + /// it. Omit this to run one-shot (see `keep`). + #[serde(default)] + pub runtime_id: Option, + /// Required when `runtime_id` is omitted — picks the sandbox image + /// ("node" or "python"). On an existing runtime: omit it, or pass the + /// runtime's own language; languages cannot be mixed in one runtime. + #[serde(default)] + pub lang: Option, + /// Only meaningful when `runtime_id` is omitted. `false` (the default): + /// one-shot — boot a VM, run `code`, return the result, destroy the VM. + /// Nothing persists: no files, no installed packages. `true`: boot a VM + /// and leave it running; the response's `runtime_id` addresses it for + /// later evals (pass it back to keep working in the same filesystem) and + /// is the capability `code-runner::teardown` needs to stop it. + #[serde(default)] + pub keep: bool, + /// Give the guest outbound network so `npm install` / `pip install` + /// work. Create-time only, so it is meaningful only when `runtime_id` is + /// omitted — and even then, only a caller-supplied `runtime_id`'s own + /// creation could ever have asked for it: neither a one-shot eval nor + /// `keep: true` can request network (both run through `sandbox::run`, + /// which has no way to enable it), so `network: true` without a + /// `runtime_id` is refused rather than silently ignored. Ignored (not + /// refused) when `runtime_id` is set: that runtime's network was fixed + /// when it was created. + #[serde(default)] + pub network: bool, + /// Wall-clock budget in milliseconds, clamped to the configured maximum. + #[serde(default)] + pub timeout_ms: Option, +} + +// `code` is tenant-authored source; `runtime_id` — when present — is a +// capability. Hand-rolled `Debug` keeps both out of `{:?}`. +impl std::fmt::Debug for EvalRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EvalRequest") + .field("code", &"") + .field( + "runtime_id", + &self.runtime_id.as_ref().map(|_| ""), + ) + .field("lang", &self.lang) + .field("keep", &self.keep) + .field("network", &self.network) + .field("timeout_ms", &self.timeout_ms) + .finish() + } +} + +#[derive(Serialize, JsonSchema)] +pub struct EvalResponse { + /// Present when this eval addresses a runtime that outlives the call: + /// the `runtime_id` you passed in, or — when you passed `keep: true` + /// with no `runtime_id` — the one just minted for the VM this call left + /// running. `None` on the default one-shot path: the VM is already gone + /// by the time this response is sent, so there is nothing to address. + /// Treat a present value as a secret: it is the capability to eval into + /// or tear down that runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + pub stdout: String, + pub stderr: String, + pub exit_code: i64, + pub success: bool, + pub duration_ms: u64, +} + +impl std::fmt::Debug for EvalResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EvalResponse") + .field( + "runtime_id", + &self.runtime_id.as_ref().map(|_| ""), + ) + .field("exit_code", &self.exit_code) + .field("success", &self.success) + .field("duration_ms", &self.duration_ms) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_does_not_leak_code_or_the_runtime_id() { + let req = EvalRequest { + code: "SECRET_TENANT_SOURCE_1234".into(), + runtime_id: Some("rt-secret-capability".into()), + lang: Some(Lang::Node), + keep: false, + network: false, + timeout_ms: None, + }; + let rendered = format!("{req:?}"); + assert!( + !rendered.contains("SECRET_TENANT_SOURCE_1234"), + "{rendered}" + ); + assert!(!rendered.contains("rt-secret-capability"), "{rendered}"); + assert!( + rendered.contains("Node"), + "non-secrets still show: {rendered}" + ); + } + + #[test] + fn response_debug_does_not_leak_the_runtime_id() { + let res = EvalResponse { + runtime_id: Some("rt-secret-capability".into()), + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + success: true, + duration_ms: 1, + }; + let rendered = format!("{res:?}"); + assert!(!rendered.contains("rt-secret-capability"), "{rendered}"); + } + + #[test] + fn response_omits_runtime_id_on_the_wire_when_absent() { + let res = EvalResponse { + runtime_id: None, + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + success: true, + duration_ms: 1, + }; + let value = serde_json::to_value(&res).unwrap(); + assert!( + !value.as_object().unwrap().contains_key("runtime_id"), + "a one-shot response must not carry a null runtime_id key: {value}" + ); + } +} diff --git a/code-runner/src/functions/inject_guidance.rs b/code-runner/src/functions/inject_guidance.rs new file mode 100644 index 000000000..b4932b608 --- /dev/null +++ b/code-runner/src/functions/inject_guidance.rs @@ -0,0 +1,179 @@ +//! `code-runner::inject-guidance` — a `pre_generate` hook that contributes the +//! `code-runner::*` usage guidance to the agent's system prompt, ONLY while this +//! worker is connected. The binding dies with the worker, so the guidance is +//! presence-gated for free: a deployment without code-runner never pays for it, +//! and the text is never hand-duplicated into a static harness prompt. +//! +//! Mirrors `web/src/functions/inject_guidance.rs`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const GUIDANCE_HOOK_ID: &str = "code-runner::inject-guidance"; +pub const GUIDANCE_HOOK_DESC: &str = + "Internal pre_generate hook: appends code-runner usage guidance to the agent system \ + prompt. Bound to harness::hook::pre-generate at worker startup; not called directly."; + +/// The single canonical copy of the code-runner usage guidance. Pure USAGE +/// guidance: the hook only fires while this worker is present, so it carries no +/// "look for it / install it" discovery text. +const CODE_RUNNER_GUIDANCE: &str = "code-runner runs Node.js and Python in isolated microVMs (iii-sandbox). `code-runner::eval` with lang \"node\" or \"python\" is ONE-SHOT by default: it boots a fresh VM, runs the code, returns the result, and destroys the VM — nothing persists, no files, no installed packages, and the response carries no runtime_id (there is nothing left to address). Pass keep: true to leave the VM running instead: the response's runtime_id then addresses it — treat it as a secret — and is the capability `code-runner::teardown` needs. Pass that runtime_id back on a later eval to keep working in the same VM (filesystem persists between evals in one runtime; variables do not) — that runtime is never auto-stopped, and a reuse can fail with code-runner::expired if it was idle-reaped; if it does, just eval again the same way (fresh keep: true, or a fresh one-shot) rather than reusing the dead id. network is create-time only and only a runtime you already hold with network can honor it — neither a one-shot eval nor keep: true can ever create a networked VM, so network: true without an existing runtime_id is refused, not silently ignored. `code-runner::register_function` needs no runtime_id at all: pass function_id, source (must define handler(payload) in lang), description, and lang — code-runner keeps one persistent runtime per namespace (the segment of function_id before `::`) and language automatically, creating it on the first registration and reusing it for later ones in the same namespace and lang. Call `code-runner::teardown` with EITHER runtime_id (a kept eval's runtime) or namespace (e.g. \"app\" for ids like app::greet) — never both, never neither — to unregister its functions and stop its microVM(s). Idle runtimes are reaped after the configured TTL, but a reaped runtime's functions are NOT unregistered at that moment: the next call into it fails with code-runner::expired, and only then are its functions unregistered. Don't assume a function id is free to reuse just because the TTL has passed."; + +/// The slice of the `pre_generate` hook envelope we read (lenient: ignores every +/// other field the harness sends). The harness nests the live generation context +/// under `generate`. +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct PreGenerateEvent { + #[serde(default)] + pub generate: GenerateContext, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GenerateContext { + /// The system prompt assembled so far (base + any prior hook's mutation). + #[serde(default)] + pub system_prompt: String, +} + +/// Hook envelope returned to the harness: the mutations to apply to the +/// generation. +#[derive(Debug, Serialize, JsonSchema)] +pub struct PreGenerateResponse { + pub mutations: PreGenerateMutations, +} + +/// The harness applies `system_prompt` only when the key is present, so `None` +/// serializes to an empty object: the safe no-op that preserves the harness's +/// assembled prompt. +#[derive(Debug, Default, Serialize, JsonSchema)] +pub struct PreGenerateMutations { + /// Full replacement system prompt (base + appended guidance). The harness + /// overwrites, it does not merge. + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, +} + +/// Build the `pre_generate` mutations for a given base prompt. Pure, so it is +/// unit-testable. +/// +/// Returns NO `system_prompt` when `base` is empty. A missing or renamed +/// `generate.system_prompt` deserializes to `""` (schema drift), and a fail-open +/// hook must PRESERVE the harness's assembled prompt, never replace it with the +/// guidance alone. +fn mutations_for(base: &str) -> PreGenerateMutations { + if base.is_empty() { + PreGenerateMutations::default() + } else { + PreGenerateMutations { + system_prompt: Some(format!("{base}\n\n{CODE_RUNNER_GUIDANCE}")), + } + } +} + +/// `pre_generate` hook entrypoint. Bound `fail_open`, so an error here never +/// blocks a turn. +pub async fn handle( + event: PreGenerateEvent, +) -> Result { + Ok(PreGenerateResponse { + mutations: mutations_for(&event.generate.system_prompt), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_guidance_after_a_real_base() { + let m = mutations_for("BASE PROMPT"); + let sp = m + .system_prompt + .expect("a non-empty base yields a system_prompt mutation"); + assert!( + sp.starts_with("BASE PROMPT\n\n"), + "the base prompt must be preserved, guidance appended after it" + ); + assert!(sp.contains("code-runner::eval"), "guidance content present"); + } + + #[test] + fn empty_base_emits_no_system_prompt_mutation() { + // A missing/malformed hook payload (system_prompt absent → "") must + // PRESERVE the harness prompt: emit no system_prompt key, rather than + // replacing the whole prompt with the guidance alone. The wire shape + // must stay `{"mutations": {}}`. + let wire = serde_json::to_value(PreGenerateResponse { + mutations: mutations_for(""), + }) + .expect("response serializes"); + assert_eq!(wire, serde_json::json!({ "mutations": {} })); + } + + /// Mirrors the registry publish gate: the derived response schema must + /// carry a schema-defining keyword, not the permissive AnyValue schema. + #[test] + fn response_schema_passes_the_publish_typed_gate() { + let schema = schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value.as_object().expect("schema is an object"); + assert!( + ["type", "properties", "$ref"] + .iter() + .any(|k| obj.contains_key(*k)), + "PreGenerateResponse schema is untyped: {value}" + ); + } + + #[test] + fn guidance_covers_this_worker_s_surface() { + // Each needle is a fact an agent gets wrong without the guidance: + // the three function ids, that eval is one-shot unless kept, that + // runtime_id is the thing to reuse, the handler signature + // register_function expects, that it needs no runtime_id, the + // network flag's limits, and that an eval reuse can come back + // code-runner::expired. + for needle in [ + "code-runner::eval", + "code-runner::register_function", + "code-runner::teardown", + "runtime_id", + "keep: true", + "handler(payload)", + "network", + "code-runner::expired", + "namespace", + ] { + assert!( + CODE_RUNNER_GUIDANCE.contains(needle), + "guidance is missing: {needle}" + ); + } + } + + /// The core behavior change this guidance must state plainly, not hedge: + /// eval is one-shot by default and nothing persists unless `keep: true`, + /// and `register_function` needs no `runtime_id` at all. A wrong or + /// vague claim here becomes an agent's confident wrong belief. + #[test] + fn guidance_states_one_shot_eval_and_runtime_id_free_register_plainly() { + assert!( + CODE_RUNNER_GUIDANCE.contains("ONE-SHOT by default"), + "guidance must state plainly that eval defaults to one-shot" + ); + assert!( + CODE_RUNNER_GUIDANCE.contains("nothing persists, no files, no installed packages"), + "guidance must state plainly that a one-shot eval leaves nothing behind" + ); + assert!( + CODE_RUNNER_GUIDANCE.contains("needs no runtime_id at all"), + "guidance must state plainly that register_function needs no runtime_id" + ); + assert!( + !CODE_RUNNER_GUIDANCE.contains("session"), + "session binding was removed; the guidance must not mention it" + ); + } +} diff --git a/code-runner/src/functions/mod.rs b/code-runner/src/functions/mod.rs new file mode 100644 index 000000000..c33b4a6ec --- /dev/null +++ b/code-runner/src/functions/mod.rs @@ -0,0 +1,212 @@ +//! The statically registered `code-runner::*` functions. +//! +//! Each `.rs` holds its typed request/response structs; the handler +//! bodies are thin wrappers over `RuntimeManager`, which is what the tests +//! drive directly. + +pub mod eval; +pub mod inject_guidance; +pub mod register; +pub mod teardown; + +use std::sync::Arc; + +use iii_sdk::errors::Error; +use iii_sdk::{IIIClient, RegisterFunction}; + +use crate::manager::RuntimeManager; + +pub const EVAL_ID: &str = "code-runner::eval"; +pub const EVAL_DESC: &str = + "Run code in an isolated microVM. Pass lang (\"node\" or \"python\"). By default eval is \ + ONE-SHOT: it boots a fresh VM, runs code, returns the result, and destroys the VM — \ + nothing persists, no files, no installed packages, and the response carries no \ + runtime_id (there is nothing left to address). Pass keep: true to leave the VM running \ + instead: the response's runtime_id then addresses it, and is the capability \ + code-runner::teardown needs to stop it later. Pass runtime_id on a later call to reuse \ + that same VM (same filesystem, fresh interpreter process each time) — that runtime is \ + never auto-stopped, you own it until you tear it down or its idle TTL reaps it, and a \ + reaped reuse fails with code-runner::expired (retry without runtime_id to boot a fresh \ + one). network: true asks for outbound network so npm/pip installs work, but only a \ + runtime you already created with network can honor it (pass its runtime_id) — neither a \ + one-shot eval nor keep: true can create a networked VM, so network: true without an \ + existing runtime_id is refused, not silently ignored. stdout, stderr and exit_code come \ + back verbatim — a failing script is a response, not an error."; + +pub const TEARDOWN_ID: &str = "code-runner::teardown"; +pub const TEARDOWN_DESC: &str = + "Destroy a runtime: unregister every bus function it registered, stop its microVM(s), and \ + free the slot(s). Pass exactly one of runtime_id (a kept eval's runtime, from \ + code-runner::eval keep=true) or namespace (a register_function namespace, e.g. \"app\" for \ + ids like app::greet) — never both, never neither."; + +pub const REGISTER_ID: &str = "code-runner::register_function"; +pub const REGISTER_DESC: &str = + "Publish a bus function whose handler executes inside a microVM. No runtime_id needed: \ + code-runner keeps one persistent runtime per namespace (the segment of function_id before \ + `::`) and language — the first registration in a namespace boots it, later ones in the \ + same namespace and lang reuse it automatically. `source` must DEFINE handler(payload) in \ + `lang` — `export function handler(payload) {...}` (node) or `def handler(payload): ...` \ + (python); each call runs it in a fresh interpreter process with the trigger payload and \ + returns its JSON-serialized result. The first registered id in a namespace claims it; \ + later ids must share both the namespace and its lang. `description` is what \ + engine::functions::info shows a caller — write one. Functions stop resolving when their \ + namespace is torn down (code-runner::teardown namespace=...) or its runtime is reaped for \ + idleness."; + +/// Every id this worker registers on its own client, in registration order. +/// `register_all` asserts it registered exactly this list, and the schema +/// test pins `catalog()` to it — the two hand-maintained lists must not +/// drift apart. +pub const STATIC_IDS: &[&str] = &[ + EVAL_ID, + TEARDOWN_ID, + REGISTER_ID, + inject_guidance::GUIDANCE_HOOK_ID, +]; + +pub fn register_all(iii: &Arc, manager: &Arc) { + // Seed the local claims registry with this worker's own ids BEFORE + // registering anything, so `RuntimeManager::register`'s reservation + // check refuses a caller-supplied `code-runner::*` id from the moment + // this function starts, rather than depending on the + // `engine::functions::info` probe (a network round trip) to catch it. + manager.seed_static_ids(STATIC_IDS); + + let mut registered: Vec<&str> = Vec::new(); + + let m = manager.clone(); + registered.push(EVAL_ID); + iii.register_function( + EVAL_ID, + RegisterFunction::new_async(move |req: eval::EvalRequest| { + let m = m.clone(); + async move { m.eval(req).await.map_err(Error::from) } + }) + .description(EVAL_DESC), + ); + + let m = manager.clone(); + registered.push(TEARDOWN_ID); + iii.register_function( + TEARDOWN_ID, + RegisterFunction::new_async(move |req: teardown::TeardownRequest| { + let m = m.clone(); + async move { m.teardown(req).await.map_err(Error::from) } + }) + .description(TEARDOWN_DESC), + ); + + let m = manager.clone(); + registered.push(REGISTER_ID); + iii.register_function( + REGISTER_ID, + RegisterFunction::new_async(move |req: register::RegisterRequest| { + let m = m.clone(); + async move { m.register(req).await.map_err(Error::from) } + }) + .description(REGISTER_DESC), + ); + + registered.push(inject_guidance::GUIDANCE_HOOK_ID); + iii.register_function( + inject_guidance::GUIDANCE_HOOK_ID, + RegisterFunction::new_async(move |event: inject_guidance::PreGenerateEvent| async move { + inject_guidance::handle(event).await + }) + .description(inject_guidance::GUIDANCE_HOOK_DESC) + // The harness calls this, never an agent; keep it out of the + // callable catalog agents browse. + .metadata(serde_json::json!({ "internal": true })), + ); + + assert_eq!( + registered, STATIC_IDS, + "register_all must register exactly STATIC_IDS — the lists must not drift" + ); + + tracing::info!("code-runner functions registered"); +} + +/// Bind the `pre_generate` hook so the guidance reaches the agent's system +/// prompt while this worker is connected. `on_error: fail_open` is +/// MANDATORY — `pre_generate` defaults to fail-CLOSED, and a missing +/// guidance line must never abort an agent's turn. +pub fn setup_harness_hooks(iii: &Arc) { + match iii.register_trigger(iii_sdk::protocol::RegisterTriggerInput { + trigger_type: "harness::hook::pre-generate".to_string(), + function_id: inject_guidance::GUIDANCE_HOOK_ID.to_string(), + config: serde_json::json!({ "on_error": "fail_open" }), + metadata: None, + }) { + Ok(_) => tracing::info!("code-runner pre-generate hook bound (guidance injection active)"), + Err(e) => tracing::warn!(error = %e, "guidance hook binding failed; continuing without it"), + } +} + +pub struct FunctionSpec { + pub function_id: &'static str, + pub description: &'static str, + pub request_schema: schemars::schema::RootSchema, + pub response_schema: schemars::schema::RootSchema, +} + +/// Schema generation MUST mirror iii-sdk's internal `json_schema_for` +/// (`SchemaSettings::draft07()` on the handler's request/response types), so +/// a catalog snapshot pins exactly what registration emits. +fn schema_of() -> schemars::schema::RootSchema { + schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::() +} + +fn spec(function_id: &'static str, description: &'static str) -> FunctionSpec +where + Req: schemars::JsonSchema, + Resp: schemars::JsonSchema, +{ + FunctionSpec { + function_id, + description, + request_schema: schema_of::(), + response_schema: schema_of::(), + } +} + +/// Every statically registered function, in registration order. +pub fn catalog() -> Vec { + vec![ + spec::(EVAL_ID, EVAL_DESC), + spec::(TEARDOWN_ID, TEARDOWN_DESC), + spec::(REGISTER_ID, REGISTER_DESC), + spec::( + inject_guidance::GUIDANCE_HOOK_ID, + inject_guidance::GUIDANCE_HOOK_DESC, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::CodeRunnerConfig; + use crate::engine::IIIEngine; + use iii_sdk::IIIClient; + + /// Before `code-runner` had a `[[bin]]` target, nothing ever called + /// `register_all` — `main` is the only caller, and the `--manifest` + /// smoke test returns before it runs. Its trailing + /// `assert_eq!(registered, STATIC_IDS, ...)` is real protection: a + /// function registered but left out of `STATIC_IDS` could be claimed by + /// tenant code and hit iii-sdk's documented panic-on-duplicate-id. Drive + /// it here so that assertion is exercised by the suite, not first by + /// production. `IIIClient::new` only builds local state — no network — + /// same trick node-engine's own `engine.rs` tests rely on. + #[test] + fn register_all_registers_exactly_static_ids() { + let iii = Arc::new(IIIClient::new("ws://127.0.0.1:1")); + let engine = Arc::new(IIIEngine::new(iii.clone())); + let manager = RuntimeManager::new(Arc::new(CodeRunnerConfig::default()), engine); + register_all(&iii, &manager); + } +} diff --git a/code-runner/src/functions/register.rs b/code-runner/src/functions/register.rs new file mode 100644 index 000000000..c2e4a144d --- /dev/null +++ b/code-runner/src/functions/register.rs @@ -0,0 +1,69 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::runner::Lang; + +#[derive(Deserialize, JsonSchema)] +pub struct RegisterRequest { + /// e.g. "my-app::greet". The first registration in a namespace (the + /// segment before `::`) claims it; later ids must share it. code-runner + /// keeps ONE persistent runtime per (namespace, lang) — the first + /// registration creates it, later ones reuse it — as an implementation + /// detail you never see or manage. + pub function_id: String, + /// Source that DEFINES `handler(payload)` in `lang`: + /// `export function handler(payload) {…}` (node) or + /// `def handler(payload): …` (python). The runner loads the file, calls + /// `handler`, and JSON-serializes the return value. + pub source: String, + /// What engine::functions::info shows a caller — write one. + #[serde(default)] + pub description: Option, + /// Which runner backs this namespace: "node" or "python". A namespace's + /// language is fixed by its first registration; a later id under the + /// same namespace but a different lang is refused. + pub lang: Lang, +} + +// `source` is tenant-authored. `function_id`, `description` and `lang` are +// not secrets, so a derived `Debug` would be fine too — hand-rolled only to +// keep `source` out. +impl std::fmt::Debug for RegisterRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegisterRequest") + .field("function_id", &self.function_id) + .field("source", &"") + .field("description", &self.description) + .field("lang", &self.lang) + .finish() + } +} + +// No secrets here — the id is public on the bus — so `Debug` derives. `//`, +// not `///`: this is internal rationale, and schemars would otherwise lift a +// doc comment here into the response schema's `description`, shipping it to +// anyone who calls `engine::functions::info`. +#[derive(Serialize, Debug, JsonSchema)] +pub struct RegisterResponse { + pub function_id: String, + pub registered: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_redacts_source_only() { + let req = RegisterRequest { + function_id: "app::greet".into(), + source: "SECRET_HANDLER_SOURCE".into(), + description: Some("greets".into()), + lang: Lang::Node, + }; + let rendered = format!("{req:?}"); + assert!(!rendered.contains("SECRET_HANDLER_SOURCE"), "{rendered}"); + assert!(rendered.contains("app::greet"), "{rendered}"); + assert!(rendered.contains("Node"), "{rendered}"); + } +} diff --git a/code-runner/src/functions/teardown.rs b/code-runner/src/functions/teardown.rs new file mode 100644 index 000000000..9cbc447cb --- /dev/null +++ b/code-runner/src/functions/teardown.rs @@ -0,0 +1,100 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Exactly one of `runtime_id` (a runtime you got back from `code-runner::eval +/// keep=true`) or `namespace` (a `register_function` namespace, e.g. "app" +/// for ids like `app::greet`) must be set — never both, never neither. +#[derive(Deserialize, JsonSchema)] +pub struct TeardownRequest { + #[serde(default)] + pub runtime_id: Option, + #[serde(default)] + pub namespace: Option, +} + +impl std::fmt::Debug for TeardownRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TeardownRequest") + .field( + "runtime_id", + &self.runtime_id.as_ref().map(|_| ""), + ) + .field("namespace", &self.namespace) + .finish() + } +} + +#[derive(Serialize, JsonSchema)] +pub struct TeardownResponse { + /// Set when this teardown was addressed by `runtime_id` (never present + /// alongside `namespace`). + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + /// Set when this teardown was addressed by `namespace` — echoes the + /// namespace, since more than one runtime (one per language) can back + /// it and there is no single `runtime_id` to report. + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + pub torn_down: bool, + /// Bus function ids this teardown unregistered, across every runtime it + /// destroyed. + pub unregistered: Vec, +} + +impl std::fmt::Debug for TeardownResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TeardownResponse") + .field( + "runtime_id", + &self.runtime_id.as_ref().map(|_| ""), + ) + .field("namespace", &self.namespace) + .field("torn_down", &self.torn_down) + .field("unregistered", &self.unregistered) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_redacts_the_runtime_id_both_ways() { + let req = TeardownRequest { + runtime_id: Some("rt-secret".into()), + namespace: None, + }; + assert!(!format!("{req:?}").contains("rt-secret")); + let res = TeardownResponse { + runtime_id: Some("rt-secret".into()), + namespace: None, + torn_down: true, + unregistered: vec!["app::x".into()], + }; + let rendered = format!("{res:?}"); + assert!(!rendered.contains("rt-secret"), "{rendered}"); + assert!(rendered.contains("app::x"), "{rendered}"); + } + + #[test] + fn wire_omits_absent_addressing_field() { + let by_id = TeardownResponse { + runtime_id: Some("rt-1".into()), + namespace: None, + torn_down: true, + unregistered: vec![], + }; + let v = serde_json::to_value(&by_id).unwrap(); + assert!(!v.as_object().unwrap().contains_key("namespace")); + + let by_ns = TeardownResponse { + runtime_id: None, + namespace: Some("app::".into()), + torn_down: true, + unregistered: vec![], + }; + let v = serde_json::to_value(&by_ns).unwrap(); + assert!(!v.as_object().unwrap().contains_key("runtime_id")); + } +} diff --git a/code-runner/src/lib.rs b/code-runner/src/lib.rs new file mode 100644 index 000000000..94e7eb0b5 --- /dev/null +++ b/code-runner/src/lib.rs @@ -0,0 +1,14 @@ +//! code-runner: eval Node/Python in iii-sandbox microVMs, register bus +//! functions whose handlers execute inside them, tear them down. +//! +//! This worker executes nothing itself — every eval and every handler call is +//! delegated over the bus to the iii-sandbox daemon's `sandbox::*` triggers. + +pub mod config; +pub mod engine; +pub mod error; +pub mod functions; +pub mod manager; +pub mod manifest; +pub mod runner; +pub mod ui; diff --git a/code-runner/src/main.rs b/code-runner/src/main.rs new file mode 100644 index 000000000..af3504f70 --- /dev/null +++ b/code-runner/src/main.rs @@ -0,0 +1,132 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; +use code_runner::engine::{Engine as _, IIIEngine}; +use code_runner::error::{classify_probe_error, ProbeOutcome}; +use code_runner::manager::RuntimeManager; +use code_runner::{config, functions, manifest}; +use iii_helpers::observability::OtelConfig; +use iii_sdk::runtime::WorkerMetadata; +use iii_sdk::{register_worker, InitOptions}; + +#[derive(Parser, Debug)] +#[command( + name = "code-runner", + version, + about = "Run Node.js and Python in iii-sandbox microVMs: eval, register bus functions, teardown" +)] +struct Cli { + /// Operator config file. + #[arg(long, default_value = "./config.yaml")] + config: String, + + /// WebSocket URL of the iii engine. + #[arg(long, env = "III_URL", default_value = "ws://127.0.0.1:49134")] + url: String, + + /// Print the registry manifest as JSON and exit without connecting. + #[arg(long)] + manifest: bool, +} + +fn worker_metadata() -> WorkerMetadata { + WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "code-runner".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + } +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + if cli.manifest { + println!( + "{}", + serde_json::to_string_pretty(&manifest::build_manifest()).unwrap() + ); + return Ok(()); + } + + let cfg = match config::load_config(&cli.config) { + Ok(c) => c, + Err(e) => { + tracing::warn!(error = %e, path = %cli.config, "failed to load config, using defaults"); + config::CodeRunnerConfig::default() + } + }; + let cfg = Arc::new(cfg); + + let iii = Arc::new(register_worker( + &cli.url, + InitOptions { + otel: Some(OtelConfig::default()), + metadata: Some(worker_metadata()), + ..InitOptions::default() + }, + )); + + let engine = Arc::new(IIIEngine::new(iii.clone())); + let manager = RuntimeManager::new(cfg.clone(), engine.clone()); + functions::register_all(&iii, &manager); + functions::setup_harness_hooks(&iii); + // Injected console UI: the function-trigger cards for the ops above. + code_runner::ui::register(&iii); + + // Startup probe: is the iii-sandbox daemon serving? Fail OPEN — the + // operator may add it later, and every call meanwhile errors with the + // daemon's own message — but say it loudly once, at boot, instead of + // letting the first caller discover it cryptically. + { + let probe = engine + .call( + "engine::functions::info".to_string(), + serde_json::json!({ "function_id": "sandbox::create" }), + 5_000, + ) + .await; + match probe { + Ok(_) => tracing::info!("iii-sandbox detected: sandbox::create is registered"), + Err(raw) => { + // Same disambiguation as the register-time probe (see + // `classify_probe_error`'s doc): a "not found" naming + // `engine::functions::info` itself means THIS engine can't + // dispatch the probe, not that `sandbox::create` is absent. + // Only mis-words a log line here (this path always fails + // open — code-runner keeps serving either way), but should + // still say the honest thing. + if classify_probe_error(&raw, "sandbox::create") == ProbeOutcome::Free { + tracing::warn!( + "iii-sandbox is NOT installed on this engine — every code-runner call \ + will fail until an operator runs `iii worker add iii-sandbox`" + ); + } else { + tracing::warn!(error = %raw, "could not verify iii-sandbox presence"); + } + } + } + } + + tracing::info!( + idle_ttl_secs = cfg.idle_ttl_secs, + default_timeout_ms = cfg.default_timeout_ms, + "code-runner ready" + ); + tokio::signal::ctrl_c().await?; + tracing::info!("code-runner shutting down"); + iii.shutdown_async().await; + Ok(()) +} diff --git a/code-runner/src/manager.rs b/code-runner/src/manager.rs new file mode 100644 index 000000000..c13e911ba --- /dev/null +++ b/code-runner/src/manager.rs @@ -0,0 +1,2525 @@ +//! Ownership and lifecycle for sandbox-backed runtimes. +//! +//! Two kinds of runtime exist, distinguished by who addresses them: +//! +//! - A KEPT-EVAL runtime: `code-runner::eval keep=true` (or a caller-supplied +//! `runtime_id`) mints or reuses one, and the caller holds its +//! `runtime_id` — the capability to eval into or tear it down. +//! - A NAMESPACE runtime: `code-runner::register_function` creates or reuses +//! one per `(namespace, lang)`, entirely as an implementation detail — the +//! caller never sees or manages its `runtime_id`, only its namespace. +//! +//! Both kinds share one `runtimes` map and the same per-runtime async mutex +//! discipline: the daemon REJECTS concurrent execs into one sandbox (S003) +//! rather than queueing them, so serialization is this module's job — the +//! mutex covers each whole write+exec sequence, giving the same +//! one-command-at-a-time semantics node-engine's runtimes have. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; + +use crate::config::CodeRunnerConfig; +use crate::engine::{Engine, UnregisterFn}; +use crate::error::{ + classify_probe_error, classify_sandbox_error, CodeRunnerError, ProbeOutcome, SandboxFailure, +}; +use crate::functions::eval::{EvalRequest, EvalResponse}; +use crate::functions::register::{RegisterRequest, RegisterResponse}; +use crate::functions::teardown::{TeardownRequest, TeardownResponse}; +use crate::runner::Lang; + +/// Trigger timeout for `sandbox::create` — a cold image pull can take tens +/// of seconds; the daemon docs recommend 300s. +const CREATE_TIMEOUT_MS: u64 = 300_000; +/// Trigger timeout for `sandbox::fs::*` and `sandbox::stop` — local to the +/// daemon, no meaningful timeout pressure. +const FS_TIMEOUT_MS: u64 = 30_000; +/// Added to the exec's in-daemon deadline for the bus round trip, so the +/// daemon's timeout (which carries the real diagnostic) fires first. +const EXEC_MARGIN_MS: u64 = 5_000; +/// Ceiling on eval `code` and register `source`: they travel as +/// `sandbox::fs::write`'s (or `sandbox::run`'s) inline UTF-8 `content`, +/// whose documented inline comfort zone is 1 MiB. +pub const MAX_SOURCE_BYTES: usize = 1_048_576; +pub const MAX_FUNCTION_ID_BYTES: usize = 256; +pub const MAX_DESCRIPTION_BYTES: usize = 4096; +pub const MAX_FUNCTIONS_PER_RUNTIME: usize = 64; +const PROBE_TIMEOUT_MS: u64 = 5_000; + +/// Ported from node-engine's `validate_worker_name`: a namespace's first +/// segment IS a worker name on the bus (the engine splits `a::b::c` into +/// service `a`), so it is held to the same rule. +fn validate_worker_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("name must not be empty".into()); + } + if name.len() > 64 { + return Err(format!("name {name:?} is longer than 64 bytes")); + } + if name.contains("::") { + return Err(format!("name {name:?} must not contain \"::\"")); + } + if name.contains("..") { + return Err(format!("name {name:?} must not contain \"..\"")); + } + if name.starts_with('.') { + return Err(format!("name {name:?} must not start with '.'")); + } + if let Some(bad) = name + .chars() + .find(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))) + { + return Err(format!( + "name {name:?} contains {bad:?}; allowed: lowercase letters, digits, '.', '_', '-'" + )); + } + Ok(()) +} + +/// `app::greet` → `app::`, with the first segment held to worker-name rules. +fn namespace_of(function_id: &str) -> Result { + let Some((head, rest)) = function_id.split_once("::") else { + return Err(format!( + "function id {function_id:?} must look like \"app::name\"" + )); + }; + if rest.is_empty() { + return Err(format!( + "function id {function_id:?} has nothing after its namespace" + )); + } + validate_worker_name(head)?; + Ok(format!("{head}::")) +} + +/// Normalize a `code-runner::teardown` `namespace` field — "app" or +/// "app::" both accepted — to the canonical `"app::"` form `namespace_of` +/// produces, validated the same way a function id's namespace segment is. +fn normalize_namespace(namespace: &str) -> Result { + let head = namespace.strip_suffix("::").unwrap_or(namespace); + validate_worker_name(head).map_err(CodeRunnerError::InvalidRequest)?; + Ok(format!("{head}::")) +} + +/// Last ~500 bytes of stderr — enough to diagnose, small enough for an +/// error message. +fn stderr_tail(stderr: &str) -> &str { + let start = stderr.len().saturating_sub(500); + // Don't split a UTF-8 char. + let mut i = start; + while i < stderr.len() && !stderr.is_char_boundary(i) { + i += 1; + } + &stderr[i..] +} + +pub(crate) struct RegisteredFn { + pub(crate) id: String, + pub(crate) unregister: UnregisterFn, +} + +/// No `Debug` impl on purpose: `sandbox_id` must never reach logs. +pub(crate) struct RuntimeRecord { + pub(crate) sandbox_id: String, + pub(crate) lang: Lang, + /// One in-flight exec per runtime — see the module doc. + pub(crate) exec_lock: tokio::sync::Mutex<()>, + /// Claimed by the first registered function id: `app::greet` claims + /// `app::`, and later ids on this runtime must share it. Always + /// `None` on a kept-eval runtime — nothing is ever registered onto one, + /// `register_function` no longer accepts a `runtime_id` at all. + pub(crate) namespace: Mutex>, + pub(crate) functions: Mutex>, +} + +/// What a namespace runtime is keyed by. The language is part of the key +/// because a runtime is single-language (mixing is refused), so a +/// namespace with functions in both node and python holds one runtime of +/// each. +type NamespaceKey = (String, Lang); + +pub struct RuntimeManager { + cfg: Arc, + engine: Arc, + runtimes: Mutex>>, + /// `(namespace, lang)` → the runtime backing it, so every + /// `register_function` call in one namespace (and language) shares one + /// microVM instead of needing a caller-managed `runtime_id`. Populated + /// only by `register`; a kept-eval runtime (`eval keep=true`, or an + /// explicit `runtime_id`) never appears here. + namespaces: Mutex>, + /// Held ACROSS `create()` on the namespace path, which is why it is a + /// `tokio` mutex: two concurrent first registrations in one namespace + /// must mint ONE VM, and the create is a network round trip, so a `std` + /// guard could not span it (nor be `Send`). Taken only when a + /// namespace's lookup finds no live binding — the steady-state reuse + /// path checks `namespaces` under its own `std` lock and never touches + /// this one. + /// + /// ponytail: one process-wide lock, so concurrent COLD starts across + /// different namespaces serialize (a warm boot is ~a second; the + /// ceiling is `CREATE_TIMEOUT_MS` against a wedged daemon). Upgrade path + /// if that ever shows up: a per-`NamespaceKey` mutex map, at the cost of + /// a second map to keep alive. + namespace_create_lock: tokio::sync::Mutex<()>, + /// Function ids this process has locally claimed, mapped to the + /// runtime that holds each one. `Engine::register`'s underlying SDK + /// registry PANICS on a duplicate id (see its `# Panics` doc) — this + /// map is what makes two concurrent `register()` calls for the same id + /// impossible in the first place: checked and reserved atomically + /// BEFORE either one ever reaches the bus, so the panic is unreachable. + /// Guards a single process only — `engine::functions::info` is what + /// covers a collision across two code-runner processes on one bus. + claims: Mutex>, +} + +/// Owner recorded in `claims` for this worker's own statically registered +/// ids (see `RuntimeManager::seed_static_ids`). `create` mints runtime ids as +/// `rt-`, so no real `runtime_id` can ever equal this constant — and +/// since no `RuntimeRecord` is ever created for it, `expire`/`teardown` +/// (which only clear claims found in a specific record's own `functions` +/// list) can never release these entries. +const STATIC_OWNER: &str = ""; + +fn str_field(v: &Value, k: &str) -> String { + v.get(k) + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string() +} + +impl RuntimeManager { + pub fn new(cfg: Arc, engine: Arc) -> Arc { + Arc::new(Self { + cfg, + engine, + runtimes: Mutex::new(HashMap::new()), + namespaces: Mutex::new(HashMap::new()), + namespace_create_lock: tokio::sync::Mutex::new(()), + claims: Mutex::new(HashMap::new()), + }) + } + + /// Seed `claims` with this worker's own statically registered ids + /// (`functions::STATIC_IDS`) before any caller-facing function is + /// invoked, so `reserve` refuses a caller who tries to register over one + /// of them the same way it refuses any other already-claimed id — this + /// protection no longer depends on the `engine::functions::info` probe + /// (a network round trip) to catch the collision. Idempotent: re-seeding + /// the same ids just overwrites their owner with the same sentinel. + pub fn seed_static_ids(&self, ids: &[&str]) { + let mut claims = self.claims.lock().unwrap(); + for id in ids { + claims.insert((*id).to_string(), STATIC_OWNER.to_string()); + } + } + + fn get(&self, runtime_id: &str) -> Result, CodeRunnerError> { + self.runtimes + .lock() + .unwrap() + .get(runtime_id) + .cloned() + .ok_or_else(|| CodeRunnerError::RuntimeNotFound(runtime_id.to_string())) + } + + /// This namespace's runtime for `lang`, but ONLY if it is still live. + /// The liveness check is what makes a stale binding structurally + /// harmless: a runtime that died without its binding being dropped just + /// reads as "not bound", and the next registration creates and rebinds. + fn bound_namespace(&self, ns: &str, lang: Lang) -> Option<(String, Arc)> { + let id = self + .namespaces + .lock() + .unwrap() + .get(&(ns.to_string(), lang)) + .cloned()?; + let record = self.runtimes.lock().unwrap().get(&id).cloned()?; + Some((id, record)) + } + + /// Drop every namespace binding pointing at `runtime_id`. Called from + /// each path that removes a record (`teardown`, `expire`) so the map + /// stays proportional to live runtimes; a kept-eval runtime is never in + /// this map, so this is a harmless no-op scan for one. + fn unbind_namespace(&self, runtime_id: &str) { + self.namespaces + .lock() + .unwrap() + .retain(|_, id| id != runtime_id); + } + + /// Resolve the runtime for a `register_function` call: reuse this + /// namespace's live runtime for `lang`, or create one and bind it. + /// Mirrors the now-deleted session-binding create path's + /// double-checked-locking shape: two concurrent first registrations in + /// one namespace must mint ONE VM, not two. + async fn namespace_runtime( + &self, + ns: &str, + lang: Lang, + ) -> Result<(String, Arc), CodeRunnerError> { + if let Some((id, record)) = self.bound_namespace(ns, lang) { + return Ok((id, record)); + } + + // Nothing bound (yet). Serialize the create so two concurrent first + // registrations in one namespace cannot both boot a VM — and + // re-check the binding on the way in, because the other one may + // have finished while this call waited for the lock. + let _creating = self.namespace_create_lock.lock().await; + if let Some((id, record)) = self.bound_namespace(ns, lang) { + return Ok((id, record)); + } + + let (id, record) = self.create(lang).await?; + self.namespaces + .lock() + .unwrap() + .insert((ns.to_string(), lang), id.clone()); + tracing::info!(namespace = %ns, lang = ?lang, "created a runtime for this namespace"); + Ok((id, record)) + } + + /// Map a failed engine call when there is no live record to expire — + /// the create path. `Gone` CAN occur here: `idle_ttl_secs` has no floor + /// (see `CodeRunnerConfig::effective_idle_ttl_secs`), so an + /// operator-set low value can make the daemon reap the sandbox between + /// `sandbox::create` returning and the runner plant's `sandbox::fs::write` + /// landing — proven with a scratch test failing the plant with S002. + /// Like `sandbox_call`'s `Gone` arm, this must NOT pass the daemon's raw + /// message through: it embeds `sandbox_id` ("no sandbox with that id + /// {id}"), and the caller here never received an id and cannot act on + /// it either way — a fixed, id-free message is strictly more useful. + fn map_failure(raw: &str) -> CodeRunnerError { + match classify_sandbox_error(raw) { + SandboxFailure::Gone => CodeRunnerError::Engine( + "the sandbox was reaped or lost during creation; retry".to_string(), + ), + SandboxFailure::Timeout => CodeRunnerError::Timeout, + SandboxFailure::Capacity(m) => CodeRunnerError::Capacity(m), + SandboxFailure::Other(m) => CodeRunnerError::Engine(m), + } + } + + /// Map a failed `sandbox::run` call — the create path for BOTH a + /// one-shot eval and `keep: true` (see `eval`'s no-`runtime_id` arm). + /// There is no live record yet, so nothing needs expiring: the daemon's + /// own `keep_sandbox` semantics already stop the VM on a sub-step + /// failure (`sandbox_daemon::run::run_inner`), so a failure here never + /// leaves an addressable orphan the way a bare `sandbox::create` + /// failure can. + /// + /// Deliberately narrower than `map_failure`: past its own `create` + /// step, `sandbox::run` wraps every sub-step failure in `RunStepFailed`, + /// whose own `Display` unconditionally embeds the real `sandbox_id` + /// (`"during sandbox::run step '{step}' (sandbox_id={sandbox_id}): ..."`) + /// regardless of the inner failure's own code. `classify_sandbox_error`'s + /// `Other` arm passes the daemon's message straight through, which for + /// `sandbox::run` specifically could leak that id — something + /// `sandbox_id` must never do (module doc). Only the Gone/Timeout/ + /// Capacity codes, whose messages here are fixed or daemon-diagnostic + /// text rather than the id-bearing wrapper prose, are passed through + /// with real detail; anything else collapses to a fixed, id-free + /// message. + fn map_run_failure(raw: &str) -> CodeRunnerError { + match classify_sandbox_error(raw) { + SandboxFailure::Gone => CodeRunnerError::Engine( + "the sandbox was reaped or lost while running; retry".to_string(), + ), + SandboxFailure::Timeout => CodeRunnerError::Timeout, + SandboxFailure::Capacity(m) => CodeRunnerError::Capacity(m), + SandboxFailure::Other(_) => CodeRunnerError::Engine( + "sandbox::run failed; its full diagnostic could embed a sandbox_id, which must \ + not reach the caller, so only this generic message is returned. Retry, or use \ + keep: true and check code-runner::teardown's response for whether a runtime \ + survived." + .to_string(), + ), + } + } + + /// Every `sandbox::*` call against a LIVE runtime goes through here: on + /// `Gone` (S002/S004 — the daemon reaped or lost the VM) the runtime is + /// expired — bus functions unregistered, record forgotten — before the + /// error returns. + pub(crate) async fn sandbox_call( + &self, + runtime_id: &str, + fn_id: &str, + payload: Value, + timeout_ms: u64, + ) -> Result { + match self + .engine + .call(fn_id.to_string(), payload, timeout_ms) + .await + { + Ok(v) => Ok(v), + Err(raw) => Err(match classify_sandbox_error(&raw) { + SandboxFailure::Gone => { + self.expire(runtime_id); + CodeRunnerError::Expired(runtime_id.to_string()) + } + SandboxFailure::Timeout => CodeRunnerError::Timeout, + SandboxFailure::Capacity(m) => CodeRunnerError::Capacity(m), + SandboxFailure::Other(m) => CodeRunnerError::Engine(m), + }), + } + } + + /// The VM is gone: unregister the runtime's bus functions and forget the + /// record. Idempotent — a second Gone for the same id finds nothing. Does + /// NOT call `sandbox::stop` — the whole premise is that the daemon + /// already reaped or lost the VM, so there is nothing left to stop. + pub(crate) fn expire(&self, runtime_id: &str) { + let record = self.runtimes.lock().unwrap().remove(runtime_id); + self.unbind_namespace(runtime_id); + if let Some(r) = record { + let mut claims = self.claims.lock().unwrap(); + for f in r.functions.lock().unwrap().drain(..) { + // A leaked claim would be a function id that can never be + // registered again for the life of the process. + claims.remove(&f.id); + (f.unregister)(); + tracing::warn!(id = %f.id, "unregistered: its runtime's VM expired"); + } + } + } + + /// Boot a sandbox, plant this language's runner, mint the record. Used + /// only by `namespace_runtime` — a namespace runtime needs the runner + /// planted (`invoke_registered` execs it), unlike a kept-eval runtime + /// (minted via `sandbox::run` in `eval`, which never registers a bus + /// function and so never needs it). + /// + /// Always creates without network: nothing in this worker's surface can + /// ask for one anymore (`register_function` carries no `network` field, + /// and `eval`'s own runtime-creation path goes through `sandbox::run`, + /// which has no way to request it either — see `eval`'s network + /// refusal). If a networked namespace runtime becomes a real need, this + /// is the parameter to reintroduce. + async fn create(&self, lang: Lang) -> Result<(String, Arc), CodeRunnerError> { + let created = self + .engine + .call( + "sandbox::create".to_string(), + json!({ + "image": lang.image(), + "idle_timeout_secs": self.cfg.effective_idle_ttl_secs(), + "network": false, + }), + CREATE_TIMEOUT_MS, + ) + .await + .map_err(|raw| Self::map_failure(&raw))?; + let sandbox_id = created + .get("sandbox_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + CodeRunnerError::Engine("sandbox::create returned no sandbox_id".into()) + })? + .to_string(); + + // Plant the runner. On failure, stop the sandbox rather than leak + // it: the caller never received an id, so nothing could ever address + // this VM again — it would sit in a daemon slot until the idle + // reaper. (node-engine leaked a slot per failed create until the + // same guard was added.) + let planted = self + .engine + .call( + "sandbox::fs::write".to_string(), + json!({ + "sandbox_id": sandbox_id, + "path": lang.runner_path(), + "content": lang.runner_source(), + "parents": true, + }), + FS_TIMEOUT_MS, + ) + .await; + if let Err(raw) = planted { + if let Err(stop_raw) = self + .engine + .call( + "sandbox::stop".to_string(), + json!({ "sandbox_id": sandbox_id, "wait": false }), + FS_TIMEOUT_MS, + ) + .await + { + if !matches!(classify_sandbox_error(&stop_raw), SandboxFailure::Gone) { + // No caller ever received this runtime's id, so a + // failed stop here leaks a daemon slot for the full + // idle TTL silently. + tracing::warn!( + error = %stop_raw, + "sandbox::stop failed after a failed runner plant; it will hold a \ + daemon slot until its own idle TTL" + ); + } + } + return Err(Self::map_failure(&raw)); + } + + let runtime_id = format!("rt-{}", uuid::Uuid::new_v4()); + let record = Arc::new(RuntimeRecord { + sandbox_id, + lang, + exec_lock: tokio::sync::Mutex::new(()), + namespace: Mutex::new(None), + functions: Mutex::new(Vec::new()), + }); + self.runtimes + .lock() + .unwrap() + .insert(runtime_id.clone(), record.clone()); + Ok((runtime_id, record)) + } + + /// `eval` has three paths, gated on `req.runtime_id` and `req.keep`: + /// + /// 1. `runtime_id` present → reuse that VM via write+exec. NOT stopped — + /// the caller owns it. `lang` mismatch is refused; `network` stays + /// documented-as-ignored (the caller already chose this runtime). + /// 2. `runtime_id` absent, `keep: true` → `sandbox::run + /// {keep_sandbox: true}`; the returned `sandbox_id` gets a minted + /// `runtime_id`, recorded and returned. + /// 3. `runtime_id` absent, default → `sandbox::run` (VM auto-stops); the + /// response carries no `runtime_id` — there is nothing left to + /// address, and returning a dead id would be worse than none. + pub async fn eval(&self, req: EvalRequest) -> Result { + if req.code.is_empty() { + return Err(CodeRunnerError::InvalidRequest( + "code must not be empty".into(), + )); + } + if req.code.len() > MAX_SOURCE_BYTES { + return Err(CodeRunnerError::InvalidRequest(format!( + "code is {} bytes; the limit is {MAX_SOURCE_BYTES}", + req.code.len() + ))); + } + + if let Some(id) = &req.runtime_id { + let record = self.get(id)?; + if let Some(lang) = req.lang { + if lang != record.lang { + return Err(CodeRunnerError::InvalidRequest(format!( + "this runtime runs {:?}; omit `lang` or pass the matching one — \ + languages cannot be mixed in one runtime", + record.lang + ))); + } + } + // `network` stays documented-as-ignored here: the caller named + // this runtime, so they know which one they got. + + let timeout_ms = self.cfg.clamp_timeout(req.timeout_ms).as_millis() as u64; + let _guard = record.exec_lock.lock().await; + + let file = format!( + "/tmp/code-runner/eval-{}.{}", + uuid::Uuid::new_v4(), + record.lang.ext() + ); + self.sandbox_call( + id, + "sandbox::fs::write", + json!({ + "sandbox_id": record.sandbox_id, + "path": file, + "content": req.code, + "parents": true, + }), + FS_TIMEOUT_MS, + ) + .await?; + + let out = self + .sandbox_call( + id, + "sandbox::exec", + json!({ + "sandbox_id": record.sandbox_id, + "cmd": record.lang.interpreter(), + "args": [file], + "timeout_ms": timeout_ms, + }), + timeout_ms + EXEC_MARGIN_MS, + ) + .await?; + + if out + .get("timed_out") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return Err(CodeRunnerError::Timeout); + } + return Ok(EvalResponse { + runtime_id: Some(id.clone()), + stdout: str_field(&out, "stdout"), + stderr: str_field(&out, "stderr"), + exit_code: out.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(-1), + success: out + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + duration_ms: out.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0), + }); + } + + let lang = req.lang.ok_or_else(|| { + CodeRunnerError::InvalidRequest( + "`lang` is required when there is no `runtime_id`: \"node\" or \"python\"".into(), + ) + })?; + + // Neither a one-shot eval nor `keep: true` can ever create a + // networked runtime — both boot through `sandbox::run`, whose + // request has no `network` field at all (confirmed against + // iii-sandbox's own `RunRequest`/`handle_run`, which always creates + // with `network: None` → the daemon's own `false` default, + // regardless of `keep_sandbox`). Silently dropping `network: true` + // here would surface later as an unexplainable `pip install` + // failure, so this refuses instead — matching the convention this + // worker already uses elsewhere for a `network` request that cannot + // be honoured. + if req.network { + return Err(CodeRunnerError::InvalidRequest( + "network: true needs an existing runtime_id: sandbox::run — which backs both a \ + one-shot eval and keep: true — has no way to enable outbound networking, so \ + neither path can ever create a networked runtime. Pass an explicit runtime_id \ + for a runtime that already has network, or drop network: true." + .into(), + )); + } + + let timeout_ms = self.cfg.clamp_timeout(req.timeout_ms).as_millis() as u64; + let out = self + .engine + .call( + "sandbox::run".to_string(), + json!({ + "image": lang.image(), + "lang": lang.image(), + "code": req.code, + "timeout_ms": timeout_ms, + "keep_sandbox": req.keep, + }), + CREATE_TIMEOUT_MS + timeout_ms + EXEC_MARGIN_MS, + ) + .await + .map_err(|raw| Self::map_run_failure(&raw))?; + + if out + .get("timed_out") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return Err(CodeRunnerError::Timeout); + } + + let runtime_id = if req.keep { + let sandbox_id = out + .get("sandbox_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + CodeRunnerError::Engine( + "sandbox::run left a sandbox running but returned no sandbox_id".into(), + ) + })? + .to_string(); + let id = format!("rt-{}", uuid::Uuid::new_v4()); + let record = Arc::new(RuntimeRecord { + sandbox_id, + lang, + exec_lock: tokio::sync::Mutex::new(()), + namespace: Mutex::new(None), + functions: Mutex::new(Vec::new()), + }); + self.runtimes.lock().unwrap().insert(id.clone(), record); + Some(id) + } else { + None + }; + + Ok(EvalResponse { + runtime_id, + stdout: str_field(&out, "stdout"), + stderr: str_field(&out, "stderr"), + exit_code: out.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(-1), + success: out + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + duration_ms: out.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0), + }) + } + + /// Destroy one runtime (whatever kind it is): drain in-flight work, + /// unregister its bus functions, best-effort stop its sandbox, forget + /// the record. Shared by `teardown`'s `runtime_id` arm and, once per + /// matching runtime, its `namespace` arm. + async fn destroy_runtime(&self, runtime_id: &str) -> Result, CodeRunnerError> { + // Remove first: new calls see NotFound immediately, and a failure + // below cannot resurrect the record. + let record = self + .runtimes + .lock() + .unwrap() + .remove(runtime_id) + .ok_or_else(|| CodeRunnerError::RuntimeNotFound(runtime_id.to_string()))?; + self.unbind_namespace(runtime_id); + + // Wait for whatever is already in flight to drain before touching + // the sandbox out from under it: an eval already past the lookup + // above holds its own `Arc` clone made before this + // removal, so it runs to completion on the SAME `exec_lock` — this + // await genuinely waits for that call to finish rather than racing + // it, and only then do we unregister and stop. Bounded: an eval + // holds this lock for at most its own clamped timeout. + let _guard = record.exec_lock.lock().await; + + let mut unregistered = Vec::new(); + { + let mut claims = self.claims.lock().unwrap(); + for f in record.functions.lock().unwrap().drain(..) { + claims.remove(&f.id); + (f.unregister)(); + unregistered.push(f.id); + } + } + + // Best-effort: Gone means the daemon already reaped it — that IS the + // requested outcome. Anything else is logged; the daemon's idle + // reaper is the backstop. + if let Err(raw) = self + .engine + .call( + "sandbox::stop".to_string(), + json!({ "sandbox_id": record.sandbox_id, "wait": false }), + FS_TIMEOUT_MS, + ) + .await + { + if !matches!(classify_sandbox_error(&raw), SandboxFailure::Gone) { + tracing::warn!( + error = %raw, + "sandbox::stop failed during teardown; the daemon's idle reaper is the backstop" + ); + } + } + + Ok(unregistered) + } + + /// Accepts exactly one of `runtime_id` (a kept-eval runtime) or + /// `namespace` (every runtime — one per language — backing a + /// `register_function` namespace). A namespace teardown unregisters + /// every function under it, exactly as a by-id teardown does today. + pub async fn teardown( + &self, + req: TeardownRequest, + ) -> Result { + match (req.runtime_id, req.namespace) { + (Some(_), Some(_)) => Err(CodeRunnerError::InvalidRequest( + "pass exactly one of runtime_id or namespace, not both: runtime_id tears down \ + a single kept-eval runtime (from code-runner::eval keep=true), namespace tears \ + down every runtime backing a register_function namespace" + .into(), + )), + (None, None) => Err(CodeRunnerError::InvalidRequest( + "pass exactly one of runtime_id (a kept eval's runtime) or namespace (a \ + register_function namespace, e.g. \"app\" for ids like app::greet)" + .into(), + )), + (Some(id), None) => { + let unregistered = self.destroy_runtime(&id).await?; + Ok(TeardownResponse { + runtime_id: Some(id), + namespace: None, + torn_down: true, + unregistered, + }) + } + (None, Some(raw_ns)) => { + let ns = normalize_namespace(&raw_ns)?; + let ids: Vec = { + let namespaces = self.namespaces.lock().unwrap(); + namespaces + .iter() + .filter(|((n, _), _)| n == &ns) + .map(|(_, id)| id.clone()) + .collect() + }; + if ids.is_empty() { + return Err(CodeRunnerError::NamespaceNotFound(ns)); + } + let mut unregistered = Vec::new(); + for id in ids { + match self.destroy_runtime(&id).await { + Ok(mut u) => unregistered.append(&mut u), + // Already gone (e.g. a concurrent teardown/expire + // beat this loop to it) reads as already torn + // down, not a failure — same as the by-id path's + // "reaped sandbox" success case. + Err(CodeRunnerError::RuntimeNotFound(_)) => {} + Err(e) => return Err(e), + } + } + Ok(TeardownResponse { + runtime_id: None, + namespace: Some(ns), + torn_down: true, + unregistered, + }) + } + } + } + + /// Atomically check-and-reserve everything two concurrent `register()` + /// calls could otherwise race on: the runtime's namespace (must match + /// what's already claimed, or be unclaimed), this process's own local + /// claim on `function_id`, and the per-runtime function cap. All three + /// checks and both writes happen under one lock acquisition, so only + /// one caller can ever win a given id — synchronously, before either + /// caller makes a network call. This is what makes + /// `Engine::register`'s duplicate-id panic (its underlying SDK + /// registry's documented `# Panics` behavior) unreachable: two callers + /// can no longer both pass the `engine::functions::info` probe and both + /// reach it for the same id. + fn reserve( + &self, + runtime_id: &str, + record: &RuntimeRecord, + function_id: &str, + ns: &str, + ) -> Result<(), CodeRunnerError> { + let mut claims = self.claims.lock().unwrap(); + let mut namespace = record.namespace.lock().unwrap(); + let functions = record.functions.lock().unwrap(); + + if let Some(existing) = namespace.as_ref() { + if existing != ns { + return Err(CodeRunnerError::InvalidRequest(format!( + "registered id {function_id:?} must start with this runtime's namespace \ + {existing:?} — rename the id, or use a runtime whose namespace covers it" + ))); + } + } + if claims.contains_key(function_id) { + return Err(CodeRunnerError::InvalidRequest(format!( + "function id {function_id} is already registered on the bus" + ))); + } + if functions.len() >= MAX_FUNCTIONS_PER_RUNTIME { + return Err(CodeRunnerError::InvalidRequest(format!( + "this runtime already holds {MAX_FUNCTIONS_PER_RUNTIME} functions" + ))); + } + + if namespace.is_none() { + *namespace = Some(ns.to_string()); + } + claims.insert(function_id.to_string(), runtime_id.to_string()); + Ok(()) + } + + /// Undo a `reserve` that a later step (the probe, the plant, or the + /// publish) failed to follow through on: release the local claim, and + /// clear the runtime's namespace ONLY if nothing else — a concurrent + /// reservation, or an already-committed function — still depends on it. + /// A refused registration must never leave a namespace pinned with + /// nothing behind it; a namespace another registration relies on must + /// never be cleared out from under it. + fn release(&self, function_id: &str, record: &RuntimeRecord, runtime_id: &str) { + let mut claims = self.claims.lock().unwrap(); + claims.remove(function_id); + if !claims.values().any(|owner| owner == runtime_id) { + *record.namespace.lock().unwrap() = None; + } + } + + /// No `runtime_id` on the wire: code-runner resolves (creating if + /// needed) the persistent runtime for `(namespace_of(function_id), + /// req.lang)` itself — see `namespace_runtime`. + pub async fn register( + self: &Arc, + req: RegisterRequest, + ) -> Result { + if req.source.is_empty() { + return Err(CodeRunnerError::InvalidRequest( + "source must not be empty; it must define handler(payload)".into(), + )); + } + if req.source.len() > MAX_SOURCE_BYTES { + return Err(CodeRunnerError::InvalidRequest(format!( + "source is {} bytes; the limit is {MAX_SOURCE_BYTES}", + req.source.len() + ))); + } + if req.function_id.len() > MAX_FUNCTION_ID_BYTES { + return Err(CodeRunnerError::InvalidRequest(format!( + "function id is longer than {MAX_FUNCTION_ID_BYTES} bytes" + ))); + } + if let Some(d) = &req.description { + if d.len() > MAX_DESCRIPTION_BYTES { + return Err(CodeRunnerError::InvalidRequest(format!( + "description is longer than {MAX_DESCRIPTION_BYTES} bytes" + ))); + } + } + let ns = namespace_of(&req.function_id).map_err(CodeRunnerError::InvalidRequest)?; + + // Cheap, synchronous fail-fast: an id already claimed (including a + // seeded static one — `code-runner::*` is never a legitimate + // namespace to register into) is refused before `namespace_runtime` + // ever creates or reuses a VM for it. Not the authoritative check — + // `reserve` still does that, atomically, once a record exists — this + // purely avoids booting a doomed namespace runtime for a request + // that cannot possibly succeed. + if self.claims.lock().unwrap().contains_key(&req.function_id) { + return Err(CodeRunnerError::InvalidRequest(format!( + "function id {} is already registered on the bus", + req.function_id + ))); + } + + let (runtime_id, record) = self.namespace_runtime(&ns, req.lang).await?; + + // Reserve the id (and, if this is the runtime's first, its + // namespace) BEFORE any network call — see `reserve`'s doc for the + // race this closes. + self.reserve(&runtime_id, &record, &req.function_id, &ns)?; + + let weak = Arc::downgrade(self); + match self.publish(&req, &runtime_id, &record, weak).await { + Ok(resp) => Ok(resp), + Err(e) => { + self.release(&req.function_id, &record, &runtime_id); + Err(Self::redact_register_error(e)) + } + } + } + + /// `register_function`'s caller never supplies or receives a + /// `runtime_id` — code-runner resolves the namespace runtime + /// internally (`namespace_runtime`) — so unlike the DIRECT `eval` / + /// `teardown` paths, where `error.rs`'s id-quoting `Expired` / + /// `RuntimeNotFound` messages are a documented exception (the id goes + /// back to the caller who already supplied it), this caller has no + /// business receiving one either. The only way either variant can + /// reach here is `publish`'s post-plant re-check racing a concurrent + /// teardown of this namespace — folds to a generic, id-free message + /// rather than a stable `expired`/`runtime_not_found` code quoting an + /// id nobody on this call ever held. Mirrors `redact_proxy_error`'s + /// intent for the proxy-invocation caller; kept separate because that + /// one returns a bare `String` (the `ProxyHandler` contract) where this + /// one must stay a `CodeRunnerError` (this function's own return type). + fn redact_register_error(e: CodeRunnerError) -> CodeRunnerError { + match e { + CodeRunnerError::Expired(_) | CodeRunnerError::RuntimeNotFound(_) => { + CodeRunnerError::Engine( + "this namespace's runtime was torn down while the registration was in \ + flight; register again" + .into(), + ) + } + other => other, + } + } + + /// The probe → plant → publish sequence, run only once `reserve` has + /// already won this id locally. Any `Err` here is rolled back by the + /// caller (`register`) via `release`. + async fn publish( + &self, + req: &RegisterRequest, + runtime_id: &str, + record: &Arc, + weak: std::sync::Weak, + ) -> Result { + // Probe the bus. Found = taken; NOT_FOUND-style error = free; any + // other answer fails CLOSED — an unverifiable id is not published. + match self + .engine + .call( + "engine::functions::info".to_string(), + json!({ "function_id": req.function_id }), + PROBE_TIMEOUT_MS, + ) + .await + { + Ok(_) => { + return Err(CodeRunnerError::InvalidRequest(format!( + "function id {} is already registered on the bus", + req.function_id + ))) + } + Err(raw) => { + // `classify_probe_error` distinguishes "the target id is + // free" from "this engine cannot dispatch the probe at all" + // — a lowercase substring match on "not found" alone cannot + // tell those apart (both raw strings contain it), and + // reading the latter as "free" would invert this + // deliberately fail-CLOSED gate: it is the ONLY cross-process + // guard against two code-runner workers colliding on one bus + // id (the SDK's own registry panics on a duplicate id with + // nothing serializing two concurrent registrations across + // processes). See `classify_probe_error`'s doc. + if classify_probe_error(&raw, &req.function_id) != ProbeOutcome::Free { + return Err(CodeRunnerError::Engine(format!( + "could not verify that {} is free: {raw}", + req.function_id + ))); + } + } + } + + // Hold `exec_lock` across the plant AND the push-plus-bus-register + // below, not just the plant: `teardown()` removes the runtime from + // `self.runtimes` FIRST (no lock needed for that) and only THEN + // waits on this same lock before draining `record.functions`. + // Releasing early (right after the plant, as a prior version of + // this code did) let teardown's drain run, find nothing to + // unregister, and finish — while this call then pushed and + // published anyway: a function live on the bus with no + // `functions` entry left to ever unregister it, and a `claims` + // entry that could never be released. Holding the lock across both + // steps rules that out: teardown's drain cannot happen in between + // "planted" and "published" — it can only land fully before (in + // which case the re-check below catches it) or fully after (in + // which case it correctly finds and unregisters what we just + // pushed). + let _guard = record.exec_lock.lock().await; + + let path = format!( + "/opt/code-runner/fns/{}.{}", + uuid::Uuid::new_v4(), + record.lang.ext() + ); + self.sandbox_call( + runtime_id, + "sandbox::fs::write", + json!({ + "sandbox_id": record.sandbox_id, + "path": path, + "content": req.source, + "parents": true, + }), + FS_TIMEOUT_MS, + ) + .await?; + let source_path = path; + + // Re-verify, still under `exec_lock`: `teardown()` may have removed + // the runtime from `self.runtimes` while the plant's network round + // trip was in flight. If it did, refuse to publish onto a runtime + // that is already gone — the caller gets the same `Expired` any + // other call against a torn-down runtime gets, not a stale `Ok`. + self.get(runtime_id) + .map_err(|_| CodeRunnerError::Expired(runtime_id.to_string()))?; + + // Publish the proxy. `Weak` breaks the cycle manager → record → + // (engine's registry) → proxy → manager, and lets a proxy that + // outlives the manager answer cleanly instead of keeping it alive. + let proxy_runtime_id = runtime_id.to_string(); + let proxy_source_path = source_path.clone(); + let proxy_function_id = req.function_id.clone(); + let handler: crate::engine::ProxyHandler = Arc::new(move |payload| { + let weak = weak.clone(); + let runtime_id = proxy_runtime_id.clone(); + let source_path = proxy_source_path.clone(); + let function_id = proxy_function_id.clone(); + Box::pin(async move { + let Some(m) = weak.upgrade() else { + return Err("code-runner is shutting down".to_string()); + }; + m.invoke_registered(&runtime_id, &function_id, &source_path, payload) + .await + .map_err(Self::redact_proxy_error) + }) + }); + let unregister = + self.engine + .register(req.function_id.clone(), req.description.clone(), handler); + + // The namespace and the local claim were already set by `reserve`; + // only the committed function list is new here. + record.functions.lock().unwrap().push(RegisteredFn { + id: req.function_id.clone(), + unregister, + }); + + Ok(RegisterResponse { + function_id: req.function_id.clone(), + registered: true, + }) + } + + /// `error.rs`'s "deliberate exception" — `Display`ing a runtime id + /// verbatim — is justified for the DIRECT `code-runner::eval` / + /// `teardown` paths: the id goes back to the caller who already + /// supplied it. A registered function's PROXY is a different caller + /// entirely — whoever calls `app::greet` never held `runtime_id` and + /// has no business receiving it. Strip it from the two variants that + /// quote it (`Expired`, `RuntimeNotFound`) before stringifying; the + /// code stays stable so a caller can still branch on it, only the + /// id-bearing message is replaced. + fn redact_proxy_error(e: CodeRunnerError) -> String { + let code = e.code(); + match e { + CodeRunnerError::Expired(_) | CodeRunnerError::RuntimeNotFound(_) => { + format!("{code}: this function is no longer backed by a live runtime") + } + other => other.to_string(), + } + } + + /// One bus call of a registered function: exec the runner against the + /// planted source with the payload on stdin. Handler prints are logged + /// at debug, not returned — the caller gets exactly what `handler` + /// returned. + async fn invoke_registered( + &self, + runtime_id: &str, + function_id: &str, + source_path: &str, + payload: Value, + ) -> Result { + // A proxy can be invoked in the window between expiry/teardown and + // its unregistration landing; answer "expired", not "not found". + let record = self + .get(runtime_id) + .map_err(|_| CodeRunnerError::Expired(runtime_id.to_string()))?; + + let timeout_ms = self.cfg.default_timeout_ms; + let _guard = record.exec_lock.lock().await; + + // The sentinel rides in the stdin envelope, NOT in argv: the handler + // is loaded into the runner's own process, so argv is ambient state + // it can read — and a handler that can read the sentinel can print a + // forged frame ahead of the runner's real one. The runner consumes + // stdin before the handler loads, so by the time handler code runs + // the envelope is gone. + let sentinel = uuid::Uuid::new_v4().to_string(); + use base64::Engine as _; + let envelope = json!({ "sentinel": sentinel, "payload": payload }); + let stdin_b64 = base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&envelope).expect("a Value serializes")); + + let out = self + .sandbox_call( + runtime_id, + "sandbox::exec", + json!({ + "sandbox_id": record.sandbox_id, + "cmd": record.lang.interpreter(), + "args": [record.lang.runner_path(), source_path], + "stdin": stdin_b64, + "timeout_ms": timeout_ms, + }), + timeout_ms + EXEC_MARGIN_MS, + ) + .await?; + + if out + .get("timed_out") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return Err(CodeRunnerError::Timeout); + } + let stdout = str_field(&out, "stdout"); + let stderr = str_field(&out, "stderr"); + let split = crate::runner::split_sentinel(&stdout, &sentinel); + if !split.logs.is_empty() { + tracing::debug!(function_id = %function_id, logs = %split.logs, "handler prints"); + } + let exit_ok = out.get("exit_code").and_then(|v| v.as_i64()) == Some(0); + + match (exit_ok, split.result) { + (true, Some(raw)) => serde_json::from_str(&raw).map_err(|_| { + CodeRunnerError::HandlerError( + "handler result is not valid JSON — return only JSON-serializable values" + .into(), + ) + }), + (false, Some(raw)) => { + let msg = serde_json::from_str::(&raw) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or(raw); + Err(CodeRunnerError::HandlerError(msg)) + } + (_, None) => Err(CodeRunnerError::HandlerError(format!( + "the runner produced no result (interpreter crash?); stderr: {}", + stderr_tail(&stderr) + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::FakeEngine; + use serde_json::json; + + fn cfg() -> Arc { + Arc::new(CodeRunnerConfig::default()) + } + + /// A realistic daemon error as it arrives through the bus framing. + fn wrapped(code: &str, msg: &str) -> String { + format!( + r#"remote error (invocation_failed): handler error: {{"type":"X","code":"{code}","message":"{msg}","retryable":false}}"# + ) + } + + fn ok_exec() -> serde_json::Value { + json!({ "stdout": "4\n", "stderr": "", "exit_code": 0, "timed_out": false, + "duration_ms": 12, "success": true }) + } + + /// The daemon calls a kept-eval reuse and a namespace-runtime creation + /// both go through: create + runner plant, then write + exec, then + /// stop. Also answers a plain `sandbox::run` with the ephemeral + /// (no-`sandbox_id`) shape — tests that need the kept shape override it + /// with a responder. + fn happy_fake() -> Arc { + let fake = FakeEngine::new(); + fake.with_response( + "sandbox::create", + Ok(json!({ "sandbox_id": "sb-1", "image": "node" })), + ); + fake.with_response( + "sandbox::fs::write", + Ok(json!({ "bytes_written": 1, "path": "p" })), + ); + fake.with_response("sandbox::exec", Ok(ok_exec())); + fake.with_response( + "sandbox::stop", + Ok(json!({ "sandbox_id": "sb-1", "stopped": true })), + ); + fake.with_response("sandbox::run", Ok(ok_exec())); + fake + } + + fn eval_req(code: &str, lang: Option, runtime_id: Option) -> EvalRequest { + EvalRequest { + code: code.into(), + runtime_id, + lang, + keep: false, + network: false, + timeout_ms: None, + } + } + + /// Directly insert a `RuntimeRecord` bypassing every daemon call — the + /// fixture for tests that only care about the REUSE path (write+exec + /// against an already-live runtime), not how it came to exist. + fn seed_runtime(m: &RuntimeManager, lang: Lang, sandbox_id: &str) -> String { + let id = format!("rt-{}", uuid::Uuid::new_v4()); + let record = Arc::new(RuntimeRecord { + sandbox_id: sandbox_id.to_string(), + lang, + exec_lock: tokio::sync::Mutex::new(()), + namespace: Mutex::new(None), + functions: Mutex::new(Vec::new()), + }); + m.runtimes.lock().unwrap().insert(id.clone(), record); + id + } + + // --------------------------------------------------------------- + // eval: the ephemeral and kept-eval (`sandbox::run`) paths. + // --------------------------------------------------------------- + + #[tokio::test] + async fn an_ephemeral_eval_makes_one_sandbox_run_call_and_leaves_no_runtime() { + let fake = FakeEngine::new(); + fake.with_responder("sandbox::run", |payload| { + assert_eq!(payload["image"], "node"); + assert_eq!(payload["lang"], "node"); + assert_eq!(payload["code"], "console.log(2+2)"); + assert_eq!(payload["keep_sandbox"], false); + assert_eq!(payload["timeout_ms"], 5_000); + Ok( + json!({ "stdout": "4\n", "stderr": "", "exit_code": 0, "timed_out": false, + "duration_ms": 12, "success": true }), + ) + }); + let m = RuntimeManager::new(cfg(), fake.clone()); + let out = m + .eval(eval_req("console.log(2+2)", Some(Lang::Node), None)) + .await + .expect("eval succeeds"); + assert_eq!(out.runtime_id, None, "nothing left to address"); + assert_eq!(out.stdout, "4\n"); + assert!(out.success); + let calls = fake.calls(); + assert_eq!(calls.len(), 1, "one call: sandbox::run"); + assert_eq!(calls[0].0, "sandbox::run"); + assert!( + m.runtimes.lock().unwrap().is_empty(), + "an ephemeral eval must not leave an addressable runtime behind" + ); + } + + #[tokio::test] + async fn a_python_ephemeral_eval_selects_the_python_image_and_lang() { + let fake = FakeEngine::new(); + fake.with_responder("sandbox::run", |payload| { + assert_eq!(payload["image"], "python"); + assert_eq!(payload["lang"], "python"); + Ok( + json!({ "stdout": "4\n", "stderr": "", "exit_code": 0, "timed_out": false, + "duration_ms": 1, "success": true }), + ) + }); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.eval(eval_req("print(2+2)", Some(Lang::Python), None)) + .await + .expect("eval succeeds"); + } + + #[tokio::test] + async fn keep_true_mints_a_runtime_id_and_it_addresses_the_kept_vm() { + let fake = FakeEngine::new(); + fake.with_responder("sandbox::run", |payload| { + assert_eq!(payload["keep_sandbox"], true); + Ok( + json!({ "stdout": "4\n", "stderr": "", "exit_code": 0, "timed_out": false, + "duration_ms": 1, "success": true, "sandbox_id": "sb-kept-1" }), + ) + }); + fake.with_response( + "sandbox::fs::write", + Ok(json!({ "bytes_written": 1, "path": "p" })), + ); + fake.with_response("sandbox::exec", Ok(ok_exec())); + let m = RuntimeManager::new(cfg(), fake.clone()); + + let mut req = eval_req("1", Some(Lang::Node), None); + req.keep = true; + let out = m.eval(req).await.expect("eval succeeds"); + let id = out + .runtime_id + .clone() + .expect("keep: true mints a runtime_id"); + assert!(id.starts_with("rt-")); + assert!(m.runtimes.lock().unwrap().contains_key(&id)); + + // The minted id addresses that VM: a later eval reuses it via + // write+exec, no second sandbox::run. + let before = fake.calls().len(); + let out2 = m + .eval(eval_req("2", None, Some(id.clone()))) + .await + .expect("reuse succeeds"); + assert_eq!(out2.runtime_id, Some(id)); + let calls = fake.calls(); + assert_eq!(calls.len(), before + 2); + assert_eq!(calls[before].0, "sandbox::fs::write"); + assert_eq!(calls[before + 1].0, "sandbox::exec"); + } + + #[tokio::test] + async fn keep_true_without_a_returned_sandbox_id_is_an_engine_error() { + let fake = FakeEngine::new(); + // Malformed daemon reply: keep was requested but no sandbox_id came + // back — must not silently mint an unaddressable "kept" runtime. + fake.with_response("sandbox::run", Ok(ok_exec())); + let m = RuntimeManager::new(cfg(), fake.clone()); + let mut req = eval_req("1", Some(Lang::Node), None); + req.keep = true; + let err = m.eval(req).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::engine"); + assert!(m.runtimes.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn a_timed_out_run_is_a_timeout_error() { + let fake = FakeEngine::new(); + fake.with_response( + "sandbox::run", + Ok( + json!({ "stdout": "", "stderr": "", "exit_code": serde_json::Value::Null, + "timed_out": true, "duration_ms": 5000, "success": false }), + ), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .eval(eval_req("while(1);", Some(Lang::Node), None)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::timeout"); + } + + #[tokio::test] + async fn a_null_exit_code_from_sandbox_run_maps_to_negative_one() { + let fake = FakeEngine::new(); + fake.with_response( + "sandbox::run", + Ok( + json!({ "stdout": "", "stderr": "boot noise", "exit_code": serde_json::Value::Null, + "timed_out": false, "duration_ms": 1, "success": false }), + ), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let out = m + .eval(eval_req("1", Some(Lang::Node), None)) + .await + .expect("a non-timeout, non-error response is a settled response"); + assert_eq!(out.exit_code, -1); + } + + #[tokio::test] + async fn sandbox_run_gone_maps_to_a_retry_error_and_creates_nothing() { + let fake = FakeEngine::new(); + fake.with_response( + "sandbox::run", + Err(wrapped("S002", "no sandbox with that id sb-9")), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .eval(eval_req("1", Some(Lang::Node), None)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::engine"); + assert!(!err.to_string().contains("sb-9"), "{err}"); + assert!(m.runtimes.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn sandbox_run_capacity_maps_to_capacity_and_calls_nothing_else() { + let fake = FakeEngine::new(); + fake.with_response( + "sandbox::run", + Err(wrapped("S400", "max_concurrent_sandboxes reached")), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .eval(eval_req("1", Some(Lang::Node), None)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::capacity"); + assert_eq!(fake.calls().len(), 1); + } + + #[tokio::test] + async fn sandbox_run_timeout_wire_error_maps_to_timeout() { + let fake = FakeEngine::new(); + fake.with_response("sandbox::run", Err(wrapped("S200", "deadline"))); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .eval(eval_req("1", Some(Lang::Node), None)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::timeout"); + } + + /// The load-bearing regression `map_run_failure` exists for: past its + /// own `create` step, `sandbox::run` wraps a sub-step failure in + /// `RunStepFailed`, whose OWN `Display` embeds the REAL `sandbox_id` + /// regardless of the inner failure's code — so an `Other`-classified + /// failure must not pass the daemon's raw message through. + #[tokio::test] + async fn a_sandbox_run_other_failure_never_leaks_a_sandbox_id() { + let fake = FakeEngine::new(); + let sandbox_id = "11111111-2222-3333-4444-555555555555"; + fake.with_response( + "sandbox::run", + Err(format!( + r#"remote error (invocation_failed): handler error: {{"type":"validation","code":"S216","message":"during sandbox::run step `fs::write (code)` (sandbox_id={sandbox_id}): disk full","docs_url":"x","fix":null,"retryable":false}}"# + )), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .eval(eval_req("1", Some(Lang::Node), None)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::engine"); + assert!( + !err.to_string().contains(sandbox_id), + "the caller-facing error leaked the sandbox_id: {err}" + ); + } + + // --------------------------------------------------------------- + // eval: `network` refusal on the no-`runtime_id` paths. + // --------------------------------------------------------------- + + #[tokio::test] + async fn network_true_without_a_runtime_id_is_refused_ephemeral_and_kept() { + // No responder configured for sandbox::run at all: the refusal must + // happen before any daemon call, or this test would error on the + // unconfigured call instead of proving the refusal. + let fake = FakeEngine::new(); + let m = RuntimeManager::new(cfg(), fake.clone()); + + let mut ephemeral = eval_req("1", Some(Lang::Node), None); + ephemeral.network = true; + let err = m.eval(ephemeral).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + assert!(err.to_string().contains("network"), "{err}"); + + let mut kept = eval_req("1", Some(Lang::Node), None); + kept.network = true; + kept.keep = true; + let err = m.eval(kept).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + + assert!( + fake.calls().is_empty(), + "refused before any daemon call: {:?}", + fake.calls() + ); + } + + #[tokio::test] + async fn network_true_with_an_explicit_runtime_id_is_ignored_not_refused() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + let mut req = eval_req("1", None, Some(id)); + req.network = true; + m.eval(req) + .await + .expect("network is ignored, not refused, on an explicit runtime_id"); + } + + // --------------------------------------------------------------- + // eval: the `runtime_id` reuse path (unchanged behaviour). + // --------------------------------------------------------------- + + #[tokio::test] + async fn eval_with_runtime_id_reuses_the_sandbox_via_write_and_exec() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + let out = m + .eval(eval_req("2", None, Some(id.clone()))) + .await + .expect("reuse succeeds"); + assert_eq!(out.runtime_id, Some(id)); + let calls = fake.calls(); + assert_eq!(calls.len(), 2, "only write + exec — no create, no run"); + assert_eq!(calls[0].0, "sandbox::fs::write"); + assert_eq!(calls[1].0, "sandbox::exec"); + } + + #[tokio::test] + async fn a_mismatched_lang_on_an_existing_runtime_is_refused() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + let err = m + .eval(eval_req("1", Some(Lang::Python), Some(id.clone()))) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + // The matching lang is fine. + m.eval(eval_req("1", Some(Lang::Node), Some(id))) + .await + .expect("matching lang accepted"); + } + + #[tokio::test] + async fn unknown_runtime_id_is_not_found() { + let m = RuntimeManager::new(cfg(), happy_fake()); + let err = m + .eval(eval_req("1", None, Some("rt-nope".into()))) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::runtime_not_found"); + } + + #[tokio::test] + async fn empty_and_oversized_code_are_invalid_requests() { + let m = RuntimeManager::new(cfg(), happy_fake()); + let err = m + .eval(eval_req("", Some(Lang::Node), None)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + let big = "x".repeat(MAX_SOURCE_BYTES + 1); + let err = m + .eval(eval_req(&big, Some(Lang::Node), None)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + } + + #[tokio::test] + async fn create_requires_a_lang() { + let m = RuntimeManager::new(cfg(), happy_fake()); + let err = m.eval(eval_req("1", None, None)).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + assert!(err.to_string().contains("lang"), "{err}"); + } + + #[tokio::test] + async fn requested_timeout_is_clamped_on_the_reuse_path() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + let mut req = eval_req("1", None, Some(id)); + req.timeout_ms = Some(999_999); + m.eval(req).await.unwrap(); + let calls = fake.calls(); + assert_eq!(calls[1].1["timeout_ms"], 30_000); + } + + #[tokio::test] + async fn requested_timeout_is_clamped_on_the_ephemeral_path() { + let fake = FakeEngine::new(); + fake.with_responder("sandbox::run", |payload| { + assert_eq!(payload["timeout_ms"], 30_000); + Ok( + json!({ "stdout": "", "stderr": "", "exit_code": 0, "timed_out": false, + "duration_ms": 1, "success": true }), + ) + }); + let m = RuntimeManager::new(cfg(), fake.clone()); + let mut req = eval_req("1", Some(Lang::Node), None); + req.timeout_ms = Some(999_999); + m.eval(req).await.unwrap(); + } + + /// The mirror image: a caller-supplied `runtime_id` is a capability the + /// caller already holds, so a failed eval against it must NOT reap — + /// they can retry or tear it down themselves. + #[tokio::test] + async fn an_eval_failure_on_a_caller_supplied_runtime_does_not_reap() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + + fake.with_response("sandbox::exec", Err(wrapped("S200", "deadline"))); + let err = m + .eval(eval_req("2", None, Some(id.clone()))) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::timeout"); + assert!( + m.runtimes.lock().unwrap().contains_key(&id), + "a caller-supplied runtime must survive a failed eval" + ); + assert!( + !fake.calls().iter().any(|(id, _)| id == "sandbox::stop"), + "must not have been stopped" + ); + + // And it is still usable: a subsequent eval succeeds normally. + fake.with_response("sandbox::exec", Ok(ok_exec())); + let out = m + .eval(eval_req("3", None, Some(id))) + .await + .expect("the surviving runtime is still usable"); + assert!(out.success); + } + + /// A failed eval (non-zero exit) is NOT an error: the response carries + /// exit_code/stderr and the caller iterates. Only infrastructure + /// failures are errors. + #[tokio::test] + async fn a_nonzero_exit_is_a_response_not_an_error() { + let fake = happy_fake(); + fake.with_response( + "sandbox::exec", + Ok( + json!({ "stdout": "", "stderr": "SyntaxError: x", "exit_code": 1, + "timed_out": false, "duration_ms": 3, "success": false }), + ), + ); + let m = RuntimeManager::new(cfg(), fake); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + let out = m.eval(eval_req("syntax(", None, Some(id))).await.unwrap(); + assert!(!out.success); + assert_eq!(out.exit_code, 1); + assert!(out.stderr.contains("SyntaxError")); + } + + /// S002/S004 on a live record: the VM was reaped behind our back. The + /// error names the runtime, and the record is gone afterwards. + #[tokio::test] + async fn a_reaped_sandbox_expires_the_runtime() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + fake.with_response( + "sandbox::fs::write", + Err(wrapped("S004", "sandbox stopped")), + ); + let err = m + .eval(eval_req("2", None, Some(id.clone()))) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::expired"); + // The record is gone: the same id is now unknown, not expired-again. + let err = m.eval(eval_req("3", None, Some(id))).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::runtime_not_found"); + } + + /// The channel that actually reaches a caller: drives a REAL `S003` + /// failure through `sandbox_call` exactly as a slow daemon would + /// produce it, and asserts the sandbox_id the daemon embedded in its + /// own message never appears in what the caller gets back. + #[tokio::test] + async fn a_concurrent_exec_error_never_leaks_the_sandbox_id_to_the_caller() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + + fake.with_response( + "sandbox::exec", + Err(wrapped( + "S003", + "concurrent exec on sandbox sb-1: an exec is already in flight. \ + Exec is serialized one-at-a-time per sandbox", + )), + ); + let err = m + .eval(eval_req("2", None, Some(id.clone()))) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::engine"); + assert!( + !err.to_string().contains("sb-1"), + "the caller-facing error leaked the sandbox_id: {err}" + ); + } + + // --------------------------------------------------------------- + // teardown: request validation (exactly one of runtime_id/namespace). + // --------------------------------------------------------------- + + fn td_by_id(id: &str) -> TeardownRequest { + TeardownRequest { + runtime_id: Some(id.to_string()), + namespace: None, + } + } + + fn td_by_ns(ns: &str) -> TeardownRequest { + TeardownRequest { + runtime_id: None, + namespace: Some(ns.to_string()), + } + } + + #[tokio::test] + async fn teardown_refuses_both_runtime_id_and_namespace() { + let m = RuntimeManager::new(cfg(), happy_fake()); + let err = m + .teardown(TeardownRequest { + runtime_id: Some("rt-x".into()), + namespace: Some("app".into()), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + assert!(err.to_string().contains("not both"), "{err}"); + } + + #[tokio::test] + async fn teardown_refuses_neither_runtime_id_nor_namespace() { + let m = RuntimeManager::new(cfg(), happy_fake()); + let err = m + .teardown(TeardownRequest { + runtime_id: None, + namespace: None, + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + assert!(err.to_string().contains("runtime_id"), "{err}"); + assert!(err.to_string().contains("namespace"), "{err}"); + } + + // --------------------------------------------------------------- + // teardown: by runtime_id (a kept-eval runtime). + // --------------------------------------------------------------- + + #[tokio::test] + async fn teardown_by_id_stops_the_sandbox_and_forgets_the_record() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + let out = m.teardown(td_by_id(&id)).await.unwrap(); + assert!(out.torn_down); + assert_eq!(out.runtime_id.as_deref(), Some(id.as_str())); + assert_eq!(out.namespace, None); + assert!(out.unregistered.is_empty()); + let stop = fake + .calls() + .into_iter() + .find(|(id, _)| id == "sandbox::stop") + .expect("stop was called"); + assert_eq!(stop.1["sandbox_id"], "sb-1"); + assert_eq!(stop.1["wait"], false); + let err = m.teardown(td_by_id(&id)).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::runtime_not_found"); + } + + /// Tearing down a runtime whose VM the daemon already reaped is + /// success, not an error — the caller asked for it to be gone and it + /// is. + #[tokio::test] + async fn teardown_of_an_already_reaped_sandbox_still_succeeds() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + fake.with_response("sandbox::stop", Err(wrapped("S004", "already stopped"))); + let out = m.teardown(td_by_id(&id)).await.unwrap(); + assert!(out.torn_down); + } + + /// Proves the serialization itself: while something is holding the + /// runtime's `exec_lock` (standing in for an in-flight eval), a + /// concurrent `teardown` must block before it unregisters or stops the + /// sandbox — and must complete, calling `sandbox::stop`, only once that + /// lock is released. + #[tokio::test] + async fn teardown_waits_for_an_in_flight_eval_before_stopping_the_sandbox() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone()); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + + let record = m.runtimes.lock().unwrap().get(&id).expect("exists").clone(); + let held = record.exec_lock.lock().await; + + let teardown_m = m.clone(); + let teardown_id = id.clone(); + let teardown_task = + tokio::spawn(async move { teardown_m.teardown(td_by_id(&teardown_id)).await }); + + for _ in 0..10 { + tokio::task::yield_now().await; + } + assert!( + !fake.calls().iter().any(|(id, _)| id == "sandbox::stop"), + "teardown must not stop the sandbox while an eval is still in flight" + ); + assert!(m.runtimes.lock().unwrap().is_empty()); + + drop(held); + let out = teardown_task + .await + .unwrap() + .expect("teardown completes once the in-flight eval drains"); + assert!(out.torn_down); + assert!(fake.calls().iter().any(|(id, _)| id == "sandbox::stop")); + } + + // --------------------------------------------------------------- + // register_function: namespace runtimes. + // --------------------------------------------------------------- + + use crate::functions::register::RegisterRequest; + + /// Probe answers "free" — the NOT_FOUND-style error every available id + /// produces. + fn probe_free(fake: &FakeEngine) { + fake.with_response( + "engine::functions::info", + Err("remote error (invocation_failed): NOT_FOUND: no function app::greet".into()), + ); + } + + /// Decode a `sandbox::exec` payload's base64 stdin back into the + /// `{sentinel, payload}` envelope the runner receives. + fn decode_envelope(exec_payload: &serde_json::Value) -> serde_json::Value { + use base64::Engine as _; + let raw = base64::engine::general_purpose::STANDARD + .decode( + exec_payload["stdin"] + .as_str() + .expect("stdin is a b64 string"), + ) + .expect("stdin decodes"); + serde_json::from_slice(&raw).expect("stdin is the JSON envelope") + } + + fn reg_req(function_id: &str, lang: Lang) -> RegisterRequest { + RegisterRequest { + function_id: function_id.into(), + source: "export function handler(p) { return p; }".into(), + description: Some("echoes".into()), + lang, + } + } + + fn creates(fake: &FakeEngine) -> usize { + fake.calls() + .iter() + .filter(|(id, _)| id == "sandbox::create") + .count() + } + + #[tokio::test] + async fn register_creates_a_namespace_runtime_when_none_exists() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + let out = m.register(reg_req("app::greet", Lang::Node)).await.unwrap(); + assert_eq!(out.function_id, "app::greet"); + assert!(out.registered); + assert_eq!(creates(&fake), 1); + + let plant = fake + .calls() + .into_iter() + .find(|(id, p)| { + id == "sandbox::fs::write" + && p["path"] + .as_str() + .unwrap() + .starts_with("/opt/code-runner/fns/") + }) + .expect("source planted under /opt/code-runner/fns/"); + assert!(plant.1["path"].as_str().unwrap().ends_with(".mjs")); + assert_eq!( + plant.1["content"], + "export function handler(p) { return p; }" + ); + assert_eq!(fake.registered_ids(), vec!["app::greet".to_string()]); + assert_eq!( + fake.registered_descriptions(), + vec![("app::greet".to_string(), Some("echoes".to_string()))] + ); + } + + #[tokio::test] + async fn a_second_registration_in_the_same_namespace_and_lang_reuses_the_runtime() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + m.register(reg_req("app::b", Lang::Node)).await.unwrap(); + assert_eq!(creates(&fake), 1, "one namespace runtime, reused"); + assert_eq!(m.runtimes.lock().unwrap().len(), 1); + } + + /// A runtime is single-language, so one namespace registering both gets + /// one runtime per language rather than a refusal. + #[tokio::test] + async fn the_same_namespace_gets_a_separate_runtime_per_language() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + m.register(reg_req("app::b", Lang::Python)).await.unwrap(); + assert_eq!(creates(&fake), 2); + assert_eq!(m.runtimes.lock().unwrap().len(), 2); + } + + /// Two concurrent FIRST registrations in one namespace must produce + /// exactly ONE microVM. `create` is a network round trip, so the check + /// and the create have to be serialized across it — that is what + /// `namespace_create_lock` is for. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn two_concurrent_first_registrations_in_one_namespace_create_exactly_one_runtime() { + let fake = happy_fake(); + probe_free(&fake); + fake.with_responder("sandbox::create", |_| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(json!({ "sandbox_id": "sb-1" })) + }); + let m = RuntimeManager::new(cfg(), fake.clone()); + + let (m1, m2) = (m.clone(), m.clone()); + let (r1, r2) = tokio::join!( + tokio::spawn(async move { m1.register(reg_req("app::a", Lang::Node)).await }), + tokio::spawn(async move { m2.register(reg_req("app::b", Lang::Node)).await }), + ); + r1.expect("task 1 must not panic").expect("registers"); + r2.expect("task 2 must not panic").expect("registers"); + + assert_eq!( + creates(&fake), + 1, + "one namespace, one microVM — not one per concurrent registration" + ); + assert_eq!(m.runtimes.lock().unwrap().len(), 1); + } + + /// The full trigger path: the proxy execs the runner with the payload on + /// stdin and a per-call sentinel, and returns the JSON after the + /// sentinel. + #[tokio::test] + async fn a_registered_function_call_execs_the_runner_and_parses_the_result() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + fake.with_responder("sandbox::exec", |payload| { + let args = payload["args"].as_array().expect("argv array"); + assert_eq!(args.len(), 2, "the sentinel must NOT be in argv: {args:?}"); + assert_eq!(args[0], "/opt/code-runner/run.mjs"); + assert!(args[1] + .as_str() + .unwrap() + .starts_with("/opt/code-runner/fns/")); + let env = decode_envelope(payload); + let sentinel = env["sentinel"] + .as_str() + .expect("envelope carries the sentinel"); + let n = env["payload"]["n"] + .as_i64() + .expect("envelope carries the real payload"); + Ok(serde_json::json!({ + "stdout": format!("handler noise\n\n{sentinel}\n{{\"doubled\":{}}}\n", n * 2), + "stderr": "", "exit_code": 0, "timed_out": false, + "duration_ms": 3, "success": true + })) + }); + m.register(reg_req("app::double", Lang::Node)) + .await + .unwrap(); + + let result = fake + .invoke("app::double", serde_json::json!({ "n": 21 })) + .await + .expect("call succeeds"); + assert_eq!(result, serde_json::json!({ "doubled": 42 })); + + let sentinels: Vec = fake + .calls() + .iter() + .filter(|(id, p)| id == "sandbox::exec" && p.get("stdin").is_some()) + .map(|(_, p)| decode_envelope(p)["sentinel"].as_str().unwrap().to_string()) + .collect(); + let exec = fake + .calls() + .into_iter() + .rev() + .find(|(id, _)| id == "sandbox::exec") + .unwrap(); + assert_eq!( + exec.1["timeout_ms"], 5_000, + "registered calls run at default_timeout_ms" + ); + + fake.invoke("app::double", serde_json::json!({ "n": 1 })) + .await + .expect("second call succeeds"); + let after: Vec = fake + .calls() + .iter() + .filter(|(id, p)| id == "sandbox::exec" && p.get("stdin").is_some()) + .map(|(_, p)| decode_envelope(p)["sentinel"].as_str().unwrap().to_string()) + .collect(); + assert!(after.len() > sentinels.len(), "the second call executed"); + let unique: std::collections::HashSet<&String> = after.iter().collect(); + assert_eq!( + unique.len(), + after.len(), + "sentinels must be per-call: {after:?}" + ); + } + + /// Adversarial review, backend leak: `app::greet`'s caller never + /// supplied a `runtime_id` and never held one — unlike a direct + /// `code-runner::eval`/`teardown` call, where `error.rs`'s id-quoting + /// message is the documented exception. + #[tokio::test] + async fn a_proxy_invocation_never_leaks_the_runtime_id_to_its_caller() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::greet", Lang::Node)).await.unwrap(); + let rt = m + .namespaces + .lock() + .unwrap() + .get(&("app::".to_string(), Lang::Node)) + .cloned() + .expect("namespace runtime exists"); + + // The race `invoke_registered`'s own doc describes: the manager's + // record is gone (as `expire`/`teardown` leave it) but the bus + // unregistration has not landed yet, so the proxy is still + // reachable. + m.runtimes.lock().unwrap().remove(&rt); + + let err = fake + .invoke("app::greet", serde_json::json!({})) + .await + .unwrap_err(); + assert!(err.starts_with("code-runner::expired: "), "{err}"); + assert!( + !err.contains(rt.as_str()) && !err.contains("rt-"), + "the proxy handed the runtime_id to a caller who never held it: {err}" + ); + } + + #[tokio::test] + async fn a_throwing_handler_surfaces_as_handler_error() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + fake.with_responder("sandbox::exec", |payload| { + let env = decode_envelope(payload); + let sentinel = env["sentinel"].as_str().unwrap(); + Ok(serde_json::json!({ + "stdout": format!("\n{sentinel}\n{{\"error\":\"ValueError: boom-3\"}}\n"), + "stderr": "", "exit_code": 1, "timed_out": false, + "duration_ms": 3, "success": false + })) + }); + m.register(reg_req("app::boom", Lang::Node)).await.unwrap(); + let err = fake + .invoke("app::boom", serde_json::json!({})) + .await + .unwrap_err(); + assert!(err.contains("code-runner::handler_error"), "{err}"); + assert!(err.contains("boom-3"), "{err}"); + } + + #[tokio::test] + async fn a_crashed_runner_is_a_handler_error_naming_the_crash() { + let fake = happy_fake(); + probe_free(&fake); + fake.with_responder("sandbox::exec", |_| { + Ok(serde_json::json!({ + "stdout": "", "stderr": "Killed", "exit_code": 137, + "timed_out": false, "duration_ms": 3, "success": false + })) + }); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::crash", Lang::Node)).await.unwrap(); + let err = fake + .invoke("app::crash", serde_json::json!({})) + .await + .unwrap_err(); + assert!(err.contains("code-runner::handler_error"), "{err}"); + assert!(err.contains("Killed"), "stderr tail included: {err}"); + } + + #[tokio::test] + async fn a_taken_id_is_refused_before_anything_is_planted() { + let fake = happy_fake(); + fake.with_response( + "engine::functions::info", + Ok(serde_json::json!({ "function_id": "app::greet", "description": "exists" })), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .register(reg_req("app::greet", Lang::Node)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + assert!(err.to_string().contains("already registered"), "{err}"); + assert!(fake.registered_ids().is_empty()); + // A namespace runtime WAS created (it happens before the probe) but + // holds no functions — same shape as before this redesign, where a + // refused registration could still leave a bare, addressable + // runtime behind. + assert_eq!(creates(&fake), 1); + } + + /// Adversarial review, backend leak (mirrors + /// `a_proxy_invocation_never_leaks_the_runtime_id_to_its_caller`, on the + /// OTHER caller `error.rs`'s id-quoting exception was never meant to + /// cover): a `register_function` caller never supplies or receives a + /// `runtime_id` — the namespace runtime is resolved internally — so if + /// a concurrent teardown races the plant and `publish`'s post-plant + /// re-check hits `Expired`, that id must not reach the direct caller + /// either. Simulates the race deterministically: the SECOND + /// `sandbox::fs::write` (the source plant, inside `publish`, as + /// opposed to the runner plant inside `create`) clears `m.runtimes` as + /// a concurrent teardown would, right before `publish`'s re-check runs. + #[tokio::test] + async fn a_registration_racing_a_teardown_never_leaks_the_runtime_id_to_its_direct_caller() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + let m_clone = m.clone(); + fake.with_responder("sandbox::fs::write", move |payload| { + if payload["path"] + .as_str() + .unwrap_or("") + .starts_with("/opt/code-runner/fns/") + { + m_clone.runtimes.lock().unwrap().clear(); + } + Ok(json!({ "bytes_written": 1, "path": "p" })) + }); + let err = m + .register(reg_req("app::greet", Lang::Node)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::engine"); + assert!( + !err.to_string().contains("rt-"), + "the direct register_function caller was handed a runtime_id it never held: {err}" + ); + } + + /// An unverifiable id fails CLOSED: a FORBIDDEN probe answer must + /// refuse, not proceed on an unknown. + #[tokio::test] + async fn an_inconclusive_probe_fails_closed() { + let fake = happy_fake(); + fake.with_response( + "engine::functions::info", + Err("remote error: FORBIDDEN: rbac denies functions.info".into()), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .register(reg_req("app::greet", Lang::Node)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::engine"); + assert!(fake.registered_ids().is_empty()); + } + + #[tokio::test] + async fn a_probe_that_cannot_dispatch_itself_fails_closed_not_free() { + let fake = happy_fake(); + fake.with_response( + "engine::functions::info", + Err( + "remote error (function_not_found): Function engine::functions::info not found" + .into(), + ), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .register(reg_req("app::greet", Lang::Node)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::engine"); + assert!( + fake.registered_ids().is_empty(), + "an engine that cannot dispatch the probe must never be treated as \ + 'the id is free' — nothing should have been published" + ); + } + + #[tokio::test] + async fn the_first_id_claims_the_namespace_for_the_runtime() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + m.register(reg_req("app::b", Lang::Node)) + .await + .expect("same namespace ok"); + assert_eq!(creates(&fake), 1); + } + + #[tokio::test] + async fn malformed_function_ids_are_refused() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + for bad in [ + "noseparator", + "::x", + "app::", + "My-App::x", + "a..b::x", + ".hidden::x", + ] { + let err = m.register(reg_req(bad, Lang::Node)).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request", "{bad}"); + } + } + + #[tokio::test] + async fn teardown_unregisters_registered_functions() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + let out = m.teardown(td_by_ns("app")).await.unwrap(); + assert_eq!(out.unregistered, vec!["app::a".to_string()]); + assert!(fake.registered_ids().is_empty()); + assert_eq!(fake.unregister_count(), 1); + } + + /// The expiry path must also unregister — a bus function whose VM is + /// gone would otherwise error forever instead of disappearing. + #[tokio::test] + async fn expiry_unregisters_registered_functions() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + let rt = m + .namespaces + .lock() + .unwrap() + .get(&("app::".to_string(), Lang::Node)) + .cloned() + .unwrap(); + fake.with_response("sandbox::fs::write", Err(wrapped("S004", "reaped"))); + let _ = m.eval(eval_req("2", None, Some(rt))).await.unwrap_err(); + assert!(fake.registered_ids().is_empty(), "expiry must unregister"); + } + + /// `invoke_registered` -> `sandbox_call` -> `Gone` -> `expire()` -> + /// `(f.unregister)()` unregisters a function while ITS OWN handler + /// future is still executing. Safe only because the caller (mirrored + /// here by `FakeEngine::invoke`) clones the handler `Arc` out and drops + /// the registry lock BEFORE calling it. + #[tokio::test] + async fn a_call_that_discovers_its_own_runtime_is_gone_unregisters_itself_without_deadlock() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::self_destruct", Lang::Node)) + .await + .unwrap(); + + fake.with_response("sandbox::exec", Err(wrapped("S004", "reaped mid-call"))); + + let err = tokio::time::timeout( + std::time::Duration::from_secs(5), + fake.invoke("app::self_destruct", serde_json::json!({})), + ) + .await + .expect("must not deadlock") + .expect_err("the runtime is gone"); + assert!(err.contains("code-runner::expired"), "{err}"); + + assert!(fake.registered_ids().is_empty()); + assert_eq!(fake.unregister_count(), 1); + } + + #[tokio::test] + async fn a_torn_down_function_is_uncallable() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + m.teardown(td_by_ns("app")).await.unwrap(); + assert!(fake.invoke("app::a", serde_json::json!({})).await.is_err()); + } + + #[tokio::test] + async fn register_caps_are_enforced() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + + let mut req = reg_req("app::x", Lang::Node); + req.source = String::new(); + assert_eq!( + m.register(req).await.unwrap_err().code(), + "code-runner::invalid_request" + ); + + let mut req = reg_req("app::x", Lang::Node); + req.source = "x".repeat(MAX_SOURCE_BYTES + 1); + assert_eq!( + m.register(req).await.unwrap_err().code(), + "code-runner::invalid_request" + ); + + let long_id = format!("app::{}", "x".repeat(MAX_FUNCTION_ID_BYTES)); + assert_eq!( + m.register(reg_req(&long_id, Lang::Node)) + .await + .unwrap_err() + .code(), + "code-runner::invalid_request" + ); + + let mut req = reg_req("app::x", Lang::Node); + req.description = Some("d".repeat(MAX_DESCRIPTION_BYTES + 1)); + assert_eq!( + m.register(req).await.unwrap_err().code(), + "code-runner::invalid_request" + ); + } + + #[tokio::test] + async fn max_functions_per_runtime_is_enforced() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + + for i in 0..MAX_FUNCTIONS_PER_RUNTIME { + m.register(reg_req(&format!("app::f{i}"), Lang::Node)) + .await + .unwrap_or_else(|e| panic!("function {i} should register: {e}")); + } + assert_eq!(fake.registered_ids().len(), MAX_FUNCTIONS_PER_RUNTIME); + + let err = m + .register(reg_req("app::one_too_many", Lang::Node)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + assert!(err.to_string().contains("already holds"), "{err}"); + assert_eq!(fake.registered_ids().len(), MAX_FUNCTIONS_PER_RUNTIME); + } + + /// Two callers racing to register the SAME id must not both win. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_registrations_of_the_same_id_leave_exactly_one_winner() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + + let (m1, m2) = (m.clone(), m.clone()); + let (r1, r2) = tokio::join!( + tokio::spawn(async move { m1.register(reg_req("app::race", Lang::Node)).await }), + tokio::spawn(async move { m2.register(reg_req("app::race", Lang::Node)).await }), + ); + let results = [ + r1.expect("task 1 must not panic"), + r2.expect("task 2 must not panic"), + ]; + + let ok_count = results.iter().filter(|r| r.is_ok()).count(); + assert_eq!( + ok_count, 1, + "exactly one registration must win: {results:?}" + ); + let err = results + .iter() + .find_map(|r| r.as_ref().err()) + .expect("the loser gets a clean error, not a hang or a panic"); + assert_eq!(err.code(), "code-runner::invalid_request"); + assert!(err.to_string().contains("already registered"), "{err}"); + assert_eq!(fake.registered_ids(), vec!["app::race".to_string()]); + } + + #[tokio::test] + async fn a_reservation_is_released_when_the_probe_fails() { + let fake = happy_fake(); + fake.with_response( + "engine::functions::info", + Err("remote error: FORBIDDEN: rbac denies functions.info".into()), + ); + let m = RuntimeManager::new(cfg(), fake.clone()); + let err = m + .register(reg_req("app::greet", Lang::Node)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::engine"); + + probe_free(&fake); + m.register(reg_req("app::greet", Lang::Node)) + .await + .expect("the released claim can be reserved again"); + } + + /// Every path that drops a `RegisteredFn` must also drop its local + /// claim — otherwise a torn-down function's id is dead for the rest of + /// the process's life. + #[tokio::test] + async fn teardown_releases_the_claim_for_reuse() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + m.teardown(td_by_ns("app")).await.unwrap(); + m.register(reg_req("app::a", Lang::Node)) + .await + .expect("teardown released the claim, and a fresh namespace runtime was created"); + } + + /// `seed_static_ids` must make this worker's own ids unclaimable by a + /// caller from the moment it is called, and that protection must + /// survive teardown of unrelated namespaces. + #[tokio::test] + async fn seeded_static_ids_cannot_be_claimed_and_survive_unrelated_teardown() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.seed_static_ids(&["code-runner::eval"]); + + let before = fake.calls().len(); + let err = m + .register(reg_req("code-runner::eval", Lang::Node)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + assert!(err.to_string().contains("already registered"), "{err}"); + // Refused by the local claim, before any probe or runtime creation. + assert_eq!(fake.calls().len(), before); + + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + m.teardown(td_by_ns("app")).await.unwrap(); + let err = m + .register(reg_req("code-runner::eval", Lang::Node)) + .await + .unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request"); + } + + // --------------------------------------------------------------- + // teardown: by namespace. + // --------------------------------------------------------------- + + #[tokio::test] + async fn teardown_by_namespace_with_no_runtime_is_not_found() { + let m = RuntimeManager::new(cfg(), happy_fake()); + let err = m.teardown(td_by_ns("app")).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::runtime_not_found"); + } + + #[tokio::test] + async fn teardown_by_namespace_accepts_the_bare_and_double_colon_forms() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + let out = m.teardown(td_by_ns("app::")).await.unwrap(); + assert!(out.torn_down); + assert_eq!(out.namespace.as_deref(), Some("app::")); + } + + #[tokio::test] + async fn teardown_by_namespace_tears_down_every_language_and_aggregates_unregistered() { + let fake = happy_fake(); + probe_free(&fake); + let m = RuntimeManager::new(cfg(), fake.clone()); + m.register(reg_req("app::a", Lang::Node)).await.unwrap(); + m.register(reg_req("app::b", Lang::Python)).await.unwrap(); + assert_eq!(m.runtimes.lock().unwrap().len(), 2); + + let out = m.teardown(td_by_ns("app")).await.unwrap(); + assert!(out.torn_down); + assert_eq!(out.runtime_id, None); + let mut got = out.unregistered.clone(); + got.sort(); + assert_eq!(got, vec!["app::a".to_string(), "app::b".to_string()]); + assert!(m.runtimes.lock().unwrap().is_empty()); + assert!(fake.registered_ids().is_empty()); + + // Both stops happened, and the namespace is free to recreate. + assert_eq!( + fake.calls() + .iter() + .filter(|(id, _)| id == "sandbox::stop") + .count(), + 2 + ); + m.register(reg_req("app::a", Lang::Node)) + .await + .expect("the namespace can be reused after a full teardown"); + } + + #[tokio::test] + async fn a_malformed_teardown_namespace_is_an_invalid_request() { + let m = RuntimeManager::new(cfg(), happy_fake()); + for bad in ["", "My-App", "a..b", ".hidden", "has::colons"] { + let err = m.teardown(td_by_ns(bad)).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::invalid_request", "{bad}"); + } + } +} diff --git a/code-runner/src/manifest.rs b/code-runner/src/manifest.rs new file mode 100644 index 000000000..9b334450c --- /dev/null +++ b/code-runner/src/manifest.rs @@ -0,0 +1,53 @@ +use serde::Serialize; + +use crate::config::CodeRunnerConfig; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> ModuleManifest { + let d = CodeRunnerConfig::default(); + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: "Run Node.js and Python in iii-sandbox microVMs: eval code, register \ + bus functions whose handlers execute inside the VM, tear down." + .to_string(), + default_config: serde_json::json!({ + "default_timeout_ms": d.default_timeout_ms, + "max_timeout_ms": d.max_timeout_ms, + "idle_ttl_secs": d.idle_ttl_secs, + }), + supported_targets: vec![env!("TARGET").to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_roundtrip_has_required_fields() { + let json = serde_json::to_string_pretty(&build_manifest()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["name"], env!("CARGO_PKG_NAME")); + assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); + assert!(!parsed["description"].as_str().unwrap().is_empty()); + assert!(parsed["default_config"].is_object()); + assert!(!parsed["supported_targets"].as_array().unwrap().is_empty()); + } + + #[test] + fn default_config_mirrors_struct_defaults() { + let m = build_manifest(); + let d = CodeRunnerConfig::default(); + assert_eq!(m.default_config["default_timeout_ms"], d.default_timeout_ms); + assert_eq!(m.default_config["idle_ttl_secs"], d.idle_ttl_secs); + } +} diff --git a/code-runner/src/runner.rs b/code-runner/src/runner.rs new file mode 100644 index 000000000..e443f0729 --- /dev/null +++ b/code-runner/src/runner.rs @@ -0,0 +1,337 @@ +//! The language table and the runner protocol — how code-runner talks to a +//! process inside the guest. +//! +//! Per registered-function call, the manager execs the runtime's runner with +//! `argv = [source_path]` and, on stdin, a JSON envelope +//! `{"sentinel": "", "payload": }`. The runner reads and +//! parses that envelope BEFORE loading the handler's source, keeps the +//! sentinel in a variable local to its own entry-point function — never at +//! module scope, since Python always registers the running script as +//! `sys.modules['__main__']` and a module-level name would have been a +//! plain, guessably-named attribute on it — and calls `handler(payload)` +//! with only the payload. On completion it prints a line holding only the +//! sentinel followed by the JSON result. +//! +//! What the sentinel is FOR: framing the result in the runner's stdout so an +//! ordinary handler's own prints (its "logs") can never be mistaken for the +//! result. It is a fresh UUID minted per call, delivered out of band on +//! stdin, and consumed before any handler code runs. It is not reachable +//! through any AMBIENT channel a handler might touch for unrelated reasons — +//! argv, environment variables, a re-read of stdin, or a module-level +//! attribute — so an ordinary handler cannot produce or collide with it by +//! accident. +//! +//! What the sentinel is NOT: a security boundary, and no list of bypass +//! techniques would make it one. The handler runs inside the runner's own +//! process, so it can read anything that process can read and write +//! anything that process can write — this frame included, by reassigning +//! `process.stdout.write` / `sys.stdout.write` before its own code ever +//! runs. Nothing here defends against that, and nothing needs to: a handler +//! already determines its own return value — that is what a handler is — +//! so there is no boundary between "the handler" and "this call's result" +//! to defend. Isolation between runtimes, and between guest and host, is +//! the microVM's job, not the sentinel's. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Lang { + Node, + Python, +} + +impl Lang { + /// The iii-sandbox preset image name. + pub fn image(self) -> &'static str { + match self { + Self::Node => "node", + Self::Python => "python", + } + } + /// The interpreter binary inside that image. + pub fn interpreter(self) -> &'static str { + match self { + Self::Node => "node", + Self::Python => "python3", + } + } + pub fn ext(self) -> &'static str { + match self { + Self::Node => "mjs", + Self::Python => "py", + } + } + /// Where `create` plants this language's runner inside the guest. + pub fn runner_path(self) -> &'static str { + match self { + Self::Node => "/opt/code-runner/run.mjs", + Self::Python => "/opt/code-runner/run.py", + } + } + pub fn runner_source(self) -> &'static str { + match self { + Self::Node => RUN_MJS, + Self::Python => RUN_PY, + } + } +} + +/// One stdout emit point at the very end of the happy/error path, so a +/// partial write can never leave a sentinel with no result behind it. +/// `JSON.stringify` of a non-serializable value (a function) yields +/// `undefined`, caught explicitly; a circular value throws, caught by the +/// catch. `process.exitCode` instead of `process.exit()` so stdout flushes +/// before the process ends. A malformed envelope is a separate, earlier +/// failure mode: there is no sentinel yet to frame a reply with, so it goes +/// to stderr instead and stdout is never touched. +pub const RUN_MJS: &str = r#"// code-runner runner — planted at runtime creation. Do not edit in place. +// Protocol: argv = [source_path]; stdin = JSON envelope +// {"sentinel": "", "payload": }, consumed before the +// handler's source ever loads. Result = JSON printed after a line holding +// only the sentinel. Exit 0 = result, exit 1 = {"error": "..."}. A +// malformed/missing envelope has no sentinel to frame a reply with: it is +// reported on stderr and the process exits non-zero with no stdout at all. +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +async function main() { + const [source] = process.argv.slice(2); + const raw = readFileSync(0, 'utf8'); + let envelope = null; + try { + envelope = JSON.parse(raw); + } catch { + envelope = null; + } + if (envelope === null || typeof envelope !== 'object' || typeof envelope.sentinel !== 'string') { + process.stderr.write( + 'code-runner runner: malformed envelope on stdin (expected {"sentinel": "...", "payload": ...})\n' + ); + process.exitCode = 1; + return; + } + const { sentinel, payload } = envelope; + + let body; + let code; + try { + const mod = await import(pathToFileURL(source).href); + if (typeof mod.handler !== 'function') { + throw new TypeError("source must export a function named 'handler(payload)'"); + } + const out = await mod.handler(payload); + body = JSON.stringify(out === undefined ? null : out); + if (body === undefined) { + throw new TypeError('handler result is not JSON-serializable'); + } + code = 0; + } catch (e) { + body = JSON.stringify({ error: String((e && e.message) || e) }); + code = 1; + } + process.stdout.write('\n' + sentinel + '\n' + body + '\n'); + process.exitCode = code; +} + +await main(); +"#; + +/// Same single-emit shape as `RUN_MJS`, and now the same scoping shape too: +/// the envelope, `sentinel`, and `payload` all live inside `main()`, never +/// at module scope. Python always registers the running script as +/// `sys.modules['__main__']`, so a module-level `sentinel = ...` would have +/// been a plain attribute any handler could read off it by name — +/// `getattr(sys.modules['__main__'], 'sentinel', None)` — regardless of how +/// the handler itself was loaded. `main()` RETURNS its exit code instead of +/// calling `sys.exit()` itself, so `sys.exit(main())` at module scope is the +/// only exit call and it stays OUTSIDE every `try`: the malformed-envelope +/// path returns 1 before the result-framing `try` is ever entered, exactly +/// as `RUN_MJS`'s `return` does before its own inner `try`. +pub const RUN_PY: &str = r#"# code-runner runner — planted at runtime creation. Do not edit in place. +# Protocol: argv = [source_path]; stdin = JSON envelope +# {"sentinel": "", "payload": }, consumed before the +# handler's source ever loads. Result = JSON printed after a line holding +# only the sentinel. Exit 0 = result, exit 1 = {"error": "..."}. A +# malformed/missing envelope has no sentinel to frame a reply with: it is +# reported on stderr and the process exits non-zero with no stdout at all. +import importlib.util +import inspect +import json +import sys + + +def main(): + source = sys.argv[1] + + raw = sys.stdin.read() + try: + envelope = json.loads(raw) + except json.JSONDecodeError: + envelope = None + + if not isinstance(envelope, dict) or not isinstance(envelope.get("sentinel"), str): + sys.stderr.write( + 'code-runner runner: malformed envelope on stdin (expected {"sentinel": "...", "payload": ...})\n' + ) + return 1 + + sentinel = envelope["sentinel"] + payload = envelope.get("payload") + + def run(): + spec = importlib.util.spec_from_file_location("code_runner_handler", source) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + handler = getattr(mod, "handler", None) + if not callable(handler): + raise TypeError("source must define a function named 'handler(payload)'") + if inspect.iscoroutinefunction(handler): + raise TypeError( + "'async def handler' is not supported in v1; define a plain 'def handler(payload)'" + ) + return handler(payload) + + try: + body, code = json.dumps(run()), 0 + except BaseException as exc: + body, code = json.dumps({"error": f"{type(exc).__name__}: {exc}"}), 1 + + sys.stdout.write("\n" + sentinel + "\n" + body + "\n") + return code + + +sys.exit(main()) +"#; + +pub struct RunnerOutput { + /// Everything the handler printed before the sentinel — returned to the + /// caller as logs, never parsed. + pub logs: String, + /// The single line of JSON text immediately after the sentinel line. + /// `None` when the sentinel never appeared in stdout at all — which + /// covers two distinct causes indistinguishably: the interpreter + /// crashed (OOM-killed, bad shebang, segfault, …) before it could write + /// the frame, OR the handler itself called `process.exit()` / + /// `os._exit()` and the runner never reached its own final write. Either + /// way, there is no result to report. + pub result: Option, +} + +/// Split an exec's stdout at the sentinel LINE, taking only the FIRST LINE +/// after it as the result. The runner always writes exactly +/// `"\n" + sentinel + "\n" + body + "\n"`, where `body` comes from +/// `JSON.stringify` / `json.dumps` — both escape embedded newlines, so a +/// serialized result is always exactly one line. Anything on a LATER line +/// (a dangling `setTimeout` firing after the frame, a live non-daemon +/// thread that outlives the runner's own exit) is therefore, by +/// construction, not part of the result: it is dropped rather than +/// appended, which would otherwise corrupt the parse. The first occurrence +/// of the needle is the runner's own frame for ordinary handler output — +/// the sentinel is a per-call UUID delivered out of band and never handed +/// to the handler, so it can't collide by accident. A handler that +/// deliberately intercepts the runner's own write and emits a forged frame +/// first is a different matter this function has no way to detect; see the +/// module doc for why that isn't something the sentinel defends against. +pub fn split_sentinel(stdout: &str, sentinel: &str) -> RunnerOutput { + let needle = format!("\n{sentinel}\n"); + match stdout.find(&needle) { + Some(i) => { + let after = &stdout[i + needle.len()..]; + let result = after.split('\n').next().unwrap_or(""); + RunnerOutput { + logs: stdout[..i].to_string(), + result: Some(result.to_string()), + } + } + None => RunnerOutput { + logs: stdout.to_string(), + result: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lang_table_is_exact() { + assert_eq!(Lang::Node.image(), "node"); + assert_eq!(Lang::Python.image(), "python"); + assert_eq!(Lang::Node.interpreter(), "node"); + assert_eq!(Lang::Python.interpreter(), "python3"); + assert_eq!(Lang::Node.ext(), "mjs"); + assert_eq!(Lang::Python.ext(), "py"); + assert_eq!(Lang::Node.runner_path(), "/opt/code-runner/run.mjs"); + assert_eq!(Lang::Python.runner_path(), "/opt/code-runner/run.py"); + } + + #[test] + fn lang_serializes_lowercase() { + assert_eq!(serde_json::to_string(&Lang::Node).unwrap(), r#""node""#); + assert_eq!( + serde_json::from_str::(r#""python""#).unwrap(), + Lang::Python + ); + assert!(serde_json::from_str::(r#""ruby""#).is_err()); + } + + #[test] + fn split_finds_the_result_after_the_sentinel_line() { + let out = split_sentinel("noise\n\nSENT-1\n{\"a\":1}\n", "SENT-1"); + assert_eq!(out.logs, "noise\n"); + assert_eq!(out.result.as_deref(), Some("{\"a\":1}")); + } + + #[test] + fn split_with_no_prior_output_has_empty_logs() { + let out = split_sentinel("\nSENT-1\nnull\n", "SENT-1"); + assert_eq!(out.logs, ""); + assert_eq!(out.result.as_deref(), Some("null")); + } + + /// A crashed interpreter (OOM-killed, bad shebang, …) produces no + /// sentinel at all; everything is logs and there is no result. + #[test] + fn split_without_sentinel_returns_no_result() { + let out = split_sentinel("Segmentation fault\n", "SENT-1"); + assert_eq!(out.logs, "Segmentation fault\n"); + assert_eq!(out.result, None); + } + + /// A print that merely CONTAINS the sentinel text mid-line must not + /// match: the runner emits it as its own line, and that framing is what + /// the split keys on. + #[test] + fn split_requires_the_sentinel_on_its_own_line() { + let out = split_sentinel("prefix SENT-1 suffix\n\nSENT-1\n42\n", "SENT-1"); + assert_eq!(out.logs, "prefix SENT-1 suffix\n"); + assert_eq!(out.result.as_deref(), Some("42")); + } + + /// A handler that leaves dangling async work (an uncleared timer, a + /// live thread) can keep the process alive past the runner's final + /// write; that late output lands on lines AFTER the result and must not + /// be folded into it. + #[test] + fn split_takes_only_the_first_line_after_the_sentinel() { + let out = split_sentinel( + "\nSENT-1\n{\"a\":1}\nlate output from a dangling timer\n", + "SENT-1", + ); + assert_eq!(out.result.as_deref(), Some("{\"a\":1}")); + } + + /// A handler that calls `process.exit(0)` / `os._exit(0)` exits cleanly + /// but skips the runner's own final write — from `split_sentinel`'s + /// point of view this is the same "no sentinel found" shape as a crash, + /// down to the most literal case: no output at all. + #[test] + fn split_after_a_clean_self_exit_also_returns_no_result() { + let out = split_sentinel("", "SENT-1"); + assert_eq!(out.logs, ""); + assert_eq!(out.result, None); + } +} diff --git a/code-runner/src/ui.rs b/code-runner/src/ui.rs new file mode 100644 index 000000000..9fadd3b9c --- /dev/null +++ b/code-runner/src/ui.rs @@ -0,0 +1,119 @@ +//! Injectable console UI for the code-runner worker +//! (iii/tech-specs/2026-07-17-injectable-ui; authoring SOP: +//! workers/docs/sops/injectable-console-ui.md). +//! +//! Ships two assets into any running console: +//! +//! - `code-runner/page.js` (`console:script`) — the function-trigger +//! renderers its `setup(host)` registers, so `code-runner::eval`, +//! `register_function` and `teardown` render as purpose-built cards in chat +//! and traces instead of raw JSON. +//! - `code-runner/styles.css` (`console:style`) — the stylesheet, every rule +//! scoped under `[data-iii-ui="code-runner"]`; the console mounts it as a +//! `` and link-swaps it on change, styles-before-scripts on boot. +//! +//! The registration machinery (content function `code-runner::ui-content`, +//! one Message-path trigger per asset, `III_CODE_RUNNER_UI_WATCH` hot-reload +//! watcher) lives in the shared `iii-console-ui` crate (path-linked from +//! `workers/crates/console-ui`); this module only names the assets and embeds +//! their bytes. +//! +//! The assets are compiled from `ui/` by esbuild (react + @iii-dev/console-ui +//! external — they resolve through the console's import map at runtime) and +//! embedded at compile time so the worker stays one self-contained binary. +//! For the dev loop, set `III_CODE_RUNNER_UI_WATCH` to the build output +//! directory (or `1` for `ui/dist`): the worker polls both files and +//! re-registers a changed asset's trigger — every open console tab hot-swaps +//! it. + +use std::sync::Arc; + +use iii_console_ui::ConsoleUi; +use iii_sdk::IIIClient; + +pub const PAGE_PATH: &str = "code-runner/page.js"; +pub const STYLES_PATH: &str = "code-runner/styles.css"; + +/// Built by `build.rs` (esbuild over `ui/`). +const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); +const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); + +fn console_ui() -> ConsoleUi { + ConsoleUi::new("code-runner") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +/// Register the code-runner worker's console UI. Call after +/// `functions::register_all`. +pub fn register(iii: &Arc) { + console_ui().register(iii); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + // The builder panics on any path/kind the console would reject. + let _ = console_ui(); + } + + #[test] + fn embedded_page_is_nonempty_esm() { + assert!(PAGE_JS.contains("export"), "built page.js looks wrong"); + } + + /// One renderer per user-facing op, all reachable from `setup(host)`. + /// Dropping one from `ui/src/function-trigger-message/index.tsx` silently + /// falls that op back to the console's raw-JSON card; this catches it. + #[test] + fn every_rendered_op_is_wired_in() { + for op in ["eval", "register_function", "teardown"] { + assert!( + PAGE_JS.contains(&format!("code-runner::{op}")), + "no renderer for code-runner::{op} in the built page.js" + ); + } + } + + /// `inject-guidance` is a harness-internal `pre_generate` hook, not a call + /// anyone makes; rendering it would put a card in front of a mechanism. + #[test] + fn the_guidance_hook_is_never_rendered() { + assert!( + !PAGE_JS.contains(crate::functions::inject_guidance::GUIDANCE_HOOK_ID), + "the harness-internal guidance hook must not have a renderer" + ); + } + + /// A bundled React copy surfaces at runtime as a cryptic "Invalid hook + /// call" — the shared specifiers have to stay bare imports resolved by the + /// console's import map. + #[test] + fn react_stays_external() { + for internal in ["__SECRET_INTERNALS", "ReactCurrentDispatcher"] { + assert!( + !PAGE_JS.contains(internal), + "react appears to be bundled into page.js ({internal} found)" + ); + } + // Every card uses hooks, so react must be in there — as a bare import + // the console's import map resolves, never as bundled source. + assert!( + PAGE_JS.contains(r#"from "react"#), + "react should be imported, not bundled" + ); + } + + #[test] + fn embedded_styles_are_scoped() { + // esbuild prints the attribute selector unquoted ([data-iii-ui=code-runner]). + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="code-runner"]"#) + || STYLES_CSS.contains("[data-iii-ui=code-runner]"), + "built styles.css must be scoped under the worker's data-iii-ui attribute" + ); + } +} diff --git a/code-runner/tests/golden/runners/run.mjs b/code-runner/tests/golden/runners/run.mjs new file mode 100644 index 000000000..3b0ac81d7 --- /dev/null +++ b/code-runner/tests/golden/runners/run.mjs @@ -0,0 +1,50 @@ +// code-runner runner — planted at runtime creation. Do not edit in place. +// Protocol: argv = [source_path]; stdin = JSON envelope +// {"sentinel": "", "payload": }, consumed before the +// handler's source ever loads. Result = JSON printed after a line holding +// only the sentinel. Exit 0 = result, exit 1 = {"error": "..."}. A +// malformed/missing envelope has no sentinel to frame a reply with: it is +// reported on stderr and the process exits non-zero with no stdout at all. +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +async function main() { + const [source] = process.argv.slice(2); + const raw = readFileSync(0, 'utf8'); + let envelope = null; + try { + envelope = JSON.parse(raw); + } catch { + envelope = null; + } + if (envelope === null || typeof envelope !== 'object' || typeof envelope.sentinel !== 'string') { + process.stderr.write( + 'code-runner runner: malformed envelope on stdin (expected {"sentinel": "...", "payload": ...})\n' + ); + process.exitCode = 1; + return; + } + const { sentinel, payload } = envelope; + + let body; + let code; + try { + const mod = await import(pathToFileURL(source).href); + if (typeof mod.handler !== 'function') { + throw new TypeError("source must export a function named 'handler(payload)'"); + } + const out = await mod.handler(payload); + body = JSON.stringify(out === undefined ? null : out); + if (body === undefined) { + throw new TypeError('handler result is not JSON-serializable'); + } + code = 0; + } catch (e) { + body = JSON.stringify({ error: String((e && e.message) || e) }); + code = 1; + } + process.stdout.write('\n' + sentinel + '\n' + body + '\n'); + process.exitCode = code; +} + +await main(); diff --git a/code-runner/tests/golden/runners/run.py b/code-runner/tests/golden/runners/run.py new file mode 100644 index 000000000..c7bea2f6a --- /dev/null +++ b/code-runner/tests/golden/runners/run.py @@ -0,0 +1,54 @@ +# code-runner runner — planted at runtime creation. Do not edit in place. +# Protocol: argv = [source_path]; stdin = JSON envelope +# {"sentinel": "", "payload": }, consumed before the +# handler's source ever loads. Result = JSON printed after a line holding +# only the sentinel. Exit 0 = result, exit 1 = {"error": "..."}. A +# malformed/missing envelope has no sentinel to frame a reply with: it is +# reported on stderr and the process exits non-zero with no stdout at all. +import importlib.util +import inspect +import json +import sys + + +def main(): + source = sys.argv[1] + + raw = sys.stdin.read() + try: + envelope = json.loads(raw) + except json.JSONDecodeError: + envelope = None + + if not isinstance(envelope, dict) or not isinstance(envelope.get("sentinel"), str): + sys.stderr.write( + 'code-runner runner: malformed envelope on stdin (expected {"sentinel": "...", "payload": ...})\n' + ) + return 1 + + sentinel = envelope["sentinel"] + payload = envelope.get("payload") + + def run(): + spec = importlib.util.spec_from_file_location("code_runner_handler", source) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + handler = getattr(mod, "handler", None) + if not callable(handler): + raise TypeError("source must define a function named 'handler(payload)'") + if inspect.iscoroutinefunction(handler): + raise TypeError( + "'async def handler' is not supported in v1; define a plain 'def handler(payload)'" + ) + return handler(payload) + + try: + body, code = json.dumps(run()), 0 + except BaseException as exc: + body, code = json.dumps({"error": f"{type(exc).__name__}: {exc}"}), 1 + + sys.stdout.write("\n" + sentinel + "\n" + body + "\n") + return code + + +sys.exit(main()) diff --git a/code-runner/tests/golden/schemas/code-runner.eval.json b/code-runner/tests/golden/schemas/code-runner.eval.json new file mode 100644 index 000000000..438ad7e4f --- /dev/null +++ b/code-runner/tests/golden/schemas/code-runner.eval.json @@ -0,0 +1,106 @@ +{ + "description": "Run code in an isolated microVM. Pass lang (\"node\" or \"python\"). By default eval is ONE-SHOT: it boots a fresh VM, runs code, returns the result, and destroys the VM — nothing persists, no files, no installed packages, and the response carries no runtime_id (there is nothing left to address). Pass keep: true to leave the VM running instead: the response's runtime_id then addresses it, and is the capability code-runner::teardown needs to stop it later. Pass runtime_id on a later call to reuse that same VM (same filesystem, fresh interpreter process each time) — that runtime is never auto-stopped, you own it until you tear it down or its idle TTL reaps it, and a reaped reuse fails with code-runner::expired (retry without runtime_id to boot a fresh one). network: true asks for outbound network so npm/pip installs work, but only a runtime you already created with network can honor it (pass its runtime_id) — neither a one-shot eval nor keep: true can create a networked VM, so network: true without an existing runtime_id is refused, not silently ignored. stdout, stderr and exit_code come back verbatim — a failing script is a response, not an error.", + "function_id": "code-runner::eval", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Lang": { + "enum": [ + "node", + "python" + ], + "type": "string" + } + }, + "properties": { + "code": { + "description": "Source run as a whole file by a fresh interpreter process. Variables do NOT survive between evals; whether files and installed packages do depends on the path below.", + "type": "string" + }, + "keep": { + "default": false, + "description": "Only meaningful when `runtime_id` is omitted. `false` (the default): one-shot — boot a VM, run `code`, return the result, destroy the VM. Nothing persists: no files, no installed packages. `true`: boot a VM and leave it running; the response's `runtime_id` addresses it for later evals (pass it back to keep working in the same filesystem) and is the capability `code-runner::teardown` needs to stop it.", + "type": "boolean" + }, + "lang": { + "anyOf": [ + { + "$ref": "#/definitions/Lang" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Required when `runtime_id` is omitted — picks the sandbox image (\"node\" or \"python\"). On an existing runtime: omit it, or pass the runtime's own language; languages cannot be mixed in one runtime." + }, + "network": { + "default": false, + "description": "Give the guest outbound network so `npm install` / `pip install` work. Create-time only, so it is meaningful only when `runtime_id` is omitted — and even then, only a caller-supplied `runtime_id`'s own creation could ever have asked for it: neither a one-shot eval nor `keep: true` can request network (both run through `sandbox::run`, which has no way to enable it), so `network: true` without a `runtime_id` is refused rather than silently ignored. Ignored (not refused) when `runtime_id` is set: that runtime's network was fixed when it was created.", + "type": "boolean" + }, + "runtime_id": { + "default": null, + "description": "Evaluate in a SPECIFIC runtime, sharing its filesystem: the write and the run land in that VM, and it is NOT stopped afterwards — you own it. Omit this to run one-shot (see `keep`).", + "type": [ + "string", + "null" + ] + }, + "timeout_ms": { + "default": null, + "description": "Wall-clock budget in milliseconds, clamped to the configured maximum.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "code" + ], + "title": "EvalRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "duration_ms": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "exit_code": { + "format": "int64", + "type": "integer" + }, + "runtime_id": { + "description": "Present when this eval addresses a runtime that outlives the call: the `runtime_id` you passed in, or — when you passed `keep: true` with no `runtime_id` — the one just minted for the VM this call left running. `None` on the default one-shot path: the VM is already gone by the time this response is sent, so there is nothing to address. Treat a present value as a secret: it is the capability to eval into or tear down that runtime.", + "type": [ + "string", + "null" + ] + }, + "stderr": { + "type": "string" + }, + "stdout": { + "type": "string" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "duration_ms", + "exit_code", + "stderr", + "stdout", + "success" + ], + "title": "EvalResponse", + "type": "object" + } +} diff --git a/code-runner/tests/golden/schemas/code-runner.inject-guidance.json b/code-runner/tests/golden/schemas/code-runner.inject-guidance.json new file mode 100644 index 000000000..ff0d26c75 --- /dev/null +++ b/code-runner/tests/golden/schemas/code-runner.inject-guidance.json @@ -0,0 +1,56 @@ +{ + "description": "Internal pre_generate hook: appends code-runner usage guidance to the agent system prompt. Bound to harness::hook::pre-generate at worker startup; not called directly.", + "function_id": "code-runner::inject-guidance", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "GenerateContext": { + "properties": { + "system_prompt": { + "default": "", + "description": "The system prompt assembled so far (base + any prior hook's mutation).", + "type": "string" + } + }, + "type": "object" + } + }, + "description": "The slice of the `pre_generate` hook envelope we read (lenient: ignores every other field the harness sends). The harness nests the live generation context under `generate`.", + "properties": { + "generate": { + "$ref": "#/definitions/GenerateContext" + } + }, + "title": "PreGenerateEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PreGenerateMutations": { + "description": "The harness applies `system_prompt` only when the key is present, so `None` serializes to an empty object: the safe no-op that preserves the harness's assembled prompt.", + "properties": { + "system_prompt": { + "description": "Full replacement system prompt (base + appended guidance). The harness overwrites, it does not merge.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "description": "Hook envelope returned to the harness: the mutations to apply to the generation.", + "properties": { + "mutations": { + "$ref": "#/definitions/PreGenerateMutations" + } + }, + "required": [ + "mutations" + ], + "title": "PreGenerateResponse", + "type": "object" + } +} diff --git a/code-runner/tests/golden/schemas/code-runner.register_function.json b/code-runner/tests/golden/schemas/code-runner.register_function.json new file mode 100644 index 000000000..ac83a82a7 --- /dev/null +++ b/code-runner/tests/golden/schemas/code-runner.register_function.json @@ -0,0 +1,66 @@ +{ + "description": "Publish a bus function whose handler executes inside a microVM. No runtime_id needed: code-runner keeps one persistent runtime per namespace (the segment of function_id before `::`) and language — the first registration in a namespace boots it, later ones in the same namespace and lang reuse it automatically. `source` must DEFINE handler(payload) in `lang` — `export function handler(payload) {...}` (node) or `def handler(payload): ...` (python); each call runs it in a fresh interpreter process with the trigger payload and returns its JSON-serialized result. The first registered id in a namespace claims it; later ids must share both the namespace and its lang. `description` is what engine::functions::info shows a caller — write one. Functions stop resolving when their namespace is torn down (code-runner::teardown namespace=...) or its runtime is reaped for idleness.", + "function_id": "code-runner::register_function", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Lang": { + "enum": [ + "node", + "python" + ], + "type": "string" + } + }, + "properties": { + "description": { + "default": null, + "description": "What engine::functions::info shows a caller — write one.", + "type": [ + "string", + "null" + ] + }, + "function_id": { + "description": "e.g. \"my-app::greet\". The first registration in a namespace (the segment before `::`) claims it; later ids must share it. code-runner keeps ONE persistent runtime per (namespace, lang) — the first registration creates it, later ones reuse it — as an implementation detail you never see or manage.", + "type": "string" + }, + "lang": { + "allOf": [ + { + "$ref": "#/definitions/Lang" + } + ], + "description": "Which runner backs this namespace: \"node\" or \"python\". A namespace's language is fixed by its first registration; a later id under the same namespace but a different lang is refused." + }, + "source": { + "description": "Source that DEFINES `handler(payload)` in `lang`: `export function handler(payload) {…}` (node) or `def handler(payload): …` (python). The runner loads the file, calls `handler`, and JSON-serializes the return value.", + "type": "string" + } + }, + "required": [ + "function_id", + "lang", + "source" + ], + "title": "RegisterRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "function_id": { + "type": "string" + }, + "registered": { + "type": "boolean" + } + }, + "required": [ + "function_id", + "registered" + ], + "title": "RegisterResponse", + "type": "object" + } +} diff --git a/code-runner/tests/golden/schemas/code-runner.teardown.json b/code-runner/tests/golden/schemas/code-runner.teardown.json new file mode 100644 index 000000000..47943c3d1 --- /dev/null +++ b/code-runner/tests/golden/schemas/code-runner.teardown.json @@ -0,0 +1,61 @@ +{ + "description": "Destroy a runtime: unregister every bus function it registered, stop its microVM(s), and free the slot(s). Pass exactly one of runtime_id (a kept eval's runtime, from code-runner::eval keep=true) or namespace (a register_function namespace, e.g. \"app\" for ids like app::greet) — never both, never neither.", + "function_id": "code-runner::teardown", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Exactly one of `runtime_id` (a runtime you got back from `code-runner::eval keep=true`) or `namespace` (a `register_function` namespace, e.g. \"app\" for ids like `app::greet`) must be set — never both, never neither.", + "properties": { + "namespace": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "runtime_id": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "title": "TeardownRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "namespace": { + "description": "Set when this teardown was addressed by `namespace` — echoes the namespace, since more than one runtime (one per language) can back it and there is no single `runtime_id` to report.", + "type": [ + "string", + "null" + ] + }, + "runtime_id": { + "description": "Set when this teardown was addressed by `runtime_id` (never present alongside `namespace`).", + "type": [ + "string", + "null" + ] + }, + "torn_down": { + "type": "boolean" + }, + "unregistered": { + "description": "Bus function ids this teardown unregistered, across every runtime it destroyed.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "torn_down", + "unregistered" + ], + "title": "TeardownResponse", + "type": "object" + } +} diff --git a/code-runner/tests/integration.rs b/code-runner/tests/integration.rs new file mode 100644 index 000000000..150bb5a36 --- /dev/null +++ b/code-runner/tests/integration.rs @@ -0,0 +1,464 @@ +//! End-to-end: engine → code-runner → iii-sandbox → microVM → runner. +//! +//! GATED: set CODE_RUNNER_E2E=1 to run (needs /dev/kvm, network for the +//! first image pull, and an engine binary — same convention as +//! node-engine's integration test). Skips silently otherwise so +//! `cargo test` is green on machines without virtualization. + +use std::time::Duration; + +fn gated() -> bool { + if std::env::var("CODE_RUNNER_E2E").as_deref() == Ok("1") { + return false; + } + eprintln!("SKIPPED: set CODE_RUNNER_E2E=1 (and III_BIN) to run the e2e test"); + true +} + +/// Bind :0 to have the OS pick a genuinely free port, then release it. +fn pick_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind :0") + .local_addr() + .unwrap() + .port() +} + +fn wait_for_listen(port: u16, deadline: Duration) { + let start = std::time::Instant::now(); + while start.elapsed() < deadline { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("nothing listening on port {port} after {deadline:?}"); +} + +/// Spawn a local `iii` engine the way `node-engine/tests/integration.rs` +/// does (Task 9 Step 1): resolve the binary, write a `workers:` config with +/// an `iii-worker-manager` block bound to `port`, and start it with +/// `-c --no-update-check`. +/// +/// Two differences from that recipe, both required by this task: +/// +/// - Binary resolution also honors `III_BIN` (checked first, falling back +/// to `which::which("iii")` — node-engine's actual lookup). node-engine's +/// committed test has no `III_BIN` support at all; it is PATH-only. This +/// test adds the env override because Step 3's verification command +/// (`III_BIN= cargo test ...`) requires it. +/// - The config additionally carries an `iii-sandbox` block. Confirmed +/// against `iii-worker/src/sandbox_daemon/README.md`'s "Sample +/// Configuration", `docs/creating-workers/sandboxes.mdx`, and — decisively +/// — the commented-out block already living under `workers:` in the real +/// engine's own `engine/config.yaml` and the `workers:` list in +/// `sdk/fixtures/config-test.yaml`: the block is a `- name: iii-sandbox` +/// entry under the same top-level `workers:` key as `iii-worker-manager`, +/// not a separate top-level key. +fn spawn_engine(port: u16, home: &std::path::Path) -> std::process::Child { + let iii_bin = std::env::var_os("III_BIN") + .map(std::path::PathBuf::from) + .or_else(|| which::which("iii").ok()) + .expect( + "III_BIN must point at an iii engine binary, or `iii` must be on PATH \ + (same convention as node-engine/tests/integration.rs)", + ); + + let cfg_path = home.join("config.yaml"); + let cfg_body = format!( + "workers:\n - name: iii-worker-manager\n config:\n host: 127.0.0.1\n port: {port}\n\n - name: iii-sandbox\n config:\n auto_install: true\n image_allowlist:\n - python\n - node\n" + ); + std::fs::write(&cfg_path, &cfg_body).expect("write engine config.yaml"); + + std::process::Command::new(&iii_bin) + .arg("-c") + .arg(&cfg_path) + .arg("--no-update-check") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn iii engine binary") +} + +/// Kills whatever processes have been handed to it and removes the scratch +/// home on drop — including when a panic unwinds through this scope, not +/// just on a normal return. +/// +/// `wait_for_listen` and the `code-runner` spawn below both run BEFORE the +/// `catch_unwind` block, so a panic there (e.g. the engine hanging mid-boot) +/// would otherwise skip straight past the manual cleanup at the bottom of +/// this function. `std::process::Child` does not kill its process on drop, +/// so that window would leak the already-spawned engine (and the scratch +/// home) on exactly the failure path a developer most needs clean state on. +/// Mirrors `KillOnDrop` in `node-engine/tests/integration.rs`. +struct Cleanup { + engine: Option, + worker: Option, + home: Option, +} + +impl Drop for Cleanup { + fn drop(&mut self) { + for c in [&mut self.engine, &mut self.worker].into_iter().flatten() { + let _ = c.kill(); + let _ = c.wait(); + } + if let Some(home) = &self.home { + let _ = std::fs::remove_dir_all(home); + } + } +} + +/// Every LIVE (never-stopped) sandbox_id the daemon currently knows about. +/// `sandbox::stop` only marks a registry entry `stopped: true` — it does not +/// remove it (`SandboxRegistry::remove` is a separate, reaper-driven path) — +/// so a raw id-set comparison across `sandbox::list` calls would see a +/// STOPPED sandbox as still "there" and falsely fail a leak check. Filtering +/// to `stopped == false` is what actually answers "is a VM still running and +/// holding a daemon slot", which is the thing a leak means. +async fn live_sandbox_ids(iii: &iii_sdk::IIIClient) -> std::collections::HashSet { + let resp = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "sandbox::list".into(), + payload: serde_json::json!({}), + action: None, + timeout_ms: Some(10_000), + }) + .await + .expect("sandbox::list"); + resp["sandboxes"] + .as_array() + .expect("sandboxes is an array") + .iter() + .filter(|s| s["stopped"] == false) + .filter_map(|s| s["sandbox_id"].as_str().map(str::to_string)) + .collect() +} + +/// Proves the redesigned execution model against REAL VMs — the one thing no +/// unit test (which only ever hands the manager a `FakeEngine`) can: +/// +/// - a one-shot eval returns a result and leaves NO live sandbox behind +/// (checked via `sandbox::list`, not just the response shape); +/// - `keep: true` DOES leave exactly one live sandbox, and its `runtime_id` +/// addresses that same VM (same filesystem) for a later eval; +/// - `network: true` with no `runtime_id` is refused over the real bus, not +/// silently dropped; +/// - `register_function` with NO `runtime_id` works, and the function it +/// publishes answers real calls on the real bus; +/// - `teardown` by `namespace` unregisters it and stops its runtime. +async fn one_shot_keep_and_namespace_over_the_real_chain(iii: &iii_sdk::IIIClient) { + let eval = |payload: serde_json::Value| iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::eval".into(), + payload, + action: None, + timeout_ms: Some(35_000), + }; + + let before = live_sandbox_ids(iii).await; + + // One-shot (default): a result, no runtime_id, and no live sandbox left. + let ephemeral = iii + .trigger(eval( + serde_json::json!({ "lang": "python", "code": "print(1+1)" }), + )) + .await + .expect("one-shot eval"); + assert_eq!(ephemeral["stdout"], "2\n", "{ephemeral}"); + assert_eq!(ephemeral["exit_code"], 0); + assert!( + ephemeral.get("runtime_id").is_none(), + "a one-shot eval's response must carry no runtime_id: {ephemeral}" + ); + let after_ephemeral = live_sandbox_ids(iii).await; + assert_eq!( + after_ephemeral, before, + "a one-shot eval left a live sandbox behind — before {before:?}, after {after_ephemeral:?}" + ); + + // `keep: true`: DOES leave exactly one live sandbox, addressable by the + // minted runtime_id. + let kept = iii + .trigger(eval(serde_json::json!({ + "lang": "python", + "code": "open('/tmp/kept-probe','w').write('kept')", + "keep": true, + }))) + .await + .expect("kept eval"); + assert_eq!(kept["exit_code"], 0, "{kept}"); + let runtime_id = kept["runtime_id"] + .as_str() + .expect("keep: true mints a runtime_id") + .to_string(); + let after_keep = live_sandbox_ids(iii).await; + assert_eq!( + after_keep.len(), + before.len() + 1, + "keep: true must leave exactly one new live sandbox — before {before:?}, after {after_keep:?}" + ); + + // The minted runtime_id really does address that same VM: its + // filesystem is still there. + let reused = iii + .trigger(eval(serde_json::json!({ + "runtime_id": runtime_id, + "code": "print(open('/tmp/kept-probe').read())", + }))) + .await + .expect("reuse of the kept runtime"); + assert_eq!( + reused["stdout"], "kept\n", + "the kept runtime's filesystem must persist across evals: {reused}" + ); + assert_eq!(reused["runtime_id"], runtime_id); + + // network: true with no runtime_id is refused over the real bus — + // sandbox::run genuinely has no way to honor it. + let refused = iii + .trigger(eval( + serde_json::json!({ "lang": "python", "code": "1", "network": true }), + )) + .await; + assert!( + refused.is_err(), + "network: true with no runtime_id must be refused over the real bus, not silently \ + ignored: {refused:?}" + ); + + // register_function with NO runtime_id: code-runner resolves its own + // namespace runtime, and the function answers real calls on the bus. + let reg = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::register_function".into(), + payload: serde_json::json!({ + "function_id": "ce-e2e-ns::double", + "lang": "python", + "source": "def handler(p):\n return {'doubled': p['n'] * 2}\n", + "description": "e2e namespace probe", + }), + action: None, + timeout_ms: Some(35_000), + }) + .await + .expect("register_function with no runtime_id"); + assert_eq!(reg["registered"], true, "{reg}"); + + let call = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "ce-e2e-ns::double".into(), + payload: serde_json::json!({ "n": 21 }), + action: None, + timeout_ms: Some(35_000), + }) + .await + .expect("the namespace-registered function answers"); + assert_eq!(call["doubled"], 42, "{call}"); + + // teardown by namespace unregisters it and stops its runtime. + let td = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::teardown".into(), + payload: serde_json::json!({ "namespace": "ce-e2e-ns" }), + action: None, + timeout_ms: Some(35_000), + }) + .await + .expect("teardown by namespace"); + assert_eq!(td["torn_down"], true, "{td}"); + assert_eq!(td["namespace"], "ce-e2e-ns::"); + assert_eq!(td["unregistered"][0], "ce-e2e-ns::double"); + + let dead = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "ce-e2e-ns::double".into(), + payload: serde_json::json!({ "n": 1 }), + action: None, + timeout_ms: Some(5_000), + }) + .await; + assert!( + dead.is_err(), + "ce-e2e-ns::double should be unregistered after teardown, but it answered: {dead:?}" + ); + + // Clean up the kept runtime, and confirm the live-sandbox count returns + // exactly to baseline — no leaks anywhere across this whole sequence. + iii.trigger(iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::teardown".into(), + payload: serde_json::json!({ "runtime_id": runtime_id }), + action: None, + timeout_ms: Some(35_000), + }) + .await + .expect("teardown the kept runtime"); + + let after_all = live_sandbox_ids(iii).await; + assert_eq!( + after_all, before, + "live sandboxes must return exactly to baseline after cleanup — before {before:?}, \ + after {after_all:?}" + ); +} + +#[test] +fn full_loop_eval_register_trigger_teardown() { + if gated() { + return; + } + let port = pick_port(); + let home = std::env::temp_dir().join(format!("ce-e2e-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&home).unwrap(); + let mut cleanup = Cleanup { + engine: None, + worker: None, + home: Some(home.clone()), + }; + + cleanup.engine = Some(spawn_engine(port, &home)); + wait_for_listen(port, Duration::from_secs(30)); + + cleanup.worker = Some( + std::process::Command::new(env!("CARGO_BIN_EXE_code-runner")) + .arg("--url") + .arg(format!("ws://127.0.0.1:{port}")) + .arg("--config") + .arg("/nonexistent-use-defaults.yaml") + .spawn() + .expect("code-runner starts"), + ); + + let result = std::panic::catch_unwind(|| { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let iii = iii_sdk::register_worker( + &format!("ws://127.0.0.1:{port}"), + iii_sdk::InitOptions::default(), + ); + + // Poll until code-runner::eval resolves (worker connect is async). + // Every pre-success error is treated as "not booted yet" and + // retried — correct for the function-resolution race, but it + // means a genuine failure looks identical to "not ready" until + // the loop expires. Keep the last error around so a real + // failure is diagnosable instead of surfacing as a bare + // `booted == false`. + let mut booted = false; + let mut last_err: Option = None; + for attempt in 0..50 { + // `keep: true` here (not the one-shot default): this probe + // both proves the worker is up AND needs a runtime_id back + // to exercise the reuse + explicit-teardown paths below. + // First call pulls the image — give it the full window. + let out = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::eval".into(), + payload: serde_json::json!({ + "lang": "python", "code": "print(6*7)", "keep": true, + }), + action: None, + timeout_ms: Some(300_000), + }) + .await; + match out { + Ok(v) => { + assert_eq!(v["stdout"], "42\n", "eval output: {v}"); + assert_eq!(v["exit_code"], 0); + let runtime_id = v["runtime_id"] + .as_str() + .expect("keep: true mints a runtime_id") + .to_string(); + + // Filesystem persists between evals in one runtime. + let w = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::eval".into(), + payload: serde_json::json!({ + "runtime_id": runtime_id, + "code": "open('/tmp/probe','w').write('kept')", + }), + action: None, + timeout_ms: Some(35_000), + }) + .await + .expect("second eval"); + assert_eq!(w["exit_code"], 0); + let r = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::eval".into(), + payload: serde_json::json!({ + "runtime_id": runtime_id, + "code": "print(open('/tmp/probe').read())", + }), + action: None, + timeout_ms: Some(35_000), + }) + .await + .expect("third eval"); + assert_eq!(r["stdout"], "kept\n"); + assert_eq!(r["runtime_id"], runtime_id); + + let td = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::teardown".into(), + payload: serde_json::json!({ "runtime_id": runtime_id }), + action: None, + timeout_ms: Some(35_000), + }) + .await + .expect("teardown succeeds"); + assert_eq!(td["torn_down"], true); + assert_eq!(td["runtime_id"], runtime_id); + assert!(td["unregistered"].as_array().unwrap().is_empty()); + + // `td` is teardown describing its own behavior — + // prove it against the bus instead of trusting the + // self-report: the runtime it lived on must + // actually be gone. Short timeout so a hung call + // (which would mean teardown didn't really finish) + // fails fast instead of stalling the suite. + let dead_eval = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "code-runner::eval".into(), + payload: serde_json::json!({ + "runtime_id": runtime_id, + "code": "1", + }), + action: None, + timeout_ms: Some(5_000), + }) + .await; + assert!( + dead_eval.is_err(), + "eval against torn-down runtime_id {runtime_id} should fail, \ + but got: {dead_eval:?}" + ); + + one_shot_keep_and_namespace_over_the_real_chain(&iii).await; + + booted = true; + break; + } + Err(e) => { + let msg = format!("attempt {attempt}: {e}"); + eprintln!("{msg}"); + last_err = Some(msg); + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + } + assert!( + booted, + "code-runner::eval never resolved on the bus; last error: {last_err:?}" + ); + }); + }); + + // `cleanup` drops here (normal return) or while `resume_unwind` below + // unwinds through this scope (failure) — either way the engine, worker, + // and scratch home get torn down exactly once. + drop(cleanup); + if let Err(p) = result { + std::panic::resume_unwind(p); + } +} diff --git a/code-runner/tests/manifest.rs b/code-runner/tests/manifest.rs new file mode 100644 index 000000000..8475c9368 --- /dev/null +++ b/code-runner/tests/manifest.rs @@ -0,0 +1,16 @@ +//! The binary's `--manifest` output: valid JSON with the registry fields, +//! printed without connecting to anything. + +#[test] +fn manifest_flag_prints_valid_json_and_exits_zero() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_code-runner")) + .arg("--manifest") + .output() + .expect("binary runs"); + assert!(out.status.success()); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout is valid JSON"); + assert_eq!(parsed["name"], "code-runner"); + assert!(parsed["default_config"].is_object()); + assert!(!parsed["supported_targets"].as_array().unwrap().is_empty()); +} diff --git a/code-runner/tests/runner_exec.rs b/code-runner/tests/runner_exec.rs new file mode 100644 index 000000000..0ca57c2df --- /dev/null +++ b/code-runner/tests/runner_exec.rs @@ -0,0 +1,379 @@ +//! The runner scripts under the REAL `node` and `python3` — the same +//! binaries the sandbox images ship. Goldens pin the bytes; this proves they +//! work: protocol framing, envelope delivery, error shape, exit codes. +//! +//! FAILS LOUDLY by default when an interpreter is missing: these are the +//! only tests that prove the runner scripts actually work, so a silent skip +//! would let a broken PATH report as a clean, green suite. Opt out +//! explicitly with `ALLOW_MISSING_INTERPRETERS=1` if you knowingly don't +//! have one of the two interpreters installed. + +mod support; + +use std::io::Write; +use std::process::{Command, Stdio}; + +use code_runner::runner::{split_sentinel, Lang}; + +const SENTINEL: &str = "0f7f37e2-golden-sentinel"; + +struct RunOutcome { + exit_ok: bool, + logs: String, + result: Option, + stderr: String, +} + +fn interpreter_available(bin: &str) -> bool { + Command::new(bin) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +/// Returns `true` when the caller should skip — only on the explicit +/// `ALLOW_MISSING_INTERPRETERS=1` opt-out. Otherwise a missing interpreter +/// FAILS the test: these tests are the only proof the runner scripts work, +/// so silence here would let a broken PATH masquerade as a passing suite. +fn require(lang: Lang) -> bool { + if interpreter_available(lang.interpreter()) { + return false; + } + if std::env::var("ALLOW_MISSING_INTERPRETERS").as_deref() == Ok("1") { + eprintln!( + "SKIPPED (ALLOW_MISSING_INTERPRETERS=1): {} not on PATH — runner untested for {:?}", + lang.interpreter(), + lang + ); + return true; + } + panic!( + "{bin} is not on PATH, so this test cannot prove the {lang:?} runner works. \ + Install {bin}, or set ALLOW_MISSING_INTERPRETERS=1 to explicitly accept that gap.", + bin = lang.interpreter(), + ); +} + +/// Write the runner + a handler source into a scratch dir, run +/// ` ` with `stdin` fed to the child verbatim +/// (no envelope wrapping — used directly by the malformed-envelope tests). +fn spawn_runner(lang: Lang, handler_src: &str, stdin: &str) -> RunOutcome { + let dir = std::env::temp_dir().join(format!("ce-runner-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let runner = dir.join(format!("run.{}", lang.ext())); + let source = dir.join(format!("handler.{}", lang.ext())); + std::fs::write(&runner, lang.runner_source()).unwrap(); + std::fs::write(&source, handler_src).unwrap(); + + // Pin the interpreter's colour behaviour instead of inheriting the + // developer's. Node >= 26 formats numbers with ANSI colour even when + // stdout is a pipe if `FORCE_COLOR` is set — and it is, in at least one + // shell here — so `console.log('working on', 21)` reaches us as + // `working on \e[33m21\e[39m` and a plain `contains` assertion fails. + // That is a property of the terminal, not of the runner under test. + let mut child = Command::new(lang.interpreter()) + .arg(&runner) + .arg(&source) + .env_remove("FORCE_COLOR") + .env("NO_COLOR", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + std::fs::remove_dir_all(&dir).ok(); + + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let split = split_sentinel(&stdout, SENTINEL); + RunOutcome { + exit_ok: out.status.success(), + logs: split.logs, + result: split + .result + .as_deref() + .and_then(|r| serde_json::from_str(r).ok()), + stderr: String::from_utf8_lossy(&out.stderr).to_string(), + } +} + +/// The normal case: wrap `payload` (a JSON literal) into the +/// `{"sentinel": ..., "payload": ...}` envelope the runner expects on +/// stdin. +fn run_runner(lang: Lang, handler_src: &str, payload: &str) -> RunOutcome { + let payload_value: serde_json::Value = + serde_json::from_str(payload).expect("test payload must be valid JSON"); + let envelope = + serde_json::json!({ "sentinel": SENTINEL, "payload": payload_value }).to_string(); + spawn_runner(lang, handler_src, &envelope) +} + +#[test] +fn goldens_pin_both_runner_scripts() { + let mut failures = Vec::new(); + for (name, contents) in [ + ("run.mjs", Lang::Node.runner_source()), + ("run.py", Lang::Python.runner_source()), + ] { + if let Err(msg) = support::check_golden(&format!("runners/{name}"), contents) { + failures.push(msg); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} + +#[test] +fn node_happy_path_returns_result_and_logs() { + if require(Lang::Node) { + return; + } + let out = run_runner( + Lang::Node, + "export function handler(p) { console.log('working on', p.n); return { doubled: p.n * 2 }; }", + r#"{"n": 21}"#, + ); + assert!(out.exit_ok); + assert_eq!(out.result, Some(serde_json::json!({ "doubled": 42 }))); + assert!(out.logs.contains("working on 21"), "logs: {}", out.logs); +} + +#[test] +fn node_async_handler_is_awaited() { + if require(Lang::Node) { + return; + } + let out = run_runner( + Lang::Node, + "export async function handler(p) { return await Promise.resolve(p.n + 1); }", + r#"{"n": 1}"#, + ); + assert!(out.exit_ok); + assert_eq!(out.result, Some(serde_json::json!(2))); +} + +#[test] +fn node_throwing_handler_reports_the_error_and_fails() { + if require(Lang::Node) { + return; + } + let out = run_runner( + Lang::Node, + "export function handler() { throw new Error('boom-7'); }", + "{}", + ); + assert!(!out.exit_ok); + let err = out.result.expect("error result after the sentinel"); + assert!(err["error"].as_str().unwrap().contains("boom-7")); +} + +#[test] +fn node_missing_handler_names_the_convention() { + if require(Lang::Node) { + return; + } + let out = run_runner(Lang::Node, "export const notHandler = 1;", "{}"); + assert!(!out.exit_ok); + let err = out.result.expect("error result"); + assert!(err["error"].as_str().unwrap().contains("handler(payload)")); +} + +#[test] +fn python_happy_path_returns_result_and_logs() { + if require(Lang::Python) { + return; + } + let out = run_runner( + Lang::Python, + "def handler(p):\n print('working on', p['n'])\n return {'doubled': p['n'] * 2}\n", + r#"{"n": 21}"#, + ); + assert!(out.exit_ok); + assert_eq!(out.result, Some(serde_json::json!({ "doubled": 42 }))); + assert!(out.logs.contains("working on 21"), "logs: {}", out.logs); +} + +#[test] +fn python_raising_handler_reports_the_error_and_fails() { + if require(Lang::Python) { + return; + } + let out = run_runner( + Lang::Python, + "def handler(p):\n raise ValueError('boom-9')\n", + "{}", + ); + assert!(!out.exit_ok); + let err = out.result.expect("error result"); + let msg = err["error"].as_str().unwrap(); + assert!( + msg.contains("ValueError") && msg.contains("boom-9"), + "{msg}" + ); +} + +#[test] +fn python_async_def_is_refused_with_a_clear_message() { + if require(Lang::Python) { + return; + } + let out = run_runner(Lang::Python, "async def handler(p):\n return 1\n", "{}"); + assert!(!out.exit_ok); + let err = out.result.expect("error result"); + assert!(err["error"].as_str().unwrap().contains("async def")); +} + +#[test] +fn python_unserializable_result_is_a_handler_error() { + if require(Lang::Python) { + return; + } + let out = run_runner(Lang::Python, "def handler(p):\n return object()\n", "{}"); + assert!(!out.exit_ok); + let err = out.result.expect("error result"); + assert!(err["error"].as_str().unwrap().contains("TypeError")); +} + +#[test] +fn node_null_payload_reaches_handler_as_null() { + if require(Lang::Node) { + return; + } + let out = run_runner( + Lang::Node, + "export function handler(p) { return p === null; }", + "null", + ); + assert!(out.exit_ok); + assert_eq!(out.result, Some(serde_json::json!(true))); +} + +#[test] +fn node_malformed_envelope_exits_nonzero_with_a_diagnostic_and_no_frame() { + if require(Lang::Node) { + return; + } + let out = spawn_runner( + Lang::Node, + "export function handler(p) { return p; }", + "not json", + ); + assert!(!out.exit_ok); + assert_eq!( + out.result, None, + "no sentinel was ever established, so there can be no frame" + ); + assert!(!out.stderr.is_empty(), "expected a diagnostic on stderr"); +} + +#[test] +fn python_malformed_envelope_exits_nonzero_with_a_diagnostic_and_no_frame() { + if require(Lang::Python) { + return; + } + // Completely missing envelope (empty stdin) rather than invalid JSON — + // covers a different real trigger (child stdin closed with no bytes) + // than the Node test above (bytes present but not valid JSON). + let out = spawn_runner(Lang::Python, "def handler(p):\n return p\n", ""); + assert!(!out.exit_ok); + assert_eq!( + out.result, None, + "no sentinel was ever established, so there can be no frame" + ); + assert!(!out.stderr.is_empty(), "expected a diagnostic on stderr"); +} + +#[test] +fn node_dangling_timer_output_after_the_frame_does_not_corrupt_the_result() { + if require(Lang::Node) { + return; + } + let out = run_runner( + Lang::Node, + "export function handler(p) { setTimeout(() => console.log('late'), 50); return { ok: true }; }", + "{}", + ); + assert!(out.exit_ok); + assert_eq!(out.result, Some(serde_json::json!({ "ok": true }))); +} + +#[test] +fn python_dangling_thread_output_after_the_frame_does_not_corrupt_the_result() { + if require(Lang::Python) { + return; + } + let out = run_runner( + Lang::Python, + "import threading\nimport time\n\n\ndef handler(p):\n def late():\n time.sleep(0.05)\n print('late')\n threading.Thread(target=late).start()\n return {'ok': True}\n", + "{}", + ); + assert!(out.exit_ok); + assert_eq!(out.result, Some(serde_json::json!({ "ok": true }))); +} + +#[test] +fn node_handler_calling_process_exit_leaves_no_sentinel_but_exits_clean() { + if require(Lang::Node) { + return; + } + let out = run_runner( + Lang::Node, + "export function handler() { process.exit(0); }", + "{}", + ); + assert!(out.exit_ok); + assert_eq!( + out.result, None, + "the runner never reached its own final write" + ); +} + +#[test] +fn python_handler_calling_os_exit_leaves_no_sentinel_but_exits_clean() { + if require(Lang::Python) { + return; + } + let out = run_runner( + Lang::Python, + "import os\n\n\ndef handler(p):\n os._exit(0)\n", + "{}", + ); + assert!(out.exit_ok); + assert_eq!( + out.result, None, + "the runner never reached its own final write" + ); +} + +/// Regression for the round-2 finding: `sentinel` used to be bound at +/// MODULE scope in `run.py`, and Python always registers the running +/// script as `sys.modules['__main__']` — so a handler could read it +/// straight off that module by name and forge a winning frame before the +/// runner ever wrote its own. `sentinel` now lives inside `main()`, so the +/// same lookup must come back empty AND the handler's genuine return value +/// must come back untouched. +#[test] +fn python_handler_cannot_steal_the_sentinel_via_dunder_main() { + if require(Lang::Python) { + return; + } + let out = run_runner( + Lang::Python, + "import sys\n\n\ndef handler(p):\n stolen = getattr(sys.modules['__main__'], 'sentinel', None)\n return {'stolen': stolen, 'real': p['n']}\n", + r#"{"n": 7}"#, + ); + assert!(out.exit_ok); + assert_eq!( + out.result, + Some(serde_json::json!({ "stolen": null, "real": 7 })), + "sentinel must not be reachable off sys.modules['__main__'], and the \ + handler's real result must come back intact" + ); +} diff --git a/code-runner/tests/schemas.rs b/code-runner/tests/schemas.rs new file mode 100644 index 000000000..f8d1b4146 --- /dev/null +++ b/code-runner/tests/schemas.rs @@ -0,0 +1,82 @@ +//! Wire-schema snapshots for the statically registered `code-runner::*` +//! functions. `code_runner::functions::catalog()` is the single source of +//! truth; each entry is serialized to pretty JSON and compared against +//! `tests/golden/schemas/.json` (`::` maps to `.` in filenames). +//! +//! Regenerate with `UPDATE_GOLDENS=1 cargo test`. + +mod support; + +use code_runner::functions::{catalog, FunctionSpec}; + +fn golden_file_name(function_id: &str) -> String { + format!("schemas/{}.json", function_id.replace("::", ".")) +} + +fn spec_to_pretty_json(spec: &FunctionSpec) -> String { + let value = serde_json::json!({ + "function_id": spec.function_id, + "description": spec.description, + "request_schema": spec.request_schema, + "response_schema": spec.response_schema, + }); + let mut pretty = serde_json::to_string_pretty(&value).expect("spec serializes"); + pretty.push('\n'); + pretty +} + +#[test] +fn catalog_lists_every_function_in_registration_order() { + let ids: Vec<&str> = catalog().iter().map(|s| s.function_id).collect(); + assert_eq!( + ids, + vec![ + "code-runner::eval", + "code-runner::teardown", + "code-runner::register_function", + "code-runner::inject-guidance" + ] + ); +} + +#[test] +fn wire_schema_snapshots_match_goldens() { + let mut failures = Vec::new(); + for spec in catalog() { + let rel = golden_file_name(spec.function_id); + if let Err(msg) = support::check_golden(&rel, &spec_to_pretty_json(&spec)) { + failures.push(msg); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} + +/// A `serde_json::Value` handler emits the permissive AnyValue schema, which +/// ships to the registry as "unknown". Every static function must be typed. +#[test] +fn every_schema_is_typed() { + for spec in catalog() { + for (kind, schema) in [ + ("request", &spec.request_schema), + ("response", &spec.response_schema), + ] { + support::assert_typed_schema(&format!("{} {kind}", spec.function_id), schema); + } + } +} + +/// The two hand-maintained lists must not drift apart. Compared as ordered +/// slices so an id appended to one list and inserted into the other fails +/// in CI, not just at deploy-time via register_all's assert. +#[test] +fn static_ids_and_catalog_match_exactly() { + let cataloged: Vec<&str> = code_runner::functions::catalog() + .iter() + .map(|s| s.function_id) + .collect(); + assert_eq!( + code_runner::functions::STATIC_IDS, + cataloged.as_slice(), + "STATIC_IDS and catalog() must list the same ids in the same order" + ); +} diff --git a/code-runner/tests/support/mod.rs b/code-runner/tests/support/mod.rs new file mode 100644 index 000000000..440e3bf0e --- /dev/null +++ b/code-runner/tests/support/mod.rs @@ -0,0 +1,118 @@ +//! Hand-rolled golden-file harness (deliberately no `insta`/snapshot +//! dependency). Goldens live under `tests/golden/` and are committed; +//! any wire-surface change must show up as an explicit, reviewed diff. +//! +//! Workflow: +//! - `cargo test` compares actual output against the committed goldens. +//! - `UPDATE_GOLDENS=1 cargo test` regenerates the files; review the git +//! diff, then commit the new goldens alongside the change that caused +//! them. + +#![allow(dead_code)] + +use std::fs; +use std::path::PathBuf; + +/// Root of the committed golden files. +pub fn golden_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +fn update_mode() -> bool { + std::env::var("UPDATE_GOLDENS") + .map(|v| v == "1") + .unwrap_or(false) +} + +/// Compare `actual` against the golden file at `tests/golden/`. +/// Returns `Err(readable diff hint)` on mismatch or missing golden; +/// with `UPDATE_GOLDENS=1` the file is (re)written and the check passes. +pub fn check_golden(rel: &str, actual: &str) -> Result<(), String> { + let path = golden_root().join(rel); + if update_mode() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + fs::write(&path, actual).map_err(|e| format!("write {}: {e}", path.display()))?; + return Ok(()); + } + let expected = fs::read_to_string(&path).map_err(|e| { + format!( + "golden file {} unreadable ({e}).\n\ + Run `UPDATE_GOLDENS=1 cargo test` to (re)generate, then review \ + and commit the diff.", + path.display() + ) + })?; + if expected == actual { + return Ok(()); + } + Err(diff_hint(rel, &expected, actual)) +} + +/// Readable first-divergence diff hint: line number, expected vs actual +/// around the mismatch, and the regeneration instructions. +fn diff_hint(rel: &str, expected: &str, actual: &str) -> String { + let exp_lines: Vec<&str> = expected.lines().collect(); + let act_lines: Vec<&str> = actual.lines().collect(); + let first_diff = exp_lines + .iter() + .zip(act_lines.iter()) + .position(|(e, a)| e != a) + .unwrap_or_else(|| exp_lines.len().min(act_lines.len())); + + const CONTEXT: usize = 3; + let lo = first_diff.saturating_sub(CONTEXT); + let hi = (first_diff + CONTEXT + 1).max(first_diff + 1); + + let mut out = format!( + "golden mismatch: tests/golden/{rel}\n\ + first divergence at line {} (expected {} lines, actual {} lines)\n", + first_diff + 1, + exp_lines.len(), + act_lines.len() + ); + out.push_str("--- expected (golden) ---\n"); + for (i, line) in exp_lines.iter().enumerate().skip(lo).take(hi - lo) { + let marker = if i == first_diff { ">" } else { " " }; + out.push_str(&format!("{marker} {:>4} | {line}\n", i + 1)); + } + out.push_str("--- actual ---\n"); + for (i, line) in act_lines.iter().enumerate().skip(lo).take(hi - lo) { + let marker = if i == first_diff { ">" } else { " " }; + out.push_str(&format!("{marker} {:>4} | {line}\n", i + 1)); + } + out.push_str( + "If this change is intentional, run `UPDATE_GOLDENS=1 cargo test`, \ + review the git diff, and commit the updated goldens.\n", + ); + out +} + +/// Assert a schemars-derived request/response schema is a *real* schema and +/// not the permissive `AnyValue` schema a `Value` handler emits (the "unknown" +/// schema this whole convention exists to prevent). A real schema carries at +/// least one schema-defining keyword. +pub fn assert_typed_schema(label: &str, schema: &schemars::schema::RootSchema) { + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value + .as_object() + .unwrap_or_else(|| panic!("{label}: schema is not a JSON object")); + const DEFINING: [&str; 8] = [ + "type", + "properties", + "$ref", + "allOf", + "anyOf", + "oneOf", + "enum", + "items", + ]; + let has_defining = DEFINING.iter().any(|k| obj.contains_key(*k)); + assert!( + has_defining, + "{label}: schema is the permissive AnyValue/empty schema (no type/properties/$ref/…). \ + The handler is registered with `Value` — give it a typed struct deriving JsonSchema. \ + Got: {value}" + ); +} diff --git a/code-runner/ui/build.mjs b/code-runner/ui/build.mjs new file mode 100644 index 000000000..2a0e7dd17 --- /dev/null +++ b/code-runner/ui/build.mjs @@ -0,0 +1,37 @@ +/** + * Build the worker's two console assets: + * + * page.tsx → dist/page.js (injected over `console:script`) + * styles.css → dist/styles.css (injected over `console:style`) + * + * The five shared specifiers stay EXTERNAL — they resolve at runtime + * through the console's import map (a bundled React copy would surface as + * a cryptic "Invalid hook call"). Everything else the page needs gets + * bundled in. `--watch` pairs with the worker's III_CODE_RUNNER_UI_WATCH + * poller for the hot-reload dev loop. + */ + +import esbuild from 'esbuild' + +const options = { + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +} + +if (process.argv.includes('--watch')) { + const ctx = await esbuild.context(options) + await ctx.watch() +} else { + await esbuild.build(options) +} diff --git a/code-runner/ui/package.json b/code-runner/ui/package.json new file mode 100644 index 000000000..1c30f3a38 --- /dev/null +++ b/code-runner/ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "@iii-workers/code-runner-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch", + "test": "vitest run" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "esbuild": "^0.25.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "typescript": "^5.9.2", + "vitest": "^4.1.6" + } +} diff --git a/code-runner/ui/page.tsx b/code-runner/ui/page.tsx new file mode 100644 index 000000000..5154da445 --- /dev/null +++ b/code-runner/ui/page.tsx @@ -0,0 +1,31 @@ +/** + * Entry for the code-runner worker's injected console UI — compiled by + * esbuild (react + @iii-dev/console-ui external) into dist/page.js and served + * over the `console:script` trigger (see src/ui.rs). The stylesheet is its + * own asset: ./styles.css ships over `console:style` as + * code-runner/styles.css — the console mounts and link-swaps it, + * styles-before-scripts on boot. + * + * The worker's only console contribution is how its function triggers render: + * + * - src/function-trigger-message/ — the per-op cards (eval, register_function, + * teardown) + * - src/lib/shared.tsx — the frame those cards share + * + * Registrations go through `host` so the loader disposes them on hot reload / + * worker disconnect. + */ + +import type { Host } from '@iii-dev/console-ui' +import { createCodeRunnerRenderers } from './src/function-trigger-message' + +export default function setup(host: Host) { + const removers = createCodeRunnerRenderers(host).map((renderer) => + host.functionTriggers.register(renderer), + ) + // The loader already disposes every registration; returning the removers + // makes an early teardown (hot reload mid-session) explicit and ordered. + return () => { + for (const remove of removers) remove() + } +} diff --git a/code-runner/ui/src/function-trigger-message/eval.test.tsx b/code-runner/ui/src/function-trigger-message/eval.test.tsx new file mode 100644 index 000000000..194a8fc09 --- /dev/null +++ b/code-runner/ui/src/function-trigger-message/eval.test.tsx @@ -0,0 +1,412 @@ +/** + * The eval card's own checks — the rules that are specific to THIS card and + * that a wrong answer on would be a lie in the feed: + * + * - a non-zero exit reads as the script failing, never as a system error; + * - the three fall-through states (non-record input, missing output, no + * readable `code` on an approval prompt) return null so the console's own + * card handles them, instead of asserting something that did not happen; + * - an error output renders here, redacted, rather than falling through to + * the default view that would print the runtime_id capability verbatim; + * - every block is capped; + * - an approval-gate excerpt is labeled as one and never has a line count + * quoted off it. + * + * Renders through `react-dom/server` against a stubbed `@iii-dev/console-ui` + * (the real package's JS entry throws by design — it is compile-time-only, + * served at runtime by the console's import map, see packages/console-ui). + * The stub renders every prop it is handed, so a renderer that stopped + * redacting would still be caught here rather than hidden behind an inert + * mock. + */ + +import type { + FunctionTriggerMessage, + FunctionTriggerRenderer, + Host, +} from '@iii-dev/console-ui' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { truncateRuntimeId } from '../lib/shared' +import { createEvalRenderer } from './eval' + +vi.mock('@iii-dev/console-ui', () => ({ + Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), + TooltipContent: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + CodeHighlight: ({ code, language }: { code: string; language: string }) => ( +
+      {code}
+    
+ ), + JsonHighlight: ({ code }: { code: string }) => ( +
{code}
+ ), +})) + +const FUNCTION_ID = 'code-runner::eval' +const RUNTIME_ID = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' +const TRUNCATED = truncateRuntimeId(RUNTIME_ID) + +const renderer: FunctionTriggerRenderer = createEvalRenderer({} as Host) + +function msg(extra: Partial): FunctionTriggerMessage { + return { + id: 'm1', + role: 'function-trigger', + functionId: FUNCTION_ID, + input: {}, + createdAt: 0, + ...extra, + } +} + +const html = (node: React.ReactNode) => renderToStaticMarkup(node) + +/** A well-formed settled call: request + process result. */ +function settled( + input: Record, + output: Record, +): string { + const node = renderer.tryRender(msg({ input, output, running: false })) + expect(node).not.toBeNull() + return html(node) +} + +const OK_RES = { + runtime_id: RUNTIME_ID, + stdout: 'hello\n', + stderr: '', + exit_code: 0, + success: true, + duration_ms: 12, +} + +describe('createEvalRenderer', () => { + it('claims only its own function id', () => { + expect(renderer.isMatch(FUNCTION_ID)).toBe(true) + for (const other of [ + 'code-runner::teardown', + 'code-runner::register_function', + 'code-runner::inject-guidance', + 'node-engine::eval', + ]) { + expect(renderer.isMatch(other)).toBe(false) + expect( + renderer.tryRender( + msg({ functionId: other, input: { code: 'x' }, output: OK_RES }), + ), + ).toBeNull() + } + }) +}) + +describe('the process result', () => { + it('renders stdout, the exit code and the duration', () => { + const out = settled({ code: 'console.log("hello")', lang: 'node' }, OK_RES) + expect(out).toContain('exit 0') + expect(out).toContain('clean exit') + expect(out).toContain('12ms') + expect(out).toContain('hello') + expect(out).toContain('data-language="javascript"') + }) + + /** The distinction the card exists for. */ + it('reads a non-zero exit as the script failing, not a system error', () => { + const out = settled( + { code: 'raise SystemExit(2)', lang: 'python' }, + { + ...OK_RES, + stdout: '', + stderr: 'Traceback (most recent call last):\nSystemExit: 2\n', + exit_code: 2, + success: false, + }, + ) + expect(out).toContain('exit 2') + expect(out).toContain('its own message is in stderr') + expect(out).toContain('cr-ui-exit-code failed') + expect(out).toContain('SystemExit: 2') + // warn, never alert — alert is reserved for infrastructure failures. + expect(out).not.toContain('cr-ui-alert') + expect(out).toContain('data-language="python"') + }) + + it('says nothing was printed when both streams are empty', () => { + const out = settled({ code: 'x = 1' }, { ...OK_RES, stdout: '' }) + expect(out).toContain('no output on stdout or stderr') + }) + + /** Malformed entries get a placeholder, never a silent drop. */ + it('flags a stream that is not a string instead of dropping it', () => { + const out = settled({ code: 'x' }, { ...OK_RES, stdout: { oops: 1 } }) + expect(out).toContain('stdout was not a string') + }) + + it('says so rather than inventing one when the exit code is missing', () => { + const out = settled({ code: 'x' }, { runtime_id: RUNTIME_ID, stdout: '' }) + expect(out).toContain('exit ?') + expect(out).toContain('no exit code in the response') + }) + + /** A reused runtime that omitted `lang` cannot be highlighted honestly. */ + it('renders unhighlighted rather than guessing a language', () => { + const out = settled({ code: 'x = 1', runtime_id: RUNTIME_ID }, OK_RES) + expect(out).toContain('data-language="text"') + }) +}) + +describe('the runtime capability', () => { + it('never puts the full runtime id in the DOM, in any state', () => { + const input = { code: `connect("${RUNTIME_ID}")`, runtime_id: RUNTIME_ID } + const states = [ + renderer.tryRender(msg({ input, output: OK_RES, running: false })), + renderer.tryRenderRunning?.(msg({ input, running: true })), + renderer.tryRenderPreview?.(msg({ input, pendingApproval: true })), + renderer.tryRender( + msg({ + input, + running: false, + output: { + error: { + message: `code-runner::expired: runtime ${RUNTIME_ID} expired: its idle VM was reaped`, + }, + }, + }), + ), + ] + expect(states.every((n) => n !== null && n !== undefined)).toBe(true) + for (const node of states) { + const out = html(node) + expect(out).not.toContain(RUNTIME_ID) // incl. the id inside the source + expect(out).toContain(TRUNCATED) + } + }) + + /** error.rs quotes the id by design, so we must render errors ourselves. */ + it('renders an error output itself, redacted, instead of falling through', () => { + const node = renderer.tryRender( + msg({ + input: { code: 'x' }, + running: false, + output: { error: `unknown runtime_id ${RUNTIME_ID}` }, + }), + ) + expect(node).not.toBeNull() + const out = html(node) + expect(out).not.toContain(RUNTIME_ID) + expect(out).toContain(TRUNCATED) + expect(out).toContain('unknown runtime_id') + }) +}) + +/** + * A gate DENIAL is not an `ErrorCard`-shaped infrastructure failure: the call + * never reached a runtime. `errorInfo`'s `'error' in output` check also + * matches the gate's DenialEnvelope shape (approval-gate/src/types.rs), so + * this must be caught first and rendered distinctly. + */ +describe('a gate denial', () => { + const DENIAL_OUTPUT = { + error: { + kind: 'function_error', + message: 'Permission denied: code-runner::eval matched rule no-eval.', + details: { + schema_version: 1, + status: 'denied', + denied_by: 'permissions', + function_id: FUNCTION_ID, + rule_id: 'no-eval', + rule_action: 'deny', + reason: 'Permission denied: code-runner::eval matched rule no-eval.', + // The gate's own excerpt of what the caller submitted — including + // the runtime_id the caller passed — is the leak this test rules out. + args_excerpt: { + runtime_id: RUNTIME_ID, + code: `evalIn("${RUNTIME_ID}")`, + }, + }, + }, + } + + it('reads as "never ran", not as an infrastructure failure', () => { + const node = renderer.tryRender( + msg({ + input: { runtime_id: RUNTIME_ID }, + running: false, + output: DENIAL_OUTPUT, + }), + ) + expect(node).not.toBeNull() + const out = html(node) + expect(out).toContain('denied at the gate') + expect(out).toContain('never ran') + expect(out).toContain('permissions') + // Never the alert tone `ErrorCard` uses for an infrastructure failure. + expect(out).not.toContain('cr-ui-alert') + }) + + it('never prints the args_excerpt runtime id, and shows no RuntimeChip', () => { + const node = renderer.tryRender( + msg({ + input: { runtime_id: RUNTIME_ID }, + running: false, + output: DENIAL_OUTPUT, + }), + ) + const out = html(node) + expect(out).not.toContain(RUNTIME_ID) + // No RuntimeChip at all — a call the gate denied never touched a runtime. + expect(out).not.toContain(TRUNCATED) + }) +}) + +describe('the runtime-origin and network chips', () => { + /** + * Three cases, and the card must name exactly which one applies: no + * runtime_id and no keep is a one-shot (the default, nothing persists); + * no runtime_id with keep: true mints a runtime to keep; a runtime_id in + * the request is a reuse of one the caller already holds. + */ + it('names one-shot, kept, or reused correctly', () => { + const oneShot = settled({ code: 'x' }, OK_RES) + expect(oneShot).toContain('>one-shot<') + expect(oneShot).not.toContain('>keeps the VM<') + expect(oneShot).not.toContain('>reused runtime<') + + const kept = settled({ code: 'x', keep: true }, OK_RES) + expect(kept).toContain('>keeps the VM<') + expect(kept).not.toContain('>one-shot<') + + expect(settled({ code: 'x', runtime_id: RUNTIME_ID }, OK_RES)).toContain( + 'reused runtime', + ) + }) + + /** + * Neither a one-shot eval nor `keep: true` can ever create a networked + * VM (`sandbox::run` has no network flag at all), so `network: true` + * without a `runtime_id` always means the worker will refuse the call — + * the card must say so, not claim network is "on". `network: false` + * needs no callout: it is simply guaranteed on this path. + */ + it('flags network: true without a runtime_id as refused, and stays quiet when false', () => { + const refused = settled({ code: 'x', network: true }, OK_RES) + expect(refused).toContain('refused: no runtime_id') + expect(refused).not.toMatch(/network <\/span>on\b/) + + const quiet = settled({ code: 'x' }, OK_RES) + expect(quiet).not.toContain('network') + }) + + /** eval.rs: "Ignored when `runtime_id` is set" — do not claim otherwise. */ + it('reports network as ignored on a reused runtime', () => { + const out = settled( + { code: 'x', runtime_id: RUNTIME_ID, network: true }, + OK_RES, + ) + expect(out).toContain('ignored on reuse') + }) + + it('shows the timeout the request asked for', () => { + expect(settled({ code: 'x', timeout_ms: 4000 }, OK_RES)).toContain('4000ms') + }) +}) + +describe('size caps', () => { + it('clamps a chatty stdout instead of flooding the chat', () => { + const stdout = Array.from({ length: 500 }, (_, i) => `line ${i}`).join('\n') + const out = settled({ code: 'x' }, { ...OK_RES, stdout }) + expect(out).toContain('line 0') + expect(out).not.toContain('line 499') + expect(out).toContain('expand') + }) + + it('clamps a long program, and a one-line minified one', () => { + const long = Array.from({ length: 400 }, (_, i) => `let a${i} = ${i}`).join( + '\n', + ) + const many = settled({ code: long }, OK_RES) + expect(many).not.toContain('a399') + expect(many).toContain('400 lines') + + const oneLine = settled({ code: 'x'.repeat(50_000) }, OK_RES) + expect(oneLine.length).toBeLessThan(20_000) + expect(oneLine).toContain('expand') + }) +}) + +describe('falling through', () => { + /** A double-encoded payload: the default card decodes it, this one cannot. */ + it('falls through on a non-record input in every state', () => { + for (const input of [JSON.stringify({ code: 'x' }), null, ['code'], 7]) { + expect(renderer.tryRender(msg({ input, output: OK_RES }))).toBeNull() + expect(renderer.tryRenderRunning?.(msg({ input, running: true }))).toBe( + null, + ) + expect( + renderer.tryRenderPreview?.(msg({ input, pendingApproval: true })), + ).toBeNull() + } + }) + + /** An aborted call, or a reloaded session whose last call never paired. */ + it('falls through when there is no response body and nothing is running', () => { + for (const output of [undefined, 'not-a-record', 42]) { + expect( + renderer.tryRender(msg({ input: { code: 'x' }, output })), + ).toBeNull() + } + }) + + it('leaves the approval prompt alone when it carries no readable code', () => { + expect( + renderer.tryRenderPreview?.( + msg({ input: { runtime_id: RUNTIME_ID }, pendingApproval: true }), + ), + ).toBeNull() + // …and never renders a settled/running card for a pending message. + expect( + renderer.tryRender(msg({ input: { code: 'x' }, pendingApproval: true })), + ).toBeNull() + }) +}) + +describe('the approval preview', () => { + it('shows the code that is about to run', () => { + const out = html( + renderer.tryRenderPreview?.( + msg({ + input: { code: 'print(1)', lang: 'python', network: true }, + pendingApproval: true, + }), + ), + ) + expect(out).toContain('will run this code') + expect(out).toContain('print(1)') + expect(out).toContain('>one-shot<') + expect(out).toContain('data-language="python"') + }) + + /** + * The gate clips every string to 256 code points with a trailing `…` + * (approval-gate ARGS_EXCERPT_LEN_CAP): label it, and never quote a line + * count off a string that is not the whole program. + */ + it('labels an approval-gate excerpt and quotes no line count from it', () => { + const excerpt = `${Array.from({ length: 40 }, (_, i) => `l${i}`).join('\n')}…` + const out = html( + renderer.tryRenderPreview?.( + msg({ input: { code: excerpt }, pendingApproval: true }), + ), + ) + expect(out).toContain('code (excerpt)') + expect(out).toContain('clipped to 256 characters by the approval gate') + expect(out).toContain('expand') + expect(out).not.toContain('40 lines') + }) +}) diff --git a/code-runner/ui/src/function-trigger-message/eval.tsx b/code-runner/ui/src/function-trigger-message/eval.tsx new file mode 100644 index 000000000..5384d5d9e --- /dev/null +++ b/code-runner/ui/src/function-trigger-message/eval.tsx @@ -0,0 +1,418 @@ +/** + * Injected function-trigger renderer for `code-runner::eval`. + * + * The default card prints the request as JSON, which turns `code` into one + * escaped single-line string and `stdout`/`stderr` into two more — unreadable, + * and this is the worker's highest-traffic call. Here the source is + * highlighted in the language it will actually run as, and the response is + * rendered as what it is: a PROCESS result. + * + * What this card exists to make obvious: + * + * - A NON-ZERO EXIT IS NOT AN ERROR. code-runner reserves errors for + * infrastructure failures (`error.rs`); a script that throws comes back as + * an ordinary response with its own compiler/runtime message in `stderr`. + * So a failing exit reads as "your code exited N, here is stderr" (warn), + * never as a system fault (alert) — see `ExitStatus` in ../lib/shared. + * - Which runtime the call ran in, and for how long. Three cases, and the + * card names exactly which one applies: a `runtime_id` in the request + * means a REUSE of a runtime the caller already holds; `keep: true` with + * none means the call is minting one to keep; neither means ONE-SHOT — + * the default — which boots a VM, runs the code, and destroys it before + * this response is even sent. A one-shot response carries no + * `runtime_id` (there is nothing left to address), which is why + * `EvalResponse.runtime_id` is optional on the wire now. + * - `network` — create-time only, and the flag that makes `npm install` / + * `pip install` possible inside the guest. `sandbox::run` — which now + * backs both the one-shot and `keep: true` paths — has no way to enable + * it at all, so `network: true` without an explicit `runtime_id` is + * always refused by the worker, never silently ignored. It is the + * security-relevant field of the request, so it is a chip rather than a + * boolean buried in JSON. See `NetworkChip` for what it may and may not + * claim. + * - That `runtime_id` is a capability: only ever shown through `RuntimeChip` + * (truncated, full value on an explicit copy), and every other string on + * the card — stdout, stderr, an error message, even the submitted source — + * routed through `redactRuntimeIds` first. + * + * Error outputs render their own compact card rather than falling through: + * code-runner's error messages quote the runtime_id BY DESIGN + * (`unknown runtime_id {id}`, `runtime {id} expired: …` — error.rs), so the + * console's default view would print the capability verbatim on an ordinary + * mistake. + */ + +import { + CodeHighlight, + type FunctionTriggerMessage, + type FunctionTriggerRenderer, + type Host, +} from '@iii-dev/console-ui' +import { useState } from 'react' +import { + asRecord, + CardShell, + DeniedCard, + ErrorCard, + ExitStatus, + deniedInfo, + errorInfo, + langToPrism, + opName, + RuntimeChip, + redactRuntimeIds, + Stream, + TimeoutChip, + unwrapEnvelope, +} from '../lib/shared' + +const FUNCTION_ID = 'code-runner::eval' + +/** Lines of source shown before the block collapses behind a toggle… */ +const CODE_CLAMP_LINES = 14 +/** …and a character ceiling, for the one-line 400 KB blob a minifier emits. */ +const CODE_CLAMP_CHARS = 2000 + +type Lang = 'node' | 'python' + +interface EvalRequest { + code?: string + runtimeId?: string + /** Only ever a value `langToPrism` can honestly map; anything else is dropped + * rather than echoed as a claim about what will execute. */ + lang?: Lang + /** Only meaningful when `runtimeId` is absent — see `Chips`. */ + keep: boolean + network: boolean + timeoutMs?: number +} + +function parseRequest(input: unknown): EvalRequest { + const obj = asRecord(input) ?? {} + return { + code: typeof obj.code === 'string' ? obj.code : undefined, + runtimeId: typeof obj.runtime_id === 'string' ? obj.runtime_id : undefined, + lang: obj.lang === 'node' || obj.lang === 'python' ? obj.lang : undefined, + keep: obj.keep === true, + network: obj.network === true, + timeoutMs: typeof obj.timeout_ms === 'number' ? obj.timeout_ms : undefined, + } +} + +/** + * Header chips. `runtimeId` is the effective one (the response's, falling back + * to the request's) and is absent on the one-shot path — nothing was left + * running to address. + */ +function Chips({ + req, + runtimeId, +}: { + req: EvalRequest + runtimeId?: string +}) { + return ( + <> + {runtimeId ? : null} + {req.runtimeId ? ( + reused runtime + ) : req.keep ? ( + + keeps the VM + + ) : ( + + one-shot + + )} + {req.lang ? ( + + lang + {req.lang} + + ) : null} + + + + ) +} + +/** + * `network` is create-time only ("Ignored when `runtime_id` is set" — + * eval.rs), so on an explicitly reused runtime it is reported as ignored + * rather than as a capability this eval has. + * + * Without a `runtime_id`, `sandbox::run` — which now backs both the + * one-shot and `keep: true` paths — has no way to enable networking at + * all, so the worker always REFUSES `network: true` there rather than + * silently dropping it. `network: false` needs no chip: it is simply + * guaranteed, the same way it always was on this path. + */ +function NetworkChip({ req }: { req: EvalRequest }) { + if (!req.runtimeId) { + if (!req.network) return null + return ( + + network + refused: no runtime_id + + ) + } + if (!req.network) return null + return ( + + network + ignored on reuse + + ) +} + +/** + * The submitted source, highlighted as the language it runs as and clamped so + * a generated 5000-line program cannot own the viewport. + * + * `lang` is `undefined` on a reuse that omitted it (the language belongs to + * the runtime, and the request does not say) — that renders unhighlighted + * rather than guessing, since a wrong language reads as a claim. + * + * `clipped` means the string came from the approval gate's + * `arguments_excerpt`, already truncated: its line count is not the program's, + * so no count is quoted from it. + */ +function CodeSection({ + code, + lang, + clipped, +}: { + code: string + lang?: Lang + clipped?: boolean +}) { + const [expanded, setExpanded] = useState(false) + + // Source can embed a runtime id — a script that calls back into the engine + // carries one as a literal. The feed is not the caller that already holds it. + const safe = redactRuntimeIds(code) + if (safe.trim().length === 0) { + return
· empty code — nothing to run
+ } + + const lines = safe.split('\n') + const long = lines.length > CODE_CLAMP_LINES || safe.length > CODE_CLAMP_CHARS + const collapsed = long && !expanded + const shown = collapsed + ? lines.slice(0, CODE_CLAMP_LINES).join('\n').slice(0, CODE_CLAMP_CHARS) + : safe + + return ( +
+
+ {clipped ? 'code (excerpt)' : 'code'} +
+
+ +
+ {long ? ( + + ) : null} +
+ ) +} + +/** + * One process stream. A present-but-not-a-string field is a malformed + * response, which gets a placeholder — dropping it silently would show a + * card that quietly disagrees with the payload. + */ +function StreamOrNote({ + label, + value, + tone, +}: { + label: string + value: unknown + tone?: 'out' | 'err' +}) { + if (typeof value === 'string') { + return + } + if (value === undefined || value === null) return null + return ( +
+ · {label} was not a string ({typeof value}) — malformed response +
+ ) +} + +function SettledView({ message }: { message: FunctionTriggerMessage }) { + const req = parseRequest(message.input) + const res = asRecord(unwrapEnvelope(message.output)) ?? {} + const runtimeId = + typeof res.runtime_id === 'string' ? res.runtime_id : req.runtimeId + + return ( + } + > + {/* The verdict first: in a chat feed you want "did it run?" before you + want the source, and a failing exit points at the stderr below. */} + + {req.code === undefined ? ( +
· the request carried no code
+ ) : ( + + )} + + + {res.stdout === '' && res.stderr === '' ? ( +
· no output on stdout or stderr
+ ) : null} +
+ ) +} + +/** In-flight: what is being run, and where. No verdict to show yet. */ +function RunningView({ message }: { message: FunctionTriggerMessage }) { + const req = parseRequest(message.input) + return ( + } + > +
· running…
+ {req.code === undefined ? null : ( + + )} +
+ ) +} + +/** + * Pending approval: the source that is about to run, before saying yes. + * + * The approval gate may hand this renderer `arguments_excerpt` instead of the + * real request — every string clipped to 256 code points with a trailing `…` + * (approval-gate/src/redact.rs `ARGS_EXCERPT_LEN_CAP`). A trailing `…` is that + * marker, so the code is labeled a possibly-partial excerpt rather than + * presented as the whole program on the one card gating arbitrary code + * execution. + */ +function PreviewView({ + message, + code, +}: { + message: FunctionTriggerMessage + code: string +}) { + const req = parseRequest(message.input) + const clipped = code.endsWith('…') + return ( + } + > +
will run this code:
+ {clipped ? ( +
+ · clipped to 256 characters by the approval gate — an excerpt, not + necessarily the whole program +
+ ) : null} + +
+ ) +} + +export function createEvalRenderer(host: Host): FunctionTriggerRenderer { + void host // the eval card reads nothing off the host + + const render = ( + message: FunctionTriggerMessage, + running: boolean, + ): React.ReactNode | null => { + if (message.functionId !== FUNCTION_ID) return null + if (message.pendingApproval) return null // tryRenderPreview handles it + // Not a record — e.g. a double-encoded (stringified) payload, which the + // default card knows how to unpack and this one does not. Fall through + // rather than asserting anything about a request we cannot read. + if (!asRecord(message.input)) return null + if (running) return + // Denied at the gate — this call never reached a runtime, so it must not + // read as one of the infrastructure failures `ErrorCard` means. Checked + // before `errorInfo`: a denial is also `'error' in output`-shaped. + const denied = deniedInfo(message.output) + if (denied) { + return ( + + ) + } + // Our own error card, never a fall-through: code-runner's error messages + // carry the runtime_id capability by design (error.rs), so the console's + // default view would print it unredacted. + const err = errorInfo(message.output) + if (err) { + return ( + + ) + } + // No parseable response body — an aborted call, or a reloaded session + // whose last call never paired. A normal state, and NOT a completed eval, + // so let the console's own "response · empty" card have it rather than + // asserting an exit status that never happened. + if (!asRecord(unwrapEnvelope(message.output))) return null + return + } + + return { + id: 'code-runner/page.js#eval', + isMatch: (functionId) => functionId === FUNCTION_ID, + tryRender: (message) => render(message, !!message.running), + tryRenderRunning: (message) => render(message, true), + tryRenderPreview: (message) => { + if (!message.pendingApproval) return null + if (message.functionId !== FUNCTION_ID) return null + if (!asRecord(message.input)) return null + // No readable `code`: the gate's default card shows the raw payload, + // which is strictly more than this card could honestly claim on an + // approval prompt for arbitrary code execution. + const { code } = parseRequest(message.input) + if (code === undefined) return null + return + }, + } +} diff --git a/code-runner/ui/src/function-trigger-message/index.tsx b/code-runner/ui/src/function-trigger-message/index.tsx new file mode 100644 index 000000000..85dea804e --- /dev/null +++ b/code-runner/ui/src/function-trigger-message/index.tsx @@ -0,0 +1,52 @@ +/** + * The code-runner function-trigger renderers — one module per rendered op: + * + * code-runner::eval → ./eval + * code-runner::register_function → ./register-function + * code-runner::teardown → ./teardown + * + * Registered through `host.functionTriggers`, so they dispatch BEFORE the + * console's first-party families and override how those calls render in chat + * and in the traces span tab. + * + * The console asks EVERY registered renderer on every message (`isMatch` is + * only used to pick a `FunctionIdLabel`), so each `tryRender*` gates on its + * own function id and returns null to fall through. + * + * Unlike node-engine's, these cards do NOT fall through on error outputs: + * code-runner's error messages quote the `runtime_id` by design + * (`unknown runtime_id {id}`, `runtime {id} expired: …` — error.rs), and a + * runtime id is a capability, so the console's default error view would print + * it verbatim. Each card renders its own `ErrorCard` with the message routed + * through `redactRuntimeIds`. + * + * `code-runner::inject-guidance` is deliberately NOT rendered: it is a + * harness-internal `pre_generate` hook (see src/functions/inject_guidance.rs) + * that appends usage guidance to an agent's system prompt, not a call anyone + * makes on purpose. It keeps the console's default card. + */ + +import type { FunctionTriggerRenderer, Host } from '@iii-dev/console-ui' +import { redactRuntimeIdsDeep } from '../lib/shared' +import { createEvalRenderer } from './eval' +import { createRegisterFunctionRenderer } from './register-function' +import { createTeardownRenderer } from './teardown' + +/** + * `redactRaw` is attached here rather than card by card: the console's + * function-trigger card mounts a `raw json` tab (and a copy button) that + * renders `input`/`output` verbatim whatever the card does, and EVERY + * code-runner payload can carry a `runtime_id` — in a field, in a line of + * program output, in one of the error messages that quote it by design + * (error.rs). It is a property of the worker's payloads, not of any one op, + * so a renderer cannot opt out by omission. + */ +export function createCodeRunnerRenderers( + host: Host, +): FunctionTriggerRenderer[] { + return [ + createEvalRenderer(host), + createRegisterFunctionRenderer(host), + createTeardownRenderer(host), + ].map((renderer) => ({ ...renderer, redactRaw: redactRuntimeIdsDeep })) +} diff --git a/code-runner/ui/src/function-trigger-message/redact-runtime-ids.test.tsx b/code-runner/ui/src/function-trigger-message/redact-runtime-ids.test.tsx new file mode 100644 index 000000000..d601f9c0a --- /dev/null +++ b/code-runner/ui/src/function-trigger-message/redact-runtime-ids.test.tsx @@ -0,0 +1,271 @@ +/** + * The capability sweep: `runtime_id` is a capability — whoever holds one can + * eval into or tear down that sandbox — so the full value must never reach the + * DOM. `RuntimeChip` truncates the `runtime_id` FIELD, but every other site + * that renders free text has to redact independently, and code-runner gives + * that text three ways in: + * + * - ERROR MESSAGES QUOTE IT BY DESIGN. `RuntimeNotFound` is + * "unknown runtime_id {id}" and `Expired` is "runtime {id} expired: …" + * (src/error.rs) — a documented exception aimed at the caller who already + * holds the id. The console feed is not that caller, which is why these + * cards render their own `ErrorCard` instead of falling through to the + * console's unredacted default error view. + * - PROGRAM OUTPUT. A script that calls back into the engine prints its own + * runtime id to stdout/stderr. + * - SUBMITTED SOURCE. That same script carries the id as a string literal, + * and `register_function` publishes source verbatim. + * + * So this renders all three cards over settled / running / preview / error and + * asserts the uuid never appears in any of them. + * + * Renders through `react-dom/server` against a stubbed `@iii-dev/console-ui` + * (the real package's JS entry throws — it is compile-time-only, served at + * runtime by the console's import map, see packages/console-ui/index.js). The + * stub still renders every prop it is handed (`code`, `children`), so a + * renderer that stopped routing text through `redactRuntimeIds` is caught here + * rather than hidden behind an inert mock. + */ + +import type { + FunctionTriggerMessage, + FunctionTriggerRenderer, + Host, +} from '@iii-dev/console-ui' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { redactRuntimeIdsDeep, truncateRuntimeId } from '../lib/shared' +import { createEvalRenderer } from './eval' +import { createRegisterFunctionRenderer } from './register-function' +import { createTeardownRenderer } from './teardown' + +// Hoisted above these imports by vitest, so every renderer module above +// resolves the stub, never the real package's throwing JS entry. +vi.mock('@iii-dev/console-ui', () => ({ + Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), + TooltipContent: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + CodeHighlight: ({ code }: { code: string }) => ( +
{code}
+ ), + JsonHighlight: ({ code }: { code: string }) => ( +
{code}
+ ), +})) + +/** `manager.rs` mints `rt-` (`format!("rt-{}", Uuid::new_v4())`). */ +const RUNTIME_ID = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' +const TRUNCATED = truncateRuntimeId(RUNTIME_ID) + +/** A script that calls back into the engine carries its runtime id inline — + * so the id is in the SOURCE, in STDOUT and in STDERR, not just the field. */ +const CODE = [ + 'import { evalIn } from "engine"', + `const rt = "${RUNTIME_ID}"`, + 'console.log("running in", rt)', + 'await evalIn(rt, "1 + 1")', +].join('\n') +const STDOUT = `running in ${RUNTIME_ID}\ndone\n` +const STDERR = `Error: connect ECONNREFUSED\n at evalIn (${RUNTIME_ID}/vm.js:3:9)\n` + +const HOST = {} as unknown as Host + +function baseMsg( + input: unknown, + extra: Partial = {}, +): FunctionTriggerMessage { + return { + id: 'm1', + role: 'function-trigger', + functionId: extra.functionId ?? '', + input, + createdAt: 0, + ...extra, + } +} + +/** Every HTML string the renderer produces for one payload, across the + * settled / running / preview states — mirroring how the console actually + * calls a `FunctionTriggerRenderer` (see FunctionTriggerCard.tsx). */ +function renderAllStates( + renderer: FunctionTriggerRenderer, + functionId: string, + input: unknown, + output: unknown, +): string[] { + const settled = renderer.tryRender( + baseMsg(input, { functionId, output, running: false }), + ) + const running = (renderer.tryRenderRunning ?? renderer.tryRender)( + baseMsg(input, { functionId, running: true }), + ) + const preview = renderer.tryRenderPreview?.( + baseMsg(input, { functionId, pendingApproval: true }), + ) + return [settled, running, preview] + .filter((n): n is React.ReactNode => n !== null && n !== undefined) + .map((n) => renderToStaticMarkup(n)) +} + +/** One node per op: renderer, its function id, a request/response pair whose + * every free-text field embeds the runtime id, and an error output carrying + * one of `error.rs`'s two real id-quoting messages. */ +const CARDS: { + name: string + renderer: FunctionTriggerRenderer + functionId: string + input: unknown + output: unknown + errorOutput: unknown +}[] = [ + { + name: 'eval', + renderer: createEvalRenderer(HOST), + functionId: 'code-runner::eval', + input: { + code: CODE, + runtime_id: RUNTIME_ID, + lang: 'node', + network: true, + timeout_ms: 4000, + }, + output: { + runtime_id: RUNTIME_ID, + stdout: STDOUT, + stderr: STDERR, + exit_code: 1, + success: false, + duration_ms: 128, + }, + // error.rs `Expired`. + errorOutput: { + error: { message: `runtime ${RUNTIME_ID} expired: idle for 600s` }, + }, + }, + { + name: 'register_function', + renderer: createRegisterFunctionRenderer(HOST), + functionId: 'code-runner::register_function', + input: { + runtime_id: RUNTIME_ID, + // A caller is free to name a function after its runtime — nothing stops + // them, so the id can arrive through `function_id` and `description` too. + function_id: `${RUNTIME_ID}::handler`, + source: CODE, + description: `handler for ${RUNTIME_ID}`, + }, + output: { function_id: `${RUNTIME_ID}::handler`, registered: true }, + // error.rs `RuntimeNotFound`. + errorOutput: { error: { message: `unknown runtime_id ${RUNTIME_ID}` } }, + }, + { + name: 'teardown', + renderer: createTeardownRenderer(HOST), + functionId: 'code-runner::teardown', + input: { runtime_id: RUNTIME_ID }, + output: { + runtime_id: RUNTIME_ID, + torn_down: true, + unregistered: [`${RUNTIME_ID}::a`, `${RUNTIME_ID}::b`], + }, + errorOutput: { error: { message: `unknown runtime_id ${RUNTIME_ID}` } }, + }, +] + +describe('runtime id redaction across the injected UI', () => { + for (const card of CARDS) { + it(`${card.name}: the full runtime id never reaches the DOM (settled/running/preview)`, () => { + const htmls = renderAllStates( + card.renderer, + card.functionId, + card.input, + card.output, + ) + // All three cards render in all three states — a card that quietly + // stopped rendering would otherwise pass this test by abstention. + expect(htmls).toHaveLength(3) + for (const html of htmls) { + expect(html).not.toContain(RUNTIME_ID) + } + // Not vacuous: every state actually surfaced the truncated id, so these + // payloads exercise the capability-bearing fields rather than skip them. + for (const html of htmls) { + expect(html).toContain(TRUNCATED) + } + }) + + it(`${card.name}: an error output is rendered here, redacted, not fallen through`, () => { + const message = baseMsg(card.input, { + functionId: card.functionId, + output: card.errorOutput, + running: false, + }) + const node = card.renderer.tryRender(message) + // Falling through (null) would hand the message to the console's default + // error view, which prints error.rs's id-quoting message verbatim. + expect(node).not.toBeNull() + const html = renderToStaticMarkup(node) + expect(html).not.toContain(RUNTIME_ID) + expect(html).toContain(TRUNCATED) + }) + } +}) + +/** + * The deep walker behind `redactRaw` — what the console runs over the raw + * request/response before the `raw json` tab renders them and before its + * copy button copies them. The card's own redaction stops at what the card + * draws; this is the other exit. + */ +describe('redactRuntimeIdsDeep', () => { + it('redacts ids nested in objects, arrays and object KEYS', () => { + const value = { + runtime_id: RUNTIME_ID, + registered: [`code-runner::${RUNTIME_ID}::foo`, { nested: [{ deep: STDERR }] }], + // A payload keyed by runtime id: the key itself is the capability. + [`runtime:${RUNTIME_ID}`]: { note: `owned by ${RUNTIME_ID}` }, + } + const out = redactRuntimeIdsDeep(value) + expect(JSON.stringify(out)).not.toContain(RUNTIME_ID) + expect(JSON.stringify(out)).toContain(TRUNCATED) + // Positive control: the input really did carry the secret everywhere. + expect(JSON.stringify(value)).toContain(RUNTIME_ID) + }) + + it('preserves shape and leaves the input untouched', () => { + const value = { + n: 1, + b: false, + nil: null, + list: [1, 'a', null, [true]], + obj: { k: 'v' }, + } + const snapshot = JSON.stringify(value) + expect(redactRuntimeIdsDeep(value)).toEqual(value) + expect(redactRuntimeIdsDeep(value)).not.toBe(value) + expect(JSON.stringify(value)).toBe(snapshot) + expect(redactRuntimeIdsDeep(RUNTIME_ID)).toBe(TRUNCATED) + expect(redactRuntimeIdsDeep(undefined)).toBeUndefined() + expect(redactRuntimeIdsDeep(null)).toBeNull() + }) + + it('terminates on a self-referential value instead of hanging', () => { + const cyclic: Record = { runtime_id: RUNTIME_ID } + cyclic.self = cyclic + cyclic.list = [cyclic] + const out = redactRuntimeIdsDeep(cyclic) as Record + expect(out.runtime_id).toBe(TRUNCATED) + expect(out.self).toBe('[circular]') + expect(out.list).toEqual(['[circular]']) + }) + + it('redacts a value repeated on two branches (not mistaken for a cycle)', () => { + const shared = { id: RUNTIME_ID } + const out = redactRuntimeIdsDeep({ a: shared, b: shared }) + expect(out).toEqual({ a: { id: TRUNCATED }, b: { id: TRUNCATED } }) + }) +}) diff --git a/code-runner/ui/src/function-trigger-message/register-function.test.tsx b/code-runner/ui/src/function-trigger-message/register-function.test.tsx new file mode 100644 index 000000000..d992d5376 --- /dev/null +++ b/code-runner/ui/src/function-trigger-message/register-function.test.tsx @@ -0,0 +1,424 @@ +/** + * The register_function card, rendered for real through `react-dom/server`. + * + * What this file is actually guarding: the claims the card makes. It must + * never print the runtime_id capability (not even out of an error message, + * which code-runner quotes it into by design), never assert an outcome that + * did not happen (no output, non-record input), never claim a language the + * request does not carry, and never flood the feed with an unbounded source. + * + * Renders against a stubbed `@iii-dev/console-ui` — the real package's JS + * entry throws by design (compile-time-only; the console serves it at runtime + * through its import map). + */ + +import type { FunctionTriggerMessage, Host } from '@iii-dev/console-ui' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { truncateRuntimeId } from '../lib/shared' +import { createRegisterFunctionRenderer } from './register-function' + +vi.mock('@iii-dev/console-ui', () => ({ + Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), + TooltipContent: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + CodeHighlight: ({ code, language }: { code: string; language: string }) => ( +
+      {code}
+    
+ ), +})) + +const FUNCTION_ID = 'code-runner::register_function' +const RUNTIME_ID = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' +const TRUNCATED = truncateRuntimeId(RUNTIME_ID) +const SOURCE = 'export function handler(payload) {\n return payload\n}\n' + +// This card reads nothing off the host (`void host`), so an empty stand-in is +// the whole fixture. +const renderer = createRegisterFunctionRenderer({} as Host) + +function msg( + over: Partial = {}, +): FunctionTriggerMessage { + return { + id: 'm1', + role: 'function-trigger', + functionId: FUNCTION_ID, + createdAt: 0, + input: { + function_id: 'app::greet', + source: SOURCE, + description: 'greets a payload', + lang: 'node', + }, + output: { function_id: 'app::greet', registered: true }, + ...over, + } +} + +const html = (node: React.ReactNode | null) => + node === null ? null : renderToStaticMarkup(node) + +describe('matching', () => { + it('claims only its own function id', () => { + expect(renderer.isMatch(FUNCTION_ID)).toBe(true) + for (const other of [ + 'code-runner::eval', + 'code-runner::teardown', + 'code-runner::inject-guidance', + 'node-engine::register_function', + ]) { + expect(renderer.isMatch(other)).toBe(false) + expect(renderer.tryRender(msg({ functionId: other }))).toBeNull() + } + }) +}) + +describe('falling through', () => { + /** (c) the default card decodes double-encoded payloads; this one cannot. */ + it('declines a non-record input instead of claiming there is no source', () => { + for (const input of [JSON.stringify({ source: SOURCE }), 42, null, []]) { + expect(renderer.tryRender(msg({ input }))).toBeNull() + expect( + renderer.tryRenderPreview?.(msg({ input, pendingApproval: true })), + ).toBeNull() + } + }) + + /** (b) aborted call / reloaded session — a normal state, not a settled one. */ + it('declines a settled message with no parseable output', () => { + for (const output of [undefined, 'oops', 7]) { + expect(renderer.tryRender(msg({ output, running: false }))).toBeNull() + } + }) + + it('leaves the approval preview to tryRenderPreview', () => { + expect(renderer.tryRender(msg({ pendingApproval: true }))).toBeNull() + expect( + html(renderer.tryRenderPreview?.(msg({ pendingApproval: true }))), + ).toContain('will register this function') + }) +}) + +describe('the settled card', () => { + it('shows the id, its namespace claim, the description and the source', () => { + const out = html(renderer.tryRender(msg())) ?? '' + expect(out).toContain('app::greet') + expect(out).toContain('claims app:: for this runtime') + expect(out).toContain('greets a payload') + expect(out).toContain('export function handler(payload)') + expect(out).toContain('registered') + }) + + it('reports registered:false as refused, and says what it means', () => { + const out = + html( + renderer.tryRender( + msg({ output: { function_id: 'app::greet', registered: false } }), + ), + ) ?? '' + expect(out).toContain('not registered') + expect(out).toContain('not callable on the bus') + }) + + it('does not invent a verdict when the response carries no flag', () => { + const out = + html( + renderer.tryRender(msg({ output: { function_id: 'app::greet' } })), + ) ?? '' + expect(out).toContain('no `registered` flag') + expect(out).not.toContain('cr-register-function-status') + }) + + it('flags a response that registered a different id', () => { + const out = + html( + renderer.tryRender( + msg({ output: { function_id: 'app::other', registered: true } }), + ), + ) ?? '' + expect(out).toContain('the response registered app::other') + }) + + /** + * The mismatch check requires both ids defined, so a request with no + * `function_id` at all used to hide the response's id entirely — a card + * could say "no function_id in the request" beside a green `registered` + * badge while never showing the id that is actually live on the bus. + */ + it('shows the response id when the request carried none at all', () => { + const out = + html( + renderer.tryRender( + msg({ + input: { source: SOURCE, description: 'greets a payload' }, + output: { function_id: 'app::greet', registered: true }, + }), + ), + ) ?? '' + expect(out).toContain('app::greet') + expect(out).toContain('from the response') + expect(out).toContain('registered') + expect(out).toContain('claims app:: for this runtime') + }) +}) + +describe('the runtime id is a capability', () => { + /** + * This request carries no `runtime_id` field at all anymore — the + * namespace runtime is resolved internally — so the settled/running/ + * preview states show no id, truncated or otherwise: there is nothing to + * show. The error state is different: a message that HAPPENS to embed an + * id (as error.rs's `Expired`/`RuntimeNotFound` would on the direct + * eval/teardown paths, or a stray one on some other path) must still come + * back truncated, never whole — belt-and-braces, since this card never + * assumes an error message is safe. + */ + it('never leaks the full id, and truncates one when an error message carries it', () => { + const noId = [ + renderer.tryRender(msg()), + renderer.tryRender(msg({ running: true })), + renderer.tryRenderRunning?.(msg({ running: true })), + renderer.tryRenderPreview?.(msg({ pendingApproval: true })), + ] + for (const node of noId) { + const out = html(node ?? null) ?? '' + expect(out).not.toBe('') + expect(out).not.toContain(RUNTIME_ID) + expect(out).not.toContain(TRUNCATED) + } + + const errored = html( + renderer.tryRender( + msg({ output: { error: `unknown runtime_id ${RUNTIME_ID}` } }), + ), + ) + expect(errored).not.toContain(RUNTIME_ID) + expect(errored).toContain(TRUNCATED) + }) + + /** (a) code-runner's own error messages quote the id — error.rs. */ + it('renders errors itself, redacted, rather than falling through', () => { + for (const message of [ + `code-runner::runtime_not_found: unknown runtime_id ${RUNTIME_ID}`, + `code-runner::expired: runtime ${RUNTIME_ID} expired: its idle VM was reaped`, + ]) { + const out = html(renderer.tryRender(msg({ output: { error: message } }))) + expect(out).not.toBeNull() + expect(out).not.toContain(RUNTIME_ID) + expect(out).toContain(TRUNCATED) + } + }) + + it('redacts an id embedded in the function id or the source', () => { + const out = + html( + renderer.tryRender( + msg({ + input: { + runtime_id: RUNTIME_ID, + function_id: `${RUNTIME_ID}::greet`, + source: `// planted from ${RUNTIME_ID}\nhandler`, + description: `for ${RUNTIME_ID}`, + }, + }), + ), + ) ?? '' + expect(out).not.toContain(RUNTIME_ID) + }) +}) + +/** + * A gate DENIAL means no source was ever published to the bus — the + * `'error' in output` shape `errorInfo` matches also matches the gate's + * DenialEnvelope, so this must be caught first and read as "never ran" + * rather than one of `ErrorCard`'s infrastructure failures. + */ +describe('a gate denial', () => { + const DENIAL_OUTPUT = { + error: { + kind: 'function_error', + message: 'Rejected by operator.', + details: { + schema_version: 1, + status: 'denied', + denied_by: 'user', + function_id: FUNCTION_ID, + reason: 'Rejected by operator.', + args_excerpt: { + runtime_id: RUNTIME_ID, + function_id: 'app::greet', + source: SOURCE, + }, + }, + }, + } + + it('reads as "never ran", not as an infrastructure failure', () => { + const out = html(renderer.tryRender(msg({ output: DENIAL_OUTPUT }))) + expect(out).not.toBeNull() + expect(out).toContain('denied at the gate') + expect(out).toContain('never ran') + expect(out).toContain('user') + expect(out).not.toContain('cr-ui-alert') + }) + + it('never prints the args_excerpt runtime id, and shows no RuntimeChip', () => { + const out = html(renderer.tryRender(msg({ output: DENIAL_OUTPUT }))) ?? '' + expect(out).not.toContain(RUNTIME_ID) + expect(out).not.toContain(TRUNCATED) + }) +}) + +describe('language', () => { + const langOf = (source: string) => { + const out = html(renderer.tryRender(msg({ input: { source } }))) ?? '' + return /data-lang="([^"]*)"/.exec(out)?.[1] + } + + /** `langOf`'s fixture omits `lang` entirely — the fallback path. */ + it('guesses from the source and says it guessed, when lang is missing', () => { + expect(langOf('def handler(payload):\n return payload')).toBe('python') + expect(langOf(SOURCE)).toBe('javascript') + const out = html( + renderer.tryRender(msg({ input: { source: SOURCE } })), + ) + expect(out).toContain('highlighted as javascript — guessed') + }) + + /** The normal case now: `lang` is on the wire, so no guessing is needed + * and none is claimed. */ + it('highlights from the request lang, without guessing', () => { + const out = html(renderer.tryRender(msg())) ?? '' + expect(out).toContain('data-lang="javascript"') + expect(out).not.toContain('highlighted as') + expect(out).not.toContain('guessed') + }) + + /** No `lang` and an unrecognizable source: unhighlighted beats + * mislabelled. */ + it('falls back to text with no language claim at all', () => { + expect(langOf('handler = 1')).toBe('text') + const out = html(renderer.tryRender(msg({ input: { source: 'handler' } }))) + expect(out).not.toContain('highlighted as') + }) + + /** A malformed `lang` (not "node"/"python") is treated the same as + * missing — never echoed as a language claim. */ + it('falls back to guessing when lang is present but invalid', () => { + const out = + html( + renderer.tryRender( + msg({ input: { source: SOURCE, lang: 'ruby' } }), + ), + ) ?? '' + expect(out).toContain('data-lang="javascript"') + expect(out).toContain('highlighted as javascript — guessed') + }) +}) + +describe('the handler convention', () => { + it('advises quietly when nothing named handler appears', () => { + const out = + html(renderer.tryRender(msg({ input: { source: 'console.log(1)' } }))) ?? + '' + expect(out).toContain('nothing named `handler`') + expect(out).not.toContain('cr-ui-alert') + }) + + it('stays silent when it does', () => { + expect(html(renderer.tryRender(msg()))).not.toContain('nothing named') + }) +}) + +describe('size caps', () => { + /** (d) a 5 000-line source must not become a 5 000-line chat message. */ + it('clamps a long source and offers expansion', () => { + const source = Array.from({ length: 400 }, (_, i) => `// line ${i}`).join( + '\n', + ) + const out = html(renderer.tryRender(msg({ input: { source } }))) ?? '' + expect(out).toContain('// line 0') + expect(out).not.toContain('// line 399') + expect(out).toContain('more of 400 lines') + }) + + it('clamps a one-line bundle, which has no newlines to clamp on', () => { + const out = + html( + renderer.tryRender(msg({ input: { source: 'x'.repeat(60_000) } })), + ) ?? '' + expect(out.length).toBeLessThan(10_000) + expect(out).toContain('expand · 60000 chars') + expect(out).not.toContain('more of') + }) +}) + +describe('malformed and partial requests', () => { + /** (e) nothing is dropped silently. */ + it('renders placeholders instead of hiding bad fields', () => { + const out = + html( + renderer.tryRender( + msg({ + input: { function_id: 42, source: SOURCE, description: [] }, + // No usable id in the response either, so the placeholder text + // below is not just the id-fallback path from a different test. + output: {}, + }), + ), + ) ?? '' + expect(out).toContain('no function_id in the request') + expect(out).toContain('non-string function_id, description') + }) + + it('says the worker refuses an id with no namespace', () => { + const out = + html( + renderer.tryRender( + msg({ input: { function_id: 'greet', source: SOURCE } }), + ), + ) ?? '' + expect(out).toContain('no namespace') + }) + + it('calls out a missing or empty source', () => { + expect( + html(renderer.tryRender(msg({ input: { function_id: 'app::greet' } }))), + ).toContain('no source in the request') + expect(html(renderer.tryRender(msg({ input: { source: '' } })))).toContain( + 'empty source', + ) + }) +}) + +describe('the approval preview', () => { + /** (f) the gate clips every string to 256 code points + `…`. */ + it('labels a clipped source an excerpt and counts no lines from it', () => { + const clipped = `${'a\n'.repeat(120)}…` + const out = + html( + renderer.tryRenderPreview?.( + msg({ pendingApproval: true, input: { source: clipped } }), + ), + ) ?? '' + expect(out).toContain('source (excerpt)') + expect(out).toContain('clips strings to 256 characters') + expect(out).not.toContain('more of') + // The `handler` definition may simply be past the cut — no advisory. + expect(out).not.toContain('nothing named') + }) + + it('presents an unclipped source as the whole program', () => { + const out = + html(renderer.tryRenderPreview?.(msg({ pendingApproval: true }))) ?? '' + expect(out).toContain('>source<') + expect(out).not.toContain('excerpt') + expect(out).not.toContain('clips strings') + }) +}) diff --git a/code-runner/ui/src/function-trigger-message/register-function.tsx b/code-runner/ui/src/function-trigger-message/register-function.tsx new file mode 100644 index 000000000..5ec960868 --- /dev/null +++ b/code-runner/ui/src/function-trigger-message/register-function.tsx @@ -0,0 +1,494 @@ +/** + * Injected function-trigger renderer for `code-runner::register_function`. + * + * The default card prints the request as JSON, which turns `source` — the + * whole point of the call — into one escaped single-line string, and buries + * the two things that decide whether the registration did what the caller + * meant: + * + * - the NAMESPACE the id claims (the segment before `::`). The first + * registration in a namespace claims it and every later id there must + * share it — AND must share its `lang`: code-runner keeps one persistent + * runtime per (namespace, lang) automatically (manager.rs `namespace_of` + * + `namespace_runtime` + `reserve`), so this is the common surprise — + * it gets its own line, not a substring of a blob. + * - whether the source actually defines `handler(payload)`. That convention + * is what the runner loads and calls (register.rs's `source` doc); a + * source without it registers fine and then fails on every call. + * + * ONE function per call: unlike node-engine's `functions: [...]`, this request + * carries a single `function_id` + `source` pair. + * + * NO `runtime_id` ON THIS WIRE AT ALL: the runtime backing a namespace is an + * implementation detail this call never sees or names — `lang` (required) + * decides which runner it needs, and code-runner creates or reuses that + * namespace's runtime itself. `lang` IS on the request, unlike before this + * redesign, so the source is highlighted honestly rather than guessed — + * `guessPrism` below is now only a fallback for a malformed request missing + * it. + * + * No capability lives on this request either, but a caller is free to name a + * function, description, or source after a runtime id it holds from + * elsewhere (e.g. planting source that calls back into a `keep: true` eval's + * runtime) — every free-text field still goes through `redactRuntimeIds` as + * belt-and-braces, and errors render through this card's own `ErrorCard` + * rather than falling through to the console's default view. + */ + +import { + CodeHighlight, + type FunctionTriggerMessage, + type FunctionTriggerRenderer, + type Host, +} from '@iii-dev/console-ui' +import { useState } from 'react' +import { + asRecord, + CardShell, + DeniedCard, + ErrorCard, + deniedInfo, + errorInfo, + langToPrism, + opName, + redactRuntimeIds, + unwrapEnvelope, +} from '../lib/shared' + +const FUNCTION_ID = 'code-runner::register_function' + +/** Lines of source shown before the block collapses behind a toggle… */ +const COLLAPSE_AFTER = 14 +/** …and a character ceiling, for the one-line source a bundler emitted. */ +const SOURCE_CHAR_CAP = 2000 + +/* --- request ------------------------------------------------------------- */ + +/** The free-text fields register.rs's `RegisterRequest` declares — `lang` is + * excluded: it is a closed enum, not free text, checked separately below. */ +const STRING_FIELDS = ['function_id', 'source', 'description'] as const + +type Lang = 'node' | 'python' + +interface RegisterRequest { + functionId?: string + source?: string + description?: string + /** Required on the wire; `undefined` here means missing or invalid — only + * ever a value `langToPrism` can honestly map. */ + lang?: Lang + /** Fields the request carried with a non-string value — surfaced, not dropped. */ + malformed: string[] +} + +function parseRequest(input: unknown): RegisterRequest { + const obj = asRecord(input) ?? {} + const str = (k: string) => (typeof obj[k] === 'string' ? obj[k] : undefined) + return { + functionId: str('function_id'), + source: str('source'), + description: str('description'), + lang: obj.lang === 'node' || obj.lang === 'python' ? obj.lang : undefined, + malformed: STRING_FIELDS.filter( + (k) => k in obj && obj[k] !== null && typeof obj[k] !== 'string', + ), + } +} + +/** The request's own `lang`, when present. */ +function LangChip({ req }: { req: RegisterRequest }) { + if (!req.lang) return null + return ( + + lang + {req.lang} + + ) +} + +/** + * The namespace this id claims: `app::greet` → `app::`. `undefined` when the + * id has no `::` or nothing after it — exactly the two shapes `namespace_of` + * (manager.rs) refuses, so the card can say so before the call lands. + */ +function namespaceOf(functionId: string): string | undefined { + const i = functionId.indexOf('::') + if (i <= 0 || i + 2 >= functionId.length) return undefined + return `${functionId.slice(0, i)}::` +} + +/** + * A Prism id when the source is unmistakably one language, `undefined` + * otherwise → rendered as `text`, i.e. unhighlighted. Only reached when the + * request's own `lang` is missing or invalid — `lang` is required on this + * wire, so this is a fallback for a malformed request, not the normal case. + * + * This is a HINT, never a claim. `def` is checked first because it is the + * one marker JavaScript cannot produce. + */ +function guessPrism(source: string): string | undefined { + if (/^[ \t]*(async[ \t]+)?def[ \t]+\w/m.test(source)) return 'python' + if (/\bexport\s+function\b|\bfunction\s+\w|=>/.test(source)) + return 'javascript' + return undefined +} + +/** The approval gate clips every string to 256 code points + `…`. */ +function looksClipped(value: string | undefined): boolean { + return value?.endsWith('…') === true +} + +/* --- body pieces --------------------------------------------------------- */ + +function MalformedFields({ names }: { names: readonly string[] }) { + if (names.length === 0) return null + return ( +
+ · the request carries a non-string {names.join(', ')} — the worker rejects + it +
+ ) +} + +function Head({ + req, + resId, + status, +}: { + req: RegisterRequest + /** + * The response's `function_id` — the id actually live on the bus. Falls + * back into the display when the request carried none, so a card can + * never say "no function_id in the request" beside a green `registered` + * badge while hiding the id that is actually callable. `undefined` in the + * pending/running states, where there is no response yet. + */ + resId?: string + /** Undefined until the response settles, or when it carried no flag. */ + status?: 'live' | 'refused' +}) { + // The request is still the id of record when it has one — a caller-echoed + // response could in principle disagree, and `SettledView`'s mismatch note + // covers that. `resId` only fills the gap when the request had nothing to + // show at all. + const displayId = req.functionId ?? resId + const ns = displayId ? namespaceOf(displayId) : undefined + return ( +
+
function
+
+ + {req.functionId + ? redactRuntimeIds(req.functionId) + : resId + ? `${redactRuntimeIds(resId)} (from the response — the request carried no function_id)` + : 'no function_id in the request'} + + {status === 'live' ? ( + registered + ) : null} + {status === 'refused' ? ( + + not registered + + ) : null} +
+ {displayId ? ( +
+ {ns ? ( + <> + claims {redactRuntimeIds(ns)} for this runtime — the + first registration takes the namespace and every later id must + share it + + ) : ( + <> + no namespace — an id must look like app::name, which + this one does not + + )} +
+ ) : null} +
+ {req.description + ? redactRuntimeIds(req.description) + : 'no description — engine::functions::info will show callers nothing'} +
+
+ ) +} + +/** + * The source, clamped by both line count and character count (a one-line + * bundle has no newlines to clamp on) and further capped in height by + * `.cr-ui-code`. + * + * `clipped` means the string is the approval gate's excerpt, not the program: + * its line count is meaningless, so the toggle drops the "+N more lines" + * arithmetic. + */ +function SourceSection({ + source, + lang, + clipped, +}: { + source: string + lang?: Lang + clipped: boolean +}) { + const [expanded, setExpanded] = useState(false) + const safe = redactRuntimeIds(source) + const lines = safe.split('\n') + const long = lines.length > COLLAPSE_AFTER || safe.length > SOURCE_CHAR_CAP + const collapsed = long && !expanded + const shown = collapsed + ? lines.slice(0, COLLAPSE_AFTER).join('\n').slice(0, SOURCE_CHAR_CAP) + : safe + const hidden = lines.length - COLLAPSE_AFTER + const known = langToPrism(lang) + const guessed = known === undefined ? guessPrism(safe) : undefined + const prism = known ?? guessed + + return ( +
+
+ {clipped ? 'source (excerpt)' : 'source'} +
+
+ +
+ {long ? ( + + ) : null} + {guessed ? ( +
+ highlighted as {guessed} — guessed from the source; this request's + lang field is missing or invalid, so the runner it will actually + run under is unconfirmed +
+ ) : null} +
+ ) +} + +/** + * The source has to DEFINE `handler(payload)` — the runner loads the file and + * calls `handler` (register.rs). Quiet, because a substring check is not a + * parser: it is an advisory, never a verdict. Suppressed on a clipped excerpt, + * where the definition may simply be past the cut. + */ +function HandlerAdvisory({ + source, + clipped, +}: { + source: string + clipped: boolean +}) { + if (clipped || /\bhandler\b/.test(source)) return null + return ( +
+ · nothing named `handler` in this source — the runner loads the file and + calls `handler(payload)`, so a source that never defines it registers and + then fails on every call +
+ ) +} + +/** Source block plus its advisories, or the placeholder when there is none. */ +function SourceBlock({ + req, + clipped, +}: { + req: RegisterRequest + clipped: boolean +}) { + if (req.source === undefined) { + return ( +
+ · no source in the request — the worker rejects a registration without + one +
+ ) + } + if (req.source.length === 0) { + return ( +
+ · empty source — the worker rejects it; it must define handler(payload) +
+ ) + } + return ( + <> + + + + ) +} + +/* --- cards --------------------------------------------------------------- */ + +function SettledView({ message }: { message: FunctionTriggerMessage }) { + const req = parseRequest(message.input) + const res = asRecord(unwrapEnvelope(message.output)) ?? {} + const registered = + typeof res.registered === 'boolean' ? res.registered : undefined + const resId = + typeof res.function_id === 'string' ? res.function_id : undefined + const mismatch = + resId !== undefined && + req.functionId !== undefined && + resId !== req.functionId + + return ( + }> + + {registered === undefined ? ( +
+ · the response carried no `registered` flag, so whether this id is on + the bus is unconfirmed +
+ ) : null} + {registered === false ? ( +
+ · the response reports `registered: false` — the function is not + callable on the bus +
+ ) : null} + {mismatch ? ( +
+ · the response registered {redactRuntimeIds(resId)}, not the id in the + request +
+ ) : null} + + +
+ ) +} + +/** + * Not settled yet: in flight, or held at the approval gate. + * + * At the gate the `input` may be the gate's `arguments_excerpt` — every string + * clipped to 256 code points with a trailing `…`. This is the one card gating + * arbitrary code publication onto the bus, so a clipped source is labelled an + * excerpt rather than presented as the whole program. + */ +function PendingView({ + message, + running, +}: { + message: FunctionTriggerMessage + running: boolean +}) { + const req = parseRequest(message.input) + const clipped = looksClipped(req.source) + const anyClipped = + clipped || looksClipped(req.description) || looksClipped(req.functionId) + + return ( + } + > +
+ {running ? '· registering…' : '· will register this function:'} +
+ {anyClipped ? ( +
+ · the approval gate clips strings to 256 characters — anything ending + in … is partial +
+ ) : null} + + + +
+ ) +} + +export function createRegisterFunctionRenderer( + host: Host, +): FunctionTriggerRenderer { + void host // this card reads nothing off the host + const isMatch = (functionId: string) => functionId === FUNCTION_ID + + const render = ( + message: FunctionTriggerMessage, + running: boolean, + ): React.ReactNode | null => { + if (!isMatch(message.functionId)) return null + // The host draws the approval bar around `tryRenderPreview`. + if (message.pendingApproval) return null + // Not a record — e.g. a double-encoded (stringified) payload, which the + // default card knows how to unpack and this one does not. Fall through + // rather than asserting "no source in the request" about a call that + // publishes arbitrary code. + if (!asRecord(message.input)) return null + if (running) return + // Denied at the gate — no source was ever published to the bus, so this + // must not read as one of the infrastructure failures `ErrorCard` means. + // Checked before `errorInfo`: a denial is also `'error' in output`-shaped. + const denied = deniedInfo(message.output) + if (denied) { + return ( + + ) + } + // Our own error card, never the default one: this request has no + // runtime_id, but a caller-chosen function_id/description/source could + // still embed one from elsewhere, and errorInfo's message goes through + // redactRuntimeIds either way — belt-and-braces over trusting the + // default view. + const err = errorInfo(message.output) + if (err) { + return + } + // No parseable response body — an aborted call, or a reloaded session + // whose last call never paired. That is a normal state, not a completed + // registration, so let the console's own "response · empty" card say so. + if (!asRecord(unwrapEnvelope(message.output))) return null + return + } + + return { + id: 'code-runner/page.js#register-function', + isMatch, + tryRender: (message) => render(message, !!message.running), + tryRenderRunning: (message) => render(message, true), + // Worth a preview: the approver is about to publish code onto the bus, and + // the namespace it claims is what they most need to check before saying + // yes. + tryRenderPreview: (message) => + isMatch(message.functionId) && + message.pendingApproval && + asRecord(message.input) ? ( + + ) : null, + } +} diff --git a/code-runner/ui/src/function-trigger-message/teardown.test.tsx b/code-runner/ui/src/function-trigger-message/teardown.test.tsx new file mode 100644 index 000000000..e16c7cd18 --- /dev/null +++ b/code-runner/ui/src/function-trigger-message/teardown.test.tsx @@ -0,0 +1,282 @@ +/** + * `code-runner::teardown` card — every state rendered for real, with the + * capability rule (a full `rt-` never reaches the DOM) asserted on each. + * + * Renders against a stubbed `@iii-dev/console-ui`: the real package's JS entry + * throws by design (it is compile-time-only, served at runtime by the + * console's import map). + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { createTeardownRenderer } from './teardown' + +vi.mock('@iii-dev/console-ui', () => ({ + Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), + TooltipContent: () => null, +})) + +const ID = 'rt-3f9a2c1e-4b5d-6e7f-8a9b-0c1d2e3f4a5b' +// biome-ignore lint/suspicious/noExplicitAny: test doubles +const r = createTeardownRenderer({} as any) +// biome-ignore lint/suspicious/noExplicitAny: test doubles +const msg = (over: any) => ({ + id: 'm', + role: 'function-trigger', + functionId: 'code-runner::teardown', + input: { runtime_id: ID }, + createdAt: 0, + ...over, +}) +// biome-ignore lint/suspicious/noExplicitAny: test doubles +const html = (node: any) => renderToStaticMarkup(node) + +describe('teardown card', () => { + it('matches only its own op', () => { + expect(r.isMatch('code-runner::teardown')).toBe(true) + expect(r.isMatch('code-runner::eval')).toBe(false) + expect(r.isMatch('code-runner::inject-guidance')).toBe(false) + expect(r.tryRender(msg({ functionId: 'code-runner::eval' }))).toBeNull() + }) + + it('empty unregistered reads as normal', () => { + const out = html( + r.tryRender( + msg({ output: { runtime_id: ID, torn_down: true, unregistered: [] } }), + ), + ) + expect(out).toContain('registered no functions') + expect(out).toContain('sandbox microVM(s) were stopped') + expect(out).not.toContain(ID) + expect(out).toContain('rt-3f9a…') + }) + + it('lists the ids that stopped resolving', () => { + const out = html( + r.tryRender( + msg({ + output: { + runtime_id: ID, + torn_down: true, + unregistered: ['app::a', 'app::b'], + }, + }), + ), + ) + expect(out).toContain('2 function ids no longer resolve') + expect(out).toContain('app::a') + expect(out).toContain('app::b') + }) + + it('redacts a runtime id embedded in a function id', () => { + const out = html( + r.tryRender( + msg({ output: { torn_down: true, unregistered: [`app::${ID}`] } }), + ), + ) + expect(out).not.toContain(ID) + }) + + it('keeps malformed entries visible', () => { + const out = html( + r.tryRender( + msg({ output: { torn_down: true, unregistered: ['app::a', 7, null] } }), + ), + ) + expect(out).toContain('3 function ids') + expect(out).toContain('malformed entry 2') + expect(out).toContain('malformed entry 3') + }) + + it('clamps a long list and offers expansion', () => { + const ids = Array.from({ length: 40 }, (_, i) => `app::f${i}`) + const out = html( + r.tryRender(msg({ output: { torn_down: true, unregistered: ids } })), + ) + expect(out).toContain('40 function ids') + expect(out).toContain('expand · 40 ids') + expect(out).toContain('app::f11') + expect(out).not.toContain('app::f12') + }) + + it('does not claim a count when unregistered is absent', () => { + const out = html( + r.tryRender(msg({ output: { runtime_id: ID, torn_down: true } })), + ) + expect(out).toContain('did not list which function ids') + }) + + it('reflects torn_down:false', () => { + const out = html( + r.tryRender( + msg({ output: { runtime_id: ID, torn_down: false, unregistered: [] } }), + ), + ) + expect(out).toContain('NOT torn down') + expect(out).toContain('cr-ui-warn') + }) + + it('unwraps the harness envelope', () => { + const out = html( + r.tryRender( + msg({ + output: { + content: [{ type: 'text', text: '{}' }], + details: { torn_down: true, unregistered: ['app::a'] }, + }, + }), + ), + ) + expect(out).toContain('app::a') + }) + + it('renders errors itself, redacted', () => { + const out = html( + r.tryRender( + msg({ output: { error: { message: `unknown runtime_id ${ID}` } } }), + ), + ) + expect(out).not.toContain(ID) + expect(out).toContain('unknown runtime_id rt-3f9a…') + expect(out).toContain('cr-ui-alert') + }) + + it('redacts the Expired message shape too', () => { + const out = html( + r.tryRender( + msg({ + output: { error: `runtime ${ID} expired: its idle VM was reaped` }, + }), + ), + ) + expect(out).not.toContain(ID) + }) + + /** + * A gate DENIAL means no runtime was ever touched — the `'error' in + * output` shape `errorInfo` matches also matches the gate's + * DenialEnvelope, so this must be caught first and read as "never ran" + * rather than the infrastructure-failure `ErrorCard`. + */ + it('a gate denial reads as "never ran", not as an infrastructure failure, and leaks nothing', () => { + const out = html( + r.tryRender( + msg({ + output: { + error: { + kind: 'function_error', + message: 'Rejected by operator.', + details: { + schema_version: 1, + status: 'denied', + denied_by: 'user', + function_id: 'code-runner::teardown', + reason: 'Rejected by operator.', + args_excerpt: { runtime_id: ID }, + }, + }, + }, + }), + ), + ) + expect(out).toContain('denied at the gate') + expect(out).toContain('never ran') + expect(out).toContain('user') + expect(out).not.toContain('cr-ui-alert') + expect(out).not.toContain(ID) + // No RuntimeChip — a call the gate denied never touched a runtime. + expect(out).not.toContain('rt-3f9a…') + }) + + it('falls through when there is no output', () => { + expect(r.tryRender(msg({}))).toBeNull() + expect(r.tryRender(msg({ output: undefined, running: false }))).toBeNull() + expect(r.tryRender(msg({ output: 'not a record' }))).toBeNull() + }) + + it('renders a running card without asserting an outcome', () => { + const out = html(r.tryRenderRunning?.(msg({ running: true }))) + expect(out).toContain('tearing down') + expect(out).not.toContain('destroyed') + expect(out).toContain('rt-3f9a…') + expect(out).not.toContain(ID) + // running with a non-record input: card, no chip, no claim + const bare = html( + r.tryRenderRunning?.(msg({ running: true, input: '{"runtime_id":"x"}' })), + ) + expect(bare).toContain('tearing down') + expect(bare).not.toContain('cr-ui-rt') + }) + + it('previews only on the approval gate, and falls through on non-record input', () => { + expect(r.tryRenderPreview?.(msg({}))).toBeNull() + expect(r.tryRender(msg({ pendingApproval: true }))).toBeNull() + const out = html(r.tryRenderPreview?.(msg({ pendingApproval: true }))) + expect(out).toContain('will destroy this runtime') + expect(out).not.toContain(ID) + expect( + r.tryRenderPreview?.( + msg({ pendingApproval: true, input: '{"runtime_id":"x"}' }), + ), + ).toBeNull() + expect( + r.tryRenderPreview?.(msg({ pendingApproval: true, input: null })), + ).toBeNull() + }) +}) + +/** + * The other addressing mode: `namespace` (every runtime — one per language + * — backing a `register_function` namespace), which carries no capability + * of its own, so it renders as plain text rather than through `RuntimeChip`. + */ +describe('teardown by namespace', () => { + const nsMsg = (over: object) => + msg({ input: { namespace: 'app' }, ...over }) + + it('shows a namespace chip, not a runtime chip, when settled', () => { + const out = html( + r.tryRender( + nsMsg({ + output: { + namespace: 'app::', + torn_down: true, + unregistered: ['app::a', 'app::b'], + }, + }), + ), + ) + expect(out).toContain('namespace') + expect(out).toContain('app::') + expect(out).not.toContain('cr-ui-rt') + expect(out).toContain('2 function ids no longer resolve') + expect(out).toContain('sandbox microVM(s) were stopped') + }) + + it('previews destroying every runtime backing the namespace', () => { + const out = html(r.tryRenderPreview?.(nsMsg({ pendingApproval: true }))) + expect(out).toContain('will destroy every runtime backing this namespace') + expect(out).toContain('app') + }) + + it('shows the namespace while running, with no runtime chip', () => { + const out = html(r.tryRenderRunning?.(nsMsg({ running: true }))) + expect(out).toContain('tearing down') + expect(out).toContain('app') + expect(out).not.toContain('cr-ui-rt') + }) + + it('renders an error for a namespace teardown without a runtime chip', () => { + const out = html( + r.tryRender( + nsMsg({ + output: { error: 'no runtime is registered for namespace "app::"' }, + }), + ), + ) + expect(out).toContain('no runtime is registered') + expect(out).not.toContain('cr-ui-rt') + }) +}) diff --git a/code-runner/ui/src/function-trigger-message/teardown.tsx b/code-runner/ui/src/function-trigger-message/teardown.tsx new file mode 100644 index 000000000..4b52858a7 --- /dev/null +++ b/code-runner/ui/src/function-trigger-message/teardown.tsx @@ -0,0 +1,227 @@ +/** + * `code-runner::teardown` — destroys one or more runtimes: it unregisters + * every bus function they put on the bus and stops the sandbox microVM(s) + * behind them (`RuntimeManager::destroy_runtime` drains in-flight evals, + * unregisters, then calls `sandbox::stop` — manager.rs). + * + * The card answers one question: WHICH function ids stopped resolving. That is + * the consequence a reader cannot recover from anywhere else. An empty + * `unregistered` list is the COMMON case — a runtime that only ever ran evals + * registered nothing — so it reads as normal, never as a failure. + * + * TWO addressing modes, never both, never neither: `runtime_id` (a kept + * eval's runtime — `code-runner::eval keep=true`) or `namespace` (every + * runtime, one per language, backing a `register_function` namespace). The + * response echoes whichever one addressed the call, never both — see + * `targetOf`. + * + * A preview IS offered even for this single-field-or-the-other request, for + * the same reason the error card is rendered here rather than fallen through + * to: the console's default card prints `runtime_id` in full, and a runtime + * id is a capability (see ../lib/shared.tsx). The one case that still falls + * through is a non-record `input` — see `tryRenderPreview`. + */ + +import type { + FunctionTriggerMessage, + FunctionTriggerRenderer, + Host, +} from '@iii-dev/console-ui' +import { useState } from 'react' +import { + asRecord, + CardShell, + DeniedCard, + ErrorCard, + deniedInfo, + errorInfo, + redactRuntimeIds, + RegisteredIds, + RuntimeChip, + unwrapEnvelope, +} from '../lib/shared' + +const FUNCTION_ID = 'code-runner::teardown' + +/** Ids listed before the list collapses — a runtime may hold up to 64. */ +const GONE_CLAMP = 12 + +type Target = + | { kind: 'runtime'; id: string } + | { kind: 'namespace'; name: string } + +/** + * Which of the two addressing modes a record (request or response) carries. + * `runtime_id` wins if a malformed record somehow carried both non-empty — + * the worker itself refuses that combination, so this is purely a display + * tie-break, never a claim about what the worker did. + */ +function targetOf(value: unknown): Target | undefined { + const rec = asRecord(value) + const id = rec?.runtime_id + if (typeof id === 'string' && id.length > 0) return { kind: 'runtime', id } + const ns = rec?.namespace + if (typeof ns === 'string' && ns.length > 0) return { kind: 'namespace', name: ns } + return undefined +} + +function TargetChip({ target }: { target?: Target }) { + if (!target) return null + if (target.kind === 'runtime') return + return ( + + namespace + {redactRuntimeIds(target.name)} + + ) +} + +/** + * `unregistered`, with non-string entries kept as visible placeholders rather + * than filtered away — a dropped entry would understate the blast radius. + * `undefined` when the field is absent or not an array, so the card can say + * "the response didn't list them" instead of claiming zero. + */ +function goneIds(result: Record | undefined) { + const raw = result?.unregistered + if (!Array.isArray(raw)) return undefined + return raw.map((v, i) => + typeof v === 'string' ? v : `⟨malformed entry ${i + 1}⟩`, + ) +} + +function SettledView({ message }: { message: FunctionTriggerMessage }) { + const [expanded, setExpanded] = useState(false) + const result = asRecord(unwrapEnvelope(message.output)) + const gone = goneIds(result) + // Only `torn_down === false` is a claim; a missing field is not. + const kept = result?.torn_down === false + // The response echoes whichever target addressed the call; the request is + // the fallback (e.g. an error response that carries neither). + const target = targetOf(result) ?? targetOf(message.input) + + const collapsed = !!gone && gone.length > GONE_CLAMP && !expanded + const shown = collapsed ? gone.slice(0, GONE_CLAMP) : (gone ?? []) + + return ( + }> +
+
+ {kept + ? '· the worker reported this was NOT torn down' + : '· destroyed — its sandbox microVM(s) were stopped'} +
+
+ {gone === undefined + ? '· the response did not list which function ids were unregistered' + : gone.length === 0 + ? '· it had registered no functions, so nothing stopped resolving on the bus' + : gone.length === 1 + ? '· 1 function id no longer resolves on the bus' + : `· ${gone.length} function ids no longer resolve on the bus`} +
+
+
+ + {gone && gone.length > GONE_CLAMP ? ( +
+ +
+ ) : null} +
+
+ ) +} + +function RunningView({ message }: { message: FunctionTriggerMessage }) { + // `input` is the only source of the target here, and a non-record one + // simply means no chip — the card claims nothing either way. Falling + // through instead would hand the raw request to the default card, which + // prints the capability in full. + return ( + }> +
· tearing down…
+
+ ) +} + +/** Pending approval: what is about to be destroyed. */ +function PreviewView({ message }: { message: FunctionTriggerMessage }) { + // The approval gate clips string arguments to 256 code points; a + // `rt-` is 39 and a namespace is short, so the value shown (and + // copied) here is always whole. + const target = targetOf(message.input) + return ( + }> +
+ {target?.kind === 'namespace' + ? 'will destroy every runtime backing this namespace — every function it registered stops resolving on the bus, and its sandbox microVM(s) are stopped' + : 'will destroy this runtime — every function it registered stops resolving on the bus, and its sandbox microVM is stopped'} +
+
+ ) +} + +export function createTeardownRenderer(host: Host): FunctionTriggerRenderer { + void host + const render = ( + message: FunctionTriggerMessage, + running: boolean, + ): React.ReactNode | null => { + if (message.functionId !== FUNCTION_ID) return null + if (message.pendingApproval) return null // tryRenderPreview handles it + if (running) return + // Denied at the gate — no runtime was ever touched, so this must not + // read as one of the infrastructure failures `ErrorCard` means. Checked + // before `errorInfo`: a denial is also `'error' in output`-shaped. + const denied = deniedInfo(message.output) + if (denied) { + return ( + + ) + } + // Our own error card, never a fall-through: code-runner's error MESSAGES + // carry the runtime_id by design on the by-id path — `unknown runtime_id + // {id}`, `runtime {id} expired: …` (error.rs) — and the default view + // would print that capability verbatim on an ordinary stale-id mistake. + // `ErrorCard` redacts the message either way. + const err = errorInfo(message.output) + if (err) { + const target = targetOf(message.input) + return ( + + ) + } + // No response body at all (aborted call, or a reloaded session whose last + // call never paired). Fall through so the console shows its own + // "response · empty" card — never assert a teardown that may not have + // happened. + if (!asRecord(unwrapEnvelope(message.output))) return null + return + } + return { + id: 'code-runner/page.js#teardown', + isMatch: (functionId) => functionId === FUNCTION_ID, + tryRender: (message) => render(message, !!message.running), + tryRenderRunning: (message) => render(message, true), + // A non-record `input` falls through: the console's default card decodes + // double-encoded payloads, and an approver of a destructive call must see + // the real request rather than a card that quietly shows no target. + tryRenderPreview: (message) => + message.pendingApproval && + message.functionId === FUNCTION_ID && + asRecord(message.input) ? ( + + ) : null, + } +} diff --git a/code-runner/ui/src/lib/shared.test.tsx b/code-runner/ui/src/lib/shared.test.tsx new file mode 100644 index 000000000..ebfa1cce3 --- /dev/null +++ b/code-runner/ui/src/lib/shared.test.tsx @@ -0,0 +1,202 @@ +/** + * The shell's own checks: the pieces with logic in them (id redaction, + * language mapping, stream clamping, exit tone) rendered for real through + * `react-dom/server`. + * + * The per-card redaction suite lives beside the renderers + * (`../function-trigger-message/`), the way node-engine's does; this file + * covers what those cards build on. + * + * Renders against a stubbed `@iii-dev/console-ui` — the real package's JS + * entry throws by design (it is compile-time-only, served at runtime by the + * console's import map, see packages/console-ui/index.js). + */ + +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { + ExitStatus, + langToPrism, + RuntimeChip, + redactRuntimeIds, + Stream, + truncateRuntimeId, +} from './shared' + +vi.mock('@iii-dev/console-ui', () => ({ + Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), + TooltipContent: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + CodeHighlight: ({ code }: { code: string }) => ( +
{code}
+ ), + JsonHighlight: ({ code }: { code: string }) => ( +
{code}
+ ), +})) + +const RUNTIME_ID = 'rt-3f9a2c1e-7b64-4d0a-9c11-5e8ab2d4f077' +const TRUNCATED = truncateRuntimeId(RUNTIME_ID) + +const html = (node: React.ReactNode) => renderToStaticMarkup(node) + +describe('redactRuntimeIds', () => { + it('replaces a bare runtime id with its truncated form', () => { + expect(redactRuntimeIds(RUNTIME_ID)).toBe(TRUNCATED) + }) + + /** error.rs quotes the id by design — `unknown runtime_id {id}`. */ + it('replaces the id embedded in code-runner own error messages', () => { + for (const message of [ + `code-runner::runtime_not_found: unknown runtime_id ${RUNTIME_ID}`, + `code-runner::expired: runtime ${RUNTIME_ID} expired: its idle VM was reaped`, + ]) { + expect(redactRuntimeIds(message)).not.toContain(RUNTIME_ID) + expect(redactRuntimeIds(message)).toContain(TRUNCATED) + } + }) + + it('leaves ordinary text and non-uuid-shaped ids untouched', () => { + expect(redactRuntimeIds('app::save')).toBe('app::save') + expect(redactRuntimeIds('rt-custom-short-id')).toBe('rt-custom-short-id') + }) + + it('is case-insensitive on the hex groups', () => { + const upper = RUNTIME_ID.toUpperCase() + expect(redactRuntimeIds(upper)).toBe(truncateRuntimeId(upper)) + }) + + /** + * `\b` never fires between two word characters, and hex digits ARE word + * characters — so a runtime id glued to `[A-Za-z0-9_]` on either side + * matched neither anchor and passed through whole. Proven against the real + * shapes this worker produces: eval stdout/stderr embedding an id inside a + * filename or identifier, and register_function/teardown ids suffixed with + * `::a`-style segments. + */ + it('redacts an id with no non-word character on either side', () => { + const cases = [ + `${RUNTIME_ID}_worker`, + `prefix_${RUNTIME_ID}`, + `/tmp/${RUNTIME_ID}_out.json`, + `app::${RUNTIME_ID}_a`, + `${RUNTIME_ID}1`, + ] + for (const text of cases) { + const redacted = redactRuntimeIds(text) + expect(redacted).not.toContain(RUNTIME_ID) + expect(redacted).toContain(TRUNCATED) + } + }) +}) + +describe('RuntimeChip', () => { + it('never puts the full id in the DOM, not even in the aria-label', () => { + const out = html() + expect(out).not.toContain(RUNTIME_ID) + expect(out).toContain(TRUNCATED) + }) +}) + +describe('langToPrism', () => { + it('maps the two runtimes to their Prism ids', () => { + expect(langToPrism('node')).toBe('javascript') + expect(langToPrism('python')).toBe('python') + }) + + /** register_function carries no `lang` — guessing one would be a claim. */ + it('is undefined for anything else, including a missing field', () => { + for (const v of [undefined, null, '', 'js', 'deno', 42, {}]) { + expect(langToPrism(v)).toBeUndefined() + } + }) +}) + +describe('Stream', () => { + it('renders nothing for an empty stream', () => { + expect(html()).toBe('') + }) + + it('shows short output whole, with no expand affordance', () => { + const out = html() + expect(out).toContain('a\nb\nc') + expect(out).not.toContain('expand') + }) + + /** A chatty script must not flood the chat: clamp, and say so. */ + it('clamps long output and offers expansion', () => { + const text = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n') + const out = html() + expect(out).toContain('line 0') + expect(out).not.toContain('line 199') + expect(out).toContain('expand') + expect(out).toContain('200 lines') + }) + + /** One 400 KB line has one newline and still has to be clamped. */ + it('clamps on characters too, not just newlines', () => { + const out = html() + expect(out.length).toBeLessThan(10_000) + expect(out).toContain('expand') + }) + + it('redacts a runtime id that reaches program output', () => { + const out = html() + expect(out).not.toContain(RUNTIME_ID) + }) +}) + +describe('ExitStatus', () => { + it('reads a zero exit as clean', () => { + const out = html() + expect(out).toContain('exit 0') + expect(out).toContain('clean exit') + expect(out).toContain('42ms') + expect(out).toContain('cr-ui-exit-code ok') + }) + + /** + * The distinction the whole card exists for: a non-zero exit is the user's + * script failing (warn), never a system error (alert). + */ + it('reads a non-zero exit as the script failing, not a system error', () => { + const out = html() + expect(out).toContain('exit 1') + expect(out).toContain('stderr') + expect(out).toContain('cr-ui-exit-code failed') + expect(out).not.toContain('cr-ui-alert') + }) + + it('says so rather than inventing one when the exit code is missing', () => { + const out = html() + expect(out).toContain('exit ?') + expect(out).toContain('no exit code in the response') + }) + + /** + * `manager.rs`'s `success: …unwrap_or(false)` makes `{exit_code: 0, + * success: false}` reachable from an honest daemon reply, not just + * malformed input. The badge must keep saying `exit 0` (that is what + * happened) while the note stops claiming "exited non-zero" — a claim the + * badge right next to it disproves. + */ + it('does not contradict itself on a 0 exit code paired with success: false', () => { + const out = html() + expect(out).toContain('exit 0') + expect(out).toContain('cr-ui-exit-code failed') + expect(out).not.toContain('exited non-zero') + expect(out).toContain('success: false') + }) + + /** An omitted `success` is not promoted to a claim either way — exit code + * 0 alone still reads as clean. */ + it('reads a 0 exit with no success field as clean', () => { + const out = html() + expect(out).toContain('clean exit') + expect(out).toContain('cr-ui-exit-code ok') + }) +}) diff --git a/code-runner/ui/src/lib/shared.tsx b/code-runner/ui/src/lib/shared.tsx new file mode 100644 index 000000000..7e76f0a94 --- /dev/null +++ b/code-runner/ui/src/lib/shared.tsx @@ -0,0 +1,523 @@ +/** + * Shared building blocks for every code-runner function-trigger renderer. + * + * The three cards (eval / register_function / teardown) differ only in their + * body — the frame, the runtime-id chip, the terminal streams, the exit + * status and the id list are identical, and live here. + * + * SECURITY — `runtime_id` is a capability: whoever holds one can eval into + * or tear down that runtime. It is NEVER rendered in full. `RuntimeChip` is + * the only sanctioned way to show one: truncated, with the full value + * reachable only by an explicit click-to-copy. Any other text that could + * embed one (an error message, a line of stdout, a function id) goes through + * `redactRuntimeIds` first. + * + * What code-runner is NOT: node-engine. An eval here returns a PROCESS + * result — stdout, stderr, exit code — not a completion value plus captured + * console lines. A non-zero exit is a normal response carrying the user's own + * compiler or runtime message; errors are reserved for infrastructure + * failures. `Stream` and `ExitStatus` exist to keep that distinction visible. + */ + +import { Tooltip, TooltipContent, TooltipTrigger } from '@iii-dev/console-ui' +import { useCallback, useState } from 'react' + +/* --- payload helpers -------------------------------------------------- */ + +/** Narrow to a plain object, or `undefined` for anything else (incl. arrays). */ +export function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return undefined + return value as Record +} + +/** `{ content: [...], details }` harness result envelope → details. */ +export function unwrapEnvelope(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value + const obj = value as Record + if (Array.isArray(obj.content) && 'details' in obj) return obj.details + return value +} + +export function isErrorOutput(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + 'error' in (value as Record) + ) +} + +export const FUNCTION_PREFIX = 'code-runner::' + +/** `code-runner::eval` → `eval` (the op pill's label). */ +export function opName(functionId: string): string { + return functionId.startsWith(FUNCTION_PREFIX) + ? functionId.slice(FUNCTION_PREFIX.length) + : functionId +} + +/** + * The Prism id for an eval request's `lang`, or `undefined` when there is no + * honest answer. + * + * Only `eval` carries a language: `register_function`'s request has no `lang` + * field at all (the language belongs to the runtime the function is being + * registered into), so that card CANNOT know it from the payload. `undefined` + * means "render unhighlighted" — never guess a language onto a code block, + * a wrong one reads as a claim about what will execute. + */ +export function langToPrism(lang: unknown): string | undefined { + if (lang === 'node') return 'javascript' + if (lang === 'python') return 'python' + return undefined +} + +/* --- runtime id (capability) ------------------------------------------ */ + +/** `rt-3f9a2c1e-…` → `rt-3f9a…`. Short ids are shown whole. */ +export function truncateRuntimeId(runtimeId: string): string { + return runtimeId.length > 8 ? `${runtimeId.slice(0, 7)}…` : runtimeId +} + +/** + * `manager.rs` mints `rt-` (`format!("rt-{}", Uuid::new_v4())`), and + * code-runner's own error MESSAGES quote it by design — `RuntimeNotFound` is + * "unknown runtime_id {id}" and `Expired` is "runtime {id} expired: …" + * (error.rs, a documented exception to the redaction convention: those go to + * the caller who already holds the id). The console feed is not that caller, + * so every string that could embed one — an error message, a line of program + * output, a bus function id — runs through this before it is rendered. + * + * No `\b` anchors: hex digits are word characters, so an id glued to + * `[A-Za-z0-9_]` (`_worker`, `app::_a`, `/tmp/_out.json`) matches + * neither boundary and would pass through whole. Matching the bare 39-char + * shape unanchored can only redact MORE, never less — it is the capability + * regardless of what touches it. + */ +const RUNTIME_ID_PATTERN = + /rt-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi + +/** Replace every `rt-` substring of `text` with its truncated form. */ +export function redactRuntimeIds(text: string): string { + return text.replace(RUNTIME_ID_PATTERN, (id) => truncateRuntimeId(id)) +} + +/** + * `redactRuntimeIds` over EVERY string in an arbitrary JSON-ish value — + * object keys included (a namespace-less runtime's id shows up as a key + * whenever a payload maps registrations by namespace). Objects, arrays, + * numbers, booleans and null keep their shape; the input is never mutated. + * + * This is what the renderers hand the console as `redactRaw`: the card's + * `raw json` tab renders the request/response verbatim and its copy button + * copies them, so a card that shows only `RuntimeChip` has not contained the + * capability until the raw value is filtered too. + * + * `seen` is the current PATH, not every visited node: a value referenced + * twice is redacted twice (correct), while a cycle collapses to + * `'[circular]'` rather than hanging the console. JSON off the wire cannot + * be cyclic, but `redactRaw` must be total for whatever it is handed. + */ +export function redactRuntimeIdsDeep( + value: unknown, + seen: WeakSet = new WeakSet(), +): unknown { + if (typeof value === 'string') return redactRuntimeIds(value) + if (value === null || typeof value !== 'object') return value + if (seen.has(value)) return '[circular]' + seen.add(value) + const out = Array.isArray(value) + ? value.map((entry) => redactRuntimeIdsDeep(entry, seen)) + : Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [ + redactRuntimeIds(k), + redactRuntimeIdsDeep(v, seen), + ]), + ) + seen.delete(value) + return out +} + +async function copyText(text: string): Promise { + try { + // Undefined outside a secure context (plain http:// over a LAN). + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch { + /* fall through to the textarea path */ + } + try { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + const ok = document.execCommand('copy') + document.body.removeChild(ta) + return ok + } catch { + return false + } +} + +/** + * The truncated runtime-id chip. The full id is a capability, so it is only + * ever handed over on an explicit click (copied to the clipboard), never + * printed into the feed. + */ +export function RuntimeChip({ runtimeId }: { runtimeId: string }) { + const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle') + + const copy = useCallback(() => { + void copyText(runtimeId).then((ok) => { + setState(ok ? 'copied' : 'failed') + window.setTimeout(() => setState('idle'), 1400) + }) + }, [runtimeId]) + + return ( + + + + + + A runtime id is a capability — anyone holding it can eval into or tear + down that sandbox, so only a prefix is shown. Click to copy the full id. + + + ) +} + +/* --- card frame -------------------------------------------------------- */ + +/** + * The frame every code-runner card shares: op pill, caller-supplied chips, + * the "code-runner ui" attribution tag (so an override is distinguishable + * from first-party rendering), and the body. + */ +export function CardShell({ + op, + running, + tag = 'code-runner ui', + chips, + children, +}: { + op: string + running?: boolean + tag?: string + chips?: React.ReactNode + children?: React.ReactNode +}) { + return ( +
+
+ {op} + {chips} + {tag} +
+ {children} +
+ ) +} + +/* --- terminal streams --------------------------------------------------- */ + +/** Lines of a stream shown before it collapses behind a toggle. */ +const STREAM_CLAMP_LINES = 12 +/** …and a character ceiling, for the one 400 KB line a minifier emits. */ +const STREAM_CLAMP_CHARS = 2000 + +/** + * `stdout` / `stderr` as terminal output: monospace, whitespace preserved, + * clamped so a chatty script cannot flood the chat (the CSS caps the height + * and scrolls; this caps what is in the DOM at all). `null` for an empty + * string — the caller decides what "no output" should say, if anything. + * + * `tone="err"` tints the stream, and nothing more: stderr on a non-zero exit + * is the user's own compiler or runtime message, not a system error. + */ +export function Stream({ + label, + text, + tone = 'out', +}: { + label: string + text: string + tone?: 'out' | 'err' +}) { + const [expanded, setExpanded] = useState(false) + if (text.length === 0) return null + + const safe = redactRuntimeIds(text) + const lines = safe.split('\n') + const long = + lines.length > STREAM_CLAMP_LINES || safe.length > STREAM_CLAMP_CHARS + const collapsed = long && !expanded + const shown = collapsed + ? lines.slice(0, STREAM_CLAMP_LINES).join('\n').slice(0, STREAM_CLAMP_CHARS) + : safe + + return ( +
+
{label}
+
{shown}
+ {long ? ( + + ) : null} +
+ ) +} + +/* --- exit status -------------------------------------------------------- */ + +/** + * The one-line verdict on an eval: exit code, what it means, how long it took. + * + * A non-zero exit is NOT an error. code-runner reserves errors for + * infrastructure failures; a script that throws comes back as an ordinary + * response with its message in `stderr`. So a failing exit is `--color-warn` + * ("your program failed") and never `--color-alert` ("the system failed"). + */ +export function ExitStatus({ + exitCode, + success, + durationMs, +}: { + exitCode?: number + success?: boolean + durationMs?: number +}) { + const cleanExit = exitCode === 0 + // An omitted `success` (undefined) never gets promoted to a claim either + // way — exit code 0 alone reads as clean, same as before. What must NOT + // happen is treating `success: false` on a 0 exit code as the ordinary + // "non-zero exit" case: manager.rs's `success: …unwrap_or(false)` makes + // exactly that pair reachable from an honest daemon reply, and the note + // has to describe what is actually shown (`exit 0`), not contradict it. + const ok = cleanExit && success !== false + const note = + exitCode === undefined + ? 'no exit code in the response' + : ok + ? 'clean exit' + : cleanExit + ? 'exit 0, but the response reported success: false — its own message is in stderr' + : 'the script exited non-zero — its own message is in stderr' + + return ( +
+ + {exitCode === undefined ? 'exit ?' : `exit ${exitCode}`} + + {note} + {durationMs === undefined ? null : ( + {durationMs}ms + )} +
+ ) +} + +/* --- registered function ids ------------------------------------------- */ + +/** + * Compact list of bus function ids a call touched. `null` when empty. + * + * The shared sink for every id list in this UI — `id` is redacted here so + * every call site gets the fix once. Ids are caller-chosen, but nothing stops + * a caller naming one after its runtime. + */ +export function RegisteredIds({ ids }: { ids: readonly string[] }) { + if (ids.length === 0) return null + return ( +
+ {ids.map((id) => ( + + {redactRuntimeIds(id)} + + ))} +
+ ) +} + +/* --- timeout chip ------------------------------------------------------- */ + +/** `timeout_ms`, when the request carried one. */ +export function TimeoutChip({ ms }: { ms?: number }) { + if (ms === undefined) return null + return ( + + timeout + {ms}ms + + ) +} + +/* --- errors -------------------------------------------------------------- */ + +/** + * Pull the message out of a code-runner error output. Checked at both the raw + * value and its unwrapped envelope — the same two places every renderer's + * `isErrorOutput` check looks — since which level carries the `{ error }` key + * depends on the path the failure took. + */ +export function errorInfo(output: unknown): { message: string } | undefined { + const direct = asRecord(output) + const nested = asRecord(unwrapEnvelope(output)) + const rec = isErrorOutput(direct) + ? direct + : isErrorOutput(nested) + ? nested + : undefined + if (!rec) return undefined + const err = rec.error + const errObj = asRecord(err) + const message = + typeof err === 'string' + ? err + : typeof errObj?.message === 'string' + ? errObj.message + : JSON.stringify(err) + return { message } +} + +/** + * The error card every code-runner renderer shows instead of falling through + * to the console's default error view: code-runner's error MESSAGES carry the + * runtime_id capability by design (`unknown runtime_id {id}`, + * `runtime {id} expired: …` — error.rs), so the unredacted default view would + * print it verbatim on an ordinary mistake. + * + * This is an infrastructure failure — the runtime is gone, the daemon refused, + * the deadline blew. A script that merely exited non-zero never lands here. + * A call the approval gate DENIED never lands here either — see `DeniedCard`. + */ +export function ErrorCard({ + op, + runtimeId, + message, +}: { + op: string + runtimeId?: string + message: string +}) { + return ( + : null} + > +
+ {redactRuntimeIds(message)} +
+
+ ) +} + +/* --- gate denials ------------------------------------------------------- */ + +/** + * A deny/timeout resolution from the approval gate rides in `error.details` + * as the gate's DenialEnvelope (`{ status: 'denied', denied_by, reason, + * args_excerpt, … }` — approval-gate/src/types.rs, assembled by + * approval-gate/src/functions/resolve.rs). This mirrors the console's own + * `isDeniedOutput` (FunctionTriggerCard.tsx) so the two agree on what a + * denial looks like — checked at both the raw value and its unwrapped + * envelope, the same two places `errorInfo` looks. + */ +export function isDeniedOutput(output: unknown): boolean { + const direct = asRecord(output) + const nested = asRecord(unwrapEnvelope(output)) + const rec = isErrorOutput(direct) + ? direct + : isErrorOutput(nested) + ? nested + : undefined + if (!rec) return false + const details = asRecord(asRecord(rec.error)?.details) + return !!details && details.status === 'denied' && 'denied_by' in details +} + +export interface DenialInfo { + reason: string + deniedBy?: string +} + +/** `{ reason, deniedBy }` out of a denial output, or `undefined` when + * `output` is not one — see `isDeniedOutput`. */ +export function deniedInfo(output: unknown): DenialInfo | undefined { + if (!isDeniedOutput(output)) return undefined + const direct = asRecord(output) + const nested = asRecord(unwrapEnvelope(output)) + const rec = (isErrorOutput(direct) ? direct : nested) as Record< + string, + unknown + > + const err = asRecord(rec.error) + const details = asRecord(err?.details) ?? {} + const reason = + typeof details.reason === 'string' + ? details.reason + : typeof err?.message === 'string' + ? err.message + : 'denied at the gate' + const deniedBy = + typeof details.denied_by === 'string' ? details.denied_by : undefined + return { reason, deniedBy } +} + +/** + * A gate denial: the call was stopped at the approval gate and never reached + * a runtime. Distinct from `ErrorCard` on purpose — `ErrorCard` means an + * infrastructure failure AFTER the call landed (the runtime is gone, the + * daemon refused, the deadline blew), and its RuntimeChip would wrongly + * imply a runtime was involved in a call that never reached one. The + * envelope's `args_excerpt` can carry the runtime_id capability (a caller is + * free to pass one as an argument), so this never falls through to the + * console's default view and never renders anything but the redacted + * `reason` — the envelope itself is not printed. + */ +export function DeniedCard({ + op, + reason, + deniedBy, +}: { + op: string + reason: string + deniedBy?: string +}) { + return ( + +
+ · denied at the gate — this never ran + {deniedBy ? ` · denied by ${deniedBy}` : ''} +
+
{redactRuntimeIds(reason)}
+
+ ) +} diff --git a/code-runner/ui/styles.css b/code-runner/ui/styles.css new file mode 100644 index 000000000..00aa1f834 --- /dev/null +++ b/code-runner/ui/styles.css @@ -0,0 +1,326 @@ +/* + * The code-runner worker's console stylesheet, shipped as its own + * `console:style` asset (code-runner/styles.css) — the console mounts it as a + * in document.head and link-swaps it on hot reload, + * styles-before-scripts on boot. + * + * Every rule is scoped under `[data-iii-ui="code-runner"]`, the wrapper the + * console mounts around every injected render. No :root/html/body/bare + * element selectors and no @font-face — injected CSS is unlayered, so an + * unscoped rule would silently beat the console's own document-wide. + * Colours are design tokens only, so light/dark theming is free. + * + * Shared shell first (`cr-ui-*`, used by every card), then one section per + * renderer (`cr--*`, used by exactly one). Anything a second renderer + * needs moves up into the shell rather than being copied. + * + * `@keyframes` names are global even here, so they carry the prefix too. + */ + +/* --- card frame -------------------------------------------------------- */ +[data-iii-ui="code-runner"] .cr-ui-msg { + border-top: 1px solid var(--color-rule-2); + background: var(--color-bg); + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--color-ink); +} +[data-iii-ui="code-runner"] .cr-ui-msg-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + flex-wrap: wrap; +} +[data-iii-ui="code-runner"] .cr-ui-pill { + border: 1px solid var(--color-accent); + color: var(--color-accent); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1px 8px; +} +[data-iii-ui="code-runner"] .cr-ui-pill.quiet { + border-color: var(--color-rule); + color: var(--color-ink-faint); +} +[data-iii-ui="code-runner"] .cr-ui-msg-tag { + margin-left: auto; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); +} +[data-iii-ui="code-runner"] .cr-ui-msg-note { + padding: 0 12px 10px; + font-size: 12px; + color: var(--color-ink-faint); +} +[data-iii-ui="code-runner"] .cr-ui-msg-note.pulse { + animation: cr-ui-pulse 1.6s ease-in-out infinite; +} +@keyframes cr-ui-pulse { + 0%, 100% { opacity: 1 } + 50% { opacity: 0.35 } +} + +/* --- chips ------------------------------------------------------------- */ +[data-iii-ui="code-runner"] .cr-ui-chip { + border: 1px solid var(--color-rule); + color: var(--color-ink); + font-size: 11.5px; + padding: 1px 8px; + white-space: nowrap; +} +[data-iii-ui="code-runner"] .cr-ui-chip .k { color: var(--color-ink-ghost); } + +/* The runtime-id capability chip: a button, because it copies on click. */ +[data-iii-ui="code-runner"] .cr-ui-rt { + border: 1px dashed var(--color-rule); + background: transparent; + color: var(--color-ink); + font-family: inherit; + font-size: 11.5px; + padding: 1px 8px; + cursor: pointer; + white-space: nowrap; +} +[data-iii-ui="code-runner"] .cr-ui-rt .k { color: var(--color-ink-ghost); } +[data-iii-ui="code-runner"] .cr-ui-rt:hover { border-color: var(--color-accent); } +[data-iii-ui="code-runner"] .cr-ui-rt:focus-visible { + outline: 1px solid var(--color-ring); + outline-offset: 1px; +} +[data-iii-ui="code-runner"] .cr-ui-rt.copied { border-color: var(--color-ok); color: var(--color-ok); } +[data-iii-ui="code-runner"] .cr-ui-rt.failed { border-color: var(--color-alert); color: var(--color-alert); } +[data-iii-ui="code-runner"] .cr-ui-rt-flash { font-size: 10.5px; letter-spacing: 0.06em; } + +/* --- terminal streams (stdout / stderr) -------------------------------- */ +/* Process output, not log records: no per-line level gutter, whitespace + preserved, and a hard height ceiling so a chatty script scrolls inside its + own box instead of pushing the conversation off screen. */ +[data-iii-ui="code-runner"] .cr-ui-stream { + border-top: 1px solid var(--color-rule-2); + padding: 6px 12px 8px; + display: flex; + flex-direction: column; +} +[data-iii-ui="code-runner"] .cr-ui-stream-label { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); + margin-bottom: 4px; +} +[data-iii-ui="code-runner"] .cr-ui-stream-body { + margin: 0; + font-family: inherit; + font-size: 12px; + line-height: 1.5; + color: var(--color-ink); + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 260px; + overflow: auto; +} +/* stderr is the user's own compiler/runtime message on a failed script — + informational, so it is tinted, not alarmed. */ +[data-iii-ui="code-runner"] .cr-ui-stream.err .cr-ui-stream-label, +[data-iii-ui="code-runner"] .cr-ui-stream.err .cr-ui-stream-body { + color: var(--color-warn); +} + +/* --- exit status -------------------------------------------------------- */ +[data-iii-ui="code-runner"] .cr-ui-exit { + border-top: 1px solid var(--color-rule-2); + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + padding: 6px 12px; + font-size: 12px; +} +[data-iii-ui="code-runner"] .cr-ui-exit-code { + border: 1px solid var(--color-rule); + font-size: 11.5px; + padding: 1px 8px; + white-space: nowrap; +} +[data-iii-ui="code-runner"] .cr-ui-exit-code.ok { + border-color: var(--color-ok); + color: var(--color-ok); +} +/* warn, never alert: a non-zero exit is the script failing, not the worker. */ +[data-iii-ui="code-runner"] .cr-ui-exit-code.failed { + border-color: var(--color-warn); + color: var(--color-warn); +} +[data-iii-ui="code-runner"] .cr-ui-exit-note { color: var(--color-ink-faint); } +[data-iii-ui="code-runner"] .cr-ui-exit-dur { + margin-left: auto; + color: var(--color-ink-ghost); + font-size: 11.5px; +} + +/* --- registered ids ----------------------------------------------------- */ +[data-iii-ui="code-runner"] .cr-ui-ids { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 12px 10px; +} +[data-iii-ui="code-runner"] .cr-ui-id { + border: 1px solid var(--color-rule-2); + background: var(--color-paper-2); + color: var(--color-ink); + font-size: 11.5px; + padding: 1px 6px; +} + +/* --- renderer body slots ----------------------------------------------- */ +[data-iii-ui="code-runner"] .cr-ui-section { + border-top: 1px solid var(--color-rule-2); + padding: 8px 12px; +} +[data-iii-ui="code-runner"] .cr-ui-section-label { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); + margin-bottom: 6px; +} +[data-iii-ui="code-runner"] .cr-ui-code { + border: 1px solid var(--color-rule-2); + background: var(--color-panel); + font-size: 12px; + max-height: 320px; + overflow: auto; +} +[data-iii-ui="code-runner"] .cr-ui-warn { color: var(--color-warn); } +[data-iii-ui="code-runner"] .cr-ui-alert { color: var(--color-alert); } + +/* The "expand · N lines" / "collapse" link. Shared by every collapsible + block: the streams, eval's source, register_function's source. */ +[data-iii-ui="code-runner"] .cr-ui-toggle { + align-self: flex-start; + margin-top: 4px; + background: transparent; + border: 0; + padding: 0; + font-family: inherit; + font-size: 11px; + color: var(--color-accent); + cursor: pointer; + text-decoration: underline; +} + +/* --- eval card ---------------------------------------------------------- */ +/* A runtime the call CREATED is a resource the caller now owns and has to + tear down — the warn tint marks "you are holding something", not an error. */ +[data-iii-ui="code-runner"] .cr-eval-fresh { + border-color: var(--color-warn); + color: var(--color-warn); +} +/* `network: true` is create-time only and is what makes npm/pip installs + possible — it is the security-relevant field of the request, so it reads as + a state, not as a boolean buried in JSON. */ +[data-iii-ui="code-runner"] .cr-eval-net { + border-color: var(--color-accent); + color: var(--color-accent); +} +[data-iii-ui="code-runner"] .cr-eval-net.off { + border-color: var(--color-rule); + color: var(--color-ink-ghost); +} + +/* --- register_function card ---------------------------------------------- */ +[data-iii-ui="code-runner"] .cr-register-function-head { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +/* A function id runs to hundreds of bytes and a description further still; + neither may push the conversation off screen. */ +[data-iii-ui="code-runner"] .cr-register-function-id { + font-size: 15px; + color: var(--color-accent); + overflow-wrap: anywhere; + max-height: 84px; + overflow: auto; +} +[data-iii-ui="code-runner"] .cr-register-function-desc { + font-size: 12px; + line-height: 1.5; + color: var(--color-ink-faint); + margin-top: 2px; + overflow-wrap: anywhere; + max-height: 120px; + overflow: auto; +} +/* `.cr-ui-warn` alone is the same specificity as the two rules above, so + whichever sits later in this file would otherwise win regardless of which + one is more meaningful — an empty id or description would render at full + accent/ink-faint weight instead of the warn tint. These compound selectors + outrank both so the warn treatment always applies when the class is + actually present on the element. */ +[data-iii-ui="code-runner"] .cr-register-function-id.cr-ui-warn, +[data-iii-ui="code-runner"] .cr-register-function-desc.cr-ui-warn { + color: var(--color-warn); +} +[data-iii-ui="code-runner"] .cr-register-function-status { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.08em; + border: 1px solid var(--color-rule); + padding: 0 6px; +} +[data-iii-ui="code-runner"] .cr-register-function-status.live { + border-color: var(--color-ok); + color: var(--color-ok); +} +[data-iii-ui="code-runner"] .cr-register-function-status.refused { + border-color: var(--color-warn); + color: var(--color-warn); +} +/* The namespace the id claims for its runtime — the surprise this card exists + to spoil, so it sits under the id rather than in a chip row. */ +[data-iii-ui="code-runner"] .cr-register-function-ns { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: var(--color-ink-faint); + overflow-wrap: anywhere; +} +/* Same specificity fight as `.cr-register-function-id`/`-desc` above: an id + with no namespace must read as warn, not the routine ink-faint tone. */ +[data-iii-ui="code-runner"] .cr-register-function-ns.cr-ui-warn { + color: var(--color-warn); +} +[data-iii-ui="code-runner"] .cr-register-function-ns code { + font-family: inherit; + color: var(--color-ink); +} +/* The highlighting is a guess (this request has no `lang`), so it is labelled + as one — quietly, at ghost weight. */ +[data-iii-ui="code-runner"] .cr-register-function-lang { + margin-top: 4px; + font-size: 11px; + line-height: 1.4; + color: var(--color-ink-ghost); +} + +/* --- teardown card: ids that no longer resolve -------------------------- */ +/* Struck through, because these ids stopped resolving on the bus. Ids are + caller-chosen strings, so one long unbroken id must wrap inside its chip + rather than push the card sideways. (The count is clamped in the DOM by the + card itself — a runtime may hold up to 64 functions.) */ +[data-iii-ui="code-runner"] .cr-teardown-gone .cr-ui-id { + color: var(--color-ink-faint); + text-decoration: line-through; + text-decoration-color: var(--color-ink-ghost); + overflow-wrap: anywhere; +} +/* The "expand · N ids" toggle sits below the chips, on the list's padding. */ +[data-iii-ui="code-runner"] .cr-teardown-more { + padding: 0 12px 10px; +} diff --git a/code-runner/ui/tsconfig.json b/code-runner/ui/tsconfig.json new file mode 100644 index 000000000..e5ac60540 --- /dev/null +++ b/code-runner/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["page.tsx", "src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aad9d6fa6..74394006a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,34 @@ importers: specifier: ^5.9.2 version: 5.9.3 + code-runner/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: ^4.1.6 + version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@25.9.5)(jiti@2.7.0)) + computer/ui: dependencies: '@iii-dev/console-ui': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d635bda3f..361935bfc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ packages: - console/web - console/ui - browser/ui + - code-runner/ui - computer/ui - database/ui - editor/ui From 855d24b1c60eaf16914df47418d61b0bfe804d53 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Wed, 5 Aug 2026 14:37:00 -0300 Subject: [PATCH 03/11] feat(code-runner): guest iii global backed by the real iii-sdk client --- code-runner/README.md | 76 +- code-runner/build.rs | 5 + code-runner/iii.worker.yaml | 2 +- code-runner/src/functions/eval.rs | 25 +- code-runner/src/functions/inject_guidance.rs | 23 +- code-runner/src/functions/mod.rs | 28 +- code-runner/src/main.rs | 2 +- code-runner/src/manager.rs | 1009 ++++++++++------- code-runner/src/runner.rs | 471 ++++++++ .../tests/golden/runners/code_runner_iii.py | 56 + code-runner/tests/golden/runners/eval.mjs | 29 + code-runner/tests/golden/runners/eval.py | 36 + code-runner/tests/golden/runners/iii.mjs | 119 ++ code-runner/tests/golden/runners/run.mjs | 19 + code-runner/tests/golden/runners/run.py | 27 + .../golden/schemas/code-runner.eval.json | 11 +- .../code-runner.register_function.json | 2 +- code-runner/tests/integration.rs | 218 +++- code-runner/tests/runner_exec.rs | 572 +++++++++- code-runner/ui/build.mjs | 67 ++ code-runner/ui/package.json | 3 +- pnpm-lock.yaml | 344 +++++- pnpm-workspace.yaml | 5 +- 23 files changed, 2677 insertions(+), 472 deletions(-) create mode 100644 code-runner/tests/golden/runners/code_runner_iii.py create mode 100644 code-runner/tests/golden/runners/eval.mjs create mode 100644 code-runner/tests/golden/runners/eval.py create mode 100644 code-runner/tests/golden/runners/iii.mjs diff --git a/code-runner/README.md b/code-runner/README.md index 807d2f8ea..795123dc9 100644 --- a/code-runner/README.md +++ b/code-runner/README.md @@ -3,6 +3,9 @@ Run Node.js and Python in isolated microVMs: iterate on code with `code-runner::eval`, publish working functions to the bus with `code-runner::register_function`, clean up with `code-runner::teardown`. +Code running inside the VM gets a global `iii` — the real +[iii-sdk](https://iii.dev/docs/reference/sdk-node) client, lazily +connected to the engine ([details below](#the-iii-global)). code-runner delegates every execution to the [iii-sandbox daemon](https://workers.iii.dev/workers/iii-sandbox) @@ -63,15 +66,12 @@ script is a response (`success: false`, `stderr`), not an error — errors are reserved for infrastructure (unknown runtime, expired VM, timeouts, capacity). -**`network` needs an existing runtime.** Outbound network -(`npm install` / `pip install`) can only ever be enabled on a runtime's -*own* creation — and neither the one-shot path nor `keep: true` can create -one with network, because both run through the daemon's `sandbox::run`, -which has no network flag at all. Passing `network: true` without an -explicit `runtime_id` is therefore refused outright (`invalid_request`), -not silently ignored. `network: true` is still accepted, and still -ignored, when reusing an existing runtime by `runtime_id` — that runtime's -network was fixed when it was created. +**Every VM has outbound network.** The `iii` global's engine link rides +the sandbox's network gateway, so networking is always enabled — +`npm install` / `pip install` work in every runtime, one-shot evals +included. (Earlier versions had a `network` request field with refusal +semantics; it is gone, and a request still carrying it is ignored +harmlessly.) ## Registering a function @@ -106,6 +106,64 @@ The runtime backing a namespace is entirely an implementation detail — you never see or manage its `runtime_id`. It carries no network access (there is no `network` field on this request either). +## The `iii` global + +Evaluated code and registered handlers both see a global `iii`: the real +iii-sdk client +([Node reference](https://iii.dev/docs/reference/sdk-node), +[Python reference](https://iii.dev/docs/reference/sdk-python)), created +lazily — nothing dials the engine until the first use, so code that never +touches `iii` pays nothing. + +```js +// node — the SDK's IIIClient; trigger returns a Promise +const rows = await iii.trigger({ + function_id: "database::query", + payload: { sql: "SELECT 1" }, +}); +``` + +```python +# python — synchronous (the SDK's trigger_async exists too) +rows = iii.trigger({'function_id': 'database::query', + 'payload': {'sql': 'SELECT 1'}}) +``` + +- **The full SDK surface is available** — `trigger`, `registerFunction`, + `registerTrigger`, connection-state listeners, `shutdown` (the wrapper + calls it for you after the run) — exactly as the reference documents it. + The global explains itself: `console.log(iii)` / `repr(iii)` print a + usage hint before anything has connected, and after first use + `Object.keys(iii)` / `dir(iii)` list the client's callable surface — + none of which opens a connection by itself. +- **SDK registrations are ephemeral.** `iii.registerFunction` registers + THIS guest process, and an eval's process exits moments later — its + registrations (and trigger bindings) go with it. They are genuinely live + while it runs (an eval can trigger its own registration through the + engine); for a function that outlives the process, call + `code-runner::register_function` through `iii.trigger` — everything in + the section above applies. +- **Delivery.** Node runtimes get the SDK planted at + `/node_modules/iii-sdk` from a bundle embedded in code-runner — no + registry, no `npm install`, works offline. Python runtimes + `pip install iii-sdk` once at runtime creation (its pydantic-core + dependency is compiled per-platform, so planting is not an option); if + that install fails — no PyPI route, say — the runtime still works and + `iii` raises a clear "not installed" error on first use. +- **Identity and reach.** The guest connects to the engine as an ordinary + worker (`III_URL` is set at runtime creation and rides the sandbox + gateway), named `code-runner:eval` or `code-runner:`. What + guest code may call is whatever the engine lets a connected worker call + — the same trust model as a worker process you run yourself. The VM's + network also reaches the internet and, via the gateway, services on the + engine host's loopback — do not run code you would not run as a worker. +- **Self-calls stall.** A registered handler that triggers a function + living on ITS OWN runtime waits on that runtime's one-exec-at-a-time + slot — the very slot its own call is holding — so it can only time out. + Calls to functions on other runtimes, to other workers, and from evals + (whose runtimes host no registered functions) all work, including + nested. + ## Teardown and expiry Pass **exactly one** of `runtime_id` (a kept eval's runtime) or diff --git a/code-runner/build.rs b/code-runner/build.rs index 63aa6755f..a3616d079 100644 --- a/code-runner/build.rs +++ b/code-runner/build.rs @@ -35,6 +35,11 @@ fn main() { let dist_assets = [ ui_dir.join("dist").join("page.js"), ui_dir.join("dist").join("styles.css"), + // The guest SDK bundle `src/runner.rs` embeds and plants into every + // Node runtime (+ its generated package manifest) — built by the + // same `pnpm build`, from the pinned `iii-sdk` dependency. + ui_dir.join("dist").join("iii-sdk-guest.mjs"), + ui_dir.join("dist").join("iii-sdk-guest-package.json"), ]; if dist_assets diff --git a/code-runner/iii.worker.yaml b/code-runner/iii.worker.yaml index 10252848b..d3f2f40b1 100644 --- a/code-runner/iii.worker.yaml +++ b/code-runner/iii.worker.yaml @@ -5,7 +5,7 @@ deploy: binary manifest: Cargo.toml bin: code-runner tags: [nodejs, python, eval, sandbox, microvm] -description: Run Node.js and Python in iii-sandbox microVMs — eval code, register bus functions from working source, and tear down runtimes on demand. +description: Run Node.js and Python in iii-sandbox microVMs — eval code, register bus functions from working source, and tear down runtimes on demand. Guest code gets the real iii-sdk client as a global `iii`, lazily connected to the engine. # code-runner has no V8 (or any other platform-restricted) # dependency — everything it links (iii-sdk, tokio, serde, schemars, clap, diff --git a/code-runner/src/functions/eval.rs b/code-runner/src/functions/eval.rs index bf2b9b2f6..487f19a51 100644 --- a/code-runner/src/functions/eval.rs +++ b/code-runner/src/functions/eval.rs @@ -7,7 +7,13 @@ use crate::runner::Lang; pub struct EvalRequest { /// Source run as a whole file by a fresh interpreter process. Variables /// do NOT survive between evals; whether files and installed packages - /// do depends on the path below. + /// do depends on the path below. A global `iii` is in scope — the real + /// iii-sdk client, lazily connected to the engine on first use: + /// `await iii.trigger({ function_id, payload })` in Node, + /// `iii.trigger({'function_id': ..., 'payload': ...})` in Python. + /// Functions registered with `iii.registerFunction` live only until + /// this process exits — use code-runner::register_function (callable + /// through `iii.trigger`) for one that persists. pub code: String, /// Evaluate in a SPECIFIC runtime, sharing its filesystem: the write and /// the run land in that VM, and it is NOT stopped afterwards — you own @@ -24,20 +30,11 @@ pub struct EvalRequest { /// Nothing persists: no files, no installed packages. `true`: boot a VM /// and leave it running; the response's `runtime_id` addresses it for /// later evals (pass it back to keep working in the same filesystem) and - /// is the capability `code-runner::teardown` needs to stop it. + /// is the capability `code-runner::teardown` needs to stop it. Every + /// runtime boots with outbound network (the `iii` global's engine link + /// needs it), so npm/pip installs work on any path. #[serde(default)] pub keep: bool, - /// Give the guest outbound network so `npm install` / `pip install` - /// work. Create-time only, so it is meaningful only when `runtime_id` is - /// omitted — and even then, only a caller-supplied `runtime_id`'s own - /// creation could ever have asked for it: neither a one-shot eval nor - /// `keep: true` can request network (both run through `sandbox::run`, - /// which has no way to enable it), so `network: true` without a - /// `runtime_id` is refused rather than silently ignored. Ignored (not - /// refused) when `runtime_id` is set: that runtime's network was fixed - /// when it was created. - #[serde(default)] - pub network: bool, /// Wall-clock budget in milliseconds, clamped to the configured maximum. #[serde(default)] pub timeout_ms: Option, @@ -55,7 +52,6 @@ impl std::fmt::Debug for EvalRequest { ) .field("lang", &self.lang) .field("keep", &self.keep) - .field("network", &self.network) .field("timeout_ms", &self.timeout_ms) .finish() } @@ -104,7 +100,6 @@ mod tests { runtime_id: Some("rt-secret-capability".into()), lang: Some(Lang::Node), keep: false, - network: false, timeout_ms: None, }; let rendered = format!("{req:?}"); diff --git a/code-runner/src/functions/inject_guidance.rs b/code-runner/src/functions/inject_guidance.rs index b4932b608..0af9bd261 100644 --- a/code-runner/src/functions/inject_guidance.rs +++ b/code-runner/src/functions/inject_guidance.rs @@ -17,7 +17,7 @@ pub const GUIDANCE_HOOK_DESC: &str = /// The single canonical copy of the code-runner usage guidance. Pure USAGE /// guidance: the hook only fires while this worker is present, so it carries no /// "look for it / install it" discovery text. -const CODE_RUNNER_GUIDANCE: &str = "code-runner runs Node.js and Python in isolated microVMs (iii-sandbox). `code-runner::eval` with lang \"node\" or \"python\" is ONE-SHOT by default: it boots a fresh VM, runs the code, returns the result, and destroys the VM — nothing persists, no files, no installed packages, and the response carries no runtime_id (there is nothing left to address). Pass keep: true to leave the VM running instead: the response's runtime_id then addresses it — treat it as a secret — and is the capability `code-runner::teardown` needs. Pass that runtime_id back on a later eval to keep working in the same VM (filesystem persists between evals in one runtime; variables do not) — that runtime is never auto-stopped, and a reuse can fail with code-runner::expired if it was idle-reaped; if it does, just eval again the same way (fresh keep: true, or a fresh one-shot) rather than reusing the dead id. network is create-time only and only a runtime you already hold with network can honor it — neither a one-shot eval nor keep: true can ever create a networked VM, so network: true without an existing runtime_id is refused, not silently ignored. `code-runner::register_function` needs no runtime_id at all: pass function_id, source (must define handler(payload) in lang), description, and lang — code-runner keeps one persistent runtime per namespace (the segment of function_id before `::`) and language automatically, creating it on the first registration and reusing it for later ones in the same namespace and lang. Call `code-runner::teardown` with EITHER runtime_id (a kept eval's runtime) or namespace (e.g. \"app\" for ids like app::greet) — never both, never neither — to unregister its functions and stop its microVM(s). Idle runtimes are reaped after the configured TTL, but a reaped runtime's functions are NOT unregistered at that moment: the next call into it fails with code-runner::expired, and only then are its functions unregistered. Don't assume a function id is free to reuse just because the TTL has passed."; +const CODE_RUNNER_GUIDANCE: &str = "code-runner runs Node.js and Python in isolated microVMs (iii-sandbox). `code-runner::eval` with lang \"node\" or \"python\" is ONE-SHOT by default: it boots a fresh VM, runs the code, returns the result, and destroys the VM — nothing persists, no files, no installed packages, and the response carries no runtime_id (there is nothing left to address). Pass keep: true to leave the VM running instead: the response's runtime_id then addresses it — treat it as a secret — and is the capability `code-runner::teardown` needs. Pass that runtime_id back on a later eval to keep working in the same VM (filesystem persists between evals in one runtime; variables do not) — that runtime is never auto-stopped, and a reuse can fail with code-runner::expired if it was idle-reaped; if it does, just eval again the same way (fresh keep: true, or a fresh one-shot) rather than reusing the dead id. Every VM boots with outbound network, so npm/pip installs work on any path. Evaluated code and registered handlers get a global `iii` — the REAL iii-sdk client, lazily connected to the engine on first use: `await iii.trigger({ function_id: 'worker::fn', payload })` in Node, `iii.trigger({'function_id': 'worker::fn', 'payload': ...})` (synchronous) in Python, with the full SDK surface behind it (registerFunction, registerTrigger, and the rest). Two sharp edges: functions registered with iii.registerFunction are EPHEMERAL — they die when the eval or handler process exits, so persist through code-runner::register_function (callable via iii.trigger); and a handler that triggers a function registered on the very runtime it executes in waits on that runtime's one-exec-at-a-time slot and can only time out — call across runtimes or workers instead. `code-runner::register_function` needs no runtime_id at all: pass function_id, source (must define handler(payload) in lang), description, and lang — code-runner keeps one persistent runtime per namespace (the segment of function_id before `::`) and language automatically, creating it on the first registration and reusing it for later ones in the same namespace and lang. Call `code-runner::teardown` with EITHER runtime_id (a kept eval's runtime) or namespace (e.g. \"app\" for ids like app::greet) — never both, never neither — to unregister its functions and stop its microVM(s). Idle runtimes are reaped after the configured TTL, but a reaped runtime's functions are NOT unregistered at that moment: the next call into it fails with code-runner::expired, and only then are its functions unregistered. Don't assume a function id is free to reuse just because the TTL has passed."; /// The slice of the `pre_generate` hook envelope we read (lenient: ignores every /// other field the harness sends). The harness nests the live generation context @@ -145,6 +145,9 @@ mod tests { "network", "code-runner::expired", "namespace", + "iii.trigger", + "iii.registerFunction", + "iii-sdk", ] { assert!( CODE_RUNNER_GUIDANCE.contains(needle), @@ -176,4 +179,22 @@ mod tests { "session binding was removed; the guidance must not mention it" ); } + + /// The iii-global claims an agent gets wrong without them: it is the + /// real SDK client, SDK-made registrations die with the guest process, + /// same-runtime self-calls stall out, and every VM is networked. + #[test] + fn guidance_states_the_iii_global_rules_plainly() { + for needle in [ + "REAL iii-sdk client", + "EPHEMERAL", + "one-exec-at-a-time", + "outbound network", + ] { + assert!( + CODE_RUNNER_GUIDANCE.contains(needle), + "guidance is missing: {needle}" + ); + } + } } diff --git a/code-runner/src/functions/mod.rs b/code-runner/src/functions/mod.rs index c33b4a6ec..f3bab5abc 100644 --- a/code-runner/src/functions/mod.rs +++ b/code-runner/src/functions/mod.rs @@ -27,11 +27,14 @@ pub const EVAL_DESC: &str = that same VM (same filesystem, fresh interpreter process each time) — that runtime is \ never auto-stopped, you own it until you tear it down or its idle TTL reaps it, and a \ reaped reuse fails with code-runner::expired (retry without runtime_id to boot a fresh \ - one). network: true asks for outbound network so npm/pip installs work, but only a \ - runtime you already created with network can honor it (pass its runtime_id) — neither a \ - one-shot eval nor keep: true can create a networked VM, so network: true without an \ - existing runtime_id is refused, not silently ignored. stdout, stderr and exit_code come \ - back verbatim — a failing script is a response, not an error."; + one). Every VM boots with outbound network, so npm/pip installs work on any path. \ + Evaluated code gets a global `iii` — the real iii-sdk client, lazily connected to the \ + engine on first use: `await iii.trigger({ function_id, payload })` (Node) / \ + `iii.trigger({'function_id': ..., 'payload': ...})` (Python, synchronous) invokes any \ + bus function, and the full SDK surface is available. Functions registered with \ + iii.registerFunction die when the eval process exits — register through \ + code-runner::register_function (via iii.trigger) for one that persists. stdout, stderr \ + and exit_code come back verbatim — a failing script is a response, not an error."; pub const TEARDOWN_ID: &str = "code-runner::teardown"; pub const TEARDOWN_DESC: &str = @@ -50,9 +53,12 @@ pub const REGISTER_DESC: &str = (python); each call runs it in a fresh interpreter process with the trigger payload and \ returns its JSON-serialized result. The first registered id in a namespace claims it; \ later ids must share both the namespace and its lang. `description` is what \ - engine::functions::info shows a caller — write one. Functions stop resolving when their \ - namespace is torn down (code-runner::teardown namespace=...) or its runtime is reaped for \ - idleness."; + engine::functions::info shows a caller — write one. Handlers get the same global `iii` \ + evaluated code gets (the real iii-sdk client, lazily connected) — but a handler that \ + triggers a function registered on ITS OWN runtime waits on the runtime's \ + one-exec-at-a-time slot and can only time out; call across runtimes or workers instead. \ + Functions stop resolving when their namespace is torn down (code-runner::teardown \ + namespace=...) or its runtime is reaped for idleness."; /// Every id this worker registers on its own client, in registration order. /// `register_all` asserts it registered exactly this list, and the schema @@ -206,7 +212,11 @@ mod tests { fn register_all_registers_exactly_static_ids() { let iii = Arc::new(IIIClient::new("ws://127.0.0.1:1")); let engine = Arc::new(IIIEngine::new(iii.clone())); - let manager = RuntimeManager::new(Arc::new(CodeRunnerConfig::default()), engine); + let manager = RuntimeManager::new( + Arc::new(CodeRunnerConfig::default()), + engine, + "ws://127.0.0.1:1", + ); register_all(&iii, &manager); } } diff --git a/code-runner/src/main.rs b/code-runner/src/main.rs index af3504f70..2ad9dd4ba 100644 --- a/code-runner/src/main.rs +++ b/code-runner/src/main.rs @@ -80,7 +80,7 @@ async fn main() -> Result<()> { )); let engine = Arc::new(IIIEngine::new(iii.clone())); - let manager = RuntimeManager::new(cfg.clone(), engine.clone()); + let manager = RuntimeManager::new(cfg.clone(), engine.clone(), &cli.url); functions::register_all(&iii, &manager); functions::setup_harness_hooks(&iii); // Injected console UI: the function-trigger cards for the ops above. diff --git a/code-runner/src/manager.rs b/code-runner/src/manager.rs index c13e911ba..4c549fac8 100644 --- a/code-runner/src/manager.rs +++ b/code-runner/src/manager.rs @@ -14,6 +14,14 @@ //! rather than queueing them, so serialization is this module's job — the //! mutex covers each whole write+exec sequence, giving the same //! one-command-at-a-time semantics node-engine's runtimes have. +//! +//! Every runtime boots with outbound network and `III_URL` in its +//! environment: guest code's `iii` global is the real iii-sdk client, +//! connected straight to the engine over the sandbox gateway (the guest's +//! /etc/hosts maps `localhost` to it — see `guest_engine_url`). That is +//! also why EVERY eval path boots through `sandbox::create` + the +//! guest-file plant rather than the daemon's one-call `sandbox::run`: +//! `sandbox::run` can neither enable networking nor set create-time env. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -36,6 +44,10 @@ const CREATE_TIMEOUT_MS: u64 = 300_000; /// Trigger timeout for `sandbox::fs::*` and `sandbox::stop` — local to the /// daemon, no meaningful timeout pressure. const FS_TIMEOUT_MS: u64 = 30_000; +/// In-guest deadline for `create`'s `pip install iii-sdk` step — a cold +/// PyPI fetch of pydantic-core and friends can take a while on a slow +/// link, and killing it just corrupts a partial site-packages. +const SDK_INSTALL_TIMEOUT_MS: u64 = 120_000; /// Added to the exec's in-daemon deadline for the bus round trip, so the /// daemon's timeout (which carries the real diagnostic) fires first. const EXEC_MARGIN_MS: u64 = 5_000; @@ -143,6 +155,9 @@ type NamespaceKey = (String, Lang); pub struct RuntimeManager { cfg: Arc, engine: Arc, + /// The engine URL guest SDK clients connect to, set as `III_URL` in + /// every runtime's create-time env — see [`guest_engine_url`]. + guest_engine_url: String, runtimes: Mutex>>, /// `(namespace, lang)` → the runtime backing it, so every /// `register_function` call in one namespace (and language) shares one @@ -190,11 +205,28 @@ fn str_field(v: &Value, k: &str) -> String { .to_string() } +/// The engine URL as a GUEST must dial it. A networked sandbox's +/// /etc/hosts maps the NAME `localhost` to the per-sandbox gateway (which +/// the daemon proxies to the host's loopback), but an IP LITERAL bypasses +/// /etc/hosts entirely and lands on the guest's own empty loopback — so a +/// loopback-IP engine address (the common `--url ws://127.0.0.1:`) +/// must travel as `localhost`. Non-loopback addresses pass through +/// untouched: the guest reaches them over its outbound network like any +/// other host. +fn guest_engine_url(engine_url: &str) -> String { + engine_url + .replace("://127.0.0.1", "://localhost") + .replace("://[::1]", "://localhost") +} + impl RuntimeManager { - pub fn new(cfg: Arc, engine: Arc) -> Arc { + /// `engine_url` is the address THIS worker was pointed at (`--url`); + /// guests get the [`guest_engine_url`] form of it as `III_URL`. + pub fn new(cfg: Arc, engine: Arc, engine_url: &str) -> Arc { Arc::new(Self { cfg, engine, + guest_engine_url: guest_engine_url(engine_url), runtimes: Mutex::new(HashMap::new()), namespaces: Mutex::new(HashMap::new()), namespace_create_lock: tokio::sync::Mutex::new(()), @@ -304,43 +336,6 @@ impl RuntimeManager { } } - /// Map a failed `sandbox::run` call — the create path for BOTH a - /// one-shot eval and `keep: true` (see `eval`'s no-`runtime_id` arm). - /// There is no live record yet, so nothing needs expiring: the daemon's - /// own `keep_sandbox` semantics already stop the VM on a sub-step - /// failure (`sandbox_daemon::run::run_inner`), so a failure here never - /// leaves an addressable orphan the way a bare `sandbox::create` - /// failure can. - /// - /// Deliberately narrower than `map_failure`: past its own `create` - /// step, `sandbox::run` wraps every sub-step failure in `RunStepFailed`, - /// whose own `Display` unconditionally embeds the real `sandbox_id` - /// (`"during sandbox::run step '{step}' (sandbox_id={sandbox_id}): ..."`) - /// regardless of the inner failure's own code. `classify_sandbox_error`'s - /// `Other` arm passes the daemon's message straight through, which for - /// `sandbox::run` specifically could leak that id — something - /// `sandbox_id` must never do (module doc). Only the Gone/Timeout/ - /// Capacity codes, whose messages here are fixed or daemon-diagnostic - /// text rather than the id-bearing wrapper prose, are passed through - /// with real detail; anything else collapses to a fixed, id-free - /// message. - fn map_run_failure(raw: &str) -> CodeRunnerError { - match classify_sandbox_error(raw) { - SandboxFailure::Gone => CodeRunnerError::Engine( - "the sandbox was reaped or lost while running; retry".to_string(), - ), - SandboxFailure::Timeout => CodeRunnerError::Timeout, - SandboxFailure::Capacity(m) => CodeRunnerError::Capacity(m), - SandboxFailure::Other(_) => CodeRunnerError::Engine( - "sandbox::run failed; its full diagnostic could embed a sandbox_id, which must \ - not reach the caller, so only this generic message is returned. Retry, or use \ - keep: true and check code-runner::teardown's response for whether a runtime \ - survived." - .to_string(), - ), - } - } - /// Every `sandbox::*` call against a LIVE runtime goes through here: on /// `Gone` (S002/S004 — the daemon reaped or lost the VM) the runtime is /// expired — bus functions unregistered, record forgotten — before the @@ -389,18 +384,22 @@ impl RuntimeManager { } } - /// Boot a sandbox, plant this language's runner, mint the record. Used - /// only by `namespace_runtime` — a namespace runtime needs the runner - /// planted (`invoke_registered` execs it), unlike a kept-eval runtime - /// (minted via `sandbox::run` in `eval`, which never registers a bus - /// function and so never needs it). + /// Boot a sandbox, plant this language's guest files (runner, iii + /// library, eval wrapper — plus the embedded SDK bundle for Node), + /// install the Python SDK where applicable, mint the record. Used by + /// `namespace_runtime` AND by every `eval` that has no `runtime_id` — + /// one-shot and `keep: true` alike — since only `sandbox::create` can + /// enable networking and set the create-time env the guest SDK link + /// (`III_URL`) depends on. /// - /// Always creates without network: nothing in this worker's surface can - /// ask for one anymore (`register_function` carries no `network` field, - /// and `eval`'s own runtime-creation path goes through `sandbox::run`, - /// which has no way to request it either — see `eval`'s network - /// refusal). If a networked namespace runtime becomes a real need, this - /// is the parameter to reintroduce. + /// Always creates WITH network: the guest `iii` global is a real + /// iii-sdk client dialing the engine through the sandbox gateway, and + /// the gateway only exists on a networked VM. (Outbound internet — + /// npm/pip installs included — comes with that NIC; there is no + /// engine-only network mode in the daemon.) `OTEL_ENABLED=false` + /// keeps guest SDK clients from starting telemetry exporters whose + /// timers and console prints would pollute eval output and delay + /// process exit. async fn create(&self, lang: Lang) -> Result<(String, Arc), CodeRunnerError> { let created = self .engine @@ -409,7 +408,11 @@ impl RuntimeManager { json!({ "image": lang.image(), "idle_timeout_secs": self.cfg.effective_idle_ttl_secs(), - "network": false, + "network": true, + "env": { + "III_URL": self.guest_engine_url, + "OTEL_ENABLED": "false", + }, }), CREATE_TIMEOUT_MS, ) @@ -423,24 +426,30 @@ impl RuntimeManager { })? .to_string(); - // Plant the runner. On failure, stop the sandbox rather than leak - // it: the caller never received an id, so nothing could ever address - // this VM again — it would sit in a daemon slot until the idle - // reaper. (node-engine leaked a slot per failed create until the - // same guard was added.) - let planted = self - .engine - .call( - "sandbox::fs::write".to_string(), - json!({ - "sandbox_id": sandbox_id, - "path": lang.runner_path(), - "content": lang.runner_source(), - "parents": true, - }), - FS_TIMEOUT_MS, - ) - .await; + // Plant the guest files. On failure, stop the sandbox rather than + // leak it: the caller never received an id, so nothing could ever + // address this VM again — it would sit in a daemon slot until the + // idle reaper. (node-engine leaked a slot per failed create until + // the same guard was added.) + let mut planted = Ok(Value::Null); + for (path, content) in lang.guest_files() { + planted = self + .engine + .call( + "sandbox::fs::write".to_string(), + json!({ + "sandbox_id": sandbox_id, + "path": path, + "content": content, + "parents": true, + }), + FS_TIMEOUT_MS, + ) + .await; + if planted.is_err() { + break; + } + } if let Err(raw) = planted { if let Err(stop_raw) = self .engine @@ -465,6 +474,47 @@ impl RuntimeManager { return Err(Self::map_failure(&raw)); } + // Python's SDK cannot be planted (pydantic-core is compiled + // per-platform), so it is pip-installed once per runtime, here, + // while nothing else can be running in the VM. DEGRADES rather + // than fails: a dead registry must not take plain `eval` down + // with it — the guest's `iii` then raises a clear + // "not installed" error on first use instead. + if lang == Lang::Python { + let install = self + .engine + .call( + "sandbox::exec".to_string(), + json!({ + "sandbox_id": sandbox_id, + "cmd": "python3", + "args": [ + "-m", "pip", "install", + "--quiet", "--disable-pip-version-check", + "iii-sdk", + ], + "timeout_ms": SDK_INSTALL_TIMEOUT_MS, + }), + SDK_INSTALL_TIMEOUT_MS + EXEC_MARGIN_MS, + ) + .await; + let ok = matches!( + &install, + Ok(v) if v.get("exit_code").and_then(|c| c.as_i64()) == Some(0) + ); + if !ok { + let detail = match &install { + Ok(v) => str_field(v, "stderr"), + Err(raw) => raw.clone(), + }; + tracing::warn!( + error = %stderr_tail(&detail), + "pip install iii-sdk failed; this Python runtime's `iii` global will \ + error on first use" + ); + } + } + let runtime_id = format!("rt-{}", uuid::Uuid::new_v4()); let record = Arc::new(RuntimeRecord { sandbox_id, @@ -485,13 +535,21 @@ impl RuntimeManager { /// 1. `runtime_id` present → reuse that VM via write+exec. NOT stopped — /// the caller owns it. `lang` mismatch is refused; `network` stays /// documented-as-ignored (the caller already chose this runtime). - /// 2. `runtime_id` absent, `keep: true` → `sandbox::run - /// {keep_sandbox: true}`; the returned `sandbox_id` gets a minted - /// `runtime_id`, recorded and returned. - /// 3. `runtime_id` absent, default → `sandbox::run` (VM auto-stops); the - /// response carries no `runtime_id` — there is nothing left to - /// address, and returning a dead id would be worse than none. - pub async fn eval(&self, req: EvalRequest) -> Result { + /// 2. `runtime_id` absent, `keep: true` → boot a runtime (`create`, so + /// the guest files are planted), eval into it, leave it running; the + /// minted `runtime_id` is recorded and returned. A FAILED eval + /// destroys the fresh runtime instead: an `Err` carries no + /// `runtime_id`, so keeping the VM would strand it in a daemon slot + /// nobody can ever address. + /// 3. `runtime_id` absent, default → same boot, destroyed after the + /// eval, success or failure; the response carries no `runtime_id` — + /// there is nothing left to address, and returning a dead id would + /// be worse than none. + /// + /// Every path runs the code under the eval wrapper, so evaluated code + /// gets the lazy `iii` SDK global (`III_URL` is in the runtime's env + /// from `create`). + pub async fn eval(self: &Arc, req: EvalRequest) -> Result { if req.code.is_empty() { return Err(CodeRunnerError::InvalidRequest( "code must not be empty".into(), @@ -503,6 +561,7 @@ impl RuntimeManager { req.code.len() ))); } + let timeout_ms = self.cfg.clamp_timeout(req.timeout_ms).as_millis() as u64; if let Some(id) = &req.runtime_id { let record = self.get(id)?; @@ -515,62 +574,9 @@ impl RuntimeManager { ))); } } - // `network` stays documented-as-ignored here: the caller named - // this runtime, so they know which one they got. - - let timeout_ms = self.cfg.clamp_timeout(req.timeout_ms).as_millis() as u64; - let _guard = record.exec_lock.lock().await; - - let file = format!( - "/tmp/code-runner/eval-{}.{}", - uuid::Uuid::new_v4(), - record.lang.ext() - ); - self.sandbox_call( - id, - "sandbox::fs::write", - json!({ - "sandbox_id": record.sandbox_id, - "path": file, - "content": req.code, - "parents": true, - }), - FS_TIMEOUT_MS, - ) - .await?; - - let out = self - .sandbox_call( - id, - "sandbox::exec", - json!({ - "sandbox_id": record.sandbox_id, - "cmd": record.lang.interpreter(), - "args": [file], - "timeout_ms": timeout_ms, - }), - timeout_ms + EXEC_MARGIN_MS, - ) - .await?; - - if out - .get("timed_out") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - { - return Err(CodeRunnerError::Timeout); - } - return Ok(EvalResponse { - runtime_id: Some(id.clone()), - stdout: str_field(&out, "stdout"), - stderr: str_field(&out, "stderr"), - exit_code: out.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(-1), - success: out - .get("success") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - duration_ms: out.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0), - }); + let mut resp = self.eval_into(id, &record, &req.code, timeout_ms).await?; + resp.runtime_id = Some(id.clone()); + return Ok(resp); } let lang = req.lang.ok_or_else(|| { @@ -579,42 +585,85 @@ impl RuntimeManager { ) })?; - // Neither a one-shot eval nor `keep: true` can ever create a - // networked runtime — both boot through `sandbox::run`, whose - // request has no `network` field at all (confirmed against - // iii-sandbox's own `RunRequest`/`handle_run`, which always creates - // with `network: None` → the daemon's own `false` default, - // regardless of `keep_sandbox`). Silently dropping `network: true` - // here would surface later as an unexplainable `pip install` - // failure, so this refuses instead — matching the convention this - // worker already uses elsewhere for a `network` request that cannot - // be honoured. - if req.network { - return Err(CodeRunnerError::InvalidRequest( - "network: true needs an existing runtime_id: sandbox::run — which backs both a \ - one-shot eval and keep: true — has no way to enable outbound networking, so \ - neither path can ever create a networked runtime. Pass an explicit runtime_id \ - for a runtime that already has network, or drop network: true." - .into(), - )); + let (id, record) = self.create(lang).await?; + let result = self.eval_into(&id, &record, &req.code, timeout_ms).await; + + // The caller receives the id ONLY on a kept, successful eval; on + // every other outcome the runtime must not outlive this call. + let keep = req.keep && result.is_ok(); + if !keep { + match self.destroy_runtime(&id).await { + // Already gone — the eval itself discovered the VM reaped + // and expired the record. That IS the destroyed outcome. + Ok(_) | Err(CodeRunnerError::RuntimeNotFound(_)) => {} + Err(e) => tracing::warn!( + error = %e, + "one-shot eval cleanup failed; the daemon's idle reaper is the backstop" + ), + } } - let timeout_ms = self.cfg.clamp_timeout(req.timeout_ms).as_millis() as u64; + let mut resp = result.map_err(Self::redact_boot_eval_error)?; + resp.runtime_id = keep.then_some(id); + Ok(resp) + } + + /// Freshly-booted eval runtimes are internal until the response hands + /// the id out, so an `Expired`/`RuntimeNotFound` raced mid-eval must + /// not quote an id this caller never held (`error.rs`'s id-quoting + /// exception covers only ids the caller supplied). Mirrors + /// `redact_register_error`. + fn redact_boot_eval_error(e: CodeRunnerError) -> CodeRunnerError { + match e { + CodeRunnerError::Expired(_) | CodeRunnerError::RuntimeNotFound(_) => { + CodeRunnerError::Engine( + "the eval's VM was reaped or lost mid-run; retry".to_string(), + ) + } + other => other, + } + } + + /// Write `code` into the runtime and run it under the eval wrapper. + /// Returns `runtime_id: None` — each caller decides what id, if any, + /// its own caller may see. + async fn eval_into( + self: &Arc, + runtime_id: &str, + record: &Arc, + code: &str, + timeout_ms: u64, + ) -> Result { + let _guard = record.exec_lock.lock().await; + + let file = format!( + "/tmp/code-runner/eval-{}.{}", + uuid::Uuid::new_v4(), + record.lang.ext() + ); + self.sandbox_call( + runtime_id, + "sandbox::fs::write", + json!({ + "sandbox_id": record.sandbox_id, + "path": file, + "content": code, + "parents": true, + }), + FS_TIMEOUT_MS, + ) + .await?; + let out = self - .engine - .call( - "sandbox::run".to_string(), - json!({ - "image": lang.image(), - "lang": lang.image(), - "code": req.code, - "timeout_ms": timeout_ms, - "keep_sandbox": req.keep, - }), - CREATE_TIMEOUT_MS + timeout_ms + EXEC_MARGIN_MS, + .exec_guest( + runtime_id, + record, + vec![record.lang.eval_wrapper_path().to_string(), file], + None, + "code-runner:eval", + timeout_ms, ) - .await - .map_err(|raw| Self::map_run_failure(&raw))?; + .await?; if out .get("timed_out") @@ -623,33 +672,8 @@ impl RuntimeManager { { return Err(CodeRunnerError::Timeout); } - - let runtime_id = if req.keep { - let sandbox_id = out - .get("sandbox_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - CodeRunnerError::Engine( - "sandbox::run left a sandbox running but returned no sandbox_id".into(), - ) - })? - .to_string(); - let id = format!("rt-{}", uuid::Uuid::new_v4()); - let record = Arc::new(RuntimeRecord { - sandbox_id, - lang, - exec_lock: tokio::sync::Mutex::new(()), - namespace: Mutex::new(None), - functions: Mutex::new(Vec::new()), - }); - self.runtimes.lock().unwrap().insert(id.clone(), record); - Some(id) - } else { - None - }; - Ok(EvalResponse { - runtime_id, + runtime_id: None, stdout: str_field(&out, "stdout"), stderr: str_field(&out, "stderr"), exit_code: out.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(-1), @@ -661,6 +685,41 @@ impl RuntimeManager { }) } + /// One guest exec. `III_URL` (and the SDK itself) is already in the + /// runtime from `create`; the only per-exec env is a worker name, so + /// a guest that does use `iii` shows up in the engine's worker list + /// as something identifiable instead of `hostname:pid`. The caller + /// MUST already hold `record.exec_lock` — this is the exec half of + /// the write+exec sequence that lock serializes. + async fn exec_guest( + self: &Arc, + runtime_id: &str, + record: &RuntimeRecord, + args: Vec, + stdin_b64: Option, + worker_name: &str, + timeout_ms: u64, + ) -> Result { + let mut payload = json!({ + "sandbox_id": record.sandbox_id, + "cmd": record.lang.interpreter(), + "args": args, + "env": { "III_WORKER_NAME": worker_name }, + "timeout_ms": timeout_ms, + }); + if let Some(stdin) = stdin_b64 { + payload["stdin"] = json!(stdin); + } + + self.sandbox_call( + runtime_id, + "sandbox::exec", + payload, + timeout_ms + EXEC_MARGIN_MS, + ) + .await + } + /// Destroy one runtime (whatever kind it is): drain in-flight work, /// unregister its bus functions, best-effort stop its sandbox, forget /// the record. Shared by `teardown`'s `runtime_id` arm and, once per @@ -1085,9 +1144,9 @@ impl RuntimeManager { /// One bus call of a registered function: exec the runner against the /// planted source with the payload on stdin. Handler prints are logged /// at debug, not returned — the caller gets exactly what `handler` - /// returned. + /// returned. Handlers get the same lazy `iii` global evals do. async fn invoke_registered( - &self, + self: &Arc, runtime_id: &str, function_id: &str, source_path: &str, @@ -1115,17 +1174,16 @@ impl RuntimeManager { .encode(serde_json::to_vec(&envelope).expect("a Value serializes")); let out = self - .sandbox_call( + .exec_guest( runtime_id, - "sandbox::exec", - json!({ - "sandbox_id": record.sandbox_id, - "cmd": record.lang.interpreter(), - "args": [record.lang.runner_path(), source_path], - "stdin": stdin_b64, - "timeout_ms": timeout_ms, - }), - timeout_ms + EXEC_MARGIN_MS, + &record, + vec![ + record.lang.runner_path().to_string(), + source_path.to_string(), + ], + Some(stdin_b64), + &format!("code-runner:{function_id}"), + timeout_ms, ) .await?; @@ -1188,11 +1246,9 @@ mod tests { "duration_ms": 12, "success": true }) } - /// The daemon calls a kept-eval reuse and a namespace-runtime creation - /// both go through: create + runner plant, then write + exec, then - /// stop. Also answers a plain `sandbox::run` with the ephemeral - /// (no-`sandbox_id`) shape — tests that need the kept shape override it - /// with a responder. + /// The daemon calls every eval and registration path goes through: + /// create + guest-file plant (+ pip install for Python), then write + + /// exec, then stop. fn happy_fake() -> Arc { let fake = FakeEngine::new(); fake.with_response( @@ -1208,7 +1264,6 @@ mod tests { "sandbox::stop", Ok(json!({ "sandbox_id": "sb-1", "stopped": true })), ); - fake.with_response("sandbox::run", Ok(ok_exec())); fake } @@ -1218,7 +1273,6 @@ mod tests { runtime_id, lang, keep: false, - network: false, timeout_ms: None, } } @@ -1240,24 +1294,27 @@ mod tests { } // --------------------------------------------------------------- - // eval: the ephemeral and kept-eval (`sandbox::run`) paths. + // eval: the boot paths (no runtime_id) — one-shot and keep: true. // --------------------------------------------------------------- #[tokio::test] - async fn an_ephemeral_eval_makes_one_sandbox_run_call_and_leaves_no_runtime() { - let fake = FakeEngine::new(); - fake.with_responder("sandbox::run", |payload| { - assert_eq!(payload["image"], "node"); - assert_eq!(payload["lang"], "node"); - assert_eq!(payload["code"], "console.log(2+2)"); - assert_eq!(payload["keep_sandbox"], false); + async fn an_ephemeral_eval_boots_evals_and_destroys_its_vm() { + let fake = happy_fake(); + fake.with_responder("sandbox::exec", |payload| { + assert_eq!(payload["cmd"], "node"); assert_eq!(payload["timeout_ms"], 5_000); - Ok( - json!({ "stdout": "4\n", "stderr": "", "exit_code": 0, "timed_out": false, - "duration_ms": 12, "success": true }), - ) + let args = payload["args"].as_array().expect("argv array"); + assert_eq!( + args[0], "/opt/code-runner/eval.mjs", + "code runs UNDER THE EVAL WRAPPER, never the bare interpreter" + ); + assert!(args[1] + .as_str() + .unwrap() + .starts_with("/tmp/code-runner/eval-")); + Ok(ok_exec()) }); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let out = m .eval(eval_req("console.log(2+2)", Some(Lang::Node), None)) .await @@ -1265,48 +1322,202 @@ mod tests { assert_eq!(out.runtime_id, None, "nothing left to address"); assert_eq!(out.stdout, "4\n"); assert!(out.success); - let calls = fake.calls(); - assert_eq!(calls.len(), 1, "one call: sandbox::run"); - assert_eq!(calls[0].0, "sandbox::run"); assert!( m.runtimes.lock().unwrap().is_empty(), "an ephemeral eval must not leave an addressable runtime behind" ); + + let ids: Vec = fake.calls().into_iter().map(|(id, _)| id).collect(); + assert_eq!( + ids, + vec![ + "sandbox::create", + "sandbox::fs::write", // run.mjs + "sandbox::fs::write", // iii.mjs + "sandbox::fs::write", // eval.mjs + "sandbox::fs::write", // /node_modules/iii-sdk/package.json + "sandbox::fs::write", // /node_modules/iii-sdk/dist/index.mjs + "sandbox::fs::write", // the code + "sandbox::exec", + "sandbox::stop", // the one-shot VM is destroyed + ] + ); + let create = fake + .calls() + .into_iter() + .find(|(id, _)| id == "sandbox::create") + .unwrap(); + assert_eq!(create.1["image"], "node"); } + /// Every runtime boots networked, with the guest-facing engine URL and + /// the OTel kill-switch in its create-time env. The URL is the + /// NORMALIZED one: a loopback IP would dodge the guest's /etc/hosts + /// gateway mapping, so `127.0.0.1` must travel as `localhost`. #[tokio::test] - async fn a_python_ephemeral_eval_selects_the_python_image_and_lang() { - let fake = FakeEngine::new(); - fake.with_responder("sandbox::run", |payload| { - assert_eq!(payload["image"], "python"); - assert_eq!(payload["lang"], "python"); - Ok( - json!({ "stdout": "4\n", "stderr": "", "exit_code": 0, "timed_out": false, - "duration_ms": 1, "success": true }), - ) + async fn create_boots_with_network_and_the_guest_engine_url() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:4912"); + m.eval(eval_req("1", Some(Lang::Node), None)) + .await + .expect("eval succeeds"); + let create = fake + .calls() + .into_iter() + .find(|(id, _)| id == "sandbox::create") + .expect("created"); + assert_eq!(create.1["network"], true); + assert_eq!(create.1["env"]["III_URL"], "ws://localhost:4912"); + assert_eq!(create.1["env"]["OTEL_ENABLED"], "false"); + } + + #[test] + fn guest_engine_url_rewrites_loopback_ips_only() { + assert_eq!( + guest_engine_url("ws://127.0.0.1:49134"), + "ws://localhost:49134" + ); + assert_eq!(guest_engine_url("ws://[::1]:49134"), "ws://localhost:49134"); + assert_eq!( + guest_engine_url("ws://localhost:49134"), + "ws://localhost:49134" + ); + assert_eq!( + guest_engine_url("wss://engine.prod.example:443"), + "wss://engine.prod.example:443", + "a remote engine address must pass through untouched" + ); + } + + /// A Python boot has one extra step between the plant and the eval: + /// `pip install iii-sdk` (the SDK's compiled deps cannot be planted). + /// The eval itself still runs under the eval wrapper. + #[tokio::test] + async fn a_python_ephemeral_eval_pip_installs_the_sdk_then_runs_the_wrapper() { + let fake = happy_fake(); + fake.with_responder("sandbox::exec", |payload| { + let args = payload["args"].as_array().expect("argv array"); + assert_eq!(payload["cmd"], "python3"); + if args[0] == "-m" { + assert_eq!(args[1], "pip"); + assert!(args.iter().any(|a| a == "iii-sdk"), "{args:?}"); + } else { + assert_eq!(args[0], "/opt/code-runner/eval.py"); + } + Ok(ok_exec()) }); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.eval(eval_req("print(2+2)", Some(Lang::Python), None)) .await .expect("eval succeeds"); + let create = fake + .calls() + .into_iter() + .find(|(id, _)| id == "sandbox::create") + .unwrap(); + assert_eq!(create.1["image"], "python"); + let execs = fake + .calls() + .iter() + .filter(|(id, _)| id == "sandbox::exec") + .count(); + assert_eq!(execs, 2, "one pip install + one eval exec"); } + /// A failed pip install DEGRADES the runtime (its `iii` errors on + /// first use, guest-side) — it must never take `eval` itself down. #[tokio::test] - async fn keep_true_mints_a_runtime_id_and_it_addresses_the_kept_vm() { - let fake = FakeEngine::new(); - fake.with_responder("sandbox::run", |payload| { - assert_eq!(payload["keep_sandbox"], true); - Ok( - json!({ "stdout": "4\n", "stderr": "", "exit_code": 0, "timed_out": false, - "duration_ms": 1, "success": true, "sandbox_id": "sb-kept-1" }), - ) + async fn a_failed_sdk_install_degrades_but_does_not_fail_the_eval() { + let fake = happy_fake(); + fake.with_responder("sandbox::exec", |payload| { + if payload["args"][0] == "-m" { + return Ok(json!({ "stdout": "", "stderr": "no route to pypi", + "exit_code": 1, "timed_out": false, + "duration_ms": 5, "success": false })); + } + Ok(ok_exec()) }); - fake.with_response( - "sandbox::fs::write", - Ok(json!({ "bytes_written": 1, "path": "p" })), + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); + let out = m + .eval(eval_req("print(2+2)", Some(Lang::Python), None)) + .await + .expect("the eval must still run"); + assert!(out.success); + } + + /// `create` plants the full guest-file table for the language — + /// runner, iii library (NOT named iii.py — sys.path[0] shadowing), + /// eval wrapper — before anything can exec. + #[tokio::test] + async fn create_plants_the_guest_file_table() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); + m.eval(eval_req("print(1)", Some(Lang::Python), None)) + .await + .expect("eval succeeds"); + let planted: Vec<(String, String)> = fake + .calls() + .iter() + .filter(|(id, _)| id == "sandbox::fs::write") + .map(|(_, p)| { + ( + p["path"].as_str().unwrap().to_string(), + p["content"].as_str().unwrap().to_string(), + ) + }) + .collect(); + let find = |path: &str| { + planted + .iter() + .find(|(p, _)| p == path) + .unwrap_or_else(|| panic!("{path} was not planted")) + .1 + .clone() + }; + assert_eq!(find("/opt/code-runner/run.py"), crate::runner::RUN_PY); + assert_eq!( + find("/opt/code-runner/code_runner_iii.py"), + crate::runner::III_PY ); - fake.with_response("sandbox::exec", Ok(ok_exec())); - let m = RuntimeManager::new(cfg(), fake.clone()); + assert_eq!(find("/opt/code-runner/eval.py"), crate::runner::EVAL_PY); + } + + /// The Node table additionally carries the embedded SDK at root + /// /node_modules, where the ESM upward walk from ANY tenant file + /// ends. + #[tokio::test] + async fn a_node_create_plants_the_sdk_bundle_at_the_root() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); + m.eval(eval_req("1", Some(Lang::Node), None)) + .await + .expect("eval succeeds"); + let bundle = fake + .calls() + .into_iter() + .find(|(id, p)| { + id == "sandbox::fs::write" && p["path"] == "/node_modules/iii-sdk/dist/index.mjs" + }) + .expect("SDK bundle planted"); + assert_eq!(bundle.1["content"], crate::runner::SDK_BUNDLE_MJS); + assert!( + fake.calls().iter().any(|(id, p)| { + id == "sandbox::fs::write" && p["path"] == "/node_modules/iii-sdk/package.json" + }), + "the SDK's manifest must be planted beside the bundle" + ); + let execs = fake + .calls() + .iter() + .filter(|(id, _)| id == "sandbox::exec") + .count(); + assert_eq!(execs, 1, "no install step for Node — the SDK is embedded"); + } + + #[tokio::test] + async fn keep_true_mints_a_runtime_id_and_it_addresses_the_kept_vm() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let mut req = eval_req("1", Some(Lang::Node), None); req.keep = true; @@ -1317,9 +1528,13 @@ mod tests { .expect("keep: true mints a runtime_id"); assert!(id.starts_with("rt-")); assert!(m.runtimes.lock().unwrap().contains_key(&id)); + assert!( + !fake.calls().iter().any(|(id, _)| id == "sandbox::stop"), + "a kept VM must not be stopped" + ); // The minted id addresses that VM: a later eval reuses it via - // write+exec, no second sandbox::run. + // write+exec, no second create. let before = fake.calls().len(); let out2 = m .eval(eval_req("2", None, Some(id.clone()))) @@ -1332,49 +1547,76 @@ mod tests { assert_eq!(calls[before + 1].0, "sandbox::exec"); } + /// keep: true hands out the id only on SUCCESS: an `Err` response has + /// no `runtime_id` field, so keeping the VM would strand it in a + /// daemon slot nothing can ever address (exactly what the pre-broker + /// `sandbox::run keep_sandbox` path used to do on a timeout). #[tokio::test] - async fn keep_true_without_a_returned_sandbox_id_is_an_engine_error() { - let fake = FakeEngine::new(); - // Malformed daemon reply: keep was requested but no sandbox_id came - // back — must not silently mint an unaddressable "kept" runtime. - fake.with_response("sandbox::run", Ok(ok_exec())); - let m = RuntimeManager::new(cfg(), fake.clone()); + async fn keep_true_with_a_failed_eval_destroys_the_fresh_runtime() { + let fake = happy_fake(); + fake.with_response("sandbox::exec", Err(wrapped("S200", "deadline"))); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let mut req = eval_req("1", Some(Lang::Node), None); req.keep = true; let err = m.eval(req).await.unwrap_err(); + assert_eq!(err.code(), "code-runner::timeout"); + assert!(m.runtimes.lock().unwrap().is_empty()); + assert!( + fake.calls().iter().any(|(id, _)| id == "sandbox::stop"), + "the unaddressable VM must be destroyed" + ); + } + + #[tokio::test] + async fn create_without_a_returned_sandbox_id_is_an_engine_error() { + let fake = happy_fake(); + // Malformed daemon reply: created, but no sandbox_id came back — + // must not silently mint an unaddressable runtime. + fake.with_response("sandbox::create", Ok(json!({ "image": "node" }))); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); + let err = m + .eval(eval_req("1", Some(Lang::Node), None)) + .await + .unwrap_err(); assert_eq!(err.code(), "code-runner::engine"); + assert!(err.to_string().contains("no sandbox_id"), "{err}"); assert!(m.runtimes.lock().unwrap().is_empty()); } #[tokio::test] - async fn a_timed_out_run_is_a_timeout_error() { - let fake = FakeEngine::new(); + async fn a_timed_out_ephemeral_eval_is_a_timeout_and_still_destroys_the_vm() { + let fake = happy_fake(); fake.with_response( - "sandbox::run", + "sandbox::exec", Ok( json!({ "stdout": "", "stderr": "", "exit_code": serde_json::Value::Null, "timed_out": true, "duration_ms": 5000, "success": false }), ), ); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .eval(eval_req("while(1);", Some(Lang::Node), None)) .await .unwrap_err(); assert_eq!(err.code(), "code-runner::timeout"); + assert!(m.runtimes.lock().unwrap().is_empty()); + assert!( + fake.calls().iter().any(|(id, _)| id == "sandbox::stop"), + "the timed-out one-shot VM must still be destroyed" + ); } #[tokio::test] - async fn a_null_exit_code_from_sandbox_run_maps_to_negative_one() { - let fake = FakeEngine::new(); + async fn a_null_exit_code_from_the_exec_maps_to_negative_one() { + let fake = happy_fake(); fake.with_response( - "sandbox::run", + "sandbox::exec", Ok( json!({ "stdout": "", "stderr": "boot noise", "exit_code": serde_json::Value::Null, "timed_out": false, "duration_ms": 1, "success": false }), ), ); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let out = m .eval(eval_req("1", Some(Lang::Node), None)) .await @@ -1383,13 +1625,13 @@ mod tests { } #[tokio::test] - async fn sandbox_run_gone_maps_to_a_retry_error_and_creates_nothing() { - let fake = FakeEngine::new(); + async fn create_gone_maps_to_a_retry_error_and_creates_nothing() { + let fake = happy_fake(); fake.with_response( - "sandbox::run", + "sandbox::create", Err(wrapped("S002", "no sandbox with that id sb-9")), ); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .eval(eval_req("1", Some(Lang::Node), None)) .await @@ -1400,13 +1642,13 @@ mod tests { } #[tokio::test] - async fn sandbox_run_capacity_maps_to_capacity_and_calls_nothing_else() { + async fn create_capacity_maps_to_capacity_and_calls_nothing_else() { let fake = FakeEngine::new(); fake.with_response( - "sandbox::run", + "sandbox::create", Err(wrapped("S400", "max_concurrent_sandboxes reached")), ); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .eval(eval_req("1", Some(Lang::Node), None)) .await @@ -1416,10 +1658,10 @@ mod tests { } #[tokio::test] - async fn sandbox_run_timeout_wire_error_maps_to_timeout() { + async fn create_timeout_wire_error_maps_to_timeout() { let fake = FakeEngine::new(); - fake.with_response("sandbox::run", Err(wrapped("S200", "deadline"))); - let m = RuntimeManager::new(cfg(), fake.clone()); + fake.with_response("sandbox::create", Err(wrapped("S200", "deadline"))); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .eval(eval_req("1", Some(Lang::Node), None)) .await @@ -1427,74 +1669,27 @@ mod tests { assert_eq!(err.code(), "code-runner::timeout"); } - /// The load-bearing regression `map_run_failure` exists for: past its - /// own `create` step, `sandbox::run` wraps a sub-step failure in - /// `RunStepFailed`, whose OWN `Display` embeds the REAL `sandbox_id` - /// regardless of the inner failure's code — so an `Other`-classified - /// failure must not pass the daemon's raw message through. + /// A VM reaped MID-EVAL on the boot path: this caller never held the + /// freshly-minted runtime id (only a kept SUCCESS hands it out), so + /// the error must not quote it — `error.rs`'s id-quoting exception + /// covers only ids the caller supplied. Mirrors + /// `redact_register_error` / `redact_proxy_error`. #[tokio::test] - async fn a_sandbox_run_other_failure_never_leaks_a_sandbox_id() { - let fake = FakeEngine::new(); - let sandbox_id = "11111111-2222-3333-4444-555555555555"; - fake.with_response( - "sandbox::run", - Err(format!( - r#"remote error (invocation_failed): handler error: {{"type":"validation","code":"S216","message":"during sandbox::run step `fs::write (code)` (sandbox_id={sandbox_id}): disk full","docs_url":"x","fix":null,"retryable":false}}"# - )), - ); - let m = RuntimeManager::new(cfg(), fake.clone()); + async fn a_mid_eval_reap_on_the_boot_path_redacts_the_id_and_leaves_nothing() { + let fake = happy_fake(); + fake.with_response("sandbox::exec", Err(wrapped("S004", "sandbox stopped"))); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .eval(eval_req("1", Some(Lang::Node), None)) .await .unwrap_err(); assert_eq!(err.code(), "code-runner::engine"); assert!( - !err.to_string().contains(sandbox_id), - "the caller-facing error leaked the sandbox_id: {err}" - ); - } - - // --------------------------------------------------------------- - // eval: `network` refusal on the no-`runtime_id` paths. - // --------------------------------------------------------------- - - #[tokio::test] - async fn network_true_without_a_runtime_id_is_refused_ephemeral_and_kept() { - // No responder configured for sandbox::run at all: the refusal must - // happen before any daemon call, or this test would error on the - // unconfigured call instead of proving the refusal. - let fake = FakeEngine::new(); - let m = RuntimeManager::new(cfg(), fake.clone()); - - let mut ephemeral = eval_req("1", Some(Lang::Node), None); - ephemeral.network = true; - let err = m.eval(ephemeral).await.unwrap_err(); - assert_eq!(err.code(), "code-runner::invalid_request"); - assert!(err.to_string().contains("network"), "{err}"); - - let mut kept = eval_req("1", Some(Lang::Node), None); - kept.network = true; - kept.keep = true; - let err = m.eval(kept).await.unwrap_err(); - assert_eq!(err.code(), "code-runner::invalid_request"); - - assert!( - fake.calls().is_empty(), - "refused before any daemon call: {:?}", - fake.calls() + !err.to_string().contains("rt-"), + "a boot-path caller was handed a runtime_id it never held: {err}" ); - } - - #[tokio::test] - async fn network_true_with_an_explicit_runtime_id_is_ignored_not_refused() { - let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); - let id = seed_runtime(&m, Lang::Node, "sb-1"); - let mut req = eval_req("1", None, Some(id)); - req.network = true; - m.eval(req) - .await - .expect("network is ignored, not refused, on an explicit runtime_id"); + assert!(err.to_string().contains("retry"), "{err}"); + assert!(m.runtimes.lock().unwrap().is_empty()); } // --------------------------------------------------------------- @@ -1504,7 +1699,7 @@ mod tests { #[tokio::test] async fn eval_with_runtime_id_reuses_the_sandbox_via_write_and_exec() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); let out = m .eval(eval_req("2", None, Some(id.clone()))) @@ -1512,15 +1707,36 @@ mod tests { .expect("reuse succeeds"); assert_eq!(out.runtime_id, Some(id)); let calls = fake.calls(); - assert_eq!(calls.len(), 2, "only write + exec — no create, no run"); - assert_eq!(calls[0].0, "sandbox::fs::write"); - assert_eq!(calls[1].0, "sandbox::exec"); + let ids: Vec<&str> = calls.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!( + ids, + vec!["sandbox::fs::write", "sandbox::exec"], + "write + exec — no create" + ); + } + + /// The per-exec env is just the worker NAME the guest SDK client + /// announces itself with if the code uses `iii` — the engine link + /// (`III_URL`) is create-time env, already in the VM. + #[tokio::test] + async fn an_eval_exec_carries_the_guest_worker_name() { + let fake = happy_fake(); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); + let id = seed_runtime(&m, Lang::Node, "sb-1"); + m.eval(eval_req("1", None, Some(id))).await.unwrap(); + + let exec = fake + .calls() + .into_iter() + .find(|(id, _)| id == "sandbox::exec") + .expect("exec happened"); + assert_eq!(exec.1["env"]["III_WORKER_NAME"], "code-runner:eval"); } #[tokio::test] async fn a_mismatched_lang_on_an_existing_runtime_is_refused() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake); + let m = RuntimeManager::new(cfg(), fake, "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); let err = m .eval(eval_req("1", Some(Lang::Python), Some(id.clone()))) @@ -1535,7 +1751,7 @@ mod tests { #[tokio::test] async fn unknown_runtime_id_is_not_found() { - let m = RuntimeManager::new(cfg(), happy_fake()); + let m = RuntimeManager::new(cfg(), happy_fake(), "ws://127.0.0.1:1"); let err = m .eval(eval_req("1", None, Some("rt-nope".into()))) .await @@ -1545,7 +1761,7 @@ mod tests { #[tokio::test] async fn empty_and_oversized_code_are_invalid_requests() { - let m = RuntimeManager::new(cfg(), happy_fake()); + let m = RuntimeManager::new(cfg(), happy_fake(), "ws://127.0.0.1:1"); let err = m .eval(eval_req("", Some(Lang::Node), None)) .await @@ -1561,7 +1777,7 @@ mod tests { #[tokio::test] async fn create_requires_a_lang() { - let m = RuntimeManager::new(cfg(), happy_fake()); + let m = RuntimeManager::new(cfg(), happy_fake(), "ws://127.0.0.1:1"); let err = m.eval(eval_req("1", None, None)).await.unwrap_err(); assert_eq!(err.code(), "code-runner::invalid_request"); assert!(err.to_string().contains("lang"), "{err}"); @@ -1570,7 +1786,7 @@ mod tests { #[tokio::test] async fn requested_timeout_is_clamped_on_the_reuse_path() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); let mut req = eval_req("1", None, Some(id)); req.timeout_ms = Some(999_999); @@ -1581,15 +1797,12 @@ mod tests { #[tokio::test] async fn requested_timeout_is_clamped_on_the_ephemeral_path() { - let fake = FakeEngine::new(); - fake.with_responder("sandbox::run", |payload| { + let fake = happy_fake(); + fake.with_responder("sandbox::exec", |payload| { assert_eq!(payload["timeout_ms"], 30_000); - Ok( - json!({ "stdout": "", "stderr": "", "exit_code": 0, "timed_out": false, - "duration_ms": 1, "success": true }), - ) + Ok(ok_exec()) }); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let mut req = eval_req("1", Some(Lang::Node), None); req.timeout_ms = Some(999_999); m.eval(req).await.unwrap(); @@ -1601,7 +1814,7 @@ mod tests { #[tokio::test] async fn an_eval_failure_on_a_caller_supplied_runtime_does_not_reap() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); fake.with_response("sandbox::exec", Err(wrapped("S200", "deadline"))); @@ -1641,7 +1854,7 @@ mod tests { "timed_out": false, "duration_ms": 3, "success": false }), ), ); - let m = RuntimeManager::new(cfg(), fake); + let m = RuntimeManager::new(cfg(), fake, "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); let out = m.eval(eval_req("syntax(", None, Some(id))).await.unwrap(); assert!(!out.success); @@ -1654,7 +1867,7 @@ mod tests { #[tokio::test] async fn a_reaped_sandbox_expires_the_runtime() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); fake.with_response( "sandbox::fs::write", @@ -1677,7 +1890,7 @@ mod tests { #[tokio::test] async fn a_concurrent_exec_error_never_leaks_the_sandbox_id_to_the_caller() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); fake.with_response( @@ -1719,7 +1932,7 @@ mod tests { #[tokio::test] async fn teardown_refuses_both_runtime_id_and_namespace() { - let m = RuntimeManager::new(cfg(), happy_fake()); + let m = RuntimeManager::new(cfg(), happy_fake(), "ws://127.0.0.1:1"); let err = m .teardown(TeardownRequest { runtime_id: Some("rt-x".into()), @@ -1733,7 +1946,7 @@ mod tests { #[tokio::test] async fn teardown_refuses_neither_runtime_id_nor_namespace() { - let m = RuntimeManager::new(cfg(), happy_fake()); + let m = RuntimeManager::new(cfg(), happy_fake(), "ws://127.0.0.1:1"); let err = m .teardown(TeardownRequest { runtime_id: None, @@ -1753,7 +1966,7 @@ mod tests { #[tokio::test] async fn teardown_by_id_stops_the_sandbox_and_forgets_the_record() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); let out = m.teardown(td_by_id(&id)).await.unwrap(); assert!(out.torn_down); @@ -1777,7 +1990,7 @@ mod tests { #[tokio::test] async fn teardown_of_an_already_reaped_sandbox_still_succeeds() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); fake.with_response("sandbox::stop", Err(wrapped("S004", "already stopped"))); let out = m.teardown(td_by_id(&id)).await.unwrap(); @@ -1792,7 +2005,7 @@ mod tests { #[tokio::test] async fn teardown_waits_for_an_in_flight_eval_before_stopping_the_sandbox() { let fake = happy_fake(); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let id = seed_runtime(&m, Lang::Node, "sb-1"); let record = m.runtimes.lock().unwrap().get(&id).expect("exists").clone(); @@ -1870,7 +2083,7 @@ mod tests { async fn register_creates_a_namespace_runtime_when_none_exists() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let out = m.register(reg_req("app::greet", Lang::Node)).await.unwrap(); assert_eq!(out.function_id, "app::greet"); assert!(out.registered); @@ -1903,7 +2116,7 @@ mod tests { async fn a_second_registration_in_the_same_namespace_and_lang_reuses_the_runtime() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); m.register(reg_req("app::b", Lang::Node)).await.unwrap(); assert_eq!(creates(&fake), 1, "one namespace runtime, reused"); @@ -1916,7 +2129,7 @@ mod tests { async fn the_same_namespace_gets_a_separate_runtime_per_language() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); m.register(reg_req("app::b", Lang::Python)).await.unwrap(); assert_eq!(creates(&fake), 2); @@ -1935,7 +2148,7 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(50)); Ok(json!({ "sandbox_id": "sb-1" })) }); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let (m1, m2) = (m.clone(), m.clone()); let (r1, r2) = tokio::join!( @@ -1960,7 +2173,7 @@ mod tests { async fn a_registered_function_call_execs_the_runner_and_parses_the_result() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); fake.with_responder("sandbox::exec", |payload| { let args = payload["args"].as_array().expect("argv array"); assert_eq!(args.len(), 2, "the sentinel must NOT be in argv: {args:?}"); @@ -2027,6 +2240,32 @@ mod tests { ); } + /// A registered-function exec carries a worker name derived from the + /// FUNCTION id, so a handler that uses `iii` shows up in the engine's + /// worker list as the function it serves. + #[tokio::test] + async fn a_registered_invoke_carries_the_function_worker_name() { + let fake = happy_fake(); + probe_free(&fake); + fake.with_responder("sandbox::exec", |payload| { + assert_eq!(payload["env"]["III_WORKER_NAME"], "code-runner:app::env"); + let sentinel = decode_envelope(payload)["sentinel"] + .as_str() + .unwrap() + .to_string(); + Ok(json!({ + "stdout": format!("\n{sentinel}\nnull\n"), + "stderr": "", "exit_code": 0, "timed_out": false, + "duration_ms": 1, "success": true + })) + }); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); + m.register(reg_req("app::env", Lang::Node)).await.unwrap(); + fake.invoke("app::env", serde_json::json!({})) + .await + .expect("invocation succeeds"); + } + /// Adversarial review, backend leak: `app::greet`'s caller never /// supplied a `runtime_id` and never held one — unlike a direct /// `code-runner::eval`/`teardown` call, where `error.rs`'s id-quoting @@ -2035,7 +2274,7 @@ mod tests { async fn a_proxy_invocation_never_leaks_the_runtime_id_to_its_caller() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::greet", Lang::Node)).await.unwrap(); let rt = m .namespaces @@ -2066,7 +2305,7 @@ mod tests { async fn a_throwing_handler_surfaces_as_handler_error() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); fake.with_responder("sandbox::exec", |payload| { let env = decode_envelope(payload); let sentinel = env["sentinel"].as_str().unwrap(); @@ -2095,7 +2334,7 @@ mod tests { "timed_out": false, "duration_ms": 3, "success": false })) }); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::crash", Lang::Node)).await.unwrap(); let err = fake .invoke("app::crash", serde_json::json!({})) @@ -2112,7 +2351,7 @@ mod tests { "engine::functions::info", Ok(serde_json::json!({ "function_id": "app::greet", "description": "exists" })), ); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .register(reg_req("app::greet", Lang::Node)) .await @@ -2142,7 +2381,7 @@ mod tests { async fn a_registration_racing_a_teardown_never_leaks_the_runtime_id_to_its_direct_caller() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let m_clone = m.clone(); fake.with_responder("sandbox::fs::write", move |payload| { if payload["path"] @@ -2174,7 +2413,7 @@ mod tests { "engine::functions::info", Err("remote error: FORBIDDEN: rbac denies functions.info".into()), ); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .register(reg_req("app::greet", Lang::Node)) .await @@ -2193,7 +2432,7 @@ mod tests { .into(), ), ); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .register(reg_req("app::greet", Lang::Node)) .await @@ -2210,7 +2449,7 @@ mod tests { async fn the_first_id_claims_the_namespace_for_the_runtime() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); m.register(reg_req("app::b", Lang::Node)) .await @@ -2222,7 +2461,7 @@ mod tests { async fn malformed_function_ids_are_refused() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); for bad in [ "noseparator", "::x", @@ -2240,7 +2479,7 @@ mod tests { async fn teardown_unregisters_registered_functions() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); let out = m.teardown(td_by_ns("app")).await.unwrap(); assert_eq!(out.unregistered, vec!["app::a".to_string()]); @@ -2254,7 +2493,7 @@ mod tests { async fn expiry_unregisters_registered_functions() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); let rt = m .namespaces @@ -2277,7 +2516,7 @@ mod tests { async fn a_call_that_discovers_its_own_runtime_is_gone_unregisters_itself_without_deadlock() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::self_destruct", Lang::Node)) .await .unwrap(); @@ -2301,7 +2540,7 @@ mod tests { async fn a_torn_down_function_is_uncallable() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); m.teardown(td_by_ns("app")).await.unwrap(); assert!(fake.invoke("app::a", serde_json::json!({})).await.is_err()); @@ -2311,7 +2550,7 @@ mod tests { async fn register_caps_are_enforced() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let mut req = reg_req("app::x", Lang::Node); req.source = String::new(); @@ -2348,7 +2587,7 @@ mod tests { async fn max_functions_per_runtime_is_enforced() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); for i in 0..MAX_FUNCTIONS_PER_RUNTIME { m.register(reg_req(&format!("app::f{i}"), Lang::Node)) @@ -2371,7 +2610,7 @@ mod tests { async fn concurrent_registrations_of_the_same_id_leave_exactly_one_winner() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let (m1, m2) = (m.clone(), m.clone()); let (r1, r2) = tokio::join!( @@ -2404,7 +2643,7 @@ mod tests { "engine::functions::info", Err("remote error: FORBIDDEN: rbac denies functions.info".into()), ); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); let err = m .register(reg_req("app::greet", Lang::Node)) .await @@ -2424,7 +2663,7 @@ mod tests { async fn teardown_releases_the_claim_for_reuse() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); m.teardown(td_by_ns("app")).await.unwrap(); m.register(reg_req("app::a", Lang::Node)) @@ -2439,7 +2678,7 @@ mod tests { async fn seeded_static_ids_cannot_be_claimed_and_survive_unrelated_teardown() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.seed_static_ids(&["code-runner::eval"]); let before = fake.calls().len(); @@ -2467,7 +2706,7 @@ mod tests { #[tokio::test] async fn teardown_by_namespace_with_no_runtime_is_not_found() { - let m = RuntimeManager::new(cfg(), happy_fake()); + let m = RuntimeManager::new(cfg(), happy_fake(), "ws://127.0.0.1:1"); let err = m.teardown(td_by_ns("app")).await.unwrap_err(); assert_eq!(err.code(), "code-runner::runtime_not_found"); } @@ -2476,7 +2715,7 @@ mod tests { async fn teardown_by_namespace_accepts_the_bare_and_double_colon_forms() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); let out = m.teardown(td_by_ns("app::")).await.unwrap(); assert!(out.torn_down); @@ -2487,7 +2726,7 @@ mod tests { async fn teardown_by_namespace_tears_down_every_language_and_aggregates_unregistered() { let fake = happy_fake(); probe_free(&fake); - let m = RuntimeManager::new(cfg(), fake.clone()); + let m = RuntimeManager::new(cfg(), fake.clone(), "ws://127.0.0.1:1"); m.register(reg_req("app::a", Lang::Node)).await.unwrap(); m.register(reg_req("app::b", Lang::Python)).await.unwrap(); assert_eq!(m.runtimes.lock().unwrap().len(), 2); @@ -2516,7 +2755,7 @@ mod tests { #[tokio::test] async fn a_malformed_teardown_namespace_is_an_invalid_request() { - let m = RuntimeManager::new(cfg(), happy_fake()); + let m = RuntimeManager::new(cfg(), happy_fake(), "ws://127.0.0.1:1"); for bad in ["", "My-App", "a..b", ".hidden", "has::colons"] { let err = m.teardown(td_by_ns(bad)).await.unwrap_err(); assert_eq!(err.code(), "code-runner::invalid_request", "{bad}"); diff --git a/code-runner/src/runner.rs b/code-runner/src/runner.rs index e443f0729..4b7aa31c9 100644 --- a/code-runner/src/runner.rs +++ b/code-runner/src/runner.rs @@ -1,6 +1,17 @@ //! The language table and the runner protocol — how code-runner talks to a //! process inside the guest. //! +//! Every guest process (an eval and a registered-function call alike) gets a +//! global `iii`: a LAZY handle on the real iii-sdk client +//! ( / +//! ), connected to the engine +//! over the sandbox's network gateway (`III_URL`, set at runtime creation). +//! Nothing connects until the first use, so code that never touches `iii` +//! pays nothing. Node runtimes get the SDK planted as an embedded +//! single-file bundle under `/node_modules/iii-sdk`; Python runtimes +//! `pip install iii-sdk` at creation (its pydantic-core dependency is +//! compiled per-platform, so there is no plantable pure-Python form). +//! //! Per registered-function call, the manager execs the runtime's runner with //! `argv = [source_path]` and, on stdin, a JSON envelope //! `{"sentinel": "", "payload": }`. The runner reads and @@ -76,8 +87,81 @@ impl Lang { Self::Python => RUN_PY, } } + /// Where `create` plants the guest `iii` library. The runner and the + /// eval wrapper both resolve it RELATIVE to their own file, so the + /// trio only has to land in one directory together (which also lets + /// tests run them from a scratch dir with no `/opt` at all). The + /// Python file is deliberately NOT `iii.py`: `python3