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

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 (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docker/broker-update-smoke.mjs +16 -2
  6. package/docker/upgrade-smoke.mjs +1 -1
  7. package/docs/architecture/ai-selection.md +9 -1
  8. package/docs/docker-runtime.md +46 -2
  9. package/docs/plugin-guide.md +3 -3
  10. package/docs/plugins.md +13 -10
  11. package/docs/scheduling.md +84 -6
  12. package/docs/setup.md +2 -2
  13. package/docs/standalone-cli.md +4 -4
  14. package/docs/upgrades.md +13 -23
  15. package/package.json +1 -1
  16. package/src/agent-guidance.ts +2 -2
  17. package/src/ai-cli.ts +28 -15
  18. package/src/ai.ts +90 -14
  19. package/src/application-channel.ts +18 -14
  20. package/src/auth-profile.ts +53 -0
  21. package/src/codex-session.ts +14 -3
  22. package/src/control-state.ts +28 -21
  23. package/src/delivery-socket.ts +2 -0
  24. package/src/executor.ts +38 -26
  25. package/src/host-executor-client.ts +19 -7
  26. package/src/host-executor.ts +35 -10
  27. package/src/index.ts +320 -122
  28. package/src/menu.ts +8 -7
  29. package/src/plugin-broker.mjs +4 -11
  30. package/src/plugins/manager.mjs +70 -48
  31. package/src/runs.ts +74 -5
  32. package/src/schedule-cli.ts +39 -7
  33. package/src/scheduled-tasks.ts +42 -8
  34. package/src/scheduler.ts +9 -4
  35. package/src/session-close.ts +17 -0
  36. package/src/setup-fallback.ts +159 -0
  37. package/src/task-executor.ts +3 -2
  38. package/src/tools-line.d.mts +1 -0
  39. package/src/tools-line.mjs +3 -0
  40. package/src/updates/binding.mjs +4 -10
  41. package/src/updates/control.mjs +3 -2
  42. package/src/updates/runtime.mjs +79 -41
  43. package/src/updates/supervisor.mjs +27 -6
  44. package/src/workspace.ts +12 -1
  45. package/test/ai.test.ts +48 -2
  46. package/test/application-channel.test.ts +24 -0
  47. package/test/application-controls.test.ts +4 -1
  48. package/test/auth-profiles.test.ts +187 -0
  49. package/test/intake-relay.test.ts +63 -2
  50. package/test/model-policy.test.ts +1 -1
  51. package/test/plugin-broker.test.mjs +51 -1
  52. package/test/plugin-manager.test.mjs +76 -38
  53. package/test/run-handoff.test.ts +132 -0
  54. package/test/scheduled-tasks.test.ts +24 -2
  55. package/test/session-close.test.ts +17 -0
  56. package/test/setup-fallback.test.ts +423 -0
  57. package/test/updates.test.mjs +77 -15
  58. package/test/workspace.test.ts +6 -0
package/test/ai.test.ts CHANGED
@@ -106,13 +106,17 @@ test('OpenCode keeps same-provider models together and separates providers encod
106
106
  const other = { ...first, id: 'other', name: 'Other', model: 'openrouter/other' }
107
107
  const choice = await store.captureChoice(first)
108
108
  await store.saveNativeSession(choice.sessionId, 'ses_original')
109
- const menu = createAiMenu(store, 'opencode', async () => [first, second, other].map(p => ({ ...p, efforts: [] })), dir, undefined, async () => true)
109
+ const closed: string[] = []
110
+ const menu = createAiMenu(store, 'opencode', async () => [first, second, other].map(p => ({ ...p, efforts: [] })), dir, undefined, async () => true,
111
+ undefined, async (session) => { if (session) closed.push(session.sessionId) })
110
112
  await store.savePreset(second)
111
113
  assert.equal((await menu.select(second, choice.sessionId)).fresh, false)
114
+ assert.deepEqual(closed, [])
112
115
  assert.equal((await store.captureChoice(first)).sessionId, choice.sessionId)
113
116
  await store.savePreset(other)
114
117
  assert.equal(await store.selectPreset(other.id, choice.sessionId), false)
115
118
  assert.equal((await menu.select(other, choice.sessionId)).fresh, true)
119
+ assert.deepEqual(closed, [choice.sessionId]) // switching provider closes the old conversation once
116
120
  const next = await store.captureChoice(first)
117
121
  assert.notEqual(next.sessionId, choice.sessionId)
118
122
  assert.equal((await store.executionSession(next)).nativeSessionId, undefined)
@@ -519,7 +523,7 @@ test('opencode model and variant selections validate against the installed catal
519
523
  await validateSelection({ id: 'default', name: 'Default', cli: 'opencode', model: 'opencode/big-pickle' }, catalog, available)
520
524
  await assert.rejects(
521
525
  validateSelection({ id: 'bad', name: 'Bad', cli: 'opencode', model: 'opencode/ling-free', effort: 'max' }, catalog, available),
522
- /installed client catalog/)
526
+ /Effort max is not supported.*low, medium/)
523
527
  await assert.rejects(
524
528
  validateSelection({ id: 'missing', name: 'Missing', cli: 'opencode', model: 'opencode/unknown' }, catalog, available),
525
529
  /installed client catalog/)
@@ -672,3 +676,45 @@ test('Claude catalog projects only aliases and efforts from the installed native
672
676
  await validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus', effort: 'xhigh' }, models, async () => true)
673
677
  await assert.rejects(validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus-guess' }, models, async () => true), /not in the installed client catalog/)
674
678
  })
679
+
680
+ test('Telegram Sonnet choice survives restart and reaches the native launch without Opus substitution', async () => {
681
+ const dir = await mkdtemp(join(tmpdir(), 'ez-sonnet-selection-'))
682
+ const priorPath = process.env.PATH
683
+ try {
684
+ const store = new ControlStore(dir, 1000)
685
+ await store.captureChoice({id:'opus',name:'Opus',cli:'claude',model:'opus',effort:'xhigh'})
686
+ const models = [{cli:'claude',model:'opus',name:'Opus',efforts:['xhigh']},
687
+ {cli:'claude',model:'sonnet',name:'Sonnet',efforts:['low','medium','high']}]
688
+ const menu = createAiMenu(store, 'claude', async()=>models, dir, undefined, async()=>true)
689
+ const replies: Array<{text:string; buttons:Array<{text:string;callback_data:string}>}> = []
690
+ const context = (data?:string) => ({callbackQuery:data ? {data} : undefined,answerCallbackQuery:async()=>({}),
691
+ reply:async(text:string, options?:{reply_markup?:{inline_keyboard?:Array<Array<{text:string;callback_data:string}>>}})=>{
692
+ replies.push({text,buttons:options?.reply_markup?.inline_keyboard?.flat() ?? []});return {} as never
693
+ }})
694
+ const click = async(text:string) => menu.handle(context(replies.at(-1)!.buttons.find(b=>b.text===text)!.callback_data) as never)
695
+ await menu.list(context() as never)
696
+ await click('claude');await click('Sonnet');await click('high')
697
+ const restarted = new ControlStore(dir,1000)
698
+ const captured = await restarted.captureChoice(menu.initial)
699
+ assert.equal(captured.preset.model,'sonnet');assert.equal(captured.preset.effort,'high')
700
+ const before = await restarted.status()
701
+ await assert.rejects(menu.saveSelection(models[1], 'xhigh'), /Effort xhigh is not supported.*low, medium, high/)
702
+ await assert.rejects(validateSelection({id:'s',name:'Sonnet',cli:'claude',model:'sonnet',effort:'high'}, [], async()=>true), /No models advertised for claude.*current selection unchanged/)
703
+ assert.deepEqual(await restarted.status(), before)
704
+ const {ownerRun} = await import('./helpers/owner-run.js')
705
+ const {startExecutorJob} = await import('../src/executor.js')
706
+ await mkdir(join(dir,'bin'))
707
+ await writeFile(join(dir,'bin','claude'), `#!${process.execPath}\nrequire('fs').writeFileSync('launched.json',JSON.stringify(process.argv.slice(2)));require('fs').readFileSync(0,'utf8')`, {mode:0o700})
708
+ process.env.PATH = join(dir,'bin') + ':' + priorPath
709
+ await ownerRun(dir,'r_sonnet_selected')
710
+ const job = await startExecutorJob(['synthetic request'], {workspace:dir,controlDir:dir,binDir:join(dir,'bin'),runId:'r_sonnet_selected',timeoutMs:5000,repairEnabled:false,
711
+ cli:captured.preset.cli,model:captured.preset.model,effort:captured.preset.effort,sessionId:captured.sessionId})
712
+ const closed = new Promise<number|null>(resolve=>job.child.once('close',resolve))
713
+ assert.equal(await closed,0)
714
+ assert.deepEqual(JSON.parse(await readFile(join(dir,'launched.json'),'utf8')).slice(-4), ['--model','sonnet','--effort','high'])
715
+ await job.cleanup()
716
+ } finally {
717
+ if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
718
+ await rm(dir,{recursive:true,force:true})
719
+ }
720
+ })
@@ -450,3 +450,27 @@ test('administrator task grant replacement preserves the runtime file owner', {s
450
450
  const disabled=await stat(file);assert.equal(disabled.uid,20000);assert.equal(disabled.gid,20000)
451
451
  }finally{await rm(dir,{recursive:true,force:true})}
452
452
  })
453
+
454
+ test('empty server Claude catalog rejects Sonnet chat selection without changing Opus xhigh', async t => {
455
+ const root = await mkdtemp(join(tmpdir(),'ez-app-missing-claude-'))
456
+ t.after(()=>rm(root,{recursive:true,force:true}))
457
+ const owned = await owner(root), control = new ControlStore(root,1000)
458
+ const initial = {id:'opus',name:'Opus',cli:'claude',model:'opus',effort:'xhigh'}
459
+ await control.captureChoice(initial)
460
+ const {createAiMenu} = await import('../src/menu.js')
461
+ let models: import('../src/ai.js').ModelChoice[] = []
462
+ const menu = createAiMenu(control,'claude',async()=>models,root,undefined,async()=>true)
463
+ const channel = new ApplicationChannel({controlDir:root,initial,wake:()=>{},cancel:async()=>{},aiControls:menu})
464
+ const binding = (await channel.bindings.register('app',token(),owned,true))!
465
+ const before = await control.status()
466
+ const input = {action:'model',expectedSession:before.activeSession!.sessionId,cli:'claude',model:'sonnet',effort:'high'}
467
+ await assert.rejects(channel.changeControls(binding.bindingId,input), /No models advertised for claude.*current selection unchanged/)
468
+ assert.deepEqual(await control.status(),before)
469
+ models = [{cli:'claude',model:'sonnet',name:'Sonnet',efforts:['low','medium','high']}]
470
+ await assert.rejects(channel.changeControls(binding.bindingId,{...input,effort:'xhigh'}), /Effort xhigh is not supported.*low, medium, high/)
471
+ assert.deepEqual(await control.status(),before)
472
+ await channel.changeControls(binding.bindingId,input)
473
+ const admitted = await channel.submit(binding.bindingId,{requestId:'sonnet',scope:'chat',text:'Synthetic request',followTelegram:true})
474
+ assert.equal(admitted.execution!.preset.model,'sonnet')
475
+ assert.equal(admitted.execution!.preset.effort,'high')
476
+ })
@@ -53,7 +53,8 @@ test('private scope reset and model controls preserve running sessions and share
53
53
  {cli:'codex',model:'fixture-model',name:'Fixture',efforts:['low','high']},
54
54
  {cli:'opencode',model:'fixture-other',name:'Other',efforts:[]},
55
55
  ],root,join(root,'native-home'),async()=>true)
56
- const channel = new ApplicationChannel({controlDir:root,initial:menu.initial,aiControls:menu,wake:()=>{},cancel:async()=>{}})
56
+ const closed:string[] = []
57
+ const channel = new ApplicationChannel({controlDir:root,initial:menu.initial,aiControls:menu,wake:()=>{},cancel:async()=>{},closeSession:async session=>{if(session)closed.push(session.sessionId)}})
57
58
  t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
58
59
  const token = randomBytes(32).toString('base64url')
59
60
  const binding = (await channel.bindings.register('private',token,owner))!
@@ -77,6 +78,7 @@ test('private scope reset and model controls preserve running sessions and share
77
78
  assert.equal((await call({action:'new',expectedSession:before.activeSessionId})).status,200)
78
79
  const next = await channel.scopeControls(binding.bindingId,'exercise')
79
80
  assert.notEqual(next.activeSessionId,before.activeSessionId)
81
+ assert.deepEqual(closed,[]) // a private scope runs under its binding, so no owner-authority close turn
80
82
  assert.equal(next.ai.selectedId,menu.initial.id)
81
83
  assert.equal((await call({action:'new',expectedSession:before.activeSessionId})).status,400)
82
84
  assert.equal((await control.executionSession(run.execution!)).nativeSessionId,'native-private')
@@ -89,6 +91,7 @@ test('private scope reset and model controls preserve running sessions and share
89
91
  assert.deepEqual((await control.status()).ai!.presets,presets)
90
92
  assert.equal((await call({action:'model',expectedSession:next.activeSessionId,cli:'codex',model:'fixture-model',effort:'high'})).status,200)
91
93
  const updated = await channel.submit(binding.bindingId,{requestId:'updated-model',scope:'exercise',text:'same engine'})
94
+ assert.deepEqual(closed,[])
92
95
  assert.equal(updated.execution!.sessionId,next.activeSessionId)
93
96
  assert.equal(updated.execution!.preset.effort,'high')
94
97
  assert.deepEqual((await new RunStore(root).get(after.id))!.execution,after.execution)
@@ -0,0 +1,187 @@
1
+ import { ownerRun } from './helpers/owner-run.js'
2
+ import assert from 'node:assert/strict'
3
+ import test from 'node:test'
4
+ import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
5
+ import { spawn, spawnSync } from 'node:child_process'
6
+ import { tmpdir } from 'node:os'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { startExecutorJob } from '../src/executor.js'
10
+ import { ControlStore } from '../src/control-state.js'
11
+ import { RunStore } from '../src/runs.js'
12
+ import { serveHostExecutor } from '../src/host-executor.js'
13
+ import { authProfileStatus, initialPreset, readModels, validateSelection, type AiPreset } from '../src/ai.js'
14
+
15
+ const restoreEnv = (keys: string[]) => {
16
+ const prior = Object.fromEntries(keys.map(key => [key, process.env[key]]))
17
+ return () => { for (const [key, value] of Object.entries(prior)) value === undefined ? delete process.env[key] : process.env[key] = value }
18
+ }
19
+
20
+ test('each Claude and Codex profile runs only with its own home and credentials', {skip:process.platform==='win32'}, async t => {
21
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-profiles-'))
22
+ t.after(() => rm(root, { recursive: true, force: true }))
23
+ t.after(restoreEnv(['PATH', 'HOME', 'EZ_ISOLATION', 'EZ_EXECUTOR_TRANSPORT']))
24
+ const bin = path.join(root, 'bin'), workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control')
25
+ await mkdir(bin); await mkdir(workspace)
26
+ const observed = path.join(root, 'observed.json')
27
+ for (const cli of ['claude', 'codex']) await writeFile(path.join(bin, cli), `#!${process.execPath}
28
+ const fs=require('fs'),p=require('path');let auth=null
29
+ try{auth=fs.lstatSync(p.join(process.env.CODEX_HOME,'auth.json')).isSymbolicLink()?'link:'+fs.readlinkSync(p.join(process.env.CODEX_HOME,'auth.json')):'file'}catch{}
30
+ fs.writeFileSync(${JSON.stringify(observed)},JSON.stringify({config:process.env.CLAUDE_CONFIG_DIR,storage:process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,token:process.env.CLAUDE_CODE_OAUTH_TOKEN,codexHome:process.env.CODEX_HOME,auth}))`, { mode: 0o700 })
31
+ process.env.PATH = bin + path.delimiter + process.env.PATH
32
+ process.env.HOME = root
33
+ delete process.env.EZ_ISOLATION; delete process.env.EZ_EXECUTOR_TRANSPORT
34
+ const tokens = { work: 'sk-ant-oat01-work_profile_token', personal: 'sk-ant-oat01-personal_profile_token' }
35
+ for (const [name, token] of Object.entries(tokens)) {
36
+ await mkdir(path.join(controlDir, 'cli', 'profiles', name, 'claude'), { recursive: true })
37
+ await writeFile(path.join(controlDir, 'cli', 'profiles', name, 'claude', 'oauth-token'), token, { mode: 0o600 })
38
+ await mkdir(path.join(controlDir, 'cli', 'profiles', name, 'codex'), { recursive: true })
39
+ await writeFile(path.join(controlDir, 'cli', 'profiles', name, 'codex', 'auth.json'), '{}', { mode: 0o600 })
40
+ }
41
+ let n = 0
42
+ const run = async (cli: string, authProfile?: string) => {
43
+ const runId = `r_profile_${n++}`
44
+ await ownerRun(controlDir, runId)
45
+ await rm(observed, { force: true })
46
+ const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli, authProfile, runId, timeoutMs: 5000, repairEnabled: false })
47
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
48
+ return JSON.parse(await readFile(observed, 'utf8'))
49
+ }
50
+ // Default bindings are unchanged: shared host login.
51
+ const claudeDefault = await run('claude')
52
+ assert.equal(claudeDefault.config, path.join(controlDir, 'cli', 'claude')); assert.equal(claudeDefault.storage, ''); assert.equal(claudeDefault.token, undefined)
53
+ const codexDefault = await run('codex')
54
+ assert.equal(codexDefault.codexHome, path.join(controlDir, 'cli', 'codex')); assert.equal(codexDefault.auth, `link:${path.join(root, '.codex', 'auth.json')}`)
55
+ // Named profiles use only their own store: no shared secure storage, no host link.
56
+ for (const [name, token] of Object.entries(tokens)) {
57
+ const claude = await run('claude', name)
58
+ assert.deepEqual(claude, { config: path.join(controlDir, 'cli', 'profiles', name, 'claude'), token, auth: null })
59
+ const codex = await run('codex', name)
60
+ assert.deepEqual(codex, { codexHome: path.join(controlDir, 'cli', 'profiles', name, 'codex'), auth: 'file' })
61
+ }
62
+ // Unprovisioned or unsupported profiles fail closed before any process starts.
63
+ for (const [cli, authProfile, error] of [['claude', 'missing', /not provisioned/], ['codex', 'missing', /not provisioned/], ['opencode', 'work', /Invalid auth profile/], ['claude', '../work', /Invalid auth profile/]] as const) {
64
+ await ownerRun(controlDir, `r_reject_${n}`); await rm(observed, { force: true })
65
+ await assert.rejects(startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli, authProfile, runId: `r_reject_${n++}`, timeoutMs: 5000, repairEnabled: false }), error)
66
+ await assert.rejects(stat(observed))
67
+ }
68
+ assert.equal((await lstat(path.join(controlDir, 'cli', 'profiles'))).isDirectory(), true)
69
+ await assert.rejects(stat(path.join(controlDir, 'cli', 'profiles', 'missing')))
70
+ })
71
+
72
+ test('a conversation stays bound to its auth profile and never resumes under another', async () => {
73
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-sessions-'))
74
+ try {
75
+ const store = new ControlStore(root, 900000)
76
+ await store.aiState(initialPreset('claude'))
77
+ const work: AiPreset = { id: 'work', name: 'Claude work', cli: 'claude', authProfile: 'work', model: 'opus' }
78
+ const personal: AiPreset = { id: 'personal', name: 'Claude personal', cli: 'claude', authProfile: 'personal', model: 'opus' }
79
+ for (const preset of [work, personal]) await store.savePreset(preset)
80
+ const first = await store.captureChoice(initialPreset('claude'))
81
+ await store.markSessionStarted(first.sessionId)
82
+ // A profile change is a new conversation, never an in-place settings change.
83
+ assert.equal(await store.selectPreset('work', first.sessionId), false)
84
+ assert.equal(await store.selectPreset('work', first.sessionId, true), true)
85
+ const working = await store.captureChoice(initialPreset('claude'))
86
+ assert.notEqual(working.sessionId, first.sessionId)
87
+ assert.equal((await store.executionSession(working)).authProfile, 'work')
88
+ await store.markSessionStarted(working.sessionId)
89
+ // A choice that names another account for this session is rejected at launch.
90
+ await assert.rejects(store.executionSession({ sessionId: working.sessionId, preset: personal }), /matching CLI binding/)
91
+ await assert.rejects(store.executionSession({ sessionId: first.sessionId, preset: work }), /matching CLI binding/)
92
+ // Switching back restores that conversation's own account.
93
+ assert.equal(await store.selectPreset('personal', working.sessionId, true), true)
94
+ const restored = await store.switchSession(working.sessionId)
95
+ assert.equal(restored.authProfile, 'work')
96
+ assert.equal((await store.captureChoice(initialPreset('claude'))).preset.authProfile, 'work')
97
+ } finally { await rm(root, { recursive: true, force: true }) }
98
+ })
99
+
100
+ test('catalog offers provisioned profiles only; host refuses a request that changes the captured profile', {skip:process.platform==='win32'}, async t => {
101
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-host-'))
102
+ t.after(() => rm(root, { recursive: true, force: true }))
103
+ t.after(restoreEnv(['PATH']))
104
+ const workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control'), bin = path.join(root, 'bin'), directory = path.join(controlDir, 'host-executor')
105
+ await mkdir(workspace, { recursive: true }); await mkdir(bin); await mkdir(directory, { recursive: true })
106
+ for (const cli of ['codex', 'claude']) await writeFile(path.join(bin, cli), '#!/bin/sh\nexit 0\n', { mode: 0o700 })
107
+ await mkdir(path.join(controlDir, 'cli', 'profiles', 'work', 'claude'), { recursive: true })
108
+ process.env.PATH = bin + path.delimiter + process.env.PATH
109
+ const catalog = await readModels(root, undefined, path.join(controlDir, 'cli', 'codex'), undefined, undefined, undefined, controlDir)
110
+ assert.deepEqual(catalog.filter(model => model.authProfile), [{ cli: 'claude', name: 'work · claude · client default', efforts: [], authProfile: 'work' }])
111
+ await validateSelection({ id: 'w', name: 'w', cli: 'claude', authProfile: 'work' }, catalog)
112
+ await assert.rejects(validateSelection({ id: 'p', name: 'p', cli: 'claude', authProfile: 'personal' }, catalog), /not provisioned/)
113
+
114
+ const runs = new RunStore(controlDir)
115
+ await ownerRun(controlDir, 'r_seed')
116
+ const execution = { sessionId: '00000000-0000-4000-8000-000000000001', preset: { id: 'work', name: 'work', cli: 'claude', authProfile: 'work' } }
117
+ for (const id of ['r_wrong', 'r_right', 'r_dropped']) {
118
+ await runs.create({ id, chatId: 101, telegramUserId: 101, texts: ['hello'], execution })
119
+ await runs.patch(id, { status: 'running' })
120
+ }
121
+ const abort = new AbortController(), launched: string[] = []
122
+ const server = serveHostExecutor({ cli: 'codex', agents: [{ name: 'test', workspace, controlDir, binDir: bin }] }, abort.signal, async (_texts, options) => {
123
+ launched.push(`${options.runId}:${options.authProfile}`)
124
+ return { child: spawn(process.execPath, ['-e', 'process.exit(0)']), cleanup: async () => {}, stdout: '' }
125
+ }, async () => catalog)
126
+ try {
127
+ const exit = async (id: string, options: Record<string, unknown>) => {
128
+ await writeFile(path.join(directory, `${id}.request.json`), JSON.stringify({ texts: ['hello'], options }))
129
+ for (let n = 0; n < 200; n++) {
130
+ const events = await readFile(path.join(directory, `${id}.events`), 'utf8').catch(() => '')
131
+ const code = events.match(/"stream":"exit","code":(\d+)/)?.[1]
132
+ if (code !== undefined) return { code: Number(code), events }
133
+ await new Promise(resolve => setTimeout(resolve, 20))
134
+ }
135
+ throw new Error('host request did not finish')
136
+ }
137
+ for (const [id, options] of [['r_wrong', { authProfile: 'personal' }], ['r_dropped', {}]] as const) {
138
+ const result = await exit(id, { cli: 'claude', sessionId: execution.sessionId, ...options })
139
+ assert.equal(result.code, 1); assert.match(result.events, /does not match its captured AI selection/)
140
+ }
141
+ assert.equal((await exit('r_right', { cli: 'claude', authProfile: 'work', sessionId: execution.sessionId })).code, 0)
142
+ assert.deepEqual(launched, ['r_right:work'])
143
+ } finally { abort.abort(); await server }
144
+ })
145
+
146
+ test('profile setup and diagnostics never print credentials or account identity', {skip:process.platform==='win32'}, async () => {
147
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-cli-'))
148
+ try {
149
+ const controlDir = path.join(root, 'control'), bin = path.join(root, 'bin')
150
+ await mkdir(bin)
151
+ await writeFile(path.join(bin, 'claude'), `#!/bin/sh
152
+ if [ "$1" = auth ]; then
153
+ if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then echo '{"loggedIn":true,"authMethod":"oauth_token","email":"owner@example.com","orgId":"org-secret"}'; exit 0; fi
154
+ echo '{"loggedIn":false,"authMethod":"none"}'; exit 1
155
+ fi
156
+ exit 0
157
+ `, { mode: 0o700 })
158
+ await writeFile(path.join(bin, 'codex'), '#!/bin/sh\n[ -f "$CODEX_HOME/auth.json" ] && { echo "Logged in using ChatGPT (owner@example.com)" >&2; exit 0; }\necho "Not logged in"; exit 1\n', { mode: 0o700 })
159
+ const store = new ControlStore(controlDir, 900000); await store.aiState(initialPreset('claude'))
160
+ const env = { ...process.env, HOME: root, PATH: bin + path.delimiter + process.env.PATH, EZ_CONTROL_DIR: controlDir }
161
+ const ai = (...args: string[]) => spawnSync(process.execPath, [fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs', import.meta.url)), ...args], { env, encoding: 'utf8' })
162
+ for (const [cli, name] of [['claude', 'work'], ['claude', 'personal'], ['codex', 'work']]) {
163
+ const added = ai('profile', 'add', '--cli', cli, '--auth-profile', name)
164
+ assert.equal(added.status, 0, added.stderr)
165
+ const home = path.join(controlDir, 'cli', 'profiles', name, cli)
166
+ assert.equal(JSON.parse(added.stdout).home, home)
167
+ assert.equal((await stat(home)).mode & 0o777, 0o700)
168
+ }
169
+ assert.notEqual(ai('profile', 'add', '--cli', 'opencode', '--auth-profile', 'work').status, 0)
170
+ await writeFile(path.join(controlDir, 'cli', 'profiles', 'work', 'claude', 'oauth-token'), 'sk-ant-oat01-never_printed_token', { mode: 0o600 })
171
+ await writeFile(path.join(controlDir, 'cli', 'profiles', 'work', 'codex', 'auth.json'), '{"tokens":"never-printed"}', { mode: 0o600 })
172
+ const profiles = ai('profiles')
173
+ assert.equal(profiles.status, 0, profiles.stderr)
174
+ for (const secret of ['never_printed', 'never-printed', 'owner@example.com', 'org-secret']) assert.equal(profiles.stdout.includes(secret), false)
175
+ const byKey = Object.fromEntries((JSON.parse(profiles.stdout) as Awaited<ReturnType<typeof authProfileStatus>>).map(item => [`${item.cli}@${item.authProfile}`, item]))
176
+ assert.deepEqual(byKey['claude@work'], { cli: 'claude', authProfile: 'work', provisioned: true, sharesHostLogin: false, credentialFiles: ['oauth-token'], loggedIn: true, method: 'oauth_token' })
177
+ assert.equal(byKey['claude@personal'].loggedIn, false)
178
+ assert.deepEqual(byKey['codex@work'], { cli: 'codex', authProfile: 'work', provisioned: true, sharesHostLogin: false, credentialFiles: ['auth.json'], loggedIn: true, method: 'chatgpt' })
179
+ assert.equal(byKey['claude@null'].sharesHostLogin, true)
180
+ // Selecting a provisioned profile binds a fresh conversation to it.
181
+ const selected = ai('select', '--cli', 'claude', '--auth-profile', 'work')
182
+ assert.equal(selected.status, 0, selected.stderr)
183
+ assert.equal((await store.status()).activeSession?.authProfile, 'work')
184
+ assert.notEqual(ai('select', '--cli', 'claude', '--auth-profile', 'unknown').status, 0)
185
+ assert.equal((await store.status()).activeSession?.authProfile, 'work')
186
+ } finally { await rm(root, { recursive: true, force: true }) }
187
+ })
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { randomBytes } from 'node:crypto'
3
+ import { randomBytes, randomUUID } from 'node:crypto'
4
4
  import { mkdtemp, readdir, rm } from 'node:fs/promises'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { join } from 'node:path'
@@ -17,6 +17,8 @@ import { atomicTaskFile, Tasks } from '../src/tasks.js'
17
17
  import { EventSources } from '../src/event-sources.js'
18
18
  import { ownerRun } from './helpers/owner-run.js'
19
19
  import { packageVersion } from '../src/version.js'
20
+ import { SESSION_CLOSE_PROMPT } from '../src/session-close.js'
21
+ import type { ExecutorOptions } from '../src/executor.js'
20
22
  import type { Config } from '../src/config.js'
21
23
 
22
24
  const message = (id: number, text = 'hello'): Update => ({
@@ -32,6 +34,7 @@ const message = (id: number, text = 'hello'): Update => ({
32
34
  const fixture = async (overrides: Partial<Config> = {}) => {
33
35
  const dir = await mkdtemp(join(tmpdir(), 'ez-intake-relay-'))
34
36
  const launched: string[][] = []
37
+ const launchOptions: ExecutorOptions[] = []
35
38
  const replies: string[] = []
36
39
  const members = new Map<number, string>()
37
40
  const keyboards: { text: string; callback_data: string }[][][] = []
@@ -47,8 +50,9 @@ const fixture = async (overrides: Partial<Config> = {}) => {
47
50
  ...overrides,
48
51
  }
49
52
  const make = () => {
50
- const relay = createRelay(config, async (texts) => {
53
+ const relay = createRelay(config, async (texts, options) => {
51
54
  launched.push(texts)
55
+ launchOptions.push(options)
52
56
  const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 50)'])
53
57
  children.push(child)
54
58
  await once(child, 'spawn')
@@ -79,6 +83,7 @@ const fixture = async (overrides: Partial<Config> = {}) => {
79
83
  return {
80
84
  dir,
81
85
  launched,
86
+ launchOptions,
82
87
  replies,
83
88
  members,
84
89
  keyboards,
@@ -336,6 +341,35 @@ test('new conversation leaves already accepted messages pinned to the old conver
336
341
  } finally { await f.close() }
337
342
  })
338
343
 
344
+ test('new conversation gives the started one a single silent close turn in its own native session', async () => {
345
+ const f = await fixture()
346
+ try {
347
+ const runs = new RunStore(f.dir), store = new ControlStore(f.dir, 1000)
348
+ const settled = async () => {
349
+ for (let i = 0; i < 200 && (await runs.list()).some((r) => ['queued', 'running'].includes(r.status)); i++)
350
+ await new Promise((resolve) => setTimeout(resolve, 10))
351
+ }
352
+ await f.relay.bot.handleUpdate(message(1, '/new'))
353
+ assert.equal(f.launched.length, 0) // an unstarted conversation has nothing to keep
354
+ await f.relay.bot.handleUpdate(message(2, 'My sister is called Ana'))
355
+ await f.relay.drainInbox(true)
356
+ await settled()
357
+ const first = (await store.getActiveSession())!
358
+ assert.equal(first.hasStarted, true)
359
+ await f.relay.bot.handleUpdate(message(3, '/new'))
360
+ await settled()
361
+ assert.deepEqual(f.launched.at(-1), [SESSION_CLOSE_PROMPT])
362
+ const close = (await runs.list()).find((r) => r.sessionClose)!
363
+ assert.equal(close.execution!.sessionId, first.sessionId)
364
+ assert.equal(close.chatId, undefined); assert.equal(close.messageId, undefined)
365
+ const options = f.launchOptions.at(-1)!
366
+ assert.equal(options.isResume, true)
367
+ assert.equal(options.promptSuffix, '') // no reply mechanism: nothing is delivered
368
+ assert.notEqual((await store.getActiveSession())!.sessionId, first.sessionId)
369
+ assert.equal((await runs.list()).filter((r) => r.sessionClose).length, 1)
370
+ } finally { await f.close() }
371
+ })
372
+
339
373
  test('accepted DM survives relay restart and duplicate delivery creates exactly one run', async () => {
340
374
  const f = await fixture()
341
375
  try {
@@ -752,3 +786,30 @@ test('tasks command never reaches the executor on invalid input or foreign sende
752
786
  assert.equal(f.launched.length, 0)
753
787
  } finally { await f.close() }
754
788
  })
789
+
790
+ test('tasks list and detail show saved frequency and ordered setup path without changing or launching work',async()=>{
791
+ const f=await fixture()
792
+ try{
793
+ const {Scheduler}=await import('../src/scheduler.js')
794
+ const control=new ControlStore(f.dir,1000),scheduler=new Scheduler(f.dir)
795
+ const preset={id:'primary',name:'Primary',cli:'claude',model:'opus',effort:'high'}
796
+ const fallbacks=[
797
+ {...preset,id:'second',authProfile:'claude2'},
798
+ {id:'third',name:'Third',cli:'codex',authProfile:'work',model:'gpt-6-sol',effort:'xhigh'},
799
+ {id:'fourth',name:'Fourth',cli:'pi',model:'anthropic/claude-sonnet',effort:'medium'}]
800
+ const saved=await scheduler.save({id:'analyst',name:'Analyst',text:'Read the saved role.',owner:(await control.status()).owner!,
801
+ execution:{sessionId:randomUUID(),preset,fallbacks},
802
+ enabled:true,trigger:{cron:'*/30 * * * *',timezone:'America/New_York',start:'2027-01-01T00:00:00Z'}})
803
+ const path='claude (default login) · opus · high → claude@claude2 · opus · high → codex@work · gpt-6-sol · xhigh → pi · anthropic/claude-sonnet · medium'
804
+ await f.relay.bot.handleUpdate(message(1,'/tasks'))
805
+ const list=f.replies.at(-1)!
806
+ assert.ok(list.includes('Schedule · Every 30 minutes · America/New_York'))
807
+ assert.ok(list.includes(`Execution · ${path}`))
808
+ await f.relay.bot.handleUpdate(message(2,'/tasks 1'))
809
+ const detail=f.replies.at(-1)!
810
+ assert.ok(detail.includes('Schedule\nEvery 30 minutes · America/New_York'))
811
+ assert.ok(detail.includes(`Execution path\n${path}`))
812
+ assert.deepEqual(await scheduler.get(saved.id),saved)
813
+ assert.equal(f.launched.length,0)
814
+ }finally{await f.close()}
815
+ })
@@ -17,7 +17,7 @@ test('engine defaults stay omitted and explicit native settings survive every ad
17
17
  const preset={id:'chosen',name:'Chosen',cli,model:'native-model',effort:'ultra'}
18
18
  assert.deepEqual(executionDefaults(cli,preset),preset)
19
19
  await validateSelection(preset,[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true)
20
- await assert.rejects(validateSelection({...preset,effort:'unsupported'},[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true),/installed client catalog/)
20
+ await assert.rejects(validateSelection({...preset,effort:'unsupported'},[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true),/Effort unsupported is not supported.*Supported efforts: ultra/)
21
21
  assert.throws(()=>executionDefaults(cli,{effort:'bad option'}),/Invalid reasoning effort/)
22
22
  }
23
23
  assert.deepEqual(executionOverrides('codex',{model:'old',effort:'max'},'new'),{model:'new',effort:undefined})
@@ -103,7 +103,8 @@ test('isolated broker authenticates an owned run, pins plugin revision, and writ
103
103
  assert.deepEqual(JSON.parse(listed.stdout), { sample: 'sample' });
104
104
  const detailed = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'list', '--details'] });
105
105
  assert.equal(detailed.ok, true);
106
- assert.deepEqual(JSON.parse(detailed.stdout).sample.commands, ['ez sample --help']);
106
+ assert.deepEqual(JSON.parse(detailed.stdout).sample.aliases, ['sample']);
107
+ assert.equal(JSON.parse(detailed.stdout).sample.example, 'ez sample --help');
107
108
  assert.deepEqual(JSON.parse(detailed.stdout).sample.skillReads, [['ez', 'tools', 'skill', 'sample', 'SKILL.md']]);
108
109
  const skill = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', 'SKILL.md'] });
109
110
  assert.equal(skill.code, 0);
@@ -209,6 +210,9 @@ test('scheduled preflight uses authoritative configuration and rejects writes or
209
210
  registry.plugins.sample.manifest.commands.sample.exposure={receivesExternalContent:true,changesRecords:false,sendsExternally:false,requiresReview:false};
210
211
  await fs.writeFile(file,JSON.stringify(registry));
211
212
  const accepted=await request(f.socket,payload);assert.equal(accepted.ok,true);assert.equal(accepted.result.eligible,true);
213
+ await fs.writeFile(path.join(f.fake,'docker'), `#!${process.execPath}\nprocess.stdout.write('SECRET-token-xyz');\n`, {mode:0o700});
214
+ const invalid=await request(f.socket,payload);assert.equal(invalid.ok,false);
215
+ assert.equal(invalid.error,'Scheduled plugin preflight returned invalid output');assert(!JSON.stringify(invalid).includes('SECRET-token-xyz'));
212
216
  await scheduler.save({...schedule,enabled:false});
213
217
  assert.equal((await request(f.socket,payload)).ok,false);
214
218
  } finally {abort.abort();await serving;process.env.PATH=previousPath;}
@@ -246,3 +250,49 @@ test('task launch broker admits only its fixed application command and writes no
246
250
  const revoked=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});assert.equal(revoked.ok,false);
247
251
  }finally{abort.abort();await serving;process.env.PATH=oldPath;await ledger.stop();provider.closeAllConnections();await new Promise(r=>provider.close(r));}
248
252
  });
253
+
254
+ // Native engines and Node scripts wire child stdin as a socket they may never
255
+ // close. The isolated client must reach the broker instead of waiting for EOF,
256
+ // while pipes and files still deliver their complete input.
257
+ test('isolated client does not wait forever on an inherited open stdin socket', async t => {
258
+ const { createServer: createSocketServer } = await import('node:net');
259
+ const { spawn } = await import('node:child_process');
260
+ const { once } = await import('node:events');
261
+ const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'e-')));
262
+ t.after(() => fs.rm(root, { recursive: true, force: true }));
263
+ const socket = path.join(root, 'b.sock'), invokes = [];
264
+ const broker = createSocketServer(connection => {
265
+ let buffer = '';
266
+ connection.on('data', chunk => {
267
+ buffer += chunk;
268
+ if (!buffer.includes('\n')) return;
269
+ const frame = JSON.parse(buffer);
270
+ if (frame.operation === 'invoke') invokes.push(frame);
271
+ connection.end(JSON.stringify(frame.operation === 'resolve' ? { ok: true, revision, capability: 'c'.repeat(64) } : { ok: true, code: 0, stdout: 'ok' }) + '\n');
272
+ });
273
+ });
274
+ await new Promise(resolve => broker.listen(socket, resolve));
275
+ t.after(() => broker.close());
276
+ const client = new URL('../bin/ez', import.meta.url).pathname;
277
+ const env = { PATH: process.env.PATH, EZ_PLUGIN_BROKER_SOCKET: socket, EZ_RUN_ID: 'r_client' };
278
+ const finish = async child => {
279
+ let stdout = '';
280
+ child.stdout.on('data', chunk => stdout += chunk);
281
+ const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
282
+ const [code, signal] = await once(child, 'close');
283
+ clearTimeout(timer);
284
+ return { code, signal, stdout };
285
+ };
286
+ const open = spawn(process.execPath, [client, 'sample', 'read'], { env });
287
+ t.after(() => open.kill('SIGKILL'));
288
+ assert.deepEqual(await finish(open), { code: 0, signal: null, stdout: 'ok' });
289
+ assert.deepEqual(invokes.at(-1).args, ['read']);
290
+ assert.equal(invokes.at(-1).stdin, undefined);
291
+ const supplied = spawn(process.execPath, [client, 'sample', 'write'], { env });
292
+ supplied.stdin.end('payload');
293
+ assert.equal((await finish(supplied)).code, 0);
294
+ assert.equal(invokes.at(-1).stdin, 'payload');
295
+ const piped = spawn('/bin/sh', ['-c', '(sleep 1.5; printf late) | "$0" "$1" sample pipe', process.execPath, client], { env, stdio: ['ignore', 'pipe', 'inherit'] });
296
+ assert.equal((await finish(piped)).code, 0);
297
+ assert.equal(invokes.at(-1).stdin, 'late');
298
+ });