@jc_stack/ez-agents 0.1.0-beta.31 → 0.1.0-beta.32

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 (52) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +26 -1
  3. package/README.md +36 -25
  4. package/docs/application-channel.md +29 -2
  5. package/docs/architecture/ai-selection.md +10 -1
  6. package/docs/architecture/authority-boundaries.md +64 -14
  7. package/docs/architecture/event-sources.md +12 -5
  8. package/docs/architecture/telegram-intake.md +1 -1
  9. package/docs/host-service.md +3 -0
  10. package/docs/plugin-contributions.md +2 -0
  11. package/docs/plugins.md +23 -0
  12. package/docs/responsive-channels.md +3 -0
  13. package/docs/selective-monitoring.md +55 -3
  14. package/docs/setup.md +2 -1
  15. package/package.json +1 -1
  16. package/src/application-channel.ts +40 -9
  17. package/src/application-origin.ts +4 -0
  18. package/src/approval.ts +19 -3
  19. package/src/codex-session.ts +2 -2
  20. package/src/desktop-bridge.ts +70 -5
  21. package/src/event-sources.ts +13 -6
  22. package/src/executor.ts +4 -2
  23. package/src/files.ts +12 -0
  24. package/src/host-executor-protocol.ts +2 -2
  25. package/src/host-executor.ts +20 -4
  26. package/src/index.ts +81 -34
  27. package/src/plugins/manager.mjs +52 -20
  28. package/src/runs.ts +19 -2
  29. package/src/task-attachments.ts +56 -0
  30. package/src/task-capability.ts +45 -0
  31. package/src/task-cli.ts +7 -4
  32. package/src/task-executor.ts +8 -4
  33. package/src/task-mcp.ts +25 -8
  34. package/src/tasks.ts +132 -26
  35. package/src/telegram-source.ts +34 -13
  36. package/src/updates/artifact.mjs +2 -2
  37. package/templates/agent-guidance.md +14 -0
  38. package/test/application-channel.test.ts +52 -1
  39. package/test/codex-session.test.ts +2 -1
  40. package/test/desktop-bridge.test.ts +98 -1
  41. package/test/event-sources.test.ts +68 -8
  42. package/test/executor.test.ts +2 -2
  43. package/test/host-executor.test.ts +32 -3
  44. package/test/intake-relay.test.ts +79 -2
  45. package/test/local-qa.test.mjs +7 -3
  46. package/test/plugin-manager.test.mjs +31 -0
  47. package/test/runs.test.ts +19 -0
  48. package/test/task-attachments.test.ts +77 -0
  49. package/test/task-capability.test.ts +48 -0
  50. package/test/task-native.test.ts +4 -1
  51. package/test/tasks.test.ts +53 -1
  52. package/test/telegram-source.test.ts +28 -1
@@ -5,7 +5,7 @@ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
5
5
  import { tmpdir } from 'node:os'
6
6
  import path from 'node:path'
7
7
  import { EventEmitter } from 'node:events'
8
- import { DESKTOP_UNAVAILABLE, runDesktopTurn, type DesktopClient } from '../src/desktop-bridge.js'
8
+ import { connectManagedDesktop, desktopServerRequestResult, DESKTOP_UNAVAILABLE, runDesktopTurn, type DesktopClient } from '../src/desktop-bridge.js'
9
9
  import { executorKey, nativeSessionId, startExecutorJob } from '../src/executor.js'
10
10
  import { initialPreset, isPreset, readModels } from '../src/ai.js'
11
11
 
@@ -35,6 +35,7 @@ const fakeClient = (script: Array<Record<string, unknown>>): DesktopClient & { c
35
35
  }
36
36
  events.on('message', onMessage)
37
37
  }),
38
+ setServerRequestContext: () => {},
38
39
  close: () => events.removeAllListeners(),
39
40
  }
40
41
  return client
@@ -73,6 +74,102 @@ test('a dedicated desktop turn emits the thread id and waits for completion', as
73
74
  assert.equal(nativeSessionId('codex-gui', lines[0]), 'thr_hello')
74
75
  })
75
76
 
77
+ test('desktop connection starts the native daemon once when its socket is absent', async () => {
78
+ const client = fakeClient([])
79
+ let attempts = 0, starts = 0
80
+ const connected = await connectManagedDesktop(async () => {
81
+ attempts++
82
+ if (attempts === 1) throw new Error(DESKTOP_UNAVAILABLE)
83
+ return client
84
+ }, async () => { starts++ })
85
+ assert.equal(connected, client)
86
+ assert.equal(attempts, 2)
87
+ assert.equal(starts, 1)
88
+ })
89
+
90
+ test('desktop connection fails closed when the native daemon cannot start', async () => {
91
+ let attempts = 0
92
+ await assert.rejects(connectManagedDesktop(async () => {
93
+ attempts++
94
+ throw new Error(DESKTOP_UNAVAILABLE)
95
+ }, async () => { throw new Error(DESKTOP_UNAVAILABLE) }), /unavailable/i)
96
+ assert.equal(attempts, 1)
97
+ })
98
+
99
+ test('desktop server requests use native response shapes and only accept computer-use elicitations', () => {
100
+ const context = { threadId: 'thread_1', turnId: 'turn_1' }
101
+ assert.deepEqual(desktopServerRequestResult({
102
+ method: 'mcpServer/elicitation/request',
103
+ params: { ...context, _meta: {
104
+ connector_id: 'browser-use', codex_approval_kind: 'mcp_tool_call', codex_request_type: 'approval_request',
105
+ tool_name: 'access_browser_origin', origin: 'https://ae.iherb.com',
106
+ tool_params: { origin: 'https://ae.iherb.com' },
107
+ } },
108
+ }, context), { action: 'accept', content: null, _meta: { persist: 'session' } })
109
+ assert.deepEqual(desktopServerRequestResult({
110
+ method: 'mcpServer/elicitation/request',
111
+ params: { ...context, _meta: {
112
+ connector_id: 'computer-use', codex_approval_kind: 'mcp_tool_call', tool_name: 'js',
113
+ tool_params: { app: 'com.google.Chrome' },
114
+ } },
115
+ }, context), { action: 'accept', content: null, _meta: { persist: 'session' } })
116
+ assert.deepEqual(desktopServerRequestResult({
117
+ method: 'mcpServer/elicitation/request',
118
+ params: { ...context, _meta: {
119
+ connector_id: 'computer-use', codex_approval_kind: 'mcp_tool_call', tool_name: 'js',
120
+ tool_params: { app: 'com.google.Chrome' },
121
+ } },
122
+ }, { threadId: context.threadId }), { action: 'accept', content: null, _meta: { persist: 'session' } })
123
+ assert.deepEqual(desktopServerRequestResult({
124
+ method: 'mcpServer/elicitation/request',
125
+ params: { ...context, _meta: {
126
+ connector_id: 'computer-use', codex_approval_kind: 'mcp_tool_call',
127
+ codex_request_type: 'approval_request', tool_name: 'start_audio_recording',
128
+ tool_params: {}, riskLevel: 'high',
129
+ } },
130
+ }, context), { action: 'decline', content: null, _meta: null })
131
+ assert.deepEqual(desktopServerRequestResult({
132
+ method: 'mcpServer/elicitation/request',
133
+ params: { ...context, _meta: {
134
+ connector_id: 'browser-use', codex_approval_kind: 'mcp_tool_call', codex_request_type: 'approval_request',
135
+ tool_name: 'access_browser_origin', origin: 'file:///tmp/private', tool_params: { origin: 'file:///tmp/private' },
136
+ } },
137
+ }, context), { action: 'decline', content: null, _meta: null })
138
+ assert.deepEqual(desktopServerRequestResult({
139
+ method: 'mcpServer/elicitation/request',
140
+ params: { ...context, turnId: 'other', _meta: {
141
+ connector_id: 'computer-use', codex_approval_kind: 'mcp_tool_call', tool_name: 'js',
142
+ tool_params: { app: 'com.google.Chrome' },
143
+ } },
144
+ }, context), { action: 'decline', content: null, _meta: null })
145
+ assert.deepEqual(desktopServerRequestResult({ method: 'item/commandExecution/requestApproval' }), { decision: 'decline' })
146
+ assert.deepEqual(desktopServerRequestResult({ method: 'unknown/request' }), { decision: 'decline' })
147
+ })
148
+
149
+ test('desktop browser authority is scheduled-only and is installed before turn start', async () => {
150
+ for (const runId of ['owner_run', 'r_schedule_browser']) {
151
+ const client = fakeClient([
152
+ { result: {} }, { result: { thread: { id: 'thread_browser' } } }, { result: {} },
153
+ { result: { turn: { id: 'turn_browser' } }, notify: [{ method: 'turn/completed', params: { turn: { id: 'turn_browser', status: 'completed' } } }] },
154
+ ])
155
+ const calls: Array<[string | undefined, string | undefined]> = []
156
+ const policies: unknown[] = []
157
+ const request = client.request
158
+ client.request = async (method, params) => {
159
+ if (method === 'thread/start' || method === 'turn/start') policies.push((params as { approvalPolicy?: unknown }).approvalPolicy)
160
+ return request(method, params)
161
+ }
162
+ client.setServerRequestContext = (threadId, turnId) => { calls.push([threadId, turnId]) }
163
+ assert.equal(await runDesktopTurn({
164
+ workspace: '/tmp/mind', controlDir: '/tmp/control', binDir: '/tmp/bin', runId, prompt: 'browser test',
165
+ }, { connect: async () => client, emit: () => {} }), 0)
166
+ assert.deepEqual(calls, runId.startsWith('r_schedule_')
167
+ ? [['thread_browser', undefined], ['thread_browser', 'turn_browser']]
168
+ : [[undefined, undefined]])
169
+ assert.deepEqual(policies, runId.startsWith('r_schedule_') ? ['on-request', 'on-request'] : ['never', 'never'])
170
+ }
171
+ })
172
+
76
173
  test('a UUID-shaped desktop thread is resumed instead of starting another task', async () => {
77
174
  const threadId = '01a07b96-2681-72f2-ae6c-6f77fe823a8c'
78
175
  const client = fakeClient([
@@ -2,14 +2,14 @@ import { Tasks } from '../src/tasks.js'
2
2
  import { ApprovalStore } from '../src/approval.js'
3
3
  import test from 'node:test'
4
4
  import assert from 'node:assert/strict'
5
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
5
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
6
6
  import { join } from 'node:path'
7
7
  import { createServer } from 'node:http'
8
8
  import { spawn } from 'node:child_process'
9
9
  import { once } from 'node:events'
10
10
  import { EventSources, eventRunId, batchReady, type SourceEvent } from '../src/event-sources.js'
11
11
  import { createRelay } from '../src/index.js'
12
- import { ControlStore } from '../src/control-state.js'
12
+ import { ControlStore, ownerEpoch, ownerId } from '../src/control-state.js'
13
13
  import { RunStore } from '../src/runs.js'
14
14
  import { initialPreset } from '../src/ai.js'
15
15
  import { executorJobEnv } from '../src/executor.js'
@@ -19,38 +19,42 @@ const until = async (check: () => Promise<boolean>) => {
19
19
  throw new Error('Test timed out')
20
20
  }
21
21
  const event = (id: string, conversationId = 'chat-a'): SourceEvent => ({ id, conversationId, text: 'Untrusted correspondence', receivedAt: Date.now() - 3000 })
22
- async function fixture(t: test.TestContext, taskProtocol = false) {
22
+ async function fixture(t: test.TestContext, taskProtocol = false, applicationOnly = false) {
23
23
  const dir = await mkdtemp('/tmp/ez-source-')
24
24
  const socketPath = join(dir, 'provider.sock')
25
25
  let rows: SourceEvent[] = [], enabled = true, offline = false
26
+ const releases:string[][]=[]
26
27
  const server = createServer(async (req, res) => {
27
28
  let body = ''; for await (const c of req) body += c
28
29
  const { command, args } = JSON.parse(body)
29
30
  if (offline) { res.statusCode = 503; res.end('{}'); return }
30
- const data = command === 'events-head' ? { cursor: rows.length, ...(taskProtocol ? { taskProtocol: 'message-v1', accountId: 'test-account' } : {}) }
31
+ const data = command === 'events-head' ? { cursor: rows.length, releaseEvents:true, ...(taskProtocol ? { taskProtocol: 'message-v1', accountId: 'test-account', persistentWatch:true, wildcardWatch:true } : {}) }
31
32
  : command === 'events-check' ? { events: enabled ? rows.filter(e => args.ids.includes(e.id)) : [] }
33
+ : command === 'events-release' ? (releases.push(args.ids),{released:true})
32
34
  : { cursor: rows.length, events: enabled ? rows.filter(e => Number(e.id) > args.after) : [] }
33
35
  res.end(JSON.stringify({ ok: true, data }))
34
36
  })
35
37
  await new Promise<void>(r => server.listen(socketPath, r))
36
38
  const control = new ControlStore(dir, 1000), runs = new RunStore(dir), sources = new EventSources(dir)
37
- await control.requestPairing(101, 101); await control.approveOwner(101)
39
+ if (applicationOnly) await control.registerOwner('application:test')
40
+ else { await control.requestPairing(101, 101); await control.approveOwner(101) }
38
41
  const owner = (await control.status()).owner!
39
42
  const launches: { texts: string[]; options: any }[] = [], children: ReturnType<typeof spawn>[] = []
40
- const relay = createRelay({ controlDir: dir, workspace: dir, telegramBotToken: 'fixture', executorTimeoutMs: 1000, pairingTtlMs: 1000, executorCli: 'grok' }, async (texts, options) => {
43
+ const relay = createRelay({ controlDir: dir, workspace: dir, telegramBotToken: applicationOnly ? '' : 'fixture', telegramEnabled:!applicationOnly,
44
+ ...(applicationOnly ? {applicationPort:8110} : {}), executorTimeoutMs: 1000, pairingTtlMs: 1000, executorCli: 'grok' }, async (texts, options) => {
41
45
  assert.ok(children.every(c => c.exitCode !== null || c.signalCode !== null), 'Only one writer')
42
46
  const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { detached: true }); await once(child, 'spawn')
43
47
  launches.push({ texts, options }); children.push(child)
44
48
  return { child, cleanup: async () => {}, stdout: '' }
45
49
  })
46
- relay.bot.api.config.use(async () => ({ ok: true, result: true }) as never)
50
+ relay.bot?.api.config.use(async () => ({ ok: true, result: true }) as never)
47
51
  t.after(async () => {
48
52
  await relay.stop()
49
53
  await until(async () => children.every(c => c.exitCode !== null || c.signalCode !== null))
50
54
  await until(async () => (await runs.list()).every(r => r.status !== 'running' || r.pid === process.pid))
51
55
  server.closeAllConnections(); await new Promise<void>(r => server.close(() => r())); await rm(dir, { recursive: true, force: true })
52
56
  })
53
- return { dir, socketPath, owner, control, runs, sources, relay, launches, children,
57
+ return { dir, socketPath, owner, control, runs, sources, relay, launches, children, releases,
54
58
  setRows: (value: SourceEvent[]) => { rows = value }, setEnabled: (v: boolean) => { enabled = v }, setOffline: (v: boolean) => { offline = v } }
55
59
  }
56
60
 
@@ -63,6 +67,7 @@ test('registered external events are durably blocked before any executor launch'
63
67
  const stored = await f.runs.list()
64
68
  assert.equal(stored.length, 1)
65
69
  assert.equal(stored[0].status, 'cancelled')
70
+ assert.deepEqual(f.releases,[['1','2']])
66
71
  // Preserve the terminal status understood by previous state-schema-1 releases.
67
72
  assert.equal((await new RunStore(f.dir).get(stored[0].id))?.blockReason, 'external-execution-unavailable')
68
73
  assert.equal(stored[0].blockReason, 'external-execution-unavailable')
@@ -148,3 +153,58 @@ test('relay launches the approved initial task and routes only matching replies
148
153
  assert.equal(f.launches.length, 2)
149
154
  assert.equal((await f.runs.list()).find(r => r.external?.eventIds.includes('2'))?.status, 'cancelled')
150
155
  })
156
+
157
+ test('any-conversation intake keeps a bounded public backlog',async t=>{
158
+ const f=await fixture(t,true);await f.sources.register('fixture',f.socketPath,f.owner)
159
+ const ownerRun=await f.runs.create({chatId:101,telegramUserId:101,texts:['Serve public questions']});await f.runs.patch(ownerRun.id,{status:'running',pid:process.pid})
160
+ const tasks=new Tasks(f.dir),proposal:any=await tasks.ownerCall(ownerRun.id,'propose',{sourceId:'fixture',conversationId:'*',purpose:'Answer public questions',context:'Public context',hours:24,waitForIncoming:true,untilRevoked:true,anyConversation:true})
161
+ await new ApprovalStore(f.dir).recordDecision(proposal.id,'approved',101);await tasks.decide(proposal.id)
162
+ f.setRows(Array.from({length:9},(_,index)=>({...event(String(index+1),`public-${index+1}`),receivedAt:Date.now()})))
163
+ await new Promise(resolve=>setTimeout(resolve,2100))
164
+ await f.relay.drainSources()
165
+ const taskRuns=(await f.runs.list()).filter(run=>run.taskId===proposal.id)
166
+ assert.equal(taskRuns.length,8)
167
+ assert.ok((await f.sources.batch((await f.sources.list())[0])).events.length>0,'unadmitted source batch stays pinned')
168
+ })
169
+
170
+ test('application-only owner can approve and execute a public channel task',async t=>{
171
+ const f=await fixture(t,true,true),source=(await f.sources.register('fixture',f.socketPath,f.owner))!
172
+ const binding=(await f.relay.applicationChannel.bindings.register('app','x'.repeat(48),f.owner))!
173
+ const ownerRun=await f.runs.create({id:`r_app_${'a'.repeat(64)}`,ownerId:ownerId(f.owner),ownerEpoch:ownerEpoch(f.owner),texts:['Serve public questions'],
174
+ application:{bindingId:binding.bindingId,requestId:'owner-request',scope:'main'}})
175
+ await f.runs.patch(ownerRun.id,{status:'running'})
176
+ const tasks=new Tasks(f.dir),proposal:any=await tasks.ownerCall(ownerRun.id,'propose',{sourceId:source.id,conversationId:'*',purpose:'Answer public questions',context:'Public context',hours:24,waitForIncoming:true,untilRevoked:true,anyConversation:true})
177
+ await new ApprovalStore(f.dir).recordOwnerDecision(proposal.id,'approved',f.owner);await tasks.decide(proposal.id)
178
+ await f.runs.patch(ownerRun.id,{status:'completed',endedAt:new Date().toISOString()})
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')
181
+ const publicRun=(await f.runs.list()).find(run=>run.taskId===proposal.id)
182
+ assert.equal(publicRun?.ownerId,ownerId(f.owner));assert.equal(publicRun?.chatId,undefined)
183
+ f.children[0].kill();await until(async()=>!(await f.runs.list()).some(run=>run.status==='running'))
184
+ })
185
+
186
+ test('application-only public exhaustion cancels and releases without launching',async t=>{
187
+ const f=await fixture(t,true,true),source=(await f.sources.register('fixture',f.socketPath,f.owner))!
188
+ const binding=(await f.relay.applicationChannel.bindings.register('app','y'.repeat(48),f.owner))!
189
+ const ownerRun=await f.runs.create({id:`r_app_${'b'.repeat(64)}`,ownerId:ownerId(f.owner),ownerEpoch:ownerEpoch(f.owner),texts:['Serve public questions'],
190
+ application:{bindingId:binding.bindingId,requestId:'owner-request',scope:'main'}})
191
+ await f.runs.patch(ownerRun.id,{status:'running'})
192
+ const tasks=new Tasks(f.dir),proposal:any=await tasks.ownerCall(ownerRun.id,'propose',{sourceId:source.id,conversationId:'*',purpose:'Answer public questions',context:'Public context',hours:24,waitForIncoming:true,untilRevoked:true,anyConversation:true})
193
+ await new ApprovalStore(f.dir).recordOwnerDecision(proposal.id,'approved',f.owner);await tasks.decide(proposal.id)
194
+ const file=join(f.dir,'tasks',`${proposal.id}.json`),task=JSON.parse(await readFile(file,'utf8'));task.publicBudget.totalRuns=1000;await writeFile(file,JSON.stringify(task))
195
+ await f.runs.patch(ownerRun.id,{status:'completed',endedAt:new Date().toISOString()})
196
+ f.setRows([{...event('1','visitor'),receivedAt:Date.now()}]);await new Promise(resolve=>setTimeout(resolve,2100));await f.relay.drainSources()
197
+ assert.equal(f.launches.length,0)
198
+ const rejected=(await f.runs.list()).find(run=>run.external)
199
+ assert.equal(rejected?.status,'cancelled');assert.equal(rejected?.blockReason,'external-execution-unavailable');assert.equal(rejected?.externalReleased,true)
200
+ assert.equal((await tasks.get(proposal.id))?.state,'revoked');assert.deepEqual(f.releases,[['1']])
201
+ })
202
+
203
+ test('terminal event release persists success and retries a provider outage',async t=>{
204
+ const f=await fixture(t),source=(await f.sources.register('fixture',f.socketPath,f.owner))!,row=event('1')
205
+ const run=await f.runs.create({id:'event_release_retry',chatId:101,telegramUserId:101,texts:[],external:{sourceId:source.id,bindingId:source.bindingId,conversationId:row.conversationId,eventIds:[row.id]}})
206
+ await f.runs.patch(run.id,{status:'cancelled',endedAt:new Date().toISOString()})
207
+ f.setOffline(true);await f.relay.drainSources();assert.equal((await f.runs.get(run.id))?.externalReleased,undefined)
208
+ f.setOffline(false);f.setRows([]);await f.relay.drainSources()
209
+ assert.equal((await f.runs.get(run.id))?.externalReleased,true);assert.deepEqual(f.releases,[['1']])
210
+ })
@@ -159,8 +159,8 @@ test('Codex jobs disable global memory and host skill discovery', () => {
159
159
  })
160
160
 
161
161
  test('Codex plugin access stays scoped to the explicitly bound registry', () => {
162
- const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind',controlDir:'/agent/control',toolsHome:'/agent/tools'},'', 'install a plugin')
163
- assert.deepEqual(args.flatMap((arg,i)=>arg==='--add-dir'?[args[i+1]]:[]),['/agent/control','/agent/tools'])
162
+ 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
+ assert.deepEqual(args.flatMap((arg,i)=>arg==='--add-dir'?[args[i+1]]:[]),['/agent/control','/app','/agent/mind/work','/agent/tools'])
164
164
  assert.ok(args.includes('sandbox_workspace_write.network_access=true'))
165
165
  assert.equal(args[args.indexOf('--sandbox')+1],'workspace-write')
166
166
  assert.ok(!EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind'},'','hello').includes('sandbox_workspace_write.network_access=true'))
@@ -14,6 +14,33 @@ import { packageVersion } from '../src/version.js'
14
14
  import { executionDefaults } from '../src/model-policy.js'
15
15
  import { workspaceLease } from '../src/plugins/workspace-lease.mjs'
16
16
 
17
+ test('host restart replaces a lock whose PID was reused by another process',async()=>{
18
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-reused-pid-'))
19
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
20
+ const abort=new AbortController();let server:Promise<void>|undefined
21
+ try {
22
+ await mkdir(workspace);await mkdir(directory,{recursive:true})
23
+ await writeFile(path.join(directory,'worker.lock'),JSON.stringify({pid:process.pid,started:'reused-pid'}))
24
+ server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
25
+ for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
26
+ await readFile(path.join(directory,'heartbeat.json'))
27
+ const lock=JSON.parse(await readFile(path.join(directory,'worker.lock'),'utf8'))
28
+ assert.equal(lock.pid,process.pid)
29
+ assert.notEqual(lock.started,'reused-pid')
30
+ }finally{abort.abort();await server;await rm(root,{recursive:true,force:true})}
31
+ })
32
+
33
+ test('legacy host lock fails closed while its PID is alive',async()=>{
34
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-legacy-lock-'))
35
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
36
+ try {
37
+ await mkdir(workspace);await mkdir(directory,{recursive:true})
38
+ await writeFile(path.join(directory,'worker.lock'),JSON.stringify({pid:process.pid}))
39
+ await assert.rejects(serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},new AbortController().signal),/already running/)
40
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'worker.lock'),'utf8')),{pid:process.pid})
41
+ }finally{await rm(root,{recursive:true,force:true})}
42
+ })
43
+
17
44
  test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
18
45
  const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
19
46
  const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
@@ -55,12 +82,13 @@ test('one installed CLI executes two agent bindings with separate minds and sani
55
82
  process.env.TELEGRAM_BOT_TOKEN='must-not-reach-host-cli'
56
83
  const sharedAlias=path.join(root,'shared-alias')
57
84
  await symlink(root,sharedAlias)
85
+ const additionalWorkspace=path.join(root,'additional');await mkdir(additionalWorkspace)
58
86
  const agents=await Promise.all(['one','two'].map(async name=>{
59
87
  const workspace=path.join(root,name,'mind'),controlDir=path.join(root,name,'control')
60
88
  await mkdir(workspace,{recursive:true});await mkdir(controlDir,{recursive:true})
61
89
  const toolsHome=path.join(root,name,'tools');await mkdir(toolsHome)
62
90
  await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}))
63
- return {name,workspace,controlDir,binDir:path.join(root,'bin'),toolsHome,sharedWorkspace:name==='two'?sharedAlias:root}
91
+ return {name,workspace,controlDir,binDir:path.join(root,'bin'),toolsHome,sharedWorkspace:name==='two'?sharedAlias:root,additionalWorkspaces:name==='two'?[additionalWorkspace]:undefined}
64
92
  }))
65
93
  server=serveHostExecutor({cli:'grok',agents},abort.signal)
66
94
  for(const agent of agents){
@@ -88,6 +116,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
88
116
  assert.equal(result.repair,'true')
89
117
  assert.ok(result.args.includes(agent.toolsHome))
90
118
  assert.ok(result.args.includes(await realpath(root)))
119
+ assert.equal(result.args.includes(await realpath(additionalWorkspace)),agent.name==='two')
91
120
  assert.ok(!result.args.includes('/wrong'))
92
121
  assert.equal(result.home,process.env.HOME)
93
122
  }
@@ -180,8 +209,8 @@ test('one installed CLI executes two agent bindings with separate minds and sani
180
209
 
181
210
 
182
211
  test('host run IDs accept production formats and reject unsafe paths',()=>{
183
- for(const id of ['tg_6293305','r_example_123','event_'+'a'.repeat(64)])assert.equal(isHostRunId(id),true)
184
- for(const id of ['../tg_1','tg_1/other','tg_abc','event_bad','event_'+'a'.repeat(63),'event_'+'g'.repeat(64),'tg_1\n','r_',''])assert.equal(isHostRunId(id),false)
212
+ for(const id of ['tg_6293305','tg_replay_702267965_20260916','r_example_123','event_'+'a'.repeat(64)])assert.equal(isHostRunId(id),true)
213
+ for(const id of ['../tg_1','tg_1/other','tg_abc','tg_replay_702267965','tg_replay_702267965_2026091x','event_bad','event_'+'a'.repeat(63),'event_'+'g'.repeat(64),'tg_1\n','r_',''])assert.equal(isHostRunId(id),false)
185
214
  })
186
215
 
187
216
  test('client tolerates missing heartbeat and consumes completion before checking host health',async()=>{
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { mkdtemp, readdir, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { spawn } from 'node:child_process'
@@ -11,7 +11,8 @@ import { ControlStore } from '../src/control-state.js'
11
11
  import { RunStore } from '../src/runs.js'
12
12
  import { InboxStore } from '../src/inbox.js'
13
13
  import { ApprovalStore } from '../src/approval.js'
14
- import { Tasks } from '../src/tasks.js'
14
+ import { atomicTaskFile, Tasks } from '../src/tasks.js'
15
+ import { EventSources } from '../src/event-sources.js'
15
16
  import { ownerRun } from './helpers/owner-run.js'
16
17
  import { packageVersion } from '../src/version.js'
17
18
  import type { Config } from '../src/config.js'
@@ -203,6 +204,55 @@ test('owner group discovery routes only to the private chat and rechecks identit
203
204
  } finally { await f.close() }
204
205
  })
205
206
 
207
+ test('an approved any-conversation channel grant admits unknown private and group text through restricted task runs', async () => {
208
+ const f = await fixture()
209
+ const external = (id:number,chatId:number,sender:number):Update => ({update_id:id,message:{message_id:id,date:Math.floor(Date.now()/1000),text:chatId>0?'Public question':'@fixture_bot Public question',from:{id:sender,is_bot:false,first_name:'Visitor'},chat:chatId>0?{id:chatId,type:'private',first_name:'Visitor'}:{id:chatId,type:'supergroup',title:'Public'}}})
210
+ try {
211
+ // One unbound owner group message initializes the built-in Telegram source.
212
+ await f.relay.bot.handleUpdate(external(1,-101,101))
213
+ const owner=await ownerRun(f.dir,'owner_public')
214
+ const tasks=new Tasks(f.dir),proposal:any=await tasks.ownerCall(owner.id,'propose',{sourceId:'telegram',conversationId:'*',purpose:'Answer public questions',context:'Public answers only',hours:24,waitForIncoming:true,untilRevoked:true,anyConversation:true})
215
+ await new ApprovalStore(f.dir).recordDecision(proposal.id,'approved',101)
216
+ await tasks.decide(proposal.id)
217
+ await new RunStore(f.dir).patch(owner.id,{status:'completed'})
218
+ await new Promise(resolve=>setTimeout(resolve,1100)) // Telegram timestamps have one-second precision.
219
+ await f.relay.bot.handleUpdate(external(2,202,202))
220
+ await f.relay.bot.handleUpdate(external(3,-303,303))
221
+ const unaddressedOwner=external(4,-404,101)
222
+ unaddressedOwner.message!.text='Plain owner follow-up'
223
+ await f.relay.bot.handleUpdate(unaddressedOwner)
224
+ assert.equal((await new InboxStore(f.dir).status()).pending,1) // Only the owner's discovery message used owner intake.
225
+ await new Promise(resolve=>setTimeout(resolve,2100))
226
+ const sources=new EventSources(f.dir),registration=(await sources.list()).find(source=>source.id==='telegram')!
227
+ const batch=await sources.batch(registration)
228
+ assert.deepEqual(batch.events.map(event=>event.id).sort(),['tg_202_2','tg_n303_3'])
229
+ assert.equal((await tasks.match('telegram',registration.bindingId,batch.events.slice(0,1)))?.id,proposal.id)
230
+ await f.relay.drainSources()
231
+ await new Promise(resolve=>setTimeout(resolve,80));await f.relay.drainSources()
232
+ const externalRuns=(await new RunStore(f.dir).list()).filter(run=>run.taskId===proposal.id)
233
+ assert.deepEqual(externalRuns.map(run=>run.external?.eventIds[0]).sort(),['tg_202_2','tg_n303_3'])
234
+ } finally { await f.close() }
235
+ })
236
+
237
+ test('a full public Telegram buffer cannot reroute an addressed owner message to owner intake', async () => {
238
+ const f = await fixture()
239
+ const group = (id:number):Update => ({update_id:id,message:{message_id:id,date:Math.floor(Date.now()/1000),text:'@fixture_bot Public question',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:-404,type:'supergroup',title:'Public'}}})
240
+ try {
241
+ await f.relay.bot.handleUpdate(group(1)) // Initialize the built-in source through legacy discovery.
242
+ const owner=await ownerRun(f.dir,'owner_public_full')
243
+ const tasks=new Tasks(f.dir),proposal:any=await tasks.ownerCall(owner.id,'propose',{sourceId:'telegram',conversationId:'*',purpose:'Answer public questions',context:'Public answers only',hours:24,waitForIncoming:true,untilRevoked:true,anyConversation:true})
244
+ await new ApprovalStore(f.dir).recordDecision(proposal.id,'approved',101)
245
+ await tasks.decide(proposal.id)
246
+ const [accountDirectory]=await readdir(join(f.dir,'telegram-source')),sourceDirectory=join(f.dir,'telegram-source',accountDirectory)
247
+ for(let cursor=1;cursor<=100;cursor++)await atomicTaskFile(join(sourceDirectory,`tg_500_${cursor}.json`),{id:`tg_500_${cursor}`,conversationId:'500',receivedAt:Date.now(),text:'Question',cursor})
248
+ await f.relay.bot.handleUpdate(group(2))
249
+ assert.equal((await new InboxStore(f.dir).status()).pending,1)
250
+ const events=(await readdir(sourceDirectory)).filter(file=>/^tg_(?:n)?\d+_\d+\.json$/.test(file))
251
+ assert.equal(events.length,100)
252
+ assert.equal(events.includes('tg_n404_2.json'),false)
253
+ } finally { await f.close() }
254
+ })
255
+
206
256
  test('conversation menu is owner-only and removes the retired settings control', async () => {
207
257
  const f = await fixture()
208
258
  const callback = (id: number, data: string, user = 101): Update => ({
@@ -612,6 +662,33 @@ test('older conversation names come from owner messages and detail keeps archive
612
662
  } finally { await f.close() }
613
663
  })
614
664
 
665
+ test('Telegram and application image/PDF/text use identical native attachment metadata and owner session', async () => {
666
+ const f=await fixture()
667
+ const originalFetch=globalThis.fetch
668
+ try {
669
+ const control=new ControlStore(f.dir,1000)
670
+ const binding=(await f.relay.applicationChannel.bindings.register('web','a'.repeat(43),(await control.status()).owner!,true))!
671
+ let updateId=500
672
+ for (const [name,bytes] of [['image.png',Buffer.from('89504e470d0a1a0a','hex')],['file.pdf',Buffer.from('%PDF-1.4\nfixture')],['notes.md',Buffer.from('# Fixture')]] as const) {
673
+ globalThis.fetch=async()=>new Response(bytes)
674
+ const update=message(updateId++)
675
+ delete update.message!.text
676
+ update.message!.document={file_id:'fixture',file_unique_id:'fixture',file_name:name}
677
+ update.message!.caption=' literal /goal\n comment '
678
+ await f.relay.bot.handleUpdate(update)
679
+ await f.relay.drainInbox(true)
680
+ const telegram=(await new RunStore(f.dir).list()).find(run=>run.messageId===update.message!.message_id)!
681
+ assert.ok(telegram)
682
+ const application=await f.relay.applicationChannel.submit(binding.bindingId,{requestId:name,scope:'chat',followOwner:true,text:update.message!.caption,attachment:{name,data:bytes.toString('base64')}})
683
+ const normalize=(text:string)=>text.replace(/inbox\/[a-f0-9-]+_/,'inbox/ID_')
684
+ assert.equal(normalize(application.texts[0]),normalize(telegram.texts[0]))
685
+ assert.equal(application.ownerId,telegram.ownerId)
686
+ assert.equal(application.execution?.sessionId,telegram.execution?.sessionId)
687
+ assert.deepEqual(application.execution?.preset,telegram.execution?.preset)
688
+ await new Promise(resolve=>setTimeout(resolve,200))
689
+ }
690
+ } finally {globalThis.fetch=originalFetch;await f.close()}
691
+ })
615
692
 
616
693
  test('web launcher is private-owner-only and bypasses native intake without replacing controls', async () => {
617
694
  const f=await fixture({webLauncher:{command:'voice',label:'Voice',url:'https://voice.example/'}})
@@ -11,16 +11,17 @@ test('local QA staging preserves source and rejects label replacement and dirty
11
11
  const root=await fs.mkdtemp(path.join(tmpdir(),'ez-qa-test-'));
12
12
  const source=path.join(root,'repo'),catalog=path.join(root,'catalog'),flow=path.join(root,'QA.md');
13
13
  await fs.mkdir(source);
14
- const pkg={name:'@fixture/main',version:'0.1.0-beta.12',files:['feature.txt'],ezRelease:{kind:'main',protocol:1,stateSchema:1,mainProtocol:1}};
14
+ const pkg={name:'@fixture/main',version:'0.1.0-beta.12',files:['feature.txt','run.sh'],ezRelease:{kind:'main',protocol:1,stateSchema:1,mainProtocol:1}};
15
15
  await fs.writeFile(path.join(source,'package.json'),JSON.stringify(pkg));
16
16
  await fs.writeFile(path.join(source,'feature.txt'),'feature source');
17
+ await fs.writeFile(path.join(source,'run.sh'),'#!/bin/sh\n',{mode:0o755});
17
18
  await fs.writeFile(flow,'Ask for the feature. Verify its result.');
18
19
  const git=args=>execFileSync('git',args,{cwd:source,stdio:'pipe'}).toString().trim();
19
- git(['init']);git(['add','package.json','feature.txt']);
20
+ git(['init']);git(['add','package.json','feature.txt','run.sh']);
20
21
  git(['-c','user.name=QA','-c','user.email=qa@example.invalid','-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false','commit','-m','fixture']);
21
22
  const script=fileURLToPath(new URL('../scripts/stage-qa.mjs',import.meta.url));
22
23
  const args=[script,'--source',source,'--catalog',catalog,'--label','beta-12','--version','0.1.0-beta.12.qa.1','--flow',flow];
23
- const stage=()=>execFileSync(process.execPath,args,{stdio:'pipe'}).toString();
24
+ const stage=()=>{const prior=process.umask(0o027);try{return execFileSync(process.execPath,args,{stdio:'pipe'}).toString();}finally{process.umask(prior);}};
24
25
  try {
25
26
  const result=JSON.parse(stage()),data=await fs.readFile(path.join(result.directory,result.file));
26
27
  assert.equal(result.sha256,digest(data));assert.equal(result.commit,git(['rev-parse','HEAD']));
@@ -29,6 +30,9 @@ test('local QA staging preserves source and rejects label replacement and dirty
29
30
  const built=JSON.parse(await fs.readFile(path.join(root,'unpacked/package.json'),'utf8'));
30
31
  assert.equal(built.version,'0.1.0-beta.12.qa.1');assert.equal(built.ezQa.commit,result.commit);
31
32
  assert.equal(await fs.readFile(path.join(root,'unpacked/feature.txt'),'utf8'),'feature source');
33
+ assert.equal((await fs.stat(path.join(root,'unpacked'))).mode&0o777,0o755);
34
+ assert.equal((await fs.stat(path.join(root,'unpacked/feature.txt'))).mode&0o777,0o644);
35
+ assert.equal((await fs.stat(path.join(root,'unpacked/run.sh'))).mode&0o777,0o755);
32
36
  assert.throws(stage,error=>/QA label already exists/.test(error.stderr.toString()));
33
37
  assert.equal(digest(await fs.readFile(path.join(result.directory,result.file))),result.sha256);
34
38
  await fs.writeFile(path.join(source,'feature.txt'),'unreviewed edit');
@@ -114,6 +114,31 @@ test('bound launcher installs without startup; literal args and exit codes; scop
114
114
  lines=(await fs.readFile(f.log,'utf8')).trim().split('\n').map(JSON.parse);assert(lines.every(x=>x.secret===undefined));assert(lines.every(x=>!x.argv.includes('-v')));
115
115
  const mode=(await fs.stat(path.join(f.home,'registry.json'))).mode&0o777;assert.equal(mode,0o600);
116
116
  });
117
+ test('concurrent registered commands release the registry lock before execution',async t=>{
118
+ const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
119
+ const started=path.join(f.root,'started');
120
+ await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nconst fs=require('fs'),a=process.argv.slice(2);if(a.includes('run')){fs.appendFileSync(${JSON.stringify(started)},JSON.stringify({name:a[a.indexOf('--name')+1]})+'\\n');setTimeout(()=>{console.log(JSON.stringify(a.slice(a.indexOf('/app/client.mjs')+1)))},800)}\n`,{mode:0o700});
121
+ const first=f.call('sample','first');
122
+ for(let i=0;i<100&&!await fs.access(started).then(()=>true,()=>false);i++)await new Promise(resolve=>setTimeout(resolve,5));
123
+ const second=f.call('sample','second');
124
+ for(let i=0;i<100&&(await fs.readFile(started,'utf8')).trim().split('\n').length<2;i++)await new Promise(resolve=>setTimeout(resolve,5));
125
+ const names=(await fs.readFile(started,'utf8')).trim().split('\n').map(line=>JSON.parse(line).name);
126
+ assert.equal(names.length,2);assert.equal(new Set(names).size,2);
127
+ await assert.rejects(f.call('plugins','uninstall','sample'),/commands are active/);
128
+ const outputs=await Promise.all([first,second]);
129
+ assert.deepEqual(outputs.map(result=>JSON.parse(result.stdout)).sort(),[['first'],['second']]);
130
+ await f.call('plugins','uninstall','sample');
131
+ });
132
+ test('unconfirmed direct-command cancellation retains its lifecycle lease',async t=>{
133
+ const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
134
+ await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nconst a=process.argv.slice(2);if(a[0]==='compose'&&a.includes('run')){console.log('ready');process.on('SIGTERM',()=>{});setInterval(()=>{},1000)}else if(a[0]==='container'&&a[1]==='rm'){console.error('daemon unavailable');process.exit(19)}else if(a[0]==='container'&&a[1]==='inspect'){console.log('still-running')}\n`,{mode:0o700});
135
+ const child=spawn(path.join(f.home,'bin','ez'),['sample','wait'],{env:f.env,stdio:['ignore','pipe','pipe']});t.after(()=>child.kill('SIGKILL'));
136
+ let stderr='';child.stderr.on('data',chunk=>stderr+=chunk);
137
+ await once(child.stdout,'data');child.kill('SIGTERM');
138
+ const [code]=await once(child,'close');assert.equal(code,1);assert.match(stderr,/cleanup failed/);
139
+ assert.equal((await fs.readdir(path.join(f.home,'command-invocations'))).length,1);
140
+ await assert.rejects(f.call('plugins','uninstall','sample'),/Stale command invocation lease/);
141
+ });
117
142
  test('changed source, symlinks, reserved aliases, arbitrary Docker fields rejected',async t=>{
118
143
  const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
119
144
  await fs.writeFile(path.join(f.source,'client.mjs'),'changed');await assert.rejects(f.call('plugins','install','sample','--source',f.source,'--revision',p.revision));
@@ -260,6 +285,12 @@ test('exposure is conservative discovery metadata and does not change literal di
260
285
  f.manifest.commands.sample.exposure=value;
261
286
  assert.throws(()=>validate(f.manifest,f.deployment,after.files),/exposure/);
262
287
  }
288
+ const query=structuredClone(f.manifest);query.commands.sample={...query.commands.sample,channelQuery:true,exposure:{receivesExternalContent:true,sendsExternally:false,changesRecords:false,requiresReview:false}};
289
+ validate(query,f.deployment,after.files);
290
+ for(const exposure of [undefined,{receivesExternalContent:true,sendsExternally:false,changesRecords:true,requiresReview:false}]) {
291
+ const unsafe=structuredClone(query);unsafe.commands.sample.exposure=exposure;
292
+ assert.throws(()=>validate(unsafe,f.deployment,after.files),/Channel queries/);
293
+ }
263
294
  });
264
295
 
265
296
  test('standalone CLI has discoverable setup, independent guidance and status without Telegram',async t=>{
package/test/runs.test.ts CHANGED
@@ -89,6 +89,25 @@ test('RunStore enqueues document, voice, and approval items properly', async ()
89
89
  assert.equal(pending.length, 3)
90
90
  }))
91
91
 
92
+ test('RunStore bounds terminal task history',async()=>fixture(async store=>{
93
+ const taskId=`task_${'a'.repeat(32)}`
94
+ for(let index=0;index<101;index++){
95
+ const run=await store.create({id:`event_history_${index}`,taskId,chatId:1,telegramUserId:1,texts:[]})
96
+ await store.patch(run.id,{status:'completed',endedAt:new Date().toISOString()})
97
+ }
98
+ await store.pruneTaskHistory(taskId)
99
+ assert.equal((await store.list()).filter(run=>run.taskId===taskId).length,100)
100
+ }))
101
+
102
+ test('RunStore retains unreleased external evidence until release is durable',async()=>fixture(async store=>{
103
+ const taskId=`task_${'b'.repeat(32)}`,external={sourceId:'fixture',bindingId:'binding',conversationId:'visitor',eventIds:['one']}
104
+ const run=await store.create({id:'event_unreleased',taskId,chatId:1,telegramUserId:1,texts:[],external})
105
+ await store.patch(run.id,{status:'completed',endedAt:new Date().toISOString()});await store.pruneTaskHistory(taskId,0)
106
+ assert.notEqual(await store.get(run.id),null)
107
+ await store.patch(run.id,{externalReleased:true});await store.pruneTaskHistory(taskId,0)
108
+ assert.equal(await store.get(run.id),null)
109
+ }))
110
+
92
111
  test('claimOutbox prevents concurrent double-processing of the same item', async () => fixture(async (store) => {
93
112
  const created = await store.create({ chatId: 1, telegramUserId: 1, texts: ['test'] })
94
113
  await store.patch(created.id, { status: 'running' })