@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37
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 +96 -0
- package/Dockerfile +20 -11
- package/README.md +10 -13
- package/THIRD_PARTY_NOTICES.md +7 -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 +41 -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/ai-selection.md +10 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +98 -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/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- 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 +81 -24
- package/src/files.ts +38 -11
- package/src/host-executor.ts +33 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +98 -54
- 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 +8 -3
- 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 +131 -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/setup.ts +1 -0
- 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 +220 -1
- 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 +120 -12
- 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 +87 -3
- 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 +136 -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
|
}
|
|
@@ -97,7 +103,6 @@ export const resolveHostCommand = (command: string, pathValue = process.env.PATH
|
|
|
97
103
|
}
|
|
98
104
|
throw new Error(`Native CLI ${command} is not executable on the host PATH`)
|
|
99
105
|
}
|
|
100
|
-
|
|
101
106
|
export type CliAdapter = {
|
|
102
107
|
name: string
|
|
103
108
|
command: string
|
|
@@ -204,6 +209,30 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
|
|
|
204
209
|
return args
|
|
205
210
|
},
|
|
206
211
|
},
|
|
212
|
+
pi: {
|
|
213
|
+
name: 'pi',
|
|
214
|
+
command: 'pi',
|
|
215
|
+
description: 'Pi coding agent',
|
|
216
|
+
buildArgs: (opts, _promptFile, promptText) => {
|
|
217
|
+
if (!opts.controlDir) throw new Error('Pi runs require a bound control directory for sessions')
|
|
218
|
+
if (opts.effort && !['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(opts.effort)) throw new Error('Invalid Pi thinking level')
|
|
219
|
+
const args = ['-p', '--mode', 'json', '--session-dir', path.join(opts.controlDir, 'cli', 'pi', 'sessions')]
|
|
220
|
+
// Owner-provisioned delivery skill (control/cli/pi/skills/ez-delivery).
|
|
221
|
+
// Loaded by path, never by prompt text, so the model cannot miss it.
|
|
222
|
+
try {
|
|
223
|
+
const skill = path.join(opts.controlDir, 'cli', 'pi', 'skills', 'ez-delivery', 'SKILL.md')
|
|
224
|
+
accessSync(skill, fsConstants.R_OK)
|
|
225
|
+
args.push('--skill', path.dirname(skill))
|
|
226
|
+
} catch { /* runs without the skill exactly as before */ }
|
|
227
|
+
// Bind Pi to this conversation, not the most recently used session in
|
|
228
|
+
// the shared directory. --session-id creates or resumes that exact ID.
|
|
229
|
+
if (opts.sessionId) args.push('--session-id', opts.sessionId)
|
|
230
|
+
if (opts.model) args.push('--model', opts.model)
|
|
231
|
+
if (opts.effort) args.push('--thinking', opts.effort)
|
|
232
|
+
args.push('--', promptText)
|
|
233
|
+
return args
|
|
234
|
+
},
|
|
235
|
+
},
|
|
207
236
|
'codex-gui': {
|
|
208
237
|
name: 'codex-gui',
|
|
209
238
|
command: 'codex',
|
|
@@ -249,13 +278,34 @@ export const validateCodexProvider = (value: CodexProviderBinding): CodexProvide
|
|
|
249
278
|
export const validateCodexProviderEnvironmentKey = (value: unknown): string => {
|
|
250
279
|
const envKey = providerToken(value, 'environment key', /^[A-Z][A-Z0-9_]{1,63}$/, 64)
|
|
251
280
|
if (allowedEnvironmentKeys.includes(envKey as typeof allowedEnvironmentKeys[number]) || envKey === 'CODEX_HOME' || envKey === 'NODE_OPTIONS' ||
|
|
252
|
-
envKey.startsWith('EZ_') || envKey.startsWith('TELEGRAM_')
|
|
281
|
+
envKey.startsWith('EZ_') || envKey.startsWith('TELEGRAM_'))
|
|
253
282
|
throw new Error('Reserved Codex provider environment key')
|
|
254
283
|
return envKey
|
|
255
284
|
}
|
|
256
285
|
|
|
257
286
|
export const resolveExecutor = (name?: string): CliAdapter => EXECUTOR_REGISTRY[executorKey(name)]
|
|
258
287
|
|
|
288
|
+
// Agent-bound OpenCode credentials live at cli/opencode/auth.json, mirroring
|
|
289
|
+
// the Codex binding. Returns the XDG data home (cli/) only when that binding
|
|
290
|
+
// exists; otherwise the host login (host transport) or the unauthenticated
|
|
291
|
+
// free tier (isolated relay) applies. Only a path is ever returned, never a
|
|
292
|
+
// secret.
|
|
293
|
+
export const opencodeDataHome = (controlDir: string): string | undefined => {
|
|
294
|
+
try {
|
|
295
|
+
accessSync(path.join(controlDir, 'cli', 'opencode', 'auth.json'), fsConstants.R_OK)
|
|
296
|
+
return path.join(controlDir, 'cli')
|
|
297
|
+
} catch { return undefined }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Pi resolves its provider/model catalog from an agent dir (settings.json,
|
|
301
|
+
// auth.json). Only a path is ever returned, never a secret.
|
|
302
|
+
export const piAgentDir = (controlDir: string): string | undefined => {
|
|
303
|
+
try {
|
|
304
|
+
accessSync(path.join(controlDir, 'cli', 'pi', 'agent', 'auth.json'), fsConstants.R_OK)
|
|
305
|
+
return path.join(controlDir, 'cli', 'pi', 'agent')
|
|
306
|
+
} catch { return undefined }
|
|
307
|
+
}
|
|
308
|
+
|
|
259
309
|
export const grokInvocation = (
|
|
260
310
|
options: Pick<ExecutorOptions, 'workspace' | 'isResume'>,
|
|
261
311
|
promptFile: string,
|
|
@@ -294,28 +344,19 @@ export const startExecutorJob = async (
|
|
|
294
344
|
options: ExecutorOptions,
|
|
295
345
|
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
296
346
|
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')
|
|
347
|
+
// Routing is caller-owned: a restricted task run goes to the task runner
|
|
348
|
+
// unless the host transport must ship it across the boundary first.
|
|
349
|
+
if (options.taskRun && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
|
|
350
|
+
if (options.nativeSession && !/^[a-zA-Z0-9_-]+$/.test(options.runId)) throw new Error('Invalid native task run ID')
|
|
351
|
+
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
352
|
const outputDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
|
|
307
353
|
const key = executorKey(options.cli)
|
|
308
354
|
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
309
355
|
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
|
|
356
|
+
const nativeSession = !host && key === 'codex' && options.nativeSession === true
|
|
357
|
+
// Literal input plus the caller-provided trigger line. No coaching,
|
|
358
|
+
// tool/delegate/schedule advice, or workflow guidance.
|
|
359
|
+
const promptText = texts.join('\n\n') + (options.promptSuffix ?? '')
|
|
319
360
|
const promptFile = path.join(outputDirectory, 'prompt.txt')
|
|
320
361
|
await writeFile(promptFile, promptText, { encoding: 'utf8', mode: 0o600 })
|
|
321
362
|
|
|
@@ -366,6 +407,22 @@ export const startExecutorJob = async (
|
|
|
366
407
|
}
|
|
367
408
|
environment.CODEX_HOME = home
|
|
368
409
|
}
|
|
410
|
+
if (!host && !gui && key === 'opencode') {
|
|
411
|
+
// Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
|
|
412
|
+
// when the owner provisioned it. Host runs keep the installer login;
|
|
413
|
+
// without a binding the CLI falls back to its free tier. Fail closed at
|
|
414
|
+
// runtime with the provider error, never with a guessed credential.
|
|
415
|
+
const dataHome = opencodeDataHome(options.controlDir)
|
|
416
|
+
if (dataHome) environment.XDG_DATA_HOME = dataHome
|
|
417
|
+
}
|
|
418
|
+
if (!host && !gui && key === 'pi') {
|
|
419
|
+
// Point Pi at the agent-bound config dir (cli/pi/agent/auth.json) when
|
|
420
|
+
// the owner provisioned it. Without a binding Pi falls back to the relay
|
|
421
|
+
// home config. Fail closed at runtime with the provider error, never with
|
|
422
|
+
// a guessed credential.
|
|
423
|
+
const agentDir = piAgentDir(options.controlDir)
|
|
424
|
+
if (agentDir) environment.PI_CODING_AGENT_DIR = agentDir
|
|
425
|
+
}
|
|
369
426
|
if (denies.length) {
|
|
370
427
|
const profile = path.join(outputDirectory, 'workspace.sb')
|
|
371
428
|
await writeFile(profile, macosWorkspaceProfile(denies, [
|