@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.
- package/AGENTS.md +47 -32
- package/CHANGELOG.md +34 -1
- package/CONTRIBUTING.md +29 -26
- package/README.md +36 -25
- package/docs/application-channel.md +29 -2
- package/docs/architecture/ai-selection.md +10 -1
- package/docs/architecture/authority-boundaries.md +64 -14
- package/docs/architecture/event-sources.md +12 -5
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/host-service.md +3 -0
- package/docs/plugin-contributions.md +6 -1
- package/docs/plugins.md +23 -0
- package/docs/responsive-channels.md +3 -0
- package/docs/selective-monitoring.md +55 -3
- package/docs/setup.md +2 -1
- package/docs/upgrades.md +6 -4
- package/package.json +1 -1
- package/src/application-channel.ts +40 -9
- package/src/application-origin.ts +4 -0
- package/src/approval.ts +19 -3
- package/src/codex-session.ts +2 -2
- package/src/desktop-bridge.ts +70 -5
- package/src/event-sources.ts +13 -6
- package/src/executor.ts +4 -2
- package/src/files.ts +12 -0
- package/src/host-executor-protocol.ts +2 -2
- package/src/host-executor.ts +20 -4
- package/src/index.ts +81 -34
- package/src/plugins/manager.mjs +52 -20
- package/src/runs.ts +19 -2
- package/src/task-attachments.ts +56 -0
- package/src/task-capability.ts +45 -0
- package/src/task-cli.ts +7 -4
- package/src/task-executor.ts +8 -4
- package/src/task-mcp.ts +25 -8
- package/src/tasks.ts +132 -26
- package/src/telegram-source.ts +34 -13
- package/src/updates/artifact.mjs +2 -2
- package/src/updates/control.mjs +11 -1
- package/src/updates/runtime.mjs +3 -1
- package/templates/agent-guidance.md +14 -0
- package/test/application-channel.test.ts +52 -1
- package/test/codex-session.test.ts +2 -1
- package/test/desktop-bridge.test.ts +98 -1
- package/test/event-sources.test.ts +68 -8
- package/test/executor.test.ts +2 -2
- package/test/host-executor.test.ts +32 -3
- package/test/intake-relay.test.ts +79 -2
- package/test/local-qa.test.mjs +7 -3
- package/test/plugin-manager.test.mjs +31 -0
- package/test/runs.test.ts +19 -0
- package/test/task-attachments.test.ts +77 -0
- package/test/task-capability.test.ts +48 -0
- package/test/task-native.test.ts +4 -1
- package/test/tasks.test.ts +53 -1
- package/test/telegram-source.test.ts +28 -1
- 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
|
-
|
|
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: '
|
|
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
|
|
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
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -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/'}})
|
package/test/local-qa.test.mjs
CHANGED
|
@@ -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=>{
|