@jc_stack/ez-agents 0.1.0-beta.38 → 0.1.0-beta.40
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 +2 -0
- package/CHANGELOG.md +15 -0
- package/docs/application-channel.md +18 -0
- package/docs/host-service.md +1 -1
- package/docs/upgrades.md +10 -7
- package/package.json +1 -1
- package/src/application-channel.ts +27 -0
- package/src/audio.ts +13 -5
- package/src/config.ts +4 -0
- package/src/index.ts +6 -0
- package/src/updates/runtime.mjs +4 -1
- package/src/updates/supervisor.mjs +22 -2
- package/test/application-channel.test.ts +52 -0
- package/test/audio.test.ts +17 -1
- package/test/updates.test.mjs +39 -2
package/.env.example
CHANGED
|
@@ -19,6 +19,8 @@ EZ_EXECUTOR_CLI=codex
|
|
|
19
19
|
|
|
20
20
|
# Optional: Audio transcription (STT) and voice synthesis (TTS)
|
|
21
21
|
# GEMINI_API_KEY=
|
|
22
|
+
# EZ_SPEECH_VOICE_EN=Kore
|
|
23
|
+
# EZ_SPEECH_VOICE_ES=Kore
|
|
22
24
|
# OPENAI_API_KEY=
|
|
23
25
|
|
|
24
26
|
# Optional: OpenRouter API key for OpenCode CLI (Nemotron 3.5 Lightning)
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.40
|
|
4
|
+
|
|
5
|
+
- Apply eligible npm releases under each agent's saved automatic update policy
|
|
6
|
+
through the existing guarded updater. Queue one update at a time, preserve
|
|
7
|
+
manual policies, and wait for a newer version after a failed automatic update.
|
|
8
|
+
|
|
9
|
+
## 0.1.0-beta.39
|
|
10
|
+
|
|
11
|
+
- Render completed, binding-scoped application replies as WAV speech with
|
|
12
|
+
configurable English and Spanish voices, while retaining Ogg/Opus for Telegram.
|
|
13
|
+
Recheck the original application binding after synthesis and report depleted
|
|
14
|
+
speech credits without provider details.
|
|
15
|
+
- Preserve live delivery sockets during upgrade backups and restart Linux host
|
|
16
|
+
services after a successful core replacement.
|
|
17
|
+
|
|
3
18
|
## 0.1.0-beta.38
|
|
4
19
|
|
|
5
20
|
- Remove the retired Antigravity CLI integration and its unused engine paths.
|
|
@@ -10,6 +10,24 @@ Run admission, status and inbox receipts include the immutable `preset` captured
|
|
|
10
10
|
for that run so an application can label delivered messages with the engine choice
|
|
11
11
|
that actually produced them.
|
|
12
12
|
|
|
13
|
+
## Speech playback
|
|
14
|
+
|
|
15
|
+
`POST /v1/runs/<id>/speech` renders the last delivered text reply of a completed
|
|
16
|
+
application run as `audio/wav` (24 kHz mono). It uses the same binding/owner
|
|
17
|
+
authorization as run readback, rechecked after synthesis, and accepts no client
|
|
18
|
+
text or provider credentials. Replies are limited to 8,000 characters. An
|
|
19
|
+
unfinished run returns 409, missing speech configuration 503, and provider
|
|
20
|
+
failure 502. Depleted Gemini credits return 503 with
|
|
21
|
+
`code: speech_credits_depleted`. Audio responses have `Cache-Control: no-store`.
|
|
22
|
+
|
|
23
|
+
Configure `GEMINI_API_KEY` on the relay. The existing speech renderer uses
|
|
24
|
+
`gemini-3.8-flash-lite-tts`; Telegram still receives Ogg Opus. Applications should
|
|
25
|
+
send `{"language":"en"}` or `{"language":"es"}` to select
|
|
26
|
+
`EZ_SPEECH_VOICE_EN` or `EZ_SPEECH_VOICE_ES` (both default to Kore). Telegram
|
|
27
|
+
voice notes use the English voice setting. Applications should retain the returned
|
|
28
|
+
audio for local replay; each POST generates fresh audio.
|
|
29
|
+
Speech is a rendering operation and does not start another agent turn.
|
|
30
|
+
|
|
13
31
|
## Shared runtime controls
|
|
14
32
|
|
|
15
33
|
Register with `--share-owner` (the existing `--share-telegram` spelling is an
|
package/docs/host-service.md
CHANGED
|
@@ -24,7 +24,7 @@ Environment=EZ_DEPLOYMENT_DIR=/absolute/private/agents/family
|
|
|
24
24
|
Environment=PATH=/absolute/node/bin:/absolute/package-manager/bin:/absolute/cli/bin:/usr/local/bin:/usr/bin:/bin
|
|
25
25
|
EnvironmentFile=-%h/.config/ez/family-provider.env
|
|
26
26
|
ExecStart=/absolute/ez-package/package/bin/ezenciel-agents-host
|
|
27
|
-
Restart=
|
|
27
|
+
Restart=always
|
|
28
28
|
RestartSec=5
|
|
29
29
|
|
|
30
30
|
[Install]
|
package/docs/upgrades.md
CHANGED
|
@@ -12,12 +12,13 @@ automatic channel for core and plugins without a saved policy. Existing explicit
|
|
|
12
12
|
stable or manual policies are preserved. The owner may select either per target.
|
|
13
13
|
The main target and installed plugins version independently.
|
|
14
14
|
|
|
15
|
-
The
|
|
16
|
-
|
|
17
|
-
agent
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
wrapper state into a session.
|
|
15
|
+
The owner chooses the saved update policy. The host supervisor owns
|
|
16
|
+
interruption-safe replacement. There is one host service per agent, not a second
|
|
17
|
+
agent or an additional updater daemon. Its replaceable child runs the normal CLI
|
|
18
|
+
transport. It checks npm every six hours while running and queues one eligible
|
|
19
|
+
release at a time when the policy is automatic. Discovery and replacement never
|
|
20
|
+
wake the agent or inject wrapper state into a session. Manual policies retain
|
|
21
|
+
owner-invoked updates through `ez updates`.
|
|
21
22
|
Checks use the installed scoped npm identity; failures are
|
|
22
23
|
visible in `updates check` and private `tools/updates/available.json`. Plugins marked
|
|
23
24
|
`private: true` in their package metadata are reported as local-source updates
|
|
@@ -265,5 +266,7 @@ execution authority.
|
|
|
265
266
|
|
|
266
267
|
Update discovery runs independently of active host work. A discovery failure is
|
|
267
268
|
logged locally and retried at the next regular six-hour check; it does not stop
|
|
268
|
-
the host or create a repair task.
|
|
269
|
+
the host or create a repair task. An automatically failed or rolled-back version
|
|
270
|
+
is not retried until a newer version appears or the owner explicitly retries.
|
|
271
|
+
Inspect `ez updates check` and `ez updates status` for target diagnostics.
|
|
269
272
|
Actual update transactions retain their existing admission, drain and rollback rules.
|
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.40",
|
|
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.",
|
|
@@ -11,6 +11,7 @@ import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
|
|
|
11
11
|
import { assertEffort } from './model-policy.js'
|
|
12
12
|
import { ApprovalStore } from './approval.js'
|
|
13
13
|
import { Tasks } from './tasks.js'
|
|
14
|
+
import { SpeechCreditsDepletedError } from './audio.js'
|
|
14
15
|
|
|
15
16
|
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
16
17
|
export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
|
|
@@ -74,6 +75,7 @@ export class ApplicationChannel {
|
|
|
74
75
|
controlDir: string; workspace?: string; initial: AiPreset
|
|
75
76
|
wake: () => void
|
|
76
77
|
cancel: (id: string) => Promise<void>
|
|
78
|
+
speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
|
|
77
79
|
createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
|
|
78
80
|
telegramAvailable?: () => boolean
|
|
79
81
|
aiControls?: {
|
|
@@ -385,6 +387,31 @@ export class ApplicationChannel {
|
|
|
385
387
|
const admitted = await this.runs.attachTelegramApplication(target.id, application)
|
|
386
388
|
send(200, { id: admitted.id, scope, status: admitted.status }); return
|
|
387
389
|
}
|
|
390
|
+
const speechMatch = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})\/speech$/)
|
|
391
|
+
if (speechMatch && request.method === 'POST') {
|
|
392
|
+
const chunks: Buffer[] = []; let size = 0
|
|
393
|
+
for await (const chunk of request) { size += chunk.length; if (size > 128) throw new Error('Invalid speech request'); chunks.push(chunk) }
|
|
394
|
+
const input = Buffer.concat(chunks).toString('utf8').trim()
|
|
395
|
+
const language = input ? (JSON.parse(input) as { language?: unknown }).language : 'en'
|
|
396
|
+
if (language !== 'en' && language !== 'es') { send(400, { error: 'Invalid speech language' }); return }
|
|
397
|
+
const snapshot = await this.snapshot(binding.bindingId, speechMatch[1])
|
|
398
|
+
if (snapshot.status !== 'completed') { send(409, { error: 'Speech requires a completed reply' }); return }
|
|
399
|
+
const text = snapshot.messages.at(-1)?.text?.trim()
|
|
400
|
+
if (!text || text.length > 8000) { send(400, { error: 'Speech requires a reply of 1–8000 characters' }); return }
|
|
401
|
+
if (!this.options.speech) { send(503, { error: 'Speech is not configured' }); return }
|
|
402
|
+
let audio: { buffer: Buffer; mimeType: string }
|
|
403
|
+
try { audio = await this.options.speech(text, language) }
|
|
404
|
+
catch (error) {
|
|
405
|
+
if (error instanceof SpeechCreditsDepletedError) { send(503, { error: 'Speech credits are depleted', code: 'speech_credits_depleted' }); return }
|
|
406
|
+
send(502, { error: 'Speech generation failed' }); return
|
|
407
|
+
}
|
|
408
|
+
// A binding can be revoked while the provider is generating audio.
|
|
409
|
+
const currentBinding = await this.bindings.authenticate(request.headers.authorization!.slice(7))
|
|
410
|
+
if (currentBinding.bindingId !== binding.bindingId) throw new Error('Application authority revoked')
|
|
411
|
+
await this.snapshot(binding.bindingId, speechMatch[1])
|
|
412
|
+
response.writeHead(200, { 'content-type': audio.mimeType, 'cache-control': 'no-store' })
|
|
413
|
+
response.end(audio.buffer); return
|
|
414
|
+
}
|
|
388
415
|
const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
|
|
389
416
|
if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
|
|
390
417
|
await this.snapshot(binding.bindingId, match[1])
|
package/src/audio.ts
CHANGED
|
@@ -7,6 +7,10 @@ export type AudioOptions = {
|
|
|
7
7
|
voice?: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export class SpeechCreditsDepletedError extends Error {
|
|
11
|
+
constructor() { super('Gemini speech credits are depleted') }
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer => {
|
|
11
15
|
const header = Buffer.alloc(44)
|
|
12
16
|
const byteRate = sampleRate * channels * 2
|
|
@@ -135,7 +139,7 @@ export const transcribeAudio = async (
|
|
|
135
139
|
|
|
136
140
|
export const synthesizeSpeech = async (
|
|
137
141
|
text: string,
|
|
138
|
-
options: AudioOptions = {},
|
|
142
|
+
options: AudioOptions & { format?: 'wav' | 'ogg' } = {},
|
|
139
143
|
): Promise<{ buffer: Buffer; mimeType: string }> => {
|
|
140
144
|
const trimmed = text.trim()
|
|
141
145
|
if (!trimmed) throw new Error('Cannot synthesize speech for empty text')
|
|
@@ -143,7 +147,7 @@ export const synthesizeSpeech = async (
|
|
|
143
147
|
const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
|
|
144
148
|
if (geminiKey) {
|
|
145
149
|
const url =
|
|
146
|
-
'https://generativelanguage.googleapis.com/v1beta/models/gemini-
|
|
150
|
+
'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash-lite-tts:generateContent'
|
|
147
151
|
const voiceName = options.voice || 'Kore'
|
|
148
152
|
const response = await fetch(url, {
|
|
149
153
|
method: 'POST',
|
|
@@ -153,14 +157,15 @@ export const synthesizeSpeech = async (
|
|
|
153
157
|
contents: [{ parts: [{ text: trimmed }] }],
|
|
154
158
|
generationConfig: {
|
|
155
159
|
responseModalities: ['AUDIO'],
|
|
156
|
-
|
|
160
|
+
responseFormat: { audio: { mimeType: 'AUDIO_L16', sampleRate: 24000 } },
|
|
161
|
+
speechConfig: { voiceConfig: { voice: voiceName } },
|
|
157
162
|
},
|
|
158
163
|
}),
|
|
159
164
|
})
|
|
160
165
|
|
|
161
166
|
if (!response.ok) {
|
|
162
|
-
|
|
163
|
-
throw new Error(`Gemini speech synthesis error ${response.status}
|
|
167
|
+
if (response.status === 402) throw new SpeechCreditsDepletedError()
|
|
168
|
+
throw new Error(`Gemini speech synthesis error ${response.status}`)
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
const payload = (await response.json()) as any
|
|
@@ -173,6 +178,9 @@ export const synthesizeSpeech = async (
|
|
|
173
178
|
throw new Error(`Gemini returned no audio data (${candidate?.finishReason || 'no candidate'})`)
|
|
174
179
|
|
|
175
180
|
const rawPcm = Buffer.from(inline.data, 'base64')
|
|
181
|
+
if (!/^audio\/l16(?:;|$)/i.test(inline.mimeType) || !rawPcm.length || rawPcm.length % 2)
|
|
182
|
+
throw new Error('Gemini returned an unsupported speech format')
|
|
183
|
+
if (options.format === 'wav') return { buffer: pcmToWav(rawPcm), mimeType: 'audio/wav' }
|
|
176
184
|
const encoded = await encodeOggOpus(rawPcm, 24000, 1)
|
|
177
185
|
return {
|
|
178
186
|
buffer: encoded,
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type Config = ControlConfig & {
|
|
|
25
25
|
applicationPort?: number
|
|
26
26
|
applicationHost?: string
|
|
27
27
|
geminiApiKey?: string
|
|
28
|
+
speechVoiceEn?: string
|
|
29
|
+
speechVoiceEs?: string
|
|
28
30
|
openaiApiKey?: string
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -84,6 +86,8 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
84
86
|
channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
|
|
85
87
|
channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
|
|
86
88
|
geminiApiKey: env.GEMINI_API_KEY?.trim(),
|
|
89
|
+
speechVoiceEn: env.EZ_SPEECH_VOICE_EN?.trim(),
|
|
90
|
+
speechVoiceEs: env.EZ_SPEECH_VOICE_ES?.trim(),
|
|
87
91
|
openaiApiKey: env.OPENAI_API_KEY?.trim(),
|
|
88
92
|
}
|
|
89
93
|
}
|
package/src/index.ts
CHANGED
|
@@ -103,6 +103,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
103
103
|
const applicationChannel = new ApplicationChannel({
|
|
104
104
|
controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
|
|
105
105
|
aiControls: aiMenu,
|
|
106
|
+
speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
|
|
107
|
+
geminiApiKey: config.geminiApiKey,
|
|
108
|
+
voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
|
|
109
|
+
format: 'wav',
|
|
110
|
+
}) : undefined,
|
|
106
111
|
wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
|
|
107
112
|
createTelegramPairing: async (bindingId, owner) => {
|
|
108
113
|
const username = bot?.botInfo?.username
|
|
@@ -581,6 +586,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
581
586
|
const { buffer } = await synthesizeSpeech(item.voiceText, {
|
|
582
587
|
geminiApiKey: config.geminiApiKey,
|
|
583
588
|
openaiApiKey: config.openaiApiKey,
|
|
589
|
+
voice: config.speechVoiceEn,
|
|
584
590
|
})
|
|
585
591
|
await paceSend()
|
|
586
592
|
await authorizeChannelDelivery()
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -31,6 +31,9 @@ export function execute(command,args,options={}) {
|
|
|
31
31
|
export async function textAtomic(file,text) {
|
|
32
32
|
const tmp=file+'.update.tmp';await fs.writeFile(tmp,text,{mode:0o600});await fs.rename(tmp,file);
|
|
33
33
|
}
|
|
34
|
+
export async function backupStateDirectory(source,destination) {
|
|
35
|
+
await fs.cp(source,destination,{recursive:true,filter:async entry=>!(await fs.lstat(entry)).isSocket()});
|
|
36
|
+
}
|
|
34
37
|
export const pluginArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
|
|
35
38
|
export async function packageManager(root,run=execute) {
|
|
36
39
|
const {packageManager:required}=await read(path.join(root,'package.json'));
|
|
@@ -116,7 +119,7 @@ export async function perform(home,job,hooks) {
|
|
|
116
119
|
job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
|
|
117
120
|
await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
|
|
118
121
|
const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
|
|
119
|
-
for(const name of ['mind','control'])await
|
|
122
|
+
for(const name of ['mind','control'])await backupStateDirectory(path.join(config.deploymentDir,name),path.join(backup,name));
|
|
120
123
|
for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
|
|
121
124
|
let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
|
|
122
125
|
await textAtomic(envFile,env);
|
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { atomic, locked } from '../plugins/manager.mjs';
|
|
6
6
|
import { callDeliverySocket } from '../delivery-socket-client.mjs';
|
|
7
|
-
import { state, read, jobs, jobPath, check, missing, cleanupStaleBackups } from './control.mjs';
|
|
7
|
+
import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
|
|
8
8
|
import { perform, environment } from './runtime.mjs';
|
|
9
9
|
|
|
10
10
|
const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
|
|
@@ -28,6 +28,22 @@ export async function idle(control) {
|
|
|
28
28
|
const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
|
|
29
29
|
return !status||status.running===0;
|
|
30
30
|
}
|
|
31
|
+
export async function queueAutomatic(home,available) {
|
|
32
|
+
const existing=await jobs(home);
|
|
33
|
+
if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
|
|
34
|
+
for(const candidate of available) {
|
|
35
|
+
if(!candidate.newer||!candidate.policy?.automatic||!candidate.available)continue;
|
|
36
|
+
// A failed release needs a newer version or an explicit owner retry.
|
|
37
|
+
if(existing.some(job=>job.target===candidate.target&&job.version===candidate.available&&['failed','rolled-back'].includes(job.status)))continue;
|
|
38
|
+
try {
|
|
39
|
+
const job=await prepare(home,candidate.target,{release:candidate.available});
|
|
40
|
+
return await submit(home,job.id,true);
|
|
41
|
+
}catch {
|
|
42
|
+
console.error(`Automatic update of ${candidate.target} failed; inspect ez updates check/status.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
31
47
|
export async function supervise(deployment,signal,{discover=check}={}) {
|
|
32
48
|
const host=await read(path.join(deployment,'host-executor.json'));
|
|
33
49
|
if(host.agents.length!==1||!host.agents[0].toolsHome)throw Error('Initialize a single deployment plugin registry before starting the supervisor');
|
|
@@ -96,12 +112,16 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
96
112
|
const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
|
|
97
113
|
if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
|
|
98
114
|
} finally {await fs.rm(pause,{force:true});}
|
|
99
|
-
if(result?.status==='completed'
|
|
115
|
+
if(result?.status==='completed') {
|
|
116
|
+
if(pending.target==='main')return;
|
|
117
|
+
nextCheck=0;
|
|
118
|
+
}
|
|
100
119
|
}
|
|
101
120
|
if(Date.now()>=nextCheck) {
|
|
102
121
|
nextCheck=Date.now()+6*60*60*1000;
|
|
103
122
|
try {
|
|
104
123
|
const results=await discover(home);await atomic(path.join(directory,'available.json'),results);
|
|
124
|
+
await queueAutomatic(home,results);
|
|
105
125
|
} catch {
|
|
106
126
|
// Discovery is optional; its failure must not terminate the host.
|
|
107
127
|
// Do not expose registry response bodies or credentials in logs.
|
|
@@ -100,6 +100,58 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
|
|
|
100
100
|
assert.equal((await request('/v1/runs',secondToken,input)).status,401)
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
+
test('speech renders only a completed bound reply and rechecks authority after generation', async t => {
|
|
104
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-speech-'))
|
|
105
|
+
const owned = await owner(root), runs = new RunStore(root)
|
|
106
|
+
const firstToken = token(), secondToken = token()
|
|
107
|
+
const spoken: string[] = []
|
|
108
|
+
let revoke = false, reassign = false
|
|
109
|
+
const channel = new ApplicationChannel({
|
|
110
|
+
controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {},
|
|
111
|
+
speech: async text => {
|
|
112
|
+
spoken.push(text)
|
|
113
|
+
if (revoke) await channel.bindings.register('first', null, owned)
|
|
114
|
+
if (reassign) {
|
|
115
|
+
await channel.bindings.register('first', token(), owned, false, true)
|
|
116
|
+
await channel.bindings.register('second', firstToken, owned, false, true)
|
|
117
|
+
}
|
|
118
|
+
return { buffer: Buffer.from('audio-fixture'), mimeType: 'audio/wav' }
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
t.after(async () => { await channel.stop(); await rm(root, { recursive: true, force: true }) })
|
|
122
|
+
const first = (await channel.bindings.register('first', firstToken, owned))!
|
|
123
|
+
await channel.bindings.register('second', secondToken, owned)
|
|
124
|
+
const run = await channel.submit(first.bindingId, { requestId: 'speech', scope: 'exercise', text: 'Explain this' })
|
|
125
|
+
const address = await channel.listen(0) as { port: number }
|
|
126
|
+
const request = (bearer: string) => fetch(`http://127.0.0.1:${address.port}/v1/runs/${run.id}/speech`, {
|
|
127
|
+
method: 'POST', headers: { Authorization: `Bearer ${bearer}` },
|
|
128
|
+
})
|
|
129
|
+
assert.equal((await request(secondToken)).status, 404)
|
|
130
|
+
assert.equal((await request(firstToken)).status, 409)
|
|
131
|
+
assert.deepEqual(spoken, [])
|
|
132
|
+
for (const text of ['Progress', 'Keep the movement controlled.']) {
|
|
133
|
+
const item = await runs.enqueueMessage(run.id, text)
|
|
134
|
+
await runs.claimOutbox(item.id)
|
|
135
|
+
await channel.deliver(run, item)
|
|
136
|
+
}
|
|
137
|
+
await runs.patch(run.id, { status: 'completed' })
|
|
138
|
+
const audio = await request(firstToken)
|
|
139
|
+
assert.equal(audio.status, 200)
|
|
140
|
+
assert.equal(audio.headers.get('content-type'), 'audio/wav')
|
|
141
|
+
assert.equal(audio.headers.get('cache-control'), 'no-store')
|
|
142
|
+
assert.equal(await audio.text(), 'audio-fixture')
|
|
143
|
+
assert.deepEqual(spoken, ['Keep the movement controlled.'])
|
|
144
|
+
reassign = true
|
|
145
|
+
const beforeReassign = spoken.length
|
|
146
|
+
assert.notEqual((await request(firstToken)).status, 200)
|
|
147
|
+
assert.equal(spoken.length, beforeReassign + 1)
|
|
148
|
+
reassign = false
|
|
149
|
+
await channel.bindings.register('second', secondToken, owned, false, true)
|
|
150
|
+
await channel.bindings.register('first', firstToken, owned, false, true)
|
|
151
|
+
revoke = true
|
|
152
|
+
assert.notEqual((await request(firstToken)).status, 200)
|
|
153
|
+
})
|
|
154
|
+
|
|
103
155
|
test('owner replacement invalidates binding and client secrets never enter native environment',async t=>{
|
|
104
156
|
const root=await mkdtemp(join(tmpdir(),'ez-app-owner-'))
|
|
105
157
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
package/test/audio.test.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech } from '../src/audio.js'
|
|
3
|
+
import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech, SpeechCreditsDepletedError } from '../src/audio.js'
|
|
4
|
+
|
|
5
|
+
test('speech reports depleted credits without exposing provider details', async (t) => {
|
|
6
|
+
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 402 }))
|
|
7
|
+
await assert.rejects(synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key' }), SpeechCreditsDepletedError)
|
|
8
|
+
})
|
|
4
9
|
|
|
5
10
|
test('speech provider failure stays a failure, never a text or WAV success', async (t) => {
|
|
6
11
|
t.mock.method(
|
|
@@ -13,9 +18,15 @@ test('speech provider failure stays a failure, never a text or WAV success', asy
|
|
|
13
18
|
})
|
|
14
19
|
|
|
15
20
|
test('speech finds audio beyond the first response part and keeps credentials out of URLs', async (t) => {
|
|
21
|
+
let calls = 0
|
|
16
22
|
t.mock.method(globalThis, 'fetch', async (url: string, options: RequestInit) => {
|
|
23
|
+
calls += 1
|
|
24
|
+
assert.match(url, /gemini-3\.8-flash-lite-tts:generateContent$/)
|
|
17
25
|
assert.equal(url.includes('fixture-key'), false)
|
|
18
26
|
assert.equal(new Headers(options.headers).get('x-goog-api-key'), 'fixture-key')
|
|
27
|
+
const config = JSON.parse(String(options.body)).generationConfig
|
|
28
|
+
assert.deepEqual(config.responseFormat, { audio: { mimeType: 'AUDIO_L16', sampleRate: 24000 } })
|
|
29
|
+
assert.deepEqual(config.speechConfig.voiceConfig, { voice: calls === 3 ? 'voice_r3yhwvxvihxg' : 'Kore' })
|
|
19
30
|
return new Response(
|
|
20
31
|
JSON.stringify({
|
|
21
32
|
candidates: [
|
|
@@ -39,6 +50,11 @@ test('speech finds audio beyond the first response part and keeps credentials ou
|
|
|
39
50
|
const speech = await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key' })
|
|
40
51
|
assert.equal(speech.mimeType, 'audio/ogg')
|
|
41
52
|
assert.equal(speech.buffer.subarray(0, 4).toString(), 'OggS')
|
|
53
|
+
const wav = await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key', format: 'wav' })
|
|
54
|
+
assert.equal(wav.mimeType, 'audio/wav')
|
|
55
|
+
assert.equal(wav.buffer.subarray(0, 4).toString(), 'RIFF')
|
|
56
|
+
assert.equal(wav.buffer.length, 24044)
|
|
57
|
+
await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key', voice: 'voice_r3yhwvxvihxg', format: 'wav' })
|
|
42
58
|
})
|
|
43
59
|
|
|
44
60
|
test('pcmToWav generates a valid 44-byte WAV header', () => {
|
package/test/updates.test.mjs
CHANGED
|
@@ -5,16 +5,30 @@ import path from 'node:path';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { gzipSync } from 'node:zlib';
|
|
7
7
|
import { spawn, execFile } from 'node:child_process';
|
|
8
|
+
import { createServer } from 'node:net';
|
|
8
9
|
import { promisify } from 'node:util';
|
|
9
10
|
import { extract, digest, version, newer, compatible } from '../src/updates/artifact.mjs';
|
|
10
11
|
import { prepare, submit, command, read, jobPath, eligibility, jobs, cleanupStaleBackups, reviewedPluginDeploymentMigration } from '../src/updates/control.mjs';
|
|
11
|
-
import { perform, environment, packageManager } from '../src/updates/runtime.mjs';
|
|
12
|
+
import { perform, environment, packageManager, backupStateDirectory } from '../src/updates/runtime.mjs';
|
|
12
13
|
import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
|
|
13
14
|
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
14
15
|
import { status as runtimeStatus } from '../src/updates/status.mjs';
|
|
15
|
-
import { providerEnvironment } from '../src/updates/supervisor.mjs';
|
|
16
|
+
import { providerEnvironment, queueAutomatic } from '../src/updates/supervisor.mjs';
|
|
16
17
|
const exec=promisify(execFile);
|
|
17
18
|
|
|
19
|
+
test('main upgrade backup omits a live plugin socket and keeps private files',async t=>{
|
|
20
|
+
const root=await fs.mkdtemp('/tmp/ez-upgrade-socket-');
|
|
21
|
+
t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
22
|
+
const source=path.join(root,'control'),destination=path.join(root,'backup');
|
|
23
|
+
await fs.mkdir(source);await fs.writeFile(path.join(source,'state.json'),'private state');
|
|
24
|
+
const socket=path.join(source,'plugin-broker.sock'),server=createServer();
|
|
25
|
+
await new Promise((resolve,reject)=>server.once('error',reject).listen(socket,resolve));
|
|
26
|
+
t.after(()=>new Promise(resolve=>server.close(resolve)));
|
|
27
|
+
await backupStateDirectory(source,destination);
|
|
28
|
+
assert.equal(await fs.readFile(path.join(destination,'state.json'),'utf8'),'private state');
|
|
29
|
+
assert.deepEqual(await fs.readdir(destination),['state.json']);
|
|
30
|
+
});
|
|
31
|
+
|
|
18
32
|
test('supervisor forwards only provider keys declared by the agent installation',()=>{
|
|
19
33
|
const host={agents:[{codexProviders:[{envKey:'OPENROUTER_API_KEY'}]}]};
|
|
20
34
|
assert.deepEqual(providerEnvironment(host,{OPENROUTER_API_KEY:'provider-secret',TELEGRAM_BOT_TOKEN:'relay-secret'}),{OPENROUTER_API_KEY:'provider-secret'});
|
|
@@ -428,6 +442,29 @@ test('npm candidates verify exact version and integrity; automatic policy is enf
|
|
|
428
442
|
pkg.version='0.1.2';await assert.rejects(prepare(f.home,'main',{release:'0.1.1'}),/version mismatch/);
|
|
429
443
|
});
|
|
430
444
|
|
|
445
|
+
test('supervisor queues an eligible release once and preserves manual and failed-version gates',async t=>{
|
|
446
|
+
const f=await fixture(t),data=await fs.readFile(await f.pack());
|
|
447
|
+
const {createHash}=await import('node:crypto');
|
|
448
|
+
const pkg={name:'@ez-test/example',version:'0.1.1',dist:{tarball:'https://registry.npmjs.org/@ez-test/example/-/example-0.1.1.tgz',integrity:'sha512-'+createHash('sha512').update(data).digest('base64')}};
|
|
449
|
+
const original=globalThis.fetch;
|
|
450
|
+
globalThis.fetch=async url=>new Response(String(url).endsWith('.tgz')?data:JSON.stringify(String(url).endsWith('/0.1.1')?pkg:{name:pkg.name,'dist-tags':{latest:pkg.version},versions:{[pkg.version]:pkg}}));
|
|
451
|
+
t.after(()=>globalThis.fetch=original);
|
|
452
|
+
await atomic(path.join(f.home,'updates/supervisor.json'),{at:Date.now()});
|
|
453
|
+
await command(f.home,['policy','main','manual']);
|
|
454
|
+
assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
|
|
455
|
+
assert.deepEqual(await jobs(f.home),[]);
|
|
456
|
+
await command(f.home,['policy','main','beta']);
|
|
457
|
+
const queued=await queueAutomatic(f.home,await command(f.home,['check']));
|
|
458
|
+
assert.equal(queued.status,'queued');assert.equal(queued.automatic,true);
|
|
459
|
+
assert.equal(queued.origin.type,'npm');assert.equal(queued.sha256,digest(data));
|
|
460
|
+
assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
|
|
461
|
+
assert.equal((await jobs(f.home)).length,1);
|
|
462
|
+
queued.status='failed';await atomic(path.join(jobPath(f.home,queued.id),'job.json'),queued);
|
|
463
|
+
assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
|
|
464
|
+
assert.equal((await jobs(f.home)).length,1);
|
|
465
|
+
await assert.rejects(fs.readFile(path.join(f.agent.controlDir,'update-attention.json')),error=>error.code==='ENOENT');
|
|
466
|
+
});
|
|
467
|
+
|
|
431
468
|
test('update discovery follows latest while preserving legacy beta and stable-only policies',async t=>{
|
|
432
469
|
const {registryCandidate}=await import('../src/updates/artifact.mjs');
|
|
433
470
|
const name='@ez-test/example',pkg=v=>({name,version:v});
|