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

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 (57) hide show
  1. package/AGENTS.md +47 -32
  2. package/CHANGELOG.md +34 -1
  3. package/CONTRIBUTING.md +29 -26
  4. package/README.md +36 -25
  5. package/docs/application-channel.md +29 -2
  6. package/docs/architecture/ai-selection.md +10 -1
  7. package/docs/architecture/authority-boundaries.md +64 -14
  8. package/docs/architecture/event-sources.md +12 -5
  9. package/docs/architecture/telegram-intake.md +1 -1
  10. package/docs/host-service.md +3 -0
  11. package/docs/plugin-contributions.md +6 -1
  12. package/docs/plugins.md +23 -0
  13. package/docs/responsive-channels.md +3 -0
  14. package/docs/selective-monitoring.md +55 -3
  15. package/docs/setup.md +2 -1
  16. package/docs/upgrades.md +6 -4
  17. package/package.json +1 -1
  18. package/src/application-channel.ts +40 -9
  19. package/src/application-origin.ts +4 -0
  20. package/src/approval.ts +19 -3
  21. package/src/codex-session.ts +2 -2
  22. package/src/desktop-bridge.ts +70 -5
  23. package/src/event-sources.ts +13 -6
  24. package/src/executor.ts +4 -2
  25. package/src/files.ts +12 -0
  26. package/src/host-executor-protocol.ts +2 -2
  27. package/src/host-executor.ts +20 -4
  28. package/src/index.ts +81 -34
  29. package/src/plugins/manager.mjs +52 -20
  30. package/src/runs.ts +19 -2
  31. package/src/task-attachments.ts +56 -0
  32. package/src/task-capability.ts +45 -0
  33. package/src/task-cli.ts +7 -4
  34. package/src/task-executor.ts +8 -4
  35. package/src/task-mcp.ts +25 -8
  36. package/src/tasks.ts +132 -26
  37. package/src/telegram-source.ts +34 -13
  38. package/src/updates/artifact.mjs +2 -2
  39. package/src/updates/control.mjs +11 -1
  40. package/src/updates/runtime.mjs +3 -1
  41. package/templates/agent-guidance.md +14 -0
  42. package/test/application-channel.test.ts +52 -1
  43. package/test/codex-session.test.ts +2 -1
  44. package/test/desktop-bridge.test.ts +98 -1
  45. package/test/event-sources.test.ts +68 -8
  46. package/test/executor.test.ts +2 -2
  47. package/test/host-executor.test.ts +32 -3
  48. package/test/intake-relay.test.ts +79 -2
  49. package/test/local-qa.test.mjs +7 -3
  50. package/test/plugin-manager.test.mjs +31 -0
  51. package/test/runs.test.ts +19 -0
  52. package/test/task-attachments.test.ts +77 -0
  53. package/test/task-capability.test.ts +48 -0
  54. package/test/task-native.test.ts +4 -1
  55. package/test/tasks.test.ts +53 -1
  56. package/test/telegram-source.test.ts +28 -1
  57. package/test/updates.test.mjs +24 -1
@@ -9,7 +9,7 @@ import { randomBytes } from 'node:crypto'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { createRequire } from 'node:module'
11
11
  import { ApplicationChannel, ApplicationBindings, applicationScope } from '../src/application-channel.js'
12
- import { ControlStore } from '../src/control-state.js'
12
+ import { ControlStore, 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 { requireOwnerExecution } from '../src/execution-authority.js'
@@ -85,6 +85,10 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
85
85
  await channel.deliver(run,message)
86
86
  assert.deepEqual(await runs.waitForDelivery(message.id),{delivered:true})
87
87
  assert.deepEqual((await channel.snapshot(first.bindingId,run.id)).messages,[{id:message.id,text:'Here is the proposal'}])
88
+ const approval=await runs.enqueueApproval(run.id,'Allow this channel grant?','task_'+'a'.repeat(32))
89
+ await runs.claimOutbox(approval.id);await channel.deliver(run,approval)
90
+ assert.deepEqual((await channel.snapshot(first.bindingId,run.id)).approvals,[{id:'task_'+'a'.repeat(32),prompt:'Allow this channel grant?',state:'delivered'}])
91
+ assert.equal((await request('/v1/approvals/task_'+'a'.repeat(32),secondToken,{decision:'approved'})).status,400)
88
92
  await channel.bindings.register('first',null,owned)
89
93
  await assert.rejects(requireOwnerExecution(root,run.id),/revoked/)
90
94
  await assert.rejects(channel.deliver(run,message),/revoked/)
@@ -281,3 +285,50 @@ test('following Telegram requires an explicit sharing grant and cannot override
281
285
  }
282
286
  assert.equal((await new RunStore(root).list()).length, 0)
283
287
  })
288
+
289
+ test('generic attachments stage after auth, preserve literal comments and reject retry/isolation failures', async t => {
290
+ const root=await mkdtemp(join(tmpdir(),'ez-app-attachment-'))
291
+ const owned=await owner(root), runs=new RunStore(root)
292
+ const channel=new ApplicationChannel({controlDir:root,workspace:root,initial:initialPreset('codex'),wake:()=>{},cancel:async()=>{}})
293
+ t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
294
+ const secret=token(), other=token()
295
+ const binding=(await channel.bindings.register('web',secret,owned,true))!
296
+ await channel.bindings.register('other',other,owned)
297
+ const address=await channel.listen(0) as {port:number}
298
+ const post=(body:unknown,bearer=secret)=>fetch(`http://127.0.0.1:${address.port}/v1/runs`,{method:'POST',headers:{Authorization:`Bearer ${bearer}`},body:JSON.stringify(body)})
299
+ const input={requestId:'image',scope:'chat',followOwner:true,text:' /goal literal\n comment ',attachment:{name:'image.png',data:Buffer.from('89504e470d0a1a0a','hex').toString('base64')}}
300
+ assert.equal((await post(input,'bad')).status,401)
301
+ const {readdir,stat}=await import('node:fs/promises')
302
+ await assert.rejects(readdir(join(root,'inbox')),/ENOENT/)
303
+ 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) {
304
+ const body={...input,requestId:name,attachment:{name,data:bytes.toString('base64')}}
305
+ const response=await post(body);assert.equal(response.status,202)
306
+ const snapshot=await response.json() as {id:string}
307
+ const run=(await runs.get(snapshot.id))!
308
+ assert.equal(run.ownerId,ownerId(owned))
309
+ assert.equal(run.application?.inputText,input.text)
310
+ assert.ok(run.texts[0].endsWith(`Caption: ${input.text}`))
311
+ const path=run.texts[0].match(/staged at (inbox\/[^ ]+)/)![1]
312
+ assert.deepEqual(await readFile(join(root,path)),bytes)
313
+ assert.equal((await stat(join(root,path))).mode & 0o777,0o600)
314
+ const before=(await readdir(join(root,'inbox'))).length
315
+ assert.equal((await post(body)).status,202)
316
+ assert.equal((await readdir(join(root,'inbox'))).length,before)
317
+ assert.equal((await post({...body,text:'changed'})).status,409)
318
+ assert.equal((await post({...body,attachment:{name,data:Buffer.from('changed').toString('base64')}})).status,409)
319
+ assert.equal((await fetch(`http://127.0.0.1:${address.port}/v1/runs/${snapshot.id}`,{headers:{Authorization:`Bearer ${other}`}})).status,404)
320
+ }
321
+ const control=new ControlStore(root,1000)
322
+ const priorSession=(await control.status()).activeSession
323
+ const invalidActivation=await post({...input,requestId:'invalid-activation',followOwner:false,activateTelegram:true,scope:'new-scope',attachment:{name:'x.exe',data:Buffer.from('text').toString('base64')}})
324
+ assert.equal(invalidActivation.status,400)
325
+ assert.deepEqual((await control.status()).activeSession,priorSession)
326
+ const before=(await readdir(join(root,'inbox'))).length
327
+ for (const attachment of [{name:'x.exe',data:Buffer.from('text').toString('base64')},{name:'x.txt',data:'%%%invalid'},{name:'x.txt',data:Buffer.alloc(10*1024*1024+1,65).toString('base64')}]) {
328
+ assert.equal((await post({...input,requestId:'bad',attachment})).status,400)
329
+ }
330
+ assert.equal((await readdir(join(root,'inbox'))).length,before)
331
+ await channel.bindings.register('web',null,owned)
332
+ await assert.rejects(channel.submit(binding.bindingId,{...input,requestId:'revoked'}),/revoked/)
333
+ assert.equal((await readdir(join(root,'inbox'))).length,before)
334
+ })
@@ -43,7 +43,7 @@ if(q.method==='thread/goal/get'){
43
43
  child.stdin.write=((chunk:any,...args:any[])=>{try{const q=JSON.parse(String(chunk));if(q.method==='turn/start'){turnPrompt=q.params.input[0].text;turnPolicy=q.params.sandboxPolicy}requests.push(q.method);if(q.method==='thread/start'){threadConfig=q.params.config;threadSandbox=q.params.sandbox}}catch{};return (write as any)(chunk,...args)}) as typeof child.stdin.write
44
44
  return child
45
45
  }
46
- const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',prompt,
46
+ const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',additionalWorkspaces:['/agent/mind/work'],prompt,
47
47
  ...(mode==='external'?{codexSandbox:'external' as const}:{})},{launch,emit:line=>output.push(line)})
48
48
  assert.equal(threadSandbox!,mode==='external'?'danger-full-access':'workspace-write')
49
49
  assert.deepEqual(turnPolicy,mode==='external'?{type:'externalSandbox',networkAccess:'enabled'}:undefined)
@@ -51,6 +51,7 @@ if(q.method==='thread/goal/get'){
51
51
  assert.equal(turnPrompt,prompt,'full input reaches the engine without goal admission or truncation')
52
52
  if(mode==='long-goal')assert.ok(prompt.length>4000)
53
53
  assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/canonical'))
54
+ assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/agent/mind/work'))
54
55
  assert.equal(result,['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
55
56
  assert.equal(requests.filter(x=>x==='turn/start').length,1,'transport must not send goal continuation prompts')
56
57
  assert.equal(requests.filter(x=>x==='thread/goal/set').length,0)
@@ -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=>{