@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,337 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm } 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 { InboxStore } from '../src/inbox.js'
13
+ import { ApprovalStore } from '../src/approval.js'
14
+ import { packageVersion } from '../src/version.js'
15
+
16
+ const message = (id: number, text = 'hello'): Update => ({
17
+ update_id: id,
18
+ message: {
19
+ message_id: id,
20
+ date: 0,
21
+ text,
22
+ from: { id: 101, is_bot: false, first_name: 'Fixture' },
23
+ chat: { id: 101, type: 'private', first_name: 'Fixture' },
24
+ },
25
+ })
26
+ const fixture = async () => {
27
+ const dir = await mkdtemp(join(tmpdir(), 'ez-intake-relay-'))
28
+ const launched: string[][] = []
29
+ const replies: string[] = []
30
+ const keyboards: { text: string; callback_data: string }[][][] = []
31
+ const children: ReturnType<typeof spawn>[] = []
32
+ const config = {
33
+ controlDir: dir,
34
+ workspace: dir,
35
+ pairingTtlMs: 1000,
36
+ executorTimeoutMs: 1000,
37
+ executorCli: 'grok' as const,
38
+ telegramBotToken: 'fixture',
39
+ geminiApiKey: 'fixture',
40
+ }
41
+ const make = () => {
42
+ const relay = createRelay(config, async (texts) => {
43
+ launched.push(texts)
44
+ const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 50)'])
45
+ children.push(child)
46
+ await once(child, 'spawn')
47
+ return { child, cleanup: async () => {}, stdout: '' }
48
+ })
49
+ relay.bot.botInfo = {
50
+ id: 999,
51
+ is_bot: true,
52
+ first_name: 'Fixture',
53
+ username: 'fixture_bot',
54
+ } as typeof relay.bot.botInfo
55
+ relay.bot.api.config.use(async (_previous, method, payload) => {
56
+ if (method === 'sendMessage') replies.push((payload as { text: string }).text)
57
+ const keyboard = (payload as { reply_markup?: { inline_keyboard?: { text: string; callback_data: string }[][] } }).reply_markup?.inline_keyboard
58
+ if (keyboard) keyboards.push(keyboard)
59
+ return {
60
+ ok: true,
61
+ result: method === 'getFile' ? { file_path: 'fixture.ogg' } : { message_id: 42 },
62
+ } as never
63
+ })
64
+ return relay
65
+ }
66
+ const control = new ControlStore(dir, 1000)
67
+ await control.requestPairing(101, 101)
68
+ await control.approveOwner(101)
69
+ let relay = make()
70
+ return {
71
+ dir,
72
+ launched,
73
+ replies,
74
+ keyboards,
75
+ get relay() {
76
+ return relay
77
+ },
78
+ async restart() {
79
+ await relay.stop()
80
+ relay = make()
81
+ },
82
+ async close() {
83
+ await relay.stop()
84
+ await relay.drainInbox()
85
+ for (const child of children) {
86
+ if (child.exitCode === null && child.signalCode === null) await once(child, 'close')
87
+ }
88
+ // Completion bookkeeping runs asynchronously after the process close event.
89
+ const runs = new RunStore(dir)
90
+ for (let i = 0; i < 100 && (await runs.list()).some((r) => r.status === 'running'); i++)
91
+ await new Promise((resolve) => setTimeout(resolve, 10))
92
+ await rm(dir, { recursive: true, force: true })
93
+ },
94
+ }
95
+ }
96
+
97
+ test('four-item menu is owner-only; saved AI buttons work and forged/stale buttons cannot change settings', async () => {
98
+ const f = await fixture()
99
+ const callback = (id: number, data: string, user = 101): Update => ({
100
+ update_id: id,
101
+ callback_query: { id: String(id), chat_instance: 'fixture', data,
102
+ from: { id: user, first_name: 'Fixture', is_bot: false }, message: message(id).message! },
103
+ })
104
+ try {
105
+ await f.relay.bot.handleUpdate(message(1, '/menu'))
106
+ assert.deepEqual(f.keyboards.at(-1)!.flat().map((b) => b.text),
107
+ ['New conversation', 'Choose AI', 'Work status', 'Settings'])
108
+ await f.relay.bot.handleUpdate(message(2, '/ai'))
109
+ const pick = f.keyboards.at(-1)!.flat()[0].callback_data
110
+ const store = new ControlStore(f.dir, 1000)
111
+ await f.relay.bot.handleUpdate(callback(3, pick, 202))
112
+ assert.equal(await store.getActiveSession(), null)
113
+ await f.relay.bot.handleUpdate(callback(4, 'ai:forged'))
114
+ assert.equal(await store.getActiveSession(), null)
115
+ await f.relay.bot.handleUpdate(callback(5, pick))
116
+ assert.ok(await store.getActiveSession())
117
+ await f.relay.bot.handleUpdate(callback(6, pick))
118
+ assert.match(f.replies.at(-1)!, /Menu expired/)
119
+ await f.relay.bot.handleUpdate(message(7, '/settings'))
120
+ assert.match(f.replies.at(-1)!, /Default for new conversations/)
121
+ assert.equal(f.launched.length, 0)
122
+ } finally { await f.close() }
123
+ })
124
+
125
+ test('new conversation leaves already accepted messages pinned to the old conversation', async () => {
126
+ const f = await fixture()
127
+ try {
128
+ await f.relay.bot.handleUpdate(message(1, 'before'))
129
+ const store = new ControlStore(f.dir, 1000)
130
+ const first = (await store.getActiveSession())!.sessionId
131
+ await f.relay.bot.handleUpdate(message(2, '/new'))
132
+ await f.relay.bot.handleUpdate(message(3, 'after'))
133
+ await f.relay.drainInbox(true)
134
+ const runs = new RunStore(f.dir)
135
+ assert.equal((await runs.get('tg_1'))!.execution!.sessionId, first)
136
+ await f.relay.drainInbox(true)
137
+ assert.notEqual((await runs.get('tg_3'))!.execution!.sessionId, first)
138
+ } finally { await f.close() }
139
+ })
140
+
141
+ test('accepted DM survives relay restart and duplicate delivery creates exactly one run', async () => {
142
+ const f = await fixture()
143
+ try {
144
+ await f.relay.bot.handleUpdate(message(1))
145
+ assert.equal(f.launched.length, 0)
146
+ await f.restart()
147
+ await f.relay.bot.handleUpdate(message(1))
148
+ await f.relay.drainInbox(true)
149
+ assert.deepEqual(f.launched, [['hello']])
150
+ await f.relay.bot.handleUpdate(message(1))
151
+ await f.relay.drainInbox(true)
152
+ assert.equal((await new RunStore(f.dir).list()).length, 1)
153
+ } finally {
154
+ await f.close()
155
+ }
156
+ })
157
+
158
+ test('slow voice normalization preserves instruction order and leaves controls responsive', async () => {
159
+ const f = await fixture()
160
+ const originalFetch = globalThis.fetch
161
+ let release!: () => void
162
+ const gate = new Promise<void>((resolve) => {
163
+ release = resolve
164
+ })
165
+ let entered!: () => void
166
+ const downloading = new Promise<void>((resolve) => {
167
+ entered = resolve
168
+ })
169
+ globalThis.fetch = async (input) => {
170
+ if (String(input).includes('api.telegram.org/file/')) {
171
+ entered()
172
+ await gate
173
+ return new Response('fixture audio')
174
+ }
175
+ return Response.json({ candidates: [{ content: { parts: [{ text: 'Fixture voice transcript' }] } }] })
176
+ }
177
+ try {
178
+ await f.relay.bot.handleUpdate(message(1, 'Use the following voice note'))
179
+ const voice = message(2)
180
+ delete voice.message!.text
181
+ voice.message!.voice = { file_id: 'fixture', file_unique_id: 'fixture', duration: 2 }
182
+ voice.message!.reply_to_message = {
183
+ ...message(91, 'Quoted context').message!,
184
+ reply_to_message: undefined,
185
+ }
186
+ await f.relay.bot.handleUpdate(voice)
187
+ const processing = f.relay.drainInbox(true)
188
+ await downloading
189
+ await f.relay.bot.handleUpdate(message(3, '/status'))
190
+ assert.ok(f.replies.some((text) => text.includes('2 incoming messages')))
191
+ assert.ok(f.replies.some((text) => text.includes(`Ez relay: ${packageVersion} (running)`)))
192
+ await f.relay.bot.handleUpdate(message(4, 'Next instruction'))
193
+ assert.equal(f.launched.length, 0)
194
+ release()
195
+ await processing
196
+ assert.equal(f.launched[0][0], 'Use the following voice note')
197
+ assert.match(f.launched[0][1], /Quoted context/)
198
+ assert.match(f.launched[0][1], /Fixture voice transcript/)
199
+ assert.equal(f.launched[0].length, 2)
200
+ assert.equal((await new InboxStore(f.dir).status()).pending, 1)
201
+ } finally {
202
+ release()
203
+ globalThis.fetch = originalFetch
204
+ await f.close()
205
+ }
206
+ })
207
+
208
+ test('cancel clears accepted and queued work, never spawns a cancelled run, and status exposes delivery uncertainty', async () => {
209
+ const f = await fixture()
210
+ try {
211
+ const runs = new RunStore(f.dir)
212
+ const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['queued'] })
213
+ const out = await runs.enqueueMessage(run.id, 'fixture delivery')
214
+ await runs.claimOutbox(out.id)
215
+ await f.relay.bot.handleUpdate(message(1))
216
+ await f.relay.bot.handleUpdate(message(2, '/cancel'))
217
+ await f.relay.drainInbox(true)
218
+ assert.equal(f.launched.length, 0)
219
+ assert.equal((await runs.get(run.id))?.status, 'cancelled')
220
+ await f.relay.bot.handleUpdate(message(3, '/status'))
221
+ assert.match(f.replies.at(-1)!, /0 runs; 0 incoming messages/)
222
+ assert.match(f.replies.at(-1)!, /1 unknown/)
223
+ } finally {
224
+ await f.close()
225
+ }
226
+ })
227
+
228
+ test('replayed approval decision can recover its wake but a new callback cannot replay consent', async () => {
229
+ const f = await fixture()
230
+ try {
231
+ const runs = new RunStore(f.dir)
232
+ const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['approval'] })
233
+ await runs.patch(run.id, { status: 'completed' })
234
+ const approvals = new ApprovalStore(f.dir)
235
+ await approvals.requestApproval('fixture_action', 'Fixture?', run.id)
236
+ const update: Update = {
237
+ update_id: 11,
238
+ callback_query: {
239
+ id: 'fixture',
240
+ chat_instance: 'fixture',
241
+ from: message(1).message!.from!,
242
+ message: message(1).message!,
243
+ data: 'approval:fixture_action:approve',
244
+ },
245
+ }
246
+ await f.relay.bot.handleUpdate(update)
247
+ // Simulate crash after consent persisted, before its agent wake was created.
248
+ await approvals.recordDecision('fixture_action', 'approved', 101, 11)
249
+ await f.restart()
250
+ await f.relay.drainInbox(true)
251
+ assert.equal(f.launched.length, 1)
252
+ assert.match(f.launched[0][0], /approval_decision/)
253
+ await assert.rejects(approvals.recordDecision('fixture_action', 'approved', 101, 12), /already decided/)
254
+ } finally {
255
+ await f.close()
256
+ }
257
+ })
258
+
259
+ test('revoked owner intake never executes or recreates a pairing request on replay', async () => {
260
+ const f = await fixture()
261
+ try {
262
+ await f.relay.bot.handleUpdate(message(1))
263
+ const control = new ControlStore(f.dir, 1000)
264
+ await control.revokeOwner()
265
+ await f.relay.drainInbox(true)
266
+ assert.equal(f.launched.length, 0)
267
+ assert.equal((await control.status()).owner, null)
268
+ assert.deepEqual((await control.status()).pending, [])
269
+ assert.deepEqual(f.replies, [])
270
+ } finally {
271
+ await f.close()
272
+ }
273
+ })
274
+
275
+ test('cancelling during a download prevents dispatch even when normalization finishes', async () => {
276
+ const f = await fixture()
277
+ const originalFetch = globalThis.fetch
278
+ let release!: () => void
279
+ const gate = new Promise<void>((resolve) => {
280
+ release = resolve
281
+ })
282
+ let entered!: () => void
283
+ const downloading = new Promise<void>((resolve) => {
284
+ entered = resolve
285
+ })
286
+ globalThis.fetch = async () => {
287
+ entered()
288
+ await gate
289
+ return new Response('Fixture document')
290
+ }
291
+ try {
292
+ const doc = message(1)
293
+ delete doc.message!.text
294
+ doc.message!.document = { file_id: 'fixture', file_unique_id: 'fixture', file_name: 'fixture.txt' }
295
+ await f.relay.bot.handleUpdate(doc)
296
+ const processing = f.relay.drainInbox(true)
297
+ await downloading
298
+ await f.relay.bot.handleUpdate(message(2, '/cancel'))
299
+ release()
300
+ await processing
301
+ assert.equal(f.launched.length, 0)
302
+ assert.equal((await new RunStore(f.dir).list()).length, 0)
303
+ assert.equal((await new InboxStore(f.dir).status()).pending, 0)
304
+ } finally {
305
+ release()
306
+ globalThis.fetch = originalFetch
307
+ await f.close()
308
+ }
309
+ })
310
+
311
+ test('media failure quarantines its instruction batch instead of executing incomplete context', async () => {
312
+ const f = await fixture()
313
+ const originalFetch = globalThis.fetch
314
+ globalThis.fetch = async () => new Response('unavailable', { status: 503 })
315
+ try {
316
+ await f.relay.bot.handleUpdate(message(1, 'Use the following attachment'))
317
+ const doc = message(2)
318
+ delete doc.message!.text
319
+ doc.message!.document = { file_id: 'fixture', file_unique_id: 'fixture', file_name: 'fixture.txt' }
320
+ await f.relay.bot.handleUpdate(doc)
321
+ await f.relay.drainInbox(true)
322
+ assert.equal(f.launched.length, 0)
323
+ assert.deepEqual(await new InboxStore(f.dir).status(), { pending: 0, failed: 1 })
324
+ globalThis.fetch = async () => new Response('Recovered fixture document')
325
+ await f.relay.bot.handleUpdate(message(3, '/retry'))
326
+ await f.relay.drainInbox(true)
327
+ assert.equal(f.launched.length, 1)
328
+ assert.equal(f.launched[0][0], 'Use the following attachment')
329
+ assert.match(f.launched[0][1], /Attached document/)
330
+ await f.relay.bot.handleUpdate(message(4, '/retry'))
331
+ await f.relay.drainInbox(true)
332
+ assert.equal(f.launched.length, 1)
333
+ } finally {
334
+ globalThis.fetch = originalFetch
335
+ await f.close()
336
+ }
337
+ })
@@ -0,0 +1,10 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {execFileSync} from 'node:child_process';
4
+
5
+ test('owner help succeeds without loading control configuration or credentials',()=>{
6
+ const output=execFileSync(process.execPath,['bin/ezenciel-agents-owner.mjs','--help'],{
7
+ encoding:'utf8',env:{PATH:process.env.PATH,EZ_CONTROL_DIR:'/dev/null/not-a-directory'}
8
+ });
9
+ assert.match(output,/Usage: ezenciel-agents-owner status/);
10
+ });
@@ -0,0 +1,157 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import * as fs from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import path from 'node:path';
6
+ import {execFile} from 'node:child_process';
7
+ import {promisify} from 'node:util';
8
+ import {snapshot,init as initManager,validate,compose,locked} from '../src/plugins/manager.mjs';
9
+ // Synthetic manager tests explicitly opt out of the product's default packages.
10
+ async function init(home,workspace,catalog,hostConfig) {
11
+ const file=path.join(path.dirname(home),'test-catalog.json');
12
+ if(!catalog)await fs.writeFile(file,'{}');
13
+ return initManager(home,workspace,catalog||file,hostConfig);
14
+ }
15
+ const exec=promisify(execFile),bin=new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname;
16
+ async function fixture(t) {
17
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
18
+ const source=path.join(root,'source'),home=path.join(root,'tools'),workspace=path.join(root,'mind'),fake=path.join(root,'fake');
19
+ for(const d of [source,workspace,fake])await fs.mkdir(d);
20
+ const manifest={schemaVersion:1,id:'sample',version:'0.1.0',description:'Synthetic',commands:{sample:{executable:'client.mjs',args:[]}},skills:['SKILL.md']};
21
+ const deployment={schemaVersion:1,services:{sample:{buildTarget:'runtime',volumes:{data:'/data'},workspace:true,healthcheck:['node','--version']}},commands:{sample:{service:'sample',argv:['node','/app/client.mjs'],suffix:[]}}};
22
+ for(const [name,value]of Object.entries({'package.json':JSON.stringify({files:['client.mjs','SKILL.md']}),'ez-plugin.json':JSON.stringify(manifest),'ez-deployment.json':JSON.stringify(deployment),'Dockerfile':'FROM scratch AS runtime','.dockerignore':'','client.mjs':'','SKILL.md':'Synthetic'}))await fs.writeFile(path.join(source,name),value);
23
+ const log=path.join(root,'docker.log');
24
+ await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nrequire('fs').appendFileSync(${JSON.stringify(log)},JSON.stringify({argv:process.argv.slice(2),secret:process.env.TELEGRAM_BOT_TOKEN})+'\\n');\nif(process.argv.includes('run')){console.log(JSON.stringify(process.argv.slice(process.argv.indexOf('/app/client.mjs')+1)));process.exit(process.argv.includes('fail')?17:0)}\n`,{mode:0o700});
25
+ const env={...process.env,PATH:fake+path.delimiter+process.env.PATH,TELEGRAM_BOT_TOKEN:'must-not-leak'};
26
+ const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
27
+ return {root,source,home,workspace,fake,log,manifest,deployment,env,call};
28
+ }
29
+ test('catalog paths resolve relative to the catalog and pin each new agent independently',async t=>{
30
+ const f=await fixture(t),catalog=path.join(f.root,'defaults.json');
31
+ await fs.writeFile(catalog,JSON.stringify({sample:'./source'}));
32
+ await initManager(f.home,f.workspace,catalog);
33
+ const first=JSON.parse((await f.call('plugins','available')).stdout);
34
+ assert.equal(first.sample.source,await fs.realpath(f.source));
35
+ await fs.writeFile(path.join(f.source,'SKILL.md'),'new release');
36
+ const second=path.join(f.root,'second');await initManager(second,f.workspace,catalog);
37
+ const next=JSON.parse(await fs.readFile(path.join(second,'config.json'),'utf8'));
38
+ assert.notEqual(first.sample.revision,next.catalog.sample.revision);
39
+ assert.deepEqual(JSON.parse((await f.call('plugins','available')).stdout),first);
40
+ });
41
+ test('initialization seeds tool guidance and preserves existing mind notes',async t=>{
42
+ const f=await fixture(t);await init(f.home,f.workspace);
43
+ const template=await fs.readFile(new URL('../templates/agent/TOOLS.md',import.meta.url),'utf8');
44
+ const target=path.join(f.workspace,'TOOLS.md');
45
+ assert.ok((await fs.readFile(target,'utf8')).startsWith(template));
46
+ const existing='Owner-maintained tool notes\n';await fs.writeFile(target,existing);
47
+ await init(path.join(f.root,'other-tools'),f.workspace);
48
+ assert.ok((await fs.readFile(target,'utf8')).startsWith(existing));
49
+ });
50
+ test('bound launcher installs without startup; literal args and exit codes; scopes and secrets',async t=>{
51
+ const f=await fixture(t);const p=await snapshot(f.source);await init(f.home,f.workspace);
52
+ const install=['plugins','install','sample','--source',f.source,'--revision',p.revision];
53
+ await f.call(...install);assert.equal(JSON.parse((await f.call(...install)).stdout).existing,true);
54
+ let lines=(await fs.readFile(f.log,'utf8')).trim().split('\n').map(JSON.parse);assert.equal(lines.length,1);assert(lines[0].argv.includes('build'));assert(!lines[0].argv.includes('up'));assert.equal(lines[0].secret,undefined);
55
+ const literal=['--home','/other','$(touch /tmp/nope)','a b','x;y','--text-file','has spaces.txt'];
56
+ assert.deepEqual(JSON.parse((await exec(path.join(f.home,'bin','ez'),['sample',...literal],{env:f.env})).stdout),literal);
57
+ await assert.rejects(f.call('sample','fail'),e=>e.code===17);
58
+ await assert.rejects(f.call('plugins','start','../sample'));
59
+ await assert.rejects(f.call('tools','install','sample'));
60
+ await f.call('plugins','start','sample');await f.call('plugins','stop','sample');
61
+ await f.call('plugins','uninstall','sample');await assert.rejects(f.call('sample','doctor'));
62
+ await f.call(...install); // preserved snapshot is safe to reuse
63
+ lines=(await fs.readFile(f.log,'utf8')).trim().split('\n').map(JSON.parse);assert(lines.every(x=>x.secret===undefined));assert(lines.every(x=>!x.argv.includes('-v')));
64
+ const mode=(await fs.stat(path.join(f.home,'registry.json'))).mode&0o777;assert.equal(mode,0o600);
65
+ });
66
+ test('changed source, symlinks, reserved aliases, arbitrary Docker fields rejected',async t=>{
67
+ const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
68
+ await fs.writeFile(path.join(f.source,'client.mjs'),'changed');await assert.rejects(f.call('plugins','install','sample','--source',f.source,'--revision',p.revision));
69
+ await fs.rm(path.join(f.source,'client.mjs'));await fs.symlink('/etc/passwd',path.join(f.source,'client.mjs'));await assert.rejects(snapshot(f.source),/symlink/);
70
+ const d=structuredClone(f.deployment);d.services.sample.privileged=true;assert.throws(()=>validate(f.manifest,d,p.files),/unknown/);
71
+ const m=structuredClone(f.manifest);m.commands={plugins:m.commands.sample};const bad=structuredClone(f.deployment);bad.commands={plugins:bad.commands.sample};assert.throws(()=>validate(m,bad,p.files),/Reserved/);
72
+ });
73
+ test('registry corruption, active writer lock and alias collision fail closed',async t=>{
74
+ const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
75
+ await locked(f.home,()=>assert.rejects(locked(f.home,()=>{}),/busy/));
76
+ await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
77
+ const m={...f.manifest,id:'second'};await fs.writeFile(path.join(f.source,'ez-plugin.json'),JSON.stringify(m));const other=await snapshot(f.source);
78
+ await assert.rejects(f.call('plugins','install','second','--source',f.source,'--revision',other.revision),/collision/);
79
+ await fs.writeFile(path.join(f.home,'registry.json'),'{bad');await assert.rejects(f.call('sample','doctor'));
80
+ });
81
+ test('Compose resources are namespaced, private, and restricted to owning workspace',async t=>{
82
+ const f=await fixture(t),p=await snapshot(f.source);
83
+ const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
84
+ assert.equal(c.name,'ezp-synthetic');assert.equal(c.services.sample.ports,undefined);assert.equal(c.services.sample.privileged,undefined);assert.equal(c.services.sample.volumes[1].read_only,true);assert.deepEqual(c.services.sample.cap_drop,['ALL']);
85
+ });
86
+ test('host onboarding binds local ez without replacing global commands',async t=>{
87
+ const f=await fixture(t),native=path.join(f.root,'native'),config=path.join(f.root,'host.json');await fs.mkdir(native);await fs.writeFile(path.join(native,'native-tool'),'hello');
88
+ await fs.writeFile(config,JSON.stringify({cli:'synthetic',agents:[{name:'demo',workspace:f.workspace,binDir:native}]}));
89
+ await init(f.home,f.workspace,undefined,config);const host=JSON.parse(await fs.readFile(config));assert.equal(host.agents[0].binDir,path.join(await fs.realpath(f.home),'bin'));assert.equal(host.agents[0].toolsHome,await fs.realpath(f.home));assert.equal(await fs.readFile(path.join(host.agents[0].binDir,'native-tool'),'utf8'),'hello');
90
+ await assert.rejects(init(f.home,f.workspace),/exists/);
91
+ });
92
+ test('host install exposes runnable public commands without source aliases',async t=>{
93
+ const f=await fixture(t),native=path.join(f.root,'native'),config=path.join(f.root,'host.json');
94
+ await fs.mkdir(native);
95
+ await fs.symlink('ezenciel-agents-message',path.join(native,'ezenciel-agents-message'));
96
+ await fs.writeFile(config,JSON.stringify({cli:'codex',agents:[{name:'demo',workspace:f.workspace,binDir:native}]}));
97
+ await init(f.home,f.workspace,undefined,config);
98
+ const manifest=JSON.parse(await fs.readFile(new URL('../package.json',import.meta.url)));
99
+ for(const [name,entry] of Object.entries(manifest.bin)) assert.equal(await fs.realpath(path.join(f.home,'bin',name)),await fs.realpath(new URL('../'+entry,import.meta.url)));
100
+ const result=await exec(path.join(f.home,'bin','ezenciel-agents-message'),['--help'],{env:{...process.env,PATH:path.dirname(process.execPath)+path.delimiter+process.env.PATH}});
101
+ assert.match(result.stdout,/Usage: ezenciel-agents-message/);
102
+ });
103
+ test('copying another agent registry is rejected before any Docker operation',async t=>{
104
+ const f=await fixture(t),other=path.join(f.root,'other');await init(f.home,f.workspace);await init(other,f.workspace);
105
+ await fs.copyFile(path.join(f.home,'registry.json'),path.join(other,'registry.json'));
106
+ await assert.rejects(exec(process.execPath,[bin,'--home',other,'tools','list'],{env:f.env}),/Corrupt registry/);
107
+ await assert.rejects(fs.stat(f.log),e=>e.code==='ENOENT');
108
+ });
109
+
110
+ test('v2 supports bounded dependency graphs and generated private secrets',async t=>{
111
+ const f=await fixture(t),p=await snapshot(f.source);
112
+ const d=structuredClone(f.deployment);d.schemaVersion=2;d.secrets=['db-password'];
113
+ d.services.database={image:'example/database@sha256:'+'a'.repeat(64),user:'999:999',healthcheck:['check'],memoryMiB:128,environment:{PASSWORD:{secret:'db-password'}}};
114
+ d.services.sample.dependsOn=['database'];d.services.sample.environment={URL:{secret:'db-password',prefix:'db://',suffix:'@database'}};
115
+ validate(f.manifest,d,p.files);
116
+ const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d},{'db-password':'b'.repeat(64)});
117
+ assert.equal(c.services.database.user,'999:999');assert.equal(c.services.sample.depends_on.database.condition,'service_healthy');assert.equal(c.services.sample.environment.URL,'db://'+'b'.repeat(64)+'@database');
118
+ assert.equal(c.services.database.mem_limit,'128m');assert.throws(()=>compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
119
+ for(const change of [x=>x.services.database.user='0:0',x=>x.services.database.environment.PASSWORD={secret:'undeclared'},x=>x.services.database.environment.PASSWORD='${HOST_SECRET}',x=>x.services.database.dependsOn=['sample'],x=>x.services.sample.dependsOn=['missing'],x=>x.services.sample.ports=['9999:9999']]){const bad=structuredClone(d);change(bad);assert.throws(()=>validate(f.manifest,bad,p.files));}
120
+ const old=structuredClone(d);old.schemaVersion=1;assert.throws(()=>validate(f.manifest,old,p.files));
121
+ });
122
+ test('catalog publication pins reviewed source without install or Docker calls',async t=>{
123
+ const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
124
+ await f.call('plugins','catalog-add','sample','--source',f.source,'--revision',p.revision);
125
+ const available=JSON.parse((await f.call('plugins','available')).stdout);assert.equal(available.sample.revision,p.revision);
126
+ assert.deepEqual(JSON.parse((await f.call('tools','list')).stdout),{});await assert.rejects(fs.stat(f.log),e=>e.code==='ENOENT');
127
+ await assert.rejects(f.call('plugins','catalog-add','wrong','--source',f.source,'--revision',p.revision),/pin/);
128
+ });
129
+
130
+ test('plugin help documents data-preserving uninstall without a plugin identifier',async t=>{
131
+ const f=await fixture(t);await init(f.home,f.workspace);
132
+ const help=JSON.parse((await f.call('plugins','--help')).stdout);
133
+ assert.ok(help.commands.includes('uninstall <id>'));assert.match(help.uninstall,/retains all volumes and secrets/);
134
+ for(const action of ['install','uninstall','start'])assert.deepEqual(JSON.parse((await f.call('plugins',action,'--help')).stdout),help);
135
+ });
136
+
137
+ test('inspection reveals stale catalog pin without changing it; explicit repin permits install',async t=>{
138
+ const f=await fixture(t);await init(f.home,f.workspace);
139
+ const old=await snapshot(f.source);
140
+ await f.call('plugins','catalog-add','sample','--source',f.source,'--revision',old.revision);
141
+ await fs.writeFile(path.join(f.source,'SKILL.md'),'Reviewed updated instructions');
142
+ const inspected=JSON.parse((await f.call('plugins','inspect','sample')).stdout);
143
+ assert.equal(inspected.catalogMatches,false);assert.equal(inspected.catalogRevision,old.revision);
144
+ assert.equal(inspected.source,await fs.realpath(f.source));assert.notEqual(inspected.revision,old.revision);
145
+ await assert.rejects(f.call('plugins','install','sample'),/Inspection is read-only/);
146
+ assert.equal(JSON.parse((await f.call('plugins','available')).stdout).sample.revision,old.revision);
147
+ await f.call('plugins','catalog-add','sample','--source',inspected.source,'--revision',inspected.revision);
148
+ assert.equal(JSON.parse((await f.call('plugins','inspect','sample')).stdout).catalogMatches,true);
149
+ assert.equal(JSON.parse((await f.call('plugins','install','sample')).stdout).revision,inspected.revision);
150
+ });
151
+
152
+
153
+ test('plugin versions accept SemVer beta releases and reject malformed versions',async t=>{
154
+ const f=await fixture(t),p=await snapshot(f.source);
155
+ for(const version of ['0.1.0','0.1.0-beta.1','1.2.3-rc.0+build.12'])validate({...f.manifest,version},f.deployment,p.files);
156
+ for(const version of ['01.2.3','1.2','1.2.3-beta.01','1.2.3-','1.2.3+','1.2.3/beta',null,{}])assert.throws(()=>validate({...f.manifest,version},f.deployment,p.files),/manifest version/);
157
+ });
@@ -0,0 +1,21 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import test from 'node:test'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../scripts/assert-local-registry.mjs')
8
+
9
+ const runGuard = (env: NodeJS.ProcessEnv) =>
10
+ spawnSync(process.execPath, [script], { encoding: 'utf8', env: { ...process.env, ...env } })
11
+
12
+ test('blocks a public npm registry', () => {
13
+ const result = runGuard({ npm_config_registry: 'https://registry.npmjs.org/' })
14
+ assert.notEqual(result.status, 0)
15
+ assert.match(result.stderr, /Refusing to publish outside the local ez registry/)
16
+ })
17
+
18
+ test('allows the local verdaccio registry', () => {
19
+ const result = runGuard({ npm_config_registry: 'http://127.0.0.1:4873/' })
20
+ assert.equal(result.status, 0, result.stderr)
21
+ })
@@ -0,0 +1,122 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFile } from 'node:child_process'
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import test from 'node:test'
7
+ import { promisify } from 'node:util'
8
+ import { isSupportedReactionEmoji, normalizeReactionEmoji, TELEGRAM_REACTIONS } from '../src/reaction.js'
9
+ import { RunStore } from '../src/runs.js'
10
+
11
+ const execFileAsync = promisify(execFile)
12
+ const reactScriptPath = path.resolve('src/react.ts')
13
+
14
+ const fixture = async (run: (store: RunStore, dir: string) => Promise<void>): Promise<void> => {
15
+ const directory = await mkdtemp(path.join(tmpdir(), 'ez-reactions-'))
16
+ try {
17
+ await run(new RunStore(directory), directory)
18
+ } finally {
19
+ await rm(directory, { recursive: true, force: true })
20
+ }
21
+ }
22
+
23
+ test('normalizes standard Telegram reaction emojis', () => {
24
+ assert.equal(normalizeReactionEmoji('👍'), '👍')
25
+ assert.equal(normalizeReactionEmoji('🔥'), '🔥')
26
+ assert.equal(normalizeReactionEmoji('👀'), '👀')
27
+ assert.equal(normalizeReactionEmoji('🫡'), '🫡')
28
+ assert.equal(normalizeReactionEmoji('❤'), '❤')
29
+ assert.equal(isSupportedReactionEmoji('👍'), true)
30
+ assert.equal(isSupportedReactionEmoji('👀'), true)
31
+ })
32
+
33
+ test('normalizes emojis with variation selector FE0F', () => {
34
+ // Red heart with FE0F should normalize to standard ❤
35
+ assert.equal(normalizeReactionEmoji('❤️'), '❤')
36
+ // Lightning bolt with FE0F should normalize to ⚡
37
+ assert.equal(normalizeReactionEmoji('⚡️'), '⚡')
38
+ // Dove with FE0F should normalize to 🕊
39
+ assert.equal(normalizeReactionEmoji('🕊️'), '🕊')
40
+ // Shrugging man/woman with FE0F
41
+ assert.equal(normalizeReactionEmoji('🤷‍♂️'), '🤷‍♂')
42
+ assert.equal(normalizeReactionEmoji('🤷‍♀️'), '🤷‍♀')
43
+ // Heart on fire
44
+ assert.equal(normalizeReactionEmoji('❤\uFE0F\u200D\u{1F525}'), '❤‍🔥')
45
+ })
46
+
47
+ test('rejects invalid or unsupported reaction emojis', () => {
48
+ assert.equal(normalizeReactionEmoji('✅'), undefined)
49
+ assert.equal(normalizeReactionEmoji('⏳'), undefined)
50
+ assert.equal(normalizeReactionEmoji('❌'), undefined)
51
+ assert.equal(normalizeReactionEmoji('🚀'), undefined)
52
+ assert.equal(normalizeReactionEmoji(''), undefined)
53
+ assert.equal(normalizeReactionEmoji(' '), undefined)
54
+ assert.equal(normalizeReactionEmoji(undefined), undefined)
55
+ assert.equal(normalizeReactionEmoji(null), undefined)
56
+ assert.equal(isSupportedReactionEmoji('✅'), false)
57
+ assert.equal(isSupportedReactionEmoji('⏳'), false)
58
+ })
59
+
60
+ test('RunStore enqueues valid reaction and normalizes emoji', async () => fixture(async (store) => {
61
+ const run = await store.create({ chatId: 123, telegramUserId: 123, texts: ['hi'], messageId: 456 })
62
+ await store.patch(run.id, { status: 'running' })
63
+
64
+ const item = await store.enqueueReaction(run.id, '❤️')
65
+ assert.equal(item.type, 'reaction')
66
+ assert.equal(item.emoji, '❤') // Normalized without FE0F
67
+ assert.equal(item.messageId, 456)
68
+ assert.equal(item.chatId, 123)
69
+
70
+ const pending = await store.pendingOutbox()
71
+ assert.equal(pending.length, 1)
72
+ assert.equal(pending[0].emoji, '❤')
73
+ }))
74
+
75
+ test('RunStore rejects invalid reaction emoji with error', async () => fixture(async (store) => {
76
+ const run = await store.create({ chatId: 123, telegramUserId: 123, texts: ['hi'], messageId: 456 })
77
+ await store.patch(run.id, { status: 'running' })
78
+
79
+ await assert.rejects(
80
+ store.enqueueReaction(run.id, '✅'),
81
+ /Invalid Telegram reaction emoji "✅"/,
82
+ )
83
+ await assert.rejects(
84
+ store.enqueueReaction(run.id, '⏳'),
85
+ /Invalid Telegram reaction emoji "⏳"/,
86
+ )
87
+ }))
88
+
89
+ test('ezenciel-agents-react CLI enforces EZ_RUN_ID and valid emoji', async () => fixture(async (store, dir) => {
90
+ // Missing EZ_RUN_ID
91
+ await assert.rejects(
92
+ execFileAsync('pnpm', ['exec', 'tsx', reactScriptPath, '--emoji', '👍'], {
93
+ env: { ...process.env, EZ_RUN_ID: '', EZ_CONTROL_DIR: dir },
94
+ }),
95
+ (err: any) => {
96
+ assert.match(err.stderr, /EZ_RUN_ID is required/)
97
+ return true
98
+ },
99
+ )
100
+
101
+ // Invalid emoji
102
+ const run = await store.create({ chatId: 123, telegramUserId: 123, texts: ['hi'], messageId: 456 })
103
+ await store.patch(run.id, { status: 'running' })
104
+
105
+ await assert.rejects(
106
+ execFileAsync('pnpm', ['exec', 'tsx', reactScriptPath, '--emoji', '✅'], {
107
+ env: { ...process.env, EZ_RUN_ID: run.id, EZ_CONTROL_DIR: dir },
108
+ }),
109
+ (err: any) => {
110
+ assert.match(err.stderr, /Invalid Telegram reaction emoji "✅"/)
111
+ return true
112
+ },
113
+ )
114
+
115
+ // Valid emoji succeeds
116
+ const { stdout } = await execFileAsync('pnpm', ['exec', 'tsx', reactScriptPath, '--emoji', '👍'], {
117
+ env: { ...process.env, EZ_RUN_ID: run.id, EZ_CONTROL_DIR: dir },
118
+ })
119
+ const parsed = JSON.parse(stdout)
120
+ assert.equal(parsed.ok, true)
121
+ assert.equal(parsed.emoji, '👍')
122
+ }))