@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.48
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 +21 -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 +86 -0
- package/docker/run.ts +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +18 -3
- package/docs/docker-runtime.md +29 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +100 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +35 -0
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai.ts +22 -3
- package/src/application-channel.ts +43 -3
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/client-defaults.ts +3 -2
- package/src/config.ts +9 -0
- package/src/control-state.ts +3 -1
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +24 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +72 -1
- package/src/files.ts +47 -3
- package/src/host-executor.ts +38 -29
- package/src/index.ts +41 -18
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +4 -2
- package/src/plugin-broker.mjs +47 -4
- package/src/plugins/manager.mjs +11 -4
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +12 -1
- package/src/schedule-cli.ts +69 -8
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +79 -18
- package/src/scripts.ts +167 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +10 -7
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +46 -7
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -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 +64 -0
- package/test/plugin-manager.test.mjs +20 -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/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +45 -9
|
@@ -178,3 +178,16 @@ After revocation/expiry no subsequent reply may dispatch. Keep these checks
|
|
|
178
178
|
agent-owned: the owner supplies only necessary confirmation, QR scan if needed,
|
|
179
179
|
and the test contact/message. Do not bypass a missing grant by polling the inbox
|
|
180
180
|
in an unrestricted scheduled owner session or sending through the raw CLI.
|
|
181
|
+
|
|
182
|
+
Task `send` accepts `voice: true` for spoken replies up to 1,000 characters on sources advertising `taskVoice`. It uses the relay’s configured speech renderer, rechecks authorization after generation, and preserves the same uncertain-send key contract. Synthesis failure happens before the provider is contacted: it returns an error, sends no text and stores no operation, so the same key may be retried. Voice and file attachments cannot be combined.
|
|
183
|
+
|
|
184
|
+
### Incoming files and images
|
|
185
|
+
|
|
186
|
+
The restricted MCP `read_attachment` tool accepts only an `incomingId` from the
|
|
187
|
+
current run's correspondence. The channel supplies private bounded bytes through
|
|
188
|
+
`task-document`, bound to its account and exact conversation. Core reuses
|
|
189
|
+
`stageChatAttachment` validation (10 MiB) and returns UTF-8 TXT/Markdown or
|
|
190
|
+
PDF text via Poppler, at most 256000 bytes. JPEG/PNG/WebP images return their
|
|
191
|
+
original bounded pixels as MCP image content to the native engine, never an
|
|
192
|
+
extra inference call or a base64 text prompt. It never accepts an owner file path.
|
|
193
|
+
MP4 clips up to 10 MiB and 120 seconds return at most eight sampled JPEG frames, 640 pixels wide and at most 1 MiB combined, through the same native image content. Speech transcripts supplied by the source remain in incoming context. The audited native CLI accepts text and images, not raw video/audio; sampled frames do not capture every motion. Scanned PDFs with no text layer need OCR and return a clear limitation.
|
package/docs/upgrades.md
CHANGED
|
@@ -93,6 +93,12 @@ same candidate again and apply the new job; do not edit the old receipt. Only
|
|
|
93
93
|
Finish the requesting turn after queuing either operation. Verify the main relay
|
|
94
94
|
before requesting a separate plugin upgrade.
|
|
95
95
|
|
|
96
|
+
Codex version discovery parses machine-readable stdout separately from package
|
|
97
|
+
manager notices on stderr. Invalid stdout fails with a named discovery error;
|
|
98
|
+
it is never repaired by stripping arbitrary lines. A historical generic JSON
|
|
99
|
+
parse error alone does not establish which command or output stream caused it.
|
|
100
|
+
Reconcile the job's completed stages and installed state before retrying.
|
|
101
|
+
|
|
96
102
|
## Agent interface
|
|
97
103
|
|
|
98
104
|
Telegram `/status` shows the relay's loaded version, fresh host version and
|
|
@@ -285,3 +291,32 @@ the host or create a repair task. An automatically failed or rolled-back version
|
|
|
285
291
|
is not retried until a newer version appears or the owner explicitly retries.
|
|
286
292
|
Inspect `ez updates check` and `ez updates status` for target diagnostics.
|
|
287
293
|
Actual update transactions retain their existing admission, drain and rollback rules.
|
|
294
|
+
|
|
295
|
+
### Isolated broker readiness
|
|
296
|
+
|
|
297
|
+
Isolated activation and rollback explicitly recreate only `plugin-broker` with
|
|
298
|
+
`--no-deps --force-recreate --no-build --pull never --wait`. Plugin-only refresh
|
|
299
|
+
pins the installed broker image ID; main activation uses the newly built image
|
|
300
|
+
and rollback uses the recorded old image. This refreshes the narrow read-only
|
|
301
|
+
host-configuration file mount after atomic replacement. The relay and native
|
|
302
|
+
sessions are preserved during plugin-only refresh. Host-capable updates do not
|
|
303
|
+
start a broker. Existing update admission and drain remain authoritative.
|
|
304
|
+
|
|
305
|
+
Socket health is liveness only. Before reporting completion, the updater runs
|
|
306
|
+
`docker/broker-readiness.mjs` inside the actual broker, with expected SHA-256
|
|
307
|
+
fingerprints of the host binding, registry and registry configuration. This
|
|
308
|
+
bounded diagnostic checks socket existence, agent/registry ownership, packaged
|
|
309
|
+
source identity, declared command routes, folder grants and reviewed network
|
|
310
|
+
pins. It reads state without rewriting Compose files, executing a plugin,
|
|
311
|
+
creating authorization or supplying `EZ_RUN_ID`. Fingerprints are checked again
|
|
312
|
+
after validation and on the host; a changed binding fails verification.
|
|
313
|
+
`brokerReadiness` and `rollbackBrokerReadiness` in update status are structural
|
|
314
|
+
readbacks at that transaction, not continuous health or native invocation proof.
|
|
315
|
+
A failed refresh or probe enters the existing rollback/recovery path.
|
|
316
|
+
|
|
317
|
+
A genuine authorized native invocation through the bound launcher, with its real
|
|
318
|
+
receipt, remains separate acceptance evidence. Structural readiness and Docker
|
|
319
|
+
health cannot prove provider delivery or a successful native command. Do not
|
|
320
|
+
invent a run ID for that evidence. The synthetic regression is
|
|
321
|
+
`EZ_RELAY_IMAGE=<exact runtime image ID> node docker/broker-update-smoke.mjs`;
|
|
322
|
+
it uses disposable Linux state, no credentials or provider calls.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Plugin-backed scheduled preflight
|
|
2
|
+
|
|
3
|
+
Empty or unchanged provider work can skip a scheduled session. Eligible work uses the schedule's saved execution settings. Core owns admission; installed read-only plugin commands own provider eligibility. Preflight requires the bound plugin broker used by isolated deployments; unavailable broker or provider reads fail closed.
|
|
4
|
+
|
|
5
|
+
A schedule's `preflight` is `{ "on": "eligible" | "changed", "checks": [{ "alias": "registered-read-only-command", "args": ["literal", "arguments"] }] }`.
|
|
6
|
+
The plugin emits `{ "schemaVersion": 1, "eligible": boolean, "fingerprint": "sha256 hex", "observedAt": "ISO timestamp", "count": integer }`.
|
|
7
|
+
Any check eligible admits the owning saved task; changed mode additionally requires a new combined fingerprint. No domain rules or DAG execution live in core. Empty/unchanged checks advance the ordinary schedule cursor without a run/session. Errors retain a sanitized preflight receipt and skip the occurrence; no empty-success fallback.
|
|
8
|
+
|
|
9
|
+
Use `--preflight-file FILE` when creating/editing a native task. Editing preserves its condition unless explicitly replaced; `--clear-preflight` removes it. Preflight receipts remain on the native schedule cursor outside the agent workspace. Manual triggers run the same gate; an ineligible manual request reports unmet preflight and creates no run.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.48",
|
|
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.",
|
package/src/ai.ts
CHANGED
|
@@ -146,7 +146,27 @@ export const readCodexCatalog = async (codexHome: string, run?: (args: string[],
|
|
|
146
146
|
try { return JSON.parse(await readFile(join(codexHome, 'models_cache.json'), 'utf8')) } catch { return {} }
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
// Claude has no catalog command; its installed help is the native metadata for
|
|
150
|
+
// the selectable model aliases and effort levels. No session or inference runs.
|
|
151
|
+
// Unparseable or unavailable help keeps the client default, never guessed names.
|
|
152
|
+
export const claudeCatalogModels = async (run?: (args: string[]) => Promise<string>): Promise<ModelChoice[]> => {
|
|
153
|
+
const fallback = [{ cli: 'claude', name: 'claude · client default', efforts: [] as string[] }]
|
|
154
|
+
let help: string
|
|
155
|
+
try {
|
|
156
|
+
help = run ? await run(['--help']) : await (async () => {
|
|
157
|
+
const invocation = executorInvocation('claude', ['--help'])
|
|
158
|
+
return (await promisify(execFile)(invocation.command, invocation.args, { env: executorEnvironment(), timeout: 8000, maxBuffer: 1024 * 1024 })).stdout
|
|
159
|
+
})()
|
|
160
|
+
} catch { return fallback }
|
|
161
|
+
const option = (flag: string) => help.split(/\r?\n(?=\s*(?:-\w, )?--)/).find((block) => block.trimStart().replace(/^-\w, /, '').startsWith(`${flag} `))?.replace(/\s+/g, ' ') ?? ''
|
|
162
|
+
const aliases = [...(option('--model').match(/\(e\.g\. ([^)]*)\)/)?.[1] ?? '').matchAll(/'([a-z][a-z0-9-]{0,31})'/g)].map((match) => match[1])
|
|
163
|
+
const levels = (option('--effort').match(/\(([a-z][a-z0-9, -]*)\)\s*$/)?.[1] ?? '').split(',').map((level) => level.trim())
|
|
164
|
+
.filter((level) => safe(level) && allowedEffort(level, undefined, 'claude'))
|
|
165
|
+
if (!aliases.length) return fallback
|
|
166
|
+
return [...fallback, ...[...new Set(aliases)].map((alias) => ({ cli: 'claude', model: alias, name: `claude · ${alias}`, efforts: levels }))]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string, codexRunner?: (args: string[], env: NodeJS.ProcessEnv) => Promise<string>, claudeRunner?: (args: string[]) => Promise<string>): Promise<ModelChoice[]> => {
|
|
150
170
|
const models: ModelChoice[] = []
|
|
151
171
|
const record = (value: unknown): Record<string, unknown> =>
|
|
152
172
|
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
@@ -179,8 +199,7 @@ export const readModels = async (home = homedir(), available = installed, codexH
|
|
|
179
199
|
}))
|
|
180
200
|
models.push(...(desktop.length ? desktop : [{ cli: 'codex-gui', name: 'codex-gui · desktop', efforts: [] }]))
|
|
181
201
|
}
|
|
182
|
-
|
|
183
|
-
if (await available('claude')) models.push({ cli: 'claude', name: 'claude · client default', efforts: [] })
|
|
202
|
+
if (await available('claude')) models.push(...await claudeCatalogModels(claudeRunner))
|
|
184
203
|
const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
|
|
185
204
|
if (await available('opencode')) models.push(...await opencodeCatalogModels(opencodeRunner, opencodeDataHome, allow))
|
|
186
205
|
// Pi has separate provider configuration. OpenCode's catalog cannot
|
|
@@ -1,6 +1,6 @@
|
|
|
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'
|
|
@@ -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
|
|
|
@@ -224,6 +246,17 @@ 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
|
}
|
|
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
|
+
}
|
|
227
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; 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')
|
|
@@ -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: {
|
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/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(),
|
package/src/control-state.ts
CHANGED
|
@@ -755,7 +755,9 @@ export class ControlStore {
|
|
|
755
755
|
const state = await this.readState()
|
|
756
756
|
await requireControlGuard(state, guard)
|
|
757
757
|
if (!state.ai) throw new Error('AI settings not initialized')
|
|
758
|
-
|
|
758
|
+
// Auto-detected client defaults come and go with installed clients; only
|
|
759
|
+
// owner-saved choices count toward the cap.
|
|
760
|
+
if (state.ai.presets.filter((p) => !p.id.startsWith('detected_')).length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
|
|
759
761
|
throw new Error('Keep it small: at most 12 saved AIs.')
|
|
760
762
|
state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
|
|
761
763
|
await this.writeState(state)
|
|
@@ -2,3 +2,5 @@ export type DeliverySocketRequest = { op: string; payload?: unknown }
|
|
|
2
2
|
export function deliverySocketPath(controlDir: string): string
|
|
3
3
|
export function callDeliverySocket(socketPath: string, op: DeliverySocketRequest, timeoutMs?: number): Promise<unknown>
|
|
4
4
|
export function deliverySocketAlive(socketPath: string, timeoutMs?: number): Promise<boolean>
|
|
5
|
+
|
|
6
|
+
export function callLocalSocket(socketPath:string, envelope:Record<string,unknown>, timeoutMs?:number):Promise<unknown>
|
|
@@ -67,6 +67,8 @@ const exchange = (connect, envelope, timeoutMs, target) => new Promise((resolve,
|
|
|
67
67
|
socket.write(encode(envelope))
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
+
export const callLocalSocket = (socketPath, envelope, timeoutMs = 130000) => exchange(() => createConnection(socketPath), envelope, timeoutMs, socketPath)
|
|
71
|
+
|
|
70
72
|
const readEndpoint = async controlDir => {
|
|
71
73
|
try {
|
|
72
74
|
const value = JSON.parse(await readFile(path.join(controlDir, endpointName), 'utf8'))
|
package/src/delivery-socket.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sameOwner } from './control-state.js'
|
|
2
|
+
import { ApplicationBindings } from './application-channel.js'
|
|
1
3
|
import { createServer, type Server, type Socket } from 'node:net'
|
|
2
4
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
5
|
import { rename, rm, writeFile } from 'node:fs/promises'
|
|
@@ -9,6 +11,8 @@ import { deliveredMessages } from './message-history.js'
|
|
|
9
11
|
import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
|
|
10
12
|
import { telegramOwner } from './control-state.js'
|
|
11
13
|
import { Scheduler } from './scheduler.js'
|
|
14
|
+
import {Tasks} from './tasks.js'
|
|
15
|
+
import { runEvidence, type EvidenceOptions } from './run-evidence.js'
|
|
12
16
|
|
|
13
17
|
// Stateless pipe transport. The relay owns the runs/outbox ledger in process
|
|
14
18
|
// memory; engine children, plugin children and the host executor reach it
|
|
@@ -28,16 +32,19 @@ export { deliverySocketPath }
|
|
|
28
32
|
export type DeliverySocketOp =
|
|
29
33
|
| { op: 'ping' }
|
|
30
34
|
| { op: 'status' }
|
|
35
|
+
| { op: 'schedulePreflight'; payload?: Record<string, unknown> }
|
|
31
36
|
| { op: 'triggerSchedule'; payload: { scheduleId: string; revision: string; key: string; callerRunId?: string } }
|
|
32
37
|
| { op: 'enqueue'; payload: Record<string, unknown> }
|
|
33
38
|
| { op: 'wait'; payload: { id: string; timeoutMs?: number } }
|
|
34
39
|
| { op: 'get'; payload: { runId: string } }
|
|
35
40
|
| { op: 'list'; payload?: Record<string, never> }
|
|
36
41
|
| { op: 'patch'; payload: { runId: string; change: Record<string, unknown> } }
|
|
42
|
+
| { op: 'taskApplication'; payload: {runId:string;ticketHash:string} }
|
|
37
43
|
| { op: 'authorize'; payload: { runId: string } }
|
|
38
44
|
| { op: 'receipt'; payload: { context: unknown; id: string } }
|
|
39
45
|
| { op: 'history'; payload: { runId: string; limit?: number; messageId?: number } }
|
|
40
46
|
| { op: 'deliveryStatus'; payload?: Record<string, never> }
|
|
47
|
+
| { op: 'evidence'; payload: EvidenceOptions }
|
|
41
48
|
| { op: 'approvalCheck'; payload: { actionId: string } }
|
|
42
49
|
| { op: 'latestReply'; payload?: Record<string, never> }
|
|
43
50
|
|
|
@@ -210,12 +217,23 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
|
|
|
210
217
|
queued: all.filter((run) => run.status === 'queued').length,
|
|
211
218
|
}
|
|
212
219
|
}
|
|
220
|
+
case 'schedulePreflight': {
|
|
221
|
+
const {scheduleId,revision}=op.payload ?? {}
|
|
222
|
+
if(!nonEmptyString(scheduleId) || !nonEmptyString(revision)) throw Error('Schedule preflight identity required')
|
|
223
|
+
const scheduler=new Scheduler(controlDir),s=await scheduler.get(scheduleId),owner=await readOnlyOwner(controlDir)
|
|
224
|
+
if(!owner || !sameOwner(owner,s.owner) || !s.enabled || s.revision!==revision || !s.preflight) throw Error('Schedule preflight binding unavailable')
|
|
225
|
+
if(s.delivery) {
|
|
226
|
+
const binding=(await new ApplicationBindings(controlDir).list()).find(b=>b.bindingId===s.delivery!.bindingId)
|
|
227
|
+
if(!binding || !sameOwner(owner,binding.owner)) throw Error('Schedule preflight delivery revoked')
|
|
228
|
+
}
|
|
229
|
+
return s.preflight
|
|
230
|
+
}
|
|
213
231
|
case 'triggerSchedule': {
|
|
214
232
|
const {scheduleId,revision,key,callerRunId}=op.payload ?? {}
|
|
215
233
|
if (!nonEmptyString(scheduleId) || !nonEmptyString(revision) || !nonEmptyString(key)) throw new Error('Task ID, revision and request key are required')
|
|
216
234
|
if (callerRunId !== undefined) {
|
|
217
235
|
const caller=await requireOwnerExecution(controlDir,callerRunId)
|
|
218
|
-
if (caller.replyOnly || (caller.scheduled && caller.scheduled.pairedAt !== (await readOnlyOwner(controlDir))?.pairedAt)) throw new Error('Scheduling requires an active owner-authorized run')
|
|
236
|
+
if (caller.replyOnly || caller.script || (caller.scheduled && caller.scheduled.pairedAt !== (await readOnlyOwner(controlDir))?.pairedAt)) throw new Error('Scheduling requires an active owner-authorized run')
|
|
219
237
|
}
|
|
220
238
|
const owner=await readOnlyOwner(controlDir)
|
|
221
239
|
if (!owner) throw new Error('Pair an owner before scheduling')
|
|
@@ -302,6 +320,10 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
|
|
|
302
320
|
if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
|
|
303
321
|
return runs.patch(payload.runId, payload.change as never)
|
|
304
322
|
}
|
|
323
|
+
case 'taskApplication': {
|
|
324
|
+
const p=op.payload as {runId:string;ticketHash:string}
|
|
325
|
+
return new Tasks(controlDir).authorizeApplicationLaunch(p.runId,p.ticketHash)
|
|
326
|
+
}
|
|
305
327
|
case 'authorize': {
|
|
306
328
|
const runId = (op.payload as { runId?: unknown } | undefined)?.runId
|
|
307
329
|
if (typeof runId !== 'string' || !runId) throw new Error('Run ID is required')
|
|
@@ -321,6 +343,7 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
|
|
|
321
343
|
...(payload.messageId === undefined ? {} : { messageId: payload.messageId as number }),
|
|
322
344
|
})
|
|
323
345
|
}
|
|
346
|
+
case 'evidence': return runEvidence(controlDir, op.payload ?? {})
|
|
324
347
|
case 'deliveryStatus': return runs.deliveryStatus()
|
|
325
348
|
case 'approvalCheck': {
|
|
326
349
|
const actionId = (op.payload as { actionId?: unknown } | undefined)?.actionId
|
package/src/event-sources.ts
CHANGED
|
@@ -27,11 +27,11 @@ async function atomic(path: string, data: unknown) {
|
|
|
27
27
|
try { await fd.writeFile(JSON.stringify(data)); await fd.sync() } finally { await fd.close() }
|
|
28
28
|
await rename(tmp, path)
|
|
29
29
|
}
|
|
30
|
-
export function sourceCall(socketPath: string, command: string, args = {}): Promise<any> {
|
|
30
|
+
export function sourceCall(socketPath: string, command: string, args = {}, maxResponseBytes = 256000): Promise<any> {
|
|
31
31
|
return new Promise((resolve, reject) => {
|
|
32
32
|
const req = request({ socketPath, path: '/', method: 'POST', timeout: 3000 }, res => {
|
|
33
33
|
let body = ''
|
|
34
|
-
res.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) >
|
|
34
|
+
res.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) > maxResponseBytes) req.destroy(new Error('Event-source response too large')) })
|
|
35
35
|
res.on('error', () => reject(new Error('Event-source response interrupted')))
|
|
36
36
|
res.on('end', () => {
|
|
37
37
|
try { const result = JSON.parse(body); if (res.statusCode !== 200 || result.ok !== true) throw new Error(); resolve(result.data) }
|