@jc_stack/ez-agents 0.1.0-beta.27 → 0.1.0-beta.29

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 (75) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +25 -1
  3. package/CHANGELOG.md +29 -0
  4. package/CONTRIBUTING.md +28 -0
  5. package/Dockerfile +1 -0
  6. package/README.md +79 -8
  7. package/bin/ezenciel-agents-application +2 -0
  8. package/bin/ezenciel-agents-application.mjs +16 -0
  9. package/compose.yaml +8 -0
  10. package/docker/entrypoint.sh +20 -2
  11. package/docker/healthcheck.mjs +1 -1
  12. package/docker/run.ts +3 -3
  13. package/docker/smoke.mjs +41 -2
  14. package/docs/application-channel.md +366 -0
  15. package/docs/docker-runtime.md +20 -0
  16. package/docs/managed-applications.md +68 -0
  17. package/docs/plugin-catalog.md +1 -0
  18. package/docs/plugin-connection.md +76 -0
  19. package/docs/plugins.md +40 -4
  20. package/docs/upgrades.md +11 -1
  21. package/package.json +7 -2
  22. package/src/application-channel.ts +308 -0
  23. package/src/application-cli.ts +41 -0
  24. package/src/application-client.mjs +87 -0
  25. package/src/application-origin.ts +15 -0
  26. package/src/codex-session.ts +5 -3
  27. package/src/config.ts +20 -2
  28. package/src/control-state.ts +256 -15
  29. package/src/conversation-menu.ts +89 -0
  30. package/src/delivery-context.d.mts +5 -0
  31. package/src/delivery-context.mjs +25 -0
  32. package/src/event-sources.ts +2 -2
  33. package/src/execution-authority.ts +2 -0
  34. package/src/executor.ts +10 -4
  35. package/src/host-executor.ts +7 -1
  36. package/src/identity.ts +11 -3
  37. package/src/index.ts +149 -54
  38. package/src/menu.ts +26 -9
  39. package/src/message-history.ts +52 -0
  40. package/src/message.ts +48 -7
  41. package/src/owner.ts +7 -1
  42. package/src/plugins/connection-artifacts.mjs +31 -0
  43. package/src/plugins/connection.mjs +124 -0
  44. package/src/plugins/manager.mjs +63 -18
  45. package/src/plugins/native-tasks.d.mts +4 -0
  46. package/src/plugins/native-tasks.mjs +66 -0
  47. package/src/plugins/workspace-lease.d.mts +3 -0
  48. package/src/plugins/workspace-lease.mjs +44 -0
  49. package/src/runs.ts +67 -9
  50. package/src/schedule-cli.ts +11 -6
  51. package/src/scheduler.ts +17 -7
  52. package/src/updates/control.mjs +4 -0
  53. package/src/web-launcher.ts +19 -0
  54. package/templates/agent-guidance.md +58 -2
  55. package/templates/deployments.md +24 -0
  56. package/test/application-channel.test.ts +283 -0
  57. package/test/application-client.test.mjs +84 -0
  58. package/test/application-controls.test.ts +224 -0
  59. package/test/application-only.test.ts +100 -0
  60. package/test/channel-delivery.test.ts +63 -0
  61. package/test/channel-owner.test.ts +161 -0
  62. package/test/codex-session.test.ts +8 -5
  63. package/test/config.test.ts +15 -0
  64. package/test/connection-artifacts.test.mjs +32 -0
  65. package/test/conversation-menu.test.ts +67 -0
  66. package/test/conversations.test.ts +84 -0
  67. package/test/executor.test.ts +56 -0
  68. package/test/host-executor.test.ts +28 -0
  69. package/test/intake-relay.test.ts +126 -5
  70. package/test/message-history.test.ts +127 -0
  71. package/test/native-tasks.test.ts +36 -0
  72. package/test/plugin-connection.test.mjs +124 -0
  73. package/test/plugin-manager.test.mjs +34 -0
  74. package/test/runtime-identity.test.mjs +18 -0
  75. package/test/updates.test.mjs +39 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.27",
3
+ "version": "0.1.0-beta.29",
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.",
@@ -21,7 +21,8 @@
21
21
  "ezenciel-agents-host": "bin/ezenciel-agents-host",
22
22
  "ezenciel-agents-tools": "bin/ezenciel-agents-tools.mjs",
23
23
  "ezenciel-agents-ai": "bin/ezenciel-agents-ai.mjs",
24
- "ezenciel-agents-watch": "bin/ezenciel-agents-watch.mjs"
24
+ "ezenciel-agents-watch": "bin/ezenciel-agents-watch.mjs",
25
+ "ezenciel-agents-application": "bin/ezenciel-agents-application.mjs"
25
26
  },
26
27
  "files": [
27
28
  "default-plugins.json",
@@ -111,5 +112,9 @@
111
112
  "kind": "main",
112
113
  "stateSchema": 1,
113
114
  "mainProtocol": 1
115
+ },
116
+ "exports": {
117
+ "./application-client": "./src/application-client.mjs",
118
+ "./*": "./*"
114
119
  }
115
120
  }
@@ -0,0 +1,308 @@
1
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
2
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
3
+ import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { ControlStore, sessionTitle, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
6
+ import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
7
+ import { ownsRun } from './identity.js'
8
+ import { applicationId, validApplicationOrigin } from './application-origin.js'
9
+ import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
10
+ import { assertEffort } from './model-policy.js'
11
+
12
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex')
13
+ export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
14
+ type Binding = { id: string; bindingId: string; tokenHash: string; owner: Owner; shareTelegram?: boolean }
15
+
16
+ export function validateApplicationRegistration(id: string, token: string | null): void {
17
+ if (!applicationId(id) || (token !== null && !/^[A-Za-z0-9_-]{43,200}$/.test(token))) throw new Error('Use a simple application ID and a random token of at least 256 bits encoded as base64url')
18
+ }
19
+
20
+ export class ApplicationBindings {
21
+ constructor(private controlDir: string) {}
22
+ async list(): Promise<Binding[]> {
23
+ try {
24
+ const bindings = JSON.parse(await readFile(join(this.controlDir, 'application-bindings.json'), 'utf8'))
25
+ if (!Array.isArray(bindings) || bindings.some(binding => !applicationId(binding?.id) || !/^[a-f0-9-]{36}$/.test(binding.bindingId) || !/^[a-f0-9]{64}$/.test(binding.tokenHash) || !validOwner(binding.owner) || (binding.shareTelegram !== undefined && typeof binding.shareTelegram !== 'boolean')) || ['tokenHash','id','bindingId'].some(key => new Set(bindings.map(b => b[key])).size !== bindings.length)) throw new Error('Invalid application binding state')
26
+ return bindings
27
+ } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
28
+ }
29
+ async register(id: string, token: string | null, owner: Owner, shareTelegram = false, rotate = false): Promise<Binding | undefined> {
30
+ validateApplicationRegistration(id, token)
31
+ await mkdir(this.controlDir, { recursive: true, mode: 0o700 })
32
+ const file = join(this.controlDir, 'application-bindings.json')
33
+ const lock = await open(`${file}.lock`, 'wx', 0o600)
34
+ try {
35
+ const bindings = await this.list()
36
+ const existing = bindings.find(binding => binding.id === id)
37
+ if (!sameOwner(owner, (await new ControlStore(this.controlDir, 900000).status()).owner)) throw new Error('Application authority revoked')
38
+ if (rotate && (!existing || !token || !sameOwner(existing.owner, owner))) throw new Error('Rotation requires a current binding and new token')
39
+ if (token && bindings.some(item => item.id !== id && item.tokenHash === hash(token))) throw new Error('Application credential already registered')
40
+ if (token !== null && existing && !rotate) throw new Error('Application already registered; rotate its token or revoke before replacing its authority')
41
+ const binding = token === null ? undefined : rotate ? {...existing!, tokenHash: hash(token)} : { id, bindingId: randomUUID(), tokenHash: hash(token), owner, ...(shareTelegram ? { shareTelegram: true } : {}) }
42
+ const next = [...bindings.filter(item => item.id !== id), ...(binding ? [binding] : [])]
43
+ const temporary = `${file}.${randomUUID()}.tmp`
44
+ await writeFile(temporary, JSON.stringify(next), { mode: 0o600 })
45
+ await rename(temporary, file)
46
+ return binding
47
+ } finally { await lock.close(); await unlink(`${file}.lock`) }
48
+ }
49
+ async authenticate(token: string): Promise<Binding> {
50
+ if (!/^[A-Za-z0-9_-]{43,200}$/.test(token)) throw new Error('Unauthorized application')
51
+ const fingerprint = Buffer.from(hash(token), 'hex')
52
+ const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
53
+ const binding = (await this.list()).find(item => timingSafeEqual(fingerprint, Buffer.from(item.tokenHash, 'hex')) && sameOwner(item.owner, owner))
54
+ if (!binding) throw new Error('Unauthorized application')
55
+ return binding
56
+ }
57
+ async authorize(run: RunRecord): Promise<void> {
58
+ const origin = run.application ?? run.delivery
59
+ if (!origin || !validApplicationOrigin({...origin, requestId: run.application?.requestId ?? run.id})) throw new Error('Invalid application origin')
60
+ const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
61
+ const binding = (await this.list()).find(item => item.bindingId === origin.bindingId)
62
+ if (!binding || !sameOwner(binding.owner, owner) || !ownsRun(owner, run)) throw new Error('Application authority revoked')
63
+ }
64
+ }
65
+
66
+ export class ApplicationChannel {
67
+ private server?: Server
68
+ private admissions = Promise.resolve()
69
+ readonly bindings: ApplicationBindings
70
+ constructor(private options: {
71
+ controlDir: string; initial: AiPreset
72
+ wake: () => void
73
+ cancel: (id: string) => Promise<void>
74
+ aiControls?: {
75
+ catalog: () => Promise<ModelChoice[]>
76
+ select: (preset: AiPreset, expectedSession: string | null, guard?: ControlGuard) => Promise<unknown>
77
+ validate: (preset: AiPreset) => Promise<void>
78
+ saveSelection: (model: ModelChoice, effort?: string, guard?: ControlGuard) => Promise<AiPreset>
79
+ }
80
+ }) { this.bindings = new ApplicationBindings(options.controlDir) }
81
+ private get runs() { return new RunStore(this.options.controlDir) }
82
+ private async sharedBinding(bindingId: string, shared = true) {
83
+ const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
84
+ const state = await new ControlStore(this.options.controlDir, 900000).status()
85
+ if (!binding || (shared && !binding.shareTelegram) || !sameOwner(binding.owner, state.owner)) throw new Error('Application authority does not permit controls')
86
+ return binding.owner
87
+ }
88
+ async controls(bindingId: string) {
89
+ await this.sharedBinding(bindingId)
90
+ if (!this.options.aiControls) throw new Error('Application controls unavailable')
91
+ const models = await this.options.aiControls.catalog()
92
+ await this.sharedBinding(bindingId)
93
+ const control = new ControlStore(this.options.controlDir, 900000)
94
+ const state = await control.status()
95
+ const ai = state.ai ?? {presets:[this.options.initial],defaultId:this.options.initial.id,selectedId:this.options.initial.id,recentIds:[]}
96
+ const sessions = (await control.listSessions()).filter(item => !item.applicationScope || item.telegramShared)
97
+ await this.sharedBinding(bindingId)
98
+ return { ai, models, activeSessionId: state.activeSession?.sessionId ?? null,
99
+ sessions: sessions.map(item => ({id:item.sessionId, title:sessionTitle(item), cli:item.cli, archived:!!item.archived})) }
100
+ }
101
+ async changeControls(bindingId: string, input: unknown) {
102
+ const owner = await this.sharedBinding(bindingId)
103
+ const controls = this.options.aiControls
104
+ if (!controls) throw new Error('Application controls unavailable')
105
+ const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; model?: unknown; effort?: unknown}
106
+ if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
107
+ !(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
108
+ const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
109
+ if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
110
+ const control = new ControlStore(this.options.controlDir, 900000)
111
+ const expected = value.expectedSession as string | null
112
+ const guard: ControlGuard = {owner, expectedSession:expected, authorize:async()=>{
113
+ // Runs inside the control lock. Binding storage has its own independent
114
+ // lock; do not call sharedBinding here because it also reads ControlStore.
115
+ const live = (await this.bindings.list()).find(item=>item.bindingId===bindingId)
116
+ if (!live?.shareTelegram || !sameOwner(owner, live.owner)) throw new Error('Application authority revoked')
117
+ }}
118
+ if (value.action === 'new') await control.resetSession(expected, guard)
119
+ if (value.action === 'switch') {
120
+ if (typeof value.sessionId !== 'string') throw new Error('Invalid application conversation')
121
+ // switchSession enforces the same visibility/engine restrictions as /chats.
122
+ await control.switchSession(value.sessionId, expected, guard)
123
+ }
124
+ if (value.action === 'select') {
125
+ const preset = (await control.status()).ai?.presets.find(item => item.id === value.presetId)
126
+ if (!preset) throw new Error('Invalid application preset')
127
+ await this.sharedBinding(bindingId)
128
+ await controls.select(preset, expected, guard)
129
+ }
130
+ if (value.action === 'model') {
131
+ const model = (await controls.catalog()).find(item => item.cli === value.cli && item.model === value.model)
132
+ if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
133
+ await this.sharedBinding(bindingId)
134
+ const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
135
+ await this.sharedBinding(bindingId)
136
+ await controls.select(preset, expected, guard)
137
+ }
138
+ return this.controls(bindingId)
139
+ }
140
+ async scopeControls(bindingId: string, scope: string) {
141
+ if (!applicationId(scope)) throw new Error('Invalid application scope')
142
+ await this.sharedBinding(bindingId, false)
143
+ if (!this.options.aiControls) throw new Error('Application controls unavailable')
144
+ const models = await this.options.aiControls.catalog()
145
+ const control = new ControlStore(this.options.controlDir, 900000)
146
+ const session = await control.applicationSession(applicationScope(bindingId, scope))
147
+ if (session?.telegramShared) throw new Error('Application scope is shared; use shared controls')
148
+ const state = await control.status()
149
+ const ai = state.ai ?? {presets:[this.options.initial],defaultId:this.options.initial.id,selectedId:this.options.initial.id}
150
+ await this.sharedBinding(bindingId, false)
151
+ return {ai:{...ai, selectedId:session?.preset?.id ?? ai.selectedId,
152
+ presets:session?.preset ? [...ai.presets.filter(item=>item.id!==session.preset!.id),session.preset] : ai.presets},
153
+ models, activeSessionId:session?.sessionId ?? null, sessions:session ? [{id:session.sessionId,title:sessionTitle(session),cli:session.cli,archived:false}] : []}
154
+ }
155
+ async changeScopeControls(bindingId: string, scope: string, input: unknown) {
156
+ if (!applicationId(scope)) throw new Error('Invalid application scope')
157
+ const owner = await this.sharedBinding(bindingId, false)
158
+ const controls = this.options.aiControls
159
+ if (!controls) throw new Error('Application controls unavailable')
160
+ const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; model?:unknown; effort?:unknown}
161
+ if (!value || !['new','select','model'].includes(String(value.action)) ||
162
+ !(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
163
+ const fields = {new:[],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'select'|'model']!
164
+ if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
165
+ const hashedScope = applicationScope(bindingId,scope)
166
+ const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
167
+ authorize:async()=>{
168
+ const binding = (await this.bindings.list()).find(item=>item.bindingId===bindingId)
169
+ if (!binding || !sameOwner(binding.owner,owner)) throw new Error('Application authority revoked')
170
+ }}
171
+ const control = new ControlStore(this.options.controlDir,900000)
172
+ let preset:AiPreset|undefined
173
+ if (value.action==='select') {
174
+ const session = await control.applicationSession(hashedScope)
175
+ preset = session?.preset && session.preset.id===value.presetId ? session.preset : (await control.status()).ai?.presets.find(item=>item.id===value.presetId)
176
+ if (!preset) throw new Error('Invalid application preset')
177
+ await controls.validate(preset)
178
+ }
179
+ if (value.action==='model') {
180
+ const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.model===value.model)
181
+ if (!model || model.cli==='agy' || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
182
+ preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
183
+ }
184
+ await control.changeApplicationSession(hashedScope,guard,preset)
185
+ return this.scopeControls(bindingId,scope)
186
+ }
187
+ async submit(bindingId: string, input: unknown): Promise<RunRecord> {
188
+ const work = this.admissions.then(async () => {
189
+ // Channel-neutral name for the existing shared owner conversation option.
190
+ if (input && typeof input === 'object' && 'followOwner' in input) {
191
+ const {followOwner, ...rest} = input as Record<string, unknown>
192
+ if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
193
+ input = {...rest, followTelegram: followOwner}
194
+ }
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')
197
+ 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
+ if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
199
+ if (value.followTelegram !== undefined && typeof value.followTelegram !== 'boolean') throw new Error('Invalid application Telegram following')
200
+ if (value.followTelegram && (value.activateTelegram || value.ai !== undefined || value.expectedNativeSessionId !== undefined)) throw new Error('Invalid application request: following Telegram uses its current conversation and AI')
201
+ let requestedPreset: AiPreset | undefined
202
+ if (value.ai !== undefined) {
203
+ const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
204
+ if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
205
+ assertEffort(candidate.effort, candidate.model, candidate.cli)
206
+ requestedPreset = candidate
207
+ }
208
+ const application = { bindingId, requestId: value.requestId, scope: value.scope, ...(value.followTelegram ? { followTelegram: true } : {}), ...(value.context === undefined ? {} : { context: value.context }) }
209
+ if (!validApplicationOrigin(application)) throw new Error('Invalid application context')
210
+ const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
211
+ if (!binding) throw new Error('Application authority revoked')
212
+ if (value.followTelegram && !binding.shareTelegram) throw new Error('Application authority does not permit following Telegram')
213
+ const id = `r_app_${hash(JSON.stringify([bindingId, value.requestId]))}`
214
+ const existing = await this.runs.get(id)
215
+ if (existing) {
216
+ await this.bindings.authorize(existing)
217
+ 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')
219
+ return existing // Retried context never replaces already admitted capabilities.
220
+ }
221
+ const control = new ControlStore(this.options.controlDir, 900000)
222
+ if (!sameOwner(binding.owner, (await control.status()).owner)) throw new Error('Application authority revoked')
223
+ 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 })
225
+ this.options.wake()
226
+ return run
227
+ })
228
+ this.admissions = work.then(() => {}, () => {})
229
+ return work
230
+ }
231
+ async snapshot(bindingId: string, id: string) {
232
+ const run = await this.runs.get(id)
233
+ if (!run || (run.application ?? run.delivery)?.bindingId !== bindingId) throw new Error('Unknown application run')
234
+ await this.bindings.authorize(run)
235
+ const state = await new ControlStore(this.options.controlDir, 900000).status()
236
+ 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' } : {}) }
238
+ }
239
+ async deliver(run: RunRecord, item: OutboxItem): Promise<void> {
240
+ await this.bindings.authorize(run)
241
+ if (item.type && item.type !== 'message') throw new Error('Application channel supports text messages only')
242
+ await this.runs.markOutboxSent(item.id)
243
+ }
244
+ private async route(request: IncomingMessage, response: ServerResponse) {
245
+ const send = (status: number, body: unknown) => { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); response.end(JSON.stringify(body)) }
246
+ let binding: Binding
247
+ try { binding = await this.bindings.authenticate(request.headers.authorization?.match(/^Bearer (.+)$/)?.[1] ?? '') }
248
+ catch { send(401, { error: 'Unauthorized application' }); return }
249
+ let admissionId: string | undefined
250
+ try {
251
+ const url = new URL(request.url ?? '/', 'http://localhost'), path = url.pathname
252
+ if (request.method === 'GET' && path === '/v1/registration') {
253
+ send(200, {ownerId: ownerId(binding.owner), bindingId: binding.bindingId, channel: binding.id}); return
254
+ }
255
+ if (request.method === 'GET' && path === '/v1/runs') {
256
+ const all = (await this.runs.list()).filter(run => (run.application ?? run.delivery)?.bindingId === binding.bindingId)
257
+ const before = url.searchParams.get('before')
258
+ const end = before ? all.findIndex(run => run.id === before) : all.length
259
+ if (end < 0) throw new Error('Invalid application inbox cursor')
260
+ const records = all.slice(Math.max(0,end-100),end)
261
+ send(200, {runs: await Promise.all(records.map(run => this.snapshot(binding.bindingId, run.id))), nextCursor:end>100?records[0].id:null}); return
262
+ }
263
+ if (path==='/v1/scope-control' && ['GET','POST'].includes(request.method ?? '')) {
264
+ const scope = url.searchParams.get('scope')
265
+ if (!applicationId(scope) || [...url.searchParams.keys()].length!==1) throw new Error('Invalid application scope')
266
+ if (request.method==='GET') {send(200,await this.scopeControls(binding.bindingId,scope!));return}
267
+ const chunks:Buffer[]=[];let size=0
268
+ for await (const chunk of request) {size+=chunk.length;if(size>65536)throw new Error('Invalid application control size');chunks.push(chunk)}
269
+ send(200,await this.changeScopeControls(binding.bindingId,scope!,JSON.parse(Buffer.concat(chunks).toString('utf8'))));return
270
+ }
271
+ if (path === '/v1/control' && ['GET','POST'].includes(request.method ?? '')) {
272
+ if (!binding.shareTelegram) { send(403, {error:'Application authority does not permit shared controls'}); return }
273
+ if (request.method === 'GET') { send(200, await this.controls(binding.bindingId)); return }
274
+ const chunks: Buffer[] = []; let size = 0
275
+ for await (const chunk of request) { size += chunk.length; if (size > 65536) throw new Error('Invalid application control size'); chunks.push(chunk) }
276
+ send(200, await this.changeControls(binding.bindingId, JSON.parse(Buffer.concat(chunks).toString('utf8')))); return
277
+ }
278
+ if (request.method === 'POST' && path === '/v1/runs') {
279
+ 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) }
281
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
282
+ if (applicationId(input?.requestId)) admissionId = `r_app_${hash(JSON.stringify([binding.bindingId, input.requestId]))}`
283
+ const run = await this.submit(binding.bindingId, input)
284
+ send(202, await this.snapshot(binding.bindingId, run.id)); return
285
+ }
286
+ const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
287
+ if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
288
+ await this.snapshot(binding.bindingId, match[1])
289
+ if (match[2]) await this.options.cancel(match[1])
290
+ send(200, await this.snapshot(binding.bindingId, match[1])); return
291
+ }
292
+ send(404, { error: 'Unknown application endpoint' })
293
+ } catch (error) {
294
+ const message = error instanceof Error ? error.message : 'Invalid application request'
295
+ let admission: { admitted: boolean; runId?: string } | undefined
296
+ if (admissionId) {
297
+ try { const existing = await this.runs.get(admissionId); admission = { admitted: !!existing, ...(existing ? { runId: existing.id } : {}) } } catch { /* Unreadable state is not proof of non-admission. */ }
298
+ }
299
+ send(message.includes('conflicts') ? 409 : message === 'Unknown application run' ? 404 : 400, { ...admission, error: /^(Invalid application|Application request|Application authority|Unknown application)/.test(message) ? message : 'Invalid application request' })
300
+ }
301
+ }
302
+ async listen(port: number, host = '127.0.0.1') {
303
+ this.server = createServer((request, response) => { void this.route(request, response) })
304
+ await new Promise<void>((resolve, reject) => { this.server!.once('error', reject); this.server!.listen(port, host, resolve) })
305
+ return this.server.address()
306
+ }
307
+ async stop() { await new Promise<void>((resolve, reject) => this.server ? this.server.close(error => error ? reject(error) : resolve()) : resolve()) }
308
+ }
@@ -0,0 +1,41 @@
1
+ import { parseArgs } from 'node:util'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { loadControlConfig } from './config.js'
4
+ import { ControlStore, sameOwner, ownerId } from './control-state.js'
5
+ import { ApplicationBindings, applicationScope, validateApplicationRegistration } from './application-channel.js'
6
+ import { initialPreset } from './ai.js'
7
+ import { applicationId } from './application-origin.js'
8
+
9
+ async function main() {
10
+ const { values } = parseArgs({ options: {
11
+ owner: {type:'string'}, id: {type:'string'}, 'token-file': {type:'string'}, revoke: {type:'boolean'}, list: {type:'boolean'}, help: {type:'boolean'},
12
+ 'owner-id': {type:'string'}, rotate: {type:'boolean'}, 'share-owner': {type:'boolean'}, 'share-active': {type:'boolean'},
13
+ 'import-scope': {type:'string'}, 'native-session': {type:'string'}, cli: {type:'string'}, 'share-telegram': {type:'boolean'},
14
+ } })
15
+ if (values.help) { console.log('ezenciel-agents-application --id CHANNEL --token-file PRIVATE_FILE [--owner-id VERIFIED_OWNER_ID] [--share-owner] [--rotate] | --id CHANNEL --revoke | --list | --id CHANNEL --import-scope SCOPE --native-session ID --cli CLI'); return }
16
+ if (process.env.EZ_RUN_ID) throw new Error('Application authority is configured by the installing administrator outside agent turns')
17
+ const config = loadControlConfig(), control = new ControlStore(config.controlDir, config.pairingTtlMs)
18
+ if (values.owner && values['owner-id']) throw new Error('Choose one owner registration form')
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')
21
+ const registrationToken = values['token-file'] ? (await readFile(values['token-file'], 'utf8')).trim() : null
22
+ if (values.owner || values['owner-id']) validateApplicationRegistration(values.id!, registrationToken)
23
+ const owner = values['owner-id'] ? await control.registerOwner(values['owner-id']) : values.owner ? await control.bootstrapApplicationOwner(Number(values.owner)) : (await control.status()).owner
24
+ if (!owner) throw new Error('Register an owner with --owner-id or pair a Telegram owner')
25
+ const bindings = new ApplicationBindings(config.controlDir)
26
+ if (values.list) { console.log(JSON.stringify((await bindings.list()).map(({id,bindingId}) => ({id,bindingId})))); return }
27
+ if (!values.id || !applicationId(values.id)) throw new Error('Application ID required')
28
+ if (values['import-scope']) {
29
+ const binding = (await bindings.list()).find(item => item.id === values.id)
30
+ if (!binding || !sameOwner(binding.owner, owner) || !applicationId(values['import-scope']) || !values['native-session'] || !/^[a-zA-Z0-9_-]{1,160}$/.test(values['native-session']) || !values.cli) throw new Error('Current application binding, scope, native session and CLI required')
31
+ if (values['share-owner'] && !binding.shareTelegram) throw new Error('This channel is not registered for shared owner chat')
32
+ const choice = await control.captureApplicationChoice(initialPreset(values.cli), applicationScope(binding.bindingId, values['import-scope']), values['share-owner'] === true)
33
+ if (choice.preset.cli !== values.cli) throw new Error('Application scope already uses a different engine')
34
+ await control.saveNativeSession(choice.sessionId, values['native-session'])
35
+ console.log(JSON.stringify({ok:true,scope:values['import-scope'],sessionId:choice.sessionId})); return
36
+ }
37
+ if (!!values['token-file'] === !!values.revoke) throw new Error('Provide --token-file or --revoke')
38
+ const binding = await bindings.register(values.id, values.revoke ? null : registrationToken, owner, values['share-owner'] || values['share-active'] || values['share-telegram'], values.rotate)
39
+ console.log(JSON.stringify({ok:true,ownerId:ownerId(owner),id:values.id,bindingId:binding?.bindingId,revoked:Boolean(values.revoke)}))
40
+ }
41
+ main().catch(error => { console.error(error.message); process.exitCode = 1 })
@@ -0,0 +1,87 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { isAbsolute } from 'node:path'
3
+ import { setTimeout as delay } from 'node:timers/promises'
4
+
5
+ // Backend-only: identity must already have been resolved by the application.
6
+ // Each entry names a separately isolated ordinary Ez deployment, not a session.
7
+ export async function applicationBinding(file, principalId) {
8
+ if (typeof principalId !== 'string' || !principalId) throw new Error('Application principal is required')
9
+ const registry = JSON.parse(await readFile(file, 'utf8'))
10
+ if (registry?.version !== 1 || !Array.isArray(registry.bindings)) throw new Error('Invalid application binding registry')
11
+ const principals = new Set(), endpoints = new Set()
12
+ for (const entry of registry.bindings) {
13
+ if (typeof entry.principalId !== 'string' || !entry.principalId || principals.has(entry.principalId) ||
14
+ typeof entry.tokenFile !== 'string' || !isAbsolute(entry.tokenFile) ||
15
+ (entry.revoked !== undefined && typeof entry.revoked !== 'boolean')) throw new Error('Invalid application binding registry')
16
+ const endpoint = applicationEndpoint(entry.url).origin
17
+ if (endpoints.has(endpoint)) throw new Error('Independent principals require separate Ez endpoints')
18
+ principals.add(entry.principalId); endpoints.add(endpoint)
19
+ }
20
+ const entry = registry.bindings.find(item => item.principalId === principalId && !item.revoked)
21
+ if (!entry) throw new Error('No authorized Ez binding for this principal')
22
+ const token = (await readFile(entry.tokenFile, 'utf8')).trim()
23
+ if (!token) throw new Error('Ez application credential is missing')
24
+ return { url: entry.url, token }
25
+ }
26
+
27
+ function applicationEndpoint(value) {
28
+ const endpoint = new URL(value)
29
+ if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password ||
30
+ endpoint.pathname !== '/' || endpoint.search || endpoint.hash) throw new Error('Invalid Ez application endpoint')
31
+ return endpoint
32
+ }
33
+
34
+ export async function applicationCall(path, body, { url, token, fetchImpl = fetch, signal } = {}) {
35
+ if (!token) throw new Error('Ez application credential is missing')
36
+ const scopeParams = path.startsWith('/v1/scope-control?') ? new URLSearchParams(path.slice('/v1/scope-control?'.length)) : null
37
+ const scoped = scopeParams && [...scopeParams.keys()].length===1 && /^[a-zA-Z0-9_:.\-]{1,200}$/.test(scopeParams.get('scope') ?? '')
38
+ if (!scoped && !/^\/v1\/(?:registration|control|runs(?:\?before=r_(?:app|schedule)_[a-f0-9]{64}|\/r_(?:app|schedule)_[a-f0-9]{64}(?:\/cancel)?)?)$/.test(path)) throw new Error('Invalid Ez application operation')
39
+ let response
40
+ const retrySafe = body === undefined || (!scoped && path !== '/v1/control')
41
+ try {
42
+ response = await fetchImpl(new URL(path, applicationEndpoint(url)), {
43
+ method: body === undefined ? 'GET' : 'POST',
44
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
45
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
46
+ redirect: 'error', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
47
+ })
48
+ } catch (cause) {
49
+ throw Object.assign(new Error('Ez application transport unavailable', { cause }), { retryable: retrySafe && !signal?.aborted })
50
+ }
51
+ if (!response.ok) {
52
+ const body = await response.json().catch(() => null)
53
+ throw Object.assign(new Error(`Ez application returned HTTP ${response.status}`), {
54
+ retryable: retrySafe && (response.status >= 500 || [408, 429].includes(response.status)), status: response.status,
55
+ ...(typeof body?.admitted === 'boolean' ? { admitted: body.admitted } : {}),
56
+ ...(typeof body?.runId === 'string' ? { runId: body.runId } : {}),
57
+ })
58
+ }
59
+ try { return await response.json() }
60
+ catch (cause) { throw Object.assign(new Error('Ez application response unavailable or invalid', { cause }), { retryable: retrySafe }) }
61
+ }
62
+
63
+ // Reconnect with the same requestId after transport failure. This client never
64
+ // creates another job, rotates its authority, or retries a domain mutation.
65
+ export async function runApplication(input, { pollMs = 1000, onAdmitted, signal, reconnect = false, ...connection } = {}) {
66
+ const call = async (path, body) => {
67
+ for (;;) {
68
+ try { return await applicationCall(path, body, { ...connection, signal }) }
69
+ catch (error) {
70
+ if (!reconnect || !error.retryable || signal?.aborted) throw error
71
+ await delay(pollMs, undefined, { signal })
72
+ }
73
+ }
74
+ }
75
+ let run = await call('/v1/runs', input)
76
+ await onAdmitted?.(run.id)
77
+ while (run.status === 'queued' || run.status === 'running') {
78
+ await delay(pollMs, undefined, { signal })
79
+ run = await call(`/v1/runs/${encodeURIComponent(run.id)}`)
80
+ }
81
+ if (run.status !== 'completed') throw Object.assign(new Error(run.error || `Ez application run ${run.status}`), {
82
+ terminal: ['failed', 'cancelled'].includes(run.status),
83
+ })
84
+ const reply = run.messages?.filter(message => typeof message.text === 'string' && message.text.trim()).at(-1)?.text
85
+ if (!reply) throw Object.assign(new Error('Ez application completed without a reply'), { terminal: true })
86
+ return { ...run, reply }
87
+ }
@@ -0,0 +1,15 @@
1
+ export type ApplicationOrigin = {
2
+ bindingId: string
3
+ scope: string
4
+ requestId: string
5
+ followTelegram?: boolean
6
+ context?: Record<string, unknown>
7
+ }
8
+ export const applicationId = (value: unknown): value is string =>
9
+ typeof value === 'string' && /^[a-zA-Z0-9_:.\-]{1,200}$/.test(value)
10
+ export const validApplicationOrigin = (value: unknown): value is ApplicationOrigin => {
11
+ const origin = value as ApplicationOrigin | undefined
12
+ return !!origin && /^[a-f0-9-]{36}$/.test(origin.bindingId) && applicationId(origin.scope) && applicationId(origin.requestId) &&
13
+ (origin.followTelegram === undefined || typeof origin.followTelegram === 'boolean') &&
14
+ (origin.context === undefined || (!!origin.context && typeof origin.context === 'object' && !Array.isArray(origin.context) && JSON.stringify(origin.context).length <= 48000))
15
+ }
@@ -5,13 +5,14 @@ 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}
8
+ type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?: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;
12
12
  // this transport never generates a continuation prompt or an Ez goal record.
13
13
  export async function runCodexSession(options:Options, io:{launch?:()=>ChildProcess;emit?:(line:string)=>void}={}):Promise<number> {
14
14
  options = executionDefaults('codex', options)
15
+ if (options.codexSandbox !== undefined && options.codexSandbox !== 'external') throw new Error('Invalid Codex sandbox selection')
15
16
  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']})
16
17
  const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
17
18
  let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
@@ -70,14 +71,15 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
70
71
  await request('initialize',{clientInfo:{name:'ezenciel-agents',version:'1'},capabilities:{experimentalApi:true}})
71
72
  send({method:'initialized',params:{}})
72
73
  const result=await request('thread/start',{
73
- cwd:options.workspace,approvalPolicy:'never',sandbox:'workspace-write',model:options.model,
74
+ cwd:options.workspace,approvalPolicy:'never',sandbox:options.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write',model:options.model,
74
75
  config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[])],
75
76
  'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
76
77
  })
77
78
  threadId=result.thread?.id
78
79
  if(!threadId)throw new Error('Codex did not return a native thread ID')
79
80
  emit(JSON.stringify({type:'thread.started',thread_id:threadId}))
80
- await request('turn/start',{threadId,input:[{type:'text',text:options.prompt}],model:options.model,effort:options.effort})
81
+ await request('turn/start',{threadId,input:[{type:'text',text:options.prompt}],model:options.model,effort:options.effort,
82
+ ...(options.codexSandbox === 'external' ? {sandboxPolicy:{type:'externalSandbox',networkAccess:'enabled'}} : {})})
81
83
  return await done
82
84
  }catch(error){fail(error);return 1}
83
85
  finally{
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseWebLauncher, type WebLauncher } from './web-launcher.js'
1
2
  import { repairEnabled } from './repair-policy.js'
2
3
  import path from 'node:path'
3
4
  import { homedir } from 'node:os'
@@ -9,13 +10,18 @@ export type ControlConfig = {
9
10
 
10
11
  export type Config = ControlConfig & {
11
12
  repairEnabled?: boolean
13
+ telegramEnabled?: boolean
14
+ webLauncher?: WebLauncher
12
15
  telegramBotToken: string
13
16
  workspace: string
14
17
  executorTimeoutMs: number
18
+ codexSandbox?: 'external'
15
19
  codexAutoCompactTokens?: number
16
20
  executorCli: string
17
21
  channelBackendUrl?: string
18
22
  channelBackendToken?: string
23
+ applicationPort?: number
24
+ applicationHost?: string
19
25
  geminiApiKey?: string
20
26
  openaiApiKey?: string
21
27
  pagerDutyRoutingKey?: string
@@ -42,10 +48,17 @@ export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): Control
42
48
  }
43
49
 
44
50
  export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
45
- const telegramBotToken = env.TELEGRAM_BOT_TOKEN?.trim()
46
- if (!telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
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() || '' : ''
54
+ 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')
47
56
 
48
57
  if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
58
+ if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
59
+ const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
60
+ 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')
49
62
  const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
50
63
  const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
51
64
  if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
@@ -59,12 +72,17 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
59
72
  }
60
73
  return {
61
74
  ...loadControlConfig(env),
75
+ telegramEnabled,
76
+ webLauncher: parseWebLauncher(env.EZ_TELEGRAM_WEB_APP),
62
77
  telegramBotToken,
63
78
  repairEnabled: repairEnabled(env.EZ_REPAIR_ENABLED),
64
79
  workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
65
80
  executorTimeoutMs: 0,
81
+ codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
66
82
  codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
67
83
  executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
84
+ applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
85
+ applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
68
86
  channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
69
87
  channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
70
88
  geminiApiKey: env.GEMINI_API_KEY?.trim(),