@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
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
// Telegram batches, registered plugin events and locally created runs cross this transport.
|
|
2
|
-
export const isHostRunId = (id: string): boolean => /^(?:r_[a-zA-Z0-9_]+|tg_\d
|
|
1
|
+
// Telegram batches (including scheduler recovery), registered plugin events and locally created runs cross this transport.
|
|
2
|
+
export const isHostRunId = (id: string): boolean => /^(?:r_[a-zA-Z0-9_]+|tg_(?:\d+|replay_\d+_\d{8})|event_[a-f0-9]{64})$/.test(id)
|
package/src/host-executor.ts
CHANGED
|
@@ -13,11 +13,14 @@ import type { ChildProcess } from 'node:child_process'
|
|
|
13
13
|
import { taskWorkspace } from './task-workspace.js'
|
|
14
14
|
import { packageVersion } from './version.js'
|
|
15
15
|
import { installedPluginVersions } from './software-status.js'
|
|
16
|
+
import { processSnapshot } from './process-tree.js'
|
|
16
17
|
|
|
17
18
|
export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
|
|
18
|
-
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
|
|
19
|
+
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
|
|
19
20
|
export type HostInstallation = { cli: string; agents: HostBinding[] }
|
|
20
21
|
|
|
22
|
+
const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
|
|
23
|
+
|
|
21
24
|
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
|
|
22
25
|
resolveExecutor(installation.cli)
|
|
23
26
|
if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
|
|
@@ -27,12 +30,17 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
27
30
|
const tasks = new Set<Promise<void>>()
|
|
28
31
|
const locks: string[] = []
|
|
29
32
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
33
|
+
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
30
34
|
const catalog = (agent: HostBinding) => readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
|
|
31
35
|
try {
|
|
32
36
|
for (const agent of installation.agents) {
|
|
33
37
|
if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
|
|
34
38
|
if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
|
|
35
39
|
if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
|
|
40
|
+
if (agent.additionalWorkspaces) {
|
|
41
|
+
if (!Array.isArray(agent.additionalWorkspaces) || agent.additionalWorkspaces.some(workspace=>typeof workspace!=='string' || !path.isAbsolute(workspace))) throw new Error('Additional workspaces require absolute paths')
|
|
42
|
+
additionalWorkspaces.set(agent,await Promise.all(agent.additionalWorkspaces.map(workspace=>realpath(workspace))))
|
|
43
|
+
}
|
|
36
44
|
if (agent.toolsHome) {
|
|
37
45
|
if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
|
|
38
46
|
const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
|
|
@@ -41,9 +49,17 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
41
49
|
const directory = path.join(agent.controlDir,'host-executor')
|
|
42
50
|
await mkdir(directory,{recursive:true,mode:0o700})
|
|
43
51
|
const lock=path.join(directory,'worker.lock')
|
|
44
|
-
try {
|
|
52
|
+
try {
|
|
53
|
+
const prior=JSON.parse(await readFile(lock,'utf8'))
|
|
54
|
+
const currentStart=await processStart(prior.pid)
|
|
55
|
+
if (!prior.started || !currentStart || prior.started===currentStart) {
|
|
56
|
+
try { process.kill(prior.pid,0); throw new Error('Host executor already running') }
|
|
57
|
+
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
58
|
+
}
|
|
59
|
+
await rm(lock)
|
|
60
|
+
}
|
|
45
61
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
46
|
-
await writeFile(lock,JSON.stringify({pid:process.pid}),{mode:0o600,flag:'wx'})
|
|
62
|
+
await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
|
|
47
63
|
locks.push(lock)
|
|
48
64
|
await installAgentGuidance(agent.workspace)
|
|
49
65
|
await writeFile(path.join(directory,'models.json'),JSON.stringify(await catalog(agent)),{mode:0o600})
|
|
@@ -114,7 +130,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
114
130
|
const cli = opts.cli || installation.cli
|
|
115
131
|
resolveExecutor(cli)
|
|
116
132
|
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,model:opts.model,effort:opts.effort},await catalog(agent))
|
|
117
|
-
const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,cli,
|
|
133
|
+
const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
118
134
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
119
135
|
sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort,codexAutoCompactTokens:opts.codexAutoCompactTokens}
|
|
120
136
|
job=await launch(request.texts,options)
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { scheduledTasksText } from './scheduled-tasks.js'
|
|
|
13
13
|
import { taskWorkspace } from './task-workspace.js'
|
|
14
14
|
import { queueUpdateAttention } from './update-attention.js'
|
|
15
15
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
16
|
+
import {removeTaskAttachments} from './task-attachments.js'
|
|
16
17
|
import { dirname, join, basename } from 'node:path'
|
|
17
18
|
import { fileURLToPath } from 'node:url'
|
|
18
19
|
import path from 'node:path'
|
|
@@ -28,7 +29,7 @@ import { startExecutorJob, terminateJob } from './executor.js'
|
|
|
28
29
|
import { RunStore, type RunRecord } from './runs.js'
|
|
29
30
|
import { splitTelegramText } from './reply.js'
|
|
30
31
|
import { markdownToTelegramHtml, escapeHtml } from './format.js'
|
|
31
|
-
import { sanitizeFileName,
|
|
32
|
+
import { sanitizeFileName, stageChatAttachment, workspaceFile } from './files.js'
|
|
32
33
|
import { transcribeAudio, synthesizeSpeech } from './audio.js'
|
|
33
34
|
import { normalizeReactionEmoji } from './reaction.js'
|
|
34
35
|
import { downloadTelegramFile } from './read-request.js'
|
|
@@ -108,7 +109,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const applicationChannel = new ApplicationChannel({
|
|
111
|
-
controlDir: config.controlDir, initial: aiMenu.initial,
|
|
112
|
+
controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
|
|
112
113
|
aiControls: aiMenu,
|
|
113
114
|
wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
|
|
114
115
|
cancel: id => withStartLock(async () => {
|
|
@@ -153,7 +154,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
153
154
|
return ids
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
const telegramSource = telegramEnabled ? new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat
|
|
157
|
+
const telegramSource = telegramEnabled ? new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat,
|
|
158
|
+
async(chatId,text,bytes,filename)=>[(await bot!.api.sendDocument(chatId,new InputFile(bytes,filename),{caption:text})).message_id]) : null
|
|
157
159
|
|
|
158
160
|
const startJob = async (run: RunRecord): Promise<void> => {
|
|
159
161
|
await withStartLock(async () => {
|
|
@@ -161,19 +163,20 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
161
163
|
// stat uses the effective UID; access uses the relay's isolated real UID.
|
|
162
164
|
if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
|
|
163
165
|
if ((await runs.get(run.id))?.status !== 'queued') return
|
|
164
|
-
if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled) return
|
|
166
|
+
if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled && !run.external) return
|
|
165
167
|
if (!run.scheduled && await runs.running(false)) return
|
|
166
168
|
const owner = (await control.status()).owner
|
|
167
169
|
if (!owner || !ownsRun(owner, run)) {
|
|
168
170
|
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
171
|
+
await releaseExternal(run)
|
|
169
172
|
return
|
|
170
173
|
}
|
|
171
174
|
if (run.application || run.delivery) {
|
|
172
175
|
try { await applicationChannel.bindings.authorize(run) }
|
|
173
|
-
catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); return }
|
|
176
|
+
catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return }
|
|
174
177
|
}
|
|
175
178
|
if (config.channelBackendUrl) {
|
|
176
|
-
if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); return }
|
|
179
|
+
if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
|
|
177
180
|
activeBackend = true
|
|
178
181
|
await runs.patch(run.id, { status: 'running', backendSubmitted: true })
|
|
179
182
|
void dispatchChannel(config, run).then(async reply => {
|
|
@@ -201,18 +204,20 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
201
204
|
try { events = await sources.check(run.external, owner); unavailableSources.delete(run.external.sourceId) } catch { unavailableSources.add(run.external.sourceId); return }
|
|
202
205
|
if (!events.length) {
|
|
203
206
|
await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
|
|
207
|
+
await releaseExternal(run)
|
|
204
208
|
return
|
|
205
209
|
}
|
|
206
210
|
texts = events.map(event => JSON.stringify(event))
|
|
207
211
|
}
|
|
208
212
|
if (run.taskId) {
|
|
209
213
|
try { await tasks.authorize(run) } catch {
|
|
210
|
-
await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); return
|
|
214
|
+
await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return
|
|
211
215
|
}
|
|
212
216
|
}
|
|
213
217
|
const blockReason = run.taskId ? undefined : executionBlockReason(run, owner)
|
|
214
218
|
if (blockReason) {
|
|
215
219
|
await runs.patch(run.id, { status: 'cancelled', blockReason, endedAt: new Date().toISOString() })
|
|
220
|
+
await releaseExternal(run)
|
|
216
221
|
return
|
|
217
222
|
}
|
|
218
223
|
try {
|
|
@@ -288,6 +293,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
288
293
|
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
289
294
|
const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
|
|
290
295
|
await runs.patch(started.id, { status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString(), exitCode: code, ...(code !== 0 && !cancelled ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
296
|
+
await releaseExternal(run)
|
|
291
297
|
} catch (error) {
|
|
292
298
|
await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
293
299
|
console.error('Session completion failed', safeError(error))
|
|
@@ -313,6 +319,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
313
319
|
activeTypingTimer = null
|
|
314
320
|
}
|
|
315
321
|
await runs.patch(run.id, { status: 'failed', failureReason: 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
322
|
+
await releaseExternal(run)
|
|
316
323
|
console.error('run start failed', run.id, safeError(error))
|
|
317
324
|
if (bot && !run.application && !run.delivery && run.chatId !== undefined) await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
|
|
318
325
|
setImmediate(() => {
|
|
@@ -326,6 +333,22 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
326
333
|
}
|
|
327
334
|
|
|
328
335
|
const unavailableSources = new Set<string>()
|
|
336
|
+
const releaseExternal = async (run: RunRecord):Promise<boolean> => {
|
|
337
|
+
if (!run.external) return true
|
|
338
|
+
try {await removeTaskAttachments(config.controlDir,run.id)} catch {return false}
|
|
339
|
+
const current=await runs.get(run.id)
|
|
340
|
+
if (current?.externalReleased) {if(run.taskId)await runs.pruneTaskHistory(run.taskId);return true}
|
|
341
|
+
const task = run.taskId ? await tasks.get(run.taskId).catch(() => null) : null
|
|
342
|
+
const registered=(await sources.list()).find(source=>source.id===run.external!.sourceId&&source.bindingId===run.external!.bindingId)
|
|
343
|
+
const owner=task?.owner ?? (registered&&ownsRun(registered.owner,run)?registered.owner:(await control.status()).owner)
|
|
344
|
+
if(!owner)return false
|
|
345
|
+
try {
|
|
346
|
+
await sources.release(run.external,owner)
|
|
347
|
+
await runs.patch(run.id,{externalReleased:true})
|
|
348
|
+
if(run.taskId)await runs.pruneTaskHistory(run.taskId)
|
|
349
|
+
return true
|
|
350
|
+
} catch {unavailableSources.add(run.external.sourceId);return false}
|
|
351
|
+
}
|
|
329
352
|
let sourceWork: Promise<void> | undefined
|
|
330
353
|
const drainSources = (): Promise<void> => {
|
|
331
354
|
if (sourceWork) return sourceWork
|
|
@@ -337,6 +360,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
337
360
|
try {
|
|
338
361
|
if (!ownsRun(owner, foreground)) throw new Error('Owner channel revoked')
|
|
339
362
|
if (foreground.application) await applicationChannel.bindings.authorize(foreground)
|
|
363
|
+
if (foreground.taskId) {
|
|
364
|
+
const task = await tasks.authorize(foreground,false)
|
|
365
|
+
const ownerWaiting=(await inbox.status()).pending>0 || (await runs.list()).some(run=>run.status==='queued'&&!run.taskId&&!run.scheduled)
|
|
366
|
+
if (task.anyConversation && ownerWaiting) throw new Error('Owner input preempts public work')
|
|
367
|
+
}
|
|
340
368
|
}
|
|
341
369
|
catch { ownerStopped.add(activeChild); terminateJob(activeChild) }
|
|
342
370
|
}
|
|
@@ -348,6 +376,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
348
376
|
} catch { ownerStopped.add(child); terminateJob(child); continue }
|
|
349
377
|
if (await scheduler.cancelled(id)) terminateJob(child)
|
|
350
378
|
}
|
|
379
|
+
const pendingRelease=(await runs.list()).find(run=>run.external&&!run.externalReleased&&['completed','failed','cancelled'].includes(run.status))
|
|
380
|
+
if(pendingRelease)await releaseExternal(pendingRelease)
|
|
351
381
|
if (!owner) return
|
|
352
382
|
if (!config.channelBackendUrl) {
|
|
353
383
|
await drainTaskRequests()
|
|
@@ -356,7 +386,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
356
386
|
}
|
|
357
387
|
if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
358
388
|
}
|
|
359
|
-
for (const source of
|
|
389
|
+
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
360
390
|
try {
|
|
361
391
|
const batch = await sources.batch(source)
|
|
362
392
|
unavailableSources.delete(source.id)
|
|
@@ -366,23 +396,37 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
366
396
|
await sources.remember(source, batch)
|
|
367
397
|
const groups = new Map<string, SourceEvent[]>()
|
|
368
398
|
for (const event of batch.events) groups.set(event.conversationId, [...(groups.get(event.conversationId) || []), event])
|
|
399
|
+
const known = await runs.list()
|
|
400
|
+
const publicTaskIds = new Set((await tasks.list()).filter(task=>task.anyConversation).map(task=>task.id))
|
|
401
|
+
let publicOutstanding = known.filter(run => run.taskId && publicTaskIds.has(run.taskId) && ['queued','running'].includes(run.status)).length
|
|
402
|
+
let admitted = true
|
|
369
403
|
for (const events of groups.values()) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
404
|
+
let task = await tasks.match(source.id, source.bindingId, events)
|
|
405
|
+
const id = eventRunId(source,events)
|
|
406
|
+
if (known.some(run => run.id === id)) continue
|
|
407
|
+
if (task?.anyConversation) {
|
|
408
|
+
const publicTaskId=task.id
|
|
409
|
+
if (publicOutstanding >= 8 || known.some(run => run.taskId === publicTaskId &&
|
|
410
|
+
run.external?.conversationId === events[0].conversationId && ['queued','running'].includes(run.status))) { admitted=false; continue }
|
|
411
|
+
const admission = await tasks.admitPublic(task.id)
|
|
412
|
+
if (admission === 'defer') { admitted=false; continue }
|
|
413
|
+
if (admission === 'revoked') task=undefined
|
|
414
|
+
}
|
|
415
|
+
const identity = owner.telegramUserId !== undefined && owner.telegramChatId !== undefined
|
|
416
|
+
? {chatId:owner.telegramChatId,telegramUserId:owner.telegramUserId}
|
|
417
|
+
: {ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner)}
|
|
418
|
+
const created = await runs.create({taskId:task?.id,id,...identity,texts:[],execution:await durableWorkerChoice(),
|
|
419
|
+
external:{sourceId:source.id,bindingId:source.bindingId,conversationId:events[0].conversationId,eventIds:events.map(e=>e.id)}})
|
|
420
|
+
known.push(created); if(task?.anyConversation)publicOutstanding++
|
|
377
421
|
}
|
|
378
422
|
// Acknowledgement follows durable run creation; replay uses the saved batch.
|
|
379
|
-
await sources.advance(source, batch.cursor)
|
|
423
|
+
if(admitted)await sources.advance(source, batch.cursor)
|
|
380
424
|
})
|
|
381
425
|
} catch { unavailableSources.add(source.id) }
|
|
382
426
|
}
|
|
383
427
|
await runs.running(true)
|
|
384
428
|
if (!config.channelBackendUrl) await scheduler.tick(owner,runs)
|
|
385
|
-
for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
|
|
429
|
+
for (const run of (await runs.list()).filter(r => r.status === 'queued').sort((a,b)=>Number(Boolean(a.taskId))-Number(Boolean(b.taskId)))) {
|
|
386
430
|
if (shuttingDown) break
|
|
387
431
|
await startJob(run)
|
|
388
432
|
}
|
|
@@ -673,6 +717,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
673
717
|
for (const run of await runs.list()) {
|
|
674
718
|
if (run.status !== 'queued' || run.backendSubmitted) continue
|
|
675
719
|
await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
|
|
720
|
+
await releaseExternal(run)
|
|
676
721
|
count++
|
|
677
722
|
}
|
|
678
723
|
})
|
|
@@ -683,26 +728,32 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
683
728
|
// return after the authorized update has reached the atomic local journal.
|
|
684
729
|
if (bot) {
|
|
685
730
|
bot.use(async (ctx, next) => {
|
|
686
|
-
|
|
687
|
-
|
|
731
|
+
const currentOwner = (await control.status()).owner
|
|
732
|
+
if (ctx.callbackQuery && currentOwner?.kind === 'group') {
|
|
733
|
+
if (!isOwner(ctx, currentOwner)) return
|
|
688
734
|
try {
|
|
689
735
|
const member = await bot!.api.getChatMember(ctx.chat!.id, ctx.from!.id)
|
|
690
736
|
if (!['creator', 'administrator', 'member'].includes(member.status) &&
|
|
691
737
|
!(member.status === 'restricted' && member.is_member)) return
|
|
692
738
|
} catch { throw new Error('Group membership verification unavailable; retry the update') }
|
|
693
739
|
}
|
|
694
|
-
|
|
695
|
-
|
|
740
|
+
const externalText = !replay.has(ctx.update) && ctx.message?.text && !ctx.message.sender_chat && ctx.from && !ctx.from.is_bot && telegramOwner(currentOwner) && !isOwner(ctx,currentOwner)
|
|
741
|
+
if (externalText) {
|
|
742
|
+
await telegramSource!.start(currentOwner!)
|
|
743
|
+
const group = ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
|
|
744
|
+
const addressed = !group || ctx.message.reply_to_message?.from?.id === bot!.botInfo.id || Boolean(bot!.botInfo.username &&
|
|
745
|
+
new RegExp(`(^|\\s)@${bot!.botInfo.username}(?=\\s|$|[,.!?;:])`,'i').test(ctx.message.text!))
|
|
746
|
+
if (await telegramSource!.capture(ctx.update.update_id,ctx.message as import('grammy/types').Message.TextMessage,ctx.from!,addressed)) return
|
|
747
|
+
}
|
|
748
|
+
if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') && !isOwner(ctx, currentOwner)) {
|
|
696
749
|
if (config.channelBackendUrl) return
|
|
697
|
-
const owner = telegramOwner(
|
|
750
|
+
const owner = telegramOwner(currentOwner)
|
|
698
751
|
const message = ctx.message
|
|
699
752
|
if (!owner && message && !message.sender_chat && ctx.from && !ctx.from.is_bot) {
|
|
700
753
|
await control.requestPairing(ctx.from.id, ctx.chat.id, ctx.chat.title)
|
|
701
754
|
return
|
|
702
755
|
}
|
|
703
756
|
if (!owner || !message?.text || message.sender_chat || !ctx.from || ctx.from.is_bot) return
|
|
704
|
-
await telegramSource!.start(owner)
|
|
705
|
-
if (await telegramSource!.capture(ctx.update.update_id, message as import('grammy/types').Message.TextMessage, ctx.from)) return
|
|
706
757
|
if (owner.kind === 'group') return
|
|
707
758
|
if (ctx.from.id !== owner.telegramUserId) return
|
|
708
759
|
if (replay.has(ctx.update)) {
|
|
@@ -859,12 +910,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
859
910
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
860
911
|
const buffer = await downloadTelegramFile(fileUrl)
|
|
861
912
|
const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
|
|
862
|
-
const staged = await
|
|
863
|
-
const caption = ctx.message.caption?.trim() || ''
|
|
864
|
-
const prompt = `[Attached image staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
|
|
913
|
+
const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
|
|
865
914
|
collectItem({
|
|
866
|
-
text:
|
|
867
|
-
attachment:
|
|
915
|
+
text: staged.text,
|
|
916
|
+
attachment: staged.attachment,
|
|
868
917
|
messageId: ctx.message.message_id,
|
|
869
918
|
updateId: ctx.update.update_id,
|
|
870
919
|
chatId: ctx.chat.id,
|
|
@@ -888,12 +937,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
888
937
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
889
938
|
const buffer = await downloadTelegramFile(fileUrl)
|
|
890
939
|
const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
|
|
891
|
-
const staged = await
|
|
892
|
-
const caption = ctx.message.caption?.trim() || ''
|
|
893
|
-
const prompt = `[Attached document staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
|
|
940
|
+
const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
|
|
894
941
|
collectItem({
|
|
895
|
-
text:
|
|
896
|
-
attachment:
|
|
942
|
+
text: staged.text,
|
|
943
|
+
attachment: staged.attachment,
|
|
897
944
|
messageId: ctx.message.message_id,
|
|
898
945
|
updateId: ctx.update.update_id,
|
|
899
946
|
chatId: ctx.chat.id,
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -41,12 +41,36 @@ export async function bindToolDiscovery(home,workspace) {
|
|
|
41
41
|
try {await fs.writeFile(tmp,next,{mode:0o600,flag:'wx'});await fs.rename(tmp,file);}finally{await fs.rm(tmp,{force:true});}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
const invocationDirectory = home => path.join(home,'command-invocations');
|
|
45
|
+
async function activeInvocations(home) {
|
|
46
|
+
const directory=invocationDirectory(home),files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
|
|
47
|
+
for(const file of files) {
|
|
48
|
+
if(!/^[0-9a-f-]{36}\.json$/.test(file))throw Error('Invalid command invocation lease; inspect before recovery');
|
|
49
|
+
const lease=await json(path.join(directory,file));
|
|
50
|
+
if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
|
|
51
|
+
try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH')throw error;}
|
|
52
|
+
const state=await run(['container','inspect',lease.container],{capture:true});
|
|
53
|
+
if(state.code===0)throw Error(`Stale command invocation lease; inspect container ${lease.container} before recovery`);
|
|
54
|
+
if(!/No such (?:object|container)/i.test(state.stderr+state.stdout))throw Error('Unable to verify a stale command invocation lease; inspect Docker before recovery');
|
|
55
|
+
await fs.rm(path.join(directory,file));
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
async function invocationLease(home,container) {
|
|
60
|
+
const directory=invocationDirectory(home);await privateDir(directory);
|
|
61
|
+
const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container}),{mode:0o600,flag:'wx'});
|
|
62
|
+
return async()=>{await fs.rm(file,{force:true});};
|
|
63
|
+
}
|
|
64
|
+
export async function locked(home, fn, {allowInvocations=false}={}) {
|
|
45
65
|
const lock = path.join(home,'registry.lock');
|
|
46
66
|
let handle;
|
|
47
67
|
try { handle=await fs.open(lock,'wx',0o600); }
|
|
48
68
|
catch(error) { if(error.code==='EEXIST') throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager'); throw error; }
|
|
49
|
-
try {
|
|
69
|
+
try {
|
|
70
|
+
await handle.writeFile(JSON.stringify({pid:process.pid}));
|
|
71
|
+
if(!allowInvocations&&await activeInvocations(home))throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
|
|
72
|
+
return await fn();
|
|
73
|
+
}
|
|
50
74
|
finally { await handle.close(); await fs.rm(lock); }
|
|
51
75
|
}
|
|
52
76
|
// Snapshot only explicitly packaged files. No symlinks, credentials inferred from cwd, or install scripts.
|
|
@@ -126,7 +150,13 @@ export function validate(m,d,files) {
|
|
|
126
150
|
if(JSON.stringify(Object.keys(m.commands).sort())!==JSON.stringify(Object.keys(d.commands).sort())) throw Error('Command bindings must match manifest');
|
|
127
151
|
for(const [alias,c] of Object.entries(m.commands)) {
|
|
128
152
|
id(alias); if(reserved.has(alias)) throw Error('Reserved alias');
|
|
129
|
-
keys(c,['executable','args','exposure']); strings(c.args); exposure(c.exposure);
|
|
153
|
+
keys(c,['executable','args','exposure','channelQuery','channelFile']); strings(c.args); exposure(c.exposure);
|
|
154
|
+
if(c.channelFile!==undefined && c.channelFile!==true) throw Error('channelFile must be true when declared');
|
|
155
|
+
if(c.channelFile && c.channelQuery)throw Error('Choose one channel output type');
|
|
156
|
+
if(c.channelQuery!==undefined && c.channelQuery!==true) throw Error('channelQuery must be true when declared');
|
|
157
|
+
if((c.channelQuery || c.channelFile) && (c.exposure?.receivesExternalContent!==true || c.exposure?.sendsExternally!==false ||
|
|
158
|
+
c.exposure?.changesRecords!==false || c.exposure?.requiresReview!==false))
|
|
159
|
+
throw Error('Channel queries must explicitly accept external input without sends, writes, or review');
|
|
130
160
|
if(!files.has(c.executable)) throw Error('Missing package executable');
|
|
131
161
|
const b=d.commands[alias];keys(b,['service','argv','suffix']);
|
|
132
162
|
if(!d.services[b.service] || !strings(b.argv).length) throw Error('Invalid command service'); strings(b.suffix||[]);
|
|
@@ -270,7 +300,10 @@ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,
|
|
|
270
300
|
if(cancelled&&container) {
|
|
271
301
|
try {
|
|
272
302
|
await removeCommandContainer(container);
|
|
273
|
-
} catch(error) {
|
|
303
|
+
} catch(error) {
|
|
304
|
+
const uncertain=error instanceof Error?error:Error('Cancelled command container cleanup failed');
|
|
305
|
+
uncertain.containerCleanupUncertain=true;return reject(uncertain);
|
|
306
|
+
}
|
|
274
307
|
}
|
|
275
308
|
if(failure)return reject(failure);
|
|
276
309
|
resolve({code:cancelled?130:code??(childSignal?130:1),stdout,stderr});});
|
|
@@ -306,7 +339,7 @@ export async function registry(home) {
|
|
|
306
339
|
return r;
|
|
307
340
|
}
|
|
308
341
|
// The lock protects admission and compose refresh, never a persistent connection.
|
|
309
|
-
export async function prepareCommand(home,alias,args,{revision,exclude,publish}={}) {
|
|
342
|
+
export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false}={}) {
|
|
310
343
|
strings(args);
|
|
311
344
|
return locked(home,async()=>{
|
|
312
345
|
const config=await json(path.join(home,'config.json')),r=await registry(home);
|
|
@@ -317,8 +350,9 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish}=
|
|
|
317
350
|
const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
318
351
|
await atomic(record.compose,await compose(config,record,secrets,home));
|
|
319
352
|
const container=`${record.project}-call-${randomUUID()}`;
|
|
320
|
-
|
|
321
|
-
|
|
353
|
+
const release=invocation?await invocationLease(home,container):undefined;
|
|
354
|
+
return {container,plugin:record.manifest.id,revision:record.revision,argv:[...composeArgs(record),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
|
|
355
|
+
},{allowInvocations:true});
|
|
322
356
|
}
|
|
323
357
|
export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
|
|
324
358
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
@@ -509,17 +543,15 @@ export async function main(args) {
|
|
|
509
543
|
emit({ok:true,plugin:name,action,dataPreserved:true});
|
|
510
544
|
});
|
|
511
545
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
process.exitCode=result.code;
|
|
524
|
-
});
|
|
546
|
+
const command=await prepareCommand(home,group,args.slice(1),{invocation:true});
|
|
547
|
+
try {
|
|
548
|
+
// Docker exec does not reliably forward cancellation to the in-container process.
|
|
549
|
+
// Run each client as a one-shot Compose container; docker compose run forwards signals.
|
|
550
|
+
const result=await run(command.argv,{container:command.container});
|
|
551
|
+
process.exitCode=result.code;
|
|
552
|
+
await command.release();
|
|
553
|
+
} catch(error) {
|
|
554
|
+
if(error?.containerCleanupUncertain!==true)await command.release();
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
525
557
|
}
|
package/src/runs.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type RunRecord = {
|
|
|
42
42
|
nativeSessionId?: string
|
|
43
43
|
scheduled?: ScheduledOrigin
|
|
44
44
|
external?: ExternalOrigin
|
|
45
|
+
externalReleased?: true
|
|
45
46
|
application?: ApplicationOrigin
|
|
46
47
|
delivery?: { bindingId: string; scope: string }
|
|
47
48
|
}
|
|
@@ -89,6 +90,7 @@ const isRun = (value: unknown): value is RunRecord => {
|
|
|
89
90
|
(candidate.scheduled === undefined || validScheduledOrigin(candidate.scheduled)) &&
|
|
90
91
|
(candidate.blockReason === undefined || ['owner-mismatch', 'external-execution-unavailable'].includes(candidate.blockReason)) &&
|
|
91
92
|
(candidate.external === undefined || validOrigin(candidate.external)) &&
|
|
93
|
+
(candidate.externalReleased === undefined || candidate.external !== undefined && candidate.externalReleased === true) &&
|
|
92
94
|
(candidate.id.startsWith('r_app_') === (candidate.application !== undefined)) &&
|
|
93
95
|
(candidate.application === undefined || (validApplicationOrigin(candidate.application) && candidate.external === undefined && candidate.scheduled === undefined && candidate.taskId === undefined && !candidate.replyOnly)) &&
|
|
94
96
|
(candidate.delivery === undefined || (!!candidate.scheduled && validApplicationOrigin({...candidate.delivery, requestId: candidate.id}) && candidate.application === undefined)) &&
|
|
@@ -186,7 +188,7 @@ export class RunStore {
|
|
|
186
188
|
|
|
187
189
|
async patch(
|
|
188
190
|
id: string,
|
|
189
|
-
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview'>>,
|
|
191
|
+
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased'>>,
|
|
190
192
|
): Promise<RunRecord> {
|
|
191
193
|
const prior = this.changes.get(id) || Promise.resolve()
|
|
192
194
|
const work = prior.catch(() => {}).then(async () => {
|
|
@@ -219,6 +221,11 @@ export class RunStore {
|
|
|
219
221
|
return runs.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
async pruneTaskHistory(taskId:string,keep=100):Promise<void>{
|
|
225
|
+
const terminal=(await this.list()).filter(run=>run.taskId===taskId&&['completed','failed','cancelled'].includes(run.status)&&(run.external===undefined||run.externalReleased===true)).sort((a,b)=>a.createdAt.localeCompare(b.createdAt))
|
|
226
|
+
for(const run of terminal.slice(0,Math.max(0,terminal.length-keep)))await rm(this.runPath(run.id),{force:true})
|
|
227
|
+
}
|
|
228
|
+
|
|
222
229
|
async running(background?: boolean): Promise<RunRecord | undefined> {
|
|
223
230
|
const runs = await this.list()
|
|
224
231
|
let first: RunRecord | undefined
|
|
@@ -231,7 +238,8 @@ export class RunStore {
|
|
|
231
238
|
}
|
|
232
239
|
|
|
233
240
|
async nextQueued(background?: boolean): Promise<RunRecord | undefined> {
|
|
234
|
-
|
|
241
|
+
const queued = (await this.list()).filter((run) => run.status === 'queued' && (background === undefined || Boolean(run.scheduled) === background))
|
|
242
|
+
return queued.find(run => !run.taskId) ?? queued[0]
|
|
235
243
|
}
|
|
236
244
|
|
|
237
245
|
async deliveryStatus(): Promise<{ failed: number; unknown: number }> {
|
|
@@ -440,6 +448,15 @@ export class RunStore {
|
|
|
440
448
|
return [...messages.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map(item => ({ id: item.id, text: item.text! }))
|
|
441
449
|
}
|
|
442
450
|
|
|
451
|
+
async applicationApprovals(runId:string):Promise<{id:string;prompt:string;state:string}[]> {
|
|
452
|
+
assertId(runId);await this.ensure();const result=[] as {id:string;prompt:string;state:string}[]
|
|
453
|
+
for(const name of await readdir(this.outboxDir))if(name.startsWith(`${runId}_`)&&name.endsWith('.json')&&!name.includes('.tmp')&&!name.endsWith('.failed.json'))try{
|
|
454
|
+
const item=JSON.parse(await readFile(path.join(this.outboxDir,name),'utf8')) as OutboxItem
|
|
455
|
+
if(item.runId===runId&&item.type==='approval'&&item.approvalActionId&&item.approvalPrompt)result.push({id:item.approvalActionId,prompt:item.approvalPrompt,state:name.endsWith('.sent.json')?'delivered':'pending'})
|
|
456
|
+
}catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error}
|
|
457
|
+
return result
|
|
458
|
+
}
|
|
459
|
+
|
|
443
460
|
async claimOutbox(id: string): Promise<boolean> {
|
|
444
461
|
assertId(id)
|
|
445
462
|
const from = path.join(this.outboxDir, `${id}.json`)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {mkdir,lstat,open,rm,readdir} from 'node:fs/promises'
|
|
2
|
+
import {constants} from 'node:fs'
|
|
3
|
+
import {join,basename} from 'node:path'
|
|
4
|
+
import {createHash} from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
export const attachmentLimit = 20 * 1024 * 1024
|
|
7
|
+
export type TaskAttachment = {runId:string;id:string;filename:string;bytes:number;sha256:string}
|
|
8
|
+
const hash = (data:Buffer) => createHash('sha256').update(data).digest('hex')
|
|
9
|
+
function directory(control:string,runId:string) {
|
|
10
|
+
if(!/^[a-zA-Z0-9_-]{1,160}$/.test(runId))throw Error('Invalid attachment run')
|
|
11
|
+
return join(control,'task-files',runId)
|
|
12
|
+
}
|
|
13
|
+
function validate(file:TaskAttachment) {
|
|
14
|
+
if(!file || !/^[a-f0-9-]{36}$/.test(file.id) || typeof file.filename!=='string' || !file.filename || file.filename!==basename(file.filename) || /[\\\x00-\x1f\x7f]/.test(file.filename) || file.filename.length>200 ||
|
|
15
|
+
!Number.isSafeInteger(file.bytes)||file.bytes<1||file.bytes>attachmentLimit||!/^[a-f0-9]{64}$/.test(file.sha256))throw Error('Invalid task attachment')
|
|
16
|
+
}
|
|
17
|
+
async function safeDirectory(control:string,runId:string,create=false) {
|
|
18
|
+
const dir=directory(control,runId)
|
|
19
|
+
for(const entry of [join(control,'task-files'),dir]) {
|
|
20
|
+
if(create)await mkdir(entry,{mode:0o700}).catch(e=>{if(e.code!=='EEXIST')throw e})
|
|
21
|
+
const stat=await lstat(entry);if(!stat.isDirectory()||stat.isSymbolicLink())throw Error('Unsafe attachment directory')
|
|
22
|
+
}
|
|
23
|
+
return dir
|
|
24
|
+
}
|
|
25
|
+
export async function stageTaskAttachment(control:string,runId:string,id:string,input:string,data:Buffer):Promise<TaskAttachment> {
|
|
26
|
+
const file={runId,id,filename:basename(input),bytes:data.length,sha256:hash(data)};validate(file)
|
|
27
|
+
const dir=await safeDirectory(control,runId,true)
|
|
28
|
+
// The task broker serializes its calls; bound the entire run, including unsent files.
|
|
29
|
+
let total=0
|
|
30
|
+
for(const entry of await readdir(dir)) {
|
|
31
|
+
const stat=await lstat(join(dir,entry))
|
|
32
|
+
if(!stat.isFile()||stat.isSymbolicLink())throw Error('Unsafe staged attachment')
|
|
33
|
+
if(entry!==id)total+=stat.size
|
|
34
|
+
}
|
|
35
|
+
if(total+data.length>attachmentLimit)throw Error('Task attachments exceed the 20 MiB run limit')
|
|
36
|
+
const fd=await open(join(dir,id),constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600).catch(async error=>{
|
|
37
|
+
if(error.code!=='EEXIST')throw error
|
|
38
|
+
await readTaskAttachment(control,file);return null
|
|
39
|
+
})
|
|
40
|
+
if(!fd)return file
|
|
41
|
+
try{await fd.writeFile(data)}finally{await fd.close()}
|
|
42
|
+
return file
|
|
43
|
+
}
|
|
44
|
+
export async function readTaskAttachment(control:string,file:TaskAttachment) {
|
|
45
|
+
validate(file)
|
|
46
|
+
const dir=await safeDirectory(control,file.runId)
|
|
47
|
+
const fd=await open(join(dir,file.id),constants.O_RDONLY|constants.O_NOFOLLOW)
|
|
48
|
+
try {
|
|
49
|
+
const stat=await fd.stat();if(!stat.isFile()||stat.size!==file.bytes)throw Error('Attachment changed')
|
|
50
|
+
const data=await fd.readFile();if(hash(data)!==file.sha256)throw Error('Attachment changed')
|
|
51
|
+
return data
|
|
52
|
+
}finally{await fd.close()}
|
|
53
|
+
}
|
|
54
|
+
export async function removeTaskAttachments(control:string,runId:string) {
|
|
55
|
+
await rm(directory(control,runId),{recursive:true,force:true})
|
|
56
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { executorEnvironment } from './executor.js'
|
|
6
|
+
import type { TaskCapability } from './tasks.js'
|
|
7
|
+
import {attachmentLimit,stageTaskAttachment,type TaskAttachment} from './task-attachments.js'
|
|
8
|
+
|
|
9
|
+
const execute = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
export async function assertChannelQuery(toolsHome: string | undefined, capability: TaskCapability) {
|
|
12
|
+
if (!toolsHome || !path.isAbsolute(toolsHome)) throw new Error('Channel capabilities require an installed tool registry')
|
|
13
|
+
const registry = JSON.parse(await readFile(path.join(toolsHome,'registry.json'),'utf8')) as {
|
|
14
|
+
commands?: Record<string,string>, plugins?: Record<string,{manifest?:{commands?:Record<string,{channelQuery?:boolean,channelFile?:boolean,exposure?:Record<string,boolean>}>}}>
|
|
15
|
+
}
|
|
16
|
+
const command = registry.plugins?.[registry.commands?.[capability.command] ?? '']?.manifest?.commands?.[capability.command]
|
|
17
|
+
const exposure = command?.exposure
|
|
18
|
+
if ((capability.output==='file'?command?.channelFile:command?.channelQuery) !== true || exposure?.receivesExternalContent !== true || exposure.sendsExternally !== false ||
|
|
19
|
+
exposure.changesRecords !== false || exposure.requiresReview !== false)
|
|
20
|
+
throw new Error(`Capability ${capability.id} is not an installed read-only channel query`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runTaskCapability(toolsHome: string | undefined, capability: TaskCapability, input: string, staging?:{controlDir:string;runId:string;lease:string}):Promise<{output:string;attachment?:never}|{attachment:TaskAttachment;output?:never}> {
|
|
24
|
+
await assertChannelQuery(toolsHome,capability)
|
|
25
|
+
const home = toolsHome!
|
|
26
|
+
const args = [capability.command, ...capability.args.map(value => value === '{input}' ? input : value)]
|
|
27
|
+
if(capability.output==='file') {
|
|
28
|
+
if(!staging)throw Error('File capability requires task staging')
|
|
29
|
+
const result=await execute(path.join(home,'bin','ez'),args,{encoding:'buffer',env:executorEnvironment(),timeout:30000,maxBuffer:attachmentLimit})
|
|
30
|
+
const attachment=await stageTaskAttachment(staging.controlDir,staging.runId,staging.lease,input,result.stdout)
|
|
31
|
+
return {attachment}
|
|
32
|
+
}
|
|
33
|
+
let stdout = '', code = 0
|
|
34
|
+
try {
|
|
35
|
+
const result = await execute(path.join(home,'bin','ez'),args,{encoding:'utf8',env:executorEnvironment(),timeout:15000,maxBuffer:131072})
|
|
36
|
+
stdout = result.stdout
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const failure = error as Error & {stdout?:string;code?:number|string}
|
|
39
|
+
stdout = typeof failure.stdout === 'string' ? failure.stdout : ''
|
|
40
|
+
code = typeof failure.code === 'number' ? failure.code : 1
|
|
41
|
+
}
|
|
42
|
+
if (Buffer.byteLength(stdout) > 131072) throw new Error('Channel capability output is too large')
|
|
43
|
+
if (code !== 0 || !stdout.trim()) throw new Error(`Channel capability failed with exit ${code}`)
|
|
44
|
+
return {output:stdout}
|
|
45
|
+
}
|