@jc_stack/ez-agents 0.1.0-beta.47 → 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/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +21 -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 +86 -0
- package/docker/run.ts +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +18 -3
- package/docs/docker-runtime.md +29 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +100 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +35 -0
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai.ts +22 -3
- 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 +24 -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 +38 -29
- package/src/index.ts +41 -18
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +4 -2
- 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 +12 -1
- package/src/schedule-cli.ts +69 -8
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +79 -18
- package/src/scripts.ts +167 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +10 -7
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +46 -7
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -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 +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-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/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +45 -9
package/src/updates/runtime.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { parseEnv } from 'node:util';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
9
10
|
import { atomic, compose, snapshot, checkFolders } from '../plugins/manager.mjs';
|
|
10
11
|
import { read, state, eligibility, jobPath, cleanupStaleBackups } from './control.mjs';
|
|
11
12
|
import { bindUpdates } from './binding.mjs';
|
|
@@ -62,9 +63,42 @@ export async function relayServices(config) {
|
|
|
62
63
|
const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
|
|
63
64
|
return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// Explicitly rebind single-file mounts after atomic replacement. Pin the running image
|
|
67
|
+
// while recreating only the broker with this deployment's Compose identity.
|
|
68
|
+
export async function refreshBroker(config, run=execute, image) {
|
|
69
|
+
if (!(await relayServices(config)).includes('plugin-broker')) return null;
|
|
70
|
+
const args=relayArgs(config);
|
|
71
|
+
if(image===undefined) {
|
|
72
|
+
const id=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
|
|
73
|
+
if(!/^[a-f0-9]{12,64}$/i.test(id))throw Error('Expected one installed isolated broker');
|
|
74
|
+
image=(await run('docker',['inspect','--format','{{.Image}}',id],{stdoutOnly:true,timeout:15000})).trim();
|
|
75
|
+
}
|
|
76
|
+
if(!/^sha256:[a-f0-9]{64}$/.test(image))throw Error('Cannot pin broker image');
|
|
77
|
+
const temporary=await fs.mkdtemp(path.join(tmpdir(),'ez-broker-refresh-'));
|
|
78
|
+
try {
|
|
79
|
+
const pin=path.join(temporary,'image.env');
|
|
80
|
+
await fs.writeFile(pin,`EZ_RELAY_IMAGE=${image}\n`,{mode:0o600});
|
|
81
|
+
await run('docker',[...args,'--env-file',pin,'up','-d','--no-deps','--force-recreate','--no-build','--pull','never','--wait','--wait-timeout','90','plugin-broker']);
|
|
82
|
+
const current=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
|
|
83
|
+
if(!/^[a-f0-9]{12,64}$/i.test(current)||(await run('docker',['inspect','--format','{{.Image}}',current],{stdoutOnly:true,timeout:15000})).trim()!==image)
|
|
84
|
+
throw Error('Recreated broker image does not match the intended installed image');
|
|
85
|
+
const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
|
|
86
|
+
const home=path.dirname(hostConfig)===config.deploymentDir
|
|
87
|
+
? (await read(hostConfig)).agents?.find(agent=>agent.workspace===config.workspace)?.toolsHome : undefined;
|
|
88
|
+
if(typeof home!=='string'||!path.isAbsolute(home))throw Error('Missing broker registry binding');
|
|
89
|
+
const files=[hostConfig,path.join(home,'registry.json'),path.join(home,'config.json')];
|
|
90
|
+
const hashes=await Promise.all(files.map(async file=>digest(await fs.readFile(file))));
|
|
91
|
+
// Supply trusted read-only code so rollback can check an older image that
|
|
92
|
+
// predates this diagnostic. Its validators still come from that image.
|
|
93
|
+
const diagnostic=await fs.readFile(new URL('../../docker/broker-readiness.mjs',import.meta.url),'utf8');
|
|
94
|
+
const probe=JSON.parse(await run('docker',[...args,'exec','-T','plugin-broker','node','--import','/app/node_modules/tsx/dist/loader.mjs',
|
|
95
|
+
'--input-type=module','-e',diagnostic,...hashes],{stdoutOnly:true,timeout:60000}));
|
|
96
|
+
if(probe.version!==1||probe.ready!==true||probe.hostSha256!==hashes[0]||probe.registrySha256!==hashes[1]||probe.configSha256!==hashes[2])
|
|
97
|
+
throw Error('Invalid isolated broker readiness readback');
|
|
98
|
+
// A concurrent writer invalidates the readback; never silently accept it.
|
|
99
|
+
for(let i=0;i<files.length;i++)if(digest(await fs.readFile(files[i]))!==hashes[i])throw Error('Broker binding changed during verification');
|
|
100
|
+
return probe;
|
|
101
|
+
}finally {await fs.rm(temporary,{recursive:true,force:true});}
|
|
68
102
|
}
|
|
69
103
|
const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
|
|
70
104
|
async function healthEvidence(config,run) {
|
|
@@ -143,7 +177,11 @@ export async function perform(home,job,hooks) {
|
|
|
143
177
|
await textAtomic(envFile,env);
|
|
144
178
|
await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),root);
|
|
145
179
|
await hooks.startHost(root);
|
|
146
|
-
await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build'
|
|
180
|
+
await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
|
|
181
|
+
if((await relayServices(config)).includes('plugin-broker')) {
|
|
182
|
+
const brokerImage=(await run('docker',['image','inspect','--format','{{.Id}}',image],{stdoutOnly:true})).trim();
|
|
183
|
+
job.brokerReadiness=await refreshBroker(config,run,brokerImage);
|
|
184
|
+
}
|
|
147
185
|
} else {
|
|
148
186
|
const old=next.old.record,r=await read(path.join(home,'registry.json'));
|
|
149
187
|
const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
@@ -163,7 +201,7 @@ export async function perform(home,job,hooks) {
|
|
|
163
201
|
r.plugins[job.target]=candidate;
|
|
164
202
|
for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
|
|
165
203
|
await atomic(path.join(home,'registry.json'),r);
|
|
166
|
-
await refreshBroker(config,run);
|
|
204
|
+
job.brokerReadiness=await refreshBroker(config,run);
|
|
167
205
|
job.runtimeVerified=running;
|
|
168
206
|
}
|
|
169
207
|
job.status='completed';job.endedAt=new Date().toISOString();await save();
|
|
@@ -195,12 +233,13 @@ export async function recover(home,job,hooks) {
|
|
|
195
233
|
await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
|
|
196
234
|
await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
|
|
197
235
|
await hooks.startHost(job.rollback.packageRoot);
|
|
198
|
-
if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build'
|
|
236
|
+
if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
|
|
237
|
+
job.rollbackBrokerReadiness=await refreshBroker(config,run,pinned);
|
|
199
238
|
} else {
|
|
200
239
|
const b=job.rollback;
|
|
201
240
|
await run('docker',[...pluginArgs(b.record),'stop']);
|
|
202
241
|
await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
|
|
203
|
-
await refreshBroker(config,run);
|
|
242
|
+
job.rollbackBrokerReadiness=await refreshBroker(config,run);
|
|
204
243
|
if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
205
244
|
}
|
|
206
245
|
job.status='rolled-back';
|
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
|
+
})
|
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,67 @@ 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 scheduler.save({...schedule,enabled:false});
|
|
213
|
+
assert.equal((await request(f.socket,payload)).ok,false);
|
|
214
|
+
} finally {abort.abort();await serving;process.env.PATH=previousPath;}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('task launch broker admits only its fixed application command and writes no bearer into receipts',async t=>{
|
|
218
|
+
const {createServer}=await import('node:http');
|
|
219
|
+
const {Tasks}=await import('../src/tasks.js');const {EventSources}=await import('../src/event-sources.js');
|
|
220
|
+
const {ApplicationBindings}=await import('../src/application-channel.js');const {serveTestLedger}=await import('./helpers/ledger.js');
|
|
221
|
+
const f=await fixture(t), runs=new RunStore(f.controlDir), control=new ControlStore(f.controlDir,900000);
|
|
222
|
+
const sourceSocket=path.join(f.root,'source.sock');const provider=createServer(async(req,res)=>{
|
|
223
|
+
let raw='';for await(const c of req)raw+=c;const v=JSON.parse(raw);
|
|
224
|
+
res.end(JSON.stringify({ok:true,data:v.command==='events-head'?{cursor:0,accountId:'fixture-account',taskProtocol:'message-v1'}:{}}));
|
|
225
|
+
});await new Promise(r=>provider.listen(sourceSocket,r));
|
|
226
|
+
const tasks=new Tasks(f.controlDir),owner=(await control.status()).owner;
|
|
227
|
+
await new EventSources(f.controlDir).register('fixture',sourceSocket,owner);
|
|
228
|
+
const task=await tasks.ownerCall(f.runId,'start',{sourceId:'fixture',conversationId:'discussion-a',purpose:'Synthetic discussion',context:'Only synthetic records',hours:1});
|
|
229
|
+
const taskRun='event_'+hash(task.id);await runs.patch(taskRun,{status:'running'});
|
|
230
|
+
const bindings=new ApplicationBindings(f.controlDir);await bindings.register('browser','z'.repeat(48),owner);
|
|
231
|
+
await bindings.taskLaunch('browser',{command:'sample',args:['launch','--task'],tasks:[task.id]});
|
|
232
|
+
const token='c'.repeat(64),ticketHash=hash(token),grant=await tasks.workerCall(taskRun,'application_launch',{application:'browser',ticketHash});
|
|
233
|
+
const input={taskId:task.id,taskToken:token,expiresAt:grant.expiresAt};
|
|
234
|
+
const ledger=await serveTestLedger(f.controlDir);
|
|
235
|
+
const binding=await loadPluginBrokerBinding({home:f.home,workspace:f.workspace,controlDir:f.controlDir,socket:f.socket,hostConfig:f.hostConfig,timeoutMs:10000});
|
|
236
|
+
const oldPath=process.env.PATH;process.env.PATH=f.fake+path.delimiter+oldPath;
|
|
237
|
+
const abort=new AbortController(),serving=servePluginBroker(binding,abort.signal);
|
|
238
|
+
try{
|
|
239
|
+
await waitForSocket(f.socket);
|
|
240
|
+
const denied=await request(f.socket,{version:1,id:randomUUID(),operation:'resolve',runId:taskRun,alias:'sample'});assert.equal(denied.ok,false);
|
|
241
|
+
const launch=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});
|
|
242
|
+
assert.equal(launch.ok,true,launch.error);assert.equal(launch.stdout,'broker-ok');assert.equal(launch.receipt.status,'completed');
|
|
243
|
+
const receipt=await fs.readFile(launch.receiptPath,'utf8');assert(!receipt.includes(token));assert(!receipt.includes('taskToken'));
|
|
244
|
+
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);
|
|
245
|
+
await bindings.taskLaunch('browser',null);
|
|
246
|
+
const revoked=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});assert.equal(revoked.ok,false);
|
|
247
|
+
}finally{abort.abort();await serving;process.env.PATH=oldPath;await ledger.stop();provider.closeAllConnections();await new Promise(r=>provider.close(r));}
|
|
248
|
+
});
|
|
@@ -652,3 +652,23 @@ 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
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {mkdtempSync,writeFileSync,openSync,closeSync,rmSync} from 'node:fs'
|
|
4
|
+
import {spawnSync} from 'node:child_process'
|
|
5
|
+
import {join} from 'node:path'
|
|
6
|
+
import {loadConfig} from '../src/config.js'
|
|
7
|
+
|
|
8
|
+
test('installed private descriptor admits speech selectors and keeps speech keys out of the executor', () => {
|
|
9
|
+
const dir=mkdtempSync('/tmp/ez-speech-env-'),file=join(dir,'relay.env'),key='synthetic-private-speech-marker'
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(file,`OPENROUTER_API_KEY=${key}\nEZ_SPEECH_PROVIDER=invalid-private-selector\nEZ_SPEECH_MODEL=fish-audio/s2.1-pro-free:free\n`,{mode:0o600})
|
|
12
|
+
let fd=openSync(file,'r')
|
|
13
|
+
const invalid=spawnSync(process.execPath,['--import','tsx','docker/run.ts','start'],{encoding:'utf8',timeout:15000,env:{PATH:process.env.PATH,EZ_APPLICATION_PORT:'8787'},stdio:['ignore','pipe','pipe',fd]});closeSync(fd)
|
|
14
|
+
assert.equal(invalid.status,1);assert.match(invalid.stderr,/Invalid EZ_SPEECH_PROVIDER/);assert.ok(!invalid.stderr.includes(key))
|
|
15
|
+
writeFileSync(file,`OPENROUTER_API_KEY=${key}\nEZ_SPEECH_PROVIDER=openrouter\nEZ_SPEECH_MODEL=fish-audio/s2.1-pro-free:free\n`,{mode:0o600})
|
|
16
|
+
fd=openSync(file,'r')
|
|
17
|
+
const child=spawnSync(process.execPath,['--import','tsx','docker/run.ts','exec',process.execPath,'-e',"if(process.env.OPENROUTER_API_KEY||process.env.EZ_SPEECH_PROVIDER)process.exit(91);console.log('isolated')"],{encoding:'utf8',timeout:15000,env:{PATH:process.env.PATH},stdio:['ignore','pipe','pipe',fd]});closeSync(fd)
|
|
18
|
+
assert.equal(child.status,0,child.stderr);assert.equal(child.stdout.trim(),'isolated')
|
|
19
|
+
const config=loadConfig({EZ_APPLICATION_PORT:'8787',OPENROUTER_API_KEY:key,EZ_SPEECH_PROVIDER:'openrouter',EZ_SPEECH_MODEL:'fish-audio/s2.1-pro-free:free'})
|
|
20
|
+
assert.equal(config.openrouterApiKey,key);assert.equal(config.speechProvider,'openrouter');assert.equal(config.speechModel,'fish-audio/s2.1-pro-free:free')
|
|
21
|
+
} finally {rmSync(dir,{recursive:true,force:true})}
|
|
22
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { promisify } from 'node:util'
|
|
9
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
10
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
11
|
+
import { RunStore } from '../src/runs.js'
|
|
12
|
+
import { ControlStore } from '../src/control-state.js'
|
|
13
|
+
import { runEvidence, projectRunEvidence } from '../src/run-evidence.js'
|
|
14
|
+
|
|
15
|
+
async function fixture(t: test.TestContext) {
|
|
16
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-evidence-'))
|
|
17
|
+
await ownerRun(dir, 'owner_run')
|
|
18
|
+
const ledger = await serveTestLedger(dir)
|
|
19
|
+
t.after(async () => { await ledger.stop(); await rm(dir, { recursive: true, force: true }) })
|
|
20
|
+
return { dir, runs: new RunStore(dir), owner: (await new ControlStore(dir, 1000).status()).owner! }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('CLI projects operational proof without sessions, failure prose or delivered text', async t => {
|
|
24
|
+
const { dir, runs } = await fixture(t)
|
|
25
|
+
const secret = 'private-session-or-token'
|
|
26
|
+
await runs.patch('owner_run', { nativeSessionId: secret, failureReason: secret,
|
|
27
|
+
failure: { error: secret, relayVersion: '0.1.0-beta.47' } })
|
|
28
|
+
const sent = await runs.enqueueMessage('owner_run', 'private financial report')
|
|
29
|
+
await runs.claimOutbox(sent.id); await runs.markOutboxSent(sent.id, [7])
|
|
30
|
+
const uncertain = await runs.enqueueMessage('owner_run', 'uncertain report')
|
|
31
|
+
await runs.claimOutbox(uncertain.id); await runs.failOutbox(uncertain.id, secret, true)
|
|
32
|
+
const control = await new ControlStore(dir, 1000).status()
|
|
33
|
+
const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-schedule.mjs', 'evidence'], {
|
|
34
|
+
env: { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'owner_run' },
|
|
35
|
+
})
|
|
36
|
+
const result = JSON.parse(stdout)
|
|
37
|
+
assert.equal(result.runs.length, 1)
|
|
38
|
+
assert.ok(!stdout.includes(secret) && !stdout.includes('private financial report') && !stdout.includes('uncertain report'))
|
|
39
|
+
const receipts = result.runs[0].deliveries
|
|
40
|
+
assert.equal(receipts.find((r: any) => r.id === sent.id).receiptVerified, true)
|
|
41
|
+
assert.equal(receipts.find((r: any) => r.id === sent.id).content.sha256, createHash('sha256').update('private financial report').digest('hex'))
|
|
42
|
+
assert.equal(receipts.find((r: any) => r.id === uncertain.id).state, 'unknown')
|
|
43
|
+
assert.equal(receipts.find((r: any) => r.id === uncertain.id).receiptVerified, false)
|
|
44
|
+
assert.equal(result.runs[0].toolDelegationMetadata.state, 'unavailable')
|
|
45
|
+
assert.deepEqual(await new ControlStore(dir, 1000).status(), control)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('pages require an unchanged snapshot and exact identity', async t => {
|
|
49
|
+
const { dir, runs } = await fixture(t)
|
|
50
|
+
await ownerRun(dir, 'second_run')
|
|
51
|
+
const first = await runEvidence(dir, { limit: 1 })
|
|
52
|
+
assert.equal(first.coverage.total, 2)
|
|
53
|
+
assert.equal(first.coverage.nextOffset, 1)
|
|
54
|
+
const second = await runEvidence(dir, { limit: 1, offset: 1, expected: first.snapshotSha256 })
|
|
55
|
+
assert.equal(second.coverage.nextOffset, null)
|
|
56
|
+
assert.notEqual(second.runs[0].id, first.runs[0].id)
|
|
57
|
+
await assert.rejects(runEvidence(dir, { offset: 1 }), /requires/)
|
|
58
|
+
await runs.patch('second_run', { status: 'completed' })
|
|
59
|
+
await assert.rejects(runEvidence(dir, { offset: 1, expected: first.snapshotSha256 }), /changed/)
|
|
60
|
+
await assert.rejects(runEvidence(dir, { runId: '../owner_run' }), /identifier/)
|
|
61
|
+
for (const options of [{ limit: 101 }, { offset: -1 }, { offset: 0.5 }, { expected: 'bad' }])
|
|
62
|
+
await assert.rejects(runEvidence(dir, options))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('revoked owners, restricted callers and unrelated bindings are excluded', async t => {
|
|
66
|
+
const { dir, runs, owner } = await fixture(t)
|
|
67
|
+
const stranger = await runs.create({ id: 'other_owner', chatId: 202, telegramUserId: 202, texts: ['secret'] })
|
|
68
|
+
const external = await ownerRun(dir, 'external', { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] })
|
|
69
|
+
const old = { ...(await runs.get('owner_run'))!, id: 'old_run', scheduled: { id: 'schedule', revision: 'rev', dueAt: '2026-01-01T00:00:00Z', pairedAt: 'old-pairing' } }
|
|
70
|
+
const app = { ...(await runs.get('owner_run'))!, id: 'app_run', delivery: { bindingId: 'private', scope: 'private' } }
|
|
71
|
+
assert.deepEqual(projectRunEvidence([stranger, external, old, app], [], owner), [])
|
|
72
|
+
await assert.rejects(runEvidence(dir, { callerRunId: 'external' }), /blocked/)
|
|
73
|
+
await runs.patch('owner_run', { replyOnly: true })
|
|
74
|
+
await assert.rejects(runEvidence(dir, { callerRunId: 'owner_run' }), /Telegram owner/)
|
|
75
|
+
await assert.rejects(runEvidence(dir, { runId: 'other_owner' }), /Unknown/)
|
|
76
|
+
await new ControlStore(dir, 1000).revokeOwner()
|
|
77
|
+
await assert.rejects(runEvidence(dir), /No authorized owner/)
|
|
78
|
+
})
|