@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36
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/.dockerignore +1 -0
- package/.env.example +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +178 -26
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +143 -5
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/scheduler.test.ts +2 -2
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -44
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createServer, type Server, type Socket } from 'node:net'
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
|
+
import { rename, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { callDeliverySocket as callSocket, deliverySocketAlive as socketAlive, deliverySocketPath } from './delivery-socket-client.mjs'
|
|
6
|
+
import { RunStore, sentOutbox, type RunRecord } from './runs.js'
|
|
7
|
+
import { ApprovalStore } from './approval.js'
|
|
8
|
+
import { deliveredMessages } from './message-history.js'
|
|
9
|
+
import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
|
|
10
|
+
import { telegramOwner } from './control-state.js'
|
|
11
|
+
|
|
12
|
+
// Stateless pipe transport. The relay owns the runs/outbox ledger in process
|
|
13
|
+
// memory; engine children, plugin children and the host executor reach it
|
|
14
|
+
// through this socket instead of control/ files. The socket lives on the
|
|
15
|
+
// shared control volume so container-colocated and host-side children use the
|
|
16
|
+
// same path. Binding it also guards single-relay ownership (replacing the old
|
|
17
|
+
// relay.lock flock): a second relay finds the address live and exits.
|
|
18
|
+
//
|
|
19
|
+
// Frames are newline-delimited JSON: {id, op, payload} -> {id, ok, result?,
|
|
20
|
+
// error?}. Every op re-verifies ownership server-side; request metadata is
|
|
21
|
+
// never trusted. A restart drops the ledger by design: in-flight sends report
|
|
22
|
+
// unknown rather than claiming success. The transport itself lives in
|
|
23
|
+
// delivery-socket-client.mjs so plain-Node tools share one implementation.
|
|
24
|
+
|
|
25
|
+
export { deliverySocketPath }
|
|
26
|
+
|
|
27
|
+
export type DeliverySocketOp =
|
|
28
|
+
| { op: 'ping' }
|
|
29
|
+
| { op: 'status' }
|
|
30
|
+
| { op: 'enqueue'; payload: Record<string, unknown> }
|
|
31
|
+
| { op: 'wait'; payload: { id: string; timeoutMs?: number } }
|
|
32
|
+
| { op: 'get'; payload: { runId: string } }
|
|
33
|
+
| { op: 'list'; payload?: Record<string, never> }
|
|
34
|
+
| { op: 'patch'; payload: { runId: string; change: Record<string, unknown> } }
|
|
35
|
+
| { op: 'authorize'; payload: { runId: string } }
|
|
36
|
+
| { op: 'receipt'; payload: { context: unknown; id: string } }
|
|
37
|
+
| { op: 'history'; payload: { runId: string; limit?: number; messageId?: number } }
|
|
38
|
+
| { op: 'deliveryStatus'; payload?: Record<string, never> }
|
|
39
|
+
| { op: 'approvalCheck'; payload: { actionId: string } }
|
|
40
|
+
| { op: 'latestReply'; payload?: Record<string, never> }
|
|
41
|
+
|
|
42
|
+
export type DeliveryHandler = (op: DeliverySocketOp) => Promise<unknown>
|
|
43
|
+
|
|
44
|
+
// Socket-first run reads for library modules that execute both inside the
|
|
45
|
+
// relay (shared memory) and in sibling processes (host executor,
|
|
46
|
+
// task executor). A relay answering on the shared-volume socket is
|
|
47
|
+
// authoritative; connect-level failures fall back to direct memory, which is
|
|
48
|
+
// empty in a fresh sibling process and therefore fails closed. Post-connect
|
|
49
|
+
// errors (unknown run, revoked owner) always propagate.
|
|
50
|
+
const connectFailure = (error: unknown): boolean =>
|
|
51
|
+
error instanceof Error && (/Delivery relay unavailable/.test(error.message) || /ECONNREFUSED|ENOENT/.test(error.message))
|
|
52
|
+
|
|
53
|
+
export const readRun = async (controlDir: string, runId: string): Promise<RunRecord | null> => {
|
|
54
|
+
try {
|
|
55
|
+
const run = await callDeliverySocket(socketPathFor(controlDir), { op: 'get', payload: { runId } }, 10_000)
|
|
56
|
+
return run as RunRecord
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!connectFailure(error)) throw error
|
|
59
|
+
if (error instanceof Error && /Unknown run/.test(error.message)) return null
|
|
60
|
+
return new RunStore(controlDir).get(runId)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const authorizeRun = async (controlDir: string, runId: string): Promise<RunRecord> => {
|
|
65
|
+
try {
|
|
66
|
+
const run = await callDeliverySocket(socketPathFor(controlDir), { op: 'authorize', payload: { runId } }, 10_000)
|
|
67
|
+
return run as RunRecord
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!connectFailure(error)) throw error
|
|
70
|
+
return requireOwnerExecution(controlDir, runId)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const encode = (value: unknown): string => `${JSON.stringify(value)}\n`
|
|
75
|
+
|
|
76
|
+
export type DeliveryEndpoint = { host: '127.0.0.1'; port: number; token: string }
|
|
77
|
+
export type DeliveryServeOptions = { tcpPort?: number }
|
|
78
|
+
const endpointFile = 'delivery-endpoint.json'
|
|
79
|
+
const tokensMatch = (candidate: unknown, token: string): boolean =>
|
|
80
|
+
typeof candidate === 'string' && candidate.length === token.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(token))
|
|
81
|
+
|
|
82
|
+
export const serveDeliverySocket = async (
|
|
83
|
+
controlDir: string,
|
|
84
|
+
handle: DeliveryHandler,
|
|
85
|
+
options: DeliveryServeOptions = {},
|
|
86
|
+
): Promise<{ socketPath: string; endpoint: DeliveryEndpoint | null; stop: () => Promise<void> }> => {
|
|
87
|
+
const socketPath = deliverySocketPath(controlDir)
|
|
88
|
+
await rm(socketPath, { force: true }).catch(() => {})
|
|
89
|
+
const attach = (socket: Socket, token: string | null): void => {
|
|
90
|
+
let buffer = ''
|
|
91
|
+
socket.setEncoding('utf8')
|
|
92
|
+
socket.on('data', (chunk: string) => {
|
|
93
|
+
buffer += chunk
|
|
94
|
+
let newline: number
|
|
95
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
96
|
+
const line = buffer.slice(0, newline)
|
|
97
|
+
buffer = buffer.slice(newline + 1)
|
|
98
|
+
if (!line.trim()) continue
|
|
99
|
+
void (async () => {
|
|
100
|
+
let id: unknown = null
|
|
101
|
+
try {
|
|
102
|
+
const envelope = JSON.parse(line) as { id?: unknown; token?: unknown; op?: unknown; payload?: unknown }
|
|
103
|
+
id = envelope.id ?? null
|
|
104
|
+
// Loopback TCP callers authenticate per frame against the token in
|
|
105
|
+
// the owner-readable control directory; the filesystem channels
|
|
106
|
+
// (Unix socket) keep their existing peer trust. Every op still
|
|
107
|
+
// re-verifies owner authority server-side.
|
|
108
|
+
if (token !== null && !tokensMatch(envelope.token, token)) { socket.destroy(); return }
|
|
109
|
+
const { token: _token, ...request } = envelope
|
|
110
|
+
const result = await handle(request as DeliverySocketOp)
|
|
111
|
+
socket.write(encode({ id, ok: true, result: result ?? null }))
|
|
112
|
+
} catch (error) {
|
|
113
|
+
socket.write(encode({ id, ok: false, error: error instanceof Error ? error.message : String(error ?? 'Unknown error') }))
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
const server: Server = createServer((socket: Socket) => attach(socket, null))
|
|
120
|
+
await new Promise<void>((resolve, reject) => {
|
|
121
|
+
server.once('error', reject)
|
|
122
|
+
server.listen(socketPath, () => {
|
|
123
|
+
server.removeListener('error', reject)
|
|
124
|
+
resolve()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
// A live sibling relay already owns this agent. Refuse to split-brain.
|
|
128
|
+
server.on('error', () => {})
|
|
129
|
+
// A deployment that no longer publishes the endpoint must not leave a stale
|
|
130
|
+
// token/port behind for host CLIs to dial before failing.
|
|
131
|
+
if (options.tcpPort === undefined) await rm(path.join(controlDir, endpointFile), { force: true }).catch(() => {})
|
|
132
|
+
let tcp: Server | null = null
|
|
133
|
+
let endpoint: DeliveryEndpoint | null = null
|
|
134
|
+
if (options.tcpPort !== undefined) {
|
|
135
|
+
if (!Number.isSafeInteger(options.tcpPort) || options.tcpPort < 0 || options.tcpPort > 65535) throw new Error('Invalid delivery TCP port')
|
|
136
|
+
const token = randomBytes(32).toString('hex')
|
|
137
|
+
const listener: Server = createServer((socket: Socket) => attach(socket, token))
|
|
138
|
+
try {
|
|
139
|
+
await new Promise<void>((resolve, reject) => {
|
|
140
|
+
listener.once('error', reject)
|
|
141
|
+
listener.listen(options.tcpPort, '0.0.0.0', () => {
|
|
142
|
+
listener.removeListener('error', reject)
|
|
143
|
+
resolve()
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
} catch (error) {
|
|
147
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
148
|
+
throw error
|
|
149
|
+
}
|
|
150
|
+
listener.on('error', () => {})
|
|
151
|
+
const address = listener.address()
|
|
152
|
+
if (!address || typeof address === 'string') {
|
|
153
|
+
await new Promise<void>((resolve) => listener.close(() => resolve()))
|
|
154
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
155
|
+
throw new Error('Delivery TCP listener address unavailable')
|
|
156
|
+
}
|
|
157
|
+
tcp = listener
|
|
158
|
+
endpoint = { host: '127.0.0.1', port: address.port, token }
|
|
159
|
+
const file = path.join(controlDir, endpointFile)
|
|
160
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
161
|
+
await writeFile(temporary, JSON.stringify(endpoint), { mode: 0o600 })
|
|
162
|
+
await rename(temporary, file)
|
|
163
|
+
}
|
|
164
|
+
const tcpServer = tcp
|
|
165
|
+
const tcpEndpoint = endpoint
|
|
166
|
+
return {
|
|
167
|
+
socketPath,
|
|
168
|
+
endpoint,
|
|
169
|
+
stop: async () => {
|
|
170
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
171
|
+
await rm(socketPath, { force: true }).catch(() => {})
|
|
172
|
+
if (tcpServer) await new Promise<void>((resolve) => tcpServer.close(() => resolve()))
|
|
173
|
+
if (tcpEndpoint) await rm(path.join(controlDir, endpointFile), { force: true }).catch(() => {})
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Children derive the rendezvous from the agent control directory they were
|
|
179
|
+
// given; EZ_DELIVERY_SOCKET overrides it (tests, custom layouts).
|
|
180
|
+
export const socketPathFor = (controlDir: string, env: NodeJS.ProcessEnv = process.env): string =>
|
|
181
|
+
env.EZ_DELIVERY_SOCKET?.trim() || deliverySocketPath(controlDir)
|
|
182
|
+
|
|
183
|
+
export type LedgerHooks = {
|
|
184
|
+
// Wake the relay outbox pump after an enqueue. Test fixtures pass a noop
|
|
185
|
+
// when they drive delivery synchronously.
|
|
186
|
+
wake: () => void
|
|
187
|
+
// Live relay surface for the status op (polling, transport, version).
|
|
188
|
+
status: () => Promise<{ polling: boolean; applicationOnly: boolean; telegramConfigured: boolean; version: string }> | { polling: boolean; applicationOnly: boolean; telegramConfigured: boolean; version: string }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Memory-ledger request handler shared by the relay and by test fixtures.
|
|
192
|
+
// Every op re-verifies ownership server-side; request metadata is never
|
|
193
|
+
// trusted. Unknown IDs, invalid payloads and revoked bindings fail closed.
|
|
194
|
+
export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): DeliveryHandler => {
|
|
195
|
+
const runs = new RunStore(controlDir)
|
|
196
|
+
const approvals = new ApprovalStore(controlDir)
|
|
197
|
+
const nonEmptyString = (value: unknown): value is string =>
|
|
198
|
+
typeof value === 'string' && value.trim().length > 0
|
|
199
|
+
return async (op: DeliverySocketOp): Promise<unknown> => {
|
|
200
|
+
switch (op.op) {
|
|
201
|
+
case 'ping': return { ok: true }
|
|
202
|
+
case 'status': {
|
|
203
|
+
const all = await runs.list()
|
|
204
|
+
return {
|
|
205
|
+
...(await hooks.status()),
|
|
206
|
+
at: Date.now(),
|
|
207
|
+
running: all.filter((run) => run.status === 'running').length,
|
|
208
|
+
queued: all.filter((run) => run.status === 'queued').length,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
case 'enqueue': {
|
|
212
|
+
const envelope = (op.payload ?? {}) as Record<string, unknown>
|
|
213
|
+
if (envelope.kind === 'owner') {
|
|
214
|
+
if (!envelope.deliveryContext || typeof envelope.deliveryContext !== 'object') throw new Error('Delivery context is required')
|
|
215
|
+
const type = envelope.type
|
|
216
|
+
if (type !== 'message' && type !== 'document' && type !== 'voice') throw new Error('Unsupported owner delivery type')
|
|
217
|
+
const text = envelope.text
|
|
218
|
+
if (type === 'message' && !nonEmptyString(text)) throw new Error('Message content is required')
|
|
219
|
+
const item = await runs.enqueueOwnerDelivery(
|
|
220
|
+
envelope.deliveryContext as never,
|
|
221
|
+
{
|
|
222
|
+
type,
|
|
223
|
+
...(nonEmptyString(text) ? { text: (text as string).trim() } : {}),
|
|
224
|
+
...(typeof envelope.documentPath === 'string' ? { documentPath: envelope.documentPath } : {}),
|
|
225
|
+
...(typeof envelope.voiceText === 'string' ? { voiceText: envelope.voiceText } : {}),
|
|
226
|
+
...(Number.isSafeInteger(envelope.replyToMessageId) ? { replyToMessageId: envelope.replyToMessageId as number } : {}),
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
hooks.wake()
|
|
230
|
+
return { outbox_id: item.id, id: item.id, type: item.type }
|
|
231
|
+
}
|
|
232
|
+
if (typeof envelope.runId !== 'string' || !envelope.runId) throw new Error('Run ID is required')
|
|
233
|
+
const replyTo = Number.isSafeInteger(envelope.replyToMessageId) ? { replyToMessageId: envelope.replyToMessageId as number } : {}
|
|
234
|
+
const id = typeof envelope.id === 'string' && envelope.id ? { id: envelope.id } : {}
|
|
235
|
+
let item
|
|
236
|
+
switch (envelope.kind) {
|
|
237
|
+
case 'message': {
|
|
238
|
+
if (!nonEmptyString(envelope.text)) throw new Error('Message content is required')
|
|
239
|
+
item = await runs.enqueueMessage(envelope.runId, (envelope.text as string).trim(), { ...replyTo, ...id })
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
case 'document': {
|
|
243
|
+
if (typeof envelope.documentPath !== 'string' || !envelope.documentPath) throw new Error('Document path is required')
|
|
244
|
+
const caption = nonEmptyString(envelope.text) ? (envelope.text as string).trim() : undefined
|
|
245
|
+
item = await runs.enqueueDocument(envelope.runId, envelope.documentPath, { ...(caption ? { caption } : {}), ...replyTo })
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
case 'voice': {
|
|
249
|
+
if (!nonEmptyString(envelope.voiceText)) throw new Error('Voice text is required')
|
|
250
|
+
item = await runs.enqueueVoice(envelope.runId, (envelope.voiceText as string).trim(), replyTo)
|
|
251
|
+
break
|
|
252
|
+
}
|
|
253
|
+
case 'reaction': {
|
|
254
|
+
if (!nonEmptyString(envelope.emoji)) throw new Error('Reaction emoji is required')
|
|
255
|
+
item = await runs.enqueueReaction(envelope.runId, envelope.emoji as string)
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
case 'approval': {
|
|
259
|
+
if (!nonEmptyString(envelope.approvalPrompt) || !nonEmptyString(envelope.approvalActionId))
|
|
260
|
+
throw new Error('Approval prompt and action ID are required')
|
|
261
|
+
await approvals.requestApproval(envelope.approvalActionId as string, (envelope.approvalPrompt as string).trim(), envelope.runId)
|
|
262
|
+
item = await runs.enqueueApproval(envelope.runId, (envelope.approvalPrompt as string).trim(), envelope.approvalActionId as string, replyTo)
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
default: throw new Error('Unsupported delivery kind')
|
|
266
|
+
}
|
|
267
|
+
hooks.wake()
|
|
268
|
+
return { outbox_id: item.id, id: item.id, type: item.type }
|
|
269
|
+
}
|
|
270
|
+
case 'wait': {
|
|
271
|
+
const { id, timeoutMs } = (op.payload ?? {}) as { id?: unknown; timeoutMs?: unknown }
|
|
272
|
+
if (typeof id !== 'string' || !id) throw new Error('Outbox ID is required')
|
|
273
|
+
const receipt = await runs.waitForDelivery(id, typeof timeoutMs === 'number' && timeoutMs > 0 ? Math.min(timeoutMs, 130_000) : 120_000)
|
|
274
|
+
return { status: 'delivered', receipt }
|
|
275
|
+
}
|
|
276
|
+
case 'get': {
|
|
277
|
+
const runId = (op.payload as { runId?: unknown } | undefined)?.runId
|
|
278
|
+
if (typeof runId !== 'string' || !runId) throw new Error('Run ID is required')
|
|
279
|
+
const run = await runs.get(runId)
|
|
280
|
+
if (!run) throw new Error(`Unknown run ${runId}`)
|
|
281
|
+
return run
|
|
282
|
+
}
|
|
283
|
+
case 'list': return runs.list()
|
|
284
|
+
case 'patch': {
|
|
285
|
+
const payload = (op.payload ?? {}) as { runId?: unknown; change?: unknown }
|
|
286
|
+
if (typeof payload.runId !== 'string' || !payload.runId) throw new Error('Run ID is required')
|
|
287
|
+
if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
|
|
288
|
+
return runs.patch(payload.runId, payload.change as never)
|
|
289
|
+
}
|
|
290
|
+
case 'authorize': {
|
|
291
|
+
const runId = (op.payload as { runId?: unknown } | undefined)?.runId
|
|
292
|
+
if (typeof runId !== 'string' || !runId) throw new Error('Run ID is required')
|
|
293
|
+
return requireOwnerExecution(controlDir, runId)
|
|
294
|
+
}
|
|
295
|
+
case 'receipt': {
|
|
296
|
+
const payload = (op.payload ?? {}) as { context?: unknown; id?: unknown }
|
|
297
|
+
if (!payload.context || typeof payload.context !== 'object') throw new Error('Delivery context is required')
|
|
298
|
+
if (typeof payload.id !== 'string' || !payload.id) throw new Error('Outbox ID is required')
|
|
299
|
+
return runs.ownerDeliveryReceipt(payload.context as never, payload.id)
|
|
300
|
+
}
|
|
301
|
+
case 'history': {
|
|
302
|
+
const payload = (op.payload ?? {}) as { runId?: unknown; limit?: unknown; messageId?: unknown }
|
|
303
|
+
if (typeof payload.runId !== 'string' || !payload.runId) throw new Error('Run ID is required')
|
|
304
|
+
return deliveredMessages(controlDir, payload.runId, {
|
|
305
|
+
...(payload.limit === undefined ? {} : { limit: payload.limit as number }),
|
|
306
|
+
...(payload.messageId === undefined ? {} : { messageId: payload.messageId as number }),
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
case 'deliveryStatus': return runs.deliveryStatus()
|
|
310
|
+
case 'approvalCheck': {
|
|
311
|
+
const actionId = (op.payload as { actionId?: unknown } | undefined)?.actionId
|
|
312
|
+
if (typeof actionId !== 'string' || !actionId) throw new Error('Approval action ID is required')
|
|
313
|
+
const record = await approvals.getDecision(actionId)
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
actionId,
|
|
317
|
+
decision: record?.decision || 'pending',
|
|
318
|
+
decidedAt: record?.decidedAt,
|
|
319
|
+
decidedBy: record?.decidedBy,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
case 'latestReply': {
|
|
323
|
+
const owner = telegramOwner(await readOnlyOwner(controlDir))
|
|
324
|
+
if (!owner) return { reply: null }
|
|
325
|
+
let reply: { runId: string; messageIds: number[]; deliveredAt: string } | null = null
|
|
326
|
+
for (const item of sentOutbox(controlDir)) {
|
|
327
|
+
if (!/^tg_\d+$/.test(item.runId || '') || item.chatId !== owner.telegramChatId ||
|
|
328
|
+
(item.type && item.type !== 'message') || !Array.isArray((item.receipt as { messageIds?: unknown } | undefined)?.messageIds)) continue
|
|
329
|
+
const receipt = item.receipt as { messageIds: number[]; deliveredAt: string }
|
|
330
|
+
if (!receipt.messageIds.length || !receipt.messageIds.every((n) => Number.isSafeInteger(n) && n > 0)) continue
|
|
331
|
+
const delivered = Date.parse(receipt.deliveredAt)
|
|
332
|
+
if (!Number.isFinite(delivered) || delivered < Date.parse(owner.pairedAt) || delivered > Date.now()) continue
|
|
333
|
+
const run = await runs.get(item.runId!)
|
|
334
|
+
if (run?.status !== 'completed' || run.external || run.chatId !== owner.telegramChatId ||
|
|
335
|
+
!Number.isSafeInteger(run.telegramUserId) || run.telegramUserId! <= 0 ||
|
|
336
|
+
(owner.kind !== 'group' && run.telegramUserId !== owner.telegramUserId)) continue
|
|
337
|
+
if (!reply || delivered > Date.parse(reply.deliveredAt))
|
|
338
|
+
reply = { runId: item.runId!, messageIds: receipt.messageIds, deliveredAt: receipt.deliveredAt }
|
|
339
|
+
}
|
|
340
|
+
return { reply }
|
|
341
|
+
}
|
|
342
|
+
default: throw new Error(`Unsupported delivery op ${(op as { op?: unknown }).op}`)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export const deliverySocketAlive = (socketPath: string, timeoutMs = 2000): Promise<boolean> =>
|
|
348
|
+
socketAlive(socketPath, timeoutMs)
|
|
349
|
+
|
|
350
|
+
// Typed facade over the shared plain-JS client so relay/TS callers keep the
|
|
351
|
+
// closed DeliverySocketOp union.
|
|
352
|
+
export const callDeliverySocket = (socketPath: string, op: DeliverySocketOp, timeoutMs = 130_000): Promise<unknown> =>
|
|
353
|
+
callSocket(socketPath, op, timeoutMs)
|
|
354
|
+
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ApplicationBindings } from './application-channel.js'
|
|
2
|
-
import { ControlStore } from './control-state.js'
|
|
3
2
|
import { RunStore, type RunRecord } from './runs.js'
|
|
4
|
-
import type
|
|
3
|
+
import { validOwner, type Owner } from './control-state.js'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
5
6
|
import { ownsRun } from './identity.js'
|
|
6
7
|
|
|
7
8
|
export const EXTERNAL_EXECUTION_BLOCK = 'external-execution-unavailable' as const
|
|
@@ -14,12 +15,27 @@ export function executionBlockReason(run: RunRecord, owner: Owner | null): strin
|
|
|
14
15
|
if (run.taskId || run.external || run.id.startsWith('event_')) return EXTERNAL_EXECUTION_BLOCK
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
18
|
+
// Stateless pipe: intake authorizes the sender before spawn. The executor
|
|
19
|
+
// re-verifies owner binding with a read-only control-state.json read: zero
|
|
20
|
+
// control/ writes, fail-closed on missing/corrupt/revoked owner. Request
|
|
21
|
+
// metadata and EZ_RUN_ID are not proof of owner identity. Local host
|
|
22
|
+
// administrators remain trusted.
|
|
23
|
+
export const readOnlyOwner = async (controlDir: string): Promise<Owner | null> => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed: unknown = JSON.parse(await readFile(path.join(controlDir, 'control-state.json'), 'utf8'))
|
|
26
|
+
const owner = (parsed as { owner?: unknown }).owner ?? null
|
|
27
|
+
return validOwner(owner) ? (owner as Owner) : null
|
|
28
|
+
} catch (error: unknown) {
|
|
29
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
export async function requireOwnerExecution(controlDir: string, runId: string): Promise<RunRecord> {
|
|
20
35
|
const run = await new RunStore(controlDir).get(runId)
|
|
21
36
|
if (!run || run.status !== 'running') throw new Error('No active core run')
|
|
22
|
-
|
|
37
|
+
if (run.taskId || run.external || run.id.startsWith('event_')) throw new Error(`Execution blocked: ${EXTERNAL_EXECUTION_BLOCK}`)
|
|
38
|
+
const owner = await readOnlyOwner(controlDir)
|
|
23
39
|
const reason = executionBlockReason(run, owner)
|
|
24
40
|
if (reason) throw new Error(`Execution blocked: ${reason}`)
|
|
25
41
|
if (run.application || run.delivery) await new ApplicationBindings(controlDir).authorize(run)
|
package/src/executor.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { executionDefaults } from './model-policy.js'
|
|
2
|
-
import { Tasks } from './tasks.js'
|
|
3
|
-
import { RunStore } from './runs.js'
|
|
4
2
|
import { startTaskExecutor } from './task-executor.js'
|
|
5
|
-
import {
|
|
3
|
+
import { deliverySocketPath } from './delivery-socket.js'
|
|
6
4
|
import { mkdtemp, rm, writeFile, mkdir, symlink, readFile } from 'node:fs/promises'
|
|
5
|
+
import { accessSync, constants as fsConstants } from 'node:fs'
|
|
7
6
|
import { tmpdir, homedir } from 'node:os'
|
|
8
7
|
import path from 'node:path'
|
|
9
8
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
@@ -11,6 +10,7 @@ import { processSnapshot, matchingProcessIds } from './process-tree.js'
|
|
|
11
10
|
import { createInterface } from 'node:readline'
|
|
12
11
|
import { fileURLToPath } from 'node:url'
|
|
13
12
|
import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
|
|
13
|
+
import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
|
|
14
14
|
|
|
15
15
|
export type ExecutorOptions = {
|
|
16
16
|
repairEnabled?: boolean
|
|
@@ -19,20 +19,35 @@ export type ExecutorOptions = {
|
|
|
19
19
|
runId: string
|
|
20
20
|
controlDir: string
|
|
21
21
|
binDir: string
|
|
22
|
+
// Caller-owned trigger line appended to literal input. The core never reads
|
|
23
|
+
// the run ledger; admission and authorization happen before launch.
|
|
24
|
+
promptSuffix?: string
|
|
25
|
+
// Caller-owned routing flags. The core only spawns and streams.
|
|
26
|
+
taskRun?: boolean
|
|
27
|
+
nativeSession?: boolean
|
|
22
28
|
toolsHome?: string
|
|
23
29
|
sharedWorkspace?: string
|
|
24
30
|
additionalWorkspaces?: string[]
|
|
25
31
|
cli?: string
|
|
26
32
|
sessionId?: string
|
|
27
33
|
isResume?: boolean
|
|
28
|
-
eventSource?: string
|
|
29
34
|
model?: string
|
|
30
35
|
effort?: string
|
|
36
|
+
provider?: string
|
|
31
37
|
codexSandbox?: 'external'
|
|
32
38
|
codexAutoCompactTokens?: number
|
|
39
|
+
codexProvider?: CodexProviderBinding
|
|
33
40
|
onSession?: (id: string) => Promise<void>
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
export type CodexProviderBinding = {
|
|
44
|
+
id: string
|
|
45
|
+
name: string
|
|
46
|
+
baseUrl: string
|
|
47
|
+
envKey: string
|
|
48
|
+
models: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
const allowedEnvironmentKeys = [
|
|
37
52
|
'HOME',
|
|
38
53
|
'LANG',
|
|
@@ -43,6 +58,8 @@ const allowedEnvironmentKeys = [
|
|
|
43
58
|
'TERM',
|
|
44
59
|
'TMPDIR',
|
|
45
60
|
'USER',
|
|
61
|
+
'EZ_PLUGIN_BROKER_SOCKET',
|
|
62
|
+
'EZ_DELIVERY_SOCKET',
|
|
46
63
|
] as const
|
|
47
64
|
|
|
48
65
|
export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => {
|
|
@@ -64,6 +81,7 @@ export const executorJobEnv = (
|
|
|
64
81
|
PATH: pathValue,
|
|
65
82
|
EZ_RUN_ID: options.runId,
|
|
66
83
|
EZ_CONTROL_DIR: options.controlDir,
|
|
84
|
+
EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
|
|
67
85
|
EZ_REPAIR_ENABLED: String(options.repairEnabled !== false),
|
|
68
86
|
...(options.toolsHome ? {BUILDX_CONFIG:path.join(options.toolsHome,'buildx')} : {}),
|
|
69
87
|
}
|
|
@@ -71,12 +89,27 @@ export const executorJobEnv = (
|
|
|
71
89
|
|
|
72
90
|
export const grokJobEnv = executorJobEnv
|
|
73
91
|
|
|
92
|
+
// Package binDir is Ez tools (ezenciel-agents-message). Native CLIs come from the
|
|
93
|
+
// host PATH so one agent's wrapper cannot retarget another agent's CODEX_HOME.
|
|
94
|
+
export const resolveHostCommand = (command: string, pathValue = process.env.PATH): string => {
|
|
95
|
+
if (!command || command.includes(path.sep) || command.includes('/')) return command
|
|
96
|
+
for (const directory of (pathValue ?? '').split(path.delimiter)) {
|
|
97
|
+
if (!directory) continue
|
|
98
|
+
const candidate = path.join(directory, command)
|
|
99
|
+
try {
|
|
100
|
+
accessSync(candidate, fsConstants.X_OK)
|
|
101
|
+
return candidate
|
|
102
|
+
} catch { /* try the next PATH entry */ }
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`Native CLI ${command} is not executable on the host PATH`)
|
|
105
|
+
}
|
|
106
|
+
|
|
74
107
|
export type CliAdapter = {
|
|
75
108
|
name: string
|
|
76
109
|
command: string
|
|
77
110
|
description: string
|
|
78
111
|
buildArgs: (
|
|
79
|
-
options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
|
|
112
|
+
options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox' | 'codexProvider'> & { controlDir?: string },
|
|
80
113
|
promptFile: string,
|
|
81
114
|
promptText: string,
|
|
82
115
|
) => string[]
|
|
@@ -88,6 +121,13 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
|
|
|
88
121
|
buildArgs: (opts, _file, prompt) => {
|
|
89
122
|
if (opts.codexSandbox !== undefined && opts.codexSandbox !== 'external') throw new Error('Invalid Codex sandbox selection')
|
|
90
123
|
const args = ['exec', '--skip-git-repo-check', '--json', '--sandbox', opts.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write', '--disable', 'memories', '--enable', 'skip_host_skill_discovery', '-c', 'approval_policy="never"']
|
|
124
|
+
if (opts.codexProvider) {
|
|
125
|
+
const provider = validateCodexProvider(opts.codexProvider)
|
|
126
|
+
args.push(
|
|
127
|
+
'-c', `model_provider=${JSON.stringify(provider.id)}`,
|
|
128
|
+
'-c', `model_providers.${provider.id}={name=${JSON.stringify(provider.name)},base_url=${JSON.stringify(provider.baseUrl)},env_key=${JSON.stringify(provider.envKey)},wire_api="responses",supports_websockets=false}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
91
131
|
const limit = opts.codexAutoCompactTokens
|
|
92
132
|
if (limit !== undefined) {
|
|
93
133
|
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error('Invalid Codex compaction token limit')
|
|
@@ -194,6 +234,32 @@ export const executorKey = (name?: string): string => {
|
|
|
194
234
|
return resolvedKey
|
|
195
235
|
}
|
|
196
236
|
|
|
237
|
+
const providerToken = (value: unknown, label: string, pattern: RegExp, limit = 160): string => {
|
|
238
|
+
if (typeof value !== 'string' || value.length > limit || !pattern.test(value)) throw new Error(`Invalid Codex provider ${label}`)
|
|
239
|
+
return value
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const validateCodexProvider = (value: CodexProviderBinding): CodexProviderBinding => {
|
|
243
|
+
if (!value || typeof value !== 'object') throw new Error('Invalid Codex provider binding')
|
|
244
|
+
const id = providerToken(value.id, 'id', /^[a-z][a-z0-9_-]{0,31}$/)
|
|
245
|
+
const name = providerToken(value.name, 'name', /^[^\r\n\0]{1,80}$/, 80)
|
|
246
|
+
const envKey = validateCodexProviderEnvironmentKey(value.envKey)
|
|
247
|
+
let url: URL
|
|
248
|
+
try { url = new URL(value.baseUrl) } catch { throw new Error('Invalid Codex provider URL') }
|
|
249
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error('Invalid Codex provider URL')
|
|
250
|
+
const models = value.models?.map(model => providerToken(model, 'model', /^[a-zA-Z0-9_./:-]{1,160}$/))
|
|
251
|
+
if (!models?.length || models.length > 64 || new Set(models).size !== models.length) throw new Error('Invalid Codex provider models')
|
|
252
|
+
return {id,name,baseUrl:url.toString().replace(/\/$/,''),envKey,models}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const validateCodexProviderEnvironmentKey = (value: unknown): string => {
|
|
256
|
+
const envKey = providerToken(value, 'environment key', /^[A-Z][A-Z0-9_]{1,63}$/, 64)
|
|
257
|
+
if (allowedEnvironmentKeys.includes(envKey as typeof allowedEnvironmentKeys[number]) || envKey === 'CODEX_HOME' || envKey === 'NODE_OPTIONS' ||
|
|
258
|
+
envKey.startsWith('EZ_') || envKey.startsWith('TELEGRAM_'))
|
|
259
|
+
throw new Error('Reserved Codex provider environment key')
|
|
260
|
+
return envKey
|
|
261
|
+
}
|
|
262
|
+
|
|
197
263
|
export const resolveExecutor = (name?: string): CliAdapter => EXECUTOR_REGISTRY[executorKey(name)]
|
|
198
264
|
|
|
199
265
|
export const grokInvocation = (
|
|
@@ -234,42 +300,45 @@ export const startExecutorJob = async (
|
|
|
234
300
|
options: ExecutorOptions,
|
|
235
301
|
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
236
302
|
options = executionDefaults(executorKey(options.cli), options)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (options.
|
|
240
|
-
if (run
|
|
241
|
-
|
|
242
|
-
await new Tasks(options.controlDir).authorize(run, process.env.EZ_EXECUTOR_TRANSPORT === 'host')
|
|
243
|
-
if (process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
|
|
244
|
-
} else await requireOwnerExecution(options.controlDir, options.runId)
|
|
245
|
-
if (!run?.taskId && options.eventSource !== undefined) throw new Error('Execution blocked: external-execution-unavailable')
|
|
303
|
+
// Routing is caller-owned: a restricted task run goes to the task runner
|
|
304
|
+
// unless the host transport must ship it across the boundary first.
|
|
305
|
+
if (options.taskRun && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
|
|
306
|
+
if (options.nativeSession && !/^[a-zA-Z0-9_-]+$/.test(options.runId)) throw new Error('Invalid native task run ID')
|
|
307
|
+
if (options.codexSandbox !== undefined && (options.codexSandbox !== 'external' || process.env.EZ_EXECUTOR_TRANSPORT !== 'local' || options.taskRun || executorKey(options.cli) !== 'codex')) throw new Error('External Codex sandbox requires an owner-authorized native local run')
|
|
246
308
|
const outputDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
|
|
247
309
|
const key = executorKey(options.cli)
|
|
248
310
|
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
249
311
|
const gui = !host && key === 'codex-gui'
|
|
250
|
-
const nativeSession = !host && key === 'codex' && options.
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
: ''
|
|
255
|
-
const chatReminder = !host && !run?.taskId && run?.messageId !== undefined
|
|
256
|
-
? '\n\n[Chat context] You are replying in chat. Send replies with ezenciel-agents-message --text "..."; your final answer alone is not delivered. Before lengthy tool or repository work, briefly acknowledge through that CLI. Keep chat responsive: use ezenciel-agents-schedule for long-running work and native subagents for useful independent parts. Decide when to delegate and what to send.'
|
|
257
|
-
: ''
|
|
258
|
-
const promptText = texts.join('\n\n') + applicationReminder + chatReminder
|
|
312
|
+
const nativeSession = !host && key === 'codex' && options.nativeSession === true
|
|
313
|
+
// Literal input plus the caller-provided trigger line. No coaching,
|
|
314
|
+
// tool/delegate/schedule advice, or workflow guidance.
|
|
315
|
+
const promptText = texts.join('\n\n') + (options.promptSuffix ?? '')
|
|
259
316
|
const promptFile = path.join(outputDirectory, 'prompt.txt')
|
|
260
317
|
await writeFile(promptFile, promptText, { encoding: 'utf8', mode: 0o600 })
|
|
261
318
|
|
|
262
319
|
const adapter = resolveExecutor(options.cli)
|
|
263
320
|
const command = adapter.command
|
|
264
|
-
const
|
|
265
|
-
|
|
321
|
+
const denies = process.platform === 'darwin' && !host && !gui && options.codexSandbox !== 'external'
|
|
322
|
+
? workspaceSiblingDenies(options.workspace) : []
|
|
323
|
+
// Codex must not apply a nested Seatbelt; this process is already confined.
|
|
324
|
+
const launchOptions = denies.length && command === 'codex' ? { ...options, codexSandbox: 'external' as const } : options
|
|
325
|
+
const args = host || nativeSession || key === 'codex-gui' ? [] : adapter.buildArgs(launchOptions, promptFile, promptText)
|
|
326
|
+
let invocation = host
|
|
266
327
|
? executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), fileURLToPath(new URL('./host-executor-client.ts', import.meta.url)), options.controlDir, options.runId])
|
|
267
328
|
: nativeSession
|
|
268
329
|
? executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), fileURLToPath(new URL('./codex-session.ts', import.meta.url))])
|
|
269
330
|
: gui
|
|
270
331
|
? executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), fileURLToPath(new URL('./desktop-bridge.ts', import.meta.url))])
|
|
271
|
-
: executorInvocation(command, args)
|
|
332
|
+
: executorInvocation(resolveHostCommand(command, executorEnvironment().PATH), args)
|
|
272
333
|
const environment = executorJobEnv(options)
|
|
334
|
+
if (!host && !gui && key === 'codex' && options.provider && !options.codexProvider)
|
|
335
|
+
throw new Error('Selected Codex provider is not configured for this agent')
|
|
336
|
+
if (!host && !gui && key === 'codex' && options.codexProvider) {
|
|
337
|
+
const provider = validateCodexProvider(options.codexProvider)
|
|
338
|
+
const secret = process.env[provider.envKey]
|
|
339
|
+
if (!secret) throw new Error(`Missing Codex provider credential ${provider.envKey}`)
|
|
340
|
+
environment[provider.envKey] = secret
|
|
341
|
+
}
|
|
273
342
|
if (!host && !gui && command === 'codex') {
|
|
274
343
|
// Share the existing authentication, never the user's memory/config/sessions.
|
|
275
344
|
const base = path.join(options.controlDir, 'cli', 'codex')
|
|
@@ -282,16 +351,26 @@ export const startExecutorJob = async (
|
|
|
282
351
|
try{await writeFile(path.join(home,'config.toml'),await readFile(path.join(base,'config.toml')),{flag:'wx',mode:0o600})}
|
|
283
352
|
catch(error){if(!['ENOENT','EEXIST'].includes((error as NodeJS.ErrnoException).code || ''))throw error}
|
|
284
353
|
}
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
const authLinks =
|
|
288
|
-
|
|
354
|
+
// Isolated agents keep auth in this CODEX_HOME. Host-capable may link the
|
|
355
|
+
// installer login. Never replace an existing binding.
|
|
356
|
+
const authLinks = process.env.EZ_ISOLATION === 'isolated'
|
|
357
|
+
? nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : []
|
|
358
|
+
: [[path.join(base, 'auth.json'), path.join(homedir(), '.codex', 'auth.json')],
|
|
359
|
+
...(nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : [])]
|
|
289
360
|
for (const [link, target] of authLinks) {
|
|
290
361
|
try { await symlink(target, link) }
|
|
291
362
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error }
|
|
292
363
|
}
|
|
293
364
|
environment.CODEX_HOME = home
|
|
294
365
|
}
|
|
366
|
+
if (denies.length) {
|
|
367
|
+
const profile = path.join(outputDirectory, 'workspace.sb')
|
|
368
|
+
await writeFile(profile, macosWorkspaceProfile(denies, [
|
|
369
|
+
options.controlDir, options.binDir, options.toolsHome, options.sharedWorkspace,
|
|
370
|
+
...(options.additionalWorkspaces ?? []), outputDirectory,
|
|
371
|
+
].filter((value): value is string => Boolean(value))), { mode: 0o600 })
|
|
372
|
+
invocation = { command: 'sandbox-exec', args: ['-f', profile, invocation.command, ...invocation.args] }
|
|
373
|
+
}
|
|
295
374
|
const child = spawn(invocation.command, invocation.args, {
|
|
296
375
|
cwd: options.workspace,
|
|
297
376
|
env: environment,
|
|
@@ -307,7 +386,7 @@ export const startExecutorJob = async (
|
|
|
307
386
|
})
|
|
308
387
|
child.stdin?.end(host
|
|
309
388
|
? JSON.stringify({texts,options:{...options,onSession:undefined,codexSandbox:undefined}})
|
|
310
|
-
: nativeSession ? JSON.stringify({...
|
|
389
|
+
: nativeSession ? JSON.stringify({...launchOptions,onSession:undefined,prompt:promptText})
|
|
311
390
|
: gui ? JSON.stringify({prompt:promptText,options:{...options,onSession:undefined}})
|
|
312
391
|
: ['codex', 'claude'].includes(key) ? promptText : undefined)
|
|
313
392
|
const timeout = options.timeoutMs > 0 ? setTimeout(() => terminateJob(child), options.timeoutMs) : undefined
|