@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.
Files changed (57) hide show
  1. package/AGENTS.md +47 -32
  2. package/CHANGELOG.md +34 -1
  3. package/CONTRIBUTING.md +29 -26
  4. package/README.md +36 -25
  5. package/docs/application-channel.md +29 -2
  6. package/docs/architecture/ai-selection.md +10 -1
  7. package/docs/architecture/authority-boundaries.md +64 -14
  8. package/docs/architecture/event-sources.md +12 -5
  9. package/docs/architecture/telegram-intake.md +1 -1
  10. package/docs/host-service.md +3 -0
  11. package/docs/plugin-contributions.md +6 -1
  12. package/docs/plugins.md +23 -0
  13. package/docs/responsive-channels.md +3 -0
  14. package/docs/selective-monitoring.md +55 -3
  15. package/docs/setup.md +2 -1
  16. package/docs/upgrades.md +6 -4
  17. package/package.json +1 -1
  18. package/src/application-channel.ts +40 -9
  19. package/src/application-origin.ts +4 -0
  20. package/src/approval.ts +19 -3
  21. package/src/codex-session.ts +2 -2
  22. package/src/desktop-bridge.ts +70 -5
  23. package/src/event-sources.ts +13 -6
  24. package/src/executor.ts +4 -2
  25. package/src/files.ts +12 -0
  26. package/src/host-executor-protocol.ts +2 -2
  27. package/src/host-executor.ts +20 -4
  28. package/src/index.ts +81 -34
  29. package/src/plugins/manager.mjs +52 -20
  30. package/src/runs.ts +19 -2
  31. package/src/task-attachments.ts +56 -0
  32. package/src/task-capability.ts +45 -0
  33. package/src/task-cli.ts +7 -4
  34. package/src/task-executor.ts +8 -4
  35. package/src/task-mcp.ts +25 -8
  36. package/src/tasks.ts +132 -26
  37. package/src/telegram-source.ts +34 -13
  38. package/src/updates/artifact.mjs +2 -2
  39. package/src/updates/control.mjs +11 -1
  40. package/src/updates/runtime.mjs +3 -1
  41. package/templates/agent-guidance.md +14 -0
  42. package/test/application-channel.test.ts +52 -1
  43. package/test/codex-session.test.ts +2 -1
  44. package/test/desktop-bridge.test.ts +98 -1
  45. package/test/event-sources.test.ts +68 -8
  46. package/test/executor.test.ts +2 -2
  47. package/test/host-executor.test.ts +32 -3
  48. package/test/intake-relay.test.ts +79 -2
  49. package/test/local-qa.test.mjs +7 -3
  50. package/test/plugin-manager.test.mjs +31 -0
  51. package/test/runs.test.ts +19 -0
  52. package/test/task-attachments.test.ts +77 -0
  53. package/test/task-capability.test.ts +48 -0
  54. package/test/task-native.test.ts +4 -1
  55. package/test/tasks.test.ts +53 -1
  56. package/test/telegram-source.test.ts +28 -1
  57. 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 or suggest blanket autonomous replies that v1 cannot
16
- safely authorize. If the owner already gave a job, continue it without this prompt.
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. All other groups retain their existing
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. Changes to privileges, services, volumes or Compose
178
- configuration fail before replacement, even for an explicit candidate. These
179
- need a separately reviewed migration, not an override flag. Release authors must
180
- truthfully declare schema compatibility. Code rollback **does not rewind private
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.31",
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
- const application = { bindingId, requestId: value.requestId, scope: value.scope, ...(value.followTelegram ? { followTelegram: true } : {}), ...(value.context === undefined ? {} : { context: value.context }) }
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
- const run = await this.runs.create({ id, ownerId: ownerId(binding.owner), ownerEpoch: ownerEpoch(binding.owner), texts: [value.text], execution, application })
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 !== 'message') throw new Error('Application channel supports text messages only')
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 > 65536) throw new Error('Application request too large'); chunks.push(chunk) }
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 === 1 &&
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'))
@@ -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
@@ -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
- sendFrame(socket, JSON.stringify({ id, result: { decision: 'approved' } }))
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
- client = await (io.connect ?? connectDesktop)()
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: 'never',
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: 'never',
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()
@@ -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 { Owner } from './control-state.js'
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) && Array.isArray(v.eventIds) && v.eventIds.length > 0 && v.eventIds.length <= 10 && v.eventIds.every(identifier)
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
- !Number.isSafeInteger(s.owner?.telegramUserId) || s.owner.telegramUserId! <= 0 || !Number.isSafeInteger(s.owner.telegramChatId) ||
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. Attachments/reactions/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) + '.'
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
+ }