@jc_stack/ez-agents 0.1.0-beta.12
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 +24 -0
- package/.env.example +26 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +73 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SECURITY.md +26 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/ezenciel-agents +2 -0
- package/bin/ezenciel-agents-ai +2 -0
- package/bin/ezenciel-agents-ai.mjs +5 -0
- package/bin/ezenciel-agents-approval +2 -0
- package/bin/ezenciel-agents-approval.mjs +16 -0
- package/bin/ezenciel-agents-create +12 -0
- package/bin/ezenciel-agents-docker +6 -0
- package/bin/ezenciel-agents-host +5 -0
- package/bin/ezenciel-agents-install +2 -0
- package/bin/ezenciel-agents-message +2 -0
- package/bin/ezenciel-agents-message.mjs +16 -0
- package/bin/ezenciel-agents-owner +2 -0
- package/bin/ezenciel-agents-owner.mjs +18 -0
- package/bin/ezenciel-agents-react +2 -0
- package/bin/ezenciel-agents-react.mjs +16 -0
- package/bin/ezenciel-agents-setup.mjs +18 -0
- package/bin/ezenciel-agents-source +2 -0
- package/bin/ezenciel-agents-source.mjs +16 -0
- package/bin/ezenciel-agents-tools.mjs +3 -0
- package/bin/ezenciel-agents.mjs +37 -0
- package/compose.whatsapp.yaml +12 -0
- package/compose.yaml +40 -0
- package/default-plugins.json +1 -0
- package/docker/entrypoint.sh +15 -0
- package/docker/healthcheck.mjs +8 -0
- package/docker/plugin-smoke.mjs +48 -0
- package/docker/pnpm-lock.yaml +415 -0
- package/docker/recovery.ts +11 -0
- package/docker/run.ts +52 -0
- package/docker/smoke.mjs +47 -0
- package/docker/status-smoke.mjs +30 -0
- package/docker/upgrade-smoke.mjs +58 -0
- package/docs/architecture/ai-selection.md +37 -0
- package/docs/architecture/authority-boundaries.md +14 -0
- package/docs/architecture/event-sources.md +34 -0
- package/docs/architecture/telegram-intake.md +29 -0
- package/docs/development-and-testing.md +18 -0
- package/docs/docker-runtime.md +118 -0
- package/docs/host-service.md +80 -0
- package/docs/plugin-contributions.md +34 -0
- package/docs/plugins.md +181 -0
- package/docs/releasing.md +71 -0
- package/docs/setup.md +234 -0
- package/docs/upgrades.md +193 -0
- package/package.json +106 -0
- package/scripts/assert-local-registry.mjs +22 -0
- package/scripts/release-check.mjs +14 -0
- package/scripts/smoke.ts +102 -0
- package/src/agent-install.ts +96 -0
- package/src/ai-cli.ts +22 -0
- package/src/ai.ts +88 -0
- package/src/approval-cli.ts +59 -0
- package/src/approval.ts +119 -0
- package/src/audio.ts +184 -0
- package/src/client-defaults.ts +101 -0
- package/src/config.ts +48 -0
- package/src/control-state.ts +350 -0
- package/src/desktop-bridge.ts +284 -0
- package/src/event-sources.ts +112 -0
- package/src/executor.ts +335 -0
- package/src/files.ts +75 -0
- package/src/format.ts +57 -0
- package/src/host-executor-client.ts +46 -0
- package/src/host-executor-protocol.ts +2 -0
- package/src/host-executor.ts +129 -0
- package/src/identity.ts +17 -0
- package/src/inbox.ts +171 -0
- package/src/index.ts +812 -0
- package/src/install-config.ts +56 -0
- package/src/install-tools.mjs +98 -0
- package/src/menu.ts +123 -0
- package/src/message-send.ts +57 -0
- package/src/message.ts +68 -0
- package/src/owner-args.ts +4 -0
- package/src/owner.ts +30 -0
- package/src/plugins/manager.mjs +272 -0
- package/src/react.ts +33 -0
- package/src/reaction.ts +32 -0
- package/src/read-request.ts +72 -0
- package/src/reply.ts +13 -0
- package/src/runs.ts +445 -0
- package/src/service.ts +28 -0
- package/src/setup.ts +180 -0
- package/src/software-status.ts +23 -0
- package/src/source-cli.ts +18 -0
- package/src/update-attention.ts +18 -0
- package/src/updates/artifact.mjs +83 -0
- package/src/updates/binding.mjs +27 -0
- package/src/updates/control.mjs +131 -0
- package/src/updates/launch.mjs +13 -0
- package/src/updates/runtime.mjs +140 -0
- package/src/updates/status.mjs +49 -0
- package/src/updates/supervisor.mjs +102 -0
- package/src/version.ts +4 -0
- package/src/workspace.ts +32 -0
- package/templates/agent/AGENTS.md +49 -0
- package/templates/agent/SOUL.md +11 -0
- package/templates/agent/TOOLS.md +46 -0
- package/templates/agent/USER.md +5 -0
- package/templates/updates.md +45 -0
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +28 -0
- package/test/ai.test.ts +105 -0
- package/test/approval.test.ts +40 -0
- package/test/audio.test.ts +77 -0
- package/test/client-defaults.test.ts +64 -0
- package/test/codex-context.test.ts +33 -0
- package/test/config.test.ts +32 -0
- package/test/control-state.test.ts +58 -0
- package/test/desktop-bridge.test.ts +159 -0
- package/test/docker-runtime.test.ts +23 -0
- package/test/event-sources.test.ts +113 -0
- package/test/executor.test.ts +135 -0
- package/test/files.test.ts +50 -0
- package/test/format.test.ts +41 -0
- package/test/host-executor.test.ts +149 -0
- package/test/inbox-burst.test.ts +66 -0
- package/test/inbox.test.ts +102 -0
- package/test/install-config.test.ts +75 -0
- package/test/install-tools.test.mjs +59 -0
- package/test/intake-relay.test.ts +337 -0
- package/test/owner-help.test.mjs +10 -0
- package/test/plugin-manager.test.mjs +157 -0
- package/test/publish-guard.test.ts +21 -0
- package/test/reaction.test.ts +122 -0
- package/test/read-request.test.ts +134 -0
- package/test/relay.test.ts +254 -0
- package/test/release-entrypoints.test.mjs +26 -0
- package/test/runs.test.ts +116 -0
- package/test/security.test.ts +85 -0
- package/test/setup.test.ts +68 -0
- package/test/software-status.test.ts +31 -0
- package/test/update-attention.test.ts +21 -0
- package/test/updates.test.mjs +282 -0
- package/test/upgrade-pause.test.ts +70 -0
- package/test/workspace.test.ts +80 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm, realpath } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { serveHostExecutor } from '../src/host-executor.js'
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { isHostRunId } from '../src/host-executor-protocol.js'
|
|
10
|
+
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
11
|
+
import { packageVersion } from '../src/version.js'
|
|
12
|
+
|
|
13
|
+
test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
|
|
14
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
|
|
15
|
+
const abort=new AbortController()
|
|
16
|
+
const old=EXECUTOR_REGISTRY.grok.command
|
|
17
|
+
const oldBuild=EXECUTOR_REGISTRY.grok.buildArgs
|
|
18
|
+
const oldToken=process.env.TELEGRAM_BOT_TOKEN
|
|
19
|
+
const oldPath=process.env.PATH
|
|
20
|
+
let server:Promise<void>|undefined
|
|
21
|
+
try {
|
|
22
|
+
const binary=path.join(root,'cli')
|
|
23
|
+
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})
|
|
24
|
+
await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
|
|
25
|
+
process.env.PATH=root+path.delimiter+oldPath
|
|
26
|
+
EXECUTOR_REGISTRY.grok.command=binary
|
|
27
|
+
EXECUTOR_REGISTRY.grok.buildArgs=EXECUTOR_REGISTRY.codex.buildArgs
|
|
28
|
+
process.env.TELEGRAM_BOT_TOKEN='must-not-reach-host-cli'
|
|
29
|
+
const agents=await Promise.all(['one','two'].map(async name=>{
|
|
30
|
+
const workspace=path.join(root,name,'mind'),controlDir=path.join(root,name,'control')
|
|
31
|
+
await mkdir(workspace,{recursive:true});await mkdir(controlDir,{recursive:true})
|
|
32
|
+
const toolsHome=path.join(root,name,'tools');await mkdir(toolsHome)
|
|
33
|
+
await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}))
|
|
34
|
+
return {name,workspace,controlDir,binDir:path.join(root,'bin'),toolsHome}
|
|
35
|
+
}))
|
|
36
|
+
server=serveHostExecutor({cli:'grok',agents},abort.signal)
|
|
37
|
+
for(const agent of agents){
|
|
38
|
+
const dir=path.join(agent.controlDir,'host-executor')
|
|
39
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
40
|
+
assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
|
|
41
|
+
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}}))
|
|
42
|
+
}
|
|
43
|
+
for(const agent of agents){
|
|
44
|
+
const file=path.join(agent.controlDir,'host-executor',`r_${agent.name}.events`)
|
|
45
|
+
let events:any[]=[]
|
|
46
|
+
for(let n=0;n<150;n++){try{events=(await readFile(file,'utf8')).trim().split('\n').map(l=>JSON.parse(l));if(events.some(e=>e.stream==='exit'))break}catch{}await new Promise(r=>setTimeout(r,20))}
|
|
47
|
+
assert.equal(events.find(e=>e.stream==='exit')?.code,0)
|
|
48
|
+
const result=JSON.parse(events.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
49
|
+
assert.equal(result.cwd,await realpath(agent.workspace))
|
|
50
|
+
assert.equal(result.control,agent.controlDir)
|
|
51
|
+
assert.equal(result.token,undefined)
|
|
52
|
+
assert.ok(result.args.includes(agent.toolsHome))
|
|
53
|
+
assert.ok(!result.args.includes('/wrong'))
|
|
54
|
+
assert.equal(result.home,process.env.HOME)
|
|
55
|
+
}
|
|
56
|
+
// Exercise the actual client -> file transport -> host CLI path, not a
|
|
57
|
+
// hand-written smoke request, with the production Telegram batch ID shape.
|
|
58
|
+
const client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),agents[0].controlDir,'tg_6293305'],{stdio:['pipe','pipe','pipe']})
|
|
59
|
+
let stdout='',stderr=''
|
|
60
|
+
client.stdout.on('data',chunk=>stdout+=chunk)
|
|
61
|
+
client.stderr.on('data',chunk=>stderr+=chunk)
|
|
62
|
+
client.stdin.end(JSON.stringify({texts:['Telegram message'],options:{cli:'grok',timeoutMs:5000}}))
|
|
63
|
+
assert.equal(await new Promise(resolve=>client.once('close',resolve)),0,stderr)
|
|
64
|
+
assert.equal(JSON.parse(stdout).run,'tg_6293305')
|
|
65
|
+
const eventId='event_'+'a'.repeat(64)
|
|
66
|
+
const eventClient=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),agents[0].controlDir,eventId],{stdio:['pipe','pipe','pipe']})
|
|
67
|
+
let eventOutput='',eventError=''
|
|
68
|
+
eventClient.stdout.on('data',chunk=>eventOutput+=chunk)
|
|
69
|
+
eventClient.stderr.on('data',chunk=>eventError+=chunk)
|
|
70
|
+
eventClient.stdin.end(JSON.stringify({texts:['Untrusted plugin event'],options:{cli:'grok',timeoutMs:5000}}))
|
|
71
|
+
assert.equal(await new Promise(resolve=>eventClient.once('close',resolve)),0,eventError)
|
|
72
|
+
assert.equal(JSON.parse(eventOutput).run,eventId)
|
|
73
|
+
const switched=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),agents[0].controlDir,'tg_6293306'],{stdio:['pipe','pipe','pipe']})
|
|
74
|
+
let switchedOutput=''
|
|
75
|
+
switched.stdout.on('data',chunk=>switchedOutput+=chunk)
|
|
76
|
+
switched.stderr.resume()
|
|
77
|
+
switched.stdin.end(JSON.stringify({texts:['Explicit CLI change'],options:{cli:'claude',timeoutMs:5000}}))
|
|
78
|
+
assert.equal(await new Promise(resolve=>switched.once('close',resolve)),0)
|
|
79
|
+
assert.ok(JSON.parse(switchedOutput).args.includes('--print'))
|
|
80
|
+
await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
|
|
81
|
+
const directory=path.join(agents[0].controlDir,'host-executor')
|
|
82
|
+
const submit=async(id:string)=>writeFile(path.join(directory,id+'.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',timeoutMs:5000}}))
|
|
83
|
+
await submit('r_hold')
|
|
84
|
+
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))}}
|
|
85
|
+
await submit('r_queued')
|
|
86
|
+
await new Promise(r=>setTimeout(r,350))
|
|
87
|
+
await assert.rejects(readFile(path.join(directory,'r_queued.running.json')),{code:'ENOENT'})
|
|
88
|
+
await writeFile(path.join(directory,'r_hold.cancel'),'')
|
|
89
|
+
let output=''
|
|
90
|
+
for(let n=0;n<200;n++){try{output=await readFile(path.join(directory,'r_queued.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
|
|
91
|
+
assert.match(output, /"stream":"exit","code":0/)
|
|
92
|
+
assert.match(await readFile(path.join(directory,'r_hold.events'),'utf8'), /"stream":"exit","code":1/)
|
|
93
|
+
} finally {
|
|
94
|
+
abort.abort();await server
|
|
95
|
+
EXECUTOR_REGISTRY.grok.command=old
|
|
96
|
+
EXECUTOR_REGISTRY.grok.buildArgs=oldBuild
|
|
97
|
+
process.env.PATH=oldPath
|
|
98
|
+
if(oldToken===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=oldToken
|
|
99
|
+
await rm(root,{recursive:true,force:true})
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
test('host run IDs accept production formats and reject unsafe paths',()=>{
|
|
105
|
+
for(const id of ['tg_6293305','r_example_123','event_'+'a'.repeat(64)])assert.equal(isHostRunId(id),true)
|
|
106
|
+
for(const id of ['../tg_1','tg_1/other','tg_abc','event_bad','event_'+'a'.repeat(63),'event_'+'g'.repeat(64),'tg_1\n','r_',''])assert.equal(isHostRunId(id),false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('client tolerates missing heartbeat and consumes completion before checking host health',async()=>{
|
|
110
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-heartbeat-'))
|
|
111
|
+
const directory=path.join(root,'host-executor');await mkdir(directory)
|
|
112
|
+
const client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'tg_99'],{stdio:['pipe','pipe','pipe']})
|
|
113
|
+
const closed=new Promise(resolve=>client.once('close',resolve))
|
|
114
|
+
let stderr='';client.stderr.on('data',chunk=>stderr+=chunk);client.stdout.resume()
|
|
115
|
+
client.stdin.end(JSON.stringify({texts:['test'],options:{}}))
|
|
116
|
+
try {
|
|
117
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_99.request.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
118
|
+
await new Promise(r=>setTimeout(r,400))
|
|
119
|
+
assert.equal(client.exitCode,null,stderr)
|
|
120
|
+
await assert.rejects(readFile(path.join(directory,'tg_99.cancel')),{code:'ENOENT'})
|
|
121
|
+
await writeFile(path.join(directory,'tg_99.events'),JSON.stringify({stream:'exit',code:0})+'\n')
|
|
122
|
+
assert.equal(await closed,0,stderr)
|
|
123
|
+
} finally {client.kill();await closed;await rm(root,{recursive:true,force:true})}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('client cancels on stale or invalid heartbeat instead of waiting indefinitely',async()=>{
|
|
127
|
+
for(const heartbeat of [{at:Date.now()-60000},{at:'invalid'}]) {
|
|
128
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-heartbeat-invalid-'))
|
|
129
|
+
try {
|
|
130
|
+
const directory=path.join(root,'host-executor');await mkdir(directory)
|
|
131
|
+
await writeFile(path.join(directory,'heartbeat.json'),JSON.stringify(heartbeat))
|
|
132
|
+
const client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'tg_98'],{stdio:['pipe','pipe','pipe']})
|
|
133
|
+
let stderr='';client.stderr.on('data',chunk=>stderr+=chunk);client.stdout.resume()
|
|
134
|
+
client.stdin.end(JSON.stringify({texts:['test'],options:{}}))
|
|
135
|
+
assert.equal(await new Promise(resolve=>client.once('close',resolve)),1)
|
|
136
|
+
assert.match(stderr,/offline|Invalid host CLI heartbeat/)
|
|
137
|
+
assert.equal(await readFile(path.join(directory,'tg_98.cancel'),'utf8'),'')
|
|
138
|
+
} finally {await rm(root,{recursive:true,force:true})}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
test('host rejects a plugin registry bound to another workspace',async()=>{
|
|
144
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-host-plugin-binding-'))
|
|
145
|
+
try {
|
|
146
|
+
await writeFile(path.join(root,'config.json'),JSON.stringify({schemaVersion:1,workspace:'/another-agent'}))
|
|
147
|
+
await assert.rejects(serveHostExecutor({cli:'codex',agents:[{name:'test',workspace:root,controlDir:root,binDir:root,toolsHome:root}]},new AbortController().signal),/another workspace/)
|
|
148
|
+
} finally {await rm(root,{recursive:true,force:true})}
|
|
149
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { InboxStore } from '../src/inbox.js'
|
|
7
|
+
|
|
8
|
+
test('durable debounce resets quiet time and respects the ten-item cap', async () => {
|
|
9
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-burst-'))
|
|
10
|
+
let now = 0
|
|
11
|
+
const inbox = new InboxStore(dir, () => now)
|
|
12
|
+
try {
|
|
13
|
+
await inbox.accept({ update_id: 1 })
|
|
14
|
+
now = 1999
|
|
15
|
+
assert.equal(await inbox.next(), undefined)
|
|
16
|
+
await inbox.accept({ update_id: 2 })
|
|
17
|
+
now = 3998
|
|
18
|
+
assert.equal(await inbox.next(), undefined)
|
|
19
|
+
now = 3999
|
|
20
|
+
const first = (await inbox.next())!
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
first.entries.map((e) => e.update.update_id),
|
|
23
|
+
[1, 2],
|
|
24
|
+
)
|
|
25
|
+
await inbox.finish(first.id)
|
|
26
|
+
for (let id = 3; id <= 13; id++) await inbox.accept({ update_id: id })
|
|
27
|
+
const full = (await inbox.next())!
|
|
28
|
+
assert.equal(full.entries.length, 10)
|
|
29
|
+
await inbox.finish(full.id)
|
|
30
|
+
now += 2000
|
|
31
|
+
assert.equal((await inbox.next())?.entries[0].update.update_id, 13)
|
|
32
|
+
} finally {
|
|
33
|
+
await rm(dir, { recursive: true, force: true })
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('restart-aged work flushes at max wait and an album is not split at the cap', async () => {
|
|
38
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-album-'))
|
|
39
|
+
let now = 0
|
|
40
|
+
const inbox = new InboxStore(dir, () => now)
|
|
41
|
+
try {
|
|
42
|
+
await inbox.accept({ update_id: 1 })
|
|
43
|
+
now = 29999
|
|
44
|
+
await inbox.accept({ update_id: 2 })
|
|
45
|
+
now = 30000
|
|
46
|
+
const first = (await inbox.next())!
|
|
47
|
+
assert.equal(first.entries.length, 2)
|
|
48
|
+
await inbox.finish(first.id)
|
|
49
|
+
for (let id = 3; id <= 13; id++)
|
|
50
|
+
await inbox.accept({
|
|
51
|
+
update_id: id,
|
|
52
|
+
message: {
|
|
53
|
+
message_id: id,
|
|
54
|
+
from: { id: 101, is_bot: false, first_name: 'Fixture' },
|
|
55
|
+
date: 0,
|
|
56
|
+
chat: { id: 101, type: 'private', first_name: 'Fixture' },
|
|
57
|
+
media_group_id: id >= 11 ? 'fixture_album' : undefined,
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
assert.equal(await inbox.next(), undefined)
|
|
61
|
+
now += 2000
|
|
62
|
+
assert.equal((await inbox.next())?.entries.length, 11)
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(dir, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, writeFile, stat, readFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import type { Update } from 'grammy/types'
|
|
7
|
+
import { InboxStore } from '../src/inbox.js'
|
|
8
|
+
import { RunStore } from '../src/runs.js'
|
|
9
|
+
|
|
10
|
+
const update = (id: number): Update => ({ update_id: id })
|
|
11
|
+
test('intake survives restart, deduplicates IDs, seals membership and retains cancellation tombstones', async () => {
|
|
12
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-'))
|
|
13
|
+
try {
|
|
14
|
+
let inbox = new InboxStore(dir)
|
|
15
|
+
assert.equal(await inbox.accept(update(1)), true)
|
|
16
|
+
inbox = new InboxStore(dir)
|
|
17
|
+
assert.equal(await inbox.accept(update(1)), false)
|
|
18
|
+
await inbox.accept(update(2))
|
|
19
|
+
assert.equal(await inbox.next(), undefined)
|
|
20
|
+
const batch = (await inbox.next(true))!
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
batch.entries.map((e) => e.update.update_id),
|
|
23
|
+
[1, 2],
|
|
24
|
+
)
|
|
25
|
+
await inbox.accept(update(3))
|
|
26
|
+
inbox = new InboxStore(dir)
|
|
27
|
+
assert.deepEqual(await inbox.next(true), batch)
|
|
28
|
+
const runs = new RunStore(dir)
|
|
29
|
+
const input = { id: batch.id, chatId: 101, telegramUserId: 101, texts: ['hello'] }
|
|
30
|
+
await runs.create(input)
|
|
31
|
+
// Crash between run creation and inbox completion must not produce a second run.
|
|
32
|
+
await runs.create(input)
|
|
33
|
+
assert.equal((await runs.list()).length, 1)
|
|
34
|
+
await inbox.finish(batch.id)
|
|
35
|
+
assert.equal((await inbox.next(true))?.id, 'tg_3')
|
|
36
|
+
assert.equal(await inbox.cancel(), 1)
|
|
37
|
+
assert.equal(await inbox.accept(update(3)), false)
|
|
38
|
+
assert.equal(await inbox.next(true), undefined)
|
|
39
|
+
assert.equal((await stat(join(dir, 'inbox.json'))).mode & 0o777, 0o600)
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(dir, { recursive: true, force: true })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('corrupt intake fails closed without overwriting evidence; invalid identifiers are rejected', async () => {
|
|
46
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-corrupt-'))
|
|
47
|
+
try {
|
|
48
|
+
await writeFile(join(dir, 'inbox.json'), '{broken')
|
|
49
|
+
const inbox = new InboxStore(dir)
|
|
50
|
+
await assert.rejects(inbox.accept(update(4)), /Inbox unreadable/)
|
|
51
|
+
assert.equal(await readFile(join(dir, 'inbox.json'), 'utf8'), '{broken')
|
|
52
|
+
assert.throws(() => inbox.accept(update(-1)), /Invalid update ID/)
|
|
53
|
+
await assert.rejects(
|
|
54
|
+
new RunStore(dir).create({ id: '../escape', chatId: 101, telegramUserId: 101, texts: [] }),
|
|
55
|
+
)
|
|
56
|
+
} finally {
|
|
57
|
+
await rm(dir, { recursive: true, force: true })
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('failed batches are visible, not automatically retried, and do not block following messages', async () => {
|
|
62
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-failed-'))
|
|
63
|
+
try {
|
|
64
|
+
const inbox = new InboxStore(dir)
|
|
65
|
+
await Promise.all([inbox.accept(update(1)), inbox.accept(update(2)), inbox.accept(update(2))])
|
|
66
|
+
const batch = (await inbox.next(true))!
|
|
67
|
+
await inbox.finish(batch.id, true)
|
|
68
|
+
assert.deepEqual(await inbox.status(), { pending: 0, failed: 1 })
|
|
69
|
+
assert.equal(await inbox.next(true), undefined)
|
|
70
|
+
await inbox.accept(update(3))
|
|
71
|
+
assert.equal((await inbox.next(true))?.id, 'tg_3')
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(dir, { recursive: true, force: true })
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('manual retry is owner/chat-bound and cannot revive pending or cancelled work', async () => {
|
|
78
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-retry-'))
|
|
79
|
+
try {
|
|
80
|
+
const inbox = new InboxStore(dir)
|
|
81
|
+
await inbox.accept({
|
|
82
|
+
update_id: 1,
|
|
83
|
+
message: {
|
|
84
|
+
message_id: 1,
|
|
85
|
+
date: 0,
|
|
86
|
+
text: 'Fixture',
|
|
87
|
+
from: { id: 101, is_bot: false, first_name: 'Fixture' },
|
|
88
|
+
chat: { id: 101, type: 'private', first_name: 'Fixture' },
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
const batch = (await inbox.next(true))!
|
|
92
|
+
await inbox.finish(batch.id, true)
|
|
93
|
+
assert.equal(await inbox.retryLatest(202, 101), undefined)
|
|
94
|
+
assert.equal(await inbox.retryLatest(101, -101), undefined)
|
|
95
|
+
assert.equal(await inbox.retryLatest(101, 101), batch.id)
|
|
96
|
+
assert.equal(await inbox.retryLatest(101, 101), undefined)
|
|
97
|
+
await inbox.cancel()
|
|
98
|
+
assert.equal(await inbox.retryLatest(101, 101), undefined)
|
|
99
|
+
} finally {
|
|
100
|
+
await rm(dir, { recursive: true, force: true })
|
|
101
|
+
}
|
|
102
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { parseEnv } from 'node:util'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import { configureInstallation } from '../src/install-config.js'
|
|
10
|
+
import { readActiveExecutor } from '../src/setup.js'
|
|
11
|
+
import { serviceDefinition } from '../src/service.js'
|
|
12
|
+
|
|
13
|
+
test('configure preserves custom paths and unknown settings, keeps token private, and never resets the mind', async () => {
|
|
14
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-'))
|
|
15
|
+
try {
|
|
16
|
+
await writeFile(path.join(root, '.env'), 'EZ_AGENT_WORKSPACE="my mind"\nEZ_CONTROL_DIR="private control"\nEXTRA="keep me"\n')
|
|
17
|
+
const token = '123456789:' + 'a'.repeat(30)
|
|
18
|
+
const result = await configureInstallation(root, 'codex', token)
|
|
19
|
+
assert.equal(result.tokenConfigured, true)
|
|
20
|
+
assert.equal(JSON.stringify(result).includes(token), false)
|
|
21
|
+
const env = parseEnv(await readFile(result.envFile, 'utf8'))
|
|
22
|
+
assert.equal(env.EXTRA, 'keep me')
|
|
23
|
+
assert.equal(env.TELEGRAM_BOT_TOKEN, token)
|
|
24
|
+
assert.equal(env.EZ_AGENT_WORKSPACE, path.join(root, 'my mind'))
|
|
25
|
+
assert.equal((await stat(result.envFile)).mode & 0o777, 0o600)
|
|
26
|
+
assert.equal(await readActiveExecutor(result.envFile), 'codex')
|
|
27
|
+
const soul = path.join(result.workspace, 'SOUL.md')
|
|
28
|
+
await writeFile(soul, 'Customized purpose')
|
|
29
|
+
const repeated = await configureInstallation(root, 'codex')
|
|
30
|
+
assert.deepEqual(repeated.created, [])
|
|
31
|
+
assert.equal(repeated.tokenConfigured, true)
|
|
32
|
+
assert.equal(await readFile(soul, 'utf8'), 'Customized purpose')
|
|
33
|
+
await assert.rejects(stat(result.controlDir), { code: 'ENOENT' }) // No control/owner mutation.
|
|
34
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('configure rejects secret/path hazards without replacing existing configuration', async () => {
|
|
38
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-'))
|
|
39
|
+
try {
|
|
40
|
+
const env = path.join(root, '.env')
|
|
41
|
+
const original = 'EZ_AGENT_WORKSPACE="."\n'
|
|
42
|
+
await writeFile(env, original)
|
|
43
|
+
await assert.rejects(configureInstallation(root, 'codex'), /separate/)
|
|
44
|
+
await assert.rejects(configureInstallation(root, 'codex', 'do-not-print-me'), error => !String(error).includes('do-not-print-me'))
|
|
45
|
+
assert.equal(await readFile(env, 'utf8'), original)
|
|
46
|
+
await rm(env)
|
|
47
|
+
const target = path.join(root, 'private-file')
|
|
48
|
+
await writeFile(target, 'preserve')
|
|
49
|
+
await symlink(target, env)
|
|
50
|
+
await assert.rejects(configureInstallation(root, 'codex'), /regular file/)
|
|
51
|
+
assert.equal(await readFile(target, 'utf8'), 'preserve')
|
|
52
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('packaged configure accepts token through stdin without echo or extra initialization', async () => {
|
|
56
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-cli-'))
|
|
57
|
+
try {
|
|
58
|
+
const token = '123456789:' + 'b'.repeat(30)
|
|
59
|
+
const bin = fileURLToPath(new URL('../bin/ezenciel-agents-setup.mjs', import.meta.url))
|
|
60
|
+
const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
|
|
61
|
+
assert.equal(result.status, 0, result.stderr)
|
|
62
|
+
assert.equal((result.stdout + result.stderr).includes(token), false)
|
|
63
|
+
assert.equal(JSON.parse(result.stdout).created.length, 4)
|
|
64
|
+
assert.equal(parseEnv(await readFile(path.join(root, '.env'), 'utf8')).TELEGRAM_BOT_TOKEN, token)
|
|
65
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('Docker startup uses explicit deployment binding and literal paths without secrets or host supervisors', () => {
|
|
69
|
+
const definition = serviceDefinition('/tmp/my agent $literal')
|
|
70
|
+
assert.equal(definition.command, 'docker')
|
|
71
|
+
assert.ok(definition.args.includes('/tmp/my agent $literal/docker.env'))
|
|
72
|
+
assert.deepEqual(definition.args.slice(-4), ['up', '-d', '--wait', 'relay'])
|
|
73
|
+
assert.doesNotMatch(JSON.stringify(definition), /TELEGRAM_BOT_TOKEN|launchctl|systemctl/)
|
|
74
|
+
assert.throws(() => serviceDefinition('/tmp/\nbad'), /single-line/)
|
|
75
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {preflight,installationStatus,build,hostEnvironment} from '../src/install-tools.mjs';
|
|
7
|
+
|
|
8
|
+
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
|
+
test('preflight checks the selected executable and reports missing prerequisites without credentials',async t=>{
|
|
10
|
+
const home=await fixture(t),binary=path.join(home,'codex');await fs.writeFile(binary,'#!/bin/sh\nexit 0',{mode:0o755});
|
|
11
|
+
const invoke=async(cmd)=>{if(cmd==='pnpm')return '10.30.3';if(cmd==='docker')throw Error('Docker unavailable');return 'codex fixture';};
|
|
12
|
+
const result=await preflight({home,executor:binary},invoke);assert.equal(result.ok,false);
|
|
13
|
+
assert.equal(result.checks.find(c=>c.name==='executor').path,binary);assert.equal(result.checks.find(c=>c.name==='executor').sandboxVerified,false);
|
|
14
|
+
assert.equal(result.checks.find(c=>c.name==='docker').ok,false);
|
|
15
|
+
const absent=await preflight({home,executor:path.join(home,'missing')},invoke);assert.equal(absent.checks.find(c=>c.name==='executor').ok,false);
|
|
16
|
+
const previous=process.env.TELEGRAM_BOT_TOKEN;process.env.TELEGRAM_BOT_TOKEN='must-not-leak';
|
|
17
|
+
try{assert.equal(hostEnvironment().TELEGRAM_BOT_TOKEN,undefined);}finally{if(previous===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=previous;}
|
|
18
|
+
});
|
|
19
|
+
test('installation status separates files, pairing and current-owner Telegram delivery evidence',async t=>{
|
|
20
|
+
const root=await fixture(t),control=path.join(root,'control');
|
|
21
|
+
assert.equal((await installationStatus(root)).stage,'not-configured');
|
|
22
|
+
for(const dir of ['host-executor','outbox','runs'])await fs.mkdir(path.join(control,dir),{recursive:true});
|
|
23
|
+
for(const name of ['agent.json','host-executor.json','docker.env','relay.env'])await fs.writeFile(path.join(root,name),'private');
|
|
24
|
+
const write=(name,obj)=>fs.writeFile(path.join(control,name),JSON.stringify(obj));
|
|
25
|
+
await write('heartbeat.json',{at:Date.now(),polling:true});await write('host-executor/heartbeat.json',{at:Date.now()});
|
|
26
|
+
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
27
|
+
const owner={telegramUserId:123,telegramChatId:123,pairedAt:new Date(Date.now()-10000).toISOString()};await write('control-state.json',{owner});
|
|
28
|
+
assert.equal((await installationStatus(root)).stage,'awaiting-telegram-reply');
|
|
29
|
+
const item={runId:'tg_12',chatId:123,receipt:{messageIds:[42],deliveredAt:new Date().toISOString()}};
|
|
30
|
+
await write('outbox/test.sent.json',item);await write('runs/tg_12.json',{status:'completed',chatId:123,telegramUserId:999});
|
|
31
|
+
assert.equal((await installationStatus(root)).telegramReplyVerified,false);
|
|
32
|
+
await write('runs/tg_12.json',{status:'completed',chatId:123,telegramUserId:123});
|
|
33
|
+
assert.equal((await installationStatus(root)).stage,'ready-for-telegram-plugin-request');
|
|
34
|
+
await write('heartbeat.json',{at:0,polling:true});assert.equal((await installationStatus(root)).stage,'runtime-offline');
|
|
35
|
+
await write('outbox/test.sent.json',{...item,runId:'../../escape'});assert.equal((await installationStatus(root)).telegramReplyVerified,false);
|
|
36
|
+
await fs.writeFile(path.join(control,'control-state.json'),'{');await assert.rejects(installationStatus(root));
|
|
37
|
+
});
|
|
38
|
+
test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
|
|
39
|
+
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
40
|
+
let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
|
|
41
|
+
const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
|
|
42
|
+
const first=build({home,source},invoke);await running;
|
|
43
|
+
assert.equal((await build({home,source},invoke)).state,'busy-or-interrupted');assert.equal(count,1);
|
|
44
|
+
finish();const result=await first;assert.equal(result.state,'completed');
|
|
45
|
+
assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
|
|
46
|
+
await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
|
|
47
|
+
});
|
|
48
|
+
test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
|
|
49
|
+
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
50
|
+
const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
|
|
51
|
+
await assert.rejects(build({home,source},invoke),/Synthetic/);
|
|
52
|
+
const [id]=await fs.readdir(path.join(home,'builds'));const dir=path.join(home,'builds',id);
|
|
53
|
+
assert.equal(JSON.parse(await fs.readFile(path.join(dir,'status.json'))).state,'failed');await assert.rejects(fs.access(path.join(dir,'lock')));
|
|
54
|
+
});
|
|
55
|
+
test('source changes during a build cannot produce a reusable success receipt',async t=>{
|
|
56
|
+
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
57
|
+
const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
|
|
58
|
+
await assert.rejects(build({home,source},invoke),/changed during build/);
|
|
59
|
+
});
|