@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
package/test/ai.test.ts
CHANGED
|
@@ -4,13 +4,49 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
|
-
import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
|
|
7
|
+
import { initialPreset, chatPreset, readModels, readCodexCatalog, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
|
|
8
8
|
import { createAiMenu } from '../src/menu.js'
|
|
9
9
|
import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
|
|
10
10
|
import { InboxStore } from '../src/inbox.js'
|
|
11
11
|
import { executionDefaults } from '../src/model-policy.js'
|
|
12
12
|
import type { Update } from 'grammy/types'
|
|
13
13
|
|
|
14
|
+
const noCodex = async (): Promise<string> => { throw new Error('Native discovery unavailable') }
|
|
15
|
+
|
|
16
|
+
test('Codex discovery refreshes through the bound native CLI without curated overrides or a turn', async () => {
|
|
17
|
+
const home = await mkdtemp(join(tmpdir(), 'ez-native-catalog-'))
|
|
18
|
+
const previousToken = process.env.TELEGRAM_BOT_TOKEN
|
|
19
|
+
try {
|
|
20
|
+
process.env.TELEGRAM_BOT_TOKEN = 'must-not-reach-native-client'
|
|
21
|
+
await writeFile(join(home, 'models_cache.json'), JSON.stringify({ models: [
|
|
22
|
+
{ slug: 'old-model', visibility: 'list' },
|
|
23
|
+
] }))
|
|
24
|
+
const calls: string[][] = []
|
|
25
|
+
const run = async (args: string[], env: NodeJS.ProcessEnv) => {
|
|
26
|
+
calls.push(args)
|
|
27
|
+
assert.equal(env.CODEX_HOME, home)
|
|
28
|
+
assert.equal(env.TELEGRAM_BOT_TOKEN, undefined)
|
|
29
|
+
return JSON.stringify({ models: [
|
|
30
|
+
{ slug: 'new-model', display_name: 'New model', visibility: 'list',
|
|
31
|
+
supported_reasoning_levels: [{ effort: 'ultra' }], model_messages: 'untrusted', api_key: 'secret' },
|
|
32
|
+
{ slug: 'hidden-model', visibility: 'hide' },
|
|
33
|
+
] })
|
|
34
|
+
}
|
|
35
|
+
assert.deepEqual(await readModels(home, async cli => cli === 'codex', home,
|
|
36
|
+
undefined, undefined, undefined, undefined, run), [
|
|
37
|
+
{ cli: 'codex', model: 'new-model', name: 'New model', efforts: ['ultra'] },
|
|
38
|
+
])
|
|
39
|
+
assert.deepEqual(calls, [['debug', 'models']])
|
|
40
|
+
for (const unavailable of [noCodex, async () => 'not json', async () => '{"models":[]}']) {
|
|
41
|
+
assert.deepEqual(await readCodexCatalog(home, unavailable), { models: [{ slug: 'old-model', visibility: 'list' }] })
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
if (previousToken === undefined) delete process.env.TELEGRAM_BOT_TOKEN
|
|
45
|
+
else process.env.TELEGRAM_BOT_TOKEN = previousToken
|
|
46
|
+
await rm(home, { recursive: true, force: true })
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
14
50
|
test('same-client model and default changes preserve history; provider changes require a fresh conversation', async () => {
|
|
15
51
|
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-model-switch-'))
|
|
16
52
|
try {
|
|
@@ -173,7 +209,7 @@ test('model catalog projects native metadata only, excluding hidden entries and
|
|
|
173
209
|
supported_reasoning_levels: [{ effort: 'high' }, { effort: 'xhigh' }, { effort: 'max' }] },
|
|
174
210
|
{ slug: 'hidden-model', visibility: 'hide' },
|
|
175
211
|
] }))
|
|
176
|
-
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex'), [
|
|
212
|
+
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', undefined, undefined, undefined, undefined, undefined, noCodex), [
|
|
177
213
|
{ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
|
|
178
214
|
{ cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh', 'max'] },
|
|
179
215
|
])
|
|
@@ -258,7 +294,7 @@ test('model catalog can read an agent-bound Codex home', async () => {
|
|
|
258
294
|
{ slug: 'gpt-6-astra', display_name: 'GPT-6 Astra', visibility: 'list',
|
|
259
295
|
supported_reasoning_levels: [{ effort: 'low' }] },
|
|
260
296
|
] }))
|
|
261
|
-
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome), [
|
|
297
|
+
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome, undefined, undefined, undefined, undefined, noCodex), [
|
|
262
298
|
{ cli: 'codex', model: 'gpt-6-astra', name: 'GPT-6 Astra', efforts: ['low'] },
|
|
263
299
|
])
|
|
264
300
|
} finally {
|
|
@@ -270,7 +306,10 @@ test('model catalog can read an agent-bound Codex home', async () => {
|
|
|
270
306
|
test('Choose AI lists the agent-bound Codex home instead of only the desktop fallback', async () => {
|
|
271
307
|
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-codex-home-'))
|
|
272
308
|
const codexHome = await mkdtemp(join(tmpdir(), 'ez-ai-menu-cache-'))
|
|
309
|
+
const previousPath = process.env.PATH
|
|
273
310
|
try {
|
|
311
|
+
await writeFile(join(codexHome, 'codex'), '#!/bin/sh\nexit 1\n', { mode: 0o700 })
|
|
312
|
+
process.env.PATH = `${codexHome}:${previousPath ?? ''}`
|
|
274
313
|
await writeFile(join(codexHome, 'models_cache.json'), JSON.stringify({ models: [
|
|
275
314
|
{ slug: 'fixture-bound-model', display_name: 'Bound Fixture', visibility: 'list',
|
|
276
315
|
supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
@@ -298,6 +337,7 @@ test('Choose AI lists the agent-bound Codex home instead of only the desktop fal
|
|
|
298
337
|
await menu.handle(context(client.callback_data) as never)
|
|
299
338
|
assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), ['Bound Fixture', 'Back to clients'])
|
|
300
339
|
} finally {
|
|
340
|
+
process.env.PATH = previousPath
|
|
301
341
|
await rm(dir, { recursive: true, force: true })
|
|
302
342
|
await rm(codexHome, { recursive: true, force: true })
|
|
303
343
|
}
|
|
@@ -522,7 +562,7 @@ test('model catalog merges user-curated entries ahead of discovered ones', async
|
|
|
522
562
|
const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
|
|
523
563
|
const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
|
|
524
564
|
const catalog = await readModels(home, installedOnly, join(home, '.codex'),
|
|
525
|
-
noRunner, undefined, undefined, control)
|
|
565
|
+
noRunner, undefined, undefined, control, noCodex)
|
|
526
566
|
assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
|
|
527
567
|
{ cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
|
|
528
568
|
{ cli: 'pi', name: 'Pi · client default', efforts: [] },
|
|
@@ -538,7 +578,7 @@ test('model catalog merges user-curated entries ahead of discovered ones', async
|
|
|
538
578
|
assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
|
|
539
579
|
await writeFile(join(control, 'ai-models.json'), 'not json')
|
|
540
580
|
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
|
|
541
|
-
noRunner, undefined, undefined, control), [
|
|
581
|
+
noRunner, undefined, undefined, control, noCodex), [
|
|
542
582
|
{ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
|
|
543
583
|
])
|
|
544
584
|
} finally {
|
|
@@ -592,3 +632,43 @@ test('private channel model choices do not consume owner preset slots', async ()
|
|
|
592
632
|
assert.deepEqual((await store.applicationSession(scope))?.preset, readback.preset)
|
|
593
633
|
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
594
634
|
})
|
|
635
|
+
|
|
636
|
+
test('detected presets do not count against the saved AI cap', async () => {
|
|
637
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-cap-'))
|
|
638
|
+
try {
|
|
639
|
+
const store = new ControlStore(dir, 1000)
|
|
640
|
+
const initial = initialPreset('codex')
|
|
641
|
+
await store.captureChoice(initial)
|
|
642
|
+
for (let n = 1; n < 11; n++) await store.savePreset({...initial, id:`saved-${n}`, name:`Saved ${n}`})
|
|
643
|
+
await store.syncClientPresets(initial, ['claude', 'grok'].map(cli => ({id:`detected_${cli}`, cli, name:`${cli} · client default`})))
|
|
644
|
+
assert.equal((await store.status()).ai!.presets.length, 13)
|
|
645
|
+
const next = {id:'one-more', name:'One more', cli:'claude', model:'sonnet', effort:'medium'}
|
|
646
|
+
await store.savePreset(next)
|
|
647
|
+
await assert.rejects(store.savePreset({...next, id:'too-many', name:'Too many'}), /at most 12/)
|
|
648
|
+
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
test('Claude catalog projects only aliases and efforts from the installed native help', async () => {
|
|
652
|
+
const help = `Options:
|
|
653
|
+
--effort <level> Effort level for the current session
|
|
654
|
+
(low, medium, high, xhigh, max)
|
|
655
|
+
--model <model> Model for the current session. Provide
|
|
656
|
+
an alias for the latest model (e.g.
|
|
657
|
+
'fable', 'opus', or 'sonnet') or a
|
|
658
|
+
model's full name.
|
|
659
|
+
-n, --name <name> Set a display name ('ignored')
|
|
660
|
+
`
|
|
661
|
+
const calls: string[][] = []
|
|
662
|
+
const models = await readModels('/nonexistent', async cli => cli === 'claude', undefined, undefined, undefined, undefined, undefined, undefined,
|
|
663
|
+
async (args) => { calls.push(args); return help })
|
|
664
|
+
assert.deepEqual(calls, [['--help']])
|
|
665
|
+
assert.deepEqual(models, [
|
|
666
|
+
{ cli: 'claude', name: 'claude · client default', efforts: [] },
|
|
667
|
+
...['fable', 'opus', 'sonnet'].map(model => ({ cli: 'claude', model, name: `claude · ${model}`, efforts: ['low', 'medium', 'high', 'xhigh', 'max'] })),
|
|
668
|
+
])
|
|
669
|
+
for (const unavailable of [async () => { throw new Error('not runnable') }, async () => 'Usage: claude'])
|
|
670
|
+
assert.deepEqual(await readModels('/nonexistent', async cli => cli === 'claude', undefined, undefined, undefined, undefined, undefined, undefined, unavailable),
|
|
671
|
+
[{ cli: 'claude', name: 'claude · client default', efforts: [] }])
|
|
672
|
+
await validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus', effort: 'xhigh' }, models, async () => true)
|
|
673
|
+
await assert.rejects(validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus-guess' }, models, async () => true), /not in the installed client catalog/)
|
|
674
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import { request as httpRequest } from 'node:http'
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
|
-
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
|
|
4
|
+
import { mkdtemp, rm, writeFile, readFile, chown, stat } from 'node:fs/promises'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { tmpdir } from 'node:os'
|
|
7
7
|
import { spawnSync } from 'node:child_process'
|
|
@@ -435,3 +435,18 @@ test('shareTelegram bindings list Telegram intake and admit one run with context
|
|
|
435
435
|
await runs.patch(finished.id,{status:'completed'})
|
|
436
436
|
assert.equal((await attach(finished.id,sharedToken,{context:{}})).status,409)
|
|
437
437
|
})
|
|
438
|
+
|
|
439
|
+
test('administrator task grant replacement preserves the runtime file owner', {skip:process.getuid?.()!==0},async()=>{
|
|
440
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-app-grant-owner-'))
|
|
441
|
+
try {
|
|
442
|
+
const who=await owner(dir),bindings=new ApplicationBindings(dir)
|
|
443
|
+
await bindings.register('voice',token(),who)
|
|
444
|
+
const file=join(dir,'application-bindings.json')
|
|
445
|
+
await chown(file,20000,20000)
|
|
446
|
+
await bindings.taskLaunch('voice',{command:'voice',args:['launch','--task'],tasks:['task_'+'a'.repeat(32)]})
|
|
447
|
+
const saved=await stat(file)
|
|
448
|
+
assert.equal(saved.uid,20000);assert.equal(saved.gid,20000);assert.equal(saved.mode&0o777,0o600)
|
|
449
|
+
await bindings.taskLaunch('voice',null)
|
|
450
|
+
const disabled=await stat(file);assert.equal(disabled.uid,20000);assert.equal(disabled.gid,20000)
|
|
451
|
+
}finally{await rm(dir,{recursive:true,force:true})}
|
|
452
|
+
})
|
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
|
+
})
|
|
@@ -85,7 +85,7 @@ test('web owner uses the standard schedule CLI, executor and outbox with Telegra
|
|
|
85
85
|
await writeFile(fixture,`import {spawnSync} from 'node:child_process';
|
|
86
86
|
if(process.env.TELEGRAM_BOT_TOKEN)throw Error('secret leak');
|
|
87
87
|
const scheduled=process.env.EZ_RUN_ID.startsWith('r_schedule_');
|
|
88
|
-
if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
|
|
88
|
+
if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--model','fixture-model','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
|
|
89
89
|
const r=spawnSync(process.execPath,[${JSON.stringify(messageCli)},'--text',scheduled?'Scheduled reply':'Chat reply'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr);`)
|
|
90
90
|
EXECUTOR_REGISTRY.grok={...original,command:process.execPath,buildArgs:()=>['--import',require.resolve('tsx'),fixture]}
|
|
91
91
|
const config=loadConfig({EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
|
|
@@ -151,7 +151,7 @@ for (const mode of ['owner','web','telegram']) test(`${mode} revocation stops it
|
|
|
151
151
|
await runs.create({id:'r_telegram',chatId:42,telegramUserId:42,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Wait'],execution:await control.captureChoice(initialPreset('grok'))})
|
|
152
152
|
} else {
|
|
153
153
|
const binding=(await relay.applicationChannel.bindings.register('web',secret(),owner))!
|
|
154
|
-
await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice(initialPreset('grok'))})
|
|
154
|
+
await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})})
|
|
155
155
|
await scheduler.tick(owner,runs,Date.now()+2000)
|
|
156
156
|
}
|
|
157
157
|
await relay.drainSources()
|
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
|
+
})
|
|
@@ -52,7 +52,8 @@ test('codex-gui is a distinct preset and catalog entry', async () => {
|
|
|
52
52
|
await writeFile(path.join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
|
|
53
53
|
{ slug: 'fixture-model', display_name: 'Fixture', visibility: 'list', supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
54
54
|
] }))
|
|
55
|
-
const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui'
|
|
55
|
+
const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui', undefined, undefined, undefined, undefined, undefined,
|
|
56
|
+
async () => { throw new Error('Native discovery unavailable') })
|
|
56
57
|
assert.ok(models.some((model) => model.cli === 'codex' && model.model === 'fixture-model'))
|
|
57
58
|
assert.ok(models.some((model) => model.cli === 'codex-gui' && model.model === 'fixture-model'))
|
|
58
59
|
} finally { await rm(home, { recursive: true, force: true }) }
|
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/failure.test.ts
CHANGED
|
@@ -100,7 +100,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
|
|
|
100
100
|
})
|
|
101
101
|
try{
|
|
102
102
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
103
|
-
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok')),start=Date.now()+2000
|
|
103
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'}),start=Date.now()+2000
|
|
104
104
|
await scheduler.save({id:'review',name:'Review failures',text:'Review failures',trigger:{everySeconds:60,start:new Date(start).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
|
|
105
105
|
await scheduler.tick(owner,runs,start)
|
|
106
106
|
assert.equal((await runs.list()).length,0,'no model job for an empty inbox')
|
|
@@ -251,7 +251,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
|
|
|
251
251
|
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
252
252
|
if(intake) await relay.bot.handleUpdate({update_id:91,message:{message_id:91,date:0,text:'fixture',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
|
|
253
253
|
else {
|
|
254
|
-
const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice(initialPreset('grok'))
|
|
254
|
+
const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
|
|
255
255
|
await scheduler.save({id:'fixture',name:'Fixture',text:'fixture',trigger:{at:new Date(at).toISOString()},enabled:true,owner,execution})
|
|
256
256
|
await scheduler.tick(owner,runs,at)
|
|
257
257
|
}
|
|
@@ -276,7 +276,7 @@ test('group members can inspect failures and wake review without exposing other
|
|
|
276
276
|
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
277
277
|
const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
|
|
278
278
|
await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
|
|
279
|
-
const execution=await control.captureChoice(initialPreset('grok'))
|
|
279
|
+
const execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
|
|
280
280
|
await runs.create({id:'tg_1',chatId:-123,telegramUserId:202,texts:['failed'],execution})
|
|
281
281
|
await runs.patch('tg_1',{status:'failed',endedAt:new Date().toISOString()})
|
|
282
282
|
await runs.create({id:'tg_2',chatId:-124,telegramUserId:202,texts:['private'],execution})
|
|
@@ -343,7 +343,7 @@ test('failed reviewer readback exposes its stop through show, list and resume un
|
|
|
343
343
|
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
344
344
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
345
345
|
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
346
|
-
const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
|
|
346
|
+
const execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'}),now=Date.now()+1000
|
|
347
347
|
const s=await scheduler.save({id:'review',name:'Review',text:'Review failures',trigger:{everySeconds:60,start:new Date(now).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
|
|
348
348
|
await runs.create({id:'r_original',chatId:101,telegramUserId:101,texts:['Work'],execution});await runs.patch('r_original',{status:'failed'})
|
|
349
349
|
await scheduler.tick(owner,runs,now)
|
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-'))
|
|
@@ -265,13 +267,13 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
265
267
|
await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
|
|
266
268
|
const directory=path.join(agents[0].controlDir,'host-executor')
|
|
267
269
|
const runs=new RunStore(agents[0].controlDir)
|
|
268
|
-
await runs.create({id:'r_hold',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
270
|
+
await runs.create({id:'r_hold',execution:{sessionId:crypto.randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
269
271
|
await runs.patch('r_hold',{status:'running'})
|
|
270
|
-
await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
|
|
272
|
+
await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',model:'fixture-model'}}))
|
|
271
273
|
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'r_hold.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
272
|
-
await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
274
|
+
await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',execution:{sessionId:crypto.randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
273
275
|
await new RunStore(agents[0].controlDir).patch('r_schedule_queued',{status:'running'})
|
|
274
|
-
await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',sharedWorkspace:'/wrong'}}))
|
|
276
|
+
await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',model:'fixture-model',sharedWorkspace:'/wrong'}}))
|
|
275
277
|
const otherDirectory=path.join(agents[1].controlDir,'host-executor')
|
|
276
278
|
await ownerRun(agents[1].controlDir,'r_other_shared')
|
|
277
279
|
await writeFile(path.join(otherDirectory,'r_other_shared.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
|
|
@@ -285,10 +287,10 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
285
287
|
return output.trim().split('\n').map(line=>JSON.parse(line))
|
|
286
288
|
}
|
|
287
289
|
// Chat completes while this agent's scheduled turn stays active. A second
|
|
288
|
-
// scheduled turn
|
|
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
|
+
});
|
|
@@ -79,19 +79,20 @@ test('isolated deployments need no host ledger endpoint',async t=>{
|
|
|
79
79
|
});
|
|
80
80
|
test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
|
|
81
81
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
82
|
-
let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
|
|
83
|
-
const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
|
|
82
|
+
let started,finish,count=0,latest='0.160.0';const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
|
|
83
|
+
const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return JSON.stringify(latest);if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
|
|
84
84
|
const first=build({home,source},invoke);await running;
|
|
85
85
|
assert.equal((await build({home,source},invoke)).state,'busy-or-interrupted');assert.equal(count,1);
|
|
86
86
|
finish();const result=await first;assert.equal(result.state,'completed');
|
|
87
87
|
assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
|
|
88
88
|
await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
|
|
89
|
+
latest='0.160.1';const newer=await build({home,source},invoke);assert.equal(count,3);assert.equal(newer.codexVersion,latest);assert.notEqual(newer.image,result.image);
|
|
89
90
|
});
|
|
90
91
|
test('source checkouts build RCs only: clean reviewed commit plus increasing label',async t=>{
|
|
91
92
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
92
93
|
let inside=true,status='',built=[],toplevel=source;const sha='a'.repeat(40);
|
|
93
94
|
const invoke=async(cmd,args)=>{
|
|
94
|
-
if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
|
|
95
|
+
if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
|
|
95
96
|
if(cmd==='git'){
|
|
96
97
|
if(!inside)throw Error('not a git repository');
|
|
97
98
|
if(args[2]==='rev-parse'&&args[3]==='--show-toplevel')return toplevel;
|
|
@@ -120,13 +121,13 @@ test('source checkouts build RCs only: clean reviewed commit plus increasing lab
|
|
|
120
121
|
});
|
|
121
122
|
test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
|
|
122
123
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
123
|
-
const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
|
|
124
|
+
const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
|
|
124
125
|
await assert.rejects(build({home,source},invoke),/Synthetic/);
|
|
125
126
|
const [id]=await fs.readdir(path.join(home,'builds'));const dir=path.join(home,'builds',id);
|
|
126
127
|
assert.equal(JSON.parse(await fs.readFile(path.join(dir,'status.json'))).state,'failed');await assert.rejects(fs.access(path.join(dir,'lock')));
|
|
127
128
|
});
|
|
128
129
|
test('source changes during a build cannot produce a reusable success receipt',async t=>{
|
|
129
130
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
130
|
-
const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
|
|
131
|
+
const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
|
|
131
132
|
await assert.rejects(build({home,source},invoke),/changed during build/);
|
|
132
133
|
});
|
|
@@ -20,7 +20,7 @@ test('native tasks use verified control binding, sanitized environment and uncha
|
|
|
20
20
|
const denied=await nativeTasks(home,['list']);assert.notEqual(denied.code,0);assert.match(denied.stderr,/Pair an owner/)
|
|
21
21
|
const control=new ControlStore(controlDir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
22
22
|
const text='Read scan; $(must-not-run) /goal literal'
|
|
23
|
-
const saved=await nativeTasks(home,['create','native-fixture','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
|
|
23
|
+
const saved=await nativeTasks(home,['create','native-fixture','--model','fixture-model','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
|
|
24
24
|
const value=JSON.parse(saved.stdout);assert.equal(value.text,text);assert.equal(value.execution.preset.cli,'grok')
|
|
25
25
|
assert.equal(JSON.parse((await nativeTasks(home,['show','native-fixture'])).stdout).text,text)
|
|
26
26
|
assert.equal((await nativeTasks(home,['remove','native-fixture'])).code,0)
|