@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.
Files changed (147) hide show
  1. package/.dockerignore +24 -0
  2. package/.env.example +26 -0
  3. package/AGENTS.md +84 -0
  4. package/CHANGELOG.md +39 -0
  5. package/CONTRIBUTING.md +73 -0
  6. package/Dockerfile +16 -0
  7. package/LICENSE +21 -0
  8. package/README.md +134 -0
  9. package/SECURITY.md +26 -0
  10. package/THIRD_PARTY_NOTICES.md +14 -0
  11. package/bin/ezenciel-agents +2 -0
  12. package/bin/ezenciel-agents-ai +2 -0
  13. package/bin/ezenciel-agents-ai.mjs +5 -0
  14. package/bin/ezenciel-agents-approval +2 -0
  15. package/bin/ezenciel-agents-approval.mjs +16 -0
  16. package/bin/ezenciel-agents-create +12 -0
  17. package/bin/ezenciel-agents-docker +6 -0
  18. package/bin/ezenciel-agents-host +5 -0
  19. package/bin/ezenciel-agents-install +2 -0
  20. package/bin/ezenciel-agents-message +2 -0
  21. package/bin/ezenciel-agents-message.mjs +16 -0
  22. package/bin/ezenciel-agents-owner +2 -0
  23. package/bin/ezenciel-agents-owner.mjs +18 -0
  24. package/bin/ezenciel-agents-react +2 -0
  25. package/bin/ezenciel-agents-react.mjs +16 -0
  26. package/bin/ezenciel-agents-setup.mjs +18 -0
  27. package/bin/ezenciel-agents-source +2 -0
  28. package/bin/ezenciel-agents-source.mjs +16 -0
  29. package/bin/ezenciel-agents-tools.mjs +3 -0
  30. package/bin/ezenciel-agents.mjs +37 -0
  31. package/compose.whatsapp.yaml +12 -0
  32. package/compose.yaml +40 -0
  33. package/default-plugins.json +1 -0
  34. package/docker/entrypoint.sh +15 -0
  35. package/docker/healthcheck.mjs +8 -0
  36. package/docker/plugin-smoke.mjs +48 -0
  37. package/docker/pnpm-lock.yaml +415 -0
  38. package/docker/recovery.ts +11 -0
  39. package/docker/run.ts +52 -0
  40. package/docker/smoke.mjs +47 -0
  41. package/docker/status-smoke.mjs +30 -0
  42. package/docker/upgrade-smoke.mjs +58 -0
  43. package/docs/architecture/ai-selection.md +37 -0
  44. package/docs/architecture/authority-boundaries.md +14 -0
  45. package/docs/architecture/event-sources.md +34 -0
  46. package/docs/architecture/telegram-intake.md +29 -0
  47. package/docs/development-and-testing.md +18 -0
  48. package/docs/docker-runtime.md +118 -0
  49. package/docs/host-service.md +80 -0
  50. package/docs/plugin-contributions.md +34 -0
  51. package/docs/plugins.md +181 -0
  52. package/docs/releasing.md +71 -0
  53. package/docs/setup.md +234 -0
  54. package/docs/upgrades.md +193 -0
  55. package/package.json +106 -0
  56. package/scripts/assert-local-registry.mjs +22 -0
  57. package/scripts/release-check.mjs +14 -0
  58. package/scripts/smoke.ts +102 -0
  59. package/src/agent-install.ts +96 -0
  60. package/src/ai-cli.ts +22 -0
  61. package/src/ai.ts +88 -0
  62. package/src/approval-cli.ts +59 -0
  63. package/src/approval.ts +119 -0
  64. package/src/audio.ts +184 -0
  65. package/src/client-defaults.ts +101 -0
  66. package/src/config.ts +48 -0
  67. package/src/control-state.ts +350 -0
  68. package/src/desktop-bridge.ts +284 -0
  69. package/src/event-sources.ts +112 -0
  70. package/src/executor.ts +335 -0
  71. package/src/files.ts +75 -0
  72. package/src/format.ts +57 -0
  73. package/src/host-executor-client.ts +46 -0
  74. package/src/host-executor-protocol.ts +2 -0
  75. package/src/host-executor.ts +129 -0
  76. package/src/identity.ts +17 -0
  77. package/src/inbox.ts +171 -0
  78. package/src/index.ts +812 -0
  79. package/src/install-config.ts +56 -0
  80. package/src/install-tools.mjs +98 -0
  81. package/src/menu.ts +123 -0
  82. package/src/message-send.ts +57 -0
  83. package/src/message.ts +68 -0
  84. package/src/owner-args.ts +4 -0
  85. package/src/owner.ts +30 -0
  86. package/src/plugins/manager.mjs +272 -0
  87. package/src/react.ts +33 -0
  88. package/src/reaction.ts +32 -0
  89. package/src/read-request.ts +72 -0
  90. package/src/reply.ts +13 -0
  91. package/src/runs.ts +445 -0
  92. package/src/service.ts +28 -0
  93. package/src/setup.ts +180 -0
  94. package/src/software-status.ts +23 -0
  95. package/src/source-cli.ts +18 -0
  96. package/src/update-attention.ts +18 -0
  97. package/src/updates/artifact.mjs +83 -0
  98. package/src/updates/binding.mjs +27 -0
  99. package/src/updates/control.mjs +131 -0
  100. package/src/updates/launch.mjs +13 -0
  101. package/src/updates/runtime.mjs +140 -0
  102. package/src/updates/status.mjs +49 -0
  103. package/src/updates/supervisor.mjs +102 -0
  104. package/src/version.ts +4 -0
  105. package/src/workspace.ts +32 -0
  106. package/templates/agent/AGENTS.md +49 -0
  107. package/templates/agent/SOUL.md +11 -0
  108. package/templates/agent/TOOLS.md +46 -0
  109. package/templates/agent/USER.md +5 -0
  110. package/templates/updates.md +45 -0
  111. package/test/agent-install.test.ts +48 -0
  112. package/test/ai-cli.test.ts +28 -0
  113. package/test/ai.test.ts +105 -0
  114. package/test/approval.test.ts +40 -0
  115. package/test/audio.test.ts +77 -0
  116. package/test/client-defaults.test.ts +64 -0
  117. package/test/codex-context.test.ts +33 -0
  118. package/test/config.test.ts +32 -0
  119. package/test/control-state.test.ts +58 -0
  120. package/test/desktop-bridge.test.ts +159 -0
  121. package/test/docker-runtime.test.ts +23 -0
  122. package/test/event-sources.test.ts +113 -0
  123. package/test/executor.test.ts +135 -0
  124. package/test/files.test.ts +50 -0
  125. package/test/format.test.ts +41 -0
  126. package/test/host-executor.test.ts +149 -0
  127. package/test/inbox-burst.test.ts +66 -0
  128. package/test/inbox.test.ts +102 -0
  129. package/test/install-config.test.ts +75 -0
  130. package/test/install-tools.test.mjs +59 -0
  131. package/test/intake-relay.test.ts +337 -0
  132. package/test/owner-help.test.mjs +10 -0
  133. package/test/plugin-manager.test.mjs +157 -0
  134. package/test/publish-guard.test.ts +21 -0
  135. package/test/reaction.test.ts +122 -0
  136. package/test/read-request.test.ts +134 -0
  137. package/test/relay.test.ts +254 -0
  138. package/test/release-entrypoints.test.mjs +26 -0
  139. package/test/runs.test.ts +116 -0
  140. package/test/security.test.ts +85 -0
  141. package/test/setup.test.ts +68 -0
  142. package/test/software-status.test.ts +31 -0
  143. package/test/update-attention.test.ts +21 -0
  144. package/test/updates.test.mjs +282 -0
  145. package/test/upgrade-pause.test.ts +70 -0
  146. package/test/workspace.test.ts +80 -0
  147. package/tsconfig.json +19 -0
@@ -0,0 +1,134 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readRequest } from '../src/read-request.js'
4
+
5
+ test('repeatable input retries connection reset and 503 with bounded backoff', async (t) => {
6
+ let attempts = 0
7
+ const delays: number[] = []
8
+ t.mock.method(globalThis, 'fetch', async () => {
9
+ attempts++
10
+ if (attempts === 1) throw new TypeError('fetch failed', { cause: { code: 'ECONNRESET' } })
11
+ return attempts === 2 ? new Response('', { status: 503 }) : new Response('transcript')
12
+ })
13
+ assert.equal(
14
+ await readRequest(
15
+ 'Fixture transcription',
16
+ 'https://example.invalid',
17
+ {},
18
+ (r) => r.text(),
19
+ async (ms) => {
20
+ delays.push(ms)
21
+ },
22
+ ),
23
+ 'transcript',
24
+ )
25
+ assert.equal(attempts, 3)
26
+ assert.deepEqual(delays, [1000, 2000])
27
+ })
28
+
29
+ test('exhausted input retries report stage and nested network cause without credentials', async (t) => {
30
+ let attempts = 0
31
+ t.mock.method(globalThis, 'fetch', async () => {
32
+ attempts++
33
+ throw new TypeError('fetch failed secret-token-url', {
34
+ cause: new AggregateError([{ code: 'ETIMEDOUT' }]),
35
+ })
36
+ })
37
+ await assert.rejects(
38
+ readRequest(
39
+ 'Telegram attachment download',
40
+ 'https://example.invalid/secret-token',
41
+ {},
42
+ (r) => r.text(),
43
+ async () => {},
44
+ ),
45
+ (error) => {
46
+ assert.match((error as Error).message, /Telegram attachment download: ETIMEDOUT \(after 3 attempts\)/)
47
+ assert.equal((error as Error).message.includes('secret-token'), false)
48
+ return true
49
+ },
50
+ )
51
+ assert.equal(attempts, 3)
52
+ })
53
+
54
+ test('authentication failures and certificate errors are not retried', async (t) => {
55
+ let attempts = 0
56
+ const fetch = t.mock.method(globalThis, 'fetch', async () => {
57
+ attempts++
58
+ return new Response('secret provider payload', { status: 401 })
59
+ })
60
+ await assert.rejects(
61
+ readRequest(
62
+ 'STT',
63
+ 'https://example.invalid',
64
+ {},
65
+ (r) => r.text(),
66
+ async () => assert.fail('must not retry'),
67
+ ),
68
+ /STT: HTTP 401 \(after 1 attempt\)/,
69
+ )
70
+ assert.equal(attempts, 1)
71
+ fetch.mock.mockImplementation(async () => {
72
+ throw new TypeError('fetch failed', { cause: { code: 'CERT_HAS_EXPIRED' } })
73
+ })
74
+ await assert.rejects(
75
+ readRequest(
76
+ 'STT',
77
+ 'https://example.invalid',
78
+ {},
79
+ (r) => r.text(),
80
+ async () => assert.fail('must not retry'),
81
+ ),
82
+ /CERT_HAS_EXPIRED.*1 attempt/,
83
+ )
84
+ })
85
+
86
+ test('429 honors Retry-After and does not shorten a long provider cooldown', async (t) => {
87
+ let attempts = 0
88
+ const delays: number[] = []
89
+ const fetch = t.mock.method(globalThis, 'fetch', async () =>
90
+ ++attempts === 1
91
+ ? new Response('', { status: 429, headers: { 'retry-after': '5' } })
92
+ : new Response('ok'),
93
+ )
94
+ await readRequest(
95
+ 'STT',
96
+ 'https://example.invalid',
97
+ {},
98
+ (r) => r.text(),
99
+ async (ms) => {
100
+ delays.push(ms)
101
+ },
102
+ )
103
+ assert.deepEqual(delays, [5000])
104
+ fetch.mock.mockImplementation(
105
+ async () => new Response('', { status: 429, headers: { 'retry-after': '120' } }),
106
+ )
107
+ await assert.rejects(
108
+ readRequest(
109
+ 'STT',
110
+ 'https://example.invalid',
111
+ {},
112
+ (r) => r.text(),
113
+ async () => assert.fail('must not retry before provider cooldown'),
114
+ ),
115
+ /HTTP 429.*1 attempt/,
116
+ )
117
+ })
118
+
119
+ test('a socket failure while consuming the body is retried, not just fetch headers', async (t) => {
120
+ let attempts = 0
121
+ t.mock.method(globalThis, 'fetch', async () => new Response('ok'))
122
+ const result = await readRequest(
123
+ 'Download',
124
+ 'https://example.invalid',
125
+ {},
126
+ async (r) => {
127
+ if (++attempts === 1) throw new TypeError('terminated', { cause: { code: 'UND_ERR_SOCKET' } })
128
+ return r.text()
129
+ },
130
+ async () => {},
131
+ )
132
+ assert.equal(result, 'ok')
133
+ assert.equal(attempts, 2)
134
+ })
@@ -0,0 +1,254 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, readdir, readFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { spawn } from 'node:child_process'
7
+ import { once } from 'node:events'
8
+ import type { Update } from 'grammy/types'
9
+ import { createRelay } from '../src/index.js'
10
+ import { ControlStore } from '../src/control-state.js'
11
+ import { RunStore } from '../src/runs.js'
12
+ import { ApprovalStore } from '../src/approval.js'
13
+ import { initialPreset } from '../src/ai.js'
14
+
15
+ const message = (userId = 101, group = false): Update => ({
16
+ update_id: 1,
17
+ message: {
18
+ message_id: 1,
19
+ date: 0,
20
+ text: 'hello',
21
+ from: { id: userId, is_bot: false, first_name: 'Fixture' },
22
+ chat: group
23
+ ? { id: -101, type: 'group', title: 'Fixture' }
24
+ : { id: userId, type: 'private', first_name: 'Fixture' },
25
+ },
26
+ })
27
+
28
+ const until = async (check: () => Promise<boolean>) => {
29
+ const deadline = Date.now() + 5000
30
+ while (!(await check())) {
31
+ if (Date.now() > deadline) throw new Error('Timed out waiting for relay')
32
+ await new Promise((resolve) => setTimeout(resolve, 25))
33
+ }
34
+ }
35
+
36
+ test('owner stop terminates the writer and starts queued work without overlap', async () => {
37
+ const dir = await mkdtemp(join(tmpdir(), 'ez-stop-'))
38
+ const children: ReturnType<typeof spawn>[] = []
39
+ const relay = createRelay(
40
+ {
41
+ controlDir: dir,
42
+ workspace: dir,
43
+ pairingTtlMs: 1000,
44
+ executorTimeoutMs: 1000,
45
+ executorCli: 'grok',
46
+ telegramBotToken: 'fixture',
47
+ },
48
+ async () => {
49
+ assert.ok(children.every((child) => child.exitCode !== null || child.signalCode !== null))
50
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
51
+ detached: process.platform !== 'win32',
52
+ })
53
+ await once(child, 'spawn')
54
+ children.push(child)
55
+ return { child, cleanup: async () => {}, stdout: '' }
56
+ },
57
+ )
58
+ relay.bot.botInfo = {
59
+ id: 999,
60
+ is_bot: true,
61
+ first_name: 'Fixture',
62
+ username: 'fixture_bot',
63
+ can_join_groups: true,
64
+ can_read_all_group_messages: false,
65
+ supports_inline_queries: false,
66
+ can_connect_to_business: false,
67
+ has_main_web_app: false,
68
+ has_topics_enabled: false,
69
+ allows_users_to_create_topics: false,
70
+ can_manage_bots: false,
71
+ supports_join_request_queries: false,
72
+ }
73
+ relay.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
74
+ try {
75
+ const control = new ControlStore(dir, 1000)
76
+ await control.requestPairing(101, 101)
77
+ await control.approveOwner(101)
78
+ await relay.bot.handleUpdate(message())
79
+ await until(async () => children.length === 1)
80
+ const runs = new RunStore(dir)
81
+ await runs.create({ chatId: 101, telegramUserId: 101, texts: ['queued follow-up'],
82
+ execution: await control.captureChoice(initialPreset('grok')) })
83
+ const stop = message()
84
+ stop.message!.text = '/stop'
85
+ await relay.bot.handleUpdate(stop)
86
+ await until(async () => children.length === 2)
87
+ assert.equal((await runs.list()).filter((run) => run.status === 'failed').length, 1)
88
+ await relay.stop()
89
+ await until(async () => children[1].signalCode !== null)
90
+ await until(async () => (await runs.list()).every((run) => run.status !== 'running'))
91
+ } finally {
92
+ await relay.stop()
93
+ for (const child of children) {
94
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
95
+ }
96
+ await rm(dir, { recursive: true, force: true })
97
+ }
98
+ })
99
+
100
+ test('real Telegram handlers never launch for first DM, another sender, group, or forged callback', async () => {
101
+ const dir = await mkdtemp(join(tmpdir(), 'ez-handler-'))
102
+ let launched = 0
103
+ const relay = createRelay(
104
+ {
105
+ controlDir: dir,
106
+ workspace: dir,
107
+ pairingTtlMs: 1000,
108
+ executorTimeoutMs: 1000,
109
+ executorCli: 'grok',
110
+ telegramBotToken: 'fixture',
111
+ },
112
+ async () => {
113
+ launched++
114
+ throw new Error('Executor must not start')
115
+ },
116
+ )
117
+ const calls: string[] = []
118
+ relay.bot.botInfo = {
119
+ id: 999,
120
+ is_bot: true,
121
+ first_name: 'Fixture',
122
+ username: 'fixture_bot',
123
+ can_join_groups: true,
124
+ can_read_all_group_messages: false,
125
+ supports_inline_queries: false,
126
+ can_connect_to_business: false,
127
+ has_main_web_app: false,
128
+ has_topics_enabled: false,
129
+ allows_users_to_create_topics: false,
130
+ can_manage_bots: false,
131
+ supports_join_request_queries: false,
132
+ }
133
+ relay.bot.api.config.use(async (_previous, method) => {
134
+ calls.push(method)
135
+ return { ok: true, result: true } as never
136
+ })
137
+ try {
138
+ await relay.bot.handleUpdate(message())
139
+ const control = new ControlStore(dir, 1000)
140
+ assert.equal((await control.status()).owner, null)
141
+ assert.equal(launched, 0)
142
+ await control.approveOwner(101)
143
+ calls.length = 0
144
+ await relay.bot.handleUpdate(message(202))
145
+ await relay.bot.handleUpdate(message(101, true))
146
+ assert.deepEqual(calls, [])
147
+ const original = message(101).message!
148
+ await relay.bot.handleUpdate({
149
+ update_id: 2,
150
+ callback_query: {
151
+ id: 'test',
152
+ chat_instance: 'fixture',
153
+ from: { id: 202, first_name: 'Intruder', is_bot: false },
154
+ message: original,
155
+ data: 'menu:new',
156
+ },
157
+ })
158
+ assert.equal(await control.getActiveSession(), null)
159
+ const runs = new RunStore(dir)
160
+ const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['test approval'] })
161
+ const approvals = new ApprovalStore(dir)
162
+ await approvals.requestApproval('test_action', 'Harmless smoke test?', run.id)
163
+ await relay.bot.handleUpdate({
164
+ update_id: 3,
165
+ callback_query: {
166
+ id: 'forged',
167
+ chat_instance: 'fixture',
168
+ from: { id: 202, first_name: 'Intruder', is_bot: false },
169
+ message: original,
170
+ data: 'approval:test_action:approve',
171
+ },
172
+ })
173
+ assert.equal((await approvals.getDecision('test_action'))?.decision, 'pending')
174
+ await new Promise((resolve) => setTimeout(resolve, 2100))
175
+ assert.equal(launched, 0)
176
+ } finally {
177
+ await relay.stop()
178
+ await rm(dir, { recursive: true, force: true })
179
+ }
180
+ })
181
+
182
+ test('failed delivery is quarantined and never blindly retried', async () => {
183
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-failure-'))
184
+ const relay = createRelay({
185
+ controlDir: dir,
186
+ workspace: dir,
187
+ pairingTtlMs: 1000,
188
+ executorTimeoutMs: 1000,
189
+ executorCli: 'grok',
190
+ telegramBotToken: 'fixture',
191
+ })
192
+ let sends = 0
193
+ relay.bot.api.config.use(async () => {
194
+ sends++
195
+ throw new Error('Ambiguous network failure')
196
+ })
197
+ try {
198
+ const control = new ControlStore(dir, 1000)
199
+ await control.requestPairing(101, 101)
200
+ await control.approveOwner(101)
201
+ const runs = new RunStore(dir)
202
+ const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['hello'] })
203
+ const item = await runs.enqueueMessage(run.id, 'Hello')
204
+ await relay.drainOutbox()
205
+ await relay.drainOutbox()
206
+ assert.equal(sends, 1)
207
+ await assert.rejects(runs.waitForDelivery(item.id), /Ambiguous network failure/)
208
+ assert.deepEqual(await runs.deliveryStatus(), { failed: 0, unknown: 1 })
209
+ await assert.rejects(runs.waitForDelivery('missing', 1), /outcome unknown/)
210
+ assert.equal(
211
+ (await readdir(join(dir, 'outbox'))).filter((name) => name.endsWith('.failed.json')).length,
212
+ 1,
213
+ )
214
+ } finally {
215
+ await relay.stop()
216
+ await rm(dir, { recursive: true, force: true })
217
+ }
218
+ })
219
+
220
+ test('outbox stores provider receipts and does not redeliver sent messages', async () => {
221
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-'))
222
+ const relay = createRelay({
223
+ controlDir: dir,
224
+ workspace: dir,
225
+ pairingTtlMs: 1000,
226
+ executorTimeoutMs: 1000,
227
+ executorCli: 'grok',
228
+ telegramBotToken: 'fixture',
229
+ })
230
+ let sends = 0
231
+ relay.bot.api.config.use(async (_previous, method) => {
232
+ assert.equal(method, 'sendMessage')
233
+ sends++
234
+ return { ok: true, result: { message_id: 42 } } as never
235
+ })
236
+ try {
237
+ const control = new ControlStore(dir, 1000)
238
+ await control.requestPairing(101, 101)
239
+ await control.approveOwner(101)
240
+ const runs = new RunStore(dir)
241
+ const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['hello'] })
242
+ const item = await runs.enqueueMessage(run.id, '**Hello** <world>')
243
+ await relay.drainOutbox()
244
+ await relay.drainOutbox()
245
+ assert.equal(sends, 1)
246
+ assert.deepEqual(((await runs.waitForDelivery(item.id)) as { messageIds: number[] }).messageIds, [42])
247
+ assert.deepEqual(await runs.pendingOutbox(), [])
248
+ const sent = (await readdir(join(dir, 'outbox'))).find((name) => name.endsWith('.sent.json'))!
249
+ assert.deepEqual(JSON.parse(await readFile(join(dir, 'outbox', sent), 'utf8')).receipt.messageIds, [42])
250
+ } finally {
251
+ await relay.stop()
252
+ await rm(dir, { recursive: true, force: true })
253
+ }
254
+ })
@@ -0,0 +1,26 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {mkdtemp,writeFile,mkdir,symlink,rm,readFile,realpath} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import path from 'node:path';
6
+ import {execFile} from 'node:child_process';
7
+ import {promisify} from 'node:util';
8
+ import {fileURLToPath} from 'node:url';
9
+ const exec=promisify(execFile),root=fileURLToPath(new URL('..',import.meta.url));
10
+ test('public launchers resolve package root through npm-style symlinks',async t=>{
11
+ const temp=await mkdtemp(path.join(tmpdir(),'ez-public-bin-'));t.after(()=>rm(temp,{recursive:true,force:true}));
12
+ const bin=path.join(temp,'bin'),deployment=path.join(temp,'deployment');await mkdir(bin);await mkdir(deployment);
13
+ await writeFile(path.join(bin,'docker'),`#!/bin/sh\nexec '${process.execPath}' -e 'console.log(JSON.stringify(process.argv.slice(1)))' -- "$@"\n`,{mode:0o700});
14
+ const env={...process.env,PATH:bin+path.delimiter+process.env.PATH,EZ_DEPLOYMENT_DIR:deployment,EZ_AGENTS_HOME:path.join(temp,'agents')};
15
+ for(const name of ['docker','create','host'])await symlink(path.join(root,'bin','ezenciel-agents-'+name),path.join(bin,'ezenciel-agents-'+name));
16
+ const docker=JSON.parse((await exec(path.join(bin,'ezenciel-agents-docker'),['config'],{env})).stdout);
17
+ assert.equal(docker[docker.indexOf('--project-directory')+1],await realpath(root));
18
+ const create=JSON.parse((await exec(path.join(bin,'ezenciel-agents-create'),['--list'],{env})).stdout);
19
+ assert.equal(create[create.indexOf('--compose-file')+1],path.join(await realpath(root),'compose.yaml'));
20
+ await writeFile(path.join(deployment,'host-executor.json'),'{');
21
+ await assert.rejects(exec(path.join(bin,'ezenciel-agents-host'),[],{env}),e=>/JSON/.test(e.stderr)&&!e.stderr.includes('ERR_MODULE_NOT_FOUND'));
22
+ });
23
+ test('main version reports package metadata without starting a relay',async()=>{
24
+ const p=JSON.parse(await readFile(new URL('../package.json',import.meta.url)));
25
+ assert.equal((await exec(process.execPath,[path.join(root,'bin/ezenciel-agents.mjs'),'--version'])).stdout.trim(),p.version);
26
+ });
@@ -0,0 +1,116 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import test from 'node:test'
6
+ import { parseMessageArgs, sendRunText } from '../src/message-send.js'
7
+ import { RunStore } from '../src/runs.js'
8
+
9
+ const fixture = async (run: (store: RunStore) => Promise<void>): Promise<void> => {
10
+ const directory = await mkdtemp(path.join(tmpdir(), 'ez-runs-'))
11
+ try {
12
+ await run(new RunStore(directory))
13
+ } finally {
14
+ await rm(directory, { recursive: true, force: true })
15
+ }
16
+ }
17
+
18
+ test('creates a queued run and binds chat id outside the workspace', async () => fixture(async (store) => {
19
+ const created = await store.create({ chatId: 101, telegramUserId: 101, texts: ['hello'] })
20
+ assert.match(created.id, /^r_/)
21
+ assert.equal(created.status, 'queued')
22
+ assert.equal((await store.get(created.id))?.chatId, 101)
23
+ }))
24
+
25
+ test('ez message writes an outbox item for the bound run, not a telegram send', async () => fixture(async (store) => {
26
+ const created = await store.create({ chatId: 9, telegramUserId: 9, texts: ['hello'] })
27
+ await store.patch(created.id, { status: 'running' })
28
+ const item = await sendRunText(store, created.id, ' started 1/100\n')
29
+ assert.equal(item.chatId, 9)
30
+ assert.equal(item.text, 'started 1/100')
31
+ const pending = await store.pendingOutbox()
32
+ assert.equal(pending.length, 1)
33
+ await store.markOutboxSent(item.id)
34
+ assert.equal((await store.pendingOutbox()).length, 0)
35
+ }))
36
+
37
+ test('ez message rejects an empty file and a finished run', async () => fixture(async (store) => {
38
+ const created = await store.create({ chatId: 1, telegramUserId: 1, texts: ['x'] })
39
+ await store.patch(created.id, { status: 'completed' })
40
+ await assert.rejects(sendRunText(store, created.id, 'hi'), /cannot send/)
41
+ await assert.rejects(sendRunText(store, created.id, ' \n'), /empty/)
42
+ }))
43
+
44
+ test('message CLI treats --text-file as the payload path', () => {
45
+ assert.deepEqual(parseMessageArgs(['--', '--text-file', './note.md']), { textFile: './note.md' })
46
+ assert.deepEqual(parseMessageArgs([]), { textFile: undefined })
47
+ })
48
+
49
+ test('message CLI parses --document, --voice, and --reply-to flags', () => {
50
+ const parsed = parseMessageArgs([
51
+ '--text-file', './note.md',
52
+ '--document', './invoice.pdf',
53
+ '--reply-to', '42',
54
+ '--voice', 'spoken note',
55
+ ])
56
+ assert.equal(parsed.textFile, './note.md')
57
+ assert.equal(parsed.document, './invoice.pdf')
58
+ assert.equal(parsed.replyTo, 42)
59
+ assert.equal(parsed.voice, 'spoken note')
60
+ })
61
+
62
+ test('RunStore enqueues document, voice, and approval items properly', async () => fixture(async (store) => {
63
+ const created = await store.create({ chatId: 1, telegramUserId: 1, texts: ['test'] })
64
+ await store.patch(created.id, { status: 'running' })
65
+
66
+ const docItem = await store.enqueueDocument(created.id, './report.pdf', { caption: 'Here is your report', replyToMessageId: 42 })
67
+ assert.equal(docItem.type, 'document')
68
+ assert.equal(docItem.documentPath, './report.pdf')
69
+ assert.equal(docItem.text, 'Here is your report')
70
+ assert.equal(docItem.replyToMessageId, 42)
71
+
72
+ const voiceItem = await store.enqueueVoice(created.id, 'Hello in audio', { replyToMessageId: 42 })
73
+ assert.equal(voiceItem.type, 'voice')
74
+ assert.equal(voiceItem.voiceText, 'Hello in audio')
75
+
76
+ const appItem = await store.enqueueApproval(created.id, 'Approve transaction?', 'tx_123', { replyToMessageId: 42 })
77
+ assert.equal(appItem.type, 'approval')
78
+ assert.equal(appItem.approvalPrompt, 'Approve transaction?')
79
+ assert.equal(appItem.approvalActionId, 'tx_123')
80
+
81
+ const pending = await store.pendingOutbox()
82
+ assert.equal(pending.length, 3)
83
+ }))
84
+
85
+ test('claimOutbox prevents concurrent double-processing of the same item', async () => fixture(async (store) => {
86
+ const created = await store.create({ chatId: 1, telegramUserId: 1, texts: ['test'] })
87
+ await store.patch(created.id, { status: 'running' })
88
+ const item = await store.enqueueMessage(created.id, 'one copy only')
89
+
90
+ // First claim succeeds
91
+ const firstClaim = await store.claimOutbox(item.id)
92
+ assert.equal(firstClaim, true)
93
+
94
+ // Item is no longer visible in pendingOutbox while claimed (.sending.json)
95
+ assert.equal((await store.pendingOutbox()).length, 0)
96
+
97
+ // Concurrent second claim must fail
98
+ const secondClaim = await store.claimOutbox(item.id)
99
+ assert.equal(secondClaim, false)
100
+
101
+ // Mark sent renames to .sent.json
102
+ await store.markOutboxSent(item.id)
103
+ assert.equal((await store.pendingOutbox()).length, 0)
104
+ }))
105
+
106
+ test('unclaimOutbox restores item for retry on transient failure', async () => fixture(async (store) => {
107
+ const created = await store.create({ chatId: 1, telegramUserId: 1, texts: ['test'] })
108
+ await store.patch(created.id, { status: 'running' })
109
+ const item = await store.enqueueMessage(created.id, 'retry item')
110
+
111
+ assert.equal(await store.claimOutbox(item.id), true)
112
+ assert.equal((await store.pendingOutbox()).length, 0)
113
+
114
+ await store.unclaimOutbox(item.id)
115
+ assert.equal((await store.pendingOutbox()).length, 1)
116
+ }))
@@ -0,0 +1,85 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, writeFile, symlink, mkdir } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { isOwner } from '../src/identity.js'
7
+ import { RunStore } from '../src/runs.js'
8
+ import { ApprovalStore } from '../src/approval.js'
9
+ import { ControlStore } from '../src/control-state.js'
10
+ import { stageIncomingFile, workspaceFile, detectFileType } from '../src/files.js'
11
+ import { EXECUTOR_REGISTRY } from '../src/executor.js'
12
+
13
+ test('owner gate denies groups, bots, missing identity, and a different sender or chat', () => {
14
+ const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '' }
15
+ const from = { id: 101, is_bot: false, first_name: 'Test' }
16
+ const chat = { id: 101, type: 'private' as const, first_name: 'Test' }
17
+ assert.ok(isOwner({ from, chat }, owner))
18
+ for (const input of [
19
+ { from: undefined, chat },
20
+ { from: { ...from, id: 202 }, chat },
21
+ { from: { ...from, is_bot: true }, chat },
22
+ { from, chat: { ...chat, id: 202 } },
23
+ { from, chat: { id: -101, type: 'group' as const, title: 'Test' } },
24
+ ])
25
+ assert.equal(isOwner(input, owner), false)
26
+ assert.equal(isOwner({ from, chat }, null), false)
27
+ })
28
+
29
+ test('store identifiers reject traversal; corrupt records cannot authorize or block healthy runs', async () => {
30
+ const dir = await mkdtemp(join(tmpdir(), 'ez-negative-'))
31
+ try {
32
+ const runs = new RunStore(dir)
33
+ await assert.rejects(runs.get('../control-state'), /Invalid/)
34
+ await assert.rejects(runs.claimOutbox('../control-state'), /Invalid/)
35
+ await assert.rejects(new ApprovalStore(dir).getDecision('../escape'), /Invalid/)
36
+ const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['test'] })
37
+ await writeFile(join(dir, 'runs', 'broken.json'), '{')
38
+ assert.equal((await runs.nextQueued())?.id, run.id)
39
+ await writeFile(join(dir, 'control-state.json'), '{')
40
+ await assert.rejects(new ControlStore(dir, 1000).status())
41
+ } finally {
42
+ await rm(dir, { recursive: true, force: true })
43
+ }
44
+ })
45
+
46
+ test('approval decisions cannot be invented, replayed or reused for another action', async () => {
47
+ const dir = await mkdtemp(join(tmpdir(), 'ez-approval-negative-'))
48
+ try {
49
+ const approvals = new ApprovalStore(dir)
50
+ await assert.rejects(approvals.recordDecision('missing', 'approved', 101), /Unknown/)
51
+ await approvals.requestApproval('sample', 'A harmless test?', 'run_a')
52
+ await assert.rejects(approvals.requestApproval('sample', 'Different?', 'run_b'), /another request/)
53
+ await approvals.recordDecision('sample', 'denied', 101)
54
+ await assert.rejects(approvals.recordDecision('sample', 'approved', 101), /already decided/)
55
+ } finally {
56
+ await rm(dir, { recursive: true, force: true })
57
+ }
58
+ })
59
+
60
+ test('files reject malformed UTF-8 and symlinks outside the workspace', async () => {
61
+ const dir = await mkdtemp(join(tmpdir(), 'ez-path-negative-'))
62
+ try {
63
+ const workspace = join(dir, 'workspace')
64
+ await mkdir(workspace)
65
+ await writeFile(join(dir, 'private.txt'), 'private')
66
+ await symlink(join(dir, 'private.txt'), join(workspace, 'escape.txt'))
67
+ await assert.rejects(workspaceFile(workspace, 'escape.txt'), /outside/)
68
+ assert.equal(detectFileType(Buffer.from([0xff, 0xfe, 0x41])), 'unknown')
69
+ await assert.rejects(stageIncomingFile(workspace, 'bad.txt', Buffer.from([0xff, 0xfe])), /Unsupported/)
70
+ } finally {
71
+ await rm(dir, { recursive: true, force: true })
72
+ }
73
+ })
74
+
75
+ test('Grok uses an exact session for continuation and a distinct fresh session for new', () => {
76
+ const build = EXECUTOR_REGISTRY.grok.buildArgs
77
+ assert.deepEqual(
78
+ build({ workspace: '/tmp', sessionId: 'test', isResume: true }, '/tmp/p', '').slice(0, 2),
79
+ ['--resume', 'test'],
80
+ )
81
+ assert.deepEqual(
82
+ build({ workspace: '/tmp', sessionId: 'new', isResume: false }, '/tmp/p', '').slice(0, 2),
83
+ ['--session-id', 'new'],
84
+ )
85
+ })
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import { findExecutableInPath, readActiveExecutor, setExecutorInEnv, getExecutorsStatus } from '../src/setup.js'
7
+
8
+ test('findExecutableInPath locates existing binary and returns null for nonexistent', async () => {
9
+ const nodePath = await findExecutableInPath('node', process.env.PATH)
10
+ assert.ok(nodePath && nodePath.includes('node'))
11
+
12
+ const missing = await findExecutableInPath('nonexistent-binary-12345', process.env.PATH)
13
+ assert.equal(missing, null)
14
+ })
15
+
16
+ test('readActiveExecutor extracts EZ_EXECUTOR_CLI or defaults to agy', async () => {
17
+ const dir = await mkdtemp(path.join(tmpdir(), 'ez-setup-test-'))
18
+ try {
19
+ const envPath = path.join(dir, '.env')
20
+ assert.equal(await readActiveExecutor(envPath), 'agy')
21
+
22
+ await writeFile(envPath, 'EZ_EXECUTOR_CLI=claude\n', 'utf8')
23
+ assert.equal(await readActiveExecutor(envPath), 'claude')
24
+ } finally {
25
+ await rm(dir, { recursive: true, force: true })
26
+ }
27
+ })
28
+
29
+ test('setExecutorInEnv updates or adds EZ_EXECUTOR_CLI without altering other env lines', async () => {
30
+ const dir = await mkdtemp(path.join(tmpdir(), 'ez-setup-test-'))
31
+ try {
32
+ const envPath = path.join(dir, '.env')
33
+ await writeFile(envPath, 'TELEGRAM_BOT_TOKEN=token123\nEZ_AGENT_WORKSPACE=./agent\n', 'utf8')
34
+
35
+ await setExecutorInEnv(envPath, 'claude')
36
+ let content = await readFile(envPath, 'utf8')
37
+ assert.match(content, /TELEGRAM_BOT_TOKEN=token123/)
38
+ assert.match(content, /EZ_EXECUTOR_CLI=claude/)
39
+
40
+ await setExecutorInEnv(envPath, 'antigravity')
41
+ content = await readFile(envPath, 'utf8')
42
+ assert.match(content, /TELEGRAM_BOT_TOKEN=token123/)
43
+ assert.match(content, /EZ_EXECUTOR_CLI=antigravity/)
44
+ assert.ok(!content.includes('claude'))
45
+ } finally {
46
+ await rm(dir, { recursive: true, force: true })
47
+ }
48
+ })
49
+
50
+ test('getExecutorsStatus returns all executors and sets isActive correctly', async () => {
51
+ const dir = await mkdtemp(path.join(tmpdir(), 'ez-setup-test-'))
52
+ try {
53
+ const envPath = path.join(dir, '.env')
54
+ await writeFile(envPath, 'EZ_EXECUTOR_CLI=claude\n', 'utf8')
55
+
56
+ const status = await getExecutorsStatus(envPath)
57
+ assert.equal(status.active, 'claude')
58
+ const claudeItem = status.items.find((item) => item.id === 'claude')
59
+ assert.ok(claudeItem)
60
+ assert.equal(claudeItem.isActive, true)
61
+
62
+ const agyItem = status.items.find((item) => item.id === 'agy')
63
+ assert.ok(agyItem)
64
+ assert.equal(agyItem.isActive, false)
65
+ } finally {
66
+ await rm(dir, { recursive: true, force: true })
67
+ }
68
+ })