@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.
Files changed (80) hide show
  1. package/.env.example +3 -0
  2. package/AGENTS.md +2 -2
  3. package/CHANGELOG.md +30 -0
  4. package/Dockerfile +5 -1
  5. package/THIRD_PARTY_NOTICES.md +6 -3
  6. package/bin/ez +7 -0
  7. package/docker/broker-readiness.mjs +54 -0
  8. package/docker/broker-update-smoke.mjs +100 -0
  9. package/docker/run.ts +1 -1
  10. package/docker/upgrade-smoke.mjs +1 -1
  11. package/docs/application-channel.md +32 -1
  12. package/docs/architecture/ai-selection.md +23 -4
  13. package/docs/docker-runtime.md +57 -0
  14. package/docs/isolated-plugin-update-readiness.md +66 -0
  15. package/docs/releasing.md +4 -0
  16. package/docs/scheduling.md +141 -6
  17. package/docs/selective-monitoring.md +13 -0
  18. package/docs/upgrades.md +33 -4
  19. package/docs/work-preflight.md +9 -0
  20. package/package.json +1 -1
  21. package/src/ai-cli.ts +28 -15
  22. package/src/ai.ts +98 -15
  23. package/src/application-channel.ts +52 -12
  24. package/src/application-cli.ts +7 -1
  25. package/src/application-origin.ts +2 -0
  26. package/src/audio.ts +37 -22
  27. package/src/auth-profile.ts +53 -0
  28. package/src/client-defaults.ts +3 -2
  29. package/src/codex-session.ts +14 -3
  30. package/src/config.ts +9 -0
  31. package/src/control-state.ts +31 -22
  32. package/src/delivery-socket-client.d.mts +2 -0
  33. package/src/delivery-socket-client.mjs +2 -0
  34. package/src/delivery-socket.ts +26 -1
  35. package/src/event-sources.ts +2 -2
  36. package/src/executor.ts +81 -7
  37. package/src/files.ts +47 -3
  38. package/src/host-executor.ts +68 -34
  39. package/src/index.ts +227 -110
  40. package/src/install-tools.mjs +8 -5
  41. package/src/menu.ts +9 -7
  42. package/src/plugin-broker.mjs +50 -14
  43. package/src/plugins/manager.mjs +34 -8
  44. package/src/run-evidence.ts +78 -0
  45. package/src/runs.ts +24 -2
  46. package/src/schedule-cli.ts +100 -13
  47. package/src/schedule-preflight.ts +23 -0
  48. package/src/scheduled-tasks.ts +16 -12
  49. package/src/scheduler.ts +85 -19
  50. package/src/scripts.ts +167 -0
  51. package/src/setup-fallback.ts +122 -0
  52. package/src/setup.ts +2 -2
  53. package/src/task-capability.ts +17 -1
  54. package/src/task-executor.ts +13 -9
  55. package/src/task-mcp.ts +20 -8
  56. package/src/tasks.ts +89 -9
  57. package/src/updates/runtime.mjs +107 -30
  58. package/src/updates/supervisor.mjs +27 -6
  59. package/test/ai.test.ts +40 -0
  60. package/test/application-channel.test.ts +16 -1
  61. package/test/audio.test.ts +19 -0
  62. package/test/auth-profiles.test.ts +187 -0
  63. package/test/config.test.ts +8 -0
  64. package/test/executor.test.ts +37 -0
  65. package/test/files.test.ts +44 -0
  66. package/test/host-executor.test.ts +45 -2
  67. package/test/install-json.test.mjs +21 -0
  68. package/test/plugin-broker.test.mjs +67 -0
  69. package/test/plugin-manager.test.mjs +44 -0
  70. package/test/private-speech-config.test.ts +22 -0
  71. package/test/run-evidence.test.ts +78 -0
  72. package/test/schedule-preflight.test.ts +49 -0
  73. package/test/scheduler-host.test.ts +7 -0
  74. package/test/scheduler-relay.test.ts +38 -3
  75. package/test/scheduler.test.ts +1 -1
  76. package/test/script-schedules.test.ts +219 -0
  77. package/test/setup-fallback.test.ts +245 -0
  78. package/test/task-native.test.ts +50 -12
  79. package/test/tasks.test.ts +97 -4
  80. package/test/updates.test.mjs +100 -11
@@ -2,7 +2,8 @@ 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, presetProvider, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
5
+ import { isPreset, persistedPreset, presetProvider, sameEngine, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
6
+ import { isAuthProfile } from './auth-profile.js'
6
7
 
7
8
  export type Owner = {
8
9
  id?: string
@@ -58,6 +59,8 @@ export type SessionState = {
58
59
  sessionId: string
59
60
  hasStarted: boolean
60
61
  cli?: string
62
+ // Bound with cli at creation: a native session never resumes under another home.
63
+ authProfile?: string
61
64
  nativeSessionId?: string
62
65
  title?: string
63
66
  archived?: boolean
@@ -129,7 +132,8 @@ const isState = (value: unknown): value is ControlState => {
129
132
  (s.archived === undefined || typeof s.archived === 'boolean') &&
130
133
  (s.applicationScope === undefined || /^[a-f0-9]{64}$/.test(s.applicationScope)) &&
131
134
  (s.telegramShared === undefined || typeof s.telegramShared === 'boolean') &&
132
- (s.preset === undefined || (isPreset(s.preset) && s.preset.cli === s.cli))
135
+ (s.authProfile === undefined || (isAuthProfile(s.authProfile) && s.cli !== undefined)) &&
136
+ (s.preset === undefined || (isPreset(s.preset) && sameEngine(s.preset, s)))
133
137
  return (
134
138
  candidate.version === 1 &&
135
139
  Array.isArray(candidate.pending) &&
@@ -150,9 +154,11 @@ const isState = (value: unknown): value is ControlState => {
150
154
  export const sessionTitle = (session: SessionState): string =>
151
155
  session.title || `Conversation ${session.sessionId.slice(0, 8)}`
152
156
 
157
+ const engineOf = (preset: AiPreset) => ({ cli: preset.cli, ...(preset.authProfile ? { authProfile: preset.authProfile } : {}) })
158
+
153
159
  const rememberPreset = (state: ControlState) => {
154
160
  const preset = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
155
- if (state.activeSession && preset && state.activeSession.cli === preset.cli)
161
+ if (state.activeSession && preset && sameEngine(state.activeSession, preset))
156
162
  state.activeSession.preset = preset
157
163
  }
158
164
 
@@ -486,8 +492,8 @@ export class ControlStore {
486
492
  const ai = state.ai
487
493
  // Older sessions did not record their model. Reuse a known preset for the
488
494
  // same CLI; never resume an engine ID through a different client.
489
- const preset = session.preset ?? ai?.presets.find(p => p.cli === session.cli)
490
- if (!ai || !preset || preset.cli !== session.cli)
495
+ const preset = session.preset ?? ai?.presets.find(p => sameEngine(p, session))
496
+ if (!ai || !preset || !sameEngine(preset, session))
491
497
  throw new Error('This older conversation has no saved AI binding. Start a new conversation.')
492
498
  if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli!) && !session.nativeSessionId)
493
499
  throw new Error('This conversation has no native session ID. Start a new conversation.')
@@ -537,10 +543,11 @@ export class ControlStore {
537
543
  const state = await this.readState()
538
544
  await requireControlGuard(state, guard)
539
545
  if (expectedSession !== undefined && (state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
546
+ const fallback = state.ai?.presets.find((p) => p.id === state.ai!.defaultId)
540
547
  const next: SessionState = {
541
548
  sessionId: crypto.randomUUID(),
542
549
  hasStarted: false,
543
- cli: state.ai?.presets.find((p) => p.id === state.ai!.defaultId)?.cli,
550
+ ...(fallback ? engineOf(fallback) : {}),
544
551
  }
545
552
  rememberPreset(state)
546
553
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
@@ -624,25 +631,25 @@ export class ControlStore {
624
631
  const settledPreset = settledAi?.presets.find((p) => p.id === settledAi.selectedId)
625
632
  const session = settled.activeSession
626
633
  if (settledPreset && session && (session.cli || session.hasStarted) &&
627
- !(session.cli === settledPreset.cli && session.hasStarted && session.preset &&
634
+ !(sameEngine(session, settledPreset) && session.hasStarted && session.preset &&
628
635
  presetProvider(session.preset) !== presetProvider(settledPreset)) &&
629
- (session.cli !== settledPreset.cli || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
636
+ (!sameEngine(session, settledPreset) || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
630
637
  Boolean(session.title || session.hasStarted || !title?.trim()))
631
638
  return { sessionId: session.sessionId, preset: settledPreset }
632
639
  return this.withLock(async () => {
633
640
  const state = await this.readState()
634
641
  state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
635
642
  const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
636
- state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
637
- if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
638
- if (state.activeSession.cli === preset.cli) {
643
+ state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset) }
644
+ if (!state.activeSession.cli && !state.activeSession.hasStarted) Object.assign(state.activeSession, engineOf(preset))
645
+ if (sameEngine(state.activeSession, preset)) {
639
646
  const previous = state.activeSession.preset
640
647
  // A provider change starts a separate native context. Model and effort
641
648
  // changes are native turn settings within the existing conversation.
642
649
  if (state.activeSession.hasStarted && previous &&
643
650
  presetProvider(previous) !== presetProvider(preset)) {
644
651
  ;(state.sessions ??= []).push(state.activeSession)
645
- state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset }
652
+ state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset }
646
653
  } else {
647
654
  state.activeSession.preset = preset
648
655
  }
@@ -675,14 +682,14 @@ export class ControlStore {
675
682
  state.ai!.selectedId = session.preset!.id
676
683
  }
677
684
  if (previous?.preset) {
678
- if (requested && requested.cli !== previous.cli) throw new Error('Application request conflicts with existing session engine')
685
+ if (requested && !sameEngine(requested, previous)) throw new Error('Application request conflicts with existing session engine')
679
686
  if (requested) previous.preset = requested
680
687
  activate(previous)
681
688
  if (shareTelegram || requested) await this.writeState(state)
682
689
  return { sessionId: previous.sessionId, preset: previous.preset }
683
690
  }
684
691
  const preset = requested ?? state.ai.presets.find(item => item.id === state.ai!.selectedId)!
685
- const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset, applicationScope: scope }
692
+ const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset, applicationScope: scope }
686
693
  state.sessions.push(session)
687
694
  activate(session)
688
695
  await this.writeState(state)
@@ -705,7 +712,7 @@ export class ControlStore {
705
712
  throw new Error('Application scope is shared; use shared controls')
706
713
  const nextPreset = preset ?? state.ai?.presets.find(item => item.id === state.ai!.defaultId)
707
714
  if (!nextPreset || !isPreset(nextPreset)) throw new Error('Invalid application AI selection')
708
- if (preset && previous?.cli === preset.cli) {
715
+ if (preset && previous && sameEngine(previous, preset)) {
709
716
  previous.preset = persistedPreset(preset)
710
717
  await this.writeState(state)
711
718
  return previous
@@ -714,7 +721,7 @@ export class ControlStore {
714
721
  // the old immutable session ID; private history stays absent from /chats.
715
722
  if (previous) previous.archived = true
716
723
  const next: SessionState = {sessionId:crypto.randomUUID(), hasStarted:false,
717
- cli:nextPreset.cli, preset:persistedPreset(nextPreset), applicationScope:scope}
724
+ ...engineOf(nextPreset), preset:persistedPreset(nextPreset), applicationScope:scope}
718
725
  ;(state.sessions ??= []).push(next)
719
726
  await this.writeState(state)
720
727
  return next
@@ -725,8 +732,8 @@ export class ControlStore {
725
732
  const state = await this.status()
726
733
  const session = state.activeSession?.sessionId === choice.sessionId ? state.activeSession
727
734
  : state.sessions?.find((s) => s.sessionId === choice.sessionId)
728
- if (!session || session.cli !== choice.preset.cli)
729
- throw new Error('Conversation has no matching CLI binding. Use New conversation explicitly; files are preserved.')
735
+ if (!session?.cli || !sameEngine(session, choice.preset))
736
+ throw new Error('Conversation has no matching CLI binding for this client and auth profile. Use New conversation explicitly; files are preserved.')
730
737
  if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli) && !session.nativeSessionId)
731
738
  throw new Error('Native session ID missing. Use New conversation explicitly; no automatic reset.')
732
739
  return session
@@ -755,7 +762,9 @@ export class ControlStore {
755
762
  const state = await this.readState()
756
763
  await requireControlGuard(state, guard)
757
764
  if (!state.ai) throw new Error('AI settings not initialized')
758
- if (state.ai.presets.length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
765
+ // Auto-detected client defaults come and go with installed clients; only
766
+ // owner-saved choices count toward the cap.
767
+ if (state.ai.presets.filter((p) => !p.id.startsWith('detected_')).length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
759
768
  throw new Error('Keep it small: at most 12 saved AIs.')
760
769
  state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
761
770
  await this.writeState(state)
@@ -772,16 +781,16 @@ export class ControlStore {
772
781
  assertEffort(preset.effort, preset.model, preset.cli)
773
782
  if ((state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Menu expired. Open Choose AI again.')
774
783
  const current = ai.presets.find((p) => p.id === ai.selectedId)!
775
- if (state.activeSession && (current.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
784
+ if (state.activeSession && (!sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
776
785
  if (fresh || !state.activeSession) {
777
786
  rememberPreset(state)
778
787
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
779
- state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset: persistedPreset(preset) }
788
+ state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset: persistedPreset(preset) }
780
789
  }
781
790
  ai.selectedId = id
782
791
  // Persist the conversation's choice now, even if the owner switches chats
783
792
  // 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)))
793
+ if (sameEngine(state.activeSession, preset) && (!state.activeSession.preset || presetProvider(state.activeSession.preset) === presetProvider(preset)))
785
794
  state.activeSession.preset = persistedPreset(preset)
786
795
  ai.recentIds = [id, ...(ai.recentIds ?? []).filter((recentId) => recentId !== id)].slice(0, 3)
787
796
  await this.writeState(state)
@@ -2,3 +2,5 @@ export type DeliverySocketRequest = { op: string; payload?: unknown }
2
2
  export function deliverySocketPath(controlDir: string): string
3
3
  export function callDeliverySocket(socketPath: string, op: DeliverySocketRequest, timeoutMs?: number): Promise<unknown>
4
4
  export function deliverySocketAlive(socketPath: string, timeoutMs?: number): Promise<boolean>
5
+
6
+ export function callLocalSocket(socketPath:string, envelope:Record<string,unknown>, timeoutMs?:number):Promise<unknown>
@@ -67,6 +67,8 @@ const exchange = (connect, envelope, timeoutMs, target) => new Promise((resolve,
67
67
  socket.write(encode(envelope))
68
68
  })
69
69
 
70
+ export const callLocalSocket = (socketPath, envelope, timeoutMs = 130000) => exchange(() => createConnection(socketPath), envelope, timeoutMs, socketPath)
71
+
70
72
  const readEndpoint = async controlDir => {
71
73
  try {
72
74
  const value = JSON.parse(await readFile(path.join(controlDir, endpointName), 'utf8'))
@@ -1,3 +1,5 @@
1
+ import { sameOwner } from './control-state.js'
2
+ import { ApplicationBindings } from './application-channel.js'
1
3
  import { createServer, type Server, type Socket } from 'node:net'
2
4
  import { randomBytes, timingSafeEqual } from 'node:crypto'
3
5
  import { rename, rm, writeFile } from 'node:fs/promises'
@@ -9,6 +11,8 @@ import { deliveredMessages } from './message-history.js'
9
11
  import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
10
12
  import { telegramOwner } from './control-state.js'
11
13
  import { Scheduler } from './scheduler.js'
14
+ import {Tasks} from './tasks.js'
15
+ import { runEvidence, type EvidenceOptions } from './run-evidence.js'
12
16
 
13
17
  // Stateless pipe transport. The relay owns the runs/outbox ledger in process
14
18
  // memory; engine children, plugin children and the host executor reach it
@@ -28,16 +32,19 @@ export { deliverySocketPath }
28
32
  export type DeliverySocketOp =
29
33
  | { op: 'ping' }
30
34
  | { op: 'status' }
35
+ | { op: 'schedulePreflight'; payload?: Record<string, unknown> }
31
36
  | { op: 'triggerSchedule'; payload: { scheduleId: string; revision: string; key: string; callerRunId?: string } }
32
37
  | { op: 'enqueue'; payload: Record<string, unknown> }
33
38
  | { op: 'wait'; payload: { id: string; timeoutMs?: number } }
34
39
  | { op: 'get'; payload: { runId: string } }
35
40
  | { op: 'list'; payload?: Record<string, never> }
36
41
  | { op: 'patch'; payload: { runId: string; change: Record<string, unknown> } }
42
+ | { op: 'taskApplication'; payload: {runId:string;ticketHash:string} }
37
43
  | { op: 'authorize'; payload: { runId: string } }
38
44
  | { op: 'receipt'; payload: { context: unknown; id: string } }
39
45
  | { op: 'history'; payload: { runId: string; limit?: number; messageId?: number } }
40
46
  | { op: 'deliveryStatus'; payload?: Record<string, never> }
47
+ | { op: 'evidence'; payload: EvidenceOptions }
41
48
  | { op: 'approvalCheck'; payload: { actionId: string } }
42
49
  | { op: 'latestReply'; payload?: Record<string, never> }
43
50
 
@@ -210,12 +217,23 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
210
217
  queued: all.filter((run) => run.status === 'queued').length,
211
218
  }
212
219
  }
220
+ case 'schedulePreflight': {
221
+ const {scheduleId,revision}=op.payload ?? {}
222
+ if(!nonEmptyString(scheduleId) || !nonEmptyString(revision)) throw Error('Schedule preflight identity required')
223
+ const scheduler=new Scheduler(controlDir),s=await scheduler.get(scheduleId),owner=await readOnlyOwner(controlDir)
224
+ if(!owner || !sameOwner(owner,s.owner) || !s.enabled || s.revision!==revision || !s.preflight) throw Error('Schedule preflight binding unavailable')
225
+ if(s.delivery) {
226
+ const binding=(await new ApplicationBindings(controlDir).list()).find(b=>b.bindingId===s.delivery!.bindingId)
227
+ if(!binding || !sameOwner(owner,binding.owner)) throw Error('Schedule preflight delivery revoked')
228
+ }
229
+ return s.preflight
230
+ }
213
231
  case 'triggerSchedule': {
214
232
  const {scheduleId,revision,key,callerRunId}=op.payload ?? {}
215
233
  if (!nonEmptyString(scheduleId) || !nonEmptyString(revision) || !nonEmptyString(key)) throw new Error('Task ID, revision and request key are required')
216
234
  if (callerRunId !== undefined) {
217
235
  const caller=await requireOwnerExecution(controlDir,callerRunId)
218
- if (caller.replyOnly || (caller.scheduled && caller.scheduled.pairedAt !== (await readOnlyOwner(controlDir))?.pairedAt)) throw new Error('Scheduling requires an active owner-authorized run')
236
+ if (caller.replyOnly || caller.script || (caller.scheduled && caller.scheduled.pairedAt !== (await readOnlyOwner(controlDir))?.pairedAt)) throw new Error('Scheduling requires an active owner-authorized run')
219
237
  }
220
238
  const owner=await readOnlyOwner(controlDir)
221
239
  if (!owner) throw new Error('Pair an owner before scheduling')
@@ -300,8 +318,14 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
300
318
  const payload = (op.payload ?? {}) as { runId?: unknown; change?: unknown }
301
319
  if (typeof payload.runId !== 'string' || !payload.runId) throw new Error('Run ID is required')
302
320
  if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
321
+ // Setup attempts select what the host admits; only the relay records them.
322
+ if ('attempts' in payload.change) throw new Error('Setup attempts are relay-owned')
303
323
  return runs.patch(payload.runId, payload.change as never)
304
324
  }
325
+ case 'taskApplication': {
326
+ const p=op.payload as {runId:string;ticketHash:string}
327
+ return new Tasks(controlDir).authorizeApplicationLaunch(p.runId,p.ticketHash)
328
+ }
305
329
  case 'authorize': {
306
330
  const runId = (op.payload as { runId?: unknown } | undefined)?.runId
307
331
  if (typeof runId !== 'string' || !runId) throw new Error('Run ID is required')
@@ -321,6 +345,7 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
321
345
  ...(payload.messageId === undefined ? {} : { messageId: payload.messageId as number }),
322
346
  })
323
347
  }
348
+ case 'evidence': return runEvidence(controlDir, op.payload ?? {})
324
349
  case 'deliveryStatus': return runs.deliveryStatus()
325
350
  case 'approvalCheck': {
326
351
  const actionId = (op.payload as { actionId?: unknown } | undefined)?.actionId
@@ -27,11 +27,11 @@ async function atomic(path: string, data: unknown) {
27
27
  try { await fd.writeFile(JSON.stringify(data)); await fd.sync() } finally { await fd.close() }
28
28
  await rename(tmp, path)
29
29
  }
30
- export function sourceCall(socketPath: string, command: string, args = {}): Promise<any> {
30
+ export function sourceCall(socketPath: string, command: string, args = {}, maxResponseBytes = 256000): Promise<any> {
31
31
  return new Promise((resolve, reject) => {
32
32
  const req = request({ socketPath, path: '/', method: 'POST', timeout: 3000 }, res => {
33
33
  let body = ''
34
- res.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) > 256000) req.destroy(new Error('Event-source response too large')) })
34
+ res.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) > maxResponseBytes) req.destroy(new Error('Event-source response too large')) })
35
35
  res.on('error', () => reject(new Error('Event-source response interrupted')))
36
36
  res.on('end', () => {
37
37
  try { const result = JSON.parse(body); if (res.statusCode !== 200 || result.ok !== true) throw new Error(); resolve(result.data) }
package/src/executor.ts CHANGED
@@ -11,6 +11,8 @@ import { createInterface } from 'node:readline'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
13
13
  import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
14
+ import { Scripts, type ScriptRunRef } from './scripts.js'
15
+ import { claudeAuthEnvironment, cliHome, provisionedHome } from './auth-profile.js'
14
16
 
15
17
  export type ExecutorOptions = {
16
18
  repairEnabled?: boolean
@@ -34,12 +36,18 @@ export type ExecutorOptions = {
34
36
  model?: string
35
37
  effort?: string
36
38
  provider?: string
39
+ // Named owner-provisioned credential home; unset keeps the agent default.
40
+ authProfile?: string
37
41
  codexSandbox?: 'external'
38
42
  codexAutoCompactTokens?: number
39
43
  codexProvider?: CodexProviderBinding
40
44
  onSession?: (id: string) => Promise<void>
45
+ // Registered script occurrence: invoke it directly, never a native CLI.
46
+ script?: ScriptLaunch
41
47
  }
42
48
 
49
+ export type ScriptLaunch = ScriptRunRef & { scheduleId: string; scheduleRevision: string; dueAt: string }
50
+
43
51
  export type CodexProviderBinding = {
44
52
  id: string
45
53
  name: string
@@ -71,7 +79,7 @@ export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env
71
79
  }
72
80
 
73
81
  export const executorJobEnv = (
74
- options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'>,
82
+ options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'> & { workspace?: string },
75
83
  environment: NodeJS.ProcessEnv = process.env,
76
84
  ): NodeJS.ProcessEnv => {
77
85
  const base = executorEnvironment(environment)
@@ -81,6 +89,7 @@ export const executorJobEnv = (
81
89
  PATH: pathValue,
82
90
  EZ_RUN_ID: options.runId,
83
91
  EZ_CONTROL_DIR: options.controlDir,
92
+ ...(options.workspace ? { EZ_AGENT_WORKSPACE: options.workspace } : {}),
84
93
  EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
85
94
  EZ_REPAIR_ENABLED: String(options.repairEnabled !== false),
86
95
  ...(options.toolsHome ? {BUILDX_CONFIG:path.join(options.toolsHome,'buildx')} : {}),
@@ -89,6 +98,10 @@ export const executorJobEnv = (
89
98
 
90
99
  export const grokJobEnv = executorJobEnv
91
100
 
101
+ // The selected native CLI is not installed or not executable. Nothing ran, so a
102
+ // scheduled occurrence may advance to its next setup.
103
+ export class CliUnavailableError extends Error {}
104
+
92
105
  // Package binDir is Ez tools (ezenciel-agents-message). Native CLIs come from the
93
106
  // host PATH so one agent's wrapper cannot retarget another agent's CODEX_HOME.
94
107
  export const resolveHostCommand = (command: string, pathValue = process.env.PATH): string => {
@@ -101,14 +114,14 @@ export const resolveHostCommand = (command: string, pathValue = process.env.PATH
101
114
  return candidate
102
115
  } catch { /* try the next PATH entry */ }
103
116
  }
104
- throw new Error(`Native CLI ${command} is not executable on the host PATH`)
117
+ throw new CliUnavailableError(`Native CLI ${command} is not executable on the host PATH`)
105
118
  }
106
119
  export type CliAdapter = {
107
120
  name: string
108
121
  command: string
109
122
  description: string
110
123
  buildArgs: (
111
- options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox' | 'codexProvider'> & { controlDir?: string },
124
+ options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox' | 'codexProvider' | 'nativeSession'> & { controlDir?: string },
112
125
  promptFile: string,
113
126
  promptText: string,
114
127
  ) => string[]
@@ -155,6 +168,11 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
155
168
  args.push('--session-id', opts.sessionId)
156
169
  }
157
170
  args.push('--print', '--dangerously-skip-permissions')
171
+ // Scheduled runs stream structured events so core can tell a provider
172
+ // rejection before work from a failure after work began.
173
+ if (opts.nativeSession) args.push('--output-format', 'stream-json', '--verbose')
174
+ for (const directory of [opts.controlDir, opts.sharedWorkspace, ...(opts.additionalWorkspaces ?? []), opts.toolsHome])
175
+ if (directory) args.push('--add-dir', directory)
158
176
  if (opts.model) args.push('--model', opts.model)
159
177
  if (opts.effort) args.push('--effort', opts.effort)
160
178
  return args
@@ -285,6 +303,9 @@ export const opencodeDataHome = (controlDir: string): string | undefined => {
285
303
  } catch { return undefined }
286
304
  }
287
305
 
306
+ // Agent-bound Claude Code configuration directory (CLAUDE_CONFIG_DIR).
307
+ export const claudeConfigHome = (controlDir: string, authProfile?: string): string => cliHome(controlDir, 'claude', authProfile)
308
+
288
309
  // Pi resolves its provider/model catalog from an agent dir (settings.json,
289
310
  // auth.json). Only a path is ever returned, never a secret.
290
311
  export const piAgentDir = (controlDir: string): string | undefined => {
@@ -323,7 +344,10 @@ export const startExecutorJob = async (
323
344
  texts: string[],
324
345
  options: ExecutorOptions,
325
346
  ): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
347
+ if (options.script && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
326
348
  options = executionDefaults(executorKey(options.cli), options)
349
+ // Named credential homes are owner-provisioned; fail closed before any launch state.
350
+ if (options.authProfile !== undefined) await provisionedHome(options.controlDir, executorKey(options.cli), options.authProfile)
327
351
  // Routing is caller-owned: a restricted task run goes to the task runner
328
352
  // unless the host transport must ship it across the boundary first.
329
353
  if (options.taskRun && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
@@ -365,7 +389,7 @@ export const startExecutorJob = async (
365
389
  }
366
390
  if (!host && !gui && command === 'codex') {
367
391
  // Share the existing authentication, never the user's memory/config/sessions.
368
- const base = path.join(options.controlDir, 'cli', 'codex')
392
+ const base = await provisionedHome(options.controlDir, 'codex', options.authProfile)
369
393
  const home = nativeSession ? path.join(base,'tasks',options.runId) : base
370
394
  await mkdir(home, {recursive:true,mode:0o700})
371
395
  if(nativeSession){
@@ -375,9 +399,10 @@ export const startExecutorJob = async (
375
399
  try{await writeFile(path.join(home,'config.toml'),await readFile(path.join(base,'config.toml')),{flag:'wx',mode:0o600})}
376
400
  catch(error){if(!['ENOENT','EEXIST'].includes((error as NodeJS.ErrnoException).code || ''))throw error}
377
401
  }
378
- // Isolated agents keep auth in this CODEX_HOME. Host-capable may link the
379
- // installer login. Never replace an existing binding.
380
- const authLinks = process.env.EZ_ISOLATION === 'isolated'
402
+ // Isolated agents and named profiles keep auth in this CODEX_HOME. The
403
+ // host-capable default may link the installer login. Never replace an
404
+ // existing binding.
405
+ const authLinks = process.env.EZ_ISOLATION === 'isolated' || options.authProfile !== undefined
381
406
  ? nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : []
382
407
  : [[path.join(base, 'auth.json'), path.join(homedir(), '.codex', 'auth.json')],
383
408
  ...(nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : [])]
@@ -387,6 +412,22 @@ export const startExecutorJob = async (
387
412
  }
388
413
  environment.CODEX_HOME = home
389
414
  }
415
+ if (!host && !gui && key === 'claude') {
416
+ // Mirror the Codex binding: agent-bound settings, memory, plugins and
417
+ // sessions; never the user's personal ~/.claude configuration.
418
+ const home = await provisionedHome(options.controlDir, 'claude', options.authProfile)
419
+ await mkdir(home, {recursive:true,mode:0o700})
420
+ // Host-capable default homes share the installer login only. An empty
421
+ // secure storage dir makes Claude use its default credential store (macOS
422
+ // Keychain entry or ~/.claude/.credentials.json) in place, so token
423
+ // refreshes stay shared rather than diverging through a copy. Isolated
424
+ // agents and named profiles keep their own login inside CLAUDE_CONFIG_DIR.
425
+ // The host executor runs unlabelled, so this matches the Codex auth-link
426
+ // check, not resolveIsolation(). An optional owner-provisioned long-lived
427
+ // token (`claude setup-token`) never refreshes, so one file can be copied
428
+ // to every agent without them invalidating each other's OAuth session.
429
+ Object.assign(environment, await claudeAuthEnvironment(home, options.authProfile))
430
+ }
390
431
  if (!host && !gui && key === 'opencode') {
391
432
  // Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
392
433
  // when the owner provisioned it. Host runs keep the installer login;
@@ -459,6 +500,39 @@ export const startExecutorJob = async (
459
500
 
460
501
  export const startGrokJob = startExecutorJob
461
502
 
503
+ // Spawn a verified registered script with the executor identity, environment
504
+ // allowlist and workspace confinement. No shell, model, prompt or native session.
505
+ export const startScriptJob = async (
506
+ options: ExecutorOptions & { script: ScriptLaunch },
507
+ ): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
508
+ const { script } = options
509
+ for (const value of [options.runId, script.scheduleId, script.scheduleRevision]) if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error('Invalid script run binding')
510
+ if (!Number.isFinite(Date.parse(script.dueAt))) throw new Error('Invalid script due time')
511
+ const environment: NodeJS.ProcessEnv = {
512
+ ...executorJobEnv(options),
513
+ EZ_SCHEDULE_ID: script.scheduleId, EZ_SCHEDULE_REVISION: script.scheduleRevision, EZ_DUE_AT: script.dueAt,
514
+ EZ_SCRIPT_ID: script.id, EZ_SCRIPT_REVISION: script.revision, EZ_SCRIPT_SHA256: script.sha256,
515
+ }
516
+ const verified = await new Scripts(options.controlDir).verifiedInvocation(script, options.workspace, environment.PATH)
517
+ let invocation = executorInvocation(verified.command, verified.args)
518
+ let profileDirectory: string | undefined
519
+ const denies = process.platform === 'darwin' ? workspaceSiblingDenies(options.workspace) : []
520
+ if (denies.length) {
521
+ profileDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
522
+ const profile = path.join(profileDirectory, 'workspace.sb')
523
+ await writeFile(profile, macosWorkspaceProfile(denies, [options.controlDir, options.binDir, options.toolsHome, options.sharedWorkspace,
524
+ ...(options.additionalWorkspaces ?? [])].filter((value): value is string => Boolean(value))), { mode: 0o600 })
525
+ invocation = { command: 'sandbox-exec', args: ['-f', profile, invocation.command, ...invocation.args] }
526
+ }
527
+ const cleanup = async () => { if (profileDirectory) await rm(profileDirectory, { recursive: true, force: true }) }
528
+ const child = spawn(invocation.command, invocation.args, {
529
+ cwd: options.workspace, env: environment, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', shell: false,
530
+ })
531
+ await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
532
+ .catch(async (error) => { await cleanup(); throw error })
533
+ return { child, cleanup, stdout: '' }
534
+ }
535
+
462
536
  // Structured client events only. Model text is never interpreted or sent to chat.
463
537
  export const nativeSessionId = (cli: string, line: string): string | undefined => {
464
538
  try {
package/src/files.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { mkdir, readFile, writeFile, realpath, stat } from 'node:fs/promises'
1
+ import { mkdir, readFile, writeFile, realpath, stat, mkdtemp, readdir, rm, unlink } from 'node:fs/promises'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
2
4
  import { randomUUID } from 'node:crypto'
3
5
  import path from 'node:path'
4
6
 
5
- export type DetectedFileType = 'pdf' | 'jpeg' | 'png' | 'webp' | 'text' | 'unknown'
7
+ export type DetectedFileType = 'pdf' | 'jpeg' | 'png' | 'webp' | 'text' | 'mp4' | 'unknown'
6
8
 
7
9
  export const detectFileType = (buffer: Buffer): DetectedFileType => {
8
10
  if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
@@ -31,6 +33,9 @@ export const detectFileType = (buffer: Buffer): DetectedFileType => {
31
33
  ) {
32
34
  return 'webp'
33
35
  }
36
+ // ISO-BMFF also carries HEIF/AVIF stills and audio-only M4A; only video brands are MP4.
37
+ if (buffer.length >= 12 && buffer.subarray(4,8).toString('ascii') === 'ftyp' &&
38
+ !/^(hei[cmsx]|hev[cmsx]|mif1|msf1|avi[fs]|M4[ABP] |F4A )$/.test(buffer.subarray(8,12).toString('latin1'))) return 'mp4'
34
39
  try {
35
40
  new TextDecoder('utf-8', { fatal: true }).decode(buffer)
36
41
  if (!buffer.includes(0)) return 'text'
@@ -108,7 +113,46 @@ export const stageChatAttachment = async (stagingRoot: string, name: string, byt
108
113
  if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
109
114
  (type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
110
115
  const staged = await stageIncomingFile(stagingRoot, name, bytes)
111
- const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
116
+ const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : staged.fileType === 'mp4' ? 'video' : 'document'
112
117
  return { text: `[Attached ${kind} staged at ${staged.fullPath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
113
118
  attachment: {path: staged.fullPath, type: staged.fileType}, fullPath: staged.fullPath }
114
119
  }
120
+
121
+ // Shared inbound staging/validation with Telegram; restricted correspondence
122
+ // can read only this already-authorized input, never arbitrary workspace paths.
123
+ export async function readChatAttachment(stagingRoot: string, name: string, bytes: Buffer) {
124
+ const staged = await stageChatAttachment(stagingRoot, name, bytes, '')
125
+ // Untrusted media parsers never inherit relay secrets.
126
+ const execute = (command: string, args: string[], options: {timeout:number;maxBuffer:number}) =>
127
+ promisify(execFile)(command, args, {...options, env: {PATH: process.env.PATH ?? '/usr/bin:/bin'}})
128
+ try {
129
+ if (['jpeg','png','webp'].includes(staged.attachment.type)) return {name:sanitizeFileName(name),type:staged.attachment.type,bytes:bytes.length,image:{data:bytes.toString('base64'),mimeType:`image/${staged.attachment.type}`}}
130
+ if (staged.attachment.type === 'mp4') {
131
+ const directory = await mkdtemp(path.join(stagingRoot,'video-'))
132
+ try {
133
+ const probe = JSON.parse((await execute('ffprobe',['-v','error','-protocol_whitelist','file,pipe','-show_entries','format=duration:stream=codec_type','-of','json',staged.fullPath],{timeout:3000,maxBuffer:8000})).stdout)
134
+ const durationSeconds = Number(probe.format?.duration)
135
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || durationSeconds > 120 || !probe.streams?.some((stream:{codec_type:string})=>stream.codec_type==='video')) throw Error('Video must contain a visual track and be at most 120 seconds')
136
+ await execute('ffmpeg',['-v','error','-nostdin','-protocol_whitelist','file,pipe','-enable_drefs','0','-use_absolute_path','0','-i',staged.fullPath,'-an','-vf',`fps=fps=${8/durationSeconds}:start_time=0,scale=640:-2`,'-frames:v','8','-q:v','3',path.join(directory,'frame-%02d.jpg')],{timeout:20000,maxBuffer:8000})
137
+ const names=(await readdir(directory)).filter(name=>/^frame-\d{2}\.jpg$/.test(name)).sort()
138
+ const images=[];let total=0
139
+ for (const frame of names) {
140
+ const image=await readFile(path.join(directory,frame));total+=image.length
141
+ if (total>1024*1024 || detectFileType(image)!=='jpeg') throw Error('Video frames exceed the bounded image limit')
142
+ images.push({data:image.toString('base64'),mimeType:'image/jpeg'})
143
+ }
144
+ if (!images.length) throw Error('Video has no decodable frames')
145
+ return {name:sanitizeFileName(name),type:'mp4',bytes:bytes.length,durationSeconds,frames:images.map((_,index)=>({frame:index+1,seconds:index*durationSeconds/8})),images}
146
+ } finally { await rm(directory,{recursive:true,force:true}) }
147
+ }
148
+ const limit = 256000
149
+ const text = staged.attachment.type === 'text' ? new TextDecoder('utf-8', {fatal:true}).decode(bytes)
150
+ : (await execute('pdftotext', ['-enc','UTF-8','-layout',staged.fullPath,'-'], {timeout:20000,maxBuffer:limit + 1}).catch(error => {
151
+ if ((error as {code?:unknown}).code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') throw Error('Extracted document exceeds the 256000-byte text limit')
152
+ throw error
153
+ })).stdout
154
+ if (!text.trim()) throw Error('Document has no extractable text; scanned PDFs require OCR')
155
+ if (Buffer.byteLength(text) > limit) throw Error('Extracted document exceeds the 256000-byte text limit')
156
+ return { name: sanitizeFileName(name), type: staged.attachment.type, bytes: bytes.length, text }
157
+ } finally { await unlink(staged.fullPath) }
158
+ }