@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
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
|
+
}
|
|
@@ -41,6 +41,12 @@ test('legacy host lock fails closed while its PID is alive',async()=>{
|
|
|
41
41
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
test('isolated installations cannot start host transport', async () => {
|
|
45
|
+
const abort=new AbortController()
|
|
46
|
+
await assert.rejects(serveHostExecutor({cli:'grok',isolation:'isolated',agents:[]},abort.signal),/Isolated agents run the native CLI in the relay/)
|
|
47
|
+
abort.abort()
|
|
48
|
+
})
|
|
49
|
+
|
|
44
50
|
test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
|
|
45
51
|
const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
|
|
46
52
|
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
|
|
@@ -63,6 +69,80 @@ test('host restart clears dead native lease only after proving previous CLI stop
|
|
|
63
69
|
}finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
|
|
64
70
|
});
|
|
65
71
|
|
|
72
|
+
test('host execution resolves a declared provider from the bound agent, not a launcher wrapper', async () => {
|
|
73
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-host-provider-'))
|
|
74
|
+
const workspace = path.join(root, 'mind')
|
|
75
|
+
const controlDir = path.join(root, 'control')
|
|
76
|
+
const directory = path.join(controlDir, 'host-executor')
|
|
77
|
+
const provider = {
|
|
78
|
+
id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1',
|
|
79
|
+
envKey: 'OPENROUTER_API_KEY', models: ['deepseek/deepseek-v4.1-flash'],
|
|
80
|
+
}
|
|
81
|
+
const installation = {
|
|
82
|
+
cli: 'codex' as const,
|
|
83
|
+
agents: [{name: 'test', workspace, controlDir, binDir: path.join(root, 'bin'), codexProviders: [provider]}],
|
|
84
|
+
}
|
|
85
|
+
const abort = new AbortController()
|
|
86
|
+
let server: Promise<void> | undefined
|
|
87
|
+
let captured: any
|
|
88
|
+
const previousPath = process.env.PATH
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(workspace, {recursive: true})
|
|
91
|
+
await mkdir(directory, {recursive: true})
|
|
92
|
+
const fakeBin = path.join(root, 'bin')
|
|
93
|
+
await mkdir(fakeBin, {recursive: true})
|
|
94
|
+
await mkdir(path.join(controlDir, 'cli', 'codex'), {recursive: true})
|
|
95
|
+
await writeFile(path.join(fakeBin, 'codex'), '#!/bin/sh\nexit 0\n', {mode: 0o700})
|
|
96
|
+
await writeFile(path.join(controlDir, 'cli', 'codex', 'models_cache.json'), JSON.stringify({models: [
|
|
97
|
+
{slug: provider.models[0], visibility: 'list', display_name: 'DeepSeek V4.1 Flash', supported_reasoning_levels: [{effort: 'max'}]},
|
|
98
|
+
]}))
|
|
99
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${previousPath ?? ''}`
|
|
100
|
+
await ownerRun(controlDir, 'r_provider')
|
|
101
|
+
server = serveHostExecutor(installation, abort.signal, async (_texts, options) => {
|
|
102
|
+
captured = options
|
|
103
|
+
const child = spawn(process.execPath, ['-e', 'process.exit(0)'])
|
|
104
|
+
return {child, cleanup: async () => {}, stdout: ''}
|
|
105
|
+
})
|
|
106
|
+
for (let n = 0; n < 100; n++) {
|
|
107
|
+
try { await readFile(path.join(directory, 'heartbeat.json')); break }
|
|
108
|
+
catch { await new Promise(resolve => setTimeout(resolve, 20)) }
|
|
109
|
+
}
|
|
110
|
+
await writeFile(path.join(directory, 'r_provider.request.json'), JSON.stringify({
|
|
111
|
+
texts: ['hello'], options: {cli: 'codex', provider: 'openrouter', model: provider.models[0], effort: 'max'},
|
|
112
|
+
}))
|
|
113
|
+
let events = ''
|
|
114
|
+
for (let n = 0; n < 100; n++) {
|
|
115
|
+
try {
|
|
116
|
+
events = await readFile(path.join(directory, 'r_provider.events'), 'utf8')
|
|
117
|
+
if (events.includes('"stream":"exit"')) break
|
|
118
|
+
} catch {}
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
120
|
+
}
|
|
121
|
+
assert.match(events, /"stream":"exit","code":0/)
|
|
122
|
+
const catalog = JSON.parse(await readFile(path.join(directory, 'models.json'), 'utf8'))
|
|
123
|
+
assert.deepEqual(catalog.filter((model: any) => model.model === provider.models[0]), [
|
|
124
|
+
{
|
|
125
|
+
cli: 'codex-gui', model: provider.models[0],
|
|
126
|
+
name: 'codex-gui ยท DeepSeek V4.1 Flash', efforts: ['max'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
cli: 'codex', provider: 'openrouter', model: provider.models[0],
|
|
130
|
+
name: `OpenRouter ยท ${provider.models[0]}`, efforts: ['max'],
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
assert.deepEqual(catalog.filter((model: any) => model.cli === 'codex' && !model.provider), [])
|
|
134
|
+
assert.equal(captured.provider, 'openrouter')
|
|
135
|
+
assert.deepEqual(captured.codexProvider, provider)
|
|
136
|
+
assert.equal(captured.model, provider.models[0])
|
|
137
|
+
} finally {
|
|
138
|
+
abort.abort()
|
|
139
|
+
await server
|
|
140
|
+
if (previousPath === undefined) delete process.env.PATH
|
|
141
|
+
else process.env.PATH = previousPath
|
|
142
|
+
await rm(root, {recursive: true, force: true})
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
66
146
|
test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
|
|
67
147
|
const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
|
|
68
148
|
const abort=new AbortController()
|
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: [] }),
|
|
@@ -14,8 +14,10 @@ test('configure preserves custom paths and unknown settings, keeps token private
|
|
|
14
14
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-'))
|
|
15
15
|
try {
|
|
16
16
|
await writeFile(path.join(root, '.env'), 'EZ_AGENT_WORKSPACE="my mind"\nEZ_CONTROL_DIR="private control"\nEXTRA="keep me"\n')
|
|
17
|
+
const purpose = path.join(root, 'purpose.md')
|
|
18
|
+
await writeFile(purpose, 'Configured assistant\n')
|
|
17
19
|
const token = '123456789:' + 'a'.repeat(30)
|
|
18
|
-
const result = await configureInstallation(root, 'codex', token)
|
|
20
|
+
const result = await configureInstallation(root, 'codex', token, purpose)
|
|
19
21
|
assert.equal(result.tokenConfigured, true)
|
|
20
22
|
assert.equal(JSON.stringify(result).includes(token), false)
|
|
21
23
|
const env = parseEnv(await readFile(result.envFile, 'utf8'))
|
|
@@ -24,12 +26,12 @@ test('configure preserves custom paths and unknown settings, keeps token private
|
|
|
24
26
|
assert.equal(env.EZ_AGENT_WORKSPACE, path.join(root, 'my mind'))
|
|
25
27
|
assert.equal((await stat(result.envFile)).mode & 0o777, 0o600)
|
|
26
28
|
assert.equal(await readActiveExecutor(result.envFile), 'codex')
|
|
27
|
-
const
|
|
28
|
-
await writeFile(
|
|
29
|
+
const agents = path.join(result.workspace, 'AGENTS.md')
|
|
30
|
+
await writeFile(agents, 'Customized purpose')
|
|
29
31
|
const repeated = await configureInstallation(root, 'codex')
|
|
30
32
|
assert.deepEqual(repeated.created, [])
|
|
31
33
|
assert.equal(repeated.tokenConfigured, true)
|
|
32
|
-
assert.
|
|
34
|
+
assert.match(await readFile(agents, 'utf8'), /Customized purpose/)
|
|
33
35
|
await assert.rejects(stat(result.controlDir), { code: 'ENOENT' }) // No control/owner mutation.
|
|
34
36
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
35
37
|
})
|
|
@@ -56,20 +58,30 @@ test('packaged configure accepts token through stdin without echo or extra initi
|
|
|
56
58
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-cli-'))
|
|
57
59
|
try {
|
|
58
60
|
const token = '123456789:' + 'b'.repeat(30)
|
|
61
|
+
const purpose = path.join(root, 'purpose.md')
|
|
62
|
+
await writeFile(purpose, 'Packaged assistant\n')
|
|
59
63
|
const bin = fileURLToPath(new URL('../bin/ezenciel-agents-setup.mjs', import.meta.url))
|
|
60
|
-
const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
|
|
64
|
+
const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--purpose-file', purpose, '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
|
|
61
65
|
assert.equal(result.status, 0, result.stderr)
|
|
62
66
|
assert.equal((result.stdout + result.stderr).includes(token), false)
|
|
63
|
-
assert.
|
|
67
|
+
assert.deepEqual(JSON.parse(result.stdout).created, ['AGENTS.md'])
|
|
64
68
|
assert.equal(parseEnv(await readFile(path.join(root, '.env'), 'utf8')).TELEGRAM_BOT_TOKEN, token)
|
|
65
69
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
66
70
|
})
|
|
67
71
|
|
|
72
|
+
test('fresh configure refuses to invent a generic purpose', async () => {
|
|
73
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-purpose-'))
|
|
74
|
+
try {
|
|
75
|
+
await assert.rejects(configureInstallation(root, 'codex'), /purpose file is required/)
|
|
76
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
77
|
+
})
|
|
78
|
+
|
|
68
79
|
test('Docker startup uses explicit deployment binding and literal paths without secrets or host supervisors', () => {
|
|
69
80
|
const definition = serviceDefinition('/tmp/my agent $literal')
|
|
70
81
|
assert.equal(definition.command, 'docker')
|
|
71
82
|
assert.ok(definition.args.includes('/tmp/my agent $literal/docker.env'))
|
|
72
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'])
|
|
73
85
|
assert.doesNotMatch(JSON.stringify(definition), /TELEGRAM_BOT_TOKEN|launchctl|systemctl/)
|
|
74
86
|
assert.throws(() => serviceDefinition('/tmp/\nbad'), /single-line/)
|
|
75
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,22 +22,61 @@ 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());
|
|
31
|
+
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
32
|
+
await fs.writeFile(path.join(root,'agent.json'),JSON.stringify({isolation:'isolated'}));
|
|
33
|
+
await fs.rm(path.join(control,'host-executor/heartbeat.json'));
|
|
34
|
+
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
35
|
+
await fs.writeFile(path.join(root,'agent.json'),'private');
|
|
36
|
+
await write('host-executor/heartbeat.json',{at:Date.now()});
|
|
26
37
|
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
27
38
|
const owner={telegramUserId:123,telegramChatId:123,pairedAt:new Date(Date.now()-10000).toISOString()};await write('control-state.json',{owner});
|
|
28
39
|
assert.equal((await installationStatus(root)).stage,'awaiting-telegram-reply');
|
|
29
|
-
const
|
|
30
|
-
|
|
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]);
|
|
31
49
|
assert.equal((await installationStatus(root)).telegramReplyVerified,false);
|
|
32
|
-
|
|
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]);
|
|
33
54
|
assert.equal((await installationStatus(root)).stage,'ready-for-telegram-plugin-request');
|
|
34
|
-
|
|
35
|
-
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');
|
|
36
57
|
await fs.writeFile(path.join(control,'control-state.json'),'{');await assert.rejects(installationStatus(root));
|
|
37
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
|
+
});
|
|
38
80
|
test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
|
|
39
81
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
40
82
|
let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
|
|
@@ -45,6 +87,37 @@ test('same-artifact build retries do not spawn a second Docker build and reuse v
|
|
|
45
87
|
assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
|
|
46
88
|
await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
|
|
47
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
|
+
});
|
|
48
121
|
test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
|
|
49
122
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
50
123
|
const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
3
4
|
import { mkdtemp, readdir, rm } from 'node:fs/promises'
|
|
4
5
|
import { tmpdir } from 'node:os'
|
|
5
6
|
import { join } from 'node:path'
|
|
@@ -8,6 +9,7 @@ import { once } from 'node:events'
|
|
|
8
9
|
import type { Update } from 'grammy/types'
|
|
9
10
|
import { createRelay } from '../src/index.js'
|
|
10
11
|
import { ControlStore } from '../src/control-state.js'
|
|
12
|
+
import { ApplicationBindings } from '../src/application-channel.js'
|
|
11
13
|
import { RunStore } from '../src/runs.js'
|
|
12
14
|
import { InboxStore } from '../src/inbox.js'
|
|
13
15
|
import { ApprovalStore } from '../src/approval.js'
|
|
@@ -113,7 +115,9 @@ test('approved owner group accepts different members, controls and group deliver
|
|
|
113
115
|
await control.revokeOwner()
|
|
114
116
|
await f.relay.bot.handleUpdate(group(1))
|
|
115
117
|
assert.equal(f.launched.length, 0)
|
|
116
|
-
|
|
118
|
+
const request = (await control.status()).pending[0]
|
|
119
|
+
if (!request || !('title' in request)) throw new Error('Group pairing request missing')
|
|
120
|
+
assert.equal(request.title, 'Team')
|
|
117
121
|
await control.approveOwner(-101, true)
|
|
118
122
|
await f.relay.bot.handleUpdate(group(2, 303))
|
|
119
123
|
await f.relay.bot.handleUpdate(group(3, 404))
|
|
@@ -263,7 +267,7 @@ test('conversation menu is owner-only and removes the retired settings control',
|
|
|
263
267
|
try {
|
|
264
268
|
await f.relay.bot.handleUpdate(message(1, '/menu'))
|
|
265
269
|
assert.deepEqual(f.keyboards.at(-1)!.flat().map((b) => b.text),
|
|
266
|
-
['New conversation', 'Conversations', 'Choose AI', 'Work status'])
|
|
270
|
+
['New conversation', 'Conversations', 'Choose AI', 'Work status', 'Scheduled tasks'])
|
|
267
271
|
await f.relay.bot.handleUpdate(message(2, '/settings'))
|
|
268
272
|
assert.match(f.replies.at(-1)!, /Settings was removed.*Use \/ai/)
|
|
269
273
|
await f.relay.bot.handleUpdate(message(3, '/ai'))
|
|
@@ -284,9 +288,11 @@ test('conversation menu is owner-only and removes the retired settings control',
|
|
|
284
288
|
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
285
289
|
assert.deepEqual(await store.status(), refreshed)
|
|
286
290
|
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
287
|
-
assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
288
|
-
await f.relay.bot.handleUpdate(
|
|
289
|
-
assert.match(f.replies.at(-1)!,
|
|
291
|
+
assert.ok(!f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
292
|
+
await f.relay.bot.handleUpdate(message(9, '/tasks'))
|
|
293
|
+
assert.match(f.replies.at(-1)!, /๐
Scheduled tasks[\s\S]*No active scheduled tasks/)
|
|
294
|
+
await f.relay.bot.handleUpdate(message(10, '/tasks 2'))
|
|
295
|
+
assert.match(f.replies.at(-1)!, /Scheduled task 2 was not found/)
|
|
290
296
|
assert.equal(f.launched.length, 0)
|
|
291
297
|
} finally { await f.close() }
|
|
292
298
|
})
|
|
@@ -386,7 +392,7 @@ test('slow voice normalization preserves instruction order and leaves controls r
|
|
|
386
392
|
release()
|
|
387
393
|
await processing
|
|
388
394
|
assert.equal(f.launched[0][0], 'Use the following voice note')
|
|
389
|
-
assert.
|
|
395
|
+
assert.doesNotMatch(f.launched[0][1], /Quoted context/)
|
|
390
396
|
assert.match(f.launched[0][1], /Fixture voice transcript/)
|
|
391
397
|
assert.equal(f.launched[0].length, 2)
|
|
392
398
|
assert.equal((await new InboxStore(f.dir).status()).pending, 1)
|
|
@@ -632,7 +638,7 @@ test('conversation menu paginates and stale pages remain usable after archiving'
|
|
|
632
638
|
})
|
|
633
639
|
|
|
634
640
|
|
|
635
|
-
test('
|
|
641
|
+
test('conversation names never read run history and detail keeps archive available when resume fails', async () => {
|
|
636
642
|
const f = await fixture()
|
|
637
643
|
try {
|
|
638
644
|
const store = new ControlStore(f.dir, 1000)
|
|
@@ -649,7 +655,8 @@ test('older conversation names come from owner messages and detail keeps archive
|
|
|
649
655
|
await store.resetSession()
|
|
650
656
|
await f.relay.bot.handleUpdate(message(1, '/chats'))
|
|
651
657
|
const rows = f.keyboards.at(-1)!
|
|
652
|
-
assert.ok(rows.flat().some(b => b.text.startsWith('
|
|
658
|
+
assert.ok(rows.flat().some(b => b.text.startsWith('Untitled conversation')))
|
|
659
|
+
assert.ok(!rows.flat().some(b => b.text.includes('Client launch checklist')))
|
|
653
660
|
assert.ok(!rows.flat().some(b => b.text === 'โ New conversation'))
|
|
654
661
|
assert.ok(!rows.flat().some(b => b.text.includes(old.sessionId.slice(0, 8))))
|
|
655
662
|
assert.ok(!rows.flat().some(b => b.callback_data.startsWith('chat:archive:')))
|
|
@@ -657,7 +664,7 @@ test('older conversation names come from owner messages and detail keeps archive
|
|
|
657
664
|
id: '2', chat_instance: 'fixture', data: `chat:open:${old.sessionId}`,
|
|
658
665
|
from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(2).message!,
|
|
659
666
|
} })
|
|
660
|
-
assert.match(f.replies.at(-1)!, /
|
|
667
|
+
assert.match(f.replies.at(-1)!, /Untitled conversation \d+[\s\S]*binding/)
|
|
661
668
|
assert.deepEqual(f.keyboards.at(-1)![0], [{text: 'Archive this conversation', callback_data: `chat:archive:${old.sessionId}`}])
|
|
662
669
|
} finally { await f.close() }
|
|
663
670
|
})
|
|
@@ -680,7 +687,7 @@ test('Telegram and application image/PDF/text use identical native attachment me
|
|
|
680
687
|
const telegram=(await new RunStore(f.dir).list()).find(run=>run.messageId===update.message!.message_id)!
|
|
681
688
|
assert.ok(telegram)
|
|
682
689
|
const application=await f.relay.applicationChannel.submit(binding.bindingId,{requestId:name,scope:'chat',followOwner:true,text:update.message!.caption,attachment:{name,data:bytes.toString('base64')}})
|
|
683
|
-
const normalize=(text:string)=>text.replace(/
|
|
690
|
+
const normalize=(text:string)=>text.replace(/attachments\/[a-f0-9-]+_/,'attachments/ID_')
|
|
684
691
|
assert.equal(normalize(application.texts[0]),normalize(telegram.texts[0]))
|
|
685
692
|
assert.equal(application.ownerId,telegram.ownerId)
|
|
686
693
|
assert.equal(application.execution?.sessionId,telegram.execution?.sessionId)
|
|
@@ -699,9 +706,49 @@ test('web launcher is private-owner-only and bypasses native intake without repl
|
|
|
699
706
|
await f.relay.bot.handleUpdate(message(2,'/voice'))
|
|
700
707
|
assert.deepEqual(f.keyboards.at(-1),[[{text:'Voice',web_app:{url:'https://voice.example/'}}]])
|
|
701
708
|
await f.relay.bot.handleUpdate(message(3,'/menu'))
|
|
702
|
-
assert.deepEqual(f.keyboards.at(-1)!.flat().map(b=>b.text),['New conversation','Conversations','Choose AI','Work status','Voice'])
|
|
709
|
+
assert.deepEqual(f.keyboards.at(-1)!.flat().map(b=>b.text),['New conversation','Conversations','Choose AI','Work status','Scheduled tasks','Voice'])
|
|
703
710
|
await f.relay.drainInbox(true);assert.equal(f.launched.length,0)
|
|
704
711
|
await new ControlStore(f.dir,1000).revokeOwner()
|
|
705
712
|
const before=f.keyboards.length;await f.relay.bot.handleUpdate(message(4,'/voice'));assert.equal(f.keyboards.length,before)
|
|
706
713
|
} finally {await f.close()}
|
|
707
714
|
})
|
|
715
|
+
|
|
716
|
+
test('application Telegram link connects without launching work; bad link replies expired', async () => {
|
|
717
|
+
const f=await fixture()
|
|
718
|
+
try {
|
|
719
|
+
const control=new ControlStore(f.dir,1000)
|
|
720
|
+
await control.revokeOwner()
|
|
721
|
+
const owner=await control.registerOwner('account:web')
|
|
722
|
+
const binding=await new ApplicationBindings(f.dir).register('webapp',randomBytes(32).toString('base64url'),owner,true)
|
|
723
|
+
assert.ok(binding)
|
|
724
|
+
const pairing=await control.createApplicationTelegramPairing(binding.bindingId,owner)
|
|
725
|
+
assert.ok(pairing)
|
|
726
|
+
await f.relay.bot.handleUpdate(message(1,`/start ${'A'.repeat(43)}`))
|
|
727
|
+
assert.match(f.replies.at(-1)!,/expired|no longer valid/)
|
|
728
|
+
assert.equal(f.launched.length,0)
|
|
729
|
+
await f.relay.bot.handleUpdate(message(2,`/start ${pairing.token}`))
|
|
730
|
+
assert.match(f.replies.at(-1)! ,/connected/)
|
|
731
|
+
assert.equal(f.launched.length,0)
|
|
732
|
+
assert.equal((await control.status()).owner?.telegramUserId,101)
|
|
733
|
+
} finally {await f.close()}
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
test('tasks command never reaches the executor on invalid input or foreign senders', async () => {
|
|
737
|
+
const f = await fixture()
|
|
738
|
+
try {
|
|
739
|
+
const stranger = message(1, '/tasks');stranger.message!.from!.id = 202
|
|
740
|
+
await f.relay.bot.handleUpdate(stranger)
|
|
741
|
+
assert.equal(f.launched.length, 0)
|
|
742
|
+
assert.ok(!f.replies.join('\n').includes('Scheduled tasks'))
|
|
743
|
+
const strangerDetail = message(2, '/tasks 1');strangerDetail.message!.from!.id = 202
|
|
744
|
+
await f.relay.bot.handleUpdate(strangerDetail)
|
|
745
|
+
assert.equal(f.launched.length, 0)
|
|
746
|
+
assert.ok(!f.replies.join('\n').includes('Scheduled tasks'))
|
|
747
|
+
for (const [id, text] of [[3, '/tasks 0'], [4, '/tasks abc'], [5, '/tasks 1.5'], [6, '/tasks 2 extra']] as const) {
|
|
748
|
+
await f.relay.bot.handleUpdate(message(id, text))
|
|
749
|
+
assert.match(f.replies.at(-1)!, /Usage: \/tasks \[number\]/)
|
|
750
|
+
}
|
|
751
|
+
await f.relay.drainInbox(true)
|
|
752
|
+
assert.equal(f.launched.length, 0)
|
|
753
|
+
} finally { await f.close() }
|
|
754
|
+
})
|