@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.34
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 +4 -0
- package/CHANGELOG.md +25 -0
- package/Dockerfile +4 -0
- package/README.md +26 -18
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/compose.yaml +2 -1
- package/docker/smoke.mjs +6 -3
- package/docs/application-channel.md +6 -0
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/docker-runtime.md +27 -10
- package/docs/host-service.md +17 -6
- package/docs/plugins.md +15 -0
- package/docs/repair.md +1 -1
- package/docs/setup.md +88 -14
- package/docs/standalone-cli.md +6 -5
- package/package.json +1 -1
- package/scripts/smoke-scheduler.ts +2 -1
- package/src/agent-install.ts +20 -6
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +10 -10
- package/src/codex-session.ts +5 -3
- package/src/config.ts +6 -1
- package/src/control-state.ts +12 -1
- package/src/executor.ts +91 -9
- package/src/host-executor.ts +25 -6
- package/src/index.ts +1 -0
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +3 -1
- package/src/isolation.ts +25 -0
- package/src/menu.ts +9 -6
- package/src/plugins/manager.mjs +49 -6
- package/src/reply-context.ts +0 -1
- package/src/setup.ts +11 -7
- package/src/task-executor.ts +4 -1
- package/src/updates/runtime.mjs +1 -1
- package/src/updates/supervisor.mjs +17 -3
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +23 -13
- package/templates/agent/AGENTS.md +4 -14
- package/templates/agent-guidance.md +13 -110
- package/templates/agent-purpose.md +1 -0
- package/test/agent-guidance.test.ts +8 -12
- package/test/agent-install.test.ts +34 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +48 -0
- package/test/application-channel.test.ts +5 -2
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +2 -0
- package/test/codex-context.test.ts +61 -1
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +17 -2
- package/test/engine-handoff.test.ts +5 -2
- package/test/executor.test.ts +38 -1
- package/test/failure.test.ts +6 -0
- package/test/host-executor.test.ts +6 -0
- package/test/install-config.test.ts +17 -6
- package/test/install-tools.test.mjs +6 -0
- package/test/plugin-manager.test.mjs +24 -3
- package/test/scheduler.test.ts +2 -2
- package/test/updates.test.mjs +10 -1
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +23 -10
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
|
@@ -17,9 +17,15 @@ test('new agents receive independent projects, secrets and plugin bindings; dupl
|
|
|
17
17
|
await assert.rejects(createAgent({...base,name:'different-cli',cli:'codex'}),/already selected/)
|
|
18
18
|
const host=JSON.parse(await readFile(join(root,'family/host-executor.json'),'utf8'))
|
|
19
19
|
assert.equal(host.cli,'grok')
|
|
20
|
+
assert.equal(host.isolation,'host-capable')
|
|
20
21
|
assert.equal(host.agents[0].workspace,'/private/agents/family/mind')
|
|
21
22
|
const a=parseEnv(await readFile(join(root,'family/docker.env'),'utf8'))
|
|
22
23
|
const b=parseEnv(await readFile(join(root,'work/docker.env'),'utf8'))
|
|
24
|
+
assert.equal(a.EZ_ISOLATION,'host-capable')
|
|
25
|
+
assert.equal(a.EZ_EXECUTOR_TRANSPORT,'host')
|
|
26
|
+
assert.equal(b.EZ_ISOLATION,'host-capable')
|
|
27
|
+
assert.equal(b.EZ_EXECUTOR_TRANSPORT,'host')
|
|
28
|
+
assert.equal(JSON.parse(await readFile(join(root,'family/agent.json'),'utf8')).isolation,'host-capable')
|
|
23
29
|
assert.equal(a.EZ_RELAY_IMAGE,'ezenciel-agents:install-candidate')
|
|
24
30
|
assert.equal(b.EZ_RELAY_IMAGE,'ezenciel-agents:local')
|
|
25
31
|
await assert.rejects(createAgent({...base,name:'bad-image',image:"bad'\nINJECT=yes"}),/Invalid relay image/)
|
|
@@ -30,7 +36,17 @@ test('new agents receive independent projects, secrets and plugin bindings; dupl
|
|
|
30
36
|
await assert.rejects(createAgent({...base,name:'family'}),{code:'EEXIST'})
|
|
31
37
|
assert.equal((await readFile(join(root,'family/relay.env'),'utf8')).includes(base.token),true)
|
|
32
38
|
await assert.rejects(createAgent({...base,name:'../family'}),/agent name/)
|
|
39
|
+
await assert.rejects(createAgent({...base,name:'bad-class',isolation:'seatbelt'}),/isolated or host-capable/)
|
|
40
|
+
await assert.rejects(createAgent({...base,name:'unsupported-isolated',isolation:'isolated'}),/unavailable for grok/)
|
|
41
|
+
const capable=await createAgent({...base,name:'capable',isolation:'host-capable'})
|
|
42
|
+
assert.equal(capable.isolation,'host-capable')
|
|
43
|
+
const capableEnv=parseEnv(await readFile(join(root,'capable/docker.env'),'utf8'))
|
|
44
|
+
assert.equal(capableEnv.EZ_ISOLATION,'host-capable')
|
|
45
|
+
assert.equal(capableEnv.EZ_EXECUTOR_TRANSPORT,'host')
|
|
46
|
+
assert.equal(JSON.parse(await readFile(join(root,'capable/host-executor.json'),'utf8')).isolation,'host-capable')
|
|
47
|
+
assert.equal(a.EZ_CODEX_SANDBOX,undefined)
|
|
33
48
|
await assert.rejects(createAgent({...base,name:'bad',token:'do-not-echo'}),e=>!String(e).includes('do-not-echo'))
|
|
49
|
+
await assert.rejects(createAgent({...base,name:'verbose',purpose:'x'.repeat(2001)}),/concise purpose/)
|
|
34
50
|
} finally { await rm(root,{recursive:true,force:true}) }
|
|
35
51
|
})
|
|
36
52
|
|
|
@@ -42,7 +58,25 @@ test('package installer records its CLI before any agent exists; creation inheri
|
|
|
42
58
|
assert.equal(await installationCli(root,'codex'),'codex')
|
|
43
59
|
const agent=await createAgent({root,hostRoot:'/private/agents',composeFile:'/opt/ez/compose.yaml',name:'shopper',purpose:'Family shopping',token:'123456789:'+'a'.repeat(30)})
|
|
44
60
|
assert.equal(agent.executor,'codex')
|
|
61
|
+
assert.equal(agent.isolation,'isolated')
|
|
62
|
+
const env=parseEnv(await readFile(join(root,'shopper/docker.env'),'utf8'))
|
|
63
|
+
assert.equal(env.EZ_ISOLATION,'isolated')
|
|
64
|
+
assert.equal(env.EZ_EXECUTOR_TRANSPORT,'local')
|
|
65
|
+
assert.equal(env.EZ_CODEX_SANDBOX,'external')
|
|
45
66
|
assert.equal(await installationCli(root),'codex')
|
|
46
67
|
await assert.rejects(installationCli(root,'grok'),/already selected/)
|
|
47
68
|
} finally { await rm(root,{recursive:true,force:true}) }
|
|
48
69
|
})
|
|
70
|
+
|
|
71
|
+
test('agent installation records an isolated Codex provider profile without its key', async () => {
|
|
72
|
+
const root=await mkdtemp(join(tmpdir(),'ez-installer-provider-'))
|
|
73
|
+
try {
|
|
74
|
+
const codexProvider={id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash','deepseek/deepseek-v4.1-flash']}
|
|
75
|
+
const agent=await createAgent({root,hostRoot:'/private/agents',composeFile:'/opt/ez/compose.yaml',name:'coach',purpose:'Fitness coach',token:'123456789:'+'a'.repeat(30),cli:'codex',codexProvider})
|
|
76
|
+
const host=JSON.parse(await readFile(join(root,'coach/host-executor.json'),'utf8'))
|
|
77
|
+
assert.deepEqual(host.agents[0].codexProviders,[codexProvider])
|
|
78
|
+
assert.equal(JSON.stringify(agent).includes('OPENROUTER_API_KEY'),false)
|
|
79
|
+
assert.equal(JSON.stringify(host).includes('sk-'),false)
|
|
80
|
+
await assert.rejects(createAgent({root,hostRoot:'/private/agents',composeFile:'/opt/ez/compose.yaml',name:'bad-provider',purpose:'x',token:'123456789:'+'b'.repeat(30),codexProvider:{...codexProvider,baseUrl:'http://openrouter.ai/api/v1'}}),/provider URL/)
|
|
81
|
+
} finally { await rm(root,{recursive:true,force:true}) }
|
|
82
|
+
})
|
package/test/ai-cli.test.ts
CHANGED
|
@@ -36,3 +36,21 @@ test('AI help is available without a bound control directory or installed engine
|
|
|
36
36
|
assert.equal(result.status,0,result.stderr)
|
|
37
37
|
assert.match(result.stdout,/select --cli/)
|
|
38
38
|
})
|
|
39
|
+
|
|
40
|
+
test('an agent can select a declared provider and model without putting the key in control state',async()=>{
|
|
41
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-ai-provider-'))
|
|
42
|
+
try{
|
|
43
|
+
const controlDir=path.join(root,'control'),binDir=path.join(root,'bin')
|
|
44
|
+
await mkdir(path.join(controlDir,'host-executor'),{recursive:true});await mkdir(binDir)
|
|
45
|
+
await writeFile(path.join(binDir,'codex'),'#!/bin/sh\nexit 0\n',{mode:0o700})
|
|
46
|
+
await writeFile(path.join(controlDir,'host-executor','models.json'),JSON.stringify([{cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',name:'Gemini',efforts:[]}]))
|
|
47
|
+
const store=new ControlStore(controlDir,900000);await store.aiState(initialPreset('codex'))
|
|
48
|
+
const env={...process.env,HOME:root,PATH:binDir+path.delimiter+process.env.PATH,EZ_CONTROL_DIR:controlDir,OPENROUTER_API_KEY:'must-not-persist'}
|
|
49
|
+
const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
|
|
50
|
+
const result=spawnSync(process.execPath,[bin,'select','--cli','codex','--provider','openrouter','--model','google/gemini-3.8-flash'],{env,encoding:'utf8'})
|
|
51
|
+
assert.equal(result.status,0,result.stderr)
|
|
52
|
+
const state=await store.status(),selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
|
|
53
|
+
assert.equal(selected?.provider,'openrouter');assert.equal(selected?.model,'google/gemini-3.8-flash')
|
|
54
|
+
assert.equal(JSON.stringify(state).includes('must-not-persist'),false)
|
|
55
|
+
}finally{await rm(root,{recursive:true,force:true})}
|
|
56
|
+
})
|
package/test/ai.test.ts
CHANGED
|
@@ -11,6 +11,54 @@ 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
|
+
test('same-client model switch does not resume the previous native thread', async () => {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-model-switch-'))
|
|
16
|
+
try {
|
|
17
|
+
const store = new ControlStore(dir, 1000)
|
|
18
|
+
const deepseek = { id: 'deepseek', name: 'DeepSeek', cli: 'codex', model: 'deepseek/flash', effort: 'max' }
|
|
19
|
+
const luna = { id: 'luna', name: 'Luna', cli: 'codex', model: 'gpt-5.6-luna', effort: 'max' }
|
|
20
|
+
const first = await store.captureChoice(initialPreset('codex'))
|
|
21
|
+
await store.savePreset(deepseek)
|
|
22
|
+
await store.selectPreset(deepseek.id, first.sessionId, false)
|
|
23
|
+
const started = await store.captureChoice(initialPreset('codex'))
|
|
24
|
+
await store.saveNativeSession(started.sessionId, 'native-deepseek')
|
|
25
|
+
const same = await store.captureChoice(initialPreset('codex'))
|
|
26
|
+
assert.equal(same.sessionId, started.sessionId)
|
|
27
|
+
assert.equal((await store.executionSession(same)).nativeSessionId, 'native-deepseek')
|
|
28
|
+
await store.savePreset(luna)
|
|
29
|
+
await store.selectPreset(luna.id, started.sessionId, false)
|
|
30
|
+
const next = await store.captureChoice(initialPreset('codex'))
|
|
31
|
+
assert.notEqual(next.sessionId, started.sessionId)
|
|
32
|
+
assert.equal(next.preset.model, luna.model)
|
|
33
|
+
assert.equal((await store.executionSession(next)).hasStarted, false)
|
|
34
|
+
assert.equal((await store.executionSession(next)).nativeSessionId, undefined)
|
|
35
|
+
assert.equal((await store.executionSession(started)).nativeSessionId, 'native-deepseek')
|
|
36
|
+
await store.saveNativeSession(next.sessionId, 'native-luna')
|
|
37
|
+
const clientDefault = initialPreset('codex')
|
|
38
|
+
await store.savePreset(clientDefault)
|
|
39
|
+
await store.selectPreset(clientDefault.id, next.sessionId, false)
|
|
40
|
+
const defaultChoice = await store.captureChoice(clientDefault)
|
|
41
|
+
assert.notEqual(defaultChoice.sessionId, next.sessionId)
|
|
42
|
+
assert.equal(defaultChoice.preset.model, undefined)
|
|
43
|
+
await store.saveNativeSession(defaultChoice.sessionId, 'native-default')
|
|
44
|
+
await store.selectPreset(deepseek.id, defaultChoice.sessionId, false)
|
|
45
|
+
const explicitAgain = await store.captureChoice(clientDefault)
|
|
46
|
+
assert.notEqual(explicitAgain.sessionId, defaultChoice.sessionId)
|
|
47
|
+
assert.equal(explicitAgain.preset.model, deepseek.model)
|
|
48
|
+
await store.saveNativeSession(explicitAgain.sessionId, 'native-explicit')
|
|
49
|
+
const openrouter = { ...deepseek, id: 'openrouter-deepseek', name: 'OpenRouter DeepSeek', provider: 'openrouter' }
|
|
50
|
+
await store.savePreset(openrouter)
|
|
51
|
+
await store.selectPreset(openrouter.id, explicitAgain.sessionId, false)
|
|
52
|
+
const providerChoice = await store.captureChoice(clientDefault)
|
|
53
|
+
assert.notEqual(providerChoice.sessionId, explicitAgain.sessionId)
|
|
54
|
+
assert.equal(providerChoice.preset.provider, 'openrouter')
|
|
55
|
+
const args = EXECUTOR_REGISTRY.codex.buildArgs({
|
|
56
|
+
workspace: dir, isResume: false, sessionId: next.sessionId,
|
|
57
|
+
}, '', '')
|
|
58
|
+
assert.equal(args.includes('resume'), false)
|
|
59
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
60
|
+
})
|
|
61
|
+
|
|
14
62
|
test('AI choices pin model, effort and session; defaults and CLI switches do not reroute old work', async () => {
|
|
15
63
|
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-'))
|
|
16
64
|
try {
|
|
@@ -84,7 +84,9 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
|
|
|
84
84
|
await runs.claimOutbox(message.id)
|
|
85
85
|
await channel.deliver(run,message)
|
|
86
86
|
assert.deepEqual(await runs.waitForDelivery(message.id),{delivered:true})
|
|
87
|
-
|
|
87
|
+
const snapshot=await channel.snapshot(first.bindingId,run.id)
|
|
88
|
+
assert.deepEqual(snapshot.messages,[{id:message.id,text:'Here is the proposal'}])
|
|
89
|
+
assert.deepEqual(snapshot.preset,run.execution?.preset)
|
|
88
90
|
const approval=await runs.enqueueApproval(run.id,'Allow this channel grant?','task_'+'a'.repeat(32))
|
|
89
91
|
await runs.claimOutbox(approval.id);await channel.deliver(run,approval)
|
|
90
92
|
assert.deepEqual((await channel.snapshot(first.bindingId,run.id)).approvals,[{id:'task_'+'a'.repeat(32),prompt:'Allow this channel grant?',state:'delivered'}])
|
|
@@ -202,8 +204,9 @@ test('application AI choice retains same-engine history and rejects cross-engine
|
|
|
202
204
|
t.after(() => rm(root, { recursive: true, force: true }))
|
|
203
205
|
const channel = new ApplicationChannel({ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {} })
|
|
204
206
|
const binding = (await channel.bindings.register('app', token(), await owner(root)))!
|
|
205
|
-
const input = { requestId: 'one', scope: 'main:codex', text: 'Hello', ai: { cli: 'codex', model: 'gpt-5.6-luna', effort: 'high' } }
|
|
207
|
+
const input = { requestId: 'one', scope: 'main:codex', text: 'Hello', ai: { cli: 'codex', provider:'openrouter', model: 'gpt-5.6-luna', effort: 'high' } }
|
|
206
208
|
const first = await channel.submit(binding.bindingId, input)
|
|
209
|
+
assert.equal(first.execution!.preset.provider,'openrouter')
|
|
207
210
|
assert.equal(first.execution!.preset.model, input.ai.model)
|
|
208
211
|
const second = await channel.submit(binding.bindingId, { ...input, requestId: 'two', ai: { ...input.ai, model: 'gpt-5.6-terra' } })
|
|
209
212
|
assert.equal(first.execution!.sessionId, second.execution!.sessionId)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, rm, open } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, rm, open, mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { randomBytes } from 'node:crypto'
|
|
@@ -9,6 +9,41 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
9
9
|
import { createAiMenu } from '../src/menu.js'
|
|
10
10
|
import { RunStore } from '../src/runs.js'
|
|
11
11
|
|
|
12
|
+
test('shared controls validate detected host presets against the host catalog', async t => {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-host-controls-'))
|
|
14
|
+
const previousTransport = process.env.EZ_EXECUTOR_TRANSPORT
|
|
15
|
+
const previousControl = process.env.EZ_CONTROL_DIR
|
|
16
|
+
process.env.EZ_EXECUTOR_TRANSPORT = 'host'
|
|
17
|
+
process.env.EZ_CONTROL_DIR = root
|
|
18
|
+
t.after(async () => {
|
|
19
|
+
if (previousTransport === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
20
|
+
else process.env.EZ_EXECUTOR_TRANSPORT = previousTransport
|
|
21
|
+
if (previousControl === undefined) delete process.env.EZ_CONTROL_DIR
|
|
22
|
+
else process.env.EZ_CONTROL_DIR = previousControl
|
|
23
|
+
await rm(root, {recursive:true, force:true})
|
|
24
|
+
})
|
|
25
|
+
await mkdir(join(root, 'host-executor'), {recursive:true})
|
|
26
|
+
await writeFile(join(root, 'host-executor', 'models.json'), JSON.stringify([
|
|
27
|
+
{cli:'grok', model:'grok-4.6', name:'Grok 4.6', efforts:['high']},
|
|
28
|
+
]))
|
|
29
|
+
const control = new ControlStore(root, 1000)
|
|
30
|
+
await control.requestPairing(42, 42); const owner = await control.approveOwner(42)
|
|
31
|
+
const menu = createAiMenu(control, 'codex')
|
|
32
|
+
const detected = {id:'detected_fixture', name:'Grok 4.6 High', cli:'grok', model:'grok-4.6', effort:'high'}
|
|
33
|
+
await control.aiState(menu.initial)
|
|
34
|
+
await control.savePreset(detected)
|
|
35
|
+
const initial = await control.captureChoice(menu.initial)
|
|
36
|
+
await control.selectPreset(detected.id, initial.sessionId, true)
|
|
37
|
+
const current = (await control.getActiveSession())!
|
|
38
|
+
const channel = new ApplicationChannel({controlDir:root, initial:menu.initial, aiControls:menu, wake:()=>{}, cancel:async()=>{}})
|
|
39
|
+
const binding = (await channel.bindings.register('shared', randomBytes(32).toString('base64url'), owner, true))!
|
|
40
|
+
|
|
41
|
+
const selected = await channel.changeControls(binding.bindingId, {
|
|
42
|
+
action:'select', presetId:detected.id, expectedSession:current.sessionId,
|
|
43
|
+
})
|
|
44
|
+
assert.equal(selected.ai.selectedId, detected.id)
|
|
45
|
+
})
|
|
46
|
+
|
|
12
47
|
test('private scope reset and model controls preserve running sessions and shared state', async t => {
|
|
13
48
|
const root = await mkdtemp(join(tmpdir(),'ez-private-controls-'))
|
|
14
49
|
const control = new ControlStore(root,1000)
|
|
@@ -75,6 +110,7 @@ test('application controls share native choices, protect hidden scopes and prese
|
|
|
75
110
|
await control.requestPairing(42, 42); const owner = await control.approveOwner(42)
|
|
76
111
|
const menu = createAiMenu(control, 'codex', async () => [
|
|
77
112
|
{cli:'codex', model:'gpt-6-astra', name:'Fixture model', efforts:['low','high']},
|
|
113
|
+
{cli:'codex', provider:'openrouter', model:'gpt-6-astra', name:'OpenRouter fixture', efforts:['low','high']},
|
|
78
114
|
], root, join(root, 'native-home'), async () => true)
|
|
79
115
|
const channel = new ApplicationChannel({controlDir:root, initial:menu.initial, aiControls:menu, wake:()=>{}, cancel:async()=>{}})
|
|
80
116
|
t.after(async()=>{await channel.stop(); await rm(root,{recursive:true,force:true})})
|
|
@@ -107,9 +143,11 @@ test('application controls share native choices, protect hidden scopes and prese
|
|
|
107
143
|
assert.deepEqual((await control.aiState(menu.initial)).presets,presetsBefore)
|
|
108
144
|
const selected = await request({action:'model',cli:'codex',model:'gpt-6-astra',effort:'high',expectedSession:old.sessionId})
|
|
109
145
|
assert.equal(selected.status,200)
|
|
110
|
-
|
|
146
|
+
const modelChoice = await control.captureChoice(menu.initial)
|
|
147
|
+
assert.equal(modelChoice.preset.effort,'high')
|
|
148
|
+
assert.notEqual(modelChoice.sessionId,old.sessionId)
|
|
111
149
|
assert.deepEqual((await new RunStore(root).get(run.id))!.execution,old)
|
|
112
|
-
const reset = await request({action:'new',expectedSession:
|
|
150
|
+
const reset = await request({action:'new',expectedSession:modelChoice.sessionId})
|
|
113
151
|
assert.equal(reset.status,200)
|
|
114
152
|
const next = (await control.getActiveSession())!
|
|
115
153
|
assert.notEqual(next.sessionId,old.sessionId)
|
|
@@ -119,6 +157,21 @@ test('application controls share native choices, protect hidden scopes and prese
|
|
|
119
157
|
assert.equal((await request()).status,401)
|
|
120
158
|
})
|
|
121
159
|
|
|
160
|
+
test('application model controls disambiguate the same model by provider', async t => {
|
|
161
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-provider-control-')),control=new ControlStore(root,1000)
|
|
162
|
+
await control.requestPairing(42,42);const owner=await control.approveOwner(42)
|
|
163
|
+
const menu=createAiMenu(control,'codex',async()=>[
|
|
164
|
+
{cli:'codex',model:'shared-model',name:'Native',efforts:[]},
|
|
165
|
+
{cli:'codex',provider:'openrouter',model:'shared-model',name:'OpenRouter',efforts:[]},
|
|
166
|
+
],root,join(root,'native-home'),async()=>true)
|
|
167
|
+
const channel=new ApplicationChannel({controlDir:root,initial:menu.initial,aiControls:menu,wake:()=>{},cancel:async()=>{}})
|
|
168
|
+
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
169
|
+
const binding=(await channel.bindings.register('shared',randomBytes(32).toString('base64url'),owner,true))!
|
|
170
|
+
const current=await control.captureChoice(menu.initial)
|
|
171
|
+
await channel.changeControls(binding.bindingId,{action:'model',cli:'codex',provider:'openrouter',model:'shared-model',expectedSession:current.sessionId})
|
|
172
|
+
assert.equal((await control.captureChoice(menu.initial)).preset.provider,'openrouter')
|
|
173
|
+
})
|
|
174
|
+
|
|
122
175
|
test('atomic control mutations reject a replaced owner even when both active sessions are empty', async t => {
|
|
123
176
|
const root = await mkdtemp(join(tmpdir(),'ez-control-owner-'))
|
|
124
177
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
@@ -13,6 +13,7 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
13
13
|
import { RunStore } from '../src/runs.js'
|
|
14
14
|
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
15
15
|
import { ApplicationBindings } from '../src/application-channel.js'
|
|
16
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
16
17
|
|
|
17
18
|
const waitFor = async (condition: () => Promise<boolean>) => {
|
|
18
19
|
for (let i=0;i<300;i++) { if (await condition()) return; await new Promise(r=>setTimeout(r,10)) }
|
|
@@ -60,6 +61,7 @@ test('administrator bootstrap is explicit, local-only and cannot replace identit
|
|
|
60
61
|
|
|
61
62
|
test('botless daemon executes application turn and rejects Telegram-origin work/outbound', async t => {
|
|
62
63
|
const root=await mkdtemp(join(tmpdir(),'ez-app-only-runtime-'))
|
|
64
|
+
const purpose=join(root,'purpose.md');await writeFile(purpose,'Fixture application agent\n');await initializeWorkspace(root,purpose)
|
|
63
65
|
const portServer=createServer();await new Promise<void>(r=>portServer.listen(0,'127.0.0.1',r))
|
|
64
66
|
const port=(portServer.address() as {port:number}).port;await new Promise<void>(r=>portServer.close(()=>r()))
|
|
65
67
|
const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:String(port),EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'codex'})
|
|
@@ -6,15 +6,67 @@ import {tmpdir} from 'node:os'
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import {startExecutorJob} from '../src/executor.js'
|
|
8
8
|
|
|
9
|
+
test('a package-bin Codex shim cannot retarget another agent home', async () => {
|
|
10
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-codex-host-cli-'))
|
|
11
|
+
const hostBin = path.join(root, 'host-bin')
|
|
12
|
+
const packageBin = path.join(root, 'package-bin')
|
|
13
|
+
const controlDir = path.join(root, 'agent')
|
|
14
|
+
const priorHome = process.env.HOME, priorPath = process.env.PATH
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(hostBin); await mkdir(packageBin); await mkdir(path.join(root, '.codex'))
|
|
17
|
+
await writeFile(path.join(root, '.codex/auth.json'), '{}')
|
|
18
|
+
await writeFile(path.join(packageBin, 'codex'), `#!${process.execPath}
|
|
19
|
+
require('fs').writeFileSync(require('path').join(process.env.HOME, 'hijacked'), 'yes'); process.exit(2)
|
|
20
|
+
`, { mode: 0o700 })
|
|
21
|
+
await writeFile(path.join(hostBin, 'codex'), `#!${process.execPath}\nconsole.log(process.env.CODEX_HOME)`, { mode: 0o700 })
|
|
22
|
+
process.env.HOME = root
|
|
23
|
+
process.env.PATH = hostBin + path.delimiter + (priorPath ?? '')
|
|
24
|
+
await ownerRun(controlDir, 'r_test')
|
|
25
|
+
const job = await startExecutorJob(['hello'], {
|
|
26
|
+
workspace: root, controlDir, binDir: packageBin, cli: 'codex', runId: 'r_test', timeoutMs: 5000,
|
|
27
|
+
})
|
|
28
|
+
let output = ''
|
|
29
|
+
job.child.stdout?.on('data', chunk => output += chunk)
|
|
30
|
+
assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0)
|
|
31
|
+
await job.cleanup()
|
|
32
|
+
assert.equal(output.trim(), path.join(controlDir, 'cli/codex'))
|
|
33
|
+
await assert.rejects(readFile(path.join(root, 'hijacked')))
|
|
34
|
+
} finally {
|
|
35
|
+
if (priorHome === undefined) delete process.env.HOME; else process.env.HOME = priorHome
|
|
36
|
+
if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
|
|
37
|
+
await rm(root, { recursive: true, force: true })
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('a package-bin Codex shim is not a fallback when the host CLI is missing', async () => {
|
|
42
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-codex-host-cli-missing-'))
|
|
43
|
+
const packageBin = path.join(root, 'package-bin')
|
|
44
|
+
const controlDir = path.join(root, 'agent')
|
|
45
|
+
const priorPath = process.env.PATH
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(packageBin)
|
|
48
|
+
await writeFile(path.join(packageBin, 'codex'), `#!${process.execPath}\nprocess.exit(91)\n`, { mode: 0o700 })
|
|
49
|
+
process.env.PATH = '/usr/bin:/bin'
|
|
50
|
+
await ownerRun(controlDir, 'r_test')
|
|
51
|
+
await assert.rejects(startExecutorJob(['hello'], {
|
|
52
|
+
workspace: root, controlDir, binDir: packageBin, cli: 'codex', runId: 'r_test', timeoutMs: 5000,
|
|
53
|
+
}), /Native CLI codex is not executable on the host PATH/)
|
|
54
|
+
} finally {
|
|
55
|
+
if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
|
|
56
|
+
await rm(root, { recursive: true, force: true })
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
9
60
|
test('Codex shares only auth through a link and keeps each agent runtime state separate',async()=>{
|
|
10
61
|
const root=await mkdtemp(path.join(tmpdir(),'ez-codex-context-'))
|
|
11
|
-
const priorHome=process.env.HOME,priorPath=process.env.PATH
|
|
62
|
+
const priorHome=process.env.HOME,priorPath=process.env.PATH,priorIsolation=process.env.EZ_ISOLATION
|
|
12
63
|
try{
|
|
13
64
|
await mkdir(path.join(root,'.codex'));await mkdir(path.join(root,'bin'))
|
|
14
65
|
await writeFile(path.join(root,'.codex/auth.json'),'{}')
|
|
15
66
|
await writeFile(path.join(root,'.codex/config.toml'),'# unrelated personal config')
|
|
16
67
|
await writeFile(path.join(root,'bin/codex'),`#!${process.execPath}\nconsole.log(process.env.CODEX_HOME)`,{mode:0o700})
|
|
17
68
|
process.env.HOME=root;process.env.PATH=path.join(root,'bin')+path.delimiter+priorPath
|
|
69
|
+
delete process.env.EZ_ISOLATION
|
|
18
70
|
for(const agent of ['one','two']){
|
|
19
71
|
const controlDir=path.join(root,agent)
|
|
20
72
|
await ownerRun(controlDir, 'r_test')
|
|
@@ -27,9 +79,17 @@ test('Codex shares only auth through a link and keeps each agent runtime state s
|
|
|
27
79
|
assert.deepEqual(await readdir(home),['auth.json'])
|
|
28
80
|
assert.equal(await readlink(path.join(home,'auth.json')),path.join(root,'.codex/auth.json'))
|
|
29
81
|
}
|
|
82
|
+
process.env.EZ_ISOLATION='isolated'
|
|
83
|
+
const isolated=path.join(root,'isolated')
|
|
84
|
+
await ownerRun(isolated, 'r_test')
|
|
85
|
+
const isolatedJob=await startExecutorJob(['hello'],{workspace:root,controlDir:isolated,binDir:path.join(root,'bin'),cli:'codex',runId:'r_test',timeoutMs:5000})
|
|
86
|
+
assert.equal(await new Promise(resolve=>isolatedJob.child.once('close',resolve)),0)
|
|
87
|
+
await isolatedJob.cleanup()
|
|
88
|
+
await assert.rejects(readlink(path.join(isolated,'cli/codex/auth.json')),{code:'ENOENT'})
|
|
30
89
|
}finally{
|
|
31
90
|
if(priorHome===undefined)delete process.env.HOME;else process.env.HOME=priorHome
|
|
32
91
|
if(priorPath===undefined)delete process.env.PATH;else process.env.PATH=priorPath
|
|
92
|
+
if(priorIsolation===undefined)delete process.env.EZ_ISOLATION;else process.env.EZ_ISOLATION=priorIsolation
|
|
33
93
|
await rm(root,{recursive:true,force:true})
|
|
34
94
|
}
|
|
35
95
|
})
|
|
@@ -43,7 +43,8 @@ if(q.method==='thread/goal/get'){
|
|
|
43
43
|
child.stdin.write=((chunk:any,...args:any[])=>{try{const q=JSON.parse(String(chunk));if(q.method==='turn/start'){turnPrompt=q.params.input[0].text;turnPolicy=q.params.sandboxPolicy}requests.push(q.method);if(q.method==='thread/start'){threadConfig=q.params.config;threadSandbox=q.params.sandbox}}catch{};return (write as any)(chunk,...args)}) as typeof child.stdin.write
|
|
44
44
|
return child
|
|
45
45
|
}
|
|
46
|
-
const
|
|
46
|
+
const codexProvider=mode==='tool-goal'?{id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['open/model']}:undefined
|
|
47
|
+
const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',additionalWorkspaces:['/agent/mind/work'],prompt,codexProvider,model:codexProvider?'open/model':undefined,
|
|
47
48
|
...(mode==='external'?{codexSandbox:'external' as const}:{})},{launch,emit:line=>output.push(line)})
|
|
48
49
|
assert.equal(threadSandbox!,mode==='external'?'danger-full-access':'workspace-write')
|
|
49
50
|
assert.deepEqual(turnPolicy,mode==='external'?{type:'externalSandbox',networkAccess:'enabled'}:undefined)
|
|
@@ -52,6 +53,10 @@ if(q.method==='thread/goal/get'){
|
|
|
52
53
|
if(mode==='long-goal')assert.ok(prompt.length>4000)
|
|
53
54
|
assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/canonical'))
|
|
54
55
|
assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/agent/mind/work'))
|
|
56
|
+
if(codexProvider){
|
|
57
|
+
assert.equal(threadConfig.model_provider,'openrouter')
|
|
58
|
+
assert.deepEqual(threadConfig['model_providers.openrouter'],{name:'OpenRouter',base_url:'https://openrouter.ai/api/v1',env_key:'OPENROUTER_API_KEY',wire_api:'responses',supports_websockets:false})
|
|
59
|
+
}
|
|
55
60
|
assert.equal(result,['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
|
|
56
61
|
assert.equal(requests.filter(x=>x==='turn/start').length,1,'transport must not send goal continuation prompts')
|
|
57
62
|
assert.equal(requests.filter(x=>x==='thread/goal/set').length,0)
|
package/test/config.test.ts
CHANGED
|
@@ -65,8 +65,23 @@ test('external Codex isolation requires explicit native local deployment', () =>
|
|
|
65
65
|
assert.equal(loadConfig(env).codexSandbox, 'external')
|
|
66
66
|
assert.equal(loadConfig({...env,EZ_TELEGRAM_ENABLED:'true',TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, 'external')
|
|
67
67
|
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, undefined)
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
assert.equal(loadConfig({...env,EZ_EXECUTOR_TRANSPORT:''}).isolation, 'isolated')
|
|
69
|
+
assert.throws(() => loadConfig({...env,EZ_EXECUTOR_TRANSPORT:'host'}), /sandbox|SANDBOX|Isolation/)
|
|
70
|
+
assert.throws(() => loadConfig({...env,EZ_CODEX_SANDBOX:'danger-full-access'}), /sandbox|SANDBOX/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('isolation class is isolated by default and rejects host transport mismatch', () => {
|
|
74
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).isolation, 'isolated')
|
|
75
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'local'}).isolation, 'isolated')
|
|
76
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
|
|
77
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
|
|
78
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'host'}), /Isolation isolated requires EZ_EXECUTOR_TRANSPORT=local/)
|
|
79
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'local'}), /Isolation host-capable requires EZ_EXECUTOR_TRANSPORT=host/)
|
|
80
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'seatbelt'}), /isolated or host-capable/)
|
|
81
|
+
assert.throws(() => loadConfig({
|
|
82
|
+
TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host',
|
|
83
|
+
EZ_CHANNEL_BACKEND_URL:'https://backend.example',EZ_CHANNEL_BACKEND_TOKEN:'secret',
|
|
84
|
+
}), /Host-capable isolation requires the host native CLI/)
|
|
70
85
|
})
|
|
71
86
|
|
|
72
87
|
test('optional web launcher preserves reserved commands and accepts only HTTPS without secrets', () => {
|
|
@@ -13,7 +13,7 @@ test('owner, resumed and native scheduled subprocesses receive literal input and
|
|
|
13
13
|
const root=await mkdtemp(path.join(tmpdir(),'ez-literal-'))
|
|
14
14
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
15
15
|
const bin=path.join(root,'bin'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
|
|
16
|
-
await mkdir(bin);await initializeWorkspace(workspace)
|
|
16
|
+
await mkdir(bin);const purpose=path.join(root,'purpose.md');await writeFile(purpose,'Fixture agent\n');await initializeWorkspace(workspace,purpose)
|
|
17
17
|
const fixture=`#!${process.execPath}
|
|
18
18
|
const fs=require('fs'), args=process.argv.slice(2);
|
|
19
19
|
const capture=prompt=>fs.writeFileSync('capture.json',JSON.stringify({prompt,args,env:{run:process.env.EZ_RUN_ID,control:process.env.EZ_CONTROL_DIR,repair:process.env.EZ_REPAIR_ENABLED,secret:process.env.TELEGRAM_BOT_TOKEN}}));
|
|
@@ -44,7 +44,10 @@ if(args[0]==='app-server'){
|
|
|
44
44
|
const captured=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
|
|
45
45
|
assert.equal(captured.prompt,text)
|
|
46
46
|
assert.deepEqual(captured.env,{run:runId,control:controlDir,repair:'false'})
|
|
47
|
-
if(cli==='codex')
|
|
47
|
+
if(cli==='codex'){
|
|
48
|
+
assert.equal(captured.args.at(-1),'-')
|
|
49
|
+
if(process.platform==='darwin')assert.equal(captured.args[captured.args.indexOf('--sandbox')+1],'danger-full-access')
|
|
50
|
+
}
|
|
48
51
|
if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
|
|
49
52
|
if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
|
|
50
53
|
if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
|
package/test/executor.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
|
|
9
|
+
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
10
10
|
import { splitTelegramText } from '../src/reply.js'
|
|
11
11
|
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
12
12
|
|
|
@@ -54,6 +54,11 @@ test('Telegram replies are split within the configured message limit', () => {
|
|
|
54
54
|
assert.ok(chunks.every((chunk) => chunk.length <= 10))
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
test('native CLIs resolve from the host PATH, not a relative package name', () => {
|
|
58
|
+
assert.equal(resolveHostCommand('/usr/bin/codex'), '/usr/bin/codex')
|
|
59
|
+
assert.throws(() => resolveHostCommand('missing-cli-xyz', '/usr/bin'), /not executable on the host PATH/)
|
|
60
|
+
})
|
|
61
|
+
|
|
57
62
|
test('the executor receives a deliberately small environment', () => {
|
|
58
63
|
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', PAGERDUTY_ROUTING_KEY: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
|
|
59
64
|
assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
|
|
@@ -158,6 +163,38 @@ test('Codex jobs disable global memory and host skill discovery', () => {
|
|
|
158
163
|
assert.equal(args[args.indexOf('skip_host_skill_discovery')-1],'--enable')
|
|
159
164
|
})
|
|
160
165
|
|
|
166
|
+
test('Codex custom providers use declared runtime state and an environment key reference', () => {
|
|
167
|
+
const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1/',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
|
|
168
|
+
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',model:'google/gemini-3.8-flash',codexProvider:provider},'','hello')
|
|
169
|
+
assert.ok(args.includes('model_provider="openrouter"'))
|
|
170
|
+
assert.ok(args.some(value=>value.includes('env_key="OPENROUTER_API_KEY"')))
|
|
171
|
+
assert.ok(args.includes('google/gemini-3.8-flash'))
|
|
172
|
+
assert.equal(JSON.stringify(args).includes('provider-secret'),false)
|
|
173
|
+
assert.throws(()=>validateCodexProvider({...provider,envKey:'TELEGRAM-BOT'}),/environment key/)
|
|
174
|
+
for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN','PAGERDUTY_ROUTING_KEY'])
|
|
175
|
+
assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
|
|
176
|
+
assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('a configured Codex provider receives only its declared key and runtime selection', async t => {
|
|
180
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
|
|
181
|
+
const bin=path.join(root,'bin');await mkdir(bin)
|
|
182
|
+
await writeFile(path.join(bin,'codex'),`#!${process.execPath}\nconst fs=require('fs');let text='';process.stdin.on('data',b=>text+=b);process.stdin.on('end',()=>fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN,text})));`,{mode:0o755})
|
|
183
|
+
const prior={path:process.env.PATH,key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN}
|
|
184
|
+
const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
|
|
185
|
+
await ownerRun(root,'r_provider')
|
|
186
|
+
try {
|
|
187
|
+
process.env.PATH=bin+path.delimiter+prior.path;process.env.OPENROUTER_API_KEY='provider-secret';process.env.TELEGRAM_BOT_TOKEN='relay-secret'
|
|
188
|
+
const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId:'r_provider',timeoutMs:0,cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',codexProvider:provider})
|
|
189
|
+
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
|
|
190
|
+
const observed=JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
|
|
191
|
+
assert.equal(observed.key,'provider-secret');assert.equal(observed.telegram,undefined);assert.equal(observed.text,'hello')
|
|
192
|
+
assert.ok(observed.args.includes('model_provider="openrouter"'));assert.ok(observed.args.includes('google/gemini-3.8-flash'))
|
|
193
|
+
} finally {
|
|
194
|
+
for(const [key,value] of [['PATH',prior.path],['OPENROUTER_API_KEY',prior.key],['TELEGRAM_BOT_TOKEN',prior.telegram]]) if(value===undefined)delete process.env[key!];else process.env[key!]=value
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
161
198
|
test('Codex plugin access stays scoped to the explicitly bound registry', () => {
|
|
162
199
|
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind',controlDir:'/agent/control',toolsHome:'/agent/tools',sharedWorkspace:'/app',additionalWorkspaces:['/agent/mind/work']},'', 'install a plugin')
|
|
163
200
|
assert.deepEqual(args.flatMap((arg,i)=>arg==='--add-dir'?[args[i+1]]:[]),['/agent/control','/app','/agent/mind/work','/agent/tools'])
|
package/test/failure.test.ts
CHANGED
|
@@ -15,12 +15,14 @@ import { initialPreset } from '../src/ai.js'
|
|
|
15
15
|
import { createRelay } from '../src/index.js'
|
|
16
16
|
import { TelegramSource } from '../src/telegram-source.js'
|
|
17
17
|
import { packageVersion } from '../src/version.js'
|
|
18
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
18
19
|
import type { Update } from 'grammy/types'
|
|
19
20
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
20
21
|
// This integration test starts subprocesses, persists their receipts, and invokes
|
|
21
22
|
// the schedule CLI. Give a busy CI worker time to settle without changing the
|
|
22
23
|
// production recovery deadline.
|
|
23
24
|
const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
|
|
25
|
+
const seedWorkspace=async(dir:string)=>{const purpose=join(dir,'purpose.md');await writeFile(purpose,'Fixture relay agent\n');await initializeWorkspace(dir,purpose)}
|
|
24
26
|
|
|
25
27
|
test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
|
|
26
28
|
const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
|
|
@@ -142,6 +144,7 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
|
|
|
142
144
|
|
|
143
145
|
test('a fresh relay initializes its bot identity before polling', async () => {
|
|
144
146
|
const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
|
|
147
|
+
await seedWorkspace(dir)
|
|
145
148
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
|
|
146
149
|
const methods:string[]=[]
|
|
147
150
|
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
@@ -165,6 +168,7 @@ test('a fresh relay initializes its bot identity before polling', async () => {
|
|
|
165
168
|
|
|
166
169
|
for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
|
|
167
170
|
const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
171
|
+
await seedWorkspace(dir)
|
|
168
172
|
let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
|
|
169
173
|
const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
|
|
170
174
|
const deliveryGate=new Promise<void>(resolve=>{releaseDelivery=resolve}),deliveryStarted=new Promise<void>(resolve=>{sending=resolve})
|
|
@@ -285,6 +289,7 @@ test('group members can inspect failures and wake review without exposing other
|
|
|
285
289
|
|
|
286
290
|
for (const failure of [400,401,503,'programming'] as const) test(`setup failure ${failure} retries only transient errors`,async t=>{
|
|
287
291
|
const dir=await mkdtemp(join(tmpdir(),'ez-setup-failure-'))
|
|
292
|
+
await seedWorkspace(dir)
|
|
288
293
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
|
|
289
294
|
let commands=0,polls=0
|
|
290
295
|
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
@@ -310,6 +315,7 @@ for (const failure of [400,401,503,'programming'] as const) test(`setup failure
|
|
|
310
315
|
|
|
311
316
|
test('shutdown aborts a pending bot initialization without starting polling',async()=>{
|
|
312
317
|
const dir=await mkdtemp(join(tmpdir(),'ez-init-stop-'))
|
|
318
|
+
await seedWorkspace(dir)
|
|
313
319
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
|
|
314
320
|
let initSignal:Parameters<typeof relay.bot.init>[0],polls=0
|
|
315
321
|
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
@@ -41,6 +41,12 @@ test('legacy host lock fails closed while its PID is alive',async()=>{
|
|
|
41
41
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
test('isolated installations cannot start host transport', async () => {
|
|
45
|
+
const abort=new AbortController()
|
|
46
|
+
await assert.rejects(serveHostExecutor({cli:'grok',isolation:'isolated',agents:[]},abort.signal),/Isolated agents run the native CLI in the relay/)
|
|
47
|
+
abort.abort()
|
|
48
|
+
})
|
|
49
|
+
|
|
44
50
|
test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
|
|
45
51
|
const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
|
|
46
52
|
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
|
|
@@ -14,8 +14,10 @@ test('configure preserves custom paths and unknown settings, keeps token private
|
|
|
14
14
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-'))
|
|
15
15
|
try {
|
|
16
16
|
await writeFile(path.join(root, '.env'), 'EZ_AGENT_WORKSPACE="my mind"\nEZ_CONTROL_DIR="private control"\nEXTRA="keep me"\n')
|
|
17
|
+
const purpose = path.join(root, 'purpose.md')
|
|
18
|
+
await writeFile(purpose, 'Configured assistant\n')
|
|
17
19
|
const token = '123456789:' + 'a'.repeat(30)
|
|
18
|
-
const result = await configureInstallation(root, 'codex', token)
|
|
20
|
+
const result = await configureInstallation(root, 'codex', token, purpose)
|
|
19
21
|
assert.equal(result.tokenConfigured, true)
|
|
20
22
|
assert.equal(JSON.stringify(result).includes(token), false)
|
|
21
23
|
const env = parseEnv(await readFile(result.envFile, 'utf8'))
|
|
@@ -24,12 +26,12 @@ test('configure preserves custom paths and unknown settings, keeps token private
|
|
|
24
26
|
assert.equal(env.EZ_AGENT_WORKSPACE, path.join(root, 'my mind'))
|
|
25
27
|
assert.equal((await stat(result.envFile)).mode & 0o777, 0o600)
|
|
26
28
|
assert.equal(await readActiveExecutor(result.envFile), 'codex')
|
|
27
|
-
const
|
|
28
|
-
await writeFile(
|
|
29
|
+
const agents = path.join(result.workspace, 'AGENTS.md')
|
|
30
|
+
await writeFile(agents, 'Customized purpose')
|
|
29
31
|
const repeated = await configureInstallation(root, 'codex')
|
|
30
32
|
assert.deepEqual(repeated.created, [])
|
|
31
33
|
assert.equal(repeated.tokenConfigured, true)
|
|
32
|
-
assert.
|
|
34
|
+
assert.match(await readFile(agents, 'utf8'), /Customized purpose/)
|
|
33
35
|
await assert.rejects(stat(result.controlDir), { code: 'ENOENT' }) // No control/owner mutation.
|
|
34
36
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
35
37
|
})
|
|
@@ -56,15 +58,24 @@ test('packaged configure accepts token through stdin without echo or extra initi
|
|
|
56
58
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-cli-'))
|
|
57
59
|
try {
|
|
58
60
|
const token = '123456789:' + 'b'.repeat(30)
|
|
61
|
+
const purpose = path.join(root, 'purpose.md')
|
|
62
|
+
await writeFile(purpose, 'Packaged assistant\n')
|
|
59
63
|
const bin = fileURLToPath(new URL('../bin/ezenciel-agents-setup.mjs', import.meta.url))
|
|
60
|
-
const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
|
|
64
|
+
const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--purpose-file', purpose, '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
|
|
61
65
|
assert.equal(result.status, 0, result.stderr)
|
|
62
66
|
assert.equal((result.stdout + result.stderr).includes(token), false)
|
|
63
|
-
assert.
|
|
67
|
+
assert.deepEqual(JSON.parse(result.stdout).created, ['AGENTS.md'])
|
|
64
68
|
assert.equal(parseEnv(await readFile(path.join(root, '.env'), 'utf8')).TELEGRAM_BOT_TOKEN, token)
|
|
65
69
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
66
70
|
})
|
|
67
71
|
|
|
72
|
+
test('fresh configure refuses to invent a generic purpose', async () => {
|
|
73
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-purpose-'))
|
|
74
|
+
try {
|
|
75
|
+
await assert.rejects(configureInstallation(root, 'codex'), /purpose file is required/)
|
|
76
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
77
|
+
})
|
|
78
|
+
|
|
68
79
|
test('Docker startup uses explicit deployment binding and literal paths without secrets or host supervisors', () => {
|
|
69
80
|
const definition = serviceDefinition('/tmp/my agent $literal')
|
|
70
81
|
assert.equal(definition.command, 'docker')
|