@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.
Files changed (80) hide show
  1. package/.env.example +3 -0
  2. package/AGENTS.md +2 -2
  3. package/CHANGELOG.md +30 -0
  4. package/Dockerfile +5 -1
  5. package/THIRD_PARTY_NOTICES.md +6 -3
  6. package/bin/ez +7 -0
  7. package/docker/broker-readiness.mjs +54 -0
  8. package/docker/broker-update-smoke.mjs +100 -0
  9. package/docker/run.ts +1 -1
  10. package/docker/upgrade-smoke.mjs +1 -1
  11. package/docs/application-channel.md +32 -1
  12. package/docs/architecture/ai-selection.md +23 -4
  13. package/docs/docker-runtime.md +57 -0
  14. package/docs/isolated-plugin-update-readiness.md +66 -0
  15. package/docs/releasing.md +4 -0
  16. package/docs/scheduling.md +141 -6
  17. package/docs/selective-monitoring.md +13 -0
  18. package/docs/upgrades.md +33 -4
  19. package/docs/work-preflight.md +9 -0
  20. package/package.json +1 -1
  21. package/src/ai-cli.ts +28 -15
  22. package/src/ai.ts +98 -15
  23. package/src/application-channel.ts +52 -12
  24. package/src/application-cli.ts +7 -1
  25. package/src/application-origin.ts +2 -0
  26. package/src/audio.ts +37 -22
  27. package/src/auth-profile.ts +53 -0
  28. package/src/client-defaults.ts +3 -2
  29. package/src/codex-session.ts +14 -3
  30. package/src/config.ts +9 -0
  31. package/src/control-state.ts +31 -22
  32. package/src/delivery-socket-client.d.mts +2 -0
  33. package/src/delivery-socket-client.mjs +2 -0
  34. package/src/delivery-socket.ts +26 -1
  35. package/src/event-sources.ts +2 -2
  36. package/src/executor.ts +81 -7
  37. package/src/files.ts +47 -3
  38. package/src/host-executor.ts +68 -34
  39. package/src/index.ts +227 -110
  40. package/src/install-tools.mjs +8 -5
  41. package/src/menu.ts +9 -7
  42. package/src/plugin-broker.mjs +50 -14
  43. package/src/plugins/manager.mjs +34 -8
  44. package/src/run-evidence.ts +78 -0
  45. package/src/runs.ts +24 -2
  46. package/src/schedule-cli.ts +100 -13
  47. package/src/schedule-preflight.ts +23 -0
  48. package/src/scheduled-tasks.ts +16 -12
  49. package/src/scheduler.ts +85 -19
  50. package/src/scripts.ts +167 -0
  51. package/src/setup-fallback.ts +122 -0
  52. package/src/setup.ts +2 -2
  53. package/src/task-capability.ts +17 -1
  54. package/src/task-executor.ts +13 -9
  55. package/src/task-mcp.ts +20 -8
  56. package/src/tasks.ts +89 -9
  57. package/src/updates/runtime.mjs +107 -30
  58. package/src/updates/supervisor.mjs +27 -6
  59. package/test/ai.test.ts +40 -0
  60. package/test/application-channel.test.ts +16 -1
  61. package/test/audio.test.ts +19 -0
  62. package/test/auth-profiles.test.ts +187 -0
  63. package/test/config.test.ts +8 -0
  64. package/test/executor.test.ts +37 -0
  65. package/test/files.test.ts +44 -0
  66. package/test/host-executor.test.ts +45 -2
  67. package/test/install-json.test.mjs +21 -0
  68. package/test/plugin-broker.test.mjs +67 -0
  69. package/test/plugin-manager.test.mjs +44 -0
  70. package/test/private-speech-config.test.ts +22 -0
  71. package/test/run-evidence.test.ts +78 -0
  72. package/test/schedule-preflight.test.ts +49 -0
  73. package/test/scheduler-host.test.ts +7 -0
  74. package/test/scheduler-relay.test.ts +38 -3
  75. package/test/scheduler.test.ts +1 -1
  76. package/test/script-schedules.test.ts +219 -0
  77. package/test/setup-fallback.test.ts +245 -0
  78. package/test/task-native.test.ts +50 -12
  79. package/test/tasks.test.ts +97 -4
  80. 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 Binding = { id: string; bindingId: string; tokenHash: string; owner: Owner; shareTelegram?: boolean }
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.cli === value.cli && item.provider === value.provider && item.model === value.model)
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.cli===value.cli && item.provider===value.provider && item.model===value.model)
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
- 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 } }
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?.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')
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 }
@@ -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
- export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Promise<Buffer> => {
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
- child.stdout.on('data', (chunk) => output.push(chunk))
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(pcm)
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
+ }
@@ -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
- const settings = Object.assign({}, await json(join(home, '.claude/settings.json')),
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') {
@@ -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',fail)
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 fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
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(),