@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.49
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/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +30 -0
- package/Dockerfile +5 -1
- 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 +100 -0
- package/docker/run.ts +1 -1
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +23 -4
- package/docs/docker-runtime.md +57 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +141 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +33 -4
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +98 -15
- package/src/application-channel.ts +52 -12
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/auth-profile.ts +53 -0
- package/src/client-defaults.ts +3 -2
- package/src/codex-session.ts +14 -3
- package/src/config.ts +9 -0
- package/src/control-state.ts +31 -22
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +26 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +81 -7
- package/src/files.ts +47 -3
- package/src/host-executor.ts +68 -34
- package/src/index.ts +227 -110
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +9 -7
- package/src/plugin-broker.mjs +50 -14
- package/src/plugins/manager.mjs +34 -8
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +24 -2
- package/src/schedule-cli.ts +100 -13
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +85 -19
- package/src/scripts.ts +167 -0
- package/src/setup-fallback.ts +122 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +13 -9
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +107 -30
- package/src/updates/supervisor.mjs +27 -6
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/auth-profiles.test.ts +187 -0
- package/test/config.test.ts +8 -0
- package/test/executor.test.ts +37 -0
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +45 -2
- package/test/install-json.test.mjs +21 -0
- package/test/plugin-broker.test.mjs +67 -0
- package/test/plugin-manager.test.mjs +44 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduler-host.test.ts +7 -0
- package/test/scheduler-relay.test.ts +38 -3
- package/test/scheduler.test.ts +1 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +100 -11
package/test/ai.test.ts
CHANGED
|
@@ -632,3 +632,43 @@ test('private channel model choices do not consume owner preset slots', async ()
|
|
|
632
632
|
assert.deepEqual((await store.applicationSession(scope))?.preset, readback.preset)
|
|
633
633
|
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
634
634
|
})
|
|
635
|
+
|
|
636
|
+
test('detected presets do not count against the saved AI cap', async () => {
|
|
637
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-cap-'))
|
|
638
|
+
try {
|
|
639
|
+
const store = new ControlStore(dir, 1000)
|
|
640
|
+
const initial = initialPreset('codex')
|
|
641
|
+
await store.captureChoice(initial)
|
|
642
|
+
for (let n = 1; n < 11; n++) await store.savePreset({...initial, id:`saved-${n}`, name:`Saved ${n}`})
|
|
643
|
+
await store.syncClientPresets(initial, ['claude', 'grok'].map(cli => ({id:`detected_${cli}`, cli, name:`${cli} · client default`})))
|
|
644
|
+
assert.equal((await store.status()).ai!.presets.length, 13)
|
|
645
|
+
const next = {id:'one-more', name:'One more', cli:'claude', model:'sonnet', effort:'medium'}
|
|
646
|
+
await store.savePreset(next)
|
|
647
|
+
await assert.rejects(store.savePreset({...next, id:'too-many', name:'Too many'}), /at most 12/)
|
|
648
|
+
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
test('Claude catalog projects only aliases and efforts from the installed native help', async () => {
|
|
652
|
+
const help = `Options:
|
|
653
|
+
--effort <level> Effort level for the current session
|
|
654
|
+
(low, medium, high, xhigh, max)
|
|
655
|
+
--model <model> Model for the current session. Provide
|
|
656
|
+
an alias for the latest model (e.g.
|
|
657
|
+
'fable', 'opus', or 'sonnet') or a
|
|
658
|
+
model's full name.
|
|
659
|
+
-n, --name <name> Set a display name ('ignored')
|
|
660
|
+
`
|
|
661
|
+
const calls: string[][] = []
|
|
662
|
+
const models = await readModels('/nonexistent', async cli => cli === 'claude', undefined, undefined, undefined, undefined, undefined, undefined,
|
|
663
|
+
async (args) => { calls.push(args); return help })
|
|
664
|
+
assert.deepEqual(calls, [['--help']])
|
|
665
|
+
assert.deepEqual(models, [
|
|
666
|
+
{ cli: 'claude', name: 'claude · client default', efforts: [] },
|
|
667
|
+
...['fable', 'opus', 'sonnet'].map(model => ({ cli: 'claude', model, name: `claude · ${model}`, efforts: ['low', 'medium', 'high', 'xhigh', 'max'] })),
|
|
668
|
+
])
|
|
669
|
+
for (const unavailable of [async () => { throw new Error('not runnable') }, async () => 'Usage: claude'])
|
|
670
|
+
assert.deepEqual(await readModels('/nonexistent', async cli => cli === 'claude', undefined, undefined, undefined, undefined, undefined, undefined, unavailable),
|
|
671
|
+
[{ cli: 'claude', name: 'claude · client default', efforts: [] }])
|
|
672
|
+
await validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus', effort: 'xhigh' }, models, async () => true)
|
|
673
|
+
await assert.rejects(validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus-guess' }, models, async () => true), /not in the installed client catalog/)
|
|
674
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import { request as httpRequest } from 'node:http'
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
|
-
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
|
|
4
|
+
import { mkdtemp, rm, writeFile, readFile, chown, stat } from 'node:fs/promises'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { tmpdir } from 'node:os'
|
|
7
7
|
import { spawnSync } from 'node:child_process'
|
|
@@ -435,3 +435,18 @@ test('shareTelegram bindings list Telegram intake and admit one run with context
|
|
|
435
435
|
await runs.patch(finished.id,{status:'completed'})
|
|
436
436
|
assert.equal((await attach(finished.id,sharedToken,{context:{}})).status,409)
|
|
437
437
|
})
|
|
438
|
+
|
|
439
|
+
test('administrator task grant replacement preserves the runtime file owner', {skip:process.getuid?.()!==0},async()=>{
|
|
440
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-app-grant-owner-'))
|
|
441
|
+
try {
|
|
442
|
+
const who=await owner(dir),bindings=new ApplicationBindings(dir)
|
|
443
|
+
await bindings.register('voice',token(),who)
|
|
444
|
+
const file=join(dir,'application-bindings.json')
|
|
445
|
+
await chown(file,20000,20000)
|
|
446
|
+
await bindings.taskLaunch('voice',{command:'voice',args:['launch','--task'],tasks:['task_'+'a'.repeat(32)]})
|
|
447
|
+
const saved=await stat(file)
|
|
448
|
+
assert.equal(saved.uid,20000);assert.equal(saved.gid,20000);assert.equal(saved.mode&0o777,0o600)
|
|
449
|
+
await bindings.taskLaunch('voice',null)
|
|
450
|
+
const disabled=await stat(file);assert.equal(disabled.uid,20000);assert.equal(disabled.gid,20000)
|
|
451
|
+
}finally{await rm(dir,{recursive:true,force:true})}
|
|
452
|
+
})
|
package/test/audio.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process'
|
|
1
2
|
import test from 'node:test'
|
|
2
3
|
import assert from 'node:assert/strict'
|
|
3
4
|
import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech, SpeechCreditsDepletedError } from '../src/audio.js'
|
|
@@ -91,3 +92,21 @@ test('synthesizeSpeech rejects empty text', async () => {
|
|
|
91
92
|
/Cannot synthesize speech for empty text/,
|
|
92
93
|
)
|
|
93
94
|
})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
test('OpenRouter uses the existing renderer and encoder for real Ogg Opus and WAV without provider fallback', async t => {
|
|
98
|
+
const mp3=execFileSync('ffmpeg',['-f','s16le','-ar','24000','-ac','1','-i','pipe:0','-f','mp3','pipe:1'],{input:Buffer.alloc(48000),stdio:['pipe','pipe','ignore']})
|
|
99
|
+
t.mock.method(globalThis,'fetch',async (url:string,options:RequestInit) => {
|
|
100
|
+
assert.equal(url,'https://openrouter.ai/api/v1/audio/speech')
|
|
101
|
+
assert.equal(new Headers(options.headers).get('authorization'),'Bearer fixture-key')
|
|
102
|
+
assert.deepEqual(JSON.parse(String(options.body)),{model:'fish-audio/s2.1-pro-free:free',input:'Fixture',response_format:'mp3'})
|
|
103
|
+
return new Response(mp3,{headers:{'content-type':'audio/mpeg'}})
|
|
104
|
+
})
|
|
105
|
+
const options={speechProvider:'openrouter' as const,openrouterApiKey:'fixture-key',geminiApiKey:'unused'}
|
|
106
|
+
const ogg=await synthesizeSpeech('Fixture',options)
|
|
107
|
+
assert.equal(ogg.mimeType,'audio/ogg');assert.equal(ogg.buffer.subarray(0,4).toString(),'OggS');assert.ok(ogg.buffer.includes(Buffer.from('OpusHead')))
|
|
108
|
+
const wav=await synthesizeSpeech('Fixture',{...options,format:'wav'})
|
|
109
|
+
assert.equal(wav.buffer.subarray(0,4).toString(),'RIFF');assert.equal(wav.buffer.readUInt32LE(24),24000)
|
|
110
|
+
t.mock.method(globalThis,'fetch',async () => new Response('private provider detail',{status:429}))
|
|
111
|
+
await assert.rejects(synthesizeSpeech('Fixture',options), /error 429/)
|
|
112
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { startExecutorJob } from '../src/executor.js'
|
|
10
|
+
import { ControlStore } from '../src/control-state.js'
|
|
11
|
+
import { RunStore } from '../src/runs.js'
|
|
12
|
+
import { serveHostExecutor } from '../src/host-executor.js'
|
|
13
|
+
import { authProfileStatus, initialPreset, readModels, validateSelection, type AiPreset } from '../src/ai.js'
|
|
14
|
+
|
|
15
|
+
const restoreEnv = (keys: string[]) => {
|
|
16
|
+
const prior = Object.fromEntries(keys.map(key => [key, process.env[key]]))
|
|
17
|
+
return () => { for (const [key, value] of Object.entries(prior)) value === undefined ? delete process.env[key] : process.env[key] = value }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('each Claude and Codex profile runs only with its own home and credentials', {skip:process.platform==='win32'}, async t => {
|
|
21
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-profiles-'))
|
|
22
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
23
|
+
t.after(restoreEnv(['PATH', 'HOME', 'EZ_ISOLATION', 'EZ_EXECUTOR_TRANSPORT']))
|
|
24
|
+
const bin = path.join(root, 'bin'), workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control')
|
|
25
|
+
await mkdir(bin); await mkdir(workspace)
|
|
26
|
+
const observed = path.join(root, 'observed.json')
|
|
27
|
+
for (const cli of ['claude', 'codex']) await writeFile(path.join(bin, cli), `#!${process.execPath}
|
|
28
|
+
const fs=require('fs'),p=require('path');let auth=null
|
|
29
|
+
try{auth=fs.lstatSync(p.join(process.env.CODEX_HOME,'auth.json')).isSymbolicLink()?'link:'+fs.readlinkSync(p.join(process.env.CODEX_HOME,'auth.json')):'file'}catch{}
|
|
30
|
+
fs.writeFileSync(${JSON.stringify(observed)},JSON.stringify({config:process.env.CLAUDE_CONFIG_DIR,storage:process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,token:process.env.CLAUDE_CODE_OAUTH_TOKEN,codexHome:process.env.CODEX_HOME,auth}))`, { mode: 0o700 })
|
|
31
|
+
process.env.PATH = bin + path.delimiter + process.env.PATH
|
|
32
|
+
process.env.HOME = root
|
|
33
|
+
delete process.env.EZ_ISOLATION; delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
34
|
+
const tokens = { work: 'sk-ant-oat01-work_profile_token', personal: 'sk-ant-oat01-personal_profile_token' }
|
|
35
|
+
for (const [name, token] of Object.entries(tokens)) {
|
|
36
|
+
await mkdir(path.join(controlDir, 'cli', 'profiles', name, 'claude'), { recursive: true })
|
|
37
|
+
await writeFile(path.join(controlDir, 'cli', 'profiles', name, 'claude', 'oauth-token'), token, { mode: 0o600 })
|
|
38
|
+
await mkdir(path.join(controlDir, 'cli', 'profiles', name, 'codex'), { recursive: true })
|
|
39
|
+
await writeFile(path.join(controlDir, 'cli', 'profiles', name, 'codex', 'auth.json'), '{}', { mode: 0o600 })
|
|
40
|
+
}
|
|
41
|
+
let n = 0
|
|
42
|
+
const run = async (cli: string, authProfile?: string) => {
|
|
43
|
+
const runId = `r_profile_${n++}`
|
|
44
|
+
await ownerRun(controlDir, runId)
|
|
45
|
+
await rm(observed, { force: true })
|
|
46
|
+
const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli, authProfile, runId, timeoutMs: 5000, repairEnabled: false })
|
|
47
|
+
assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
|
|
48
|
+
return JSON.parse(await readFile(observed, 'utf8'))
|
|
49
|
+
}
|
|
50
|
+
// Default bindings are unchanged: shared host login.
|
|
51
|
+
const claudeDefault = await run('claude')
|
|
52
|
+
assert.equal(claudeDefault.config, path.join(controlDir, 'cli', 'claude')); assert.equal(claudeDefault.storage, ''); assert.equal(claudeDefault.token, undefined)
|
|
53
|
+
const codexDefault = await run('codex')
|
|
54
|
+
assert.equal(codexDefault.codexHome, path.join(controlDir, 'cli', 'codex')); assert.equal(codexDefault.auth, `link:${path.join(root, '.codex', 'auth.json')}`)
|
|
55
|
+
// Named profiles use only their own store: no shared secure storage, no host link.
|
|
56
|
+
for (const [name, token] of Object.entries(tokens)) {
|
|
57
|
+
const claude = await run('claude', name)
|
|
58
|
+
assert.deepEqual(claude, { config: path.join(controlDir, 'cli', 'profiles', name, 'claude'), token, auth: null })
|
|
59
|
+
const codex = await run('codex', name)
|
|
60
|
+
assert.deepEqual(codex, { codexHome: path.join(controlDir, 'cli', 'profiles', name, 'codex'), auth: 'file' })
|
|
61
|
+
}
|
|
62
|
+
// Unprovisioned or unsupported profiles fail closed before any process starts.
|
|
63
|
+
for (const [cli, authProfile, error] of [['claude', 'missing', /not provisioned/], ['codex', 'missing', /not provisioned/], ['opencode', 'work', /Invalid auth profile/], ['claude', '../work', /Invalid auth profile/]] as const) {
|
|
64
|
+
await ownerRun(controlDir, `r_reject_${n}`); await rm(observed, { force: true })
|
|
65
|
+
await assert.rejects(startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli, authProfile, runId: `r_reject_${n++}`, timeoutMs: 5000, repairEnabled: false }), error)
|
|
66
|
+
await assert.rejects(stat(observed))
|
|
67
|
+
}
|
|
68
|
+
assert.equal((await lstat(path.join(controlDir, 'cli', 'profiles'))).isDirectory(), true)
|
|
69
|
+
await assert.rejects(stat(path.join(controlDir, 'cli', 'profiles', 'missing')))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('a conversation stays bound to its auth profile and never resumes under another', async () => {
|
|
73
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-sessions-'))
|
|
74
|
+
try {
|
|
75
|
+
const store = new ControlStore(root, 900000)
|
|
76
|
+
await store.aiState(initialPreset('claude'))
|
|
77
|
+
const work: AiPreset = { id: 'work', name: 'Claude work', cli: 'claude', authProfile: 'work', model: 'opus' }
|
|
78
|
+
const personal: AiPreset = { id: 'personal', name: 'Claude personal', cli: 'claude', authProfile: 'personal', model: 'opus' }
|
|
79
|
+
for (const preset of [work, personal]) await store.savePreset(preset)
|
|
80
|
+
const first = await store.captureChoice(initialPreset('claude'))
|
|
81
|
+
await store.markSessionStarted(first.sessionId)
|
|
82
|
+
// A profile change is a new conversation, never an in-place settings change.
|
|
83
|
+
assert.equal(await store.selectPreset('work', first.sessionId), false)
|
|
84
|
+
assert.equal(await store.selectPreset('work', first.sessionId, true), true)
|
|
85
|
+
const working = await store.captureChoice(initialPreset('claude'))
|
|
86
|
+
assert.notEqual(working.sessionId, first.sessionId)
|
|
87
|
+
assert.equal((await store.executionSession(working)).authProfile, 'work')
|
|
88
|
+
await store.markSessionStarted(working.sessionId)
|
|
89
|
+
// A choice that names another account for this session is rejected at launch.
|
|
90
|
+
await assert.rejects(store.executionSession({ sessionId: working.sessionId, preset: personal }), /matching CLI binding/)
|
|
91
|
+
await assert.rejects(store.executionSession({ sessionId: first.sessionId, preset: work }), /matching CLI binding/)
|
|
92
|
+
// Switching back restores that conversation's own account.
|
|
93
|
+
assert.equal(await store.selectPreset('personal', working.sessionId, true), true)
|
|
94
|
+
const restored = await store.switchSession(working.sessionId)
|
|
95
|
+
assert.equal(restored.authProfile, 'work')
|
|
96
|
+
assert.equal((await store.captureChoice(initialPreset('claude'))).preset.authProfile, 'work')
|
|
97
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('catalog offers provisioned profiles only; host refuses a request that changes the captured profile', {skip:process.platform==='win32'}, async t => {
|
|
101
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-host-'))
|
|
102
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
103
|
+
t.after(restoreEnv(['PATH']))
|
|
104
|
+
const workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control'), bin = path.join(root, 'bin'), directory = path.join(controlDir, 'host-executor')
|
|
105
|
+
await mkdir(workspace, { recursive: true }); await mkdir(bin); await mkdir(directory, { recursive: true })
|
|
106
|
+
for (const cli of ['codex', 'claude']) await writeFile(path.join(bin, cli), '#!/bin/sh\nexit 0\n', { mode: 0o700 })
|
|
107
|
+
await mkdir(path.join(controlDir, 'cli', 'profiles', 'work', 'claude'), { recursive: true })
|
|
108
|
+
process.env.PATH = bin + path.delimiter + process.env.PATH
|
|
109
|
+
const catalog = await readModels(root, undefined, path.join(controlDir, 'cli', 'codex'), undefined, undefined, undefined, controlDir)
|
|
110
|
+
assert.deepEqual(catalog.filter(model => model.authProfile), [{ cli: 'claude', name: 'work · claude · client default', efforts: [], authProfile: 'work' }])
|
|
111
|
+
await validateSelection({ id: 'w', name: 'w', cli: 'claude', authProfile: 'work' }, catalog)
|
|
112
|
+
await assert.rejects(validateSelection({ id: 'p', name: 'p', cli: 'claude', authProfile: 'personal' }, catalog), /not provisioned/)
|
|
113
|
+
|
|
114
|
+
const runs = new RunStore(controlDir)
|
|
115
|
+
await ownerRun(controlDir, 'r_seed')
|
|
116
|
+
const execution = { sessionId: '00000000-0000-4000-8000-000000000001', preset: { id: 'work', name: 'work', cli: 'claude', authProfile: 'work' } }
|
|
117
|
+
for (const id of ['r_wrong', 'r_right', 'r_dropped']) {
|
|
118
|
+
await runs.create({ id, chatId: 101, telegramUserId: 101, texts: ['hello'], execution })
|
|
119
|
+
await runs.patch(id, { status: 'running' })
|
|
120
|
+
}
|
|
121
|
+
const abort = new AbortController(), launched: string[] = []
|
|
122
|
+
const server = serveHostExecutor({ cli: 'codex', agents: [{ name: 'test', workspace, controlDir, binDir: bin }] }, abort.signal, async (_texts, options) => {
|
|
123
|
+
launched.push(`${options.runId}:${options.authProfile}`)
|
|
124
|
+
return { child: spawn(process.execPath, ['-e', 'process.exit(0)']), cleanup: async () => {}, stdout: '' }
|
|
125
|
+
}, async () => catalog)
|
|
126
|
+
try {
|
|
127
|
+
const exit = async (id: string, options: Record<string, unknown>) => {
|
|
128
|
+
await writeFile(path.join(directory, `${id}.request.json`), JSON.stringify({ texts: ['hello'], options }))
|
|
129
|
+
for (let n = 0; n < 200; n++) {
|
|
130
|
+
const events = await readFile(path.join(directory, `${id}.events`), 'utf8').catch(() => '')
|
|
131
|
+
const code = events.match(/"stream":"exit","code":(\d+)/)?.[1]
|
|
132
|
+
if (code !== undefined) return { code: Number(code), events }
|
|
133
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
134
|
+
}
|
|
135
|
+
throw new Error('host request did not finish')
|
|
136
|
+
}
|
|
137
|
+
for (const [id, options] of [['r_wrong', { authProfile: 'personal' }], ['r_dropped', {}]] as const) {
|
|
138
|
+
const result = await exit(id, { cli: 'claude', sessionId: execution.sessionId, ...options })
|
|
139
|
+
assert.equal(result.code, 1); assert.match(result.events, /does not match its captured AI selection/)
|
|
140
|
+
}
|
|
141
|
+
assert.equal((await exit('r_right', { cli: 'claude', authProfile: 'work', sessionId: execution.sessionId })).code, 0)
|
|
142
|
+
assert.deepEqual(launched, ['r_right:work'])
|
|
143
|
+
} finally { abort.abort(); await server }
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('profile setup and diagnostics never print credentials or account identity', {skip:process.platform==='win32'}, async () => {
|
|
147
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-cli-'))
|
|
148
|
+
try {
|
|
149
|
+
const controlDir = path.join(root, 'control'), bin = path.join(root, 'bin')
|
|
150
|
+
await mkdir(bin)
|
|
151
|
+
await writeFile(path.join(bin, 'claude'), `#!/bin/sh
|
|
152
|
+
if [ "$1" = auth ]; then
|
|
153
|
+
if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then echo '{"loggedIn":true,"authMethod":"oauth_token","email":"owner@example.com","orgId":"org-secret"}'; exit 0; fi
|
|
154
|
+
echo '{"loggedIn":false,"authMethod":"none"}'; exit 1
|
|
155
|
+
fi
|
|
156
|
+
exit 0
|
|
157
|
+
`, { mode: 0o700 })
|
|
158
|
+
await writeFile(path.join(bin, 'codex'), '#!/bin/sh\n[ -f "$CODEX_HOME/auth.json" ] && { echo "Logged in using ChatGPT (owner@example.com)" >&2; exit 0; }\necho "Not logged in"; exit 1\n', { mode: 0o700 })
|
|
159
|
+
const store = new ControlStore(controlDir, 900000); await store.aiState(initialPreset('claude'))
|
|
160
|
+
const env = { ...process.env, HOME: root, PATH: bin + path.delimiter + process.env.PATH, EZ_CONTROL_DIR: controlDir }
|
|
161
|
+
const ai = (...args: string[]) => spawnSync(process.execPath, [fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs', import.meta.url)), ...args], { env, encoding: 'utf8' })
|
|
162
|
+
for (const [cli, name] of [['claude', 'work'], ['claude', 'personal'], ['codex', 'work']]) {
|
|
163
|
+
const added = ai('profile', 'add', '--cli', cli, '--auth-profile', name)
|
|
164
|
+
assert.equal(added.status, 0, added.stderr)
|
|
165
|
+
const home = path.join(controlDir, 'cli', 'profiles', name, cli)
|
|
166
|
+
assert.equal(JSON.parse(added.stdout).home, home)
|
|
167
|
+
assert.equal((await stat(home)).mode & 0o777, 0o700)
|
|
168
|
+
}
|
|
169
|
+
assert.notEqual(ai('profile', 'add', '--cli', 'opencode', '--auth-profile', 'work').status, 0)
|
|
170
|
+
await writeFile(path.join(controlDir, 'cli', 'profiles', 'work', 'claude', 'oauth-token'), 'sk-ant-oat01-never_printed_token', { mode: 0o600 })
|
|
171
|
+
await writeFile(path.join(controlDir, 'cli', 'profiles', 'work', 'codex', 'auth.json'), '{"tokens":"never-printed"}', { mode: 0o600 })
|
|
172
|
+
const profiles = ai('profiles')
|
|
173
|
+
assert.equal(profiles.status, 0, profiles.stderr)
|
|
174
|
+
for (const secret of ['never_printed', 'never-printed', 'owner@example.com', 'org-secret']) assert.equal(profiles.stdout.includes(secret), false)
|
|
175
|
+
const byKey = Object.fromEntries((JSON.parse(profiles.stdout) as Awaited<ReturnType<typeof authProfileStatus>>).map(item => [`${item.cli}@${item.authProfile}`, item]))
|
|
176
|
+
assert.deepEqual(byKey['claude@work'], { cli: 'claude', authProfile: 'work', provisioned: true, sharesHostLogin: false, credentialFiles: ['oauth-token'], loggedIn: true, method: 'oauth_token' })
|
|
177
|
+
assert.equal(byKey['claude@personal'].loggedIn, false)
|
|
178
|
+
assert.deepEqual(byKey['codex@work'], { cli: 'codex', authProfile: 'work', provisioned: true, sharesHostLogin: false, credentialFiles: ['auth.json'], loggedIn: true, method: 'chatgpt' })
|
|
179
|
+
assert.equal(byKey['claude@null'].sharesHostLogin, true)
|
|
180
|
+
// Selecting a provisioned profile binds a fresh conversation to it.
|
|
181
|
+
const selected = ai('select', '--cli', 'claude', '--auth-profile', 'work')
|
|
182
|
+
assert.equal(selected.status, 0, selected.stderr)
|
|
183
|
+
assert.equal((await store.status()).activeSession?.authProfile, 'work')
|
|
184
|
+
assert.notEqual(ai('select', '--cli', 'claude', '--auth-profile', 'unknown').status, 0)
|
|
185
|
+
assert.equal((await store.status()).activeSession?.authProfile, 'work')
|
|
186
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
187
|
+
})
|
package/test/config.test.ts
CHANGED
|
@@ -84,3 +84,11 @@ test('Telegram transport is inferred from a valid token; legacy flag contradicti
|
|
|
84
84
|
assert.equal(loadConfig({ EZ_TELEGRAM_ENABLED: 'false', EZ_APPLICATION_PORT: '8110' }).telegramEnabled, false)
|
|
85
85
|
assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'true' }).telegramEnabled, true)
|
|
86
86
|
})
|
|
87
|
+
|
|
88
|
+
test('scheduled concurrency defaults to six and accepts only positive integers',()=>{
|
|
89
|
+
const env={EZ_APPLICATION_PORT:'8110'}
|
|
90
|
+
assert.equal(loadConfig(env).scheduledConcurrency,6)
|
|
91
|
+
assert.equal(loadConfig({...env,EZ_SCHEDULED_CONCURRENCY:'2'}).scheduledConcurrency,2)
|
|
92
|
+
for(const value of ['0','-1','1.5','bad','9007199254740992'])
|
|
93
|
+
assert.throws(()=>loadConfig({...env,EZ_SCHEDULED_CONCURRENCY:value}),/EZ_SCHEDULED_CONCURRENCY must be a positive integer/)
|
|
94
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -372,3 +372,40 @@ test('external local owner chat preserves literal input and admission rejects fo
|
|
|
372
372
|
if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
|
|
373
373
|
}
|
|
374
374
|
})
|
|
375
|
+
|
|
376
|
+
test('Claude runs with agent-bound configuration, shared login and the agent directories', {skip:process.platform==='win32'}, async t => {
|
|
377
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-claude-home-'))
|
|
378
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
379
|
+
const bin = path.join(root, 'bin'), workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control'), shared = path.join(root, 'shared')
|
|
380
|
+
await mkdir(bin); await mkdir(workspace)
|
|
381
|
+
await writeFile(path.join(bin, 'claude'), `#!${process.execPath}\nrequire('fs').writeFileSync('observed.json',JSON.stringify({args:process.argv.slice(2),config:process.env.CLAUDE_CONFIG_DIR,storage:process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,token:process.env.CLAUDE_CODE_OAUTH_TOKEN,prompt:require('fs').readFileSync(0,'utf8')}))`, { mode: 0o700 })
|
|
382
|
+
const previous = { PATH: process.env.PATH, EZ_ISOLATION: process.env.EZ_ISOLATION, EZ_EXECUTOR_TRANSPORT: process.env.EZ_EXECUTOR_TRANSPORT }
|
|
383
|
+
t.after(() => { for (const [key, value] of Object.entries(previous)) value === undefined ? delete process.env[key] : process.env[key] = value })
|
|
384
|
+
process.env.PATH = bin + path.delimiter + process.env.PATH
|
|
385
|
+
delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
386
|
+
for (const isolation of [undefined, 'isolated']) {
|
|
387
|
+
if (isolation) process.env.EZ_ISOLATION = isolation; else delete process.env.EZ_ISOLATION
|
|
388
|
+
await ownerRun(controlDir, `r_claude_${isolation ?? 'host'}`)
|
|
389
|
+
const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId: `r_claude_${isolation ?? 'host'}`, timeoutMs: 5000,
|
|
390
|
+
sessionId: '00000000-0000-4000-8000-000000000000', sharedWorkspace: shared, model: 'opus', effort: 'high', repairEnabled: false })
|
|
391
|
+
assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
|
|
392
|
+
const observed = JSON.parse(await readFile(path.join(workspace, 'observed.json'), 'utf8'))
|
|
393
|
+
assert.equal(observed.config, path.join(controlDir, 'cli', 'claude'))
|
|
394
|
+
assert.equal(observed.storage, isolation ? undefined : '')
|
|
395
|
+
assert.equal(observed.prompt, 'hello')
|
|
396
|
+
for (const directory of [controlDir, shared]) assert.equal(observed.args[observed.args.indexOf(directory) - 1], '--add-dir')
|
|
397
|
+
assert.deepEqual(observed.args.slice(-4), ['--model', 'opus', '--effort', 'high'])
|
|
398
|
+
assert.equal(observed.token, undefined)
|
|
399
|
+
}
|
|
400
|
+
const run = async (runId: string) => {
|
|
401
|
+
await ownerRun(controlDir, runId)
|
|
402
|
+
const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId, timeoutMs: 5000, repairEnabled: false })
|
|
403
|
+
assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
|
|
404
|
+
return JSON.parse(await readFile(path.join(workspace, 'observed.json'), 'utf8'))
|
|
405
|
+
}
|
|
406
|
+
await writeFile(path.join(controlDir, 'cli', 'claude', 'oauth-token'), 'sk-ant-oat01-fixture_token-value\n', { mode: 0o600 })
|
|
407
|
+
assert.equal((await run('r_claude_token')).token, 'sk-ant-oat01-fixture_token-value')
|
|
408
|
+
await writeFile(path.join(controlDir, 'cli', 'claude', 'oauth-token'), 'bad token\n--flag', { mode: 0o600 })
|
|
409
|
+
await ownerRun(controlDir, 'r_claude_bad_token')
|
|
410
|
+
await assert.rejects(startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId: 'r_claude_bad_token', timeoutMs: 5000, repairEnabled: false }), /Invalid Claude token binding/)
|
|
411
|
+
})
|
package/test/files.test.ts
CHANGED
|
@@ -48,3 +48,47 @@ test('stages file into control attachments, never the workspace', async () => {
|
|
|
48
48
|
await rm(tmp, { recursive: true, force: true })
|
|
49
49
|
}
|
|
50
50
|
})
|
|
51
|
+
|
|
52
|
+
test('shared incoming document reader extracts PDF text and removes its staged file', async t => {
|
|
53
|
+
const {readChatAttachment}=await import('../src/files.js')
|
|
54
|
+
const {execFile}=await import('node:child_process'), {promisify}=await import('node:util')
|
|
55
|
+
try { await promisify(execFile)('pdftotext',['-v']) } catch { t.skip('Poppler is exercised in the Docker artifact test');return }
|
|
56
|
+
const dir=await mkdtemp('/tmp/ez-pdf-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
57
|
+
const stream='BT /F1 12 Tf 20 80 Td (Shop opens at 7am.) Tj ET'
|
|
58
|
+
const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R] /Count 1 >>','<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>','<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`]
|
|
59
|
+
let pdf='%PDF-1.4\n';const offsets=[0]
|
|
60
|
+
for(const [i,value] of objects.entries()){offsets.push(Buffer.byteLength(pdf));pdf+=`${i+1} 0 obj\n${value}\nendobj\n`}
|
|
61
|
+
const xref=Buffer.byteLength(pdf);pdf+='xref\n0 6\n0000000000 65535 f \n'+offsets.slice(1).map(n=>String(n).padStart(10,'0')+' 00000 n \n').join('')+`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
|
|
62
|
+
const result=await readChatAttachment(dir,'shop.pdf',Buffer.from(pdf));assert.match(result.text ?? '',/Shop opens at 7am\./);assert.equal(result.type,'pdf')
|
|
63
|
+
const {readdir}=await import('node:fs/promises');assert.deepEqual(await readdir(path.join(dir,'attachments')),[])
|
|
64
|
+
await assert.rejects(readChatAttachment(dir,'invalid.pdf',Buffer.from('%PDF-invalid')))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('shared image read returns original pixels with native MIME and no staged residue',async t=>{
|
|
68
|
+
const {readChatAttachment}=await import('../src/files.js'),{readdir}=await import('node:fs/promises')
|
|
69
|
+
const dir=await mkdtemp('/tmp/ez-image-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
70
|
+
const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')
|
|
71
|
+
const result=await readChatAttachment(dir,'photo.png',png)
|
|
72
|
+
assert.equal(result.image?.mimeType,'image/png');assert.equal(result.image?.data,png.toString('base64'));assert.equal(result.text,undefined)
|
|
73
|
+
assert.deepEqual(await readdir(path.join(dir,'attachments')),[])
|
|
74
|
+
await assert.rejects(readChatAttachment(dir,'photo.png',Buffer.from('Spoofed image bytes')))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('video read uses bounded native frames and removes temporary decoder files',async t=>{
|
|
78
|
+
const {readChatAttachment}=await import('../src/files.js'),{readdir}=await import('node:fs/promises'),{execFile}=await import('node:child_process'),{promisify}=await import('node:util')
|
|
79
|
+
const execute=promisify(execFile)
|
|
80
|
+
try {await execute('ffmpeg',['-version'])}catch{t.skip('FFmpeg is exercised in the Docker artifact test');return}
|
|
81
|
+
const dir=await mkdtemp('/tmp/ez-video-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
82
|
+
const video=path.join(dir,'fixture.mp4')
|
|
83
|
+
await execute('ffmpeg',['-v','error','-f','lavfi','-i','color=c=blue:s=64x64:r=8','-f','lavfi','-i','sine=frequency=1000:sample_rate=24000','-t','1','-c:v','mpeg4','-c:a','aac',video])
|
|
84
|
+
const result=await readChatAttachment(dir,'video.mp4',await readFile(video))
|
|
85
|
+
assert.equal(result.type,'mp4');assert.ok((result.images?.length??0)>0);assert.ok((result.images?.length??0)<=8)
|
|
86
|
+
assert.ok(result.images?.every(image=>image.mimeType==='image/jpeg'));assert.ok(result.durationSeconds!<=120)
|
|
87
|
+
assert.deepEqual(await readdir(path.join(dir,'attachments')),[]);assert.ok(!(await readdir(dir)).some(name=>name.startsWith('video-')))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('HEIF stills and audio-only M4A are not treated as MP4 video', () => {
|
|
91
|
+
const box = (brand: string) => Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from(`ftyp${brand}`), Buffer.alloc(12)])
|
|
92
|
+
for (const brand of ['heic', 'avif', 'mif1', 'M4A ']) assert.equal(detectFileType(box(brand)), 'unknown')
|
|
93
|
+
for (const brand of ['isom', 'mp42']) assert.equal(detectFileType(box(brand)), 'mp4')
|
|
94
|
+
})
|
|
@@ -13,6 +13,8 @@ import { RunStore } from '../src/runs.js'
|
|
|
13
13
|
import { packageVersion } from '../src/version.js'
|
|
14
14
|
import { executionDefaults } from '../src/model-policy.js'
|
|
15
15
|
import { workspaceLease } from '../src/plugins/workspace-lease.mjs'
|
|
16
|
+
import { ControlStore } from '../src/control-state.js'
|
|
17
|
+
import { Scripts } from '../src/scripts.js'
|
|
16
18
|
|
|
17
19
|
test('host restart replaces a lock whose PID was reused by another process',async()=>{
|
|
18
20
|
const root=await mkdtemp(path.join(tmpdir(),'ez-host-reused-pid-'))
|
|
@@ -285,10 +287,10 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
285
287
|
return output.trim().split('\n').map(line=>JSON.parse(line))
|
|
286
288
|
}
|
|
287
289
|
// Chat completes while this agent's scheduled turn stays active. A second
|
|
288
|
-
// scheduled turn
|
|
290
|
+
// scheduled turn also completes, and the held job keeps the lifecycle lease.
|
|
289
291
|
await completed(otherDirectory,'r_other_shared')
|
|
290
292
|
const chatEvents=await completed(directory,'tg_42')
|
|
291
|
-
|
|
293
|
+
await completed(directory,'r_schedule_queued')
|
|
292
294
|
assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
|
|
293
295
|
await ownerRun(agents[0].controlDir,'tg_43')
|
|
294
296
|
await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
|
|
@@ -525,3 +527,44 @@ test('startup replaces prior catalog after provider policy changes even when dis
|
|
|
525
527
|
assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),[])
|
|
526
528
|
} finally {abort.abort();await server;await rm(root,{recursive:true,force:true})}
|
|
527
529
|
})
|
|
530
|
+
|
|
531
|
+
test('host transport runs a registered script from the run record and refuses changed entry-point bytes', async () => {
|
|
532
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-host-script-'))
|
|
533
|
+
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
|
|
534
|
+
const abort=new AbortController();let server:Promise<void>|undefined
|
|
535
|
+
const oldToken=process.env.TELEGRAM_BOT_TOKEN
|
|
536
|
+
try {
|
|
537
|
+
await mkdir(workspace);await mkdir(directory,{recursive:true})
|
|
538
|
+
await writeFile(path.join(workspace,'guard.mjs'),"console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),due:process.env.EZ_DUE_AT,token:process.env.TELEGRAM_BOT_TOKEN}))\n")
|
|
539
|
+
process.env.TELEGRAM_BOT_TOKEN='must-not-reach-host-script'
|
|
540
|
+
await ownerRun(controlDir,'r_seed')
|
|
541
|
+
const owner=(await new ControlStore(controlDir,900000).status()).owner!
|
|
542
|
+
const registration=await new Scripts(controlDir).save({id:'guard',owner,workspace,entry:'guard.mjs',interpreter:'node',args:['saved'],timeoutSeconds:60},true)
|
|
543
|
+
const runs=new RunStore(controlDir)
|
|
544
|
+
const scripted=async(id:string)=>{
|
|
545
|
+
await runs.create({id,chatId:101,telegramUserId:101,texts:['[schedule]'],script:{id:'guard',args:['extra'],revision:registration.revision,sha256:registration.sha256},
|
|
546
|
+
scheduled:{id:'s_guard',revision:'rev1',dueAt:'2026-10-07T11:15:00.000Z',pairedAt:owner.pairedAt}})
|
|
547
|
+
await runs.patch(id,{status:'running'})
|
|
548
|
+
// The request cannot choose the command, script or workspace.
|
|
549
|
+
await writeFile(path.join(directory,`${id}.request.json`),JSON.stringify({texts:['x'],options:{workspace:'/wrong',cli:'grok',script:{id:'other',args:['injected']},timeoutMs:0}}))
|
|
550
|
+
let events:any[]=[]
|
|
551
|
+
for(let n=0;n<300;n++){try{events=(await readFile(path.join(directory,`${id}.events`),'utf8')).trim().split('\n').map(l=>JSON.parse(l));if(events.some(e=>e.stream==='exit'))break}catch{}await new Promise(r=>setTimeout(r,20))}
|
|
552
|
+
return events
|
|
553
|
+
}
|
|
554
|
+
server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,undefined,async()=>[])
|
|
555
|
+
const events=await scripted('r_schedule_script_ok')
|
|
556
|
+
assert.equal(events.find(e=>e.stream==='exit')?.code,0)
|
|
557
|
+
const result=JSON.parse(events.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
558
|
+
assert.equal(result.cwd,await realpath(workspace));assert.deepEqual(result.args,['saved','extra'])
|
|
559
|
+
assert.equal(result.due,'2026-10-07T11:15:00.000Z');assert.equal(result.token,undefined)
|
|
560
|
+
await writeFile(path.join(workspace,'guard.mjs'),"console.log('changed')\n")
|
|
561
|
+
const refused=await scripted('r_schedule_script_changed')
|
|
562
|
+
assert.equal(refused.find(e=>e.stream==='exit')?.code,1)
|
|
563
|
+
assert.equal(refused.filter(e=>e.stream==='stdout').length,0)
|
|
564
|
+
assert.match(refused.filter(e=>e.stream==='stderr').map(e=>e.text).join(''),/entry point changed since registration/)
|
|
565
|
+
}finally{
|
|
566
|
+
abort.abort();await server
|
|
567
|
+
if(oldToken===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=oldToken
|
|
568
|
+
await rm(root,{recursive:true,force:true})
|
|
569
|
+
}
|
|
570
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { run, latestCodexVersion } from '../src/install-tools.mjs';
|
|
4
|
+
|
|
5
|
+
test('Codex discovery parses stdout and retains failure diagnostics',async()=>{
|
|
6
|
+
const script=`process.stdout.write('"0.160.1"\\n');process.stderr.write('package manager notice\\n');`;
|
|
7
|
+
const invoke=(command,args,options)=>{
|
|
8
|
+
assert.equal(command,'pnpm');
|
|
9
|
+
assert.deepEqual(args,['view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/']);
|
|
10
|
+
assert.equal(options.stdoutOnly,true);
|
|
11
|
+
return run(process.execPath,['-e',script],options);
|
|
12
|
+
};
|
|
13
|
+
assert.equal(await latestCodexVersion(invoke,{command:'pnpm',args:[]}),'0.160.1');
|
|
14
|
+
await assert.rejects(run(process.execPath,['-e',script+'process.exitCode=1'],{stdoutOnly:true}),/package manager notice/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('Codex discovery rejects contaminated stdout without leaking it',async()=>{
|
|
18
|
+
await assert.rejects(latestCodexVersion(async()=> '"0.160.1"\nprivate-output'),{message:'Codex version discovery returned invalid JSON on stdout'});
|
|
19
|
+
for(const value of ['null','{}','["0.160.1"]','"latest"'])
|
|
20
|
+
await assert.rejects(latestCodexVersion(async()=>value),/Invalid latest Codex version/);
|
|
21
|
+
});
|
|
@@ -182,3 +182,70 @@ test('broker rejects resolve and invoke for a revoked run', async t => {
|
|
|
182
182
|
await serving;
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
test('scheduled preflight uses authoritative configuration and rejects writes or stale revisions', async t => {
|
|
187
|
+
const f = await fixture(t);
|
|
188
|
+
const { Scheduler } = await import('../src/scheduler.js');
|
|
189
|
+
const { createLedgerHandler, serveDeliverySocket } = await import('../src/delivery-socket.js');
|
|
190
|
+
const control = new ControlStore(f.controlDir, 1000);
|
|
191
|
+
const owner = (await control.status()).owner;
|
|
192
|
+
const scheduler = new Scheduler(f.controlDir);
|
|
193
|
+
const schedule = await scheduler.save({id:'check', name:'Check', text:'Saved worker', enabled:true, owner,
|
|
194
|
+
execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},
|
|
195
|
+
trigger:{everySeconds:60,start:new Date().toISOString()},preflight:{on:'eligible',checks:[{alias:'sample',args:['queue']}]}});
|
|
196
|
+
const ledger = await serveDeliverySocket(f.controlDir, createLedgerHandler(f.controlDir,{wake:()=>{},status:()=>({polling:false,applicationOnly:true,telegramConfigured:false,version:'fixture'})}));
|
|
197
|
+
t.after(()=>ledger.stop());
|
|
198
|
+
await fs.writeFile(path.join(f.fake,'docker'), `#!${process.execPath}\nprocess.stdout.write(JSON.stringify({schemaVersion:1,eligible:true,count:1,fingerprint:'a'.repeat(64),observedAt:new Date().toISOString()}));\n`, {mode:0o700});
|
|
199
|
+
const previousPath=process.env.PATH;process.env.PATH=f.fake+path.delimiter+previousPath;
|
|
200
|
+
const binding=await loadPluginBrokerBinding({home:f.home,workspace:f.workspace,controlDir:f.controlDir,socket:f.socket,hostConfig:f.hostConfig,timeoutMs:10000});
|
|
201
|
+
const abort=new AbortController(),serving=servePluginBroker(binding,abort.signal);
|
|
202
|
+
try {
|
|
203
|
+
await waitForSocket(f.socket);
|
|
204
|
+
const payload={version:1,id:randomUUID(),operation:'preflight',scheduleId:schedule.id,revision:schedule.revision};
|
|
205
|
+
assert.equal((await request(f.socket,{...payload,args:['write']})).ok,false);
|
|
206
|
+
assert.equal((await request(f.socket,{...payload,revision:randomUUID()})).ok,false);
|
|
207
|
+
assert.equal((await request(f.socket,payload)).ok,false); // undeclared read-only command
|
|
208
|
+
const file=path.join(f.home,'registry.json'),registry=JSON.parse(await fs.readFile(file,'utf8'));
|
|
209
|
+
registry.plugins.sample.manifest.commands.sample.exposure={receivesExternalContent:true,changesRecords:false,sendsExternally:false,requiresReview:false};
|
|
210
|
+
await fs.writeFile(file,JSON.stringify(registry));
|
|
211
|
+
const accepted=await request(f.socket,payload);assert.equal(accepted.ok,true);assert.equal(accepted.result.eligible,true);
|
|
212
|
+
await fs.writeFile(path.join(f.fake,'docker'), `#!${process.execPath}\nprocess.stdout.write('SECRET-token-xyz');\n`, {mode:0o700});
|
|
213
|
+
const invalid=await request(f.socket,payload);assert.equal(invalid.ok,false);
|
|
214
|
+
assert.equal(invalid.error,'Scheduled plugin preflight returned invalid output');assert(!JSON.stringify(invalid).includes('SECRET-token-xyz'));
|
|
215
|
+
await scheduler.save({...schedule,enabled:false});
|
|
216
|
+
assert.equal((await request(f.socket,payload)).ok,false);
|
|
217
|
+
} finally {abort.abort();await serving;process.env.PATH=previousPath;}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('task launch broker admits only its fixed application command and writes no bearer into receipts',async t=>{
|
|
221
|
+
const {createServer}=await import('node:http');
|
|
222
|
+
const {Tasks}=await import('../src/tasks.js');const {EventSources}=await import('../src/event-sources.js');
|
|
223
|
+
const {ApplicationBindings}=await import('../src/application-channel.js');const {serveTestLedger}=await import('./helpers/ledger.js');
|
|
224
|
+
const f=await fixture(t), runs=new RunStore(f.controlDir), control=new ControlStore(f.controlDir,900000);
|
|
225
|
+
const sourceSocket=path.join(f.root,'source.sock');const provider=createServer(async(req,res)=>{
|
|
226
|
+
let raw='';for await(const c of req)raw+=c;const v=JSON.parse(raw);
|
|
227
|
+
res.end(JSON.stringify({ok:true,data:v.command==='events-head'?{cursor:0,accountId:'fixture-account',taskProtocol:'message-v1'}:{}}));
|
|
228
|
+
});await new Promise(r=>provider.listen(sourceSocket,r));
|
|
229
|
+
const tasks=new Tasks(f.controlDir),owner=(await control.status()).owner;
|
|
230
|
+
await new EventSources(f.controlDir).register('fixture',sourceSocket,owner);
|
|
231
|
+
const task=await tasks.ownerCall(f.runId,'start',{sourceId:'fixture',conversationId:'discussion-a',purpose:'Synthetic discussion',context:'Only synthetic records',hours:1});
|
|
232
|
+
const taskRun='event_'+hash(task.id);await runs.patch(taskRun,{status:'running'});
|
|
233
|
+
const bindings=new ApplicationBindings(f.controlDir);await bindings.register('browser','z'.repeat(48),owner);
|
|
234
|
+
await bindings.taskLaunch('browser',{command:'sample',args:['launch','--task'],tasks:[task.id]});
|
|
235
|
+
const token='c'.repeat(64),ticketHash=hash(token),grant=await tasks.workerCall(taskRun,'application_launch',{application:'browser',ticketHash});
|
|
236
|
+
const input={taskId:task.id,taskToken:token,expiresAt:grant.expiresAt};
|
|
237
|
+
const ledger=await serveTestLedger(f.controlDir);
|
|
238
|
+
const binding=await loadPluginBrokerBinding({home:f.home,workspace:f.workspace,controlDir:f.controlDir,socket:f.socket,hostConfig:f.hostConfig,timeoutMs:10000});
|
|
239
|
+
const oldPath=process.env.PATH;process.env.PATH=f.fake+path.delimiter+oldPath;
|
|
240
|
+
const abort=new AbortController(),serving=servePluginBroker(binding,abort.signal);
|
|
241
|
+
try{
|
|
242
|
+
await waitForSocket(f.socket);
|
|
243
|
+
const denied=await request(f.socket,{version:1,id:randomUUID(),operation:'resolve',runId:taskRun,alias:'sample'});assert.equal(denied.ok,false);
|
|
244
|
+
const launch=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});
|
|
245
|
+
assert.equal(launch.ok,true,launch.error);assert.equal(launch.stdout,'broker-ok');assert.equal(launch.receipt.status,'completed');
|
|
246
|
+
const receipt=await fs.readFile(launch.receiptPath,'utf8');assert(!receipt.includes(token));assert(!receipt.includes('taskToken'));
|
|
247
|
+
const wrong=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:f.runId,stdin:JSON.stringify(input)});assert.equal(wrong.ok,false);
|
|
248
|
+
await bindings.taskLaunch('browser',null);
|
|
249
|
+
const revoked=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});assert.equal(revoked.ok,false);
|
|
250
|
+
}finally{abort.abort();await serving;process.env.PATH=oldPath;await ledger.stop();provider.closeAllConnections();await new Promise(r=>provider.close(r));}
|
|
251
|
+
});
|
|
@@ -652,3 +652,47 @@ test('writable folders require an explicit per-folder grant and can be revoked',
|
|
|
652
652
|
assert.equal((await compose(config,record)).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
653
653
|
await assert.rejects(f.call('plugins','folder-unbind','sample','--service','sample','--target','/data/files','--writable'),/Supply/);
|
|
654
654
|
});
|
|
655
|
+
|
|
656
|
+
test('command admission waits for a live short lock without removing it',async t=>{
|
|
657
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
658
|
+
let release,started;
|
|
659
|
+
const ready=new Promise(resolve=>started=resolve),hold=new Promise(resolve=>release=resolve);
|
|
660
|
+
const writer=locked(f.home,async()=>{started();await hold;});
|
|
661
|
+
await ready;
|
|
662
|
+
const call=f.call('sample','read');
|
|
663
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
664
|
+
assert.equal(JSON.parse(await fs.readFile(path.join(f.home,'registry.lock'),'utf8')).pid,process.pid);
|
|
665
|
+
release();await writer;
|
|
666
|
+
assert.deepEqual(JSON.parse((await call).stdout),['read']);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test('bounded lock admission times out without stealing an interrupted lock',async t=>{
|
|
670
|
+
const f=await fixture(t),file=path.join(f.home,'registry.lock');
|
|
671
|
+
await fs.mkdir(f.home,{recursive:true});await fs.writeFile(file,'preserved');
|
|
672
|
+
await assert.rejects(locked(f.home,async()=>assert.fail('must not enter'),{waitMs:50}),/Registry busy/);
|
|
673
|
+
assert.equal(await fs.readFile(file,'utf8'),'preserved');
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
test('cancelled plugin upgrade drain preserves live invocation and releases admission lock',async t=>{
|
|
678
|
+
const f=await fixture(t),controller=new AbortController();
|
|
679
|
+
const directory=path.join(f.home,'command-invocations');await fs.mkdir(directory,{recursive:true});
|
|
680
|
+
const lease=path.join(directory,'00000000-0000-4000-8000-000000000000.json');
|
|
681
|
+
await fs.writeFile(lease,JSON.stringify({pid:process.pid,container:'active-call'}));
|
|
682
|
+
let activated=false;
|
|
683
|
+
const drain=locked(f.home,async()=>{activated=true;},{drainInvocations:true,signal:controller.signal});
|
|
684
|
+
for(let n=0;n<100&&!await fs.access(path.join(f.home,'registry.lock')).then(()=>true,()=>false);n++)await new Promise(resolve=>setTimeout(resolve,10));
|
|
685
|
+
controller.abort();await assert.rejects(drain,{name:'AbortError'});assert.equal(activated,false);
|
|
686
|
+
await fs.access(lease);await assert.rejects(fs.access(path.join(f.home,'registry.lock')),{code:'ENOENT'});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
test('native call cancelled while admission is closed never acquires an invocation lease',async t=>{
|
|
691
|
+
const f=await fixture(t),controller=new AbortController();await fs.mkdir(f.home,{recursive:true});
|
|
692
|
+
let entered,release;const ready=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
|
|
693
|
+
const activation=locked(f.home,async()=>{entered();await hold;});await ready;
|
|
694
|
+
const call=prepareCommand(f.home,'sample',[],{invocation:true,signal:controller.signal});
|
|
695
|
+
controller.abort();await assert.rejects(call,{name:'AbortError'});
|
|
696
|
+
await assert.rejects(fs.access(path.join(f.home,'command-invocations')),{code:'ENOENT'});
|
|
697
|
+
release();await activation;
|
|
698
|
+
});
|