@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +30 -0
- package/Dockerfile +5 -1
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +100 -0
- package/docker/run.ts +1 -1
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +23 -4
- package/docs/docker-runtime.md +57 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +141 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +33 -4
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +98 -15
- package/src/application-channel.ts +52 -12
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/auth-profile.ts +53 -0
- package/src/client-defaults.ts +3 -2
- package/src/codex-session.ts +14 -3
- package/src/config.ts +9 -0
- package/src/control-state.ts +31 -22
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +26 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +81 -7
- package/src/files.ts +47 -3
- package/src/host-executor.ts +68 -34
- package/src/index.ts +227 -110
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +9 -7
- package/src/plugin-broker.mjs +50 -14
- package/src/plugins/manager.mjs +34 -8
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +24 -2
- package/src/schedule-cli.ts +100 -13
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +85 -19
- package/src/scripts.ts +167 -0
- package/src/setup-fallback.ts +122 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +13 -9
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +107 -30
- package/src/updates/supervisor.mjs +27 -6
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/auth-profiles.test.ts +187 -0
- package/test/config.test.ts +8 -0
- package/test/executor.test.ts +37 -0
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +45 -2
- package/test/install-json.test.mjs +21 -0
- package/test/plugin-broker.test.mjs +67 -0
- package/test/plugin-manager.test.mjs +44 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduler-host.test.ts +7 -0
- package/test/scheduler-relay.test.ts +38 -3
- package/test/scheduler.test.ts +1 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +100 -11
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
2
2
|
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
3
|
-
import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'
|
|
3
|
+
import { mkdir, readFile, writeFile, rename, open, unlink, stat, chown } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
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'
|
|
9
|
-
import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
|
|
9
|
+
import { isPreset, sameChoice, sameEngine, type AiPreset, type ModelChoice } from './ai.js'
|
|
10
10
|
import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
|
|
11
11
|
import { assertEffort } from './model-policy.js'
|
|
12
12
|
import { ApprovalStore } from './approval.js'
|
|
@@ -16,7 +16,11 @@ import { SpeechCreditsDepletedError } from './audio.js'
|
|
|
16
16
|
|
|
17
17
|
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
18
18
|
export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
|
|
19
|
-
type
|
|
19
|
+
export type TaskLaunch = { command: string; args: string[]; tasks: "all" | string[] }
|
|
20
|
+
export const validTaskLaunch = (v: unknown): v is TaskLaunch => {
|
|
21
|
+
const x=v as TaskLaunch; return !!x && Object.keys(x).every(k=>["command","args","tasks"].includes(k)) && /^[a-zA-Z0-9_-]{1,100}$/.test(x.command) && Array.isArray(x.args) && x.args.length>0 && x.args.length<=8 && (x.tasks==="all" || (Array.isArray(x.tasks) && x.tasks.length>0 && x.tasks.length<=128 && x.tasks.every(t=>/^task_[a-f0-9]{32}$/.test(t)))) && x.args.every(a=>typeof a==="string" && a.length<=200 && !/[\x00-\x1f\x7f]/.test(a))
|
|
22
|
+
}
|
|
23
|
+
export type Binding = { id: string; bindingId: string; tokenHash: string; owner: Owner; shareTelegram?: boolean; taskLaunch?: TaskLaunch }
|
|
20
24
|
|
|
21
25
|
export function validateApplicationRegistration(id: string, token: string | null): void {
|
|
22
26
|
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')
|
|
@@ -27,7 +31,7 @@ export class ApplicationBindings {
|
|
|
27
31
|
async list(): Promise<Binding[]> {
|
|
28
32
|
try {
|
|
29
33
|
const bindings = JSON.parse(await readFile(join(this.controlDir, 'application-bindings.json'), 'utf8'))
|
|
30
|
-
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')
|
|
34
|
+
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.taskLaunch !== undefined && !validTaskLaunch(binding.taskLaunch)) || (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')
|
|
31
35
|
return bindings
|
|
32
36
|
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
|
|
33
37
|
}
|
|
@@ -51,6 +55,23 @@ export class ApplicationBindings {
|
|
|
51
55
|
return binding
|
|
52
56
|
} finally { await lock.close(); await unlink(`${file}.lock`) }
|
|
53
57
|
}
|
|
58
|
+
async taskLaunch(id: string, launch: TaskLaunch | null): Promise<void> {
|
|
59
|
+
if (launch !== null && !validTaskLaunch(launch)) throw Error('Invalid task launch command')
|
|
60
|
+
const file=join(this.controlDir,'application-bindings.json'), lock=await open(`${file}.lock`,'wx',0o600)
|
|
61
|
+
try {
|
|
62
|
+
const bindings=await this.list(), binding=bindings.find(b=>b.id===id)
|
|
63
|
+
const owner=(await new ControlStore(this.controlDir,900000).status()).owner
|
|
64
|
+
if(!binding || !sameOwner(binding.owner,owner))throw Error('Application authority revoked')
|
|
65
|
+
if(launch)binding.taskLaunch=launch; else delete binding.taskLaunch
|
|
66
|
+
const previous=await stat(file),tmp=`${file}.${randomUUID()}.tmp`
|
|
67
|
+
try {
|
|
68
|
+
await writeFile(tmp,JSON.stringify(bindings),{mode:0o600,flag:'wx'})
|
|
69
|
+
const created=await stat(tmp)
|
|
70
|
+
if(created.uid!==previous.uid || created.gid!==previous.gid)await chown(tmp,previous.uid,previous.gid)
|
|
71
|
+
await rename(tmp,file)
|
|
72
|
+
}finally{await unlink(tmp).catch(error=>{if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error})}
|
|
73
|
+
}finally{await lock.close();await unlink(`${file}.lock`)}
|
|
74
|
+
}
|
|
54
75
|
async authenticate(token: string): Promise<Binding> {
|
|
55
76
|
if (!/^[A-Za-z0-9_-]{43,200}$/.test(token)) throw new Error('Unauthorized application')
|
|
56
77
|
const fingerprint = Buffer.from(hash(token), 'hex')
|
|
@@ -65,6 +86,7 @@ export class ApplicationBindings {
|
|
|
65
86
|
const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
|
|
66
87
|
const binding = (await this.list()).find(item => item.bindingId === origin.bindingId)
|
|
67
88
|
if (!binding || !sameOwner(binding.owner, owner) || !ownsRun(owner, run)) throw new Error('Application authority revoked')
|
|
89
|
+
if(run.application?.taskAccess)await new Tasks(this.controlDir).applicationAccess(run.application.taskAccess,binding.bindingId)
|
|
68
90
|
}
|
|
69
91
|
}
|
|
70
92
|
|
|
@@ -136,7 +158,7 @@ export class ApplicationChannel {
|
|
|
136
158
|
const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
|
|
137
159
|
if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
|
|
138
160
|
!(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
|
|
139
|
-
const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
|
|
161
|
+
const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
|
|
140
162
|
if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
|
|
141
163
|
const control = new ControlStore(this.options.controlDir, 900000)
|
|
142
164
|
const expected = value.expectedSession as string | null
|
|
@@ -159,7 +181,7 @@ export class ApplicationChannel {
|
|
|
159
181
|
await controls.select(preset, expected, guard)
|
|
160
182
|
}
|
|
161
183
|
if (value.action === 'model') {
|
|
162
|
-
const model = (await controls.catalog()).find(item => item
|
|
184
|
+
const model = (await controls.catalog()).find(item => sameEngine(item, value as {cli?:string;authProfile?:string}) && item.provider === value.provider && item.model === value.model)
|
|
163
185
|
if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
|
|
164
186
|
await this.sharedBinding(bindingId)
|
|
165
187
|
const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
|
|
@@ -189,10 +211,10 @@ export class ApplicationChannel {
|
|
|
189
211
|
const owner = await this.sharedBinding(bindingId, false)
|
|
190
212
|
const controls = this.options.aiControls
|
|
191
213
|
if (!controls) throw new Error('Application controls unavailable')
|
|
192
|
-
const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; model?:unknown; effort?:unknown}
|
|
214
|
+
const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; authProfile?:unknown; model?:unknown; effort?:unknown}
|
|
193
215
|
if (!value || !['new','select','model'].includes(String(value.action)) ||
|
|
194
216
|
!(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
|
|
195
|
-
const fields = {new:[],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'select'|'model']!
|
|
217
|
+
const fields = {new:[],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'select'|'model']!
|
|
196
218
|
if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
|
|
197
219
|
const hashedScope = applicationScope(bindingId,scope)
|
|
198
220
|
const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
|
|
@@ -209,7 +231,7 @@ export class ApplicationChannel {
|
|
|
209
231
|
await controls.validate(preset)
|
|
210
232
|
}
|
|
211
233
|
if (value.action==='model') {
|
|
212
|
-
const model = (await controls.catalog()).find(item=>item
|
|
234
|
+
const model = (await controls.catalog()).find(item=>sameEngine(item,value as {cli?:string;authProfile?:string}) && item.provider===value.provider && item.model===value.model)
|
|
213
235
|
if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
|
|
214
236
|
preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
|
|
215
237
|
}
|
|
@@ -224,7 +246,18 @@ export class ApplicationChannel {
|
|
|
224
246
|
if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
|
|
225
247
|
input = {...rest, followTelegram: followOwner}
|
|
226
248
|
}
|
|
227
|
-
|
|
249
|
+
if(input && typeof input==='object' && 'taskToken' in input) {
|
|
250
|
+
const v=input as Record<string,unknown>
|
|
251
|
+
if(Object.keys(v).some(k=>!['requestId','scope','text','taskToken'].includes(k)) || !applicationId(v.requestId) || typeof v.text!=='string' || !v.text.trim() || v.text.length>16000 || typeof v.taskToken!=='string' || !/^[a-f0-9]{64}$/.test(v.taskToken))throw Error('Invalid application task request')
|
|
252
|
+
const ticketHash=hash(v.taskToken), access=await new Tasks(this.options.controlDir).applicationAccess(ticketHash,bindingId)
|
|
253
|
+
const scope='task:'+access.task.id
|
|
254
|
+
if(v.scope!==scope)throw Error('Invalid application task scope')
|
|
255
|
+
const id=`r_app_${hash(JSON.stringify([bindingId,v.requestId]))}`, existing=await this.runs.get(id)
|
|
256
|
+
if(existing){await this.bindings.authorize(existing);if(existing.application?.taskAccess!==ticketHash || existing.texts[0]!==v.text || existing.taskId!==access.task.id)throw Error('Application request ID conflicts with prior task');return existing}
|
|
257
|
+
const run=await this.runs.create({id,ownerId:ownerId(access.task.owner),ownerEpoch:ownerEpoch(access.task.owner),taskId:access.task.id,texts:[v.text],execution:access.execution,application:{bindingId,scope,requestId:v.requestId,taskAccess:ticketHash}})
|
|
258
|
+
this.options.wake();return run
|
|
259
|
+
}
|
|
260
|
+
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; authProfile?: unknown; model?: unknown; effort?: unknown } }
|
|
228
261
|
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')
|
|
229
262
|
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')
|
|
230
263
|
if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
|
|
@@ -233,7 +266,7 @@ export class ApplicationChannel {
|
|
|
233
266
|
let requestedPreset: AiPreset | undefined
|
|
234
267
|
if (value.ai !== undefined) {
|
|
235
268
|
const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
|
|
236
|
-
if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
|
|
269
|
+
if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'authProfile', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
|
|
237
270
|
assertEffort(candidate.effort, candidate.model, candidate.cli)
|
|
238
271
|
requestedPreset = candidate
|
|
239
272
|
}
|
|
@@ -256,7 +289,7 @@ export class ApplicationChannel {
|
|
|
256
289
|
const existing = await this.runs.get(id)
|
|
257
290
|
if (existing) {
|
|
258
291
|
await this.bindings.authorize(existing)
|
|
259
|
-
if (requestedPreset && (existing.execution
|
|
292
|
+
if (requestedPreset && (!existing.execution || !sameChoice(existing.execution.preset, requestedPreset))) throw new Error('Application request ID conflicts with prior AI selection')
|
|
260
293
|
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')
|
|
261
294
|
return existing // Retried context never replaces already admitted capabilities.
|
|
262
295
|
}
|
|
@@ -299,6 +332,13 @@ export class ApplicationChannel {
|
|
|
299
332
|
if (request.method === 'GET' && path === '/v1/registration') {
|
|
300
333
|
send(200, {ownerId: ownerId(binding.owner), bindingId: binding.bindingId, channel: binding.id}); return
|
|
301
334
|
}
|
|
335
|
+
if(request.method==='POST' && path==='/v1/registration') {
|
|
336
|
+
let raw='';for await(const chunk of request){raw+=chunk;if(Buffer.byteLength(raw)>1024)throw Error('Invalid application task request')}
|
|
337
|
+
const v=JSON.parse(raw)
|
|
338
|
+
if(Object.keys(v).length!==1 || typeof v.taskToken!=='string' || !/^[a-f0-9]{64}$/.test(v.taskToken))throw Error('Invalid application task request')
|
|
339
|
+
const access=await new Tasks(this.options.controlDir).applicationAccess(hash(v.taskToken),binding.bindingId)
|
|
340
|
+
send(200,{taskId:access.task.id,expiresAt:access.expiresAt,purpose:access.task.purpose});return
|
|
341
|
+
}
|
|
302
342
|
if (path === '/v1/telegram' && request.method === 'GET') {
|
|
303
343
|
if ([...url.searchParams.keys()].length) throw new Error('Invalid application Telegram request')
|
|
304
344
|
if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram connection' }); return }
|
package/src/application-cli.ts
CHANGED
|
@@ -8,11 +8,12 @@ import { applicationId } from './application-origin.js'
|
|
|
8
8
|
|
|
9
9
|
async function main() {
|
|
10
10
|
const { values } = parseArgs({ options: {
|
|
11
|
+
'task-launch-command': {type:'string'}, 'task-launch-args':{type:'string'}, 'task-launch-tasks':{type:'string'}, 'disable-task-launch':{type:'boolean'},
|
|
11
12
|
owner: {type:'string'}, id: {type:'string'}, 'token-file': {type:'string'}, revoke: {type:'boolean'}, list: {type:'boolean'}, help: {type:'boolean'},
|
|
12
13
|
'owner-id': {type:'string'}, rotate: {type:'boolean'}, 'share-owner': {type:'boolean'}, 'share-active': {type:'boolean'},
|
|
13
14
|
'import-scope': {type:'string'}, 'native-session': {type:'string'}, cli: {type:'string'}, 'share-telegram': {type:'boolean'},
|
|
14
15
|
} })
|
|
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 (values.help) { console.log('ezenciel-agents-application --id CHANNEL --token-file PRIVATE_FILE [--owner-id VERIFIED_OWNER_ID] [--share-owner] [--rotate] | --id CHANNEL --task-launch-command COMMAND --task-launch-args JSON --task-launch-tasks JSON | --id CHANNEL --disable-task-launch | --id CHANNEL --revoke | --list | --id CHANNEL --import-scope SCOPE --native-session ID --cli CLI'); return }
|
|
16
17
|
if (process.env.EZ_RUN_ID) throw new Error('Application authority is configured by the installing administrator outside agent turns')
|
|
17
18
|
const config = loadControlConfig(), control = new ControlStore(config.controlDir, config.pairingTtlMs)
|
|
18
19
|
if (values.owner && values['owner-id']) throw new Error('Choose one owner registration form')
|
|
@@ -25,6 +26,11 @@ async function main() {
|
|
|
25
26
|
const bindings = new ApplicationBindings(config.controlDir)
|
|
26
27
|
if (values.list) { console.log(JSON.stringify((await bindings.list()).map(({id,bindingId}) => ({id,bindingId})))); return }
|
|
27
28
|
if (!values.id || !applicationId(values.id)) throw new Error('Application ID required')
|
|
29
|
+
if(values['task-launch-command'] || values['disable-task-launch']) {
|
|
30
|
+
if(values['token-file'] || values.revoke || values.rotate || values['import-scope'] || (values['task-launch-command'] && values['disable-task-launch']))throw Error('Task launch settings cannot be combined with registration')
|
|
31
|
+
await bindings.taskLaunch(values.id,values['disable-task-launch'] ? null : {command:values['task-launch-command']!,args:JSON.parse(values['task-launch-args'] ?? '[]'),tasks:JSON.parse(values['task-launch-tasks'] ?? 'null')})
|
|
32
|
+
console.log(JSON.stringify({ok:true,id:values.id,taskLaunch:!values['disable-task-launch']}));return
|
|
33
|
+
}
|
|
28
34
|
if (values['import-scope']) {
|
|
29
35
|
const binding = (await bindings.list()).find(item => item.id === values.id)
|
|
30
36
|
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')
|
|
@@ -2,6 +2,7 @@ export type ApplicationOrigin = {
|
|
|
2
2
|
bindingId: string
|
|
3
3
|
scope: string
|
|
4
4
|
requestId: string
|
|
5
|
+
taskAccess?: string
|
|
5
6
|
followTelegram?: boolean
|
|
6
7
|
inputText?: string
|
|
7
8
|
attachmentHash?: string
|
|
@@ -12,6 +13,7 @@ export const applicationId = (value: unknown): value is string =>
|
|
|
12
13
|
export const validApplicationOrigin = (value: unknown): value is ApplicationOrigin => {
|
|
13
14
|
const origin = value as ApplicationOrigin | undefined
|
|
14
15
|
return !!origin && /^[a-f0-9-]{36}$/.test(origin.bindingId) && applicationId(origin.scope) && applicationId(origin.requestId) &&
|
|
16
|
+
(origin.taskAccess === undefined || /^[a-f0-9]{64}$/.test(origin.taskAccess)) &&
|
|
15
17
|
(origin.followTelegram === undefined || typeof origin.followTelegram === 'boolean') &&
|
|
16
18
|
(origin.inputText === undefined || (typeof origin.inputText === 'string' && origin.inputText.length <= 16000)) &&
|
|
17
19
|
(origin.attachmentHash === undefined || /^[a-f0-9]{64}$/.test(origin.attachmentHash)) &&
|
package/src/audio.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type AudioOptions = {
|
|
|
6
6
|
geminiApiKey?: string
|
|
7
7
|
openaiApiKey?: string
|
|
8
8
|
voice?: string
|
|
9
|
+
speechProvider?: 'gemini' | 'openrouter'
|
|
10
|
+
speechModel?: string
|
|
11
|
+
openrouterApiKey?: string
|
|
12
|
+
signal?: AbortSignal
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
export class SpeechCreditsDepletedError extends Error {
|
|
@@ -32,27 +36,12 @@ export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer
|
|
|
32
36
|
return Buffer.concat([header, pcm])
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
const convertSpeech = (audio: Buffer, input: 's16le' | 'mp3', format: 'wav' | 'ogg', sampleRate = 24000, channels = 1): Promise<Buffer> => {
|
|
36
40
|
return new Promise((resolve, reject) => {
|
|
37
41
|
const invocation = executorInvocation(
|
|
38
42
|
'ffmpeg',
|
|
39
|
-
[
|
|
40
|
-
'-f',
|
|
41
|
-
's16le',
|
|
42
|
-
'-ar',
|
|
43
|
-
String(sampleRate),
|
|
44
|
-
'-ac',
|
|
45
|
-
String(channels),
|
|
46
|
-
'-i',
|
|
47
|
-
'-',
|
|
48
|
-
'-c:a',
|
|
49
|
-
'libopus',
|
|
50
|
-
'-b:a',
|
|
51
|
-
'32k',
|
|
52
|
-
'-f',
|
|
53
|
-
'ogg',
|
|
54
|
-
'-',
|
|
55
|
-
],
|
|
43
|
+
[...(input === 's16le' ? ['-f', 's16le', '-ar', String(sampleRate), '-ac', String(channels)] : ['-f', 'mp3']),
|
|
44
|
+
'-i', '-', ...(format === 'ogg' ? ['-c:a', 'libopus', '-b:a', '32k', '-f', 'ogg'] : ['-ar', '24000', '-ac', '1', '-f', 's16le']), '-'],
|
|
56
45
|
)
|
|
57
46
|
const child = spawn(invocation.command, invocation.args,
|
|
58
47
|
{ env: executorEnvironment(), stdio: ['pipe', 'pipe', 'pipe'] })
|
|
@@ -60,7 +49,8 @@ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Pr
|
|
|
60
49
|
const output: Buffer[] = []
|
|
61
50
|
let error = ''
|
|
62
51
|
|
|
63
|
-
|
|
52
|
+
let outputBytes = 0
|
|
53
|
+
child.stdout.on('data', (chunk) => { outputBytes += chunk.length; if(outputBytes > 16 * 1024 * 1024) { child.kill('SIGKILL'); reject(new Error('Encoded speech exceeds limit')) } else output.push(chunk) })
|
|
64
54
|
child.stderr.on('data', (chunk) => {
|
|
65
55
|
error = (error + chunk.toString()).slice(-2048)
|
|
66
56
|
})
|
|
@@ -70,16 +60,18 @@ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Pr
|
|
|
70
60
|
|
|
71
61
|
child.on('close', (code) => {
|
|
72
62
|
if (code === 0 && output.length > 0) {
|
|
73
|
-
resolve(Buffer.concat(output))
|
|
63
|
+
resolve(format === 'wav' ? pcmToWav(Buffer.concat(output)) : Buffer.concat(output))
|
|
74
64
|
} else {
|
|
75
65
|
reject(new Error(`Voice encoding failed (${code}): ${error}`))
|
|
76
66
|
}
|
|
77
67
|
})
|
|
78
68
|
|
|
79
|
-
child.stdin.end(
|
|
69
|
+
child.stdin.end(audio)
|
|
80
70
|
})
|
|
81
71
|
}
|
|
82
72
|
|
|
73
|
+
export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1) => convertSpeech(pcm, 's16le', 'ogg', sampleRate, channels)
|
|
74
|
+
|
|
83
75
|
export const transcribeAudio = async (
|
|
84
76
|
audioBytes: Buffer,
|
|
85
77
|
mimeType: string,
|
|
@@ -146,6 +138,29 @@ export const synthesizeSpeech = async (
|
|
|
146
138
|
const trimmed = text.trim()
|
|
147
139
|
if (!trimmed) throw new Error('Cannot synthesize speech for empty text')
|
|
148
140
|
|
|
141
|
+
if ((options.speechProvider ?? process.env.EZ_SPEECH_PROVIDER) === 'openrouter') {
|
|
142
|
+
const key = (options.openrouterApiKey ?? process.env.OPENROUTER_API_KEY)?.trim()
|
|
143
|
+
if (!key) throw new Error('OpenRouter speech is not configured')
|
|
144
|
+
const response = await fetch('https://openrouter.ai/api/v1/audio/speech', {
|
|
145
|
+
method: 'POST', signal: options.signal ?? AbortSignal.timeout(60000),
|
|
146
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
|
147
|
+
body: JSON.stringify({ model: options.speechModel ?? process.env.EZ_SPEECH_MODEL ?? 'fish-audio/s2.1-pro-free:free',
|
|
148
|
+
input: trimmed, response_format: 'mp3', ...(options.voice ? { voice: options.voice } : {}) })
|
|
149
|
+
})
|
|
150
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error(`OpenRouter speech synthesis error ${response.status}`) }
|
|
151
|
+
const chunks: Buffer[] = []; let size = 0
|
|
152
|
+
const reader = response.body?.getReader()
|
|
153
|
+
if (!reader) throw new Error('OpenRouter returned no audio')
|
|
154
|
+
try {
|
|
155
|
+
for (;;) { const {done,value} = await reader.read(); if(done) break
|
|
156
|
+
size += value.length; if(size > 16 * 1024 * 1024) throw new Error('Speech audio exceeds limit')
|
|
157
|
+
chunks.push(Buffer.from(value)) }
|
|
158
|
+
} finally { await reader.cancel() }
|
|
159
|
+
if (!size) throw new Error('OpenRouter returned no audio')
|
|
160
|
+
const buffer = await convertSpeech(Buffer.concat(chunks), 'mp3', options.format ?? 'ogg')
|
|
161
|
+
return { buffer, mimeType: options.format === 'wav' ? 'audio/wav' : 'audio/ogg' }
|
|
162
|
+
}
|
|
163
|
+
|
|
149
164
|
const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
|
|
150
165
|
if (geminiKey) {
|
|
151
166
|
const url =
|
|
@@ -154,7 +169,7 @@ export const synthesizeSpeech = async (
|
|
|
154
169
|
const response = await fetch(url, {
|
|
155
170
|
method: 'POST',
|
|
156
171
|
headers: { 'content-type': 'application/json', 'x-goog-api-key': geminiKey },
|
|
157
|
-
signal: AbortSignal.timeout(60_000),
|
|
172
|
+
signal: options.signal ?? AbortSignal.timeout(60_000),
|
|
158
173
|
body: JSON.stringify({
|
|
159
174
|
contents: [{ parts: [{ text: trimmed }] }],
|
|
160
175
|
generationConfig: {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Owner-provisioned named credential homes beside the agent default binding.
|
|
5
|
+
// Unset keeps control/cli/<cli>; a name selects control/cli/profiles/<name>/<cli>
|
|
6
|
+
// with its own credentials, configuration and native sessions. Ez never stores,
|
|
7
|
+
// copies or prints credentials; the native CLI logs in inside the home.
|
|
8
|
+
export const AUTH_PROFILE_CLIS: readonly string[] = ['codex', 'claude']
|
|
9
|
+
export const isAuthProfile = (value: unknown): value is string =>
|
|
10
|
+
typeof value === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value)
|
|
11
|
+
|
|
12
|
+
export const cliHome = (controlDir: string, cli: string, authProfile?: string): string => {
|
|
13
|
+
if (authProfile === undefined) return path.join(controlDir, 'cli', cli)
|
|
14
|
+
if (!isAuthProfile(authProfile) || !AUTH_PROFILE_CLIS.includes(cli)) throw new Error('Invalid auth profile selection')
|
|
15
|
+
return path.join(controlDir, 'cli', 'profiles', authProfile, cli)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const realDirectory = (directory: string) => lstat(directory).then(stat => stat.isDirectory(), () => false)
|
|
19
|
+
|
|
20
|
+
// A named home exists only once the owner provisioned it as a real directory.
|
|
21
|
+
// Execution never creates, links or falls back from it.
|
|
22
|
+
export const provisionedHome = async (controlDir: string, cli: string, authProfile?: string): Promise<string> => {
|
|
23
|
+
const home = cliHome(controlDir, cli, authProfile)
|
|
24
|
+
if (authProfile !== undefined && !await realDirectory(home)) throw new Error(`Auth profile ${authProfile} is not provisioned for ${cli}`)
|
|
25
|
+
return home
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const listAuthProfiles = async (controlDir?: string): Promise<{ cli: string; authProfile: string }[]> => {
|
|
29
|
+
if (!controlDir) return []
|
|
30
|
+
const names = await readdir(path.join(controlDir, 'cli', 'profiles')).catch(() => [] as string[])
|
|
31
|
+
const found: { cli: string; authProfile: string }[] = []
|
|
32
|
+
for (const authProfile of names.filter(isAuthProfile).sort())
|
|
33
|
+
for (const cli of AUTH_PROFILE_CLIS)
|
|
34
|
+
if (await realDirectory(cliHome(controlDir, cli, authProfile))) found.push({ cli, authProfile })
|
|
35
|
+
return found
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Claude credentials for one home. Host-capable default homes share the
|
|
39
|
+
// installer login in place; named homes and isolated agents use only their
|
|
40
|
+
// own store, plus an optional owner-provisioned long-lived `oauth-token`.
|
|
41
|
+
export const claudeAuthEnvironment = async (home: string, authProfile?: string, isolated = process.env.EZ_ISOLATION === 'isolated'): Promise<NodeJS.ProcessEnv> => {
|
|
42
|
+
const environment: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: home }
|
|
43
|
+
if (!isolated && authProfile === undefined) environment.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''
|
|
44
|
+
const token = await readFile(path.join(home, 'oauth-token'), 'utf8').then(value => value.trim(), (error: NodeJS.ErrnoException) => {
|
|
45
|
+
if (error.code === 'ENOENT') return undefined
|
|
46
|
+
throw error
|
|
47
|
+
})
|
|
48
|
+
if (token !== undefined) {
|
|
49
|
+
if (!/^[A-Za-z0-9._-]{20,512}$/.test(token)) throw new Error('Invalid Claude token binding')
|
|
50
|
+
environment.CLAUDE_CODE_OAUTH_TOKEN = token
|
|
51
|
+
}
|
|
52
|
+
return environment
|
|
53
|
+
}
|
package/src/client-defaults.ts
CHANGED
|
@@ -78,7 +78,7 @@ export const grokSettings = (text: string): { model?: string; effort?: string }
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
export const discoverDefaults = async (cwd: string, options: {
|
|
81
|
-
home?: string; codexHome?: string; nativeCodexFallback?: boolean; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
|
|
81
|
+
home?: string; codexHome?: string; claudeHome?: string; nativeCodexFallback?: boolean; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
|
|
82
82
|
} = {}): Promise<AiPreset[]> => {
|
|
83
83
|
const home = options.home ?? homedir()
|
|
84
84
|
const available = options.available ?? installed
|
|
@@ -99,7 +99,8 @@ export const discoverDefaults = async (cwd: string, options: {
|
|
|
99
99
|
model = value(config.model); effort = value(config.effort)
|
|
100
100
|
} else if (cli === 'claude') {
|
|
101
101
|
// Match Claude's documented user -> project -> local settings precedence.
|
|
102
|
-
|
|
102
|
+
// User settings come from the agent-bound CLAUDE_CONFIG_DIR when bound.
|
|
103
|
+
const settings = Object.assign({}, await json(join(options.claudeHome ?? join(home, '.claude'), 'settings.json')),
|
|
103
104
|
await json(join(cwd, '.claude/settings.json')), await json(join(cwd, '.claude/settings.local.json')))
|
|
104
105
|
model = value(settings.model); effort = value(settings.effortLevel)
|
|
105
106
|
} else if (cli === 'opencode') {
|
package/src/codex-session.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createInterface } from 'node:readline'
|
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { terminateJob, validateCodexProvider, type CodexProviderBinding } from './executor.js'
|
|
8
|
+
import { codexRejection, codexResetAt, startupLine } from './setup-fallback.js'
|
|
8
9
|
|
|
9
10
|
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external';codexProvider?:CodexProviderBinding}
|
|
10
11
|
type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
|
|
@@ -17,7 +18,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
17
18
|
const provider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
|
|
18
19
|
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']})
|
|
19
20
|
const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
|
|
20
|
-
let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
|
|
21
|
+
let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false,workBegan=false,resetAt:string|undefined
|
|
21
22
|
let resolveDone!:(code:number)=>void
|
|
22
23
|
const done=new Promise<number>(resolve=>{resolveDone=resolve})
|
|
23
24
|
const pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>()
|
|
@@ -38,7 +39,10 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
38
39
|
finish(0)
|
|
39
40
|
}
|
|
40
41
|
child.stderr?.pipe(process.stderr)
|
|
41
|
-
child.on('error',
|
|
42
|
+
child.on('error',error=>{
|
|
43
|
+
if((error as NodeJS.ErrnoException).code==='ENOENT')emit(startupLine({workBegan:false,rejection:{category:'cli-unavailable'}}))
|
|
44
|
+
fail(error)
|
|
45
|
+
})
|
|
42
46
|
child.stdin?.on('error',fail)
|
|
43
47
|
child.once('close',()=>{
|
|
44
48
|
for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Codex app-server closed'))}
|
|
@@ -57,16 +61,23 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
57
61
|
// Never turn an unexpected approval/elicitation request into permission.
|
|
58
62
|
send({id:message.id,error:{code:-32601,message:`Unsupported unattended request: ${message.method}`}});fail(new Error(`Codex requires attention: ${message.method}`));return
|
|
59
63
|
}
|
|
64
|
+
if(message.method==='account/rateLimits/updated')resetAt=codexResetAt(message.params?.rateLimits) ?? resetAt
|
|
60
65
|
if(message.params?.threadId!==threadId)return
|
|
61
66
|
if(message.method==='turn/started'){activeTurn=message.params.turn.id;sawTurn=true}
|
|
67
|
+
// Any item beyond the echoed input means the provider accepted the turn.
|
|
68
|
+
if(message.method==='item/started' && message.params.item?.type!=='userMessage' && !workBegan){workBegan=true;emit(startupLine({workBegan}))}
|
|
62
69
|
if(message.method==='thread/goal/updated')settled(message.params.goal)
|
|
63
70
|
if(message.method==='thread/goal/cleared')settled(null)
|
|
64
71
|
if(message.method==='turn/completed'){
|
|
65
72
|
if(activeTurn===message.params.turn.id)activeTurn=undefined
|
|
66
73
|
if(message.params.turn.status!=='completed'){
|
|
67
74
|
if(message.params.turn.status==='interrupted')finish(130)
|
|
68
|
-
else
|
|
75
|
+
else {
|
|
76
|
+
const category=workBegan ? undefined : codexRejection(message.params.turn.error?.codexErrorInfo)
|
|
77
|
+
if(category)emit(startupLine({workBegan,rejection:{category,...(category==='quota' && resetAt ? {resetAt} : {})}}))
|
|
78
|
+
fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
|
|
69
79
|
? message.params.turn.error.message : 'Native turn failed without error details')))
|
|
80
|
+
}
|
|
70
81
|
return
|
|
71
82
|
}
|
|
72
83
|
// Completion of a turn is not completion of a native goal. The native
|
package/src/config.ts
CHANGED
|
@@ -19,12 +19,16 @@ export type Config = ControlConfig & {
|
|
|
19
19
|
isolation?: IsolationClass
|
|
20
20
|
codexSandbox?: 'external'
|
|
21
21
|
codexAutoCompactTokens?: number
|
|
22
|
+
scheduledConcurrency?: number
|
|
22
23
|
executorCli: string
|
|
23
24
|
channelBackendUrl?: string
|
|
24
25
|
channelBackendToken?: string
|
|
25
26
|
applicationPort?: number
|
|
26
27
|
applicationHost?: string
|
|
27
28
|
geminiApiKey?: string
|
|
29
|
+
speechProvider?: 'gemini' | 'openrouter'
|
|
30
|
+
speechModel?: string
|
|
31
|
+
openrouterApiKey?: string
|
|
28
32
|
speechVoiceEn?: string
|
|
29
33
|
speechVoiceEs?: string
|
|
30
34
|
openaiApiKey?: string
|
|
@@ -69,6 +73,7 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
69
73
|
const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
|
|
70
74
|
if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
|
|
71
75
|
if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
|
|
76
|
+
if (env.EZ_SPEECH_PROVIDER && !['gemini','openrouter'].includes(env.EZ_SPEECH_PROVIDER)) throw new Error('Invalid EZ_SPEECH_PROVIDER')
|
|
72
77
|
return {
|
|
73
78
|
...loadControlConfig(env),
|
|
74
79
|
telegramEnabled,
|
|
@@ -80,12 +85,16 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
80
85
|
isolation,
|
|
81
86
|
codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
|
|
82
87
|
codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
|
|
88
|
+
scheduledConcurrency: positiveInteger(env.EZ_SCHEDULED_CONCURRENCY, 'EZ_SCHEDULED_CONCURRENCY', 6),
|
|
83
89
|
executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'codex',
|
|
84
90
|
applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
|
|
85
91
|
applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
|
|
86
92
|
channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
|
|
87
93
|
channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
|
|
88
94
|
geminiApiKey: env.GEMINI_API_KEY?.trim(),
|
|
95
|
+
speechProvider: env.EZ_SPEECH_PROVIDER as Config['speechProvider'],
|
|
96
|
+
speechModel: env.EZ_SPEECH_MODEL?.trim(),
|
|
97
|
+
openrouterApiKey: env.OPENROUTER_API_KEY?.trim(),
|
|
89
98
|
speechVoiceEn: env.EZ_SPEECH_VOICE_EN?.trim(),
|
|
90
99
|
speechVoiceEs: env.EZ_SPEECH_VOICE_ES?.trim(),
|
|
91
100
|
openaiApiKey: env.OPENAI_API_KEY?.trim(),
|