@jc_stack/ez-agents 0.1.0-beta.34 → 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 +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +67 -0
- package/Dockerfile +7 -9
- package/README.md +10 -13
- 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 +40 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +79 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/application-channel.ts +78 -7
- 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/config.ts +12 -27
- package/src/control-state.ts +175 -8
- 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 +20 -23
- package/src/files.ts +38 -11
- package/src/host-executor.ts +28 -14
- package/src/inbox.ts +15 -49
- package/src/index.ts +95 -52
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +7 -2
- 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 +132 -23
- 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/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- 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 +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +36 -0
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -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 +2 -2
- package/test/config.test.ts +38 -43
- 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 +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +10 -11
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +74 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -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 +119 -2
- 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/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 +93 -13
- package/test/workspace.test.ts +4 -1
- 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 -43
- 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 -5
- package/templates/agent-guidance.md +0 -17
- 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,8 +1,6 @@
|
|
|
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'
|
|
7
5
|
import { accessSync, constants as fsConstants } from 'node:fs'
|
|
8
6
|
import { tmpdir, homedir } from 'node:os'
|
|
@@ -21,13 +19,18 @@ export type ExecutorOptions = {
|
|
|
21
19
|
runId: string
|
|
22
20
|
controlDir: string
|
|
23
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
|
|
24
28
|
toolsHome?: string
|
|
25
29
|
sharedWorkspace?: string
|
|
26
30
|
additionalWorkspaces?: string[]
|
|
27
31
|
cli?: string
|
|
28
32
|
sessionId?: string
|
|
29
33
|
isResume?: boolean
|
|
30
|
-
eventSource?: string
|
|
31
34
|
model?: string
|
|
32
35
|
effort?: string
|
|
33
36
|
provider?: string
|
|
@@ -55,6 +58,8 @@ const allowedEnvironmentKeys = [
|
|
|
55
58
|
'TERM',
|
|
56
59
|
'TMPDIR',
|
|
57
60
|
'USER',
|
|
61
|
+
'EZ_PLUGIN_BROKER_SOCKET',
|
|
62
|
+
'EZ_DELIVERY_SOCKET',
|
|
58
63
|
] as const
|
|
59
64
|
|
|
60
65
|
export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => {
|
|
@@ -76,6 +81,7 @@ export const executorJobEnv = (
|
|
|
76
81
|
PATH: pathValue,
|
|
77
82
|
EZ_RUN_ID: options.runId,
|
|
78
83
|
EZ_CONTROL_DIR: options.controlDir,
|
|
84
|
+
EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
|
|
79
85
|
EZ_REPAIR_ENABLED: String(options.repairEnabled !== false),
|
|
80
86
|
...(options.toolsHome ? {BUILDX_CONFIG:path.join(options.toolsHome,'buildx')} : {}),
|
|
81
87
|
}
|
|
@@ -249,7 +255,7 @@ export const validateCodexProvider = (value: CodexProviderBinding): CodexProvide
|
|
|
249
255
|
export const validateCodexProviderEnvironmentKey = (value: unknown): string => {
|
|
250
256
|
const envKey = providerToken(value, 'environment key', /^[A-Z][A-Z0-9_]{1,63}$/, 64)
|
|
251
257
|
if (allowedEnvironmentKeys.includes(envKey as typeof allowedEnvironmentKeys[number]) || envKey === 'CODEX_HOME' || envKey === 'NODE_OPTIONS' ||
|
|
252
|
-
envKey.startsWith('EZ_') || envKey.startsWith('TELEGRAM_')
|
|
258
|
+
envKey.startsWith('EZ_') || envKey.startsWith('TELEGRAM_'))
|
|
253
259
|
throw new Error('Reserved Codex provider environment key')
|
|
254
260
|
return envKey
|
|
255
261
|
}
|
|
@@ -294,28 +300,19 @@ export const startExecutorJob = async (
|
|
|
294
300
|
options: ExecutorOptions,
|
|
295
301
|
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
296
302
|
options = executionDefaults(executorKey(options.cli), options)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (options.
|
|
300
|
-
if (run
|
|
301
|
-
|
|
302
|
-
await new Tasks(options.controlDir).authorize(run, process.env.EZ_EXECUTOR_TRANSPORT === 'host')
|
|
303
|
-
if (process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
|
|
304
|
-
} else await requireOwnerExecution(options.controlDir, options.runId)
|
|
305
|
-
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')
|
|
306
308
|
const outputDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
|
|
307
309
|
const key = executorKey(options.cli)
|
|
308
310
|
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
309
311
|
const gui = !host && key === 'codex-gui'
|
|
310
|
-
const nativeSession = !host && key === 'codex' && options.
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
: ''
|
|
315
|
-
const chatReminder = !host && !run?.taskId && run?.messageId !== undefined
|
|
316
|
-
? '\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.'
|
|
317
|
-
: ''
|
|
318
|
-
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 ?? '')
|
|
319
316
|
const promptFile = path.join(outputDirectory, 'prompt.txt')
|
|
320
317
|
await writeFile(promptFile, promptText, { encoding: 'utf8', mode: 0o600 })
|
|
321
318
|
|
package/src/files.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, writeFile, realpath, stat } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, readFile, writeFile, realpath, stat } from 'node:fs/promises'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
@@ -43,28 +43,55 @@ export const sanitizeFileName = (name: string): string => {
|
|
|
43
43
|
return base || 'file'
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Runtime inbound staging lives under the agent control directory, never the
|
|
47
|
+
// workspace mind folder. The engine resolves the absolute path regardless of
|
|
48
|
+
// its working directory; the prompt carries no workspace-relative prefix.
|
|
46
49
|
export const stageIncomingFile = async (
|
|
47
|
-
|
|
50
|
+
stagingRoot: string,
|
|
48
51
|
fileName: string,
|
|
49
52
|
bytes: Buffer,
|
|
50
53
|
): Promise<{ relativePath: string; fullPath: string; fileType: DetectedFileType }> => {
|
|
51
54
|
const fileType = detectFileType(bytes)
|
|
52
55
|
if (fileType === 'unknown' || bytes.length > 20 * 1024 * 1024)
|
|
53
56
|
throw new Error('Unsupported or oversized attachment')
|
|
54
|
-
const
|
|
55
|
-
await mkdir(
|
|
57
|
+
const attachmentsDir = path.join(stagingRoot, 'attachments')
|
|
58
|
+
await mkdir(attachmentsDir, { recursive: true, mode: 0o700 })
|
|
56
59
|
const sanitized = sanitizeFileName(fileName)
|
|
57
60
|
const targetName = `${randomUUID()}_${sanitized}`
|
|
58
|
-
await workspaceFile(
|
|
59
|
-
const fullPath = path.join(
|
|
61
|
+
await workspaceFile(stagingRoot, 'attachments', false)
|
|
62
|
+
const fullPath = path.join(attachmentsDir, targetName)
|
|
60
63
|
await writeFile(fullPath, bytes, { mode: 0o600 })
|
|
61
64
|
return {
|
|
62
|
-
relativePath: path.join('
|
|
65
|
+
relativePath: path.join('attachments', targetName),
|
|
63
66
|
fullPath,
|
|
64
67
|
fileType,
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
// Reads a staged attachment produced by stageIncomingFile. Relative paths
|
|
72
|
+
// resolve under the first containing root (backward compatible with
|
|
73
|
+
// workspace inbox staging); absolute paths must stay inside one of the roots.
|
|
74
|
+
export const readStagedAttachment = async (roots: (string | undefined)[], file: string): Promise<Buffer> => {
|
|
75
|
+
const candidates = [...new Set(roots.filter((root): root is string => Boolean(root)))]
|
|
76
|
+
if (!path.isAbsolute(file)) {
|
|
77
|
+
for (const root of candidates) {
|
|
78
|
+
try { return await readFile(await workspaceFile(root, file)) } catch {}
|
|
79
|
+
}
|
|
80
|
+
throw new Error('File is outside the workspace')
|
|
81
|
+
}
|
|
82
|
+
const target = await realpath(file).catch(() => { throw new Error('File is outside the workspace') })
|
|
83
|
+
for (const root of candidates) {
|
|
84
|
+
const base = await realpath(root).catch(() => null)
|
|
85
|
+
if (!base) continue
|
|
86
|
+
const relative = path.relative(base, target)
|
|
87
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative) && relative !== '') {
|
|
88
|
+
if (!(await stat(target)).isFile()) throw new Error('Expected a regular file')
|
|
89
|
+
return readFile(target)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new Error('File is outside the workspace')
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
export const workspaceFile = async (workspace: string, file: string, regular = true): Promise<string> => {
|
|
69
96
|
const root = await realpath(workspace)
|
|
70
97
|
const target = await realpath(path.resolve(root, file))
|
|
@@ -76,12 +103,12 @@ export const workspaceFile = async (workspace: string, file: string, regular = t
|
|
|
76
103
|
|
|
77
104
|
// Canonical native-engine attachment metadata for every inbound channel.
|
|
78
105
|
export const MAX_INCOMING_ATTACHMENT_BYTES = 10 * 1024 * 1024
|
|
79
|
-
export const stageChatAttachment = async (
|
|
106
|
+
export const stageChatAttachment = async (stagingRoot: string, name: string, bytes: Buffer, comment: string) => {
|
|
80
107
|
const type = detectFileType(bytes)
|
|
81
108
|
if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
|
|
82
109
|
(type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
|
|
83
|
-
const staged = await stageIncomingFile(
|
|
110
|
+
const staged = await stageIncomingFile(stagingRoot, name, bytes)
|
|
84
111
|
const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
|
|
85
|
-
return { text: `[Attached ${kind} staged at ${staged.
|
|
86
|
-
attachment: {path: staged.
|
|
112
|
+
return { text: `[Attached ${kind} staged at ${staged.fullPath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
|
|
113
|
+
attachment: {path: staged.fullPath, type: staged.fileType}, fullPath: staged.fullPath }
|
|
87
114
|
}
|