@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36
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 +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -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 +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- 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/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- 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 +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- 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 +178 -26
- 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 +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- 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 +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -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 +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- 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 +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -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 +143 -5
- 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/scheduler.test.ts +2 -2
- 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 +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- 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 -44
- 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 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
|
@@ -2,10 +2,9 @@ import test from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import { loadConfig } from '../src/config.js'
|
|
4
4
|
import { executorJobEnv } from '../src/executor.js'
|
|
5
|
-
import { agentGuidance } from '../src/agent-guidance.js'
|
|
6
5
|
|
|
7
6
|
test('repair preference is bound in the environment, never rewritten into the request', () => {
|
|
8
|
-
const config = (value?:string) => loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
7
|
+
const config = (value?:string) => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', ...(value===undefined ? {} : {EZ_REPAIR_ENABLED:value})})
|
|
9
8
|
assert.equal(config().repairEnabled,true)
|
|
10
9
|
assert.equal(config('false').repairEnabled,false)
|
|
11
10
|
assert.throws(()=>config('disabled'),/EZ_REPAIR_ENABLED/)
|
|
@@ -14,6 +13,4 @@ test('repair preference is bound in the environment, never rewritten into the re
|
|
|
14
13
|
assert.equal(env.EZ_REPAIR_ENABLED,String(enabled))
|
|
15
14
|
assert.equal(env.TELEGRAM_BOT_TOKEN,undefined)
|
|
16
15
|
}
|
|
17
|
-
assert.match(agentGuidance(),/EZ_REPAIR_ENABLED/)
|
|
18
|
-
assert.match(agentGuidance(),/explicit owner request or saved maintenance mandate/)
|
|
19
16
|
})
|
|
@@ -9,9 +9,12 @@ import { fileURLToPath } from 'node:url'
|
|
|
9
9
|
import { ControlStore } from '../src/control-state.js'
|
|
10
10
|
import { RunStore } from '../src/runs.js'
|
|
11
11
|
import { initialPreset } from '../src/ai.js'
|
|
12
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
12
13
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
13
14
|
test('public scheduler CLI saves literal text, reads back, edits, pauses, and rejects external or finished callers',async t=>{
|
|
14
15
|
const dir=await mkdtemp(join(tmpdir(),'ez-schedule-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
16
|
+
// The schedule CLI child reaches the relay memory ledger through the socket.
|
|
17
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
15
18
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
16
19
|
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:'',EZ_EXECUTOR_CLI:'grok'}
|
|
17
20
|
await assert.rejects(exec(process.execPath,[bin,'list'],{env}),/Pair an owner/)
|
|
@@ -19,8 +22,8 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
19
22
|
const execution=await control.captureChoice(initialPreset('grok'))
|
|
20
23
|
const run=await runs.create({chatId:101,telegramUserId:101,texts:['owner request'],execution})
|
|
21
24
|
await runs.patch(run.id,{status:'running'});env.EZ_RUN_ID=run.id
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const context=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
|
|
26
|
+
assert.deepEqual(context.run.texts,['owner request']);assert.equal(context.busyReplies,undefined)
|
|
24
27
|
await assert.rejects(exec(process.execPath,[bin,'context'],{env:{...env,EZ_RUN_ID:''}}),/active owner run/)
|
|
25
28
|
const args=['create','test','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
|
|
26
29
|
const saved=JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
|
|
@@ -59,13 +62,14 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
59
62
|
|
|
60
63
|
test('executor PATH exposes the extensionless scheduler command',async()=>{
|
|
61
64
|
const command=fileURLToPath(new URL('../bin/ezenciel-agents-schedule',import.meta.url))
|
|
62
|
-
|
|
65
|
+
assert.match((await exec(command,['--help'])).stdout,/Creates a scheduled task/)
|
|
63
66
|
})
|
|
64
67
|
|
|
65
68
|
|
|
66
69
|
test('deferred literal input retains owner-scoped conversation through source metadata',async t=>{
|
|
67
70
|
const {Scheduler}=await import('../src/scheduler.js')
|
|
68
71
|
const dir=await mkdtemp(join(tmpdir(),'ez-origin-context-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
72
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
69
73
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
70
74
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
71
75
|
const execution=await control.captureChoice(initialPreset('codex'))
|
|
@@ -76,13 +80,14 @@ test('deferred literal input retains owner-scoped conversation through source me
|
|
|
76
80
|
await new Scheduler(dir).save({id:'legacy-deferred',name:'Owner request',text:'Do the same for March',originRunId:'tg_2',owner:(await control.status()).owner!,execution,enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()}},true)
|
|
77
81
|
await new Scheduler(dir).tick((await control.status()).owner!,runs,Date.now()+2000)
|
|
78
82
|
const worker=(await runs.list()).find(r=>r.scheduled)!
|
|
79
|
-
assert.
|
|
83
|
+
assert.match(worker.texts[0],/^\[schedule legacy-deferred due .+\]$/);assert.equal(worker.texts[1],'Do the same for March');assert.equal(worker.scheduled?.originRunId,'tg_2')
|
|
80
84
|
await runs.patch(worker.id,{status:'running'})
|
|
81
85
|
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:worker.id}
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
const result=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
|
|
87
|
+
assert.equal(result.run.texts[1],'Do the same for March')
|
|
88
|
+
assert.equal(result.origin.id,'tg_2')
|
|
84
89
|
await runs.create({id:'other',chatId:999,telegramUserId:999,texts:['PRIVATE OTHER OWNER']})
|
|
85
|
-
const
|
|
86
|
-
await
|
|
87
|
-
await assert.rejects(exec(process.execPath,[bin,'context'],{env}),/outside this owner binding/)
|
|
90
|
+
const tampered=await runs.create({id:'tg_tampered',chatId:101,telegramUserId:101,texts:worker.texts,execution,scheduled:{...worker.scheduled!,originRunId:'other'}})
|
|
91
|
+
await runs.patch(tampered.id,{status:'running'})
|
|
92
|
+
await assert.rejects(exec(process.execPath,[bin,'context'],{env:{...env,EZ_RUN_ID:tampered.id}}),/outside this owner binding/)
|
|
88
93
|
})
|
|
@@ -5,7 +5,8 @@ import { randomUUID } from 'node:crypto'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { tmpdir } from 'node:os'
|
|
7
7
|
import { Scheduler } from '../src/scheduler.js'
|
|
8
|
-
import { scheduledTasksText } from '../src/scheduled-tasks.js'
|
|
8
|
+
import { scheduledTaskDetailText, scheduledTasksText } from '../src/scheduled-tasks.js'
|
|
9
|
+
import { RunStore } from '../src/runs.js'
|
|
9
10
|
|
|
10
11
|
const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
|
|
11
12
|
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
|
|
@@ -18,7 +19,7 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
|
|
|
18
19
|
assert.deepEqual(await scheduler.listReadOnly(), [])
|
|
19
20
|
await assert.rejects(access(join(dir, 'schedules')), /ENOENT/)
|
|
20
21
|
|
|
21
|
-
await scheduler.save({
|
|
22
|
+
const saved = await scheduler.save({
|
|
22
23
|
id: 'owner-task', name: 'Daily report', text: 'Read the ledger and send the owner a concise report.', owner, execution, enabled: true,
|
|
23
24
|
trigger: { cron: '0 9 * * 1-5', timezone: 'Asia/Dubai', start: '2026-01-01T00:00:00.000Z' },
|
|
24
25
|
})
|
|
@@ -30,9 +31,17 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
|
|
|
30
31
|
const scheduleDir = join(dir, 'schedules')
|
|
31
32
|
const before = await readFile(join(scheduleDir, 'owner-task.json'), 'utf8')
|
|
32
33
|
const entries = await readdir(scheduleDir)
|
|
33
|
-
const
|
|
34
|
+
const runs = new RunStore(dir)
|
|
35
|
+
const run = await runs.create({
|
|
36
|
+
id: 'owner-task-run', ownerId: 'telegram:101:101', ownerEpoch: owner.pairedAt, chatId: 101, telegramUserId: 101, texts: [],
|
|
37
|
+
scheduled: { id: saved.id, revision: saved.revision, dueAt: '2026-01-01T05:00:00.000Z', pairedAt: owner.pairedAt },
|
|
38
|
+
})
|
|
39
|
+
await runs.patch(run.id, { status: 'completed', endedAt: '2026-01-01T05:04:00.000Z' })
|
|
40
|
+
const active = await scheduler.listActiveReadOnly(await runs.list())
|
|
41
|
+
const text = scheduledTasksText(active, owner)
|
|
34
42
|
|
|
35
|
-
assert.equal(text, '
|
|
43
|
+
assert.equal(text, '📅 Scheduled tasks · 1 active\n\nAll times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n codex · client default · default effort\n Read the ledger and send the owner a concise report.')
|
|
44
|
+
assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
|
|
36
45
|
assert.doesNotMatch(text, /Other owner task|This must never be visible/)
|
|
37
46
|
assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
|
|
38
47
|
assert.deepEqual(await readdir(scheduleDir), entries)
|
|
@@ -42,7 +51,6 @@ test('active view follows existing cursor and run state without changing schedul
|
|
|
42
51
|
const dir = await mkdtemp(join(tmpdir(),'ez-active-schedules-'))
|
|
43
52
|
t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
44
53
|
const scheduler = new Scheduler(dir)
|
|
45
|
-
const { RunStore } = await import('../src/runs.js')
|
|
46
54
|
const runs = new RunStore(dir)
|
|
47
55
|
const due = Date.now()+60_000
|
|
48
56
|
const common = {text:'/goal List files.',owner,execution,enabled:true}
|
|
@@ -88,7 +96,7 @@ test('prompt preview is one bounded Unicode sentence and preserves stored input'
|
|
|
88
96
|
enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
|
|
89
97
|
const output=scheduledTasksText(await scheduler.listActiveReadOnly([]),owner)
|
|
90
98
|
assert.match(output,/gpt-5.6-terra · high/)
|
|
91
|
-
assert.match(output,/Next
|
|
99
|
+
assert.match(output,/Next · Fri, Jan 1 · 00:00 UTC/)
|
|
92
100
|
assert.equal(Array.from(output.split('\n').at(-1)!.trim()).length,140)
|
|
93
101
|
assert.ok(output.endsWith('…'))
|
|
94
102
|
assert.doesNotMatch(output,/Do not display/)
|
|
@@ -112,3 +120,18 @@ test('a legacy invalid selection does not prevent the active menu from rendering
|
|
|
112
120
|
const active = await scheduler.listActiveReadOnly([])
|
|
113
121
|
assert.match(scheduledTasksText(active,owner),/codex · gpt-5.6-terra · max/)
|
|
114
122
|
})
|
|
123
|
+
|
|
124
|
+
test('detail view truncates long instructions and tolerates invalid timestamps', async t => {
|
|
125
|
+
const dir = await mkdtemp(join(tmpdir(),'ez-schedule-detail-bounds-'))
|
|
126
|
+
t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
127
|
+
const scheduler = new Scheduler(dir)
|
|
128
|
+
const saved = await scheduler.save({id:'long',name:'Long task',text:'/goal '+'x'.repeat(5000),owner,
|
|
129
|
+
execution,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
|
|
130
|
+
const active = await scheduler.listActiveReadOnly([])
|
|
131
|
+
const detail = scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!,1,1)
|
|
132
|
+
assert.match(detail,/📅 1 of 1 · Long task/)
|
|
133
|
+
assert.match(detail,/truncated; full text lives in the workspace/)
|
|
134
|
+
assert.ok(detail.length < 3000)
|
|
135
|
+
const broken = {...active.find(schedule => schedule.id === saved.id)!,nextAt:'not-a-date',lastRun:{status:'completed',at:'also-bad',currentRevision:true}} as never
|
|
136
|
+
assert.doesNotMatch(scheduledTaskDetailText(broken,1,1),/NaN/)
|
|
137
|
+
})
|
|
@@ -10,6 +10,7 @@ import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
|
10
10
|
import { RunStore } from '../src/runs.js'
|
|
11
11
|
|
|
12
12
|
const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
|
|
13
|
+
const stdout=(events:string) => events.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(event=>event.stream==='stdout').map(event=>event.text).join('')
|
|
13
14
|
test('host transport reserves separate task and main lanes, pins directories, and cancels only its target',async()=>{
|
|
14
15
|
const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
|
|
15
16
|
await mkdir(workspace);await mkdir(controlDir)
|
|
@@ -32,12 +33,11 @@ test('host transport reserves separate task and main lanes, pins directories, an
|
|
|
32
33
|
await submit('tg_1')
|
|
33
34
|
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
34
35
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
35
|
-
const scheduled=JSON.parse(
|
|
36
|
-
const main=JSON.parse(
|
|
37
|
-
assert.equal(scheduled.cwd,await realpath(join(
|
|
36
|
+
const scheduled=JSON.parse(stdout(await exists(id+'.events')))
|
|
37
|
+
const main=JSON.parse(stdout(await exists('tg_1.events')))
|
|
38
|
+
assert.equal(scheduled.cwd,await realpath(join(controlDir,'work/tasks',id)));assert.equal(main.cwd,await realpath(workspace))
|
|
38
39
|
assert.equal(scheduled.token,undefined);assert.equal(main.token,undefined)
|
|
39
|
-
//
|
|
40
|
-
await writeFile(join(controlDir,'runs/r_schedule_corrupt.json'),'{')
|
|
40
|
+
// An unknown scheduled run must not unwind the shared host service.
|
|
41
41
|
await submit('r_schedule_corrupt')
|
|
42
42
|
await until(async()=>(await exists('r_schedule_corrupt.events')).includes('"stream":"exit","code":1'))
|
|
43
43
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
@@ -11,16 +11,18 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
11
11
|
import { RunStore } from '../src/runs.js'
|
|
12
12
|
import { Scheduler } from '../src/scheduler.js'
|
|
13
13
|
import { initialPreset } from '../src/ai.js'
|
|
14
|
-
import { randomUUID } from 'node:crypto'
|
|
15
14
|
|
|
16
15
|
const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<200;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
// Adversarial: the relay must not reserve wrapper semantics for an
|
|
18
|
+
// update-shaped run id or a stale attention notice.
|
|
19
|
+
test('a wrapper-shaped run id has no maintenance treatment and a stale notice is inert',async()=>{
|
|
20
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-no-maintenance-session-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
21
|
+
let launchSession='',onSessionCalls=0,launches=0
|
|
21
22
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
|
|
23
|
+
launches++
|
|
22
24
|
launchSession=options.sessionId ?? ''
|
|
23
|
-
if(options.onSession){onSessionCalls++;await options.onSession('native-
|
|
25
|
+
if(options.onSession){onSessionCalls++;await options.onSession('native-review')}
|
|
24
26
|
const child=spawn(process.execPath,['-e','setTimeout(()=>{},10)'],{detached:process.platform!=='win32'})
|
|
25
27
|
await once(child,'spawn')
|
|
26
28
|
return {child,cleanup:async()=>{},stdout:''}
|
|
@@ -29,13 +31,17 @@ test('maintenance wakeups use an independent ephemeral session after a relay res
|
|
|
29
31
|
relay.bot.api.config.use(async()=>({ok:true,result:{message_id:42}}) as never)
|
|
30
32
|
try{
|
|
31
33
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
32
|
-
const execution=
|
|
34
|
+
const execution=await control.captureChoice(initialPreset('codex'))
|
|
33
35
|
await runs.create({id:'r_update_fixture',chatId:101,telegramUserId:101,texts:['maintenance'],execution})
|
|
34
36
|
await relay.drainSources()
|
|
35
37
|
await until(async()=> (await runs.get('r_update_fixture'))?.status==='completed')
|
|
36
|
-
assert.
|
|
37
|
-
assert.equal(onSessionCalls,
|
|
38
|
-
|
|
38
|
+
assert.equal(launchSession,execution.sessionId)
|
|
39
|
+
assert.equal(onSessionCalls,1)
|
|
40
|
+
await writeFile(join(dir,'update-attention.json'),JSON.stringify({id:'a'.repeat(64)}))
|
|
41
|
+
await relay.drainSources()
|
|
42
|
+
assert.equal(launches,1)
|
|
43
|
+
assert.equal((await runs.list()).length,1)
|
|
44
|
+
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
39
45
|
})
|
|
40
46
|
|
|
41
47
|
test('chat replies through the real ingress/outbox while scheduled CLI remains alive; targeted cancellation',async()=>{
|
package/test/scheduler.test.ts
CHANGED
|
@@ -78,10 +78,10 @@ test('pause, edit, removal, owner revocation, corrupt records and traversal fail
|
|
|
78
78
|
})
|
|
79
79
|
test('task workspaces are distinct and cannot escape through symlinks',async t=>{
|
|
80
80
|
const f=await fixture(t)
|
|
81
|
-
await writeFile(join(f.dir,'
|
|
81
|
+
await writeFile(join(f.dir,'private.md'),'Owner context')
|
|
82
82
|
const first=await taskWorkspace(f.dir,'r_one'),second=await taskWorkspace(f.dir,'r_two')
|
|
83
83
|
assert.notEqual(first,second)
|
|
84
|
-
await assert.rejects(readFile(join(first,'
|
|
84
|
+
await assert.rejects(readFile(join(first,'private.md'),'utf8'),{code:'ENOENT'})
|
|
85
85
|
await assert.rejects(readFile(join(first,'AGENTS.md'),'utf8'),{code:'ENOENT'})
|
|
86
86
|
await assert.rejects(taskWorkspace(f.dir,'../escape'))
|
|
87
87
|
const other=join(f.dir,'other');await mkdir(other)
|
package/test/security.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, rm, writeFile, symlink, mkdir } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, rm, writeFile, symlink, mkdir, stat } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { isOwner } from '../src/identity.js'
|
|
@@ -26,7 +26,7 @@ test('owner gate denies groups, bots, missing identity, and a different sender o
|
|
|
26
26
|
assert.equal(isOwner({ from, chat }, null), false)
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
test('store identifiers reject traversal; corrupt
|
|
29
|
+
test('store identifiers reject traversal; corrupt control state fails closed and the ledger writes nothing', async () => {
|
|
30
30
|
const dir = await mkdtemp(join(tmpdir(), 'ez-negative-'))
|
|
31
31
|
try {
|
|
32
32
|
const runs = new RunStore(dir)
|
|
@@ -34,8 +34,11 @@ test('store identifiers reject traversal; corrupt records cannot authorize or bl
|
|
|
34
34
|
await assert.rejects(runs.claimOutbox('../control-state'), /Invalid/)
|
|
35
35
|
await assert.rejects(new ApprovalStore(dir).getDecision('../escape'), /Invalid/)
|
|
36
36
|
const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['test'] })
|
|
37
|
-
await writeFile(join(dir, 'runs', 'broken.json'), '{')
|
|
38
37
|
assert.equal((await runs.nextQueued())?.id, run.id)
|
|
38
|
+
// Run/outbox/inbox bookkeeping is memory-only: no control/ files exist to corrupt.
|
|
39
|
+
await assert.rejects(stat(join(dir, 'runs')), { code: 'ENOENT' })
|
|
40
|
+
await assert.rejects(stat(join(dir, 'outbox')), { code: 'ENOENT' })
|
|
41
|
+
await assert.rejects(stat(join(dir, 'inbox.json')), { code: 'ENOENT' })
|
|
39
42
|
await writeFile(join(dir, 'control-state.json'), '{')
|
|
40
43
|
await assert.rejects(new ControlStore(dir, 1000).status())
|
|
41
44
|
} finally {
|
|
@@ -4,7 +4,13 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { installedPluginVersions, softwareStatus } from '../src/software-status.js'
|
|
7
|
-
import { packageVersion } from '../src/version.js'
|
|
7
|
+
import { formatRelayLabel, packageVersion } from '../src/version.js'
|
|
8
|
+
|
|
9
|
+
test('relay label shows the running RC tag first', () => {
|
|
10
|
+
assert.equal(formatRelayLabel('0.1.0-beta.35', 'rc2', 'abc123def4567890'), 'rc2 (v0.1.0-beta.35 · abc123def456)')
|
|
11
|
+
assert.equal(formatRelayLabel('0.1.0-beta.35', 'rc2'), 'rc2 (v0.1.0-beta.35)')
|
|
12
|
+
assert.equal(formatRelayLabel('1.0.0'), 'v1.0.0')
|
|
13
|
+
})
|
|
8
14
|
|
|
9
15
|
test('Telegram software status uses loaded version and only fresh host plugin metadata', async t => {
|
|
10
16
|
const root=await mkdtemp(path.join(tmpdir(),'ez-software-status-'))
|
|
@@ -14,9 +20,10 @@ test('Telegram software status uses loaded version and only fresh host plugin me
|
|
|
14
20
|
const plugins=await installedPluginVersions(root)
|
|
15
21
|
assert.deepEqual(plugins,[{id:'whatsapp',version:'0.1.0-beta.3'}])
|
|
16
22
|
const heartbeat=path.join(root,'host-executor/heartbeat.json')
|
|
17
|
-
await writeFile(heartbeat,JSON.stringify({at:Date.now(),version:'0.1.0-beta.4',plugins}))
|
|
18
|
-
const lines=await softwareStatus(root)
|
|
23
|
+
await writeFile(heartbeat,JSON.stringify({at:Date.now(),version:'0.1.0-beta.4',platform:'darwin',arch:'arm64',plugins}))
|
|
24
|
+
const lines=await softwareStatus(root,'host-capable')
|
|
19
25
|
assert.equal(lines[0],`Relay: running · v${packageVersion}`)
|
|
26
|
+
assert(lines.includes('Env: mac (darwin/arm64) · host-capable · host'))
|
|
20
27
|
assert(lines.includes('Host transport: running · v0.1.0-beta.4'))
|
|
21
28
|
assert(lines.includes('Plugins: whatsapp 0.1.0-beta.3'))
|
|
22
29
|
assert(!JSON.stringify(lines).includes('/private'))
|
|
@@ -28,4 +35,15 @@ test('Telegram software status uses loaded version and only fresh host plugin me
|
|
|
28
35
|
assert((await softwareStatus(root)).includes('Host transport: running · version unknown'))
|
|
29
36
|
await writeFile(path.join(root,'registry.json'),'broken')
|
|
30
37
|
assert.equal(await installedPluginVersions(root),null)
|
|
38
|
+
await rm(heartbeat)
|
|
39
|
+
assert((await softwareStatus(root)).includes('Host transport: unavailable'))
|
|
40
|
+
assert((await softwareStatus(root)).includes('Plugins: unknown'))
|
|
41
|
+
assert((await softwareStatus(root)).some(l => l.startsWith('Env:') && l.includes('unknown · unknown')))
|
|
42
|
+
assert((await softwareStatus(root,'isolated')).includes('Host transport: n/a · isolated execution'))
|
|
43
|
+
assert((await softwareStatus(root,'isolated')).some(l => l.startsWith('Env:') && l.includes('isolated · local')))
|
|
44
|
+
assert((await softwareStatus(root,'isolated')).includes('Plugins: isolated broker · versions via `ez tools list`'))
|
|
45
|
+
await writeFile(path.join(root,'plugin-broker-plugins.json'),JSON.stringify({version:1,at:new Date().toISOString(),plugins:[{id:'voice',version:'0.2.0'}]}))
|
|
46
|
+
assert((await softwareStatus(root,'isolated')).includes('Plugins: voice 0.2.0'))
|
|
47
|
+
await writeFile(path.join(root,'plugin-broker-plugins.json'),'broken')
|
|
48
|
+
assert((await softwareStatus(root,'isolated')).includes('Plugins: isolated broker · versions via `ez tools list`'))
|
|
31
49
|
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { provisionTelegramBot } from '../src/telegram-provisioning.js'
|
|
7
|
+
|
|
8
|
+
const token = '123456:abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMN'
|
|
9
|
+
|
|
10
|
+
const fixture = async () => {
|
|
11
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-telegram-provisioning-'))
|
|
12
|
+
const composeFile = path.join(root, 'compose.yml')
|
|
13
|
+
const configFile = path.join(root, 'telegram-provisioning.json')
|
|
14
|
+
const relayEnvFile = path.join(root, 'secrets', 'relay.env')
|
|
15
|
+
const overrideFile = path.join(root, 'telegram.compose.yml')
|
|
16
|
+
const deploymentEnvFile = path.join(root, 'docker.env')
|
|
17
|
+
await writeFile(composeFile, 'services: {}\n')
|
|
18
|
+
await writeFile(deploymentEnvFile, "EZ_RELAY_IMAGE='ezenciel-agents:0.1.0-beta.36.rc.4'\n", { mode: 0o600 })
|
|
19
|
+
await writeFile(configFile, JSON.stringify({
|
|
20
|
+
version: 1,
|
|
21
|
+
composeFile,
|
|
22
|
+
projectDirectory: root,
|
|
23
|
+
projectName: 'tenant-aifit',
|
|
24
|
+
service: 'relay',
|
|
25
|
+
relayEnvFile,
|
|
26
|
+
overrideFile,
|
|
27
|
+
}), { mode: 0o600 })
|
|
28
|
+
return { root, composeFile, configFile, relayEnvFile, overrideFile, deploymentEnvFile }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('Telegram provisioning writes only a private relay secret and restarts the exact relay', async (t) => {
|
|
32
|
+
const value = await fixture()
|
|
33
|
+
t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
|
|
34
|
+
const calls: string[][] = []
|
|
35
|
+
const environments: NodeJS.ProcessEnv[] = []
|
|
36
|
+
const priorImage = process.env.EZ_RELAY_IMAGE
|
|
37
|
+
process.env.EZ_RELAY_IMAGE = 'stale:shell-export'
|
|
38
|
+
try {
|
|
39
|
+
await provisionTelegramBot(value.configFile, token, async (args, environment) => { calls.push(args); environments.push(environment) })
|
|
40
|
+
} finally {
|
|
41
|
+
if (priorImage === undefined) delete process.env.EZ_RELAY_IMAGE; else process.env.EZ_RELAY_IMAGE = priorImage
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The deployment's docker.env is the only source for its runtime values:
|
|
45
|
+
// shell exports never override the installed image or mounts.
|
|
46
|
+
assert.equal('EZ_RELAY_IMAGE' in environments[0], false)
|
|
47
|
+
assert.equal('TELEGRAM_BOT_TOKEN' in environments[0], false)
|
|
48
|
+
assert.equal(environments[0].PATH, process.env.PATH)
|
|
49
|
+
assert.equal(await readFile(value.relayEnvFile, 'utf8'), `TELEGRAM_BOT_TOKEN=${token}\n`)
|
|
50
|
+
assert.equal((await stat(value.relayEnvFile)).mode & 0o777, 0o600)
|
|
51
|
+
const override = await readFile(value.overrideFile, 'utf8')
|
|
52
|
+
// The installed deployment supplies the relay image; the override never sets one.
|
|
53
|
+
assert.ok(!override.includes('image:'))
|
|
54
|
+
assert.match(override, /relay_env/)
|
|
55
|
+
assert.match(override, /EZ_TELEGRAM_ENABLED: "true"/)
|
|
56
|
+
assert.ok(!override.includes(token))
|
|
57
|
+
assert.equal((await stat(value.overrideFile)).mode & 0o777, 0o600)
|
|
58
|
+
assert.deepEqual(calls, [[
|
|
59
|
+
'--project-directory', value.root,
|
|
60
|
+
'--project-name', 'tenant-aifit',
|
|
61
|
+
'--env-file', value.deploymentEnvFile,
|
|
62
|
+
'-f', value.composeFile,
|
|
63
|
+
'-f', value.overrideFile,
|
|
64
|
+
'up', '-d', '--wait', 'relay',
|
|
65
|
+
]])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('provisioning rejects a self-set image and custom deployments without a Docker env file still start', async (t) => {
|
|
69
|
+
const value = await fixture()
|
|
70
|
+
t.after(async () => { await rm(value.root, { recursive: true, force: true }) })
|
|
71
|
+
const configured = JSON.parse(await readFile(value.configFile, 'utf8'))
|
|
72
|
+
await writeFile(value.configFile, JSON.stringify({ ...configured, image: 'eizenciel-agents:0.1.0-beta.36.rc.3' }))
|
|
73
|
+
await assert.rejects(provisionTelegramBot(value.configFile, token, async () => {}), /Invalid Telegram provisioning configuration/)
|
|
74
|
+
await writeFile(value.configFile, JSON.stringify(configured))
|
|
75
|
+
await rm(value.deploymentEnvFile)
|
|
76
|
+
const calls: string[][] = []
|
|
77
|
+
await provisionTelegramBot(value.configFile, token, async (args) => { calls.push(args) })
|
|
78
|
+
assert.ok(!calls[0].includes('--env-file'))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('provisioning fails closed when the deployment docker.env has no installed image', async (t) => {
|
|
82
|
+
const value = await fixture()
|
|
83
|
+
t.after(async () => { await rm(value.root, { recursive: true, force: true }) })
|
|
84
|
+
await writeFile(value.deploymentEnvFile, "EZ_AGENT_WORKSPACE='/private/agents/tenant/mind'\n", { mode: 0o600 })
|
|
85
|
+
await assert.rejects(provisionTelegramBot(value.configFile, token, async () => {}), /must define EZ_RELAY_IMAGE/)
|
|
86
|
+
await assert.rejects(readFile(value.relayEnvFile, 'utf8'), { code: 'ENOENT' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('Telegram provisioning restores the botless relay when startup fails', async (t) => {
|
|
90
|
+
const value = await fixture()
|
|
91
|
+
t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
|
|
92
|
+
const calls: string[][] = []
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
provisionTelegramBot(value.configFile, token, async (args) => {
|
|
95
|
+
calls.push(args)
|
|
96
|
+
if (calls.length === 1) throw new Error('Bot rejected')
|
|
97
|
+
}),
|
|
98
|
+
/Bot rejected/,
|
|
99
|
+
)
|
|
100
|
+
await assert.rejects(readFile(value.relayEnvFile, 'utf8'), { code: 'ENOENT' })
|
|
101
|
+
await assert.rejects(readFile(value.overrideFile, 'utf8'), { code: 'ENOENT' })
|
|
102
|
+
assert.equal(calls.length, 2)
|
|
103
|
+
assert.ok(!calls[1].includes(value.overrideFile))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('Telegram provisioning rejects invalid tokens and configs before touching the deployment', async (t) => {
|
|
107
|
+
const value = await fixture()
|
|
108
|
+
t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
|
|
109
|
+
await assert.rejects(provisionTelegramBot(value.configFile, 'not-a-token', async () => {}), /Invalid Telegram bot token/)
|
|
110
|
+
await assert.rejects(provisionTelegramBot(value.configFile, `${'1'.repeat(600)}:x`, async () => {}), /Invalid Telegram bot token/)
|
|
111
|
+
await assert.rejects(readFile(value.relayEnvFile, 'utf8'), { code: 'ENOENT' })
|
|
112
|
+
await writeFile(value.configFile, JSON.stringify({ version: 1 }))
|
|
113
|
+
await assert.rejects(provisionTelegramBot(value.configFile, token, async () => {}), /Invalid Telegram provisioning configuration/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('Telegram rotation failure restores both files and restarts with the previous override', async (t) => {
|
|
117
|
+
const value = await fixture()
|
|
118
|
+
t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
|
|
119
|
+
const { mkdir } = await import('node:fs/promises')
|
|
120
|
+
await mkdir(path.dirname(value.relayEnvFile), { recursive: true })
|
|
121
|
+
await writeFile(value.relayEnvFile, 'TELEGRAM_BOT_TOKEN=111111:old-old-old-old-old-old-old-old\n', { mode: 0o600 })
|
|
122
|
+
await writeFile(value.overrideFile, 'previous-override\n')
|
|
123
|
+
const calls: string[][] = []
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
provisionTelegramBot(value.configFile, token, async (args) => {
|
|
126
|
+
calls.push(args)
|
|
127
|
+
if (calls.length === 1) throw new Error('Bot rejected rotation')
|
|
128
|
+
}),
|
|
129
|
+
/Bot rejected rotation/,
|
|
130
|
+
)
|
|
131
|
+
assert.equal(await readFile(value.relayEnvFile, 'utf8'), 'TELEGRAM_BOT_TOKEN=111111:old-old-old-old-old-old-old-old\n')
|
|
132
|
+
assert.equal(await readFile(value.overrideFile, 'utf8'), 'previous-override\n')
|
|
133
|
+
assert.equal(calls.length, 2)
|
|
134
|
+
assert.ok(calls[1].includes(value.overrideFile))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('Telegram provisioning refuses a live lock but recovers a stale one', async (t) => {
|
|
138
|
+
const value = await fixture()
|
|
139
|
+
t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
|
|
140
|
+
const calls: string[][] = []
|
|
141
|
+
await writeFile(path.join(value.root, 'telegram-provisioning.lock'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }))
|
|
142
|
+
await assert.rejects(provisionTelegramBot(value.configFile, token, async (args) => { calls.push(args) }), /already in progress/)
|
|
143
|
+
assert.equal(calls.length, 0)
|
|
144
|
+
await writeFile(path.join(value.root, 'telegram-provisioning.lock'), JSON.stringify({ pid: 2147483647, startedAt: '2020-01-01T00:00:00.000Z' }))
|
|
145
|
+
await provisionTelegramBot(value.configFile, token, async (args) => { calls.push(args) })
|
|
146
|
+
assert.equal(calls.length, 1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('Telegram provisioning CLI rejects TTY input and oversized stdin', async () => {
|
|
150
|
+
const { runTelegramProvisioningCli } = await import('../src/telegram-provisioning-cli.js')
|
|
151
|
+
await assert.rejects(runTelegramProvisioningCli(['--config', 'x'], { isTTY: true } as never), /stdin/)
|
|
152
|
+
let destroyed = false
|
|
153
|
+
const oversized = {
|
|
154
|
+
isTTY: false,
|
|
155
|
+
destroy() { destroyed = true },
|
|
156
|
+
async *[Symbol.asyncIterator]() { yield 'x'.repeat(600) },
|
|
157
|
+
}
|
|
158
|
+
await assert.rejects(runTelegramProvisioningCli(['--config', 'x'], oversized as never), /too long/)
|
|
159
|
+
assert.equal(destroyed, true)
|
|
160
|
+
})
|