From 0fd7844666adf8364257cb6bc21edf11f22de01f Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 23:59:54 +0900 Subject: [PATCH 01/13] test(interactive): M6 separate scripted worker with strict model-visible task contract --- tests/integration/m6-worker.mjs | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/integration/m6-worker.mjs diff --git a/tests/integration/m6-worker.mjs b/tests/integration/m6-worker.mjs new file mode 100644 index 0000000..34b2a96 --- /dev/null +++ b/tests/integration/m6-worker.mjs @@ -0,0 +1,38 @@ +/** M6 deterministic external worker fixture — NOT an AI model provider. */ +import {readFile} from 'node:fs/promises'; +import process from 'node:process'; + +const chunks=[]; +let total=0; +for await (const chunk of process.stdin) { + total+=chunk.length; + if(total>512*1024) throw new RangeError('Public task exceeds worker limit'); + chunks.push(chunk); +} +const task=JSON.parse(Buffer.concat(chunks).toString('utf8')); +const allowed=new Set([ + 'schemaVersion','kind','id','nodeId','runId','requestId','attempt', + 'name','instruction','input','missing','resultSchema','reasons','overview', +]); +if (!task || task.schemaVersion!==1 || task.kind!=='agent' + || typeof task.runId!=='string' || typeof task.requestId!=='string' + || typeof task.nodeId!=='string' || typeof task.instruction!=='string' + || Object.keys(task).some(key=>!allowed.has(key)) + || JSON.stringify(task).includes('HOST_PRIVATE_SENTINEL') + || Object.keys(task.input).some(key=>key!=='message') + || task.missing.some(key=>key!=='message')) { + throw new Error('MODEL_VIEW_LEAK_OR_INVALID_TASK'); +} +for(const secretPath of process.argv.slice(2)) { + let denied=false; + try {await readFile(secretPath)} catch(error) { + denied=['EACCES','EPERM'].includes(error.code); + } + if(!denied) throw new Error('MODEL_CAN_READ_HOST_PRIVATE_FILE'); +} +const message=task.input.message; +if(typeof message!=='string') throw new TypeError('Public task must have string message'); +const output={requestId:task.requestId,kind:'agent-result',result:{ + answer:'external-worker-processed:'+message, +}}; +process.stdout.write(JSON.stringify(output)+'\n'); From 0a1ee5ecd8576ceb16cde492f7432de1a1737c61 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:01:20 +0900 Subject: [PATCH 02/13] test(interactive): M6 real Host subprocess with trusted attestation and separate worker IPC --- tests/integration/m6-host.mjs | 186 ++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/integration/m6-host.mjs diff --git a/tests/integration/m6-host.mjs b/tests/integration/m6-host.mjs new file mode 100644 index 0000000..dc62f23 --- /dev/null +++ b/tests/integration/m6-host.mjs @@ -0,0 +1,186 @@ +/** + * M6 real Node Host process fixture. It uses the production FlowKit + * compiler artifact, SQLite Store and Ed25519 Trusted Host, then exchanges + * JSON with a distinct scripted worker process over stdin/stdout. + * + * It is intentionally not a Codex/Claude/Grok provider integration. + * No endpoint allows worker/model stdin to invoke complete() or sign receipts. + */ +import {readFile} from 'node:fs/promises'; +import {spawn} from 'node:child_process'; +import { + createPrivateKey,createPublicKey,sign,randomUUID, +} from 'node:crypto'; +import process from 'node:process'; +import { + createSqliteCheckpointStore,createTrustedInteractiveHost, + createAgentReceiptStatement,encodeAgentReceiptStatement, +} from '../../src/interactive/index.mjs'; + +function trace(event, fields={}) { + process.stdout.write(JSON.stringify({event,...fields})+'\n'); +} +function onlyAllowedConfig(cfg) { + if (!cfg || typeof cfg!=='object' || Array.isArray(cfg)) throw new TypeError('Expected trusted Host config'); + for(const field of ['namespace','runId','startKey','storePath','programPath','identityPath','privateKeyPath','skillPath','workerPath']) { + if(typeof cfg[field]!=='string' || !cfg[field]) throw new TypeError('Missing Host config '+field); + } + if (!cfg.state || typeof cfg.state!=='object' || !Array.isArray(cfg.allowedInputKeys)) { + throw new TypeError('Invalid Host start configuration'); + } +} +async function spawnScriptedWorker(cfg,task) { + let command=process.execPath; + let args=[cfg.workerPath]; + if(cfg.osIsolation===true && process.platform!=='linux') { + throw new Error('Cross-UID mode requires a Linux Host'); + } + if(cfg.osIsolation===true) { + args.push(cfg.privateKeyPath,cfg.storePath); + if(process.getuid?.()===0) { + // Set in spawn options below, do not inherit Host UID or GID. + } else { + command='sudo'; + args=['-n','-u','nobody','--',process.execPath,...args]; + } + } + // Deliberately do not inherit process.env: it might contain Host secrets. + const env={ + PATH:process.env.PATH||'', + ...(process.platform==='win32'?{ + SystemRoot:process.env.SystemRoot||'C:\\Windows', + TEMP:process.env.TEMP||'',TMP:process.env.TMP||'', + }:{HOME:'/tmp'}), + }; + const options={ + cwd:process.cwd(),env,stdio:['pipe','pipe','pipe'], + ...(cfg.osIsolation===true && process.getuid?.()===0 + ? {uid:65534,gid:65534} : {}), + }; + const child=spawn(command,args,options); + return new Promise((resolve,reject)=>{ + let stdout='',stderr='',bytes=0; + const timeout=setTimeout(()=>{child.kill();reject(new Error('M6 worker timeout'))},15000); + child.stdout.on('data',chunk=>{ + bytes+=chunk.length; + if(bytes>128*1024) { + child.kill(); + reject(new Error('M6 worker output limit exceeded')); + } else stdout+=chunk.toString('utf8'); + }); + child.stderr.on('data',chunk=>{ + if(stderr.length<2048)stderr+=chunk.toString('utf8').slice(0,2048-stderr.length); + }); + child.on('error',error=>{clearTimeout(timeout);reject(error)}); + child.on('close',code=>{ + clearTimeout(timeout); + if(code!==0) { + reject(new Error('M6 external worker exited '+code+': '+stderr.slice(0,300))); + return; + } + try { + if(stdout.trim().split(/\r?\n/).length!==1) { + throw new TypeError('Worker must send one JSON response'); + } + const data=JSON.parse(stdout); + const keys=Object.keys(data).sort(); + if(keys.join(',')!=='kind,requestId,result' || data.kind!=='agent-result' + || data.requestId!==task.requestId + || !data.result || typeof data.result.answer!=='string' + || Object.keys(data.result).join(',')!=='answer') { + throw new TypeError('Uncertified worker response envelope'); + } + resolve(data); + } catch(error) {reject(error)} + }); + child.stdin.on('error',()=>{}); // close errors handled through child's close result + child.stdin.end(JSON.stringify(task)); + }); +} + +const [configPath,mode='start',cut='none']=process.argv.slice(2); +if(!configPath || !['start','resume'].includes(mode) + || !['none','after-pending','after-worker'].includes(cut)) { + throw new TypeError('Usage: m6-host.mjs start|resume none|after-pending|after-worker'); +} +const config=JSON.parse(await readFile(configPath,'utf8')); +onlyAllowedConfig(config); +const programBytes=await readFile(config.programPath); +const programIdentity=JSON.parse(await readFile(config.identityPath,'utf8')); +const privateKey=createPrivateKey(await readFile(config.privateKeyPath)); +const publicKey=createPublicKey(privateKey); +const store=createSqliteCheckpointStore(config.storePath,{namespace:config.namespace}); +try { + const host=createTrustedInteractiveHost({ + store, + runtime:{ + skillResolver:async()=>readFile(config.skillPath,'utf8'), + validateResult:(_schema,result)=>typeof result?.answer==='string'?[]:['Expected a string answer'], + }, + policy:{testHostId:'m6-isolated-node-host-v1'}, + trustedPublicKeys:{'m6-host-key':publicKey}, + trustedArtifacts:[programIdentity.artifactHash], + allowedInputKeys:config.allowedInputKeys, + disclosurePolicyId:'m6-explicit-input-allowlist', + authorizeDisclosure:async({task})=>( + Object.keys(task.input).every(key=>config.allowedInputKeys.includes(key)) + && task.missing.every(key=>config.allowedInputKeys.includes(key)) + ), + maxStagesPerCall:config.stageBudget??128, + }); + let frame=mode==='start' + ? await host.start({ + programBytes,programIdentity,startKey:config.startKey, + runId:config.runId,state:config.state, + }) + : await host.resume({runId:config.runId}); + trace('host-start',{status:frame.status,runId:frame.runId,revision:frame.revision}); + let loops=0; + while(loops++<1000) { + if(frame.status==='paused') { + if(frame.pauseReason!=='stage-budget') throw new Error('Unexpected durable pause'); + frame=await host.resume({runId:config.runId}); + continue; + } + if(frame.status!=='waiting')break; + const task=await host.getPublicAgentTask({runId:frame.runId}); + if(!task || task.requestId!==frame.pending?.requestId) { + throw new TypeError('Published task and Store Pending differ'); + } + trace('task-published',{runId:task.runId,requestId:task.requestId,nodeId:task.nodeId, + revision:frame.revision,attempt:task.attempt}); + if(cut==='after-pending') { + trace('fault-injected',{where:'after-pending',requestId:task.requestId}); + process.kill(process.pid,'SIGKILL'); + } + const external=await spawnScriptedWorker(config,task); + trace('worker-exited',{requestId:external.requestId}); + if(cut==='after-worker') { + trace('fault-injected',{where:'after-worker',requestId:task.requestId}); + process.kill(process.pid,'SIGKILL'); + } + // Host constructs this envelope. The worker cannot select run identity, + // proof ID, policy, destination node or the signer. + const receipt={ + kind:'agent-result',runId:task.runId,requestId:task.requestId, + nodeId:task.nodeId,attempt:task.attempt,result:external.result, + }; + const issuedAt=Date.now(); + const statement=createAgentReceiptStatement({ + run:store.load(task.runId),receipt,keyId:'m6-host-key', + proofId:'host-process:'+randomUUID(), + issuedAt,expiresAt:issuedAt+60000, + }); + const signature=sign(null,encodeAgentReceiptStatement(statement),privateKey) + .toString('base64url'); + frame=await host.accept({receipt,attestation:{statement,signature}}); + trace('receipt-accepted',{requestId:task.requestId, + acceptedRevision:frame.acceptedRevision,duplicate:frame.duplicate}); + } + if(loops>=1000) throw new Error('M6 stage budget made no progress'); + trace('host-terminal',{status:frame.status,runId:frame.runId,revision:frame.revision}); + if(frame.status==='errored')process.exitCode=2; + else if(!['succeeded','failed'].includes(frame.status))process.exitCode=3; +} finally { + store.close(); +} From 64af8b3a1884469b259933f1108cff15ed7aba76 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:03:38 +0900 Subject: [PATCH 03/13] test(interactive): M6 real Host subprocess, IPC worker, crash recovery, artifact tamper, Standard equivalence --- tests/integration/m6-e2e.test.mjs | 284 ++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 tests/integration/m6-e2e.test.mjs diff --git a/tests/integration/m6-e2e.test.mjs b/tests/integration/m6-e2e.test.mjs new file mode 100644 index 0000000..2c32480 --- /dev/null +++ b/tests/integration/m6-e2e.test.mjs @@ -0,0 +1,284 @@ +import test,{after} from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtemp,mkdir,writeFile,readFile,chmod,rm, +} from 'node:fs/promises'; +import {generateKeyPairSync} from 'node:crypto'; +import {spawn} from 'node:child_process'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {compileInteractive,createSqliteCheckpointStore} from '../../src/interactive/index.mjs'; +import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {run,isControlSignal,evalWhen} from '../../src/index.mjs'; + +const hostScript=fileURLToPath(new URL('./m6-host.mjs',import.meta.url)); +const workerScript=fileURLToPath(new URL('./m6-worker.mjs',import.meta.url)); +const roots=[]; +after(async()=>{for(const root of roots)await rm(root,{recursive:true,force:true})}); + +const workflowText=[ + "import {defineWorkflow,condition,node,terminal} from '@devcodex-labs/flowkit/dsl';", + "export default defineWorkflow({id:'m6-acceptance',version:'1.0.0',name:'Acceptance',nodes:[", + "condition('gate',{name:'Gate',branches:[{when:'ready',next:'worker'},{when:'true',next:'declined'}]}),", + "node('worker',{name:'Worker',skill:'skills/writer.md',inputs:['message'],", + " output:{type:'object',properties:{answer:{type:'string'}}},retries:2,next:'done'}),", + "terminal('done',{name:'Done',status:'succeeded'}),", + "terminal('declined',{name:'Declined',status:'failed'})]});", +].join('\n'); + +const privateSentinel='HOST_PRIVATE_SENTINEL'; +async function fixture(t,{stageBudget=128,ready=true,allowedInputKeys=['message'],osIsolation=false}={}) { + const root=await mkdtemp(path.join(tmpdir(),'flowkit-m6-realhost-')); + roots.push(root); + await chmod(root,0o755); // allow low-privilege model worker to traverse public ancestors + const privateRoot=path.join(root,'host-private'); + const skillDir=path.join(root,'skills'); + await mkdir(privateRoot,{mode:0o700}); + await mkdir(skillDir,{mode:0o755}); + await chmod(privateRoot,0o700); + const skillPath=path.join(skillDir,'writer.md'); + await writeFile(skillPath,'Review and return a JSON answer.',{mode:0o644}); + const sourcePath=path.join(root,'acceptance.workflow.js'); + await writeFile(sourcePath,workflowText,{mode:0o644}); + const compiled=await compileInteractive(sourcePath,{ + output:'memory',featureStage:'P0-A', + }); + assert.equal(compiled.diagnostics.filter(x=>x.severity==='error').length,0, + JSON.stringify(compiled.diagnostics)); + assert.ok(compiled.programIdentity?.artifactHash); + const identityPath=path.join(privateRoot,'manifest.json'); + const programPath=path.join(privateRoot,'accepted.interactive.mjs'); + const storePath=path.join(privateRoot,'run.db'); + const privateKeyPath=path.join(privateRoot,'signing-key.pem'); + const configPath=path.join(privateRoot,'host-config.json'); + const pair=generateKeyPairSync('ed25519'); + await writeFile(programPath,compiled.code,{mode:0o600}); + await writeFile(identityPath,JSON.stringify(compiled.programIdentity),{mode:0o600}); + await writeFile(privateKeyPath, + pair.privateKey.export({type:'pkcs8',format:'pem'}),{mode:0o600}); + const config={ + namespace:'m6-reference-host',runId:'run-m6-test', + startKey:'m6-acceptance-start',storePath,programPath,identityPath, + privateKeyPath,skillPath,workerPath:workerScript, + state:{ready,message:'hello',secret:privateSentinel}, + allowedInputKeys,stageBudget,osIsolation, + }; + await writeFile(configPath,JSON.stringify(config),{mode:0o600}); + await chmod(privateRoot,0o700); + const readStore=()=>{ + const store=createSqliteCheckpointStore(storePath,{namespace:config.namespace}); + try { + const cp=store.load(config.runId); + return {cp,journal:store.listJournal(config.runId), + artifact:cp&&store.getArtifact(cp.artifactHash)}; + } finally {store.close()} + }; + return { + root,sourcePath,configPath,config,compiled,readStore, + async updateConfig(update) { + Object.assign(config,update); + await writeFile(configPath,JSON.stringify(config),{mode:0o600}); + }, + async standardResult() { + const host={ + isControlSignal,evalWhen, + runAgent:async step=>({answer:'external-worker-processed:'+step.input.message}), + validateResult:(_schema,result)=>typeof result?.answer==='string'?[]:['bad answer'], + }; + return run(sourcePath,host,{...config.state},{projectRoot:root,skillCheck:true}); + }, + }; +} +function readEvents(stdout) { + return stdout.trim().split(/\r?\n/).filter(Boolean).map(line=>{ + let event=JSON.parse(line); + if(typeof event.event!=='string') throw new TypeError('Malformed Host event'); + return event; + }); +} +async function invoke(fixture,mode='start',cut='none') { + return new Promise((resolve,reject)=>{ + const child=spawn(process.execPath,[hostScript,fixture.configPath,mode,cut], + {stdio:['ignore','pipe','pipe']}); + let stdout='',stderr='',length=0; + const timeout=setTimeout(()=>{child.kill();reject(new Error('M6 Host timeout'))},60000); + child.stdout.on('data',b=>{ + length+=b.length; + if(length>512*1024) {child.kill();reject(new RangeError('Host trace exceeds limit'))} + else stdout+=b.toString(); + }); + child.stderr.on('data',b=>{if(stderr.length<2048)stderr+=b.toString().slice(0,2048-stderr.length)}); + child.on('error',e=>{clearTimeout(timeout);reject(e)}); + child.on('close',(code,signal)=>{ + clearTimeout(timeout); + try { + const events=readEvents(stdout); + resolve({code,signal,events,stderr,stdout}); + } catch(e) {reject(e)} + }); + }); +} +function assertContinuousJournal(snapshot) { + assert.deepEqual(snapshot.journal.map(item=>item.revision), + Array.from({length:snapshot.cp.revision+1},(_,i)=>i)); + assert.equal(snapshot.journal.at(-1).checkpointHash, + sha256Bytes(canonicalBytes(snapshot.cp))); + for(let i=1;i Node Host -> separately spawned worker -> authenticated receipt -> terminal matches Standard',async t=>{ + const f=await fixture(t,{osIsolation:process.env.FLOWKIT_M6_ISOLATION==='1'}); + const host=await invoke(f); + assert.equal(host.code,0,host.stderr); + assertModelNeverSawSecret(host); + assert.deepEqual(host.events.map(x=>x.event),[ + 'host-start','task-published','worker-exited','receipt-accepted','host-terminal', + ]); + assert.equal(host.events[0].status,'waiting'); + assert.equal(host.events.at(-1).status,'succeeded'); + const persisted=f.readStore(); + assertContinuousJournal(persisted); + assert.equal(persisted.cp.status,'succeeded'); + assert.equal(persisted.cp.state.secret,privateSentinel); + assert.equal(persisted.cp.state.answer,'external-worker-processed:hello'); + assert.equal(persisted.cp.receipts.length,1); + assert.equal(persisted.cp.receipts[0].acceptedRevision, + host.events.find(x=>x.event==='receipt-accepted').acceptedRevision); + assert.equal(persisted.cp.artifactHash,f.compiled.programIdentity.artifactHash); + assert.ok(persisted.artifact.bytes.equals(Buffer.from(f.compiled.code))); + const standard=await f.standardResult(); + assert.equal(standard.terminal,'done'); + assert.equal(sha256Bytes(canonicalBytes(standard.state)), + sha256Bytes(canonicalBytes(persisted.cp.state))); + const replay=await invoke(f,'resume'); + assert.equal(replay.code,0); + assert.deepEqual(replay.events.map(x=>x.event),['host-start','host-terminal']); + assert.equal(replay.events.at(-1).revision,persisted.cp.revision); +}); + +test('M6: after Pending publish, killing Host and reopening only persisted Program/Store preserves requestId exactly',async t=>{ + const f=await fixture(t,{osIsolation:process.env.FLOWKIT_M6_ISOLATION==='1'}); + const killed=await invoke(f,'start','after-pending'); + assert.notEqual(killed.code,0,'fault must terminate Host'); + assert.ok(killed.events.some(e=>e.event==='fault-injected')); + assertModelNeverSawSecret(killed); + const prior=f.readStore(); + assert.equal(prior.cp.status,'waiting'); + assert.equal(prior.cp.pending.requestId, + killed.events.find(e=>e.event==='task-published').requestId); + const revision=prior.cp.revision; + // No source DSL is needed for resume: Program bytes are retained in Store. + await rm(f.sourcePath); + const recovered=await invoke(f,'resume'); + assert.equal(recovered.code,0,recovered.stderr); + assert.equal(recovered.events.find(x=>x.event==='task-published').requestId, + prior.cp.pending.requestId); + assert.ok(recovered.events.find(x=>x.event==='receipt-accepted').acceptedRevision>revision); + const done=f.readStore(); + assert.equal(done.cp.status,'succeeded'); + assert.equal(done.cp.receipts.length,1); + assertContinuousJournal(done); +}); + +test('M6: worker finished but Host crashed before durable receipt; retry preserves Pending identity, not exactly-once provider execution',async t=>{ + const f=await fixture(t); + const killed=await invoke(f,'start','after-worker'); + assert.notEqual(killed.code,0); + assert.ok(killed.events.some(e=>e.event==='worker-exited')); + const before=f.readStore(); + assert.equal(before.cp.status,'waiting'); + assert.equal(before.cp.receipts.length,0); + const sameRequest=before.cp.pending.requestId; + const resumed=await invoke(f,'resume'); + assert.equal(resumed.code,0,resumed.stderr); + assert.equal(resumed.events.find(x=>x.event==='task-published').requestId,sameRequest); + assert.equal(f.readStore().cp.receipts.length,1); + assert.equal(f.readStore().cp.state.answer,'external-worker-processed:hello'); +}); + +test('M6: budget 1 versus budget 128 reaches byte-equal terminal State, with monotonic CAS and no fake waiting',async t=>{ + const a=await fixture(t,{stageBudget:1}); + const b=await fixture(t,{stageBudget:128}); + const [fast,slow]=await Promise.all([invoke(a),invoke(b)]); + assert.equal(fast.code,0,fast.stderr); + assert.equal(slow.code,0,slow.stderr); + for(const f of [a,b]) assertContinuousJournal(f.readStore()); + const left=a.readStore(),right=b.readStore(); + assert.equal(sha256Bytes(canonicalBytes(left.cp.state)), + sha256Bytes(canonicalBytes(right.cp.state))); + assert.equal(left.cp.status,'succeeded'); + assert.equal(right.cp.status,'succeeded'); + assert.ok(left.cp.revision>=right.cp.revision); + for(const run of [fast,slow]) { + assert.equal(run.events.filter(x=>x.event==='task-published').length,1); + assert.equal(run.events.at(-1).status,'succeeded'); + } +}); + +test('M6: changed Host disclosure policy denies resume with no journal/CAS mutation',async t=>{ + const f=await fixture(t); + const killed=await invoke(f,'start','after-pending'); + assert.notEqual(killed.code,0); + const original=f.readStore(); + const bytes=canonicalBytes(original.cp); + await f.updateConfig({allowedInputKeys:[]}); + const denied=await invoke(f,'resume'); + assert.notEqual(denied.code,0); + assert.equal(denied.events.some(x=>x.event==='worker-exited'),false); + assert.deepEqual(canonicalBytes(f.readStore().cp),bytes); + assert.equal(f.readStore().journal.length,original.journal.length); +}); + +test('M6: false Condition chooses failure Terminal without launching worker and equals Standard',async t=>{ + const f=await fixture(t,{ready:false}); + const host=await invoke(f); + assert.equal(host.code,0,host.stderr); + assert.equal(host.events.at(-1).status,'failed'); + assert.equal(host.events.some(x=>x.event==='task-published'),false); + assert.equal(f.readStore().cp.receipts.length,0); + const standard=await f.standardResult(); + assert.equal(standard.terminal,'declined'); + assert.equal(sha256Bytes(canonicalBytes(standard.state)), + sha256Bytes(canonicalBytes(f.readStore().cp.state))); +}); + +test('M6: tampered SQLite artifact byte is rejected before worker dispatch; never silently repaired',async t=>{ + const f=await fixture(t); + const killed=await invoke(f,'start','after-pending'); + assert.notEqual(killed.code,0); + const before=f.readStore(); + // External DB corruption is tested through raw SQLite only in this adversarial test. + const {DatabaseSync}=await import('node:sqlite'); + const db=new DatabaseSync(f.config.storePath); + try { + db.prepare('UPDATE fk_i_artifacts SET program_bytes = ? WHERE namespace = ? AND artifact_hash = ?') + .run(Buffer.from('tampered'),f.config.namespace,before.cp.artifactHash); + } finally {db.close()} + const attempted=await invoke(f,'resume'); + assert.notEqual(attempted.code,0); + assert.equal(attempted.events.some(x=>x.event==='worker-exited'),false); + const cpSnapshot=(()=>{ + const s=createSqliteCheckpointStore(f.config.storePath,{namespace:f.config.namespace}); + try{return s.load(f.config.runId)}finally{s.close()} + })(); + assert.deepEqual(canonicalBytes(cpSnapshot),canonicalBytes(before.cp)); +}); + +test('M6: model worker with empty inherited environment receives only public task and cannot read Host secret files on isolated Linux',async t=>{ + if(process.env.FLOWKIT_M6_ISOLATION!=='1')return t.skip('Dedicated Linux isolation job runs this proof'); + const f=await fixture(t,{osIsolation:true}); + const run=await invoke(f); + assert.equal(run.code,0,run.stderr); + assert.equal(run.events.filter(e=>e.event==='worker-exited').length,1); + assertModelNeverSawSecret(run); + assert.equal(f.readStore().cp.status,'succeeded'); +}); From 058900133bdb379b65cc8a707accc47a558b7801 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:04:59 +0900 Subject: [PATCH 04/13] test(interactive): M6 Host pins real Skill/source/Program bytes before resuming or dispatching --- tests/integration/m6-host.mjs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/integration/m6-host.mjs b/tests/integration/m6-host.mjs index dc62f23..c1c6241 100644 --- a/tests/integration/m6-host.mjs +++ b/tests/integration/m6-host.mjs @@ -7,11 +7,15 @@ * No endpoint allows worker/model stdin to invoke complete() or sign receipts. */ import {readFile} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; import {spawn} from 'node:child_process'; import { createPrivateKey,createPublicKey,sign,randomUUID, } from 'node:crypto'; import process from 'node:process'; + +const digest=bytes=>'sha256:'+createHash('sha256').update(bytes).digest('hex'); +function drift(message){return Object.assign(new Error('FK_I_POLICY_MISMATCH: '+message),{code:'FK_I_POLICY_MISMATCH'});} import { createSqliteCheckpointStore,createTrustedInteractiveHost, createAgentReceiptStatement,encodeAgentReceiptStatement, @@ -22,7 +26,7 @@ function trace(event, fields={}) { } function onlyAllowedConfig(cfg) { if (!cfg || typeof cfg!=='object' || Array.isArray(cfg)) throw new TypeError('Expected trusted Host config'); - for(const field of ['namespace','runId','startKey','storePath','programPath','identityPath','privateKeyPath','skillPath','workerPath']) { + for(const field of ['namespace','runId','startKey','storePath','programPath','identityPath','privateKeyPath','skillPath','workerPath','skillHash']) { if(typeof cfg[field]!=='string' || !cfg[field]) throw new TypeError('Missing Host config '+field); } if (!cfg.state || typeof cfg.state!=='object' || !Array.isArray(cfg.allowedInputKeys)) { @@ -107,6 +111,21 @@ const config=JSON.parse(await readFile(configPath,'utf8')); onlyAllowedConfig(config); const programBytes=await readFile(config.programPath); const programIdentity=JSON.parse(await readFile(config.identityPath,'utf8')); +if(digest(programBytes)!==programIdentity.artifactHash) throw drift('Program artifact bytes changed'); +async function checkSkill(){ + if(digest(await readFile(config.skillPath))!==config.skillHash) + throw drift('Skill instruction bytes changed'); +} +await checkSkill(); +// Source is optional after compiling; when still present it MUST match pinned +// source identity. Deleting source cannot change the immutable saved Program. +if(config.sourcePath){ + let existingSource; + try {existingSource=await readFile(config.sourcePath)} + catch(e){if(e.code!=='ENOENT')throw e} + if(existingSource && digest(existingSource)!==programIdentity.sourceHash) + throw drift('Original source bytes changed'); +} const privateKey=createPrivateKey(await readFile(config.privateKeyPath)); const publicKey=createPublicKey(privateKey); const store=createSqliteCheckpointStore(config.storePath,{namespace:config.namespace}); @@ -114,10 +133,10 @@ try { const host=createTrustedInteractiveHost({ store, runtime:{ - skillResolver:async()=>readFile(config.skillPath,'utf8'), + skillResolver:async()=>{await checkSkill();return readFile(config.skillPath,'utf8')}, validateResult:(_schema,result)=>typeof result?.answer==='string'?[]:['Expected a string answer'], }, - policy:{testHostId:'m6-isolated-node-host-v1'}, + policy:{testHostId:'m6-isolated-node-host-v1',skillManifestHash:config.skillHash}, trustedPublicKeys:{'m6-host-key':publicKey}, trustedArtifacts:[programIdentity.artifactHash], allowedInputKeys:config.allowedInputKeys, @@ -143,6 +162,7 @@ try { continue; } if(frame.status!=='waiting')break; + await checkSkill(); const task=await host.getPublicAgentTask({runId:frame.runId}); if(!task || task.requestId!==frame.pending?.requestId) { throw new TypeError('Published task and Store Pending differ'); @@ -154,6 +174,7 @@ try { process.kill(process.pid,'SIGKILL'); } const external=await spawnScriptedWorker(config,task); + await checkSkill(); trace('worker-exited',{requestId:external.requestId}); if(cut==='after-worker') { trace('fault-injected',{where:'after-worker',requestId:task.requestId}); From 222906645987f2a93de9dd2718379f0f82742ef2 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:05:10 +0900 Subject: [PATCH 05/13] test(interactive): record source and Skill byte hashes in Host-owned fixture manifest --- tests/integration/m6-e2e.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/m6-e2e.test.mjs b/tests/integration/m6-e2e.test.mjs index 2c32480..8d1f091 100644 --- a/tests/integration/m6-e2e.test.mjs +++ b/tests/integration/m6-e2e.test.mjs @@ -60,7 +60,7 @@ async function fixture(t,{stageBudget=128,ready=true,allowedInputKeys=['message' const config={ namespace:'m6-reference-host',runId:'run-m6-test', startKey:'m6-acceptance-start',storePath,programPath,identityPath, - privateKeyPath,skillPath,workerPath:workerScript, + privateKeyPath,skillPath,sourcePath,skillHash:sha256Bytes(await readFile(skillPath)),workerPath:workerScript, state:{ready,message:'hello',secret:privateSentinel}, allowedInputKeys,stageBudget,osIsolation, }; From 74df721eb6e38ef6466f9939be7938db6cc6a717 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:05:48 +0900 Subject: [PATCH 06/13] test(interactive): M6 source/artifact/Skill drift and durable missing-when error negative proofs --- tests/integration/m6-e2e.test.mjs | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/integration/m6-e2e.test.mjs b/tests/integration/m6-e2e.test.mjs index 8d1f091..fb4b368 100644 --- a/tests/integration/m6-e2e.test.mjs +++ b/tests/integration/m6-e2e.test.mjs @@ -282,3 +282,46 @@ test('M6: model worker with empty inherited environment receives only public tas assertModelNeverSawSecret(run); assert.equal(f.readStore().cp.status,'succeeded'); }); + + +test('M6 S06: source/Program/Skill bytes changing while waiting fail closed without another Pending',async t=>{ + const cases=[ + {kind:'source',mutate:async f=>writeFile(f.sourcePath, + workflowText+'\n// unauthorized source edit\n')}, + {kind:'Program artifact',mutate:async f=>writeFile(f.config.programPath, + Buffer.concat([await readFile(f.config.programPath),Buffer.from('\n// unauthorized artifact edit')]))}, + {kind:'Skill instruction',mutate:async f=>writeFile(f.config.skillPath, + 'Modified Skill instruction after Agent Pending was published')}, + ]; + for(const scenario of cases) { + const f=await fixture(t); + const crashed=await invoke(f,'start','after-pending'); + assert.notEqual(crashed.code,0,scenario.kind); + const before=f.readStore(); + assert.equal(before.cp.status,'waiting'); + const original=canonicalBytes(before.cp); + await scenario.mutate(f); + const rejected=await invoke(f,'resume'); + assert.notEqual(rejected.code,0,scenario.kind); + assert.equal(rejected.events.some(e=>e.event==='worker-exited'),false,scenario.kind); + assert.deepEqual(canonicalBytes(f.readStore().cp),original,scenario.kind); + assert.equal(f.readStore().journal.length,before.journal.length,scenario.kind); + } +}); + +test('M6 S01: missing Condition input yields persistent FK_R002 error, not waiting or a model task',async t=>{ + const f=await fixture(t); + await f.updateConfig({state:{message:'hello',secret:privateSentinel}}); + const run=await invoke(f); + assert.equal(run.code,2,run.stderr); + assert.equal(run.events.at(-1)?.status,'errored'); + assert.equal(run.events.some(x=>x.event==='worker-exited'),false); + const persisted=f.readStore(); + assert.equal(persisted.cp.status,'errored'); + assert.equal(persisted.cp.terminal.causeCode,'FK_R002'); + const history=persisted.journal.length; + const resume=await invoke(f,'resume'); + assert.equal(resume.code,2); + assert.equal(f.readStore().journal.length,history); + await assert.rejects(f.standardResult(),{code:'FK_R002'}); +}); From a13ddf1431cb0ce0d63fbe5fa8ed85ce83d3de0e Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:06:00 +0900 Subject: [PATCH 07/13] test(interactive): make M6 cross-process integration acceptance mandatory in npm run verify --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d06238c..9d16e5b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ ], "scripts": { "build": "node scripts/build.mjs", - "test": "node --test tests/dsl.test.mjs tests/contracts.test.mjs tests/compiler/*.test.mjs tests/interactive/*.test.mjs tests/when/*.test.mjs tests/runtime/*.test.mjs tests/cli/*.test.mjs tests/integrations/*.test.mjs tests/package/*.test.mjs", + "test": "node --test tests/dsl.test.mjs tests/contracts.test.mjs tests/compiler/*.test.mjs tests/interactive/*.test.mjs tests/integration/*.test.mjs tests/when/*.test.mjs tests/runtime/*.test.mjs tests/cli/*.test.mjs tests/integrations/*.test.mjs tests/package/*.test.mjs", "test:types": "node scripts/test-types.mjs", "check:package": "node scripts/check-package.mjs", "test:package": "node scripts/test-package.mjs", From 921cee9e1ce18efcb3354e05fdfcfd51cf9c7c82 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:06:16 +0900 Subject: [PATCH 08/13] ci(interactive): M6 real Host/worker cross-UID acceptance on Node22 and Node24 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c892d1..fa7fcd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,8 +78,14 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: ${{ matrix.node }} + - name: Install locked dependencies for real Host/worker integration + run: npm ci --ignore-scripts --no-audit --no-fund - name: Prove cross-UID model worker cannot read Host key or SQLite DB run: node tests/security/host-isolation.mjs + - name: Prove end-to-end trusted Host and separate unprivileged worker + env: + FLOWKIT_M6_ISOLATION: '1' + run: node --test tests/integration/m6-e2e.test.mjs website: runs-on: ubuntu-24.04 From 68430973c270b1c2d5c37b569aae376e39df37df Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:09:14 +0900 Subject: [PATCH 09/13] test(interactive): publish only scripted worker into readable temp root for real cross-UID Host IPC --- tests/integration/m6-e2e.test.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/integration/m6-e2e.test.mjs b/tests/integration/m6-e2e.test.mjs index fb4b368..2a57ae6 100644 --- a/tests/integration/m6-e2e.test.mjs +++ b/tests/integration/m6-e2e.test.mjs @@ -41,6 +41,12 @@ async function fixture(t,{stageBudget=128,ready=true,allowedInputKeys=['message' await writeFile(skillPath,'Review and return a JSON answer.',{mode:0o644}); const sourcePath=path.join(root,'acceptance.workflow.js'); await writeFile(sourcePath,workflowText,{mode:0o644}); + // The isolated nobody worker cannot traverse the CI checkout on every + // runner. Publish *only* this non-secret scripted worker into a world- + // readable temp root, while Host DB/keys remain under privateRoot/0700. + const publicWorker=path.join(root,'public-m6-worker.mjs'); + await writeFile(publicWorker,await readFile(workerScript),{mode:0o644}); + await chmod(publicWorker,0o644); const compiled=await compileInteractive(sourcePath,{ output:'memory',featureStage:'P0-A', }); @@ -60,7 +66,7 @@ async function fixture(t,{stageBudget=128,ready=true,allowedInputKeys=['message' const config={ namespace:'m6-reference-host',runId:'run-m6-test', startKey:'m6-acceptance-start',storePath,programPath,identityPath, - privateKeyPath,skillPath,sourcePath,skillHash:sha256Bytes(await readFile(skillPath)),workerPath:workerScript, + privateKeyPath,skillPath,sourcePath,skillHash:sha256Bytes(await readFile(skillPath)),workerPath:publicWorker, state:{ready,message:'hello',secret:privateSentinel}, allowedInputKeys,stageBudget,osIsolation, }; From db5d8eaf54816f38f188096bafc00b008ee15a6e Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 00:12:09 +0900 Subject: [PATCH 10/13] test(interactive): M6 zero skipped matrix tests and measurable S07/restart CAS, size, latency evidence --- tests/integration/m6-e2e.test.mjs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/integration/m6-e2e.test.mjs b/tests/integration/m6-e2e.test.mjs index 2a57ae6..31b39f2 100644 --- a/tests/integration/m6-e2e.test.mjs +++ b/tests/integration/m6-e2e.test.mjs @@ -184,7 +184,9 @@ test('M6: after Pending publish, killing Host and reopening only persisted Progr const revision=prior.cp.revision; // No source DSL is needed for resume: Program bytes are retained in Store. await rm(f.sourcePath); + const resumeStarted=process.hrtime.bigint(); const recovered=await invoke(f,'resume'); + const resumeMs=Number(process.hrtime.bigint()-resumeStarted)/1e6; assert.equal(recovered.code,0,recovered.stderr); assert.equal(recovered.events.find(x=>x.event==='task-published').requestId, prior.cp.pending.requestId); @@ -193,6 +195,13 @@ test('M6: after Pending publish, killing Host and reopening only persisted Progr assert.equal(done.cp.status,'succeeded'); assert.equal(done.cp.receipts.length,1); assertContinuousJournal(done); + console.log('M6_RECOVERY_METRICS '+JSON.stringify({ + schemaVersion:1,resumeMs:Math.round(resumeMs), + revisionBefore:revision,revisionAfter:done.cp.revision, + casCount:done.cp.revision,journalEntries:done.journal.length, + checkpointBytes:canonicalBytes(done.cp).byteLength, + sameRequestId:true, + })); }); test('M6: worker finished but Host crashed before durable receipt; retry preserves Pending identity, not exactly-once provider execution',async t=>{ @@ -214,7 +223,9 @@ test('M6: worker finished but Host crashed before durable receipt; retry preserv test('M6: budget 1 versus budget 128 reaches byte-equal terminal State, with monotonic CAS and no fake waiting',async t=>{ const a=await fixture(t,{stageBudget:1}); const b=await fixture(t,{stageBudget:128}); + const startClock=process.hrtime.bigint(); const [fast,slow]=await Promise.all([invoke(a),invoke(b)]); + const durationMs=Number(process.hrtime.bigint()-startClock)/1e6; assert.equal(fast.code,0,fast.stderr); assert.equal(slow.code,0,slow.stderr); for(const f of [a,b]) assertContinuousJournal(f.readStore()); @@ -228,6 +239,14 @@ test('M6: budget 1 versus budget 128 reaches byte-equal terminal State, with mon assert.equal(run.events.filter(x=>x.event==='task-published').length,1); assert.equal(run.events.at(-1).status,'succeeded'); } + console.log('M6_S07_METRICS '+JSON.stringify({ + schemaVersion:1,wallMs:Math.round(durationMs), + budget1:{checkpointBytes:canonicalBytes(left.cp).byteLength,casCount:left.cp.revision, + journalEntries:left.journal.length}, + budget128:{checkpointBytes:canonicalBytes(right.cp).byteLength,casCount:right.cp.revision, + journalEntries:right.journal.length}, + equalTerminalState:true, + })); }); test('M6: changed Host disclosure policy denies resume with no journal/CAS mutation',async t=>{ @@ -279,9 +298,10 @@ test('M6: tampered SQLite artifact byte is rejected before worker dispatch; neve assert.deepEqual(canonicalBytes(cpSnapshot),canonicalBytes(before.cp)); }); -test('M6: model worker with empty inherited environment receives only public task and cannot read Host secret files on isolated Linux',async t=>{ - if(process.env.FLOWKIT_M6_ISOLATION!=='1')return t.skip('Dedicated Linux isolation job runs this proof'); - const f=await fixture(t,{osIsolation:true}); +test('M6: external worker receives only public task; dedicated Linux job additionally enforces cross-UID access denial',async t=>{ + const isolation=process.env.FLOWKIT_M6_ISOLATION==='1'; + if(isolation) assert.equal(process.platform,'linux'); + const f=await fixture(t,{osIsolation:isolation}); const run=await invoke(f); assert.equal(run.code,0,run.stderr); assert.equal(run.events.filter(e=>e.event==='worker-exited').length,1); From 1280a87180bf5a63ccaec9876f7ceb5011d85d46 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 08:16:26 +0900 Subject: [PATCH 11/13] test(interactive): expose near-2MiB pending receipt saturation and atomic rejection --- tests/integration/m6-ledger-boundary.test.mjs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/integration/m6-ledger-boundary.test.mjs diff --git a/tests/integration/m6-ledger-boundary.test.mjs b/tests/integration/m6-ledger-boundary.test.mjs new file mode 100644 index 0000000..9de7668 --- /dev/null +++ b/tests/integration/m6-ledger-boundary.test.mjs @@ -0,0 +1,97 @@ +/** + * M6 S07 adversarial capacity probe. This records the *currently unresolved* + * liveness boundary instead of silently declaring the 2 MiB ledger gate PASS: + * a legitimate Pending can fit in the Store while accepting its receipt cannot. + * The Store must at least reject atomically (never half-accept a result). + */ +import test,{after} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {analyzeSource} from '../../src/compiler/analyze.mjs'; +import {lowerInteractive} from '../../src/compiler/interactive/lower.mjs'; +import {emitInteractive} from '../../src/compiler/interactive/emit.mjs'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; +import {createInteractiveCoordinator} from '../../src/interactive/coordinator.mjs'; +import {canonicalBytes,MAX_CHECKPOINT_BYTES} from '../../src/interactive/canonical.mjs'; + +const roots=[]; +after(async()=>{for(const dir of roots)await rm(dir,{recursive:true,force:true})}); +const workflow=[ + "import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl';", + "export default defineWorkflow({id:'m6-ledger-boundary',version:'1.0.0',name:'Ledger',nodes:[", + "node('worker',{name:'Worker',agent:'writer',inputs:['message'],", + "output:{type:'object',properties:{answer:{type:'string'}}},retries:0,next:'done'}),", + "terminal('done',{name:'Done',status:'succeeded'})]});", +].join('\n'); + +test('M6 S07: near-2MiB waiting Checkpoint rejects an otherwise valid receipt atomically; liveness gate remains unresolved',async()=>{ + const dir=await mkdtemp(path.join(tmpdir(),'flowkit-m6-ledger-')); + roots.push(dir); + const store=createSqliteCheckpointStore(path.join(dir,'store.db'),{namespace:'m6-s07'}); + try { + const analysis=await analyzeSource(workflow,{ + filename:'m6-ledger-boundary.workflow.js',projectRoot:dir,skillCheck:false, + }); + assert.equal(analysis.diagnostics.some(d=>d.severity==='error'),false, + JSON.stringify(analysis.diagnostics)); + const lowered=lowerInteractive(analysis); + assert.ok(lowered.ir,JSON.stringify(lowered.diagnostics)); + const compiled=emitInteractive(lowered.ir); + assert.ok(compiled.code,JSON.stringify(compiled.diagnostics)); + const api=createInteractiveCoordinator({ + store, + runtime:{ + skillResolver:async()=> 'Write a valid answer', + validateResult:()=>[], + }, + }); + const started=await api.start({ + programBytes:Buffer.from(compiled.code), + programIdentity:compiled.programIdentity, + runId:'s07-near-limit',startKey:'s07-near-limit',state:{message:'hello'}, + }); + assert.equal(started.status,'waiting'); + const old=store.load(started.runId); + const candidate={ + ...old,revision:old.revision+1, + state:{...old.state,filler:''}, + }; + // Near the actual 2 MiB persisted Checkpoint limit without exceeding it. + const headroom=75; + const baseSize=canonicalBytes(candidate).byteLength; + assert.ok(baseSize+headroom Date: Wed, 11 Nov 2026 08:17:54 +0900 Subject: [PATCH 12/13] test(interactive): use valid 64KiB Agent result to reproduce bounded ledger saturation --- tests/integration/m6-ledger-boundary.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/m6-ledger-boundary.test.mjs b/tests/integration/m6-ledger-boundary.test.mjs index 9de7668..1dd0f67 100644 --- a/tests/integration/m6-ledger-boundary.test.mjs +++ b/tests/integration/m6-ledger-boundary.test.mjs @@ -78,7 +78,7 @@ test('M6 S07: near-2MiB waiting Checkpoint rejects an otherwise valid receipt at const receipt={ runId:started.runId,requestId:started.pending.requestId, nodeId:started.pending.nodeId,attempt:started.pending.attempt, - result:{answer:'ok'}, + result:{answer:'x'.repeat(64*1024)}, }; for(let i=0;i<2;i++){ await assert.rejects(api.complete(receipt),/Canonical JSON exceeds maximum bytes/); From e1e15a70356e53401288d393aa7527c6723fe797 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 11:05:30 +0900 Subject: [PATCH 13/13] test(interactive): M6 S07 now asserts near-2MiB bug is fixed by reviewed main PR #8 --- tests/integration/m6-ledger-boundary.test.mjs | 59 ++++++++----------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/tests/integration/m6-ledger-boundary.test.mjs b/tests/integration/m6-ledger-boundary.test.mjs index 1dd0f67..fa742f9 100644 --- a/tests/integration/m6-ledger-boundary.test.mjs +++ b/tests/integration/m6-ledger-boundary.test.mjs @@ -1,8 +1,7 @@ /** - * M6 S07 adversarial capacity probe. This records the *currently unresolved* - * liveness boundary instead of silently declaring the 2 MiB ledger gate PASS: - * a legitimate Pending can fit in the Store while accepting its receipt cannot. - * The Store must at least reject atomically (never half-accept a result). + * M6 S07 positive regression after scoped PR #8: a near-2MiB waiting + * Checkpoint is rejected before mutation, so a valid Agent result from the + * previous safely admitted Pending still reaches a terminal state. */ import test,{after} from 'node:test'; import assert from 'node:assert/strict'; @@ -26,7 +25,7 @@ const workflow=[ "terminal('done',{name:'Done',status:'succeeded'})]});", ].join('\n'); -test('M6 S07: near-2MiB waiting Checkpoint rejects an otherwise valid receipt atomically; liveness gate remains unresolved',async()=>{ +test('M6 S07: near-hard-limit waiting mutation fails before CAS; valid 64-KiB receipt reaches terminal',async()=>{ const dir=await mkdtemp(path.join(tmpdir(),'flowkit-m6-ledger-')); roots.push(dir); const store=createSqliteCheckpointStore(path.join(dir,'store.db'),{namespace:'m6-s07'}); @@ -44,7 +43,8 @@ test('M6 S07: near-2MiB waiting Checkpoint rejects an otherwise valid receipt at store, runtime:{ skillResolver:async()=> 'Write a valid answer', - validateResult:()=>[], + validateResult:(_schema,result)=> + typeof result?.answer==='string'?[]:['Expected string answer'], }, }); const started=await api.start({ @@ -55,43 +55,36 @@ test('M6 S07: near-2MiB waiting Checkpoint rejects an otherwise valid receipt at assert.equal(started.status,'waiting'); const old=store.load(started.runId); const candidate={ - ...old,revision:old.revision+1, - state:{...old.state,filler:''}, + ...old,revision:old.revision+1,state:{...old.state,filler:''}, }; - // Near the actual 2 MiB persisted Checkpoint limit without exceeding it. const headroom=75; const baseSize=canonicalBytes(candidate).byteLength; - assert.ok(baseSize+headroomMAX_CHECKPOINT_BYTES-128); + await assert.rejects(async()=>store.compareAndSwap({ + runId:started.runId,expectedRevision:old.revision, + nextCheckpoint:candidate,transitionId:'m6-s07-too-big-before-publish', + journalEntry:{kind:'m6-s07-reject-large-State'}, + }),{code:'FK_I_CHECKPOINT_CAPACITY'}); + assert.deepEqual(canonicalBytes(store.load(started.runId)),beforeBytes); + assert.deepEqual(store.listJournal(started.runId),beforeJournal); const receipt={ runId:started.runId,requestId:started.pending.requestId, nodeId:started.pending.nodeId,attempt:started.pending.attempt, result:{answer:'x'.repeat(64*1024)}, }; - for(let i=0;i<2;i++){ - await assert.rejects(api.complete(receipt),/Canonical JSON exceeds maximum bytes/); - assert.deepEqual(canonicalBytes(store.load(started.runId)),beforeBytes); - assert.deepEqual(store.listJournal(started.runId),beforeJournal); - } - const recovered=await api.resume({runId:started.runId}); - assert.equal(recovered.status,'waiting'); - assert.equal(recovered.pending.requestId,started.pending.requestId); - console.log('M6_S07_LEDGER_SATURATION_BLOCKER '+JSON.stringify({ - schemaVersion:1,checkpointBytes:beforeBytes.byteLength, - maximumBytes:MAX_CHECKPOINT_BYTES,receiptAccepted:false, - journalUnchanged:true,pendingReplayable:true,livenessResolved:false, + const completed=await api.complete(receipt); + assert.equal(completed.status,'succeeded'); + assert.equal(completed.state.answer.length,64*1024); + assert.equal(store.getReceipt(started.runId,receipt.requestId).acceptedRevision, + completed.acceptedRevision); + assert.equal(store.listJournal(started.runId).length,completed.revision+1); + console.log('M6_S07_LEDGER_CAPACITY_FIXED '+JSON.stringify({ + schemaVersion:1,checkpointLimitBytes:MAX_CHECKPOINT_BYTES, + unsafeMutationRejected:true,unchangedOnRejection:true, + validReceiptAccepted:true,ended:completed.status, })); }finally{store.close()} });