@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37
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/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +96 -0
- package/Dockerfile +20 -11
- package/README.md +10 -13
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +41 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +98 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +81 -24
- package/src/files.ts +38 -11
- package/src/host-executor.ts +33 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +98 -54
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +8 -3
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +131 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +1 -0
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +220 -1
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +120 -12
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +87 -3
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +136 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/test/ai.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
|
-
import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
|
|
7
|
+
import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
|
|
8
8
|
import { createAiMenu } from '../src/menu.js'
|
|
9
9
|
import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
|
|
10
10
|
import { InboxStore } from '../src/inbox.js'
|
|
@@ -242,6 +242,42 @@ test('model catalog can read an agent-bound Codex home', async () => {
|
|
|
242
242
|
}
|
|
243
243
|
})
|
|
244
244
|
|
|
245
|
+
test('Choose AI lists the agent-bound Codex home instead of only the desktop fallback', async () => {
|
|
246
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-codex-home-'))
|
|
247
|
+
const codexHome = await mkdtemp(join(tmpdir(), 'ez-ai-menu-cache-'))
|
|
248
|
+
try {
|
|
249
|
+
await writeFile(join(codexHome, 'models_cache.json'), JSON.stringify({ models: [
|
|
250
|
+
{ slug: 'fixture-bound-model', display_name: 'Bound Fixture', visibility: 'list',
|
|
251
|
+
supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
252
|
+
] }))
|
|
253
|
+
const store = new ControlStore(dir, 1000)
|
|
254
|
+
await store.aiState(initialPreset('codex'))
|
|
255
|
+
// Default catalog (no explicit override): the menu must bind it to codexHome,
|
|
256
|
+
// otherwise an empty process-home cache drops codex and only codex-gui lists.
|
|
257
|
+
const menu = createAiMenu(store, 'codex', undefined, dir, codexHome,
|
|
258
|
+
async (cli) => cli === 'codex' || cli === 'codex-gui')
|
|
259
|
+
const replies: Array<{ text: string; buttons: Array<{ text: string; callback_data: string }> }> = []
|
|
260
|
+
const context = (data?: string) => ({
|
|
261
|
+
callbackQuery: data ? { data } : undefined,
|
|
262
|
+
answerCallbackQuery: async () => ({}),
|
|
263
|
+
reply: async (text: string, options?: { reply_markup?: { inline_keyboard?: Array<Array<{ text: string; callback_data: string }>> } }) => {
|
|
264
|
+
replies.push({ text, buttons: options?.reply_markup?.inline_keyboard?.flat() ?? [] })
|
|
265
|
+
return {} as never
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
await menu.list(context() as never)
|
|
269
|
+
assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), [
|
|
270
|
+
'codex', 'codex-gui (desktop)', 'Refresh available AIs',
|
|
271
|
+
])
|
|
272
|
+
const client = replies.at(-1)!.buttons.find((button) => button.text === 'codex')!
|
|
273
|
+
await menu.handle(context(client.callback_data) as never)
|
|
274
|
+
assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), ['Bound Fixture', 'Back to clients'])
|
|
275
|
+
} finally {
|
|
276
|
+
await rm(dir, { recursive: true, force: true })
|
|
277
|
+
await rm(codexHome, { recursive: true, force: true })
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
245
281
|
test('native executor flags carry the exact model and effort; only structured metadata binds sessions', () => {
|
|
246
282
|
const opts = { workspace: '/tmp/fixture', sessionId: crypto.randomUUID(), isResume: true,
|
|
247
283
|
model: 'fixture-model', effort: 'medium' }
|
|
@@ -251,6 +287,12 @@ test('native executor flags carry the exact model and effort; only structured me
|
|
|
251
287
|
const codex = EXECUTOR_REGISTRY.codex.buildArgs(opts, '', 'fixture')
|
|
252
288
|
assert.ok(codex.includes('model_reasoning_effort="medium"'))
|
|
253
289
|
assert.deepEqual(codex.slice(-3), ['resume', opts.sessionId, '-'])
|
|
290
|
+
// Opencode variants pass through verbatim on every platform; there is no
|
|
291
|
+
// OS-specific effort mapping between the catalog and `run --variant`.
|
|
292
|
+
const opencode = EXECUTOR_REGISTRY.opencode.buildArgs(
|
|
293
|
+
{ workspace: '/tmp/fixture', model: 'opencode/muse-spark-1.3-contributor-free', effort: 'xhigh' }, '', 'fixture')
|
|
294
|
+
assert.equal(opencode[opencode.indexOf('-m') + 1], 'opencode/muse-spark-1.3-contributor-free')
|
|
295
|
+
assert.equal(opencode[opencode.indexOf('--variant') + 1], 'xhigh')
|
|
254
296
|
assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'thread.started', thread_id: opts.sessionId })), opts.sessionId)
|
|
255
297
|
assert.equal(nativeSessionId('opencode', JSON.stringify({ type: 'step_start', sessionID: 'ses_fixture' })), 'ses_fixture')
|
|
256
298
|
assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'text', thread_id: opts.sessionId })), undefined)
|
|
@@ -317,3 +359,180 @@ test('upgrades expose responsive chat without replacing an existing default or q
|
|
|
317
359
|
assert.ok(state.presets.some(p => p.id === 'chat-default'))
|
|
318
360
|
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
319
361
|
})
|
|
362
|
+
|
|
363
|
+
test('opencode catalog projects installed models with variants as efforts', async () => {
|
|
364
|
+
const verbose = [
|
|
365
|
+
'opencode/big-pickle',
|
|
366
|
+
JSON.stringify({ id: 'big-pickle', providerID: 'opencode', name: 'Big Pickle', variants: {} }),
|
|
367
|
+
'opencode/ling-free',
|
|
368
|
+
JSON.stringify({ id: 'ling-free', providerID: 'opencode', name: 'Ling Free',
|
|
369
|
+
variants: { low: { reasoningEffort: 'low' }, medium: { reasoningEffort: 'medium' }, 'bad option': {} } }),
|
|
370
|
+
'openrouter/~aliased/model',
|
|
371
|
+
JSON.stringify({ id: 'model', providerID: 'openrouter', name: 'Aliased', variants: {} }),
|
|
372
|
+
].join('\n')
|
|
373
|
+
const catalog = await readOpencodeModels(async (args) => {
|
|
374
|
+
assert.deepEqual(args, ['models', '--verbose'])
|
|
375
|
+
return verbose
|
|
376
|
+
})
|
|
377
|
+
assert.deepEqual(catalog, [
|
|
378
|
+
{ cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
|
|
379
|
+
{ cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
|
|
380
|
+
])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('opencode catalog falls back to the plain list, then to the client default', async () => {
|
|
384
|
+
const plain = await readOpencodeModels(async (args) => {
|
|
385
|
+
if (args.includes('--verbose')) throw new Error('no verbose metadata')
|
|
386
|
+
return 'opencode/alpha\nopenrouter/~aliased/model\nnot a model line\n'
|
|
387
|
+
})
|
|
388
|
+
assert.deepEqual(plain, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
|
|
389
|
+
assert.deepEqual(await readOpencodeModels(async () => { throw new Error('opencode unavailable') }), [])
|
|
390
|
+
const fallback = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
|
|
391
|
+
async () => { throw new Error('opencode unavailable') })
|
|
392
|
+
assert.deepEqual(fallback, [{ cli: 'opencode', name: 'opencode · client default', efforts: [] }])
|
|
393
|
+
const listed = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
|
|
394
|
+
async () => 'opencode/alpha\n')
|
|
395
|
+
assert.deepEqual(listed, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('opencode provider allowlist scopes the catalog to Go and never falls back outside it', async () => {
|
|
399
|
+
assert.equal(opencodeProviderAllowlist({}), undefined)
|
|
400
|
+
assert.equal(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' ' }), undefined)
|
|
401
|
+
assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: 'opencode-go' }), ['opencode-go'])
|
|
402
|
+
assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' opencode-go ,openrouter,opencode-go ' }), ['opencode-go', 'openrouter'])
|
|
403
|
+
for (const bad of ['Bad Name!', 'has space', 'UPPER', 'a'.repeat(33), ',,,', 'ok,,bad name'])
|
|
404
|
+
assert.throws(() => opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: bad }), /one to sixteen unique provider IDs/)
|
|
405
|
+
const verbose = [
|
|
406
|
+
'opencode/mimo-free',
|
|
407
|
+
JSON.stringify({ id: 'mimo-free', providerID: 'opencode', name: 'Mimo Free', variants: {} }),
|
|
408
|
+
'opencode-go/muse-spark-1.3-contributor',
|
|
409
|
+
JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor',
|
|
410
|
+
variants: { high: {}, xhigh: {} } }),
|
|
411
|
+
'openrouter/some/model',
|
|
412
|
+
JSON.stringify({ id: 'some/model', providerID: 'openrouter', name: 'Some', variants: {} }),
|
|
413
|
+
].join('\n')
|
|
414
|
+
const runner = async () => verbose
|
|
415
|
+
const prior = process.env.EZ_OPENCODE_PROVIDERS
|
|
416
|
+
try {
|
|
417
|
+
process.env.EZ_OPENCODE_PROVIDERS = 'opencode-go'
|
|
418
|
+
const scoped = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)
|
|
419
|
+
assert.deepEqual(scoped.map((m) => m.model), ['opencode-go/muse-spark-1.3-contributor'])
|
|
420
|
+
await assert.rejects(
|
|
421
|
+
validateSelection({ id: 'free', name: 'Free', cli: 'opencode', model: 'opencode/mimo-free' }, scoped, async () => true),
|
|
422
|
+
/installed client catalog/)
|
|
423
|
+
process.env.EZ_OPENCODE_PROVIDERS = 'missing-provider'
|
|
424
|
+
assert.deepEqual(await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner), [])
|
|
425
|
+
delete process.env.EZ_OPENCODE_PROVIDERS
|
|
426
|
+
assert.equal((await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)).length, 3)
|
|
427
|
+
} finally {
|
|
428
|
+
if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
|
|
429
|
+
else process.env.EZ_OPENCODE_PROVIDERS = prior
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
test('explicit host binding allowlist overrides the process environment', async () => {
|
|
433
|
+
const runner = async () => 'opencode/a\nopencode-go/b\n'
|
|
434
|
+
const only = (models: { cli: string; model?: string }[]) => models.filter((m) => m.cli === 'opencode').map((m) => m.model)
|
|
435
|
+
const prior = process.env.EZ_OPENCODE_PROVIDERS
|
|
436
|
+
try {
|
|
437
|
+
process.env.EZ_OPENCODE_PROVIDERS = 'opencode'
|
|
438
|
+
assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner)), ['opencode/a'])
|
|
439
|
+
assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, ['opencode-go'])), ['opencode-go/b'])
|
|
440
|
+
assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, undefined)), ['opencode/a'])
|
|
441
|
+
} finally {
|
|
442
|
+
if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
|
|
443
|
+
else process.env.EZ_OPENCODE_PROVIDERS = prior
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('opencode model and variant selections validate against the installed catalog', async () => {
|
|
448
|
+
const catalog = [
|
|
449
|
+
{ cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
|
|
450
|
+
{ cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
|
|
451
|
+
]
|
|
452
|
+
const available = async (cli: string) => cli === 'opencode'
|
|
453
|
+
await validateSelection({ id: 'choice', name: 'Ling', cli: 'opencode', model: 'opencode/ling-free', effort: 'low' }, catalog, available)
|
|
454
|
+
await validateSelection({ id: 'default', name: 'Default', cli: 'opencode', model: 'opencode/big-pickle' }, catalog, available)
|
|
455
|
+
await assert.rejects(
|
|
456
|
+
validateSelection({ id: 'bad', name: 'Bad', cli: 'opencode', model: 'opencode/ling-free', effort: 'max' }, catalog, available),
|
|
457
|
+
/installed client catalog/)
|
|
458
|
+
await assert.rejects(
|
|
459
|
+
validateSelection({ id: 'missing', name: 'Missing', cli: 'opencode', model: 'opencode/unknown' }, catalog, available),
|
|
460
|
+
/installed client catalog/)
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('pi does not borrow an unrelated OpenCode catalog', async () => {
|
|
464
|
+
const runner = async () => [
|
|
465
|
+
'opencode-go/muse-spark-1.3-contributor',
|
|
466
|
+
JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor', variants: { high: {}, xhigh: {} } }),
|
|
467
|
+
].join('\n')
|
|
468
|
+
const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never, runner)
|
|
469
|
+
assert.deepEqual(catalog, [{ cli: 'pi', name: 'Pi · client default', efforts: [] }])
|
|
470
|
+
assert.equal(isPreset({ id: 'x', name: 'Pi', cli: 'pi' }), true)
|
|
471
|
+
assert.equal(isPreset({ id: 'x', name: 'Unavailable', cli: 'unreal-agent' }), false)
|
|
472
|
+
await validateSelection({ id: 'choice', name: 'Default', cli: 'pi' }, catalog, async (cli) => cli === 'pi')
|
|
473
|
+
await assert.rejects(
|
|
474
|
+
validateSelection(
|
|
475
|
+
{ id: 'bad', name: 'Bad', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'max' },
|
|
476
|
+
catalog, async (cli) => cli === 'pi'),
|
|
477
|
+
/installed client catalog/)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('model catalog merges user-curated entries ahead of discovered ones', async () => {
|
|
481
|
+
const control = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-'))
|
|
482
|
+
const home = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-home-'))
|
|
483
|
+
try {
|
|
484
|
+
await mkdir(join(home, '.codex'))
|
|
485
|
+
await writeFile(join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
|
|
486
|
+
{ slug: 'fixture-model', display_name: 'Fixture', visibility: 'list',
|
|
487
|
+
supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
488
|
+
] }))
|
|
489
|
+
await writeFile(join(control, 'ai-models.json'), JSON.stringify([
|
|
490
|
+
{ cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
|
|
491
|
+
{ cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Dupe', efforts: [] },
|
|
492
|
+
{ cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
|
|
493
|
+
{ cli: 'nope', model: 'x' },
|
|
494
|
+
'not-an-entry',
|
|
495
|
+
]))
|
|
496
|
+
const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
|
|
497
|
+
const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
|
|
498
|
+
const catalog = await readModels(home, installedOnly, join(home, '.codex'),
|
|
499
|
+
noRunner, undefined, undefined, control)
|
|
500
|
+
assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
|
|
501
|
+
{ cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
|
|
502
|
+
{ cli: 'pi', name: 'Pi · client default', efforts: [] },
|
|
503
|
+
])
|
|
504
|
+
// Curated entries win over the same discovered id and validate for selection.
|
|
505
|
+
assert.deepEqual(catalog.filter((m) => m.cli === 'codex'), [
|
|
506
|
+
{ cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
|
|
507
|
+
])
|
|
508
|
+
await validateSelection(
|
|
509
|
+
{ id: 'choice', name: 'Spark', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
|
|
510
|
+
catalog, async (cli) => cli === 'pi')
|
|
511
|
+
// Missing or malformed curation files fall back to discovery alone.
|
|
512
|
+
assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
|
|
513
|
+
await writeFile(join(control, 'ai-models.json'), 'not json')
|
|
514
|
+
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
|
|
515
|
+
noRunner, undefined, undefined, control), [
|
|
516
|
+
{ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
|
|
517
|
+
])
|
|
518
|
+
} finally {
|
|
519
|
+
await rm(control, { recursive: true, force: true })
|
|
520
|
+
await rm(home, { recursive: true, force: true })
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
test('curated models respect installed clients and provider scope', async () => {
|
|
525
|
+
const control = await mkdtemp(join(tmpdir(), 'ez-catalog-scope-'))
|
|
526
|
+
try {
|
|
527
|
+
await writeFile(join(control, 'ai-models.json'), JSON.stringify([
|
|
528
|
+
{ cli: 'pi', model: 'opencode-go/allowed', efforts: ['xhigh'] },
|
|
529
|
+
{ cli: 'pi', model: 'other/blocked' },
|
|
530
|
+
{ cli: 'opencode', model: 'other/blocked' },
|
|
531
|
+
{ cli: 'claude', model: 'uninstalled' },
|
|
532
|
+
]))
|
|
533
|
+
const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never,
|
|
534
|
+
async () => { throw new Error('no OpenCode catalog') }, undefined, ['opencode-go'], control)
|
|
535
|
+
assert.deepEqual(catalog.filter(entry => entry.model).map(entry => entry.model), ['opencode-go/allowed'])
|
|
536
|
+
assert.equal(catalog.some(entry => entry.cli === 'pi' && !entry.model), false)
|
|
537
|
+
} finally { await rm(control, { recursive: true, force: true }) }
|
|
538
|
+
})
|
|
@@ -9,12 +9,13 @@ import { randomBytes } from 'node:crypto'
|
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
11
11
|
import { ApplicationChannel, ApplicationBindings, applicationScope } from '../src/application-channel.js'
|
|
12
|
-
import { ControlStore, ownerId } from '../src/control-state.js'
|
|
12
|
+
import { ControlStore, ownerId, ownerEpoch } from '../src/control-state.js'
|
|
13
13
|
import { RunStore } from '../src/runs.js'
|
|
14
14
|
import { initialPreset } from '../src/ai.js'
|
|
15
15
|
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
16
16
|
import { createRelay } from '../src/index.js'
|
|
17
17
|
import { EXECUTOR_REGISTRY, executorEnvironment } from '../src/executor.js'
|
|
18
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
18
19
|
|
|
19
20
|
const token = () => randomBytes(32).toString('base64url')
|
|
20
21
|
const owner = async (dir: string) => {
|
|
@@ -31,7 +32,7 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
|
|
|
31
32
|
const root=await mkdtemp(join(tmpdir(),'ez-app-channel-'))
|
|
32
33
|
const owned=await owner(root), runs=new RunStore(root), control=new ControlStore(root,1000)
|
|
33
34
|
let wakes=0
|
|
34
|
-
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{wakes++},cancel:async id=>{await runs.patch(id,{status:'cancelled'})}})
|
|
35
|
+
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{wakes++},cancel:async id=>{await runs.patch(id,{status:'cancelled'})},createTelegramPairing:async()=>{throw new Error('unavailable')},telegramAvailable:()=>false})
|
|
35
36
|
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
36
37
|
const firstToken=token(), secondToken=token()
|
|
37
38
|
const first=(await channel.bindings.register('first',firstToken,owned))!
|
|
@@ -124,12 +125,14 @@ if(result.status!==0){console.error(result.stderr);process.exit(1)};
|
|
|
124
125
|
`)
|
|
125
126
|
EXECUTOR_REGISTRY.codex={...original,command:process.execPath,buildArgs:(options,_file,prompt)=>{prompts.push(prompt);resumeIds.push(options.isResume?options.sessionId:undefined);return ['--import',require.resolve('tsx'),fixture]}}
|
|
126
127
|
const relay=createRelay({controlDir:root,workspace:root,pairingTtlMs:1000,telegramBotToken:'fixture',executorTimeoutMs:0,executorCli:'codex'})
|
|
128
|
+
// The engine child reaches the relay memory ledger through the delivery socket.
|
|
129
|
+
const ledger=await serveTestLedger(root)
|
|
127
130
|
let telegramCalls=0
|
|
128
131
|
relay.bot.api.config.use(async()=>{telegramCalls++;return {ok:true,result:{message_id:1}} as never})
|
|
129
132
|
const owned=await owner(root), secret=token()
|
|
130
133
|
const binding=(await relay.applicationChannel.bindings.register('app',secret,owned))!
|
|
131
134
|
const drain=setInterval(()=>void relay.drainOutbox(),10)
|
|
132
|
-
t.after(async()=>{clearInterval(drain);await relay.stop();EXECUTOR_REGISTRY.codex=original;await rm(root,{recursive:true,force:true})})
|
|
135
|
+
t.after(async()=>{clearInterval(drain);await relay.stop();EXECUTOR_REGISTRY.codex=original;await ledger.stop();await rm(root,{recursive:true,force:true})})
|
|
133
136
|
const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'one',scope:'program',text:'Hello from the app',context:{secret:'must-stay-out-of-prompt'}})
|
|
134
137
|
await waitFor(async()=> (await new RunStore(root).get(first.id))?.status==='completed')
|
|
135
138
|
const second=await relay.applicationChannel.submit(binding.bindingId,{requestId:'two',scope:'program',text:'Continue'})
|
|
@@ -137,8 +140,9 @@ if(result.status!==0){console.error(result.stderr);process.exit(1)};
|
|
|
137
140
|
assert.deepEqual(resumeIds,[undefined,nativeId])
|
|
138
141
|
assert.equal(telegramCalls,0)
|
|
139
142
|
assert.ok(prompts[0].startsWith('Hello from the app'))
|
|
140
|
-
assert.ok(prompts[0].includes('[
|
|
143
|
+
assert.ok(prompts[0].includes('[application scope "program"]'))
|
|
141
144
|
assert.ok(!prompts[0].includes('must-stay-out-of-prompt'))
|
|
145
|
+
assert.ok(!prompts[0].includes('delegate'))
|
|
142
146
|
assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,second.id)).messages[0].text,'Fixture engine reply')
|
|
143
147
|
})
|
|
144
148
|
|
|
@@ -302,7 +306,7 @@ test('generic attachments stage after auth, preserve literal comments and reject
|
|
|
302
306
|
const input={requestId:'image',scope:'chat',followOwner:true,text:' /goal literal\n comment ',attachment:{name:'image.png',data:Buffer.from('89504e470d0a1a0a','hex').toString('base64')}}
|
|
303
307
|
assert.equal((await post(input,'bad')).status,401)
|
|
304
308
|
const {readdir,stat}=await import('node:fs/promises')
|
|
305
|
-
await assert.rejects(readdir(join(root,'
|
|
309
|
+
await assert.rejects(readdir(join(root,'attachments')),/ENOENT/)
|
|
306
310
|
for (const [name,bytes] of [['image.png',Buffer.from('89504e470d0a1a0a','hex')],['file.pdf',Buffer.from('%PDF-1.4\nfixture')],['notes.md',Buffer.from('# Fixture')]] as const) {
|
|
307
311
|
const body={...input,requestId:name,attachment:{name,data:bytes.toString('base64')}}
|
|
308
312
|
const response=await post(body);assert.equal(response.status,202)
|
|
@@ -311,12 +315,12 @@ test('generic attachments stage after auth, preserve literal comments and reject
|
|
|
311
315
|
assert.equal(run.ownerId,ownerId(owned))
|
|
312
316
|
assert.equal(run.application?.inputText,input.text)
|
|
313
317
|
assert.ok(run.texts[0].endsWith(`Caption: ${input.text}`))
|
|
314
|
-
const path=run.texts[0].match(/staged at (
|
|
315
|
-
assert.deepEqual(await readFile(
|
|
316
|
-
assert.equal((await stat(
|
|
317
|
-
const before=(await readdir(join(root,'
|
|
318
|
+
const path=run.texts[0].match(/staged at (\/[^ ]+)/)![1]
|
|
319
|
+
assert.deepEqual(await readFile(path),bytes)
|
|
320
|
+
assert.equal((await stat(path)).mode & 0o777,0o600)
|
|
321
|
+
const before=(await readdir(join(root,'attachments'))).length
|
|
318
322
|
assert.equal((await post(body)).status,202)
|
|
319
|
-
assert.equal((await readdir(join(root,'
|
|
323
|
+
assert.equal((await readdir(join(root,'attachments'))).length,before)
|
|
320
324
|
assert.equal((await post({...body,text:'changed'})).status,409)
|
|
321
325
|
assert.equal((await post({...body,attachment:{name,data:Buffer.from('changed').toString('base64')}})).status,409)
|
|
322
326
|
assert.equal((await fetch(`http://127.0.0.1:${address.port}/v1/runs/${snapshot.id}`,{headers:{Authorization:`Bearer ${other}`}})).status,404)
|
|
@@ -326,12 +330,56 @@ test('generic attachments stage after auth, preserve literal comments and reject
|
|
|
326
330
|
const invalidActivation=await post({...input,requestId:'invalid-activation',followOwner:false,activateTelegram:true,scope:'new-scope',attachment:{name:'x.exe',data:Buffer.from('text').toString('base64')}})
|
|
327
331
|
assert.equal(invalidActivation.status,400)
|
|
328
332
|
assert.deepEqual((await control.status()).activeSession,priorSession)
|
|
329
|
-
const before=(await readdir(join(root,'
|
|
333
|
+
const before=(await readdir(join(root,'attachments'))).length
|
|
330
334
|
for (const attachment of [{name:'x.exe',data:Buffer.from('text').toString('base64')},{name:'x.txt',data:'%%%invalid'},{name:'x.txt',data:Buffer.alloc(10*1024*1024+1,65).toString('base64')}]) {
|
|
331
335
|
assert.equal((await post({...input,requestId:'bad',attachment})).status,400)
|
|
332
336
|
}
|
|
333
|
-
assert.equal((await readdir(join(root,'
|
|
337
|
+
assert.equal((await readdir(join(root,'attachments'))).length,before)
|
|
334
338
|
await channel.bindings.register('web',null,owned)
|
|
335
339
|
await assert.rejects(channel.submit(binding.bindingId,{...input,requestId:'revoked'}),/revoked/)
|
|
336
|
-
assert.equal((await readdir(join(root,'
|
|
340
|
+
assert.equal((await readdir(join(root,'attachments'))).length,before)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('shareTelegram bindings list Telegram intake and admit one run with context', async t => {
|
|
344
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-telegram-'))
|
|
345
|
+
const control=new ControlStore(root,1000), runs=new RunStore(root)
|
|
346
|
+
await control.requestPairing(42,42); await control.approveOwner(42)
|
|
347
|
+
const owned=(await control.status()).owner!
|
|
348
|
+
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{},cancel:async()=>{},createTelegramPairing:async()=>{throw new Error('unavailable')},telegramAvailable:()=>false})
|
|
349
|
+
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
350
|
+
const sharedToken=token(), plainToken=token()
|
|
351
|
+
await channel.bindings.register('tg-app',sharedToken,owned,true)
|
|
352
|
+
await channel.bindings.register('plain',plainToken,owned)
|
|
353
|
+
const address=await channel.listen(0) as {port:number}
|
|
354
|
+
const url=`http://127.0.0.1:${address.port}`
|
|
355
|
+
const get=(bearer:string)=>fetch(url+'/v1/runs',{headers:{Authorization:`Bearer ${bearer}`}})
|
|
356
|
+
const attach=(id:string,bearer:string,body:unknown)=>fetch(url+`/v1/runs/${id}/application`,{method:'POST',headers:{Authorization:`Bearer ${bearer}`},body:JSON.stringify(body)})
|
|
357
|
+
const telegram=await runs.create({id:'tg_intake_1',chatId:42,telegramUserId:42,texts:['Attach the session'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
|
|
358
|
+
assert.equal(telegram.application,undefined)
|
|
359
|
+
const listed=await (await get(sharedToken)).json() as {runs:{id:string;scope:string}[]}
|
|
360
|
+
assert.ok(listed.runs.some(run=>run.id==='tg_intake_1' && run.scope==='telegram'))
|
|
361
|
+
const hidden=await (await get(plainToken)).json() as {runs:{id:string}[]}
|
|
362
|
+
assert.ok(!hidden.runs.some(run=>run.id==='tg_intake_1'))
|
|
363
|
+
assert.equal((await attach('tg_intake_1',plainToken,{context:{aifit:{capability:'x'}}})).status,403)
|
|
364
|
+
assert.equal((await attach('tg_intake_1',sharedToken,{context:'nope'})).status,400)
|
|
365
|
+
assert.equal((await attach('tg_nope',sharedToken,{context:{}})).status,404)
|
|
366
|
+
const admitted=await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'granted'}}})
|
|
367
|
+
assert.equal(admitted.status,200)
|
|
368
|
+
assert.deepEqual(await admitted.json(),{id:'tg_intake_1',scope:'telegram',status:'queued'})
|
|
369
|
+
const stored=await runs.get('tg_intake_1')
|
|
370
|
+
const storedContext=stored?.telegramApplication?.context as {aifit?:{capability?:string}}|undefined
|
|
371
|
+
assert.equal(storedContext?.aifit?.capability,'granted')
|
|
372
|
+
assert.equal(stored?.application,undefined,'owner delivery routing is untouched')
|
|
373
|
+
const retrySame=await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'granted'}}})
|
|
374
|
+
assert.equal(retrySame.status,200)
|
|
375
|
+
assert.equal((await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'changed'}}})).status,409)
|
|
376
|
+
const stranger=await runs.create({id:'tg_stranger_1',chatId:43,telegramUserId:43,texts:['Not the owner'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
|
|
377
|
+
const stillHidden=await (await get(sharedToken)).json() as {runs:{id:string}[]}
|
|
378
|
+
assert.ok(!stillHidden.runs.some(run=>run.id===stranger.id))
|
|
379
|
+
assert.equal((await attach(stranger.id,sharedToken,{context:{}})).status,400)
|
|
380
|
+
const plain=await runs.create({id:'r_plain_1',texts:['No telegram origin'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
|
|
381
|
+
assert.equal((await attach(plain.id,sharedToken,{context:{}})).status,404)
|
|
382
|
+
const finished=await runs.create({id:'tg_finished_1',chatId:42,telegramUserId:42,texts:['Done already'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
|
|
383
|
+
await runs.patch(finished.id,{status:'completed'})
|
|
384
|
+
assert.equal((await attach(finished.id,sharedToken,{context:{}})).status,409)
|
|
337
385
|
})
|
|
@@ -20,12 +20,10 @@ const waitFor = async (condition: () => Promise<boolean>) => {
|
|
|
20
20
|
throw new Error('Timed out')
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
test('application-only mode
|
|
24
|
-
assert.throws(()=>loadConfig({}), /
|
|
25
|
-
assert.throws(()=>loadConfig({
|
|
26
|
-
|
|
27
|
-
assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8110',EZ_CHANNEL_BACKEND_URL:'http://backend',EZ_CHANNEL_BACKEND_TOKEN:'secret'}), /native Ez executor/)
|
|
28
|
-
const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8110',TELEGRAM_BOT_TOKEN:'unused-secret'})
|
|
23
|
+
test('application-only mode is inferred from the absence of a bot token and requires a native listener', () => {
|
|
24
|
+
assert.throws(()=>loadConfig({}), /EZ_APPLICATION_PORT/)
|
|
25
|
+
assert.throws(()=>loadConfig({EZ_APPLICATION_PORT:'8110',EZ_CHANNEL_BACKEND_URL:'http://backend',EZ_CHANNEL_BACKEND_TOKEN:'secret'}), /native Ez executor/)
|
|
26
|
+
const config=loadConfig({EZ_APPLICATION_PORT:'8110'})
|
|
29
27
|
assert.equal(config.telegramBotToken,'')
|
|
30
28
|
assert.equal(config.telegramEnabled,false)
|
|
31
29
|
})
|
|
@@ -36,8 +34,8 @@ test('administrator bootstrap is explicit, local-only and cannot replace identit
|
|
|
36
34
|
const token=join(root,'token'); await writeFile(token,'x'.repeat(48),{mode:0o600})
|
|
37
35
|
const cli=fileURLToPath(new URL('../bin/ezenciel-agents-application.mjs',import.meta.url))
|
|
38
36
|
const args=[cli,'--id','app','--token-file',token,'--owner','42']
|
|
39
|
-
const env={...process.env,EZ_CONTROL_DIR:root,EZ_RUN_ID:'',
|
|
40
|
-
assert.notEqual(spawnSync(process.execPath,args,{env:{...env,
|
|
37
|
+
const env={...process.env,EZ_CONTROL_DIR:root,EZ_RUN_ID:'',TELEGRAM_BOT_TOKEN:''}
|
|
38
|
+
assert.notEqual(spawnSync(process.execPath,args,{env:{...env,TELEGRAM_BOT_TOKEN:'fixture'}}).status,0)
|
|
41
39
|
assert.notEqual(spawnSync(process.execPath,args,{env:{...env,EZ_RUN_ID:'r_agent'}}).status,0)
|
|
42
40
|
assert.equal((await new ControlStore(root,1000).status()).owner,null)
|
|
43
41
|
const invalidToken=join(root,'invalid-token');await writeFile(invalidToken,'short')
|
|
@@ -64,7 +62,7 @@ test('botless daemon executes application turn and rejects Telegram-origin work/
|
|
|
64
62
|
const purpose=join(root,'purpose.md');await writeFile(purpose,'Fixture application agent\n');await initializeWorkspace(root,purpose)
|
|
65
63
|
const portServer=createServer();await new Promise<void>(r=>portServer.listen(0,'127.0.0.1',r))
|
|
66
64
|
const port=(portServer.address() as {port:number}).port;await new Promise<void>(r=>portServer.close(()=>r()))
|
|
67
|
-
const config=loadConfig({
|
|
65
|
+
const config=loadConfig({EZ_APPLICATION_PORT:String(port),EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'codex'})
|
|
68
66
|
const native='11111111-1111-1111-1111-111111111111', fixture=join(root,'engine.mjs')
|
|
69
67
|
await writeFile(fixture,`import {spawnSync} from 'node:child_process';if(process.env.TELEGRAM_BOT_TOKEN)throw Error('secret leak');console.log(JSON.stringify({type:'thread.started',thread_id:${JSON.stringify(native)}}));const r=spawnSync(process.execPath,[${JSON.stringify(fileURLToPath(new URL('../bin/ezenciel-agents-message.mjs',import.meta.url)))},'--text','Application reply'],{env:process.env});process.exit(r.status);`)
|
|
70
68
|
const original=EXECUTOR_REGISTRY.codex,require=createRequire(import.meta.url)
|
|
@@ -91,12 +89,15 @@ test('botless daemon executes application turn and rejects Telegram-origin work/
|
|
|
91
89
|
assert.equal((await runs.get(legacy.id))?.status,'queued')
|
|
92
90
|
})
|
|
93
91
|
|
|
94
|
-
test('Docker health accepts application readiness
|
|
92
|
+
test('Docker health accepts application readiness when the agent has no bot', async t => {
|
|
95
93
|
const root=await mkdtemp(join(tmpdir(),'ez-app-only-health-'))
|
|
96
94
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
97
|
-
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true}))
|
|
95
|
+
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true,telegramConfigured:false}))
|
|
98
96
|
const command=fileURLToPath(new URL('../docker/healthcheck.mjs',import.meta.url))
|
|
99
97
|
const env={...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:root,EZ_EXECUTOR_TRANSPORT:'local'}
|
|
100
|
-
assert.equal(spawnSync(process.execPath,[command],{env
|
|
101
|
-
|
|
98
|
+
assert.equal(spawnSync(process.execPath,[command],{env}).status,0)
|
|
99
|
+
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:false,telegramConfigured:false}))
|
|
100
|
+
assert.notEqual(spawnSync(process.execPath,[command],{env}).status,0)
|
|
101
|
+
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true,telegramConfigured:true}))
|
|
102
|
+
assert.notEqual(spawnSync(process.execPath,[command],{env}).status,0)
|
|
102
103
|
})
|
|
@@ -43,7 +43,6 @@ test('structured photo transport and deterministic reply recovery', async () =>
|
|
|
43
43
|
test('backend dispatch keeps owner/private gate and never starts a CLI', async () => {
|
|
44
44
|
const { createRelay } = await import('../src/index.js')
|
|
45
45
|
const { ControlStore } = await import('../src/control-state.js')
|
|
46
|
-
const { recoverInterruptedRuns } = await import('../docker/recovery.js')
|
|
47
46
|
const root = await mkdtemp(join(tmpdir(), 'channel-gate-'))
|
|
48
47
|
const originalFetch = globalThis.fetch
|
|
49
48
|
let calls = 0
|
|
@@ -68,16 +67,14 @@ test('backend dispatch keeps owner/private gate and never starts a CLI', async (
|
|
|
68
67
|
assert.equal(calls, 1)
|
|
69
68
|
const store = new RunStore(root)
|
|
70
69
|
assert.equal((await store.get('tg_3'))?.status, 'completed')
|
|
71
|
-
await store.patch('tg_3', { status: 'running' })
|
|
72
|
-
await recoverInterruptedRuns(root, true)
|
|
73
|
-
assert.equal((await store.get('tg_3'))?.status, 'queued')
|
|
70
|
+
await store.patch('tg_3', { status: 'running', backendSubmitted: true })
|
|
74
71
|
const cancel = update(4, 42); cancel.message.text = '/cancel'
|
|
75
72
|
await relay.bot.handleUpdate(cancel)
|
|
76
|
-
assert.equal((await store.get('tg_3'))?.status, '
|
|
73
|
+
assert.equal((await store.get('tg_3'))?.status, 'running', 'cancel must retain submitted operation polling')
|
|
77
74
|
} finally { await relay.stop(); globalThis.fetch = originalFetch; await rm(root, { recursive: true, force: true }) }
|
|
78
75
|
})
|
|
79
76
|
|
|
80
|
-
test('backend mode cannot dispatch restricted, external
|
|
77
|
+
test('backend mode cannot dispatch restricted, external or scheduled runs', async () => {
|
|
81
78
|
const { createRelay } = await import('../src/index.js')
|
|
82
79
|
const { ControlStore } = await import('../src/control-state.js')
|
|
83
80
|
const root = await mkdtemp(join(tmpdir(), 'channel-authority-'))
|
|
@@ -92,7 +89,6 @@ test('backend mode cannot dispatch restricted, external, scheduled or update run
|
|
|
92
89
|
await runs.create({ ...common, id: 'restricted', taskId: `task_${'a'.repeat(32)}` })
|
|
93
90
|
await runs.create({ ...common, id: 'external', external: { sourceId: 'fixture', bindingId: 'binding', eventIds: ['1'] } })
|
|
94
91
|
await runs.create({ ...common, id: 'scheduled', scheduled: { id: 's', revision: 'r', dueAt: new Date().toISOString(), pairedAt: owner.pairedAt } })
|
|
95
|
-
await runs.create({ ...common, id: 'r_update_fixture' })
|
|
96
92
|
await relay.drainSources()
|
|
97
93
|
assert.equal(calls, 0); assert.equal(launches, 0)
|
|
98
94
|
assert.ok((await runs.list()).every(run => run.status === 'failed'))
|
|
@@ -8,11 +8,14 @@ import {ControlStore} from '../src/control-state.js'
|
|
|
8
8
|
import {RunStore} from '../src/runs.js'
|
|
9
9
|
import {authorizeDeliveryContext,captureDeliveryContext} from '../src/delivery-context.mjs'
|
|
10
10
|
import {nativeTasks,nativeTaskBinding} from '../src/plugins/native-tasks.mjs'
|
|
11
|
+
import {serveTestLedger} from './helpers/ledger.js'
|
|
11
12
|
|
|
12
13
|
test('authenticated connection sends text and files through ordinary outbox receipts without a native run',async()=>{
|
|
13
14
|
const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'ez-channel-delivery-'))),workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),home=path.join(root,'tools'),hostConfig=path.join(root,'host-executor.json')
|
|
14
15
|
for(const dir of [workspace,controlDir,home])await fs.mkdir(dir)
|
|
15
16
|
const relay=createRelay({controlDir,workspace,pairingTtlMs:1000,executorTimeoutMs:1000,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('Must not launch native execution')})
|
|
17
|
+
// The message CLI child reaches the relay memory ledger through the delivery socket.
|
|
18
|
+
const ledger=await serveTestLedger(controlDir)
|
|
16
19
|
const calls:string[]=[];relay.bot.api.config.use(async(_prev,method)=>{calls.push(method);return {ok:true,result:{message_id:42}} as never})
|
|
17
20
|
try {
|
|
18
21
|
await fs.writeFile(path.join(home,'config.json'),JSON.stringify({schemaVersion:1,workspace,hostConfig}))
|
|
@@ -38,7 +41,7 @@ test('authenticated connection sends text and files through ordinary outbox rece
|
|
|
38
41
|
await relay.drainOutbox();assert.equal(calls.length,2);await assert.rejects(store.waitForDelivery(queued.id),/revoked/)
|
|
39
42
|
await assert.rejects(nativeTasks(home,['--text','denied'],{command:'message',deliveryContext:context}),/revoked/)
|
|
40
43
|
await assert.rejects(store.ownerDeliveryReceipt(context,queued.id),/revoked/)
|
|
41
|
-
} finally {await relay.stop();await fs.rm(root,{recursive:true,force:true})}
|
|
44
|
+
} finally {await relay.stop();await ledger.stop();await fs.rm(root,{recursive:true,force:true})}
|
|
42
45
|
})
|
|
43
46
|
test('delivery authority cannot revive after Telegram relink or same-time owner replacement',async t=>{
|
|
44
47
|
const root=await fs.mkdtemp(path.join(os.tmpdir(),'ez-channel-delivery-epoch-'))
|
|
@@ -16,6 +16,7 @@ import { ownsRun, isOwner } from '../src/identity.js'
|
|
|
16
16
|
import { createRelay } from '../src/index.js'
|
|
17
17
|
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
18
18
|
import { loadConfig } from '../src/config.js'
|
|
19
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
19
20
|
|
|
20
21
|
const secret = () => randomBytes(32).toString('base64url')
|
|
21
22
|
const waitFor = async (check: () => Promise<boolean>) => {
|
|
@@ -87,13 +88,15 @@ test('web owner uses the standard schedule CLI, executor and outbox with Telegra
|
|
|
87
88
|
if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
|
|
88
89
|
const r=spawnSync(process.execPath,[${JSON.stringify(messageCli)},'--text',scheduled?'Scheduled reply':'Chat reply'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr);`)
|
|
89
90
|
EXECUTOR_REGISTRY.grok={...original,command:process.execPath,buildArgs:()=>['--import',require.resolve('tsx'),fixture]}
|
|
90
|
-
const config=loadConfig({
|
|
91
|
+
const config=loadConfig({EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
|
|
91
92
|
const relay=createRelay(config), control=new ControlStore(root,60000), runs=new RunStore(root)
|
|
93
|
+
// Engine children reach the relay memory ledger through the delivery socket.
|
|
94
|
+
const ledger=await serveTestLedger(root)
|
|
92
95
|
const owner=await control.registerOwner('web-owner'),token=secret()
|
|
93
96
|
const binding=(await relay.applicationChannel.bindings.register('web',token,owner))!
|
|
94
97
|
const address=await relay.applicationChannel.listen(0) as {port:number}
|
|
95
98
|
const timer=setInterval(()=>{void relay.drainSources();void relay.drainOutbox()},25)
|
|
96
|
-
t.after(async()=>{clearInterval(timer);await relay.stop();EXECUTOR_REGISTRY.grok=original;await rm(root,{recursive:true,force:true})})
|
|
99
|
+
t.after(async()=>{clearInterval(timer);await relay.stop();EXECUTOR_REGISTRY.grok=original;await ledger.stop();await rm(root,{recursive:true,force:true})})
|
|
97
100
|
const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'chat',scope:'main',text:'Schedule a followup'})
|
|
98
101
|
await waitFor(async()=> (await runs.get(first.id))?.status==='completed')
|
|
99
102
|
await waitFor(async()=> (await runs.list()).some(r=>r.scheduled && r.status==='completed'))
|
|
@@ -141,7 +144,7 @@ for (const mode of ['owner','web','telegram']) test(`${mode} revocation stops it
|
|
|
141
144
|
const control=new ControlStore(root,60000),runs=new RunStore(root),scheduler=new Scheduler(root)
|
|
142
145
|
await control.requestPairing(42,42)
|
|
143
146
|
const owner=await control.approveOwner(42)
|
|
144
|
-
const relay=createRelay(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
147
|
+
const relay=createRelay(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'}),async()=>({child:spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'pipe'}),stdout:'',cleanup:async()=>{}}))
|
|
145
148
|
relay.bot.api.config.use(async()=>({ok:true,result:true}) as never)
|
|
146
149
|
t.after(async()=>{await relay.stop();await rm(root,{recursive:true,force:true})})
|
|
147
150
|
if (mode==='telegram') {
|
|
@@ -110,11 +110,11 @@ if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});send({metho
|
|
|
110
110
|
send({id:q.id,result:q.method==='thread/goal/get'?{goal:null}:{}});});setInterval(()=>{},1000);
|
|
111
111
|
`,{mode:0o700})
|
|
112
112
|
process.env.HOME=root;process.env.PATH=bin+path.delimiter+priorPath
|
|
113
|
-
await assert.rejects(startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId:'r_schedule_/../../escape',timeoutMs:0}),/Invalid native task run ID/)
|
|
113
|
+
await assert.rejects(startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId:'r_schedule_/../../escape',timeoutMs:0,nativeSession:true}),/Invalid native task run ID/)
|
|
114
114
|
await ownerRun(controlDir, 'r_pair_fixture')
|
|
115
115
|
await Promise.all(['r_schedule_one','r_schedule_two'].map(async runId=>{
|
|
116
116
|
await ownerRun(controlDir, runId)
|
|
117
|
-
const job=await startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:0})
|
|
117
|
+
const job=await startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:0,nativeSession:true})
|
|
118
118
|
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
|
|
119
119
|
const home=path.join(controlDir,'cli/codex/tasks',runId)
|
|
120
120
|
assert.equal(JSON.parse(await readFile(path.join(home,'observed.json'),'utf8')).home,home)
|