@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.
- package/.dockerignore +2 -0
- package/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +1 -1
- package/Dockerfile +18 -10
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +86 -0
- package/docker/engine-pnpm-workspace.yaml +4 -0
- package/docker/run.ts +1 -1
- package/docker/smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +26 -5
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/development-and-testing.md +1 -1
- package/docs/docker-runtime.md +39 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/plugin-guide.md +1 -1
- package/docs/plugins.md +1 -1
- package/docs/releasing.md +5 -1
- package/docs/scheduling.md +117 -9
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +38 -1
- package/docs/work-preflight.md +9 -0
- package/package.json +4 -3
- package/pnpm-workspace.yaml +7 -0
- package/scripts/smoke-scheduler.ts +4 -1
- package/src/ai.ts +41 -4
- package/src/application-channel.ts +43 -3
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/client-defaults.ts +3 -2
- package/src/config.ts +9 -0
- package/src/control-state.ts +3 -1
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +38 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +72 -1
- package/src/files.ts +47 -3
- package/src/host-executor.ts +40 -26
- package/src/index.ts +42 -17
- package/src/install-tools.mjs +18 -9
- package/src/menu.ts +4 -2
- package/src/model-policy.ts +4 -0
- package/src/plugin-broker.mjs +47 -4
- package/src/plugins/manager.mjs +11 -4
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +16 -2
- package/src/schedule-cli.ts +80 -10
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +102 -16
- package/src/scripts.ts +167 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +11 -8
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +53 -11
- package/src/updates/supervisor.mjs +23 -13
- package/test/ai.test.ts +85 -5
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/channel-owner.test.ts +2 -2
- package/test/config.test.ts +8 -0
- package/test/desktop-bridge.test.ts +2 -1
- package/test/executor.test.ts +37 -0
- package/test/failure.test.ts +4 -4
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +49 -6
- package/test/install-json.test.mjs +21 -0
- package/test/install-tools.test.mjs +6 -5
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +64 -0
- package/test/plugin-manager.test.mjs +20 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-cli.test.ts +47 -3
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduled-tasks.test.ts +2 -2
- package/test/scheduler-host.test.ts +21 -3
- package/test/scheduler-relay.test.ts +84 -4
- package/test/scheduler.test.ts +21 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- 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
|
+
})
|
package/test/task-native.test.ts
CHANGED
|
@@ -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 =
|
|
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}),
|
|
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.
|
|
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
|
|
84
|
-
assert.
|
|
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
|
}
|
package/test/tasks.test.ts
CHANGED
|
@@ -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.
|
|
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
|
+
})
|