@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
@@ -0,0 +1,219 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { execFile, spawn } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { once } from 'node:events'
6
+ import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile, access } from 'node:fs/promises'
7
+ import { join } from 'node:path'
8
+ import { tmpdir } from 'node:os'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { createRelay } from '../src/index.js'
11
+ import { ControlStore } from '../src/control-state.js'
12
+ import { RunStore, type RunRecord } from '../src/runs.js'
13
+ import { Scheduler } from '../src/scheduler.js'
14
+ import { Scripts } from '../src/scripts.js'
15
+ import { initialPreset } from '../src/ai.js'
16
+ import { startExecutorJob, type ExecutorOptions } from '../src/executor.js'
17
+ import { serveTestLedger } from './helpers/ledger.js'
18
+
19
+ const exec = promisify(execFile), bin = fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs', import.meta.url))
20
+ const until = async (check: () => Promise<boolean>, tries = 400) => { for (let i = 0; i < tries; i++) { if (await check()) return; await new Promise(r => setTimeout(r, 25)) } throw new Error('Timed out') }
21
+ const alive = (pid: number) => { try { process.kill(pid, 0); return true } catch { return false } }
22
+
23
+ // The script records only what it was given; it never prints secrets.
24
+ const fixture = `import { writeFileSync } from 'node:fs'
25
+ import { spawn } from 'node:child_process'
26
+ const mode = process.argv[2]
27
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k.startsWith('EZ_')))
28
+ writeFileSync('out-' + process.env.EZ_RUN_ID + '.json', JSON.stringify({ argv: process.argv.slice(2), env,
29
+ leaked: ['TELEGRAM_BOT_TOKEN', 'EZ_TEST_SECRET', 'OPENAI_API_KEY'].filter(k => process.env[k] !== undefined) }))
30
+ console.log('routine check ok token=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP')
31
+ console.error('diagnostic line')
32
+ if (mode === 'hang') {
33
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })
34
+ writeFileSync('child-' + process.env.EZ_RUN_ID + '.pid', String(child.pid))
35
+ setInterval(() => {}, 1000)
36
+ }
37
+ if (mode === 'fail') process.exit(3)
38
+ `
39
+
40
+ const setup = async () => {
41
+ const root = await mkdtemp(join(tmpdir(), 'ez-script-schedules-'))
42
+ const dir = join(root, 'control'), workspace = join(root, 'workspace'), fakeBin = join(root, 'bin')
43
+ await Promise.all([mkdir(dir), mkdir(join(workspace, 'scripts'), { recursive: true }), mkdir(fakeBin)])
44
+ await writeFile(join(workspace, 'scripts', 'guard.mjs'), fixture)
45
+ // Any native engine launch would leave this marker: script runs must not start one.
46
+ for (const engine of ['codex', 'claude', 'grok']) {
47
+ await writeFile(join(fakeBin, engine), `#!/bin/sh\necho "$0" >> ${JSON.stringify(join(root, 'llm-invocations'))}\n`)
48
+ await chmod(join(fakeBin, engine), 0o755)
49
+ }
50
+ const control = new ControlStore(dir, 1000)
51
+ await control.requestPairing(101, 101); await control.approveOwner(101)
52
+ const owner = (await control.status()).owner!
53
+ return { root, dir, workspace, fakeBin, control, owner, runs: new RunStore(dir), scheduler: new Scheduler(dir), scripts: new Scripts(dir) }
54
+ }
55
+
56
+ test('script registration confines the entry point, rejects shell strings and records the entry hash', async t => {
57
+ const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
58
+ const base = { id: 'guard', owner: f.owner, workspace: f.workspace, interpreter: 'node', args: [], timeoutSeconds: 60 }
59
+ const saved = await f.scripts.save({ ...base, entry: 'scripts/guard.mjs' }, true)
60
+ assert.equal(saved.entry, join('scripts', 'guard.mjs')); assert.match(saved.sha256, /^[a-f0-9]{64}$/)
61
+ await assert.rejects(f.scripts.save({ ...base, entry: 'scripts/guard.mjs' }, true), /already registered/)
62
+ await writeFile(join(f.root, 'outside.mjs'), 'process.exit(0)')
63
+ await assert.rejects(f.scripts.save({ ...base, id: 'escape', entry: '../outside.mjs' }, true), /inside the agent workspace/)
64
+ await symlink(join(f.root, 'outside.mjs'), join(f.workspace, 'link.mjs'))
65
+ await assert.rejects(f.scripts.save({ ...base, id: 'escape', entry: 'link.mjs' }, true), /inside the agent workspace/)
66
+ await assert.rejects(f.scripts.save({ ...base, id: 'shell', entry: 'scripts/guard.mjs', interpreter: 'node -e "x"' }, true), /not a shell command/)
67
+ await assert.rejects(f.scripts.save({ ...base, id: 'shell', entry: 'scripts/guard.mjs', interpreter: 'sh;rm' }, true), /not a shell command/)
68
+ await assert.rejects(f.scripts.save({ ...base, id: 'missing', entry: 'scripts/guard.mjs', interpreter: 'no-such-interpreter-ez' }, true), /not installed/)
69
+ await assert.rejects(f.scripts.save({ ...base, id: 'Bad ID', entry: 'scripts/guard.mjs' }, true), /Script ID/)
70
+ // An interpreter inside the workspace would be unhashed code.
71
+ await writeFile(join(f.workspace, 'run.sh'), '#!/bin/sh\n'); await chmod(join(f.workspace, 'run.sh'), 0o755)
72
+ await assert.rejects(f.scripts.save({ ...base, id: 'local', entry: 'scripts/guard.mjs', interpreter: join(f.workspace, 'run.sh') }, true), /outside the agent workspace/)
73
+ await assert.rejects(f.scripts.save({ ...base, id: 'local', entry: 'scripts/guard.mjs', interpreter: 'run.sh', pathValue: '.' }, true), /not installed/)
74
+ // Another owner's registration cannot be scheduled.
75
+ const other = { ...f.owner, generation: '00000000-0000-4000-8000-000000000000', pairedAt: new Date(Date.parse(f.owner.pairedAt) + 1000).toISOString() }
76
+ await assert.rejects(f.scheduler.save({ id: 'stranger', name: 'x', text: '', owner: other, enabled: true, trigger: { at: '2027-01-01T00:00:00Z' }, script: { id: 'guard', args: [] } }, true), /outside this owner binding/)
77
+ })
78
+
79
+ test('agent registers and schedules a script natively; script runs cannot change scripts or schedules', async t => {
80
+ const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
81
+ const ledger = await serveTestLedger(f.dir); t.after(() => ledger.stop())
82
+ const execution = await f.control.captureChoice(initialPreset('codex'))
83
+ const turn = await f.runs.create({ chatId: 101, telegramUserId: 101, texts: ['register it'], execution })
84
+ await f.runs.patch(turn.id, { status: 'running' })
85
+ const env = { ...process.env, EZ_CONTROL_DIR: f.dir, EZ_RUN_ID: turn.id, EZ_AGENT_WORKSPACE: f.workspace }
86
+ const cli = async (...args: string[]) => JSON.parse((await exec(process.execPath, [bin, ...args], { env, cwd: f.workspace })).stdout)
87
+ const registered = await cli('script', 'register', 'book-freshness', '--file', 'scripts/guard.mjs', '--interpreter', 'node', '--timeout-seconds', '120')
88
+ assert.equal(registered.interpreter, 'node'); assert.equal(registered.timeoutSeconds, 120)
89
+ await assert.rejects(cli('create', 'bad', '--script', 'book-freshness', '--model', 'gpt-6-astra', '--now'), /run no model/)
90
+ await assert.rejects(cli('create', 'bad', '--script', 'unknown', '--now'), /not registered/)
91
+ const schedule = await cli('create', 'actual-book-freshness-guard', '--script', 'book-freshness', '--arg=--quiet', '--cron', '15 * * * *', '--timezone', 'Asia/Dubai')
92
+ assert.equal(schedule.executionType, 'script'); assert.deepEqual(schedule.script, { id: 'book-freshness', args: ['--quiet'] })
93
+ assert.equal(schedule.execution, undefined); assert.equal(schedule.text, '')
94
+ const shown = await cli('script', 'show', 'book-freshness')
95
+ assert.equal(shown.matchesRegistration, true); assert.deepEqual(shown.schedules, ['actual-book-freshness-guard'])
96
+ await assert.rejects(cli('script', 'remove', 'book-freshness'), /used by schedules/)
97
+ // An existing agent schedule remains readable beside it.
98
+ const agent = await cli('create', 'daily', '--text', 'Prepare the report', '--model', 'gpt-6-astra', '--at', '2027-09-09T09:00:00+04:00')
99
+ assert.equal(agent.executionType, 'agent'); assert.equal(agent.execution.preset.model, 'gpt-6-astra')
100
+
101
+ const scriptRun = await f.runs.create({ chatId: 101, telegramUserId: 101, texts: ['[schedule]'],
102
+ script: { id: 'book-freshness', args: [], revision: registered.revision, sha256: registered.sha256 },
103
+ scheduled: { id: 'actual-book-freshness-guard', revision: schedule.revision, dueAt: new Date().toISOString(), pairedAt: f.owner.pairedAt } })
104
+ await f.runs.patch(scriptRun.id, { status: 'running' })
105
+ const asScript = { env: { ...env, EZ_RUN_ID: scriptRun.id }, cwd: f.workspace }
106
+ for (const args of [['script', 'update', 'book-freshness'], ['script', 'register', 'other', '--file', 'scripts/guard.mjs', '--interpreter', 'node'],
107
+ ['edit', 'actual-book-freshness-guard', '--script', 'book-freshness', '--now'], ['pause', 'daily'], ['trigger', 'daily', '--key', 'k']])
108
+ await assert.rejects(exec(process.execPath, [bin, ...args], asScript), /Script runs cannot change scripts or schedules/)
109
+ assert.equal(JSON.parse((await exec(process.execPath, [bin, 'script', 'show', 'book-freshness'], asScript)).stdout).id, 'book-freshness')
110
+ // A relative --file resolves from the caller's directory, stored relative to the workspace.
111
+ const nested = JSON.parse((await exec(process.execPath, [bin, 'script', 'register', 'nested', '--file', 'guard.mjs', '--interpreter', 'node'], { env, cwd: join(f.workspace, 'scripts') })).stdout)
112
+ assert.equal(nested.entry, join('scripts', 'guard.mjs'))
113
+ })
114
+
115
+ test('script CLI creates, preserves and explicitly clears preflight gates', async t => {
116
+ const f=await setup();t.after(()=>rm(f.root,{recursive:true,force:true}))
117
+ const ledger=await serveTestLedger(f.dir);t.after(()=>ledger.stop())
118
+ await f.scripts.save({id:'guard',owner:f.owner,workspace:f.workspace,entry:'scripts/guard.mjs',interpreter:'node',args:[],timeoutSeconds:60},true)
119
+ const preflight={on:'eligible',checks:[{alias:'provider-work',args:[]}]},file=join(f.root,'preflight.json')
120
+ await writeFile(file,JSON.stringify(preflight))
121
+ const env={...process.env,EZ_CONTROL_DIR:f.dir,EZ_RUN_ID:'',EZ_AGENT_WORKSPACE:f.workspace}
122
+ const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env,cwd:f.workspace})).stdout)
123
+ const options=['--script','guard','--at','2027-09-09T09:00:00+04:00']
124
+ const created=await cli('create','gated',...options,'--preflight-file',file)
125
+ assert.deepEqual(created.preflight,preflight)
126
+ const edited=await cli('edit','gated',...options,'--name','Renamed')
127
+ assert.deepEqual(edited.preflight,preflight)
128
+ const cleared=await cli('edit','gated',...options,'--clear-preflight')
129
+ assert.equal(cleared.preflight,undefined)
130
+ })
131
+
132
+ test('script schedules run with zero LLM invocations, exact revision receipts, isolation and fail-safe integrity', async t => {
133
+ const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
134
+ const savedEnv = { PATH: process.env.PATH, TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN, EZ_TEST_SECRET: process.env.EZ_TEST_SECRET }
135
+ process.env.PATH = `${f.fakeBin}:${process.env.PATH}`; process.env.TELEGRAM_BOT_TOKEN = '123456:secret-token-value-for-tests-only'; process.env.EZ_TEST_SECRET = 'do-not-inherit'
136
+ t.after(() => { for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) delete process.env[k]; else process.env[k] = v } })
137
+ const agentLaunches: ExecutorOptions[] = []
138
+ const relay = createRelay({ workspace: f.workspace, controlDir: f.dir, pairingTtlMs: 1000, executorTimeoutMs: 0, executorCli: 'codex', telegramBotToken: 'fixture' },
139
+ async (texts, options) => {
140
+ if (options.script) return startExecutorJob(texts, options)
141
+ agentLaunches.push(options)
142
+ const child = spawn(process.execPath, ['-e', 'setTimeout(()=>{},10)'], { detached: true }); await once(child, 'spawn')
143
+ return { child, cleanup: async () => {}, stdout: '' }
144
+ })
145
+ relay.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
146
+ t.after(() => relay.stop())
147
+ const registration = await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['ok'], timeoutSeconds: 60 }, true)
148
+ const schedule = await f.scheduler.save({ id: 'guard', name: 'Guard', text: '', owner: f.owner, enabled: true, trigger: { cron: '15 * * * *', timezone: 'Asia/Dubai', start: new Date().toISOString() }, script: { id: 'book-freshness', args: ['extra'] } }, true)
149
+ const finished = (id: string) => until(async () => ['completed', 'failed', 'cancelled'].includes((await f.runs.get(id))?.status ?? ''))
150
+ const output = async (run: RunRecord) => JSON.parse(await readFile(join(f.workspace, `out-${run.id}.json`), 'utf8'))
151
+
152
+ // Manual trigger.
153
+ const manual = await f.scheduler.trigger(schedule.id, schedule.revision, 'smoke-1', f.owner, f.runs)
154
+ await relay.drainSources(); await finished(manual.id)
155
+ const done = (await f.runs.get(manual.id))!
156
+ assert.equal(done.status, 'completed'); assert.equal(done.exitCode, 0); assert.equal(done.execution, undefined)
157
+ assert.deepEqual(done.script, { id: 'book-freshness', args: ['extra'], revision: registration.revision, sha256: registration.sha256 })
158
+ assert.ok(done.startedAt && done.endedAt && done.createdAt <= done.startedAt)
159
+ assert.match(done.output!, /routine check ok/); assert.match(done.output!, /diagnostic line/); assert.doesNotMatch(done.output!, /abcdefghijklmnopqrstuvwxyz0123456789ABCDEF/)
160
+ const seen = await output(done)
161
+ assert.deepEqual(seen.argv, ['ok', 'extra']); assert.deepEqual(seen.leaked, [])
162
+ assert.equal(seen.env.EZ_RUN_ID, manual.id); assert.equal(seen.env.EZ_SCHEDULE_ID, 'guard'); assert.equal(seen.env.EZ_SCHEDULE_REVISION, schedule.revision)
163
+ assert.equal(seen.env.EZ_DUE_AT, done.scheduled!.dueAt); assert.equal(seen.env.EZ_SCRIPT_REVISION, registration.revision); assert.equal(seen.env.EZ_SCRIPT_SHA256, registration.sha256)
164
+ assert.equal((await relay.drainOutbox(), (await f.runs.pendingOutbox()).length), 0)
165
+
166
+ // Natural occurrence through the existing tick.
167
+ const due = await f.scheduler.pendingOccurrence(schedule)
168
+ await f.scheduler.tick(f.owner, f.runs, due!)
169
+ const natural = (await f.runs.list()).find(r => r.id !== manual.id && r.scheduled?.id === 'guard')!
170
+ assert.equal(natural.scheduled!.dueAt, new Date(due!).toISOString())
171
+ await relay.drainSources(); await finished(natural.id)
172
+ assert.equal((await f.runs.get(natural.id))?.status, 'completed')
173
+ await assert.rejects(access(join(f.root, 'llm-invocations')))
174
+ assert.equal(agentLaunches.length, 0)
175
+
176
+ // Changed entry-point bytes refuse to run until an explicit update.
177
+ await writeFile(join(f.workspace, 'scripts', 'guard.mjs'), fixture + '\n// edited\n')
178
+ const tampered = await f.scheduler.trigger(schedule.id, schedule.revision, 'tampered', f.owner, f.runs)
179
+ await relay.drainSources(); await finished(tampered.id)
180
+ const refused = (await f.runs.get(tampered.id))!
181
+ assert.equal(refused.status, 'failed'); assert.match(refused.failure!.error, /entry point changed since registration/)
182
+ await assert.rejects(readFile(join(f.workspace, `out-${tampered.id}.json`)))
183
+ const updated = await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['hang'], timeoutSeconds: 60 }, false)
184
+ assert.notEqual(updated.sha256, registration.sha256)
185
+
186
+ // Cancellation stops the script and its children; overlap is refused meanwhile.
187
+ const long = await f.scheduler.trigger(schedule.id, schedule.revision, 'long', f.owner, f.runs)
188
+ await relay.drainSources()
189
+ const childPid = Number(await until(async () => { try { await access(join(f.workspace, `child-${long.id}.pid`)); return true } catch { return false } })
190
+ .then(() => readFile(join(f.workspace, `child-${long.id}.pid`), 'utf8')))
191
+ assert.ok(alive(childPid))
192
+ assert.equal((await f.runs.get(long.id))?.script?.revision, updated.revision)
193
+ await assert.rejects(f.scheduler.trigger(schedule.id, schedule.revision, 'overlap', f.owner, f.runs), /queued or running/)
194
+ const before = (await f.runs.list()).length
195
+ await f.scheduler.tick(f.owner, f.runs, (await f.scheduler.pendingOccurrence(schedule))! + 3600_000)
196
+ assert.equal((await f.runs.list()).length, before)
197
+ await f.scheduler.cancel(long.id); await relay.drainSources(); await finished(long.id)
198
+ assert.equal((await f.runs.get(long.id))?.status, 'cancelled')
199
+ await until(async () => !alive(childPid))
200
+
201
+ // A bounded timeout terminates the process; it is a failure, never retried.
202
+ await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['hang'], timeoutSeconds: 1 }, false)
203
+ const slow = await f.scheduler.trigger(schedule.id, schedule.revision, 'slow', f.owner, f.runs)
204
+ await relay.drainSources(); await finished(slow.id)
205
+ const timedOut = (await f.runs.get(slow.id))!
206
+ assert.equal(timedOut.status, 'failed'); assert.equal(timedOut.failureReason, 'script-timeout'); assert.equal(timedOut.timedOut, true)
207
+ const slowChild = Number(await readFile(join(f.workspace, `child-${slow.id}.pid`), 'utf8'))
208
+ await until(async () => !alive(slowChild))
209
+ await relay.drainSources()
210
+ assert.equal((await f.runs.list()).filter(r => r.scheduled?.id === 'guard' && r.status === 'queued').length, 0)
211
+
212
+ // An existing LLM schedule still launches its native engine settings.
213
+ const execution = await f.control.captureChoice({ ...initialPreset('codex'), model: 'gpt-6-astra' })
214
+ const agent = await f.scheduler.save({ id: 'report', name: 'Report', text: 'Prepare the report', owner: f.owner, execution, enabled: true, trigger: { at: '2027-01-01T00:00:00Z' } }, true)
215
+ const agentRun = await f.scheduler.trigger(agent.id, agent.revision, 'agent', f.owner, f.runs)
216
+ await relay.drainSources(); await finished(agentRun.id)
217
+ assert.equal((await f.runs.get(agentRun.id))?.status, 'completed')
218
+ assert.equal(agentLaunches.length, 1); assert.equal(agentLaunches[0].model, 'gpt-6-astra'); assert.equal(agentLaunches[0].nativeSession, true)
219
+ })
@@ -1,10 +1,12 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
3
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises'
4
4
  import { createServer } from 'node:http'
5
+ import {createServer as createNetServer} from 'node:net'
5
6
  import { spawn, execFile } from 'node:child_process'
6
7
  import { promisify } from 'node:util'
7
8
  import { fileURLToPath } from 'node:url'
9
+ import { createHash } from 'node:crypto'
8
10
  import { homedir } from 'node:os'
9
11
  import { taskArguments, taskModelCatalog, TASK_CODEX_VERSION } from '../src/task-executor.js'
10
12
  import { Tasks } from '../src/tasks.js'
@@ -12,33 +14,60 @@ import { ownerRun } from './helpers/owner-run.js'
12
14
  import { EventSources } from '../src/event-sources.js'
13
15
  import { ControlStore } from '../src/control-state.js'
14
16
  import { RunStore } from '../src/runs.js'
17
+ import {ApplicationBindings} from '../src/application-channel.js'
15
18
  import { taskRequests } from '../src/task-rpc.js'
16
19
 
17
20
  // Real bundled model metadata plus native CLI, synthetic endpoint, no credentials or provider sends.
18
21
  // Unknown fixture model names miss model-driven tool overrides.
19
22
  // Run explicitly with EZ_TEST_NATIVE_TASKS=1 after installing the audited CLI.
20
23
  test('native restricted task has only bounded MCP tools, ignores private guidance, and executes broker calls', { skip: !process.env.EZ_TEST_NATIVE_TASKS, timeout: 30000 }, async () => {
24
+ assert.equal((await promisify(execFile)('codex',['--version'])).stdout.trim(), `codex-cli ${TASK_CODEX_VERSION}`)
21
25
  const root = await mkdtemp('/tmp/ez-native-task-'), directory = `${root}/task`, home = `${root}/home`
22
26
  await mkdir(directory); await mkdir(home)
23
27
  await writeFile(`${root}/AGENTS.md`, 'PRIVATE_CANARY_DO_NOT_LOAD')
24
28
  await writeFile(`${home}/config.toml`, 'invalid = [ syntax')
25
29
  const requests: any[] = [], sends: any[] = []
30
+ const incoming={id:'1',conversationId:'contact-a',receivedAt:Date.now()+1000,text:'shop.txt attached'}
31
+ const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')
32
+ const videoPath = `${root}/fixture.mp4`
33
+ await promisify(execFile)('ffmpeg',['-v','error','-f','lavfi','-i','color=c=blue:s=64x64:d=1','-c:v','mpeg4',videoPath])
34
+ const video=await readFile(videoPath)
26
35
  const provider = createServer(async (req, res) => {
27
36
  let body = ''; for await (const chunk of req) body += chunk
28
37
  const { command, args } = JSON.parse(body)
29
- res.end(JSON.stringify({ ok: true, data: command === 'events-head' ? { cursor: 0, accountId: 'fixture-account', taskProtocol: 'message-v1' }
38
+ res.end(JSON.stringify({ ok: true, data: command === 'events-head' ? { cursor: 0, accountId: 'fixture-account', taskProtocol: 'message-v1',taskVoice:true }
39
+ : command === 'events-check' ? {events:[incoming,{...incoming,id:'2',text:'image.png attached'},{...incoming,id:'3',text:'video.mp4 attached; speech transcript: Shop closes at 6pm.'}]}
40
+ : command === 'task-document' && args.incomingId === '3' ? {name:'video.mp4',data:video.toString('base64'),sha256:createHash('sha256').update(video).digest('hex')}
41
+ : command === 'task-document' && args.incomingId === '2' ? {name:'image.png',data:png.toString('base64'),sha256:createHash('sha256').update(png).digest('hex')}
42
+ : command === 'task-document' ? {name:'shop.txt',data:Buffer.from('Shop opens at 7am.').toString('base64'),sha256:createHash('sha256').update('Shop opens at 7am.').digest('hex')}
30
43
  : command === 'task-send' ? (sends.push(args), { ...args, state: 'accepted' }) : {} }))
31
44
  })
32
45
  await new Promise<void>(r => provider.listen(`${root}/p.sock`, r))
33
46
  await ownerRun(root, 'owner')
34
47
  await new EventSources(root).register('fixture', `${root}/p.sock`, (await new ControlStore(root, 900000).status()).owner!)
35
- const tasks = new Tasks(root), drain = taskRequests(tasks)
48
+ const tasks = new Tasks(root,async () => ({buffer:Buffer.from('OggSfixture OpusHead'),mimeType:'audio/ogg'})), drain = taskRequests(tasks)
36
49
  const proposal: any = await tasks.ownerCall('owner', 'start', { sourceId: 'fixture', conversationId: 'contact-a', purpose: 'Book dinner without payment', context: 'Two people at 7pm', hours: 1 })
37
50
  assert.equal(proposal.state, 'active')
38
- const runs = new RunStore(root), run = (await runs.list()).find(r => r.taskId)!
51
+ const runs = new RunStore(root), run = await runs.create({id:'event_document_fixture',taskId:proposal.id,chatId:101,telegramUserId:101,texts:[],external:{sourceId:'fixture',bindingId:(await tasks.get(proposal.id))!.bindingId,eventIds:['1','2','3']}})
39
52
  await runs.patch(run.id, { status: 'running' })
53
+ const bindings=new ApplicationBindings(root), owner=(await new ControlStore(root,900000).status()).owner!
54
+ await bindings.register('browser','z'.repeat(48),owner)
55
+ await bindings.taskLaunch('browser',{command:'fixture',args:['launch','--task'],tasks:[proposal.id]})
56
+ let launchRequests=0
57
+ const launchSocket=`${root}/launch.sock`
58
+ const launchBroker=createNetServer(socket=>{
59
+ let raw='';socket.on('data',chunk=>{raw+=chunk;if(!raw.includes('\n'))return;void(async()=>{
60
+ const request=JSON.parse(raw.trim()),input=JSON.parse(request.stdin)
61
+ assert.equal(request.operation,'task-launch');assert.equal(request.runId,run.id)
62
+ assert.equal(input.taskId,proposal.id)
63
+ const grant=await tasks.authorizeApplicationLaunch(run.id,createHash('sha256').update(input.taskToken).digest('hex'))
64
+ assert.equal(grant.launch.command,'fixture');assert.deepEqual(grant.launch.args,['launch','--task'])
65
+ launchRequests++
66
+ socket.end(JSON.stringify({version:1,id:request.id,ok:true,code:0,stdout:JSON.stringify({launch:{url:'https://fixture.example/#launch='+ 'e'.repeat(64),expiresAt:Date.now()+300000}})})+'\n')
67
+ })().catch(error=>socket.end(JSON.stringify({ok:false,error:error.message})+'\n'))})
68
+ });await new Promise<void>(r=>launchBroker.listen(launchSocket,r))
40
69
  const sequence = [
41
- ['context', {}], ['send', { text: 'Is a table for two available at 7pm?', key: 'first' }],
70
+ ['context', {}], ['browser_link',{application:'browser'}], ['read_attachment',{incomingId:'1'}], ['read_attachment',{incomingId:'2'}], ['read_attachment',{incomingId:'3'}], ['send', { text: 'Is a table for two available at 7pm?', key: 'first', voice:true }],
42
71
  ['note', { text: 'Awaiting confirmation' }], ['complete', { text: 'Request sent; no booking confirmation received.' }],
43
72
  ['send', { text: 'A completed task cannot send', key: 'second' }],
44
73
  ]
@@ -61,30 +90,39 @@ test('native restricted task has only bounded MCP tools, ignores private guidanc
61
90
  try {
62
91
  const entry = new URL('../src/task-mcp.ts', import.meta.url).href
63
92
  await writeFile(`${root}/broker.mjs`, `if(process.env.HOME !== ${JSON.stringify(homedir())}) throw Error('Broker lost host CLI configuration'); await import(${JSON.stringify(entry)});`)
64
- const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), `${root}/broker.mjs`, root, run.id]
93
+ const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), `${root}/broker.mjs`, root, run.id,'','[]','["browser"]']
65
94
  const catalog = await promisify(execFile)('codex', ['debug', 'models', '--bundled'], { maxBuffer: 4 * 1024 * 1024 });
66
95
  await writeFile(`${root}/models.json`, JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))));
67
- const args = taskArguments(directory, broker, JSON.stringify({event:'task_activated',taskId:proposal.id}), undefined, {model:'gpt-6-astra'})
96
+ const args = taskArguments(directory, broker, JSON.stringify({event:'task_activated',taskId:proposal.id}), ['context','browser_link','read_attachment','send','note','report','complete'], {model:'gpt-6-astra'}, {EZ_PLUGIN_BROKER_SOCKET:launchSocket,EZ_DELIVERY_SOCKET:`${root}/delivery.sock`})
68
97
  args.splice(-1, 0, '--disable', 'enable_request_compression', '-c', 'model_provider="fixture"', '-c', `model_providers.fixture={name="fixture",base_url="http://127.0.0.1:${(server.address() as any).port}/v1",wire_api="responses",requires_openai_auth=false}`)
69
98
  child = spawn('codex', args, { cwd: directory, env: { PATH: process.env.PATH, HOME: home, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'] })
70
99
  child.stdin!.end(JSON.stringify({event:'task_activated',taskId:proposal.id}));
71
100
  let stderr = ''; child.stderr!.on('data', c => { stderr += c }); child.stdout!.resume()
72
101
  const code = await new Promise(r => child!.on('close', r))
73
102
  assert.equal(code, 0, `Requires audited Codex ${TASK_CODEX_VERSION}: ${stderr}`)
74
- assert.ok(requests.length === 6, 'Native tool call completed a second model turn')
103
+ assert.equal(launchRequests,1, 'Native MCP uses the installed isolated broker launcher without tools home')
104
+ assert.ok(requests.length === 10, 'Native tool call completed a second model turn')
75
105
  assert.ok(!JSON.stringify(requests).includes('PRIVATE_CANARY_DO_NOT_LOAD'))
106
+ assert.ok(JSON.stringify(requests).includes('https://fixture.example/#launch='))
107
+ assert.ok(!JSON.stringify(requests).includes('taskToken'))
76
108
  const messages=requests[0].input.filter((v:any)=>v.role==='user')
77
109
  assert.ok(messages.some((m:any)=>m.content.some((c:any)=>c.text===JSON.stringify({event:'task_activated',taskId:proposal.id}))))
78
110
  const tools = requests[0].tools ?? requests[0].input.find((v: any) => v.type === 'additional_tools')?.tools
79
111
  assert.deepEqual(tools.filter((t: any) => t.type === 'function').map((t: any) => t.name).sort(), ['list_mcp_resource_templates', 'list_mcp_resources', 'read_mcp_resource', 'request_user_input'])
80
112
  const namespaces = tools.filter((t: any) => t.type === 'namespace')
81
113
  assert.equal(namespaces.length, 1); assert.equal(namespaces[0].name, 'mcp__ez')
82
- assert.deepEqual(namespaces[0].tools.map((t: any) => t.name).sort(), ['complete', 'context', 'note', 'report', 'send'])
83
- assert.match(JSON.stringify(requests[5].input), /inactive or expired/)
84
- assert.equal(sends.length, 1); assert.equal(sends[0].conversationId, 'contact-a')
114
+ assert.deepEqual(namespaces[0].tools.map((t: any) => t.name).sort(), ['browser_link','complete', 'context', 'note', 'read_attachment', 'report', 'send'])
115
+ assert.match(JSON.stringify(requests.at(-1).input), /inactive or expired/)
116
+ assert.ok(JSON.stringify(requests).includes('Shop opens at 7am.'))
117
+ assert.ok(JSON.stringify(requests).includes('data:image/'), 'Native CLI forwards MCP images into vision input: '+JSON.stringify(requests.map(request=>request.input)))
118
+ assert.ok(JSON.stringify(requests).includes('durationSeconds'), 'Native input includes video frame metadata')
119
+ assert.ok(JSON.stringify(requests).includes('Shop closes at 6pm.'))
120
+ const videoTurn=requests[5].input
121
+ assert.ok(JSON.stringify(videoTurn).includes('data:image/'), 'Native CLI receives sampled video frames as vision input')
122
+ assert.equal(sends.length, 1);assert.equal(Buffer.from(sends[0].audio.data,'base64').toString(),'OggSfixture OpusHead'); assert.equal(sends[0].conversationId, 'contact-a')
85
123
  assert.equal((await tasks.get(proposal.id))!.state, 'completed')
86
124
  } finally {
87
- child?.kill(); clearInterval(timer); server.closeAllConnections(); await new Promise<void>(r => server.close(() => r()))
125
+ child?.kill(); clearInterval(timer); await new Promise<void>(r=>launchBroker.close(()=>r())); server.closeAllConnections(); await new Promise<void>(r => server.close(() => r()))
88
126
  provider.closeAllConnections(); await new Promise<void>(r => provider.close(() => r()))
89
127
  await rm(root, { recursive: true, force: true })
90
128
  }
@@ -15,16 +15,17 @@ import { requireOwnerExecution } from '../src/execution-authority.js'
15
15
  import { execFile } from 'node:child_process'
16
16
  import { promisify } from 'node:util'
17
17
 
18
- async function fixture(t: test.TestContext, channelOwner = false) {
18
+ async function fixture(t: test.TestContext, channelOwner = false, speech?: (text:string) => Promise<{buffer:Buffer;mimeType:string}>) {
19
19
  const dir = await mkdtemp('/tmp/ez-task-test-'), socket = join(dir, 's.sock')
20
20
  let accountId = 'account-a', events: SourceEvent[] = [], uncertain = false
21
21
  const sends: any[] = [], watches: any[] = []
22
22
  const server = createServer(async (req, res) => {
23
23
  let text = ''; for await (const chunk of req) text += chunk
24
24
  const { command, args } = JSON.parse(text)
25
- const data = command === 'events-head' ? { cursor: 0, accountId, taskProtocol: 'message-v1', persistentWatch: true, wildcardWatch: true }
25
+ const data = command === 'events-head' ? { cursor: 0, accountId, taskProtocol: 'message-v1', persistentWatch: true, wildcardWatch: true, taskVoice: true }
26
26
  : command === 'task-watch' ? (watches.push(args), { watching: args.conversationId })
27
27
  : command === 'events-check' ? { events: events.filter(e => args.ids.includes(e.id)) }
28
+ : command === 'task-document' ? {name:'shop.txt',data:Buffer.from('Shop opens at 7am.').toString('base64'),sha256:createHash('sha256').update('Shop opens at 7am.').digest('hex')}
28
29
  : command === 'task-send' ? (sends.push(args), { ...args, state: uncertain ? 'uncertain' : 'accepted', receiptId: 'provider-1' }) : {}
29
30
  res.end(JSON.stringify({ ok: true, data }))
30
31
  })
@@ -35,7 +36,7 @@ async function fixture(t: test.TestContext, channelOwner = false) {
35
36
  await runs.create({ id: 'owner', ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner), texts: ['Own this conversation'] })
36
37
  await runs.patch('owner', { status: 'running' })
37
38
  } else await ownerRun(dir, 'owner')
38
- const control = new ControlStore(dir, 900000), sources = new EventSources(dir), runs = new RunStore(dir), tasks = new Tasks(dir)
39
+ const control = new ControlStore(dir, 900000), sources = new EventSources(dir), runs = new RunStore(dir), tasks = new Tasks(dir,speech)
39
40
  await sources.register('generic', socket, (await control.status()).owner!)
40
41
  t.after(async () => { server.closeAllConnections(); await new Promise<void>(r => server.close(() => r())); await rm(dir, { recursive: true, force: true }) })
41
42
  async function proposal() {
@@ -247,7 +248,7 @@ test('approved initial task crosses the real host file client and uses a fresh r
247
248
  const { isHostRunId } = await import('../src/host-executor-protocol.js')
248
249
  const f = await fixture(t), { run } = await f.activate()
249
250
  assert.ok(isHostRunId(run.id))
250
- await writeFile(join(f.dir, 'codex'), `#!${process.execPath}\nif(process.argv[2]==='--version')console.log('codex-cli 0.153.4');else if(process.argv[2]==='debug')console.log(JSON.stringify({models:[{slug:'fixture',tool_mode:'code_mode_only',apply_patch_tool_type:'freeform',multi_agent_version:'v2'}]}));else console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),control:process.env.EZ_CONTROL_DIR}));`, { mode: 0o700 })
251
+ await writeFile(join(f.dir, 'codex'), `#!${process.execPath}\nif(process.argv[2]==='--version')console.log('codex-cli 0.160.1');else if(process.argv[2]==='debug')console.log(JSON.stringify({models:[{slug:'fixture',tool_mode:'code_mode_only',apply_patch_tool_type:'freeform',multi_agent_version:'v2'}]}));else console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),control:process.env.EZ_CONTROL_DIR}));`, { mode: 0o700 })
251
252
  const priorPath = process.env.PATH
252
253
  process.env.PATH = `${f.dir}:${priorPath}`
253
254
  const abort = new AbortController(), host = serveHostExecutor({ cli: 'codex', agents: [{ name: 'test', workspace: f.dir, controlDir: f.dir, binDir: f.dir }] }, abort.signal)
@@ -341,3 +342,95 @@ test('public grants enforce persisted hourly and cumulative admission budgets',a
341
342
  assert.equal(await f.tasks.admitPublic(proposal.id),'revoked')
342
343
  assert.equal((await f.tasks.get(proposal.id))?.state,'revoked')
343
344
  })
345
+
346
+
347
+ test('voice reuses task send authority and receipt keys, rechecks the account after synthesis, and never falls back to text', async t => {
348
+ let generated = 0, changed = false
349
+ let changeAccount: (value:string) => void
350
+ const f = await fixture(t,false,async text => {
351
+ generated++; assert.equal(text, 'Spoken fixture')
352
+ if(changed)changeAccount('replaced')
353
+ return {buffer:Buffer.from('OggSfixture OpusHead'),mimeType:'audio/ogg'}
354
+ })
355
+ changeAccount = f.account
356
+ const {run} = await f.activate()
357
+ const sent = await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'spoken',voice:true})
358
+ assert.ok('state' in sent);assert.equal(sent.state,'accepted');assert.equal(generated,1);assert.equal(f.sends.length,1)
359
+ assert.equal(Buffer.from(f.sends[0].audio.data,'base64').toString(),'OggSfixture OpusHead')
360
+ await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'spoken',voice:true})
361
+ assert.equal(generated,1);assert.equal(f.sends.length,1)
362
+ await assert.rejects(f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'spoken'}), /different text/)
363
+ changed=true
364
+ assert.equal((await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'changed',voice:true}) as {state:string}).state,'uncertain')
365
+ assert.equal(f.sends.length,1)
366
+ })
367
+
368
+ test('voice generation failure is a definite non-send: no text, no stored operation, same key retryable', async t => {
369
+ let generated=0
370
+ const f=await fixture(t,false,async () => { if(++generated===1)throw new Error('provider failed'); return {buffer:Buffer.from('OggSfixture OpusHead'),mimeType:'audio/ogg'} })
371
+ const {run, taskId}=await f.activate()
372
+ await assert.rejects(f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'failed',voice:true}),/provider failed/)
373
+ assert.equal(f.sends.length,0);assert.equal(Object.hasOwn((await f.tasks.get(taskId))!.operations,'failed'),false)
374
+ assert.equal((await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'failed',voice:true}) as {state:string}).state,'accepted')
375
+ assert.equal(generated,2);assert.equal(f.sends.length,1)
376
+ })
377
+
378
+
379
+ test('a cancelled worker cannot dispatch a voice generated after stop', async t => {
380
+ let stop: () => Promise<unknown>
381
+ const f=await fixture(t,false,async () => { await stop(); return {buffer:Buffer.from('OggSfixture OpusHead'),mimeType:'audio/ogg'} })
382
+ const {run}=await f.activate();stop=()=>f.runs.patch(run.id,{status:'cancelled'})
383
+ assert.equal((await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'stopped',voice:true}) as {state:string}).state,'uncertain')
384
+ assert.equal(f.sends.length,0)
385
+ })
386
+
387
+ test('incoming documents use shared staging and never expose another event or owner path', async t => {
388
+ const f=await fixture(t), {taskId}=await f.activate(), task=(await f.tasks.get(taskId))!
389
+ const row={id:'1',conversationId:'contact-a',receivedAt:Date.now(),text:'Document attached'};f.rows([row])
390
+ const run=await f.runs.create({id:'event_document',taskId,chatId:101,telegramUserId:101,texts:[],external:{sourceId:'generic',bindingId:task.bindingId,eventIds:['1']}})
391
+ await f.runs.patch(run.id,{status:'running'})
392
+ const document:any=await f.tasks.workerCall(run.id,'read_attachment',{incomingId:'1'})
393
+ assert.equal(document.text,'Shop opens at 7am.');assert.equal(document.name,'shop.txt');assert.equal(document.type,'text')
394
+ await assert.rejects(f.tasks.workerCall(run.id,'read_attachment',{incomingId:'2'}),/outside/)
395
+ await assert.rejects(f.tasks.workerCall(run.id,'read_attachment',{incomingId:'/etc/passwd'}),/outside/)
396
+ f.rows([{...row,conversationId:'contact-b'}]);await assert.rejects(f.tasks.workerCall(run.id,'read_attachment',{incomingId:'1'}),/correspondence/)
397
+ })
398
+
399
+ test('explicit application launch continues restricted task; cross-task, owner mode, revocation and replay changes fail closed',async t=>{
400
+ const {ApplicationBindings,ApplicationChannel}=await import('../src/application-channel.js')
401
+ const {initialPreset}=await import('../src/ai.js')
402
+ const f=await fixture(t), {taskId,run}=await f.activate(), owner=(await f.control.status()).owner!
403
+ const bindings=new ApplicationBindings(f.dir), binding=(await bindings.register('browser','x'.repeat(48),owner))!
404
+ const token='a'.repeat(64),ticketHash=createHash('sha256').update(token).digest('hex')
405
+ await assert.rejects(f.tasks.workerCall(run.id,'application_launch',{application:'browser',ticketHash}),/not authorized/)
406
+ await bindings.taskLaunch('browser',{command:'fixture',args:['launch','--task'],tasks:[taskId]})
407
+ const grant=await f.tasks.workerCall(run.id,'application_launch',{application:'browser',ticketHash}) as {taskId:string;expiresAt:number}
408
+ assert.equal(grant.taskId,taskId)
409
+ assert(!(await readFile(join(f.dir,'task-applications',ticketHash+'.json'),'utf8')).includes(token))
410
+ const admission=await f.tasks.authorizeApplicationLaunch(run.id,ticketHash)
411
+ assert.deepEqual(admission.launch,{command:'fixture',args:['launch','--task'],tasks:[taskId]})
412
+ await assert.rejects(f.tasks.authorizeApplicationLaunch('owner',ticketHash))
413
+ const app=new ApplicationChannel({controlDir:f.dir,initial:initialPreset('codex'),wake(){},async cancel(){}})
414
+ const input={requestId:'voice-test',scope:'task:'+taskId,text:'When is the booking?',taskToken:token}
415
+ await assert.rejects(app.submit(binding.bindingId,{...input,followOwner:true}),/Invalid application task/)
416
+ await assert.rejects(app.submit(binding.bindingId,{...input,scope:'task:other'}),/scope/)
417
+ const delegated=await app.submit(binding.bindingId,input)
418
+ assert.equal(delegated.taskId,taskId);assert.equal(delegated.version,2)
419
+ assert.equal((await app.submit(binding.bindingId,input)).id,delegated.id)
420
+ await assert.rejects(app.submit(binding.bindingId,{...input,text:'Changed request'}),/conflicts/)
421
+ await f.runs.patch(delegated.id,{status:'running'})
422
+ const context=await f.tasks.workerCall(delegated.id,'context',{}) as {notes:string[];request:string;context:string}
423
+ assert.match(context.context,/Two people/);assert.equal(context.request,input.text)
424
+ await f.tasks.workerCall(delegated.id,'note',{text:'Discussion-only voice result'})
425
+ const reply=await f.tasks.workerCall(delegated.id,'send',{key:'answer',text:'7pm'}) as {receipt:{queued:string}}
426
+ assert.equal(f.sends.length,0)
427
+ const outbox=(await f.runs.pendingOutbox()).find(x=>x.id===reply.receipt.queued)!
428
+ await app.deliver(delegated,outbox)
429
+ assert.deepEqual((await app.snapshot(binding.bindingId,delegated.id)).messages.map(m=>m.text),['7pm'])
430
+ const other=(await bindings.register('other','y'.repeat(48),owner))!
431
+ await bindings.taskLaunch('other',{command:'fixture',args:['launch'],tasks:'all'})
432
+ await assert.rejects(app.submit(other.bindingId,{...input,requestId:'other'}),/revoked/)
433
+ await bindings.taskLaunch('browser',null)
434
+ await assert.rejects(f.tasks.authorize(delegated),/revoked/)
435
+ await assert.rejects(app.snapshot(binding.bindingId,delegated.id),/revoked/)
436
+ })