@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.
Files changed (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
@@ -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 workspace inbox cleanly', async () => {
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, /^inbox[/\\][a-f0-9-]+_sample\.pdf$/)
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()
@@ -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 survives restart, deduplicates IDs, seals membership and retains cancellation tombstones', async () => {
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
- let inbox = new InboxStore(dir)
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
- // Crash between run creation and inbox completion must not produce a second run.
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
- assert.equal((await stat(join(dir, 'inbox.json'))).mode & 0o777, 0o600)
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('corrupt intake fails closed without overwriting evidence; invalid identifiers are rejected', async () => {
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
- await writeFile(join(dir, 'inbox.json'), '{broken')
47
+ // A fresh directory starts empty: no durable journal survives a restart.
49
48
  const inbox = new InboxStore(dir)
50
- await assert.rejects(inbox.accept(update(4)), /Inbox unreadable/)
51
- assert.equal(await readFile(join(dir, 'inbox.json'), 'utf8'), '{broken')
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 soul = path.join(result.workspace, 'SOUL.md')
28
- await writeFile(soul, 'Customized purpose')
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.equal(await readFile(soul, 'utf8'), 'Customized purpose')
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.equal(JSON.parse(result.stdout).created.length, 3)
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
- for(const dir of ['host-executor','outbox','runs'])await fs.mkdir(path.join(control,dir),{recursive:true});
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 item={runId:'tg_12',chatId:123,receipt:{messageIds:[42],deliveredAt:new Date().toISOString()}};
30
- await write('outbox/test.sent.json',item);await write('runs/tg_12.json',{status:'completed',chatId:123,telegramUserId:999});
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
- await write('runs/tg_12.json',{status:'completed',chatId:123,telegramUserId:123});
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
- await write('heartbeat.json',{at:0,polling:true});assert.equal((await installationStatus(root)).stage,'runtime-offline');
35
- await write('outbox/test.sent.json',{...item,runId:'../../escape'});assert.equal((await installationStatus(root)).telegramReplyVerified,false);
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
- assert.equal((await control.status()).pending[0].title, 'Team')
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(callback(9, 'menu:scheduled-tasks'))
289
- assert.match(f.replies.at(-1)!, /No active scheduled tasks for this owner/)
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.match(f.launched[0][1], /Quoted context/)
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('older conversation names come from owner messages and detail keeps archive available when resume fails', async () => {
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('Client launch checklist ยท ')))
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)!, /Client launch checklist.*\n.*binding/)
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(/inbox\/[a-f0-9-]+_/,'inbox/ID_')
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
+ })