@jc_stack/ez-agents 0.1.0-beta.34 → 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 (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
@@ -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'
@@ -61,16 +62,16 @@ if(args[0]==='app-server'){
61
62
  }
62
63
  for(const isResume of [false,true]) {
63
64
  const runId='tg_chat_'+String(isResume)
64
- 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})
65
66
  await new RunStore(controlDir).patch(runId,{status:'running'})
66
- 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)})
67
68
  const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
68
69
  const {prompt}=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
69
- assert.ok(prompt.startsWith('hi\n\n[Chat context]'))
70
- assert.equal(prompt.split('[Chat context]').length,2)
70
+ assert.ok(prompt.startsWith('hi\n\n[chat message 42]'))
71
71
  assert.match(prompt,/ezenciel-agents-message/)
72
- assert.match(prompt,/ezenciel-agents-schedule/)
73
- assert.match(prompt,/native subagents/)
72
+ assert.doesNotMatch(prompt,/ezenciel-agents-schedule/)
73
+ assert.doesNotMatch(prompt,/native subagents/)
74
+ assert.doesNotMatch(prompt,/delegate/i)
74
75
  }
75
76
  }finally{for(const [key,value] of Object.entries(previous))if(value===undefined)delete process.env[key];else process.env[key]=value}
76
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
  })
@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process'
7
7
  import { tmpdir } from 'node:os'
8
8
  import path from 'node:path'
9
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
 
@@ -60,10 +61,9 @@ test('native CLIs resolve from the host PATH, not a relative package name', () =
60
61
  })
61
62
 
62
63
  test('the executor receives a deliberately small environment', () => {
63
- 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' })
64
65
  assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
65
66
  assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
66
- assert.ok(!('PAGERDUTY_ROUTING_KEY' in environment))
67
67
  })
68
68
 
69
69
  test('the Grok job env binds the run and still strips the bot token', () => {
@@ -73,6 +73,7 @@ test('the Grok job env binds the run and still strips the bot token', () => {
73
73
  )
74
74
  assert.equal(environment.EZ_RUN_ID, 'r_1')
75
75
  assert.equal(environment.EZ_CONTROL_DIR, '/tmp/control')
76
+ assert.equal(environment.EZ_DELIVERY_SOCKET, '/tmp/control/delivery.sock')
76
77
  assert.match(environment.PATH ?? '', /^\/tmp\/bin/)
77
78
  assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
78
79
  })
@@ -171,7 +172,7 @@ test('Codex custom providers use declared runtime state and an environment key r
171
172
  assert.ok(args.includes('google/gemini-3.8-flash'))
172
173
  assert.equal(JSON.stringify(args).includes('provider-secret'),false)
173
174
  assert.throws(()=>validateCodexProvider({...provider,envKey:'TELEGRAM-BOT'}),/environment key/)
174
- for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN','PAGERDUTY_ROUTING_KEY'])
175
+ for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
175
176
  assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
176
177
  assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
177
178
  })
@@ -230,21 +231,19 @@ test('external Codex isolation cannot launch host runs', async t => {
230
231
  const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
231
232
  t.after(() => rm(root,{recursive:true,force:true}))
232
233
  await ownerRun(root,'r_sandbox')
233
- const previous = {transport:process.env.EZ_EXECUTOR_TRANSPORT,telegram:process.env.EZ_TELEGRAM_ENABLED}
234
+ const previous = process.env.EZ_EXECUTOR_TRANSPORT
234
235
  try {
235
- process.env.EZ_TELEGRAM_ENABLED='false'
236
236
  for (const transport of ['host','']) {
237
237
  process.env.EZ_EXECUTOR_TRANSPORT=transport
238
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/)
239
239
  }
240
240
  } finally {
241
- for (const [key,value] of [['EZ_EXECUTOR_TRANSPORT',previous.transport],['EZ_TELEGRAM_ENABLED',previous.telegram]]) {
242
- if (value === undefined) delete process.env[key!]; else process.env[key!]=value
243
- }
241
+ if (previous === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
242
+ else process.env.EZ_EXECUTOR_TRANSPORT=previous
244
243
  }
245
244
  })
246
245
 
247
- 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 => {
248
247
  const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
249
248
  t.after(()=>rm(root,{recursive:true,force:true}))
250
249
  const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
@@ -264,10 +263,10 @@ test('external local owner chat preserves authorization and literal input', asyn
264
263
  const runs=new RunStore(root)
265
264
  await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
266
265
  await runs.patch('r_foreign',{status:'running'})
267
- await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_foreign'}),/blocked|owner/i)
266
+ await assert.rejects(requireOwnerExecution(root,'r_foreign'),/blocked: owner-mismatch/i)
268
267
  await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
269
268
  await runs.patch('r_restricted',{status:'running'})
270
- await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_restricted'}),/owner-authorized native local/)
269
+ await assert.rejects(requireOwnerExecution(root,'r_restricted'),/external-execution-unavailable/)
271
270
  } finally {
272
271
  if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
273
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,11 +8,12 @@ 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'
18
19
  import { initializeWorkspace } from '../src/workspace.js'
@@ -33,6 +34,8 @@ test('failure evidence is bounded and redacts configured credentials, headers, t
33
34
 
34
35
  test('failure review CLI is owner-bound, rejects stale reviews, and keeps failure evidence immutable',async t=>{
35
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())
36
39
  const runs=new RunStore(dir),control=new ControlStore(dir,1000)
37
40
  const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}
38
41
  const cli=(args:string[],overrides={})=>exec(process.execPath,[bin,...args],{env:{...env,...overrides}})
@@ -61,7 +64,7 @@ test('failure review CLI is owner-bound, rejects stale reviews, and keeps failur
61
64
  await runs.patch(id,{status:'running',...('replyOnly' in extra?{replyOnly:true}:{})})
62
65
  await assert.rejects(cli(review,{EZ_RUN_ID:id}),/owner-authorized/)
63
66
  }
64
- await writeFile(join(dir,'runs','r_bad.json'),'{broken')
67
+ await assert.rejects(stat(join(dir,'runs')),{code:'ENOENT'})
65
68
  assert.equal(JSON.parse((await cli(['failures'])).stdout).total,1)
66
69
  })
67
70
 
@@ -86,6 +89,8 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
86
89
  })
87
90
  relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
88
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)
89
94
  const originalPatch=RunStore.prototype.patch
90
95
  t.mock.method(RunStore.prototype,'patch',async function(this:RunStore,...args:Parameters<RunStore['patch']>){
91
96
  // Hold the PID write until the fast child has closed, reproducing slow disk
@@ -114,7 +119,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
114
119
  await relay.bot.handleUpdate({...update,update_id:12,message:{...update.message!,message_id:12,text:'/stop'}})
115
120
  await until(async()=>(await runs.get('tg_11'))?.status==='cancelled')
116
121
  assert.equal(needsFailureReview((await runs.get('tg_11'))!),false)
117
- }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})}
118
123
  })
119
124
 
120
125
 
@@ -220,7 +225,7 @@ for (const cleanupFails of [false,true]) test(`polling conflict preserves work u
220
225
  await stopped
221
226
  assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
222
227
  assert.equal(sourceStops,1)
223
- 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])
224
229
  assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
225
230
  assert.equal(relay.bot.isRunning(),false)
226
231
  assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
@@ -268,6 +273,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
268
273
 
269
274
  test('group members can inspect failures and wake review without exposing other chats', async t => {
270
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())
271
277
  const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
272
278
  await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
273
279
  const execution=await control.captureChoice(initialPreset('grok'))
@@ -334,6 +340,7 @@ test('shutdown aborts a pending bot initialization without starting polling',asy
334
340
 
335
341
  test('failed reviewer readback exposes its stop through show, list and resume until explicit edit',async t=>{
336
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())
337
344
  const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
338
345
  await control.requestPairing(101,101);const owner=await control.approveOwner(101)
339
346
  const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
@@ -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
+ }
@@ -69,6 +69,80 @@ test('host restart clears dead native lease only after proving previous CLI stop
69
69
  }finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
70
70
  });
71
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
+
72
146
  test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
73
147
  const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
74
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: [] }),
@@ -81,6 +81,7 @@ test('Docker startup uses explicit deployment binding and literal paths without
81
81
  assert.equal(definition.command, 'docker')
82
82
  assert.ok(definition.args.includes('/tmp/my agent $literal/docker.env'))
83
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'])
84
85
  assert.doesNotMatch(JSON.stringify(definition), /TELEGRAM_BOT_TOKEN|launchctl|systemctl/)
85
86
  assert.throws(() => serviceDefinition('/tmp/\nbad'), /single-line/)
86
87
  })