Repository navigation
feat(interactive): M2 executable Program ABI V1 and immutable-byte loader #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7038fd1
8e7e957
e9fc58d
79fd373
7578cc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import { createHash } from 'node:crypto'; | ||
| import { readFileSync } from 'node:fs'; | ||
| import { evaluateWhenAst } from '../../when/evaluate.mjs'; | ||
| import { validateInteractiveIR } from './validate-ir.mjs'; | ||
|
|
||
| const compilerVersion = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')).version; | ||
| const SHA = value => 'sha256:' + createHash('sha256').update(value).digest('hex'); | ||
|
|
||
| // The generated program includes the original Standard evaluator *implementation*. | ||
| // Only requireBoolean is a tiny standalone wrapper for its existing free binding. | ||
| function requireBoolean(value) { | ||
| if (typeof value !== 'boolean') throw Object.assign(new Error('When operand and final result must be boolean'), { code: 'FK_R002' }); | ||
| return value; | ||
| } | ||
|
|
||
| /** Emits executable, synchronous JavaScript. No application code is run while compiling. */ | ||
| function advance(view, event) { | ||
| if (!view || !event || event.schemaVersion !== 1 || view.blockId !== event.forBlockId) { | ||
| throw new TypeError('Event does not target the current checkpoint block'); | ||
| } | ||
| const blockId = view.blockId; | ||
| const phase = view.phase; | ||
| const block = programTable.blocks.find(item => item.id === blockId); | ||
| if (!block || block.phase !== phase || !block.eventKinds.includes(event.kind)) { | ||
| throw new TypeError('Uncertified event or invalid stage'); | ||
| } | ||
| const nodeId = block.nodeId; | ||
| const record = programTable.nodes[nodeId]; | ||
| const none = { kind: 'none' }; | ||
| const proposal = (kind, toBlockId, stackEffect = none, mergeIntent = none) => | ||
| ({ kind, fromBlockId: blockId, nodeId, toBlockId, stackEffect, mergeIntent }); | ||
| const fail = code => ({ | ||
| kind: 'raise-error', fromBlockId: blockId, nodeId, | ||
| causeCode: code || 'FK_R002', stackEffect: none, mergeIntent: none, | ||
| }); | ||
| if (event.kind === 'deterministic-failure') return fail(event.error?.code); | ||
| const to = (node, name) => node + '::' + name; | ||
| if (phase === 'enter') { | ||
| if (record.kind === 'agent') { | ||
| if (!Number.isSafeInteger(event.activation) || event.activation < 1) { | ||
| throw new TypeError('Missing certified Agent activation'); | ||
| } | ||
| const frame = { | ||
| kind: 'agent', nodeId, activation: event.activation, | ||
| attempt: 1, phase: 'prepare', reasons: [], overviewStep: event.activation, | ||
| }; | ||
| return proposal('move', to(nodeId, 'prepare'), {kind: 'push', frame}); | ||
| } | ||
| return proposal('move', to(nodeId, 'route')); | ||
| } | ||
| if (record.kind === 'condition' && phase === 'route') { | ||
| try { | ||
| for (const branch of record.metadata.branches) { | ||
| if (evaluateWhenAst(branch.ast, view.state)) { | ||
| return proposal('move', to(branch.next, 'enter')); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| if (error?.code === 'FK_R002') return fail('FK_R002'); | ||
| throw error; // trusted Coordinator classifies unexpected program failures | ||
| } | ||
| return fail('FK_R002'); | ||
| } | ||
| if (record.kind === 'terminal' && phase === 'route') { | ||
| return { | ||
| kind: 'terminal', fromBlockId: blockId, nodeId, | ||
| terminal: {status: record.metadata.status, nodeId}, | ||
| stackEffect: none, mergeIntent: none, | ||
| }; | ||
| } | ||
| if (record.kind !== 'agent') throw new TypeError('Unsupported P0-A block'); | ||
| const frame = view.controlStack[view.controlStack.length - 1]; | ||
| if (!frame || frame.kind !== 'agent' || frame.nodeId !== nodeId) { | ||
| throw new TypeError('Missing certified Agent control frame'); | ||
| } | ||
| if (phase === 'prepare') return proposal('prepare-pending', to(nodeId, 'dispatch-ready')); | ||
| if (phase === 'dispatch-ready') return proposal('publish-pending', to(nodeId, 'awaiting')); | ||
| if (phase === 'validate') { | ||
| if (event.kind === 'validation-passed') { | ||
| if (view.candidate?.kind !== 'agent') throw new TypeError('Verified Agent candidate not committed'); | ||
| return proposal('commit-candidate', to(nodeId, 'commit')); | ||
| } | ||
| if (event.kind === 'validation-rejected' || event.kind === 'agent-call-failed') { | ||
| const attempts = record.metadata.retries + 1; | ||
| if (frame.attempt >= attempts) return fail('FK_R004'); | ||
| return proposal('retry-agent', to(nodeId, 'prepare'), | ||
| {kind: 'update', expectedKind: 'agent', expectedNodeId: nodeId, | ||
| field: 'attempt', value: frame.attempt + 1}); | ||
| } | ||
| throw new TypeError('Agent validation event is not ready for Program advancement'); | ||
| } | ||
| if (phase === 'commit') { | ||
| if (view.candidate?.kind !== 'agent') throw new TypeError('Missing committed Agent candidate'); | ||
| return proposal('commit-state', to(nodeId, 'route'), none, | ||
| {kind:'commit-candidate', requestId:view.candidate.requestId}); | ||
| } | ||
| if (phase === 'route') { | ||
| return proposal('move', to(record.metadata.next, 'enter'), | ||
| {kind:'pop', expectedKind:'agent', expectedNodeId:nodeId}); | ||
| } | ||
| throw new TypeError('Illegal Program advancement at awaiting/unknown phase'); | ||
| } | ||
| function freezeData(value) { | ||
| if (value && typeof value === 'object') { | ||
| for (const key of Object.keys(value)) freezeData(value[key]); | ||
| Object.freeze(value); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| export function emitInteractive(ir, options = {}) { | ||
| const validation = validateInteractiveIR(ir); | ||
| if (!validation.ok) { | ||
| return Object.freeze({ code:null, programIdentity:null, diagnostics:validation.diagnostics }); | ||
| } | ||
| if (options === null || typeof options !== 'object' || Array.isArray(options)) { | ||
| throw new TypeError('Emit options must be an object'); | ||
| } | ||
| const manifest = { | ||
| artifactFormat:'FlowkitInteractiveProgramV1', | ||
| identity:{ | ||
| workflowId: ir.workflowId, workflowVersion:ir.workflowVersion, | ||
| sourceHash: 'sha256:' + ir.sourceHash, compilerVersion, | ||
| programAbiVersion:1, semanticsVersion:'interactive-state-machine-v1', | ||
| checkpointSchemaVersion:1, canonicalizationVersion:'jcs-rfc8785-v1', | ||
| implementedStage:'P0-A', | ||
| }, | ||
| }; | ||
| const agents = Object.values(ir.nodes).some(node => node.kind === 'agent'); | ||
| const bindings = agents ? [ | ||
| {id:'runtime.validateResult',version:'1',mode:'pure-local',kind:'agent-validation'}, | ||
| {id:'runtime.skillResolver',version:'1',mode:'trusted-read',kind:'skill'}, | ||
| ] : []; | ||
| const table = { | ||
| schemaVersion:1, entryBlockId:ir.entryBlockId, blocks:ir.blocks, | ||
| nodes:ir.nodes, requiredBindings:bindings, | ||
| }; | ||
| const code = [ | ||
| '// @generated by FlowKit Interactive — P0-A / Program ABI V1', | ||
| '// This trusted compiler output MUST be loaded from verified immutable bytes.', | ||
| requireBoolean.toString(), | ||
| evaluateWhenAst.toString(), | ||
| freezeData.toString(), | ||
| 'export const interactiveProgram = freezeData(' + JSON.stringify(manifest) + ');', | ||
| 'export const programTable = freezeData(' + JSON.stringify(table) + ');', | ||
| 'export ' + advance.toString(), | ||
| '', | ||
| ].join('\n'); | ||
| const programIdentity = Object.freeze({ | ||
| ...manifest.identity, artifactHash:SHA(Buffer.from(code, 'utf8')), | ||
| }); | ||
| return Object.freeze({code, programIdentity, diagnostics:Object.freeze([])}); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import {readFile} from 'node:fs/promises'; | ||
| import {mkdir,open,rename,unlink} from 'node:fs/promises'; | ||
| import {randomUUID} from 'node:crypto'; | ||
| import path from 'node:path'; | ||
| import {analyzeSource} from '../compiler/analyze.mjs'; | ||
| import {lowerInteractive} from '../compiler/interactive/lower.mjs'; | ||
| import {emitInteractive} from '../compiler/interactive/emit.mjs'; | ||
|
|
||
| /** Internal M2 compile API; the exported SDK subpath is introduced in M4. */ | ||
| export async function compileInteractive(file, options) { | ||
| if (typeof file !== 'string' || !/\.workflow\.(js|ts)$/.test(file)) { | ||
| throw new TypeError('Expected .workflow.js or .workflow.ts file'); | ||
| } | ||
| if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Compile options object required'); | ||
| const known = new Set(['output','outDir','featureStage','targetVersion']); | ||
| for (const key of Reflect.ownKeys(options)) { | ||
| if (typeof key !== 'string' || !known.has(key)) throw new TypeError('Unknown Interactive compile option'); | ||
| if (!Object.hasOwn(Object.getOwnPropertyDescriptor(options,key),'value')) throw new TypeError('Compile option accessors forbidden'); | ||
| } | ||
| const snapshotOptions = Object.fromEntries(Object.keys(options).map(key=>[key,options[key]])); | ||
| if (!['memory','file'].includes(snapshotOptions.output)) throw new TypeError('Invalid compile output'); | ||
| if (snapshotOptions.featureStage !== 'P0-A') throw new TypeError('Only P0-A is implemented'); | ||
| if (snapshotOptions.targetVersion !== undefined && snapshotOptions.targetVersion !== 'interactive-state-machine-v1') { | ||
| throw new TypeError('Unsupported semantics version'); | ||
| } | ||
| if (snapshotOptions.outDir !== undefined && typeof snapshotOptions.outDir !== 'string') throw new TypeError('outDir must be a string'); | ||
| const cwd = process.cwd(); | ||
| const filename = path.resolve(cwd,file); | ||
| const outDir = snapshotOptions.outDir === undefined ? path.dirname(filename) : path.resolve(cwd,snapshotOptions.outDir); | ||
| const snapshot = await readFile(filename); // original source bytes, never imported | ||
| const analysis = await analyzeSource(snapshot,{ | ||
| filename, projectRoot:path.dirname(filename), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Compiler uses wrong project rootMedium Severity
Reviewed by Cursor Bugbot for commit 7578cc7. Configure here. |
||
| }); | ||
| const lowered = lowerInteractive(analysis,{featureStage:options.featureStage}); | ||
| if (!lowered.ir) return {code:'',diagnostics:[...lowered.diagnostics]}; | ||
| const emitted = emitInteractive(lowered.ir); | ||
| if (emitted.diagnostics.some(d => d.severity === 'error')) { | ||
| return {code:'',diagnostics:[...lowered.diagnostics,...emitted.diagnostics]}; | ||
| } | ||
| const result = {code:emitted.code,diagnostics:[...lowered.diagnostics],programIdentity:emitted.programIdentity}; | ||
| if (snapshotOptions.output === 'file') { | ||
| const stem = path.basename(filename).replace(/\.workflow\.(js|ts)$/,''); | ||
| const artifactPath = path.join(outDir,stem + '.interactive.mjs'); | ||
| await mkdir(outDir,{recursive:true}); | ||
| const temporary = path.join(outDir,'.' + stem + '.' + randomUUID() + '.tmp'); | ||
| const handle = await open(temporary,'wx'); | ||
| try { | ||
| await handle.writeFile(emitted.code,'utf8'); await handle.sync(); await handle.close(); | ||
| await rename(temporary,artifactPath); | ||
| } catch(e) { | ||
| await handle.close().catch(()=>{}); | ||
| await unlink(temporary).catch(()=>{}); | ||
| throw e; | ||
| } | ||
| result.artifactPath = artifactPath; | ||
| } | ||
| return result; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import {createHash} from 'node:crypto'; | ||
|
|
||
| export function sha256Bytes(bytes) { | ||
| if (!(bytes instanceof Uint8Array)) throw new TypeError('Expected immutable program byte snapshot'); | ||
| return 'sha256:' + createHash('sha256').update(bytes).digest('hex'); | ||
| } | ||
|
|
||
| export function assertSha256(hash) { | ||
| if (typeof hash !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(hash)) { | ||
| throw new TypeError('Invalid SHA-256 identity'); | ||
| } | ||
| return hash; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import {parse} from 'acorn'; | ||
| import {sha256Bytes,assertSha256} from './identity.mjs'; | ||
| import {validateInteractiveIR,agentFrameRule} from '../compiler/interactive/validate-ir.mjs'; | ||
|
|
||
| /** A verified program is trusted compiler output, NOT an untrusted JavaScript sandbox. */ | ||
| function ensureNoImports(ast) { | ||
| const pending = [ast]; | ||
| while (pending.length) { | ||
| const node = pending.pop(); | ||
| if (!node || typeof node !== 'object') continue; | ||
| if (['ImportDeclaration','ImportExpression','ExportAllDeclaration'].includes(node.type)) { | ||
| throw new TypeError('Interactive Program imports are forbidden'); | ||
| } | ||
| if (node.type === 'ExportNamedDeclaration' && node.source) { | ||
| throw new TypeError('Re-exports are forbidden'); | ||
| } | ||
| for (const value of Object.values(node)) { | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) if (item?.type) pending.push(item); | ||
| } else if (value?.type) { | ||
| pending.push(value); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| function ownJSON(value, depth = 0, seen = new Set()) { | ||
| if (depth > 64) throw new TypeError('Excessive Program metadata depth'); | ||
| if (value === null || ['string','number','boolean'].includes(typeof value)) { | ||
| if (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value,-0))) throw new TypeError('Invalid number'); | ||
| return value; | ||
| } | ||
| if (!value || typeof value !== 'object' || seen.has(value)) throw new TypeError('Invalid Program metadata'); | ||
| const plain = Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null; | ||
| if (!plain) throw new TypeError('Untrusted Program metadata prototype'); | ||
| seen.add(value); | ||
| const result = Array.isArray(value) ? [] : Object.create(null); | ||
| const props = Reflect.ownKeys(value); | ||
| for (const key of props) { | ||
| if (typeof key !== 'string') throw new TypeError('Program metadata must have string keys'); | ||
| const descriptor = Object.getOwnPropertyDescriptor(value,key); | ||
| if (!descriptor || !Object.hasOwn(descriptor,'value')) throw new TypeError('Program metadata has accessor'); | ||
| if (Array.isArray(value) && key === 'length') continue; | ||
| if (Array.isArray(value) && !/^(0|[1-9]\d*)$/.test(key)) throw new TypeError('Invalid Program metadata array key'); | ||
| if (!Array.isArray(value) && (key === 'constructor' || key === 'prototype')) throw new TypeError('Unsafe metadata key'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Loader rejects valid schema keysMedium Severity
Reviewed by Cursor Bugbot for commit 7578cc7. Configure here. |
||
| const own = ownJSON(descriptor.value, depth+1,seen); | ||
| Object.defineProperty(result,key,{value:own,enumerable:true,writable:false,configurable:false}); | ||
| } | ||
| if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new TypeError('Sparse Program metadata array'); | ||
| seen.delete(value); | ||
| return Object.freeze(result); | ||
| } | ||
|
|
||
| /** Loads exactly the verified byte snapshot, regardless of file-name swaps. */ | ||
| export async function loadInteractiveProgram(bytes, expectedArtifactHash) { | ||
| if (!(bytes instanceof Uint8Array)) throw new TypeError('Program bytes must be Uint8Array'); | ||
| assertSha256(expectedArtifactHash); | ||
| const immutable = Buffer.from(bytes); | ||
| if (immutable.byteLength > 4 * 1024 * 1024) throw new TypeError('Oversize Interactive Program'); | ||
| if (sha256Bytes(immutable) !== expectedArtifactHash) throw new Error('FK_I_PROGRAM_MISMATCH: artifact hash differs'); | ||
| const code = new TextDecoder('utf-8', {fatal:true,ignoreBOM:true}).decode(immutable); | ||
| const ast = parse(code,{ecmaVersion:'latest',sourceType:'module'}); | ||
| ensureNoImports(ast); | ||
| const advances = ast.body.filter(n => n.type === 'ExportNamedDeclaration' && n.declaration?.type === 'FunctionDeclaration' && n.declaration.id.name === 'advance'); | ||
| if (advances.length !== 1) throw new TypeError('Exactly one exported advance function required'); | ||
| const originalBody = code.slice(advances[0].declaration.start,advances[0].declaration.end); | ||
| const module = await import('data:text/javascript;base64,' + immutable.toString('base64')); | ||
| if (typeof module.advance !== 'function' || module.advance.constructor.name === 'AsyncFunction' | ||
| || Function.prototype.toString.call(module.advance) !== originalBody) { | ||
| throw new TypeError('Loaded advance function differs from verified bytes'); | ||
| } | ||
| if (Object.keys(module).sort().join(',') !== 'advance,interactiveProgram,programTable') { | ||
| throw new TypeError('Unexpected Interactive Program exports'); | ||
| } | ||
| const manifest = ownJSON(module.interactiveProgram); | ||
| const table = ownJSON(module.programTable); | ||
| if (manifest.artifactFormat !== 'FlowkitInteractiveProgramV1' | ||
| || manifest.identity.programAbiVersion !== 1 | ||
| || manifest.identity.semanticsVersion !== 'interactive-state-machine-v1' | ||
| || manifest.identity.implementedStage !== 'P0-A' | ||
| || table.schemaVersion !== 1 || !Array.isArray(table.blocks) | ||
| || !table.blocks.length || !table.nodes || !Array.isArray(table.requiredBindings)) { | ||
| throw new TypeError('Invalid Interactive Program ABI V1'); | ||
| } | ||
| // Validate the ProgramTable as a self-contained compiler contract. | ||
| // No source files or runtime lowering are consulted. | ||
| if (!/^sha256:[0-9a-f]{64}$/.test(manifest.identity.sourceHash)) throw new TypeError('Invalid Program source identity'); | ||
| const staticIR = { | ||
| schemaVersion:1,featureStage:'P0-A',sourceHash:manifest.identity.sourceHash.slice(7), | ||
| entryBlockId:table.entryBlockId,blocks:table.blocks,nodes:table.nodes, | ||
| frameRules:Object.keys(table.nodes).filter(id=>table.nodes[id].kind==='agent').map(id=>agentFrameRule(id)), | ||
| }; | ||
| const validated = validateInteractiveIR(staticIR); | ||
| if (!validated.ok) throw new TypeError('FK_I_INVALID_TRANSITION: invalid ProgramTable'); | ||
| return Object.freeze({ | ||
| artifactHash:expectedArtifactHash, | ||
| interactiveProgram:manifest,programTable:table, | ||
| advance:module.advance, | ||
| }); | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Embedded JSON mishandles proto keys
High Severity
emitInteractivesplicesJSON.stringify(manifest)andJSON.stringify(table)into generated object literals. A quoted"__proto__"key is a prototype setter in JavaScript, so own__proto__keys in Agent output schemas are dropped and the object's prototype is replaced.loadInteractiveProgramthen rejects the table as untrusted metadata, so programs that M1 already preserves fail to load.Reviewed by Cursor Bugbot for commit 7578cc7. Configure here.