From c4b976de4f8521a3c91a1aab2e03cbf4009a98db Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Tue, 16 Jun 2026 13:20:30 -0400 Subject: [PATCH 1/6] Add Arrow sandbox artifact runtime --- apps/demo/src/pages/generate/constants.ts | 3 +- .../pages/generate/hooks/useSurfaceStream.ts | 14 ++ apps/demo/src/showcase.ts | 227 ++++++++++-------- apps/server/src/generate-route.test.ts | 1 + apps/server/src/surface-plan.test.ts | 23 +- packages/devtools/src/types.ts | 2 +- packages/engine/src/arrow-artifact.ts | 128 ++++++++++ packages/engine/src/contracts.ts | 6 + packages/engine/src/index.ts | 14 ++ packages/engine/src/prompt.ts | 28 +++ packages/engine/src/protocol-hardener.ts | 9 + packages/engine/src/protocol.ts | 18 +- .../engine/src/runtime-validator/protocol.ts | 13 + packages/engine/src/surface-plan.ts | 38 ++- packages/engine/src/surface-policy.ts | 3 + packages/engine/test/contracts.test.ts | 28 ++- .../test/runtime-validator-protocol.test.ts | 66 +++++ packages/engine/test/surface-policy.test.ts | 4 + packages/host/src/browser.ts | 1 + packages/host/src/index.ts | 2 + packages/host/src/policy-engine.ts | 17 +- packages/host/src/policy.ts | 1 + packages/host/src/sandbox-spawner.ts | 82 ++++++- packages/host/src/surface-stream.ts | 28 ++- packages/host/src/types.ts | 24 +- packages/host/test/surface-stream.test.ts | 26 ++ packages/react/src/index.ts | 60 ++++- packages/sandbox-runtime/package.json | 9 +- packages/sandbox-runtime/scripts/build.mjs | 102 +++++++- .../src/arrow-runtime-entry.ts | 9 + packages/sandbox-runtime/src/assets.ts | 1 + packages/sandbox-runtime/src/bootstrap.js | 114 +++++++++ packages/server/src/plan.ts | 6 +- packages/server/test/agent-broker.test.ts | 30 +-- .../test/generate-surface-stream.test.ts | 18 +- packages/summon/package.json | 1 + packages/summon/src/assets.ts | 1 + packages/summon/src/browser.ts | 1 + packages/summon/src/policy.ts | 1 + pnpm-lock.yaml | 95 +++++++- scripts/build-public-packages.mjs | 139 ++++++----- scripts/check-public-api.mjs | 11 +- scripts/public-api-manifest.json | 30 ++- tests/safety-smoke.spec.ts | 17 +- 44 files changed, 1200 insertions(+), 251 deletions(-) create mode 100644 packages/engine/src/arrow-artifact.ts create mode 100644 packages/sandbox-runtime/src/arrow-runtime-entry.ts diff --git a/apps/demo/src/pages/generate/constants.ts b/apps/demo/src/pages/generate/constants.ts index 13392b3..921d0cd 100644 --- a/apps/demo/src/pages/generate/constants.ts +++ b/apps/demo/src/pages/generate/constants.ts @@ -40,10 +40,11 @@ export const layoutPresets = new Map([ ]); export const demoSurfaceCeiling: SurfaceCeiling = { - runtimes: ['static', 'declarative', 'worker'], + runtimes: ['arrow'], data: ['embedded', 'host-resource', 'worker'], authorities: ['none', 'read', 'host-action', 'approval-gated'], persistences: ['replayable'], + networks: ['none'], }; export const scenarioCategoryOrder = [ diff --git a/apps/demo/src/pages/generate/hooks/useSurfaceStream.ts b/apps/demo/src/pages/generate/hooks/useSurfaceStream.ts index 5abab85..c0e2e04 100644 --- a/apps/demo/src/pages/generate/hooks/useSurfaceStream.ts +++ b/apps/demo/src/pages/generate/hooks/useSurfaceStream.ts @@ -2,6 +2,7 @@ import { useCallback, type MutableRefObject } from 'react'; import { consumeSurfaceStream, type SurfaceStreamContext } from '@anarchitecture/summon/browser'; import { normalizeSurfacePlan, + type ArrowSurfaceArtifact, type ProtocolLine, type SectionAccumulator, type SurfaceContractView, @@ -234,6 +235,16 @@ export function useSurfaceStream({ logLine('op-meta', `meta ${line.path} = ${JSON.stringify(line.value)}`); return; } + if (line.op === 'artifact') { + const artifact = line.value as ArrowSurfaceArtifact | undefined; + const files = artifact && artifact.runtime === 'arrow' + ? Object.keys(artifact.source).join(', ') + : 'invalid'; + logLine('op-add', `artifact ${line.path} -> ${files}`); + artifactRevisionRef.current += 1; + setArtifactRevision(artifactRevisionRef.current); + return; + } if (line.op === 'set') { const changed = context.applyResult?.changed ?? false; logLine('op-set', `set ${line.path} = ${JSON.stringify(line.value)}`); @@ -365,6 +376,9 @@ export function useSurfaceStream({ if (line.path === '/shape' && typeof line.value === 'string') shapeFromStream = line.value; applyLineTo(line, context); }, + onArtifact: (artifact, line, context) => { + surfaceRef.current?.renderArtifact(artifact); + }, onParseError: (raw) => { appendDevEvent({ kind: 'protocol-parse-error', at: Date.now(), raw }); logLine('raw', `. ${raw.slice(0, 120)}`); diff --git a/apps/demo/src/showcase.ts b/apps/demo/src/showcase.ts index c190b32..90d08a5 100644 --- a/apps/demo/src/showcase.ts +++ b/apps/demo/src/showcase.ts @@ -66,14 +66,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['search'], surfacePolicy: { tier: 'declarative', purpose: 'explore', grants: ['search'] }, - surfacePlan: { - purpose: 'explore', - runtime: 'declarative', - data: 'host-resource', - authority: 'read', - persistence: 'replayable', + surfacePlan: { + purpose: 'explore', + runtime: 'declarative', + data: 'host-resource', + authority: 'read', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'host-ai-brainstorm', label: 'Host AI brainstorm', @@ -82,14 +83,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['ai'], surfacePolicy: { tier: 'declarative', purpose: 'explore', grants: ['ai'] }, - surfacePlan: { - purpose: 'explore', - runtime: 'declarative', - data: 'host-resource', - authority: 'read', - persistence: 'replayable', + surfacePlan: { + purpose: 'explore', + runtime: 'declarative', + data: 'host-resource', + authority: 'read', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'github-profile-lookup', label: 'GitHub profile lookup', @@ -98,14 +100,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['github_lookup'], surfacePolicy: { tier: 'declarative', purpose: 'explore', grants: ['github_lookup'] }, - surfacePlan: { - purpose: 'explore', - runtime: 'declarative', - data: 'host-resource', - authority: 'read', - persistence: 'replayable', + surfacePlan: { + purpose: 'explore', + runtime: 'declarative', + data: 'host-resource', + authority: 'read', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'component-islands', label: 'Trusted Components', @@ -120,14 +123,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ grants: ['choose'], components: ['MetricCard', 'TrendSparkline', 'ApprovalStatus'], }, - surfacePlan: { - purpose: 'review', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', + surfacePlan: { + purpose: 'review', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'static-summary', label: 'Static summary', @@ -135,14 +139,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'static', capabilityNames: [], surfacePolicy: { tier: 'static', purpose: 'compare' }, - surfacePlan: { - purpose: 'compare', - runtime: 'static', - data: 'embedded', - authority: 'none', - persistence: 'replayable', + surfacePlan: { + purpose: 'compare', + runtime: 'static', + data: 'embedded', + authority: 'none', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'decision-picker', label: 'Decision Picker', @@ -151,14 +156,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['choose'], surfacePolicy: { tier: 'declarative', purpose: 'compare', grants: ['choose'] }, - surfacePlan: { - purpose: 'compare', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', + surfacePlan: { + purpose: 'compare', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'declarative-form', label: 'Declarative form', @@ -167,14 +173,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['submit'], surfacePolicy: { tier: 'declarative', purpose: 'collect', grants: ['submit'] }, - surfacePlan: { - purpose: 'collect', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', + surfacePlan: { + purpose: 'collect', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'worker-analysis', label: 'Worker Analysis', @@ -183,14 +190,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['analysis', 'compute_score'], surfacePolicy: { tier: 'worker', purpose: 'review', grants: ['analysis', 'compute_score'] }, - surfacePlan: { - purpose: 'review', - runtime: 'worker', - data: 'worker', - authority: 'host-action', - persistence: 'replayable', + surfacePlan: { + purpose: 'review', + runtime: 'worker', + data: 'worker', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'approval-publish', label: 'Approval Publish', @@ -199,14 +207,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['publish_summary'], surfacePolicy: { tier: 'approval', purpose: 'operate', grants: ['publish_summary'] }, - surfacePlan: { - purpose: 'operate', - runtime: 'declarative', - data: 'embedded', - authority: 'approval-gated', - persistence: 'replayable', + surfacePlan: { + purpose: 'operate', + runtime: 'declarative', + data: 'embedded', + authority: 'approval-gated', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'local-state-motion', label: 'Local state + motion', @@ -215,14 +224,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['choose', 'counter'], surfacePolicy: { tier: 'declarative', purpose: 'explore', grants: ['choose', 'counter'] }, - surfacePlan: { - purpose: 'explore', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', + surfacePlan: { + purpose: 'explore', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'token-override', label: 'Token override', @@ -235,14 +245,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ 'color-accent': '#0f8cff', 'color-accent-fg': '#ffffff', }, - surfacePlan: { - purpose: 'explore', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', - }, - directionId: 'pulse', + surfacePlan: { + purpose: 'explore', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, + directionId: 'pulse', }, { id: 'layout-card', @@ -253,14 +264,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ capabilityNames: ['submit'], surfacePolicy: { tier: 'declarative', purpose: 'collect', grants: ['submit'] }, layoutId: 'card-structured', - surfacePlan: { - purpose: 'collect', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', + surfacePlan: { + purpose: 'collect', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'sibling-summon', label: 'Sibling summon', @@ -269,14 +281,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ mode: 'interactive', capabilityNames: ['search', 'summon'], surfacePolicy: { tier: 'declarative', purpose: 'explore', grants: ['search', 'summon'] }, - surfacePlan: { - purpose: 'explore', - runtime: 'declarative', - data: 'host-resource', - authority: 'host-action', - persistence: 'replayable', + surfacePlan: { + purpose: 'explore', + runtime: 'declarative', + data: 'host-resource', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, }, - }, { id: 'repair-diagnostics', label: 'Validation Retry Diagnostics', @@ -286,14 +299,15 @@ export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [ capabilityNames: ['submit'], surfacePolicy: { tier: 'declarative', purpose: 'collect', grants: ['submit'] }, repair: { enabled: true, maxAttempts: 1, maxTargets: 2 }, - surfacePlan: { - purpose: 'collect', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', + surfacePlan: { + purpose: 'collect', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, }, - }, ]; export function createGhostShowcaseScenario(rootId: string): ShowcaseScenario { @@ -305,13 +319,14 @@ export function createGhostShowcaseScenario(rootId: string): ShowcaseScenario { mode: 'interactive', capabilityNames: ['choose'], surfacePolicy: { tier: 'declarative', purpose: 'review', grants: ['choose'] }, - surfacePlan: { - purpose: 'review', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', - }, + surfacePlan: { + purpose: 'review', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }, directionId: `ghost:${rootId}`, }; } diff --git a/apps/server/src/generate-route.test.ts b/apps/server/src/generate-route.test.ts index dddfc9e..e8b220d 100644 --- a/apps/server/src/generate-route.test.ts +++ b/apps/server/src/generate-route.test.ts @@ -38,6 +38,7 @@ const surfacePlan: SurfacePlan = { data: 'host-resource', authority: 'read', persistence: 'replayable', + network: 'none', }; const surfaceCeiling: SurfaceCeiling = { diff --git a/apps/server/src/surface-plan.test.ts b/apps/server/src/surface-plan.test.ts index e3bf4a6..5af26b4 100644 --- a/apps/server/src/surface-plan.test.ts +++ b/apps/server/src/surface-plan.test.ts @@ -61,6 +61,7 @@ test('explicit surface plan is honored when within ceiling', () => { data: 'worker', authority: 'approval-gated', persistence: 'replayable', + network: 'none', }); }); @@ -77,14 +78,15 @@ test('missing surface plan returns inert interactive default despite capable gra assert.equal(resolved.scriptPolicy, 'forbid'); assert.deepEqual(resolved.surfacePlan, { purpose: 'inform', - runtime: 'declarative', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }); }); -test('static mode stays static while preserving compatible surface metadata', () => { +test('static mode defaults to inert Arrow surface metadata', () => { const resolved = resolveSurfaceGenerationPlan({ prompt: 'search for recipes', mode: 'static', @@ -93,14 +95,15 @@ test('static mode stays static while preserving compatible surface metadata', () assert.equal(resolved.explicitAccepted, false); assert.equal(resolved.source, 'default'); - assert.equal(resolved.mode, 'static'); + assert.equal(resolved.mode, 'interactive'); assert.equal(resolved.scriptPolicy, 'forbid'); assert.deepEqual(resolved.surfacePlan, { purpose: 'inform', - runtime: 'static', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }); }); @@ -154,10 +157,11 @@ test('parsed worker capability resolves to worker surface when explicitly reques data: 'worker', authority: 'host-action', persistence: 'replayable', + network: 'none', }); }); -test('legacy scripted surface plan falls back to declarative defaults', () => { +test('legacy scripted surface plan falls back to ceiling-constrained defaults', () => { const resolved = resolveSurfaceGenerationPlan({ prompt: 'build keyboard shortcuts with local highlighted selection', mode: 'interactive', @@ -182,14 +186,15 @@ test('legacy scripted surface plan falls back to declarative defaults', () => { assert.equal(resolved.scriptPolicy, 'forbid'); assert.deepEqual(resolved.surfacePlan, { purpose: 'inform', - runtime: 'declarative', + runtime: 'static', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }); }); -test('legacy script allow falls back to forbid when plan is invalid', () => { +test('legacy script allow falls back to forbid and ceiling-constrained defaults', () => { const resolved = resolveSurfaceGenerationPlan({ prompt: 'build keyboard shortcuts with local highlighted selection', mode: 'interactive', @@ -214,10 +219,11 @@ test('legacy script allow falls back to forbid when plan is invalid', () => { assert.equal(resolved.scriptPolicy, 'forbid'); assert.deepEqual(resolved.surfacePlan, { purpose: 'inform', - runtime: 'declarative', + runtime: 'static', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }); }); @@ -263,5 +269,6 @@ test('parsed approval capability resolves to approval-gated authority', () => { data: 'embedded', authority: 'approval-gated', persistence: 'replayable', + network: 'none', }); }); diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts index 30fe8fc..5dd576c 100644 --- a/packages/devtools/src/types.ts +++ b/packages/devtools/src/types.ts @@ -91,7 +91,7 @@ export interface StatePushedEvent extends BaseEvent { /** A streaming protocol line was successfully parsed. */ export interface ProtocolLineEvent extends BaseEvent { kind: 'protocol-line'; - line: { op: 'add' | 'set' | 'meta'; path: string; html?: string; value?: unknown }; + line: { op: 'add' | 'set' | 'meta' | 'artifact'; path: string; html?: string; value?: unknown }; } /** A line in the LLM stream did not parse as a protocol line. */ diff --git a/packages/engine/src/arrow-artifact.ts b/packages/engine/src/arrow-artifact.ts new file mode 100644 index 0000000..f5b7238 --- /dev/null +++ b/packages/engine/src/arrow-artifact.ts @@ -0,0 +1,128 @@ +import type { ContractIssue } from './contracts.js'; +import { contractIssue } from './contracts.js'; + +export type ArrowNetworkPolicy = 'none' | 'restricted-fetch'; + +export interface ArrowSurfaceArtifact { + runtime: 'arrow'; + source: Record; + network?: ArrowNetworkPolicy; +} + +export interface ArrowArtifactValidationOptions { + maxSourceBytes?: number; + network?: ArrowNetworkPolicy; +} + +const DEFAULT_MAX_SOURCE_BYTES = 256 * 1024; +const ENTRY_FILES = new Set(['main.ts', 'main.js']); +const OPTIONAL_FILES = new Set(['main.css']); +const MODULE_RE = /^[A-Za-z0-9_./-]+\.(?:ts|js|mjs|css)$/; + +export function isArrowSurfaceArtifact(value: unknown): value is ArrowSurfaceArtifact { + return normalizeArrowSurfaceArtifact(value).artifact !== null; +} + +export function normalizeArrowSurfaceArtifact(value: unknown): { + artifact: ArrowSurfaceArtifact | null; + issues: ContractIssue[]; +} { + const issues: ContractIssue[] = []; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { + artifact: null, + issues: [arrowIssue('invalid-arrow-artifact', 'Arrow artifact must be an object')], + }; + } + + const input = value as Record; + if (input.runtime !== 'arrow') { + issues.push(arrowIssue('invalid-arrow-runtime', 'Arrow artifact runtime must be "arrow"')); + } + if (!input.source || typeof input.source !== 'object' || Array.isArray(input.source)) { + issues.push(arrowIssue('invalid-arrow-source', 'Arrow artifact source must be a file map')); + return { artifact: null, issues }; + } + + const source: Record = {}; + for (const [rawPath, rawContents] of Object.entries(input.source as Record)) { + const path = normalizeArrowSourcePath(rawPath); + if (!path) { + issues.push(arrowIssue('invalid-arrow-source-path', `Invalid Arrow source path "${rawPath}"`)); + continue; + } + if (typeof rawContents !== 'string') { + issues.push(arrowIssue('invalid-arrow-source-file', `Arrow source file "${rawPath}" must be a string`)); + continue; + } + source[path] = rawContents; + } + + const entries = Object.keys(source).filter((path) => ENTRY_FILES.has(path)); + if (entries.length !== 1) { + issues.push(arrowIssue('invalid-arrow-entry', 'Arrow artifact must include exactly one main.ts or main.js entry file')); + } + if (Object.keys(source).length === 0) { + issues.push(arrowIssue('invalid-arrow-source', 'Arrow artifact source cannot be empty')); + } + + const network = input.network === 'restricted-fetch' || input.network === 'none' + ? input.network + : undefined; + if (input.network !== undefined && !network) { + issues.push(arrowIssue('invalid-arrow-network', 'Arrow artifact network must be "none" or "restricted-fetch"')); + } + + if (issues.some((issue) => issue.severity === 'block')) { + return { artifact: null, issues }; + } + return { + artifact: { + runtime: 'arrow', + source, + ...(network ? { network } : {}), + }, + issues, + }; +} + +export function validateArrowSurfaceArtifact( + artifact: ArrowSurfaceArtifact, + options: ArrowArtifactValidationOptions = {}, +): ContractIssue[] { + const { artifact: normalized, issues } = normalizeArrowSurfaceArtifact(artifact); + if (!normalized) return issues; + const maxSourceBytes = options.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES; + const sourceBytes = byteLength(JSON.stringify(normalized.source)); + if (sourceBytes > maxSourceBytes) { + issues.push(arrowIssue('arrow-source-limit', `Arrow source exceeds ${maxSourceBytes} bytes`)); + } + if (options.network === 'none' && normalized.network === 'restricted-fetch') { + issues.push(arrowIssue('arrow-network-not-granted', 'Arrow artifact requested restricted fetch without a host network grant')); + } + return issues; +} + +export function normalizeArrowSourcePath(path: string): string | null { + const normalized = path.replace(/^\/+/, ''); + if (!normalized || normalized.includes('..') || normalized.startsWith('.') || normalized.includes('\\')) { + return null; + } + if (!MODULE_RE.test(normalized)) return null; + if (normalized.endsWith('.css') && !OPTIONAL_FILES.has(normalized)) return null; + return normalized; +} + +function arrowIssue(code: string, message: string): ContractIssue { + return contractIssue({ + source: 'protocol', + severity: 'block', + code, + message, + path: '/artifact', + }); +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length; +} diff --git a/packages/engine/src/contracts.ts b/packages/engine/src/contracts.ts index 87fa5d6..b06db3b 100644 --- a/packages/engine/src/contracts.ts +++ b/packages/engine/src/contracts.ts @@ -1,6 +1,7 @@ import type { ProtocolLine } from './protocol.js'; import { SUMMON_FIXED_INSTRUCTIONS, + SUMMON_ARROW_ARTIFACT_INSTRUCTIONS, buildCapabilitiesBlock, buildComponentsBlock, buildDirectionBlock, @@ -356,6 +357,11 @@ export function compileSystemContracts( text: SUMMON_FIXED_INSTRUCTIONS, cache: 'ephemeral', }, + { + id: 'arrow-artifact-runtime', + text: SUMMON_ARROW_ARTIFACT_INSTRUCTIONS, + cache: 'ephemeral', + }, ]; const issues: ContractIssue[] = []; const startupLines: ProtocolLine[] = []; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6abb16e..e3f17fc 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -14,12 +14,23 @@ export type { AddLine, SetLine, MetaLine, + ArtifactLine, BlockTarget, HtmlNodePatch, HtmlNodeTarget, ProtocolParseErrorCode, ProtocolParseOptions, } from './protocol.js'; +export { + isArrowSurfaceArtifact, + normalizeArrowSurfaceArtifact, + validateArrowSurfaceArtifact, +} from './arrow-artifact.js'; +export type { + ArrowNetworkPolicy, + ArrowSurfaceArtifact, + ArrowArtifactValidationOptions, +} from './arrow-artifact.js'; export { DEFAULT_VALIDATION_LIMITS, normalizeValidationLimits, @@ -44,6 +55,7 @@ export type { export { SUMMON_SYSTEM_PROMPT, SUMMON_FIXED_INSTRUCTIONS, + SUMMON_ARROW_ARTIFACT_INSTRUCTIONS, buildDirectionBlock, buildLayoutBlock, buildCapabilitiesBlock, @@ -163,6 +175,7 @@ export { SURFACE_PERSISTENCE_VALUES, SURFACE_PURPOSE_VALUES, SURFACE_RUNTIME_VALUES, + SURFACE_NETWORK_VALUES, buildSurfacePlanBlock, constrainSurfacePlan, deriveSurfacePlanControls, @@ -186,6 +199,7 @@ export type { SurfacePlanMode, SurfacePurpose, SurfaceRuntime, + SurfaceNetwork, } from './surface-plan.js'; export { compileSurfacePolicy, diff --git a/packages/engine/src/prompt.ts b/packages/engine/src/prompt.ts index a2633a5..5b00f21 100644 --- a/packages/engine/src/prompt.ts +++ b/packages/engine/src/prompt.ts @@ -170,6 +170,34 @@ Pick one structural approach and ship it. Reconsidering mid-stream is the wrong Begin. Emit any host-required \`meta\` prelude lines first. Then emit the \`set /screen\` structural line unless a host layout block says not to, followed by one \`add\` line per section.`; +export const SUMMON_ARROW_ARTIFACT_INSTRUCTIONS = `## Arrow sandbox artifact output + +For this runtime, ignore the legacy section HTML protocol. Emit exactly one renderable line: + +\`\`\`json +{"op":"artifact","path":"/artifact","value":{"runtime":"arrow","source":{"main.ts":"...","main.css":"..."}}} +\`\`\` + +Rules: + +- The \`value.runtime\` must be \`"arrow"\`. +- \`source\` must contain exactly one entry file: \`main.ts\` or \`main.js\`. +- \`main.css\` is optional and should contain all visual styling. +- The default export from \`main.ts\` must be an Arrow template. +- Use Arrow primitives from \`@arrow-js/core\` through normal imports or free identifiers: \`html\`, \`reactive\`, \`component\`, \`props\`, \`pick\`, \`onCleanup\`, and \`nextTick\`. +- For host actions and resources, import from \`host-bridge:summon\`: + +\`\`\`ts +import { invoke, getState } from "host-bridge:summon" +\`\`\` + +- Call \`await invoke(intentName, args)\` for granted host capabilities. The result is \`{ ok, state, error? }\`. +- Call \`await getState()\` to read the latest host-pushed state. +- Do not use \`window\`, \`document\`, localStorage, cookies, direct DOM refs, external imports, or native bridges. +- Use \`fetch()\` only when the Surface plan network is \`restricted-fetch\`; otherwise use host capabilities. +- Do not emit \`set /screen\`, \`add /section/*\`, \`data-summon-*\` bindings, scripts, or host-owned meta lines. +- Keep every JSONL line on one physical line. Escape newlines inside source strings as \`\\n\`.`; + /** * Compose the direction-specific block that follows the fixed instructions: * diff --git a/packages/engine/src/protocol-hardener.ts b/packages/engine/src/protocol-hardener.ts index ced4368..055410c 100644 --- a/packages/engine/src/protocol-hardener.ts +++ b/packages/engine/src/protocol-hardener.ts @@ -92,6 +92,7 @@ const STRUCTURAL_SKIP_CODES = new Set([ 'invalid-node-parent', 'undeclared-node-parent', 'undeclared-block', + 'invalid-artifact-path', ]); interface BlockSectionState { @@ -168,6 +169,14 @@ export function createProtocolHardener(options: ProtocolHardenerOptions): Protoc }; } + if (line.op === 'artifact') { + return { + outboundLines: [line], + acceptedLines: [line], + issues: validationIssues, + }; + } + if (line.op === 'set') { return processSetLine(line, validationIssues); } diff --git a/packages/engine/src/protocol.ts b/packages/engine/src/protocol.ts index 938dac5..8698a3c 100644 --- a/packages/engine/src/protocol.ts +++ b/packages/engine/src/protocol.ts @@ -30,7 +30,13 @@ export interface MetaLine { value?: unknown; } -export type ProtocolLine = AddLine | SetLine | MetaLine; +export interface ArtifactLine { + op: 'artifact'; + path: '/artifact'; + value?: unknown; +} + +export type ProtocolLine = AddLine | SetLine | MetaLine | ArtifactLine; export const SUMMON_PROTOCOL_VERSION = 1; @@ -58,7 +64,8 @@ export type ProtocolParseErrorCode = | 'invalid-shape' | 'invalid-op' | 'invalid-add-html' - | 'invalid-add-parent'; + | 'invalid-add-parent' + | 'invalid-artifact-path'; export class ProtocolParseError extends Error { readonly code: ProtocolParseErrorCode; @@ -118,6 +125,12 @@ export function parseProtocolLineStrict( } return p as unknown as AddLine; } + if (p.op === 'artifact') { + if (p.path !== '/artifact') { + throw new ProtocolParseError('invalid-artifact-path', 'Artifact line path must be /artifact'); + } + return p as unknown as ArtifactLine; + } if (p.op === 'set') return p as unknown as SetLine; if (p.op === 'meta') return p as unknown as MetaLine; throw new ProtocolParseError('invalid-op', `Unsupported protocol op "${p.op}"`); @@ -141,6 +154,7 @@ export function isProtocolLine(value: unknown): value is ProtocolLine { (p.parent === undefined || typeof p.parent === 'string') ); } + if (p.op === 'artifact') return p.path === '/artifact'; return p.op === 'set' || p.op === 'meta'; } diff --git a/packages/engine/src/runtime-validator/protocol.ts b/packages/engine/src/runtime-validator/protocol.ts index f3f7ddd..59b21ab 100644 --- a/packages/engine/src/runtime-validator/protocol.ts +++ b/packages/engine/src/runtime-validator/protocol.ts @@ -4,6 +4,7 @@ import { sectionIdFromSectionPath, type ProtocolLine, } from '../protocol.js'; +import { validateArrowSurfaceArtifact } from '../arrow-artifact.js'; import { normalizeValidationLimits } from '../validation-limits.js'; import { protocolBlock } from './issues.js'; import { validateHtmlFragment } from './html.js'; @@ -39,6 +40,18 @@ export function validateProtocolLine( return issues; } + if (line.op === 'artifact') { + if (!line.value || typeof line.value !== 'object') { + issues.push(protocolBlock('invalid-arrow-artifact', 'Artifact line value must be an Arrow artifact object', line.path)); + return issues; + } + issues.push(...validateArrowSurfaceArtifact(line.value as never, { + maxSourceBytes: limits.maxProtocolLineBytes, + network: context.surfacePlan?.network ?? 'none', + })); + return issues; + } + if (line.op === 'set') { if (line.path === '/screen') { const value = line.value as { sections?: unknown } | undefined; diff --git a/packages/engine/src/surface-plan.ts b/packages/engine/src/surface-plan.ts index 8e91f95..108c6a8 100644 --- a/packages/engine/src/surface-plan.ts +++ b/packages/engine/src/surface-plan.ts @@ -9,11 +9,12 @@ export type SurfacePurpose = | 'review' | 'export'; -export type SurfaceRuntime = 'static' | 'declarative' | 'worker'; +export type SurfaceRuntime = 'arrow' | 'static' | 'declarative' | 'worker'; export type SurfaceData = 'embedded' | 'host-resource' | 'worker'; export type SurfaceAuthority = 'none' | 'read' | 'host-action' | 'approval-gated'; export type SurfacePersistence = 'ephemeral' | 'replayable'; export type SurfacePlanMode = 'static' | 'interactive'; +export type SurfaceNetwork = 'none' | 'restricted-fetch'; export const SURFACE_PURPOSE_VALUES = [ 'inform', @@ -26,11 +27,17 @@ export const SURFACE_PURPOSE_VALUES = [ ] as const satisfies readonly SurfacePurpose[]; export const SURFACE_RUNTIME_VALUES = [ + 'arrow', 'static', 'declarative', 'worker', ] as const satisfies readonly SurfaceRuntime[]; +export const SURFACE_NETWORK_VALUES = [ + 'none', + 'restricted-fetch', +] as const satisfies readonly SurfaceNetwork[]; + export const SURFACE_DATA_VALUES = [ 'embedded', 'host-resource', @@ -55,6 +62,7 @@ export interface SurfacePlan { data: SurfaceData; authority: SurfaceAuthority; persistence: SurfacePersistence; + network?: SurfaceNetwork; } export interface CapabilitySurface { @@ -73,6 +81,7 @@ export interface SurfaceCeiling { data?: SurfaceData[]; authorities?: SurfaceAuthority[]; persistences?: SurfacePersistence[]; + networks?: SurfaceNetwork[]; } export interface SurfacePlanInferenceInput { @@ -90,18 +99,20 @@ export interface SurfacePlanControls { export const DEFAULT_SURFACE_PLAN: SurfacePlan = { purpose: 'inform', - runtime: 'static', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }; export const DEFAULT_SURFACE_CEILING: Required = { purposes: [...SURFACE_PURPOSE_VALUES], - runtimes: ['static', 'declarative'], + runtimes: ['arrow'], data: ['embedded', 'host-resource'], authorities: ['none', 'read', 'host-action'], persistences: ['ephemeral', 'replayable'], + networks: ['none'], }; const PURPOSES = new Set(SURFACE_PURPOSE_VALUES); @@ -109,6 +120,7 @@ const RUNTIMES = new Set(SURFACE_RUNTIME_VALUES); const DATA = new Set(SURFACE_DATA_VALUES); const AUTHORITIES = new Set(SURFACE_AUTHORITY_VALUES); const PERSISTENCES = new Set(SURFACE_PERSISTENCE_VALUES); +const NETWORKS = new Set(SURFACE_NETWORK_VALUES); export function normalizeSurfacePlan(raw: unknown): SurfacePlan | null { if (!raw || typeof raw !== 'object') return null; @@ -118,8 +130,9 @@ export function normalizeSurfacePlan(raw: unknown): SurfacePlan | null { const data = enumValue(input.data, DATA); const authority = enumValue(input.authority, AUTHORITIES); const persistence = enumValue(input.persistence, PERSISTENCES); + const network = enumValue(input.network, NETWORKS) ?? 'none'; if (!purpose || !runtime || !data || !authority || !persistence) return null; - return { purpose, runtime, data, authority, persistence }; + return { purpose, runtime, data, authority, persistence, network }; } export function normalizeSurfaceCeiling(raw: unknown): SurfaceCeiling | null { @@ -131,6 +144,7 @@ export function normalizeSurfaceCeiling(raw: unknown): SurfaceCeiling | null { data: enumList(input.data, DATA), authorities: enumList(input.authorities, AUTHORITIES), persistences: enumList(input.persistences, PERSISTENCES), + networks: enumList(input.networks, NETWORKS), }; } @@ -140,7 +154,8 @@ export function surfacePlanWithinCeiling(plan: SurfacePlan, ceiling: SurfaceCeil allowed(plan.runtime, ceiling.runtimes, DEFAULT_SURFACE_CEILING.runtimes) && allowed(plan.data, ceiling.data, DEFAULT_SURFACE_CEILING.data) && allowed(plan.authority, ceiling.authorities, DEFAULT_SURFACE_CEILING.authorities) && - allowed(plan.persistence, ceiling.persistences, DEFAULT_SURFACE_CEILING.persistences) + allowed(plan.persistence, ceiling.persistences, DEFAULT_SURFACE_CEILING.persistences) && + allowed(plan.network ?? 'none', ceiling.networks, DEFAULT_SURFACE_CEILING.networks) ); } @@ -155,6 +170,7 @@ export function constrainSurfacePlan(plan: SurfacePlan, ceiling: SurfaceCeiling) ceiling.persistences, DEFAULT_SURFACE_CEILING.persistences, ), + network: constrain(plan.network ?? 'none', ceiling.networks, DEFAULT_SURFACE_CEILING.networks), }; } @@ -162,10 +178,11 @@ export function suggestSurfacePlan(input: SurfacePlanInferenceInput): SurfacePla if (input.mode === 'static') { return { purpose: inferPurpose(input.prompt), - runtime: 'static', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: input.persistence ?? 'replayable', + network: 'none', }; } @@ -181,10 +198,11 @@ export function suggestSurfacePlan(input: SurfacePlanInferenceInput): SurfacePla return { purpose: inferPurpose(input.prompt), - runtime: hasWorker ? 'worker' : 'declarative', + runtime: 'arrow', data: hasWorker ? 'worker' : hasResource ? 'host-resource' : 'embedded', authority: hasApproval ? 'approval-gated' : hasAction ? 'host-action' : hasResource ? 'read' : 'none', persistence: input.persistence ?? 'replayable', + network: 'none', }; } @@ -218,13 +236,15 @@ The host has selected this minimum safe surface plan: - Data: \`${plan.data}\` - Authority: \`${plan.authority}\` - Persistence: \`${plan.persistence}\` +- Network: \`${plan.network ?? 'none'}\` This plan is a host decision, not part of your generated artifact. Do not emit a \`/surface-plan\` meta line and do not imply capabilities outside this plan. Runtime rules: -- \`static\`: render read-only HTML. Do not emit scripts, intents, resources, forms, or controls that require host action. -- \`declarative\`: use only \`data-summon-*\` bindings and host-granted capabilities. +- \`arrow\`: emit one \`op: "artifact"\` line at \`/artifact\` containing an Arrow sandbox source tree with one \`main.ts\` or \`main.js\`. +- \`static\`: legacy read-only HTML runtime; avoid for new generated surfaces. +- \`declarative\`: legacy data-summon runtime; avoid for new generated surfaces. - \`worker\`: use only capabilities the host describes as worker-backed; the worker remains host-owned. Authority rules: diff --git a/packages/engine/src/surface-policy.ts b/packages/engine/src/surface-policy.ts index 333a8e3..f8ed1d3 100644 --- a/packages/engine/src/surface-policy.ts +++ b/packages/engine/src/surface-policy.ts @@ -276,6 +276,7 @@ function planForPolicy( data: 'embedded', authority: 'none', persistence: policy.persistence, + network: 'none', }; } @@ -300,6 +301,7 @@ function planForPolicy( data, authority, persistence: policy.persistence, + network: 'none', }; } @@ -349,6 +351,7 @@ function exactCeiling(plan: SurfacePlan): SurfaceCeiling { data: [plan.data], authorities: [plan.authority], persistences: [plan.persistence], + networks: [plan.network ?? 'none'], }; } diff --git a/packages/engine/test/contracts.test.ts b/packages/engine/test/contracts.test.ts index 3b44179..3a1b0f5 100644 --- a/packages/engine/test/contracts.test.ts +++ b/packages/engine/test/contracts.test.ts @@ -6,13 +6,16 @@ import { compileSurfaceContractView, compileSystemContracts, compileTokenContract, + constrainSurfacePlan, deriveSurfacePlanControls, inferSurfacePlan, normalizeSurfacePlan, suggestSurfacePlan, + surfacePlanWithinCeiling, SUMMON_FIXED_INSTRUCTIONS, SURFACE_AUTHORITY_VALUES, SURFACE_DATA_VALUES, + SURFACE_NETWORK_VALUES, SURFACE_PERSISTENCE_VALUES, SURFACE_PURPOSE_VALUES, SURFACE_RUNTIME_VALUES, @@ -72,7 +75,7 @@ test('system compiler includes component island prompt and validation metadata', assert.deepEqual( compiled.promptBlocks.map((block) => block.id), - ['fixed', 'components'], + ['fixed', 'arrow-artifact-runtime', 'components'], ); const block = compiled.promptBlocks.find((promptBlock) => promptBlock.id === 'components'); assert.match(block?.text ?? '', /Component islands/); @@ -163,6 +166,7 @@ test('system compiler returns deterministic prompt block order and validation co compiled.promptBlocks.map((block) => block.id), [ 'fixed', + 'arrow-artifact-runtime', 'direction:demo', 'ghost', 'layout:two-slot', @@ -212,7 +216,7 @@ test('system compiler includes a host-owned surface plan block', () => { assert.deepEqual( compiled.promptBlocks.map((block) => block.id), - ['fixed', 'surface-plan', 'capabilities'], + ['fixed', 'arrow-artifact-runtime', 'surface-plan', 'capabilities'], ); assert.equal(compiled.validationContext.scriptPolicy, 'forbid'); assert.deepEqual(compiled.validationContext.surfacePlan, { @@ -269,7 +273,7 @@ test('system compiler includes compact surface contract view without dropping de assert.deepEqual( compiled.promptBlocks.map((block) => block.id), - ['fixed', 'surface-contract', 'capabilities', 'components'], + ['fixed', 'arrow-artifact-runtime', 'surface-contract', 'capabilities', 'components'], ); const surfaceBlock = compiled.promptBlocks.find((block) => block.id === 'surface-contract'); assert.match(surfaceBlock?.text ?? '', /compact, read-only view/); @@ -296,6 +300,7 @@ test('surface plan normalization and suggestions are stable', () => { data: 'worker', authority: 'approval-gated', persistence: 'replayable', + network: 'none', }); const suggestion = suggestSurfacePlan({ @@ -317,10 +322,11 @@ test('surface plan normalization and suggestions are stable', () => { assert.deepEqual(suggestion, { purpose: 'compare', - runtime: 'declarative', + runtime: 'arrow', data: 'embedded', authority: 'host-action', persistence: 'replayable', + network: 'none', }); assert.deepEqual(inferSurfacePlan({ prompt: 'compare payment plans and help me pick one', @@ -351,6 +357,7 @@ test('surface plan host control helpers expose values and derive defaults', () = 'export', ]); assert.deepEqual([...SURFACE_RUNTIME_VALUES], [ + 'arrow', 'static', 'declarative', 'worker', @@ -370,13 +377,26 @@ test('surface plan host control helpers expose values and derive defaults', () = 'ephemeral', 'replayable', ]); + assert.deepEqual([...SURFACE_NETWORK_VALUES], [ + 'none', + 'restricted-fetch', + ]); const base: Omit = { purpose: 'explore', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }; + assert.equal(surfacePlanWithinCeiling( + { ...base, runtime: 'arrow', network: 'restricted-fetch' }, + { runtimes: ['arrow'], networks: ['none'] }, + ), false); + assert.equal(constrainSurfacePlan( + { ...base, runtime: 'arrow', network: 'restricted-fetch' }, + { runtimes: ['arrow'], networks: ['none'] }, + ).network, 'none'); assert.deepEqual(deriveSurfacePlanControls({ ...base, runtime: 'static' }), { mode: 'static', diff --git a/packages/engine/test/runtime-validator-protocol.test.ts b/packages/engine/test/runtime-validator-protocol.test.ts index 57f7a37..ee92e3e 100644 --- a/packages/engine/test/runtime-validator-protocol.test.ts +++ b/packages/engine/test/runtime-validator-protocol.test.ts @@ -94,3 +94,69 @@ test('allows safe static markup', () => { ); assert.deepEqual(issues, []); }); + +test('accepts valid Arrow artifacts', () => { + const issues = validateProtocolLine( + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': 'export 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 malformed Arrow artifacts and ungranted restricted fetch', () => { + assert.deepEqual( + codes(validateProtocolLine( + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': 'export default html`
A
`', + 'main.js': 'export default html`
B
`', + }, + }, + }, + baseContext, + )), + ['invalid-arrow-entry'], + ); + + assert.deepEqual( + codes(validateProtocolLine( + { + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + network: 'restricted-fetch', + source: { + 'main.ts': 'export default html`
Weather
`', + }, + }, + }, + baseContext, + )), + ['arrow-network-not-granted'], + ); +}); diff --git a/packages/engine/test/surface-policy.test.ts b/packages/engine/test/surface-policy.test.ts index 2fbee6d..f36bd45 100644 --- a/packages/engine/test/surface-policy.test.ts +++ b/packages/engine/test/surface-policy.test.ts @@ -103,6 +103,7 @@ test('compiles static policy to static embedded plan with no packs', () => { data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }); }); @@ -125,6 +126,7 @@ test('compiles declarative policy and narrows grants, components, and patterns', data: 'host-resource', authority: 'host-action', persistence: 'replayable', + network: 'none', }); }); @@ -152,6 +154,7 @@ test('compiles worker policy and requires worker-backed surface area', () => { data: 'worker', authority: 'host-action', persistence: 'replayable', + network: 'none', }); const missing = compileSurfacePolicy({ tier: 'worker' }, { capabilities }); @@ -172,6 +175,7 @@ test('compiles approval policy and requires approval-gated grant', () => { data: 'embedded', authority: 'approval-gated', persistence: 'replayable', + network: 'none', }); const missing = compileSurfacePolicy({ tier: 'approval', grants: ['choose'] }, { capabilities }); diff --git a/packages/host/src/browser.ts b/packages/host/src/browser.ts index 048dce5..f297203 100644 --- a/packages/host/src/browser.ts +++ b/packages/host/src/browser.ts @@ -48,6 +48,7 @@ export type { ComponentsMessage, FatalMessage, IntentMessage, + IntentResultMessage, ReadyMessage, SandboxHandle, SandboxInboundMessage, diff --git a/packages/host/src/index.ts b/packages/host/src/index.ts index e860c30..c6924c3 100644 --- a/packages/host/src/index.ts +++ b/packages/host/src/index.ts @@ -6,6 +6,7 @@ export type { IntentEntry, IntentHandler, PolicyEngineOptions, + PolicyDispatchResult, TypedIntentEntry, } from './policy-engine.js'; export { @@ -97,6 +98,7 @@ export type { SandboxHandle, StateMessage, IntentMessage, + IntentResultMessage, ReadyMessage, FatalMessage, SandboxInboundMessage, diff --git a/packages/host/src/policy-engine.ts b/packages/host/src/policy-engine.ts index 729a79b..79bd017 100644 --- a/packages/host/src/policy-engine.ts +++ b/packages/host/src/policy-engine.ts @@ -102,6 +102,12 @@ export interface PolicyEngineOptions { events?: EventStore; } +export interface PolicyDispatchResult { + ok: boolean; + state: Record; + error?: string; +} + export class PolicyEngine { private state: Record; private readonly handlers: Record>; @@ -140,12 +146,13 @@ export class PolicyEngine { this.onStateChange(next); } - async dispatch(intent: string, args: Record): Promise { + async dispatch(intent: string, args: Record): Promise { const entry = this.handlers[intent]; 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 intent "${intent}"`); + this.onHandlerError?.(intent, error); + return { ok: false, state: this.getState(), error: error.message }; } const id = `${Date.now()}-${++this.dispatchSeq}`; @@ -172,17 +179,19 @@ export class PolicyEngine { const err = new IntentArgsError(intent, parsed.error); settle(false, err.message); this.onHandlerError?.(intent, err); - return; + 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); + 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..cf49a1e 100644 --- a/packages/host/src/policy.ts +++ b/packages/host/src/policy.ts @@ -3,6 +3,7 @@ export type { IntentContext, IntentEntry, IntentHandler, + PolicyDispatchResult, PolicyEngineOptions, TypedIntentEntry, } from './policy-engine.js'; diff --git a/packages/host/src/sandbox-spawner.ts b/packages/host/src/sandbox-spawner.ts index 7a30418..0957a21 100644 --- a/packages/host/src/sandbox-spawner.ts +++ b/packages/host/src/sandbox-spawner.ts @@ -1,6 +1,8 @@ import type { EventStore } from '@summon-internal/devtools'; import { hasCompleteResourceStateKeys, type ValidationCapability } from '@summon-internal/engine'; import type { + ArrowNetworkPolicy, + ArrowSurfaceArtifact, Artifact, CompiledHtmlNodePatch, ComponentIslandDescriptor, @@ -14,14 +16,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'", @@ -54,8 +59,22 @@ export interface SpawnOptions { bootstrapSource: string; /** Raw token CSS source; published consumers can use `@anarchitecture/summon/assets`. */ tokensSource: string; + /** + * 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 intents that passed the bridge allowlist. */ - onIntent?: (intent: string, args: Record) => void; + onIntent?: (intent: string, args: Record) => + | void + | Record + | Promise>; /** Receives intents that were rejected by the allowlist. Useful for logging / tests. */ onIntentRejected?: (reason: string, raw: unknown) => void; /** Receives sandbox-measured component island placeholders. */ @@ -99,6 +118,8 @@ function buildSrcdoc(params: { 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 @@ -112,10 +133,12 @@ function buildSrcdoc(params: { return ` - + + +${params.arrowRuntimeSource ? `` : ''} @@ -346,6 +369,7 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { const intentAllowlist = new Set(opts.grantedIntents); const grantedCapabilities = opts.grantedCapabilities ?? opts.artifact.capabilities ?? []; const resourceMap = resourceMapFromCapabilities(grantedCapabilities); + 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. @@ -355,6 +379,7 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { const pendingStates: Record[] = []; const pendingDomOps: Array< | { kind: 'render'; html: string } + | { kind: 'artifact'; artifact: ArrowSurfaceArtifact } | { kind: 'node-patch'; patch: CompiledHtmlNodePatch } > = []; // Chrome attributes are merged before flush so a flurry of setChrome calls @@ -381,12 +406,30 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { const op = pendingDomOps.shift()!; if (op.kind === 'render') { opts.iframe.contentWindow.postMessage({ type: 'SUMMON_RENDER', sandbox_id: sandboxId, html: op.html }, '*'); + } else if (op.kind === 'artifact') { + opts.iframe.contentWindow.postMessage({ type: 'SUMMON_RENDER', sandbox_id: sandboxId, artifact: op.artifact }, '*'); } else { opts.iframe.contentWindow.postMessage({ type: 'SUMMON_NODE_PATCH', sandbox_id: sandboxId, patch: op.patch }, '*'); } } } + function postIntentResult(requestId: string | undefined, result: { + ok: boolean; + state?: Record; + error?: string; + }) { + if (!requestId || !opts.iframe.contentWindow) return; + opts.iframe.contentWindow.postMessage({ + type: 'SUMMON_INTENT_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; @@ -475,6 +518,7 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { raw: data, }); opts.onIntentRejected?.('intent not a non-empty string', data); + postIntentResult(data.request_id, { ok: false, error: 'intent not a non-empty string' }); return; } if (!intentAllowlist.has(intent)) { @@ -486,6 +530,7 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { raw: data, }); opts.onIntentRejected?.(`intent "${intent}" not granted`, data); + postIntentResult(data.request_id, { ok: false, error: `intent "${intent}" not granted` }); return; } const safeArgs = @@ -497,7 +542,17 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { intent, args: safeArgs, }); - opts.onIntent?.(intent, safeArgs); + void Promise.resolve(opts.onIntent?.(intent, safeArgs)) + .then((state) => { + postIntentResult(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); + postIntentResult(data.request_id, { ok: false, error }); + }); } } @@ -509,10 +564,15 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { bootstrapSource: opts.bootstrapSource, tokensSource: opts.tokensSource, resourceMap, + networkPolicy: arrowNetworkPolicy, + arrowRuntimeSource: opts.arrowRuntimeSource, }); if (opts.artifact.html) { pendingDomOps.push({ kind: 'render', html: opts.artifact.html }); } + if (opts.artifact.arrow) { + pendingDomOps.push({ kind: 'artifact', artifact: opts.artifact.arrow }); + } opts.events?.push({ kind: 'sandbox-spawned', @@ -540,6 +600,16 @@ export function spawnSandbox(opts: SpawnOptions): SandboxHandle { }); flushPending(); }, + renderArtifact(artifact) { + pendingDomOps.push({ kind: 'artifact', artifact }); + opts.events?.push({ + kind: 'render', + at: Date.now(), + sandboxId, + bytes: JSON.stringify(artifact.source).length, + }); + flushPending(); + }, patchNode(patch) { pendingDomOps.push({ kind: 'node-patch', patch }); opts.events?.push({ diff --git a/packages/host/src/surface-stream.ts b/packages/host/src/surface-stream.ts index 36d150b..281d95d 100644 --- a/packages/host/src/surface-stream.ts +++ b/packages/host/src/surface-stream.ts @@ -6,6 +6,8 @@ import { type CompiledArtifactHtml, type CompiledHtmlNodePatch, type ContractIssue, + type ArrowSurfaceArtifact, + type ArtifactLine, type MetaLine, type ProtocolLine, type SectionApplyResult, @@ -59,6 +61,11 @@ export interface SurfaceStreamOptions { line: MetaLine, context: SurfaceStreamContext, ) => void | Promise; + onArtifact?: ( + artifact: ArrowSurfaceArtifact, + line: ArtifactLine, + context: SurfaceStreamContext, + ) => void | Promise; onParseError?: ( raw: string, context: SurfaceStreamContext, @@ -164,7 +171,7 @@ export async function consumeSurfaceStream( graph.applyLine(acceptedLine); let applyResult: SectionApplyResult | undefined; - if (acceptedLine.op !== 'meta') { + if (acceptedLine.op !== 'meta' && acceptedLine.op !== 'artifact') { applyResult = accumulator.applyDetailed(acceptedLine); acceptedStructuralLines += 1; } @@ -185,6 +192,13 @@ export async function consumeSurfaceStream( await options.onMeta?.(acceptedLine, ctx); return; } + if (acceptedLine.op === 'artifact') { + await emitGraph(ctx); + if (isArrowSurfaceArtifactValue(acceptedLine.value)) { + await options.onArtifact?.(acceptedLine.value, acceptedLine, ctx); + } + return; + } await emitGraph(ctx); if (renderMode() === 'live' && applyResult?.changed) { @@ -255,6 +269,18 @@ function compileAcceptedLine( return { ...line, html: result.html }; } +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( source: SurfaceStreamSource, ): AsyncGenerator { diff --git a/packages/host/src/types.ts b/packages/host/src/types.ts index 3880828..f03e01a 100644 --- a/packages/host/src/types.ts +++ b/packages/host/src/types.ts @@ -1,4 +1,6 @@ import type { + ArrowNetworkPolicy, + ArrowSurfaceArtifact, CompiledArtifactHtml, CompiledHtmlNodePatch, ValidationCapability, @@ -6,6 +8,8 @@ import type { } from '@summon-internal/engine'; export type { + ArrowNetworkPolicy, + ArrowSurfaceArtifact, CompiledArtifactHtml, CompiledHtmlNodePatch, HtmlNodePatch, @@ -27,7 +31,8 @@ export interface NodePatchMessage { export interface RenderMessage { type: 'SUMMON_RENDER'; sandbox_id: string; - html: CompiledArtifactHtml; + html?: CompiledArtifactHtml; + artifact?: ArrowSurfaceArtifact; } /** @@ -52,6 +57,16 @@ export interface IntentMessage { sandbox_id: string; intent: string; args: Record; + request_id?: string; +} + +export interface IntentResultMessage { + type: 'SUMMON_INTENT_RESULT'; + sandbox_id: string; + request_id: string; + ok: boolean; + state: Record; + error?: string; } export interface ComponentIslandBounds { @@ -104,6 +119,8 @@ export interface SandboxHandle { pushState(state: Record): void; /** Replace the compiled HTML inside #summon-root. */ render(html: CompiledArtifactHtml): void; + /** Replace the Arrow source artifact inside #summon-root. */ + renderArtifact(artifact: ArrowSurfaceArtifact): void; /** Patch one validated data-summon-node subtree in place. Experimental. */ patchNode(patch: CompiledHtmlNodePatch): void; /** @@ -121,6 +138,7 @@ export interface SandboxHandle { /** Artifact — generated HTML plus advisory declarations used for diagnostics and replay. */ export interface Artifact { + runtime?: 'html' | 'arrow'; /** 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. */ @@ -128,7 +146,9 @@ export interface Artifact { /** 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; + 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..9f24c04 100644 --- a/packages/host/test/surface-stream.test.ts +++ b/packages/host/test/surface-stream.test.ts @@ -48,6 +48,32 @@ test('consumeSurfaceStream accepts Uint8Array chunks', async () => { assert.match(result.html, /Bytes/); }); +test('consumeSurfaceStream delivers Arrow artifacts without HTML rendering', async () => { + const artifacts: string[] = []; + const renders: string[] = []; + const result = await consumeSurfaceStream([ + `${JSON.stringify({ + op: 'artifact', + path: '/artifact', + value: { + runtime: 'arrow', + source: { + 'main.ts': 'export default html`

Arrow

`', + }, + }, + })}\n`, + ], { + mode: 'interactive', + onArtifact: (artifact) => artifacts.push(artifact.source['main.ts'] ?? ''), + onRenderHtml: (html) => renders.push(html), + }); + + assert.equal(result.protocolLines.length, 1); + assert.deepEqual(artifacts, ['export default html`

Arrow

`']); + assert.deepEqual(renders, []); + assert.equal(result.html, ''); +}); + test('consumeSurfaceStream accepts ReadableStream sources', async () => { const source = new ReadableStream({ start(controller) { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 682911f..5e39e4a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,11 +5,15 @@ import { } from '@anarchitecture/summon'; import { compileArtifactHtml, + isArrowSurfaceArtifact, SectionAccumulator, + type ArtifactLine, + type ArrowNetworkPolicy, type CompiledArtifactHtml, type CompiledHtmlNodePatch, type HtmlNodePatch, type ProtocolLine, + type ArrowSurfaceArtifact, type ValidationContext, } from '@anarchitecture/summon/engine'; import { @@ -24,6 +28,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'; @@ -45,6 +50,7 @@ export interface SummonSurfaceChrome { export interface SummonSurfaceProps { envelope?: SurfaceEnvelope | null; + artifact?: ArrowSurfaceArtifact | null; html?: string; protocolLines?: ProtocolLine[]; artifactIntents?: string[]; @@ -54,6 +60,8 @@ export interface SummonSurfaceProps { capabilityRegistry?: CapabilityRegistry | null; componentRegistry?: ComponentRegistry | null; bootstrapSource?: string; + arrowRuntimeSource?: string; + arrowNetworkPolicy?: ArrowNetworkPolicy; tokensSource?: string; initialState?: Record; chrome?: SummonSurfaceChrome; @@ -73,6 +81,7 @@ export interface SummonSurfaceHandle { iframe: HTMLIFrameElement | null; sandboxId: string | null; render(html: string): void; + renderArtifact(artifact: ArrowSurfaceArtifact): void; patchNode(patch: HtmlNodePatch): void; pushState(state: Record): void; setChrome(chrome: SummonSurfaceChrome): void; @@ -107,6 +116,9 @@ export const SummonSurface = forwardRef ); handleRef.current?.render(compiled); }, + renderArtifact(artifact: ArrowSurfaceArtifact) { + handleRef.current?.renderArtifact(artifact); + }, patchNode(patch: HtmlNodePatch) { const compiled = compilePatchForRender(patch, validationContextRef.current ?? defaultValidationContext()); if (compiled) { @@ -130,7 +142,7 @@ export const SummonSurface = forwardRef useEffect(() => { lastRenderedHtmlRef.current = null; - }, [props.envelope, props.html, props.protocolLines]); + }, [props.envelope, props.artifact, props.html, props.protocolLines]); useEffect(() => { if (!props.onEvent) return; @@ -160,6 +172,8 @@ export const SummonSurface = forwardRef 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 +195,14 @@ export const SummonSurface = forwardRef }); const artifact: Artifact = { + runtime: arrowArtifact ? 'arrow' : 'html', // Advisory only. spawnSandbox receives host grants below. intents: props.artifactIntents ?? props.envelope?.grants.intents ?? grantedIntents, capabilities: props.envelope?.grants.capabilities ?? props.grantedCapabilities, components: props.artifactComponents ?? props.envelope?.grants.components ?? componentContract?.validationComponents, - html: resolveCompiledHtml(props, validationContext), + ...(arrowArtifact + ? { arrow: arrowArtifact } + : { html: resolveCompiledHtml(props, validationContext) }), initialState, }; const grantedComponentNames = new Set((artifact.components ?? []).map((component) => component.name)); @@ -196,14 +213,17 @@ export const SummonSurface = forwardRef grantedIntents, grantedCapabilities, 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); + return policy.dispatch(intent, args).then((result) => result.state); } + return policy.getState(); }, onIntentRejected: props.onIntentRejected, onComponents: (components, sandboxId) => { @@ -252,13 +272,15 @@ export const SummonSurface = forwardRef }, }); handleRef.current = handle; - preflightComponentProps( - artifact.html, - props.componentRegistry, - handle.sandboxId, - events, - props.onComponentError, - ); + if (artifact.html) { + 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); @@ -275,10 +297,13 @@ export const SummonSurface = forwardRef }, [ events, props.bootstrapSource, + props.arrowRuntimeSource, + props.arrowNetworkPolicy, props.capabilityRegistry, props.chrome, props.componentRegistry, props.envelope, + props.artifact, props.artifactIntents, props.grantedIntents, props.grantedCapabilities, @@ -359,6 +384,21 @@ function resolveCompiledHtml(props: SummonSurfaceProps, context: ValidationConte return compileForRender(accumulator.compose(), context); } +function resolveArrowArtifact(props: SummonSurfaceProps): ArrowSurfaceArtifact | null { + if (props.artifact) return props.artifact; + const lines = props.envelope?.protocolLines ?? props.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; + } + } + return null; +} + function compileForRender(html: string, context: ValidationContext): CompiledArtifactHtml { const result = compileArtifactHtml(html, context); if (result.issues.some((issue) => issue.severity === 'block')) { 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..17e61ce 100644 --- a/packages/sandbox-runtime/src/bootstrap.js +++ b/packages/sandbox-runtime/src/bootstrap.js @@ -8,6 +8,9 @@ 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. return; @@ -17,6 +20,7 @@ try { delete window.__SUMMON_SANDBOX_ID__; delete window.__SUMMON_RESOURCES__; + delete window.__SUMMON_NETWORK_POLICY__; } catch (_) { /* sealed elsewhere */ } function normalizeResources(raw) { @@ -103,6 +107,8 @@ let componentResizeObserver = null; const componentResizeObserved = new Set(); const SAFE_ATTR_BINDINGS = Object.freeze(['src', 'alt', 'title', 'aria-label', 'value', 'placeholder', 'disabled']); + const pendingIntentResults = new Map(); + let arrowTeardown = null; function notify() { const snapshot = currentState; @@ -121,6 +127,96 @@ }, '*'); } + function emitFatal(reason) { + try { + PARENT.postMessage({ + type: 'SUMMON_FATAL', + sandbox_id: SANDBOX_ID, + reason, + }, '*'); + } catch (_) { /* parent gone */ } + } + + function invokeIntent(intent, args) { + if (typeof intent !== 'string' || !intent) { + return Promise.resolve({ ok: false, state: currentState, error: 'intent not a non-empty string' }); + } + const requestId = 'arrow-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); + return new Promise((resolve) => { + const timeout = window.setTimeout(function () { + pendingIntentResults.delete(requestId); + resolve({ ok: false, state: currentState, error: 'intent timed out' }); + }, 15000); + pendingIntentResults.set(requestId, function (result) { + window.clearTimeout(timeout); + resolve(result); + }); + PARENT.postMessage({ + type: 'SUMMON_INTENT', + sandbox_id: SANDBOX_ID, + request_id: requestId, + intent, + args: args == null || typeof args !== 'object' ? {} : args, + }, '*'); + }); + } + + 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; + } + if (artifact.network === 'restricted-fetch' && NETWORK_POLICY !== 'restricted-fetch') { + emitFatal('Arrow artifact requested restricted fetch without host network grant'); + return; + } + if (typeof arrowTeardown === 'function') { + try { arrowTeardown(); } catch (_) { /* best effort */ } + arrowTeardown = null; + } + 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; + } + 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 === 'intent') { + void invokeIntent(payload.intent, payload.args); + } + }, + }, + { + 'host-bridge:summon': { + getState: function () { + return currentState; + }, + invoke: function (intent, args) { + return invokeIntent(intent, args); + }, + }, + }, + ); + const maybeTeardown = view(root); + if (typeof maybeTeardown === 'function') arrowTeardown = maybeTeardown; + } catch (err) { + emitFatal('Arrow runtime failed to mount: ' + String(err && err.message ? err.message : err)); + } + } + function onState(cb) { if (typeof cb !== 'function') return () => {}; subscribers.add(cb); @@ -1127,6 +1223,10 @@ } if (data.type === 'SUMMON_RENDER') { + if (data.artifact) { + renderArrowArtifact(data.artifact); + return; + } // renderRoot decides whether to wipe subscribers: a full innerHTML // replace clears them, but a section-by-section diff leaves alive // sections (and their onState subscribers) in place. @@ -1134,6 +1234,20 @@ return; } + if (data.type === 'SUMMON_INTENT_RESULT') { + var requestId = data.request_id; + if (typeof requestId !== 'string') return; + var resolve = pendingIntentResults.get(requestId); + if (!resolve) return; + pendingIntentResults.delete(requestId); + resolve({ + ok: data.ok === true, + state: data.state && typeof data.state === 'object' ? data.state : currentState, + error: typeof data.error === 'string' ? data.error : undefined, + }); + return; + } + if (data.type === 'SUMMON_NODE_PATCH') { patchHtmlNode(data.patch); return; diff --git a/packages/server/src/plan.ts b/packages/server/src/plan.ts index 8bd07f6..192a566 100644 --- a/packages/server/src/plan.ts +++ b/packages/server/src/plan.ts @@ -31,18 +31,20 @@ function defaultSurfacePlanForMode(mode: ResolveSurfaceGenerationPlanInput['mode if (mode === 'static') { return { purpose: 'inform', - runtime: 'static', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }; } return { purpose: 'inform', - runtime: 'declarative', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }; } diff --git a/packages/server/test/agent-broker.test.ts b/packages/server/test/agent-broker.test.ts index 43f70e3..539c7a1 100644 --- a/packages/server/test/agent-broker.test.ts +++ b/packages/server/test/agent-broker.test.ts @@ -113,13 +113,14 @@ test('planAgentSurface proposes and compiles a declarative policy', async () => }); assert.equal(plan.intentSource, 'deterministic'); assert.deepEqual(plan.compiledPolicy.issues, []); - assert.deepEqual(plan.compiledPolicy.surfacePlan, { - purpose: 'explore', - runtime: 'declarative', - data: 'host-resource', - authority: 'read', - persistence: 'replayable', - }); + assert.deepEqual(plan.compiledPolicy.surfacePlan, { + purpose: 'explore', + runtime: 'declarative', + data: 'host-resource', + authority: 'read', + persistence: 'replayable', + network: 'none', + }); }); test('planAgentSurface keeps passive summary prompts static despite powerful nouns', async () => { @@ -206,13 +207,14 @@ test('planAgentSurface selects host actions only from explicit action phrasing', assert.equal(plan.surfacePolicy.tier, 'declarative'); assert.equal(plan.surfacePolicy.purpose, 'operate'); assert.deepEqual(plan.surfacePolicy.grants, ['choose']); - assert.deepEqual(plan.compiledPolicy.surfacePlan, { - purpose: 'operate', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', - }); + assert.deepEqual(plan.compiledPolicy.surfacePlan, { + purpose: 'operate', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }); }); test('model-assisted intent can narrow to known names but cannot add unknown grants', async () => { diff --git a/packages/server/test/generate-surface-stream.test.ts b/packages/server/test/generate-surface-stream.test.ts index 26fe7eb..38e8a3d 100644 --- a/packages/server/test/generate-surface-stream.test.ts +++ b/packages/server/test/generate-surface-stream.test.ts @@ -218,13 +218,14 @@ test('runSurfaceGeneration compiles surface policy, emits metadata, and narrows persistence: 'replayable', }); assert.equal(lines[1]?.op, 'meta'); - assert.deepEqual((lines[1] as Extract).value, { - purpose: 'compare', - runtime: 'declarative', - data: 'embedded', - authority: 'host-action', - persistence: 'replayable', - }); + assert.deepEqual((lines[1] as Extract).value, { + purpose: 'compare', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + network: 'none', + }); assert.equal(lines[2]?.op, 'meta'); const surfaceContract = (lines[2] as Extract).value as { tools?: Array<{ name: string }>; @@ -621,9 +622,10 @@ test('resolveSurfaceGenerationPlan preserves server surface resolution behavior' assert.equal(resolved.source, 'default'); assert.deepEqual(resolved.surfacePlan, { purpose: 'inform', - runtime: 'static', + runtime: 'arrow', data: 'embedded', authority: 'none', persistence: 'replayable', + network: 'none', }); }); diff --git a/packages/summon/package.json b/packages/summon/package.json index f154d85..ba6d8b4 100644 --- a/packages/summon/package.json +++ b/packages/summon/package.json @@ -56,6 +56,7 @@ "import": "./dist/devtools.js" }, "./bootstrap.js": "./dist/_internal/sandbox-runtime/bootstrap.js", + "./arrow-runtime.js": "./dist/_internal/sandbox-runtime/arrow-runtime.js", "./tokens.css": "./dist/_internal/sandbox-runtime/tokens.css", "./package.json": "./package.json" }, diff --git a/packages/summon/src/assets.ts b/packages/summon/src/assets.ts index b489296..d4c2738 100644 --- a/packages/summon/src/assets.ts +++ b/packages/summon/src/assets.ts @@ -1,4 +1,5 @@ export { + arrowRuntimeSource, bootstrapSource, tokensSource, } from '@summon-internal/sandbox-runtime/assets'; diff --git a/packages/summon/src/browser.ts b/packages/summon/src/browser.ts index 317195b..99fb341 100644 --- a/packages/summon/src/browser.ts +++ b/packages/summon/src/browser.ts @@ -18,6 +18,7 @@ export type { ComponentsMessage, FatalMessage, IntentMessage, + IntentResultMessage, ReadyMessage, SandboxHandle, SandboxInboundMessage, diff --git a/packages/summon/src/policy.ts b/packages/summon/src/policy.ts index bb31a16..9bba2e6 100644 --- a/packages/summon/src/policy.ts +++ b/packages/summon/src/policy.ts @@ -9,6 +9,7 @@ export type { IntentContext, IntentEntry, IntentHandler, + PolicyDispatchResult, PolicyEngineOptions, TypedIntentEntry, } from '@summon-internal/host/policy'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14d2786..387cad8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,7 +219,15 @@ importers: specifier: ^5.4.0 version: 5.9.3 - packages/sandbox-runtime: {} + packages/sandbox-runtime: + dependencies: + '@arrow-js/sandbox': + specifier: 1.0.6 + version: 1.0.6 + devDependencies: + esbuild: + specifier: 0.27.7 + version: 0.27.7 packages/server: dependencies: @@ -300,6 +308,13 @@ packages: zod: optional: true + '@arrow-js/core@1.0.6': + resolution: {integrity: sha512-75f4N0iHtHTwgxf9XS8a1vtfGZZmCvqT0Ix+ScDLclNuR9Fqp2yOIK1u8vmN1BPsS4aRfMVTdSFSmgGCqkJSCg==} + engines: {node: '>=20.19.0 || >=22.12.0'} + + '@arrow-js/sandbox@1.0.6': + resolution: {integrity: sha512-vXZdu5bgY5Aic2o0gMIW9VfAsy85ObtGPyWasf1eayveNwwXmjI9BRl25npRi4LyJCU0ieS4xV0MQMrfIMNIMA==} + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -677,6 +692,21 @@ packages: '@types/node': optional: true + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1131,6 +1161,15 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1776,6 +1815,13 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2102,6 +2148,17 @@ snapshots: optionalDependencies: zod: 4.4.3 + '@arrow-js/core@1.0.6': {} + + '@arrow-js/sandbox@1.0.6': + dependencies: + '@arrow-js/core': 1.0.6 + acorn: 8.17.0 + acorn-walk: 8.3.5 + magic-string: 0.30.21 + quickjs-emscripten: 0.32.0 + typescript: 5.9.3 + '@babel/runtime@7.29.2': {} '@changesets/apply-release-plan@7.1.1': @@ -2432,6 +2489,24 @@ snapshots: optionalDependencies: '@types/node': 20.19.39 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2777,6 +2852,12 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -3390,6 +3471,18 @@ snapshots: queue-microtask@1.2.3: {} + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + range-parser@1.2.1: {} raw-body@2.5.3: diff --git a/scripts/build-public-packages.mjs b/scripts/build-public-packages.mjs index 1d03a8b..9d3cb7d 100644 --- a/scripts/build-public-packages.mjs +++ b/scripts/build-public-packages.mjs @@ -23,15 +23,18 @@ const coreExports = { '.': { values: { './_internal/engine/index.js': [ - 'compileSurfaceContractView', - 'compileSurfacePolicy', - 'compileArtifactHtml', - 'normalizeSurfacePolicy', - 'surfaceContractViewFromCompiledPolicy', - 'SURFACE_PERSISTENCE_VALUES', - 'SURFACE_PURPOSE_VALUES', - 'SURFACE_TIER_VALUES', - ], + 'compileSurfaceContractView', + 'compileSurfacePolicy', + 'compileArtifactHtml', + 'isArrowSurfaceArtifact', + 'normalizeArrowSurfaceArtifact', + 'normalizeSurfacePolicy', + 'surfaceContractViewFromCompiledPolicy', + 'SURFACE_PERSISTENCE_VALUES', + 'SURFACE_PURPOSE_VALUES', + 'SURFACE_TIER_VALUES', + 'validateArrowSurfaceArtifact', + ], './_internal/host/index.js': [ 'createCapabilityRegistry', 'createComponentRegistry', @@ -59,9 +62,12 @@ const coreExports = { 'CapabilityTriggerSpec', 'ComponentPack', 'ComponentSpec', - 'ComponentSurface', - 'ArtifactCompileResult', - 'CompiledArtifactHtml', + 'ComponentSurface', + 'ArtifactCompileResult', + 'ArrowArtifactValidationOptions', + 'ArrowNetworkPolicy', + 'ArrowSurfaceArtifact', + 'CompiledArtifactHtml', 'CompiledHtmlNodePatch', 'CompileSurfaceContractViewOptions', 'CompiledSurfacePolicy', @@ -72,9 +78,10 @@ const coreExports = { 'SurfaceContractLayout', 'SurfaceContractTool', 'SurfaceContractView', - 'SurfacePersistence', - 'SurfacePolicy', - 'SurfacePurpose', + 'SurfacePersistence', + 'SurfacePolicy', + 'SurfaceNetwork', + 'SurfacePurpose', 'SurfaceTier', ], './_internal/host/index.js': [ @@ -92,10 +99,11 @@ const coreExports = { 'ComponentRenderer', 'DataResourceDefinition', 'IntentContext', - 'IntentEntry', - 'IntentHandler', - 'PolicyEngineOptions', - 'StateShapeDescriptor', + 'IntentEntry', + 'IntentHandler', + 'PolicyEngineOptions', + 'PolicyDispatchResult', + 'StateShapeDescriptor', 'TypedIntentEntry', ], }, @@ -113,13 +121,15 @@ const coreExports = { 'OPT_OUT_TOKENS', 'ProtocolParseError', 'REQUIRED_TOKENS', - 'SHADOW_TOKENS', - 'SUMMON_FIXED_INSTRUCTIONS', - 'SUMMON_PROTOCOL_VERSION', - 'SUMMON_SYSTEM_PROMPT', - 'SURFACE_AUTHORITY_VALUES', - 'SURFACE_DATA_VALUES', - 'SURFACE_PERSISTENCE_VALUES', + 'SHADOW_TOKENS', + 'SUMMON_ARROW_ARTIFACT_INSTRUCTIONS', + 'SUMMON_FIXED_INSTRUCTIONS', + 'SUMMON_PROTOCOL_VERSION', + 'SUMMON_SYSTEM_PROMPT', + 'SURFACE_AUTHORITY_VALUES', + 'SURFACE_DATA_VALUES', + 'SURFACE_NETWORK_VALUES', + 'SURFACE_PERSISTENCE_VALUES', 'SURFACE_PURPOSE_VALUES', 'SURFACE_RUNTIME_VALUES', 'SectionAccumulator', @@ -150,11 +160,13 @@ const coreExports = { 'formatCapabilityProtocolContract', 'formatTokenContract', 'hasCompleteResourceStateKeys', - 'hintsForContractIssue', - 'inferSurfacePlan', - 'isProtocolLine', - 'normalizeSurfaceCeiling', - 'normalizeSurfacePolicy', + 'hintsForContractIssue', + 'inferSurfacePlan', + 'isArrowSurfaceArtifact', + 'isProtocolLine', + 'normalizeArrowSurfaceArtifact', + 'normalizeSurfaceCeiling', + 'normalizeSurfacePolicy', 'normalizeSurfacePlan', 'suggestSurfacePlan', 'normalizeValidationLimits', @@ -164,18 +176,23 @@ const coreExports = { 'parseTokenValues', 'surfaceContractViewFromCompiledPolicy', 'surfacePlanScriptPolicy', - 'surfacePlanWithinCeiling', - 'SURFACE_TIER_VALUES', - 'validateDirection', + 'surfacePlanWithinCeiling', + 'SURFACE_TIER_VALUES', + 'validateArrowSurfaceArtifact', + 'validateDirection', 'validateHtmlFragment', 'validateProtocolLine', 'withIssueSeverity', ], }, types: { - './_internal/engine/index.js': [ - 'AddLine', - 'CapabilitiesBlockOptions', + './_internal/engine/index.js': [ + 'AddLine', + 'ArtifactLine', + 'ArrowArtifactValidationOptions', + 'ArrowNetworkPolicy', + 'ArrowSurfaceArtifact', + 'CapabilitiesBlockOptions', 'CapabilityBindingSpec', 'CapabilityContractOptions', 'CapabilityKind', @@ -249,8 +266,9 @@ const coreExports = { 'SurfaceContractSurface', 'SurfaceContractTool', 'SurfaceContractView', - 'SurfaceData', - 'SurfacePersistence', + 'SurfaceData', + 'SurfaceNetwork', + 'SurfacePersistence', 'SurfacePolicy', 'SurfacePlan', 'SurfacePlanControls', @@ -303,10 +321,12 @@ const coreExports = { './_internal/host/index.js': [ 'ActionDefinition', 'ApprovalActionDefinition', - 'ApprovalDecision', - 'ApprovalStateKeys', - 'Artifact', - 'CompiledArtifactHtml', + 'ApprovalDecision', + 'ApprovalStateKeys', + 'Artifact', + 'ArrowNetworkPolicy', + 'ArrowSurfaceArtifact', + 'CompiledArtifactHtml', 'CompiledHtmlNodePatch', 'CapabilityDefinition', 'CapabilityRegistry', @@ -331,10 +351,12 @@ const coreExports = { 'FatalMessage', 'IntentContext', 'IntentEntry', - 'IntentHandler', - 'IntentMessage', - 'PolicyEngineOptions', - 'ReadyMessage', + 'IntentHandler', + 'IntentMessage', + 'IntentResultMessage', + 'PolicyEngineOptions', + 'PolicyDispatchResult', + 'ReadyMessage', 'SandboxHandle', 'SandboxInboundMessage', 'SpawnOptions', @@ -368,9 +390,11 @@ const coreExports = { ], }, types: { - './_internal/host/browser.js': [ - 'Artifact', - 'CompiledArtifactHtml', + './_internal/host/browser.js': [ + 'Artifact', + 'ArrowNetworkPolicy', + 'ArrowSurfaceArtifact', + 'CompiledArtifactHtml', 'CompiledHtmlNodePatch', 'ComponentIslandBounds', 'ComponentIslandDescriptor', @@ -380,9 +404,10 @@ const coreExports = { 'ComponentIslandRegistryOptions', 'ComponentIslandSyncContext', 'ComponentsMessage', - 'FatalMessage', - 'IntentMessage', - 'ReadyMessage', + 'FatalMessage', + 'IntentMessage', + 'IntentResultMessage', + 'ReadyMessage', 'SandboxHandle', 'SandboxInboundMessage', 'SpawnOptions', @@ -415,9 +440,10 @@ const coreExports = { './_internal/host/policy.js': [ 'IntentContext', 'IntentEntry', - 'IntentHandler', - 'PolicyEngineOptions', - 'TypedIntentEntry', + 'IntentHandler', + 'PolicyEngineOptions', + 'PolicyDispatchResult', + 'TypedIntentEntry', ], }, }, @@ -440,6 +466,7 @@ const coreExports = { './assets': { values: { './_internal/sandbox-runtime/assets.js': [ + 'arrowRuntimeSource', 'bootstrapSource', 'tokensSource', ], diff --git a/scripts/check-public-api.mjs b/scripts/check-public-api.mjs index aa33718..8d8d858 100644 --- a/scripts/check-public-api.mjs +++ b/scripts/check-public-api.mjs @@ -23,8 +23,11 @@ const expectedRootExports = [ 'defineIntent', 'defineWorkerAction', 'defineWorkerResource', + 'isArrowSurfaceArtifact', + 'normalizeArrowSurfaceArtifact', 'normalizeSurfacePolicy', 'surfaceContractViewFromCompiledPolicy', + 'validateArrowSurfaceArtifact', ].sort(); const expectedServerExports = [ @@ -119,8 +122,12 @@ assertHas('@anarchitecture/summon/envelope', await importDist('summon', 'envelop 'createSurfaceEnvelope', ]); const assets = await importDist('summon', 'assets.js'); -if (typeof assets.bootstrapSource !== 'string' || typeof assets.tokensSource !== 'string') { - throw new Error('@anarchitecture/summon/assets must export bootstrapSource and tokensSource strings'); +if ( + typeof assets.arrowRuntimeSource !== 'string' || + typeof assets.bootstrapSource !== 'string' || + typeof assets.tokensSource !== 'string' +) { + throw new Error('@anarchitecture/summon/assets must export arrowRuntimeSource, bootstrapSource, and tokensSource strings'); } assertHas('@anarchitecture/summon/devtools', await importDist('summon', 'devtools.js'), [ 'createEventStore', diff --git a/scripts/public-api-manifest.json b/scripts/public-api-manifest.json index ce91b3f..f28ff47 100644 --- a/scripts/public-api-manifest.json +++ b/scripts/public-api-manifest.json @@ -21,14 +21,20 @@ "defineIntent", "defineWorkerAction", "defineWorkerResource", + "isArrowSurfaceArtifact", + "normalizeArrowSurfaceArtifact", "normalizeSurfacePolicy", - "surfaceContractViewFromCompiledPolicy" + "surfaceContractViewFromCompiledPolicy", + "validateArrowSurfaceArtifact" ], "types": [ "ActionDefinition", "ApprovalActionDefinition", "ApprovalDecision", "ApprovalStateKeys", + "ArrowArtifactValidationOptions", + "ArrowNetworkPolicy", + "ArrowSurfaceArtifact", "CapabilityBindingSpec", "CapabilityDefinition", "CapabilityKind", @@ -61,6 +67,7 @@ "IntentSpec", "NormalizedSurfacePolicy", "PolicyEngineOptions", + "PolicyDispatchResult", "StateShapeDescriptor", "SurfaceContractComponent", "SurfaceContractLayout", @@ -68,6 +75,7 @@ "SurfaceContractView", "SurfacePersistence", "SurfacePolicy", + "SurfaceNetwork", "SurfacePurpose", "SurfaceTier", "TypedIntentEntry" @@ -75,7 +83,7 @@ }, "./assets": { "file": "assets", - "values": ["bootstrapSource", "tokensSource"], + "values": ["arrowRuntimeSource", "bootstrapSource", "tokensSource"], "types": [] }, "./browser": { @@ -88,6 +96,8 @@ ], "types": [ "Artifact", + "ArrowNetworkPolicy", + "ArrowSurfaceArtifact", "CompiledArtifactHtml", "CompiledHtmlNodePatch", "ComponentIslandBounds", @@ -100,6 +110,7 @@ "ComponentsMessage", "FatalMessage", "IntentMessage", + "IntentResultMessage", "ReadyMessage", "SandboxHandle", "SandboxInboundMessage", @@ -163,11 +174,13 @@ "ProtocolParseError", "REQUIRED_TOKENS", "SHADOW_TOKENS", + "SUMMON_ARROW_ARTIFACT_INSTRUCTIONS", "SUMMON_FIXED_INSTRUCTIONS", "SUMMON_PROTOCOL_VERSION", "SUMMON_SYSTEM_PROMPT", "SURFACE_AUTHORITY_VALUES", "SURFACE_DATA_VALUES", + "SURFACE_NETWORK_VALUES", "SURFACE_PERSISTENCE_VALUES", "SURFACE_PURPOSE_VALUES", "SURFACE_RUNTIME_VALUES", @@ -202,7 +215,9 @@ "hasCompleteResourceStateKeys", "hintsForContractIssue", "inferSurfacePlan", + "isArrowSurfaceArtifact", "isProtocolLine", + "normalizeArrowSurfaceArtifact", "normalizeSurfaceCeiling", "normalizeSurfacePolicy", "normalizeSurfacePlan", @@ -215,6 +230,7 @@ "surfacePlanScriptPolicy", "surfaceContractViewFromCompiledPolicy", "surfacePlanWithinCeiling", + "validateArrowSurfaceArtifact", "validateDirection", "validateHtmlFragment", "validateProtocolLine", @@ -222,6 +238,10 @@ ], "types": [ "AddLine", + "ArtifactLine", + "ArrowArtifactValidationOptions", + "ArrowNetworkPolicy", + "ArrowSurfaceArtifact", "CapabilitiesBlockOptions", "CapabilityBindingSpec", "CapabilityContractOptions", @@ -297,6 +317,7 @@ "SurfaceContractTool", "SurfaceContractView", "SurfaceData", + "SurfaceNetwork", "SurfacePersistence", "SurfacePolicy", "SurfacePlan", @@ -360,6 +381,8 @@ "ApprovalDecision", "ApprovalStateKeys", "Artifact", + "ArrowNetworkPolicy", + "ArrowSurfaceArtifact", "CompiledArtifactHtml", "CompiledHtmlNodePatch", "CapabilityDefinition", @@ -387,7 +410,9 @@ "IntentEntry", "IntentHandler", "IntentMessage", + "IntentResultMessage", "PolicyEngineOptions", + "PolicyDispatchResult", "ReadyMessage", "SandboxHandle", "SandboxInboundMessage", @@ -423,6 +448,7 @@ "IntentEntry", "IntentHandler", "PolicyEngineOptions", + "PolicyDispatchResult", "TypedIntentEntry" ] } diff --git a/tests/safety-smoke.spec.ts b/tests/safety-smoke.spec.ts index 911d712..cb589a8 100644 --- a/tests/safety-smoke.spec.ts +++ b/tests/safety-smoke.spec.ts @@ -91,10 +91,10 @@ test('generate page boots without server credentials', async ({ page }) => { await expect(page.locator('#sandbox')).toHaveAttribute('sandbox', 'allow-scripts'); await expect(page.locator('#go')).toBeEnabled(); await expect(page.locator('#welcome')).toBeVisible(); - await expect(page.locator('#welcome')).toContainText('Host Data Search'); + await expect(page.locator('#welcome')).toContainText('Describe a surface to generate.'); await expect(page.locator('#scenario')).toContainText('Host Data Search'); - await expect(page.locator('#scenario-list')).toBeVisible(); - await expect(page.locator('[data-scenario-id="host-resource-search"][aria-pressed="true"]')).toContainText('Host Data Search'); + await expect(page.locator('#scenario')).toHaveValue('host-resource-search'); + await page.getByRole('button', { name: 'Options' }).click(); await expect(page.locator('#contract-summary [data-contract-row="requested"]')).toContainText( 'Requested surface config', ); @@ -502,21 +502,22 @@ test('generate showcase sends raw SurfacePlan from the advanced override', async await page.goto('/generate'); await expect(page.locator('#scenario')).toContainText('Validation Retry Diagnostics'); await page.locator('#scenario').selectOption('repair-diagnostics'); + await page.getByRole('button', { name: 'Options' }).click(); await page.locator('#token-preset').selectOption('accent-blue'); await expect(page.locator('#prompt')).toHaveValue(/onboarding checklist/); - await expect(page.locator('[data-scenario-id="repair-diagnostics"][aria-pressed="true"]')).toContainText('Validation Retry Diagnostics'); + await expect(page.locator('#scenario')).toHaveValue('repair-diagnostics'); await expect(page.locator('#repair-enabled')).toBeChecked(); await expect(page.locator('#custom-contract-panel')).toBeHidden(); await page.locator('#custom-contract-enabled').check(); await expect(page.locator('#custom-contract-panel')).toBeVisible(); await expect(page.locator('#surface-purpose')).toHaveValue('collect'); - await expect(page.locator('#edit-card')).toBeHidden(); + await page.getByRole('button', { name: 'Close' }).click(); await page.locator('#go').click(); await expect(page.locator('#iframe-status')).toContainText('done'); - await expect(page.locator('#result-toolbar')).toBeVisible(); - await expect(page.locator('#edit-card')).toBeVisible(); + await expect(page.frameLocator('#sandbox').locator('form')).toBeVisible(); + await page.getByRole('button', { name: 'Options' }).click(); await expect(page.locator('#contract-summary [data-contract-row="effective"]')).toContainText( 'collect · declarative', ); @@ -531,6 +532,7 @@ test('generate showcase sends raw SurfacePlan from the advanced override', async data: 'embedded', authority: 'host-action', persistence: 'replayable', + network: 'none', }); expect(captured.capabilities.intents.map((intent: any) => intent.name)).toEqual(['submit']); expect(captured.capabilities.intents[0].surface).toEqual({ authority: 'host-action' }); @@ -742,6 +744,7 @@ test('component islands render in host overlay without widening the sandbox', as await page.locator('#scenario').selectOption('component-islands'); await page.locator('#go').click(); + await expect(page.locator('#iframe-status')).toContainText('done'); await page.locator('#sandbox').scrollIntoViewIfNeeded(); const sandbox = page.frameLocator('#sandbox'); await expect(sandbox.locator('#sandbox-proof')).toContainText('Sandbox placeholder only'); From 476db5bdb7bf1b0c0998588148648ca75c0b5917 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Tue, 16 Jun 2026 14:01:57 -0400 Subject: [PATCH 2/6] Respect Anthropic prompt cache block limit --- apps/server/src/generate-route.test.ts | 10 ++++++++- apps/server/src/model-providers.ts | 29 ++++++++++++++++---------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/server/src/generate-route.test.ts b/apps/server/src/generate-route.test.ts index e8b220d..4e6fe36 100644 --- a/apps/server/src/generate-route.test.ts +++ b/apps/server/src/generate-route.test.ts @@ -176,9 +176,17 @@ test('api generate sends narrowed contract and stream meta shape through package assert.equal(response.status, 200, body); assert.equal(anthropicRequests.length, 1); - const request = anthropicRequests[0] as { model?: string; system?: Array<{ text?: string }>; stream?: boolean }; + const request = anthropicRequests[0] as { + model?: string; + system?: Array<{ text?: string; cache_control?: unknown }>; + stream?: boolean; + }; assert.equal(request.stream, true); assert.equal(request.model, 'claude-opus-4-8'); + assert.ok( + (request.system ?? []).filter((block) => block.cache_control !== undefined).length <= 4, + 'Anthropic accepts at most four system blocks with cache_control', + ); const systemText = request.system?.map((block) => block.text ?? '').join('\n') ?? ''; assert.match(systemText, /Search host-owned dinner data/); assert.match(systemText, /host-resource/); diff --git a/apps/server/src/model-providers.ts b/apps/server/src/model-providers.ts index 0337ecd..a150d93 100644 --- a/apps/server/src/model-providers.ts +++ b/apps/server/src/model-providers.ts @@ -512,7 +512,7 @@ function createAnthropicProvider(env: NodeJS.ProcessEnv): ModelProviderAdapter { ...(selection.options.effort ? { output_config: { effort: selection.options.effort } } : {}), - system: request.promptBlocks.map(anthropicSystemBlock), + system: anthropicSystemBlocks(request.promptBlocks), messages: [{ role: 'user', content: request.prompt }], }); @@ -541,14 +541,10 @@ function createAnthropicProvider(env: NodeJS.ProcessEnv): ModelProviderAdapter { const repairMessage = await ensureClient().messages.create({ model: selection.generationModel, max_tokens: selection.options.repairMaxOutputTokens, - system: [ - ...request.promptBlocks.map(anthropicSystemBlock), - { - type: 'text', - text: repairModeSystemText(), - cache_control: { type: 'ephemeral' }, - }, - ], + system: anthropicSystemBlocks([ + ...request.promptBlocks, + { id: 'repair-mode', text: repairModeSystemText(), cache: 'ephemeral' }, + ]), messages: [{ role: 'user', content: request.prompt }], }); return extractAnthropicText(repairMessage.content); @@ -812,8 +808,19 @@ function createGeminiProvider(env: NodeJS.ProcessEnv): ModelProviderAdapter { }; } -function anthropicSystemBlock(block: ContractPromptBlock): Anthropic.TextBlockParam { - if (block.cache === 'ephemeral') { +const ANTHROPIC_MAX_CACHE_CONTROL_BLOCKS = 4; + +function anthropicSystemBlocks(blocks: ContractPromptBlock[]): Anthropic.TextBlockParam[] { + let cacheBlocksRemaining = ANTHROPIC_MAX_CACHE_CONTROL_BLOCKS; + return blocks.map((block) => { + const shouldCache = block.cache === 'ephemeral' && cacheBlocksRemaining > 0; + if (shouldCache) cacheBlocksRemaining -= 1; + return anthropicSystemBlock(block, shouldCache); + }); +} + +function anthropicSystemBlock(block: ContractPromptBlock, cache: boolean): Anthropic.TextBlockParam { + if (cache) { return { type: 'text', text: block.text, From f4f70924106afb3ba89999eceb089e4ab8b02637 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Tue, 16 Jun 2026 21:44:25 -0400 Subject: [PATCH 3/6] Make Summon Arrow-only --- apps/demo/src/App.tsx | 2 - apps/demo/src/components/chrome.tsx | 3 +- apps/demo/src/components/ui.tsx | 2 +- apps/demo/src/pages/BatchPage.tsx | 13 +- apps/demo/src/pages/FragmentComparePage.tsx | 444 ------------ apps/demo/src/pages/LandingPage.tsx | 17 +- apps/demo/src/pages/generate/GeneratePage.tsx | 64 +- .../generate/components/ChildSurface.tsx | 7 +- .../generate/components/ContractInspector.tsx | 47 +- .../generate/components/GenerationStage.tsx | 1 - apps/demo/src/pages/generate/devtools.ts | 6 +- .../pages/generate/hooks/useGenerationRuns.ts | 138 +--- .../pages/generate/hooks/useSurfaceStream.ts | 92 +-- .../demo/src/pages/generate/modelProviders.ts | 4 +- .../demo/src/pages/generate/surfaceHelpers.ts | 25 +- apps/demo/src/pages/generate/types.ts | 10 - apps/demo/src/showcase.test.ts | 6 +- apps/demo/src/showcase.ts | 55 +- apps/server/src/generate-route.test.ts | 157 ++-- apps/server/src/ghost-adapter.test.ts | 23 +- apps/server/src/ghost-adapter.ts | 27 +- apps/server/src/main.ts | 173 +---- apps/server/src/model-providers.ts | 102 +-- apps/server/src/surface-plan.test.ts | 2 +- apps/surface-gallery/src/main.ts | 27 +- apps/surface-gallery/src/presets.ts | 4 +- .../tests/gallery-smoke.spec.ts | 252 ++++--- docs/adoption/debugging.md | 143 ++-- docs/adoption/integration.md | 78 +- docs/adoption/package-consumption.md | 164 ++--- packages/devtools/src/types.ts | 25 +- packages/engine/src/arrow-artifact.ts | 23 +- packages/engine/src/contracts.ts | 38 +- packages/engine/src/index.ts | 33 +- packages/engine/src/prompt.ts | 208 ++---- packages/engine/src/protocol-hardener.ts | 679 ++---------------- packages/engine/src/protocol.ts | 102 +-- packages/engine/src/runtime-validator.ts | 10 +- .../src/runtime-validator/binding-rules.ts | 435 ----------- .../src/runtime-validator/capabilities.ts | 116 --- .../src/runtime-validator/component-rules.ts | 218 ------ .../src/runtime-validator/html-parser.ts | 134 ---- packages/engine/src/runtime-validator/html.ts | 417 ----------- .../engine/src/runtime-validator/protocol.ts | 190 +---- .../src/runtime-validator/safety-rules.ts | 173 ----- .../src/runtime-validator/style-rules.ts | 38 - .../engine/src/runtime-validator/types.ts | 81 --- packages/engine/src/section-accumulator.ts | 468 ------------ packages/engine/src/stream-graph.ts | 560 ++------------- packages/engine/src/surface-policy.ts | 8 +- packages/engine/test/contracts.test.ts | 37 +- .../engine/test/protocol-hardener.test.ts | 409 ++--------- packages/engine/test/runtime-compiler.test.ts | 74 -- .../test/runtime-validator-bindings.test.ts | 324 --------- .../test/runtime-validator-protocol.test.ts | 99 ++- .../test/runtime-validator-safety.test.ts | 114 --- .../runtime-validator-surface-style.test.ts | 115 --- .../engine/test/section-accumulator.test.ts | 238 ------ packages/engine/test/stream-graph.test.ts | 200 ++---- packages/engine/test/surface-contract.test.ts | 6 +- packages/engine/test/surface-policy.test.ts | 10 +- packages/host/src/browser.ts | 3 - packages/host/src/index.ts | 3 - packages/host/src/sandbox-spawner.ts | 122 +--- packages/host/src/surface-envelope.ts | 85 +-- packages/host/src/surface-stream.ts | 93 +-- packages/host/src/types.ts | 33 +- .../host/test/capability-registry.test.ts | 52 +- packages/host/test/surface-stream.test.ts | 291 +++----- packages/react/src/index.ts | 155 +--- packages/sandbox-runtime/src/bootstrap.js | 391 +++------- packages/server/src/compat.ts | 49 -- packages/server/src/edit.ts | 30 - packages/server/src/index.ts | 8 - packages/server/src/plan.ts | 22 +- packages/server/src/repair.ts | 187 ----- packages/server/src/runner.ts | 1 - packages/server/src/session.ts | 98 +-- packages/server/src/summary.ts | 10 - packages/server/src/types.ts | 50 -- packages/server/test/agent-broker.test.ts | 21 +- .../test/generate-surface-stream.test.ts | 631 ---------------- .../test/run-surface-generation.test.ts | 375 ++++++++++ packages/summon-server/src/index.ts | 7 - packages/summon/src/browser.ts | 3 - packages/summon/src/index.ts | 4 - scripts/build-public-packages.mjs | 39 +- scripts/check-public-api.mjs | 3 - scripts/eval-directions.ts | 112 ++- scripts/public-api-manifest.json | 59 +- scripts/smoke-public-packages.mjs | 14 +- tests/safety-smoke.spec.ts | 340 ++++----- 92 files changed, 1811 insertions(+), 9150 deletions(-) delete mode 100644 apps/demo/src/pages/FragmentComparePage.tsx delete mode 100644 packages/engine/src/runtime-validator/binding-rules.ts delete mode 100644 packages/engine/src/runtime-validator/capabilities.ts delete mode 100644 packages/engine/src/runtime-validator/component-rules.ts delete mode 100644 packages/engine/src/runtime-validator/html-parser.ts delete mode 100644 packages/engine/src/runtime-validator/html.ts delete mode 100644 packages/engine/src/runtime-validator/safety-rules.ts delete mode 100644 packages/engine/src/runtime-validator/style-rules.ts delete mode 100644 packages/engine/src/section-accumulator.ts delete mode 100644 packages/engine/test/runtime-compiler.test.ts delete mode 100644 packages/engine/test/runtime-validator-bindings.test.ts delete mode 100644 packages/engine/test/runtime-validator-safety.test.ts delete mode 100644 packages/engine/test/runtime-validator-surface-style.test.ts delete mode 100644 packages/engine/test/section-accumulator.test.ts delete mode 100644 packages/server/src/compat.ts delete mode 100644 packages/server/src/edit.ts delete mode 100644 packages/server/src/repair.ts delete mode 100644 packages/server/test/generate-surface-stream.test.ts create mode 100644 packages/server/test/run-surface-generation.test.ts 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/components/chrome.tsx b/apps/demo/src/components/chrome.tsx index 08ec519..58be9db 100644 --- a/apps/demo/src/components/chrome.tsx +++ b/apps/demo/src/components/chrome.tsx @@ -4,13 +4,12 @@ import { cn } from '../lib/cn.js'; import { elevatedPanelClass, panelHeaderClass, pageWidthClass, StatusText } from './ui.js'; export interface AppNavProps { - active?: 'generate' | 'batch' | 'fragment-compare'; + active?: 'generate' | 'batch'; } const navItems = [ { id: 'generate', label: 'Generate', href: '/generate' }, { id: 'batch', label: 'Batch', href: '/batch' }, - { id: 'fragment-compare', label: 'Fragment compare', href: '/fragment-compare' }, ] as const; const navItemBaseClass = diff --git a/apps/demo/src/components/ui.tsx b/apps/demo/src/components/ui.tsx index f5f36ca..814106a 100644 --- a/apps/demo/src/components/ui.tsx +++ b/apps/demo/src/components/ui.tsx @@ -107,7 +107,7 @@ export function statusToneClass(status?: string): string { } export function devtoolsEventKindClass(kind: string): string { - if (kind === 'sandbox-spawned' || kind === 'sandbox-ready') return 'text-good'; + if (kind === 'sandbox-spawned' || kind === 'sandbox-ready' || kind === 'rendered') return 'text-good'; if (kind === 'sandbox-fatal' || kind === 'sandbox-disposed' || kind === 'intent-rejected' || kind === 'protocol-parse-error') { return 'text-danger'; } diff --git a/apps/demo/src/pages/BatchPage.tsx b/apps/demo/src/pages/BatchPage.tsx index 47e1b81..b59535e 100644 --- a/apps/demo/src/pages/BatchPage.tsx +++ b/apps/demo/src/pages/BatchPage.tsx @@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { SummonSurface, type SummonSurfaceHandle } from '@anarchitecture/summon-react'; import { type CapabilityPack } from '@anarchitecture/summon'; import { + isArrowSurfaceArtifact, parseProtocolLine, - SectionAccumulator, type ValidationCapability, } from '@anarchitecture/summon/engine'; import defaultTokensSource from '@anarchitecture/summon/tokens.css?raw'; @@ -93,7 +93,6 @@ function BatchTile({ useEffect(() => { let cancelled = false; - const acc = new SectionAccumulator(); const start = performance.now(); let byteCount = 0; @@ -103,7 +102,6 @@ function BatchTile({ setStatus('streaming'); setBytes(0); setIntent(null); - const renderIncrementally = run.interactivity === 'static'; try { const res = await fetch('/api/generate', { @@ -133,9 +131,8 @@ function BatchTile({ if (parsed.op === 'meta' && parsed.path === '/agent-policy-resolution') { setIntent({ text: `agent policy: ${summarizeAgentMeta(parsed.value)}` }); } - const changed = acc.apply(parsed); - if (renderIncrementally && changed && acc.hasAnySection()) { - surfaceRef.current?.render(acc.compose()); + if (parsed.op === 'artifact' && isArrowSurfaceArtifact(parsed.value)) { + surfaceRef.current?.renderArtifact(parsed.value); } }; @@ -155,9 +152,6 @@ function BatchTile({ } const tail = buffer.trim(); if (tail) processLine(tail); - if (!renderIncrementally && acc.hasAnySection()) { - surfaceRef.current?.render(acc.compose()); - } const ms = Math.round(performance.now() - start); if (cancelled) return; @@ -203,7 +197,6 @@ function BatchTile({ ref={surfaceRef} title={run.prompt} className={cn('block w-full border-0 bg-surface-raised', stacked ? 'h-[880px]' : 'h-[760px]')} - html="" tokensSource={run.tokensCss} capabilityRegistry={registry} grantedCapabilities={validationCapabilities ?? undefined} diff --git a/apps/demo/src/pages/FragmentComparePage.tsx b/apps/demo/src/pages/FragmentComparePage.tsx deleted file mode 100644 index cce7294..0000000 --- a/apps/demo/src/pages/FragmentComparePage.tsx +++ /dev/null @@ -1,444 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { SummonSurface, type SummonSurfaceHandle } from '@anarchitecture/summon-react'; -import { consumeSurfaceStream } from '@anarchitecture/summon/browser'; -import type { - HtmlNodePatch, - ProtocolLine, - StreamGraphSnapshot, -} from '@anarchitecture/summon/engine'; -import tokensSource from '@anarchitecture/summon/tokens.css?raw'; -import { AppNav, LogView, PageHeader } from '../components/chrome.js'; -import { Button, fieldLabelClass, pageWidthClass, statusToneClass, textareaClass, logToneClass } from '../components/ui.js'; -import { cn } from '../lib/cn.js'; - -type FragmentSide = 'section' | 'html-node-v0'; -type PromptComplexity = 'simple' | 'medium' | 'complex'; -type PromptUseCase = 'status' | 'decision' | 'operations' | 'customer'; - -interface PromptPreset { - id: string; - useCase: PromptUseCase; - complexity: PromptComplexity; - title: string; - prompt: string; -} - -interface TargetMetrics { - lines: number; - bytes: number; - nodeCommits: number; - firstNodeAt: number | null; - graph: StreamGraphSnapshot | null; -} - -const promptComplexities: Array<{ id: PromptComplexity; label: string }> = [ - { id: 'simple', label: 'Simple' }, - { id: 'medium', label: 'Medium' }, - { id: 'complex', label: 'Complex' }, -]; - -const promptUseCases: Array<{ id: PromptUseCase; label: string; description: string }> = [ - { id: 'status', label: 'Status surfaces', description: 'Dashboards, recaps, health checks' }, - { id: 'decision', label: 'Decision briefs', description: 'Tradeoffs, comparisons, recommendations' }, - { id: 'operations', label: 'Operational workflows', description: 'Triage, control rooms, execution plans' }, - { id: 'customer', label: 'Customer follow-up', description: 'Merchant notes, outreach, account plans' }, -]; - -const promptPresets: PromptPreset[] = [ - { id: 'status-simple-launch-pulse', useCase: 'status', complexity: 'simple', title: 'Launch Pulse', prompt: 'Show me a clean end-of-day sales snapshot for a coffee shop.' }, - { id: 'status-medium-shift-recap', useCase: 'status', complexity: 'medium', title: 'Shift Recap', prompt: 'Show me a weekly cafe performance recap with sales, staffing, inventory, customer sentiment, and next actions.' }, - { id: 'status-complex-portfolio-review', useCase: 'status', complexity: 'complex', title: 'Portfolio Review', prompt: 'Show me a quarterly portfolio review across five product initiatives, including progress, spend, adoption, dependencies, risks, staffing pressure, decisions needed, and an executive verdict.' }, - { id: 'decision-simple-vendor-pick', useCase: 'decision', complexity: 'simple', title: 'Vendor Pick', prompt: 'Compare two weekend promo ideas for a coffee shop and recommend one.' }, - { id: 'decision-medium-roadmap-tradeoff', useCase: 'decision', complexity: 'medium', title: 'Roadmap Tradeoff', prompt: 'Compare three checkout roadmap bets with customer impact, engineering effort, risk, confidence, and a recommendation.' }, - { id: 'decision-complex-pricing-strategy', useCase: 'decision', complexity: 'complex', title: 'Pricing Strategy', prompt: 'Show me a pricing strategy decision room for a SaaS billing change, with rollout options, revenue forecast, churn risk, merchant segments, support impact, confidence, and an executive recommendation.' }, - { id: 'operations-simple-support-snapshot', useCase: 'operations', complexity: 'simple', title: 'Support Snapshot', prompt: 'Make a packing checklist for a Saturday pop-up booth.' }, - { id: 'operations-medium-incident-command', useCase: 'operations', complexity: 'medium', title: 'Incident Command', prompt: 'Show me a triage board for today\'s support queue, with priority groups, aging tickets, escalations, owners, and next actions.' }, - { id: 'operations-complex-migration-control', useCase: 'operations', complexity: 'complex', title: 'Migration Control', prompt: 'Show me a migration control room for moving 42 merchants from a legacy invoicing workflow to a new billing platform, including cohorts, blockers, data quality checks, support load, rollback criteria, comms, and day-by-day execution plan.' }, - { id: 'customer-simple-sales-note', useCase: 'customer', complexity: 'simple', title: 'Sales Note', prompt: 'Draft a follow-up note after a restaurant POS demo.' }, - { id: 'customer-medium-renewal-prep', useCase: 'customer', complexity: 'medium', title: 'Renewal Prep', prompt: 'Prepare a renewal prep brief for a neighborhood grocer, with usage wins, adoption gaps, billing concerns, stakeholders, risks, and meeting goals.' }, - { id: 'customer-complex-save-plan', useCase: 'customer', complexity: 'complex', title: 'Account Save Plan', prompt: 'Show me a 30-day save plan for an at-risk enterprise merchant, with account health, executive relationships, product pain, support history, commercial exposure, competitive threat, negotiation plan, and timeline.' }, -]; - -const modelOptions = { - maxOutputTokens: 16000, - repairMaxOutputTokens: 4000, - anthropicThinking: 'off', - effort: 'low', -} as const; - -const blankArtifact = - ''; - -const matrixHeaderClass = - 'flex min-h-7 items-center text-xs font-bold uppercase tracking-normal text-ink-soft max-[820px]:hidden'; - -const useCaseCellClass = - 'min-w-0 rounded-card border border-line-hover bg-surface-muted p-3 max-[820px]:mt-1.5'; - -const presetButtonClass = - 'group min-h-[92px] min-w-0 rounded-card p-3 text-left [font:inherit] tracking-normal text-ink transition-[background-color,border-color,box-shadow,transform] duration-150 focus-visible:border-line-strong focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[var(--focus-ring)]'; - -const idlePresetButtonClass = - 'border border-line-hover bg-surface-muted hover:-translate-y-px hover:border-line-strong hover:bg-surface-raised'; - -const activePresetButtonClass = - 'border border-line-strong bg-surface-raised shadow-card'; - -function lineClass(line: ProtocolLine): string { - if (line.op === 'add') return 'op-add'; - if (line.op === 'set') return 'op-set'; - return 'op-meta'; -} - -function fragmentLogToneClass(tone: string): string { - return tone === 'op-meta' || tone === 'info' ? 'text-ink-soft' : logToneClass(tone); -} - -function agentMetaLabel(value: unknown): string { - if (!value || typeof value !== 'object') return ''; - const item = value as Record; - const policy = item.surfacePolicy && typeof item.surfacePolicy === 'object' - ? item.surfacePolicy as Record - : null; - if (policy) { - const tier = typeof policy.tier === 'string' ? policy.tier : 'policy'; - const purpose = typeof policy.purpose === 'string' ? policy.purpose : 'inform'; - return `${tier}/${purpose}`; - } - const purpose = typeof item.purpose === 'string' ? item.purpose : 'intent'; - const interaction = typeof item.interaction === 'string' ? item.interaction : 'none'; - return `${purpose}/${interaction}`; -} - -function lineLabel(line: ProtocolLine): string { - if (line.op === 'meta' && line.path === '/agent-intent') return `agent intent ${agentMetaLabel(line.value)}`; - if (line.op === 'meta' && line.path === '/agent-policy-resolution') return `agent policy ${agentMetaLabel(line.value)}`; - if (line.op === 'meta' && line.path === '/status') return `meta /status ${String(line.value)}`; - if (line.op === 'meta' && line.path === '/thinking') return 'meta /thinking ...'; - if (line.op === 'add' && line.path.includes('/node/')) { - return `${line.op} ${line.path}${line.parent ? ` parent=${line.parent}` : ''}`; - } - return `${line.op} ${line.path}`; -} - -function metricsText(metrics: TargetMetrics, side: FragmentSide): string { - const sections = metrics.graph?.sections ?? []; - const presentSections = sections.filter((section) => section.present).length; - const presentNodes = sections.reduce((sum, section) => sum + (section.presentNodeCount ?? 0), 0); - const nodePart = side === 'html-node-v0' - ? ` · ${presentNodes} nodes · ${metrics.nodeCommits} patches${metrics.firstNodeAt === null ? '' : ` · first ${(metrics.firstNodeAt / 1000).toFixed(1)}s`}` - : ''; - return `${metrics.lines} lines · ${metrics.bytes.toLocaleString()} B · ${presentSections} sections${nodePart}`; -} - -async function* countedStream( - stream: ReadableStream, - onBytes: (count: number) => void, -): AsyncGenerator { - const reader = stream.getReader(); - try { - while (true) { - const { value, done } = await reader.read(); - if (done) return; - if (value) { - onBytes(value.byteLength); - yield value; - } - } - } finally { - reader.releaseLock(); - } -} - -function CompareTargetPane({ - side, - run, - onDone, -}: { - side: FragmentSide; - run: { id: number; prompt: string; signal: AbortSignal } | null; - onDone: (side: FragmentSide, ok: boolean) => void; -}) { - const surfaceRef = useRef(null); - const startedAtRef = useRef(0); - const [status, setStatus] = useState('idle'); - const [logs, setLogs] = useState>([]); - const [metrics, setMetrics] = useState({ - lines: 0, - bytes: 0, - nodeCommits: 0, - firstNodeAt: null, - graph: null, - }); - - const logLine = useCallback((cls: string, text: string) => { - setLogs((items) => [...items.slice(-139), { cls, text }]); - }, []); - - useEffect(() => { - if (!run) return; - const currentRun = run; - let cancelled = false; - startedAtRef.current = performance.now(); - setStatus('starting'); - setLogs([]); - setMetrics({ lines: 0, bytes: 0, nodeCommits: 0, firstNodeAt: null, graph: null }); - surfaceRef.current?.render(blankArtifact); - logLine('info', side === 'html-node-v0' - ? 'POST /api/generate fragmentMode=html-node-v0' - : 'POST /api/generate fragmentMode=section'); - - const applyNodePatch = (patch: HtmlNodePatch) => { - setMetrics((current) => ({ - ...current, - nodeCommits: current.nodeCommits + 1, - firstNodeAt: current.firstNodeAt ?? performance.now() - startedAtRef.current, - })); - surfaceRef.current?.patchNode(patch); - logLine('op-add', `patch node ${patch.sectionId}/${patch.nodeId}${patch.parentId ? ` parent=${patch.parentId}` : ''}`); - }; - - async function runTarget() { - await new Promise((resolve) => window.setTimeout(resolve, 0)); - try { - const res = await fetch('/api/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - prompt: currentRun.prompt, - directionId: '', - mode: 'static', - modelOptions, - agent: { enabled: true }, - scriptPolicy: 'forbid', - ...(side === 'html-node-v0' ? { fragmentMode: 'html-node-v0' } : {}), - }), - signal: currentRun.signal, - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - if (!res.body) throw new Error('No response body'); - setStatus('streaming'); - let byteTotal = 0; - const result = await consumeSurfaceStream(countedStream(res.body, (count) => { - byteTotal += count; - setMetrics((current) => ({ ...current, bytes: byteTotal })); - }), { - mode: 'static', - renderMode: 'live', - onLine: (line, context) => { - setMetrics((current) => ({ - ...current, - lines: current.lines + 1, - graph: context.graph.snapshot(), - })); - logLine(lineClass(line), lineLabel(line)); - }, - onMeta: (line) => { - if (line.path === '/experimental-fragments') { - logLine('info', `experimental ${JSON.stringify(line.value)}`); - } - if (line.path === '/validation-blocked' || line.path === '/protocol-skip') { - logLine('op-error', `${line.path} ${JSON.stringify(line.value)}`); - } - }, - onGraph: (graph) => { - setMetrics((current) => ({ ...current, graph })); - }, - onRenderHtml: (html) => { - surfaceRef.current?.render(html); - }, - onNodePatch: applyNodePatch, - onParseError: (raw) => { - logLine('op-error', `parse ${raw.slice(0, 120)}`); - }, - }); - if (cancelled) return; - const elapsed = ((performance.now() - startedAtRef.current) / 1000).toFixed(1); - setStatus(`done ${elapsed}s`); - setMetrics((current) => ({ ...current, graph: result.streamGraph })); - onDone(side, true); - } catch (err) { - if (cancelled) return; - if ((err as Error).name === 'AbortError') { - setStatus('aborted'); - logLine('op-error', 'aborted'); - onDone(side, false); - return; - } - const message = err instanceof Error ? err.message : String(err); - setStatus('error'); - logLine('op-error', message); - onDone(side, false); - } - } - - void runTarget(); - return () => { - cancelled = true; - }; - }, [logLine, onDone, run, side]); - - return ( -
-
-
- {side === 'section' ? 'Sections' : 'HTML Nodes'} - {side === 'section' ? 'current behavior' : 'experimental html-node-v0'} -
- - {status} - -
- -
- {metricsText(metrics, side)} -
- - {logs.map((log, index) =>
{log.text}
)} -
-
- ); -} - -export function FragmentComparePage() { - const [prompt, setPrompt] = useState('Show me a clean end-of-day sales snapshot for a coffee shop.'); - const [activePresetId, setActivePresetId] = useState( - promptPresets.find((preset) => preset.prompt === 'Show me a clean end-of-day sales snapshot for a coffee shop.')?.id ?? null, - ); - const [run, setRun] = useState<{ id: number; prompt: string; signal: AbortSignal } | null>(null); - const [running, setRunning] = useState(false); - const [summary, setSummary] = useState('Idle'); - const abortRef = useRef(null); - const doneRef = useRef(new Map()); - - const onDone = useCallback((side: FragmentSide, ok: boolean) => { - doneRef.current.set(side, ok); - if (doneRef.current.size !== 2) return; - const complete = [...doneRef.current.values()].every(Boolean); - setSummary(complete ? 'Both streams complete' : 'Run stopped or failed'); - setRunning(false); - abortRef.current = null; - }, []); - - function runBoth() { - const value = prompt.trim(); - if (!value) { - setSummary('Add a prompt first'); - return; - } - abortRef.current?.abort(); - const abort = new AbortController(); - abortRef.current = abort; - doneRef.current = new Map(); - setSummary('Running both streams'); - setRunning(true); - setRun({ id: Date.now(), prompt: value, signal: abort.signal }); - } - - function updatePrompt(value: string) { - setPrompt(value); - setActivePresetId(promptPresets.find((preset) => preset.prompt === value)?.id ?? null); - } - - return ( - <> - - {summary}} - /> -
{ - event.preventDefault(); - runBoth(); - }}> -
-
- Sample prompt matrix - Rows are Summon use cases. Columns are complexity. -
-
-
Use case
- {promptComplexities.map((complexity) => ( -
{complexity.label}
- ))} - {promptUseCases.map((useCase) => ( - - ))} -
-
-