@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +96 -0
- package/Dockerfile +20 -11
- package/README.md +10 -13
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +41 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +98 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +81 -24
- package/src/files.ts +38 -11
- package/src/host-executor.ts +33 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +98 -54
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +8 -3
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +131 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +1 -0
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +220 -1
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +120 -12
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +87 -3
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +136 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/test/executor.test.ts
CHANGED
|
@@ -6,7 +6,8 @@ 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, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
9
|
+
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
10
|
+
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
10
11
|
import { splitTelegramText } from '../src/reply.js'
|
|
11
12
|
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
12
13
|
|
|
@@ -60,10 +61,9 @@ test('native CLIs resolve from the host PATH, not a relative package name', () =
|
|
|
60
61
|
})
|
|
61
62
|
|
|
62
63
|
test('the executor receives a deliberately small environment', () => {
|
|
63
|
-
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret',
|
|
64
|
+
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
|
|
64
65
|
assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
|
|
65
66
|
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
66
|
-
assert.ok(!('PAGERDUTY_ROUTING_KEY' in environment))
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
test('the Grok job env binds the run and still strips the bot token', () => {
|
|
@@ -73,6 +73,7 @@ test('the Grok job env binds the run and still strips the bot token', () => {
|
|
|
73
73
|
)
|
|
74
74
|
assert.equal(environment.EZ_RUN_ID, 'r_1')
|
|
75
75
|
assert.equal(environment.EZ_CONTROL_DIR, '/tmp/control')
|
|
76
|
+
assert.equal(environment.EZ_DELIVERY_SOCKET, '/tmp/control/delivery.sock')
|
|
76
77
|
assert.match(environment.PATH ?? '', /^\/tmp\/bin/)
|
|
77
78
|
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
78
79
|
})
|
|
@@ -124,6 +125,7 @@ test('resolveExecutor correctly maps keys and aliases', () => {
|
|
|
124
125
|
assert.equal(resolveExecutor('grok').command, 'grok')
|
|
125
126
|
assert.equal(resolveExecutor('opencode').command, 'opencode')
|
|
126
127
|
assert.equal(resolveExecutor('oc').command, 'opencode')
|
|
128
|
+
assert.throws(() => resolveExecutor('unreal-agent'), /Unsupported executor CLI/)
|
|
127
129
|
assert.equal(resolveExecutor('codex-gui').name, 'codex-gui')
|
|
128
130
|
assert.notEqual(resolveExecutor('codex-gui').name, resolveExecutor('codex').name)
|
|
129
131
|
assert.equal(resolveExecutor(undefined).command, 'agy')
|
|
@@ -171,11 +173,119 @@ test('Codex custom providers use declared runtime state and an environment key r
|
|
|
171
173
|
assert.ok(args.includes('google/gemini-3.8-flash'))
|
|
172
174
|
assert.equal(JSON.stringify(args).includes('provider-secret'),false)
|
|
173
175
|
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'
|
|
176
|
+
for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
|
|
175
177
|
assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
|
|
176
178
|
assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
|
|
177
179
|
})
|
|
178
180
|
|
|
181
|
+
test('opencode jobs use the agent-bound data home only when its auth binding exists', async t => {
|
|
182
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-opencode-auth-'));t.after(()=>rm(root,{recursive:true,force:true}))
|
|
183
|
+
const bin=path.join(root,'bin');await mkdir(bin)
|
|
184
|
+
await writeFile(path.join(bin,'opencode'),`#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),xdg:process.env.XDG_DATA_HOME,telegram:process.env.TELEGRAM_BOT_TOKEN}));`,{mode:0o755})
|
|
185
|
+
assert.equal(opencodeDataHome(root),undefined)
|
|
186
|
+
await mkdir(path.join(root,'cli','opencode'),{recursive:true})
|
|
187
|
+
assert.equal(opencodeDataHome(root),undefined)
|
|
188
|
+
await writeFile(path.join(root,'cli','opencode','auth.json'),'{}',{mode:0o600})
|
|
189
|
+
assert.equal(opencodeDataHome(root),path.join(root,'cli'))
|
|
190
|
+
const prior={path:process.env.PATH,telegram:process.env.TELEGRAM_BOT_TOKEN,transport:process.env.EZ_EXECUTOR_TRANSPORT}
|
|
191
|
+
const launch=async(runId:string)=>{
|
|
192
|
+
await ownerRun(root,runId)
|
|
193
|
+
process.env.PATH=bin+path.delimiter+prior.path;process.env.TELEGRAM_BOT_TOKEN='relay-secret';delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
194
|
+
const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId,timeoutMs:0,cli:'opencode',model:'opencode-go/muse-spark-1.3-contributor',effort:'xhigh'})
|
|
195
|
+
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
|
|
196
|
+
return JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const bound=await launch('r_ocbound')
|
|
200
|
+
assert.equal(bound.xdg,path.join(root,'cli'))
|
|
201
|
+
assert.equal(bound.telegram,undefined)
|
|
202
|
+
assert.equal(bound.args[bound.args.indexOf('-m')+1],'opencode-go/muse-spark-1.3-contributor')
|
|
203
|
+
assert.equal(bound.args[bound.args.indexOf('--variant')+1],'xhigh')
|
|
204
|
+
await rm(path.join(root,'cli','opencode','auth.json'))
|
|
205
|
+
const free=await launch('r_ocfree')
|
|
206
|
+
assert.equal(free.xdg,undefined)
|
|
207
|
+
assert.equal(free.telegram,undefined)
|
|
208
|
+
} finally {
|
|
209
|
+
if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
|
|
210
|
+
if(prior.telegram===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=prior.telegram
|
|
211
|
+
if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('pi jobs use the agent-bound config dir only when its auth binding exists', async t => {
|
|
216
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-pi-auth-'));t.after(()=>rm(root,{recursive:true,force:true}))
|
|
217
|
+
const bin=path.join(root,'bin');await mkdir(bin)
|
|
218
|
+
await writeFile(path.join(bin,'pi'),`#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),agentDir:process.env.PI_CODING_AGENT_DIR,telegram:process.env.TELEGRAM_BOT_TOKEN}));`,{mode:0o755})
|
|
219
|
+
assert.equal(piAgentDir(root),undefined)
|
|
220
|
+
await mkdir(path.join(root,'cli','pi','agent'),{recursive:true})
|
|
221
|
+
assert.equal(piAgentDir(root),undefined)
|
|
222
|
+
await writeFile(path.join(root,'cli','pi','agent','auth.json'),'{}',{mode:0o600})
|
|
223
|
+
assert.equal(piAgentDir(root),path.join(root,'cli','pi','agent'))
|
|
224
|
+
const prior={path:process.env.PATH,telegram:process.env.TELEGRAM_BOT_TOKEN,transport:process.env.EZ_EXECUTOR_TRANSPORT,agentDir:process.env.PI_CODING_AGENT_DIR}
|
|
225
|
+
const launch=async(runId:string)=>{
|
|
226
|
+
await ownerRun(root,runId)
|
|
227
|
+
process.env.PATH=bin+path.delimiter+prior.path;process.env.TELEGRAM_BOT_TOKEN='relay-secret';delete process.env.EZ_EXECUTOR_TRANSPORT;delete process.env.PI_CODING_AGENT_DIR
|
|
228
|
+
const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId,timeoutMs:0,cli:'pi',model:'opencode-go/muse-spark-1.3-contributor',effort:'xhigh'})
|
|
229
|
+
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
|
|
230
|
+
return JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const bound=await launch('r_pibound')
|
|
234
|
+
assert.equal(bound.agentDir,path.join(root,'cli','pi','agent'))
|
|
235
|
+
assert.equal(bound.telegram,undefined)
|
|
236
|
+
assert.equal(bound.args[bound.args.indexOf('--model')+1],'opencode-go/muse-spark-1.3-contributor')
|
|
237
|
+
assert.equal(bound.args[bound.args.indexOf('--thinking')+1],'xhigh')
|
|
238
|
+
await rm(path.join(root,'cli','pi','agent','auth.json'))
|
|
239
|
+
const free=await launch('r_pifree')
|
|
240
|
+
assert.equal(free.agentDir,undefined)
|
|
241
|
+
assert.equal(free.telegram,undefined)
|
|
242
|
+
// Owner-provisioned delivery skill loads by path; absence keeps prior args.
|
|
243
|
+
assert.ok(!bound.args.includes('--skill'))
|
|
244
|
+
await mkdir(path.join(root,'cli','pi','skills','ez-delivery'),{recursive:true})
|
|
245
|
+
await writeFile(path.join(root,'cli','pi','skills','ez-delivery','SKILL.md'),'# delivery',{mode:0o644})
|
|
246
|
+
const skilled=await launch('r_piskilled')
|
|
247
|
+
assert.equal(skilled.args[skilled.args.indexOf('--skill')+1],path.join(root,'cli','pi','skills','ez-delivery'))
|
|
248
|
+
} finally {
|
|
249
|
+
if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
|
|
250
|
+
if(prior.telegram===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=prior.telegram
|
|
251
|
+
if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
|
|
252
|
+
if(prior.agentDir===undefined)delete process.env.PI_CODING_AGENT_DIR;else process.env.PI_CODING_AGENT_DIR=prior.agentDir
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('the pi invocation pins control sessions, model, and thinking level', () => {
|
|
257
|
+
assert.equal(resolveExecutor('pi').command, 'pi')
|
|
258
|
+
const args = EXECUTOR_REGISTRY.pi.buildArgs(
|
|
259
|
+
{ workspace: '/agent/mind', controlDir: '/agent/control', sessionId: 'ez-session', isResume: true, model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
|
|
260
|
+
'/prompt', 'review CAMT')
|
|
261
|
+
assert.deepEqual(args, ['-p', '--mode', 'json', '--session-dir', '/agent/control/cli/pi/sessions', '--session-id', 'ez-session',
|
|
262
|
+
'--model', 'opencode-go/muse-spark-1.3-contributor', '--thinking', 'xhigh', '--', 'review CAMT'])
|
|
263
|
+
const other = EXECUTOR_REGISTRY.pi.buildArgs({ workspace: '/agent/mind', controlDir: '/agent/control', sessionId: 'other-session', isResume: true }, '/prompt', 'other')
|
|
264
|
+
assert.equal(other[other.indexOf('--session-id') + 1], 'other-session')
|
|
265
|
+
assert.ok(!other.includes('--continue'))
|
|
266
|
+
assert.throws(() => EXECUTOR_REGISTRY.pi.buildArgs({ workspace: '/agent' }, '/prompt', 'hi'), /bound control directory/)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('pi jobs run with control-bound sessions and no secret leakage', async t => {
|
|
270
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-pi-auth-')); t.after(() => rm(root, { recursive: true, force: true }))
|
|
271
|
+
const bin = path.join(root, 'bin'); await mkdir(bin)
|
|
272
|
+
await writeFile(path.join(bin, 'pi'), `#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root, 'observed.json'))},JSON.stringify({args:process.argv.slice(2),telegram:process.env.TELEGRAM_BOT_TOKEN}));`, { mode: 0o755 })
|
|
273
|
+
const prior = { path: process.env.PATH, telegram: process.env.TELEGRAM_BOT_TOKEN, transport: process.env.EZ_EXECUTOR_TRANSPORT }
|
|
274
|
+
try {
|
|
275
|
+
await ownerRun(root, 'r_pibound')
|
|
276
|
+
process.env.PATH = bin + path.delimiter + prior.path; process.env.TELEGRAM_BOT_TOKEN = 'relay-secret'; delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
277
|
+
const job = await startExecutorJob(['review CAMT'], { workspace: root, controlDir: root, binDir: bin, runId: 'r_pibound', timeoutMs: 0, cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' })
|
|
278
|
+
assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
|
|
279
|
+
const observed = JSON.parse(await readFile(path.join(root, 'observed.json'), 'utf8'))
|
|
280
|
+
assert.deepEqual(observed.args, ['-p', '--mode', 'json', '--session-dir', path.join(root, 'cli', 'pi', 'sessions'),
|
|
281
|
+
'--model', 'opencode-go/muse-spark-1.3-contributor', '--thinking', 'xhigh', '--', 'review CAMT'])
|
|
282
|
+
assert.equal(observed.telegram, undefined)
|
|
283
|
+
} finally {
|
|
284
|
+
if (prior.path === undefined) delete process.env.PATH; else process.env.PATH = prior.path
|
|
285
|
+
if (prior.telegram === undefined) delete process.env.TELEGRAM_BOT_TOKEN; else process.env.TELEGRAM_BOT_TOKEN = prior.telegram
|
|
286
|
+
if (prior.transport === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT; else process.env.EZ_EXECUTOR_TRANSPORT = prior.transport
|
|
287
|
+
}
|
|
288
|
+
})
|
|
179
289
|
test('a configured Codex provider receives only its declared key and runtime selection', async t => {
|
|
180
290
|
const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
|
|
181
291
|
const bin=path.join(root,'bin');await mkdir(bin)
|
|
@@ -230,21 +340,19 @@ test('external Codex isolation cannot launch host runs', async t => {
|
|
|
230
340
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
|
|
231
341
|
t.after(() => rm(root,{recursive:true,force:true}))
|
|
232
342
|
await ownerRun(root,'r_sandbox')
|
|
233
|
-
const previous =
|
|
343
|
+
const previous = process.env.EZ_EXECUTOR_TRANSPORT
|
|
234
344
|
try {
|
|
235
|
-
process.env.EZ_TELEGRAM_ENABLED='false'
|
|
236
345
|
for (const transport of ['host','']) {
|
|
237
346
|
process.env.EZ_EXECUTOR_TRANSPORT=transport
|
|
238
347
|
await assert.rejects(startExecutorJob(['Hello'],{workspace:root,controlDir:root,binDir:root,runId:'r_sandbox',timeoutMs:0,cli:'codex',codexSandbox:'external'}), /owner-authorized native local/)
|
|
239
348
|
}
|
|
240
349
|
} finally {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
350
|
+
if (previous === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
351
|
+
else process.env.EZ_EXECUTOR_TRANSPORT=previous
|
|
244
352
|
}
|
|
245
353
|
})
|
|
246
354
|
|
|
247
|
-
test('external local owner chat preserves
|
|
355
|
+
test('external local owner chat preserves literal input and admission rejects foreign or restricted runs', async t => {
|
|
248
356
|
const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
|
|
249
357
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
250
358
|
const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
|
|
@@ -264,10 +372,10 @@ test('external local owner chat preserves authorization and literal input', asyn
|
|
|
264
372
|
const runs=new RunStore(root)
|
|
265
373
|
await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
|
|
266
374
|
await runs.patch('r_foreign',{status:'running'})
|
|
267
|
-
await assert.rejects(
|
|
375
|
+
await assert.rejects(requireOwnerExecution(root,'r_foreign'),/blocked: owner-mismatch/i)
|
|
268
376
|
await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
|
|
269
377
|
await runs.patch('r_restricted',{status:'running'})
|
|
270
|
-
await assert.rejects(
|
|
378
|
+
await assert.rejects(requireOwnerExecution(root,'r_restricted'),/external-execution-unavailable/)
|
|
271
379
|
} finally {
|
|
272
380
|
if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
|
|
273
381
|
if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
|
package/test/failure.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { execFile, spawn } from 'node:child_process'
|
|
@@ -8,11 +8,12 @@ import { promisify } from 'node:util'
|
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { once } from 'node:events'
|
|
10
10
|
import { redactFailure, failureStamp, needsFailureReview } from '../src/failure.js'
|
|
11
|
-
import { RunStore } from '../src/runs.js'
|
|
11
|
+
import { RunStore, sentOutbox } from '../src/runs.js'
|
|
12
12
|
import { ControlStore } from '../src/control-state.js'
|
|
13
13
|
import { Scheduler } from '../src/scheduler.js'
|
|
14
14
|
import { initialPreset } from '../src/ai.js'
|
|
15
15
|
import { createRelay } from '../src/index.js'
|
|
16
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
16
17
|
import { TelegramSource } from '../src/telegram-source.js'
|
|
17
18
|
import { packageVersion } from '../src/version.js'
|
|
18
19
|
import { initializeWorkspace } from '../src/workspace.js'
|
|
@@ -33,6 +34,8 @@ test('failure evidence is bounded and redacts configured credentials, headers, t
|
|
|
33
34
|
|
|
34
35
|
test('failure review CLI is owner-bound, rejects stale reviews, and keeps failure evidence immutable',async t=>{
|
|
35
36
|
const dir=await mkdtemp(join(tmpdir(),'ez-failure-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
37
|
+
// The schedule CLI child reaches the relay memory ledger through the socket.
|
|
38
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
36
39
|
const runs=new RunStore(dir),control=new ControlStore(dir,1000)
|
|
37
40
|
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}
|
|
38
41
|
const cli=(args:string[],overrides={})=>exec(process.execPath,[bin,...args],{env:{...env,...overrides}})
|
|
@@ -61,7 +64,7 @@ test('failure review CLI is owner-bound, rejects stale reviews, and keeps failur
|
|
|
61
64
|
await runs.patch(id,{status:'running',...('replyOnly' in extra?{replyOnly:true}:{})})
|
|
62
65
|
await assert.rejects(cli(review,{EZ_RUN_ID:id}),/owner-authorized/)
|
|
63
66
|
}
|
|
64
|
-
await
|
|
67
|
+
await assert.rejects(stat(join(dir,'runs')),{code:'ENOENT'})
|
|
65
68
|
assert.equal(JSON.parse((await cli(['failures'])).stdout).total,1)
|
|
66
69
|
})
|
|
67
70
|
|
|
@@ -86,6 +89,8 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
|
|
|
86
89
|
})
|
|
87
90
|
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
88
91
|
relay.bot.api.config.use(async(_p,method,payload)=>{if(method==='sendMessage')replies.push((payload as any).text);return {ok:true,result:{message_id:replies.length}} as any})
|
|
92
|
+
// The schedule CLI children reach the relay memory ledger through the socket.
|
|
93
|
+
const ledger=await serveTestLedger(dir)
|
|
89
94
|
const originalPatch=RunStore.prototype.patch
|
|
90
95
|
t.mock.method(RunStore.prototype,'patch',async function(this:RunStore,...args:Parameters<RunStore['patch']>){
|
|
91
96
|
// Hold the PID write until the fast child has closed, reproducing slow disk
|
|
@@ -114,7 +119,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
|
|
|
114
119
|
await relay.bot.handleUpdate({...update,update_id:12,message:{...update.message!,message_id:12,text:'/stop'}})
|
|
115
120
|
await until(async()=>(await runs.get('tg_11'))?.status==='cancelled')
|
|
116
121
|
assert.equal(needsFailureReview((await runs.get('tg_11'))!),false)
|
|
117
|
-
}finally{await relay.stop();for(const child of children)child.kill();await rm(dir,{recursive:true,force:true})}
|
|
122
|
+
}finally{await relay.stop();for(const child of children)child.kill();await ledger.stop();await rm(dir,{recursive:true,force:true})}
|
|
118
123
|
})
|
|
119
124
|
|
|
120
125
|
|
|
@@ -220,7 +225,7 @@ for (const cleanupFails of [false,true]) test(`polling conflict preserves work u
|
|
|
220
225
|
await stopped
|
|
221
226
|
assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
|
|
222
227
|
assert.equal(sourceStops,1)
|
|
223
|
-
assert.deepEqual(
|
|
228
|
+
assert.deepEqual((sentOutbox(dir).find(entry=>entry.id===item.id)?.receipt as {messageIds:number[]}).messageIds,[1])
|
|
224
229
|
assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
|
|
225
230
|
assert.equal(relay.bot.isRunning(),false)
|
|
226
231
|
assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
|
|
@@ -268,6 +273,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
|
|
|
268
273
|
|
|
269
274
|
test('group members can inspect failures and wake review without exposing other chats', async t => {
|
|
270
275
|
const dir=await mkdtemp(join(tmpdir(),'ez-group-failure-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
276
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
271
277
|
const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
|
|
272
278
|
await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
|
|
273
279
|
const execution=await control.captureChoice(initialPreset('grok'))
|
|
@@ -334,6 +340,7 @@ test('shutdown aborts a pending bot initialization without starting polling',asy
|
|
|
334
340
|
|
|
335
341
|
test('failed reviewer readback exposes its stop through show, list and resume until explicit edit',async t=>{
|
|
336
342
|
const dir=await mkdtemp(join(tmpdir(),'ez-review-stop-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
343
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
337
344
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
338
345
|
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
339
346
|
const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
|
package/test/files.test.ts
CHANGED
|
@@ -35,13 +35,13 @@ test('sanitizes dangerous file names', () => {
|
|
|
35
35
|
assert.equal(sanitizeFileName('report 2026!@#.pdf'), 'report_2026___.pdf')
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
-
test('stages file into
|
|
38
|
+
test('stages file into control attachments, never the workspace', async () => {
|
|
39
39
|
const tmp = await mkdtemp(path.join(tmpdir(), 'ez-files-'))
|
|
40
40
|
try {
|
|
41
41
|
const data = Buffer.from('%PDF-1.4 sample content')
|
|
42
42
|
const staged = await stageIncomingFile(tmp, 'sample.pdf', data)
|
|
43
43
|
assert.equal(staged.fileType, 'pdf')
|
|
44
|
-
assert.match(staged.relativePath, /^
|
|
44
|
+
assert.match(staged.relativePath, /^attachments[/\\][a-f0-9-]+_sample\.pdf$/)
|
|
45
45
|
const read = await readFile(staged.fullPath)
|
|
46
46
|
assert.deepEqual(read, data)
|
|
47
47
|
} finally {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createLedgerHandler, serveDeliverySocket } from '../../src/delivery-socket.js'
|
|
2
|
+
|
|
3
|
+
// Serves the relay memory ledger for child-process CLIs (message, react,
|
|
4
|
+
// approval, schedule) in tests that do not start a full relay. Handlers run
|
|
5
|
+
// in this process, so test-created runs are visible to CLI children through
|
|
6
|
+
// the shared-volume socket. Fixtures that start a relay must NOT also serve:
|
|
7
|
+
// the relay serves the same path.
|
|
8
|
+
export const serveTestLedger = async (
|
|
9
|
+
controlDir: string,
|
|
10
|
+
status: () => { polling: boolean; applicationOnly: boolean; telegramConfigured: boolean; version: string } =
|
|
11
|
+
() => ({ polling: false, applicationOnly: false, telegramConfigured: true, version: 'test' }),
|
|
12
|
+
) => {
|
|
13
|
+
const server = await serveDeliverySocket(
|
|
14
|
+
controlDir,
|
|
15
|
+
createLedgerHandler(controlDir, { wake: () => {}, status }),
|
|
16
|
+
)
|
|
17
|
+
return server
|
|
18
|
+
}
|
|
@@ -22,7 +22,7 @@ test('host restart replaces a lock whose PID was reused by another process',asyn
|
|
|
22
22
|
await mkdir(workspace);await mkdir(directory,{recursive:true})
|
|
23
23
|
await writeFile(path.join(directory,'worker.lock'),JSON.stringify({pid:process.pid,started:'reused-pid'}))
|
|
24
24
|
server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
|
|
25
|
-
for(let n=0;n<
|
|
25
|
+
for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
26
26
|
await readFile(path.join(directory,'heartbeat.json'))
|
|
27
27
|
const lock=JSON.parse(await readFile(path.join(directory,'worker.lock'),'utf8'))
|
|
28
28
|
assert.equal(lock.pid,process.pid)
|
|
@@ -47,6 +47,16 @@ test('isolated installations cannot start host transport', async () => {
|
|
|
47
47
|
abort.abort()
|
|
48
48
|
})
|
|
49
49
|
|
|
50
|
+
test('host bindings reject invalid opencode provider scopes before touching state', async () => {
|
|
51
|
+
const abort=new AbortController()
|
|
52
|
+
for (const opencodeProviders of [['BAD NAME'], [], ['ok', 'ok'], new Array(17).fill('ok')]) {
|
|
53
|
+
await assert.rejects(serveHostExecutor({cli:'grok',agents:[{
|
|
54
|
+
name:'t',workspace:'/tmp/ez-scope-x',controlDir:'/tmp/ez-scope-y',binDir:'/tmp/ez-scope-z',opencodeProviders} as never]},abort.signal),
|
|
55
|
+
/one to sixteen unique provider IDs/)
|
|
56
|
+
}
|
|
57
|
+
abort.abort()
|
|
58
|
+
})
|
|
59
|
+
|
|
50
60
|
test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
|
|
51
61
|
const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
|
|
52
62
|
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
|
|
@@ -63,12 +73,86 @@ test('host restart clears dead native lease only after proving previous CLI stop
|
|
|
63
73
|
await readFile(path.join(toolsHome,'workspace-writer.lock'));
|
|
64
74
|
await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:deadPid}));
|
|
65
75
|
server=serveHostExecutor(installation,abort.signal);
|
|
66
|
-
for(let n=0;n<
|
|
76
|
+
for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
67
77
|
await readFile(path.join(directory,'heartbeat.json'));
|
|
68
78
|
await assert.rejects(readFile(path.join(toolsHome,'workspace-writer.lock')),{code:'ENOENT'});
|
|
69
79
|
}finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
|
|
70
80
|
});
|
|
71
81
|
|
|
82
|
+
test('host execution resolves a declared provider from the bound agent, not a launcher wrapper', async () => {
|
|
83
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-host-provider-'))
|
|
84
|
+
const workspace = path.join(root, 'mind')
|
|
85
|
+
const controlDir = path.join(root, 'control')
|
|
86
|
+
const directory = path.join(controlDir, 'host-executor')
|
|
87
|
+
const provider = {
|
|
88
|
+
id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1',
|
|
89
|
+
envKey: 'OPENROUTER_API_KEY', models: ['deepseek/deepseek-v4.1-flash'],
|
|
90
|
+
}
|
|
91
|
+
const installation = {
|
|
92
|
+
cli: 'codex' as const,
|
|
93
|
+
agents: [{name: 'test', workspace, controlDir, binDir: path.join(root, 'bin'), codexProviders: [provider]}],
|
|
94
|
+
}
|
|
95
|
+
const abort = new AbortController()
|
|
96
|
+
let server: Promise<void> | undefined
|
|
97
|
+
let captured: any
|
|
98
|
+
const previousPath = process.env.PATH
|
|
99
|
+
try {
|
|
100
|
+
await mkdir(workspace, {recursive: true})
|
|
101
|
+
await mkdir(directory, {recursive: true})
|
|
102
|
+
const fakeBin = path.join(root, 'bin')
|
|
103
|
+
await mkdir(fakeBin, {recursive: true})
|
|
104
|
+
await mkdir(path.join(controlDir, 'cli', 'codex'), {recursive: true})
|
|
105
|
+
await writeFile(path.join(fakeBin, 'codex'), '#!/bin/sh\nexit 0\n', {mode: 0o700})
|
|
106
|
+
await writeFile(path.join(controlDir, 'cli', 'codex', 'models_cache.json'), JSON.stringify({models: [
|
|
107
|
+
{slug: provider.models[0], visibility: 'list', display_name: 'DeepSeek V4.1 Flash', supported_reasoning_levels: [{effort: 'max'}]},
|
|
108
|
+
]}))
|
|
109
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${previousPath ?? ''}`
|
|
110
|
+
await ownerRun(controlDir, 'r_provider')
|
|
111
|
+
server = serveHostExecutor(installation, abort.signal, async (_texts, options) => {
|
|
112
|
+
captured = options
|
|
113
|
+
const child = spawn(process.execPath, ['-e', 'process.exit(0)'])
|
|
114
|
+
return {child, cleanup: async () => {}, stdout: ''}
|
|
115
|
+
})
|
|
116
|
+
for (let n = 0; n < 100; n++) {
|
|
117
|
+
try { await readFile(path.join(directory, 'heartbeat.json')); break }
|
|
118
|
+
catch { await new Promise(resolve => setTimeout(resolve, 20)) }
|
|
119
|
+
}
|
|
120
|
+
await writeFile(path.join(directory, 'r_provider.request.json'), JSON.stringify({
|
|
121
|
+
texts: ['hello'], options: {cli: 'codex', provider: 'openrouter', model: provider.models[0], effort: 'max'},
|
|
122
|
+
}))
|
|
123
|
+
let events = ''
|
|
124
|
+
for (let n = 0; n < 100; n++) {
|
|
125
|
+
try {
|
|
126
|
+
events = await readFile(path.join(directory, 'r_provider.events'), 'utf8')
|
|
127
|
+
if (events.includes('"stream":"exit"')) break
|
|
128
|
+
} catch {}
|
|
129
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
130
|
+
}
|
|
131
|
+
assert.match(events, /"stream":"exit","code":0/)
|
|
132
|
+
const catalog = JSON.parse(await readFile(path.join(directory, 'models.json'), 'utf8'))
|
|
133
|
+
assert.deepEqual(catalog.filter((model: any) => model.model === provider.models[0]), [
|
|
134
|
+
{
|
|
135
|
+
cli: 'codex-gui', model: provider.models[0],
|
|
136
|
+
name: 'codex-gui · DeepSeek V4.1 Flash', efforts: ['max'],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
cli: 'codex', provider: 'openrouter', model: provider.models[0],
|
|
140
|
+
name: `OpenRouter · ${provider.models[0]}`, efforts: ['max'],
|
|
141
|
+
},
|
|
142
|
+
])
|
|
143
|
+
assert.deepEqual(catalog.filter((model: any) => model.cli === 'codex' && !model.provider), [])
|
|
144
|
+
assert.equal(captured.provider, 'openrouter')
|
|
145
|
+
assert.deepEqual(captured.codexProvider, provider)
|
|
146
|
+
assert.equal(captured.model, provider.models[0])
|
|
147
|
+
} finally {
|
|
148
|
+
abort.abort()
|
|
149
|
+
await server
|
|
150
|
+
if (previousPath === undefined) delete process.env.PATH
|
|
151
|
+
else process.env.PATH = previousPath
|
|
152
|
+
await rm(root, {recursive: true, force: true})
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
72
156
|
test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
|
|
73
157
|
const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
|
|
74
158
|
const abort=new AbortController()
|
|
@@ -99,7 +183,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
99
183
|
server=serveHostExecutor({cli:'grok',agents},abort.signal)
|
|
100
184
|
for(const agent of agents){
|
|
101
185
|
const dir=path.join(agent.controlDir,'host-executor')
|
|
102
|
-
for(let n=0;n<
|
|
186
|
+
for(let n=0;n<300;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
103
187
|
assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
|
|
104
188
|
const release = await workspaceLease(agent.toolsHome)
|
|
105
189
|
await ownerRun(agent.controlDir, `r_${agent.name}`)
|
package/test/inbox.test.ts
CHANGED
|
@@ -8,12 +8,11 @@ import { InboxStore } from '../src/inbox.js'
|
|
|
8
8
|
import { RunStore } from '../src/runs.js'
|
|
9
9
|
|
|
10
10
|
const update = (id: number): Update => ({ update_id: id })
|
|
11
|
-
test('intake
|
|
11
|
+
test('intake deduplicates IDs, seals membership and retains cancellation tombstones (memory only)', async () => {
|
|
12
12
|
const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-'))
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
const inbox = new InboxStore(dir)
|
|
15
15
|
assert.equal(await inbox.accept(update(1)), true)
|
|
16
|
-
inbox = new InboxStore(dir)
|
|
17
16
|
assert.equal(await inbox.accept(update(1)), false)
|
|
18
17
|
await inbox.accept(update(2))
|
|
19
18
|
assert.equal(await inbox.next(), undefined)
|
|
@@ -23,12 +22,11 @@ test('intake survives restart, deduplicates IDs, seals membership and retains ca
|
|
|
23
22
|
[1, 2],
|
|
24
23
|
)
|
|
25
24
|
await inbox.accept(update(3))
|
|
26
|
-
inbox = new InboxStore(dir)
|
|
27
25
|
assert.deepEqual(await inbox.next(true), batch)
|
|
28
26
|
const runs = new RunStore(dir)
|
|
29
27
|
const input = { id: batch.id, chatId: 101, telegramUserId: 101, texts: ['hello'] }
|
|
30
28
|
await runs.create(input)
|
|
31
|
-
//
|
|
29
|
+
// Retry with the same run ID returns the existing run; no second run.
|
|
32
30
|
await runs.create(input)
|
|
33
31
|
assert.equal((await runs.list()).length, 1)
|
|
34
32
|
await inbox.finish(batch.id)
|
|
@@ -36,19 +34,19 @@ test('intake survives restart, deduplicates IDs, seals membership and retains ca
|
|
|
36
34
|
assert.equal(await inbox.cancel(), 1)
|
|
37
35
|
assert.equal(await inbox.accept(update(3)), false)
|
|
38
36
|
assert.equal(await inbox.next(true), undefined)
|
|
39
|
-
|
|
37
|
+
// Stateless pipe: no inbox.json exists on disk; restart drops by design.
|
|
38
|
+
await assert.rejects(stat(join(dir, 'inbox.json')))
|
|
40
39
|
} finally {
|
|
41
40
|
await rm(dir, { recursive: true, force: true })
|
|
42
41
|
}
|
|
43
42
|
})
|
|
44
43
|
|
|
45
|
-
test('
|
|
44
|
+
test('restart loses nothing by design; invalid identifiers are rejected', async () => {
|
|
46
45
|
const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-corrupt-'))
|
|
47
46
|
try {
|
|
48
|
-
|
|
47
|
+
// A fresh directory starts empty: no durable journal survives a restart.
|
|
49
48
|
const inbox = new InboxStore(dir)
|
|
50
|
-
|
|
51
|
-
assert.equal(await readFile(join(dir, 'inbox.json'), 'utf8'), '{broken')
|
|
49
|
+
assert.deepEqual(await inbox.status(), { pending: 0, failed: 0 })
|
|
52
50
|
assert.throws(() => inbox.accept(update(-1)), /Invalid update ID/)
|
|
53
51
|
await assert.rejects(
|
|
54
52
|
new RunStore(dir).create({ id: '../escape', chatId: 101, telegramUserId: 101, texts: [] }),
|
|
@@ -81,6 +81,7 @@ test('Docker startup uses explicit deployment binding and literal paths without
|
|
|
81
81
|
assert.equal(definition.command, 'docker')
|
|
82
82
|
assert.ok(definition.args.includes('/tmp/my agent $literal/docker.env'))
|
|
83
83
|
assert.deepEqual(definition.args.slice(-4), ['up', '-d', '--wait', 'relay'])
|
|
84
|
+
assert.deepEqual(serviceDefinition('/tmp/isolated', true).args.slice(-5), ['up', '-d', '--wait', 'relay', 'plugin-broker'])
|
|
84
85
|
assert.doesNotMatch(JSON.stringify(definition), /TELEGRAM_BOT_TOKEN|launchctl|systemctl/)
|
|
85
86
|
assert.throws(() => serviceDefinition('/tmp/\nbad'), /single-line/)
|
|
86
87
|
})
|
|
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import * as fs from 'node:fs/promises';
|
|
4
4
|
import {tmpdir} from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import {preflight,installationStatus,build,hostEnvironment} from '../src/install-tools.mjs';
|
|
6
|
+
import {preflight,installationStatus,build,hostEnvironment,migrateLedger} from '../src/install-tools.mjs';
|
|
7
|
+
import {parseEnv} from 'node:util';
|
|
8
|
+
import {RunStore} from '../src/runs.js';
|
|
9
|
+
import {serveTestLedger} from './helpers/ledger.js';
|
|
7
10
|
|
|
8
11
|
async function fixture(t){const root=await fs.mkdtemp(path.join(tmpdir(),'ez-install-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));return root;}
|
|
9
12
|
test('preflight checks the selected executable and reports missing prerequisites without credentials',async t=>{
|
|
@@ -19,10 +22,12 @@ test('preflight checks the selected executable and reports missing prerequisites
|
|
|
19
22
|
test('installation status separates files, pairing and current-owner Telegram delivery evidence',async t=>{
|
|
20
23
|
const root=await fixture(t),control=path.join(root,'control');
|
|
21
24
|
assert.equal((await installationStatus(root)).stage,'not-configured');
|
|
22
|
-
|
|
25
|
+
await fs.mkdir(path.join(control,'host-executor'),{recursive:true});
|
|
23
26
|
for(const name of ['agent.json','host-executor.json','docker.env','relay.env'])await fs.writeFile(path.join(root,name),'private');
|
|
24
27
|
const write=(name,obj)=>fs.writeFile(path.join(control,name),JSON.stringify(obj));
|
|
25
28
|
await write('heartbeat.json',{at:Date.now(),polling:true});await write('host-executor/heartbeat.json',{at:Date.now()});
|
|
29
|
+
// Delivery evidence comes from the running relay's memory ledger.
|
|
30
|
+
const ledger=await serveTestLedger(control,()=>({polling:true,applicationOnly:false,telegramConfigured:true,version:'test'}));t.after(()=>ledger.stop());
|
|
26
31
|
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
27
32
|
await fs.writeFile(path.join(root,'agent.json'),JSON.stringify({isolation:'isolated'}));
|
|
28
33
|
await fs.rm(path.join(control,'host-executor/heartbeat.json'));
|
|
@@ -32,15 +37,46 @@ test('installation status separates files, pairing and current-owner Telegram de
|
|
|
32
37
|
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
33
38
|
const owner={telegramUserId:123,telegramChatId:123,pairedAt:new Date(Date.now()-10000).toISOString()};await write('control-state.json',{owner});
|
|
34
39
|
assert.equal((await installationStatus(root)).stage,'awaiting-telegram-reply');
|
|
35
|
-
const
|
|
36
|
-
|
|
40
|
+
const runs=new RunStore(control),deliver=async(id,text,chatId,telegramUserId,ids,status='completed')=>{
|
|
41
|
+
const run=await runs.create({id,chatId,telegramUserId,texts:[text]});
|
|
42
|
+
await runs.patch(run.id,{status:'running'});
|
|
43
|
+
const item=await runs.enqueueMessage(run.id,text);
|
|
44
|
+
await runs.claimOutbox(item.id);await runs.markOutboxSent(item.id,ids);
|
|
45
|
+
await runs.patch(run.id,{status});
|
|
46
|
+
};
|
|
47
|
+
// Another chat member's completed delivery is not the owner's reply evidence.
|
|
48
|
+
await deliver('tg_13','other member',123,999,[42]);
|
|
37
49
|
assert.equal((await installationStatus(root)).telegramReplyVerified,false);
|
|
38
|
-
|
|
50
|
+
// A failed run never counts, even with a receipt.
|
|
51
|
+
await deliver('tg_14','failed',123,123,[43],'failed');
|
|
52
|
+
assert.equal((await installationStatus(root)).telegramReplyVerified,false);
|
|
53
|
+
await deliver('tg_12','hello',123,123,[42]);
|
|
39
54
|
assert.equal((await installationStatus(root)).stage,'ready-for-telegram-plugin-request');
|
|
40
|
-
|
|
41
|
-
await write('
|
|
55
|
+
// With the relay down, status falls back to the legacy heartbeat file.
|
|
56
|
+
await ledger.stop();await write('heartbeat.json',{at:0,polling:true});assert.equal((await installationStatus(root)).stage,'runtime-offline');
|
|
42
57
|
await fs.writeFile(path.join(control,'control-state.json'),'{');await assert.rejects(installationStatus(root));
|
|
43
58
|
});
|
|
59
|
+
test('host-capable deployments migrate once to a loopback ledger endpoint',async t=>{
|
|
60
|
+
const root=await fixture(t),deployment=path.join(root,'agent');await fs.mkdir(deployment);
|
|
61
|
+
const envFile=path.join(deployment,'docker.env');
|
|
62
|
+
await fs.writeFile(envFile,"COMPOSE_PROJECT_NAME='ez-agent-canary'\nCOMPOSE_FILE='/opt/ez/compose.yaml:/opt/ez/extra.compose.yaml'\n",{mode:0o600});
|
|
63
|
+
const first=await migrateLedger({deployment});
|
|
64
|
+
assert.equal(first.changed,true);assert.ok(Number.isSafeInteger(first.port)&&first.port>=1024);
|
|
65
|
+
const values=parseEnv(await fs.readFile(envFile,'utf8'));
|
|
66
|
+
assert.equal(values.EZ_DELIVERY_TCP_PORT,String(first.port));
|
|
67
|
+
assert.equal(values.COMPOSE_FILE,`/opt/ez/compose.yaml:/opt/ez/extra.compose.yaml:${path.join(deployment,'ledger.compose.yaml')}`);
|
|
68
|
+
assert.match(await fs.readFile(path.join(deployment,'ledger.compose.yaml'),'utf8'),/127\.0\.0\.1:\$\{EZ_DELIVERY_TCP_PORT:\?\}/);
|
|
69
|
+
assert.equal((await fs.stat(envFile)).mode&0o777,0o600);
|
|
70
|
+
const second=await migrateLedger({deployment});
|
|
71
|
+
assert.equal(second.changed,false);assert.equal(second.port,first.port);
|
|
72
|
+
await assert.rejects(migrateLedger({deployment:path.join(root,'missing')}),/docker.env not found/);
|
|
73
|
+
});
|
|
74
|
+
test('isolated deployments need no host ledger endpoint',async t=>{
|
|
75
|
+
const root=await fixture(t),deployment=path.join(root,'isolated');await fs.mkdir(deployment);
|
|
76
|
+
await fs.writeFile(path.join(deployment,'docker.env'),"COMPOSE_PROJECT_NAME='ez-agent-isolated'\nCOMPOSE_FILE='/opt/ez/compose.yaml'\nEZ_ISOLATION='isolated'\nEZ_EXECUTOR_TRANSPORT='local'\n",{mode:0o600});
|
|
77
|
+
const result=await migrateLedger({deployment});
|
|
78
|
+
assert.equal(result.changed,false);assert.match(result.note,/Isolated/);
|
|
79
|
+
});
|
|
44
80
|
test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
|
|
45
81
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
46
82
|
let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
|
|
@@ -51,6 +87,37 @@ test('same-artifact build retries do not spawn a second Docker build and reuse v
|
|
|
51
87
|
assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
|
|
52
88
|
await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
|
|
53
89
|
});
|
|
90
|
+
test('source checkouts build RCs only: clean reviewed commit plus increasing label',async t=>{
|
|
91
|
+
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
92
|
+
let inside=true,status='',built=[],toplevel=source;const sha='a'.repeat(40);
|
|
93
|
+
const invoke=async(cmd,args)=>{
|
|
94
|
+
if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
|
|
95
|
+
if(cmd==='git'){
|
|
96
|
+
if(!inside)throw Error('not a git repository');
|
|
97
|
+
if(args[2]==='rev-parse'&&args[3]==='--show-toplevel')return toplevel;
|
|
98
|
+
if(args[2]==='status')return status;
|
|
99
|
+
if(args[2]==='rev-parse'&&args[3]==='HEAD')return sha;
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
if(args[0]==='build'){built.push(args);return 'image';}
|
|
103
|
+
return 'sha256:fixture';
|
|
104
|
+
};
|
|
105
|
+
await assert.rejects(build({home,source},invoke),/requires --label/);
|
|
106
|
+
await assert.rejects(build({home,source,label:'0.1.0-beta.36'},invoke),/RC label/);
|
|
107
|
+
status=' M src/executor.ts';
|
|
108
|
+
await assert.rejects(build({home,source,label:'0.1.0-beta.36.rc.2'},invoke),/Commit the reviewed source/);
|
|
109
|
+
status='';
|
|
110
|
+
const result=await build({home,source,label:'0.1.0-beta.36.rc.2'},invoke);
|
|
111
|
+
assert.equal(result.label,'0.1.0-beta.36.rc.2');assert.equal(result.sha,sha);
|
|
112
|
+
const args=built.at(-1);
|
|
113
|
+
assert.ok(args.includes('BUILD_TAG=0.1.0-beta.36.rc.2'));assert.ok(args.includes(`BUILD_SHA=${sha}`));
|
|
114
|
+
assert.ok(args.includes('ezenciel-agents:0.1.0-beta.36.rc.2'));
|
|
115
|
+
inside=false;
|
|
116
|
+
await assert.rejects(build({home,source,label:'0.1.0-beta.36.rc.2'},invoke),/--label requires a git checkout/);
|
|
117
|
+
// A tarball unpacked inside an unrelated repository is not a source checkout.
|
|
118
|
+
inside=true;toplevel=home;
|
|
119
|
+
assert.equal((await build({home,source},invoke)).label,undefined);
|
|
120
|
+
});
|
|
54
121
|
test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
|
|
55
122
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
56
123
|
const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
|