@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
@@ -2,7 +2,7 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
2
2
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
3
3
  import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
- import { ControlStore, sessionTitle, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
5
+ import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
6
6
  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'
@@ -74,6 +74,8 @@ export class ApplicationChannel {
74
74
  controlDir: string; workspace?: string; initial: AiPreset
75
75
  wake: () => void
76
76
  cancel: (id: string) => Promise<void>
77
+ createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
78
+ telegramAvailable?: () => boolean
77
79
  aiControls?: {
78
80
  catalog: () => Promise<ModelChoice[]>
79
81
  select: (preset: AiPreset, expectedSession: string | null, guard?: ControlGuard) => Promise<unknown>
@@ -88,6 +90,29 @@ export class ApplicationChannel {
88
90
  if (!binding || (shared && !binding.shareTelegram) || !sameOwner(binding.owner, state.owner)) throw new Error('Application authority does not permit controls')
89
91
  return binding.owner
90
92
  }
93
+ private async telegramConnection(bindingId: string) {
94
+ await this.sharedBinding(bindingId)
95
+ const owner = (await new ControlStore(this.options.controlDir, 900000).status()).owner
96
+ const connected = Boolean(telegramOwner(owner))
97
+ return this.options.telegramAvailable
98
+ ? { connected, ready: this.options.telegramAvailable() }
99
+ : { connected }
100
+ }
101
+ private async createTelegramConnection(bindingId: string) {
102
+ const owner = await this.sharedBinding(bindingId)
103
+ const current = (await new ControlStore(this.options.controlDir, 900000).status()).owner
104
+ if (telegramOwner(current)) return { connected: true } as const
105
+ if (!this.options.createTelegramPairing) throw new Error('Telegram connection is unavailable')
106
+ return this.options.createTelegramPairing(bindingId, owner)
107
+ }
108
+ async claimTelegramConnection(token: string, telegramUserId: number, telegramChatId: number): Promise<boolean> {
109
+ const control = new ControlStore(this.options.controlDir, 900000)
110
+ const owner = await control.claimApplicationTelegramPairing(token, telegramUserId, telegramChatId, async (bindingId, expectedOwner) => {
111
+ const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
112
+ return Boolean(binding?.shareTelegram && sameOwner(binding.owner, expectedOwner))
113
+ })
114
+ return Boolean(owner)
115
+ }
91
116
  async controls(bindingId: string) {
92
117
  await this.sharedBinding(bindingId)
93
118
  if (!this.options.aiControls) throw new Error('Application controls unavailable')
@@ -105,10 +130,10 @@ export class ApplicationChannel {
105
130
  const owner = await this.sharedBinding(bindingId)
106
131
  const controls = this.options.aiControls
107
132
  if (!controls) throw new Error('Application controls unavailable')
108
- const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; model?: unknown; effort?: unknown}
133
+ const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
109
134
  if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
110
135
  !(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
111
- const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
136
+ const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
112
137
  if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
113
138
  const control = new ControlStore(this.options.controlDir, 900000)
114
139
  const expected = value.expectedSession as string | null
@@ -131,7 +156,7 @@ export class ApplicationChannel {
131
156
  await controls.select(preset, expected, guard)
132
157
  }
133
158
  if (value.action === 'model') {
134
- const model = (await controls.catalog()).find(item => item.cli === value.cli && item.model === value.model)
159
+ const model = (await controls.catalog()).find(item => item.cli === value.cli && item.provider === value.provider && item.model === value.model)
135
160
  if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
136
161
  await this.sharedBinding(bindingId)
137
162
  const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
@@ -160,10 +185,10 @@ export class ApplicationChannel {
160
185
  const owner = await this.sharedBinding(bindingId, false)
161
186
  const controls = this.options.aiControls
162
187
  if (!controls) throw new Error('Application controls unavailable')
163
- const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; model?:unknown; effort?:unknown}
188
+ const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; model?:unknown; effort?:unknown}
164
189
  if (!value || !['new','select','model'].includes(String(value.action)) ||
165
190
  !(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
166
- const fields = {new:[],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'select'|'model']!
191
+ const fields = {new:[],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'select'|'model']!
167
192
  if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
168
193
  const hashedScope = applicationScope(bindingId,scope)
169
194
  const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
@@ -180,7 +205,7 @@ export class ApplicationChannel {
180
205
  await controls.validate(preset)
181
206
  }
182
207
  if (value.action==='model') {
183
- const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.model===value.model)
208
+ const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.provider===value.provider && item.model===value.model)
184
209
  if (!model || model.cli==='agy' || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
185
210
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
186
211
  }
@@ -195,7 +220,7 @@ export class ApplicationChannel {
195
220
  if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
196
221
  input = {...rest, followTelegram: followOwner}
197
222
  }
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 } }
223
+ 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; provider?: unknown; model?: unknown; effort?: unknown } }
199
224
  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')
200
225
  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')
201
226
  if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
@@ -204,7 +229,7 @@ export class ApplicationChannel {
204
229
  let requestedPreset: AiPreset | undefined
205
230
  if (value.ai !== undefined) {
206
231
  const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
207
- if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
232
+ if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
208
233
  assertEffort(candidate.effort, candidate.model, candidate.cli)
209
234
  requestedPreset = candidate
210
235
  }
@@ -227,14 +252,14 @@ export class ApplicationChannel {
227
252
  const existing = await this.runs.get(id)
228
253
  if (existing) {
229
254
  await this.bindings.authorize(existing)
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')
255
+ if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.provider !== requestedPreset.provider || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
231
256
  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')
232
257
  return existing // Retried context never replaces already admitted capabilities.
233
258
  }
234
259
  const control = new ControlStore(this.options.controlDir, 900000)
235
260
  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
261
+ if (bytes && !this.options.controlDir) throw new Error('Invalid application attachment: control unavailable')
262
+ const staged = bytes ? await stageChatAttachment(this.options.controlDir, value.attachment!.name as string, bytes, value.text) : undefined
238
263
  let run: RunRecord
239
264
  try {
240
265
  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)
@@ -252,7 +277,7 @@ export class ApplicationChannel {
252
277
  await this.bindings.authorize(run)
253
278
  const state = await new ControlStore(this.options.controlDir, 900000).status()
254
279
  const session = state.activeSession?.sessionId === run.execution?.sessionId ? state.activeSession : state.sessions?.find(item => item.sessionId === run.execution?.sessionId)
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' } : {}) }
280
+ return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.execution?.preset ? {preset:run.execution.preset} : {}), ...(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' } : {}) }
256
281
  }
257
282
  async deliver(run: RunRecord, item: OutboxItem): Promise<void> {
258
283
  await this.bindings.authorize(run)
@@ -270,13 +295,33 @@ export class ApplicationChannel {
270
295
  if (request.method === 'GET' && path === '/v1/registration') {
271
296
  send(200, {ownerId: ownerId(binding.owner), bindingId: binding.bindingId, channel: binding.id}); return
272
297
  }
298
+ if (path === '/v1/telegram' && request.method === 'GET') {
299
+ if ([...url.searchParams.keys()].length) throw new Error('Invalid application Telegram request')
300
+ if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram connection' }); return }
301
+ send(200, await this.telegramConnection(binding.bindingId)); return
302
+ }
303
+ if (path === '/v1/telegram/link' && request.method === 'POST') {
304
+ const chunks: Buffer[] = []; let size = 0
305
+ for await (const chunk of request) { size += chunk.length; if (size > 1024) throw new Error('Invalid application Telegram request'); chunks.push(chunk) }
306
+ if (Buffer.concat(chunks).toString('utf8').trim()) throw new Error('Invalid application Telegram request')
307
+ if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram connection' }); return }
308
+ send(200, await this.createTelegramConnection(binding.bindingId)); return
309
+ }
273
310
  if (request.method === 'GET' && path === '/v1/runs') {
274
- const all = (await this.runs.list()).filter(run => (run.application ?? run.delivery)?.bindingId === binding.bindingId)
311
+ const owned = await this.runs.list()
312
+ const all = owned.filter(run => (run.application ?? run.delivery)?.bindingId === binding.bindingId)
313
+ const currentOwner = binding.shareTelegram ? telegramOwner((await new ControlStore(this.options.controlDir, 900000).status()).owner) : null
314
+ const telegram = currentOwner
315
+ ? owned.filter(run => run.application === undefined && run.delivery === undefined && run.taskId === undefined && run.external === undefined && run.scheduled === undefined && run.telegramUserId === currentOwner.telegramUserId && run.ownerId === ownerId(currentOwner) && run.ownerEpoch === ownerEpoch(currentOwner) && (run.telegramApplication === undefined || run.telegramApplication.bindingId === binding.bindingId) && !all.some(item => item.id === run.id))
316
+ : []
317
+ const visible = [...all, ...telegram]
275
318
  const before = url.searchParams.get('before')
276
- const end = before ? all.findIndex(run => run.id === before) : all.length
319
+ const end = before ? visible.findIndex(run => run.id === before) : visible.length
277
320
  if (end < 0) throw new Error('Invalid application inbox cursor')
278
- const records = all.slice(Math.max(0,end-100),end)
279
- send(200, {runs: await Promise.all(records.map(run => this.snapshot(binding.bindingId, run.id))), nextCursor:end>100?records[0].id:null}); return
321
+ const records = visible.slice(Math.max(0,end-100),end)
322
+ send(200, {runs: await Promise.all(records.map(run => (run.application ?? run.delivery)
323
+ ? this.snapshot(binding.bindingId, run.id)
324
+ : Promise.resolve({id: run.id, scope: 'telegram', status: run.status, texts: run.texts, telegramUserId: run.telegramUserId, createdAt: run.createdAt}))), nextCursor:end>100?records[0].id:null}); return
280
325
  }
281
326
  if (path==='/v1/scope-control' && ['GET','POST'].includes(request.method ?? '')) {
282
327
  const scope = url.searchParams.get('scope')
@@ -314,6 +359,32 @@ export class ApplicationChannel {
314
359
  if(!await new Tasks(this.options.controlDir).decide(approvalMatch[1]))throw new Error('Unknown application approval')
315
360
  send(200,{id:approvalMatch[1],decision:input.decision});return
316
361
  }
362
+ const attachMatch = path.match(/^\/v1\/runs\/([A-Za-z0-9_-]{1,160})\/application$/)
363
+ if (attachMatch && request.method === 'POST') {
364
+ if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram admission' }); return }
365
+ const chunks: Buffer[] = []; let size = 0
366
+ for await (const chunk of request) { size += chunk.length; if (size > 65536) throw new Error('Invalid application admission size'); chunks.push(chunk) }
367
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { context?: unknown; scope?: unknown }
368
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => key !== 'context' && key !== 'scope')) throw new Error('Invalid application admission')
369
+ const scope = input.scope === undefined ? 'telegram' : input.scope
370
+ if (typeof scope !== 'string') throw new Error('Invalid application admission')
371
+ const target = await this.runs.get(attachMatch[1])
372
+ if (target?.telegramApplication?.bindingId === binding.bindingId) {
373
+ const same = target.telegramApplication.scope === scope &&
374
+ JSON.stringify(target.telegramApplication.context ?? null) === JSON.stringify(input.context ?? null)
375
+ if (same) { send(200, { id: target.id, scope: target.telegramApplication.scope, status: target.status }); return }
376
+ throw new Error('Application admission conflicts with prior context')
377
+ }
378
+ if (!target || target.application || target.telegramApplication || target.taskId || target.external || target.scheduled || target.telegramUserId === undefined) throw new Error('Unknown application run')
379
+ const state = await new ControlStore(this.options.controlDir, 900000).status()
380
+ const linked = telegramOwner(state.owner)
381
+ if (!linked || target.telegramUserId !== linked.telegramUserId || !sameOwner(binding.owner, state.owner)) throw new Error('Application authority does not permit Telegram admission')
382
+ if (target.status !== 'queued' && target.status !== 'running') throw new Error('Application admission conflicts with finished run')
383
+ const application = { bindingId: binding.bindingId, scope, requestId: target.id, context: input.context as Record<string, unknown> }
384
+ if (!validApplicationOrigin(application)) throw new Error('Invalid application admission')
385
+ const admitted = await this.runs.attachTelegramApplication(target.id, application)
386
+ send(200, { id: admitted.id, scope, status: admitted.status }); return
387
+ }
317
388
  const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
318
389
  if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
319
390
  await this.snapshot(binding.bindingId, match[1])
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from 'node:util'
2
2
  import { readFile } from 'node:fs/promises'
3
- import { loadControlConfig } from './config.js'
3
+ import { loadControlConfig, telegramToken } from './config.js'
4
4
  import { ControlStore, sameOwner, ownerId } from './control-state.js'
5
5
  import { ApplicationBindings, applicationScope, validateApplicationRegistration } from './application-channel.js'
6
6
  import { initialPreset } from './ai.js'
@@ -17,7 +17,7 @@ async function main() {
17
17
  const config = loadControlConfig(), control = new ControlStore(config.controlDir, config.pairingTtlMs)
18
18
  if (values.owner && values['owner-id']) throw new Error('Choose one owner registration form')
19
19
  if (values['owner-id'] && (!values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner-id requires channel registration')
20
- if (values.owner && (process.env.EZ_TELEGRAM_ENABLED !== 'false' || !values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner bootstrap requires explicit application-only registration')
20
+ if (values.owner && (telegramToken(process.env) || !values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner bootstrap requires an agent without a Telegram bot')
21
21
  const registrationToken = values['token-file'] ? (await readFile(values['token-file'], 'utf8')).trim() : null
22
22
  if (values.owner || values['owner-id']) validateApplicationRegistration(values.id!, registrationToken)
23
23
  const owner = values['owner-id'] ? await control.registerOwner(values['owner-id']) : values.owner ? await control.bootstrapApplicationOwner(Number(values.owner)) : (await control.status()).owner
@@ -1,6 +1,5 @@
1
1
  import { loadControlConfig } from './config.js'
2
- import { ApprovalStore } from './approval.js'
3
- import { RunStore } from './runs.js'
2
+ import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
4
3
 
5
4
  const args = process.argv.slice(2).filter((arg) => arg !== '--')
6
5
  if (args.includes('--help') || args.includes('-h')) {
@@ -27,10 +26,12 @@ for (let i = 0; i < args.length; i++) {
27
26
  }
28
27
 
29
28
  const config = loadControlConfig()
30
- const approvalStore = new ApprovalStore(config.controlDir)
29
+ const socketPath = socketPathFor(config.controlDir)
31
30
 
32
31
  if (checkId) {
33
- const record = await approvalStore.getDecision(checkId)
32
+ const record = await callDeliverySocket(socketPath, { op: 'approvalCheck', payload: { actionId: checkId } }) as {
33
+ actionId: string; decision: string; decidedAt?: string; decidedBy?: number
34
+ }
34
35
  console.log(JSON.stringify({
35
36
  ok: true,
36
37
  actionId: checkId,
@@ -53,7 +54,13 @@ if (!runId) {
53
54
  process.exit(1)
54
55
  }
55
56
 
56
- const runStore = new RunStore(config.controlDir)
57
- await approvalStore.requestApproval(actionId, prompt, runId)
58
- const item = await runStore.enqueueApproval(runId, prompt, actionId, { replyToMessageId: replyTo })
57
+ // The relay records the approval request and delivers its card; the decision
58
+ // arrives later through Telegram and is read back with --check.
59
+ const item = await callDeliverySocket(socketPath, {
60
+ op: 'enqueue',
61
+ payload: {
62
+ kind: 'approval', runId, approvalPrompt: prompt, approvalActionId: actionId,
63
+ ...(replyTo !== undefined ? { replyToMessageId: replyTo } : {}),
64
+ },
65
+ }) as { outbox_id: string; id: string }
59
66
  console.log(JSON.stringify({ ok: true, run: runId, actionId, outbox_id: item.id, status: 'requested' }))
package/src/approval.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
- import path from 'node:path'
3
1
  import { assertId } from './identity.js'
4
2
  import { ownerEpoch, ownerId, type Owner } from './control-state.js'
5
3
 
@@ -34,28 +32,32 @@ const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
34
32
  )
35
33
  }
36
34
 
37
- export class ApprovalStore {
38
- private readonly approvalsDir: string
35
+ // Stateless pipe: approvals live in relay memory. Engine children request
36
+ // decisions through the delivery socket (folded into the approval send);
37
+ // callbacks and task arbitration record them in-relay. A restart drops
38
+ // pending approvals by design.
39
+ const approvalsByControl = new Map<string, Map<string, ApprovalRecord>>()
39
40
 
40
- constructor(controlDir: string) {
41
- this.approvalsDir = path.join(controlDir, 'approvals')
42
- }
41
+ const approvalsFor = (controlDir: string): Map<string, ApprovalRecord> => {
42
+ let map = approvalsByControl.get(controlDir)
43
+ if (!map) { map = new Map(); approvalsByControl.set(controlDir, map) }
44
+ return map
45
+ }
43
46
 
44
- private async ensure(): Promise<void> {
45
- await mkdir(this.approvalsDir, { recursive: true, mode: 0o700 })
46
- }
47
+ export class ApprovalStore {
48
+ constructor(private readonly controlDir: string) {}
47
49
 
48
- private filePath(actionId: string): string {
50
+ private key(actionId: string): string {
49
51
  const safe = assertId(actionId)
50
52
  if (safe.length > 40) throw new Error('Approval identifier must be at most 40 characters')
51
- return path.join(this.approvalsDir, `${safe}.json`)
53
+ return safe
52
54
  }
53
55
 
54
56
  async requestApproval(actionId: string, prompt: string, runId?: string): Promise<ApprovalRecord> {
55
57
  if (!prompt.trim()) throw new Error('Approval prompt is empty')
56
58
  if (runId) assertId(runId)
57
- await this.ensure()
58
- const existing = await this.getDecision(actionId)
59
+ const store = approvalsFor(this.controlDir)
60
+ const existing = store.get(this.key(actionId)) ?? null
59
61
  if (existing) {
60
62
  if (existing.runId !== runId || existing.prompt !== prompt)
61
63
  throw new Error('Approval ID already belongs to another request')
@@ -70,10 +72,7 @@ export class ApprovalStore {
70
72
  decision: 'pending',
71
73
  createdAt: new Date().toISOString(),
72
74
  }
73
- const file = this.filePath(actionId)
74
- const temporary = `${file}.${process.pid}.tmp`
75
- await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
76
- await rename(temporary, file)
75
+ store.set(this.key(actionId), record)
77
76
  return record
78
77
  }
79
78
 
@@ -83,8 +82,8 @@ export class ApprovalStore {
83
82
  decidedBy: number,
84
83
  decisionUpdateId?: number,
85
84
  ): Promise<ApprovalRecord> {
86
- await this.ensure()
87
- const existing = await this.getDecision(actionId)
85
+ const store = approvalsFor(this.controlDir)
86
+ const existing = store.get(this.key(actionId)) ?? null
88
87
  if (!existing) throw new Error('Unknown approval request')
89
88
  if (
90
89
  decisionUpdateId !== undefined &&
@@ -102,34 +101,25 @@ export class ApprovalStore {
102
101
  decidedBy,
103
102
  decisionUpdateId,
104
103
  }
105
- const file = this.filePath(actionId)
106
- const temporary = `${file}.${process.pid}.tmp`
107
- await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
108
- await rename(temporary, file)
104
+ store.set(this.key(actionId), record)
109
105
  return record
110
106
  }
111
107
 
112
108
  async recordOwnerDecision(actionId: string, decision: 'approved' | 'denied', owner: Owner): Promise<ApprovalRecord> {
113
- await this.ensure()
114
- const existing = await this.getDecision(actionId)
109
+ const store = approvalsFor(this.controlDir)
110
+ const existing = store.get(this.key(actionId)) ?? null
115
111
  if (!existing) throw new Error('Unknown approval request')
116
112
  if (existing.decision !== 'pending') throw new Error('Approval already decided')
117
113
  if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
118
114
  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)
115
+ store.set(this.key(actionId), record)
121
116
  return record
122
117
  }
123
118
 
124
119
  async getDecision(actionId: string): Promise<ApprovalRecord | null> {
125
- try {
126
- const parsed: unknown = JSON.parse(await readFile(this.filePath(actionId), 'utf8'))
127
- if (!isApprovalRecord(parsed) || parsed.actionId !== actionId)
128
- throw new Error('Invalid approval record shape')
129
- return parsed
130
- } catch (error: unknown) {
131
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
132
- throw error
133
- }
120
+ const record = approvalsFor(this.controlDir).get(this.key(actionId)) ?? null
121
+ if (record && (!isApprovalRecord(record) || record.actionId !== actionId))
122
+ throw new Error('Invalid approval record shape')
123
+ return record
134
124
  }
135
125
  }
@@ -1,7 +1,6 @@
1
- import { readFile } from 'node:fs/promises'
2
1
  import type { Config } from './config.js'
3
2
  import type { RunRecord } from './runs.js'
4
- import { workspaceFile } from './files.js'
3
+ import { readStagedAttachment } from './files.js'
5
4
 
6
5
  // Application-owned jobs; no CLI state, provider credentials or business routing here.
7
6
  export async function dispatchChannel(config: Config, run: RunRecord): Promise<string | null> {
@@ -14,7 +13,7 @@ export async function dispatchChannel(config: Config, run: RunRecord): Promise<s
14
13
  for (const item of run.items ?? []) {
15
14
  let attachment
16
15
  if (item.attachment) {
17
- const bytes = await readFile(await workspaceFile(config.workspace, item.attachment.path))
16
+ const bytes = await readStagedAttachment([config.controlDir, config.workspace], item.attachment.path)
18
17
  if (bytes.length > 12 * 1024 * 1024) throw new Error('Channel attachment exceeds 12 MB')
19
18
  attachment = { type: item.attachment.type, data: bytes.toString('base64') }
20
19
  }
@@ -3,9 +3,9 @@ import { spawn, type ChildProcess } from 'node:child_process'
3
3
  import { createInterface } from 'node:readline'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import path from 'node:path'
6
- import { terminateJob } from './executor.js'
6
+ import { terminateJob, validateCodexProvider, type CodexProviderBinding } from './executor.js'
7
7
 
8
- type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?: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';codexProvider?:CodexProviderBinding}
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;
@@ -13,6 +13,7 @@ type Message = {id?:number;method?:string;params?:any;result?:any;error?:{messag
13
13
  export async function runCodexSession(options:Options, io:{launch?:()=>ChildProcess;emit?:(line:string)=>void}={}):Promise<number> {
14
14
  options = executionDefaults('codex', options)
15
15
  if (options.codexSandbox !== undefined && options.codexSandbox !== 'external') throw new Error('Invalid Codex sandbox selection')
16
+ const provider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
16
17
  const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
17
18
  const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
18
19
  let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
@@ -73,7 +74,8 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
73
74
  const result=await request('thread/start',{
74
75
  cwd:options.workspace,approvalPolicy:'never',sandbox:options.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write',model:options.model,
75
76
  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
- 'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
77
+ 'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{}),
78
+ ...(provider?{model_provider:provider.id,[`model_providers.${provider.id}`]:{name:provider.name,base_url:provider.baseUrl,env_key:provider.envKey,wire_api:'responses',supports_websockets:false}}:{})},
77
79
  })
78
80
  threadId=result.thread?.id
79
81
  if(!threadId)throw new Error('Codex did not return a native thread ID')
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseWebLauncher, type WebLauncher } from './web-launcher.js'
2
2
  import { repairEnabled } from './repair-policy.js'
3
+ import { isolationTransport, resolveIsolation, type IsolationClass } from './isolation.js'
3
4
  import path from 'node:path'
4
5
  import { homedir } from 'node:os'
5
6
 
@@ -15,6 +16,7 @@ export type Config = ControlConfig & {
15
16
  telegramBotToken: string
16
17
  workspace: string
17
18
  executorTimeoutMs: number
19
+ isolation?: IsolationClass
18
20
  codexSandbox?: 'external'
19
21
  codexAutoCompactTokens?: number
20
22
  executorCli: string
@@ -24,10 +26,6 @@ export type Config = ControlConfig & {
24
26
  applicationHost?: string
25
27
  geminiApiKey?: string
26
28
  openaiApiKey?: string
27
- pagerDutyRoutingKey?: string
28
- pagerDutyStocksHealthUrl?: string
29
- pagerDutyPollMs?: number
30
- pagerDutyFailureThreshold?: number
31
29
  }
32
30
 
33
31
  const positiveInteger = (value: string | undefined, name: string, fallback?: number): number => {
@@ -47,29 +45,28 @@ export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): Control
47
45
  }
48
46
  }
49
47
 
48
+ export const telegramToken = (env: NodeJS.ProcessEnv = process.env): string => {
49
+ const token = env.TELEGRAM_BOT_TOKEN?.trim() || ''
50
+ if (token && !/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) throw new Error('TELEGRAM_BOT_TOKEN is malformed; refusing to start Telegram transport')
51
+ return token
52
+ }
53
+
50
54
  export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
51
- if (env.EZ_TELEGRAM_ENABLED !== undefined && !['true', 'false'].includes(env.EZ_TELEGRAM_ENABLED)) throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
52
- const telegramEnabled = env.EZ_TELEGRAM_ENABLED !== 'false'
53
- const telegramBotToken = telegramEnabled ? env.TELEGRAM_BOT_TOKEN?.trim() || '' : ''
55
+ const legacy = env.EZ_TELEGRAM_ENABLED
56
+ if (legacy !== undefined && legacy !== 'true' && legacy !== 'false') throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
57
+ const telegramBotToken = telegramToken(env)
58
+ const telegramEnabled = Boolean(telegramBotToken)
59
+ if (legacy !== undefined && (legacy === 'false') === telegramEnabled)
60
+ throw new Error('EZ_TELEGRAM_ENABLED was removed; it contradicts the inferred Telegram transport (a token means enabled). Unset EZ_TELEGRAM_ENABLED, and remove any stale TELEGRAM_BOT_TOKEN to stay application-only.')
54
61
  if (!telegramEnabled && !env.EZ_APPLICATION_PORT) throw new Error('Application-only execution requires EZ_APPLICATION_PORT')
55
- if (telegramEnabled && !telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
56
62
 
57
63
  if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
58
64
  if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
65
+ const isolation = resolveIsolation(env)
66
+ const transport = env.EZ_EXECUTOR_TRANSPORT?.trim() || isolationTransport(isolation)
59
67
  const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
60
68
  if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
61
- if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || env.EZ_EXECUTOR_TRANSPORT !== 'local')) throw new Error('External Codex sandbox requires native local execution')
62
- const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
63
- const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
64
- if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
65
- throw new Error('PAGERDUTY_ROUTING_KEY is required when EZ_PAGERDUTY_STOCKS_HEALTH_URL is set')
66
- if (pagerDutyStocksHealthUrl) {
67
- let url: URL
68
- try { url = new URL(pagerDutyStocksHealthUrl) }
69
- catch { throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL') }
70
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash)
71
- throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL without credentials or a fragment')
72
- }
69
+ if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
73
70
  return {
74
71
  ...loadControlConfig(env),
75
72
  telegramEnabled,
@@ -78,6 +75,7 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
78
75
  repairEnabled: repairEnabled(env.EZ_REPAIR_ENABLED),
79
76
  workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
80
77
  executorTimeoutMs: 0,
78
+ isolation,
81
79
  codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
82
80
  codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
83
81
  executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
@@ -87,13 +85,5 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
87
85
  channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
88
86
  geminiApiKey: env.GEMINI_API_KEY?.trim(),
89
87
  openaiApiKey: env.OPENAI_API_KEY?.trim(),
90
- pagerDutyRoutingKey,
91
- pagerDutyStocksHealthUrl,
92
- pagerDutyPollMs: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
93
- ? positiveInteger(env.EZ_PAGERDUTY_POLL_SECONDS, 'EZ_PAGERDUTY_POLL_SECONDS', 30) * 1_000
94
- : undefined,
95
- pagerDutyFailureThreshold: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
96
- ? positiveInteger(env.EZ_PAGERDUTY_FAILURE_THRESHOLD, 'EZ_PAGERDUTY_FAILURE_THRESHOLD', 3)
97
- : undefined,
98
88
  }
99
89
  }