@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/control-state.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
490
|
-
if (!ai || !preset || preset
|
|
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
|
-
|
|
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
|
|
634
|
+
!(sameEngine(session, settledPreset) && session.hasStarted && session.preset &&
|
|
628
635
|
presetProvider(session.preset) !== presetProvider(settledPreset)) &&
|
|
629
|
-
(session
|
|
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,
|
|
637
|
-
if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession
|
|
638
|
-
if (state.activeSession
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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'))
|
package/src/delivery-socket.ts
CHANGED
|
@@ -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
|
package/src/event-sources.ts
CHANGED
|
@@ -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) >
|
|
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
|
|
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 =
|
|
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.
|
|
379
|
-
// installer login. Never replace an
|
|
380
|
-
|
|
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
|
+
}
|