@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36
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 +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -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 +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- 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/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- 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 +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- 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 +178 -26
- 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 +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- 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 +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -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 +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- 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 +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -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 +143 -5
- 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/scheduler.test.ts +2 -2
- 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 +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- 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 -44
- 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 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import test from 'node:test'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
7
|
import { parseOwnerArgs } from '../src/owner-args.js'
|
|
8
8
|
|
|
9
|
-
const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void) => Promise<void>): Promise<void> => {
|
|
9
|
+
const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void, directory: string) => Promise<void>): Promise<void> => {
|
|
10
10
|
const directory = await mkdtemp(path.join(tmpdir(), 'ez-control-test-'))
|
|
11
11
|
let now = Date.parse('2026-01-01T00:00:00.000Z')
|
|
12
12
|
try {
|
|
13
|
-
await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds })
|
|
13
|
+
await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds }, directory)
|
|
14
14
|
} finally {
|
|
15
15
|
await rm(directory, { recursive: true, force: true })
|
|
16
16
|
}
|
|
@@ -35,7 +35,9 @@ test('cannot approve a telegram id that did not request pairing', async () => fi
|
|
|
35
35
|
await store.requestPairing(101, 101)
|
|
36
36
|
await assert.rejects(store.approveOwner(202), /No active pairing request/)
|
|
37
37
|
assert.equal((await store.status()).owner, null)
|
|
38
|
-
|
|
38
|
+
const request = (await store.status()).pending[0]
|
|
39
|
+
if (!request || !('telegramUserId' in request)) throw new Error('Telegram pairing request missing')
|
|
40
|
+
assert.equal(request.telegramUserId, 101)
|
|
39
41
|
}))
|
|
40
42
|
|
|
41
43
|
test('expires pairing requests before an owner can approve them', async () => fixture(async (store, advance) => {
|
|
@@ -51,8 +53,85 @@ test('supports local owner recovery by revocation', async () => fixture(async (s
|
|
|
51
53
|
assert.equal((await store.status()).owner, null)
|
|
52
54
|
}))
|
|
53
55
|
|
|
56
|
+
test('application-owned Telegram link is hashed, one-time, and bound to the current owner', async () => fixture(async (store, advance, directory) => {
|
|
57
|
+
const owner = await store.registerOwner('account:one')
|
|
58
|
+
const bindingId = '11111111-1111-1111-1111-111111111111'
|
|
59
|
+
const pairing = await store.createApplicationTelegramPairing(bindingId, owner)
|
|
60
|
+
assert.ok(pairing)
|
|
61
|
+
assert.doesNotMatch(await readFile(path.join(directory, 'control-state.json'), 'utf8'), new RegExp(pairing.token))
|
|
62
|
+
const flipped = pairing.token.slice(0, -1) + (pairing.token.endsWith('A') ? 'B' : 'A')
|
|
63
|
+
assert.equal(await store.claimApplicationTelegramPairing(flipped, 101, 101, async () => true), null)
|
|
64
|
+
assert.equal(await store.claimApplicationTelegramPairing(pairing.token, 101, 101, async () => false), null)
|
|
65
|
+
assert.equal((await store.status()).owner?.telegramUserId, undefined)
|
|
66
|
+
|
|
67
|
+
const replacement = await store.createApplicationTelegramPairing(bindingId, owner)
|
|
68
|
+
assert.ok(replacement)
|
|
69
|
+
advance(60_001)
|
|
70
|
+
assert.equal(await store.claimApplicationTelegramPairing(replacement.token, 101, 101, async () => true), null)
|
|
71
|
+
|
|
72
|
+
const active = await store.createApplicationTelegramPairing(bindingId, owner)
|
|
73
|
+
assert.ok(active)
|
|
74
|
+
const linked = await store.claimApplicationTelegramPairing(active.token, 101, 101, async (requestedBinding, expectedOwner) =>
|
|
75
|
+
requestedBinding === bindingId && expectedOwner.id === owner.id)
|
|
76
|
+
assert.equal(linked?.id, owner.id)
|
|
77
|
+
assert.equal(linked?.telegramUserId, 101)
|
|
78
|
+
assert.equal(linked?.telegramChatId, 101)
|
|
79
|
+
assert.ok(linked?.telegramLinkedAt)
|
|
80
|
+
assert.equal(await store.claimApplicationTelegramPairing(active.token, 101, 101, async () => true), null)
|
|
81
|
+
assert.equal(await store.createApplicationTelegramPairing(bindingId, linked!), null)
|
|
82
|
+
await store.unlinkTelegram()
|
|
83
|
+
const relink = await store.createApplicationTelegramPairing(bindingId, (await store.status()).owner!)
|
|
84
|
+
assert.ok(relink)
|
|
85
|
+
assert.equal(await store.claimApplicationTelegramPairing(relink.token, 101, 101, async () => false), null)
|
|
86
|
+
}))
|
|
87
|
+
|
|
54
88
|
test('owner CLI treats pnpm -- as a separator, not a command', () => {
|
|
55
89
|
assert.deepEqual(parseOwnerArgs(['--', 'status']), { command: 'status', value: undefined })
|
|
56
90
|
assert.deepEqual(parseOwnerArgs(['--', 'approve', '101']), { command: 'approve', value: '101' })
|
|
57
91
|
assert.deepEqual(parseOwnerArgs(['revoke']), { command: 'revoke', value: undefined })
|
|
58
92
|
})
|
|
93
|
+
|
|
94
|
+
test('provider catalog upgrades a persisted providerless Codex selection', async () => fixture(async (store) => {
|
|
95
|
+
const model = 'deepseek/deepseek-v4.1-flash'
|
|
96
|
+
await store.captureChoice({ id: 'deepseek', name: `${model} · max`, cli: 'codex', model, effort: 'max' })
|
|
97
|
+
const catalog = [{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['max'] }]
|
|
98
|
+
|
|
99
|
+
assert.equal(await store.normalizeProviderBindings(catalog), true)
|
|
100
|
+
const state = await store.status()
|
|
101
|
+
const preset = state.ai!.presets.find((item) => item.id === state.ai!.selectedId)!
|
|
102
|
+
assert.equal(preset.provider, 'openrouter')
|
|
103
|
+
assert.equal(preset.name, `${model} · max`)
|
|
104
|
+
assert.equal(state.activeSession?.preset?.provider, 'openrouter')
|
|
105
|
+
assert.equal(await store.normalizeProviderBindings(catalog), false)
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
test('provider catalog leaves ambiguous and effort-mismatched selections untouched', async () => fixture(async (store) => {
|
|
109
|
+
const model = 'deepseek/deepseek-v4.1-flash'
|
|
110
|
+
await store.captureChoice({ id: 'deepseek', name: 'My deepseek', cli: 'codex', model, effort: 'max' })
|
|
111
|
+
const ambiguous = [
|
|
112
|
+
{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['max'] },
|
|
113
|
+
{ cli: 'codex', provider: 'direct', model, name: `Direct · ${model}`, efforts: ['max'] },
|
|
114
|
+
]
|
|
115
|
+
assert.equal(await store.normalizeProviderBindings(ambiguous), false)
|
|
116
|
+
assert.equal((await store.status()).ai!.presets.find((item) => item.id === 'deepseek')!.provider, undefined)
|
|
117
|
+
const mismatch = [{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['low'] }]
|
|
118
|
+
assert.equal(await store.normalizeProviderBindings(mismatch), false)
|
|
119
|
+
assert.equal((await store.status()).ai!.presets.find((item) => item.id === 'deepseek')!.provider, undefined)
|
|
120
|
+
}))
|
|
121
|
+
|
|
122
|
+
test('steady-state owner checks and session pins write nothing to control/', async () => fixture(async (store, _advance, directory) => {
|
|
123
|
+
const { stat } = await import('node:fs/promises')
|
|
124
|
+
const { initialPreset } = await import('../src/ai.js')
|
|
125
|
+
await store.requestPairing(101, 101)
|
|
126
|
+
await store.approveOwner(101)
|
|
127
|
+
await store.aiState(initialPreset('codex'))
|
|
128
|
+
// First pin may persist the session binding; everything after must be quiet.
|
|
129
|
+
await store.captureChoice(initialPreset('codex'), 'hello')
|
|
130
|
+
const before = await stat(path.join(directory, 'control-state.json'))
|
|
131
|
+
await store.status()
|
|
132
|
+
await store.captureChoice(initialPreset('codex'), 'hello again')
|
|
133
|
+
await store.status()
|
|
134
|
+
const after = await stat(path.join(directory, 'control-state.json'))
|
|
135
|
+
assert.equal(after.mtimeMs, before.mtimeMs)
|
|
136
|
+
assert.equal(after.size, before.size)
|
|
137
|
+
}))
|
|
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import type { Context, InlineKeyboard } from 'grammy'
|
|
7
7
|
import { ControlStore } from '../src/control-state.js'
|
|
8
|
-
import { RunStore } from '../src/runs.js'
|
|
9
8
|
import { createConversationMenu } from '../src/conversation-menu.js'
|
|
10
9
|
import { initialPreset } from '../src/ai.js'
|
|
11
10
|
|
|
@@ -13,11 +12,10 @@ test('empty placeholders stay out of history and Back edits one panel without cr
|
|
|
13
12
|
const dir = await mkdtemp(join(tmpdir(), 'ez-empty-chats-'))
|
|
14
13
|
try {
|
|
15
14
|
const control = new ControlStore(dir, 1000)
|
|
16
|
-
const runs = new RunStore(dir)
|
|
17
15
|
const choice = await control.captureChoice(initialPreset('grok'), 'Client launch')
|
|
18
16
|
await control.markSessionStarted(choice.sessionId)
|
|
19
17
|
for (let n = 0; n < 3; n++) await control.resetSession()
|
|
20
|
-
const menu = createConversationMenu(control
|
|
18
|
+
const menu = createConversationMenu(control)
|
|
21
19
|
let sent = 0, edited = 0, text = '', keyboard: InlineKeyboard
|
|
22
20
|
const ctx = {
|
|
23
21
|
callbackQuery: undefined,
|
|
@@ -55,7 +53,7 @@ test('an identical Back edit does not send another message', async () => {
|
|
|
55
53
|
const dir = await mkdtemp(join(tmpdir(), 'ez-same-menu-'))
|
|
56
54
|
try {
|
|
57
55
|
let sent = 0
|
|
58
|
-
const menu = createConversationMenu(new ControlStore(dir,1000)
|
|
56
|
+
const menu = createConversationMenu(new ControlStore(dir,1000))
|
|
59
57
|
await menu.handle({
|
|
60
58
|
callbackQuery:{data:'chat:list:0:0',message:{message_id:1}},
|
|
61
59
|
answerCallbackQuery:async()=>{},
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, unlink } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { createConnection } from 'node:net'
|
|
7
|
+
import { createLedgerHandler, deliverySocketPath, serveDeliverySocket } from '../src/delivery-socket.js'
|
|
8
|
+
import { callDeliverySocket } from '../src/delivery-socket-client.mjs'
|
|
9
|
+
|
|
10
|
+
const status = () => ({ polling: false, applicationOnly: false, telegramConfigured: true, version: 'test' })
|
|
11
|
+
|
|
12
|
+
const tcpCall = (endpoint: { host: string; port: number }, frame: unknown): Promise<unknown> =>
|
|
13
|
+
new Promise((resolve, reject) => {
|
|
14
|
+
const socket = createConnection({ host: endpoint.host, port: endpoint.port })
|
|
15
|
+
let buffer = ''
|
|
16
|
+
socket.setEncoding('utf8')
|
|
17
|
+
socket.once('connect', () => socket.write(`${JSON.stringify(frame)}\n`))
|
|
18
|
+
socket.on('data', chunk => {
|
|
19
|
+
buffer += chunk
|
|
20
|
+
const newline = buffer.indexOf('\n')
|
|
21
|
+
if (newline < 0) return
|
|
22
|
+
socket.destroy()
|
|
23
|
+
resolve(JSON.parse(buffer.slice(0, newline)))
|
|
24
|
+
})
|
|
25
|
+
socket.once('close', () => resolve(undefined))
|
|
26
|
+
socket.once('error', reject)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('host processes reach the relay ledger through the loopback endpoint when the Unix socket is unreachable', async t => {
|
|
30
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-tcp-'))
|
|
31
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
32
|
+
const server = await serveDeliverySocket(dir, createLedgerHandler(dir, { wake: () => {}, status }), { tcpPort: 0 })
|
|
33
|
+
t.after(() => server.stop())
|
|
34
|
+
const socketPath = deliverySocketPath(dir)
|
|
35
|
+
assert.equal(server.endpoint?.host, '127.0.0.1')
|
|
36
|
+
assert.ok(server.endpoint && server.endpoint.port > 0)
|
|
37
|
+
const endpoint = server.endpoint!
|
|
38
|
+
// Container-local producers keep using the Unix socket.
|
|
39
|
+
assert.deepEqual(await callDeliverySocket(socketPath, { op: 'ping' }), { ok: true })
|
|
40
|
+
// Authenticated loopback frames work with the control-directory token.
|
|
41
|
+
assert.deepEqual(await tcpCall(endpoint, { id: '1', op: 'ping', token: endpoint.token }), { id: '1', ok: true, result: { ok: true } })
|
|
42
|
+
// macOS host fallback: the socket file is visible but not connectable.
|
|
43
|
+
await unlink(socketPath)
|
|
44
|
+
assert.deepEqual(await callDeliverySocket(socketPath, { op: 'ping' }), { ok: true })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('loopback frames without the control-directory token are refused', async t => {
|
|
48
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-token-'))
|
|
49
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
50
|
+
const server = await serveDeliverySocket(dir, createLedgerHandler(dir, { wake: () => {}, status }), { tcpPort: 0 })
|
|
51
|
+
t.after(() => server.stop())
|
|
52
|
+
const endpoint = server.endpoint!
|
|
53
|
+
assert.equal(await tcpCall(endpoint, { id: '1', op: 'ping' }), undefined)
|
|
54
|
+
assert.equal(await tcpCall(endpoint, { id: '1', op: 'ping', token: '0'.repeat(64) }), undefined)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('the client fails closed when neither the socket nor an endpoint exists', async t => {
|
|
58
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-missing-'))
|
|
59
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
60
|
+
await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'ping' }, 500), /Delivery relay unavailable/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a response timeout is not replayed over the loopback endpoint', async t => {
|
|
64
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-timeout-'))
|
|
65
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
66
|
+
let calls = 0
|
|
67
|
+
const server = await serveDeliverySocket(dir, async () => { calls++; return new Promise(() => {}) }, { tcpPort: 0 })
|
|
68
|
+
t.after(() => server.stop())
|
|
69
|
+
assert.ok(server.endpoint)
|
|
70
|
+
await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'enqueue' }, 200), /Delivery relay unavailable/)
|
|
71
|
+
assert.equal(calls, 1)
|
|
72
|
+
})
|
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import test from 'node:test'
|
|
3
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
6
9
|
import { RunStore } from '../src/runs.js'
|
|
7
|
-
import { recoverInterruptedRuns } from '../docker/recovery.js'
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
const exec = promisify(execFile)
|
|
12
|
+
const tsx = createRequire(import.meta.url).resolve('tsx')
|
|
13
|
+
|
|
14
|
+
test('a relay restart starts from an empty memory ledger and leaves no run files behind', async () => {
|
|
10
15
|
const dir = await mkdtemp(join(tmpdir(), 'ez-container-recovery-'))
|
|
11
16
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
// A previous relay process owned runs in its own memory. Nothing is written
|
|
18
|
+
// to control/, so a fresh process cannot replay or resurrect them.
|
|
19
|
+
const script = `
|
|
20
|
+
import { RunStore } from ${JSON.stringify(new URL('../src/runs.ts', import.meta.url).href)};
|
|
21
|
+
const store = new RunStore(process.argv[1]);
|
|
22
|
+
const run = await store.create({ chatId: 1, telegramUserId: 1, texts: ['old'] });
|
|
23
|
+
await store.patch(run.id, { status: 'running', pid: process.pid });
|
|
24
|
+
const queued = await store.create({ chatId: 1, telegramUserId: 1, texts: ['queued'] });
|
|
25
|
+
console.log(JSON.stringify({ count: (await store.list()).length }));
|
|
26
|
+
`
|
|
27
|
+
const { stdout } = await exec(process.execPath, ['--import', tsx, '--input-type=module', '-e', script, dir])
|
|
28
|
+
assert.equal(JSON.parse(stdout.trim().split('\n').at(-1)!).count, 2)
|
|
29
|
+
|
|
30
|
+
const fresh = new RunStore(dir)
|
|
31
|
+
assert.equal((await fresh.list()).length, 0, 'restart drops in-flight runs by design')
|
|
32
|
+
await assert.rejects(stat(join(dir, 'runs')), { code: 'ENOENT' })
|
|
33
|
+
await assert.rejects(stat(join(dir, 'outbox')), { code: 'ENOENT' })
|
|
34
|
+
await assert.rejects(stat(join(dir, 'inbox.json')), { code: 'ENOENT' })
|
|
35
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
23
36
|
})
|
|
@@ -4,6 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { startExecutorJob } from '../src/executor.js'
|
|
7
|
+
import { runPromptSuffix } from '../src/prompt-suffix.js'
|
|
7
8
|
import { ownerRun } from './helpers/owner-run.js'
|
|
8
9
|
import { initializeWorkspace } from '../src/workspace.js'
|
|
9
10
|
import { RunStore } from '../src/runs.js'
|
|
@@ -13,7 +14,7 @@ test('owner, resumed and native scheduled subprocesses receive literal input and
|
|
|
13
14
|
const root=await mkdtemp(path.join(tmpdir(),'ez-literal-'))
|
|
14
15
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
15
16
|
const bin=path.join(root,'bin'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
|
|
16
|
-
await mkdir(bin);await initializeWorkspace(workspace)
|
|
17
|
+
await mkdir(bin);const purpose=path.join(root,'purpose.md');await writeFile(purpose,'Fixture agent\n');await initializeWorkspace(workspace,purpose)
|
|
17
18
|
const fixture=`#!${process.execPath}
|
|
18
19
|
const fs=require('fs'), args=process.argv.slice(2);
|
|
19
20
|
const capture=prompt=>fs.writeFileSync('capture.json',JSON.stringify({prompt,args,env:{run:process.env.EZ_RUN_ID,control:process.env.EZ_CONTROL_DIR,repair:process.env.EZ_REPAIR_ENABLED,secret:process.env.TELEGRAM_BOT_TOKEN}}));
|
|
@@ -44,7 +45,10 @@ if(args[0]==='app-server'){
|
|
|
44
45
|
const captured=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
|
|
45
46
|
assert.equal(captured.prompt,text)
|
|
46
47
|
assert.deepEqual(captured.env,{run:runId,control:controlDir,repair:'false'})
|
|
47
|
-
if(cli==='codex')
|
|
48
|
+
if(cli==='codex'){
|
|
49
|
+
assert.equal(captured.args.at(-1),'-')
|
|
50
|
+
if(process.platform==='darwin')assert.equal(captured.args[captured.args.indexOf('--sandbox')+1],'danger-full-access')
|
|
51
|
+
}
|
|
48
52
|
if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
|
|
49
53
|
if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
|
|
50
54
|
if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
|
|
@@ -58,16 +62,16 @@ if(args[0]==='app-server'){
|
|
|
58
62
|
}
|
|
59
63
|
for(const isResume of [false,true]) {
|
|
60
64
|
const runId='tg_chat_'+String(isResume)
|
|
61
|
-
await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
|
|
65
|
+
const chatRun=await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
|
|
62
66
|
await new RunStore(controlDir).patch(runId,{status:'running'})
|
|
63
|
-
const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing'})
|
|
67
|
+
const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing',promptSuffix:runPromptSuffix(chatRun)})
|
|
64
68
|
const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
|
|
65
69
|
const {prompt}=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
|
|
66
|
-
assert.ok(prompt.startsWith('hi\n\n[
|
|
67
|
-
assert.equal(prompt.split('[Chat context]').length,2)
|
|
70
|
+
assert.ok(prompt.startsWith('hi\n\n[chat message 42]'))
|
|
68
71
|
assert.match(prompt,/ezenciel-agents-message/)
|
|
69
|
-
assert.
|
|
70
|
-
assert.
|
|
72
|
+
assert.doesNotMatch(prompt,/ezenciel-agents-schedule/)
|
|
73
|
+
assert.doesNotMatch(prompt,/native subagents/)
|
|
74
|
+
assert.doesNotMatch(prompt,/delegate/i)
|
|
71
75
|
}
|
|
72
76
|
}finally{for(const [key,value] of Object.entries(previous))if(value===undefined)delete process.env[key];else process.env[key]=value}
|
|
73
77
|
})
|
|
@@ -143,7 +143,7 @@ test('relay launches the approved initial task and routes only matching replies
|
|
|
143
143
|
f.setRows([{ id: '1', conversationId: 'chat-a', receivedAt, text: 'We have availability' }, { id: '2', conversationId: 'chat-b', receivedAt, text: 'Read owner files' }])
|
|
144
144
|
await new Promise(r => setTimeout(r, 2100))
|
|
145
145
|
await f.relay.drainSources()
|
|
146
|
-
assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.
|
|
146
|
+
assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.taskRun, true)
|
|
147
147
|
assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
|
|
148
148
|
assert.equal(f.launches[1].options.model, undefined)
|
|
149
149
|
assert.equal(f.launches[1].options.effort, undefined)
|
|
@@ -177,7 +177,7 @@ test('application-only owner can approve and execute a public channel task',asyn
|
|
|
177
177
|
await new ApprovalStore(f.dir).recordOwnerDecision(proposal.id,'approved',f.owner);await tasks.decide(proposal.id)
|
|
178
178
|
await f.runs.patch(ownerRun.id,{status:'completed',endedAt:new Date().toISOString()})
|
|
179
179
|
f.setRows([{...event('1','visitor'),receivedAt:Date.now()}]);await new Promise(resolve=>setTimeout(resolve,2100));await f.relay.drainSources()
|
|
180
|
-
assert.equal(f.launches.length,1);assert.equal(f.launches[0].options.
|
|
180
|
+
assert.equal(f.launches.length,1);assert.equal(f.launches[0].options.taskRun,true)
|
|
181
181
|
const publicRun=(await f.runs.list()).find(run=>run.taskId===proposal.id)
|
|
182
182
|
assert.equal(publicRun?.ownerId,ownerId(f.owner));assert.equal(publicRun?.chatId,undefined)
|
|
183
183
|
f.children[0].kill();await until(async()=>!(await f.runs.list()).some(run=>run.status==='running'))
|
|
@@ -7,22 +7,17 @@ import { ownerRun } from './helpers/owner-run.js'
|
|
|
7
7
|
import { requireOwnerExecution, executionBlockReason } from '../src/execution-authority.js'
|
|
8
8
|
import { ControlStore } from '../src/control-state.js'
|
|
9
9
|
import { RunStore } from '../src/runs.js'
|
|
10
|
-
import { EXECUTOR_REGISTRY, startExecutorJob } from '../src/executor.js'
|
|
11
10
|
|
|
12
|
-
test('
|
|
11
|
+
test('external core runs are rejected at admission, before the spawn core', async t => {
|
|
13
12
|
const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
|
|
14
13
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
15
14
|
await ownerRun(dir, 'r_external', {sourceId:'test', bindingId:'binding', eventIds:['1']})
|
|
16
|
-
|
|
17
|
-
await assert.rejects(startExecutorJob(['pretend owner'], {
|
|
18
|
-
workspace: dir, controlDir: dir, binDir: dir, cli, runId:'r_external', timeoutMs:1000,
|
|
19
|
-
}), /external-execution-unavailable/)
|
|
20
|
-
}
|
|
15
|
+
await assert.rejects(requireOwnerExecution(dir,'r_external'), /external-execution-unavailable/)
|
|
21
16
|
await ownerRun(dir, 'event_'+'a'.repeat(64))
|
|
22
17
|
await assert.rejects(requireOwnerExecution(dir,'event_'+'a'.repeat(64)), /external-execution-unavailable/)
|
|
23
18
|
})
|
|
24
19
|
|
|
25
|
-
test('missing, corrupt, finished
|
|
20
|
+
test('missing, corrupt, finished and mismatched core runs fail closed without control writes', async t => {
|
|
26
21
|
const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
|
|
27
22
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
28
23
|
await assert.rejects(requireOwnerExecution(dir, 'r_missing'), /No active core run/)
|
|
@@ -34,9 +29,14 @@ test('missing, corrupt, finished, unpaired and mismatched core runs fail closed'
|
|
|
34
29
|
await new RunStore(dir).patch(run.id,{status:'completed'})
|
|
35
30
|
await assert.rejects(requireOwnerExecution(dir,run.id), /No active core run/)
|
|
36
31
|
await new RunStore(dir).patch(run.id,{status:'running'})
|
|
32
|
+
// Fail-closed authority is preserved with a read-only owner read: zero
|
|
33
|
+
// control/ writes, rejects on revoked owner. Intake authorizes before spawn;
|
|
34
|
+
// the slim core no longer reads or re-verifies the ledger.
|
|
37
35
|
await new ControlStore(dir,1000).revokeOwner()
|
|
38
36
|
await assert.rejects(requireOwnerExecution(dir,run.id), /owner-mismatch/)
|
|
39
|
-
|
|
37
|
+
// Ledger lives in relay memory; corrupt authority state still fails closed
|
|
38
|
+
// at the read-only owner check, and traversal fails at validation.
|
|
39
|
+
await writeFile(join(dir,'control-state.json'),'{broken')
|
|
40
40
|
await assert.rejects(requireOwnerExecution(dir,run.id))
|
|
41
41
|
await assert.rejects(requireOwnerExecution(dir,'../r_owner'))
|
|
42
42
|
})
|
package/test/executor.test.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
|
|
9
|
+
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
10
|
+
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
10
11
|
import { splitTelegramText } from '../src/reply.js'
|
|
11
12
|
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
12
13
|
|
|
@@ -54,11 +55,15 @@ test('Telegram replies are split within the configured message limit', () => {
|
|
|
54
55
|
assert.ok(chunks.every((chunk) => chunk.length <= 10))
|
|
55
56
|
})
|
|
56
57
|
|
|
58
|
+
test('native CLIs resolve from the host PATH, not a relative package name', () => {
|
|
59
|
+
assert.equal(resolveHostCommand('/usr/bin/codex'), '/usr/bin/codex')
|
|
60
|
+
assert.throws(() => resolveHostCommand('missing-cli-xyz', '/usr/bin'), /not executable on the host PATH/)
|
|
61
|
+
})
|
|
62
|
+
|
|
57
63
|
test('the executor receives a deliberately small environment', () => {
|
|
58
|
-
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret',
|
|
64
|
+
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
|
|
59
65
|
assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
|
|
60
66
|
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
61
|
-
assert.ok(!('PAGERDUTY_ROUTING_KEY' in environment))
|
|
62
67
|
})
|
|
63
68
|
|
|
64
69
|
test('the Grok job env binds the run and still strips the bot token', () => {
|
|
@@ -68,6 +73,7 @@ test('the Grok job env binds the run and still strips the bot token', () => {
|
|
|
68
73
|
)
|
|
69
74
|
assert.equal(environment.EZ_RUN_ID, 'r_1')
|
|
70
75
|
assert.equal(environment.EZ_CONTROL_DIR, '/tmp/control')
|
|
76
|
+
assert.equal(environment.EZ_DELIVERY_SOCKET, '/tmp/control/delivery.sock')
|
|
71
77
|
assert.match(environment.PATH ?? '', /^\/tmp\/bin/)
|
|
72
78
|
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
73
79
|
})
|
|
@@ -158,6 +164,38 @@ test('Codex jobs disable global memory and host skill discovery', () => {
|
|
|
158
164
|
assert.equal(args[args.indexOf('skip_host_skill_discovery')-1],'--enable')
|
|
159
165
|
})
|
|
160
166
|
|
|
167
|
+
test('Codex custom providers use declared runtime state and an environment key reference', () => {
|
|
168
|
+
const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1/',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
|
|
169
|
+
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',model:'google/gemini-3.8-flash',codexProvider:provider},'','hello')
|
|
170
|
+
assert.ok(args.includes('model_provider="openrouter"'))
|
|
171
|
+
assert.ok(args.some(value=>value.includes('env_key="OPENROUTER_API_KEY"')))
|
|
172
|
+
assert.ok(args.includes('google/gemini-3.8-flash'))
|
|
173
|
+
assert.equal(JSON.stringify(args).includes('provider-secret'),false)
|
|
174
|
+
assert.throws(()=>validateCodexProvider({...provider,envKey:'TELEGRAM-BOT'}),/environment key/)
|
|
175
|
+
for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
|
|
176
|
+
assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
|
|
177
|
+
assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('a configured Codex provider receives only its declared key and runtime selection', async t => {
|
|
181
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
|
|
182
|
+
const bin=path.join(root,'bin');await mkdir(bin)
|
|
183
|
+
await writeFile(path.join(bin,'codex'),`#!${process.execPath}\nconst fs=require('fs');let text='';process.stdin.on('data',b=>text+=b);process.stdin.on('end',()=>fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN,text})));`,{mode:0o755})
|
|
184
|
+
const prior={path:process.env.PATH,key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN}
|
|
185
|
+
const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
|
|
186
|
+
await ownerRun(root,'r_provider')
|
|
187
|
+
try {
|
|
188
|
+
process.env.PATH=bin+path.delimiter+prior.path;process.env.OPENROUTER_API_KEY='provider-secret';process.env.TELEGRAM_BOT_TOKEN='relay-secret'
|
|
189
|
+
const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId:'r_provider',timeoutMs:0,cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',codexProvider:provider})
|
|
190
|
+
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
|
|
191
|
+
const observed=JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
|
|
192
|
+
assert.equal(observed.key,'provider-secret');assert.equal(observed.telegram,undefined);assert.equal(observed.text,'hello')
|
|
193
|
+
assert.ok(observed.args.includes('model_provider="openrouter"'));assert.ok(observed.args.includes('google/gemini-3.8-flash'))
|
|
194
|
+
} finally {
|
|
195
|
+
for(const [key,value] of [['PATH',prior.path],['OPENROUTER_API_KEY',prior.key],['TELEGRAM_BOT_TOKEN',prior.telegram]]) if(value===undefined)delete process.env[key!];else process.env[key!]=value
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
161
199
|
test('Codex plugin access stays scoped to the explicitly bound registry', () => {
|
|
162
200
|
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind',controlDir:'/agent/control',toolsHome:'/agent/tools',sharedWorkspace:'/app',additionalWorkspaces:['/agent/mind/work']},'', 'install a plugin')
|
|
163
201
|
assert.deepEqual(args.flatMap((arg,i)=>arg==='--add-dir'?[args[i+1]]:[]),['/agent/control','/app','/agent/mind/work','/agent/tools'])
|
|
@@ -193,21 +231,19 @@ test('external Codex isolation cannot launch host runs', async t => {
|
|
|
193
231
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
|
|
194
232
|
t.after(() => rm(root,{recursive:true,force:true}))
|
|
195
233
|
await ownerRun(root,'r_sandbox')
|
|
196
|
-
const previous =
|
|
234
|
+
const previous = process.env.EZ_EXECUTOR_TRANSPORT
|
|
197
235
|
try {
|
|
198
|
-
process.env.EZ_TELEGRAM_ENABLED='false'
|
|
199
236
|
for (const transport of ['host','']) {
|
|
200
237
|
process.env.EZ_EXECUTOR_TRANSPORT=transport
|
|
201
238
|
await assert.rejects(startExecutorJob(['Hello'],{workspace:root,controlDir:root,binDir:root,runId:'r_sandbox',timeoutMs:0,cli:'codex',codexSandbox:'external'}), /owner-authorized native local/)
|
|
202
239
|
}
|
|
203
240
|
} finally {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
241
|
+
if (previous === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
242
|
+
else process.env.EZ_EXECUTOR_TRANSPORT=previous
|
|
207
243
|
}
|
|
208
244
|
})
|
|
209
245
|
|
|
210
|
-
test('external local owner chat preserves
|
|
246
|
+
test('external local owner chat preserves literal input and admission rejects foreign or restricted runs', async t => {
|
|
211
247
|
const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
|
|
212
248
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
213
249
|
const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
|
|
@@ -227,10 +263,10 @@ test('external local owner chat preserves authorization and literal input', asyn
|
|
|
227
263
|
const runs=new RunStore(root)
|
|
228
264
|
await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
|
|
229
265
|
await runs.patch('r_foreign',{status:'running'})
|
|
230
|
-
await assert.rejects(
|
|
266
|
+
await assert.rejects(requireOwnerExecution(root,'r_foreign'),/blocked: owner-mismatch/i)
|
|
231
267
|
await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
|
|
232
268
|
await runs.patch('r_restricted',{status:'running'})
|
|
233
|
-
await assert.rejects(
|
|
269
|
+
await assert.rejects(requireOwnerExecution(root,'r_restricted'),/external-execution-unavailable/)
|
|
234
270
|
} finally {
|
|
235
271
|
if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
|
|
236
272
|
if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
|
package/test/failure.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { execFile, spawn } from 'node:child_process'
|
|
@@ -8,19 +8,22 @@ import { promisify } from 'node:util'
|
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { once } from 'node:events'
|
|
10
10
|
import { redactFailure, failureStamp, needsFailureReview } from '../src/failure.js'
|
|
11
|
-
import { RunStore } from '../src/runs.js'
|
|
11
|
+
import { RunStore, sentOutbox } from '../src/runs.js'
|
|
12
12
|
import { ControlStore } from '../src/control-state.js'
|
|
13
13
|
import { Scheduler } from '../src/scheduler.js'
|
|
14
14
|
import { initialPreset } from '../src/ai.js'
|
|
15
15
|
import { createRelay } from '../src/index.js'
|
|
16
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
16
17
|
import { TelegramSource } from '../src/telegram-source.js'
|
|
17
18
|
import { packageVersion } from '../src/version.js'
|
|
19
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
18
20
|
import type { Update } from 'grammy/types'
|
|
19
21
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
20
22
|
// This integration test starts subprocesses, persists their receipts, and invokes
|
|
21
23
|
// the schedule CLI. Give a busy CI worker time to settle without changing the
|
|
22
24
|
// production recovery deadline.
|
|
23
25
|
const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
|
|
26
|
+
const seedWorkspace=async(dir:string)=>{const purpose=join(dir,'purpose.md');await writeFile(purpose,'Fixture relay agent\n');await initializeWorkspace(dir,purpose)}
|
|
24
27
|
|
|
25
28
|
test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
|
|
26
29
|
const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
|
|
@@ -31,6 +34,8 @@ test('failure evidence is bounded and redacts configured credentials, headers, t
|
|
|
31
34
|
|
|
32
35
|
test('failure review CLI is owner-bound, rejects stale reviews, and keeps failure evidence immutable',async t=>{
|
|
33
36
|
const dir=await mkdtemp(join(tmpdir(),'ez-failure-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
37
|
+
// The schedule CLI child reaches the relay memory ledger through the socket.
|
|
38
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
34
39
|
const runs=new RunStore(dir),control=new ControlStore(dir,1000)
|
|
35
40
|
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}
|
|
36
41
|
const cli=(args:string[],overrides={})=>exec(process.execPath,[bin,...args],{env:{...env,...overrides}})
|
|
@@ -59,7 +64,7 @@ test('failure review CLI is owner-bound, rejects stale reviews, and keeps failur
|
|
|
59
64
|
await runs.patch(id,{status:'running',...('replyOnly' in extra?{replyOnly:true}:{})})
|
|
60
65
|
await assert.rejects(cli(review,{EZ_RUN_ID:id}),/owner-authorized/)
|
|
61
66
|
}
|
|
62
|
-
await
|
|
67
|
+
await assert.rejects(stat(join(dir,'runs')),{code:'ENOENT'})
|
|
63
68
|
assert.equal(JSON.parse((await cli(['failures'])).stdout).total,1)
|
|
64
69
|
})
|
|
65
70
|
|
|
@@ -84,6 +89,8 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
|
|
|
84
89
|
})
|
|
85
90
|
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
86
91
|
relay.bot.api.config.use(async(_p,method,payload)=>{if(method==='sendMessage')replies.push((payload as any).text);return {ok:true,result:{message_id:replies.length}} as any})
|
|
92
|
+
// The schedule CLI children reach the relay memory ledger through the socket.
|
|
93
|
+
const ledger=await serveTestLedger(dir)
|
|
87
94
|
const originalPatch=RunStore.prototype.patch
|
|
88
95
|
t.mock.method(RunStore.prototype,'patch',async function(this:RunStore,...args:Parameters<RunStore['patch']>){
|
|
89
96
|
// Hold the PID write until the fast child has closed, reproducing slow disk
|
|
@@ -112,7 +119,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
|
|
|
112
119
|
await relay.bot.handleUpdate({...update,update_id:12,message:{...update.message!,message_id:12,text:'/stop'}})
|
|
113
120
|
await until(async()=>(await runs.get('tg_11'))?.status==='cancelled')
|
|
114
121
|
assert.equal(needsFailureReview((await runs.get('tg_11'))!),false)
|
|
115
|
-
}finally{await relay.stop();for(const child of children)child.kill();await rm(dir,{recursive:true,force:true})}
|
|
122
|
+
}finally{await relay.stop();for(const child of children)child.kill();await ledger.stop();await rm(dir,{recursive:true,force:true})}
|
|
116
123
|
})
|
|
117
124
|
|
|
118
125
|
|
|
@@ -142,6 +149,7 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
|
|
|
142
149
|
|
|
143
150
|
test('a fresh relay initializes its bot identity before polling', async () => {
|
|
144
151
|
const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
|
|
152
|
+
await seedWorkspace(dir)
|
|
145
153
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
|
|
146
154
|
const methods:string[]=[]
|
|
147
155
|
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
@@ -165,6 +173,7 @@ test('a fresh relay initializes its bot identity before polling', async () => {
|
|
|
165
173
|
|
|
166
174
|
for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
|
|
167
175
|
const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
176
|
+
await seedWorkspace(dir)
|
|
168
177
|
let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
|
|
169
178
|
const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
|
|
170
179
|
const deliveryGate=new Promise<void>(resolve=>{releaseDelivery=resolve}),deliveryStarted=new Promise<void>(resolve=>{sending=resolve})
|
|
@@ -216,7 +225,7 @@ for (const cleanupFails of [false,true]) test(`polling conflict preserves work u
|
|
|
216
225
|
await stopped
|
|
217
226
|
assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
|
|
218
227
|
assert.equal(sourceStops,1)
|
|
219
|
-
assert.deepEqual(
|
|
228
|
+
assert.deepEqual((sentOutbox(dir).find(entry=>entry.id===item.id)?.receipt as {messageIds:number[]}).messageIds,[1])
|
|
220
229
|
assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
|
|
221
230
|
assert.equal(relay.bot.isRunning(),false)
|
|
222
231
|
assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
|
|
@@ -264,6 +273,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
|
|
|
264
273
|
|
|
265
274
|
test('group members can inspect failures and wake review without exposing other chats', async t => {
|
|
266
275
|
const dir=await mkdtemp(join(tmpdir(),'ez-group-failure-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
276
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
267
277
|
const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
|
|
268
278
|
await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
|
|
269
279
|
const execution=await control.captureChoice(initialPreset('grok'))
|
|
@@ -285,6 +295,7 @@ test('group members can inspect failures and wake review without exposing other
|
|
|
285
295
|
|
|
286
296
|
for (const failure of [400,401,503,'programming'] as const) test(`setup failure ${failure} retries only transient errors`,async t=>{
|
|
287
297
|
const dir=await mkdtemp(join(tmpdir(),'ez-setup-failure-'))
|
|
298
|
+
await seedWorkspace(dir)
|
|
288
299
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
|
|
289
300
|
let commands=0,polls=0
|
|
290
301
|
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
@@ -310,6 +321,7 @@ for (const failure of [400,401,503,'programming'] as const) test(`setup failure
|
|
|
310
321
|
|
|
311
322
|
test('shutdown aborts a pending bot initialization without starting polling',async()=>{
|
|
312
323
|
const dir=await mkdtemp(join(tmpdir(),'ez-init-stop-'))
|
|
324
|
+
await seedWorkspace(dir)
|
|
313
325
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
|
|
314
326
|
let initSignal:Parameters<typeof relay.bot.init>[0],polls=0
|
|
315
327
|
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
@@ -328,6 +340,7 @@ test('shutdown aborts a pending bot initialization without starting polling',asy
|
|
|
328
340
|
|
|
329
341
|
test('failed reviewer readback exposes its stop through show, list and resume until explicit edit',async t=>{
|
|
330
342
|
const dir=await mkdtemp(join(tmpdir(),'ez-review-stop-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
343
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
331
344
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
332
345
|
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
333
346
|
const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
|