diff --git a/.agents/skills/summon/SKILL.md b/.agents/skills/summon/SKILL.md index 7a2bbc8..fafa29b 100644 --- a/.agents/skills/summon/SKILL.md +++ b/.agents/skills/summon/SKILL.md @@ -1,6 +1,6 @@ --- name: summon -description: "Build, debug, or integrate Summon sandboxed generative UI: SurfacePlan contracts, contract-first prompts, JSONL protocol streaming, host-owned capabilities/resources, PolicyEngine grants, StreamGraph diagnostics, safety smoke tests, and adoption docs. Use when working in the Summon repo, adding capabilities/resources/workers/approval actions, debugging validation or sandbox behavior, or creating agent-authored Summon UIs." +description: "Build, debug, or integrate Summon sandboxed generative UI: SurfacePolicy contracts, Arrow JSONL artifact streaming, host-owned tools/resources, PolicyEngine grants, StreamGraph diagnostics, safety smoke tests, and adoption docs. Use when working in the Summon repo, adding tools/resources/workers/approval actions, debugging validation or sandbox behavior, or creating agent-authored Summon UIs." --- # Summon @@ -19,31 +19,30 @@ host app. native-wrapper behavior. 6. Read `docs/adoption/security.md` before changing sandbox, CSP, grants, script policy, worker, approval, or production-tier behavior. -7. Read `docs/adoption/debugging.md` before changing validation, repair, - stream graph, protocol, Devtools, or sandbox diagnostics. +7. Read `docs/adoption/debugging.md` before changing validation, stream graph, + protocol, Devtools, or sandbox diagnostics. ## Core Architecture Follow this path unless the user explicitly asks for a runtime redesign: ```txt -host capability registry +host tool registry -> SurfacePolicy: tier/grants/components/purpose/persistence -> compiled SurfacePlan: purpose/runtime/data/authority/persistence - -> createCapabilityRegistry(...).toContract() + -> createToolRegistry(...).toContract() -> compileSystemContracts() - -> protocol hardener and repair feedback - -> SectionAccumulator and StreamGraph + -> Arrow protocol hardener + -> StreamGraph artifact diagnostics -> PolicyEngine and spawnSandbox() ``` -Capabilities are host-owned. The model sees the contract; the host owns -handlers, network, credentials, state, grants, and the selected `SurfacePolicy`. +Tools are host-owned. The model sees the contract; the host owns handlers, +network, credentials, state, grants, and the selected `SurfacePolicy`. Generated artifacts must not emit or widen `/surface-policy` or `/surface-plan`. -New generation servers should prefer `runSurfaceGeneration(input, emit)` from -`@anarchitecture/summon-server`; `generateSurfaceStream()` remains available for -existing async-generator integrations. Applications should consume built public +Generation servers should use `runSurfaceGeneration(input, emit)` from +`@anarchitecture/summon-server`. Applications should consume built public package exports, not `src/*.ts` paths or `@summon-internal/*` packages. Use `defineAction` and `defineDataResource` for common host-backed @@ -54,12 +53,11 @@ host approval adapter. ## Safe Output Rules - Keep the iframe null-origin. Do not add `allow-same-origin`. -- Grant intents and capabilities from the host with `grantedIntents` and - `grantedCapabilities`; never trust artifact-declared intents or capabilities - as permission. -- Prefer declarative interactive surfaces with `scriptPolicy: "forbid"` and - `data-summon-*` bindings. Treat `scriptPolicy: "allow"` as an escalation for - hosts that intentionally permit custom artifact scripts. +- Grant tools from the host with `grantedTools`; never trust artifact-declared + tools as permission. +- Prefer Arrow-native generated artifacts with `host-bridge:summon` and + `callTool()`. Generated custom scripts, legacy runtime controls, and raw + section/fragment protocols are rejected before generation or at the parser. - Use `defineDataResource` for host-backed async data, with loading, error, and data state keys. - Resource UIs must render loading, error, and data states. @@ -70,14 +68,14 @@ host approval adapter. ## Debug Loop For generation failures, inspect `/error`, `/validation-summary`, -`/validation-blocked`, `/repair-feedback`, `/repair-summary`, -`/stream-graph-summary`, `/protocol-skip`, `/surface-policy`, -`/surface-plan`, `/shape`, `/token-overrides`, `/screen-synthesized`, and -`/mode-upgraded`. - -For client behavior, inspect Devtools events: `surface-plan`, `protocol-line`, -`protocol-parse-error`, `sandbox-ready`, `render`, `intent-emitted`, -`intent-rejected`, `intent-dispatched`, `intent-settled`, `state-pushed`, +`/validation-blocked`, `/stream-graph-summary`, `/protocol-skip`, +`/surface-policy`, `/surface-plan`, `/surface-contract`, `/agent-goal`, +`/agent-policy-resolution`, `/shape`, `/token-overrides`, and `/mode-upgraded`. + +For client behavior, inspect Devtools events: `surface-plan`, +`surface-contract`, `protocol-line`, `protocol-parse-error`, `sandbox-ready`, +`render`, `rendered`, `tool-called`, `tool-rejected`, +`tool-dispatched`, `tool-settled`, `state-pushed`, `component-sync`, `stream-graph`, and `sandbox-fatal`. Use `ContractIssue` plus `hintsForContractIssue(issue)` when feeding validation @@ -91,7 +89,10 @@ the requested grant/component exceeds the selected `SurfacePolicy` or compiled pnpm typecheck pnpm test pnpm test:safety +pnpm test:gallery pnpm build +pnpm check:public-api +pnpm smoke:public-packages pnpm pack:dry-run pnpm dev:gallery pnpm dev:workbench @@ -107,7 +108,7 @@ boot. It starts only the Vite demo app and does not require Manual smoke path: run `pnpm dev:workbench`, open `http://localhost:5173/generate`, choose the **Host-resource search** showcase scenario, keep **Free layout**, confirm the -contract cockpit shows `explore/declarative/host-resource/read/replayable` and +contract cockpit shows `explore/arrow/host-resource/read/replayable` and `Grants 1: search`, run the scenario, submit a generated search such as `chicken pasta`, inspect the Stream and Devtools drawers, replay from Saved surfaces, then open `http://localhost:5173/adversarial`. Use `/batch`, diff --git a/README.md b/README.md index aca26e6..6ee0b54 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ the canonical `.ghost/fingerprint/manifest.yml` package layout. The Surface Gallery adds a Ghost fingerprint preset for each root, and the Generate workbench adds a `Fingerprint · ` option. A fingerprint run is not a bundled visual direction: Summon consumes the Ghost relay brief as product design -direction, then applies host-owned policy, capabilities, and token CSS. +direction, then applies host-owned policy, tools, and token CSS. The full guided path lives in [docs/adoption/quickstart.md](docs/adoption/quickstart.md). @@ -98,7 +98,7 @@ pnpm dev:demos surface, and a small event strip. - `/generate` - diagnostic maintainer workbench for broker-selected surface configs, allowed host tools, trusted host components, token - overrides, validation retries, edit/replay, Ghost steering, Devtools, and + overrides, validation summaries, replay, Ghost steering, Devtools, and stream diagnostics. - `/batch` - parallel broker harness for prompt coverage, host tool wiring, direction-token visual coverage, throughput, and consistency checks. @@ -114,7 +114,8 @@ pnpm dev:demos helpers, and explicit subpaths for advanced browser, engine, host, policy, envelope, assets, and Devtools APIs. - `@anarchitecture/summon-server` - provider-neutral generation lifecycle, - validation retries, summaries, and model-provider interfaces. + Arrow protocol hardening, validation summaries, and model-provider + interfaces. - `@anarchitecture/summon-react` - `SummonSurface` and React trusted-component adapter. `react` and `react-dom` are peer dependencies. @@ -125,7 +126,7 @@ pnpm dev:demos `packages/sandbox-runtime`, `packages/server`, `packages/react` - private implementation workspaces published only through the public facades. - `apps/server` - multi-provider demo server for Anthropic, OpenAI, and Gemini, - direction loading, validation retry feedback, and demo backing routes. + direction loading, Arrow protocol diagnostics, and demo backing routes. - `apps/surface-gallery` - first-run live example app for OSS adopters. - `apps/demo` - Vite maintainer workbench for generation, batch runs, adversarial checks, strict input, Ghost steering, diagnostics, and fatal diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index 4f51fe6..7fe2d38 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -7,7 +7,6 @@ import { ThemeProvider, ThemeToggle } from './theme.js'; const AdversarialPage = lazy(() => import('./pages/AdversarialPage.js').then((module) => ({ default: module.AdversarialPage }))); const BatchPage = lazy(() => import('./pages/BatchPage.js').then((module) => ({ default: module.BatchPage }))); const FatalPage = lazy(() => import('./pages/FatalPage.js').then((module) => ({ default: module.FatalPage }))); -const FragmentComparePage = lazy(() => import('./pages/FragmentComparePage.js').then((module) => ({ default: module.FragmentComparePage }))); const GeneratePage = lazy(() => import('./pages/generate/GeneratePage.js').then((module) => ({ default: module.GeneratePage }))); const StrictPage = lazy(() => import('./pages/StrictPage.js').then((module) => ({ default: module.StrictPage }))); @@ -27,7 +26,6 @@ function AppRoutes() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/apps/demo/src/adversarial-artifact.ts b/apps/demo/src/adversarial-artifact.ts index 93124e5..99fb3c6 100644 --- a/apps/demo/src/adversarial-artifact.ts +++ b/apps/demo/src/adversarial-artifact.ts @@ -1,246 +1,91 @@ -/** - * Adversarial artifact body. Each test tries to break out of the sandbox in a - * different way. All results are reported back via sandbox.emit('report', ...). - * - * PASS = reported as "blocked" - * FAIL = reported as "allowed" - * - * The host tallies results. - */ -export const ADVERSARIAL_BODY_HTML = /* html */ ` -
-
Adversarial sandbox
-
Running breakout tests and reporting back…
-
-
- - + await expectBlocked("emit-unknown-tool", () => rejectedTool("emit-unknown-tool", "exfiltrate", { data: "secret" })); + await expectBlocked("emit-declared-but-not-granted", () => rejectedTool("emit-declared-but-not-granted", "escalate", { test: "emit-declared-but-not-granted" })); + await expectBlocked("empty-tool-name", () => rejectedTool("empty-tool-name", "", {})); + await report("__DONE__", "info", ""); +} + +void runAll(); + +export default html\` +
+
Adversarial sandbox
+
Running Arrow VM boundary checks and reporting back through callTool().
+
Tests started
+
+\`; `; + +export const ADVERSARIAL_ARTIFACT: ArrowSurfaceArtifact = { + runtime: 'arrow', + source: { + 'main.ts': adversarialMain, + }, +}; diff --git a/apps/demo/src/components/TrustedFixtureSurface.tsx b/apps/demo/src/components/TrustedFixtureSurface.tsx deleted file mode 100644 index 73748d4..0000000 --- a/apps/demo/src/components/TrustedFixtureSurface.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useRef, - type CSSProperties, -} from 'react'; -import bootstrapSource from '@anarchitecture/summon/bootstrap.js?raw'; -import tokensSource from '@anarchitecture/summon/tokens.css?raw'; - -export interface TrustedFixtureSurfaceHandle { - iframe: HTMLIFrameElement | null; - sandboxId: string | null; - pushState(state: Record): void; -} - -export interface TrustedFixtureSurfaceProps { - html: string; - grantedIntents: string[]; - initialState?: Record; - onIntent?: (intent: string, args: Record) => void; - onIntentRejected?: (reason: string, raw: unknown) => void; - onFatal?: (reason: string) => void; - id?: string; - title?: string; - className?: string; - style?: CSSProperties; -} - -function randomId(): string { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); -} - -function escapeHtml(s: string): string { - return s - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"'); -} - -function escapeScript(s: string): string { - return s.replace(/<\/script/gi, '<\\/script'); -} - -function escapeScriptJson(value: unknown): string { - return JSON.stringify(value).replaceAll('<', '\\u003c'); -} - -function cspForNonce(nonce: string): string { - return [ - "default-src 'none'", - `script-src 'nonce-${nonce}'`, - "style-src 'unsafe-inline'", - "img-src data:", - "font-src data:", - "connect-src 'none'", - "form-action 'none'", - "base-uri 'none'", - "frame-src 'none'", - "child-src 'none'", - "media-src 'none'", - "object-src 'none'", - "worker-src 'none'", - ].join('; '); -} - -function nonceFixtureScripts(html: string, nonce: string): string { - return html.replace(/]*\bnonce=)/gi, ` - - - -
${nonceFixtureScripts(params.html, params.nonce)}
-`; -} - -/** - * Demo-only escape hatch for trusted adversarial fixtures. It keeps the sandbox - * and host intent bridge under test without letting generated artifacts regain - * public script execution. - */ -export const TrustedFixtureSurface = forwardRef( - function TrustedFixtureSurface(props, ref) { - const iframeRef = useRef(null); - const sandboxId = useMemo(randomId, []); - const nonce = useMemo(randomId, []); - const readyRef = useRef(false); - const pendingStatesRef = useRef[]>([]); - - function postState(state: Record) { - const iframe = iframeRef.current; - if (!readyRef.current || !iframe?.contentWindow) { - pendingStatesRef.current.push(state); - return; - } - iframe.contentWindow.postMessage({ - type: 'SUMMON_STATE', - sandbox_id: sandboxId, - state, - }, '*'); - } - - useImperativeHandle(ref, () => ({ - get iframe() { - return iframeRef.current; - }, - get sandboxId() { - return sandboxId; - }, - pushState(state: Record) { - postState(state); - }, - }), [sandboxId]); - - useEffect(() => { - readyRef.current = false; - pendingStatesRef.current = props.initialState ? [props.initialState] : []; - const intentAllowlist = new Set(props.grantedIntents); - - function flushPending() { - const iframe = iframeRef.current; - if (!readyRef.current || !iframe?.contentWindow) return; - while (pendingStatesRef.current.length > 0) { - const state = pendingStatesRef.current.shift()!; - iframe.contentWindow.postMessage({ - type: 'SUMMON_STATE', - sandbox_id: sandboxId, - state, - }, '*'); - } - } - - function handleMessage(event: MessageEvent) { - const data = event.data as { - type?: string; - sandbox_id?: string; - reason?: unknown; - intent?: unknown; - args?: unknown; - } | undefined; - if (!data || typeof data !== 'object') return; - if ( - data.type !== 'SUMMON_READY' && - data.type !== 'SUMMON_FATAL' && - data.type !== 'SUMMON_INTENT' - ) { - return; - } - if (data.sandbox_id !== sandboxId) return; - - if (data.type === 'SUMMON_FATAL') { - readyRef.current = false; - props.onFatal?.(typeof data.reason === 'string' ? data.reason : 'unknown'); - return; - } - - if (data.type === 'SUMMON_READY') { - readyRef.current = true; - flushPending(); - return; - } - - const intent = data.intent; - if (typeof intent !== 'string' || !intent) { - props.onIntentRejected?.('intent not a non-empty string', data); - return; - } - if (!intentAllowlist.has(intent)) { - props.onIntentRejected?.(`intent "${intent}" not granted`, data); - return; - } - const args = data.args && typeof data.args === 'object' - ? data.args as Record - : {}; - props.onIntent?.(intent, args); - } - - window.addEventListener('message', handleMessage); - if (iframeRef.current) { - iframeRef.current.setAttribute('sandbox', 'allow-scripts'); - iframeRef.current.srcdoc = buildSrcdoc({ - sandboxId, - nonce, - html: props.html, - }); - } - - return () => { - window.removeEventListener('message', handleMessage); - readyRef.current = false; - pendingStatesRef.current = []; - if (iframeRef.current) iframeRef.current.srcdoc = ''; - }; - }, [ - nonce, - props.grantedIntents, - props.html, - props.initialState, - props.onFatal, - props.onIntent, - props.onIntentRejected, - sandboxId, - ]); - - return ( - ', baseContext); - assert.ok(codes(iframe.issues).includes('unsafe-tag')); - - const img = compileArtifactHtml('>', baseContext); - assert.deepEqual(codes(img.issues), ['external-url']); -}); - -test('compiler decodes HTML entities before URL checks', () => { - const jsUrl = compileArtifactHtml('bad', baseContext); - assert.deepEqual(codes(jsUrl.issues), ['external-url']); - - const assetUrl = compileArtifactHtml('', baseContext); - assert.deepEqual(assetUrl.issues, []); -}); - -test('compiler ignores comments and escaped raw-text false positives', () => { - const result = compileArtifactHtml( - '

<script>safe text</script>

', - baseContext, - ); - - assert.deepEqual(result.issues, []); - assert.match(result.html, /<script>safe text<\/script>/); -}); - -test('compiler blocks unsafe SVG and executable SVG escapes', () => { - const foreignObject = compileArtifactHtml('
x
', baseContext); - assert.ok(codes(foreignObject.issues).includes('unsafe-tag')); - - const svgScript = compileArtifactHtml('', { mode: 'interactive' }); - assert.ok(codes(svgScript.issues).includes('script-not-granted')); -}); - -test('compiler blocks CSS imports and escaped external url values', () => { - const result = compileArtifactHtml( - '', - baseContext, - ); - - assert.deepEqual(codes(result.issues), ['css-import', 'external-url']); -}); - -test('compiler accepts declarative local state and motion primitives', () => { - const result = compileArtifactHtml( - '
', - { - mode: 'interactive', - scriptPolicy: 'forbid', - }, - ); - - assert.deepEqual(result.issues, []); -}); diff --git a/packages/engine/test/runtime-validator-bindings.test.ts b/packages/engine/test/runtime-validator-bindings.test.ts deleted file mode 100644 index 88a511e..0000000 --- a/packages/engine/test/runtime-validator-bindings.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { validateHtmlFragment } from '../src/index.ts'; -import { baseContext, codes } from './runtime-validator-fixtures.ts'; - -test('accepts click, submit, and mount attributes only for matching triggers', () => { - const issues = validateHtmlFragment( - '
', - { - mode: 'interactive', - capabilities: [ - { name: 'choose', triggers: ['click'] }, - { name: 'search', triggers: ['submit', 'mount'] }, - ], - }, - ); - assert.deepEqual(issues, []); -}); - -test('rejects malformed data-summon-args JSON', () => { - const issues = validateHtmlFragment( - '', - { ...baseContext, mode: 'interactive' }, - ); - assert.deepEqual(codes(issues), ['invalid-args-json']); -}); - -test('accepts declarative resource submit, mount, foreach, and safe attr bindings', () => { - const issues = validateHtmlFragment( - `
-
- - -
-

Searching...

-

-
-
-
-

Loading...

-

- -
-
`, - { - mode: 'interactive', - capabilities: [ - { - name: 'search', - kind: 'resource', - triggers: ['submit'], - stateKeys: { loading: 'searching', data: 'results', error: 'searchError' }, - }, - { - name: 'profile', - kind: 'resource', - triggers: ['mount'], - stateKeys: { loading: 'profileLoading', data: 'profile', error: 'profileError' }, - }, - ], - }, - ); - assert.deepEqual(issues, []); -}); - -test('warns when data resource UI omits lifecycle bindings without blocking', () => { - const issues = validateHtmlFragment( - `
- - -
`, - { - mode: 'interactive', - capabilities: [ - { - name: 'search', - kind: 'resource', - triggers: ['submit'], - stateKeys: { loading: 'searching', data: 'results', error: 'searchError' }, - }, - ], - }, - ); - - assert.equal(issues.some((issue) => issue.severity === 'block'), false); - assert.deepEqual(codes(issues), [ - 'resource-data-not-rendered', - 'resource-error-not-rendered', - 'resource-loading-not-rendered', - ]); -}); - -test('warns when data resource UI omits declared empty state binding', () => { - const issues = validateHtmlFragment( - `
-
- - -
-

Searching...

-

-
-
`, - { - mode: 'interactive', - capabilities: [ - { - name: 'search', - kind: 'resource', - triggers: ['submit'], - stateKeys: { loading: 'searching', data: 'results', error: 'searchError', empty: 'noResults' }, - }, - ], - }, - ); - - assert.equal(issues.some((issue) => issue.severity === 'block'), false); - assert.deepEqual(codes(issues), ['resource-empty-not-rendered']); -}); - -test('accepts declared empty state binding for data resources', () => { - const issues = validateHtmlFragment( - `
-
- - -
-

Searching...

-

-

No results

-
-
`, - { - mode: 'interactive', - capabilities: [ - { - name: 'search', - kind: 'resource', - triggers: ['submit'], - stateKeys: { loading: 'searching', data: 'results', error: 'searchError', empty: 'noResults' }, - }, - ], - }, - ); - - assert.deepEqual(issues, []); -}); - -test('warns when controlled action UI omits pending or error state bindings', () => { - const issues = validateHtmlFragment( - '', - { - mode: 'interactive', - capabilities: [ - { - name: 'save', - kind: 'action', - triggers: ['click'], - actionStateKeys: { pending: 'savePending', done: 'saveDone', error: 'saveError' }, - }, - ], - }, - ); - - assert.equal(issues.some((issue) => issue.severity === 'block'), false); - assert.deepEqual(codes(issues), [ - 'action-error-not-rendered', - 'action-pending-not-rendered', - ]); -}); - -test('accepts pending and error bindings for controlled actions', () => { - const issues = validateHtmlFragment( - ` -

Saving...

-

`, - { - mode: 'interactive', - capabilities: [ - { - name: 'save', - kind: 'action', - triggers: ['click'], - actionStateKeys: { pending: 'savePending', done: 'saveDone', error: 'saveError' }, - }, - ], - }, - ); - - assert.deepEqual(issues, []); -}); - -test('rejects invalid resource declarations and unsafe attr bindings', () => { - const issues = validateHtmlFragment( - ` -
-
- -

Loading...

-

-
-
-
- x -
`, - { - mode: 'interactive', - capabilities: [ - { name: 'choose', kind: 'action', triggers: ['click'] }, - { - name: 'search', - kind: 'resource', - triggers: ['submit'], - stateKeys: { loading: 'searching', data: 'results', error: 'searchError' }, - }, - { - name: 'slow', - kind: 'resource', - triggers: ['mount'], - stateKeys: { loading: 'slowLoading' }, - }, - ], - }, - ); - assert.deepEqual(codes(issues), [ - 'bad-attr-binding-placement', - 'intent-trigger-not-granted', - 'mixed-resource-legacy-trigger', - 'non-resource-capability', - 'resource-state-keys-incomplete', - 'resource-trigger-without-resource', - 'unknown-resource', - 'unsafe-attr-binding', - ]); -}); - -test('rejects declarative attributes for unknown or non-matching trigger grants', () => { - const issues = validateHtmlFragment( - '
', - { - mode: 'interactive', - capabilities: [ - { name: 'choose', triggers: ['click'] }, - { name: 'search', triggers: ['submit', 'mount'] }, - ], - }, - ); - assert.deepEqual(codes(issues), [ - 'intent-trigger-not-granted', - 'intent-trigger-not-granted', - 'unknown-intent', - ]); -}); - -test('accepts registered component placeholders', () => { - const issues = validateHtmlFragment( - `
-
-
`, - { - mode: 'static', - surfacePlan: { - purpose: 'inform', - runtime: 'static', - data: 'embedded', - authority: 'none', - persistence: 'replayable', - }, - components: [ - { name: 'MetricCard', surface: { data: 'embedded', authority: 'none' } }, - ], - }, - ); - assert.deepEqual(issues, []); -}); - -test('rejects malformed component placeholders', () => { - const issues = validateHtmlFragment( - `
-
-
-
-
-
-
`, - { - mode: 'interactive', - components: [ - { name: 'MetricCard', surface: { data: 'embedded', authority: 'none' } }, - ], - }, - ); - assert.deepEqual(codes(issues), [ - 'component-id-duplicate', - 'component-id-missing', - 'component-props-invalid', - 'nested-component', - 'unknown-component', - ]); -}); - -test('rejects component islands that exceed the selected surface plan', () => { - const issues = validateHtmlFragment( - `
`, - { - mode: 'static', - surfacePlan: { - purpose: 'inform', - runtime: 'static', - data: 'embedded', - authority: 'none', - persistence: 'replayable', - }, - components: [ - { name: 'HostChart', surface: { data: 'host-resource', authority: 'read' } }, - ], - }, - ); - assert.deepEqual(codes(issues), [ - 'surface-authority-exceeded', - 'surface-data-exceeded', - 'surface-runtime-exceeded', - ]); -}); diff --git a/packages/engine/test/runtime-validator-fixtures.ts b/packages/engine/test/runtime-validator-fixtures.ts index 5ffcdb4..aea232f 100644 --- a/packages/engine/test/runtime-validator-fixtures.ts +++ b/packages/engine/test/runtime-validator-fixtures.ts @@ -2,8 +2,8 @@ import type { ContractIssue } from '../src/index.ts'; export const baseContext = { mode: 'static' as const, - allowedIntents: ['choose'], - capabilities: [{ name: 'choose', triggers: ['click' as const] }], + allowedTools: ['choose'], + tools: [{ name: 'choose', triggers: ['click' as const] }], definedTokens: new Set(['color-text', 'space-2', 'radius-pill']), }; diff --git a/packages/engine/test/runtime-validator-protocol.test.ts b/packages/engine/test/runtime-validator-protocol.test.ts index 57f7a37..e1c306c 100644 --- a/packages/engine/test/runtime-validator-protocol.test.ts +++ b/packages/engine/test/runtime-validator-protocol.test.ts @@ -8,14 +8,6 @@ import { } from '../src/index.ts'; import { baseContext, codes } from './runtime-validator-fixtures.ts'; -test('blocks invalid section paths', () => { - const issues = validateProtocolLine( - { op: 'add', path: '/section/Bad_Section', html: '

Hi

' }, - baseContext, - ); - assert.deepEqual(codes(issues), ['invalid-section-path']); -}); - test('rejects malformed JSONL before validation', () => { assert.equal(parseProtocolLine('not-json'), null); }); @@ -27,70 +19,190 @@ test('strict protocol parser rejects oversized lines', () => { ); }); -test('blocks malformed screen declarations', () => { - const issues = validateProtocolLine( - { op: 'set', path: '/screen', value: { sections: ['hero', 'hero'] } }, - baseContext, - ); - assert.deepEqual(codes(issues), ['duplicate-section-id']); -}); - -test('blocks malformed block declarations and paths', () => { +test('blocks generated host-owned surface meta paths', () => { assert.deepEqual( codes(validateProtocolLine( - { op: 'set', path: '/section/hero', value: { blocks: ['headline', 'headline'] } }, + { op: 'meta', path: '/surface-policy', value: { tier: 'static' } }, baseContext, )), - ['duplicate-block-id'], + ['host-owned-meta'], ); assert.deepEqual( codes(validateProtocolLine( - { op: 'set', path: '/section/hero', value: { blocks: [] } }, + { op: 'meta', path: '/surface-plan', value: {} }, baseContext, )), - ['invalid-block-count'], + ['host-owned-meta'], ); assert.deepEqual( codes(validateProtocolLine( - { op: 'add', path: '/section/hero/block/Bad_Block', html: '

Hi

' }, + { op: 'meta', path: '/surface-contract', value: {} }, baseContext, )), - ['invalid-block-path'], + ['host-owned-meta'], ); }); -test('blocks generated host-owned surface meta paths', () => { +test('accepts valid Arrow artifacts', () => { + const issues = validateProtocolLine( + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': 'import { html } from "@arrow-js/core";\nexport default html``', + 'main.css': 'button { color: var(--color-text); }', + }, + }, + }, + { + ...baseContext, + surfacePlan: { + purpose: 'operate', + runtime: 'arrow', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, + }, + ); + assert.deepEqual(issues, []); +}); + +test('blocks legacy data-summon binding attributes in Arrow artifacts', () => { + const issues = validateProtocolLine( + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': [ + 'import { html } from "@arrow-js/core";', + 'export default html`', + '
', + '', + '

', + '
', + '
`;', + ].join('\n'), + }, + }, + }, + baseContext, + ); + assert.deepEqual(codes(issues), ['unsupported-legacy-data-summon-binding']); + assert.match(issues[0]?.message ?? '', /data-summon-bind/); + assert.match(issues[0]?.message ?? '', /data-summon-local/); + assert.match(issues[0]?.message ?? '', /data-summon-on-click/); + assert.match(issues[0]?.message ?? '', /data-summon-resource/); + assert.match(issues[0]?.message ?? '', /data-summon-resource-trigger/); + assert.match(issues[0]?.message ?? '', /data-summon-show/); +}); + +test('allows trusted component placeholder attributes in Arrow artifacts', () => { + const issues = validateProtocolLine( + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': [ + 'import { html } from "@arrow-js/core";', + 'export default html`', + '
', + '`;', + ].join('\n'), + }, + }, + }, + baseContext, + ); + assert.deepEqual(issues, []); +}); + +test('parser rejects legacy section protocol ops', () => { + assert.throws( + () => parseProtocolLineStrict(JSON.stringify({ op: 'set', path: '/screen', value: { sections: ['hero'] } })), + (err) => err instanceof ProtocolParseError && err.code === 'invalid-op', + ); + assert.throws( + () => parseProtocolLineStrict(JSON.stringify({ op: 'add', path: '/section/hero', html: '

Hi

' })), + (err) => err instanceof ProtocolParseError && err.code === 'invalid-op', + ); +}); + +test('blocks malformed Arrow artifacts and ungranted restricted fetch', () => { assert.deepEqual( codes(validateProtocolLine( - { op: 'meta', path: '/surface-policy', value: { tier: 'static' } }, + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': 'export default html`
A
`', + 'main.js': 'export default html`
B
`', + }, + }, + }, baseContext, )), - ['host-owned-meta'], + ['invalid-arrow-entry'], ); + assert.deepEqual( codes(validateProtocolLine( - { op: 'meta', path: '/surface-plan', value: {} }, + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + network: 'restricted-fetch', + source: { + 'main.ts': 'export default html`
Weather
`', + }, + }, + }, baseContext, )), - ['host-owned-meta'], + ['arrow-network-not-granted'], ); + assert.deepEqual( codes(validateProtocolLine( - { op: 'meta', path: '/surface-contract', value: {} }, + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': 'import { html } from "@arrow-js/core"; export default html``', + }, + }, + }, baseContext, )), - ['host-owned-meta'], + ['unsupported-arrow-idl-binding'], ); -}); -test('allows safe static markup', () => { - const issues = validateProtocolLine( - { - op: 'add', - path: '/section/hero', - html: '

Hi

', - }, - baseContext, + assert.deepEqual( + codes(validateProtocolLine( + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': 'import { html } from "@arrow-js/core"; export default html``', + }, + }, + }, + baseContext, + )), + ['unsupported-arrow-open-tag-expression'], ); - assert.deepEqual(issues, []); }); diff --git a/packages/engine/test/runtime-validator-safety.test.ts b/packages/engine/test/runtime-validator-safety.test.ts deleted file mode 100644 index e614180..0000000 --- a/packages/engine/test/runtime-validator-safety.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { validateHtmlFragment } from '../src/index.ts'; -import { - baseContext, - codes, -} from './runtime-validator-fixtures.ts'; - -test('blocks static scripts and inline handlers', () => { - const issues = validateHtmlFragment( - '', - baseContext, - ); - assert.deepEqual(codes(issues), ['inline-handler', 'static-script']); -}); - -test('blocks interactive scripts when script policy is declarative-only', () => { - const issues = validateHtmlFragment( - '', - { - mode: 'interactive', - scriptPolicy: 'forbid', - capabilities: [{ name: 'choose', triggers: ['click'] }], - }, - ); - assert.deepEqual(codes(issues), ['script-not-granted']); -}); - -test('blocks interactive scripts by default without a scripted surface plan', () => { - const issues = validateHtmlFragment( - '', - { - mode: 'interactive', - capabilities: [{ name: 'choose', triggers: ['click'] }], - }, - ); - assert.deepEqual(codes(issues), ['script-not-granted']); -}); - -test('rejects legacy scripted surface plan with allow policy', () => { - const issues = validateHtmlFragment( - '', - { - mode: 'interactive', - scriptPolicy: 'allow', - surfacePlan: { - purpose: 'explore', - runtime: 'scripted', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', - } as never, - capabilities: [{ name: 'choose', kind: 'action', triggers: ['click'] }], - }, - ); - assert.deepEqual(codes(issues), ['script-not-granted', 'surface-script-policy-removed']); -}); - -test('blocks external assets and unsafe tags', () => { - const issues = validateHtmlFragment( - '
', - baseContext, - ); - assert.deepEqual(codes(issues), [ - 'external-url', - 'external-url', - 'unsafe-attribute', - 'unsafe-tag', - 'unsafe-tag', - ]); -}); - -test('blocks unknown declarative and script intents', () => { - const issues = validateHtmlFragment( - '', - { - ...baseContext, - mode: 'interactive', - scriptPolicy: 'allow', - surfacePlan: { - purpose: 'explore', - runtime: 'scripted', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', - } as never, - }, - ); - assert.deepEqual(codes(issues), [ - 'script-not-granted', - 'surface-script-policy-removed', - 'unknown-intent', - 'unknown-intent', - ]); -}); - -test('rejects sandbox.emit even when the intent name is granted', () => { - const issues = validateHtmlFragment( - '', - { - mode: 'interactive', - scriptPolicy: 'allow', - surfacePlan: { - purpose: 'explore', - runtime: 'scripted', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', - } as never, - capabilities: [{ name: 'search', triggers: ['mount'] }], - }, - ); - assert.deepEqual(codes(issues), ['script-not-granted', 'surface-script-policy-removed']); -}); diff --git a/packages/engine/test/runtime-validator-surface-style.test.ts b/packages/engine/test/runtime-validator-surface-style.test.ts deleted file mode 100644 index 7029a0f..0000000 --- a/packages/engine/test/runtime-validator-surface-style.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { validateHtmlFragment } from '../src/index.ts'; -import { baseContext, codes } from './runtime-validator-fixtures.ts'; - -test('surface plan blocks scripts and capabilities that exceed declarative static scope', () => { - const issues = validateHtmlFragment( - '', - { - mode: 'interactive', - scriptPolicy: 'forbid', - surfacePlan: { - purpose: 'inform', - runtime: 'declarative', - data: 'embedded', - authority: 'none', - persistence: 'replayable', - }, - capabilities: [{ name: 'choose', kind: 'action', triggers: ['click'] }], - }, - ); - assert.deepEqual(codes(issues), [ - 'script-not-granted', - 'surface-authority-exceeded', - ]); -}); - -test('surface plan blocks approval-gated capability usage without approval authority', () => { - const issues = validateHtmlFragment( - '', - { - mode: 'interactive', - surfacePlan: { - purpose: 'operate', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', - }, - capabilities: [ - { - name: 'approve_price', - kind: 'action', - triggers: ['click'], - surface: { authority: 'approval-gated' }, - }, - ], - }, - ); - assert.deepEqual(codes(issues), ['surface-authority-exceeded']); -}); - -test('surface plan requires worker-backed capabilities for worker runtime', () => { - const issues = validateHtmlFragment( - '

Loading

', - { - mode: 'interactive', - surfacePlan: { - purpose: 'explore', - runtime: 'worker', - data: 'worker', - authority: 'read', - persistence: 'replayable', - }, - capabilities: [ - { - name: 'analysis', - kind: 'resource', - triggers: ['mount'], - stateKeys: { loading: 'loading', data: 'analysis', error: 'error' }, - surface: { data: 'host-resource', authority: 'read' }, - }, - ], - }, - ); - assert.deepEqual(codes(issues), [ - 'surface-data-exceeded', - 'surface-runtime-exceeded', - ]); -}); - -test('warns for style drift without blocking', () => { - const issues = validateHtmlFragment( - '
Hi
', - baseContext, - ); - assert.equal(issues.some((issue) => issue.severity === 'block'), false); - assert.deepEqual(codes(issues), ['raw-color', 'raw-px', 'unknown-token']); -}); - -test('enforces centralized validation limits', () => { - const htmlLimit = validateHtmlFragment('

too large

', { - ...baseContext, - limits: { maxSectionHtmlBytes: 4 }, - }); - assert.deepEqual(codes(htmlLimit), ['section-html-limit']); - - const depthLimit = validateHtmlFragment('
deep
', { - ...baseContext, - limits: { maxDomDepth: 2 }, - }); - assert.deepEqual(codes(depthLimit), ['dom-depth-limit']); - - const nodeLimit = validateHtmlFragment('

a

b

c

', { - ...baseContext, - limits: { maxDomNodes: 2 }, - }); - assert.deepEqual(codes(nodeLimit), ['dom-node-limit']); - - const cssLimit = validateHtmlFragment('', { - ...baseContext, - limits: { maxCssBytes: 4 }, - }); - assert.deepEqual(codes(cssLimit), ['css-size-limit']); -}); diff --git a/packages/engine/test/section-accumulator.test.ts b/packages/engine/test/section-accumulator.test.ts deleted file mode 100644 index 96123e8..0000000 --- a/packages/engine/test/section-accumulator.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { SectionAccumulator } from '../src/index.ts'; - -test('snapshots, hydrates, and reports detailed section changes', () => { - const acc = new SectionAccumulator(); - assert.deepEqual( - acc.applyDetailed({ op: 'set', path: '/screen', value: { sections: ['hero', 'details'] } }), - { changed: true, kind: 'screen', orderChanged: true }, - ); - assert.equal(acc.apply({ op: 'add', path: '/section/hero', html: '

Hero

' }), true); - assert.equal(acc.apply({ op: 'add', path: '/section/details', html: '

Details

' }), true); - - const snapshot = acc.snapshot(); - assert.deepEqual(snapshot, { - sections: [ - { id: 'hero', html: '

Hero

' }, - { id: 'details', html: '

Details

' }, - ], - }); - - const restored = SectionAccumulator.fromSnapshot(snapshot); - const replacement = restored.applyDetailed({ - op: 'add', - path: '/section/details', - html: '

Updated

', - }); - assert.equal(replacement.changed, true); - assert.equal(replacement.kind, 'section'); - assert.equal(replacement.sectionId, 'details'); - assert.equal(replacement.htmlChanged, true); - assert.equal(replacement.orderChanged, false); - assert.match(restored.compose(), /Updated/); -}); - -test('repeated section add replaces existing html without changing order', () => { - const acc = new SectionAccumulator(); - acc.applyDetailed({ op: 'set', path: '/screen', value: { sections: ['hero'] } }); - - const placeholder = acc.applyDetailed({ - op: 'add', - path: '/section/hero', - html: '
Drafting...
', - }); - assert.deepEqual(placeholder, { - changed: true, - kind: 'section', - sectionId: 'hero', - orderChanged: false, - htmlChanged: true, - }); - - const final = acc.applyDetailed({ - op: 'add', - path: '/section/hero', - html: '

Final answer

', - }); - assert.deepEqual(final, { - changed: true, - kind: 'section', - sectionId: 'hero', - orderChanged: false, - htmlChanged: true, - }); - assert.doesNotMatch(acc.compose(), /Drafting/); - assert.match(acc.compose(), /Final answer/); - assert.deepEqual(acc.snapshot().sections.map((section) => section.id), ['hero']); -}); - -test('block fragments compose inside stable section wrappers', () => { - const acc = new SectionAccumulator(); - acc.applyDetailed({ op: 'set', path: '/screen', value: { sections: ['summary'] } }); - assert.deepEqual( - acc.applyDetailed({ op: 'set', path: '/section/summary', value: { blocks: ['headline', 'metrics'] } }), - { changed: true, kind: 'section', sectionId: 'summary', orderChanged: true }, - ); - acc.applyDetailed({ - op: 'add', - path: '/section/summary/block/headline', - html: '

Closeout

', - }); - acc.applyDetailed({ - op: 'add', - path: '/section/summary/block/metrics', - html: '

42 orders

', - }); - - assert.equal(acc.compose(), [ - '
', - '
', - '

Closeout

', - '
', - '
', - '

42 orders

', - '
', - '
', - ].join('\n')); -}); - -test('block replacement updates one block and whole-section add clears block state', () => { - const acc = new SectionAccumulator(); - acc.applyDetailed({ op: 'set', path: '/screen', value: { sections: ['summary'] } }); - acc.applyDetailed({ op: 'set', path: '/section/summary', value: { blocks: ['a', 'b'] } }); - acc.applyDetailed({ op: 'add', path: '/section/summary/block/a', html: '

A

' }); - acc.applyDetailed({ op: 'add', path: '/section/summary/block/b', html: '

Draft

' }); - - const replacement = acc.applyDetailed({ - op: 'add', - path: '/section/summary/block/b', - html: '

Final

', - }); - assert.equal(replacement.changed, true); - assert.equal(replacement.blockId, 'b'); - assert.match(acc.compose(), /

A<\/p>/); - assert.match(acc.compose(), /

Final<\/p>/); - assert.doesNotMatch(acc.compose(), /Draft/); - - acc.applyDetailed({ op: 'add', path: '/section/summary', html: '

Opaque
' }); - assert.equal( - acc.compose(), - '
\n
Opaque
\n
', - ); -}); - -test('html node patches compose into nested section HTML', () => { - const acc = new SectionAccumulator(); - acc.applyDetailed({ op: 'set', path: '/screen', value: { sections: ['main'] } }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/root', - html: '
', - }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/headline', - parent: 'root', - html: '

Closeout

', - }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/metric', - parent: 'root', - html: '
42
', - }); - - assert.equal(acc.compose(), [ - '
', - '
', - '

Closeout

', - '
42
', - '
', - '
', - ].join('\n')); -}); - -test('html node patches compose children into explicit node slots', () => { - const acc = new SectionAccumulator(); - acc.applyDetailed({ op: 'set', path: '/screen', value: { sections: ['main'] } }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/root', - html: '
', - }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/card', - parent: 'root', - html: '

Sales

Loading
', - }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/card-value', - parent: 'card', - html: '

$1,240

', - }); - - assert.equal(acc.compose(), [ - '
', - '
', - '

Sales

', - '

$1,240

', - '
', - '
', - '
', - ].join('\n')); - assert.doesNotMatch(acc.compose(), /data-summon-skeleton/); -}); - -test('html node replacement updates only that node in composed HTML', () => { - const acc = new SectionAccumulator(); - acc.applyDetailed({ op: 'set', path: '/screen', value: { sections: ['main'] } }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/root', - html: '
', - }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/a', - parent: 'root', - html: '

A

', - }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/b', - parent: 'root', - html: '

Draft

', - }); - - const replacement = acc.applyDetailed({ - op: 'add', - path: '/section/main/node/b', - parent: 'root', - html: '

Final

', - }); - assert.equal(replacement.changed, true); - assert.equal(replacement.nodeId, 'b'); - assert.equal(replacement.nodePatch?.parentId, 'root'); - assert.match(acc.compose(), /data-summon-node="a">A/); - assert.match(acc.compose(), /data-summon-node="b">Final/); - assert.doesNotMatch(acc.compose(), /Draft/); -}); - -test('whole-section add clears html node state', () => { - const acc = new SectionAccumulator(); - acc.applyDetailed({ op: 'set', path: '/screen', value: { sections: ['main'] } }); - acc.applyDetailed({ - op: 'add', - path: '/section/main/node/root', - html: '
', - }); - - acc.applyDetailed({ op: 'add', path: '/section/main', html: '
Opaque
' }); - assert.equal( - acc.compose(), - '
\n
Opaque
\n
', - ); -}); diff --git a/packages/engine/test/stream-graph.test.ts b/packages/engine/test/stream-graph.test.ts index ca4f442..1811c7a 100644 --- a/packages/engine/test/stream-graph.test.ts +++ b/packages/engine/test/stream-graph.test.ts @@ -1,121 +1,64 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - compileSystemContracts, StreamGraph, type ContractIssue, - type RepairFeedbackMetaValue, - type SummonLayout, } from '../src/index.ts'; -test('screen declaration creates ordered edges and missing declared nodes', () => { +const artifact = { + runtime: 'arrow', + source: { + 'main.ts': 'export default html`

Hello

`', + }, +}; + +test('artifact lines create ordered Arrow revisions', () => { const graph = new StreamGraph(); - graph.applyLine({ op: 'set', path: '/screen', value: { sections: ['hero', 'details'] } }); + graph.applyLine({ op: 'artifact', path: '/artifact', value: artifact }); + graph.applyLine({ + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': 'export default html`

Updated

`', + 'main.css': 'p { color: var(--color-text); }', + }, + }, + }); const snap = graph.snapshot(); - assert.deepEqual(snap.edges, [ - { from: 'screen', to: 'hero', order: 0 }, - { from: 'screen', to: 'details', order: 1 }, - ]); + assert.equal(snap.artifacts.length, 2); assert.deepEqual( - snap.sections.map(({ id, declared, present, revision, bytes }) => ({ - id, - declared, - present, + snap.artifacts.map(({ revision, runtime, firstSeenLine, lastUpdatedLine }) => ({ revision, - bytes, + runtime, + firstSeenLine, + lastUpdatedLine, })), [ - { id: 'hero', declared: true, present: false, revision: 0, bytes: 0 }, - { id: 'details', declared: true, present: false, revision: 0, bytes: 0 }, + { revision: 1, runtime: 'arrow', firstSeenLine: 1, lastUpdatedLine: 1 }, + { revision: 2, runtime: 'arrow', firstSeenLine: 1, lastUpdatedLine: 2 }, ], ); - assert.deepEqual(snap.health.missingDeclared, ['hero', 'details']); - assert.equal(snap.health.complete, false); -}); - -test('section adds mark nodes present and increment revisions on replacement', () => { - const graph = new StreamGraph(); - graph.applyLine({ op: 'set', path: '/screen', value: { sections: ['hero'] } }); - graph.applyLine({ op: 'add', path: '/section/hero', html: '

Hero

' }); - graph.applyLine({ op: 'add', path: '/section/hero', html: '

Updated

' }); - - const hero = graph.snapshot().sections[0]!; - assert.equal(hero.present, true); - assert.equal(hero.revision, 2); - assert.equal(hero.bytes, '

Updated

'.length); - assert.equal(hero.firstDeclaredLine, 1); - assert.equal(hero.firstSeenLine, 2); - assert.equal(hero.lastUpdatedLine, 3); - assert.deepEqual(graph.snapshot().health.missingDeclared, []); -}); - -test('block fragments add optional diagnostics under section nodes', () => { - const graph = new StreamGraph(); - graph.applyLine({ op: 'set', path: '/screen', value: { sections: ['summary'] } }); - graph.applyLine({ op: 'set', path: '/section/summary', value: { blocks: ['headline', 'metrics'] } }); - graph.applyLine({ op: 'add', path: '/section/summary/block/headline', html: '

Ready

' }); - - const summary = graph.snapshot().sections[0]!; - assert.equal(summary.present, true); - assert.equal(summary.revision, 1); - assert.equal(summary.declaredBlockCount, 2); - assert.equal(summary.presentBlockCount, 1); - assert.deepEqual( - summary.blocks?.map(({ id, declared, present, revision }) => ({ id, declared, present, revision })), - [ - { id: 'headline', declared: true, present: true, revision: 1 }, - { id: 'metrics', declared: true, present: false, revision: 0 }, - ], - ); -}); - -test('block path issues attach to parent section and block diagnostics', () => { - const graph = new StreamGraph(); - const issue: ContractIssue = { - source: 'protocol', - severity: 'warn', - code: 'undeclared-block', - message: 'Block was not declared', - path: '/section/summary/block/metrics', - }; - - graph.recordIssue(issue); - - const summary = graph.snapshot().sections[0]!; - assert.equal(summary.id, 'summary'); - assert.equal(summary.lastIssue?.code, 'undeclared-block'); - assert.equal(summary.lastBlockIssue?.code, 'undeclared-block'); - assert.equal(summary.blocks?.[0]?.id, 'metrics'); - assert.equal(summary.blocks?.[0]?.lastIssue?.code, 'undeclared-block'); -}); - -test('add-before-screen records undeclared present state', () => { - const graph = new StreamGraph(); - graph.applyLine({ op: 'add', path: '/section/hero', html: '

Hero

' }); - - const snap = graph.snapshot(); - assert.deepEqual(snap.health.undeclaredPresent, ['hero']); - assert.equal(snap.health.complete, false); - assert.equal(snap.sections[0]?.declared, false); - assert.equal(snap.sections[0]?.present, true); + assert.equal(snap.health.complete, true); }); test('contract issues update skipped and blocked health counters', () => { const graph = new StreamGraph(); + graph.applyLine({ op: 'artifact', path: '/artifact', value: artifact }); const skipped: ContractIssue = { source: 'protocol', severity: 'warn', - code: 'undeclared-section', - message: 'Section was not declared', - path: '/section/details', + code: 'protocol-skip', + message: 'Protocol line skipped', }; const blocked: ContractIssue = { - source: 'html', + source: 'protocol', severity: 'block', - code: 'external-url', - message: 'External URL is not allowed', - path: '/section/details', + code: 'invalid-arrow-artifact', + message: 'Artifact line value must be an Arrow artifact object', + path: '/artifact', }; graph.recordIssue(skipped); @@ -124,8 +67,8 @@ test('contract issues update skipped and blocked health counters', () => { const snap = graph.snapshot(); assert.equal(snap.health.skippedCount, 1); assert.equal(snap.health.blockedCount, 1); - assert.equal(snap.sections[0]?.id, 'details'); - assert.equal(snap.sections[0]?.lastIssue?.code, 'external-url'); + assert.equal(snap.health.complete, false); + assert.equal(snap.artifacts[0]?.lastIssue?.code, 'invalid-arrow-artifact'); }); test('validation summary merges aggregate graph health', () => { @@ -136,76 +79,17 @@ test('validation summary merges aggregate graph health', () => { value: { blocked: 2, warnings: 3, - codes: { - 'undeclared-section': 2, - 'token-contract-warning': 1, - 'external-url': 2, - }, }, }); const snap = graph.snapshot(); - assert.equal(snap.health.skippedCount, 2); + assert.equal(snap.health.skippedCount, 3); assert.equal(snap.health.blockedCount, 2); }); -test('repair feedback updates repaired and blocked status', () => { - const graph = new StreamGraph(); - const issue: ContractIssue = { - source: 'html', - severity: 'block', - code: 'unsafe-tag', - message: 'Unsafe tag', - path: '/section/hero', - }; - const blocked: RepairFeedbackMetaValue = { - schemaId: 'summon.repair-feedback.v2', - status: 'blocked', - target: '/section/hero', - issues: [issue], - retryable: true, - hints: ['Repair it.'], - }; - const repaired: RepairFeedbackMetaValue = { - ...blocked, - status: 'repaired', - retryable: false, - }; - - graph.recordRepairFeedback(blocked); - graph.recordRepairFeedback(repaired); - - const snap = graph.snapshot(); - assert.equal(snap.health.blockedCount, 1); - assert.equal(snap.health.repairedCount, 1); - assert.equal(snap.sections[0]?.lastIssue?.code, 'unsafe-tag'); -}); - -test('startup layout lines seed graph state', () => { - const layout: SummonLayout = { - id: 'two-slot', - slots: [ - { id: 'summary', purpose: 'main answer' }, - { id: 'details', purpose: 'supporting detail' }, - ], - }; - const contracts = compileSystemContracts({ - mode: 'static', - layout, - }); - const graph = new StreamGraph(); - for (const line of contracts.startupLines) graph.applyLine(line); - - assert.deepEqual(graph.snapshot().edges, [ - { from: 'screen', to: 'summary', order: 0 }, - { from: 'screen', to: 'details', order: 1 }, - ]); -}); - test('snapshots, hydrates, and resets deterministically', () => { const graph = new StreamGraph(); - graph.applyLine({ op: 'set', path: '/screen', value: { sections: ['hero'] } }); - graph.applyLine({ op: 'add', path: '/section/hero', html: '

Hero

' }); + graph.applyLine({ op: 'artifact', path: '/artifact', value: artifact }); graph.applyLine({ op: 'meta', path: '/protocol-skip', @@ -222,15 +106,11 @@ test('snapshots, hydrates, and resets deterministically', () => { restored.reset(); assert.deepEqual(restored.snapshot(), { - sections: [], - edges: [], + artifacts: [], health: { complete: true, - missingDeclared: [], - undeclaredPresent: [], skippedCount: 0, blockedCount: 0, - repairedCount: 0, }, }); }); diff --git a/packages/engine/test/surface-contract.test.ts b/packages/engine/test/surface-contract.test.ts index 0639317..0b5f923 100644 --- a/packages/engine/test/surface-contract.test.ts +++ b/packages/engine/test/surface-contract.test.ts @@ -4,12 +4,12 @@ import { compileSurfaceContractView, compileSurfacePolicy, surfaceContractViewFromCompiledPolicy, - type CapabilityPack, + type ToolPack, type ComponentPack, } from '../src/index.ts'; -const capabilities: CapabilityPack = { - intents: [ +const tools: ToolPack = { + tools: [ { name: 'search', description: 'Search host records.', @@ -70,18 +70,17 @@ const components: ComponentPack = { ], }; -test('static policy contract view has no tools/components and static runtime', () => { +test('static policy contract view has no tools/components and Arrow runtime', () => { const view = compileSurfaceContractView({ tier: 'static', purpose: 'inform' }, { - capabilities, + tools, components, }); assert.deepEqual(view.tools, []); assert.deepEqual(view.components, []); assert.equal(view.surface.policy.tier, 'static'); - assert.equal(view.surface.plan.runtime, 'static'); + assert.equal(view.surface.plan.runtime, 'arrow'); assert.equal(view.surface.mode, 'static'); - assert.equal(view.surface.scriptPolicy, 'forbid'); assert.deepEqual(view.issues, []); }); @@ -90,7 +89,7 @@ test('declarative search policy includes only selected resource state keys', () tier: 'declarative', purpose: 'explore', grants: ['search'], - }, { capabilities }); + }, { tools }); assert.deepEqual(view.tools.map((tool) => tool.name), ['search']); assert.deepEqual(view.tools[0], { @@ -110,7 +109,7 @@ test('declarative search policy includes only selected resource state keys', () defaultDataShape: '[]', surface: { data: 'host-resource', authority: 'read' }, }); - assert.equal(view.surface.plan.runtime, 'declarative'); + assert.equal(view.surface.plan.runtime, 'arrow'); assert.equal(view.surface.plan.data, 'host-resource'); assert.equal(view.surface.plan.authority, 'read'); }); @@ -138,7 +137,7 @@ test('invalid grants/components preserve compile issues in derived view', () => tier: 'declarative', grants: ['missing', 'analysis'], components: ['MissingComponent', 'WorkerChart'], - }, { capabilities, components }); + }, { tools, components }); const view = surfaceContractViewFromCompiledPolicy(compiled, { id: 'host-layout', slots: [{ id: 'hero', purpose: 'Main result' }], diff --git a/packages/engine/test/surface-policy.test.ts b/packages/engine/test/surface-policy.test.ts index 2fbee6d..0c87d63 100644 --- a/packages/engine/test/surface-policy.test.ts +++ b/packages/engine/test/surface-policy.test.ts @@ -3,12 +3,12 @@ import test from 'node:test'; import { compileSurfacePolicy, normalizeSurfacePolicy, - type CapabilityPack, + type ToolPack, type ComponentPack, } from '../src/index.ts'; -const capabilities: CapabilityPack = { - intents: [ +const tools: ToolPack = { + tools: [ { name: 'search', description: 'Search host data', @@ -51,8 +51,8 @@ const capabilities: CapabilityPack = { }, ], patterns: [ - { name: 'Search', code: '
', intent: 'search' }, - { name: 'Choose', code: '', intent: 'choose' }, + { name: 'Search', code: 'import { callTool } from "host-bridge:summon";\nconst search = (query: string) => callTool("search", { query });', tool: 'search' }, + { name: 'Choose', code: 'import { callTool } from "host-bridge:summon";\nconst choose = () => callTool("choose", {});', tool: 'choose' }, ], }; @@ -89,20 +89,20 @@ test('normalizes defaults and dedupes policy names', () => { test('compiles static policy to static embedded plan with no packs', () => { const compiled = compileSurfacePolicy({ tier: 'static', purpose: 'compare' }, { - capabilities, + tools, components, }); assert.deepEqual(compiled.issues, []); assert.equal(compiled.mode, 'static'); - assert.equal(compiled.scriptPolicy, 'forbid'); - assert.equal(compiled.capabilities, null); + assert.equal(compiled.tools, null); assert.equal(compiled.components, null); assert.deepEqual(compiled.surfacePlan, { purpose: 'compare', - runtime: 'static', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }); }); @@ -112,19 +112,19 @@ test('compiles declarative policy and narrows grants, components, and patterns', purpose: 'explore', grants: ['search', 'choose'], components: ['MetricCard'], - }, { capabilities, components }); + }, { tools, components }); assert.deepEqual(compiled.issues, []); assert.equal(compiled.mode, 'interactive'); - assert.equal(compiled.scriptPolicy, 'forbid'); - assert.deepEqual(compiled.capabilities?.intents.map((intent) => intent.name), ['search', 'choose']); - assert.deepEqual(compiled.capabilities?.patterns?.map((pattern) => pattern.intent), ['search', 'choose']); + assert.deepEqual(compiled.tools?.tools.map((tool) => tool.name), ['search', 'choose']); + assert.deepEqual(compiled.tools?.patterns?.map((pattern) => pattern.tool), ['search', 'choose']); assert.deepEqual(compiled.components?.components.map((component) => component.name), ['MetricCard']); assert.deepEqual(compiled.surfacePlan, { purpose: 'explore', - runtime: 'declarative', + runtime: 'arrow', data: 'host-resource', authority: 'host-action', persistence: 'replayable', + network: 'none', }); }); @@ -132,11 +132,10 @@ test('rejects removed scripted policy tier', () => { const compiled = compileSurfacePolicy({ tier: 'scripted', grants: ['choose'], - } as never, { capabilities }); + } as never, { tools }); assert.deepEqual(compiled.issues.map((issue) => issue.code), ['surface-policy-invalid']); assert.equal(compiled.mode, 'static'); - assert.equal(compiled.scriptPolicy, 'forbid'); - assert.equal(compiled.surfacePlan.runtime, 'static'); + assert.equal(compiled.surfacePlan.runtime, 'arrow'); }); test('compiles worker policy and requires worker-backed surface area', () => { @@ -144,17 +143,18 @@ test('compiles worker policy and requires worker-backed surface area', () => { tier: 'worker', purpose: 'review', grants: ['analysis', 'compute'], - }, { capabilities }); + }, { tools }); assert.deepEqual(compiled.issues, []); assert.deepEqual(compiled.surfacePlan, { purpose: 'review', - runtime: 'worker', + runtime: 'arrow', data: 'worker', authority: 'host-action', persistence: 'replayable', + network: 'none', }); - const missing = compileSurfacePolicy({ tier: 'worker' }, { capabilities }); + const missing = compileSurfacePolicy({ tier: 'worker' }, { tools }); assert.deepEqual(missing.issues.map((issue) => issue.code), ['surface-policy-tier-requirement']); }); @@ -163,18 +163,18 @@ test('compiles approval policy and requires approval-gated grant', () => { tier: 'approval', purpose: 'operate', grants: ['publish'], - }, { capabilities }); + }, { tools }); assert.deepEqual(compiled.issues, []); - assert.equal(compiled.scriptPolicy, 'forbid'); assert.deepEqual(compiled.surfacePlan, { purpose: 'operate', - runtime: 'declarative', + runtime: 'arrow', data: 'embedded', authority: 'approval-gated', persistence: 'replayable', + network: 'none', }); - const missing = compileSurfacePolicy({ tier: 'approval', grants: ['choose'] }, { capabilities }); + const missing = compileSurfacePolicy({ tier: 'approval', grants: ['choose'] }, { tools }); assert.deepEqual(missing.issues.map((issue) => issue.code), [ 'surface-policy-tier-exceeded', 'surface-policy-tier-requirement', @@ -186,7 +186,7 @@ test('blocks unknown names and tier-exceeded grants/components', () => { tier: 'declarative', grants: ['missing', 'analysis', 'publish'], components: ['MissingComponent', 'WorkerChart'], - }, { capabilities, components }); + }, { tools, components }); assert.deepEqual(compiled.issues.map((issue) => issue.code), [ 'surface-policy-unknown-grant', 'surface-policy-tier-exceeded', diff --git a/packages/engine/test/capability-contract.test.ts b/packages/engine/test/tool-contract.test.ts similarity index 68% rename from packages/engine/test/capability-contract.test.ts rename to packages/engine/test/tool-contract.test.ts index e82598c..7618112 100644 --- a/packages/engine/test/capability-contract.test.ts +++ b/packages/engine/test/tool-contract.test.ts @@ -1,28 +1,28 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - CAPABILITY_BINDING_SPECS, - CAPABILITY_TRIGGER_SPECS, - buildCapabilitiesBlock, - compileCapabilityContract, - formatCapabilityProtocolContract, + buildToolsBlock, + compileToolContract, + formatToolProtocolContract, } from '../src/index.ts'; -test('capability protocol contract includes every trigger and binding spec', () => { - const text = formatCapabilityProtocolContract(); +test('tool protocol contract documents Arrow host bridge', () => { + const text = formatToolProtocolContract(); - for (const spec of CAPABILITY_TRIGGER_SPECS) { - assert.match(text, new RegExp(spec.legacyAttribute)); - assert.match(text, new RegExp(`data-summon-resource-trigger="${spec.resourceTriggerValue}"`)); - } - for (const spec of CAPABILITY_BINDING_SPECS) { - assert.match(text, new RegExp(spec.attribute)); - } + assert.match(text, /Arrow host bridge/); + assert.match(text, /host-bridge:summon/); + assert.match(text, /callTool/); + assert.match(text, /getState/); + assert.match(text, /onState/); + assert.match(text, /reactive\(\)/); + assert.doesNotMatch(text, /data-summon-on-click/); + assert.doesNotMatch(text, /data-summon-resource-trigger/); + assert.doesNotMatch(text, /data-summon-bind/); }); -test('capability compiler returns prompt, pack, intent names, and validation metadata', () => { - const contract = compileCapabilityContract({ - intents: [ +test('tool compiler returns prompt, pack, tool names, and validation metadata', () => { + const contract = compileToolContract({ + tools: [ { name: 'search', description: 'Run a search.', @@ -47,8 +47,8 @@ test('capability compiler returns prompt, pack, intent names, and validation met ], }); - assert.deepEqual(contract.intentNames, ['search', 'save']); - assert.deepEqual(contract.validationCapabilities, [ + assert.deepEqual(contract.toolNames, ['search', 'save']); + assert.deepEqual(contract.validationTools, [ { name: 'search', kind: 'resource', @@ -71,13 +71,13 @@ test('capability compiler returns prompt, pack, intent names, and validation met saveDone: false, saveError: null, }); - assert.equal(contract.promptBlock?.id, 'capabilities'); + assert.equal(contract.promptBlock?.id, 'tools'); assert.match(contract.promptBlock?.text ?? '', /Available data resources/); }); -test('capabilities block renders generated protocol docs', () => { - const text = buildCapabilitiesBlock({ - intents: [ +test('tools block renders Arrow-native protocol docs', () => { + const text = buildToolsBlock({ + tools: [ { name: 'search', description: 'Run a search.', @@ -105,21 +105,26 @@ test('capabilities block renders generated protocol docs', () => { code: '', }, { - name: 'declarative search', + name: 'legacy declarative search', code: '
', }, + { + name: 'arrow search', + code: 'import { callTool, onState } from "host-bridge:summon";\nconst run = () => callTool("search", { query: "boots" });\nonState(() => {});', + }, ], }); assert.match(text, /Available data resources/); - assert.match(text, /Declarative-only interactivity/); - assert.match(text, /data-summon-resource=""/); - assert.match(text, /\$alias\.loading/); - assert.match(text, /\$alias\.empty/); + assert.match(text, /Arrow-native interactivity/); + assert.match(text, /host-bridge:summon/); + assert.match(text, /reactive\(\)/); + assert.match(text, /callTool/); + assert.match(text, /getState/); + assert.match(text, /onState/); assert.match(text, /Default data: `\[\]`/); assert.match(text, /Never hallucinate fetched rows/); - assert.match(text, /Render "no results" from `\$alias\.empty`/); - assert.match(text, /data-summon-show="\$alias\.data"/); + assert.match(text, /Render "no results" from the declared empty key/); assert.match(text, /State keys: loading=searching, data=results, error=searchError, empty=noResults/); assert.match(text, /Action state: pending=savePending, done=saveDone, error=saveError/); assert.match(text, /Controlled actions expose host-owned pending\/done\/error keys/); @@ -127,12 +132,13 @@ test('capabilities block renders generated protocol docs', () => { assert.doesNotMatch(text, /data-summon-on-submit="submit"/); assert.doesNotMatch(text, /data-summon-on-click="log"/); assert.doesNotMatch(text, /document\.getElementById/); - assert.match(text, /data-summon-resource="search"/); + assert.doesNotMatch(text, /data-summon-resource="search"/); + assert.match(text, /arrow search/); }); -test('capabilities block filters script patterns even with legacy allow policy', () => { - const text = buildCapabilitiesBlock({ - intents: [ +test('tools block filters script patterns', () => { + const text = buildToolsBlock({ + tools: [ { name: 'choose', description: 'Choose an option.', @@ -146,9 +152,9 @@ test('capabilities block filters script patterns even with legacy allow policy', code: '', }, ], - }, { scriptPolicy: 'allow' }); + }); - assert.match(text, /Script policy — declarative only/); + assert.match(text, /Host tool bridge/); assert.doesNotMatch(text, /Rules for scripts/); assert.doesNotMatch(text, /document\.getElementById/); }); diff --git a/packages/host/src/bind-endpoint.ts b/packages/host/src/bind-endpoint.ts index bc7b5d0..0aab9b3 100644 --- a/packages/host/src/bind-endpoint.ts +++ b/packages/host/src/bind-endpoint.ts @@ -1,14 +1,14 @@ /** * Endpoint binding — the trusted shape of a host-owned network call that an - * intent fires. Turns the ad-hoc "set loading, fetch, catch, push" ceremony + * tool fires. Turns the ad-hoc "set loading, fetch, catch, push" ceremony * into a declared contract: validate args, call fetch with an AbortSignal, * surface loading/data/error under a named triple of state keys. * - * The sandbox never sees any of this. It only emits the intent and reads + * The sandbox never sees any of this. It only emits the tool and reads * the resulting state keys. */ -import type { IntentHandler } from './policy-engine.js'; +import type { ToolHandler } from './policy-engine.js'; export interface EndpointStateKeys { /** State flag set to true while the request is in flight, false otherwise. */ @@ -54,13 +54,13 @@ export interface EndpointBinding { } /** - * Build an IntentHandler from an endpoint binding. The returned handler + * Build an ToolHandler from an endpoint binding. The returned handler * manages loading/error state, concurrency, and AbortSignal wiring; the * caller only supplies the parse, fetch, and state-key shape. */ export function bindEndpoint( binding: EndpointBinding -): IntentHandler { +): ToolHandler { const { stateKeys, concurrency = 'latest' } = binding; let inflight: AbortController | null = null; diff --git a/packages/host/src/browser.ts b/packages/host/src/browser.ts index 048dce5..074cb9a 100644 --- a/packages/host/src/browser.ts +++ b/packages/host/src/browser.ts @@ -6,7 +6,6 @@ export type { SurfaceStreamLineDecision, SurfaceStreamOptions, SurfaceStreamParseError, - SurfaceStreamRenderMode, SurfaceStreamResult, SurfaceStreamSource, } from './surface-stream.js'; @@ -41,13 +40,12 @@ export type { } from './component-islands.js'; export type { Artifact, - CompiledArtifactHtml, - CompiledHtmlNodePatch, ComponentIslandBounds, ComponentIslandDescriptor, ComponentsMessage, FatalMessage, - IntentMessage, + ToolCallMessage, + ToolResultMessage, ReadyMessage, SandboxHandle, SandboxInboundMessage, diff --git a/packages/host/src/component-islands.ts b/packages/host/src/component-islands.ts index 78a39e6..80adcc9 100644 --- a/packages/host/src/component-islands.ts +++ b/packages/host/src/component-islands.ts @@ -10,7 +10,7 @@ import type { export interface ComponentIslandSyncContext { sandboxId: string; - emitIntent?: (intent: string, args?: Record) => void; + callTool?: (tool: string, args?: Record) => void; } export interface ComponentIslandRegistryOptions { @@ -47,7 +47,7 @@ interface MountedIsland { wrapper: HTMLDivElement; bounds: ComponentIslandBounds; sandboxId: string; - emitIntent: (intent: string, args?: Record) => void; + callTool: (tool: string, args?: Record) => void; } export function createComponentIslandRegistry( @@ -146,7 +146,7 @@ export function createComponentIslandRegistry( props, componentId: island.id, sandboxId: island.sandboxId, - emitIntent: island.emitIntent, + callTool: island.callTool, } as ComponentRenderContext); } @@ -157,7 +157,7 @@ export function createComponentIslandRegistry( container: island.wrapper, componentId: island.id, sandboxId: island.sandboxId, - emitIntent: island.emitIntent, + callTool: island.callTool, }); island.wrapper.remove(); mounted.delete(id); @@ -169,7 +169,7 @@ export function createComponentIslandRegistry( ): void { const seen = new Set(); const sandboxId = context.sandboxId ?? ''; - const emitIntent = context.emitIntent ?? (() => {}); + const callTool = context.callTool ?? (() => {}); for (const component of components) { seen.add(component.id); @@ -220,14 +220,14 @@ export function createComponentIslandRegistry( wrapper, bounds: clipped, sandboxId, - emitIntent, + callTool, }; mounted.set(component.id, island); } island.bounds = clipped; island.sandboxId = sandboxId; - island.emitIntent = emitIntent; + island.callTool = callTool; position(island); render(island, parsed.data); } diff --git a/packages/host/src/component-registry.ts b/packages/host/src/component-registry.ts index 7e6ab4b..48042f3 100644 --- a/packages/host/src/component-registry.ts +++ b/packages/host/src/component-registry.ts @@ -7,14 +7,14 @@ import type { } from '@summon-internal/engine'; import { compileComponentContract } from '@summon-internal/engine'; import type { ZodType, ZodTypeAny } from 'zod'; -import { formatZodSchema } from './capability-registry.js'; +import { formatZodSchema } from './tool-registry.js'; export interface ComponentRenderContext { container: HTMLElement; props: T; componentId: string; sandboxId: string; - emitIntent: (intent: string, args?: Record) => void; + callTool: (tool: string, args?: Record) => void; } export type ComponentRenderer = (ctx: ComponentRenderContext) => void; diff --git a/packages/host/src/index.ts b/packages/host/src/index.ts index e860c30..4f117b8 100644 --- a/packages/host/src/index.ts +++ b/packages/host/src/index.ts @@ -1,22 +1,23 @@ export { spawnSandbox } from './sandbox-spawner.js'; export type { SpawnOptions } from './sandbox-spawner.js'; -export { PolicyEngine, defineIntent, IntentArgsError } from './policy-engine.js'; +export { PolicyEngine, defineToolHandler, ToolArgsError } from './policy-engine.js'; export type { - IntentContext, - IntentEntry, - IntentHandler, + ToolContext, + ToolHandlerEntry, + ToolHandler, PolicyEngineOptions, - TypedIntentEntry, + PolicyDispatchResult, + TypedToolHandlerEntry, } from './policy-engine.js'; export { - createCapabilityRegistry, + createToolRegistry, defineAction, defineApprovalAction, - defineCapability, + defineTool, defineDataResource, defineWorkerAction, defineWorkerResource, -} from './capability-registry.js'; +} from './tool-registry.js'; export type { ActionDefinition, ActionStateKeys, @@ -25,12 +26,12 @@ export type { ApprovalPrepared, ApprovalRequest, ApprovalStateKeys, - CapabilityDefinition, - CapabilityRegistry, + ToolDefinition, + ToolRegistry, DataResourceDefinition, ResourceStateKeys, StateShapeDescriptor, -} from './capability-registry.js'; +} from './tool-registry.js'; export { createComponentRegistry, defineComponent, @@ -74,7 +75,6 @@ export type { SurfaceStreamLineDecision, SurfaceStreamOptions, SurfaceStreamParseError, - SurfaceStreamRenderMode, SurfaceStreamResult, SurfaceStreamSource, } from './surface-stream.js'; @@ -89,14 +89,13 @@ export type { } from './strict-input.js'; export type { Artifact, - CompiledArtifactHtml, - CompiledHtmlNodePatch, ComponentIslandBounds, ComponentIslandDescriptor, ComponentsMessage, SandboxHandle, StateMessage, - IntentMessage, + ToolCallMessage, + ToolResultMessage, ReadyMessage, FatalMessage, SandboxInboundMessage, diff --git a/packages/host/src/policy-engine.ts b/packages/host/src/policy-engine.ts index 729a79b..9f64306 100644 --- a/packages/host/src/policy-engine.ts +++ b/packages/host/src/policy-engine.ts @@ -1,18 +1,18 @@ /** - * Policy engine: the trusted surface that intents hit after passing the bridge's + * Policy engine: the trusted surface that tools hit after passing the bridge's * vocabulary check. Handlers are host-authored — this is where network I/O, * credential access, and state mutation live. The sandbox never sees any of it. */ import type { EventStore } from '@summon-internal/devtools'; import type { ZodType } from 'zod'; -import type { ApprovalRequest } from './capability-registry.js'; +import type { ApprovalRequest } from './tool-registry.js'; -export interface IntentContext> { +export interface ToolContext> { /** * Args the sandbox emitted. For bare-function handlers this is the raw * untrusted bag — validate before use. For handlers registered via - * `defineIntent(schema, run)`, this is the schema-parsed value with `T`'s + * `defineToolHandler(schema, run)`, this is the schema-parsed value with `T`'s * type, so the handler body can trust the shape. */ args: T; @@ -26,56 +26,56 @@ export interface IntentContext> { approval?: ApprovalRequest; } -export type IntentHandler> = ( - ctx: IntentContext, +export type ToolHandler> = ( + ctx: ToolContext, ) => Promise | void; /** - * Schema-bound intent entry. Created via {@link defineIntent}. When dispatch + * Schema-bound tool entry. Created via {@link defineToolHandler}. When dispatch * sees one of these, it runs `args` through `schema.safeParse` first and only * calls `run` with the parsed value. Parse failures route to `onHandlerError` * with a structured Zod error and the run is skipped. */ -export interface TypedIntentEntry { +export interface TypedToolHandlerEntry { schema: ZodType; - run: IntentHandler; + run: ToolHandler; } -export type IntentEntry> = - | IntentHandler - | TypedIntentEntry; +export type ToolHandlerEntry> = + | ToolHandler + | TypedToolHandlerEntry; /** - * Sugar for declaring a typed, schema-validated intent. Preserves `T` from the + * Sugar for declaring a typed, schema-validated tool. Preserves `T` from the * schema through to the handler so `ctx.args` is correctly typed at the call * site. * - * defineIntent(z.object({ q: z.string().min(1) }), async ({ args, push }) => { + * defineToolHandler(z.object({ q: z.string().min(1) }), async ({ args, push }) => { * // args: { q: string } * }); */ -export function defineIntent( +export function defineToolHandler( schema: ZodType, - run: IntentHandler, -): TypedIntentEntry { + run: ToolHandler, +): TypedToolHandlerEntry { return { schema, run }; } -function isTypedEntry(entry: IntentEntry): entry is TypedIntentEntry { +function isTypedEntry(entry: ToolHandlerEntry): entry is TypedToolHandlerEntry { return typeof entry === 'object' && entry !== null && 'schema' in entry; } /** - * Error thrown into `onHandlerError` when a typed intent's args fail schema + * Error thrown into `onHandlerError` when a typed tool's args fail schema * validation. The original ZodError lives on `.cause` for callers that want * structured details (path, expected type, etc.). */ -export class IntentArgsError extends Error { - readonly intent: string; - constructor(intent: string, cause: unknown) { - super(`intent "${intent}" args failed schema validation`); - this.name = 'IntentArgsError'; - this.intent = intent; +export class ToolArgsError extends Error { + readonly tool: string; + constructor(tool: string, cause: unknown) { + super(`tool "${tool}" args failed schema validation`); + this.name = 'ToolArgsError'; + this.tool = tool; this.cause = cause; } } @@ -83,30 +83,36 @@ export class IntentArgsError extends Error { export interface PolicyEngineOptions { /** * Handlers may be bare functions (legacy, untyped) or schema-bound entries - * created by {@link defineIntent}. The two shapes coexist; migrate + * created by {@link defineToolHandler}. The two shapes coexist; migrate * incrementally. The `unknown` generic preserves the union without forcing * every entry to share a single arg type. */ - handlers: Record>; + handlers: Record>; /** Called with the new merged state every time a handler pushes. */ onStateChange: (state: Record) => void; /** Optional — receives unhandled handler exceptions and schema rejections. */ - onHandlerError?: (intent: string, error: Error) => void; + onHandlerError?: (tool: string, error: Error) => void; /** Initial state. Pushed to the sandbox once it is ready. */ initialState?: Record; /** * Optional devtools event store. When set, the engine pushes - * intent-dispatched/settled and state-pushed events. Behavior is identical + * tool-dispatched/settled and state-pushed events. Behavior is identical * when omitted. */ events?: EventStore; } +export interface PolicyDispatchResult { + ok: boolean; + state: Record; + error?: string; +} + export class PolicyEngine { private state: Record; - private readonly handlers: Record>; + private readonly handlers: Record>; private readonly onStateChange: (state: Record) => void; - private readonly onHandlerError?: (intent: string, error: Error) => void; + private readonly onHandlerError?: (tool: string, error: Error) => void; private readonly events?: EventStore; private dispatchSeq = 0; @@ -118,8 +124,8 @@ export class PolicyEngine { this.events = options.events; } - /** Full intent vocabulary — wire this into the Artifact.intents list. */ - get intents(): string[] { + /** Full tool vocabulary — wire this into the Artifact.tools list. */ + get tools(): string[] { return Object.keys(this.handlers); } @@ -128,9 +134,9 @@ export class PolicyEngine { } /** - * Merge a patch into current state and notify. Intent handlers receive this + * Merge a patch into current state and notify. Tool handlers receive this * as `ctx.push`; host code can also call it directly to push state changes - * that did not originate from a sandbox intent — server-sent events, timers, + * that did not originate from a sandbox tool — server-sent events, timers, * external webhooks, cross-tab broadcasts. */ pushState(patch: Record): void { @@ -140,25 +146,26 @@ export class PolicyEngine { this.onStateChange(next); } - async dispatch(intent: string, args: Record): Promise { - const entry = this.handlers[intent]; + async dispatch(tool: string, args: Record): Promise { + const entry = this.handlers[tool]; if (!entry) { // The bridge should have rejected this already; defensive. - this.onHandlerError?.(intent, new Error(`No handler for intent "${intent}"`)); - return; + const error = new Error(`No handler for tool "${tool}"`); + this.onHandlerError?.(tool, error); + return { ok: false, state: this.getState(), error: error.message }; } const id = `${Date.now()}-${++this.dispatchSeq}`; const startedAt = Date.now(); - this.events?.push({ kind: 'intent-dispatched', at: startedAt, id, intent, args }); + this.events?.push({ kind: 'tool-dispatched', at: startedAt, id, tool, args }); const push = (patch: Record) => this.pushState(patch); const settle = (ok: boolean, error?: string) => { this.events?.push({ - kind: 'intent-settled', + kind: 'tool-settled', at: Date.now(), id, - intent, + tool, ok, error, durationMs: Date.now() - startedAt, @@ -169,20 +176,22 @@ export class PolicyEngine { if (isTypedEntry(entry)) { const parsed = entry.schema.safeParse(args); if (!parsed.success) { - const err = new IntentArgsError(intent, parsed.error); + const err = new ToolArgsError(tool, parsed.error); settle(false, err.message); - this.onHandlerError?.(intent, err); - return; + this.onHandlerError?.(tool, err); + return { ok: false, state: this.getState(), error: err.message }; } await entry.run({ args: parsed.data, push }); } else { await entry({ args, push }); } settle(true); + return { ok: true, state: this.getState() }; } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); settle(false, error.message); - this.onHandlerError?.(intent, error); + this.onHandlerError?.(tool, error); + return { ok: false, state: this.getState(), error: error.message }; } } } diff --git a/packages/host/src/policy.ts b/packages/host/src/policy.ts index 41a93e4..bd9d79e 100644 --- a/packages/host/src/policy.ts +++ b/packages/host/src/policy.ts @@ -1,20 +1,21 @@ -export { PolicyEngine, defineIntent, IntentArgsError } from './policy-engine.js'; +export { PolicyEngine, defineToolHandler, ToolArgsError } from './policy-engine.js'; export type { - IntentContext, - IntentEntry, - IntentHandler, + ToolContext, + ToolHandlerEntry, + ToolHandler, + PolicyDispatchResult, PolicyEngineOptions, - TypedIntentEntry, + TypedToolHandlerEntry, } from './policy-engine.js'; export { - createCapabilityRegistry, + createToolRegistry, defineAction, defineApprovalAction, - defineCapability, + defineTool, defineDataResource, defineWorkerAction, defineWorkerResource, -} from './capability-registry.js'; +} from './tool-registry.js'; export type { ActionDefinition, ActionStateKeys, @@ -23,9 +24,9 @@ export type { ApprovalPrepared, ApprovalRequest, ApprovalStateKeys, - CapabilityDefinition, - CapabilityRegistry, + ToolDefinition, + ToolRegistry, DataResourceDefinition, ResourceStateKeys, StateShapeDescriptor, -} from './capability-registry.js'; +} from './tool-registry.js'; diff --git a/packages/host/src/sandbox-spawner.ts b/packages/host/src/sandbox-spawner.ts index 7a30418..480e4a1 100644 --- a/packages/host/src/sandbox-spawner.ts +++ b/packages/host/src/sandbox-spawner.ts @@ -1,8 +1,9 @@ import type { EventStore } from '@summon-internal/devtools'; -import { hasCompleteResourceStateKeys, type ValidationCapability } from '@summon-internal/engine'; +import type { ValidationTool } from '@summon-internal/engine'; import type { + ArrowNetworkPolicy, + ArrowSurfaceArtifact, Artifact, - CompiledHtmlNodePatch, ComponentIslandDescriptor, SandboxHandle, SandboxInboundMessage, @@ -14,14 +15,17 @@ import type { * SUMMON_READY and never receives a script nonce. Generated CSS remains inline * because the compiler constrains it and visual richness is a core Summon goal. */ -function cspForNonce(nonce: string): string { +function cspForNonce(nonce: string, networkPolicy: ArrowNetworkPolicy = 'none'): string { + const connectSrc = networkPolicy === 'restricted-fetch' + ? 'connect-src https: http://localhost:* http://127.0.0.1:* http://[::1]:*' + : "connect-src 'none'"; return [ "default-src 'none'", - `script-src 'nonce-${nonce}'`, + `script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`, "style-src 'unsafe-inline'", "img-src data:", "font-src data:", - "connect-src 'none'", + connectSrc, "form-action 'none'", "base-uri 'none'", "frame-src 'none'", @@ -36,28 +40,42 @@ export interface SpawnOptions { iframe: HTMLIFrameElement; artifact: Artifact; /** - * Host-controlled allowlist of intents this sandbox may emit. The bridge - * enforces it; anything else is rejected before reaching `onIntent`. + * Host-controlled allowlist of tools this sandbox may emit. The bridge + * enforces it; anything else is rejected before reaching `onToolCall`. * * This is required even for static/read-only surfaces. Pass `[]` when the - * host grants no executable intents. `artifact.intents` is advisory only and + * host grants no executable tools. `artifact.tools` is advisory only and * never becomes executable authority. */ - grantedIntents: string[]; + grantedTools: string[]; /** - * Host-controlled capability grant metadata. Used by the sandbox runtime - * only to resolve declarative resource aliases to host-owned state keys. - * Intent execution remains governed solely by `grantedIntents`. + * Host-controlled tool grant metadata. Tool execution remains + * governed solely by `grantedTools`; this metadata is recorded for + * validation, diagnostics, and component/policy context. */ - grantedCapabilities?: ValidationCapability[]; + validationTools?: ValidationTool[]; /** Raw bootstrap source; published consumers can use `@anarchitecture/summon/assets`. */ bootstrapSource: string; /** Raw token CSS source; published consumers can use `@anarchitecture/summon/assets`. */ tokensSource: string; - /** Receives only intents that passed the bridge allowlist. */ - onIntent?: (intent: string, args: Record) => void; - /** Receives intents that were rejected by the allowlist. Useful for logging / tests. */ - onIntentRejected?: (reason: string, raw: unknown) => void; + /** + * Optional trusted Arrow runtime bundle. It must install + * `window.__SUMMON_ARROW_SANDBOX__ = { sandbox }` inside the iframe before + * the Summon Arrow adapter receives an artifact. + */ + arrowRuntimeSource?: string; + /** + * Host-owned network grant for Arrow sandboxes. Defaults to `none`; do not + * derive this from generated artifact metadata. + */ + arrowNetworkPolicy?: ArrowNetworkPolicy; + /** Receives only tools that passed the bridge allowlist. */ + onToolCall?: (tool: string, args: Record) => + | void + | Record + | Promise>; + /** Receives tools that were rejected by the allowlist. Useful for logging / tests. */ + onToolRejected?: (reason: string, raw: unknown) => void; /** Receives sandbox-measured component island placeholders. */ onComponents?: (components: ComponentIslandDescriptor[], sandboxId: string) => void; /** @@ -68,8 +86,8 @@ export interface SpawnOptions { onSandboxFatal?: (reason: string) => void; /** * Optional devtools event store. When set, the spawner pushes lifecycle and - * intent-bridge events into it (sandbox-spawned/ready/fatal/disposed, - * intent-emitted/rejected, render). Behavior is identical when omitted. + * tool-bridge events into it (sandbox-spawned/ready/fatal/disposed, + * tool-called/rejected, render). Behavior is identical when omitted. */ events?: EventStore; } @@ -98,26 +116,23 @@ function buildSrcdoc(params: { scriptNonce: string; bootstrapSource: string; tokensSource: string; - resourceMap: ResourceMap; + networkPolicy: ArrowNetworkPolicy; + arrowRuntimeSource?: string; }): string { // The CSP meta must come FIRST in — anything before it is unprotected. // Artifact HTML is deliberately absent from initial srcdoc. The trusted // bootstrap sends SUMMON_READY, then the host queues the compiled render // through SUMMON_RENDER. // - // The base style block (entrance animation for live-paint sections) is - // emitted BEFORE the direction's tokensSource so directions can override — - // e.g., a direction wanting no decorative motion can redeclare the - // `[data-summon-section]` rule with `animation: none`. return ` - + - + +${params.arrowRuntimeSource ? `` : ''} - @@ -127,176 +142,6 @@ function buildSrcdoc(params: { `; } -const SUMMON_BASE_CSS = ` -[data-summon-section] { - animation: summon-section-in 0.45s cubic-bezier(0.33, 1, 0.68, 1) both; -} -.summon-node-enter { - animation: summon-node-enter 0.32s cubic-bezier(0.33, 1, 0.68, 1) both; - will-change: opacity, filter, transform; -} -.summon-node-update { - animation: summon-node-update 0.42s ease-out both; -} -.summon-slot-filled { - animation: summon-slot-filled 0.42s ease-out both; -} -.summon-motion-enter-rise { - animation: summon-motion-rise 0.34s cubic-bezier(0.33, 1, 0.68, 1) both; -} -.summon-motion-enter-fade { - animation: summon-motion-fade 0.24s ease-out both; -} -.summon-motion-enter-fade-slide { - animation: summon-motion-fade-slide 0.32s cubic-bezier(0.33, 1, 0.68, 1) both; -} -.summon-motion-enter-pop { - animation: summon-motion-pop 0.28s cubic-bezier(0.2, 0.9, 0.2, 1.2) both; -} -.summon-motion-update-pulse { - animation: summon-motion-pulse 0.46s ease-out both; -} -.summon-motion-update-fade { - animation: summon-motion-update-fade 0.28s ease-out both; -} -.summon-motion-update-pop { - animation: summon-motion-pop 0.24s cubic-bezier(0.2, 0.9, 0.2, 1.2) both; -} -.summon-transition-fade, -.summon-transition-rise, -.summon-transition-fade-slide, -.summon-transition-pop { - transition: - opacity 0.18s ease-out, - filter 0.18s ease-out, - transform 0.18s ease-out, - box-shadow 0.18s ease-out; -} -[data-summon-skeleton] { - position: relative; - overflow: hidden; - min-height: 0.8em; - border-radius: 6px; - color: transparent !important; - background: rgba(127, 127, 127, 0.14); - pointer-events: none; - user-select: none; -} -[data-summon-skeleton] > * { - visibility: hidden; -} -[data-summon-skeleton]::after { - content: ""; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.38), transparent); - animation: summon-skeleton-sheen 1.35s ease-in-out infinite; -} -@keyframes summon-section-in { - from { opacity: 0; filter: blur(8px); transform: translateY(8px); } - to { opacity: 1; filter: blur(0); transform: translateY(0); } -} -@keyframes summon-node-enter { - from { opacity: 0; filter: blur(5px); transform: translateY(6px); } - to { opacity: 1; filter: blur(0); transform: translateY(0); } -} -@keyframes summon-node-update { - 0% { box-shadow: 0 0 0 0 rgba(80, 112, 255, 0); } - 35% { box-shadow: 0 0 0 2px rgba(80, 112, 255, 0.18); } - 100% { box-shadow: 0 0 0 0 rgba(80, 112, 255, 0); } -} -@keyframes summon-slot-filled { - 0% { box-shadow: inset 0 0 0 0 rgba(80, 112, 255, 0); } - 45% { box-shadow: inset 0 0 0 1px rgba(80, 112, 255, 0.12); } - 100% { box-shadow: inset 0 0 0 0 rgba(80, 112, 255, 0); } -} -@keyframes summon-motion-rise { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} -@keyframes summon-motion-fade { - from { opacity: 0; } - to { opacity: 1; } -} -@keyframes summon-motion-fade-slide { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } -} -@keyframes summon-motion-pop { - from { opacity: 0; transform: scale(0.985); } - to { opacity: 1; transform: scale(1); } -} -@keyframes summon-motion-pulse { - 0% { box-shadow: 0 0 0 0 rgba(80, 112, 255, 0); } - 35% { box-shadow: 0 0 0 3px rgba(80, 112, 255, 0.16); } - 100% { box-shadow: 0 0 0 0 rgba(80, 112, 255, 0); } -} -@keyframes summon-motion-update-fade { - 0% { opacity: 0.72; } - 100% { opacity: 1; } -} -@keyframes summon-skeleton-sheen { - 0% { transform: translateX(-100%); } - 60%, 100% { transform: translateX(100%); } -} -@media (prefers-reduced-motion: reduce) { - [data-summon-section], - .summon-node-enter, - .summon-node-update, - .summon-slot-filled, - .summon-motion-enter-rise, - .summon-motion-enter-fade, - .summon-motion-enter-fade-slide, - .summon-motion-enter-pop, - .summon-motion-update-pulse, - .summon-motion-update-fade, - .summon-motion-update-pop, - [data-summon-skeleton]::after { - animation: none; - } - .summon-transition-fade, - .summon-transition-rise, - .summon-transition-fade-slide, - .summon-transition-pop { - transition: none; - } - .summon-node-enter { - opacity: 1; - filter: none; - transform: none; - } -} -`; - -interface ResourceMapEntry { - stateKeys: { - loading: string; - data: string; - error: string; - empty?: string; - }; -} - -type ResourceMap = Record; - -function resourceMapFromCapabilities(capabilities: ValidationCapability[] | undefined): ResourceMap { - const out: ResourceMap = {}; - for (const capability of capabilities ?? []) { - if (capability.kind !== 'resource') continue; - if (!hasCompleteResourceStateKeys(capability.stateKeys)) continue; - out[capability.name] = { - stateKeys: { - loading: capability.stateKeys.loading, - data: capability.stateKeys.data, - error: capability.stateKeys.error, - ...(capability.stateKeys.empty ? { empty: capability.stateKeys.empty } : {}), - }, - }; - } - return out; -} - function normalizeComponentDescriptors(raw: unknown): ComponentIslandDescriptor[] { if (!Array.isArray(raw)) return []; const out: ComponentIslandDescriptor[] = []; @@ -342,51 +187,48 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { const sandboxId = randomSandboxId(); const scriptNonce = randomSandboxId(); // Bridge allowlist comes only from the host grant. A JS caller that omits - // grantedIntents fails closed because `new Set(undefined)` grants nothing. - const intentAllowlist = new Set(opts.grantedIntents); - const grantedCapabilities = opts.grantedCapabilities ?? opts.artifact.capabilities ?? []; - const resourceMap = resourceMapFromCapabilities(grantedCapabilities); + // grantedTools fails closed because `new Set(undefined)` grants nothing. + const toolAllowlist = new Set(opts.grantedTools); + const validationTools = opts.validationTools ?? opts.artifact.validationTools ?? []; + const arrowNetworkPolicy = opts.arrowNetworkPolicy ?? 'none'; // Deliberately NOT adding allow-same-origin. That keeps the iframe null-origin: // no storage, no parent DOM access, cross-origin isolation applies. opts.iframe.setAttribute('sandbox', 'allow-scripts'); + opts.iframe.dataset.summonSandboxId = sandboxId; let ready = false; const pendingStates: Record[] = []; - const pendingDomOps: Array< - | { kind: 'render'; html: string } - | { kind: 'node-patch'; patch: CompiledHtmlNodePatch } - > = []; - // Chrome attributes are merged before flush so a flurry of setChrome calls - // pre-ready collapses into a single postMessage. Post-ready, each setChrome - // call dispatches immediately. - const pendingChrome: Record = {}; - let hasPendingChrome = false; + const pendingDomOps: Array<{ kind: 'artifact'; artifact: ArrowSurfaceArtifact }> = []; function flushPending() { if (!ready || !opts.iframe.contentWindow) return; - if (hasPendingChrome) { - opts.iframe.contentWindow.postMessage( - { type: 'SUMMON_CHROME', sandbox_id: sandboxId, attrs: { ...pendingChrome } }, - '*' - ); - for (const k of Object.keys(pendingChrome)) delete pendingChrome[k]; - hasPendingChrome = false; - } while (pendingStates.length > 0) { const state = pendingStates.shift()!; opts.iframe.contentWindow.postMessage({ type: 'SUMMON_STATE', sandbox_id: sandboxId, state }, '*'); } while (pendingDomOps.length > 0) { const op = pendingDomOps.shift()!; - if (op.kind === 'render') { - opts.iframe.contentWindow.postMessage({ type: 'SUMMON_RENDER', sandbox_id: sandboxId, html: op.html }, '*'); - } else { - opts.iframe.contentWindow.postMessage({ type: 'SUMMON_NODE_PATCH', sandbox_id: sandboxId, patch: op.patch }, '*'); - } + opts.iframe.contentWindow.postMessage({ type: 'SUMMON_RENDER', sandbox_id: sandboxId, artifact: op.artifact }, '*'); } } + function postToolResult(requestId: string | undefined, result: { + ok: boolean; + state?: Record; + error?: string; + }) { + if (!requestId || !opts.iframe.contentWindow) return; + opts.iframe.contentWindow.postMessage({ + type: 'SUMMON_TOOL_RESULT', + sandbox_id: sandboxId, + request_id: requestId, + ok: result.ok, + state: result.state ?? {}, + ...(result.error ? { error: result.error } : {}), + }, '*'); + } + function handleMessage(event: MessageEvent) { const data = event.data as SandboxInboundMessage | undefined; if (!data || typeof data !== 'object') return; @@ -396,8 +238,9 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { // delivered to the host page — only those claiming to speak the Summon // protocol should reach the sandbox_id gate below. if ( - data.type !== 'SUMMON_INTENT' && + data.type !== 'SUMMON_TOOL_CALL' && data.type !== 'SUMMON_READY' && + data.type !== 'SUMMON_RENDERED' && data.type !== 'SUMMON_FATAL' && data.type !== 'SUMMON_COMPONENTS' ) { @@ -418,8 +261,8 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { // A nonce miss is silently dropped rather than reported as a rejection. // The listener is bound to `window`, so on a page with multiple sandboxes // every listener sees every sibling's messages. Those aren't this - // sandbox's intents to validate — they're not addressed to it. Reserving - // onIntentRejected for messages that *do* claim this sandbox's identity + // sandbox's tools to validate — they're not addressed to it. Reserving + // onToolRejected for messages that *do* claim this sandbox's identity // (and fail later checks) keeps the rejection signal meaningful. if (data.sandbox_id !== sandboxId) { return; @@ -427,7 +270,7 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { if (data.type === 'SUMMON_FATAL') { // Bootstrap's self-test failed. Tear down: never set ready, never push - // state, never deliver intents. The sandbox is structurally unsound. + // state, never deliver tools. The sandbox is structurally unsound. const reason = typeof data.reason === 'string' ? data.reason : 'unknown'; window.removeEventListener('message', handleMessage); opts.iframe.srcdoc = ''; @@ -447,6 +290,16 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { return; } + if (data.type === 'SUMMON_RENDERED') { + opts.events?.push({ + kind: 'rendered', + at: Date.now(), + sandboxId, + revision: typeof data.revision === 'number' ? data.revision : 0, + }); + return; + } + if (data.type === 'SUMMON_COMPONENTS') { const components = normalizeComponentDescriptors(data.components); opts.events?.push({ @@ -464,63 +317,76 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { return; } - if (data.type === 'SUMMON_INTENT') { - const { intent, args } = data; - if (typeof intent !== 'string' || !intent) { + if (data.type === 'SUMMON_TOOL_CALL') { + const { tool, args } = data; + if (typeof tool !== 'string' || !tool) { opts.events?.push({ - kind: 'intent-rejected', + kind: 'tool-rejected', at: Date.now(), sandboxId, - reason: 'intent not a non-empty string', + reason: 'tool not a non-empty string', raw: data, }); - opts.onIntentRejected?.('intent not a non-empty string', data); + opts.onToolRejected?.('tool not a non-empty string', data); + postToolResult(data.request_id, { ok: false, error: 'tool not a non-empty string' }); return; } - if (!intentAllowlist.has(intent)) { + if (!toolAllowlist.has(tool)) { opts.events?.push({ - kind: 'intent-rejected', + kind: 'tool-rejected', at: Date.now(), sandboxId, - reason: `intent "${intent}" not granted`, + reason: `tool "${tool}" not granted`, raw: data, }); - opts.onIntentRejected?.(`intent "${intent}" not granted`, data); + opts.onToolRejected?.(`tool "${tool}" not granted`, data); + postToolResult(data.request_id, { ok: false, error: `tool "${tool}" not granted` }); return; } const safeArgs = args && typeof args === 'object' ? (args as Record) : {}; opts.events?.push({ - kind: 'intent-emitted', + kind: 'tool-called', at: Date.now(), sandboxId, - intent, + tool, args: safeArgs, }); - opts.onIntent?.(intent, safeArgs); + void Promise.resolve(opts.onToolCall?.(tool, safeArgs)) + .then((state) => { + postToolResult(data.request_id, { + ok: true, + state: state && typeof state === 'object' && !Array.isArray(state) ? state : {}, + }); + }) + .catch((err) => { + const error = err instanceof Error ? err.message : String(err); + postToolResult(data.request_id, { ok: false, error }); + }); } } window.addEventListener('message', handleMessage); + if (opts.artifact.arrow) { + pendingDomOps.push({ kind: 'artifact', artifact: opts.artifact.arrow }); + } opts.iframe.srcdoc = buildSrcdoc({ sandboxId, scriptNonce, bootstrapSource: opts.bootstrapSource, tokensSource: opts.tokensSource, - resourceMap, + networkPolicy: arrowNetworkPolicy, + arrowRuntimeSource: opts.arrowRuntimeSource, }); - if (opts.artifact.html) { - pendingDomOps.push({ kind: 'render', html: opts.artifact.html }); - } opts.events?.push({ kind: 'sandbox-spawned', at: Date.now(), sandboxId, - grantedIntents: Array.from(intentAllowlist), - artifactCapabilities: opts.artifact.capabilities, - grantedCapabilities, + grantedTools: Array.from(toolAllowlist), + artifactTools: opts.artifact.tools, + validationTools, }); return { @@ -530,42 +396,25 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { pendingStates.push(state); flushPending(); }, - render(html) { - pendingDomOps.push({ kind: 'render', html }); + renderArtifact(artifact) { + pendingDomOps.push({ kind: 'artifact', artifact }); opts.events?.push({ kind: 'render', at: Date.now(), sandboxId, - bytes: html.length, + bytes: JSON.stringify(artifact.source).length, }); flushPending(); }, - patchNode(patch) { - pendingDomOps.push({ kind: 'node-patch', patch }); - opts.events?.push({ - kind: 'render', - at: Date.now(), - sandboxId, - bytes: patch.html.length, - }); - flushPending(); - }, - setChrome(attrs) { - // Validate keys defensively. We get away with `unsafe-inline` everywhere - // else because the iframe is null-origin, but `data-summon-` is - // host-controlled — keep it boring. - for (const [k, v] of Object.entries(attrs)) { - if (!/^[a-z][a-z0-9-]*$/.test(k)) continue; - pendingChrome[k] = String(v); - hasPendingChrome = true; - } - flushPending(); - }, dispose() { window.removeEventListener('message', handleMessage); - opts.iframe.srcdoc = ''; ready = false; opts.events?.push({ kind: 'sandbox-disposed', at: Date.now(), sandboxId }); + window.setTimeout(() => { + if (opts.iframe.dataset.summonSandboxId !== sandboxId) return; + opts.iframe.srcdoc = ''; + delete opts.iframe.dataset.summonSandboxId; + }, 0); }, }; } diff --git a/packages/host/src/strict-input.ts b/packages/host/src/strict-input.ts index 8992990..ec75ca9 100644 --- a/packages/host/src/strict-input.ts +++ b/packages/host/src/strict-input.ts @@ -3,7 +3,7 @@ * * Architecture: a generative outer sandbox describes WHERE a sensitive field * should appear (via a placeholder div + bounds), but it never renders the - * input itself. The host sees a `mount_strict_input` intent, computes screen + * input itself. The host sees a `mount_strict_input` tool, computes screen * coordinates from the outer iframe's bounding rect plus the sandbox-reported * bounds, and absolute-positions a host-trusted element on top. * diff --git a/packages/host/src/surface-envelope.ts b/packages/host/src/surface-envelope.ts index 6c8e3e6..d0a7f15 100644 --- a/packages/host/src/surface-envelope.ts +++ b/packages/host/src/surface-envelope.ts @@ -1,37 +1,34 @@ import type { - CompiledArtifactHtml, + ArrowSurfaceArtifact, ContractIssue, ProtocolLine, StreamGraphSnapshot, SurfacePlan, - ValidationCapability, + ValidationTool, ValidationComponent, } from '@summon-internal/engine'; import { - compileArtifactHtml, + isArrowSurfaceArtifact, isProtocolLine, normalizeSurfacePlan, - surfacePlanScriptPolicy, validateProtocolLine, } from '@summon-internal/engine'; -export const SUMMON_SURFACE_ENVELOPE_VERSION = 2; +export const SUMMON_SURFACE_ENVELOPE_VERSION = 4; export interface SurfaceEnvelope { - version: 2; + version: 4; id: string; createdAt: string; prompt: string; surfacePlan: SurfacePlan; + artifact: ArrowSurfaceArtifact; protocolLines: ProtocolLine[]; - compiledHtml: CompiledArtifactHtml; - compilerVersion: string; - compilerIssues: ContractIssue[]; validationIssues: ContractIssue[]; streamGraph: StreamGraphSnapshot | null; grants: { - intents: string[]; - capabilities?: ValidationCapability[]; + tools: string[]; + validationTools?: ValidationTool[]; components?: ValidationComponent[]; }; metadata: { @@ -49,13 +46,13 @@ export interface CreateSurfaceEnvelopeInput { createdAt?: string | Date; prompt: string; surfacePlan: SurfacePlan; + artifact: ArrowSurfaceArtifact; protocolLines: ProtocolLine[]; - html: string; validationIssues?: ContractIssue[]; streamGraph?: StreamGraphSnapshot | null; grants: { - intents: string[]; - capabilities?: ValidationCapability[]; + tools: string[]; + validationTools?: ValidationTool[]; components?: ValidationComponent[]; }; metadata?: SurfaceEnvelope['metadata']; @@ -68,31 +65,31 @@ export function createSurfaceEnvelope(input: CreateSurfaceEnvelopeInput): Surfac ? input.createdAt.toISOString() : input.createdAt ?? new Date().toISOString(); const validationContext = validationContextForEnvelope(input); - const compiled = compileArtifactHtml(input.html, validationContext); const protocolIssues: ContractIssue[] = []; - const protocolLines = input.protocolLines.map((line) => - compileEnvelopeProtocolLine(line, validationContext, protocolIssues), - ); + const protocolLines = input.protocolLines.map((line) => { + for (const issue of validateProtocolLine(line, validationContext)) { + protocolIssues.push(issue); + } + return { ...line }; + }); return { - version: 2, + version: SUMMON_SURFACE_ENVELOPE_VERSION, id: input.id ?? newEnvelopeId(), createdAt, prompt: input.prompt, surfacePlan: input.surfacePlan, + artifact: input.artifact, protocolLines, - compiledHtml: compiled.html, - compilerVersion: compiled.compilerVersion, - compilerIssues: compiled.issues, - validationIssues: [...(input.validationIssues ?? []), ...compiled.issues, ...protocolIssues], + validationIssues: [...(input.validationIssues ?? []), ...protocolIssues], streamGraph: input.streamGraph ?? null, grants: { - intents: [...input.grants.intents], - capabilities: input.grants.capabilities?.map((capability) => ({ ...capability })), + tools: [...input.grants.tools], + validationTools: input.grants.validationTools?.map((tool) => ({ ...tool })), components: input.grants.components?.map((component) => ({ ...component })), }, metadata: input.metadata ?? {}, tokenCss: input.tokenCss ?? null, - runtimeVersion: input.runtimeVersion ?? 'summon-surface-envelope-v2', + runtimeVersion: input.runtimeVersion ?? 'summon-surface-envelope-v4', }; } @@ -108,11 +105,6 @@ export function parseSurfaceEnvelope(raw: string | unknown): SurfaceEnvelope | n if (isSurfaceEnvelope(parsed)) { return parsed; } - if (isLegacySurfaceEnvelope(parsed)) { - const envelope = createSurfaceEnvelope(parsed); - if (envelope.compilerIssues.some((issue) => issue.severity === 'block')) return null; - return envelope; - } return null; } @@ -123,11 +115,7 @@ export function isSurfaceEnvelope(value: unknown): value is SurfaceEnvelope { if (typeof input.id !== 'string' || !input.id) return false; if (typeof input.createdAt !== 'string' || Number.isNaN(Date.parse(input.createdAt))) return false; if (typeof input.prompt !== 'string') return false; - if (typeof input.compiledHtml !== 'string') return false; - if (typeof input.compilerVersion !== 'string' || !input.compilerVersion) return false; - if (!Array.isArray(input.compilerIssues) || !input.compilerIssues.every(isContractIssue)) { - return false; - } + if (!isArrowSurfaceArtifact(input.artifact)) return false; if (typeof input.runtimeVersion !== 'string' || !input.runtimeVersion) return false; const surfacePlan = normalizeSurfacePlan(input.surfacePlan); @@ -151,60 +139,24 @@ export function isSurfaceEnvelope(value: unknown): value is SurfaceEnvelope { const issues = validateProtocolLine(line, validationContext); if (issues.some((issue) => issue.severity === 'block')) return false; } - const compiled = compileArtifactHtml(input.compiledHtml, validationContext); - if (compiled.issues.some((issue) => issue.severity === 'block')) return false; - if (compiled.html !== input.compiledHtml) return false; return true; } -function compileEnvelopeProtocolLine( - line: ProtocolLine, - validationContext: ReturnType, - issues: ContractIssue[], -): ProtocolLine { - if (line.op !== 'add' || line.html === undefined) return { ...line }; - const compiled = compileArtifactHtml(line.html, validationContext); - for (const issue of compiled.issues) { - issues.push(issue.path ? issue : { ...issue, path: line.path }); - } - return { ...line, html: compiled.html }; -} - function validationContextForEnvelope(input: { surfacePlan: SurfacePlan; grants: SurfaceEnvelope['grants']; metadata?: SurfaceEnvelope['metadata']; }) { return { - mode: input.metadata?.mode ?? (input.surfacePlan.runtime === 'static' ? 'static' as const : 'interactive' as const), - scriptPolicy: surfacePlanScriptPolicy(input.surfacePlan), - capabilities: input.grants.capabilities, + mode: input.metadata?.mode ?? (input.grants.tools.length === 0 ? 'static' as const : 'interactive' as const), + tools: input.grants.validationTools, components: input.grants.components, - allowedIntents: input.grants.intents, + allowedTools: input.grants.tools, surfacePlan: input.surfacePlan, }; } -function isLegacySurfaceEnvelope(value: unknown): value is CreateSurfaceEnvelopeInput { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const input = value as Record; - if (input.version !== 1) return false; - if (typeof input.id !== 'string' || !input.id) return false; - if (typeof input.createdAt !== 'string' || Number.isNaN(Date.parse(input.createdAt))) return false; - if (typeof input.prompt !== 'string') return false; - if (typeof input.html !== 'string') return false; - const surfacePlan = normalizeSurfacePlan(input.surfacePlan); - if (!surfacePlan) return false; - if (!Array.isArray(input.protocolLines) || !input.protocolLines.every(isProtocolLine)) return false; - if (!Array.isArray(input.validationIssues) || !input.validationIssues.every(isContractIssue)) return false; - if (!isStreamGraphSnapshot(input.streamGraph)) return false; - if (!isGrants(input.grants)) return false; - if (!isMetadata(input.metadata)) return false; - if (input.tokenCss !== undefined && input.tokenCss !== null && typeof input.tokenCss !== 'string') return false; - return true; -} - function newEnvelopeId(): string { const cryptoLike = globalThis.crypto as Crypto | undefined; if (cryptoLike?.randomUUID) return cryptoLike.randomUUID(); @@ -233,13 +185,13 @@ function isGrants(value: unknown): value is SurfaceEnvelope['grants'] { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const grants = value as Record; return ( - Array.isArray(grants.intents) && - grants.intents.every((intent) => typeof intent === 'string') && + Array.isArray(grants.tools) && + grants.tools.every((tool) => typeof tool === 'string') && ( - grants.capabilities === undefined || + grants.validationTools === undefined || ( - Array.isArray(grants.capabilities) && - grants.capabilities.every(isValidationCapability) + Array.isArray(grants.validationTools) && + grants.validationTools.every(isValidationTool) ) ) && ( @@ -252,10 +204,10 @@ function isGrants(value: unknown): value is SurfaceEnvelope['grants'] { ); } -function isValidationCapability(value: unknown): value is ValidationCapability { +function isValidationTool(value: unknown): value is ValidationTool { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const capability = value as Record; - return typeof capability.name === 'string' && capability.name.length > 0; + const tool = value as Record; + return typeof tool.name === 'string' && tool.name.length > 0; } function isValidationComponent(value: unknown): value is ValidationComponent { diff --git a/packages/host/src/surface-stream.ts b/packages/host/src/surface-stream.ts index 36d150b..c5728ab 100644 --- a/packages/host/src/surface-stream.ts +++ b/packages/host/src/surface-stream.ts @@ -1,14 +1,14 @@ import { - compileArtifactHtml, + normalizeArrowSurfaceArtifact, + normalizeValidationLimits, parseProtocolLine, - SectionAccumulator, StreamGraph, - type CompiledArtifactHtml, - type CompiledHtmlNodePatch, + validateArrowSurfaceArtifact, type ContractIssue, + type ArrowSurfaceArtifact, + type ArtifactLine, type MetaLine, type ProtocolLine, - type SectionApplyResult, type StreamGraphSnapshot, type SurfacePlanMode, type ValidationContext, @@ -20,7 +20,6 @@ export type SurfaceStreamSource = | AsyncIterable | Iterable; -export type SurfaceStreamRenderMode = 'live' | 'final' | 'manual'; export type SurfaceStreamLineDecision = | boolean | 'apply' @@ -30,11 +29,9 @@ export type SurfaceStreamLineDecision = export interface SurfaceStreamContext { lineNumber: number; raw?: string; - accumulator: SectionAccumulator; graph: StreamGraph; protocolLines: readonly ProtocolLine[]; acceptedStructuralLines: number; - applyResult?: SectionApplyResult; } export interface SurfaceStreamParseError { @@ -44,9 +41,7 @@ export interface SurfaceStreamParseError { export interface SurfaceStreamOptions { mode: SurfacePlanMode | (() => SurfacePlanMode); - accumulator?: SectionAccumulator; streamGraph?: StreamGraph; - renderMode?: SurfaceStreamRenderMode; shouldApplyLine?: ( line: ProtocolLine, context: SurfaceStreamContext, @@ -59,6 +54,11 @@ export interface SurfaceStreamOptions { line: MetaLine, context: SurfaceStreamContext, ) => void | Promise; + onArtifact?: ( + artifact: ArrowSurfaceArtifact, + line: ArtifactLine, + context: SurfaceStreamContext, + ) => void | Promise; onParseError?: ( raw: string, context: SurfaceStreamContext, @@ -67,14 +67,6 @@ export interface SurfaceStreamOptions { snapshot: StreamGraphSnapshot, context: SurfaceStreamContext, ) => void | Promise; - onRenderHtml?: ( - html: CompiledArtifactHtml, - context: SurfaceStreamContext, - ) => void | Promise; - onNodePatch?: ( - patch: CompiledHtmlNodePatch, - context: SurfaceStreamContext, - ) => void | Promise; onError?: ( error: Error, context: SurfaceStreamContext, @@ -84,7 +76,6 @@ export interface SurfaceStreamOptions { export interface SurfaceStreamResult { protocolLines: ProtocolLine[]; - html: CompiledArtifactHtml; streamGraph: StreamGraphSnapshot; validationIssues: ContractIssue[]; parseErrors: SurfaceStreamParseError[]; @@ -96,7 +87,6 @@ export async function consumeSurfaceStream( source: SurfaceStreamSource, options: SurfaceStreamOptions, ): Promise { - const accumulator = options.accumulator ?? new SectionAccumulator(); const graph = options.streamGraph ?? new StreamGraph(); const protocolLines: ProtocolLine[] = []; const validationIssues: ContractIssue[] = []; @@ -110,30 +100,18 @@ export async function consumeSurfaceStream( const context = ( raw?: string, - applyResult?: SectionApplyResult, ): SurfaceStreamContext => ({ lineNumber, raw, - accumulator, graph, protocolLines, acceptedStructuralLines, - ...(applyResult ? { applyResult } : {}), }); - const renderMode = (): SurfaceStreamRenderMode => ( - options.renderMode ?? (resolveMode(options.mode) === 'static' ? 'live' : 'final') - ); - const emitGraph = async (ctx: SurfaceStreamContext) => { await options.onGraph?.(graph.snapshot(), ctx); }; - const emitRender = async (ctx: SurfaceStreamContext) => { - if (!accumulator.hasAnySection()) return; - await options.onRenderHtml?.(accumulator.compose() as CompiledArtifactHtml, ctx); - }; - const handleRawLine = async (raw: string) => { lineNumber += 1; if (stopped) return; @@ -163,12 +141,10 @@ export async function consumeSurfaceStream( } graph.applyLine(acceptedLine); - let applyResult: SectionApplyResult | undefined; if (acceptedLine.op !== 'meta') { - applyResult = accumulator.applyDetailed(acceptedLine); acceptedStructuralLines += 1; } - const ctx = context(raw, applyResult); + const ctx = context(raw); protocolLines.push(acceptedLine); if (acceptedLine.op === 'meta' && acceptedLine.path === '/validation-blocked' && isContractIssue(acceptedLine.value)) { @@ -185,15 +161,15 @@ export async function consumeSurfaceStream( await options.onMeta?.(acceptedLine, ctx); return; } - - await emitGraph(ctx); - if (renderMode() === 'live' && applyResult?.changed) { - if (applyResult.nodePatch && options.onNodePatch) { - await options.onNodePatch(applyResult.nodePatch as CompiledHtmlNodePatch, ctx); - return; + if (acceptedLine.op === 'artifact') { + await emitGraph(ctx); + if (isArrowSurfaceArtifactValue(acceptedLine.value)) { + await options.onArtifact?.(acceptedLine.value, acceptedLine, ctx); } - await emitRender(ctx); + return; } + + await emitGraph(ctx); }; try { @@ -214,9 +190,6 @@ export async function consumeSurfaceStream( if (tail) await handleRawLine(tail); const finalContext = context(); - if (renderMode() === 'final') { - await emitRender(finalContext); - } await emitGraph(finalContext); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); @@ -224,10 +197,8 @@ export async function consumeSurfaceStream( throw error; } - const html = accumulator.compose() as CompiledArtifactHtml; return { protocolLines, - html, streamGraph: graph.snapshot(), validationIssues, parseErrors, @@ -242,17 +213,49 @@ function compileAcceptedLine( validationIssues: ContractIssue[], graph: StreamGraph, ): ProtocolLine | null { - if (!validationContext || line.op !== 'add' || line.html === undefined) return line; - const result = compileArtifactHtml(line.html, validationContext); + if (line.op === 'artifact') { + return compileAcceptedArtifactLine(line, validationContext, validationIssues, graph); + } + if (line.op === 'meta') return line; + return null; +} + +function compileAcceptedArtifactLine( + line: ArtifactLine, + validationContext: ValidationContext | undefined, + validationIssues: ContractIssue[], + graph: StreamGraph, +): ArtifactLine | null { + const normalized = normalizeArrowSurfaceArtifact(line.value); + const issues = [...normalized.issues]; + if (normalized.artifact) { + const limits = normalizeValidationLimits(validationContext?.limits); + issues.push(...validateArrowSurfaceArtifact(normalized.artifact, { + maxSourceBytes: limits.maxProtocolLineBytes, + network: validationContext?.surfacePlan?.network ?? 'none', + })); + } + let blocked = false; - for (const issue of result.issues) { - const scoped = issue.path ? issue : { ...issue, path: line.path }; - pushValidationIssue(validationIssues, scoped); - graph.recordIssue(scoped); + for (const issue of issues) { + pushValidationIssue(validationIssues, issue); + graph.recordIssue(issue); if (issue.severity === 'block') blocked = true; } - if (blocked) return null; - return { ...line, html: result.html }; + if (blocked || !normalized.artifact) return null; + return { ...line, value: normalized.artifact }; +} + +function isArrowSurfaceArtifactValue(value: unknown): value is ArrowSurfaceArtifact { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + (value as { runtime?: unknown }).runtime === 'arrow' && + typeof (value as { source?: unknown }).source === 'object' && + (value as { source?: unknown }).source !== null && + !Array.isArray((value as { source?: unknown }).source) + ); } async function* chunksFromSource( @@ -286,10 +289,6 @@ function decodeChunk(chunk: SurfaceStreamChunk, decoder: TextDecoder): string { : decoder.decode(chunk, { stream: true }); } -function resolveMode(mode: SurfacePlanMode | (() => SurfacePlanMode)): SurfacePlanMode { - return typeof mode === 'function' ? mode() : mode; -} - function normalizeLineDecision( decision: SurfaceStreamLineDecision | undefined, ): Exclude { diff --git a/packages/host/src/capability-registry.ts b/packages/host/src/tool-registry.ts similarity index 86% rename from packages/host/src/capability-registry.ts rename to packages/host/src/tool-registry.ts index d96001f..80f438f 100644 --- a/packages/host/src/capability-registry.ts +++ b/packages/host/src/tool-registry.ts @@ -1,40 +1,40 @@ import type { ActionStateKeys, - CapabilityKind, - CapabilityPack, - CapabilityPattern, - CapabilityStateKeys, - CapabilityTrigger, - CompiledCapabilityContract, - IntentSpec, + ToolKind, + ToolPack, + ToolPattern, + ToolStateKeys, + ToolTrigger, + CompiledToolContract, + ToolSpec, ResourceStateKeys, - CapabilitySurface, + ToolSurface, } from '@summon-internal/engine'; -import { compileCapabilityContract } from '@summon-internal/engine'; +import { compileToolContract } from '@summon-internal/engine'; import type { ZodType, ZodTypeAny } from 'zod'; -import { defineIntent, type IntentEntry, type IntentHandler } from './policy-engine.js'; +import { defineToolHandler, type ToolHandlerEntry, type ToolHandler } from './policy-engine.js'; export type { ActionStateKeys, ResourceStateKeys } from '@summon-internal/engine'; export type StateShapeDescriptor = string | Record; -export interface CapabilityDefinition { +export interface ToolDefinition { name: string; description: string; argsSchema: ZodType; /** Optional override for prompt-facing schema text when Zod introspection is too lossy. */ argsSchemaText?: string; stateShape: StateShapeDescriptor; - kind?: CapabilityKind; - triggers?: CapabilityTrigger[]; - stateKeys?: CapabilityStateKeys; + kind?: ToolKind; + triggers?: ToolTrigger[]; + stateKeys?: ToolStateKeys; actionStateKeys?: ActionStateKeys; resultSchema?: string; defaultDataShape?: string; defaultData?: unknown; - patterns?: CapabilityPattern[]; - surface?: CapabilitySurface; - handler: IntentHandler; + patterns?: ToolPattern[]; + surface?: ToolSurface; + handler: ToolHandler; } export interface ActionDefinition { @@ -43,11 +43,11 @@ export interface ActionDefinition { argsSchema: ZodType; argsSchemaText?: string; stateShape: StateShapeDescriptor; - triggers?: CapabilityTrigger[]; - patterns?: CapabilityPattern[]; - surface?: CapabilitySurface; + triggers?: ToolTrigger[]; + patterns?: ToolPattern[]; + surface?: ToolSurface; controlled?: boolean | { stateKeys?: Partial }; - handler: IntentHandler; + handler: ToolHandler; } export interface DataResourceDefinition { @@ -60,10 +60,10 @@ export interface DataResourceDefinition { defaultData?: Out | null; stateShape?: StateShapeDescriptor; stateKeys: ResourceStateKeys; - triggers: CapabilityTrigger[]; - patterns?: CapabilityPattern[]; + triggers: ToolTrigger[]; + patterns?: ToolPattern[]; concurrency?: 'latest' | 'drop'; - surface?: CapabilitySurface; + surface?: ToolSurface; onStart?: (input: In) => Record; onError?: (message: string) => void; isEmpty?: (data: Out) => boolean; @@ -87,7 +87,7 @@ export interface ApprovalPrepared { export interface ApprovalRequest { id: string; - capability: string; + tool: string; args: TArgs; summary: string; details?: unknown; @@ -116,16 +116,16 @@ export interface ApprovalActionDefinition extends A }; } -export interface CapabilityRegistry { - toContract(): CompiledCapabilityContract; - toPolicyHandlers(): Record>; - intents(): string[]; - without(names: string[]): CapabilityRegistry; +export interface ToolRegistry { + toContract(): CompiledToolContract; + toPolicyHandlers(): Record>; + tools(): string[]; + without(names: string[]): ToolRegistry; } -export function defineCapability( - definition: CapabilityDefinition, -): CapabilityDefinition { +export function defineTool( + definition: ToolDefinition, +): ToolDefinition { const kind = definition.kind ?? 'action'; return { ...definition, @@ -135,9 +135,9 @@ export function defineCapability( }; } -export function defineAction(definition: ActionDefinition): CapabilityDefinition { +export function defineAction(definition: ActionDefinition): ToolDefinition { const stateKeys = actionStateKeys(definition.name, definition.controlled); - return defineCapability({ + return defineTool({ ...definition, kind: 'action', triggers: definition.triggers ?? ['click', 'submit'], @@ -151,7 +151,7 @@ export function defineAction(definition: ActionDefinition): CapabilityDefi }); } -export function defineWorkerAction(definition: ActionDefinition): CapabilityDefinition { +export function defineWorkerAction(definition: ActionDefinition): ToolDefinition { return defineAction({ ...definition, surface: { @@ -164,7 +164,7 @@ export function defineWorkerAction(definition: ActionDefinition): Capabili export function defineApprovalAction( definition: ApprovalActionDefinition, -): CapabilityDefinition { +): ToolDefinition { const stateKeys = approvalStateKeys(definition.name, definition.approval.stateKeys); const approvedHandler = definition.handler; return defineAction({ @@ -223,12 +223,12 @@ export function defineApprovalAction( export function defineDataResource( definition: DataResourceDefinition, -): CapabilityDefinition { +): ToolDefinition { validateDefaultData(definition); const handler = createDataResourceHandler(definition); const hasDefaultData = hasOwnDefaultData(definition); const resultSchema = definition.resultSchemaText ?? formatZodSchema(definition.resultSchema); - return defineCapability({ + return defineTool({ name: definition.name, description: definition.description, argsSchema: definition.argsSchema, @@ -258,7 +258,7 @@ export function defineDataResource( export function defineWorkerResource( definition: DataResourceDefinition, -): CapabilityDefinition { +): ToolDefinition { return defineDataResource({ ...definition, surface: { @@ -269,19 +269,19 @@ export function defineWorkerResource( }); } -export function createCapabilityRegistry( - definitions: CapabilityDefinition[], -): CapabilityRegistry { - assertUniqueCapabilityNames(definitions); - return new StaticCapabilityRegistry(definitions); +export function createToolRegistry( + definitions: ToolDefinition[], +): ToolRegistry { + assertUniqueToolNames(definitions); + return new StaticToolRegistry(definitions); } -class StaticCapabilityRegistry implements CapabilityRegistry { - constructor(private readonly definitions: CapabilityDefinition[]) {} +class StaticToolRegistry implements ToolRegistry { + constructor(private readonly definitions: ToolDefinition[]) {} - toContract(): CompiledCapabilityContract { - const intents: IntentSpec[] = this.definitions.map((definition) => { - const intent: IntentSpec = { + toContract(): CompiledToolContract { + const tools: ToolSpec[] = this.definitions.map((definition) => { + const tool: ToolSpec = { name: definition.name, description: definition.description, argsSchema: definition.argsSchemaText ?? formatZodSchema(definition.argsSchema), @@ -289,40 +289,40 @@ class StaticCapabilityRegistry implements CapabilityRegistry { kind: definition.kind ?? 'action', triggers: definition.triggers ?? ['click', 'submit'], }; - if (definition.stateKeys) intent.stateKeys = definition.stateKeys; - if (definition.actionStateKeys) intent.actionStateKeys = definition.actionStateKeys; - if (definition.surface) intent.surface = definition.surface; - if (definition.resultSchema) intent.resultSchema = definition.resultSchema; - if (definition.defaultDataShape) intent.defaultDataShape = definition.defaultDataShape; - if ('defaultData' in definition) intent.defaultData = definition.defaultData; - return intent; + if (definition.stateKeys) tool.stateKeys = definition.stateKeys; + if (definition.actionStateKeys) tool.actionStateKeys = definition.actionStateKeys; + if (definition.surface) tool.surface = definition.surface; + if (definition.resultSchema) tool.resultSchema = definition.resultSchema; + if (definition.defaultDataShape) tool.defaultDataShape = definition.defaultDataShape; + if ('defaultData' in definition) tool.defaultData = definition.defaultData; + return tool; }); const patterns = this.definitions.flatMap((definition) => (definition.patterns ?? []).map((pattern) => ({ ...pattern, - intent: pattern.intent ?? definition.name, + tool: pattern.tool ?? definition.name, })), ); - const pack: CapabilityPack = patterns.length > 0 ? { intents, patterns } : { intents }; - return compileCapabilityContract(pack); + const pack: ToolPack = patterns.length > 0 ? { tools, patterns } : { tools }; + return compileToolContract(pack); } - toPolicyHandlers(): Record> { + toPolicyHandlers(): Record> { return Object.fromEntries( this.definitions.map((definition) => [ definition.name, - defineIntent(definition.argsSchema, definition.handler), + defineToolHandler(definition.argsSchema, definition.handler), ]), ); } - intents(): string[] { + tools(): string[] { return this.definitions.map((definition) => definition.name); } - without(names: string[]): CapabilityRegistry { + without(names: string[]): ToolRegistry { const excluded = new Set(names); - return new StaticCapabilityRegistry( + return new StaticToolRegistry( this.definitions.filter((definition) => !excluded.has(definition.name)), ); } @@ -330,7 +330,7 @@ class StaticCapabilityRegistry implements CapabilityRegistry { function createDataResourceHandler( definition: DataResourceDefinition, -): IntentHandler { +): ToolHandler { const { stateKeys, concurrency = 'latest' } = definition; let inflight: AbortController | null = null; const defaultData = definition.defaultData ?? null; @@ -407,7 +407,7 @@ function actionStateKeys( function createControlledActionHandler( definition: ActionDefinition, stateKeys: ActionStateKeys, -): IntentHandler { +): ToolHandler { const handler = definition.handler; return async (ctx) => { ctx.push({ @@ -488,11 +488,11 @@ function deriveResourceStateShape( return `{${keys.loading}: boolean, ${keys.data}: ${result} | null, ${keys.error}: string | null${empty}}`; } -function assertUniqueCapabilityNames(definitions: CapabilityDefinition[]): void { +function assertUniqueToolNames(definitions: ToolDefinition[]): void { const seen = new Set(); for (const definition of definitions) { if (seen.has(definition.name)) { - throw new Error(`Duplicate capability "${definition.name}"`); + throw new Error(`Duplicate tool "${definition.name}"`); } seen.add(definition.name); } @@ -503,14 +503,14 @@ function formatStateShape(shape: StateShapeDescriptor): string { return JSON.stringify(shape); } -function defaultTriggersForHostKind(kind: string): CapabilityTrigger[] { +function defaultTriggersForHostKind(kind: string): ToolTrigger[] { return kind === 'resource' ? ['submit', 'mount'] : ['click', 'submit']; } function normalizeSurfaceForKind( kind: string, - surface: CapabilitySurface | undefined, -): CapabilitySurface { + surface: ToolSurface | undefined, +): ToolSurface { if (kind === 'resource') { return { data: surface?.data ?? 'host-resource', @@ -563,14 +563,14 @@ function approvalSummary( } function createApprovalRequest( - capability: string, + tool: string, args: T, id: string, prepared: ApprovalPrepared, ): ApprovalRequest { const request: ApprovalRequest = { id, - capability, + tool, args, summary: prepared.summary, plan: prepared.plan, diff --git a/packages/host/src/types.ts b/packages/host/src/types.ts index 3880828..a834158 100644 --- a/packages/host/src/types.ts +++ b/packages/host/src/types.ts @@ -1,14 +1,13 @@ import type { - CompiledArtifactHtml, - CompiledHtmlNodePatch, - ValidationCapability, + ArrowNetworkPolicy, + ArrowSurfaceArtifact, + ValidationTool, ValidationComponent, } from '@summon-internal/engine'; export type { - CompiledArtifactHtml, - CompiledHtmlNodePatch, - HtmlNodePatch, + ArrowNetworkPolicy, + ArrowSurfaceArtifact, } from '@summon-internal/engine'; /** Messages from host into the sandbox iframe. */ @@ -18,40 +17,28 @@ export interface StateMessage { state: Record; } -export interface NodePatchMessage { - type: 'SUMMON_NODE_PATCH'; - sandbox_id: string; - patch: CompiledHtmlNodePatch; -} - export interface RenderMessage { type: 'SUMMON_RENDER'; sandbox_id: string; - html: CompiledArtifactHtml; + artifact?: ArrowSurfaceArtifact; } -/** - * Host → iframe declaration of chrome attributes the artifact's CSS may - * target. Each entry becomes `="">` inside the - * sandbox document, set before any artifact CSS evaluates against it (or - * applied live mid-stream via `setChrome`). Used for orthogonal axes the - * artifact shouldn't author itself — posture, theme, density. - * - * The host decides the keys; the iframe just mirrors them. Keys must be - * lowercase ASCII (kebab-case allowed); values are coerced to strings. - */ -export interface ChromeMessage { - type: 'SUMMON_CHROME'; +/** Messages from the sandbox iframe back to the host. */ +export interface ToolCallMessage { + type: 'SUMMON_TOOL_CALL'; sandbox_id: string; - attrs: Record; + tool: string; + args: Record; + request_id?: string; } -/** Messages from the sandbox iframe back to the host. */ -export interface IntentMessage { - type: 'SUMMON_INTENT'; +export interface ToolResultMessage { + type: 'SUMMON_TOOL_RESULT'; sandbox_id: string; - intent: string; - args: Record; + request_id: string; + ok: boolean; + state: Record; + error?: string; } export interface ComponentIslandBounds { @@ -81,6 +68,12 @@ export interface ReadyMessage { sandbox_id: string; } +export interface RenderedMessage { + type: 'SUMMON_RENDERED'; + sandbox_id: string; + revision: number; +} + /** * Sent by bootstrap when its startup self-test detects the sandbox is not * configured the way Summon requires (e.g. someone added `allow-same-origin`, @@ -94,7 +87,12 @@ export interface FatalMessage { reason: string; } -export type SandboxInboundMessage = IntentMessage | ReadyMessage | FatalMessage | ComponentsMessage; +export type SandboxInboundMessage = + | ToolCallMessage + | ReadyMessage + | RenderedMessage + | FatalMessage + | ComponentsMessage; /** A spawned sandbox instance. */ export interface SandboxHandle { @@ -102,33 +100,23 @@ export interface SandboxHandle { iframe: HTMLIFrameElement; /** Push new state into the sandbox. Replaces current state on the sandbox side. */ pushState(state: Record): void; - /** Replace the compiled HTML inside #summon-root. */ - render(html: CompiledArtifactHtml): void; - /** Patch one validated data-summon-node subtree in place. Experimental. */ - patchNode(patch: CompiledHtmlNodePatch): void; - /** - * Declare chrome attributes that should appear on the sandbox document's - * `` element. Each entry becomes `data-summon-=""` and is - * applied live — including before the first render — so artifact CSS can - * target e.g. `[data-summon-posture="tap"]` without round-trips. Calls are - * additive: keys present in a previous call but absent from the new one - * are left in place. Pass an empty string to clear a key explicitly. - */ - setChrome(attrs: Record): void; + /** Replace the Arrow source artifact inside #summon-root. */ + renderArtifact(artifact: ArrowSurfaceArtifact): void; /** Tear down the sandbox: removes listeners, clears srcdoc. */ dispose(): void; } /** Artifact — generated HTML plus advisory declarations used for diagnostics and replay. */ export interface Artifact { - /** Intents the artifact declares it may emit. Execution is governed by host grants. */ - intents: string[]; - /** Advisory capabilities the artifact claims to use. Execution is still governed by grants. */ - capabilities?: ValidationCapability[]; + runtime?: 'arrow'; + /** Tools the artifact declares it may emit. Execution is governed by host grants. */ + tools: string[]; + /** Advisory validation metadata for tools the artifact claims to use. */ + validationTools?: ValidationTool[]; /** Advisory components the artifact claims to use. Host registry remains the rendering grant. */ components?: ValidationComponent[]; - /** Compiled canonical HTML body to render inside the sandbox. */ - html: CompiledArtifactHtml; + /** Arrow source artifact to render inside the sandbox. */ + arrow?: ArrowSurfaceArtifact; /** Optional initial state pushed after SANDBOX_READY. */ initialState?: Record; } diff --git a/packages/host/test/surface-stream.test.ts b/packages/host/test/surface-stream.test.ts index 2861963..af42ec5 100644 --- a/packages/host/test/surface-stream.test.ts +++ b/packages/host/test/surface-stream.test.ts @@ -1,10 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { ProtocolLine } from '@summon-internal/engine'; -import { - SectionAccumulator, - StreamGraph, -} from '@summon-internal/engine'; +import { StreamGraph } from '@summon-internal/engine'; import { consumeSurfaceStream, type SurfaceStreamContext, @@ -12,64 +9,110 @@ import { const encoder = new TextEncoder(); -test('consumeSurfaceStream parses split string chunks and renders static updates live', async () => { - const renders: string[] = []; +function artifactLine(source = 'import { html } from "@arrow-js/core";\nexport default html`

Arrow

`'): string { + return `${JSON.stringify({ + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': source, + }, + }, + })}\n`; +} + +test('consumeSurfaceStream parses split chunks and delivers Arrow artifacts', async () => { + const artifacts: string[] = []; const graphSnapshots: number[] = []; const lines: ProtocolLine[] = []; - + const line = artifactLine(); const result = await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n{"op":"add","path":"/section', - '/hero","html":"

Hello

"}\n', + line.slice(0, 35), + line.slice(35), ], { - mode: 'static', - onLine: (line) => lines.push(line), - onGraph: (snapshot) => graphSnapshots.push(snapshot.sections.length), - onRenderHtml: (html) => renders.push(html), + mode: 'interactive', + onLine: (accepted) => lines.push(accepted), + onGraph: (snapshot) => graphSnapshots.push(snapshot.health.blockedCount), + onArtifact: (artifact) => artifacts.push(artifact.source['main.ts'] ?? ''), }); - assert.equal(result.protocolLines.length, 2); - assert.deepEqual(lines.map((line) => line.op), ['set', 'add']); - assert.equal(renders.length, 1); - assert.equal(renders[0], '
\n

Hello

\n
'); - assert.equal(result.html, renders[0]); + assert.equal(result.protocolLines.length, 1); + assert.deepEqual(lines.map((accepted) => accepted.op), ['artifact']); + assert.equal(artifacts.length, 1); + assert.match(artifacts[0]!, /Arrow/); assert.equal(result.streamGraph.health.complete, true); - assert.ok(graphSnapshots.length >= 2); + assert.ok(graphSnapshots.length >= 1); }); -test('consumeSurfaceStream accepts Uint8Array chunks', async () => { - const result = await consumeSurfaceStream([ - encoder.encode('{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n'), - encoder.encode('{"op":"add","path":"/section/hero","html":"

Bytes

"}\n'), +test('consumeSurfaceStream accepts Uint8Array and ReadableStream sources', async () => { + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(artifactLine())); + controller.close(); + }, + }); + + const bytesResult = await consumeSurfaceStream([ + encoder.encode(artifactLine()), ], { mode: 'static', }); + const streamResult = await consumeSurfaceStream(readable, { + mode: 'static', + }); - assert.equal(result.protocolLines.length, 2); - assert.match(result.html, /Bytes/); + assert.equal(bytesResult.protocolLines.length, 1); + assert.equal(streamResult.protocolLines.length, 1); }); -test('consumeSurfaceStream accepts ReadableStream sources', async () => { - const source = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n')); - controller.enqueue(encoder.encode('{"op":"add","path":"/section/hero","html":"

Readable

"}\n')); - controller.close(); - }, +test('consumeSurfaceStream blocks invalid Arrow artifacts before callback delivery', async () => { + const artifacts: string[] = []; + const result = await consumeSurfaceStream([ + artifactLine('import { html } from "@arrow-js/core";\nexport default html``'), + ], { + mode: 'interactive', + onArtifact: (artifact) => artifacts.push(artifact.source['main.ts'] ?? ''), }); - const result = await consumeSurfaceStream(source, { - mode: 'static', + assert.deepEqual(artifacts, []); + assert.equal(result.protocolLines.length, 0); + assert.deepEqual(result.validationIssues.map((issue) => issue.code), [ + 'unsupported-arrow-idl-binding', + ]); + assert.equal(result.streamGraph.health.blockedCount, 1); +}); + +test('consumeSurfaceStream rejects legacy section protocol at parse boundary', async () => { + const result = await consumeSurfaceStream([ + '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n', + '{"op":"add","path":"/section/hero","html":"

Legacy

"}\n', + ], { + mode: 'interactive', + validationContext: { + mode: 'interactive', + surfacePlan: { + purpose: 'inform', + runtime: 'arrow', + data: 'embedded', + authority: 'none', + persistence: 'replayable', + network: 'none', + }, + }, }); - assert.equal(result.protocolLines.length, 2); - assert.match(result.html, /Readable/); + assert.equal(result.protocolLines.length, 0); + assert.deepEqual(result.validationIssues.map((issue) => issue.code), []); + assert.equal(result.parseErrors.length, 2); + assert.equal(result.streamGraph.health.blockedCount, 0); }); test('consumeSurfaceStream records malformed lines and calls parse-error callback', async () => { const parseErrors: string[] = []; const result = await consumeSurfaceStream([ 'not jsonl\n', - '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n', + artifactLine(), ], { mode: 'static', onParseError: (raw) => parseErrors.push(raw), @@ -83,17 +126,16 @@ test('consumeSurfaceStream records malformed lines and calls parse-error callbac test('consumeSurfaceStream delivers meta lines and collects validation-blocked issues', async () => { const metas: string[] = []; const result = await consumeSurfaceStream([ - JSON.stringify({ + `${JSON.stringify({ op: 'meta', path: '/validation-blocked', value: { - source: 'html', + source: 'protocol', severity: 'block', - code: 'unsafe-tag', - message: 'bad tag', + code: 'arrow-only-protocol', + message: 'old protocol', }, - }), - '\n', + })}\n`, ], { mode: 'interactive', onMeta: (line) => metas.push(line.path), @@ -101,16 +143,16 @@ test('consumeSurfaceStream delivers meta lines and collects validation-blocked i assert.deepEqual(metas, ['/validation-blocked']); assert.equal(result.validationIssues.length, 1); - assert.equal(result.validationIssues[0]?.code, 'unsafe-tag'); + assert.equal(result.validationIssues[0]?.code, 'arrow-only-protocol'); assert.equal(result.streamGraph.health.blockedCount, 1); }); test('consumeSurfaceStream collects validation-summary examples without duplicating blocked issues', async () => { const blocked = { - source: 'html', + source: 'protocol', severity: 'block', - code: 'unsafe-tag', - message: 'bad tag', + code: 'arrow-only-protocol', + message: 'old protocol', } as const; const warning = { source: 'token', @@ -127,7 +169,7 @@ test('consumeSurfaceStream collects validation-summary examples without duplicat value: { blocked: 1, warnings: 1, - codes: { 'unsafe-tag': 1, 'unknown-token': 1 }, + codes: { 'arrow-only-protocol': 1, 'unknown-token': 1 }, examples: [blocked, warning], }, })}\n`, @@ -136,149 +178,47 @@ test('consumeSurfaceStream collects validation-summary examples without duplicat }); assert.deepEqual(result.validationIssues.map((issue) => issue.code), [ - 'unsafe-tag', + 'arrow-only-protocol', 'unknown-token', ]); }); -test('consumeSurfaceStream renders interactive streams only at completion', async () => { - const renders: string[] = []; - const result = await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["hero","body"]}}\n', - '{"op":"add","path":"/section/hero","html":"

Title

"}\n', - '{"op":"add","path":"/section/body","html":"

Done

"}\n', - ], { - mode: 'interactive', - onRenderHtml: (html) => renders.push(html), - }); - - assert.equal(renders.length, 1); - assert.equal(renders[0], result.html); - assert.match(result.html, /Title/); - assert.match(result.html, /Done/); -}); - -test('consumeSurfaceStream live render mode renders interactive section replacements', async () => { - const renders: string[] = []; - const result = await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n', - '{"op":"add","path":"/section/hero","html":"
Drafting...
"}\n', - '{"op":"add","path":"/section/hero","html":"

Final answer

"}\n', - ], { - mode: 'interactive', - renderMode: 'live', - onRenderHtml: (html) => renders.push(html), - }); - - assert.equal(renders.length, 2); - assert.match(renders[0]!, /Drafting/); - assert.doesNotMatch(renders[1]!, /Drafting/); - assert.match(renders[1]!, /Final answer/); - assert.equal(renders[1], result.html); -}); - -test('consumeSurfaceStream emits live html node patches when a hook is provided', async () => { - const renders: string[] = []; - const patches: string[] = []; - const result = await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["main"]}}\n', - '{"op":"add","path":"/section/main/node/root","html":"
"}\n', - '{"op":"add","path":"/section/main/node/headline","parent":"root","html":"

Ready

"}\n', - ], { - mode: 'static', - renderMode: 'live', - onRenderHtml: (html) => renders.push(html), - onNodePatch: (patch) => patches.push(`${patch.sectionId}/${patch.nodeId}/${patch.parentId ?? ''}`), - }); - - assert.deepEqual(renders, []); - assert.deepEqual(patches, ['main/root/', 'main/headline/root']); - assert.match(result.html, /data-summon-node="headline"/); -}); - -test('consumeSurfaceStream falls back to composed html for node patches without a hook', async () => { - const renders: string[] = []; - await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["main"]}}\n', - '{"op":"add","path":"/section/main/node/root","html":"
"}\n', - ], { - mode: 'static', - renderMode: 'live', - onRenderHtml: (html) => renders.push(html), - }); - - assert.equal(renders.length, 1); - assert.match(renders[0]!, /data-summon-node="root"/); -}); - -test('consumeSurfaceStream manual render mode does not call render callback', async () => { - let renderCount = 0; - const result = await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n', - '{"op":"add","path":"/section/hero","html":"

Manual

"}\n', - ], { - mode: 'static', - renderMode: 'manual', - onRenderHtml: () => { - renderCount += 1; - }, - }); - - assert.equal(renderCount, 0); - assert.match(result.html, /Manual/); -}); - -test('consumeSurfaceStream can discard a line and keep consuming', async () => { - const result = await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["hero","body"]}}\n', - '{"op":"add","path":"/section/hero","html":"

Discard

"}\n', - '{"op":"add","path":"/section/body","html":"

Keep

"}\n', +test('consumeSurfaceStream can discard or stop before applying a line', async () => { + const contexts: SurfaceStreamContext[] = []; + let decisions = 0; + const discardResult = await consumeSurfaceStream([ + artifactLine(), + artifactLine('import { html } from "@arrow-js/core";\nexport default html`

Keep

`'), ], { mode: 'static', - shouldApplyLine: (line) => line.path === '/section/hero' ? 'discard' : 'apply', + shouldApplyLine: () => decisions++ === 0 ? 'discard' : 'apply', + onLine: (_line, context) => contexts.push(context), }); - assert.equal(result.discarded, true); - assert.equal(result.stopped, false); - assert.equal(result.protocolLines.length, 2); - assert.doesNotMatch(result.html, /Discard/); - assert.match(result.html, /Keep/); -}); + assert.equal(discardResult.discarded, true); + assert.equal(discardResult.stopped, false); + assert.equal(discardResult.protocolLines.length, 1); -test('consumeSurfaceStream can stop before applying a line', async () => { - const contexts: SurfaceStreamContext[] = []; - const result = await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n', - '{"op":"add","path":"/section/hero","html":"

Stop

"}\n', - '{"op":"add","path":"/section/hero","html":"

Ignored

"}\n', + const stopResult = await consumeSurfaceStream([ + artifactLine(), ], { mode: 'static', - shouldApplyLine: (line, context) => { - contexts.push(context); - return line.op === 'add' ? 'stop' : 'apply'; - }, + shouldApplyLine: () => 'stop', }); - assert.equal(result.stopped, true); - assert.equal(result.discarded, true); - assert.equal(result.protocolLines.length, 1); - assert.equal(contexts.at(-1)?.acceptedStructuralLines, 1); - assert.equal(result.html, ''); + assert.equal(stopResult.stopped, true); + assert.equal(stopResult.discarded, true); + assert.equal(stopResult.protocolLines.length, 0); }); -test('consumeSurfaceStream can use supplied accumulator and graph instances', async () => { - const accumulator = new SectionAccumulator(); +test('consumeSurfaceStream can use a supplied graph instance', async () => { const streamGraph = new StreamGraph(); - const result = await consumeSurfaceStream([ - '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n', - '{"op":"add","path":"/section/hero","html":"

Shared

"}\n', + artifactLine(), ], { mode: () => 'interactive', - accumulator, streamGraph, }); - assert.equal(result.html, accumulator.compose()); assert.deepEqual(result.streamGraph, streamGraph.snapshot()); }); diff --git a/packages/host/test/capability-registry.test.ts b/packages/host/test/tool-registry.test.ts similarity index 88% rename from packages/host/test/capability-registry.test.ts rename to packages/host/test/tool-registry.test.ts index b9b934b..e922ed4 100644 --- a/packages/host/test/capability-registry.test.ts +++ b/packages/host/test/tool-registry.test.ts @@ -1,14 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - IntentArgsError, + ToolArgsError, PolicyEngine, - createCapabilityRegistry, + createToolRegistry, createComponentRegistry, createSurfaceEnvelope, defineAction, defineApprovalAction, - defineCapability, + defineTool, defineDataResource, defineComponent, defineWorkerAction, @@ -18,7 +18,7 @@ import { import { z } from 'zod'; test('registry converts actions and resources into prompt and validation metadata', () => { - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineAction({ name: 'counter', description: 'Adjust the counter.', @@ -41,7 +41,7 @@ test('registry converts actions and resources into prompt and validation metadat const contract = registry.toContract(); assert.deepEqual(contract.pack, { - intents: [ + tools: [ { name: 'counter', description: 'Adjust the counter.', @@ -66,9 +66,9 @@ test('registry converts actions and resources into prompt and validation metadat surface: { data: 'host-resource', authority: 'read' }, }, ], - patterns: [{ name: 'Counter', code: '', intent: 'counter' }], + patterns: [{ name: 'Counter', code: '', tool: 'counter' }], }); - assert.deepEqual(contract.validationCapabilities, [ + assert.deepEqual(contract.validationTools, [ { name: 'counter', kind: 'action', triggers: ['click', 'submit'], surface: { authority: 'host-action' } }, { name: 'lookup', @@ -171,13 +171,13 @@ test('component registry rejects duplicate names and dispatches render lifecycle props: { label: 'Revenue' }, componentId: 'metric', sandboxId: 'sandbox', - emitIntent: () => {}, + callTool: () => {}, }); registry.destroy('MetricCard', { container, componentId: 'metric', sandboxId: 'sandbox', - emitIntent: () => {}, + callTool: () => {}, }); assert.deepEqual(calls, ['render:metric:Revenue', 'destroy:metric']); }); @@ -187,7 +187,7 @@ test('registry formats richer Zod schemas for prompts', () => { Alpha = 'alpha', Beta = 'beta', } - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineAction({ name: 'complex', description: 'Exercise schema formatting.', @@ -209,14 +209,14 @@ test('registry formats richer Zod schemas for prompts', () => { ]); assert.equal( - registry.toContract().pack.intents[0]!.argsSchema, + registry.toContract().pack.tools[0]!.argsSchema, '{required: string, optional?: number, nullable: string | null, choice: "one" | "two", literal: "fixed", union: string | number, native: "alpha" | "beta", list: {id: string, score: number | null}[], record: {[key: string]: boolean}, nested: {"display-name"?: string}}', ); }); -test('registry converts capabilities into PolicyEngine handlers', async () => { - const registry = createCapabilityRegistry([ - defineCapability({ +test('registry converts tools into PolicyEngine handlers', async () => { + const registry = createToolRegistry([ + defineTool({ name: 'counter', description: 'Adjust the counter.', argsSchema: z.object({ delta: z.number() }), @@ -235,7 +235,7 @@ test('registry converts capabilities into PolicyEngine handlers', async () => { }, }); - assert.deepEqual(policy.intents, ['counter']); + assert.deepEqual(policy.tools, ['counter']); await policy.dispatch('counter', { delta: 3 }); assert.equal(latestState.count, 3); }); @@ -243,8 +243,8 @@ test('registry converts capabilities into PolicyEngine handlers', async () => { test('registry-generated handlers reject invalid args before execution', async () => { let calls = 0; const errors: Error[] = []; - const registry = createCapabilityRegistry([ - defineCapability({ + const registry = createToolRegistry([ + defineTool({ name: 'counter', description: 'Adjust the counter.', argsSchema: z.object({ delta: z.number() }), @@ -258,7 +258,7 @@ test('registry-generated handlers reject invalid args before execution', async ( const policy = new PolicyEngine({ handlers: registry.toPolicyHandlers(), onStateChange: () => {}, - onHandlerError: (_intent, error) => { + onHandlerError: (_tool, error) => { errors.push(error); }, }); @@ -267,12 +267,12 @@ test('registry-generated handlers reject invalid args before execution', async ( assert.equal(calls, 0); assert.equal(errors.length, 1); - assert.ok(errors[0] instanceof IntentArgsError); + assert.ok(errors[0] instanceof ToolArgsError); }); test('controlled actions push pending, done, and error lifecycle state', async () => { const states: Record[] = []; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineAction({ name: 'save', description: 'Save a selection.', @@ -286,7 +286,7 @@ test('controlled actions push pending, done, and error lifecycle state', async ( ]); const contract = registry.toContract(); - assert.deepEqual(contract.pack.intents[0]?.actionStateKeys, { + assert.deepEqual(contract.pack.tools[0]?.actionStateKeys, { pending: 'savePending', done: 'saveDone', error: 'saveError', @@ -318,7 +318,7 @@ test('controlled actions push pending, done, and error lifecycle state', async ( test('controlled actions support custom state keys and rethrow failures to diagnostics', async () => { const states: Record[] = []; const errors: Error[] = []; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineAction({ name: 'save', description: 'Save a selection.', @@ -340,7 +340,7 @@ test('controlled actions support custom state keys and rethrow failures to diagn const policy = new PolicyEngine({ handlers: registry.toPolicyHandlers(), onStateChange: (state) => states.push(state), - onHandlerError: (_intent, error) => errors.push(error), + onHandlerError: (_tool, error) => errors.push(error), }); await policy.dispatch('save', { label: 'Balanced path' }); @@ -353,7 +353,7 @@ test('controlled actions support custom state keys and rethrow failures to diagn }); test('default actions do not receive controlled lifecycle state', () => { - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineAction({ name: 'save', description: 'Save a selection.', @@ -364,12 +364,12 @@ test('default actions do not receive controlled lifecycle state', () => { ]); const contract = registry.toContract(); - assert.equal(contract.pack.intents[0]?.actionStateKeys, undefined); + assert.equal(contract.pack.tools[0]?.actionStateKeys, undefined); assert.deepEqual(contract.initialState, {}); }); test('data resource handlers push loading, data, and error state', async () => { - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineDataResource({ name: 'lookup', description: 'Fetch a lookup result.', @@ -411,7 +411,7 @@ test('data resource handlers push loading, data, and error state', async () => { test('data resource handlers expose optional empty state after successful empty results', async () => { let nextResult: { title: string }[] = []; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineDataResource({ name: 'lookup', description: 'Fetch lookup results.', @@ -454,7 +454,7 @@ test('data resource handlers expose optional empty state after successful empty test('data resource empty state resets false on invalid and failed host results', async () => { let mode: 'invalid' | 'throw' = 'invalid'; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineDataResource({ name: 'lookup', description: 'Fetch lookup results.', @@ -494,7 +494,7 @@ test('data resource empty state resets false on invalid and failed host results' }); test('data resource empty state supports custom isEmpty logic', async () => { - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineDataResource({ name: 'lookup', description: 'Fetch lookup result.', @@ -528,7 +528,7 @@ test('data resource empty state supports custom isEmpty logic', async () => { test('data resource result validation converts invalid host data into error state', async () => { const errors: string[] = []; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineDataResource({ name: 'lookup', description: 'Fetch a lookup result.', @@ -575,7 +575,7 @@ test('defineDataResource validates default data against result schema', () => { }); test('data resource handlers restore default data on host errors', async () => { - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineDataResource({ name: 'lookup', description: 'Fetch a lookup result.', @@ -611,7 +611,7 @@ test('data resource handlers drop duplicate work when concurrency is drop', asyn const pending = new Promise<{ ok: boolean }>((resolve) => { release = resolve; }); - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineDataResource({ name: 'slow', description: 'Run one slow lookup.', @@ -642,7 +642,7 @@ test('data resource handlers drop duplicate work when concurrency is drop', asyn test('data resource handlers abort stale work when concurrency is latest', async () => { const signals: AbortSignal[] = []; let latestState: Record = {}; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineDataResource({ name: 'slow', description: 'Run the latest lookup.', @@ -676,16 +676,16 @@ test('data resource handlers abort stale work when concurrency is latest', async assert.deepEqual(latestState.slowResult, { id: 'second' }); }); -test('without removes a capability from both pack and handlers', () => { - const registry = createCapabilityRegistry([ - defineCapability({ +test('without removes a tool from both pack and handlers', () => { + const registry = createToolRegistry([ + defineTool({ name: 'counter', description: 'Adjust the counter.', argsSchema: z.object({ delta: z.number() }), stateShape: '{count: number}', handler: () => {}, }), - defineCapability({ + defineTool({ name: 'summon', description: 'Spawn a child UI.', argsSchema: z.object({ prompt: z.string() }), @@ -695,18 +695,18 @@ test('without removes a capability from both pack and handlers', () => { ]).without(['summon']); assert.deepEqual( - registry.toContract().pack.intents.map((intent) => intent.name), + registry.toContract().pack.tools.map((tool) => tool.name), ['counter'], ); assert.deepEqual(Object.keys(registry.toPolicyHandlers()), ['counter']); - assert.deepEqual(registry.toContract().validationCapabilities, [ + assert.deepEqual(registry.toContract().validationTools, [ { name: 'counter', kind: 'action', triggers: ['click', 'submit'], surface: { authority: 'host-action' } }, ]); - assert.deepEqual(registry.intents(), ['counter']); + assert.deepEqual(registry.tools(), ['counter']); }); -test('worker helpers annotate capabilities without changing policy dispatch', async () => { - const registry = createCapabilityRegistry([ +test('worker helpers annotate tools without changing policy dispatch', async () => { + const registry = createToolRegistry([ defineWorkerAction({ name: 'analyze', description: 'Run host-owned analysis.', @@ -726,7 +726,7 @@ test('worker helpers annotate capabilities without changing policy dispatch', as ]); const contract = registry.toContract(); - assert.deepEqual(contract.validationCapabilities.map((capability) => capability.surface), [ + assert.deepEqual(contract.validationTools.map((tool) => tool.surface), [ { data: 'worker', authority: 'host-action' }, { data: 'worker', authority: 'read' }, ]); @@ -745,7 +745,7 @@ test('worker helpers annotate capabilities without changing policy dispatch', as test('approval action runs approved handler only after approval', async () => { let approvedCalls = 0; const states: Record[] = []; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineApprovalAction({ name: 'publish', description: 'Publish after approval.', @@ -762,8 +762,8 @@ test('approval action runs approved handler only after approval', async () => { ]); const contract = registry.toContract(); - assert.equal(contract.validationCapabilities[0]?.surface?.authority, 'approval-gated'); - assert.match(contract.pack.intents[0]?.stateShape ?? '', /publishApprovalRequestId: string \| null/); + assert.equal(contract.validationTools[0]?.surface?.authority, 'approval-gated'); + assert.match(contract.pack.tools[0]?.stateShape ?? '', /publishApprovalRequestId: string \| null/); const policy = new PolicyEngine({ handlers: registry.toPolicyHandlers(), @@ -789,7 +789,7 @@ test('approval action rejects invalid args before requesting approval', async () let prepareCalls = 0; let approvalCalls = 0; let approvedCalls = 0; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineApprovalAction({ name: 'publish', description: 'Publish after approval.', @@ -815,14 +815,14 @@ test('approval action rejects invalid args before requesting approval', async () const policy = new PolicyEngine({ handlers: registry.toPolicyHandlers(), onStateChange: () => {}, - onHandlerError: (_intent, error) => errors.push(error), + onHandlerError: (_tool, error) => errors.push(error), }); await policy.dispatch('publish', { title: 42 }); assert.equal(prepareCalls, 0); assert.equal(approvalCalls, 0); assert.equal(approvedCalls, 0); - assert.ok(errors[0] instanceof IntentArgsError); + assert.ok(errors[0] instanceof ToolArgsError); }); test('approval action prepares a frozen request for host approval and approved handler', async () => { @@ -830,7 +830,7 @@ test('approval action prepares a frozen request for host approval and approved h let seenRequest: | { id: string; - capability: string; + tool: string; summary: string; details?: unknown; plan: unknown; @@ -840,7 +840,7 @@ test('approval action prepares a frozen request for host approval and approved h | undefined; let handlerPlan: unknown; const states: Record[] = []; - const registry = createCapabilityRegistry([ + const registry = createToolRegistry([ defineApprovalAction({ name: 'publish', description: 'Publish after approval.', @@ -880,7 +880,7 @@ test('approval action prepares a frozen request for host approval and approved h assert.equal(prepareCalls, 1); assert.ok(seenRequest); - assert.equal(seenRequest.capability, 'publish'); + assert.equal(seenRequest.tool, 'publish'); assert.equal(seenRequest.summary, 'Publish "launch note"'); assert.deepEqual(seenRequest.details, { channel: 'demo-updates' }); assert.deepEqual(seenRequest.plan, { operation: 'publish', title: 'LAUNCH NOTE' }); @@ -893,49 +893,60 @@ test('approval action prepares a frozen request for host approval and approved h }); test('surface envelope serializes replay metadata', () => { + const artifact = { + runtime: 'arrow' as const, + source: { + 'main.ts': 'import { html } from "@arrow-js/core";\nexport default html`

Saved

`', + }, + }; const envelope = createSurfaceEnvelope({ prompt: 'compare options', surfacePlan: { purpose: 'compare', - runtime: 'static', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }, - protocolLines: [{ op: 'set', path: '/screen', value: { sections: ['hero'] } }], - html: '
Saved
', - grants: { intents: [] }, + artifact, + protocolLines: [{ op: 'artifact', path: '/artifact', value: artifact }], + grants: { tools: [] }, metadata: { directionId: 'ghost', mode: 'static' }, runtimeVersion: 'test', }); - assert.equal(envelope.version, 2); + assert.equal(envelope.version, 4); assert.equal(envelope.prompt, 'compare options'); - assert.equal(envelope.compiledHtml, '
Saved
'); - assert.equal(envelope.compilerIssues.length, 0); - assert.equal(envelope.compilerVersion, 'summon-artifact-compiler-v2'); + assert.deepEqual(envelope.artifact, artifact); assert.equal(envelope.metadata.directionId, 'ghost'); assert.deepEqual(envelope.protocolLines, [ - { op: 'set', path: '/screen', value: { sections: ['hero'] } }, + { op: 'artifact', path: '/artifact', value: artifact }, ]); }); test('surface envelope parser accepts valid replay envelopes', () => { + const artifact = { + runtime: 'arrow' as const, + source: { + 'main.ts': 'import { html } from "@arrow-js/core";\nexport default html`

Saved

`', + }, + }; const envelope = createSurfaceEnvelope({ prompt: 'compare options', surfacePlan: { purpose: 'compare', - runtime: 'static', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }, + artifact, protocolLines: [ - { op: 'set', path: '/screen', value: { sections: ['hero'] } }, - { op: 'add', path: '/section/hero', html: '

Saved

' }, + { op: 'artifact', path: '/artifact', value: artifact }, ], - html: '
Saved
', - grants: { intents: [] }, + grants: { tools: [] }, metadata: { mode: 'static' }, }); @@ -943,28 +954,35 @@ test('surface envelope parser accepts valid replay envelopes', () => { }); test('surface envelope parser rejects malformed, wrong-version, and escalating envelopes', () => { + const artifact = { + runtime: 'arrow' as const, + source: { + 'main.ts': 'import { html } from "@arrow-js/core";\nexport default html`

Saved

`', + }, + }; const envelope = createSurfaceEnvelope({ prompt: 'pick an option', surfacePlan: { purpose: 'collect', - runtime: 'declarative', + runtime: 'arrow', data: 'embedded', authority: 'host-action', persistence: 'replayable', + network: 'none', }, + artifact, protocolLines: [ { op: 'add', path: '/section/hero', - html: '', - }, + html: '

Legacy

', + } as never, ], - html: '', - grants: { intents: ['choose'], capabilities: [{ name: 'choose', triggers: ['click'] }] }, + grants: { tools: ['choose'], validationTools: [{ name: 'choose', triggers: ['click'] }] }, metadata: { mode: 'interactive' }, }); assert.equal(parseSurfaceEnvelope('{bad'), null); - assert.equal(parseSurfaceEnvelope({ ...envelope, version: 3 }), null); + assert.equal(parseSurfaceEnvelope({ ...envelope, version: 2 }), null); assert.equal(parseSurfaceEnvelope(envelope), null); }); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 682911f..66a23dc 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,15 +1,13 @@ import { - type CapabilityRegistry, + type ToolRegistry, type ComponentDefinition, type ComponentRegistry, } from '@anarchitecture/summon'; import { - compileArtifactHtml, - SectionAccumulator, - type CompiledArtifactHtml, - type CompiledHtmlNodePatch, - type HtmlNodePatch, - type ProtocolLine, + isArrowSurfaceArtifact, + type ArtifactLine, + type ArrowNetworkPolicy, + type ArrowSurfaceArtifact, type ValidationContext, } from '@anarchitecture/summon/engine'; import { @@ -24,6 +22,7 @@ import { createEventStore, type DevtoolsEvent } from '@anarchitecture/summon/dev import type { SurfaceEnvelope } from '@anarchitecture/summon/envelope'; import { PolicyEngine } from '@anarchitecture/summon/policy'; import { + arrowRuntimeSource as defaultArrowRuntimeSource, bootstrapSource as defaultBootstrapSource, tokensSource as defaultTokensSource, } from '@anarchitecture/summon/assets'; @@ -39,29 +38,28 @@ import { } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -export interface SummonSurfaceChrome { - [key: string]: string; -} - export interface SummonSurfaceProps { envelope?: SurfaceEnvelope | null; - html?: string; - protocolLines?: ProtocolLine[]; - artifactIntents?: string[]; - grantedIntents?: string[]; - grantedCapabilities?: Artifact['capabilities']; + artifact?: ArrowSurfaceArtifact | null; + artifactTools?: string[]; + grantedTools?: string[]; + validationTools?: Artifact['validationTools']; artifactComponents?: Artifact['components']; - capabilityRegistry?: CapabilityRegistry | null; + toolRegistry?: ToolRegistry | null; componentRegistry?: ComponentRegistry | null; bootstrapSource?: string; + arrowRuntimeSource?: string; + arrowNetworkPolicy?: ArrowNetworkPolicy; tokensSource?: string; initialState?: Record; - chrome?: SummonSurfaceChrome; - onIntent?: (intent: string, args: Record) => void; - onIntentRejected?: (reason: string, raw: unknown) => void; + onToolCall?: (tool: string, args: Record) => + | void + | Record + | Promise>; + onToolRejected?: (reason: string, raw: unknown) => void; onEvent?: (event: DevtoolsEvent) => void; onFatal?: (reason: string) => void; - onHandlerError?: (intent: string, error: Error) => void; + onHandlerError?: (tool: string, error: Error) => void; onComponentError?: (error: ComponentIslandError) => void; id?: string; title?: string; @@ -72,10 +70,8 @@ export interface SummonSurfaceProps { export interface SummonSurfaceHandle { iframe: HTMLIFrameElement | null; sandboxId: string | null; - render(html: string): void; - patchNode(patch: HtmlNodePatch): void; + renderArtifact(artifact: ArrowSurfaceArtifact): void; pushState(state: Record): void; - setChrome(chrome: SummonSurfaceChrome): void; } export const SummonSurface = forwardRef(function SummonSurface( @@ -85,7 +81,7 @@ export const SummonSurface = forwardRef const iframeRef = useRef(null); const handleRef = useRef(null); const validationContextRef = useRef(null); - const lastRenderedHtmlRef = useRef(null); + const lastRenderedArtifactRef = useRef(null); const events = useMemo(() => createEventStore(), []); useImperativeHandle(ref, () => ({ @@ -95,42 +91,18 @@ export const SummonSurface = forwardRef get sandboxId() { return handleRef.current?.sandboxId ?? null; }, - render(html: string) { - const compiled = compileForRender(html, validationContextRef.current ?? defaultValidationContext()); - lastRenderedHtmlRef.current = compiled; - preflightComponentProps( - compiled, - props.componentRegistry, - handleRef.current?.sandboxId ?? undefined, - events, - props.onComponentError, - ); - handleRef.current?.render(compiled); - }, - patchNode(patch: HtmlNodePatch) { - const compiled = compilePatchForRender(patch, validationContextRef.current ?? defaultValidationContext()); - if (compiled) { - preflightComponentProps( - compiled.html, - props.componentRegistry, - handleRef.current?.sandboxId ?? undefined, - events, - props.onComponentError, - ); - handleRef.current?.patchNode(compiled); - } + renderArtifact(artifact: ArrowSurfaceArtifact) { + lastRenderedArtifactRef.current = artifact; + handleRef.current?.renderArtifact(artifact); }, pushState(state: Record) { handleRef.current?.pushState(state); }, - setChrome(chrome: SummonSurfaceChrome) { - handleRef.current?.setChrome(chrome); - }, }), []); useEffect(() => { - lastRenderedHtmlRef.current = null; - }, [props.envelope, props.html, props.protocolLines]); + lastRenderedArtifactRef.current = null; + }, [props.envelope, props.artifact]); useEffect(() => { if (!props.onEvent) return; @@ -144,22 +116,24 @@ export const SummonSurface = forwardRef const iframe = iframeRef.current; if (!iframe) return; - const contract = props.capabilityRegistry?.toContract(); + const contract = props.toolRegistry?.toContract(); const componentContract = props.componentRegistry?.toContract(); - const handlers = props.capabilityRegistry?.toPolicyHandlers() ?? {}; - const grantedIntents = props.grantedIntents ?? props.capabilityRegistry?.intents() ?? []; - const grantedCapabilities = props.grantedCapabilities ?? contract?.validationCapabilities ?? []; + const handlers = props.toolRegistry?.toPolicyHandlers() ?? {}; + const grantedTools = props.grantedTools ?? props.toolRegistry?.tools() ?? []; + const validationTools = props.validationTools ?? contract?.validationTools ?? []; const initialState = { ...(contract?.initialState ?? {}), ...(props.initialState ?? {}), }; const validationContext = validationContextFromProps( props, - grantedIntents, - grantedCapabilities, + grantedTools, + validationTools, componentContract?.validationComponents ?? props.artifactComponents ?? props.envelope?.grants.components, ); validationContextRef.current = validationContext; + const arrowArtifact = resolveArrowArtifact(props); + const arrowNetworkPolicy = props.arrowNetworkPolicy ?? props.envelope?.surfacePlan.network ?? 'none'; let handle: SandboxHandle | null = null; let islands: ComponentIslandRegistry | null = props.componentRegistry @@ -181,11 +155,12 @@ export const SummonSurface = forwardRef }); const artifact: Artifact = { + runtime: 'arrow', // Advisory only. spawnSandbox receives host grants below. - intents: props.artifactIntents ?? props.envelope?.grants.intents ?? grantedIntents, - capabilities: props.envelope?.grants.capabilities ?? props.grantedCapabilities, + tools: props.artifactTools ?? props.envelope?.grants.tools ?? grantedTools, + validationTools: props.envelope?.grants.validationTools ?? props.validationTools, components: props.artifactComponents ?? props.envelope?.grants.components ?? componentContract?.validationComponents, - html: resolveCompiledHtml(props, validationContext), + ...(arrowArtifact ? { arrow: arrowArtifact } : {}), initialState, }; const grantedComponentNames = new Set((artifact.components ?? []).map((component) => component.name)); @@ -193,19 +168,25 @@ export const SummonSurface = forwardRef handle = spawnSandbox({ iframe, artifact, - grantedIntents, - grantedCapabilities, + grantedTools, + validationTools, bootstrapSource: props.bootstrapSource ?? defaultBootstrapSource, + arrowRuntimeSource: props.arrowRuntimeSource ?? defaultArrowRuntimeSource, + arrowNetworkPolicy, tokensSource: props.tokensSource ?? props.envelope?.tokenCss ?? defaultTokensSource, events, onSandboxFatal: props.onFatal, - onIntent: (intent, args) => { - props.onIntent?.(intent, args); - if (Object.prototype.hasOwnProperty.call(handlers, intent)) { - void policy.dispatch(intent, args); + onToolCall: async (tool, args) => { + const customState = await props.onToolCall?.(tool, args); + if (customState && typeof customState === 'object' && !Array.isArray(customState)) { + return customState; } + if (Object.prototype.hasOwnProperty.call(handlers, tool)) { + return policy.dispatch(tool, args).then((result) => result.state); + } + return policy.getState(); }, - onIntentRejected: props.onIntentRejected, + onToolRejected: props.onToolRejected, onComponents: (components, sandboxId) => { const grantedComponents = components.filter((component) => grantedComponentNames.has(component.name)); for (const component of components) { @@ -245,23 +226,15 @@ export const SummonSurface = forwardRef } islands?.sync(grantedComponents, { sandboxId, - emitIntent: (intent, args = {}) => { - void policy.dispatch(intent, args); + callTool: (tool, args = {}) => { + void policy.dispatch(tool, args); }, }); }, }); handleRef.current = handle; - preflightComponentProps( - artifact.html, - props.componentRegistry, - handle.sandboxId, - events, - props.onComponentError, - ); - if (props.chrome) handle.setChrome(props.chrome); - if (lastRenderedHtmlRef.current !== null) { - handle.render(lastRenderedHtmlRef.current); + if (lastRenderedArtifactRef.current !== null) { + handle.renderArtifact(lastRenderedArtifactRef.current); } return () => { @@ -275,22 +248,22 @@ export const SummonSurface = forwardRef }, [ events, props.bootstrapSource, - props.capabilityRegistry, - props.chrome, + props.arrowRuntimeSource, + props.arrowNetworkPolicy, + props.toolRegistry, props.componentRegistry, props.envelope, - props.artifactIntents, - props.grantedIntents, - props.grantedCapabilities, + props.artifact, + props.artifactTools, + props.grantedTools, + props.validationTools, props.artifactComponents, - props.html, props.initialState, - props.onIntent, - props.onIntentRejected, + props.onToolCall, + props.onToolRejected, props.onFatal, props.onComponentError, props.onHandlerError, - props.protocolLines, props.tokensSource, ]); @@ -311,7 +284,7 @@ export interface ReactComponentDefinition export interface ReactComponentRuntimeContext { componentId: string; sandboxId: string; - emitIntent: (intent: string, args?: Record) => void; + callTool: (tool: string, args?: Record) => void; } export interface ReactComponentWithRuntimeDefinition @@ -327,13 +300,13 @@ export function defineReactComponent( const { component, mapProps, ...rest } = definition; return { ...rest, - render: ({ container, props, componentId, sandboxId, emitIntent }) => { + render: ({ container, props, componentId, sandboxId, callTool }) => { let root = roots.get(container); if (!root) { root = createRoot(container); roots.set(container, root); } - const runtimeContext = { componentId, sandboxId, emitIntent }; + const runtimeContext = { componentId, sandboxId, callTool }; const componentProps = mapProps ? mapProps(props as T, runtimeContext) : props as unknown as P; @@ -350,109 +323,33 @@ export function defineReactComponent( }; } -function resolveCompiledHtml(props: SummonSurfaceProps, context: ValidationContext): CompiledArtifactHtml { - if (props.envelope) return props.envelope.compiledHtml; - if (props.html !== undefined) return compileForRender(props.html, context); - if (!props.protocolLines) return compileForRender('', context); - const accumulator = new SectionAccumulator(); - for (const line of props.protocolLines) accumulator.apply(line); - return compileForRender(accumulator.compose(), context); -} - -function compileForRender(html: string, context: ValidationContext): CompiledArtifactHtml { - const result = compileArtifactHtml(html, context); - if (result.issues.some((issue) => issue.severity === 'block')) { - return '' as CompiledArtifactHtml; - } - return result.html; -} - -function compilePatchForRender( - patch: HtmlNodePatch, - context: ValidationContext, -): CompiledHtmlNodePatch | null { - const result = compileArtifactHtml(patch.html, { - ...context, - experimentalFragmentMode: 'html-node-v0', - }); - if (result.issues.some((issue) => issue.severity === 'block')) return null; - return { - sectionId: patch.sectionId, - nodeId: patch.nodeId, - ...(patch.parentId ? { parentId: patch.parentId } : {}), - html: result.html, - }; -} - -function preflightComponentProps( - html: CompiledArtifactHtml, - componentRegistry: ComponentRegistry | null | undefined, - sandboxId: string | undefined, - events: ReturnType, - onError: ((error: ComponentIslandError) => void) | undefined, -): void { - if (!componentRegistry || typeof DOMParser === 'undefined') return; - const doc = new DOMParser().parseFromString(`
${html}
`, 'text/html'); - const placeholders = doc.querySelectorAll('[data-summon-component]'); - for (const placeholder of placeholders) { - const componentName = placeholder.getAttribute('data-summon-component') ?? ''; - const componentId = placeholder.getAttribute('data-summon-component-id') ?? undefined; - const rawProps = placeholder.getAttribute('data-summon-props') ?? '{}'; - let props: unknown; - try { - props = JSON.parse(rawProps); - } catch { - emitComponentPreflightError({ - code: 'props-invalid', - componentId, - componentName, - sandboxId, - reason: `component "${componentName}" props are not valid JSON`, - }, events, onError); - continue; +function resolveArrowArtifact(props: SummonSurfaceProps): ArrowSurfaceArtifact | null { + if (props.artifact) return props.artifact; + const lines = props.envelope?.protocolLines; + if (!lines) return null; + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line || line.op !== 'artifact' || line.path !== '/artifact') continue; + const value = (line as ArtifactLine).value; + if (isArrowSurfaceArtifact(value)) { + return value; } - const parsed = componentRegistry.validateProps(componentName, props); - if (parsed.ok) continue; - emitComponentPreflightError({ - code: parsed.error?.startsWith('unknown component') ? 'unknown-component' : 'props-invalid', - componentId, - componentName, - sandboxId, - reason: parsed.error ?? 'component props failed validation', - }, events, onError); } -} - -function emitComponentPreflightError( - error: ComponentIslandError, - events: ReturnType, - onError: ((error: ComponentIslandError) => void) | undefined, -): void { - events.push({ - kind: 'component-error', - at: Date.now(), - code: error.code, - sandboxId: error.sandboxId, - componentId: error.componentId, - componentName: error.componentName, - reason: error.reason, - }); - onError?.(error); + return null; } function validationContextFromProps( props: SummonSurfaceProps, - grantedIntents: string[], - grantedCapabilities: Artifact['capabilities'], + grantedTools: string[], + validationTools: Artifact['validationTools'], components: Artifact['components'], ): ValidationContext { const surfacePlan = props.envelope?.surfacePlan; return { mode: props.envelope?.metadata.mode ?? - (surfacePlan?.runtime === 'static' || grantedIntents.length === 0 ? 'static' : 'interactive'), - scriptPolicy: 'forbid', - allowedIntents: props.envelope?.grants.intents ?? grantedIntents, - capabilities: props.envelope?.grants.capabilities ?? grantedCapabilities, + (grantedTools.length === 0 ? 'static' : 'interactive'), + allowedTools: props.envelope?.grants.tools ?? grantedTools, + tools: props.envelope?.grants.validationTools ?? validationTools, components, ...(surfacePlan ? { surfacePlan } : {}), }; @@ -461,9 +358,8 @@ function validationContextFromProps( function defaultValidationContext(): ValidationContext { return { mode: 'static', - scriptPolicy: 'forbid', - allowedIntents: [], - capabilities: [], + allowedTools: [], + tools: [], components: [], }; } diff --git a/packages/sandbox-runtime/package.json b/packages/sandbox-runtime/package.json index 8cbcad3..0e8d95e 100644 --- a/packages/sandbox-runtime/package.json +++ b/packages/sandbox-runtime/package.json @@ -10,6 +10,7 @@ "import": "./dist/assets.js" }, "./bootstrap.js": "./dist/bootstrap.js", + "./arrow-runtime.js": "./dist/arrow-runtime.js", "./tokens.css": "./dist/tokens.css", "./package.json": "./package.json" }, @@ -20,5 +21,11 @@ "build": "node scripts/build.mjs", "typecheck": "node scripts/build.mjs --check" }, - "private": true + "private": true, + "dependencies": { + "@arrow-js/sandbox": "1.0.6" + }, + "devDependencies": { + "esbuild": "0.27.7" + } } diff --git a/packages/sandbox-runtime/scripts/build.mjs b/packages/sandbox-runtime/scripts/build.mjs index 81ae879..24e9897 100644 --- a/packages/sandbox-runtime/scripts/build.mjs +++ b/packages/sandbox-runtime/scripts/build.mjs @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { build as esbuild } from 'esbuild'; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, '..'); @@ -9,14 +10,16 @@ const dist = join(root, 'dist'); const checkOnly = process.argv.includes('--check'); async function main() { - const [bootstrapSource, tokensSource] = await Promise.all([ + const [bootstrapSource, tokensSource, arrowRuntimeSource] = await Promise.all([ readFile(join(src, 'bootstrap.js'), 'utf8'), readFile(join(src, 'tokens.css'), 'utf8'), + bundleArrowRuntime(), ]); if (checkOnly) { if (!bootstrapSource.trim()) throw new Error('bootstrap.js is empty'); if (!tokensSource.trim()) throw new Error('tokens.css is empty'); + if (!arrowRuntimeSource.trim()) throw new Error('arrow runtime bundle is empty'); return; } @@ -24,11 +27,13 @@ async function main() { await Promise.all([ writeFile(join(dist, 'bootstrap.js'), bootstrapSource), writeFile(join(dist, 'tokens.css'), tokensSource), + writeFile(join(dist, 'arrow-runtime.js'), arrowRuntimeSource), writeFile( join(dist, 'assets.js'), [ `export const bootstrapSource = ${JSON.stringify(bootstrapSource)};`, `export const tokensSource = ${JSON.stringify(tokensSource)};`, + `export const arrowRuntimeSource = ${JSON.stringify(arrowRuntimeSource)};`, '//# sourceMappingURL=assets.js.map', '', ].join('\n'), @@ -38,6 +43,7 @@ async function main() { [ 'export declare const bootstrapSource: string;', 'export declare const tokensSource: string;', + 'export declare const arrowRuntimeSource: string;', '//# sourceMappingURL=assets.d.ts.map', '', ].join('\n'), @@ -47,8 +53,8 @@ async function main() { JSON.stringify({ version: 3, file: 'assets.js', - sources: ['../src/bootstrap.js', '../src/tokens.css'], - sourcesContent: [bootstrapSource, tokensSource], + sources: ['../src/bootstrap.js', '../src/tokens.css', '../src/arrow-runtime-entry.ts'], + sourcesContent: [bootstrapSource, tokensSource, arrowRuntimeSource], names: [], mappings: '', }), @@ -60,7 +66,7 @@ async function main() { file: 'assets.d.ts', sources: ['../src/assets.ts'], sourcesContent: [ - "export const bootstrapSource = '';\nexport const tokensSource = '';\n", + "export const bootstrapSource = '';\nexport const tokensSource = '';\nexport const arrowRuntimeSource = '';\n", ], names: [], mappings: '', @@ -69,6 +75,94 @@ async function main() { ]); } +async function bundleArrowRuntime() { + const wasmBinary = await readFile( + join( + root, + '..', + '..', + 'node_modules', + '@jitl', + 'quickjs-wasmfile-release-asyncify', + 'dist', + 'emscripten-module.wasm', + ), + ); + const wasmBase64 = wasmBinary.toString('base64'); + const releaseAsyncifyModulePath = join( + root, + '..', + '..', + 'node_modules', + '@jitl', + 'quickjs-wasmfile-release-asyncify', + 'dist', + 'emscripten-module.browser.mjs', + ); + + const result = await esbuild({ + entryPoints: [join(src, 'arrow-runtime-entry.ts')], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: ['es2022'], + sourcemap: false, + logLevel: 'silent', + define: { + 'process.env.NODE_ENV': '"production"', + }, + plugins: [inlineQuickJsWasmPlugin({ + wasmBase64, + releaseAsyncifyModulePath, + })], + }); + + const output = result.outputFiles[0]?.text; + if (!output) throw new Error('Arrow runtime bundle did not produce output'); + return output; +} + +function inlineQuickJsWasmPlugin({ wasmBase64, releaseAsyncifyModulePath }) { + return { + name: 'summon-inline-quickjs-wasm', + setup(build) { + build.onResolve( + { filter: /^@jitl\/quickjs-wasmfile-release-asyncify\/emscripten-module$/ }, + () => ({ path: 'summon-inline-quickjs-release-asyncify', namespace: 'summon-quickjs' }), + ); + build.onLoad( + { filter: /^summon-inline-quickjs-release-asyncify$/, namespace: 'summon-quickjs' }, + () => ({ + loader: 'js', + resolveDir: dirname(releaseAsyncifyModulePath), + contents: [ + 'import createModule from "./emscripten-module.browser.mjs";', + `const wasmBase64 = ${JSON.stringify(wasmBase64)};`, + 'let wasmBinary;', + 'function decodeWasm() {', + ' if (wasmBinary) return wasmBinary;', + ' const binary = atob(wasmBase64);', + ' const bytes = new Uint8Array(binary.length);', + ' for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);', + ' wasmBinary = bytes;', + ' return wasmBinary;', + '}', + 'export default function createInlineQuickJsModule(moduleArg = {}) {', + ' return createModule({', + ' ...moduleArg,', + ' wasmBinary: moduleArg.wasmBinary || decodeWasm(),', + ' locateFile: moduleArg.locateFile || (() => "summon-inline-quickjs.wasm"),', + ' });', + '}', + '', + ].join('\n'), + }), + ); + }, + }; +} + main().catch((err) => { console.error(err); process.exitCode = 1; diff --git a/packages/sandbox-runtime/src/arrow-runtime-entry.ts b/packages/sandbox-runtime/src/arrow-runtime-entry.ts new file mode 100644 index 0000000..bb65d04 --- /dev/null +++ b/packages/sandbox-runtime/src/arrow-runtime-entry.ts @@ -0,0 +1,9 @@ +import { sandbox } from '@arrow-js/sandbox'; + +const target = globalThis as typeof globalThis & { + __SUMMON_ARROW_SANDBOX__?: { + sandbox: typeof sandbox; + }; +}; + +target.__SUMMON_ARROW_SANDBOX__ = Object.freeze({ sandbox }); diff --git a/packages/sandbox-runtime/src/assets.ts b/packages/sandbox-runtime/src/assets.ts index f8b6df3..da6d3d0 100644 --- a/packages/sandbox-runtime/src/assets.ts +++ b/packages/sandbox-runtime/src/assets.ts @@ -6,3 +6,4 @@ */ export const bootstrapSource = ''; export const tokensSource = ''; +export const arrowRuntimeSource = ''; diff --git a/packages/sandbox-runtime/src/bootstrap.js b/packages/sandbox-runtime/src/bootstrap.js index 3cea847..c11f723 100644 --- a/packages/sandbox-runtime/src/bootstrap.js +++ b/packages/sandbox-runtime/src/bootstrap.js @@ -1,50 +1,25 @@ // Summon sandbox bootstrap — runs FIRST inside every sandbox iframe, before any -// compiled artifact HTML. Installs window.sandbox (frozen) as the trusted -// bridge for controlled test shells and legacy hosts. Generated artifacts do -// not receive executable scripts. +// Arrow artifact runtime. The generated artifact talks to the host only through +// the `host-bridge:summon` virtual module supplied by the Arrow runtime. (() => { 'use strict'; const PARENT = window.parent; const SANDBOX_ID = window.__SUMMON_SANDBOX_ID__; - const RESOURCE_MAP = normalizeResources(window.__SUMMON_RESOURCES__); + const NETWORK_POLICY = window.__SUMMON_NETWORK_POLICY__ === 'restricted-fetch' + ? 'restricted-fetch' + : 'none'; if (!SANDBOX_ID || typeof SANDBOX_ID !== 'string') { - // No ID means host didn't spawn this correctly. Refuse to install SDK. + // No ID means host didn't spawn this correctly. Refuse to boot. return; } // Scrub globals so artifact code can't read or overwrite them after bootstrap. try { delete window.__SUMMON_SANDBOX_ID__; - delete window.__SUMMON_RESOURCES__; + delete window.__SUMMON_NETWORK_POLICY__; } catch (_) { /* sealed elsewhere */ } - function normalizeResources(raw) { - const out = Object.create(null); - if (!raw || typeof raw !== 'object') return out; - for (const name in raw) { - if (!Object.prototype.hasOwnProperty.call(raw, name)) continue; - if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(name)) continue; - const entry = raw[name]; - const keys = entry && typeof entry === 'object' ? entry.stateKeys : null; - if (!keys || typeof keys !== 'object') continue; - const loading = keys.loading; - const data = keys.data; - const error = keys.error; - const empty = keys.empty; - if (typeof loading !== 'string' || typeof data !== 'string' || typeof error !== 'string') continue; - out[name] = Object.freeze({ - stateKeys: Object.freeze({ - loading, - data, - error, - ...(typeof empty === 'string' ? { empty } : {}), - }), - }); - } - return Object.freeze(out); - } - // ----- startup self-test -------------------------------------------------- // Fail closed if the sandbox is not configured the way Summon requires. Any // result here is something a casual misconfiguration (e.g. accidentally @@ -93,16 +68,26 @@ // ------------------------------------------------------------------------- let currentState = Object.freeze({}); - const localState = Object.create(null); const subscribers = new Set(); - const mountedIntentKeys = new Set(); let componentSyncScheduled = false; let componentSyncFallbackTimer = 0; let componentLayoutPollTimer = 0; let componentLayoutSignature = ''; let componentResizeObserver = null; const componentResizeObserved = new Set(); - const SAFE_ATTR_BINDINGS = Object.freeze(['src', 'alt', 'title', 'aria-label', 'value', 'placeholder', 'disabled']); + const MAX_PENDING_TOOL_RESULTS = 32; + const pendingToolResults = new Map(); + let arrowTeardown = null; + let renderRevision = 0; + + function cloneStateSnapshot(value) { + if (!value || typeof value !== 'object') return Object.freeze({}); + try { + return Object.freeze(JSON.parse(JSON.stringify(value))); + } catch (_) { + return Object.freeze({}); + } + } function notify() { const snapshot = currentState; @@ -111,402 +96,173 @@ } } - function emit(intent, args) { - if (typeof intent !== 'string' || !intent) return; - PARENT.postMessage({ - type: 'SUMMON_INTENT', - sandbox_id: SANDBOX_ID, - intent, - args: args == null ? {} : args, - }, '*'); - } - - function onState(cb) { - if (typeof cb !== 'function') return () => {}; - subscribers.add(cb); - // Fire immediately with current snapshot for convenience. - try { cb(currentState); } catch (_) { /* swallow */ } - return () => subscribers.delete(cb); - } - - // ── Declarative bindings ─────────────────────────────────────────────────── - // The LLM authors HTML with `data-summon-*` attributes; this binder is what - // makes them live. Two halves: - // - // 1. Listeners for `data-summon-on-click` and `data-summon-on-submit` — - // installed ONCE on document, dispatched via `closest(...)` so re-renders - // don't re-bind. Always preventDefault(). - // `data-summon-on-mount` is handled after render by applyMountIntents(). - // 2. State-driven attributes — `data-summon-bind` (textContent), - // `data-summon-show`/`data-summon-hide` (visibility) — recomputed from - // currentState after every state push and every render. They're a pure - // function of (DOM, state); recomputing is idempotent and the cheapest - // possible model. - // - // Generated scripts are not an artifact capability. The `window.sandbox` - // object remains a narrow trusted bridge for host-owned tests and legacy - // shells, but generated UI should express local behavior with attributes. - - function walkPath(obj, path) { - if (!path) return obj; - let cur = obj; - const parts = path.split('.'); - for (let i = 0; i < parts.length; i++) { - if (cur == null) return undefined; - cur = cur[parts[i]]; - } - return cur; + function emitFatal(reason) { + try { + PARENT.postMessage({ + type: 'SUMMON_FATAL', + sandbox_id: SANDBOX_ID, + reason, + }, '*'); + } catch (_) { /* parent gone */ } } - function hasPath(obj, path) { - if (!path) return true; - let cur = obj; - const parts = path.split('.'); - for (let i = 0; i < parts.length; i++) { - if (cur == null || !Object.prototype.hasOwnProperty.call(Object(cur), parts[i])) return false; - cur = cur[parts[i]]; + function callToolInternal(tool, args) { + if (typeof tool !== 'string' || !tool) { + return Promise.resolve({ ok: false, state: cloneStateSnapshot(currentState), error: 'tool not a non-empty string' }); } - return true; - } - - // Walk up to find the nearest ancestor (inclusive) with a matching foreach - // scope name. Scope markers are JS properties on stamped clones — invisible - // to the LLM-authored markup. - function findScope(name, fromEl) { - let el = fromEl; - while (el) { - if (el.__summon_scope === name) return el; - el = el.parentElement; + if (pendingToolResults.size >= MAX_PENDING_TOOL_RESULTS) { + return Promise.resolve({ ok: false, state: cloneStateSnapshot(currentState), error: 'too many pending tools' }); } - return null; + const requestId = 'arrow-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); + return new Promise((resolve) => { + const timeout = window.setTimeout(function () { + pendingToolResults.delete(requestId); + resolve({ ok: false, state: cloneStateSnapshot(currentState), error: 'tool timed out' }); + }, 15000); + pendingToolResults.set(requestId, function (result) { + window.clearTimeout(timeout); + resolve(result); + }); + PARENT.postMessage({ + type: 'SUMMON_TOOL_CALL', + sandbox_id: SANDBOX_ID, + request_id: requestId, + tool, + args: args == null || typeof args !== 'object' ? {} : args, + }, '*'); + }); } - function findResourceScope(name, fromEl) { - let el = fromEl; - while (el) { - const resource = el.__summon_resource; - if (resource && resource.alias === name) return resource; - el = el.parentElement; + function renderArrowArtifact(artifact) { + const root = document.getElementById('summon-root'); + if (!root) return; + if (!artifact || artifact.runtime !== 'arrow' || !artifact.source || typeof artifact.source !== 'object') { + emitFatal('invalid Arrow artifact'); + return; } - return null; - } - - function resourceStateValue(resource, rest) { - const keys = resource && resource.stateKeys; - if (!keys) return undefined; - const loading = walkPath(currentState, keys.loading); - const data = walkPath(currentState, keys.data); - const error = walkPath(currentState, keys.error); - const empty = keys.empty ? walkPath(currentState, keys.empty) : undefined; - if (!rest) return { loading, data, error, empty }; - const dot = rest.indexOf('.'); - const head = dot === -1 ? rest : rest.slice(0, dot); - const tail = dot === -1 ? '' : rest.slice(dot + 1); - let base; - if (head === 'loading') base = loading; - else if (head === 'data') base = data; - else if (head === 'error') base = error; - else if (head === 'empty') base = empty; - else return undefined; - return tail ? walkPath(base, tail) : base; - } - - // Resolve a path against either currentState or a foreach scope. - // Bare `key` / `nested.key` → root state. - // `$name` → the entire item from foreach scope `name`. - // `$name.field.sub` → field walk inside that item. - // fromEl is the binder/click element — used as the starting point for - // scope lookup. Falls back to root state if no scope matches. - function resolveKey(path, fromEl) { - if (!path) return undefined; - if (path.charCodeAt(0) === 0x24 /* $ */) { - const dot = path.indexOf('.'); - const name = dot === -1 ? path.slice(1) : path.slice(1, dot); - const rest = dot === -1 ? '' : path.slice(dot + 1); - const scopeEl = findScope(name, fromEl); - if (scopeEl) { - const item = scopeEl.__summon_item; - return rest ? walkPath(item, rest) : item; - } - const resource = findResourceScope(name, fromEl); - return resourceStateValue(resource, rest); + if (artifact.network === 'restricted-fetch' && NETWORK_POLICY !== 'restricted-fetch') { + emitFatal('Arrow artifact requested restricted fetch without host network grant'); + return; } - if (hasPath(localState, path)) return walkPath(localState, path); - return walkPath(currentState, path); - } - - function truthy(v) { - if (v == null || v === false || v === 0 || v === '') return false; - if (Array.isArray(v)) return v.length > 0; - if (typeof v === 'object') return Object.keys(v).length > 0; - return true; - } - - // Recursively replace string leaves matching `^\$\w+(\..+)?$` with their - // resolved value. Used at click/submit time to bake the foreach item into - // the args payload — `{"picked": "$r"}` becomes `{"picked": }`. - function interpolate(value, fromEl) { - if (typeof value === 'string') { - if (value.length > 1 && value.charCodeAt(0) === 0x24 && /^\$[A-Za-z_]\w*(\..+)?$/.test(value)) { - return resolveKey(value, fromEl); - } - return value; + if (typeof arrowTeardown === 'function') { + try { arrowTeardown(); } catch (_) { /* best effort */ } + arrowTeardown = null; } - if (Array.isArray(value)) return value.map((v) => interpolate(v, fromEl)); - if (value && typeof value === 'object') { - const out = {}; - for (const k in value) { - if (Object.prototype.hasOwnProperty.call(value, k)) out[k] = interpolate(value[k], fromEl); - } - return out; + const revision = ++renderRevision; + root.replaceChildren(); + const runtime = window.__SUMMON_ARROW_SANDBOX__; + const sandbox = runtime && runtime.sandbox; + if (typeof sandbox !== 'function') { + emitFatal('Arrow runtime missing — expected window.__SUMMON_ARROW_SANDBOX__.sandbox'); + return; } - return value; - } - - function seedLocalState(root) { - const hosts = root.querySelectorAll('[data-summon-local]'); - for (const host of hosts) { - const raw = host.getAttribute('data-summon-local') || ''; - if (!raw.trim()) continue; - let parsed; - try { parsed = JSON.parse(raw); } - catch (_) { continue; } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; - for (const key in parsed) { - if (!Object.prototype.hasOwnProperty.call(parsed, key)) continue; - if (!/^[A-Za-z_$][\w$]{0,39}$/.test(key)) continue; - if (!Object.prototype.hasOwnProperty.call(localState, key)) { - localState[key] = parsed[key]; - } - } + try { + const view = sandbox( + { + source: artifact.source, + shadowDOM: false, + onError: function (error) { + emitFatal('Arrow runtime error: ' + String(error && error.message ? error.message : error)); + }, + }, + { + output: function (payload) { + if (payload && typeof payload === 'object' && payload.type === 'tool') { + void callToolInternal(payload.tool, payload.args); + } + }, + }, + { + 'host-bridge:summon': { + getState: function () { + return cloneStateSnapshot(currentState); + }, + onState: function (cb) { + return onState(cb); + }, + callTool: function (tool, args) { + return callToolInternal(tool, args); + }, + }, + }, + ); + const maybeTeardown = view(root); + if (typeof maybeTeardown === 'function') arrowTeardown = maybeTeardown; + waitForArrowRuntimeReady(root, revision); + } catch (err) { + emitFatal('Arrow runtime failed to mount: ' + String(err && err.message ? err.message : err)); } } - function parseConditionLiteral(raw) { - const trimmed = String(raw || '').trim(); - if (trimmed.length >= 2 && trimmed[0] === '"' && trimmed[trimmed.length - 1] === '"') { - try { return JSON.parse(trimmed); } catch (_) { return trimmed.slice(1, -1); } - } - if (trimmed.length >= 2 && trimmed[0] === "'" && trimmed[trimmed.length - 1] === "'") { - return trimmed.slice(1, -1); - } - return trimmed; + function emitRendered(revision) { + if (revision !== renderRevision) return; + try { + PARENT.postMessage({ type: 'SUMMON_RENDERED', sandbox_id: SANDBOX_ID, revision }, '*'); + } catch (_) { /* parent gone */ } } - function evalCondition(expr, fromEl) { - const raw = String(expr || '').trim(); - if (!raw) return false; - const match = raw.match(/^(!)?(\$?[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s*(==|!=)\s*("[^"]*"|'[^']*'))?$/); - if (!match) return truthy(resolveKey(raw, fromEl)); - const negated = !!match[1]; - const path = match[2]; - const op = match[3]; - const literal = match[4]; - const value = resolveKey(path, fromEl); - let result; - if (op) { - const expected = parseConditionLiteral(literal); - result = String(value ?? '') === String(expected); - if (op === '!=') result = !result; + function finishArrowRender(revision) { + if (revision !== renderRevision) return; + scheduleComponentSync(); + const done = function () { + if (revision !== renderRevision) return; + scheduleComponentSync(); + emitRendered(revision); + }; + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(done); } else { - result = truthy(value); + setTimeout(done, 0); } - return negated ? !result : result; } - function applyResourceScopes(root) { - const hosts = root.querySelectorAll('[data-summon-resource]'); - for (const host of hosts) { - const name = host.getAttribute('data-summon-resource') || ''; - const entry = RESOURCE_MAP[name]; - if (!entry) { - try { delete host.__summon_resource; } catch (_) { host.__summon_resource = undefined; } - continue; - } - const alias = host.getAttribute('data-summon-resource-as') || name; - host.__summon_resource = { - name, - alias, - stateKeys: entry.stateKeys, - }; - } - } - - // Stamp `