@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
@@ -1,16 +1,16 @@
1
1
  import assert from 'node:assert/strict'
2
- import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import path from 'node:path'
5
5
  import test from 'node:test'
6
6
  import { ControlStore } from '../src/control-state.js'
7
7
  import { parseOwnerArgs } from '../src/owner-args.js'
8
8
 
9
- const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void) => Promise<void>): Promise<void> => {
9
+ const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void, directory: string) => Promise<void>): Promise<void> => {
10
10
  const directory = await mkdtemp(path.join(tmpdir(), 'ez-control-test-'))
11
11
  let now = Date.parse('2026-01-01T00:00:00.000Z')
12
12
  try {
13
- await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds })
13
+ await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds }, directory)
14
14
  } finally {
15
15
  await rm(directory, { recursive: true, force: true })
16
16
  }
@@ -35,7 +35,9 @@ test('cannot approve a telegram id that did not request pairing', async () => fi
35
35
  await store.requestPairing(101, 101)
36
36
  await assert.rejects(store.approveOwner(202), /No active pairing request/)
37
37
  assert.equal((await store.status()).owner, null)
38
- assert.equal((await store.status()).pending[0]?.telegramUserId, 101)
38
+ const request = (await store.status()).pending[0]
39
+ if (!request || !('telegramUserId' in request)) throw new Error('Telegram pairing request missing')
40
+ assert.equal(request.telegramUserId, 101)
39
41
  }))
40
42
 
41
43
  test('expires pairing requests before an owner can approve them', async () => fixture(async (store, advance) => {
@@ -51,8 +53,85 @@ test('supports local owner recovery by revocation', async () => fixture(async (s
51
53
  assert.equal((await store.status()).owner, null)
52
54
  }))
53
55
 
56
+ test('application-owned Telegram link is hashed, one-time, and bound to the current owner', async () => fixture(async (store, advance, directory) => {
57
+ const owner = await store.registerOwner('account:one')
58
+ const bindingId = '11111111-1111-1111-1111-111111111111'
59
+ const pairing = await store.createApplicationTelegramPairing(bindingId, owner)
60
+ assert.ok(pairing)
61
+ assert.doesNotMatch(await readFile(path.join(directory, 'control-state.json'), 'utf8'), new RegExp(pairing.token))
62
+ const flipped = pairing.token.slice(0, -1) + (pairing.token.endsWith('A') ? 'B' : 'A')
63
+ assert.equal(await store.claimApplicationTelegramPairing(flipped, 101, 101, async () => true), null)
64
+ assert.equal(await store.claimApplicationTelegramPairing(pairing.token, 101, 101, async () => false), null)
65
+ assert.equal((await store.status()).owner?.telegramUserId, undefined)
66
+
67
+ const replacement = await store.createApplicationTelegramPairing(bindingId, owner)
68
+ assert.ok(replacement)
69
+ advance(60_001)
70
+ assert.equal(await store.claimApplicationTelegramPairing(replacement.token, 101, 101, async () => true), null)
71
+
72
+ const active = await store.createApplicationTelegramPairing(bindingId, owner)
73
+ assert.ok(active)
74
+ const linked = await store.claimApplicationTelegramPairing(active.token, 101, 101, async (requestedBinding, expectedOwner) =>
75
+ requestedBinding === bindingId && expectedOwner.id === owner.id)
76
+ assert.equal(linked?.id, owner.id)
77
+ assert.equal(linked?.telegramUserId, 101)
78
+ assert.equal(linked?.telegramChatId, 101)
79
+ assert.ok(linked?.telegramLinkedAt)
80
+ assert.equal(await store.claimApplicationTelegramPairing(active.token, 101, 101, async () => true), null)
81
+ assert.equal(await store.createApplicationTelegramPairing(bindingId, linked!), null)
82
+ await store.unlinkTelegram()
83
+ const relink = await store.createApplicationTelegramPairing(bindingId, (await store.status()).owner!)
84
+ assert.ok(relink)
85
+ assert.equal(await store.claimApplicationTelegramPairing(relink.token, 101, 101, async () => false), null)
86
+ }))
87
+
54
88
  test('owner CLI treats pnpm -- as a separator, not a command', () => {
55
89
  assert.deepEqual(parseOwnerArgs(['--', 'status']), { command: 'status', value: undefined })
56
90
  assert.deepEqual(parseOwnerArgs(['--', 'approve', '101']), { command: 'approve', value: '101' })
57
91
  assert.deepEqual(parseOwnerArgs(['revoke']), { command: 'revoke', value: undefined })
58
92
  })
93
+
94
+ test('provider catalog upgrades a persisted providerless Codex selection', async () => fixture(async (store) => {
95
+ const model = 'deepseek/deepseek-v4.1-flash'
96
+ await store.captureChoice({ id: 'deepseek', name: `${model} · max`, cli: 'codex', model, effort: 'max' })
97
+ const catalog = [{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['max'] }]
98
+
99
+ assert.equal(await store.normalizeProviderBindings(catalog), true)
100
+ const state = await store.status()
101
+ const preset = state.ai!.presets.find((item) => item.id === state.ai!.selectedId)!
102
+ assert.equal(preset.provider, 'openrouter')
103
+ assert.equal(preset.name, `${model} · max`)
104
+ assert.equal(state.activeSession?.preset?.provider, 'openrouter')
105
+ assert.equal(await store.normalizeProviderBindings(catalog), false)
106
+ }))
107
+
108
+ test('provider catalog leaves ambiguous and effort-mismatched selections untouched', async () => fixture(async (store) => {
109
+ const model = 'deepseek/deepseek-v4.1-flash'
110
+ await store.captureChoice({ id: 'deepseek', name: 'My deepseek', cli: 'codex', model, effort: 'max' })
111
+ const ambiguous = [
112
+ { cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['max'] },
113
+ { cli: 'codex', provider: 'direct', model, name: `Direct · ${model}`, efforts: ['max'] },
114
+ ]
115
+ assert.equal(await store.normalizeProviderBindings(ambiguous), false)
116
+ assert.equal((await store.status()).ai!.presets.find((item) => item.id === 'deepseek')!.provider, undefined)
117
+ const mismatch = [{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['low'] }]
118
+ assert.equal(await store.normalizeProviderBindings(mismatch), false)
119
+ assert.equal((await store.status()).ai!.presets.find((item) => item.id === 'deepseek')!.provider, undefined)
120
+ }))
121
+
122
+ test('steady-state owner checks and session pins write nothing to control/', async () => fixture(async (store, _advance, directory) => {
123
+ const { stat } = await import('node:fs/promises')
124
+ const { initialPreset } = await import('../src/ai.js')
125
+ await store.requestPairing(101, 101)
126
+ await store.approveOwner(101)
127
+ await store.aiState(initialPreset('codex'))
128
+ // First pin may persist the session binding; everything after must be quiet.
129
+ await store.captureChoice(initialPreset('codex'), 'hello')
130
+ const before = await stat(path.join(directory, 'control-state.json'))
131
+ await store.status()
132
+ await store.captureChoice(initialPreset('codex'), 'hello again')
133
+ await store.status()
134
+ const after = await stat(path.join(directory, 'control-state.json'))
135
+ assert.equal(after.mtimeMs, before.mtimeMs)
136
+ assert.equal(after.size, before.size)
137
+ }))
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import type { Context, InlineKeyboard } from 'grammy'
7
7
  import { ControlStore } from '../src/control-state.js'
8
- import { RunStore } from '../src/runs.js'
9
8
  import { createConversationMenu } from '../src/conversation-menu.js'
10
9
  import { initialPreset } from '../src/ai.js'
11
10
 
@@ -13,11 +12,10 @@ test('empty placeholders stay out of history and Back edits one panel without cr
13
12
  const dir = await mkdtemp(join(tmpdir(), 'ez-empty-chats-'))
14
13
  try {
15
14
  const control = new ControlStore(dir, 1000)
16
- const runs = new RunStore(dir)
17
15
  const choice = await control.captureChoice(initialPreset('grok'), 'Client launch')
18
16
  await control.markSessionStarted(choice.sessionId)
19
17
  for (let n = 0; n < 3; n++) await control.resetSession()
20
- const menu = createConversationMenu(control, runs)
18
+ const menu = createConversationMenu(control)
21
19
  let sent = 0, edited = 0, text = '', keyboard: InlineKeyboard
22
20
  const ctx = {
23
21
  callbackQuery: undefined,
@@ -55,7 +53,7 @@ test('an identical Back edit does not send another message', async () => {
55
53
  const dir = await mkdtemp(join(tmpdir(), 'ez-same-menu-'))
56
54
  try {
57
55
  let sent = 0
58
- const menu = createConversationMenu(new ControlStore(dir,1000),new RunStore(dir))
56
+ const menu = createConversationMenu(new ControlStore(dir,1000))
59
57
  await menu.handle({
60
58
  callbackQuery:{data:'chat:list:0:0',message:{message_id:1}},
61
59
  answerCallbackQuery:async()=>{},
@@ -0,0 +1,72 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, unlink } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { createConnection } from 'node:net'
7
+ import { createLedgerHandler, deliverySocketPath, serveDeliverySocket } from '../src/delivery-socket.js'
8
+ import { callDeliverySocket } from '../src/delivery-socket-client.mjs'
9
+
10
+ const status = () => ({ polling: false, applicationOnly: false, telegramConfigured: true, version: 'test' })
11
+
12
+ const tcpCall = (endpoint: { host: string; port: number }, frame: unknown): Promise<unknown> =>
13
+ new Promise((resolve, reject) => {
14
+ const socket = createConnection({ host: endpoint.host, port: endpoint.port })
15
+ let buffer = ''
16
+ socket.setEncoding('utf8')
17
+ socket.once('connect', () => socket.write(`${JSON.stringify(frame)}\n`))
18
+ socket.on('data', chunk => {
19
+ buffer += chunk
20
+ const newline = buffer.indexOf('\n')
21
+ if (newline < 0) return
22
+ socket.destroy()
23
+ resolve(JSON.parse(buffer.slice(0, newline)))
24
+ })
25
+ socket.once('close', () => resolve(undefined))
26
+ socket.once('error', reject)
27
+ })
28
+
29
+ test('host processes reach the relay ledger through the loopback endpoint when the Unix socket is unreachable', async t => {
30
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-tcp-'))
31
+ t.after(() => rm(dir, { recursive: true, force: true }))
32
+ const server = await serveDeliverySocket(dir, createLedgerHandler(dir, { wake: () => {}, status }), { tcpPort: 0 })
33
+ t.after(() => server.stop())
34
+ const socketPath = deliverySocketPath(dir)
35
+ assert.equal(server.endpoint?.host, '127.0.0.1')
36
+ assert.ok(server.endpoint && server.endpoint.port > 0)
37
+ const endpoint = server.endpoint!
38
+ // Container-local producers keep using the Unix socket.
39
+ assert.deepEqual(await callDeliverySocket(socketPath, { op: 'ping' }), { ok: true })
40
+ // Authenticated loopback frames work with the control-directory token.
41
+ assert.deepEqual(await tcpCall(endpoint, { id: '1', op: 'ping', token: endpoint.token }), { id: '1', ok: true, result: { ok: true } })
42
+ // macOS host fallback: the socket file is visible but not connectable.
43
+ await unlink(socketPath)
44
+ assert.deepEqual(await callDeliverySocket(socketPath, { op: 'ping' }), { ok: true })
45
+ })
46
+
47
+ test('loopback frames without the control-directory token are refused', async t => {
48
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-token-'))
49
+ t.after(() => rm(dir, { recursive: true, force: true }))
50
+ const server = await serveDeliverySocket(dir, createLedgerHandler(dir, { wake: () => {}, status }), { tcpPort: 0 })
51
+ t.after(() => server.stop())
52
+ const endpoint = server.endpoint!
53
+ assert.equal(await tcpCall(endpoint, { id: '1', op: 'ping' }), undefined)
54
+ assert.equal(await tcpCall(endpoint, { id: '1', op: 'ping', token: '0'.repeat(64) }), undefined)
55
+ })
56
+
57
+ test('the client fails closed when neither the socket nor an endpoint exists', async t => {
58
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-missing-'))
59
+ t.after(() => rm(dir, { recursive: true, force: true }))
60
+ await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'ping' }, 500), /Delivery relay unavailable/)
61
+ })
62
+
63
+ test('a response timeout is not replayed over the loopback endpoint', async t => {
64
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-timeout-'))
65
+ t.after(() => rm(dir, { recursive: true, force: true }))
66
+ let calls = 0
67
+ const server = await serveDeliverySocket(dir, async () => { calls++; return new Promise(() => {}) }, { tcpPort: 0 })
68
+ t.after(() => server.stop())
69
+ assert.ok(server.endpoint)
70
+ await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'enqueue' }, 200), /Delivery relay unavailable/)
71
+ assert.equal(calls, 1)
72
+ })
@@ -1,23 +1,36 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
- import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { mkdtemp, rm, stat } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import { createRequire } from 'node:module'
6
9
  import { RunStore } from '../src/runs.js'
7
- import { recoverInterruptedRuns } from '../docker/recovery.js'
8
10
 
9
- test('exclusive Docker restart fails interrupted runs even when a container PID has been reused, without replaying queued work', async () => {
11
+ const exec = promisify(execFile)
12
+ const tsx = createRequire(import.meta.url).resolve('tsx')
13
+
14
+ test('a relay restart starts from an empty memory ledger and leaves no run files behind', async () => {
10
15
  const dir = await mkdtemp(join(tmpdir(), 'ez-container-recovery-'))
11
16
  try {
12
- const store = new RunStore(dir)
13
- const prior = await store.create({ chatId: 1, telegramUserId: 1, texts: ['old'] })
14
- const queued = await store.create({ chatId: 1, telegramUserId: 1, texts: ['queued'] })
15
- await store.patch(prior.id, { status: 'running', pid: process.pid })
16
- await recoverInterruptedRuns(dir)
17
- assert.equal((await store.get(prior.id))?.status, 'failed')
18
- assert.equal((await store.get(queued.id))?.status, 'queued')
19
- const ended = (await store.get(prior.id))?.endedAt
20
- await recoverInterruptedRuns(dir)
21
- assert.equal((await store.get(prior.id))?.endedAt, ended)
22
- } finally { await rm(dir, {recursive:true, force:true}) }
17
+ // A previous relay process owned runs in its own memory. Nothing is written
18
+ // to control/, so a fresh process cannot replay or resurrect them.
19
+ const script = `
20
+ import { RunStore } from ${JSON.stringify(new URL('../src/runs.ts', import.meta.url).href)};
21
+ const store = new RunStore(process.argv[1]);
22
+ const run = await store.create({ chatId: 1, telegramUserId: 1, texts: ['old'] });
23
+ await store.patch(run.id, { status: 'running', pid: process.pid });
24
+ const queued = await store.create({ chatId: 1, telegramUserId: 1, texts: ['queued'] });
25
+ console.log(JSON.stringify({ count: (await store.list()).length }));
26
+ `
27
+ const { stdout } = await exec(process.execPath, ['--import', tsx, '--input-type=module', '-e', script, dir])
28
+ assert.equal(JSON.parse(stdout.trim().split('\n').at(-1)!).count, 2)
29
+
30
+ const fresh = new RunStore(dir)
31
+ assert.equal((await fresh.list()).length, 0, 'restart drops in-flight runs by design')
32
+ await assert.rejects(stat(join(dir, 'runs')), { code: 'ENOENT' })
33
+ await assert.rejects(stat(join(dir, 'outbox')), { code: 'ENOENT' })
34
+ await assert.rejects(stat(join(dir, 'inbox.json')), { code: 'ENOENT' })
35
+ } finally { await rm(dir, { recursive: true, force: true }) }
23
36
  })
@@ -4,6 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { startExecutorJob } from '../src/executor.js'
7
+ import { runPromptSuffix } from '../src/prompt-suffix.js'
7
8
  import { ownerRun } from './helpers/owner-run.js'
8
9
  import { initializeWorkspace } from '../src/workspace.js'
9
10
  import { RunStore } from '../src/runs.js'
@@ -13,7 +14,7 @@ test('owner, resumed and native scheduled subprocesses receive literal input and
13
14
  const root=await mkdtemp(path.join(tmpdir(),'ez-literal-'))
14
15
  t.after(()=>rm(root,{recursive:true,force:true}))
15
16
  const bin=path.join(root,'bin'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
16
- await mkdir(bin);await initializeWorkspace(workspace)
17
+ await mkdir(bin);const purpose=path.join(root,'purpose.md');await writeFile(purpose,'Fixture agent\n');await initializeWorkspace(workspace,purpose)
17
18
  const fixture=`#!${process.execPath}
18
19
  const fs=require('fs'), args=process.argv.slice(2);
19
20
  const capture=prompt=>fs.writeFileSync('capture.json',JSON.stringify({prompt,args,env:{run:process.env.EZ_RUN_ID,control:process.env.EZ_CONTROL_DIR,repair:process.env.EZ_REPAIR_ENABLED,secret:process.env.TELEGRAM_BOT_TOKEN}}));
@@ -44,7 +45,10 @@ if(args[0]==='app-server'){
44
45
  const captured=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
45
46
  assert.equal(captured.prompt,text)
46
47
  assert.deepEqual(captured.env,{run:runId,control:controlDir,repair:'false'})
47
- if(cli==='codex')assert.equal(captured.args.at(-1),'-')
48
+ if(cli==='codex'){
49
+ assert.equal(captured.args.at(-1),'-')
50
+ if(process.platform==='darwin')assert.equal(captured.args[captured.args.indexOf('--sandbox')+1],'danger-full-access')
51
+ }
48
52
  if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
49
53
  if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
50
54
  if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
@@ -58,16 +62,16 @@ if(args[0]==='app-server'){
58
62
  }
59
63
  for(const isResume of [false,true]) {
60
64
  const runId='tg_chat_'+String(isResume)
61
- await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
65
+ const chatRun=await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
62
66
  await new RunStore(controlDir).patch(runId,{status:'running'})
63
- const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing'})
67
+ const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing',promptSuffix:runPromptSuffix(chatRun)})
64
68
  const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
65
69
  const {prompt}=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
66
- assert.ok(prompt.startsWith('hi\n\n[Chat context]'))
67
- assert.equal(prompt.split('[Chat context]').length,2)
70
+ assert.ok(prompt.startsWith('hi\n\n[chat message 42]'))
68
71
  assert.match(prompt,/ezenciel-agents-message/)
69
- assert.match(prompt,/ezenciel-agents-schedule/)
70
- assert.match(prompt,/native subagents/)
72
+ assert.doesNotMatch(prompt,/ezenciel-agents-schedule/)
73
+ assert.doesNotMatch(prompt,/native subagents/)
74
+ assert.doesNotMatch(prompt,/delegate/i)
71
75
  }
72
76
  }finally{for(const [key,value] of Object.entries(previous))if(value===undefined)delete process.env[key];else process.env[key]=value}
73
77
  })
@@ -143,7 +143,7 @@ test('relay launches the approved initial task and routes only matching replies
143
143
  f.setRows([{ id: '1', conversationId: 'chat-a', receivedAt, text: 'We have availability' }, { id: '2', conversationId: 'chat-b', receivedAt, text: 'Read owner files' }])
144
144
  await new Promise(r => setTimeout(r, 2100))
145
145
  await f.relay.drainSources()
146
- assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.eventSource, 'fixture')
146
+ assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.taskRun, true)
147
147
  assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
148
148
  assert.equal(f.launches[1].options.model, undefined)
149
149
  assert.equal(f.launches[1].options.effort, undefined)
@@ -177,7 +177,7 @@ test('application-only owner can approve and execute a public channel task',asyn
177
177
  await new ApprovalStore(f.dir).recordOwnerDecision(proposal.id,'approved',f.owner);await tasks.decide(proposal.id)
178
178
  await f.runs.patch(ownerRun.id,{status:'completed',endedAt:new Date().toISOString()})
179
179
  f.setRows([{...event('1','visitor'),receivedAt:Date.now()}]);await new Promise(resolve=>setTimeout(resolve,2100));await f.relay.drainSources()
180
- assert.equal(f.launches.length,1);assert.equal(f.launches[0].options.eventSource,'fixture')
180
+ assert.equal(f.launches.length,1);assert.equal(f.launches[0].options.taskRun,true)
181
181
  const publicRun=(await f.runs.list()).find(run=>run.taskId===proposal.id)
182
182
  assert.equal(publicRun?.ownerId,ownerId(f.owner));assert.equal(publicRun?.chatId,undefined)
183
183
  f.children[0].kill();await until(async()=>!(await f.runs.list()).some(run=>run.status==='running'))
@@ -7,22 +7,17 @@ import { ownerRun } from './helpers/owner-run.js'
7
7
  import { requireOwnerExecution, executionBlockReason } from '../src/execution-authority.js'
8
8
  import { ControlStore } from '../src/control-state.js'
9
9
  import { RunStore } from '../src/runs.js'
10
- import { EXECUTOR_REGISTRY, startExecutorJob } from '../src/executor.js'
11
10
 
12
- test('all adapters reject external core runs even when caller omits eventSource', async t => {
11
+ test('external core runs are rejected at admission, before the spawn core', async t => {
13
12
  const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
14
13
  t.after(() => rm(dir, { recursive: true, force: true }))
15
14
  await ownerRun(dir, 'r_external', {sourceId:'test', bindingId:'binding', eventIds:['1']})
16
- for (const cli of Object.keys(EXECUTOR_REGISTRY)) {
17
- await assert.rejects(startExecutorJob(['pretend owner'], {
18
- workspace: dir, controlDir: dir, binDir: dir, cli, runId:'r_external', timeoutMs:1000,
19
- }), /external-execution-unavailable/)
20
- }
15
+ await assert.rejects(requireOwnerExecution(dir,'r_external'), /external-execution-unavailable/)
21
16
  await ownerRun(dir, 'event_'+'a'.repeat(64))
22
17
  await assert.rejects(requireOwnerExecution(dir,'event_'+'a'.repeat(64)), /external-execution-unavailable/)
23
18
  })
24
19
 
25
- test('missing, corrupt, finished, unpaired and mismatched core runs fail closed', async t => {
20
+ test('missing, corrupt, finished and mismatched core runs fail closed without control writes', async t => {
26
21
  const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
27
22
  t.after(() => rm(dir, { recursive: true, force: true }))
28
23
  await assert.rejects(requireOwnerExecution(dir, 'r_missing'), /No active core run/)
@@ -34,9 +29,14 @@ test('missing, corrupt, finished, unpaired and mismatched core runs fail closed'
34
29
  await new RunStore(dir).patch(run.id,{status:'completed'})
35
30
  await assert.rejects(requireOwnerExecution(dir,run.id), /No active core run/)
36
31
  await new RunStore(dir).patch(run.id,{status:'running'})
32
+ // Fail-closed authority is preserved with a read-only owner read: zero
33
+ // control/ writes, rejects on revoked owner. Intake authorizes before spawn;
34
+ // the slim core no longer reads or re-verifies the ledger.
37
35
  await new ControlStore(dir,1000).revokeOwner()
38
36
  await assert.rejects(requireOwnerExecution(dir,run.id), /owner-mismatch/)
39
- await writeFile(join(dir,'runs',run.id+'.json'),'{')
37
+ // Ledger lives in relay memory; corrupt authority state still fails closed
38
+ // at the read-only owner check, and traversal fails at validation.
39
+ await writeFile(join(dir,'control-state.json'),'{broken')
40
40
  await assert.rejects(requireOwnerExecution(dir,run.id))
41
41
  await assert.rejects(requireOwnerExecution(dir,'../r_owner'))
42
42
  })
@@ -6,7 +6,8 @@ import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { tmpdir } from 'node:os'
8
8
  import path from 'node:path'
9
- import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
9
+ import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
10
+ import { requireOwnerExecution } from '../src/execution-authority.js'
10
11
  import { splitTelegramText } from '../src/reply.js'
11
12
  import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
12
13
 
@@ -54,11 +55,15 @@ test('Telegram replies are split within the configured message limit', () => {
54
55
  assert.ok(chunks.every((chunk) => chunk.length <= 10))
55
56
  })
56
57
 
58
+ test('native CLIs resolve from the host PATH, not a relative package name', () => {
59
+ assert.equal(resolveHostCommand('/usr/bin/codex'), '/usr/bin/codex')
60
+ assert.throws(() => resolveHostCommand('missing-cli-xyz', '/usr/bin'), /not executable on the host PATH/)
61
+ })
62
+
57
63
  test('the executor receives a deliberately small environment', () => {
58
- const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', PAGERDUTY_ROUTING_KEY: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
64
+ const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
59
65
  assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
60
66
  assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
61
- assert.ok(!('PAGERDUTY_ROUTING_KEY' in environment))
62
67
  })
63
68
 
64
69
  test('the Grok job env binds the run and still strips the bot token', () => {
@@ -68,6 +73,7 @@ test('the Grok job env binds the run and still strips the bot token', () => {
68
73
  )
69
74
  assert.equal(environment.EZ_RUN_ID, 'r_1')
70
75
  assert.equal(environment.EZ_CONTROL_DIR, '/tmp/control')
76
+ assert.equal(environment.EZ_DELIVERY_SOCKET, '/tmp/control/delivery.sock')
71
77
  assert.match(environment.PATH ?? '', /^\/tmp\/bin/)
72
78
  assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
73
79
  })
@@ -158,6 +164,38 @@ test('Codex jobs disable global memory and host skill discovery', () => {
158
164
  assert.equal(args[args.indexOf('skip_host_skill_discovery')-1],'--enable')
159
165
  })
160
166
 
167
+ test('Codex custom providers use declared runtime state and an environment key reference', () => {
168
+ const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1/',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
169
+ const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',model:'google/gemini-3.8-flash',codexProvider:provider},'','hello')
170
+ assert.ok(args.includes('model_provider="openrouter"'))
171
+ assert.ok(args.some(value=>value.includes('env_key="OPENROUTER_API_KEY"')))
172
+ assert.ok(args.includes('google/gemini-3.8-flash'))
173
+ assert.equal(JSON.stringify(args).includes('provider-secret'),false)
174
+ assert.throws(()=>validateCodexProvider({...provider,envKey:'TELEGRAM-BOT'}),/environment key/)
175
+ for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
176
+ assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
177
+ assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
178
+ })
179
+
180
+ test('a configured Codex provider receives only its declared key and runtime selection', async t => {
181
+ const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
182
+ const bin=path.join(root,'bin');await mkdir(bin)
183
+ await writeFile(path.join(bin,'codex'),`#!${process.execPath}\nconst fs=require('fs');let text='';process.stdin.on('data',b=>text+=b);process.stdin.on('end',()=>fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN,text})));`,{mode:0o755})
184
+ const prior={path:process.env.PATH,key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN}
185
+ const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
186
+ await ownerRun(root,'r_provider')
187
+ try {
188
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.OPENROUTER_API_KEY='provider-secret';process.env.TELEGRAM_BOT_TOKEN='relay-secret'
189
+ const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId:'r_provider',timeoutMs:0,cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',codexProvider:provider})
190
+ assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
191
+ const observed=JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
192
+ assert.equal(observed.key,'provider-secret');assert.equal(observed.telegram,undefined);assert.equal(observed.text,'hello')
193
+ assert.ok(observed.args.includes('model_provider="openrouter"'));assert.ok(observed.args.includes('google/gemini-3.8-flash'))
194
+ } finally {
195
+ for(const [key,value] of [['PATH',prior.path],['OPENROUTER_API_KEY',prior.key],['TELEGRAM_BOT_TOKEN',prior.telegram]]) if(value===undefined)delete process.env[key!];else process.env[key!]=value
196
+ }
197
+ })
198
+
161
199
  test('Codex plugin access stays scoped to the explicitly bound registry', () => {
162
200
  const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind',controlDir:'/agent/control',toolsHome:'/agent/tools',sharedWorkspace:'/app',additionalWorkspaces:['/agent/mind/work']},'', 'install a plugin')
163
201
  assert.deepEqual(args.flatMap((arg,i)=>arg==='--add-dir'?[args[i+1]]:[]),['/agent/control','/app','/agent/mind/work','/agent/tools'])
@@ -193,21 +231,19 @@ test('external Codex isolation cannot launch host runs', async t => {
193
231
  const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
194
232
  t.after(() => rm(root,{recursive:true,force:true}))
195
233
  await ownerRun(root,'r_sandbox')
196
- const previous = {transport:process.env.EZ_EXECUTOR_TRANSPORT,telegram:process.env.EZ_TELEGRAM_ENABLED}
234
+ const previous = process.env.EZ_EXECUTOR_TRANSPORT
197
235
  try {
198
- process.env.EZ_TELEGRAM_ENABLED='false'
199
236
  for (const transport of ['host','']) {
200
237
  process.env.EZ_EXECUTOR_TRANSPORT=transport
201
238
  await assert.rejects(startExecutorJob(['Hello'],{workspace:root,controlDir:root,binDir:root,runId:'r_sandbox',timeoutMs:0,cli:'codex',codexSandbox:'external'}), /owner-authorized native local/)
202
239
  }
203
240
  } finally {
204
- for (const [key,value] of [['EZ_EXECUTOR_TRANSPORT',previous.transport],['EZ_TELEGRAM_ENABLED',previous.telegram]]) {
205
- if (value === undefined) delete process.env[key!]; else process.env[key!]=value
206
- }
241
+ if (previous === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
242
+ else process.env.EZ_EXECUTOR_TRANSPORT=previous
207
243
  }
208
244
  })
209
245
 
210
- test('external local owner chat preserves authorization and literal input', async t => {
246
+ test('external local owner chat preserves literal input and admission rejects foreign or restricted runs', async t => {
211
247
  const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
212
248
  t.after(()=>rm(root,{recursive:true,force:true}))
213
249
  const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
@@ -227,10 +263,10 @@ test('external local owner chat preserves authorization and literal input', asyn
227
263
  const runs=new RunStore(root)
228
264
  await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
229
265
  await runs.patch('r_foreign',{status:'running'})
230
- await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_foreign'}),/blocked|owner/i)
266
+ await assert.rejects(requireOwnerExecution(root,'r_foreign'),/blocked: owner-mismatch/i)
231
267
  await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
232
268
  await runs.patch('r_restricted',{status:'running'})
233
- await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_restricted'}),/owner-authorized native local/)
269
+ await assert.rejects(requireOwnerExecution(root,'r_restricted'),/external-execution-unavailable/)
234
270
  } finally {
235
271
  if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
236
272
  if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
3
+ import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { execFile, spawn } from 'node:child_process'
@@ -8,19 +8,22 @@ import { promisify } from 'node:util'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { once } from 'node:events'
10
10
  import { redactFailure, failureStamp, needsFailureReview } from '../src/failure.js'
11
- import { RunStore } from '../src/runs.js'
11
+ import { RunStore, sentOutbox } from '../src/runs.js'
12
12
  import { ControlStore } from '../src/control-state.js'
13
13
  import { Scheduler } from '../src/scheduler.js'
14
14
  import { initialPreset } from '../src/ai.js'
15
15
  import { createRelay } from '../src/index.js'
16
+ import { serveTestLedger } from './helpers/ledger.js'
16
17
  import { TelegramSource } from '../src/telegram-source.js'
17
18
  import { packageVersion } from '../src/version.js'
19
+ import { initializeWorkspace } from '../src/workspace.js'
18
20
  import type { Update } from 'grammy/types'
19
21
  const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
20
22
  // This integration test starts subprocesses, persists their receipts, and invokes
21
23
  // the schedule CLI. Give a busy CI worker time to settle without changing the
22
24
  // production recovery deadline.
23
25
  const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
26
+ const seedWorkspace=async(dir:string)=>{const purpose=join(dir,'purpose.md');await writeFile(purpose,'Fixture relay agent\n');await initializeWorkspace(dir,purpose)}
24
27
 
25
28
  test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
26
29
  const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
@@ -31,6 +34,8 @@ test('failure evidence is bounded and redacts configured credentials, headers, t
31
34
 
32
35
  test('failure review CLI is owner-bound, rejects stale reviews, and keeps failure evidence immutable',async t=>{
33
36
  const dir=await mkdtemp(join(tmpdir(),'ez-failure-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
37
+ // The schedule CLI child reaches the relay memory ledger through the socket.
38
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
34
39
  const runs=new RunStore(dir),control=new ControlStore(dir,1000)
35
40
  const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}
36
41
  const cli=(args:string[],overrides={})=>exec(process.execPath,[bin,...args],{env:{...env,...overrides}})
@@ -59,7 +64,7 @@ test('failure review CLI is owner-bound, rejects stale reviews, and keeps failur
59
64
  await runs.patch(id,{status:'running',...('replyOnly' in extra?{replyOnly:true}:{})})
60
65
  await assert.rejects(cli(review,{EZ_RUN_ID:id}),/owner-authorized/)
61
66
  }
62
- await writeFile(join(dir,'runs','r_bad.json'),'{broken')
67
+ await assert.rejects(stat(join(dir,'runs')),{code:'ENOENT'})
63
68
  assert.equal(JSON.parse((await cli(['failures'])).stdout).total,1)
64
69
  })
65
70
 
@@ -84,6 +89,8 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
84
89
  })
85
90
  relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
86
91
  relay.bot.api.config.use(async(_p,method,payload)=>{if(method==='sendMessage')replies.push((payload as any).text);return {ok:true,result:{message_id:replies.length}} as any})
92
+ // The schedule CLI children reach the relay memory ledger through the socket.
93
+ const ledger=await serveTestLedger(dir)
87
94
  const originalPatch=RunStore.prototype.patch
88
95
  t.mock.method(RunStore.prototype,'patch',async function(this:RunStore,...args:Parameters<RunStore['patch']>){
89
96
  // Hold the PID write until the fast child has closed, reproducing slow disk
@@ -112,7 +119,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
112
119
  await relay.bot.handleUpdate({...update,update_id:12,message:{...update.message!,message_id:12,text:'/stop'}})
113
120
  await until(async()=>(await runs.get('tg_11'))?.status==='cancelled')
114
121
  assert.equal(needsFailureReview((await runs.get('tg_11'))!),false)
115
- }finally{await relay.stop();for(const child of children)child.kill();await rm(dir,{recursive:true,force:true})}
122
+ }finally{await relay.stop();for(const child of children)child.kill();await ledger.stop();await rm(dir,{recursive:true,force:true})}
116
123
  })
117
124
 
118
125
 
@@ -142,6 +149,7 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
142
149
 
143
150
  test('a fresh relay initializes its bot identity before polling', async () => {
144
151
  const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
152
+ await seedWorkspace(dir)
145
153
  const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
146
154
  const methods:string[]=[]
147
155
  relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
@@ -165,6 +173,7 @@ test('a fresh relay initializes its bot identity before polling', async () => {
165
173
 
166
174
  for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
167
175
  const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
176
+ await seedWorkspace(dir)
168
177
  let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
169
178
  const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
170
179
  const deliveryGate=new Promise<void>(resolve=>{releaseDelivery=resolve}),deliveryStarted=new Promise<void>(resolve=>{sending=resolve})
@@ -216,7 +225,7 @@ for (const cleanupFails of [false,true]) test(`polling conflict preserves work u
216
225
  await stopped
217
226
  assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
218
227
  assert.equal(sourceStops,1)
219
- assert.deepEqual(JSON.parse(await readFile(join(dir,'outbox',`${item.id}.sent.json`),'utf8')).receipt.messageIds,[1])
228
+ assert.deepEqual((sentOutbox(dir).find(entry=>entry.id===item.id)?.receipt as {messageIds:number[]}).messageIds,[1])
220
229
  assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
221
230
  assert.equal(relay.bot.isRunning(),false)
222
231
  assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
@@ -264,6 +273,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
264
273
 
265
274
  test('group members can inspect failures and wake review without exposing other chats', async t => {
266
275
  const dir=await mkdtemp(join(tmpdir(),'ez-group-failure-'));t.after(()=>rm(dir,{recursive:true,force:true}))
276
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
267
277
  const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
268
278
  await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
269
279
  const execution=await control.captureChoice(initialPreset('grok'))
@@ -285,6 +295,7 @@ test('group members can inspect failures and wake review without exposing other
285
295
 
286
296
  for (const failure of [400,401,503,'programming'] as const) test(`setup failure ${failure} retries only transient errors`,async t=>{
287
297
  const dir=await mkdtemp(join(tmpdir(),'ez-setup-failure-'))
298
+ await seedWorkspace(dir)
288
299
  const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
289
300
  let commands=0,polls=0
290
301
  relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
@@ -310,6 +321,7 @@ for (const failure of [400,401,503,'programming'] as const) test(`setup failure
310
321
 
311
322
  test('shutdown aborts a pending bot initialization without starting polling',async()=>{
312
323
  const dir=await mkdtemp(join(tmpdir(),'ez-init-stop-'))
324
+ await seedWorkspace(dir)
313
325
  const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
314
326
  let initSignal:Parameters<typeof relay.bot.init>[0],polls=0
315
327
  relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
@@ -328,6 +340,7 @@ test('shutdown aborts a pending bot initialization without starting polling',asy
328
340
 
329
341
  test('failed reviewer readback exposes its stop through show, list and resume until explicit edit',async t=>{
330
342
  const dir=await mkdtemp(join(tmpdir(),'ez-review-stop-'));t.after(()=>rm(dir,{recursive:true,force:true}))
343
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
331
344
  const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
332
345
  await control.requestPairing(101,101);const owner=await control.approveOwner(101)
333
346
  const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000