@jc_stack/ez-agents 0.1.0-beta.31 → 0.1.0-beta.33
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/AGENTS.md +47 -32
- package/CHANGELOG.md +34 -1
- package/CONTRIBUTING.md +29 -26
- package/README.md +36 -25
- package/docs/application-channel.md +29 -2
- package/docs/architecture/ai-selection.md +10 -1
- package/docs/architecture/authority-boundaries.md +64 -14
- package/docs/architecture/event-sources.md +12 -5
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/host-service.md +3 -0
- package/docs/plugin-contributions.md +6 -1
- package/docs/plugins.md +23 -0
- package/docs/responsive-channels.md +3 -0
- package/docs/selective-monitoring.md +55 -3
- package/docs/setup.md +2 -1
- package/docs/upgrades.md +6 -4
- package/package.json +1 -1
- package/src/application-channel.ts +40 -9
- package/src/application-origin.ts +4 -0
- package/src/approval.ts +19 -3
- package/src/codex-session.ts +2 -2
- package/src/desktop-bridge.ts +70 -5
- package/src/event-sources.ts +13 -6
- package/src/executor.ts +4 -2
- package/src/files.ts +12 -0
- package/src/host-executor-protocol.ts +2 -2
- package/src/host-executor.ts +20 -4
- package/src/index.ts +81 -34
- package/src/plugins/manager.mjs +52 -20
- package/src/runs.ts +19 -2
- package/src/task-attachments.ts +56 -0
- package/src/task-capability.ts +45 -0
- package/src/task-cli.ts +7 -4
- package/src/task-executor.ts +8 -4
- package/src/task-mcp.ts +25 -8
- package/src/tasks.ts +132 -26
- package/src/telegram-source.ts +34 -13
- package/src/updates/artifact.mjs +2 -2
- package/src/updates/control.mjs +11 -1
- package/src/updates/runtime.mjs +3 -1
- package/templates/agent-guidance.md +14 -0
- package/test/application-channel.test.ts +52 -1
- package/test/codex-session.test.ts +2 -1
- package/test/desktop-bridge.test.ts +98 -1
- package/test/event-sources.test.ts +68 -8
- package/test/executor.test.ts +2 -2
- package/test/host-executor.test.ts +32 -3
- package/test/intake-relay.test.ts +79 -2
- package/test/local-qa.test.mjs +7 -3
- package/test/plugin-manager.test.mjs +31 -0
- package/test/runs.test.ts +19 -0
- package/test/task-attachments.test.ts +77 -0
- package/test/task-capability.test.ts +48 -0
- package/test/task-native.test.ts +4 -1
- package/test/tasks.test.ts +53 -1
- package/test/telegram-source.test.ts +28 -1
- package/test/updates.test.mjs +24 -1
package/docs/plugins.md
CHANGED
|
@@ -168,6 +168,29 @@ attention during setup/use; it does not enable an automated reviewer. Declaring
|
|
|
168
168
|
false never grants permissions, disables core checks or certifies a plugin safe.
|
|
169
169
|
The core owns authority; plugins own provider transport/authentication/receipts.
|
|
170
170
|
|
|
171
|
+
A command may additionally set `"channelQuery": true` only when all four
|
|
172
|
+
exposure fields are explicit and describe external input with no external send,
|
|
173
|
+
record change, or interactive review. This is narrow eligibility for inclusion
|
|
174
|
+
in an owner-approved restricted channel grant, not an activated permission or a
|
|
175
|
+
safety label for the rest of the plugin. Mixed read/write aliases are ineligible;
|
|
176
|
+
publish a dedicated query-only alias.
|
|
177
|
+
|
|
178
|
+
For original file bytes, declare `"channelFile": true` instead, with the same
|
|
179
|
+
read-only exposure fields. The approved task capability must specify
|
|
180
|
+
`"output": "file"`. Its command accepts one literal input and writes only the
|
|
181
|
+
file bytes to stdout (maximum 20 MiB across a run). Core returns an attachment ID and filename
|
|
182
|
+
to the agent; binary content stays in private task staging. The input basename
|
|
183
|
+
supplies the download filename.
|
|
184
|
+
|
|
185
|
+
The task's existing `send` tool accepts `attachmentId` plus text and an
|
|
186
|
+
idempotency key. Only a completed file capability from that run can supply it.
|
|
187
|
+
The destination remains the authorized conversation. Providers opt in through
|
|
188
|
+
`events-head.taskAttachments: true`, optionally declaring `attachmentCaptionLimit`,
|
|
189
|
+
and accept the attachment metadata in `task-send`. Telegram delivers a document
|
|
190
|
+
with a caption of at most 1024 characters; channels without this contract fail
|
|
191
|
+
explicitly before dispatch. Staged bytes are removed when the external run is
|
|
192
|
+
released. File capability access must be included in the owner's grant.
|
|
193
|
+
|
|
171
194
|
Owner adapters retain owner access. External correspondence can run only in an
|
|
172
195
|
approved core messaging task through the restricted task runner. Declarations
|
|
173
196
|
and monitoring subscriptions alone never grant task execution. See
|
|
@@ -12,4 +12,7 @@ the message CLI. Native final text is not automatically delivered to Telegram.
|
|
|
12
12
|
|
|
13
13
|
Restricted correspondence receives a typed activation event and scoped tools.
|
|
14
14
|
Authorization, sandboxing and tool handlers enforce contact and lifecycle limits.
|
|
15
|
+
An owner-approved channel capability adds only a fixed installed alias declared
|
|
16
|
+
as a read-only channel query; it does not expose the workspace, shell, owner
|
|
17
|
+
session or other installed tools.
|
|
15
18
|
Maintenance and group notifications carry event data; CLI help owns operations.
|
|
@@ -12,8 +12,9 @@ is a reminder, never an activated source, watch or reply permission.
|
|
|
12
12
|
Keep mode names internal. Linking/syncing an account defaults to quiet capture;
|
|
13
13
|
finish connection verification, then at most one short optional prompt such as
|
|
14
14
|
“WhatsApp is connected. Want me to follow up with anyone?” Do not present a
|
|
15
|
-
technical three-mode menu
|
|
16
|
-
|
|
15
|
+
technical three-mode menu. Suggest an any-conversation reply grant only when the
|
|
16
|
+
owner has actually asked to serve everyone on that source. If the owner already
|
|
17
|
+
gave a job, continue it without this prompt.
|
|
17
18
|
|
|
18
19
|
Infer the complete job from ordinary language:
|
|
19
20
|
|
|
@@ -42,7 +43,8 @@ when it matters to that proposed job; never silently expand or renew permission.
|
|
|
42
43
|
- Selected: wake for named contacts only. Never turn on all-inbox mode to satisfy
|
|
43
44
|
a one-contact request.
|
|
44
45
|
- All: attention for all eligible incoming contacts; requires that broader
|
|
45
|
-
owner request. Attention alone never grants autonomous reply permission
|
|
46
|
+
owner request. Attention alone never grants autonomous reply permission; a
|
|
47
|
+
separate approved any-conversation task does.
|
|
46
48
|
|
|
47
49
|
For an approved core reply task, the provider's task-watch supplies expiring
|
|
48
50
|
selected attention even if its general policy remains manual. That is expected:
|
|
@@ -95,6 +97,56 @@ members of that approved group may converse; this does not grant owner tools.
|
|
|
95
97
|
The group runs with the existing restricted messaging runner, and its notes are
|
|
96
98
|
separate from private PA memory. Group text is supported; media is not yet.
|
|
97
99
|
|
|
100
|
+
## Everyone on an enabled channel
|
|
101
|
+
|
|
102
|
+
When the owner explicitly asks to answer anyone on one channel, propose one
|
|
103
|
+
incoming-only `--any-conversation` grant for that registered source. The flag
|
|
104
|
+
also selects `--until-revoked` and contact `*`. Do not widen “this group,” “this
|
|
105
|
+
person,” or “customers in this list” into everyone. The source must support a
|
|
106
|
+
wildcard watch; otherwise report that adapter gap without inventing a parallel
|
|
107
|
+
poller. Telegram's built-in source supports private chats and groups. Other
|
|
108
|
+
channels use the same core grant when their adapter advertises the same event-
|
|
109
|
+
source contract. Authenticated owner application channels remain owner routes;
|
|
110
|
+
an untrusted web, voice or phone audience needs an event-source adapter and this
|
|
111
|
+
grant.
|
|
112
|
+
|
|
113
|
+
Capabilities are optional and provider-neutral. Put an array in a private
|
|
114
|
+
capability file; each entry has `id`, `description`, registered `command`, and a
|
|
115
|
+
fixed `args` array ending in `"--", "{input}"`. Example for an already installed
|
|
116
|
+
Library source:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
[
|
|
120
|
+
{
|
|
121
|
+
"id": "knowledge",
|
|
122
|
+
"description": "Search the approved public Library source",
|
|
123
|
+
"command": "library-query",
|
|
124
|
+
"args": ["--library", "public", "--limit", "5", "--", "{input}"]
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The registered alias must declare `channelQuery: true` with explicit exposure
|
|
130
|
+
metadata showing that it accepts external input but cannot send, change records,
|
|
131
|
+
or require interactive review. Core verifies that installed declaration on every
|
|
132
|
+
invocation. Verify the selected source and query alias first, then propose:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
ezenciel-agents-task propose --source telegram --any-conversation \
|
|
136
|
+
--purpose "Answer questions using approved public knowledge" \
|
|
137
|
+
--context-file work/public-answer-policy.txt \
|
|
138
|
+
--capability-file work/public-answer-capabilities.json
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The approval names the audience, disclosure context and exact command vectors.
|
|
142
|
+
Command output may be shown to anyone in that audience. External writes need
|
|
143
|
+
their own bounded tool contract and receipts. Revocation stops new capture,
|
|
144
|
+
cancels queued replies and rechecks every tool/send. Public Telegram groups must
|
|
145
|
+
mention or reply to the bot; one conversation can have only one outstanding run
|
|
146
|
+
and the source-wide public backlog is bounded. Core admits at most 60 public
|
|
147
|
+
conversations and 60 replies per hour. A grant revokes after 1,000 conversations
|
|
148
|
+
or replies so the owner must review continued broad access.
|
|
149
|
+
|
|
98
150
|
The core presents the exact proposal for owner confirmation. Ordinary messages
|
|
99
151
|
inside that grant need no repeated confirmations. Incoming-only grants create
|
|
100
152
|
no initial run or opening message. They remain active across replies until expiry
|
package/docs/setup.md
CHANGED
|
@@ -19,7 +19,8 @@ Make the bot a group admin so Telegram delivers ordinary member messages and
|
|
|
19
19
|
allows membership verification for approval and control buttons. Group ownership only authorizes
|
|
20
20
|
the exact approved chat, not other groups or members' private DMs. Telegram group
|
|
21
21
|
migration to a new chat ID requires a new explicit binding. Verify a real group
|
|
22
|
-
reply before considering setup complete.
|
|
22
|
+
reply before considering setup complete. Unless an explicit any-conversation
|
|
23
|
+
grant is active, all other groups retain their existing
|
|
23
24
|
restricted conversation-grant behavior.
|
|
24
25
|
|
|
25
26
|
Ez supports two independent, composable uses: an autonomous Telegram agent and
|
package/docs/upgrades.md
CHANGED
|
@@ -174,10 +174,12 @@ No backup deletes live data or revokes credentials. Retain old package roots and
|
|
|
174
174
|
images until QA and any recovery window are complete; there is no automatic GC.
|
|
175
175
|
|
|
176
176
|
The updater accepts only matching state-schema and protocol contracts and an
|
|
177
|
-
unchanged deployment layout.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
unchanged deployment layout. Plugins may add command routes to existing services;
|
|
178
|
+
existing routes and all services, privileges, volumes and other deployment
|
|
179
|
+
configuration must remain unchanged. Incompatible changes fail before replacement,
|
|
180
|
+
even for an explicit candidate, and need a separately reviewed migration rather
|
|
181
|
+
than an override flag. Release authors must truthfully declare schema compatibility.
|
|
182
|
+
Code rollback **does not rewind private
|
|
181
183
|
state**, provider cursors or operation receipts; uncertain actions are never
|
|
182
184
|
replayed. Docker health is not proof of live provider identity or delivery.
|
|
183
185
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.33",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
|
|
@@ -7,7 +7,10 @@ import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
|
|
|
7
7
|
import { ownsRun } from './identity.js'
|
|
8
8
|
import { applicationId, validApplicationOrigin } from './application-origin.js'
|
|
9
9
|
import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
|
|
10
|
+
import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
|
|
10
11
|
import { assertEffort } from './model-policy.js'
|
|
12
|
+
import { ApprovalStore } from './approval.js'
|
|
13
|
+
import { Tasks } from './tasks.js'
|
|
11
14
|
|
|
12
15
|
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
13
16
|
export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
|
|
@@ -68,7 +71,7 @@ export class ApplicationChannel {
|
|
|
68
71
|
private admissions = Promise.resolve()
|
|
69
72
|
readonly bindings: ApplicationBindings
|
|
70
73
|
constructor(private options: {
|
|
71
|
-
controlDir: string; initial: AiPreset
|
|
74
|
+
controlDir: string; workspace?: string; initial: AiPreset
|
|
72
75
|
wake: () => void
|
|
73
76
|
cancel: (id: string) => Promise<void>
|
|
74
77
|
aiControls?: {
|
|
@@ -192,8 +195,8 @@ export class ApplicationChannel {
|
|
|
192
195
|
if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
|
|
193
196
|
input = {...rest, followTelegram: followOwner}
|
|
194
197
|
}
|
|
195
|
-
const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; model?: unknown; effort?: unknown } }
|
|
196
|
-
if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || !value.text.trim() || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
|
|
198
|
+
const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; model?: unknown; effort?: unknown } }
|
|
199
|
+
if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || (!value.text.trim() && value.attachment === undefined) || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','attachment','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
|
|
197
200
|
if (value.expectedNativeSessionId !== undefined && (typeof value.expectedNativeSessionId !== 'string' || !/^[a-zA-Z0-9_-]{1,160}$/.test(value.expectedNativeSessionId))) throw new Error('Invalid application native session assertion')
|
|
198
201
|
if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
|
|
199
202
|
if (value.followTelegram !== undefined && typeof value.followTelegram !== 'boolean') throw new Error('Invalid application Telegram following')
|
|
@@ -205,7 +208,17 @@ export class ApplicationChannel {
|
|
|
205
208
|
assertEffort(candidate.effort, candidate.model, candidate.cli)
|
|
206
209
|
requestedPreset = candidate
|
|
207
210
|
}
|
|
208
|
-
|
|
211
|
+
let bytes: Buffer | undefined
|
|
212
|
+
if (value.attachment !== undefined) {
|
|
213
|
+
const attachment = value.attachment
|
|
214
|
+
if (!attachment || typeof attachment.name !== 'string' || !attachment.name.length || attachment.name.length > 255 ||
|
|
215
|
+
typeof attachment.data !== 'string' || attachment.data.length > Math.ceil(MAX_INCOMING_ATTACHMENT_BYTES / 3) * 4 ||
|
|
216
|
+
Object.keys(attachment).some(key => !['name', 'data'].includes(key))) throw new Error('Invalid application attachment')
|
|
217
|
+
bytes = Buffer.from(attachment.data, 'base64')
|
|
218
|
+
if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || bytes.toString('base64') !== attachment.data) throw new Error('Invalid application attachment encoding or size')
|
|
219
|
+
}
|
|
220
|
+
const attachmentHash = bytes ? hash(JSON.stringify([value.attachment!.name, bytes.toString('base64')])) : undefined
|
|
221
|
+
const application = { bindingId, requestId: value.requestId, scope: value.scope, ...(attachmentHash ? {inputText: value.text, attachmentHash} : {}), ...(value.followTelegram ? { followTelegram: true } : {}), ...(value.context === undefined ? {} : { context: value.context }) }
|
|
209
222
|
if (!validApplicationOrigin(application)) throw new Error('Invalid application context')
|
|
210
223
|
const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
|
|
211
224
|
if (!binding) throw new Error('Application authority revoked')
|
|
@@ -215,13 +228,18 @@ export class ApplicationChannel {
|
|
|
215
228
|
if (existing) {
|
|
216
229
|
await this.bindings.authorize(existing)
|
|
217
230
|
if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
|
|
218
|
-
if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || existing.texts[0] !== value.text) throw new Error('Application request ID conflicts with prior scope or text')
|
|
231
|
+
if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || (existing.application?.inputText ?? existing.texts[0]) !== value.text || existing.application?.attachmentHash !== attachmentHash) throw new Error('Application request ID conflicts with prior scope or text')
|
|
219
232
|
return existing // Retried context never replaces already admitted capabilities.
|
|
220
233
|
}
|
|
221
234
|
const control = new ControlStore(this.options.controlDir, 900000)
|
|
222
235
|
if (!sameOwner(binding.owner, (await control.status()).owner)) throw new Error('Application authority revoked')
|
|
236
|
+
if (bytes && !this.options.workspace) throw new Error('Invalid application attachment: workspace unavailable')
|
|
237
|
+
const staged = bytes ? await stageChatAttachment(this.options.workspace!, value.attachment!.name as string, bytes, value.text) : undefined
|
|
238
|
+
let run: RunRecord
|
|
239
|
+
try {
|
|
223
240
|
const execution = value.followTelegram ? await control.captureChoice(this.options.initial) : await control.captureApplicationChoice(this.options.initial, applicationScope(bindingId, value.scope), binding.shareTelegram === true && value.activateTelegram === true, requestedPreset, value.expectedNativeSessionId as string | undefined)
|
|
224
|
-
|
|
241
|
+
run = await this.runs.create({ id, ownerId: ownerId(binding.owner), ownerEpoch: ownerEpoch(binding.owner), texts: [staged?.text ?? value.text], execution, application }) }
|
|
242
|
+
catch (error) { if (staged) await unlink(staged.fullPath).catch(() => {}); throw error }
|
|
225
243
|
this.options.wake()
|
|
226
244
|
return run
|
|
227
245
|
})
|
|
@@ -234,11 +252,11 @@ export class ApplicationChannel {
|
|
|
234
252
|
await this.bindings.authorize(run)
|
|
235
253
|
const state = await new ControlStore(this.options.controlDir, 900000).status()
|
|
236
254
|
const session = state.activeSession?.sessionId === run.execution?.sessionId ? state.activeSession : state.sessions?.find(item => item.sessionId === run.execution?.sessionId)
|
|
237
|
-
return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
|
|
255
|
+
return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), approvals:await this.runs.applicationApprovals(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
|
|
238
256
|
}
|
|
239
257
|
async deliver(run: RunRecord, item: OutboxItem): Promise<void> {
|
|
240
258
|
await this.bindings.authorize(run)
|
|
241
|
-
if (item.type && item.type
|
|
259
|
+
if (item.type && !['message','approval'].includes(item.type)) throw new Error('Application channel supports text messages and approvals only')
|
|
242
260
|
await this.runs.markOutboxSent(item.id)
|
|
243
261
|
}
|
|
244
262
|
private async route(request: IncomingMessage, response: ServerResponse) {
|
|
@@ -277,12 +295,25 @@ export class ApplicationChannel {
|
|
|
277
295
|
}
|
|
278
296
|
if (request.method === 'POST' && path === '/v1/runs') {
|
|
279
297
|
const chunks: Buffer[] = []; let size = 0
|
|
280
|
-
for await (const chunk of request) { size += chunk.length; if (size >
|
|
298
|
+
for await (const chunk of request) { size += chunk.length; if (size > Math.ceil(MAX_INCOMING_ATTACHMENT_BYTES / 3) * 4 + 256 * 1024) throw new Error('Application request too large'); chunks.push(chunk) }
|
|
281
299
|
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
282
300
|
if (applicationId(input?.requestId)) admissionId = `r_app_${hash(JSON.stringify([binding.bindingId, input.requestId]))}`
|
|
283
301
|
const run = await this.submit(binding.bindingId, input)
|
|
284
302
|
send(202, await this.snapshot(binding.bindingId, run.id)); return
|
|
285
303
|
}
|
|
304
|
+
const approvalMatch=path.match(/^\/v1\/approvals\/(task_[a-f0-9]{32})$/)
|
|
305
|
+
if(approvalMatch && request.method==='POST') {
|
|
306
|
+
const approval=await new ApprovalStore(this.options.controlDir).getDecision(approvalMatch[1])
|
|
307
|
+
const run=approval?.runId ? await this.runs.get(approval.runId) : null
|
|
308
|
+
if(!run || run.application?.bindingId!==binding.bindingId)throw new Error('Unknown application approval')
|
|
309
|
+
await this.bindings.authorize(run)
|
|
310
|
+
const chunks:Buffer[]=[];let size=0;for await(const chunk of request){size+=chunk.length;if(size>1024)throw new Error('Invalid application approval');chunks.push(chunk)}
|
|
311
|
+
const input=JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
312
|
+
if(!input || !['approved','denied'].includes(input.decision) || Object.keys(input).some(key=>key!=='decision'))throw new Error('Invalid application approval')
|
|
313
|
+
await new ApprovalStore(this.options.controlDir).recordOwnerDecision(approvalMatch[1],input.decision,binding.owner)
|
|
314
|
+
if(!await new Tasks(this.options.controlDir).decide(approvalMatch[1]))throw new Error('Unknown application approval')
|
|
315
|
+
send(200,{id:approvalMatch[1],decision:input.decision});return
|
|
316
|
+
}
|
|
286
317
|
const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
|
|
287
318
|
if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
|
|
288
319
|
await this.snapshot(binding.bindingId, match[1])
|
|
@@ -3,6 +3,8 @@ export type ApplicationOrigin = {
|
|
|
3
3
|
scope: string
|
|
4
4
|
requestId: string
|
|
5
5
|
followTelegram?: boolean
|
|
6
|
+
inputText?: string
|
|
7
|
+
attachmentHash?: string
|
|
6
8
|
context?: Record<string, unknown>
|
|
7
9
|
}
|
|
8
10
|
export const applicationId = (value: unknown): value is string =>
|
|
@@ -11,5 +13,7 @@ export const validApplicationOrigin = (value: unknown): value is ApplicationOrig
|
|
|
11
13
|
const origin = value as ApplicationOrigin | undefined
|
|
12
14
|
return !!origin && /^[a-f0-9-]{36}$/.test(origin.bindingId) && applicationId(origin.scope) && applicationId(origin.requestId) &&
|
|
13
15
|
(origin.followTelegram === undefined || typeof origin.followTelegram === 'boolean') &&
|
|
16
|
+
(origin.inputText === undefined || (typeof origin.inputText === 'string' && origin.inputText.length <= 16000)) &&
|
|
17
|
+
(origin.attachmentHash === undefined || /^[a-f0-9]{64}$/.test(origin.attachmentHash)) &&
|
|
14
18
|
(origin.context === undefined || (!!origin.context && typeof origin.context === 'object' && !Array.isArray(origin.context) && JSON.stringify(origin.context).length <= 48000))
|
|
15
19
|
}
|
package/src/approval.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { assertId } from './identity.js'
|
|
4
|
+
import { ownerEpoch, ownerId, type Owner } from './control-state.js'
|
|
4
5
|
|
|
5
6
|
export type ApprovalDecision = 'pending' | 'approved' | 'denied'
|
|
6
7
|
|
|
7
8
|
export type ApprovalRecord = {
|
|
8
|
-
version: 1
|
|
9
|
+
version: 1 | 2
|
|
9
10
|
actionId: string
|
|
10
11
|
runId?: string
|
|
11
12
|
prompt: string
|
|
@@ -14,19 +15,22 @@ export type ApprovalRecord = {
|
|
|
14
15
|
decidedAt?: string
|
|
15
16
|
decidedBy?: number
|
|
16
17
|
decisionUpdateId?: number
|
|
18
|
+
decidedOwnerId?: string
|
|
19
|
+
decidedOwnerEpoch?: string
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
|
|
20
23
|
if (!value || typeof value !== 'object') return false
|
|
21
24
|
const candidate = value as Partial<ApprovalRecord>
|
|
22
25
|
return (
|
|
23
|
-
candidate.version
|
|
26
|
+
[1,2].includes(candidate.version ?? 0) &&
|
|
24
27
|
typeof candidate.actionId === 'string' &&
|
|
25
28
|
/^[a-zA-Z0-9_-]{1,40}$/.test(candidate.actionId) &&
|
|
26
29
|
typeof candidate.prompt === 'string' &&
|
|
27
30
|
['pending', 'approved', 'denied'].includes(candidate.decision ?? '') &&
|
|
28
31
|
typeof candidate.createdAt === 'string' &&
|
|
29
|
-
Number.isFinite(Date.parse(candidate.createdAt))
|
|
32
|
+
Number.isFinite(Date.parse(candidate.createdAt)) &&
|
|
33
|
+
(candidate.version === 1 || typeof candidate.decidedOwnerId === 'string' && typeof candidate.decidedOwnerEpoch === 'string')
|
|
30
34
|
)
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -105,6 +109,18 @@ export class ApprovalStore {
|
|
|
105
109
|
return record
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
async recordOwnerDecision(actionId: string, decision: 'approved' | 'denied', owner: Owner): Promise<ApprovalRecord> {
|
|
113
|
+
await this.ensure()
|
|
114
|
+
const existing = await this.getDecision(actionId)
|
|
115
|
+
if (!existing) throw new Error('Unknown approval request')
|
|
116
|
+
if (existing.decision !== 'pending') throw new Error('Approval already decided')
|
|
117
|
+
if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
|
|
118
|
+
const record: ApprovalRecord = {...existing,version:2,decision,decidedAt:new Date().toISOString(),decidedOwnerId:ownerId(owner),decidedOwnerEpoch:ownerEpoch(owner)}
|
|
119
|
+
const file=this.filePath(actionId),temporary=`${file}.${process.pid}.tmp`
|
|
120
|
+
await writeFile(temporary,`${JSON.stringify(record,null,2)}\n`,{mode:0o600});await rename(temporary,file)
|
|
121
|
+
return record
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
async getDecision(actionId: string): Promise<ApprovalRecord | null> {
|
|
109
125
|
try {
|
|
110
126
|
const parsed: unknown = JSON.parse(await readFile(this.filePath(actionId), 'utf8'))
|
package/src/codex-session.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { terminateJob } from './executor.js'
|
|
7
7
|
|
|
8
|
-
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;model?:string;effort?:string;prompt:string;codexSandbox?:'external'}
|
|
8
|
+
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external'}
|
|
9
9
|
type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
|
|
10
10
|
|
|
11
11
|
// Keep Codex's native session alive. Codex itself starts goal continuation turns;
|
|
@@ -72,7 +72,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
72
72
|
send({method:'initialized',params:{}})
|
|
73
73
|
const result=await request('thread/start',{
|
|
74
74
|
cwd:options.workspace,approvalPolicy:'never',sandbox:options.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write',model:options.model,
|
|
75
|
-
config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[])],
|
|
75
|
+
config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[]),...(options.additionalWorkspaces ?? [])],
|
|
76
76
|
'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
|
|
77
77
|
})
|
|
78
78
|
threadId=result.thread?.id
|
package/src/desktop-bridge.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { executionDefaults } from './model-policy.js'
|
|
|
3
3
|
import { access, constants } from 'node:fs/promises'
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto'
|
|
5
5
|
import { createConnection, type Socket } from 'node:net'
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
6
7
|
import { homedir } from 'node:os'
|
|
7
8
|
import { delimiter, join } from 'node:path'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
@@ -29,6 +30,7 @@ export type DesktopClient = {
|
|
|
29
30
|
request: (method: string, params?: unknown) => Promise<Record<string, unknown>>
|
|
30
31
|
notify: (method: string, params?: unknown) => void
|
|
31
32
|
wait: (match: (message: Record<string, unknown>) => boolean, timeoutMs: number) => Promise<Record<string, unknown>>
|
|
33
|
+
setServerRequestContext: (threadId?: string, turnId?: string) => void
|
|
32
34
|
close: () => void
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -57,6 +59,8 @@ export const desktopCodexPath = async (home = homedir(), envPath = process.env.P
|
|
|
57
59
|
const writableRoots = (options: DesktopTurnOptions): string[] =>
|
|
58
60
|
[options.controlDir, options.toolsHome].filter((value): value is string => Boolean(value))
|
|
59
61
|
|
|
62
|
+
const approvalPolicy = (scheduledDesktop: boolean): 'on-request' | 'never' => scheduledDesktop ? 'on-request' : 'never'
|
|
63
|
+
|
|
60
64
|
const sendFrame = (socket: Socket, text: string) => {
|
|
61
65
|
const payload = Buffer.from(text)
|
|
62
66
|
const mask = randomBytes(4)
|
|
@@ -71,6 +75,29 @@ const sendFrame = (socket: Socket, text: string) => {
|
|
|
71
75
|
socket.write(Buffer.concat([header, mask, masked]))
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
export const desktopServerRequestResult = (
|
|
79
|
+
message: Record<string, unknown>,
|
|
80
|
+
context?: { threadId: string; turnId?: string },
|
|
81
|
+
): Record<string, unknown> => {
|
|
82
|
+
if (message.method === 'mcpServer/elicitation/request') {
|
|
83
|
+
const params = message.params as { threadId?: unknown; turnId?: unknown; _meta?: Record<string, unknown> } | undefined
|
|
84
|
+
const meta = params?._meta
|
|
85
|
+
const toolParams = meta?.tool_params as Record<string, unknown> | undefined
|
|
86
|
+
const origin = toolParams?.origin
|
|
87
|
+
const browserOrigin = meta?.connector_id === 'browser-use' && meta.codex_approval_kind === 'mcp_tool_call' &&
|
|
88
|
+
meta.codex_request_type === 'approval_request' && meta.tool_name === 'access_browser_origin' &&
|
|
89
|
+
typeof origin === 'string' && /^https?:\/\//.test(origin) && meta.origin === origin
|
|
90
|
+
const chromeApp = meta?.connector_id === 'computer-use' && meta.codex_approval_kind === 'mcp_tool_call' &&
|
|
91
|
+
meta.codex_request_type !== 'approval_request' && meta.tool_name !== 'start_audio_recording' &&
|
|
92
|
+
toolParams?.app === 'com.google.Chrome'
|
|
93
|
+
if (context && params?.threadId === context.threadId && typeof params.turnId === 'string' &&
|
|
94
|
+
(!context.turnId || params.turnId === context.turnId) && (browserOrigin || chromeApp))
|
|
95
|
+
return { action: 'accept', content: null, _meta: { persist: 'session' } }
|
|
96
|
+
return { action: 'decline', content: null, _meta: null }
|
|
97
|
+
}
|
|
98
|
+
return { decision: 'decline' }
|
|
99
|
+
}
|
|
100
|
+
|
|
74
101
|
export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopClient => {
|
|
75
102
|
let buffer = Buffer.concat(pending)
|
|
76
103
|
let nextId = 1
|
|
@@ -79,6 +106,7 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
|
|
|
79
106
|
const failedWaits = new Set<() => void>()
|
|
80
107
|
const notifications: Record<string, unknown>[] = []
|
|
81
108
|
const watchers: Array<(message: Record<string, unknown>) => void> = []
|
|
109
|
+
let serverRequestContext: { threadId: string; turnId?: string } | undefined
|
|
82
110
|
const deliver = (message: Record<string, unknown>) => {
|
|
83
111
|
const id = message.id
|
|
84
112
|
if (typeof id === 'number' && replies.has(id) && (message.result !== undefined || message.error)) {
|
|
@@ -89,8 +117,13 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
|
|
|
89
117
|
else reply.resolve(message.result as Record<string, unknown>)
|
|
90
118
|
return
|
|
91
119
|
}
|
|
92
|
-
if (typeof id === 'number' && typeof message.method === 'string') {
|
|
93
|
-
|
|
120
|
+
if ((typeof id === 'number' || typeof id === 'string') && typeof message.method === 'string') {
|
|
121
|
+
const result = desktopServerRequestResult(message, serverRequestContext)
|
|
122
|
+
if (result.action === 'accept' && serverRequestContext && !serverRequestContext.turnId) {
|
|
123
|
+
const turnId = (message.params as { turnId?: unknown } | undefined)?.turnId
|
|
124
|
+
if (typeof turnId === 'string') serverRequestContext.turnId = turnId
|
|
125
|
+
}
|
|
126
|
+
sendFrame(socket, JSON.stringify({ id, result }))
|
|
94
127
|
return
|
|
95
128
|
}
|
|
96
129
|
notifications.push(message)
|
|
@@ -168,6 +201,9 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
|
|
|
168
201
|
failedWaits.add(fail)
|
|
169
202
|
watchers.push(watcher)
|
|
170
203
|
}),
|
|
204
|
+
setServerRequestContext: (threadId, turnId) => {
|
|
205
|
+
serverRequestContext = threadId ? { threadId, turnId } : undefined
|
|
206
|
+
},
|
|
171
207
|
close: () => socket.destroy(),
|
|
172
208
|
}
|
|
173
209
|
}
|
|
@@ -200,6 +236,32 @@ export const connectDesktop = (socketPath = desktopControlSocket()): Promise<Des
|
|
|
200
236
|
socket.on('data', onData)
|
|
201
237
|
})
|
|
202
238
|
|
|
239
|
+
const startDesktopDaemon = async (
|
|
240
|
+
home = homedir(),
|
|
241
|
+
envPath = process.env.PATH || '',
|
|
242
|
+
run: typeof execFile = execFile,
|
|
243
|
+
): Promise<void> => {
|
|
244
|
+
const codex = await desktopCodexPath(home, envPath)
|
|
245
|
+
if (!codex) throw new Error(DESKTOP_UNAVAILABLE)
|
|
246
|
+
await new Promise<void>((resolve, reject) => run(codex, ['app-server', 'daemon', 'start'], {
|
|
247
|
+
env: { HOME: home, PATH: envPath }, timeout: 15_000, maxBuffer: 64 * 1024,
|
|
248
|
+
}, (error) => error ? reject(new Error(DESKTOP_UNAVAILABLE)) : resolve()))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const connectManagedDesktop = async (
|
|
252
|
+
connect: typeof connectDesktop = connectDesktop,
|
|
253
|
+
start: typeof startDesktopDaemon = startDesktopDaemon,
|
|
254
|
+
): Promise<DesktopClient> => {
|
|
255
|
+
try { return await connect() }
|
|
256
|
+
catch {
|
|
257
|
+
// The native Codex daemon owns its lifecycle and is idempotent. New desktop
|
|
258
|
+
// builds do not necessarily create the control socket merely by opening the
|
|
259
|
+
// app, so ask Codex to restore its own bridge before failing closed.
|
|
260
|
+
await start()
|
|
261
|
+
return connect()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
203
265
|
export const runDesktopTurn = async (
|
|
204
266
|
options: DesktopTurnOptions,
|
|
205
267
|
io: { connect?: typeof connectDesktop; emit?: (line: string) => void; signal?: AbortSignal } = {},
|
|
@@ -208,7 +270,8 @@ export const runDesktopTurn = async (
|
|
|
208
270
|
const emit = io.emit ?? ((line: string) => process.stdout.write(`${line}\n`))
|
|
209
271
|
let client: DesktopClient | undefined
|
|
210
272
|
try {
|
|
211
|
-
|
|
273
|
+
const scheduledDesktop = options.runId.startsWith('r_schedule_')
|
|
274
|
+
client = await (io.connect ?? connectManagedDesktop)()
|
|
212
275
|
await client.request('initialize', { clientInfo: { name: 'ezenciel-agents', title: 'ez', version: '1' } })
|
|
213
276
|
client.notify('initialized', {})
|
|
214
277
|
const roots = writableRoots(options)
|
|
@@ -227,7 +290,7 @@ export const runDesktopTurn = async (
|
|
|
227
290
|
const started = await client.request('thread/start', {
|
|
228
291
|
cwd: options.workspace,
|
|
229
292
|
config,
|
|
230
|
-
approvalPolicy:
|
|
293
|
+
approvalPolicy: approvalPolicy(scheduledDesktop),
|
|
231
294
|
sandbox: 'workspace-write',
|
|
232
295
|
model: options.model,
|
|
233
296
|
serviceName: 'ezenciel-agents',
|
|
@@ -238,17 +301,19 @@ export const runDesktopTurn = async (
|
|
|
238
301
|
}
|
|
239
302
|
if (!nativeThread(threadId)) throw new Error(DESKTOP_UNAVAILABLE)
|
|
240
303
|
emit(JSON.stringify({ type: 'thread.started', thread_id: threadId }))
|
|
304
|
+
client.setServerRequestContext(scheduledDesktop ? threadId! : undefined)
|
|
241
305
|
const turn = await client.request('turn/start', {
|
|
242
306
|
threadId,
|
|
243
307
|
input: [{ type: 'text', text: options.prompt }],
|
|
244
308
|
model: options.model,
|
|
245
309
|
effort: options.effort,
|
|
246
310
|
cwd: options.workspace,
|
|
247
|
-
approvalPolicy:
|
|
311
|
+
approvalPolicy: approvalPolicy(scheduledDesktop),
|
|
248
312
|
sandboxPolicy: { type: 'workspaceWrite', writableRoots: roots, networkAccess: Boolean(options.toolsHome) },
|
|
249
313
|
})
|
|
250
314
|
const turnId = (turn.turn as { id?: string } | undefined)?.id
|
|
251
315
|
if (!turnId) throw new Error(DESKTOP_UNAVAILABLE)
|
|
316
|
+
if (scheduledDesktop) client.setServerRequestContext(threadId!, turnId)
|
|
252
317
|
const interrupt = () => { void client?.request('turn/interrupt', { threadId, turnId }).catch(() => {}) }
|
|
253
318
|
io.signal?.addEventListener('abort', interrupt, { once: true })
|
|
254
319
|
if (io.signal?.aborted) interrupt()
|
package/src/event-sources.ts
CHANGED
|
@@ -2,21 +2,22 @@ import { mkdir, open, readFile, rename, unlink, lstat } from 'node:fs/promises'
|
|
|
2
2
|
import { join, isAbsolute } from 'node:path'
|
|
3
3
|
import { randomUUID, createHash } from 'node:crypto'
|
|
4
4
|
import { request } from 'node:http'
|
|
5
|
-
import type
|
|
5
|
+
import { sameOwner, validOwner, type Owner } from './control-state.js'
|
|
6
6
|
|
|
7
|
-
export type ExternalOrigin = { sourceId: string; bindingId: string; eventIds: string[] }
|
|
7
|
+
export type ExternalOrigin = { sourceId: string; bindingId: string; conversationId?: string; eventIds: string[] }
|
|
8
8
|
export type SourceEvent = { id: string; conversationId: string; receivedAt: number; text: string }
|
|
9
9
|
export type EventSource = { id: string; bindingId: string; socketPath: string; initialCursor: number; owner: Owner }
|
|
10
10
|
const identifier = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(s)
|
|
11
11
|
export const validOrigin = (o: unknown): o is ExternalOrigin => {
|
|
12
12
|
const v = o as ExternalOrigin | undefined
|
|
13
|
-
return !!v && identifier(v.sourceId) && identifier(v.bindingId) &&
|
|
13
|
+
return !!v && identifier(v.sourceId) && identifier(v.bindingId) &&
|
|
14
|
+
(v.conversationId === undefined || typeof v.conversationId === 'string' && v.conversationId.length > 0 && v.conversationId.length <= 200) &&
|
|
15
|
+
Array.isArray(v.eventIds) && v.eventIds.length > 0 && v.eventIds.length <= 10 && v.eventIds.every(identifier)
|
|
14
16
|
}
|
|
15
17
|
const cursorOK = (v: unknown): v is number => Number.isSafeInteger(v) && Number(v) >= 0
|
|
16
18
|
export const validEvents = (v: unknown): v is SourceEvent[] => Array.isArray(v) && v.length <= 10 && v.every(e =>
|
|
17
19
|
identifier(e?.id) && typeof e.conversationId === 'string' && e.conversationId.length > 0 && e.conversationId.length <= 200 &&
|
|
18
20
|
Number.isFinite(e.receivedAt) && typeof e.text === 'string' && e.text.length <= 16000) && new Set(v.map(e => e.id)).size === v.length
|
|
19
|
-
const sameOwner = (a: Owner, b: Owner) => a.telegramUserId === b.telegramUserId && a.telegramChatId === b.telegramChatId
|
|
20
21
|
async function read<T>(path: string, fallback: T): Promise<T> {
|
|
21
22
|
try { return JSON.parse(await readFile(path, 'utf8')) as T } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return fallback; throw new Error('Unreadable event-source state') }
|
|
22
23
|
}
|
|
@@ -49,8 +50,7 @@ export class EventSources {
|
|
|
49
50
|
const value = await read<{ version: number; sources: EventSource[] }>(this.registry, { version: 1, sources: [] })
|
|
50
51
|
if (value.version !== 1 || !Array.isArray(value.sources) || value.sources.some(s => !identifier(s.id) || !identifier(s.bindingId) ||
|
|
51
52
|
typeof s.socketPath !== 'string' || !isAbsolute(s.socketPath) || !cursorOK(s.initialCursor) ||
|
|
52
|
-
!
|
|
53
|
-
(s.owner.kind === 'group' ? s.owner.telegramChatId! >= 0 : s.owner.kind !== undefined || s.owner.telegramChatId! <= 0)) ||
|
|
53
|
+
!validOwner(s.owner)) ||
|
|
54
54
|
new Set(value.sources.map(s => s.id)).size !== value.sources.length) throw new Error('Invalid event-source registry')
|
|
55
55
|
return value.sources
|
|
56
56
|
}
|
|
@@ -106,6 +106,13 @@ export class EventSources {
|
|
|
106
106
|
if (!validEvents(result?.events) || result.events.some((e: SourceEvent) => !origin.eventIds.includes(e.id))) throw new Error('Invalid event recheck')
|
|
107
107
|
return result.events
|
|
108
108
|
}
|
|
109
|
+
async release(origin: ExternalOrigin, owner: Owner): Promise<void> {
|
|
110
|
+
if (!validOrigin(origin)) return
|
|
111
|
+
const source = (await this.available(owner)).find(s => s.id === origin.sourceId && s.bindingId === origin.bindingId)
|
|
112
|
+
if (!source) return
|
|
113
|
+
const head = await sourceCall(source.socketPath, 'events-head')
|
|
114
|
+
if (head.releaseEvents === true) await sourceCall(source.socketPath, 'events-release', {ids:origin.eventIds})
|
|
115
|
+
}
|
|
109
116
|
}
|
|
110
117
|
export const eventRunId = (source: EventSource, events: SourceEvent[]) => 'event_' + createHash('sha256')
|
|
111
118
|
.update(JSON.stringify([source.bindingId, events.map(e => e.id)])).digest('hex')
|
package/src/executor.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type ExecutorOptions = {
|
|
|
21
21
|
binDir: string
|
|
22
22
|
toolsHome?: string
|
|
23
23
|
sharedWorkspace?: string
|
|
24
|
+
additionalWorkspaces?: string[]
|
|
24
25
|
cli?: string
|
|
25
26
|
sessionId?: string
|
|
26
27
|
isResume?: boolean
|
|
@@ -75,7 +76,7 @@ export type CliAdapter = {
|
|
|
75
76
|
command: string
|
|
76
77
|
description: string
|
|
77
78
|
buildArgs: (
|
|
78
|
-
options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
|
|
79
|
+
options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
|
|
79
80
|
promptFile: string,
|
|
80
81
|
promptText: string,
|
|
81
82
|
) => string[]
|
|
@@ -94,6 +95,7 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
|
|
|
94
95
|
}
|
|
95
96
|
if (opts.controlDir) args.push('--add-dir', opts.controlDir)
|
|
96
97
|
if (opts.sharedWorkspace) args.push('--add-dir', opts.sharedWorkspace)
|
|
98
|
+
for (const workspace of opts.additionalWorkspaces ?? []) args.push('--add-dir', workspace)
|
|
97
99
|
if (opts.toolsHome) args.push('--add-dir', opts.toolsHome, '-c', 'sandbox_workspace_write.network_access=true')
|
|
98
100
|
if (opts.model) args.push('--model', opts.model)
|
|
99
101
|
if (opts.effort) args.push('-c', `model_reasoning_effort=${JSON.stringify(opts.effort)}`)
|
|
@@ -248,7 +250,7 @@ export const startExecutorJob = async (
|
|
|
248
250
|
const nativeSession = !host && key === 'codex' && options.runId.startsWith('r_schedule_')
|
|
249
251
|
// Chat-mode experiment: only direct chat input at the engine boundary.
|
|
250
252
|
const applicationReminder = !host && (run?.application || run?.delivery)
|
|
251
|
-
? '\n\n[Application channel] This is an owner-authorized application conversation. Send text replies using ezenciel-agents-message; stdout alone is not delivered.
|
|
253
|
+
? '\n\n[Application channel] This is an owner-authorized application conversation. Send text replies using ezenciel-agents-message; stdout alone is not delivered. Outgoing file delivery, reactions and approval controls are unsupported here. Domain tools can retrieve private context from ezenciel-agents-schedule context under run.application.context; do not expose credentials from that data. The application scope is ' + JSON.stringify((run.application ?? run.delivery)!.scope) + '.'
|
|
252
254
|
: ''
|
|
253
255
|
const chatReminder = !host && !run?.taskId && run?.messageId !== undefined
|
|
254
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.'
|
package/src/files.ts
CHANGED
|
@@ -73,3 +73,15 @@ export const workspaceFile = async (workspace: string, file: string, regular = t
|
|
|
73
73
|
if (regular && !(await stat(target)).isFile()) throw new Error('Expected a regular file')
|
|
74
74
|
return target
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
// Canonical native-engine attachment metadata for every inbound channel.
|
|
78
|
+
export const MAX_INCOMING_ATTACHMENT_BYTES = 10 * 1024 * 1024
|
|
79
|
+
export const stageChatAttachment = async (workspace: string, name: string, bytes: Buffer, comment: string) => {
|
|
80
|
+
const type = detectFileType(bytes)
|
|
81
|
+
if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
|
|
82
|
+
(type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
|
|
83
|
+
const staged = await stageIncomingFile(workspace, name, bytes)
|
|
84
|
+
const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
|
|
85
|
+
return { text: `[Attached ${kind} staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
|
|
86
|
+
attachment: {path: staged.relativePath, type: staged.fileType}, fullPath: staged.fullPath }
|
|
87
|
+
}
|