@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.dockerignore +2 -0
  2. package/.env.example +3 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +38 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/Dockerfile +18 -10
  7. package/THIRD_PARTY_NOTICES.md +6 -3
  8. package/bin/ez +7 -0
  9. package/docker/broker-readiness.mjs +54 -0
  10. package/docker/broker-update-smoke.mjs +86 -0
  11. package/docker/engine-pnpm-workspace.yaml +4 -0
  12. package/docker/run.ts +1 -1
  13. package/docker/smoke.mjs +1 -1
  14. package/docs/application-channel.md +32 -1
  15. package/docs/architecture/ai-selection.md +26 -5
  16. package/docs/architecture/authority-boundaries.md +2 -2
  17. package/docs/development-and-testing.md +1 -1
  18. package/docs/docker-runtime.md +39 -0
  19. package/docs/isolated-plugin-update-readiness.md +66 -0
  20. package/docs/plugin-guide.md +1 -1
  21. package/docs/plugins.md +1 -1
  22. package/docs/releasing.md +5 -1
  23. package/docs/scheduling.md +117 -9
  24. package/docs/selective-monitoring.md +13 -0
  25. package/docs/upgrades.md +38 -1
  26. package/docs/work-preflight.md +9 -0
  27. package/package.json +4 -3
  28. package/pnpm-workspace.yaml +7 -0
  29. package/scripts/smoke-scheduler.ts +4 -1
  30. package/src/ai.ts +41 -4
  31. package/src/application-channel.ts +43 -3
  32. package/src/application-cli.ts +7 -1
  33. package/src/application-origin.ts +2 -0
  34. package/src/audio.ts +37 -22
  35. package/src/client-defaults.ts +3 -2
  36. package/src/config.ts +9 -0
  37. package/src/control-state.ts +3 -1
  38. package/src/delivery-socket-client.d.mts +2 -0
  39. package/src/delivery-socket-client.mjs +2 -0
  40. package/src/delivery-socket.ts +38 -1
  41. package/src/event-sources.ts +2 -2
  42. package/src/executor.ts +72 -1
  43. package/src/files.ts +47 -3
  44. package/src/host-executor.ts +40 -26
  45. package/src/index.ts +42 -17
  46. package/src/install-tools.mjs +18 -9
  47. package/src/menu.ts +4 -2
  48. package/src/model-policy.ts +4 -0
  49. package/src/plugin-broker.mjs +47 -4
  50. package/src/plugins/manager.mjs +11 -4
  51. package/src/run-evidence.ts +78 -0
  52. package/src/runs.ts +16 -2
  53. package/src/schedule-cli.ts +80 -10
  54. package/src/schedule-preflight.ts +23 -0
  55. package/src/scheduled-tasks.ts +16 -12
  56. package/src/scheduler.ts +102 -16
  57. package/src/scripts.ts +167 -0
  58. package/src/setup.ts +2 -2
  59. package/src/task-capability.ts +17 -1
  60. package/src/task-executor.ts +11 -8
  61. package/src/task-mcp.ts +20 -8
  62. package/src/tasks.ts +89 -9
  63. package/src/updates/runtime.mjs +53 -11
  64. package/src/updates/supervisor.mjs +23 -13
  65. package/test/ai.test.ts +85 -5
  66. package/test/application-channel.test.ts +16 -1
  67. package/test/audio.test.ts +19 -0
  68. package/test/channel-owner.test.ts +2 -2
  69. package/test/config.test.ts +8 -0
  70. package/test/desktop-bridge.test.ts +2 -1
  71. package/test/executor.test.ts +37 -0
  72. package/test/failure.test.ts +4 -4
  73. package/test/files.test.ts +44 -0
  74. package/test/host-executor.test.ts +49 -6
  75. package/test/install-json.test.mjs +21 -0
  76. package/test/install-tools.test.mjs +6 -5
  77. package/test/native-tasks.test.ts +1 -1
  78. package/test/plugin-broker.test.mjs +64 -0
  79. package/test/plugin-manager.test.mjs +20 -0
  80. package/test/private-speech-config.test.ts +22 -0
  81. package/test/run-evidence.test.ts +78 -0
  82. package/test/schedule-cli.test.ts +47 -3
  83. package/test/schedule-preflight.test.ts +49 -0
  84. package/test/scheduled-tasks.test.ts +2 -2
  85. package/test/scheduler-host.test.ts +21 -3
  86. package/test/scheduler-relay.test.ts +84 -4
  87. package/test/scheduler.test.ts +21 -1
  88. package/test/script-schedules.test.ts +219 -0
  89. package/test/task-native.test.ts +50 -12
  90. package/test/tasks.test.ts +97 -4
  91. package/test/updates.test.mjs +77 -18
package/test/ai.test.ts CHANGED
@@ -4,13 +4,49 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { ControlStore } from '../src/control-state.js'
7
- import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
7
+ import { initialPreset, chatPreset, readModels, readCodexCatalog, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
8
8
  import { createAiMenu } from '../src/menu.js'
9
9
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
10
10
  import { InboxStore } from '../src/inbox.js'
11
11
  import { executionDefaults } from '../src/model-policy.js'
12
12
  import type { Update } from 'grammy/types'
13
13
 
14
+ const noCodex = async (): Promise<string> => { throw new Error('Native discovery unavailable') }
15
+
16
+ test('Codex discovery refreshes through the bound native CLI without curated overrides or a turn', async () => {
17
+ const home = await mkdtemp(join(tmpdir(), 'ez-native-catalog-'))
18
+ const previousToken = process.env.TELEGRAM_BOT_TOKEN
19
+ try {
20
+ process.env.TELEGRAM_BOT_TOKEN = 'must-not-reach-native-client'
21
+ await writeFile(join(home, 'models_cache.json'), JSON.stringify({ models: [
22
+ { slug: 'old-model', visibility: 'list' },
23
+ ] }))
24
+ const calls: string[][] = []
25
+ const run = async (args: string[], env: NodeJS.ProcessEnv) => {
26
+ calls.push(args)
27
+ assert.equal(env.CODEX_HOME, home)
28
+ assert.equal(env.TELEGRAM_BOT_TOKEN, undefined)
29
+ return JSON.stringify({ models: [
30
+ { slug: 'new-model', display_name: 'New model', visibility: 'list',
31
+ supported_reasoning_levels: [{ effort: 'ultra' }], model_messages: 'untrusted', api_key: 'secret' },
32
+ { slug: 'hidden-model', visibility: 'hide' },
33
+ ] })
34
+ }
35
+ assert.deepEqual(await readModels(home, async cli => cli === 'codex', home,
36
+ undefined, undefined, undefined, undefined, run), [
37
+ { cli: 'codex', model: 'new-model', name: 'New model', efforts: ['ultra'] },
38
+ ])
39
+ assert.deepEqual(calls, [['debug', 'models']])
40
+ for (const unavailable of [noCodex, async () => 'not json', async () => '{"models":[]}']) {
41
+ assert.deepEqual(await readCodexCatalog(home, unavailable), { models: [{ slug: 'old-model', visibility: 'list' }] })
42
+ }
43
+ } finally {
44
+ if (previousToken === undefined) delete process.env.TELEGRAM_BOT_TOKEN
45
+ else process.env.TELEGRAM_BOT_TOKEN = previousToken
46
+ await rm(home, { recursive: true, force: true })
47
+ }
48
+ })
49
+
14
50
  test('same-client model and default changes preserve history; provider changes require a fresh conversation', async () => {
15
51
  const dir = await mkdtemp(join(tmpdir(), 'ez-ai-model-switch-'))
16
52
  try {
@@ -173,7 +209,7 @@ test('model catalog projects native metadata only, excluding hidden entries and
173
209
  supported_reasoning_levels: [{ effort: 'high' }, { effort: 'xhigh' }, { effort: 'max' }] },
174
210
  { slug: 'hidden-model', visibility: 'hide' },
175
211
  ] }))
176
- assert.deepEqual(await readModels(home, async (cli) => cli === 'codex'), [
212
+ assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', undefined, undefined, undefined, undefined, undefined, noCodex), [
177
213
  { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
178
214
  { cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh', 'max'] },
179
215
  ])
@@ -258,7 +294,7 @@ test('model catalog can read an agent-bound Codex home', async () => {
258
294
  { slug: 'gpt-6-astra', display_name: 'GPT-6 Astra', visibility: 'list',
259
295
  supported_reasoning_levels: [{ effort: 'low' }] },
260
296
  ] }))
261
- assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome), [
297
+ assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome, undefined, undefined, undefined, undefined, noCodex), [
262
298
  { cli: 'codex', model: 'gpt-6-astra', name: 'GPT-6 Astra', efforts: ['low'] },
263
299
  ])
264
300
  } finally {
@@ -270,7 +306,10 @@ test('model catalog can read an agent-bound Codex home', async () => {
270
306
  test('Choose AI lists the agent-bound Codex home instead of only the desktop fallback', async () => {
271
307
  const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-codex-home-'))
272
308
  const codexHome = await mkdtemp(join(tmpdir(), 'ez-ai-menu-cache-'))
309
+ const previousPath = process.env.PATH
273
310
  try {
311
+ await writeFile(join(codexHome, 'codex'), '#!/bin/sh\nexit 1\n', { mode: 0o700 })
312
+ process.env.PATH = `${codexHome}:${previousPath ?? ''}`
274
313
  await writeFile(join(codexHome, 'models_cache.json'), JSON.stringify({ models: [
275
314
  { slug: 'fixture-bound-model', display_name: 'Bound Fixture', visibility: 'list',
276
315
  supported_reasoning_levels: [{ effort: 'medium' }] },
@@ -298,6 +337,7 @@ test('Choose AI lists the agent-bound Codex home instead of only the desktop fal
298
337
  await menu.handle(context(client.callback_data) as never)
299
338
  assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), ['Bound Fixture', 'Back to clients'])
300
339
  } finally {
340
+ process.env.PATH = previousPath
301
341
  await rm(dir, { recursive: true, force: true })
302
342
  await rm(codexHome, { recursive: true, force: true })
303
343
  }
@@ -522,7 +562,7 @@ test('model catalog merges user-curated entries ahead of discovered ones', async
522
562
  const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
523
563
  const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
524
564
  const catalog = await readModels(home, installedOnly, join(home, '.codex'),
525
- noRunner, undefined, undefined, control)
565
+ noRunner, undefined, undefined, control, noCodex)
526
566
  assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
527
567
  { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
528
568
  { cli: 'pi', name: 'Pi · client default', efforts: [] },
@@ -538,7 +578,7 @@ test('model catalog merges user-curated entries ahead of discovered ones', async
538
578
  assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
539
579
  await writeFile(join(control, 'ai-models.json'), 'not json')
540
580
  assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
541
- noRunner, undefined, undefined, control), [
581
+ noRunner, undefined, undefined, control, noCodex), [
542
582
  { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
543
583
  ])
544
584
  } finally {
@@ -592,3 +632,43 @@ test('private channel model choices do not consume owner preset slots', async ()
592
632
  assert.deepEqual((await store.applicationSession(scope))?.preset, readback.preset)
593
633
  } finally { await rm(dir, {recursive:true, force:true}) }
594
634
  })
635
+
636
+ test('detected presets do not count against the saved AI cap', async () => {
637
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-cap-'))
638
+ try {
639
+ const store = new ControlStore(dir, 1000)
640
+ const initial = initialPreset('codex')
641
+ await store.captureChoice(initial)
642
+ for (let n = 1; n < 11; n++) await store.savePreset({...initial, id:`saved-${n}`, name:`Saved ${n}`})
643
+ await store.syncClientPresets(initial, ['claude', 'grok'].map(cli => ({id:`detected_${cli}`, cli, name:`${cli} · client default`})))
644
+ assert.equal((await store.status()).ai!.presets.length, 13)
645
+ const next = {id:'one-more', name:'One more', cli:'claude', model:'sonnet', effort:'medium'}
646
+ await store.savePreset(next)
647
+ await assert.rejects(store.savePreset({...next, id:'too-many', name:'Too many'}), /at most 12/)
648
+ } finally { await rm(dir, {recursive:true, force:true}) }
649
+ })
650
+
651
+ test('Claude catalog projects only aliases and efforts from the installed native help', async () => {
652
+ const help = `Options:
653
+ --effort <level> Effort level for the current session
654
+ (low, medium, high, xhigh, max)
655
+ --model <model> Model for the current session. Provide
656
+ an alias for the latest model (e.g.
657
+ 'fable', 'opus', or 'sonnet') or a
658
+ model's full name.
659
+ -n, --name <name> Set a display name ('ignored')
660
+ `
661
+ const calls: string[][] = []
662
+ const models = await readModels('/nonexistent', async cli => cli === 'claude', undefined, undefined, undefined, undefined, undefined, undefined,
663
+ async (args) => { calls.push(args); return help })
664
+ assert.deepEqual(calls, [['--help']])
665
+ assert.deepEqual(models, [
666
+ { cli: 'claude', name: 'claude · client default', efforts: [] },
667
+ ...['fable', 'opus', 'sonnet'].map(model => ({ cli: 'claude', model, name: `claude · ${model}`, efforts: ['low', 'medium', 'high', 'xhigh', 'max'] })),
668
+ ])
669
+ for (const unavailable of [async () => { throw new Error('not runnable') }, async () => 'Usage: claude'])
670
+ assert.deepEqual(await readModels('/nonexistent', async cli => cli === 'claude', undefined, undefined, undefined, undefined, undefined, undefined, unavailable),
671
+ [{ cli: 'claude', name: 'claude · client default', efforts: [] }])
672
+ await validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus', effort: 'xhigh' }, models, async () => true)
673
+ await assert.rejects(validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus-guess' }, models, async () => true), /not in the installed client catalog/)
674
+ })
@@ -1,7 +1,7 @@
1
1
  import test from 'node:test'
2
2
  import { request as httpRequest } from 'node:http'
3
3
  import assert from 'node:assert/strict'
4
- import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
4
+ import { mkdtemp, rm, writeFile, readFile, chown, stat } from 'node:fs/promises'
5
5
  import { join } from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
7
  import { spawnSync } from 'node:child_process'
@@ -435,3 +435,18 @@ test('shareTelegram bindings list Telegram intake and admit one run with context
435
435
  await runs.patch(finished.id,{status:'completed'})
436
436
  assert.equal((await attach(finished.id,sharedToken,{context:{}})).status,409)
437
437
  })
438
+
439
+ test('administrator task grant replacement preserves the runtime file owner', {skip:process.getuid?.()!==0},async()=>{
440
+ const dir=await mkdtemp(join(tmpdir(),'ez-app-grant-owner-'))
441
+ try {
442
+ const who=await owner(dir),bindings=new ApplicationBindings(dir)
443
+ await bindings.register('voice',token(),who)
444
+ const file=join(dir,'application-bindings.json')
445
+ await chown(file,20000,20000)
446
+ await bindings.taskLaunch('voice',{command:'voice',args:['launch','--task'],tasks:['task_'+'a'.repeat(32)]})
447
+ const saved=await stat(file)
448
+ assert.equal(saved.uid,20000);assert.equal(saved.gid,20000);assert.equal(saved.mode&0o777,0o600)
449
+ await bindings.taskLaunch('voice',null)
450
+ const disabled=await stat(file);assert.equal(disabled.uid,20000);assert.equal(disabled.gid,20000)
451
+ }finally{await rm(dir,{recursive:true,force:true})}
452
+ })
@@ -1,3 +1,4 @@
1
+ import {execFileSync} from 'node:child_process'
1
2
  import test from 'node:test'
2
3
  import assert from 'node:assert/strict'
3
4
  import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech, SpeechCreditsDepletedError } from '../src/audio.js'
@@ -91,3 +92,21 @@ test('synthesizeSpeech rejects empty text', async () => {
91
92
  /Cannot synthesize speech for empty text/,
92
93
  )
93
94
  })
95
+
96
+
97
+ test('OpenRouter uses the existing renderer and encoder for real Ogg Opus and WAV without provider fallback', async t => {
98
+ const mp3=execFileSync('ffmpeg',['-f','s16le','-ar','24000','-ac','1','-i','pipe:0','-f','mp3','pipe:1'],{input:Buffer.alloc(48000),stdio:['pipe','pipe','ignore']})
99
+ t.mock.method(globalThis,'fetch',async (url:string,options:RequestInit) => {
100
+ assert.equal(url,'https://openrouter.ai/api/v1/audio/speech')
101
+ assert.equal(new Headers(options.headers).get('authorization'),'Bearer fixture-key')
102
+ assert.deepEqual(JSON.parse(String(options.body)),{model:'fish-audio/s2.1-pro-free:free',input:'Fixture',response_format:'mp3'})
103
+ return new Response(mp3,{headers:{'content-type':'audio/mpeg'}})
104
+ })
105
+ const options={speechProvider:'openrouter' as const,openrouterApiKey:'fixture-key',geminiApiKey:'unused'}
106
+ const ogg=await synthesizeSpeech('Fixture',options)
107
+ assert.equal(ogg.mimeType,'audio/ogg');assert.equal(ogg.buffer.subarray(0,4).toString(),'OggS');assert.ok(ogg.buffer.includes(Buffer.from('OpusHead')))
108
+ const wav=await synthesizeSpeech('Fixture',{...options,format:'wav'})
109
+ assert.equal(wav.buffer.subarray(0,4).toString(),'RIFF');assert.equal(wav.buffer.readUInt32LE(24),24000)
110
+ t.mock.method(globalThis,'fetch',async () => new Response('private provider detail',{status:429}))
111
+ await assert.rejects(synthesizeSpeech('Fixture',options), /error 429/)
112
+ })
@@ -85,7 +85,7 @@ test('web owner uses the standard schedule CLI, executor and outbox with Telegra
85
85
  await writeFile(fixture,`import {spawnSync} from 'node:child_process';
86
86
  if(process.env.TELEGRAM_BOT_TOKEN)throw Error('secret leak');
87
87
  const scheduled=process.env.EZ_RUN_ID.startsWith('r_schedule_');
88
- if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
88
+ if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--model','fixture-model','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
89
89
  const r=spawnSync(process.execPath,[${JSON.stringify(messageCli)},'--text',scheduled?'Scheduled reply':'Chat reply'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr);`)
90
90
  EXECUTOR_REGISTRY.grok={...original,command:process.execPath,buildArgs:()=>['--import',require.resolve('tsx'),fixture]}
91
91
  const config=loadConfig({EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
@@ -151,7 +151,7 @@ for (const mode of ['owner','web','telegram']) test(`${mode} revocation stops it
151
151
  await runs.create({id:'r_telegram',chatId:42,telegramUserId:42,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Wait'],execution:await control.captureChoice(initialPreset('grok'))})
152
152
  } else {
153
153
  const binding=(await relay.applicationChannel.bindings.register('web',secret(),owner))!
154
- await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice(initialPreset('grok'))})
154
+ await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})})
155
155
  await scheduler.tick(owner,runs,Date.now()+2000)
156
156
  }
157
157
  await relay.drainSources()
@@ -84,3 +84,11 @@ test('Telegram transport is inferred from a valid token; legacy flag contradicti
84
84
  assert.equal(loadConfig({ EZ_TELEGRAM_ENABLED: 'false', EZ_APPLICATION_PORT: '8110' }).telegramEnabled, false)
85
85
  assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'true' }).telegramEnabled, true)
86
86
  })
87
+
88
+ test('scheduled concurrency defaults to six and accepts only positive integers',()=>{
89
+ const env={EZ_APPLICATION_PORT:'8110'}
90
+ assert.equal(loadConfig(env).scheduledConcurrency,6)
91
+ assert.equal(loadConfig({...env,EZ_SCHEDULED_CONCURRENCY:'2'}).scheduledConcurrency,2)
92
+ for(const value of ['0','-1','1.5','bad','9007199254740992'])
93
+ assert.throws(()=>loadConfig({...env,EZ_SCHEDULED_CONCURRENCY:value}),/EZ_SCHEDULED_CONCURRENCY must be a positive integer/)
94
+ })
@@ -52,7 +52,8 @@ test('codex-gui is a distinct preset and catalog entry', async () => {
52
52
  await writeFile(path.join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
53
53
  { slug: 'fixture-model', display_name: 'Fixture', visibility: 'list', supported_reasoning_levels: [{ effort: 'medium' }] },
54
54
  ] }))
55
- const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui')
55
+ const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui', undefined, undefined, undefined, undefined, undefined,
56
+ async () => { throw new Error('Native discovery unavailable') })
56
57
  assert.ok(models.some((model) => model.cli === 'codex' && model.model === 'fixture-model'))
57
58
  assert.ok(models.some((model) => model.cli === 'codex-gui' && model.model === 'fixture-model'))
58
59
  } finally { await rm(home, { recursive: true, force: true }) }
@@ -372,3 +372,40 @@ test('external local owner chat preserves literal input and admission rejects fo
372
372
  if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
373
373
  }
374
374
  })
375
+
376
+ test('Claude runs with agent-bound configuration, shared login and the agent directories', {skip:process.platform==='win32'}, async t => {
377
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-claude-home-'))
378
+ t.after(() => rm(root, { recursive: true, force: true }))
379
+ const bin = path.join(root, 'bin'), workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control'), shared = path.join(root, 'shared')
380
+ await mkdir(bin); await mkdir(workspace)
381
+ await writeFile(path.join(bin, 'claude'), `#!${process.execPath}\nrequire('fs').writeFileSync('observed.json',JSON.stringify({args:process.argv.slice(2),config:process.env.CLAUDE_CONFIG_DIR,storage:process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,token:process.env.CLAUDE_CODE_OAUTH_TOKEN,prompt:require('fs').readFileSync(0,'utf8')}))`, { mode: 0o700 })
382
+ const previous = { PATH: process.env.PATH, EZ_ISOLATION: process.env.EZ_ISOLATION, EZ_EXECUTOR_TRANSPORT: process.env.EZ_EXECUTOR_TRANSPORT }
383
+ t.after(() => { for (const [key, value] of Object.entries(previous)) value === undefined ? delete process.env[key] : process.env[key] = value })
384
+ process.env.PATH = bin + path.delimiter + process.env.PATH
385
+ delete process.env.EZ_EXECUTOR_TRANSPORT
386
+ for (const isolation of [undefined, 'isolated']) {
387
+ if (isolation) process.env.EZ_ISOLATION = isolation; else delete process.env.EZ_ISOLATION
388
+ await ownerRun(controlDir, `r_claude_${isolation ?? 'host'}`)
389
+ const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId: `r_claude_${isolation ?? 'host'}`, timeoutMs: 5000,
390
+ sessionId: '00000000-0000-4000-8000-000000000000', sharedWorkspace: shared, model: 'opus', effort: 'high', repairEnabled: false })
391
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
392
+ const observed = JSON.parse(await readFile(path.join(workspace, 'observed.json'), 'utf8'))
393
+ assert.equal(observed.config, path.join(controlDir, 'cli', 'claude'))
394
+ assert.equal(observed.storage, isolation ? undefined : '')
395
+ assert.equal(observed.prompt, 'hello')
396
+ for (const directory of [controlDir, shared]) assert.equal(observed.args[observed.args.indexOf(directory) - 1], '--add-dir')
397
+ assert.deepEqual(observed.args.slice(-4), ['--model', 'opus', '--effort', 'high'])
398
+ assert.equal(observed.token, undefined)
399
+ }
400
+ const run = async (runId: string) => {
401
+ await ownerRun(controlDir, runId)
402
+ const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId, timeoutMs: 5000, repairEnabled: false })
403
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
404
+ return JSON.parse(await readFile(path.join(workspace, 'observed.json'), 'utf8'))
405
+ }
406
+ await writeFile(path.join(controlDir, 'cli', 'claude', 'oauth-token'), 'sk-ant-oat01-fixture_token-value\n', { mode: 0o600 })
407
+ assert.equal((await run('r_claude_token')).token, 'sk-ant-oat01-fixture_token-value')
408
+ await writeFile(path.join(controlDir, 'cli', 'claude', 'oauth-token'), 'bad token\n--flag', { mode: 0o600 })
409
+ await ownerRun(controlDir, 'r_claude_bad_token')
410
+ await assert.rejects(startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId: 'r_claude_bad_token', timeoutMs: 5000, repairEnabled: false }), /Invalid Claude token binding/)
411
+ })
@@ -100,7 +100,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
100
100
  })
101
101
  try{
102
102
  await control.requestPairing(101,101);await control.approveOwner(101)
103
- const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok')),start=Date.now()+2000
103
+ const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'}),start=Date.now()+2000
104
104
  await scheduler.save({id:'review',name:'Review failures',text:'Review failures',trigger:{everySeconds:60,start:new Date(start).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
105
105
  await scheduler.tick(owner,runs,start)
106
106
  assert.equal((await runs.list()).length,0,'no model job for an empty inbox')
@@ -251,7 +251,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
251
251
  await control.requestPairing(101,101);const owner=await control.approveOwner(101)
252
252
  if(intake) await relay.bot.handleUpdate({update_id:91,message:{message_id:91,date:0,text:'fixture',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
253
253
  else {
254
- const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice(initialPreset('grok'))
254
+ const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
255
255
  await scheduler.save({id:'fixture',name:'Fixture',text:'fixture',trigger:{at:new Date(at).toISOString()},enabled:true,owner,execution})
256
256
  await scheduler.tick(owner,runs,at)
257
257
  }
@@ -276,7 +276,7 @@ test('group members can inspect failures and wake review without exposing other
276
276
  const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
277
277
  const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
278
278
  await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
279
- const execution=await control.captureChoice(initialPreset('grok'))
279
+ const execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
280
280
  await runs.create({id:'tg_1',chatId:-123,telegramUserId:202,texts:['failed'],execution})
281
281
  await runs.patch('tg_1',{status:'failed',endedAt:new Date().toISOString()})
282
282
  await runs.create({id:'tg_2',chatId:-124,telegramUserId:202,texts:['private'],execution})
@@ -343,7 +343,7 @@ test('failed reviewer readback exposes its stop through show, list and resume un
343
343
  const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
344
344
  const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
345
345
  await control.requestPairing(101,101);const owner=await control.approveOwner(101)
346
- const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
346
+ const execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'}),now=Date.now()+1000
347
347
  const s=await scheduler.save({id:'review',name:'Review',text:'Review failures',trigger:{everySeconds:60,start:new Date(now).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
348
348
  await runs.create({id:'r_original',chatId:101,telegramUserId:101,texts:['Work'],execution});await runs.patch('r_original',{status:'failed'})
349
349
  await scheduler.tick(owner,runs,now)
@@ -48,3 +48,47 @@ test('stages file into control attachments, never the workspace', async () => {
48
48
  await rm(tmp, { recursive: true, force: true })
49
49
  }
50
50
  })
51
+
52
+ test('shared incoming document reader extracts PDF text and removes its staged file', async t => {
53
+ const {readChatAttachment}=await import('../src/files.js')
54
+ const {execFile}=await import('node:child_process'), {promisify}=await import('node:util')
55
+ try { await promisify(execFile)('pdftotext',['-v']) } catch { t.skip('Poppler is exercised in the Docker artifact test');return }
56
+ const dir=await mkdtemp('/tmp/ez-pdf-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
57
+ const stream='BT /F1 12 Tf 20 80 Td (Shop opens at 7am.) Tj ET'
58
+ const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R] /Count 1 >>','<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>','<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`]
59
+ let pdf='%PDF-1.4\n';const offsets=[0]
60
+ for(const [i,value] of objects.entries()){offsets.push(Buffer.byteLength(pdf));pdf+=`${i+1} 0 obj\n${value}\nendobj\n`}
61
+ const xref=Buffer.byteLength(pdf);pdf+='xref\n0 6\n0000000000 65535 f \n'+offsets.slice(1).map(n=>String(n).padStart(10,'0')+' 00000 n \n').join('')+`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
62
+ const result=await readChatAttachment(dir,'shop.pdf',Buffer.from(pdf));assert.match(result.text ?? '',/Shop opens at 7am\./);assert.equal(result.type,'pdf')
63
+ const {readdir}=await import('node:fs/promises');assert.deepEqual(await readdir(path.join(dir,'attachments')),[])
64
+ await assert.rejects(readChatAttachment(dir,'invalid.pdf',Buffer.from('%PDF-invalid')))
65
+ })
66
+
67
+ test('shared image read returns original pixels with native MIME and no staged residue',async t=>{
68
+ const {readChatAttachment}=await import('../src/files.js'),{readdir}=await import('node:fs/promises')
69
+ const dir=await mkdtemp('/tmp/ez-image-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
70
+ const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')
71
+ const result=await readChatAttachment(dir,'photo.png',png)
72
+ assert.equal(result.image?.mimeType,'image/png');assert.equal(result.image?.data,png.toString('base64'));assert.equal(result.text,undefined)
73
+ assert.deepEqual(await readdir(path.join(dir,'attachments')),[])
74
+ await assert.rejects(readChatAttachment(dir,'photo.png',Buffer.from('Spoofed image bytes')))
75
+ })
76
+
77
+ test('video read uses bounded native frames and removes temporary decoder files',async t=>{
78
+ const {readChatAttachment}=await import('../src/files.js'),{readdir}=await import('node:fs/promises'),{execFile}=await import('node:child_process'),{promisify}=await import('node:util')
79
+ const execute=promisify(execFile)
80
+ try {await execute('ffmpeg',['-version'])}catch{t.skip('FFmpeg is exercised in the Docker artifact test');return}
81
+ const dir=await mkdtemp('/tmp/ez-video-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
82
+ const video=path.join(dir,'fixture.mp4')
83
+ await execute('ffmpeg',['-v','error','-f','lavfi','-i','color=c=blue:s=64x64:r=8','-f','lavfi','-i','sine=frequency=1000:sample_rate=24000','-t','1','-c:v','mpeg4','-c:a','aac',video])
84
+ const result=await readChatAttachment(dir,'video.mp4',await readFile(video))
85
+ assert.equal(result.type,'mp4');assert.ok((result.images?.length??0)>0);assert.ok((result.images?.length??0)<=8)
86
+ assert.ok(result.images?.every(image=>image.mimeType==='image/jpeg'));assert.ok(result.durationSeconds!<=120)
87
+ assert.deepEqual(await readdir(path.join(dir,'attachments')),[]);assert.ok(!(await readdir(dir)).some(name=>name.startsWith('video-')))
88
+ })
89
+
90
+ test('HEIF stills and audio-only M4A are not treated as MP4 video', () => {
91
+ const box = (brand: string) => Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from(`ftyp${brand}`), Buffer.alloc(12)])
92
+ for (const brand of ['heic', 'avif', 'mif1', 'M4A ']) assert.equal(detectFileType(box(brand)), 'unknown')
93
+ for (const brand of ['isom', 'mp42']) assert.equal(detectFileType(box(brand)), 'mp4')
94
+ })
@@ -13,6 +13,8 @@ import { RunStore } from '../src/runs.js'
13
13
  import { packageVersion } from '../src/version.js'
14
14
  import { executionDefaults } from '../src/model-policy.js'
15
15
  import { workspaceLease } from '../src/plugins/workspace-lease.mjs'
16
+ import { ControlStore } from '../src/control-state.js'
17
+ import { Scripts } from '../src/scripts.js'
16
18
 
17
19
  test('host restart replaces a lock whose PID was reused by another process',async()=>{
18
20
  const root=await mkdtemp(path.join(tmpdir(),'ez-host-reused-pid-'))
@@ -265,13 +267,13 @@ test('one installed CLI executes two agent bindings with separate minds and sani
265
267
  await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
266
268
  const directory=path.join(agents[0].controlDir,'host-executor')
267
269
  const runs=new RunStore(agents[0].controlDir)
268
- await runs.create({id:'r_hold',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
270
+ await runs.create({id:'r_hold',execution:{sessionId:crypto.randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
269
271
  await runs.patch('r_hold',{status:'running'})
270
- await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
272
+ await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',model:'fixture-model'}}))
271
273
  for(let n=0;n<100;n++){try{await readFile(path.join(directory,'r_hold.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
272
- await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
274
+ await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',execution:{sessionId:crypto.randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
273
275
  await new RunStore(agents[0].controlDir).patch('r_schedule_queued',{status:'running'})
274
- await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',sharedWorkspace:'/wrong'}}))
276
+ await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',model:'fixture-model',sharedWorkspace:'/wrong'}}))
275
277
  const otherDirectory=path.join(agents[1].controlDir,'host-executor')
276
278
  await ownerRun(agents[1].controlDir,'r_other_shared')
277
279
  await writeFile(path.join(otherDirectory,'r_other_shared.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
@@ -285,10 +287,10 @@ test('one installed CLI executes two agent bindings with separate minds and sani
285
287
  return output.trim().split('\n').map(line=>JSON.parse(line))
286
288
  }
287
289
  // Chat completes while this agent's scheduled turn stays active. A second
288
- // scheduled turn still waits, and the shared lifecycle lease stays held.
290
+ // scheduled turn also completes, and the held job keeps the lifecycle lease.
289
291
  await completed(otherDirectory,'r_other_shared')
290
292
  const chatEvents=await completed(directory,'tg_42')
291
- assert.ok(await readFile(path.join(directory,'r_schedule_queued.request.json')))
293
+ await completed(directory,'r_schedule_queued')
292
294
  assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
293
295
  await ownerRun(agents[0].controlDir,'tg_43')
294
296
  await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
@@ -525,3 +527,44 @@ test('startup replaces prior catalog after provider policy changes even when dis
525
527
  assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),[])
526
528
  } finally {abort.abort();await server;await rm(root,{recursive:true,force:true})}
527
529
  })
530
+
531
+ test('host transport runs a registered script from the run record and refuses changed entry-point bytes', async () => {
532
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-script-'))
533
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
534
+ const abort=new AbortController();let server:Promise<void>|undefined
535
+ const oldToken=process.env.TELEGRAM_BOT_TOKEN
536
+ try {
537
+ await mkdir(workspace);await mkdir(directory,{recursive:true})
538
+ await writeFile(path.join(workspace,'guard.mjs'),"console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),due:process.env.EZ_DUE_AT,token:process.env.TELEGRAM_BOT_TOKEN}))\n")
539
+ process.env.TELEGRAM_BOT_TOKEN='must-not-reach-host-script'
540
+ await ownerRun(controlDir,'r_seed')
541
+ const owner=(await new ControlStore(controlDir,900000).status()).owner!
542
+ const registration=await new Scripts(controlDir).save({id:'guard',owner,workspace,entry:'guard.mjs',interpreter:'node',args:['saved'],timeoutSeconds:60},true)
543
+ const runs=new RunStore(controlDir)
544
+ const scripted=async(id:string)=>{
545
+ await runs.create({id,chatId:101,telegramUserId:101,texts:['[schedule]'],script:{id:'guard',args:['extra'],revision:registration.revision,sha256:registration.sha256},
546
+ scheduled:{id:'s_guard',revision:'rev1',dueAt:'2026-10-07T11:15:00.000Z',pairedAt:owner.pairedAt}})
547
+ await runs.patch(id,{status:'running'})
548
+ // The request cannot choose the command, script or workspace.
549
+ await writeFile(path.join(directory,`${id}.request.json`),JSON.stringify({texts:['x'],options:{workspace:'/wrong',cli:'grok',script:{id:'other',args:['injected']},timeoutMs:0}}))
550
+ let events:any[]=[]
551
+ for(let n=0;n<300;n++){try{events=(await readFile(path.join(directory,`${id}.events`),'utf8')).trim().split('\n').map(l=>JSON.parse(l));if(events.some(e=>e.stream==='exit'))break}catch{}await new Promise(r=>setTimeout(r,20))}
552
+ return events
553
+ }
554
+ server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,undefined,async()=>[])
555
+ const events=await scripted('r_schedule_script_ok')
556
+ assert.equal(events.find(e=>e.stream==='exit')?.code,0)
557
+ const result=JSON.parse(events.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
558
+ assert.equal(result.cwd,await realpath(workspace));assert.deepEqual(result.args,['saved','extra'])
559
+ assert.equal(result.due,'2026-10-07T11:15:00.000Z');assert.equal(result.token,undefined)
560
+ await writeFile(path.join(workspace,'guard.mjs'),"console.log('changed')\n")
561
+ const refused=await scripted('r_schedule_script_changed')
562
+ assert.equal(refused.find(e=>e.stream==='exit')?.code,1)
563
+ assert.equal(refused.filter(e=>e.stream==='stdout').length,0)
564
+ assert.match(refused.filter(e=>e.stream==='stderr').map(e=>e.text).join(''),/entry point changed since registration/)
565
+ }finally{
566
+ abort.abort();await server
567
+ if(oldToken===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=oldToken
568
+ await rm(root,{recursive:true,force:true})
569
+ }
570
+ })
@@ -0,0 +1,21 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { run, latestCodexVersion } from '../src/install-tools.mjs';
4
+
5
+ test('Codex discovery parses stdout and retains failure diagnostics',async()=>{
6
+ const script=`process.stdout.write('"0.160.1"\\n');process.stderr.write('package manager notice\\n');`;
7
+ const invoke=(command,args,options)=>{
8
+ assert.equal(command,'pnpm');
9
+ assert.deepEqual(args,['view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/']);
10
+ assert.equal(options.stdoutOnly,true);
11
+ return run(process.execPath,['-e',script],options);
12
+ };
13
+ assert.equal(await latestCodexVersion(invoke,{command:'pnpm',args:[]}),'0.160.1');
14
+ await assert.rejects(run(process.execPath,['-e',script+'process.exitCode=1'],{stdoutOnly:true}),/package manager notice/);
15
+ });
16
+
17
+ test('Codex discovery rejects contaminated stdout without leaking it',async()=>{
18
+ await assert.rejects(latestCodexVersion(async()=> '"0.160.1"\nprivate-output'),{message:'Codex version discovery returned invalid JSON on stdout'});
19
+ for(const value of ['null','{}','["0.160.1"]','"latest"'])
20
+ await assert.rejects(latestCodexVersion(async()=>value),/Invalid latest Codex version/);
21
+ });
@@ -79,19 +79,20 @@ test('isolated deployments need no host ledger endpoint',async t=>{
79
79
  });
80
80
  test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
81
81
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
82
- let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
83
- const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
82
+ let started,finish,count=0,latest='0.160.0';const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
83
+ const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return JSON.stringify(latest);if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
84
84
  const first=build({home,source},invoke);await running;
85
85
  assert.equal((await build({home,source},invoke)).state,'busy-or-interrupted');assert.equal(count,1);
86
86
  finish();const result=await first;assert.equal(result.state,'completed');
87
87
  assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
88
88
  await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
89
+ latest='0.160.1';const newer=await build({home,source},invoke);assert.equal(count,3);assert.equal(newer.codexVersion,latest);assert.notEqual(newer.image,result.image);
89
90
  });
90
91
  test('source checkouts build RCs only: clean reviewed commit plus increasing label',async t=>{
91
92
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
92
93
  let inside=true,status='',built=[],toplevel=source;const sha='a'.repeat(40);
93
94
  const invoke=async(cmd,args)=>{
94
- if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
95
+ if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
95
96
  if(cmd==='git'){
96
97
  if(!inside)throw Error('not a git repository');
97
98
  if(args[2]==='rev-parse'&&args[3]==='--show-toplevel')return toplevel;
@@ -120,13 +121,13 @@ test('source checkouts build RCs only: clean reviewed commit plus increasing lab
120
121
  });
121
122
  test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
122
123
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
123
- const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
124
+ const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
124
125
  await assert.rejects(build({home,source},invoke),/Synthetic/);
125
126
  const [id]=await fs.readdir(path.join(home,'builds'));const dir=path.join(home,'builds',id);
126
127
  assert.equal(JSON.parse(await fs.readFile(path.join(dir,'status.json'))).state,'failed');await assert.rejects(fs.access(path.join(dir,'lock')));
127
128
  });
128
129
  test('source changes during a build cannot produce a reusable success receipt',async t=>{
129
130
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
130
- const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
131
+ const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
131
132
  await assert.rejects(build({home,source},invoke),/changed during build/);
132
133
  });
@@ -20,7 +20,7 @@ test('native tasks use verified control binding, sanitized environment and uncha
20
20
  const denied=await nativeTasks(home,['list']);assert.notEqual(denied.code,0);assert.match(denied.stderr,/Pair an owner/)
21
21
  const control=new ControlStore(controlDir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
22
22
  const text='Read scan; $(must-not-run) /goal literal'
23
- const saved=await nativeTasks(home,['create','native-fixture','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
23
+ const saved=await nativeTasks(home,['create','native-fixture','--model','fixture-model','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
24
24
  const value=JSON.parse(saved.stdout);assert.equal(value.text,text);assert.equal(value.execution.preset.cli,'grok')
25
25
  assert.equal(JSON.parse((await nativeTasks(home,['show','native-fixture'])).stdout).text,text)
26
26
  assert.equal((await nativeTasks(home,['remove','native-fixture'])).code,0)