@jc_stack/ez-agents 0.1.0-beta.42 → 0.1.0-beta.43
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/CHANGELOG.md +15 -0
- package/Dockerfile +3 -1
- package/bin/ez +3 -1
- package/compose.yaml +2 -0
- package/docker/entrypoint.sh +4 -1
- package/docker/smoke.mjs +92 -1
- package/docs/application-channel.md +6 -2
- package/docs/architecture/ai-selection.md +7 -4
- package/docs/architecture/authority-boundaries.md +15 -4
- package/docs/docker-runtime.md +25 -2
- package/docs/local-qa.md +5 -0
- package/docs/plugins.md +16 -2
- package/docs/selective-monitoring.md +26 -12
- package/docs/upgrades.md +5 -1
- package/package.json +1 -1
- package/src/agent-install.ts +5 -3
- package/src/ai-cli.ts +13 -4
- package/src/ai.ts +7 -3
- package/src/audio.ts +4 -2
- package/src/client-defaults.ts +7 -4
- package/src/control-state.ts +12 -8
- package/src/conversation-menu.ts +5 -2
- package/src/executor.ts +5 -5
- package/src/menu.ts +9 -7
- package/src/plugin-broker.mjs +1 -0
- package/src/plugins/manager.mjs +26 -9
- package/src/plugins/native-tasks.d.mts +1 -0
- package/src/plugins/native-tasks.mjs +13 -0
- package/src/task-cli.ts +1 -1
- package/src/task-executor.ts +7 -4
- package/src/tasks.ts +54 -16
- package/src/updates/control.mjs +5 -3
- package/src/updates/runtime.mjs +27 -7
- package/test/agent-install.test.ts +6 -0
- package/test/ai-cli.test.ts +6 -1
- package/test/ai.test.ts +69 -14
- package/test/application-controls.test.ts +2 -1
- package/test/conversation-menu.test.ts +2 -0
- package/test/conversations.test.ts +26 -0
- package/test/plugin-broker.test.mjs +1 -0
- package/test/plugin-manager.test.mjs +44 -1
- package/test/scheduler-host.test.ts +1 -0
- package/test/task-native.test.ts +2 -4
- package/test/tasks.test.ts +107 -3
- package/test/updates.test.mjs +55 -3
- package/test/upgrade-pause.test.ts +10 -2
package/src/client-defaults.ts
CHANGED
|
@@ -6,14 +6,16 @@ import { homedir } from 'node:os'
|
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { createHash } from 'node:crypto'
|
|
8
8
|
import { installed, type AiPreset } from './ai.js'
|
|
9
|
-
import { executorEnvironment } from './executor.js'
|
|
9
|
+
import { executorEnvironment, executorInvocation } from './executor.js'
|
|
10
10
|
|
|
11
11
|
const record = (v: unknown): Record<string, unknown> =>
|
|
12
12
|
v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
|
|
13
13
|
const value = (v: unknown): string | undefined =>
|
|
14
14
|
typeof v === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(v) ? v : undefined
|
|
15
|
-
const command = async (cli: string, args: string[], cwd: string): Promise<string> =>
|
|
16
|
-
|
|
15
|
+
const command = async (cli: string, args: string[], cwd: string): Promise<string> => {
|
|
16
|
+
const invocation = executorInvocation(cli, args)
|
|
17
|
+
return (await promisify(execFile)(invocation.command, invocation.args, { cwd, env: executorEnvironment(), timeout: 8000, maxBuffer: 2 * 1024 * 1024 })).stdout
|
|
18
|
+
}
|
|
17
19
|
|
|
18
20
|
export const resolvedCodexDefaults = (configValue: unknown, catalogValue: unknown): Record<string, unknown> => {
|
|
19
21
|
const config = record(configValue)
|
|
@@ -32,7 +34,8 @@ export const resolvedCodexDefaults = (configValue: unknown, catalogValue: unknow
|
|
|
32
34
|
|
|
33
35
|
// Native config/read resolves Codex's layers; do not reimplement TOML or start a turn.
|
|
34
36
|
export const codexDefaults = (cwd: string, codexHome?: string, nativeFallback = false): Promise<Record<string, unknown>> => new Promise((resolve) => {
|
|
35
|
-
const
|
|
37
|
+
const invocation = executorInvocation('codex', ['app-server'])
|
|
38
|
+
const child = spawn(invocation.command, invocation.args, { cwd,
|
|
36
39
|
env: { ...executorEnvironment(), ...(codexHome ? { CODEX_HOME: codexHome } : {}) }, stdio: ['pipe', 'pipe', 'ignore'] })
|
|
37
40
|
const lines = createInterface({ input: child.stdout })
|
|
38
41
|
let done = false
|
package/src/control-state.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { assertEffort } from './model-policy.js'
|
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
3
|
import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import { isPreset, persistedPreset, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
|
|
5
|
+
import { isPreset, persistedPreset, presetProvider, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
|
|
6
6
|
|
|
7
7
|
export type Owner = {
|
|
8
8
|
id?: string
|
|
@@ -615,7 +615,7 @@ export class ControlStore {
|
|
|
615
615
|
// Hot path (every inbound message and every source tick): when the pinned
|
|
616
616
|
// session already matches the selected preset, return it with zero
|
|
617
617
|
// control/ writes and without taking the lock. Any divergence (missing
|
|
618
|
-
// session,
|
|
618
|
+
// session, choice change, untitled fresh session) falls
|
|
619
619
|
// through to the serializing slow path. A concurrent control edit inside
|
|
620
620
|
// the microsecond unlocked window fails closed downstream (the execution
|
|
621
621
|
// session lookup rejects a stale pin; the batch can be retried).
|
|
@@ -625,7 +625,7 @@ export class ControlStore {
|
|
|
625
625
|
const session = settled.activeSession
|
|
626
626
|
if (settledPreset && session && (session.cli || session.hasStarted) &&
|
|
627
627
|
!(session.cli === settledPreset.cli && session.hasStarted && session.preset &&
|
|
628
|
-
(session.preset
|
|
628
|
+
presetProvider(session.preset) !== presetProvider(settledPreset)) &&
|
|
629
629
|
(session.cli !== settledPreset.cli || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
|
|
630
630
|
Boolean(session.title || session.hasStarted || !title?.trim()))
|
|
631
631
|
return { sessionId: session.sessionId, preset: settledPreset }
|
|
@@ -637,10 +637,10 @@ export class ControlStore {
|
|
|
637
637
|
if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
|
|
638
638
|
if (state.activeSession.cli === preset.cli) {
|
|
639
639
|
const previous = state.activeSession.preset
|
|
640
|
-
//
|
|
641
|
-
//
|
|
640
|
+
// A provider change starts a separate native context. Model and effort
|
|
641
|
+
// changes are native turn settings within the existing conversation.
|
|
642
642
|
if (state.activeSession.hasStarted && previous &&
|
|
643
|
-
(previous
|
|
643
|
+
presetProvider(previous) !== presetProvider(preset)) {
|
|
644
644
|
;(state.sessions ??= []).push(state.activeSession)
|
|
645
645
|
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset }
|
|
646
646
|
} else {
|
|
@@ -772,13 +772,17 @@ export class ControlStore {
|
|
|
772
772
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
773
773
|
if ((state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Menu expired. Open Choose AI again.')
|
|
774
774
|
const current = ai.presets.find((p) => p.id === ai.selectedId)!
|
|
775
|
-
if (state.activeSession && (current.cli !== preset.cli || !state.activeSession.cli) && !fresh) return false
|
|
775
|
+
if (state.activeSession && (current.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
|
|
776
776
|
if (fresh || !state.activeSession) {
|
|
777
777
|
rememberPreset(state)
|
|
778
778
|
if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
|
|
779
|
-
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
|
|
779
|
+
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset: persistedPreset(preset) }
|
|
780
780
|
}
|
|
781
781
|
ai.selectedId = id
|
|
782
|
+
// Persist the conversation's choice now, even if the owner switches chats
|
|
783
|
+
// or restarts before sending another message. Queued choices are snapshots.
|
|
784
|
+
if (state.activeSession.cli === preset.cli && (!state.activeSession.preset || presetProvider(state.activeSession.preset) === presetProvider(preset)))
|
|
785
|
+
state.activeSession.preset = persistedPreset(preset)
|
|
782
786
|
ai.recentIds = [id, ...(ai.recentIds ?? []).filter((recentId) => recentId !== id)].slice(0, 3)
|
|
783
787
|
await this.writeState(state)
|
|
784
788
|
return true
|
package/src/conversation-menu.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { InlineKeyboard, type Context } from 'grammy'
|
|
2
2
|
import { ControlStore, sessionTitle } from './control-state.js'
|
|
3
|
+
import { presetLabel } from './ai.js'
|
|
3
4
|
|
|
4
5
|
// IDs identify existing relay bindings only; the engine still owns all context.
|
|
5
6
|
// Titles come from owner renames; no run text or delivery history is read here.
|
|
@@ -61,13 +62,15 @@ export const createConversationMenu = (control: ControlStore) => {
|
|
|
61
62
|
} else {
|
|
62
63
|
const keyboard = new InlineKeyboard().text('Archive this conversation', `chat:archive:${id}`).row()
|
|
63
64
|
.text('Back to conversations', 'chat:list:0:0')
|
|
64
|
-
|
|
65
|
+
let selected
|
|
66
|
+
try { selected = await control.switchSession(id) }
|
|
65
67
|
catch (error) {
|
|
66
68
|
// Even an older session that cannot resume can still be archived.
|
|
67
69
|
await render(ctx, `${sessionTitle(session)}\n${error instanceof Error ? error.message : 'Unable to continue.'}`, keyboard)
|
|
68
70
|
return true
|
|
69
71
|
}
|
|
70
|
-
|
|
72
|
+
keyboard.inline_keyboard.unshift([{ text: 'Choose AI', callback_data: 'menu:ai' }])
|
|
73
|
+
await render(ctx, `Current conversation: ${sessionTitle(selected)}\nAI: ${selected.preset ? presetLabel(selected.preset) : 'Not saved; open Choose AI.'}\nSend a message to continue. To rename it, use /rename followed by a name.`, keyboard)
|
|
71
74
|
}
|
|
72
75
|
} catch (error) {
|
|
73
76
|
await ctx.reply(error instanceof Error ? error.message : 'Conversation selection failed.')
|
package/src/executor.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { executionDefaults } from './model-policy.js'
|
|
|
2
2
|
import { startTaskExecutor } from './task-executor.js'
|
|
3
3
|
import { deliverySocketPath } from './delivery-socket.js'
|
|
4
4
|
import { mkdtemp, rm, writeFile, mkdir, symlink, readFile } from 'node:fs/promises'
|
|
5
|
-
import { accessSync, constants as fsConstants } from 'node:fs'
|
|
5
|
+
import { accessSync, openSync, closeSync, constants as fsConstants } from 'node:fs'
|
|
6
6
|
import { tmpdir, homedir } from 'node:os'
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
@@ -210,7 +210,7 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
|
|
|
210
210
|
// Loaded by path, never by prompt text, so the model cannot miss it.
|
|
211
211
|
try {
|
|
212
212
|
const skill = path.join(opts.controlDir, 'cli', 'pi', 'skills', 'ez-delivery', 'SKILL.md')
|
|
213
|
-
|
|
213
|
+
closeSync(openSync(skill, 'r'))
|
|
214
214
|
args.push('--skill', path.dirname(skill))
|
|
215
215
|
} catch { /* runs without the skill exactly as before */ }
|
|
216
216
|
// Bind Pi to this conversation, not the most recently used session in
|
|
@@ -280,7 +280,7 @@ export const resolveExecutor = (name?: string): CliAdapter => EXECUTOR_REGISTRY[
|
|
|
280
280
|
// secret.
|
|
281
281
|
export const opencodeDataHome = (controlDir: string): string | undefined => {
|
|
282
282
|
try {
|
|
283
|
-
|
|
283
|
+
closeSync(openSync(path.join(controlDir, 'cli', 'opencode', 'auth.json'), 'r'))
|
|
284
284
|
return path.join(controlDir, 'cli')
|
|
285
285
|
} catch { return undefined }
|
|
286
286
|
}
|
|
@@ -289,7 +289,7 @@ export const opencodeDataHome = (controlDir: string): string | undefined => {
|
|
|
289
289
|
// auth.json). Only a path is ever returned, never a secret.
|
|
290
290
|
export const piAgentDir = (controlDir: string): string | undefined => {
|
|
291
291
|
try {
|
|
292
|
-
|
|
292
|
+
closeSync(openSync(path.join(controlDir, 'cli', 'pi', 'agent', 'auth.json'), 'r'))
|
|
293
293
|
return path.join(controlDir, 'cli', 'pi', 'agent')
|
|
294
294
|
} catch { return undefined }
|
|
295
295
|
}
|
|
@@ -315,7 +315,7 @@ export const opencodeInvocation = (
|
|
|
315
315
|
export const executorInvocation = (command: string, args: string[]) => {
|
|
316
316
|
const uid = process.geteuid?.()
|
|
317
317
|
return process.platform === 'linux' && uid !== undefined && process.getuid?.() !== uid
|
|
318
|
-
? { command: 'setpriv', args: [`--ruid=${uid}`, `--euid=${uid}`, '--', command, ...args] }
|
|
318
|
+
? { command: 'setpriv', args: [`--ruid=${uid}`, `--euid=${uid}`, '--no-new-privs', '--', command, ...args] }
|
|
319
319
|
: { command, args }
|
|
320
320
|
}
|
|
321
321
|
|
package/src/menu.ts
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path'
|
|
|
4
4
|
import { randomBytes } from 'node:crypto'
|
|
5
5
|
import { InlineKeyboard, type Context } from 'grammy'
|
|
6
6
|
import { ControlStore, type ControlGuard } from './control-state.js'
|
|
7
|
-
import { chatPreset, installed, persistedPreset, presetLabel, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
7
|
+
import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
8
8
|
import { discoverDefaults } from './client-defaults.js'
|
|
9
9
|
|
|
10
10
|
export const mainCommands = [
|
|
@@ -64,7 +64,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
64
64
|
const state = (await control.status()).ai
|
|
65
65
|
if (!state) throw new Error('AI settings not initialized')
|
|
66
66
|
const current = state.presets.find((p) => p.id === state.selectedId)!
|
|
67
|
-
const fresh = current.cli !== preset.cli || current
|
|
67
|
+
const fresh = current.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
|
|
68
68
|
if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
|
|
69
69
|
return { preset, fresh }
|
|
70
70
|
}
|
|
@@ -72,12 +72,13 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
72
72
|
const session = await control.getActiveSession()
|
|
73
73
|
const { fresh } = await select(preset, session?.sessionId ?? null)
|
|
74
74
|
await ctx.reply(`${preset.name}\n${presetLabel(preset)}\n${fresh
|
|
75
|
-
? '
|
|
76
|
-
: '
|
|
75
|
+
? 'Client or provider changed: fresh conversation. Files kept; queued work unchanged.'
|
|
76
|
+
: 'Saved for this conversation. History kept; queued work unchanged.'}`)
|
|
77
77
|
}
|
|
78
78
|
const list = async (ctx: Context) => {
|
|
79
79
|
const models = await catalog()
|
|
80
80
|
const state = await control.aiState(initial)
|
|
81
|
+
const selected = state.presets.find((preset) => preset.id === state.selectedId)!
|
|
81
82
|
const keyboard = new InlineKeyboard()
|
|
82
83
|
if (models.length) {
|
|
83
84
|
const recent = (state.recentIds ?? [])
|
|
@@ -99,8 +100,8 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
99
100
|
await list(next)
|
|
100
101
|
})
|
|
101
102
|
await ctx.reply(models.length
|
|
102
|
-
?
|
|
103
|
-
:
|
|
103
|
+
? `Choose AI\nCurrent: ${presetLabel(selected)}\nSaved per conversation. Select a client, model and reasoning level; changing client or provider starts a new conversation.`
|
|
104
|
+
: `Choose AI\nCurrent: ${presetLabel(selected)}\nNo client catalog available. Showing the current client setup only.`, { reply_markup: keyboard })
|
|
104
105
|
}
|
|
105
106
|
const available = async (ctx: Context, cli: string, page = 0, listed?: ModelChoice[]) => {
|
|
106
107
|
const models = (listed ?? await catalog()).filter((model) => model.cli === cli)
|
|
@@ -129,7 +130,8 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
129
130
|
const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.provider === stored.provider && preset.model === stored.model && preset.effort === stored.effort)
|
|
130
131
|
const preset = existing ?? candidate
|
|
131
132
|
await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
132
|
-
|
|
133
|
+
// Private scopes persist their selection on the session, not the owner menu.
|
|
134
|
+
if (!guard?.applicationScope) await control.savePreset(preset, guard)
|
|
133
135
|
return preset
|
|
134
136
|
}
|
|
135
137
|
const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
|
package/src/plugin-broker.mjs
CHANGED
|
@@ -90,6 +90,7 @@ const validateManagerArgs = async (value, workspace) => {
|
|
|
90
90
|
(args.length === 3 || ['stable', 'beta', 'manual'].includes(extra))) return args;
|
|
91
91
|
if (action === 'prepare' && args.length === 5 && IDENTIFIER.test(name) && extra === '--file' &&
|
|
92
92
|
typeof tail[0] === 'string' && path.isAbsolute(tail[0])) {
|
|
93
|
+
if (name === 'main') throw Error('Main runtime candidates require operator preparation outside the agent');
|
|
93
94
|
let resolved;
|
|
94
95
|
try { resolved = await fs.realpath(tail[0]); } catch { throw Error('Upgrade candidate must be an existing file inside the agent workspace'); }
|
|
95
96
|
if (!childOf(resolved, workspace)) throw Error('Upgrade candidate must stay inside the agent workspace');
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sharedService, attachShared } from './shared.mjs';
|
|
2
2
|
import { exposure, commandExposure } from './exposure.mjs';
|
|
3
|
+
import { installedNativeHelp } from './native-tasks.mjs';
|
|
3
4
|
import * as fs from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -17,7 +18,16 @@ const emit = value => console.log(JSON.stringify(value));
|
|
|
17
18
|
const keys = (object, allowed) => { if(!object || typeof object !== 'object' || Array.isArray(object) || Object.keys(object).some(k=>!allowed.includes(k))) throw Error('Invalid or unknown descriptor fields'); };
|
|
18
19
|
const strings = value => { if(!Array.isArray(value) || value.some(x=>typeof x!=='string' || x.includes('\0'))) throw Error('Expected literal string arguments'); return value; };
|
|
19
20
|
const containerPath = value => { if(typeof value!=='string' || !value.startsWith('/') || value.includes('..') || /[\0\n\r:$]/.test(value) || value.startsWith('/var/run') || value.startsWith('/proc') || value.startsWith('/sys')) throw Error('Invalid container path'); return value; };
|
|
20
|
-
|
|
21
|
+
export async function privateDir(dir) {
|
|
22
|
+
const created=await fs.mkdir(dir,{recursive:true,mode:0o700});
|
|
23
|
+
if(!created)return;
|
|
24
|
+
// A root broker must leave new private directories with the installer's
|
|
25
|
+
// ownership, just as atomic files are. Do not change existing directories.
|
|
26
|
+
let current=created;await stewardOwned(current);
|
|
27
|
+
for(const part of path.relative(created,dir).split(path.sep).filter(Boolean)) {
|
|
28
|
+
current=path.join(current,part);await stewardOwned(current);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
21
31
|
export async function atomic(file, value) {
|
|
22
32
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
23
33
|
await fs.writeFile(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});
|
|
@@ -280,11 +290,12 @@ export async function hostNetworkBindings(config, record, home) {
|
|
|
280
290
|
throw Error('Invalid host network bindings');
|
|
281
291
|
const route = configured?.[record.manifest?.id];
|
|
282
292
|
if (route === undefined) return [];
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
293
|
+
const isolated = process.env.EZ_DOCKER_COMPOSE === 'standalone';
|
|
294
|
+
if (isolated && host.isolation !== 'isolated') throw Error('Isolated broker requires an isolated host binding');
|
|
295
|
+
// Like folder grants, network grants use the exact mounted identities. The
|
|
296
|
+
// broker does not receive the host deployment's convenience symlinks.
|
|
297
|
+
if (!isolated && (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
298
|
+
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
|
|
288
299
|
throw Error('Invalid host network binding deployment');
|
|
289
300
|
keys(route, ['revisions', 'bindings']);
|
|
290
301
|
const trusted = await snapshot(record.source);
|
|
@@ -519,7 +530,14 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
|
|
|
519
530
|
await fs.access(target,fs.constants.X_OK);
|
|
520
531
|
await fs.symlink(target,path.join(bin,name));
|
|
521
532
|
}
|
|
522
|
-
for(const file of await fs.readdir(agent.binDir)) {
|
|
533
|
+
for(const file of await fs.readdir(agent.binDir)) {
|
|
534
|
+
if(file==='ez') {
|
|
535
|
+
if(await fs.realpath(path.join(agent.binDir,file))!==await fs.realpath(new URL('../../bin/ez',import.meta.url)))throw Error('Existing ez binding collision');
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if(Object.hasOwn(manifest.bin,file))continue;
|
|
539
|
+
await fs.symlink(path.join(agent.binDir,file),path.join(bin,file));
|
|
540
|
+
}
|
|
523
541
|
agent.binDir=bin;agent.toolsHome=home;await atomic(hostConfig,host);
|
|
524
542
|
}
|
|
525
543
|
});
|
|
@@ -540,7 +558,6 @@ export async function install(home,config,name,source,revision) {
|
|
|
540
558
|
const stage=path.join(base,`.stage-${randomUUID()}`);await privateDir(stage);
|
|
541
559
|
try {
|
|
542
560
|
for(const [relative,file] of p.files) {const dest=path.join(stage,relative);await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});await fs.writeFile(dest,file.data,{mode:file.mode,flag:'wx'});}
|
|
543
|
-
await fs.chmod(stage,0o755);
|
|
544
561
|
// A previously interrupted install may leave a snapshot. Never silently replace it.
|
|
545
562
|
try { await fs.rename(stage,target); } catch(error) {
|
|
546
563
|
if(!['EEXIST','ENOTEMPTY'].includes(error.code)) throw error;
|
|
@@ -583,7 +600,7 @@ export async function main(args) {
|
|
|
583
600
|
return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1),{publish:port,serve:true});
|
|
584
601
|
}
|
|
585
602
|
if(group==='tools'&&action==='connect')return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1));
|
|
586
|
-
if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
|
|
603
|
+
if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],native:await installedNativeHelp(path.join(home,'bin')),foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
|
|
587
604
|
if(group==='plugins'&&(!action||args.includes('--help'))) return emit({commands:['available','list','inspect <id>','install <id>','start <id>','stop <id>','status <id>','logs <id>','uninstall <id>','catalog-add <id> --source PATH --revision HASH','export <id> <artifact> --output PATH','folder-bind <id> --service NAME --source PATH --target PATH [--writable]','folder-unbind <id> --service NAME --target PATH','folders <id>','shared-enable <id> <service>','shared-disable <id> <service>','shared-status <id> <service>'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
|
|
588
605
|
if(group==='plugins'||group==='tools') {
|
|
589
606
|
args=rest;args=args.filter(a=>a!=='--json');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export function nativeTaskBinding(home:string, environment?:NodeJS.ProcessEnv):Promise<{cwd:string;env:NodeJS.ProcessEnv}>;
|
|
2
2
|
import type {DeliveryContext} from '../delivery-context.mjs';
|
|
3
3
|
export function nativeCommands():{command:string;description:string;limitations?:string[]}[];
|
|
4
|
+
export function installedNativeHelp(binDirectory:string):Promise<{description:string;argv:string[]}[]>;
|
|
4
5
|
export function nativeTasks(home:string,args:string[],options?:{signal?:AbortSignal;command?:string;deliveryContext?:DeliveryContext}):Promise<{code:number;stdout:string;stderr:string}>;
|
|
@@ -9,6 +9,19 @@ export const nativeCommands=()=>[
|
|
|
9
9
|
{command:'message',description:"Send text, voice or a workspace document to the paired owner's Telegram chat and wait for its Telegram delivery receipt. Read --help.",limitations:['History requires a native run; inline text only.']},
|
|
10
10
|
];
|
|
11
11
|
|
|
12
|
+
export async function installedNativeHelp(binDirectory) {
|
|
13
|
+
const commands=[];
|
|
14
|
+
for(const {command,description} of nativeCommands()) {
|
|
15
|
+
const executable=path.join(binDirectory,`ezenciel-agents-${command}`);
|
|
16
|
+
try {await fs.access(executable,fs.constants.X_OK);} catch(error) {
|
|
17
|
+
if(['ENOENT','EACCES'].includes(error.code))continue;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
commands.push({description,argv:[executable,'--help']});
|
|
21
|
+
}
|
|
22
|
+
return commands;
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
export async function nativeTaskBinding(home,environment=process.env) {
|
|
13
26
|
home=await fs.realpath(home);
|
|
14
27
|
const config=JSON.parse(await fs.readFile(path.join(home,'config.json'),'utf8'));
|
package/src/task-cli.ts
CHANGED
|
@@ -6,7 +6,7 @@ const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
|
6
6
|
'context-file': { type: 'string' }, 'capability-file': { type: 'string' }, hours: { type: 'string' }, id: { type: 'string' }, help: { type: 'boolean' },
|
|
7
7
|
'incoming-only': { type: 'boolean' }, 'until-revoked': { type: 'boolean' }, 'any-conversation': { type: 'boolean' },
|
|
8
8
|
} })
|
|
9
|
-
if (values.help) { console.log('ezenciel-agents-task propose --source NAME (--contact EXACT_ID | --any-conversation) --purpose TEXT --context-file FILE [--capability-file FILE] --hours 24 [--incoming-only [--until-revoked]] | list | revoke --id TASK_ID'); console.log(await readFile(new URL('../docs/selective-monitoring.md', import.meta.url), 'utf8')) }
|
|
9
|
+
if (values.help) { console.log('ezenciel-agents-task start|propose --source NAME (--contact EXACT_ID | --any-conversation) --purpose TEXT --context-file FILE [--capability-file FILE] --hours 24 [--incoming-only [--until-revoked]] | list | revoke --id TASK_ID'); console.log(await readFile(new URL('../docs/selective-monitoring.md', import.meta.url), 'utf8')) }
|
|
10
10
|
else {
|
|
11
11
|
if (!process.env.EZ_CONTROL_DIR || !process.env.EZ_RUN_ID) throw new Error('Run from the current owner turn')
|
|
12
12
|
const anyConversation = values['any-conversation'] === true
|
package/src/task-executor.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { spawn, execFile } from 'node:child_process'
|
|
|
7
7
|
import { promisify } from 'node:util'
|
|
8
8
|
import { readRun } from './delivery-socket.js'
|
|
9
9
|
import { Tasks } from './tasks.js'
|
|
10
|
-
import { executorEnvironment, terminateJob, type ExecutorOptions } from './executor.js'
|
|
10
|
+
import { executorEnvironment, executorInvocation, terminateJob, type ExecutorOptions } from './executor.js'
|
|
11
11
|
|
|
12
12
|
// This adapter is deliberately version-pinned: a new native tool default needs
|
|
13
13
|
// a fresh tool-inventory audit before external correspondence can use it.
|
|
@@ -45,13 +45,15 @@ export async function startTaskExecutor(options: ExecutorOptions) {
|
|
|
45
45
|
const toolNames = [...(task.anyConversation ? ['context','send'] : ['context','send','note','report','complete']),...capabilityNames]
|
|
46
46
|
if (capabilityNames.length && !options.toolsHome) throw new Error('Channel capabilities require an installed tool registry')
|
|
47
47
|
const environment = executorEnvironment()
|
|
48
|
-
const
|
|
48
|
+
const versionInvocation = executorInvocation('codex', ['--version'])
|
|
49
|
+
const version = await promisify(execFile)(versionInvocation.command, versionInvocation.args, { env: environment })
|
|
49
50
|
if (version.stdout.trim() !== `codex-cli ${TASK_CODEX_VERSION}`) throw new Error(`Restricted tasks require audited Codex ${TASK_CODEX_VERSION}`)
|
|
50
51
|
const temporary = await mkdtemp(join(tmpdir(), 'ez-task-'))
|
|
51
52
|
try {
|
|
52
53
|
const directory = join(temporary, 'workspace'), home = join(temporary, 'home')
|
|
53
54
|
await mkdir(directory, { mode: 0o700 }); await mkdir(home, { mode: 0o700 })
|
|
54
|
-
const
|
|
55
|
+
const catalogInvocation = executorInvocation('codex', ['debug', 'models', '--bundled'])
|
|
56
|
+
const catalog = await promisify(execFile)(catalogInvocation.command, catalogInvocation.args, { env: environment, maxBuffer: 4 * 1024 * 1024 })
|
|
55
57
|
await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
|
|
56
58
|
const auth = process.env.EZ_ISOLATION === 'isolated'
|
|
57
59
|
? join(options.controlDir, 'cli', 'codex', 'auth.json')
|
|
@@ -60,7 +62,8 @@ export async function startTaskExecutor(options: ExecutorOptions) {
|
|
|
60
62
|
const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
|
|
61
63
|
fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
|
|
62
64
|
const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
|
|
63
|
-
const
|
|
65
|
+
const invocation = executorInvocation('codex', taskArguments(directory, broker, prompt, toolNames, options))
|
|
66
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
64
67
|
cwd: directory, env: { ...environment, HOME: home, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32',
|
|
65
68
|
})
|
|
66
69
|
await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
|
package/src/tasks.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type Task = {
|
|
|
22
22
|
sourceId: string; bindingId: string; accountId: string; conversationId: string
|
|
23
23
|
purpose: string; context: string; createdAt: number; expiresAt: number
|
|
24
24
|
state: 'pending' | 'active' | 'revoked' | 'completed'
|
|
25
|
+
grant?: string; initialRunDispatched?: true
|
|
25
26
|
notes: string[]; operations: Record<string, { text: string; attachmentId?:string; state: 'uncertain' | 'accepted'; receipt?: unknown }>
|
|
26
27
|
capabilityOperations?: Record<string, { capabilityId: string; inputHash: string; lease: string; attachment?:TaskAttachment; state: 'authorized' | 'completed' }>
|
|
27
28
|
publicBudget?: PublicBudget
|
|
@@ -59,6 +60,8 @@ export class Tasks {
|
|
|
59
60
|
!bounded(task.accountId, 200) || !bounded(task.conversationId, 200) || !bounded(task.purpose, 1000) ||
|
|
60
61
|
!bounded(task.context, 6000) || !Number.isFinite(task.createdAt) || !Number.isFinite(task.expiresAt) ||
|
|
61
62
|
!['pending', 'active', 'revoked', 'completed'].includes(task.state) || !Array.isArray(task.notes) ||
|
|
63
|
+
(task.grant !== undefined && (typeof task.grant !== 'string' || !/^[a-f0-9]{64}$/.test(task.grant))) ||
|
|
64
|
+
(task.initialRunDispatched !== undefined && task.initialRunDispatched !== true) ||
|
|
62
65
|
!task.operations || typeof task.operations !== 'object' || (task.capabilityOperations !== undefined && (!task.capabilityOperations || typeof task.capabilityOperations !== 'object')) ||
|
|
63
66
|
(task.anyConversation && !validPublicBudget(task.publicBudget)) || !task.owner) throw new Error('Invalid task record')
|
|
64
67
|
return task
|
|
@@ -91,11 +94,7 @@ export class Tasks {
|
|
|
91
94
|
!ownsRun(task.owner, run))
|
|
92
95
|
throw new Error('Task is inactive or expired')
|
|
93
96
|
const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
|
|
94
|
-
|
|
95
|
-
const approvedByOwner = approval?.version === 2
|
|
96
|
-
? approval.decidedOwnerId === ownerId(task.owner) && approval.decidedOwnerEpoch === ownerEpoch(task.owner)
|
|
97
|
-
: !!owner && ownsRun(owner,{telegramUserId:approval?.decidedBy!,chatId:task.owner.telegramChatId})
|
|
98
|
-
if (!sameOwner(task.owner, owner) || approval?.decision !== 'approved' || !approvedByOwner || approval.runId !== task.runId || approval.prompt !== this.prompt(task))
|
|
97
|
+
if (!sameOwner(task.owner, owner) || (task.grant ? task.grant !== this.scopeHash(task) : !await this.approved(task)))
|
|
99
98
|
throw new Error('Task approval is no longer valid')
|
|
100
99
|
if (checkProvider) await this.source(task)
|
|
101
100
|
if (run.external && checkProvider) {
|
|
@@ -107,6 +106,34 @@ export class Tasks {
|
|
|
107
106
|
}
|
|
108
107
|
return task
|
|
109
108
|
}
|
|
109
|
+
// Persist only the bounded grant, not transient approval/outbox state. Bind every
|
|
110
|
+
// immutable authority field so later scope changes cannot reuse an old grant.
|
|
111
|
+
private scopeHash(task: Task) {
|
|
112
|
+
return createHash('sha256').update(JSON.stringify([
|
|
113
|
+
task.id, task.runId, task.owner, task.sourceId, task.bindingId, task.accountId,
|
|
114
|
+
task.conversationId, task.purpose, task.context, task.createdAt, task.expiresAt,
|
|
115
|
+
task.waitForIncoming, task.untilRevoked, task.anyConversation, task.capabilities,
|
|
116
|
+
])).digest('hex')
|
|
117
|
+
}
|
|
118
|
+
private async approved(task: Task) {
|
|
119
|
+
const approval = await new ApprovalStore(this.controlDir).getDecision(task.id)
|
|
120
|
+
const approvedByOwner = approval?.version === 2
|
|
121
|
+
? approval.decidedOwnerId === ownerId(task.owner) && approval.decidedOwnerEpoch === ownerEpoch(task.owner)
|
|
122
|
+
: ownsRun(task.owner,{telegramUserId:approval?.decidedBy!,chatId:task.owner.telegramChatId})
|
|
123
|
+
return approval?.decision === 'approved' && approvedByOwner && approval.runId === task.runId && approval.prompt === this.prompt(task)
|
|
124
|
+
}
|
|
125
|
+
private async dispatchInitial(task: Task) {
|
|
126
|
+
if (task.waitForIncoming || task.initialRunDispatched) return
|
|
127
|
+
// Runs are intentionally memory-only. Do not replay an opener after restart,
|
|
128
|
+
// when its delivery may be unknown; subsequent inbound events still resume.
|
|
129
|
+
task.initialRunDispatched = true
|
|
130
|
+
await this.save(task)
|
|
131
|
+
await new RunStore(this.controlDir).create({
|
|
132
|
+
id: `event_${createHash('sha256').update(task.id).digest('hex')}`, taskId: task.id,
|
|
133
|
+
ownerId: ownerId(task.owner), ownerEpoch: ownerEpoch(task.owner),
|
|
134
|
+
chatId: task.owner.telegramChatId, telegramUserId: task.owner.telegramUserId, texts: [],
|
|
135
|
+
})
|
|
136
|
+
}
|
|
110
137
|
async match(sourceId: string, bindingId: string, events: SourceEvent[]) {
|
|
111
138
|
if (!events.length || new Set(events.map(event => event.conversationId)).size !== 1) return
|
|
112
139
|
const matches = (await this.list()).filter(t => t.state === 'active' && t.expiresAt > Date.now() &&
|
|
@@ -137,19 +164,23 @@ export class Tasks {
|
|
|
137
164
|
const task = await this.get(id)
|
|
138
165
|
if (!task) return false
|
|
139
166
|
if (task.state === 'revoked' && task.unwatchPending) { await this.unwatch(task); return true }
|
|
140
|
-
|
|
141
|
-
if (!
|
|
142
|
-
|
|
143
|
-
task.
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
167
|
+
if (!['pending', 'active'].includes(task.state) || task.expiresAt <= Date.now()) return false
|
|
168
|
+
if (!task.grant) {
|
|
169
|
+
const approval = await new ApprovalStore(this.controlDir).getDecision(id)
|
|
170
|
+
if (!approval || approval.runId !== task.runId || approval.prompt !== this.prompt(task)) throw new Error('Task approval mismatch')
|
|
171
|
+
if (approval.decision === 'pending') return true
|
|
172
|
+
if (approval.decision === 'denied') {
|
|
173
|
+
task.state = 'revoked'; await this.save(task); return true
|
|
147
174
|
}
|
|
175
|
+
if (!await this.approved(task)) throw new Error('Task approval is no longer valid')
|
|
176
|
+
const source = await this.source(task)
|
|
177
|
+
await sourceCall(source.socketPath, 'task-watch', { accountId: task.accountId, conversationId: task.conversationId, expiresAt: task.expiresAt })
|
|
178
|
+
if (task.state === 'active' && !task.waitForIncoming) task.initialRunDispatched = true
|
|
179
|
+
task.state = 'active'; task.grant = this.scopeHash(task)
|
|
148
180
|
await this.save(task)
|
|
149
181
|
}
|
|
150
|
-
if (task.
|
|
151
|
-
|
|
152
|
-
})
|
|
182
|
+
if (task.grant !== this.scopeHash(task)) throw new Error('Task grant scope changed')
|
|
183
|
+
await this.dispatchInitial(task)
|
|
153
184
|
return true
|
|
154
185
|
})
|
|
155
186
|
}
|
|
@@ -182,7 +213,7 @@ export class Tasks {
|
|
|
182
213
|
if(task.unwatchPending) await this.unwatch(task)
|
|
183
214
|
return { id: task.id, state: task.state }
|
|
184
215
|
}
|
|
185
|
-
if (command !== 'propose') throw new Error('Unknown owner task command')
|
|
216
|
+
if (command !== 'start' && command !== 'propose') throw new Error('Unknown owner task command')
|
|
186
217
|
if (args.waitForIncoming !== undefined && typeof args.waitForIncoming !== 'boolean') throw new Error('Invalid incoming-only option')
|
|
187
218
|
if (args.untilRevoked !== undefined && (typeof args.untilRevoked !== 'boolean' || (args.untilRevoked && args.waitForIncoming !== true))) throw new Error('Persistent permission requires incoming-only mode')
|
|
188
219
|
if (args.anyConversation !== undefined && typeof args.anyConversation !== 'boolean') throw new Error('Invalid any-conversation option')
|
|
@@ -207,6 +238,13 @@ export class Tasks {
|
|
|
207
238
|
const task: Task = { version: args.anyConversation ? 4 : args.untilRevoked ? 3 : args.waitForIncoming ? 2 : 1, ...(args.untilRevoked ? {untilRevoked:true as const} : {}), ...(args.waitForIncoming ? { waitForIncoming: true as const } : {}), ...(args.anyConversation ? {anyConversation:true as const,publicBudget:{windowStartedAt:Date.now(),runs:0,responses:0,totalRuns:0,totalResponses:0}} : {}), ...(capabilities ? {capabilities} : {}), id: `task_${randomUUID().replaceAll('-', '')}`, runId, owner, sourceId: source.id,
|
|
208
239
|
bindingId: source.bindingId, accountId: head.accountId, conversationId: args.conversationId, purpose: args.purpose,
|
|
209
240
|
context: args.context, createdAt: Date.now(), expiresAt: args.untilRevoked ? 8640000000000000 : Date.now() + args.hours * 3600000, state: 'pending', notes: [], operations: {} }
|
|
241
|
+
if (command === 'start') {
|
|
242
|
+
await sourceCall(source.socketPath, 'task-watch', { accountId: task.accountId, conversationId: task.conversationId, expiresAt: task.expiresAt })
|
|
243
|
+
task.state = 'active'; task.grant = this.scopeHash(task)
|
|
244
|
+
await this.save(task)
|
|
245
|
+
await this.dispatchInitial(task)
|
|
246
|
+
return { id: task.id, state: task.state }
|
|
247
|
+
}
|
|
210
248
|
if (this.prompt(task).length > 3500) throw new Error('Proposal is too long for owner review; shorten the shared context')
|
|
211
249
|
await this.save(task)
|
|
212
250
|
await new ApprovalStore(this.controlDir).requestApproval(task.id, this.prompt(task), runId)
|
package/src/updates/control.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { parseEnv } from 'node:util';
|
|
5
|
-
import { atomic, locked, snapshot } from '../plugins/manager.mjs';
|
|
5
|
+
import { atomic, locked, snapshot, privateDir, stewardOwned } from '../plugins/manager.mjs';
|
|
6
6
|
import { digest, extract, newer, compatible, version, releaseContract, registryVersion, registryCandidate, download } from './artifact.mjs';
|
|
7
7
|
|
|
8
8
|
export const read = async file => JSON.parse(await fs.readFile(file,'utf8'));
|
|
@@ -129,17 +129,19 @@ export async function eligibility(home,target,root,automatic) {
|
|
|
129
129
|
}
|
|
130
130
|
export async function prepare(home,target,{file,release}) {
|
|
131
131
|
targetId(target);if(Boolean(file)===Boolean(release))throw Error('Supply one --file tarball or --version exact-version');
|
|
132
|
-
const {directory}=await state(home);await
|
|
132
|
+
const {directory}=await state(home);await privateDir(directory);
|
|
133
133
|
const old=await installed(home,target);
|
|
134
134
|
let data,origin;
|
|
135
135
|
if(file){data=await fs.readFile(file);origin={type:'local'};}
|
|
136
136
|
else {version(release);const pkg=await registryVersion(old.pkg.name,release);data=await download(pkg);origin={type:'npm',name:pkg.name,version:pkg.version,integrity:pkg.dist.integrity};}
|
|
137
|
-
const id=randomUUID(),dir=path.join(directory,id),root=path.join(dir,'package');await
|
|
137
|
+
const id=randomUUID(),dir=path.join(directory,id),root=path.join(dir,'package');await privateDir(dir);
|
|
138
138
|
try {
|
|
139
139
|
await extract(data,root);
|
|
140
|
+
await stewardOwned(root);
|
|
140
141
|
const next=await eligibility(home,target,root,false);
|
|
141
142
|
if(origin.type==='npm'&&next.pkg.version!==origin.version)throw Error('Artifact version differs from registry metadata');
|
|
142
143
|
await fs.writeFile(path.join(dir,'candidate.tgz'),data,{mode:0o600,flag:'wx'});
|
|
144
|
+
await stewardOwned(path.join(dir,'candidate.tgz'));
|
|
143
145
|
const job={id,target,source:root,releaseNotes:path.join(root,'CHANGELOG.md'),status:'prepared',version:next.pkg.version,previousVersion:next.old.pkg.version,previousRoot:next.old.root,sha256:digest(data),origin,...(next.deploymentMigration?{deploymentMigration:next.deploymentMigration}:{}),createdAt:new Date().toISOString()};
|
|
144
146
|
await atomic(path.join(dir,'job.json'),job);return job;
|
|
145
147
|
} catch(error){await fs.rm(dir,{recursive:true,force:true});throw error;}
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -17,15 +17,15 @@ export function execute(command,args,options={}) {
|
|
|
17
17
|
return new Promise((resolve,reject)=>{
|
|
18
18
|
const invocation=process.env.EZ_DOCKER_COMPOSE==='standalone'&&command==='docker'&&args[0]==='compose'
|
|
19
19
|
? {command:'docker-compose',args:args.slice(1)} : {command,args};
|
|
20
|
-
const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='';
|
|
20
|
+
const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='',stdout='';
|
|
21
21
|
const timer=setTimeout(()=>child.kill('SIGKILL'),options.timeout||600000);
|
|
22
22
|
const file=options.outputFile?createWriteStream(options.outputFile,{flags:'wx',mode:0o600}):null;
|
|
23
23
|
const written=file?new Promise((resolve,reject)=>{file.once('finish',resolve);file.once('error',reject);}):Promise.resolve();
|
|
24
|
-
if(file)child.stdout.pipe(file);else child.stdout.on('data',b=>{output=(output+b).slice(-16000);});
|
|
24
|
+
if(file)child.stdout.pipe(file);else child.stdout.on('data',b=>{output=(output+b).slice(-16000);if(options.stdoutOnly)stdout=(stdout+b).slice(-16000);});
|
|
25
25
|
child.stderr.on('data',b=>{output=(output+b).slice(-16000);});
|
|
26
26
|
void written.catch(()=>child.kill('SIGKILL'));
|
|
27
27
|
child.once('error',error=>{clearTimeout(timer);reject(error);});
|
|
28
|
-
child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${invocation.command} failed (${code}): ${output}`);resolve(output);}catch(error){reject(error);}});
|
|
28
|
+
child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${invocation.command} failed (${code}): ${output}`);resolve(options.stdoutOnly?stdout:output);}catch(error){reject(error);}});
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
export async function textAtomic(file,text) {
|
|
@@ -109,8 +109,18 @@ export async function perform(home,job,hooks) {
|
|
|
109
109
|
await run(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'bin/ezenciel-agents.mjs'),'--version'],{cwd:root});
|
|
110
110
|
try { await replaceGuidance(root, config.workspace, run); }
|
|
111
111
|
catch(error) { console.error(`Workspace guidance replacement failed; upgrade continues: ${error.message}`); }
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// Preserve operator-owned native CLI pins from the effective deployment.
|
|
113
|
+
// Building only the package defaults silently downgrades those clients.
|
|
114
|
+
const deployment=JSON.parse(await run('docker',[...relayArgs(config),'config','--format','json'],{stdoutOnly:true}));
|
|
115
|
+
const args=deployment.services?.relay?.build?.args||{};
|
|
116
|
+
if(typeof args!=='object'||Array.isArray(args))throw Error('Invalid relay build arguments');
|
|
117
|
+
const buildArgs=Object.keys(args).sort().flatMap(key=>{
|
|
118
|
+
const value=args[key];
|
|
119
|
+
if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)||typeof value!=='string'||value.includes('\0'))throw Error('Unresolved relay build argument');
|
|
120
|
+
return ['--build-arg',`${key}=${value}`];
|
|
121
|
+
});
|
|
122
|
+
const image=`ez-upgrade-${digest(JSON.stringify([job.sha256,buildArgs])).slice(0,24)}`;
|
|
123
|
+
await run('docker',['build','--target','runtime',...buildArgs,'-t',image,root]);
|
|
114
124
|
const envFile=path.join(config.deploymentDir,'docker.env'),oldEnv=await fs.readFile(envFile,'utf8');
|
|
115
125
|
const cid=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
116
126
|
if(!cid||/\s/.test(cid))throw Error('Expected one running relay');
|
|
@@ -162,11 +172,21 @@ export async function recover(home,job,hooks) {
|
|
|
162
172
|
try {
|
|
163
173
|
if(job.rollback) {
|
|
164
174
|
if(job.target==='main') {
|
|
165
|
-
|
|
175
|
+
// Keep a healthy instance of the exact pinned image when a failed
|
|
176
|
+
// activation retained it, even if Docker has removed its image index.
|
|
177
|
+
const pinned=parseEnv(job.rollback.env).EZ_RELAY_IMAGE;
|
|
178
|
+
const retained=await (async()=>{
|
|
179
|
+
const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
180
|
+
if(!/^[a-f0-9]{12,64}$/i.test(id))return false;
|
|
181
|
+
const current=JSON.parse(await run('docker',['inspect','--format','{{json .}}',id]));
|
|
182
|
+
return /^sha256:[a-f0-9]{64}$/.test(pinned||'')&¤t.Image===pinned&¤t.State?.Running===true&¤t.State?.Health?.Status==='healthy';
|
|
183
|
+
})().catch(()=>false);
|
|
184
|
+
if(!retained)await run('docker',[...relayArgs(config),'stop','relay']);
|
|
185
|
+
await hooks.stopHost();
|
|
166
186
|
await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
|
|
167
187
|
await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
|
|
168
188
|
await hooks.startHost(job.rollback.packageRoot);
|
|
169
|
-
await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
|
|
189
|
+
if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
|
|
170
190
|
} else {
|
|
171
191
|
const b=job.rollback;
|
|
172
192
|
await run('docker',[...pluginArgs(b.record),'stop']);
|