@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28
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 +10 -1
- package/AGENTS.md +40 -9
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +31 -1
- package/Dockerfile +1 -0
- package/README.md +84 -12
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/architecture/ai-selection.md +12 -15
- package/docs/docker-runtime.md +29 -0
- package/docs/host-service.md +5 -8
- package/docs/local-qa.md +1 -1
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +54 -5
- package/docs/repair.md +26 -25
- package/docs/responsive-channels.md +13 -55
- package/docs/scheduling.md +40 -36
- package/docs/setup.md +11 -21
- package/docs/standalone-cli.md +2 -2
- package/docs/upgrades.md +43 -18
- package/package.json +8 -4
- package/src/agent-guidance.ts +32 -3
- package/src/ai-cli.ts +5 -1
- package/src/ai.ts +6 -28
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +7 -10
- package/src/config.ts +23 -5
- package/src/control-state.ts +274 -21
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/desktop-bridge.ts +11 -43
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +29 -58
- package/src/host-executor.ts +11 -9
- package/src/identity.ts +11 -3
- package/src/index.ts +191 -93
- package/src/menu.ts +76 -55
- package/src/message-history.ts +52 -0
- package/src/message-send.ts +1 -1
- package/src/message.ts +49 -7
- package/src/model-policy.ts +5 -15
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +93 -23
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/repair-policy.ts +0 -8
- package/src/reply-context.ts +3 -29
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +33 -15
- package/src/scheduled-tasks.ts +20 -21
- package/src/scheduler.ts +55 -22
- package/src/task-executor.ts +4 -5
- package/src/task-workspace.ts +2 -11
- package/src/update-attention.ts +1 -1
- package/src/updates/binding.mjs +2 -6
- package/src/updates/control.mjs +4 -0
- package/src/updates/supervisor.mjs +10 -4
- package/src/web-launcher.ts +19 -0
- package/src/workspace.ts +3 -1
- package/templates/agent/AGENTS.md +13 -55
- package/templates/agent-guidance.md +90 -37
- package/templates/deployments.md +24 -0
- package/templates/failure-review.md +6 -0
- package/templates/maintainer-purpose.md +12 -6
- package/test/agent-guidance.test.ts +29 -39
- package/test/ai-cli.test.ts +9 -0
- package/test/ai.test.ts +66 -22
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/busy-reply-relay.test.ts +11 -7
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/client-defaults.test.ts +1 -1
- package/test/codex-session.test.ts +18 -10
- package/test/config.test.ts +16 -1
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/desktop-bridge.test.ts +17 -11
- package/test/engine-handoff.test.ts +73 -0
- package/test/event-sources.test.ts +5 -8
- package/test/executor.test.ts +68 -16
- package/test/failure.test.ts +64 -0
- package/test/host-executor.test.ts +58 -17
- package/test/install-config.test.ts +1 -1
- package/test/intake-relay.test.ts +169 -25
- package/test/message-history.test.ts +127 -0
- package/test/model-policy.test.ts +23 -48
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +70 -10
- package/test/repair-policy.test.ts +8 -12
- package/test/runs.test.ts +13 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/schedule-cli.test.ts +34 -5
- package/test/scheduled-tasks.test.ts +79 -8
- package/test/scheduler.test.ts +30 -1
- package/test/task-native.test.ts +5 -2
- package/test/update-attention.test.ts +1 -2
- package/test/updates.test.mjs +44 -5
- package/test/workspace.test.ts +2 -3
- package/scripts/smoke-busy-reply.ts +0 -58
- package/src/reply-executor.ts +0 -55
- package/src/reply-mcp.ts +0 -23
- package/templates/agent/TOOLS.md +0 -105
- package/templates/chat-guidance.md +0 -23
- package/templates/standalone-tools.md +0 -20
- package/templates/updates.md +0 -45
- package/test/reply.test.ts +0 -159
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { ControlStore, sessionTitle } from '../src/control-state.js'
|
|
7
|
+
import { initialPreset } from '../src/ai.js'
|
|
8
|
+
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
9
|
+
|
|
10
|
+
const fixture = async (work: (store: ControlStore, dir: string) => Promise<void>) => {
|
|
11
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-conversations-'))
|
|
12
|
+
try { await work(new ControlStore(dir, 1000), dir) }
|
|
13
|
+
finally { await rm(dir, { recursive: true, force: true }) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('switching after restart restores native context and model while queued choices stay pinned', async () => fixture(async (store, dir) => {
|
|
17
|
+
const first = await store.captureChoice(initialPreset('codex'), 'Client launch')
|
|
18
|
+
await store.saveNativeSession(first.sessionId, 'native_first')
|
|
19
|
+
await store.savePreset({ id: 'second', name: 'Second', cli: 'grok', model: 'fixture' })
|
|
20
|
+
await store.selectPreset('second', first.sessionId, true)
|
|
21
|
+
const second = await store.captureChoice(initialPreset('grok'), 'Holiday planning')
|
|
22
|
+
await store.markSessionStarted(second.sessionId)
|
|
23
|
+
const restarted = new ControlStore(dir, 1000)
|
|
24
|
+
await restarted.switchSession(first.sessionId)
|
|
25
|
+
assert.deepEqual(await restarted.captureChoice(initialPreset('grok')), first)
|
|
26
|
+
const session = await restarted.executionSession(first)
|
|
27
|
+
const args = EXECUTOR_REGISTRY.codex.buildArgs({ workspace: dir, isResume: session.hasStarted, sessionId: session.nativeSessionId }, '', '')
|
|
28
|
+
assert.equal(args[args.indexOf('resume') + 1], 'native_first')
|
|
29
|
+
assert.equal((await restarted.executionSession(second)).sessionId, second.sessionId)
|
|
30
|
+
await restarted.switchSession(second.sessionId)
|
|
31
|
+
assert.deepEqual(await restarted.captureChoice(initialPreset('codex')), second)
|
|
32
|
+
assert.equal((await restarted.listSessions()).length, 2)
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
test('archive hides without deleting, late completions keep their binding, restore survives restart', async () => fixture(async (store, dir) => {
|
|
36
|
+
const first = await store.captureChoice(initialPreset('codex'), 'Topic one')
|
|
37
|
+
await store.archiveSession(first.sessionId, true)
|
|
38
|
+
assert.equal(await store.getActiveSession(), null)
|
|
39
|
+
await store.saveNativeSession(first.sessionId, 'native_late')
|
|
40
|
+
const second = await store.captureChoice(initialPreset('codex'), 'Topic two')
|
|
41
|
+
assert.notEqual(second.sessionId, first.sessionId)
|
|
42
|
+
await assert.rejects(store.switchSession(first.sessionId), /unavailable/)
|
|
43
|
+
const restarted = new ControlStore(dir, 1000)
|
|
44
|
+
assert.equal((await restarted.executionSession(first)).nativeSessionId, 'native_late')
|
|
45
|
+
await restarted.archiveSession(first.sessionId, false)
|
|
46
|
+
await restarted.switchSession(first.sessionId)
|
|
47
|
+
assert.equal((await restarted.executionSession(first)).nativeSessionId, 'native_late')
|
|
48
|
+
assert.equal((await restarted.listSessions()).length, 2)
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
test('titles are bounded and rename is durable; malformed IDs and unsupported metadata fail closed', async () => fixture(async (store, dir) => {
|
|
52
|
+
const first = await store.captureChoice(initialPreset('grok'), ' Topic\n one ')
|
|
53
|
+
assert.equal(sessionTitle((await store.getActiveSession())!), 'Topic one')
|
|
54
|
+
await store.captureChoice(initialPreset('grok'), 'second message')
|
|
55
|
+
assert.equal(sessionTitle((await store.getActiveSession())!), 'Topic one')
|
|
56
|
+
await store.renameSession('<Client & launch>')
|
|
57
|
+
assert.equal(sessionTitle((await new ControlStore(dir, 1000).getActiveSession())!), '<Client & launch>')
|
|
58
|
+
await assert.rejects(store.renameSession('x'.repeat(81)), /1–80/)
|
|
59
|
+
await assert.rejects(store.switchSession('../escape'), /unavailable/)
|
|
60
|
+
await assert.rejects(store.archiveSession('../escape', true), /unavailable/)
|
|
61
|
+
assert.equal((await store.getActiveSession())!.sessionId, first.sessionId)
|
|
62
|
+
const file = join(dir, 'control-state.json')
|
|
63
|
+
const state = JSON.parse(await readFile(file, 'utf8'))
|
|
64
|
+
state.activeSession.preset.cli = 'sh'
|
|
65
|
+
await writeFile(file, JSON.stringify(state))
|
|
66
|
+
await assert.rejects(store.listSessions(), /unsupported shape/)
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
test('legacy sessions retain IDs; unbound and latest-only engines cannot resume a different context', async () => fixture(async (store, dir) => {
|
|
70
|
+
const legacy = await store.ensureActiveSession()
|
|
71
|
+
await store.markSessionStarted(legacy.sessionId)
|
|
72
|
+
await store.captureChoice(initialPreset('grok'))
|
|
73
|
+
await store.resetSession()
|
|
74
|
+
await assert.rejects(store.switchSession(legacy.sessionId), /binding/)
|
|
75
|
+
assert.match(sessionTitle((await store.listSessions()).find(s => s.sessionId === legacy.sessionId)!), /Conversation /)
|
|
76
|
+
await store.archiveSession(legacy.sessionId, true)
|
|
77
|
+
const agy = { id: 'agy-test', name: 'AGY', cli: 'agy' }
|
|
78
|
+
await store.savePreset(agy)
|
|
79
|
+
await store.selectPreset(agy.id, (await store.getActiveSession())!.sessionId, true)
|
|
80
|
+
const old = await store.captureChoice(agy)
|
|
81
|
+
await store.markSessionStarted(old.sessionId)
|
|
82
|
+
await store.resetSession()
|
|
83
|
+
await assert.rejects(store.switchSession(old.sessionId), /latest conversation/)
|
|
84
|
+
}))
|
|
@@ -5,7 +5,7 @@ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { EventEmitter } from 'node:events'
|
|
8
|
-
import { DESKTOP_UNAVAILABLE,
|
|
8
|
+
import { DESKTOP_UNAVAILABLE, runDesktopTurn, type DesktopClient } from '../src/desktop-bridge.js'
|
|
9
9
|
import { executorKey, nativeSessionId, startExecutorJob } from '../src/executor.js'
|
|
10
10
|
import { initialPreset, isPreset, readModels } from '../src/ai.js'
|
|
11
11
|
|
|
@@ -40,16 +40,6 @@ const fakeClient = (script: Array<Record<string, unknown>>): DesktopClient & { c
|
|
|
40
40
|
return client
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
test('desktop prompt carries run identity and tool paths, never a bot token', () => {
|
|
44
|
-
const prompt = desktopJobPrompt('r_gui', ['hello'], undefined, '/tmp/bin', '/tmp/control')
|
|
45
|
-
assert.match(prompt, /EZ_RUN_ID=r_gui/)
|
|
46
|
-
assert.match(prompt, /EZ_CONTROL_DIR=\/tmp\/control/)
|
|
47
|
-
assert.match(prompt, /PATH=\/tmp\/bin:\$PATH/)
|
|
48
|
-
assert.match(prompt, /ezenciel-agents-message/)
|
|
49
|
-
assert.ok(!prompt.includes('TELEGRAM_BOT_TOKEN'))
|
|
50
|
-
assert.ok(!prompt.includes('token'))
|
|
51
|
-
})
|
|
52
|
-
|
|
53
43
|
test('codex-gui is a distinct preset and catalog entry', async () => {
|
|
54
44
|
assert.equal(executorKey('codex-gui'), 'codex-gui')
|
|
55
45
|
assert.equal(initialPreset('codex-gui').cli, 'codex-gui')
|
|
@@ -176,3 +166,19 @@ test('unlimited desktop waits reject on disconnect and do not miss an early comp
|
|
|
176
166
|
assert.equal((await early.wait(m=>m.method==='turn/completed',0)).method,'turn/completed')
|
|
177
167
|
early.close()
|
|
178
168
|
})
|
|
169
|
+
|
|
170
|
+
test('desktop fresh and resumed turns bind current run environment without prompt prose',async()=>{
|
|
171
|
+
for(const isResume of [false,true]) {
|
|
172
|
+
const text=' /goal audit list of files and give me a simple list with filenames\n'
|
|
173
|
+
const client=fakeClient([{result:{}},{result:{thread:{id:'native-env'}}},...(!isResume?[{result:{}}]:[]),{result:{turn:{id:'env-turn'}},notify:[{method:'turn/completed',params:{turn:{id:'env-turn',status:'completed'}}}]}])
|
|
174
|
+
const original=client.request;let nativeConfig:any,submitted:any
|
|
175
|
+
client.request=async(method,params)=>{if(method===`thread/${isResume?'resume':'start'}`)nativeConfig=(params as any).config;if(method==='turn/start')submitted=params;return original(method,params)}
|
|
176
|
+
assert.equal(await runDesktopTurn({workspace:'/mind',controlDir:'/control',binDir:'/bin',runId:'r_current',repairEnabled:false,prompt:text,isResume,sessionId:'native-env'},{connect:async()=>client,emit:()=>{}}),0)
|
|
177
|
+
assert.deepEqual(submitted.input,[{type:'text',text}])
|
|
178
|
+
assert.equal(nativeConfig['shell_environment_policy.inherit'],'none')
|
|
179
|
+
assert.equal(nativeConfig['shell_environment_policy.set'].EZ_RUN_ID,'r_current')
|
|
180
|
+
assert.equal(nativeConfig['shell_environment_policy.set'].EZ_CONTROL_DIR,'/control')
|
|
181
|
+
assert.equal(nativeConfig['shell_environment_policy.set'].EZ_REPAIR_ENABLED,'false')
|
|
182
|
+
assert.equal(nativeConfig['shell_environment_policy.set'].TELEGRAM_BOT_TOKEN,undefined)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { startExecutorJob } from '../src/executor.js'
|
|
7
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
8
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
9
|
+
import { RunStore } from '../src/runs.js'
|
|
10
|
+
|
|
11
|
+
// Capture the actual subprocess input, not a prompt-building helper.
|
|
12
|
+
test('owner, resumed and native scheduled subprocesses receive literal input and isolated run bindings', async t => {
|
|
13
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-literal-'))
|
|
14
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
15
|
+
const bin=path.join(root,'bin'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
|
|
16
|
+
await mkdir(bin);await initializeWorkspace(workspace)
|
|
17
|
+
const fixture=`#!${process.execPath}
|
|
18
|
+
const fs=require('fs'), args=process.argv.slice(2);
|
|
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}}));
|
|
20
|
+
if(args[0]==='app-server'){
|
|
21
|
+
const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');
|
|
22
|
+
require('readline').createInterface({input:process.stdin}).on('line',line=>{
|
|
23
|
+
const q=JSON.parse(line);if(!q.id)return;
|
|
24
|
+
if(q.method==='initialize')send({id:q.id,result:{}});
|
|
25
|
+
if(q.method==='thread/start')send({id:q.id,result:{thread:{id:'fixture-thread'}}});
|
|
26
|
+
if(q.method==='turn/start'){
|
|
27
|
+
capture(q.params.input[0].text);send({id:q.id,result:{turn:{id:'one'}}});
|
|
28
|
+
send({method:'turn/started',params:{threadId:'fixture-thread',turn:{id:'one'}}});
|
|
29
|
+
send({method:'turn/completed',params:{threadId:'fixture-thread',turn:{id:'one',status:'completed'}}});
|
|
30
|
+
}
|
|
31
|
+
if(q.method==='thread/goal/get')send({id:q.id,result:{goal:null}});
|
|
32
|
+
});
|
|
33
|
+
}else capture(args.includes('--prompt-file')?fs.readFileSync(args[args.indexOf('--prompt-file')+1],'utf8'):args.some(a=>a.startsWith('--print='))?args.find(a=>a.startsWith('--print=')).slice(8):args.includes('--print')||(args[0]==='exec'&&args.at(-1)==='-')?fs.readFileSync(0,'utf8'):args.at(-1));
|
|
34
|
+
`
|
|
35
|
+
for(const name of ['codex','grok','agy','claude','opencode'])await writeFile(path.join(bin,name),fixture,{mode:0o700})
|
|
36
|
+
const previous={PATH:process.env.PATH,TELEGRAM_BOT_TOKEN:process.env.TELEGRAM_BOT_TOKEN,EZ_EXECUTOR_TRANSPORT:process.env.EZ_EXECUTOR_TRANSPORT}
|
|
37
|
+
process.env.PATH=bin+path.delimiter+process.env.PATH;process.env.TELEGRAM_BOT_TOKEN='do-not-inherit';delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
38
|
+
try {
|
|
39
|
+
for(const cli of ['codex','grok','agy','claude','opencode'])for(const isResume of [false,true])for(const text of [' /goal audit list of files and give me a simple list with filenames\n','--help','-','resume']) {
|
|
40
|
+
const runId='r_'+cli+'_'+String(isResume)+'_'+Buffer.from(text).toString('hex').slice(0,20)
|
|
41
|
+
await ownerRun(controlDir,runId)
|
|
42
|
+
const job=await startExecutorJob([text],{workspace,controlDir,binDir:bin,cli,runId,timeoutMs:5000,isResume,sessionId:'native-existing',repairEnabled:false})
|
|
43
|
+
const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
|
|
44
|
+
const captured=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
|
|
45
|
+
assert.equal(captured.prompt,text)
|
|
46
|
+
assert.deepEqual(captured.env,{run:runId,control:controlDir,repair:'false'})
|
|
47
|
+
if(cli==='codex')assert.equal(captured.args.at(-1),'-')
|
|
48
|
+
if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
|
|
49
|
+
if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
|
|
50
|
+
if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
|
|
51
|
+
if(cli==='claude')assert.ok(!captured.args.includes('--append-system-prompt-file'))
|
|
52
|
+
}
|
|
53
|
+
for(const [runId,texts] of [['r_schedule_literal',['/goal audit list of files and give me a simple list with filenames']],['r_batch',['first\nline',' second ']]] as const) {
|
|
54
|
+
await ownerRun(controlDir,runId)
|
|
55
|
+
const job=await startExecutorJob([...texts],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000})
|
|
56
|
+
const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
|
|
57
|
+
assert.equal(JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8')).prompt,texts.join('\n\n'))
|
|
58
|
+
}
|
|
59
|
+
for(const isResume of [false,true]) {
|
|
60
|
+
const runId='tg_chat_'+String(isResume)
|
|
61
|
+
await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
|
|
62
|
+
await new RunStore(controlDir).patch(runId,{status:'running'})
|
|
63
|
+
const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing'})
|
|
64
|
+
const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
|
|
65
|
+
const {prompt}=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
|
|
66
|
+
assert.ok(prompt.startsWith('hi\n\n[Chat context]'))
|
|
67
|
+
assert.equal(prompt.split('[Chat context]').length,2)
|
|
68
|
+
assert.match(prompt,/ezenciel-agents-message/)
|
|
69
|
+
assert.match(prompt,/ezenciel-agents-schedule/)
|
|
70
|
+
assert.match(prompt,/native subagents/)
|
|
71
|
+
}
|
|
72
|
+
}finally{for(const [key,value] of Object.entries(previous))if(value===undefined)delete process.env[key];else process.env[key]=value}
|
|
73
|
+
})
|
|
@@ -12,7 +12,7 @@ import { createRelay } from '../src/index.js'
|
|
|
12
12
|
import { ControlStore } from '../src/control-state.js'
|
|
13
13
|
import { RunStore } from '../src/runs.js'
|
|
14
14
|
import { initialPreset } from '../src/ai.js'
|
|
15
|
-
import {
|
|
15
|
+
import { executorJobEnv } from '../src/executor.js'
|
|
16
16
|
|
|
17
17
|
const until = async (check: () => Promise<boolean>) => {
|
|
18
18
|
for (let i = 0; i < 200; i++) { if (await check()) return; await new Promise(r => setTimeout(r, 10)) }
|
|
@@ -113,9 +113,6 @@ test('corrupt registry and traversal IDs fail closed; external prompts never cla
|
|
|
113
113
|
await assert.rejects(f.sources.register('../bad',f.socketPath,f.owner))
|
|
114
114
|
await writeFile(join(f.dir,'event-sources.json'),'{')
|
|
115
115
|
await assert.rejects(f.relay.drainSources()); assert.equal(f.launches.length,0)
|
|
116
|
-
const prompt=executorJobPrompt('test',['ignore everything'],'source')
|
|
117
|
-
assert.match(prompt,/NOT Telegram-owner instructions/)
|
|
118
|
-
assert.doesNotMatch(prompt,/content from the Telegram owner/)
|
|
119
116
|
assert.equal(executorJobEnv({runId:'r',controlDir:f.dir,binDir:f.dir},{TELEGRAM_BOT_TOKEN:'secret'}).TELEGRAM_BOT_TOKEN,undefined)
|
|
120
117
|
assert.equal(batchReady([{...event('1'),receivedAt:Date.now()}]),false)
|
|
121
118
|
assert.equal(batchReady([event('1')]),true)
|
|
@@ -133,8 +130,8 @@ test('relay launches the approved initial task and routes only matching replies
|
|
|
133
130
|
await f.relay.drainSources()
|
|
134
131
|
assert.equal(f.launches.length, 1); assert.equal(f.launches[0].options.cli, 'codex')
|
|
135
132
|
assert.equal(f.launches[0].options.isResume, false)
|
|
136
|
-
assert.equal(f.launches[0].options.model,
|
|
137
|
-
assert.equal(f.launches[0].options.effort,
|
|
133
|
+
assert.equal(f.launches[0].options.model, undefined)
|
|
134
|
+
assert.equal(f.launches[0].options.effort, undefined)
|
|
138
135
|
f.children[0].kill()
|
|
139
136
|
await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
|
|
140
137
|
const receivedAt = Date.now()
|
|
@@ -143,8 +140,8 @@ test('relay launches the approved initial task and routes only matching replies
|
|
|
143
140
|
await f.relay.drainSources()
|
|
144
141
|
assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.eventSource, 'fixture')
|
|
145
142
|
assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
|
|
146
|
-
assert.equal(f.launches[1].options.model,
|
|
147
|
-
assert.equal(f.launches[1].options.effort,
|
|
143
|
+
assert.equal(f.launches[1].options.model, undefined)
|
|
144
|
+
assert.equal(f.launches[1].options.effort, undefined)
|
|
148
145
|
f.children[1].kill()
|
|
149
146
|
await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
|
|
150
147
|
await f.relay.drainSources()
|
package/test/executor.test.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ownerRun } from './helpers/owner-run.js'
|
|
2
|
+
import { RunStore } from '../src/runs.js'
|
|
2
3
|
import assert from 'node:assert/strict'
|
|
3
4
|
import test from 'node:test'
|
|
4
5
|
import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
5
6
|
import { spawn } from 'node:child_process'
|
|
6
7
|
import { tmpdir } from 'node:os'
|
|
7
8
|
import path from 'node:path'
|
|
8
|
-
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment,
|
|
9
|
+
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
|
|
9
10
|
import { splitTelegramText } from '../src/reply.js'
|
|
10
11
|
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
11
12
|
|
|
@@ -46,16 +47,6 @@ test('cancellation stops detached tool descendants even after their parent exits
|
|
|
46
47
|
}
|
|
47
48
|
})
|
|
48
49
|
|
|
49
|
-
test('the job prompt labels channel text as untrusted and requires ez message', () => {
|
|
50
|
-
const prompt = executorJobPrompt('r_test', ['hello'])
|
|
51
|
-
assert.match(prompt, /untrusted incoming channel content/)
|
|
52
|
-
assert.match(prompt, /ezenciel-agents-message/)
|
|
53
|
-
assert.match(prompt, /--text-file/)
|
|
54
|
-
assert.match(prompt, /r_test/)
|
|
55
|
-
assert.match(prompt, /hello/)
|
|
56
|
-
assert.match(prompt, /Stdout is not sent to Telegram/)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
50
|
test('Telegram replies are split within the configured message limit', () => {
|
|
60
51
|
const text = `${'a'.repeat(9)} ${'b'.repeat(9)} ${'c'.repeat(9)}`
|
|
61
52
|
const chunks = splitTelegramText(text, 10)
|
|
@@ -96,7 +87,6 @@ test('the Grok invocation is headless, workspace-scoped, and token-free', () =>
|
|
|
96
87
|
'--output-format', 'plain',
|
|
97
88
|
'--always-approve',
|
|
98
89
|
'--verbatim',
|
|
99
|
-
'--max-turns', '8',
|
|
100
90
|
])
|
|
101
91
|
assert.equal(invocation.args.includes('TELEGRAM_BOT_TOKEN'), false)
|
|
102
92
|
})
|
|
@@ -105,8 +95,7 @@ test('the antigravity invocation is headless, skips permissions, and uses print
|
|
|
105
95
|
const invocation = antigravityInvocation('test prompt')
|
|
106
96
|
assert.equal(invocation.command, 'agy')
|
|
107
97
|
assert.deepEqual(invocation.args, [
|
|
108
|
-
'--
|
|
109
|
-
'--dangerously-skip-permissions',
|
|
98
|
+
'--dangerously-skip-permissions', '--print=test prompt',
|
|
110
99
|
])
|
|
111
100
|
})
|
|
112
101
|
|
|
@@ -118,6 +107,7 @@ test('the opencode invocation is headless, auto-approves, and sets model and wor
|
|
|
118
107
|
'--auto',
|
|
119
108
|
'--format',
|
|
120
109
|
'json',
|
|
110
|
+
'--',
|
|
121
111
|
'test prompt',
|
|
122
112
|
])
|
|
123
113
|
})
|
|
@@ -179,8 +169,70 @@ test('Codex plugin access stays scoped to the explicitly bound registry', () =>
|
|
|
179
169
|
test('Codex compaction preserves native resume and validates transported options',()=>{
|
|
180
170
|
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',sessionId:'native-id',isResume:true,codexAutoCompactTokens:32000},'', 'hello')
|
|
181
171
|
assert.ok(args.includes('model_auto_compact_token_limit=32000'))
|
|
182
|
-
assert.deepEqual(args.slice(-3),['resume','native-id','
|
|
183
|
-
assert.ok(EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'','hello').includes('model_auto_compact_token_limit
|
|
172
|
+
assert.deepEqual(args.slice(-3),['resume','native-id','-'])
|
|
173
|
+
assert.ok(!EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'','hello').some(a=>a.includes('model_auto_compact_token_limit')))
|
|
184
174
|
for(const value of [0,-1,NaN,1.5]) assert.throws(()=>EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',codexAutoCompactTokens:value},'','hello'),/compaction/)
|
|
185
175
|
assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/agent',codexAutoCompactTokens:32000},'','hello').some(arg=>arg.includes('compact')))
|
|
186
176
|
})
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
test('adapters do not append instruction files or impose a workflow turn budget', () => {
|
|
180
|
+
const opts={workspace:'/agent/work/tasks/example'}
|
|
181
|
+
assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs(opts,'','literal').includes('--append-system-prompt-file'))
|
|
182
|
+
assert.ok(!EXECUTOR_REGISTRY.grok.buildArgs(opts,'/tmp/prompt','literal').includes('--max-turns'))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('Codex external isolation changes only explicit sandbox argv and never enters child environment', () => {
|
|
186
|
+
const args = EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',codexSandbox:'external',sessionId:'native-id',isResume:true},'', 'hello')
|
|
187
|
+
assert.equal(args[args.indexOf('--sandbox')+1], 'danger-full-access')
|
|
188
|
+
assert.ok(args.includes('native-id'))
|
|
189
|
+
assert.equal(executorEnvironment({EZ_CODEX_SANDBOX:'external'}).EZ_CODEX_SANDBOX, undefined)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('external Codex isolation cannot launch host runs', async t => {
|
|
193
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
|
|
194
|
+
t.after(() => rm(root,{recursive:true,force:true}))
|
|
195
|
+
await ownerRun(root,'r_sandbox')
|
|
196
|
+
const previous = {transport:process.env.EZ_EXECUTOR_TRANSPORT,telegram:process.env.EZ_TELEGRAM_ENABLED}
|
|
197
|
+
try {
|
|
198
|
+
process.env.EZ_TELEGRAM_ENABLED='false'
|
|
199
|
+
for (const transport of ['host','']) {
|
|
200
|
+
process.env.EZ_EXECUTOR_TRANSPORT=transport
|
|
201
|
+
await assert.rejects(startExecutorJob(['Hello'],{workspace:root,controlDir:root,binDir:root,runId:'r_sandbox',timeoutMs:0,cli:'codex',codexSandbox:'external'}), /owner-authorized native local/)
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
for (const [key,value] of [['EZ_EXECUTOR_TRANSPORT',previous.transport],['EZ_TELEGRAM_ENABLED',previous.telegram]]) {
|
|
205
|
+
if (value === undefined) delete process.env[key!]; else process.env[key!]=value
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('external local owner chat preserves authorization and literal input', async t => {
|
|
211
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
|
|
212
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
213
|
+
const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
|
|
214
|
+
const bin=path.join(root,'bin');await mkdir(bin)
|
|
215
|
+
await writeFile(path.join(bin,'codex'),`#!/usr/bin/env node\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),text,secret:process.env.TELEGRAM_BOT_TOKEN})));`,{mode:0o755})
|
|
216
|
+
await ownerRun(root,'r_owner_external')
|
|
217
|
+
const opts={workspace:root,controlDir:root,binDir:bin,runId:'r_owner_external',timeoutMs:0,cli:'codex',codexSandbox:'external' as const}
|
|
218
|
+
try {
|
|
219
|
+
process.env.PATH=bin+path.delimiter+prior.path;process.env.EZ_EXECUTOR_TRANSPORT='local'
|
|
220
|
+
const job=await startExecutorJob([' literal /goal request\n'],opts)
|
|
221
|
+
const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup()
|
|
222
|
+
assert.equal(code,0)
|
|
223
|
+
const observed=JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
|
|
224
|
+
assert.equal(observed.text,' literal /goal request\n')
|
|
225
|
+
assert.equal(observed.args[observed.args.indexOf('--sandbox')+1],'danger-full-access')
|
|
226
|
+
assert.equal(observed.secret,undefined)
|
|
227
|
+
const runs=new RunStore(root)
|
|
228
|
+
await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
|
|
229
|
+
await runs.patch('r_foreign',{status:'running'})
|
|
230
|
+
await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_foreign'}),/blocked|owner/i)
|
|
231
|
+
await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
|
|
232
|
+
await runs.patch('r_restricted',{status:'running'})
|
|
233
|
+
await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_restricted'}),/owner-authorized native local/)
|
|
234
|
+
} finally {
|
|
235
|
+
if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
|
|
236
|
+
if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
|
|
237
|
+
}
|
|
238
|
+
})
|
package/test/failure.test.ts
CHANGED
|
@@ -198,6 +198,8 @@ for (const cleanupFails of [false,true]) test(`polling conflict preserves work u
|
|
|
198
198
|
let finished=false
|
|
199
199
|
const start=relay.start().finally(()=>{finished=true})
|
|
200
200
|
await until(async()=>polls===1)
|
|
201
|
+
if(!cleanupFails)await new Promise(resolve=>setTimeout(resolve,5200))
|
|
202
|
+
assert.equal(polls,1,'permanent conflict must not restart after the old five-second retry interval')
|
|
201
203
|
assert.equal(sourceStops,0,'a polling conflict must not stop the relay')
|
|
202
204
|
assert.equal((await runs.get('tg_92'))?.status,'running')
|
|
203
205
|
assert.ok(child && child.exitCode===null && child.signalCode===null)
|
|
@@ -280,3 +282,65 @@ test('group members can inspect failures and wake review without exposing other
|
|
|
280
282
|
assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
|
|
281
283
|
await assert.rejects(exec(process.execPath,[bin,'run','tg_2'],{env}),/Unknown owner/)
|
|
282
284
|
})
|
|
285
|
+
|
|
286
|
+
for (const failure of [400,401,503,'programming'] as const) test(`setup failure ${failure} retries only transient errors`,async t=>{
|
|
287
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-setup-failure-'))
|
|
288
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
|
|
289
|
+
let commands=0,polls=0
|
|
290
|
+
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
291
|
+
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
292
|
+
if(method==='setMyCommands' && ++commands===1) {
|
|
293
|
+
if(failure==='programming')throw Error('Synthetic permanent setup fault')
|
|
294
|
+
return {ok:false,error_code:failure,description:'Synthetic setup failure'} as any
|
|
295
|
+
}
|
|
296
|
+
if(method==='getUpdates'){
|
|
297
|
+
polls++
|
|
298
|
+
if(signal && !signal.aborted)await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
|
|
299
|
+
return {ok:true,result:[]} as any
|
|
300
|
+
}
|
|
301
|
+
return {ok:true,result:true} as any
|
|
302
|
+
})
|
|
303
|
+
const started=relay.start()
|
|
304
|
+
try {
|
|
305
|
+
await until(async()=>commands>0)
|
|
306
|
+
if(failure===503){await until(async()=>polls>0);assert.equal(commands,3)}
|
|
307
|
+
else {await new Promise(resolve=>setTimeout(resolve,5200));assert.equal(commands,1);assert.equal(polls,0)}
|
|
308
|
+
}finally{await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('shutdown aborts a pending bot initialization without starting polling',async()=>{
|
|
312
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-init-stop-'))
|
|
313
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
|
|
314
|
+
let initSignal:Parameters<typeof relay.bot.init>[0],polls=0
|
|
315
|
+
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
316
|
+
if(method==='getMe'){
|
|
317
|
+
initSignal=signal
|
|
318
|
+
if(signal && !signal.aborted)await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
|
|
319
|
+
throw Error('Initialization aborted')
|
|
320
|
+
}
|
|
321
|
+
if(method==='getUpdates')polls++
|
|
322
|
+
return {ok:true,result:true} as any
|
|
323
|
+
})
|
|
324
|
+
const started=relay.start()
|
|
325
|
+
try {await until(async()=>Boolean(initSignal));await relay.stop();await started;assert.equal(initSignal?.aborted,true);assert.equal(polls,0)}
|
|
326
|
+
finally {await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('failed reviewer readback exposes its stop through show, list and resume until explicit edit',async t=>{
|
|
330
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-review-stop-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
331
|
+
const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
332
|
+
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
333
|
+
const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
|
|
334
|
+
const s=await scheduler.save({id:'review',name:'Review',text:'Review failures',trigger:{everySeconds:60,start:new Date(now).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
|
|
335
|
+
await runs.create({id:'r_original',chatId:101,telegramUserId:101,texts:['Work'],execution});await runs.patch('r_original',{status:'failed'})
|
|
336
|
+
await scheduler.tick(owner,runs,now)
|
|
337
|
+
const reviewer=(await runs.list()).find(r=>r.scheduled)!
|
|
338
|
+
await runs.patch(reviewer.id,{status:'failed'})
|
|
339
|
+
const cli=async(args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env:{...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}})).stdout)
|
|
340
|
+
for(const action of ['show','resume']){
|
|
341
|
+
const result=await cli([action,s.id]);assert.equal(result.nextEligibleAt,null);assert.deepEqual(result.failedReviewRunIds,[reviewer.id]);assert.match(result.recovery,/explicitly edit/)
|
|
342
|
+
}
|
|
343
|
+
const listed=await cli(['list']);assert.equal(listed[0].nextEligibleAt,null)
|
|
344
|
+
await scheduler.save({...s,text:'Repaired reviewer'})
|
|
345
|
+
const result=await cli(['show',s.id]);assert.notEqual(result.nextEligibleAt,null);assert.deepEqual(result.failedReviewRunIds,[])
|
|
346
|
+
})
|
|
@@ -12,6 +12,29 @@ import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
|
12
12
|
import { RunStore } from '../src/runs.js'
|
|
13
13
|
import { packageVersion } from '../src/version.js'
|
|
14
14
|
import { executionDefaults } from '../src/model-policy.js'
|
|
15
|
+
import { workspaceLease } from '../src/plugins/workspace-lease.mjs'
|
|
16
|
+
|
|
17
|
+
test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
|
|
18
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
|
|
19
|
+
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
|
|
20
|
+
const abort=new AbortController();let server:Promise<void>|undefined;
|
|
21
|
+
try {
|
|
22
|
+
await mkdir(workspace);await mkdir(toolsHome);await mkdir(directory,{recursive:true});
|
|
23
|
+
await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}));
|
|
24
|
+
const child=spawn(process.execPath,['-e','process.exit(0)']);const deadPid=child.pid;await new Promise(r=>child.once('close',r));
|
|
25
|
+
await writeFile(path.join(toolsHome,'workspace-writer.lock'),JSON.stringify({pid:deadPid,kind:'native',runId:'r_old'}));
|
|
26
|
+
await writeFile(path.join(directory,'r_old.running.json'),'{}');
|
|
27
|
+
await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:process.pid}));
|
|
28
|
+
const installation={cli:'grok',agents:[{name:'test',workspace,controlDir,toolsHome,binDir:path.join(root,'bin')}]};
|
|
29
|
+
await assert.rejects(serveHostExecutor(installation,abort.signal),/Previous host CLI is still running/);
|
|
30
|
+
await readFile(path.join(toolsHome,'workspace-writer.lock'));
|
|
31
|
+
await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:deadPid}));
|
|
32
|
+
server=serveHostExecutor(installation,abort.signal);
|
|
33
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
34
|
+
await readFile(path.join(directory,'heartbeat.json'));
|
|
35
|
+
await assert.rejects(readFile(path.join(toolsHome,'workspace-writer.lock')),{code:'ENOENT'});
|
|
36
|
+
}finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
|
|
37
|
+
});
|
|
15
38
|
|
|
16
39
|
test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
|
|
17
40
|
const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
|
|
@@ -23,12 +46,12 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
23
46
|
let server:Promise<void>|undefined
|
|
24
47
|
try {
|
|
25
48
|
const binary=path.join(root,'cli')
|
|
26
|
-
await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID==='r_hold')setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,args:process.argv.slice(2)}));\n`,{mode:0o700})
|
|
49
|
+
await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID==='r_hold')setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,repair:process.env.EZ_REPAIR_ENABLED,args:process.argv.slice(2)}));\n`,{mode:0o700})
|
|
27
50
|
await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
|
|
28
51
|
await writeFile(path.join(root,'codex'),await readFile(binary),{mode:0o700})
|
|
29
52
|
process.env.PATH=root+path.delimiter+oldPath
|
|
30
53
|
EXECUTOR_REGISTRY.grok.command=binary
|
|
31
|
-
EXECUTOR_REGISTRY.grok.buildArgs=EXECUTOR_REGISTRY.codex.buildArgs
|
|
54
|
+
EXECUTOR_REGISTRY.grok.buildArgs=(opts,file,prompt)=>[...EXECUTOR_REGISTRY.codex.buildArgs(opts,file,prompt).slice(0,-1),prompt]
|
|
32
55
|
process.env.TELEGRAM_BOT_TOKEN='must-not-reach-host-cli'
|
|
33
56
|
const sharedAlias=path.join(root,'shared-alias')
|
|
34
57
|
await symlink(root,sharedAlias)
|
|
@@ -44,8 +67,13 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
44
67
|
const dir=path.join(agent.controlDir,'host-executor')
|
|
45
68
|
for(let n=0;n<100;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
46
69
|
assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
|
|
70
|
+
const release = await workspaceLease(agent.toolsHome)
|
|
47
71
|
await ownerRun(agent.controlDir, `r_${agent.name}`)
|
|
48
72
|
await writeFile(path.join(dir,`r_${agent.name}.request.json`),JSON.stringify({texts:['test'],options:{workspace:'/wrong',controlDir:'/wrong',toolsHome:'/wrong',cli:'grok',timeoutMs:5000}}))
|
|
73
|
+
await new Promise(r=>setTimeout(r,300))
|
|
74
|
+
await readFile(path.join(dir,`r_${agent.name}.request.json`))
|
|
75
|
+
await assert.rejects(readFile(path.join(dir,`r_${agent.name}.process.json`)),{code:'ENOENT'})
|
|
76
|
+
await release?.()
|
|
49
77
|
}
|
|
50
78
|
for(const agent of agents){
|
|
51
79
|
const file=path.join(agent.controlDir,'host-executor',`r_${agent.name}.events`)
|
|
@@ -56,7 +84,8 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
56
84
|
assert.equal(result.cwd,await realpath(agent.workspace))
|
|
57
85
|
assert.equal(result.control,agent.controlDir)
|
|
58
86
|
assert.equal(result.token,undefined)
|
|
59
|
-
assert.
|
|
87
|
+
assert.equal(result.args.at(-1),'test')
|
|
88
|
+
assert.equal(result.repair,'true')
|
|
60
89
|
assert.ok(result.args.includes(agent.toolsHome))
|
|
61
90
|
assert.ok(result.args.includes(await realpath(root)))
|
|
62
91
|
assert.ok(!result.args.includes('/wrong'))
|
|
@@ -72,8 +101,8 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
72
101
|
client.stdin.end(JSON.stringify({texts:['Telegram message'],options:{cli:'grok',timeoutMs:5000,codexAutoCompactTokens:32000,repairEnabled:false}}))
|
|
73
102
|
assert.equal(await new Promise(resolve=>client.once('close',resolve)),0,stderr)
|
|
74
103
|
assert.equal(JSON.parse(stdout).run,'tg_6293305')
|
|
75
|
-
assert.
|
|
76
|
-
assert.
|
|
104
|
+
assert.equal(JSON.parse(stdout).args.at(-1),'Telegram message')
|
|
105
|
+
assert.equal(JSON.parse(stdout).repair,'false')
|
|
77
106
|
assert.ok(JSON.parse(stdout).args.includes('model_auto_compact_token_limit=32000'))
|
|
78
107
|
const eventId='event_'+'a'.repeat(64)
|
|
79
108
|
await ownerRun(agents[0].controlDir, eventId, {sourceId:'fixture',bindingId:'binding',eventIds:['1']})
|
|
@@ -107,8 +136,10 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
107
136
|
assert.ok(JSON.parse(boundOutput).args.includes('agent-only-fixture'))
|
|
108
137
|
await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
|
|
109
138
|
const directory=path.join(agents[0].controlDir,'host-executor')
|
|
110
|
-
const
|
|
111
|
-
await
|
|
139
|
+
const runs=new RunStore(agents[0].controlDir)
|
|
140
|
+
await runs.create({id:'r_hold',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
141
|
+
await runs.patch('r_hold',{status:'running'})
|
|
142
|
+
await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
|
|
112
143
|
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'r_hold.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
113
144
|
await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
114
145
|
await new RunStore(agents[0].controlDir).patch('r_schedule_queued',{status:'running'})
|
|
@@ -116,17 +147,27 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
116
147
|
const otherDirectory=path.join(agents[1].controlDir,'host-executor')
|
|
117
148
|
await ownerRun(agents[1].controlDir,'r_other_shared')
|
|
118
149
|
await writeFile(path.join(otherDirectory,'r_other_shared.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
|
|
119
|
-
await
|
|
120
|
-
await
|
|
121
|
-
await
|
|
122
|
-
|
|
150
|
+
await runs.create({id:'tg_42',chatId:101,telegramUserId:101,messageId:42,texts:['Chat while scheduled work runs']})
|
|
151
|
+
await runs.patch('tg_42',{status:'running'})
|
|
152
|
+
await writeFile(path.join(directory,'tg_42.request.json'),JSON.stringify({texts:['Chat while scheduled work runs'],options:{cli:'grok'}}))
|
|
153
|
+
const completed=async(dir:string,id:string)=>{
|
|
154
|
+
let output=''
|
|
155
|
+
for(let n=0;n<200;n++){try{output=await readFile(path.join(dir,id+'.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
|
|
156
|
+
assert.match(output, /"stream":"exit","code":0/)
|
|
157
|
+
return output.trim().split('\n').map(line=>JSON.parse(line))
|
|
158
|
+
}
|
|
159
|
+
// A running scheduled engine does not reserve either its agent or its
|
|
160
|
+
// shared workspace, including another binding through a filesystem alias.
|
|
161
|
+
const [,,chatEvents]=await Promise.all([completed(directory,'r_schedule_queued'),completed(otherDirectory,'r_other_shared'),completed(directory,'tg_42')])
|
|
162
|
+
const chat=JSON.parse(chatEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
163
|
+
assert.match(chat.args.at(-1),/^Chat while scheduled work runs/)
|
|
164
|
+
assert.match(chat.args.at(-1),/ezenciel-agents-message/)
|
|
165
|
+
assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
|
|
166
|
+
await readFile(path.join(directory,'r_hold.running.json'))
|
|
123
167
|
await writeFile(path.join(directory,'r_hold.cancel'),'')
|
|
124
|
-
let
|
|
125
|
-
for(let n=0;n<200;n++){
|
|
126
|
-
assert.match(
|
|
127
|
-
assert.match(await readFile(path.join(directory,'r_hold.events'),'utf8'), /"stream":"exit","code":1/)
|
|
128
|
-
for(let n=0;n<200;n++){try{output=await readFile(path.join(otherDirectory,'r_other_shared.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
|
|
129
|
-
assert.match(output, /"stream":"exit","code":0/)
|
|
168
|
+
let heldOutput=''
|
|
169
|
+
for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
|
|
170
|
+
assert.match(heldOutput, /"stream":"exit","code":1/)
|
|
130
171
|
} finally {
|
|
131
172
|
abort.abort();await server
|
|
132
173
|
EXECUTOR_REGISTRY.grok.command=old
|
|
@@ -60,7 +60,7 @@ test('packaged configure accepts token through stdin without echo or extra initi
|
|
|
60
60
|
const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
|
|
61
61
|
assert.equal(result.status, 0, result.stderr)
|
|
62
62
|
assert.equal((result.stdout + result.stderr).includes(token), false)
|
|
63
|
-
assert.equal(JSON.parse(result.stdout).created.length,
|
|
63
|
+
assert.equal(JSON.parse(result.stdout).created.length, 3)
|
|
64
64
|
assert.equal(parseEnv(await readFile(path.join(root, '.env'), 'utf8')).TELEGRAM_BOT_TOKEN, token)
|
|
65
65
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
66
66
|
})
|