@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.49
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/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +30 -0
- package/Dockerfile +5 -1
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +100 -0
- package/docker/run.ts +1 -1
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +23 -4
- package/docs/docker-runtime.md +57 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +141 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +33 -4
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +98 -15
- package/src/application-channel.ts +52 -12
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/auth-profile.ts +53 -0
- package/src/client-defaults.ts +3 -2
- package/src/codex-session.ts +14 -3
- package/src/config.ts +9 -0
- package/src/control-state.ts +31 -22
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +26 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +81 -7
- package/src/files.ts +47 -3
- package/src/host-executor.ts +68 -34
- package/src/index.ts +227 -110
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +9 -7
- package/src/plugin-broker.mjs +50 -14
- package/src/plugins/manager.mjs +34 -8
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +24 -2
- package/src/schedule-cli.ts +100 -13
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +85 -19
- package/src/scripts.ts +167 -0
- package/src/setup-fallback.ts +122 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +13 -9
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +107 -30
- package/src/updates/supervisor.mjs +27 -6
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/auth-profiles.test.ts +187 -0
- package/test/config.test.ts +8 -0
- package/test/executor.test.ts +37 -0
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +45 -2
- package/test/install-json.test.mjs +21 -0
- package/test/plugin-broker.test.mjs +67 -0
- package/test/plugin-manager.test.mjs +44 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduler-host.test.ts +7 -0
- package/test/scheduler-relay.test.ts +38 -3
- package/test/scheduler.test.ts +1 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +100 -11
package/src/task-executor.ts
CHANGED
|
@@ -6,12 +6,14 @@ import { fileURLToPath } from 'node:url'
|
|
|
6
6
|
import { spawn, execFile } from 'node:child_process'
|
|
7
7
|
import { promisify } from 'node:util'
|
|
8
8
|
import { readRun } from './delivery-socket.js'
|
|
9
|
+
import {ApplicationBindings} from './application-channel.js'
|
|
9
10
|
import { Tasks } from './tasks.js'
|
|
11
|
+
import { provisionedHome } from './auth-profile.js'
|
|
10
12
|
import { executorEnvironment, executorInvocation, terminateJob, type ExecutorOptions } from './executor.js'
|
|
11
13
|
|
|
12
14
|
// This adapter is deliberately version-pinned: a new native tool default needs
|
|
13
15
|
// a fresh tool-inventory audit before external correspondence can use it.
|
|
14
|
-
export const TASK_CODEX_VERSION = '0.160.
|
|
16
|
+
export const TASK_CODEX_VERSION = '0.160.1'
|
|
15
17
|
export const taskDisabledFeatures = ['apps', 'browser_use', 'computer_use', 'in_app_browser', 'image_generation',
|
|
16
18
|
'memories', 'goals', 'multi_agent', 'multi_agent_v2', 'hooks', 'shell_tool', 'unified_exec', 'code_mode', 'code_mode_host',
|
|
17
19
|
'skill_search', 'skill_mcp_dependency_install', 'tool_suggest', 'workspace_dependencies', 'view_image']
|
|
@@ -23,8 +25,9 @@ export function taskModelCatalog(catalog: { models: Record<string, unknown>[] })
|
|
|
23
25
|
apply_patch_tool_type: null, experimental_supported_tools: [], multi_agent_version: null,
|
|
24
26
|
supports_search_tool: false, use_responses_lite: false })) };
|
|
25
27
|
}
|
|
26
|
-
export function taskArguments(directory: string, broker: string[], prompt: string, toolNames = ['context', 'send', 'note', 'report', 'complete'], selection: {model?:string;effort?:string} = {}) {
|
|
28
|
+
export function taskArguments(directory: string, broker: string[], prompt: string, toolNames = ['context', 'read_attachment', 'send', 'note', 'report', 'complete'], selection: {model?:string;effort?:string} = {}, brokerEnvironment: NodeJS.ProcessEnv = {}) {
|
|
27
29
|
const preset = executionDefaults('codex', selection)
|
|
30
|
+
const brokerEnv={HOME:homedir(),...Object.fromEntries(['EZ_PLUGIN_BROKER_SOCKET','EZ_DELIVERY_SOCKET'].flatMap(key=>brokerEnvironment[key] ? [[key,brokerEnvironment[key]]] : []))}
|
|
28
31
|
return ['exec', ...(preset.model ? ['--model',preset.model] : []), ...(preset.effort ? ['-c',`model_reasoning_effort=${JSON.stringify(preset.effort)}`] : []), '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--strict-config', '--json', '-C', directory,
|
|
29
32
|
...taskDisabledFeatures.flatMap(feature => ['--disable', feature]), '--enable', 'skip_host_skill_discovery',
|
|
30
33
|
'-c', `model_catalog_json=${JSON.stringify(join(directory, '..', 'models.json'))}`,
|
|
@@ -33,7 +36,7 @@ export function taskArguments(directory: string, broker: string[], prompt: strin
|
|
|
33
36
|
'-c', `permissions.ez-task.filesystem={":root"="deny",":minimal"="read",${JSON.stringify(directory)}="write"}`,
|
|
34
37
|
'-c', 'permissions.ez-task.network.enabled=false',
|
|
35
38
|
// The trusted broker needs the host's CLI configuration; the model keeps its isolated HOME.
|
|
36
|
-
'-c', `mcp_servers.ez={command=${JSON.stringify(broker[0])},args=${JSON.stringify(broker.slice(1))},env={
|
|
39
|
+
'-c', `mcp_servers.ez={command=${JSON.stringify(broker[0])},args=${JSON.stringify(broker.slice(1))},env={${Object.entries(brokerEnv).map(([key,value])=>`${key}=${JSON.stringify(value)}`).join(',')}},required=true,enabled_tools=${JSON.stringify(toolNames)}}`,
|
|
37
40
|
...toolNames.flatMap(name => ['-c', `mcp_servers.ez.tools.${name}.approval_mode="approve"`]),
|
|
38
41
|
'-'] // Literal input travels on stdin, including slash commands and leading options.
|
|
39
42
|
}
|
|
@@ -41,9 +44,10 @@ export async function startTaskExecutor(options: ExecutorOptions) {
|
|
|
41
44
|
const run = await readRun(options.controlDir, options.runId)
|
|
42
45
|
if (!run || run.status !== 'running') throw new Error('No active task run')
|
|
43
46
|
const task = await new Tasks(options.controlDir).authorize(run, false)
|
|
47
|
+
const applications=(run.application || task.anyConversation) ? [] : (await new ApplicationBindings(options.controlDir).list()).filter(b=>b.taskLaunch && (b.taskLaunch.tasks==="all" || b.taskLaunch.tasks.includes(task.id))).map(b=>b.id)
|
|
44
48
|
const capabilityNames = (task.capabilities ?? []).map(item => `capability_${item.id}`)
|
|
45
|
-
const toolNames = [...(task.anyConversation ? ['context','send'] : ['context','send','note','report','complete']),...capabilityNames]
|
|
46
|
-
if (capabilityNames.length && !options.toolsHome) throw new Error('Channel capabilities require an installed tool registry')
|
|
49
|
+
const toolNames = [...(task.anyConversation ? ['context','read_attachment','send'] : ['context','read_attachment','send','note','report','complete']),...capabilityNames,...(applications.length?["browser_link"]:[])]
|
|
50
|
+
if ((capabilityNames.length || (applications.length && !process.env.EZ_PLUGIN_BROKER_SOCKET)) && !options.toolsHome) throw new Error('Channel capabilities require an installed tool registry')
|
|
47
51
|
const environment = executorEnvironment()
|
|
48
52
|
const versionInvocation = executorInvocation('codex', ['--version'])
|
|
49
53
|
const version = await promisify(execFile)(versionInvocation.command, versionInvocation.args, { env: environment })
|
|
@@ -55,14 +59,14 @@ export async function startTaskExecutor(options: ExecutorOptions) {
|
|
|
55
59
|
const catalogInvocation = executorInvocation('codex', ['debug', 'models', '--bundled'])
|
|
56
60
|
const catalog = await promisify(execFile)(catalogInvocation.command, catalogInvocation.args, { env: environment, maxBuffer: 4 * 1024 * 1024 })
|
|
57
61
|
await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
|
|
58
|
-
const auth = process.env.EZ_ISOLATION === 'isolated'
|
|
59
|
-
? join(options.controlDir, '
|
|
62
|
+
const auth = process.env.EZ_ISOLATION === 'isolated' || options.authProfile !== undefined
|
|
63
|
+
? join(await provisionedHome(options.controlDir, 'codex', options.authProfile), 'auth.json')
|
|
60
64
|
: join(homedir(), '.codex', 'auth.json')
|
|
61
65
|
await symlink(auth, join(home, 'auth.json'))
|
|
62
66
|
const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
|
|
63
|
-
fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
|
|
67
|
+
fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? []),JSON.stringify(applications)]
|
|
64
68
|
const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
|
|
65
|
-
const invocation = executorInvocation('codex', taskArguments(directory, broker, prompt, toolNames, options))
|
|
69
|
+
const invocation = executorInvocation('codex', taskArguments(directory, broker, prompt, toolNames, options, Object.fromEntries(['EZ_PLUGIN_BROKER_SOCKET','EZ_DELIVERY_SOCKET'].flatMap(key=>environment[key] ? [[key,environment[key]]] : []))))
|
|
66
70
|
const child = spawn(invocation.command, invocation.args, {
|
|
67
71
|
cwd: directory, env: { ...environment, HOME: home, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32',
|
|
68
72
|
})
|
package/src/task-mcp.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import {randomBytes,createHash} from 'node:crypto'
|
|
1
2
|
import { createInterface } from 'node:readline'
|
|
2
3
|
import { taskCall } from './task-rpc.js'
|
|
3
|
-
import { runTaskCapability } from './task-capability.js'
|
|
4
|
+
import { runTaskCapability,launchTaskApplication } from './task-capability.js'
|
|
4
5
|
import type { TaskCapability } from './tasks.js'
|
|
5
|
-
const [controlDir, runId, toolsHome, capabilityJson='[]'] = process.argv.slice(2)
|
|
6
|
+
const [controlDir, runId, toolsHome, capabilityJson='[]', applicationsJson='[]'] = process.argv.slice(2)
|
|
6
7
|
const capabilities = JSON.parse(capabilityJson) as TaskCapability[]
|
|
8
|
+
const applications=JSON.parse(applicationsJson) as string[]
|
|
7
9
|
const descriptions: Record<string, string> = {
|
|
10
|
+
...(applications.length ? {browser_link:`Create a short-lived single-use browser conversation link for this discussion. Applications: ${applications.join(", ")}. Return the exact link as text to this same discussion; the link is a private bearer credential.`} : {}),
|
|
8
11
|
context: 'Read the owner-approved purpose and shareable context, task notes, receipts, and untrusted correspondence.',
|
|
9
|
-
|
|
12
|
+
read_attachment: 'Read a JPEG/PNG/WebP image, an MP4 video as sampled frames (up to 120 seconds), or a TXT/Markdown/PDF document from an incomingId in this run context. Returns the image to native vision, or bounded extracted document text, with file provenance; no owner files are accessible.',
|
|
13
|
+
send: 'Send text, or set voice=true for a spoken reply (at most 1000 characters). Optionally use an attachmentId returned by an approved file capability to this conversation. Reuse the same key for the same message. Uncertain means do not retry with a new key.',
|
|
10
14
|
note: 'Save a task-scoped note. No owner files or memory are accessible.',
|
|
11
15
|
report: 'Report task evidence or a blocker to the owner. This is a report, never an owner instruction.',
|
|
12
16
|
complete: 'Report the result and close a finite task, stopping further messages and replies. Not available for incoming-only watches; save a note and end the run instead.',
|
|
@@ -15,8 +19,8 @@ const descriptions: Record<string, string> = {
|
|
|
15
19
|
const tools = Object.entries(descriptions).map(([name, description]) => {
|
|
16
20
|
const capability = name.startsWith('capability_')
|
|
17
21
|
return { name, description, inputSchema: {
|
|
18
|
-
type: 'object', properties: name === 'context' ? {} : capability ? {input:{type:'string',maxLength:1000}} : { text: { type: 'string', maxLength: 4096 }, ...(name === 'send' ? { key: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,80}$' }, attachmentId:{type:'string',pattern:'^[a-f0-9-]{36}$'} } : {}) },
|
|
19
|
-
required: name === 'context' ? [] : capability ? ['input'] : name === 'send' ? ['text', 'key'] : ['text'], additionalProperties: false,
|
|
22
|
+
type: 'object', properties: name==='browser_link' ? {application:{type:'string',enum:applications}} : name === 'context' ? {} : name === 'read_attachment' ? {incomingId:{type:'string',maxLength:100}} : capability ? {input:{type:'string',maxLength:1000}} : { text: { type: 'string', maxLength: 4096 }, ...(name === 'send' ? { voice: {type:'boolean'}, key: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,80}$' }, attachmentId:{type:'string',pattern:'^[a-f0-9-]{36}$'} } : {}) },
|
|
23
|
+
required: name==='browser_link' ? ['application'] : name === 'context' ? [] : name === 'read_attachment' ? ['incomingId'] : capability ? ['input'] : name === 'send' ? ['text', 'key'] : ['text'], additionalProperties: false,
|
|
20
24
|
} }
|
|
21
25
|
})
|
|
22
26
|
for await (const line of createInterface({ input: process.stdin })) {
|
|
@@ -33,16 +37,24 @@ for await (const line of createInterface({ input: process.stdin })) {
|
|
|
33
37
|
if (!Object.hasOwn(descriptions, name)) throw new Error('Unknown task tool')
|
|
34
38
|
const args = request.params.arguments ?? {}
|
|
35
39
|
const capabilityId = typeof name === 'string' && name.startsWith('capability_') ? name.slice(11) : undefined
|
|
36
|
-
if (Object.keys(args).some(key => capabilityId ? key !== 'input' : !['text', ...(name === 'send' ? ['key','attachmentId'] : [])].includes(key))) throw new Error('Unexpected tool argument')
|
|
40
|
+
if (Object.keys(args).some(key => name==='browser_link' ? key!=='application' : name === 'read_attachment' ? key !== 'incomingId' : capabilityId ? key !== 'input' : !['text', ...(name === 'send' ? ['key','attachmentId','voice'] : [])].includes(key))) throw new Error('Unexpected tool argument')
|
|
37
41
|
try {
|
|
38
42
|
let data: unknown
|
|
39
|
-
if
|
|
43
|
+
if(name==='browser_link') {
|
|
44
|
+
if(!applications.includes(args.application))throw Error('Application is not authorized')
|
|
45
|
+
const token=randomBytes(32).toString('hex'),ticketHash=createHash('sha256').update(token).digest('hex')
|
|
46
|
+
const grant=await taskCall(controlDir,runId,'worker','application_launch',{application:args.application,ticketHash}) as {launch:{command:string;args:string[]};taskId:string;expiresAt:number}
|
|
47
|
+
data=await launchTaskApplication(toolsHome,grant.launch,{taskId:grant.taskId,taskToken:token,expiresAt:grant.expiresAt},runId)
|
|
48
|
+
} else if (capabilityId) {
|
|
40
49
|
const begun = await taskCall(controlDir,runId,'worker','capability_begin',{id:capabilityId,input:args.input}) as {capability:TaskCapability;lease:string}
|
|
41
50
|
const result = await runTaskCapability(toolsHome || undefined,begun.capability,args.input,{controlDir,runId,lease:begun.lease})
|
|
42
51
|
await taskCall(controlDir,runId,'worker','capability_result',{id:capabilityId,input:args.input,lease:begun.lease,...('attachment' in result?{attachment:result.attachment}:{})})
|
|
43
52
|
data = result.attachment ? {attachmentId:result.attachment.id,filename:result.attachment.filename,bytes:result.attachment.bytes} : result
|
|
44
53
|
} else data = await taskCall(controlDir, runId, 'worker', name, args)
|
|
45
|
-
|
|
54
|
+
if (name === 'read_attachment' && data && typeof data === 'object' && ('image' in data || 'images' in data)) {
|
|
55
|
+
const {image,images,...metadata} = data as {image?:{data:string;mimeType:string};images?:{data:string;mimeType:string}[];[key:string]:unknown}
|
|
56
|
+
result = { content: [{type:'text',text:JSON.stringify(metadata)}, ...(images ?? [image!]).map(value=>({type:'image',...value}))] }
|
|
57
|
+
} else result = { content: [{ type: 'text', text: JSON.stringify(data) }] }
|
|
46
58
|
}
|
|
47
59
|
catch (error) { result = { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Task tool failed' }] } }
|
|
48
60
|
} else throw new Error('Unsupported MCP method')
|
package/src/tasks.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, readFile, readdir, rename, writeFile, unlink } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { randomUUID, createHash } from 'node:crypto'
|
|
4
4
|
import { ControlStore, sameOwner, ownerId, ownerEpoch, type Owner } from './control-state.js'
|
|
@@ -7,6 +7,8 @@ import { EventSources, sourceCall, type SourceEvent } from './event-sources.js'
|
|
|
7
7
|
import { RunStore, type RunRecord } from './runs.js'
|
|
8
8
|
import { requireOwnerExecution } from './execution-authority.js'
|
|
9
9
|
import { ownsRun } from './identity.js'
|
|
10
|
+
import {MAX_INCOMING_ATTACHMENT_BYTES,readChatAttachment} from './files.js'
|
|
11
|
+
import {ApplicationBindings} from './application-channel.js'
|
|
10
12
|
import {readTaskAttachment,type TaskAttachment} from './task-attachments.js'
|
|
11
13
|
|
|
12
14
|
export type TaskCapability = { id: string; description: string; command: string; args: string[]; output?:'file' }
|
|
@@ -23,7 +25,7 @@ export type Task = {
|
|
|
23
25
|
purpose: string; context: string; createdAt: number; expiresAt: number
|
|
24
26
|
state: 'pending' | 'active' | 'revoked' | 'completed'
|
|
25
27
|
grant?: string; initialRunDispatched?: true
|
|
26
|
-
notes: string[]; operations: Record<string, { text: string; attachmentId?:string; state: 'uncertain' | 'accepted'; receipt?: unknown }>
|
|
28
|
+
notes: string[]; operations: Record<string, { text: string; attachmentId?:string; voice?:true; state: 'uncertain' | 'accepted'; receipt?: unknown }>
|
|
27
29
|
capabilityOperations?: Record<string, { capabilityId: string; inputHash: string; lease: string; attachment?:TaskAttachment; state: 'authorized' | 'completed' }>
|
|
28
30
|
publicBudget?: PublicBudget
|
|
29
31
|
}
|
|
@@ -45,7 +47,7 @@ export async function atomicTaskFile(file: string, value: unknown) {
|
|
|
45
47
|
}
|
|
46
48
|
export class Tasks {
|
|
47
49
|
private work: Promise<unknown> = Promise.resolve()
|
|
48
|
-
constructor(readonly controlDir: string) {}
|
|
50
|
+
constructor(readonly controlDir: string, private speech?: (text:string) => Promise<{buffer:Buffer;mimeType:string}>) {}
|
|
49
51
|
private serial<T>(fn: () => Promise<T>): Promise<T> {
|
|
50
52
|
const next = this.work.then(fn, fn); this.work = next.catch(() => {}); return next
|
|
51
53
|
}
|
|
@@ -90,12 +92,16 @@ export class Tasks {
|
|
|
90
92
|
}
|
|
91
93
|
async authorize(run: RunRecord, checkProvider = true): Promise<Task> {
|
|
92
94
|
const task = run.taskId ? await this.get(run.taskId) : null
|
|
93
|
-
if (!task || (task.waitForIncoming && !run.external) || task.state !== 'active' || task.expiresAt <= Date.now() || run.version !== 2 ||
|
|
95
|
+
if (!task || (task.waitForIncoming && !run.external && !run.application?.taskAccess) || task.state !== 'active' || task.expiresAt <= Date.now() || run.version !== 2 ||
|
|
94
96
|
!ownsRun(task.owner, run))
|
|
95
97
|
throw new Error('Task is inactive or expired')
|
|
96
98
|
const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
|
|
97
99
|
if (!sameOwner(task.owner, owner) || (task.grant ? task.grant !== this.scopeHash(task) : !await this.approved(task)))
|
|
98
100
|
throw new Error('Task approval is no longer valid')
|
|
101
|
+
if(run.application?.taskAccess) {
|
|
102
|
+
const access=await this.applicationAccess(run.application.taskAccess,run.application.bindingId,checkProvider)
|
|
103
|
+
if(access.task.id!==task.id)throw Error('Task application scope mismatch')
|
|
104
|
+
}
|
|
99
105
|
if (checkProvider) await this.source(task)
|
|
100
106
|
if (run.external && checkProvider) {
|
|
101
107
|
if (run.external.sourceId !== task.sourceId || run.external.bindingId !== task.bindingId) throw new Error('Task origin mismatch')
|
|
@@ -106,6 +112,27 @@ export class Tasks {
|
|
|
106
112
|
}
|
|
107
113
|
return task
|
|
108
114
|
}
|
|
115
|
+
async authorizeApplicationLaunch(runId:string,ticketHash:string) {
|
|
116
|
+
const run=await new RunStore(this.controlDir).get(runId)
|
|
117
|
+
if(!run || run.status!=='running' || run.application)throw Error('No active channel task')
|
|
118
|
+
const task=await this.authorize(run)
|
|
119
|
+
if(!/^[a-f0-9]{64}$/.test(ticketHash))throw Error('Invalid task launch')
|
|
120
|
+
const record=JSON.parse(await readFile(join(this.controlDir,'task-applications',ticketHash+'.json'),'utf8'))
|
|
121
|
+
const access=await this.applicationAccess(ticketHash,record.bindingId)
|
|
122
|
+
if(access.runId!==runId || access.task.id!==task.id)throw Error('Task launch admission mismatch')
|
|
123
|
+
const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.bindingId===record.bindingId)!
|
|
124
|
+
return {launch:binding.taskLaunch!,taskId:task.id,expiresAt:access.expiresAt}
|
|
125
|
+
}
|
|
126
|
+
async applicationAccess(ticketHash: string, bindingId: string, checkProvider=true) {
|
|
127
|
+
if(!/^[a-f0-9]{64}$/.test(ticketHash))throw Error('Invalid task access')
|
|
128
|
+
const record=JSON.parse(await readFile(join(this.controlDir,'task-applications',ticketHash+'.json'),'utf8'))
|
|
129
|
+
const task=await this.get(record.taskId), owner=(await new ControlStore(this.controlDir,900000).status()).owner
|
|
130
|
+
const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.bindingId===bindingId)
|
|
131
|
+
if(!task || task.state!=='active' || task.expiresAt<=Date.now() || record.expiresAt<=Date.now() || record.bindingId!==bindingId || record.grant!==this.scopeHash(task) || !binding?.taskLaunch || (binding.taskLaunch.tasks!=="all" && !binding.taskLaunch.tasks.includes(task.id)) || !sameOwner(binding.owner,owner) || !sameOwner(task.owner,owner) || (task.grant ? task.grant!==this.scopeHash(task) : !await this.approved(task)))throw Error('Task application access revoked or expired')
|
|
132
|
+
if(checkProvider)await this.source(task)
|
|
133
|
+
if(!bounded(record.contact,200) || (!task.anyConversation && record.contact!==task.conversationId))throw Error('Task application contact mismatch')
|
|
134
|
+
return {task,contact:record.contact,runId:record.runId as string,expiresAt:record.expiresAt as number,execution:record.execution as RunRecord['execution']}
|
|
135
|
+
}
|
|
109
136
|
// Persist only the bounded grant, not transient approval/outbox state. Bind every
|
|
110
137
|
// immutable authority field so later scope changes cannot reuse an old grant.
|
|
111
138
|
private scopeHash(task: Task) {
|
|
@@ -258,14 +285,44 @@ export class Tasks {
|
|
|
258
285
|
const run = await new RunStore(this.controlDir).get(runId)
|
|
259
286
|
if (!run || run.status !== 'running') throw new Error('No active task run')
|
|
260
287
|
const task = await this.authorize(run)
|
|
288
|
+
const access=run.application?.taskAccess ? await this.applicationAccess(run.application.taskAccess,run.application.bindingId) : undefined
|
|
261
289
|
const incoming = run.external ? await new EventSources(this.controlDir).check(run.external, task.owner) : []
|
|
262
|
-
const contact = task.anyConversation ? incoming[0]?.conversationId : task.conversationId
|
|
290
|
+
const contact = access?.contact ?? (task.anyConversation ? incoming[0]?.conversationId : task.conversationId)
|
|
263
291
|
if (!contact || incoming.some(event => event.conversationId !== contact)) throw new Error('Task correspondence changed')
|
|
292
|
+
if(command==='application_launch') {
|
|
293
|
+
if(run.application || task.anyConversation || Object.keys(args).some(k=>!['application','ticketHash'].includes(k)) || typeof args.ticketHash!=='string' || !/^[a-f0-9]{64}$/.test(args.ticketHash))throw Error('Invalid task application launch')
|
|
294
|
+
const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.id===args.application && b.taskLaunch && sameOwner(b.owner,task.owner))
|
|
295
|
+
if(!binding?.taskLaunch || (binding.taskLaunch.tasks!=="all" && !binding.taskLaunch.tasks.includes(task.id)))throw Error('Task application launch is not authorized')
|
|
296
|
+
const root=join(this.controlDir,'task-applications');await mkdir(root,{recursive:true,mode:0o700})
|
|
297
|
+
let count=0
|
|
298
|
+
for(const file of await readdir(root))if(/^[a-f0-9]{64}\.json$/.test(file)) {
|
|
299
|
+
const value=await readFile(join(root,file),'utf8').then(JSON.parse,()=>null);if(!value || !(value.expiresAt>Date.now()))await unlink(join(root,file)).catch(()=>{});else count++
|
|
300
|
+
}
|
|
301
|
+
if(count>=128)throw Error('Too many task application launches')
|
|
302
|
+
const expiresAt=Math.min(Date.now()+20*60*1000,task.expiresAt)
|
|
303
|
+
await atomicTaskFile(join(root,args.ticketHash+'.json'),{taskId:task.id,bindingId:binding.bindingId,grant:this.scopeHash(task),contact,expiresAt,runId:run.id,execution:run.execution})
|
|
304
|
+
return {launch:binding.taskLaunch,taskId:task.id,expiresAt}
|
|
305
|
+
}
|
|
264
306
|
if (command === 'context') {
|
|
265
307
|
return { purpose: task.purpose, context: task.context, contact,
|
|
266
308
|
waitForIncoming: task.waitForIncoming === true, capabilities: task.capabilities?.map(({id,description}) => ({id,description})),
|
|
267
309
|
expiresAt: task.untilRevoked ? null : task.expiresAt, notes: task.notes, operations: task.untilRevoked ? Object.fromEntries(Object.entries(task.operations).filter(([key])=>key.startsWith(`${run.id}_`))) : task.operations,
|
|
268
|
-
incoming }
|
|
310
|
+
incoming, ...(access ? {request:run.texts.join("\n")} : {}) }
|
|
311
|
+
}
|
|
312
|
+
if (command === 'read_attachment') {
|
|
313
|
+
if (!run.external || typeof args.incomingId !== 'string' || !run.external.eventIds.includes(args.incomingId) || !incoming.some(event => event.id === args.incomingId)) throw Error('Document is outside this incoming task run')
|
|
314
|
+
const source = (await new EventSources(this.controlDir).available(task.owner)).find(item => item.id === task.sourceId && item.bindingId === task.bindingId)
|
|
315
|
+
if (!source) throw Error('Task source changed')
|
|
316
|
+
const maximum = Math.ceil(MAX_INCOMING_ATTACHMENT_BYTES / 3) * 4
|
|
317
|
+
const file = await sourceCall(source.socketPath, 'task-document', {accountId:task.accountId,conversationId:contact,incomingId:args.incomingId}, maximum + 1000)
|
|
318
|
+
if (!file || typeof file.name !== 'string' || file.name.length > 255 || typeof file.data !== 'string' || file.data.length > maximum || !/^[a-f0-9]{64}$/.test(file.sha256)) throw Error('Invalid incoming document')
|
|
319
|
+
const bytes = Buffer.from(file.data,'base64')
|
|
320
|
+
if (bytes.toString('base64') !== file.data || createHash('sha256').update(bytes).digest('hex') !== file.sha256) throw Error('Incoming document changed')
|
|
321
|
+
const result = await readChatAttachment(this.controlDir,file.name,bytes)
|
|
322
|
+
const current = await new RunStore(this.controlDir).get(runId)
|
|
323
|
+
if (!current || current.status !== 'running') throw Error('Task run cancelled')
|
|
324
|
+
await this.authorize(current)
|
|
325
|
+
return {incomingId:args.incomingId,sha256:file.sha256,...result}
|
|
269
326
|
}
|
|
270
327
|
if (command === 'capability_begin' || command === 'capability_result') {
|
|
271
328
|
const selected = task.capabilities?.find(item => item.id === args.id)
|
|
@@ -306,20 +363,35 @@ export class Tasks {
|
|
|
306
363
|
return { queued: item.id }
|
|
307
364
|
}
|
|
308
365
|
if (command !== 'send' || typeof args.key !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(args.key)) throw new Error('Invalid task send')
|
|
366
|
+
if (args.voice !== undefined && typeof args.voice !== 'boolean') throw new Error('Invalid voice flag')
|
|
367
|
+
if (args.voice && (args.attachmentId !== undefined || args.text.length > 1000)) throw new Error('Voice replies require at most 1000 characters and no attachment')
|
|
309
368
|
let attachment:TaskAttachment|undefined
|
|
310
369
|
if(args.attachmentId!==undefined) {
|
|
311
370
|
const operation=Object.entries(task.capabilityOperations??{}).find(([key,value])=>key.startsWith(`${run.id}_`)&&value.lease===args.attachmentId&&value.state==='completed')?.[1]
|
|
312
371
|
attachment=operation?.attachment
|
|
313
372
|
if(!attachment||attachment.runId!==run.id)throw Error('Attachment is not authorized for this run')
|
|
314
373
|
}
|
|
374
|
+
if(access) {
|
|
375
|
+
if(args.voice || args.attachmentId!==undefined)throw Error('Application task replies support text only')
|
|
376
|
+
const key=`${run.id}_${args.key}`, prior=task.operations[key]
|
|
377
|
+
if(prior){if(prior.text!==args.text)throw Error('Message key already used for different text');return prior}
|
|
378
|
+
const item=await new RunStore(this.controlDir).enqueueMessage(run.id,args.text)
|
|
379
|
+
task.operations[key]={text:args.text,state:'accepted',receipt:{application:run.application!.bindingId,queued:item.id}}
|
|
380
|
+
await this.save(task);return task.operations[key]
|
|
381
|
+
}
|
|
315
382
|
const key = task.untilRevoked ? `${run.id}_${args.key}` : args.key
|
|
316
383
|
const providerKey = `${task.id}_${task.untilRevoked ? createHash('sha256').update(key).digest('hex') : key}`
|
|
317
384
|
const prior = Object.hasOwn(task.operations, key) ? task.operations[key] : undefined
|
|
318
385
|
if (prior) {
|
|
319
|
-
if (prior.text !== args.text || prior.attachmentId!==args.attachmentId) throw new Error('Message key already used for different text or attachment')
|
|
386
|
+
if (prior.text !== args.text || prior.attachmentId!==args.attachmentId || Boolean(prior.voice)!==Boolean(args.voice)) throw new Error('Message key already used for different text or attachment')
|
|
320
387
|
return prior // Uncertain sends are never blindly retried.
|
|
321
388
|
}
|
|
322
389
|
const source = await this.source(task)
|
|
390
|
+
if(args.voice) {
|
|
391
|
+
const head=await sourceCall(source.socketPath,'events-head')
|
|
392
|
+
if(head.taskVoice!==true)throw Error('This channel does not support voice replies')
|
|
393
|
+
if(!this.speech)throw Error('Speech synthesis is not configured')
|
|
394
|
+
}
|
|
323
395
|
if(attachment) {
|
|
324
396
|
const head=await sourceCall(source.socketPath,'events-head')
|
|
325
397
|
if(head.taskAttachments!==true)throw Error('This channel does not support task attachments')
|
|
@@ -334,14 +406,22 @@ export class Tasks {
|
|
|
334
406
|
task.operations=Object.fromEntries(Object.entries(task.operations).filter(([item,value])=>active.some(prefix=>item.startsWith(prefix))||value.state==='uncertain'))
|
|
335
407
|
}
|
|
336
408
|
if (Object.keys(task.operations).filter(k=>!task.untilRevoked || k.startsWith(`${run.id}_`)).length >= 30) throw new Error('Task message limit reached; report to the owner')
|
|
337
|
-
const message={text:args.text,...(attachment?{attachmentId:attachment.id}:{})}
|
|
409
|
+
const message={text:args.text,...(args.voice?{voice:true as const}:{}),...(attachment?{attachmentId:attachment.id}:{})}
|
|
410
|
+
// Synthesis happens before the provider is contacted, so its failure is a
|
|
411
|
+
// definite non-send the engine may correct, not an uncertain operation.
|
|
412
|
+
const audio = args.voice ? await this.speech!(args.text) : undefined
|
|
413
|
+
if(audio && (audio.mimeType!=='audio/ogg' || audio.buffer.length>256000 || audio.buffer.subarray(0,4).toString()!=='OggS'))throw Error('Voice audio exceeds channel limits or has invalid format')
|
|
338
414
|
task.operations = { ...task.operations, [key]: { ...message, state: 'uncertain' } }
|
|
339
415
|
await this.save(task)
|
|
340
416
|
try {
|
|
417
|
+
const current = await new RunStore(this.controlDir).get(run.id)
|
|
418
|
+
if (!current || current.status !== 'running') throw new Error('Task run ended before voice dispatch')
|
|
419
|
+
await this.authorize(current)
|
|
420
|
+
if ((await new RunStore(this.controlDir).get(run.id))?.status !== 'running') throw new Error('Task run ended before dispatch')
|
|
341
421
|
if (task.expiresAt <= Date.now()) throw new Error('Task expired before dispatch')
|
|
342
422
|
const receipt = await sourceCall(source.socketPath, 'task-send', {
|
|
343
423
|
accountId: task.accountId, conversationId: contact, text: args.text, key: providerKey,
|
|
344
|
-
...(attachment?{attachment}:{}),
|
|
424
|
+
...(attachment?{attachment}:{}), ...(audio?{audio:{data:audio.buffer.toString('base64'),mimeType:audio.mimeType}}:{}),
|
|
345
425
|
})
|
|
346
426
|
if (receipt.accountId !== task.accountId || receipt.conversationId !== contact || receipt.key !== providerKey || receipt.state !== 'accepted')
|
|
347
427
|
throw new Error('Uncertain provider receipt')
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { parseEnv } from 'node:util';
|
|
9
|
-
import {
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { atomic, compose, snapshot, checkFolders, locked, publishBrokerPlugins } from '../plugins/manager.mjs';
|
|
10
11
|
import { read, state, eligibility, jobPath, cleanupStaleBackups } from './control.mjs';
|
|
11
12
|
import { bindUpdates } from './binding.mjs';
|
|
12
13
|
import { extract, digest } from './artifact.mjs';
|
|
@@ -62,10 +63,63 @@ export async function relayServices(config) {
|
|
|
62
63
|
const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
|
|
63
64
|
return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// Explicitly rebind single-file mounts after atomic replacement. Pin the running image
|
|
67
|
+
// while recreating only the broker with this deployment's Compose identity.
|
|
68
|
+
export async function refreshBroker(config, run=execute, image) {
|
|
69
|
+
if (!(await relayServices(config)).includes('plugin-broker')) return null;
|
|
70
|
+
const args=relayArgs(config);
|
|
71
|
+
if(image===undefined) {
|
|
72
|
+
const id=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
|
|
73
|
+
if(!/^[a-f0-9]{12,64}$/i.test(id))throw Error('Expected one installed isolated broker');
|
|
74
|
+
image=(await run('docker',['inspect','--format','{{.Image}}',id],{stdoutOnly:true,timeout:15000})).trim();
|
|
75
|
+
}
|
|
76
|
+
if(!/^sha256:[a-f0-9]{64}$/.test(image))throw Error('Cannot pin broker image');
|
|
77
|
+
const temporary=await fs.mkdtemp(path.join(tmpdir(),'ez-broker-refresh-'));
|
|
78
|
+
try {
|
|
79
|
+
const pin=path.join(temporary,'image.env');
|
|
80
|
+
await fs.writeFile(pin,`EZ_RELAY_IMAGE=${image}\n`,{mode:0o600});
|
|
81
|
+
await run('docker',[...args,'--env-file',pin,'up','-d','--no-deps','--force-recreate','--no-build','--pull','never','--wait','--wait-timeout','90','plugin-broker']);
|
|
82
|
+
const current=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
|
|
83
|
+
if(!/^[a-f0-9]{12,64}$/i.test(current)||(await run('docker',['inspect','--format','{{.Image}}',current],{stdoutOnly:true,timeout:15000})).trim()!==image)
|
|
84
|
+
throw Error('Recreated broker image does not match the intended installed image');
|
|
85
|
+
return verifyBroker(config,run);
|
|
86
|
+
}finally {await fs.rm(temporary,{recursive:true,force:true});}
|
|
87
|
+
}
|
|
88
|
+
// A plugin update normally leaves this single-file binding unchanged. If an
|
|
89
|
+
// operator replaced it (for example to approve new network pins), recreation
|
|
90
|
+
// remains necessary and must retain the existing full-turn drain.
|
|
91
|
+
export async function brokerNeedsRefresh(config,run=execute) {
|
|
92
|
+
if (!(await relayServices(config)).includes('plugin-broker')) return false;
|
|
93
|
+
const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
|
|
94
|
+
const expected=digest(await fs.readFile(hostConfig));
|
|
95
|
+
const script="const fs=require('fs'),crypto=require('crypto');console.log(crypto.createHash('sha256').update(fs.readFileSync(process.env.EZ_PLUGIN_BROKER_HOST_CONFIG)).digest('hex'));";
|
|
96
|
+
const mounted=(await run('docker',[...relayArgs(config),'exec','-T','plugin-broker','node','-e',script],{stdoutOnly:true,timeout:15000})).trim();
|
|
97
|
+
if(!/^[a-f0-9]{64}$/.test(mounted))throw Error('Invalid broker host binding fingerprint');
|
|
98
|
+
return mounted!==expected;
|
|
68
99
|
}
|
|
100
|
+
// Plugin upgrades change the directory-mounted registry, not the broker's
|
|
101
|
+
// single-file host binding. Verify the live broker without replacing its socket.
|
|
102
|
+
export async function verifyBroker(config,run=execute) {
|
|
103
|
+
if (!(await relayServices(config)).includes('plugin-broker')) return null;
|
|
104
|
+
const args=relayArgs(config);
|
|
105
|
+
const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
|
|
106
|
+
const home=path.dirname(hostConfig)===config.deploymentDir
|
|
107
|
+
? (await read(hostConfig)).agents?.find(agent=>agent.workspace===config.workspace)?.toolsHome : undefined;
|
|
108
|
+
if(typeof home!=='string'||!path.isAbsolute(home))throw Error('Missing broker registry binding');
|
|
109
|
+
const files=[hostConfig,path.join(home,'registry.json'),path.join(home,'config.json')];
|
|
110
|
+
const hashes=await Promise.all(files.map(async file=>digest(await fs.readFile(file))));
|
|
111
|
+
// Supply trusted read-only code so rollback can check an older image that
|
|
112
|
+
// predates this diagnostic. Its validators still come from that image.
|
|
113
|
+
const diagnostic=await fs.readFile(new URL('../../docker/broker-readiness.mjs',import.meta.url),'utf8');
|
|
114
|
+
const probe=JSON.parse(await run('docker',[...args,'exec','-T','plugin-broker','node','--import','/app/node_modules/tsx/dist/loader.mjs',
|
|
115
|
+
'--input-type=module','-e',diagnostic,...hashes],{stdoutOnly:true,timeout:60000}));
|
|
116
|
+
if(probe.version!==1||probe.ready!==true||probe.hostSha256!==hashes[0]||probe.registrySha256!==hashes[1]||probe.configSha256!==hashes[2])
|
|
117
|
+
throw Error('Invalid isolated broker readiness readback');
|
|
118
|
+
// A concurrent writer invalidates the readback; never silently accept it.
|
|
119
|
+
for(let i=0;i<files.length;i++)if(digest(await fs.readFile(files[i]))!==hashes[i])throw Error('Broker binding changed during verification');
|
|
120
|
+
return probe;
|
|
121
|
+
}
|
|
122
|
+
|
|
69
123
|
const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
|
|
70
124
|
async function healthEvidence(config,run) {
|
|
71
125
|
const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
@@ -106,6 +160,17 @@ export async function perform(home,job,hooks) {
|
|
|
106
160
|
const next=await eligibility(home,job.target,root,job.automatic);
|
|
107
161
|
if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
|
|
108
162
|
job.status='applying';job.root=root;job.startedAt=new Date().toISOString();await save();
|
|
163
|
+
const complete=async()=>{
|
|
164
|
+
job.status='completed';job.endedAt=new Date().toISOString();await save();
|
|
165
|
+
try { job.cleanup=await cleanupStaleBackups(home);await save(); }
|
|
166
|
+
catch(error) { job.cleanupError=error.message;await save().catch(()=>{}); }
|
|
167
|
+
return job;
|
|
168
|
+
};
|
|
169
|
+
const fail=async(error,recoveryHooks)=>{
|
|
170
|
+
let message=error.message;
|
|
171
|
+
if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
|
|
172
|
+
job.error=message;await save();return recover(home,job,recoveryHooks);
|
|
173
|
+
};
|
|
109
174
|
try {
|
|
110
175
|
if(job.target==='main') {
|
|
111
176
|
job.packageManager=await packageManager(root,run);await save();
|
|
@@ -143,40 +208,50 @@ export async function perform(home,job,hooks) {
|
|
|
143
208
|
await textAtomic(envFile,env);
|
|
144
209
|
await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),root);
|
|
145
210
|
await hooks.startHost(root);
|
|
146
|
-
await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build'
|
|
211
|
+
await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
|
|
212
|
+
if((await relayServices(config)).includes('plugin-broker')) {
|
|
213
|
+
const brokerImage=(await run('docker',['image','inspect','--format','{{.Id}}',image],{stdoutOnly:true})).trim();
|
|
214
|
+
job.brokerReadiness=await refreshBroker(config,run,brokerImage);
|
|
215
|
+
}
|
|
147
216
|
} else {
|
|
148
|
-
const old=next.old.record
|
|
217
|
+
const old=next.old.record;
|
|
149
218
|
const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
150
219
|
const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
|
|
151
220
|
for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
|
|
152
221
|
await checkFolders(config,candidate,home);
|
|
153
222
|
const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
|
|
154
223
|
for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
224
|
+
return await locked(home,async()=>{
|
|
225
|
+
try {
|
|
226
|
+
const current=await eligibility(home,job.target,root,job.automatic);
|
|
227
|
+
if(current.old.root!==job.previousRoot||current.old.pkg.version!==job.previousVersion||JSON.stringify(current.old.record)!==JSON.stringify(old)||JSON.stringify((await state(home)).config)!==JSON.stringify(config))throw Error('Stale upgrade job');
|
|
228
|
+
if(await brokerNeedsRefresh(config,run)&&!hooks.refreshPluginBroker)throw Error('Broker host binding changed; retry the plugin update after native work drains');
|
|
229
|
+
const r=await read(path.join(home,'registry.json'));
|
|
230
|
+
const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
|
|
231
|
+
job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
|
|
232
|
+
await run('docker',[...pluginArgs(old),'stop']);
|
|
233
|
+
const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
|
|
234
|
+
const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
|
|
235
|
+
for(const name of volumes)await backupVolume(run,old,name,backup);
|
|
236
|
+
await atomic(old.compose,await compose(config,candidate,secrets,home));
|
|
237
|
+
if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
238
|
+
r.plugins[job.target]=candidate;
|
|
239
|
+
for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
|
|
240
|
+
await atomic(path.join(home,'registry.json'),r);
|
|
241
|
+
job.brokerReadiness=hooks.refreshPluginBroker?await refreshBroker(config,run):await verifyBroker(config,run);
|
|
242
|
+
await publishBrokerPlugins(home,agent.controlDir);
|
|
243
|
+
job.runtimeVerified=running;
|
|
244
|
+
return await complete();
|
|
245
|
+
} catch(error) { return fail(error,{...hooks,registryLocked:true}); }
|
|
246
|
+
},{drainInvocations:true,signal:hooks.signal});
|
|
168
247
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return job;
|
|
173
|
-
}catch(error){
|
|
174
|
-
let message=error.message;
|
|
175
|
-
if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
|
|
176
|
-
job.error=message;await save();return recover(home,job,hooks);
|
|
177
|
-
}
|
|
248
|
+
return await complete();
|
|
249
|
+
}catch(error){return fail(error,hooks);}
|
|
250
|
+
|
|
178
251
|
}
|
|
179
252
|
export async function recover(home,job,hooks) {
|
|
253
|
+
if(job.target!=='main'&&job.rollback&&!hooks.registryLocked)
|
|
254
|
+
return locked(home,()=>recover(home,job,{...hooks,registryLocked:true}),{drainInvocations:true,signal:hooks.signal});
|
|
180
255
|
const run=hooks.execute||execute,{config}=await state(home);
|
|
181
256
|
try {
|
|
182
257
|
if(job.rollback) {
|
|
@@ -195,12 +270,14 @@ export async function recover(home,job,hooks) {
|
|
|
195
270
|
await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
|
|
196
271
|
await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
|
|
197
272
|
await hooks.startHost(job.rollback.packageRoot);
|
|
198
|
-
if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build'
|
|
273
|
+
if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
|
|
274
|
+
job.rollbackBrokerReadiness=await refreshBroker(config,run,pinned);
|
|
199
275
|
} else {
|
|
200
276
|
const b=job.rollback;
|
|
201
277
|
await run('docker',[...pluginArgs(b.record),'stop']);
|
|
202
278
|
await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
|
|
203
|
-
await refreshBroker(config,run);
|
|
279
|
+
job.rollbackBrokerReadiness=hooks.refreshPluginBroker?await refreshBroker(config,run):await verifyBroker(config,run);
|
|
280
|
+
await publishBrokerPlugins(home,(await state(home)).agent.controlDir);
|
|
204
281
|
if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
205
282
|
}
|
|
206
283
|
job.status='rolled-back';
|
|
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import { atomic, locked } from '../plugins/manager.mjs';
|
|
6
6
|
import { callDeliverySocket } from '../delivery-socket-client.mjs';
|
|
7
7
|
import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
|
|
8
|
-
import { perform, environment } from './runtime.mjs';
|
|
8
|
+
import { perform, environment, brokerNeedsRefresh } from './runtime.mjs';
|
|
9
9
|
|
|
10
10
|
const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
|
|
11
11
|
const providerEnvironmentKey=value=>{
|
|
@@ -52,6 +52,11 @@ export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait
|
|
|
52
52
|
if(!signal.aborted)return await apply();
|
|
53
53
|
} finally {await fs.rm(pause,{force:true});}
|
|
54
54
|
}
|
|
55
|
+
// Plugin replacement drains command leases at activation, not native turns.
|
|
56
|
+
export async function withUpgrade(control,job,signal,apply,options) {
|
|
57
|
+
if(job.target==='main'||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
|
|
58
|
+
if(!signal.aborted)return apply();
|
|
59
|
+
}
|
|
55
60
|
export async function queueAutomatic(home,available) {
|
|
56
61
|
const existing=await jobs(home);
|
|
57
62
|
if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
|
|
@@ -94,6 +99,15 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
94
99
|
await waitForHostHeartbeat(agent.controlDir,child,()=>error);
|
|
95
100
|
};
|
|
96
101
|
const pause=path.join(agent.controlDir,'upgrade-pause.json');
|
|
102
|
+
const refreshRequirement=async job=>{
|
|
103
|
+
if(job.target==='main')return false;
|
|
104
|
+
try {return await brokerNeedsRefresh((await state(home)).config);}
|
|
105
|
+
catch(error) {
|
|
106
|
+
job.status=job.status==='applying'?'recovery-required':'failed';job.error=error.message;
|
|
107
|
+
await atomic(path.join(jobPath(home,job.id),'job.json'),job);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
97
111
|
const beat=setInterval(()=>{void atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()}).catch(()=>{});},1000);
|
|
98
112
|
try {
|
|
99
113
|
await atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()});
|
|
@@ -107,8 +121,11 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
107
121
|
await sleep(1200);
|
|
108
122
|
const interrupted=(await jobs(home)).find(j=>j.status==='applying');
|
|
109
123
|
if(interrupted){
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
const refreshPluginBroker=await refreshRequirement(interrupted);
|
|
125
|
+
const result=refreshPluginBroker===null?undefined:interrupted.target==='main'
|
|
126
|
+
? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));}finally{await fs.rm(pause,{force:true});}})()
|
|
127
|
+
: await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
|
|
128
|
+
if(result?.status==='completed'&&interrupted.target==='main')return;
|
|
112
129
|
}
|
|
113
130
|
const isolated=host.isolation==='isolated'
|
|
114
131
|
if(!isolated && !child)await startHost((await state(home)).config.packageRoot);
|
|
@@ -117,15 +134,19 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
117
134
|
if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
|
|
118
135
|
const pending=(await jobs(home)).find(j=>j.status==='queued');
|
|
119
136
|
if(pending) {
|
|
120
|
-
const
|
|
137
|
+
const refreshPluginBroker=await refreshRequirement(pending);
|
|
138
|
+
if(refreshPluginBroker===null)continue;
|
|
139
|
+
const apply=async()=>{
|
|
121
140
|
try {
|
|
122
|
-
|
|
141
|
+
const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,signal,refreshPluginBroker});};
|
|
142
|
+
return pending.target==='main'?await locked(home,activate):await activate();
|
|
123
143
|
} catch(error) {
|
|
124
144
|
// A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
|
|
125
145
|
const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
|
|
126
146
|
if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
|
|
127
147
|
}
|
|
128
|
-
}
|
|
148
|
+
};
|
|
149
|
+
const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker});
|
|
129
150
|
if(result?.status==='completed') {
|
|
130
151
|
if(pending.target==='main')return;
|
|
131
152
|
nextCheck=0;
|