@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.18
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 +4 -0
- package/.env.example +16 -1
- package/AGENTS.md +16 -4
- package/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +37 -4
- package/README.md +114 -9
- package/SECURITY.md +7 -1
- package/bin/ezenciel-agents-schedule +2 -0
- package/bin/ezenciel-agents-schedule.mjs +16 -0
- package/bin/ezenciel-agents-task +2 -0
- package/bin/ezenciel-agents-task.mjs +16 -0
- package/compose.yaml +10 -2
- package/docker/recovery.ts +2 -2
- package/docker/run.ts +2 -2
- package/docs/architecture/ai-selection.md +8 -0
- package/docs/architecture/authority-boundaries.md +137 -12
- package/docs/architecture/event-sources.md +12 -7
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/channel-backend.md +36 -0
- package/docs/docker-runtime.md +35 -0
- package/docs/host-service.md +19 -0
- package/docs/local-qa.md +45 -0
- package/docs/pagerduty.md +42 -0
- package/docs/plugin-catalog.md +71 -0
- package/docs/plugin-contributions.md +12 -0
- package/docs/plugins.md +61 -1
- package/docs/releasing.md +20 -9
- package/docs/repair.md +41 -0
- package/docs/scheduling.md +153 -0
- package/docs/selective-monitoring.md +114 -0
- package/docs/setup.md +46 -0
- package/docs/standalone-cli.md +62 -0
- package/docs/trusted-publishing.md +140 -0
- package/docs/upgrades.md +24 -4
- package/package.json +12 -4
- package/scripts/generate-publish-caller.mjs +60 -0
- package/scripts/smoke-busy-reply.ts +58 -0
- package/scripts/smoke-scheduler.ts +90 -0
- package/scripts/stage-qa.mjs +42 -0
- package/scripts/trusted-beta.mjs +289 -0
- package/src/agent-guidance.ts +5 -0
- package/src/ai-cli.ts +2 -1
- package/src/ai.ts +15 -5
- package/src/channel-backend.ts +46 -0
- package/src/client-defaults.ts +29 -13
- package/src/codex-session.ts +98 -0
- package/src/config.ts +35 -2
- package/src/control-state.ts +24 -7
- package/src/desktop-bridge.ts +37 -12
- package/src/event-sources.ts +2 -1
- package/src/execution-authority.ts +25 -0
- package/src/executor.ts +97 -21
- package/src/failure.ts +32 -0
- package/src/host-executor.ts +48 -19
- package/src/identity.ts +8 -3
- package/src/inbox.ts +11 -3
- package/src/index.ts +315 -91
- package/src/install-tools.mjs +2 -2
- package/src/menu.ts +6 -4
- package/src/model-policy.ts +15 -0
- package/src/owner.ts +3 -3
- package/src/pagerduty.ts +109 -0
- package/src/plugins/exposure.mjs +13 -0
- package/src/plugins/manager.mjs +74 -20
- package/src/plugins/shared.mjs +76 -0
- package/src/process-tree.ts +33 -0
- package/src/repair-policy.ts +13 -0
- package/src/reply-context.ts +67 -0
- package/src/reply-executor.ts +54 -0
- package/src/reply-mcp.ts +23 -0
- package/src/runs.ts +63 -19
- package/src/schedule-cli.ts +98 -0
- package/src/schedule-time.ts +85 -0
- package/src/scheduler.ts +130 -0
- package/src/setup.ts +2 -1
- package/src/software-status.ts +5 -5
- package/src/source-cli.ts +1 -1
- package/src/task-cli.ts +16 -0
- package/src/task-executor.ts +65 -0
- package/src/task-mcp.ts +36 -0
- package/src/task-rpc.ts +45 -0
- package/src/task-workspace.ts +22 -0
- package/src/tasks.ts +210 -0
- package/src/telegram-source.ts +94 -0
- package/src/updates/artifact.mjs +16 -0
- package/src/updates/binding.mjs +4 -1
- package/src/updates/control.mjs +4 -4
- package/src/updates/runtime.mjs +3 -1
- package/src/updates/status.mjs +7 -1
- package/templates/agent/AGENTS.md +10 -2
- package/templates/agent/TOOLS.md +60 -1
- package/templates/agent-guidance.md +13 -0
- package/templates/failure-review.md +9 -0
- package/templates/maintainer-purpose.md +15 -0
- package/templates/standalone-tools.md +20 -0
- package/templates/updates.md +2 -2
- package/test/agent-guidance.test.ts +110 -0
- package/test/ai-cli.test.ts +7 -6
- package/test/ai.test.ts +41 -0
- package/test/busy-reply-relay.test.ts +41 -0
- package/test/channel-backend.test.ts +100 -0
- package/test/client-defaults.test.ts +37 -5
- package/test/codex-context.test.ts +39 -1
- package/test/codex-session.test.ts +51 -0
- package/test/config.test.ts +31 -2
- package/test/desktop-bridge.test.ts +19 -0
- package/test/event-sources.test.ts +47 -11
- package/test/execution-authority.test.ts +42 -0
- package/test/executor.test.ts +53 -2
- package/test/failure.test.ts +250 -0
- package/test/group-owner.test.ts +36 -0
- package/test/helpers/owner-run.ts +13 -0
- package/test/host-executor.test.ts +47 -10
- package/test/intake-relay.test.ts +141 -4
- package/test/local-qa.test.mjs +38 -0
- package/test/model-policy.test.ts +61 -0
- package/test/pagerduty.test.ts +104 -0
- package/test/plugin-manager.test.mjs +73 -3
- package/test/relay.test.ts +2 -2
- package/test/repair-policy.test.ts +23 -0
- package/test/reply.test.ts +131 -0
- package/test/schedule-cli.test.ts +55 -0
- package/test/scheduler-host.test.ts +55 -0
- package/test/scheduler-relay.test.ts +67 -0
- package/test/scheduler.test.ts +104 -0
- package/test/shared-services.test.mjs +98 -0
- package/test/software-status.test.ts +5 -5
- package/test/task-native.test.ts +87 -0
- package/test/tasks.test.ts +187 -0
- package/test/telegram-source.test.ts +75 -0
- package/test/trusted-beta.test.mjs +224 -0
- package/test/updates.test.mjs +35 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { dispatchChannel } from '../src/channel-backend.js'
|
|
7
|
+
import { stageIncomingFile } from '../src/files.js'
|
|
8
|
+
import { RunStore } from '../src/runs.js'
|
|
9
|
+
|
|
10
|
+
const config = { telegramBotToken: 'never-forward-this', workspace: '', controlDir: '', pairingTtlMs: 10,
|
|
11
|
+
executorTimeoutMs: 1000, executorCli: 'codex', channelBackendUrl: 'https://example.invalid/events', channelBackendToken: 'private' }
|
|
12
|
+
|
|
13
|
+
test('structured photo transport and deterministic reply recovery', async () => {
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), 'channel-'))
|
|
15
|
+
const originalFetch = globalThis.fetch
|
|
16
|
+
try {
|
|
17
|
+
const file = await stageIncomingFile(root, 'meal.jpg', Buffer.from([0xff, 0xd8, 0xff, 1]))
|
|
18
|
+
const store = new RunStore(root)
|
|
19
|
+
const run = await store.create({ id: 'tg_1', chatId: 42, telegramUserId: 42, texts: [], items: [
|
|
20
|
+
{ text: 'internal path', caption: '', attachment: { path: file.relativePath, type: 'jpeg' },
|
|
21
|
+
updateId: 1, chatId: 42, fromId: 42, messageId: 7, sentAt: 1234 }] })
|
|
22
|
+
globalThis.fetch = async (_url, options) => {
|
|
23
|
+
const body = JSON.parse(String(options?.body))
|
|
24
|
+
assert.equal(body.items[0].text, '')
|
|
25
|
+
assert.equal(body.items[0].attachment.data, '/9j/AQ==')
|
|
26
|
+
assert.equal(body.sender_id, '42')
|
|
27
|
+
assert.ok(!String(options?.body).includes('never-forward-this'))
|
|
28
|
+
assert.ok(!String(options?.body).includes(file.relativePath))
|
|
29
|
+
return new Response(JSON.stringify({ status: 'complete', reply: 'Meal saved' }))
|
|
30
|
+
}
|
|
31
|
+
assert.equal(await dispatchChannel({ ...config, workspace: root }, run), 'Meal saved')
|
|
32
|
+
const a = await store.enqueueMessage(run.id, 'Meal saved', { id: 'tg_1_backend' })
|
|
33
|
+
await store.claimOutbox(a.id)
|
|
34
|
+
const b = await store.enqueueMessage(run.id, 'Meal saved', { id: 'tg_1_backend' })
|
|
35
|
+
assert.equal(a.id, b.id)
|
|
36
|
+
assert.equal((await store.pendingOutbox()).length, 0)
|
|
37
|
+
await assert.rejects(dispatchChannel({ ...config, channelBackendUrl: 'http://example.invalid/events' }, run), /HTTPS/)
|
|
38
|
+
run.items![0].attachment!.path = '../secret'
|
|
39
|
+
await assert.rejects(dispatchChannel({ ...config, workspace: root }, run))
|
|
40
|
+
} finally { globalThis.fetch = originalFetch; await rm(root, { recursive: true, force: true }) }
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('backend dispatch keeps owner/private gate and never starts a CLI', async () => {
|
|
44
|
+
const { createRelay } = await import('../src/index.js')
|
|
45
|
+
const { ControlStore } = await import('../src/control-state.js')
|
|
46
|
+
const { recoverInterruptedRuns } = await import('../docker/recovery.js')
|
|
47
|
+
const root = await mkdtemp(join(tmpdir(), 'channel-gate-'))
|
|
48
|
+
const originalFetch = globalThis.fetch
|
|
49
|
+
let calls = 0
|
|
50
|
+
const relay = createRelay({ ...config, workspace: root, controlDir: root }, async () => { throw new Error('Must never launch CLI') })
|
|
51
|
+
relay.bot.botInfo = { id: 999, is_bot: true, first_name: 'Fixture', username: 'fixture_bot' } as typeof relay.bot.botInfo
|
|
52
|
+
relay.bot.api.config.use(async () => ({ ok: true, result: { message_id: 9 } }) as never)
|
|
53
|
+
const control = new ControlStore(root, 1000)
|
|
54
|
+
try {
|
|
55
|
+
await control.requestPairing(42, 42); await control.approveOwner(42)
|
|
56
|
+
globalThis.fetch = async () => { calls++; return new Response(JSON.stringify({ status: 'complete', reply: 'Done' })) }
|
|
57
|
+
const update = (id: number, sender: number, group = false) => ({ update_id: id, message: {
|
|
58
|
+
message_id: id, date: 1234, text: 'Hello', from: { id: sender, is_bot: false, first_name: 'Test' },
|
|
59
|
+
chat: group ? { id: -42, type: 'group' as const, title: 'Group' } : { id: sender, type: 'private' as const, first_name: 'Test' } } })
|
|
60
|
+
await relay.bot.handleUpdate(update(1, 43))
|
|
61
|
+
await relay.bot.handleUpdate(update(2, 42, true))
|
|
62
|
+
await relay.drainInbox(true)
|
|
63
|
+
assert.equal(calls, 0)
|
|
64
|
+
await relay.bot.handleUpdate(update(3, 42))
|
|
65
|
+
await relay.drainInbox(true)
|
|
66
|
+
for (let i = 0; i < 20 && !(await new RunStore(root).get('tg_3'))?.endedAt; i++)
|
|
67
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
68
|
+
assert.equal(calls, 1)
|
|
69
|
+
const store = new RunStore(root)
|
|
70
|
+
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')
|
|
74
|
+
const cancel = update(4, 42); cancel.message.text = '/cancel'
|
|
75
|
+
await relay.bot.handleUpdate(cancel)
|
|
76
|
+
assert.equal((await store.get('tg_3'))?.status, 'queued', 'cancel must retain submitted operation polling')
|
|
77
|
+
} finally { await relay.stop(); globalThis.fetch = originalFetch; await rm(root, { recursive: true, force: true }) }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('backend mode cannot dispatch restricted, external, scheduled or update runs', async () => {
|
|
81
|
+
const { createRelay } = await import('../src/index.js')
|
|
82
|
+
const { ControlStore } = await import('../src/control-state.js')
|
|
83
|
+
const root = await mkdtemp(join(tmpdir(), 'channel-authority-'))
|
|
84
|
+
const originalFetch = globalThis.fetch
|
|
85
|
+
let calls = 0, launches = 0
|
|
86
|
+
const relay = createRelay({ ...config, workspace: root, controlDir: root }, async () => { launches++; throw new Error('No CLI fallback') })
|
|
87
|
+
try {
|
|
88
|
+
const control = new ControlStore(root, 1000)
|
|
89
|
+
await control.requestPairing(42, 42); const owner = await control.approveOwner(42)
|
|
90
|
+
globalThis.fetch = async () => { calls++; throw new Error('No application dispatch') }
|
|
91
|
+
const runs = new RunStore(root), common = { chatId: 42, telegramUserId: 42, texts: ['Do not forward'] }
|
|
92
|
+
await runs.create({ ...common, id: 'restricted', taskId: `task_${'a'.repeat(32)}` })
|
|
93
|
+
await runs.create({ ...common, id: 'external', external: { sourceId: 'fixture', bindingId: 'binding', eventIds: ['1'] } })
|
|
94
|
+
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
|
+
await relay.drainSources()
|
|
97
|
+
assert.equal(calls, 0); assert.equal(launches, 0)
|
|
98
|
+
assert.ok((await runs.list()).every(run => run.status === 'failed'))
|
|
99
|
+
} finally { await relay.stop(); globalThis.fetch = originalFetch; await rm(root, { recursive: true, force: true }) }
|
|
100
|
+
})
|
|
@@ -3,9 +3,9 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
-
import { discoverDefaults, grokSettings } from '../src/client-defaults.js'
|
|
6
|
+
import { discoverDefaults, grokSettings, resolvedCodexDefaults } from '../src/client-defaults.js'
|
|
7
7
|
import { ControlStore } from '../src/control-state.js'
|
|
8
|
-
import { initialPreset } from '../src/ai.js'
|
|
8
|
+
import { initialPreset, statusPreset } from '../src/ai.js'
|
|
9
9
|
|
|
10
10
|
test('discovery projects configured choices only; no credentials or guessed wrapper defaults', async () => {
|
|
11
11
|
const home = await mkdtemp(join(tmpdir(), 'ez-defaults-'))
|
|
@@ -15,8 +15,11 @@ test('discovery projects configured choices only; no credentials or guessed wrap
|
|
|
15
15
|
await mkdir(join(home, '.claude'))
|
|
16
16
|
await writeFile(join(home, '.claude/settings.json'), JSON.stringify({ model: 'fixture-claude', effortLevel: 'medium', apiKey: 'fixture-secret' }))
|
|
17
17
|
const choices = await discoverDefaults(home, {
|
|
18
|
-
home, available: async () => true,
|
|
19
|
-
codex: async () =>
|
|
18
|
+
home, codexHome: '/agent/control/cli/codex', available: async () => true,
|
|
19
|
+
codex: async (_cwd, codexHome) => {
|
|
20
|
+
assert.equal(codexHome, '/agent/control/cli/codex')
|
|
21
|
+
return { model: 'fixture-codex', effort: 'low', apiKey: 'fixture-secret' }
|
|
22
|
+
},
|
|
20
23
|
run: async () => JSON.stringify({ model: 'fixture/provider', token: 'fixture-secret' }),
|
|
21
24
|
})
|
|
22
25
|
assert.deepEqual(choices.map(({ cli, model, effort }) => ({ cli, model, effort })), [
|
|
@@ -24,13 +27,32 @@ test('discovery projects configured choices only; no credentials or guessed wrap
|
|
|
24
27
|
{ cli: 'codex', model: 'fixture-codex', effort: 'low' },
|
|
25
28
|
{ cli: 'claude', model: 'fixture-claude', effort: 'medium' },
|
|
26
29
|
{ cli: 'opencode', model: 'fixture/provider', effort: undefined },
|
|
27
|
-
{ cli: 'codex-gui', model:
|
|
30
|
+
{ cli: 'codex-gui', model: undefined, effort: undefined },
|
|
28
31
|
])
|
|
29
32
|
assert.ok(!JSON.stringify(choices).includes('fixture-secret'))
|
|
30
33
|
assert.ok(choices.every((p) => /^detected_[a-z0-9]+$/.test(p.id)))
|
|
31
34
|
} finally { await rm(home, { recursive: true, force: true }) }
|
|
32
35
|
})
|
|
33
36
|
|
|
37
|
+
test('Codex config defaults fall back to its native default-model catalog', () => {
|
|
38
|
+
const catalog = [
|
|
39
|
+
{ id: 'fast', model: 'gpt-fast', defaultReasoningEffort: 'low' },
|
|
40
|
+
{ id: 'default', model: 'gpt-default', defaultReasoningEffort: 'medium', isDefault: true },
|
|
41
|
+
]
|
|
42
|
+
assert.deepEqual(resolvedCodexDefaults({}, catalog), { model: 'gpt-default', effort: 'medium' })
|
|
43
|
+
assert.deepEqual(resolvedCodexDefaults({ model: 'gpt-fast' }, catalog), { model: 'gpt-fast', effort: 'low' })
|
|
44
|
+
assert.deepEqual(resolvedCodexDefaults({ model: 'custom', model_reasoning_effort: 'high' }, catalog),
|
|
45
|
+
{ model: 'custom', effort: 'high' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('native Codex fallback is opt-in so setup does not pin a client default', async () => {
|
|
49
|
+
const codex = async (_cwd: string, _home?: string, nativeFallback = false) =>
|
|
50
|
+
nativeFallback ? { model: 'native-default', effort: 'medium' } : {}
|
|
51
|
+
const options = { available: async (cli: string) => cli === 'codex', codex }
|
|
52
|
+
assert.equal((await discoverDefaults('/tmp', options))[0]?.model, undefined)
|
|
53
|
+
assert.equal((await discoverDefaults('/tmp', { ...options, nativeCodexFallback: true }))[0]?.model, 'native-default')
|
|
54
|
+
})
|
|
55
|
+
|
|
34
56
|
test('missing clients are excluded; unavailable metadata stays client default', async () => {
|
|
35
57
|
const choices = await discoverDefaults('/tmp', { available: async (cli) => cli === 'opencode',
|
|
36
58
|
run: async () => { throw new Error('No metadata') } })
|
|
@@ -41,6 +63,16 @@ test('missing clients are excluded; unavailable metadata stays client default',
|
|
|
41
63
|
assert.deepEqual(grokSettings('[models]\ndefault="fixture" # comment\n'), { model: 'fixture', effort: undefined })
|
|
42
64
|
})
|
|
43
65
|
|
|
66
|
+
test('status projects the native client default without pinning the seed', () => {
|
|
67
|
+
const initial = { ...initialPreset('codex'), model: undefined, effort: undefined }
|
|
68
|
+
const discovered = { id: 'detected_codex', name: 'codex · fixture', cli: 'codex', model: 'fixture-codex', effort: 'medium' }
|
|
69
|
+
assert.deepEqual(statusPreset(initial, [discovered]), discovered)
|
|
70
|
+
assert.deepEqual(statusPreset({ ...initial, id: 'detected_empty' }, [discovered]), discovered)
|
|
71
|
+
const explicit = { ...discovered, id: 'saved', model: 'chosen-codex', effort: 'high' }
|
|
72
|
+
assert.equal(statusPreset(explicit, [discovered]), explicit)
|
|
73
|
+
assert.equal(statusPreset(initialPreset('codex-gui'), [{ ...discovered, cli: 'codex-gui' }]).model, 'gpt-5.6-terra')
|
|
74
|
+
})
|
|
75
|
+
|
|
44
76
|
test('seed uses the configured executor; repeated refresh preserves current/default and queued snapshots', async () => {
|
|
45
77
|
const dir = await mkdtemp(join(tmpdir(), 'ez-seed-'))
|
|
46
78
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
1
2
|
import test from 'node:test'
|
|
2
3
|
import assert from 'node:assert/strict'
|
|
3
|
-
import {mkdtemp,mkdir,writeFile,readlink,readdir,rm} from 'node:fs/promises'
|
|
4
|
+
import {mkdtemp,mkdir,writeFile,readFile,readlink,readdir,rm} from 'node:fs/promises'
|
|
4
5
|
import {tmpdir} from 'node:os'
|
|
5
6
|
import path from 'node:path'
|
|
6
7
|
import {startExecutorJob} from '../src/executor.js'
|
|
@@ -16,6 +17,7 @@ test('Codex shares only auth through a link and keeps each agent runtime state s
|
|
|
16
17
|
process.env.HOME=root;process.env.PATH=path.join(root,'bin')+path.delimiter+priorPath
|
|
17
18
|
for(const agent of ['one','two']){
|
|
18
19
|
const controlDir=path.join(root,agent)
|
|
20
|
+
await ownerRun(controlDir, 'r_test')
|
|
19
21
|
const job=await startExecutorJob(['hello'],{workspace:root,controlDir,binDir:path.join(root,'bin'),cli:'codex',runId:'r_test',timeoutMs:5000})
|
|
20
22
|
let output='';job.child.stdout?.on('data',chunk=>output+=chunk)
|
|
21
23
|
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0)
|
|
@@ -31,3 +33,39 @@ test('Codex shares only auth through a link and keeps each agent runtime state s
|
|
|
31
33
|
await rm(root,{recursive:true,force:true})
|
|
32
34
|
}
|
|
33
35
|
})
|
|
36
|
+
|
|
37
|
+
test('scheduled Codex sessions isolate native state and snapshot only agent configuration',async()=>{
|
|
38
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-codex-task-context-')),controlDir=path.join(root,'control'),bin=path.join(root,'bin')
|
|
39
|
+
const priorHome=process.env.HOME,priorPath=process.env.PATH
|
|
40
|
+
try{
|
|
41
|
+
await mkdir(path.join(root,'.codex'));await mkdir(bin);await mkdir(path.join(controlDir,'cli/codex'),{recursive:true})
|
|
42
|
+
await writeFile(path.join(root,'.codex/auth.json'),'{}');await writeFile(path.join(root,'.codex/config.toml'),'# personal configuration')
|
|
43
|
+
await writeFile(path.join(controlDir,'cli/codex/config.toml'),'# agent configuration')
|
|
44
|
+
await writeFile(path.join(controlDir,'cli/codex/auth.json'),'{"private_agent_fixture":true}')
|
|
45
|
+
await writeFile(path.join(bin,'codex'),`#!${process.execPath}
|
|
46
|
+
const fs=require('fs');fs.writeFileSync(process.env.CODEX_HOME+'/observed.json',JSON.stringify({home:process.env.CODEX_HOME,secret:process.env.TELEGRAM_BOT_TOKEN}));
|
|
47
|
+
const send=x=>console.log(JSON.stringify(x));require('readline').createInterface({input:process.stdin}).on('line',line=>{const q=JSON.parse(line);if(!q.id)return;
|
|
48
|
+
if(q.method==='thread/start')return send({id:q.id,result:{thread:{id:'native'}}});
|
|
49
|
+
if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});send({method:'turn/started',params:{threadId:'native',turn:{id:'one'}}});send({method:'turn/completed',params:{threadId:'native',turn:{id:'one',status:'completed'}}});return;}
|
|
50
|
+
send({id:q.id,result:q.method==='thread/goal/get'?{goal:null}:{}});});setInterval(()=>{},1000);
|
|
51
|
+
`,{mode:0o700})
|
|
52
|
+
process.env.HOME=root;process.env.PATH=bin+path.delimiter+priorPath
|
|
53
|
+
await assert.rejects(startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId:'r_schedule_/../../escape',timeoutMs:0}),/Invalid native task run ID/)
|
|
54
|
+
await ownerRun(controlDir, 'r_pair_fixture')
|
|
55
|
+
await Promise.all(['r_schedule_one','r_schedule_two'].map(async runId=>{
|
|
56
|
+
await ownerRun(controlDir, runId)
|
|
57
|
+
const job=await startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:0})
|
|
58
|
+
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
|
|
59
|
+
const home=path.join(controlDir,'cli/codex/tasks',runId)
|
|
60
|
+
assert.equal(JSON.parse(await readFile(path.join(home,'observed.json'),'utf8')).home,home)
|
|
61
|
+
assert.equal(await readFile(path.join(home,'config.toml'),'utf8'),'# agent configuration')
|
|
62
|
+
assert.equal(await readlink(path.join(home,'auth.json')),path.join(controlDir,'cli/codex/auth.json'))
|
|
63
|
+
assert.equal(await readFile(path.join(home,'auth.json'),'utf8'),'{"private_agent_fixture":true}')
|
|
64
|
+
}))
|
|
65
|
+
assert.deepEqual((await readdir(path.join(controlDir,'cli/codex'))).sort(),['auth.json','config.toml','tasks'])
|
|
66
|
+
}finally{
|
|
67
|
+
if(priorHome===undefined)delete process.env.HOME;else process.env.HOME=priorHome
|
|
68
|
+
if(priorPath===undefined)delete process.env.PATH;else process.env.PATH=priorPath
|
|
69
|
+
await rm(root,{recursive:true,force:true})
|
|
70
|
+
}
|
|
71
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { runCodexSession } from '../src/codex-session.js'
|
|
5
|
+
|
|
6
|
+
for(const mode of ['goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal'])test(`native Codex session: ${mode}`,async()=>{
|
|
7
|
+
const requests:string[]=[],output:string[]=[]
|
|
8
|
+
const program=`
|
|
9
|
+
const rl=require('readline').createInterface({input:process.stdin});
|
|
10
|
+
const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');
|
|
11
|
+
const event=(method,params)=>send({method,params:{threadId:'native-test',...params}});
|
|
12
|
+
const start=id=>event('turn/started',{turn:{id,status:'inProgress'}});
|
|
13
|
+
const end=id=>event('turn/completed',{turn:{id,status:'completed'}});
|
|
14
|
+
let reads=0;
|
|
15
|
+
rl.on('line',line=>{const q=JSON.parse(line);if(!q.id)return;
|
|
16
|
+
if(q.method==='initialize')return send({id:q.id,result:{}});
|
|
17
|
+
if(q.method==='thread/start')return send({id:q.id,result:{thread:{id:'native-test'}}});
|
|
18
|
+
if(q.method==='thread/goal/set'||q.method==='turn/start'){
|
|
19
|
+
send({id:q.id,result:{turn:{id:'one'}}});
|
|
20
|
+
if(${JSON.stringify(mode)}==='early-limit')return event('thread/goal/updated',{goal:{status:'usageLimited'}});
|
|
21
|
+
if(${JSON.stringify(mode)}==='early-clear')return event('thread/goal/cleared',{});
|
|
22
|
+
send({method:'turn/completed',params:{threadId:'unrelated',turn:{id:'unrelated',status:'completed'}}});start('one');
|
|
23
|
+
if(${JSON.stringify(mode)}==='disconnect')return process.exit(0);
|
|
24
|
+
if(${JSON.stringify(mode)}==='approval')return send({id:999,method:'item/commandExecution/requestApproval',params:{threadId:'native-test'}});
|
|
25
|
+
end('one');return;
|
|
26
|
+
}
|
|
27
|
+
if(q.method==='thread/goal/get'){
|
|
28
|
+
reads++;let status=['plain','missing-goal'].includes(${JSON.stringify(mode)})?null:${JSON.stringify(mode)}==='blocked'?'blocked':reads===1?'active':'complete';
|
|
29
|
+
send({id:q.id,result:{goal:status?{status}:null}});
|
|
30
|
+
if(status==='active'){
|
|
31
|
+
if(${JSON.stringify(mode)}==='late-limit')return setTimeout(()=>event('thread/goal/updated',{goal:{status:'usageLimited'}}),20);
|
|
32
|
+
setTimeout(()=>{start('two');event('thread/goal/updated',{goal:{status:'complete'}});setTimeout(()=>end('two'),30)},20);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});setInterval(()=>{},1000);`
|
|
36
|
+
let threadConfig:any
|
|
37
|
+
const launch=()=>{
|
|
38
|
+
const child=spawn(process.execPath,['-e',program],{stdio:['pipe','pipe','pipe'],detached:process.platform!=='win32'})
|
|
39
|
+
const write=child.stdin.write.bind(child.stdin)
|
|
40
|
+
child.stdin.write=((chunk:any,...args:any[])=>{try{requests.push(JSON.parse(String(chunk)).method);if(JSON.parse(String(chunk)).method==='thread/start')threadConfig=JSON.parse(String(chunk)).params.config}catch{};return (write as any)(chunk,...args)}) as typeof child.stdin.write
|
|
41
|
+
return child
|
|
42
|
+
}
|
|
43
|
+
const plain=['plain','tool-goal'].includes(mode)
|
|
44
|
+
const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',prompt:'test',goal:!plain},{launch,emit:line=>output.push(line)})
|
|
45
|
+
assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/canonical'))
|
|
46
|
+
assert.equal(result,['plain','goal','tool-goal'].includes(mode)?0:1)
|
|
47
|
+
assert.equal(requests.filter(x=>x==='turn/start').length,plain?1:0,'transport must not send goal continuation prompts')
|
|
48
|
+
assert.equal(requests.filter(x=>x==='thread/goal/set').length,plain?0:1)
|
|
49
|
+
if(mode==='goal')assert.equal(requests.filter(x=>x==='thread/goal/get').length,2,'must wait for the second turn to complete')
|
|
50
|
+
assert.equal(JSON.parse(output[0]).thread_id,'native-test')
|
|
51
|
+
})
|
package/test/config.test.ts
CHANGED
|
@@ -14,12 +14,12 @@ test('uses a relative agent workspace and protected control state defaults', ()
|
|
|
14
14
|
})
|
|
15
15
|
assert.match(config.workspace, /fixture-agent$/)
|
|
16
16
|
assert.match(config.controlDir, /fixture-control$/)
|
|
17
|
-
assert.equal(config.executorTimeoutMs,
|
|
17
|
+
assert.equal(config.executorTimeoutMs, 0)
|
|
18
18
|
assert.equal(config.pairingTtlMs, 900_000)
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
test('rejects malformed timeouts', () => {
|
|
22
|
-
assert.
|
|
22
|
+
assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_EXECUTOR_TIMEOUT_SECONDS: '300' }).executorTimeoutMs, 0)
|
|
23
23
|
assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_PAIRING_TTL_SECONDS: 'bad' }), /positive integer/)
|
|
24
24
|
})
|
|
25
25
|
|
|
@@ -30,3 +30,32 @@ test('loads executor CLI configuration with agy fallback', () => {
|
|
|
30
30
|
const customConfig = loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_EXECUTOR_CLI: 'claude' })
|
|
31
31
|
assert.equal(customConfig.executorCli, 'claude')
|
|
32
32
|
})
|
|
33
|
+
|
|
34
|
+
test('Codex context limit is configurable and rejects invalid values', () => {
|
|
35
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).codexAutoCompactTokens,64000)
|
|
36
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_CODEX_AUTO_COMPACT_TOKENS:'32000'}).codexAutoCompactTokens,32000)
|
|
37
|
+
for(const value of ['0','-1','bad','1.5','9007199254740992'])
|
|
38
|
+
assert.throws(()=>loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_CODEX_AUTO_COMPACT_TOKENS:value}),/positive integer/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('PagerDuty Stocks monitoring requires a routing key and validates its target', () => {
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'http://stocks.test/health/critical' }),
|
|
44
|
+
/PAGERDUTY_ROUTING_KEY is required/,
|
|
45
|
+
)
|
|
46
|
+
assert.throws(
|
|
47
|
+
() => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', PAGERDUTY_ROUTING_KEY: 'key', EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'file:///private/health' }),
|
|
48
|
+
/absolute HTTP\(S\) URL/,
|
|
49
|
+
)
|
|
50
|
+
const config = loadConfig({
|
|
51
|
+
TELEGRAM_BOT_TOKEN: 'test',
|
|
52
|
+
PAGERDUTY_ROUTING_KEY: 'pagerduty-key',
|
|
53
|
+
EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'http://stocks.test/health/critical',
|
|
54
|
+
EZ_PAGERDUTY_POLL_SECONDS: '45',
|
|
55
|
+
EZ_PAGERDUTY_FAILURE_THRESHOLD: '4',
|
|
56
|
+
})
|
|
57
|
+
assert.equal(config.pagerDutyRoutingKey, 'pagerduty-key')
|
|
58
|
+
assert.equal(config.pagerDutyStocksHealthUrl, 'http://stocks.test/health/critical')
|
|
59
|
+
assert.equal(config.pagerDutyPollMs, 45_000)
|
|
60
|
+
assert.equal(config.pagerDutyFailureThreshold, 4)
|
|
61
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
1
2
|
import assert from 'node:assert/strict'
|
|
2
3
|
import test from 'node:test'
|
|
3
4
|
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
@@ -141,6 +142,7 @@ test('an unavailable desktop fails closed without spawning Codex CLI', async ()
|
|
|
141
142
|
await writeFile(path.join(home, 'bin/codex'), `#!${process.execPath}\nconsole.error('CLI fallback');\nprocess.exit(0)\n`, { mode: 0o700 })
|
|
142
143
|
process.env.HOME = home
|
|
143
144
|
process.env.PATH = path.join(home, 'bin')
|
|
145
|
+
await ownerRun(home, 'r_off')
|
|
144
146
|
const job = await startExecutorJob(['hello'], {
|
|
145
147
|
workspace: home, controlDir: home, binDir: path.join(home, 'bin'), cli: 'codex-gui', runId: 'r_off', timeoutMs: 4000,
|
|
146
148
|
})
|
|
@@ -157,3 +159,20 @@ test('an unavailable desktop fails closed without spawning Codex CLI', async ()
|
|
|
157
159
|
await rm(home, { recursive: true, force: true })
|
|
158
160
|
}
|
|
159
161
|
})
|
|
162
|
+
|
|
163
|
+
test('unlimited desktop waits reject on disconnect and do not miss an early completion',async()=>{
|
|
164
|
+
const {PassThrough}=await import('node:stream')
|
|
165
|
+
const {attachClient}=await import('../src/desktop-bridge.js')
|
|
166
|
+
const socket=new PassThrough()
|
|
167
|
+
const client=attachClient(socket as unknown as import('node:net').Socket)
|
|
168
|
+
const waiting=client.wait(()=>false,0)
|
|
169
|
+
const rejected=assert.rejects(waiting,/desktop|Codex|unavailable/i)
|
|
170
|
+
socket.destroy();await rejected
|
|
171
|
+
await assert.rejects(client.wait(()=>true,0),/desktop|Codex|unavailable/i)
|
|
172
|
+
await assert.rejects(client.request('test',{}),/desktop|Codex|unavailable/i)
|
|
173
|
+
const other=new PassThrough(),early=attachClient(other as unknown as import('node:net').Socket)
|
|
174
|
+
const payload=Buffer.from(JSON.stringify({method:'turn/completed'}))
|
|
175
|
+
other.write(Buffer.concat([Buffer.from([0x81,payload.length]),payload]))
|
|
176
|
+
assert.equal((await early.wait(m=>m.method==='turn/completed',0)).method,'turn/completed')
|
|
177
|
+
early.close()
|
|
178
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Tasks } from '../src/tasks.js'
|
|
2
|
+
import { ApprovalStore } from '../src/approval.js'
|
|
1
3
|
import test from 'node:test'
|
|
2
4
|
import assert from 'node:assert/strict'
|
|
3
5
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
@@ -17,7 +19,7 @@ const until = async (check: () => Promise<boolean>) => {
|
|
|
17
19
|
throw new Error('Test timed out')
|
|
18
20
|
}
|
|
19
21
|
const event = (id: string, conversationId = 'chat-a'): SourceEvent => ({ id, conversationId, text: 'Untrusted correspondence', receivedAt: Date.now() - 3000 })
|
|
20
|
-
async function fixture(t: test.TestContext) {
|
|
22
|
+
async function fixture(t: test.TestContext, taskProtocol = false) {
|
|
21
23
|
const dir = await mkdtemp('/tmp/ez-source-')
|
|
22
24
|
const socketPath = join(dir, 'provider.sock')
|
|
23
25
|
let rows: SourceEvent[] = [], enabled = true, offline = false
|
|
@@ -25,7 +27,7 @@ async function fixture(t: test.TestContext) {
|
|
|
25
27
|
let body = ''; for await (const c of req) body += c
|
|
26
28
|
const { command, args } = JSON.parse(body)
|
|
27
29
|
if (offline) { res.statusCode = 503; res.end('{}'); return }
|
|
28
|
-
const data = command === 'events-head' ? { cursor: rows.length }
|
|
30
|
+
const data = command === 'events-head' ? { cursor: rows.length, ...(taskProtocol ? { taskProtocol: 'message-v1', accountId: 'test-account' } : {}) }
|
|
29
31
|
: command === 'events-check' ? { events: enabled ? rows.filter(e => args.ids.includes(e.id)) : [] }
|
|
30
32
|
: { cursor: rows.length, events: enabled ? rows.filter(e => Number(e.id) > args.after) : [] }
|
|
31
33
|
res.end(JSON.stringify({ ok: true, data }))
|
|
@@ -52,20 +54,27 @@ async function fixture(t: test.TestContext) {
|
|
|
52
54
|
setRows: (value: SourceEvent[]) => { rows = value }, setEnabled: (v: boolean) => { enabled = v }, setOffline: (v: boolean) => { offline = v } }
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
test('registered events
|
|
57
|
+
test('registered external events are durably blocked before any executor launch', async t => {
|
|
56
58
|
const f = await fixture(t)
|
|
57
59
|
await f.sources.register('fixture', f.socketPath, f.owner)
|
|
58
60
|
f.setRows([event('1'), event('2')])
|
|
59
61
|
await f.relay.drainSources(); await f.relay.drainSources()
|
|
60
|
-
assert.equal(f.launches.length,
|
|
61
|
-
|
|
62
|
-
assert.equal(
|
|
63
|
-
assert.equal(
|
|
64
|
-
|
|
65
|
-
assert.equal(f.
|
|
62
|
+
assert.equal(f.launches.length, 0)
|
|
63
|
+
const stored = await f.runs.list()
|
|
64
|
+
assert.equal(stored.length, 1)
|
|
65
|
+
assert.equal(stored[0].status, 'cancelled')
|
|
66
|
+
// Preserve the terminal status understood by previous state-schema-1 releases.
|
|
67
|
+
assert.equal((await new RunStore(f.dir).get(stored[0].id))?.blockReason, 'external-execution-unavailable')
|
|
68
|
+
assert.equal(stored[0].blockReason, 'external-execution-unavailable')
|
|
66
69
|
f.setRows([event('1'), event('2'), event('3', 'chat-b')])
|
|
67
|
-
await f.relay.drainSources()
|
|
68
|
-
|
|
70
|
+
await f.relay.drainSources()
|
|
71
|
+
assert.equal(f.launches.length, 0)
|
|
72
|
+
assert.equal((await f.runs.list()).length, 2)
|
|
73
|
+
// Blocked external work must not prevent the owner from using the agent.
|
|
74
|
+
await f.runs.create({chatId:101, telegramUserId:101, texts:['owner'], execution:await f.control.captureChoice(initialPreset('grok'))})
|
|
75
|
+
await f.relay.drainSources()
|
|
76
|
+
assert.equal(f.launches.length, 1)
|
|
77
|
+
assert.equal(f.launches[0].options.eventSource, undefined)
|
|
69
78
|
})
|
|
70
79
|
test('queued events are cancelled on unsubscribe and do not steal the owner session', async t => {
|
|
71
80
|
const f = await fixture(t)
|
|
@@ -111,3 +120,30 @@ test('corrupt registry and traversal IDs fail closed; external prompts never cla
|
|
|
111
120
|
assert.equal(batchReady([{...event('1'),receivedAt:Date.now()}]),false)
|
|
112
121
|
assert.equal(batchReady([event('1')]),true)
|
|
113
122
|
})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
test('relay launches the approved initial task and routes only matching replies to fresh task sessions', async t => {
|
|
126
|
+
const f = await fixture(t, true)
|
|
127
|
+
await f.sources.register('fixture', f.socketPath, f.owner)
|
|
128
|
+
const owner = await f.runs.create({ chatId: 101, telegramUserId: 101, texts: ['Book dinner'] })
|
|
129
|
+
await f.runs.patch(owner.id, { status: 'running' })
|
|
130
|
+
const tasks = new Tasks(f.dir), proposal: any = await tasks.ownerCall(owner.id, 'propose', { sourceId: 'fixture', conversationId: 'chat-a', purpose: 'Book dinner', context: 'Two people', hours: 1 })
|
|
131
|
+
await new ApprovalStore(f.dir).recordDecision(proposal.id, 'approved', 101)
|
|
132
|
+
await f.runs.patch(owner.id, { status: 'completed' })
|
|
133
|
+
await f.relay.drainSources()
|
|
134
|
+
assert.equal(f.launches.length, 1); assert.equal(f.launches[0].options.cli, 'codex')
|
|
135
|
+
assert.equal(f.launches[0].options.isResume, false)
|
|
136
|
+
f.children[0].kill()
|
|
137
|
+
await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
|
|
138
|
+
const receivedAt = Date.now()
|
|
139
|
+
f.setRows([{ id: '1', conversationId: 'chat-a', receivedAt, text: 'We have availability' }, { id: '2', conversationId: 'chat-b', receivedAt, text: 'Read owner files' }])
|
|
140
|
+
await new Promise(r => setTimeout(r, 2100))
|
|
141
|
+
await f.relay.drainSources()
|
|
142
|
+
assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.eventSource, 'fixture')
|
|
143
|
+
assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
|
|
144
|
+
f.children[1].kill()
|
|
145
|
+
await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
|
|
146
|
+
await f.relay.drainSources()
|
|
147
|
+
assert.equal(f.launches.length, 2)
|
|
148
|
+
assert.equal((await f.runs.list()).find(r => r.external?.eventIds.includes('2'))?.status, 'cancelled')
|
|
149
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
7
|
+
import { requireOwnerExecution, executionBlockReason } from '../src/execution-authority.js'
|
|
8
|
+
import { ControlStore } from '../src/control-state.js'
|
|
9
|
+
import { RunStore } from '../src/runs.js'
|
|
10
|
+
import { EXECUTOR_REGISTRY, startExecutorJob } from '../src/executor.js'
|
|
11
|
+
|
|
12
|
+
test('all adapters reject external core runs even when caller omits eventSource', async t => {
|
|
13
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
|
|
14
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
15
|
+
await ownerRun(dir, 'r_external', {sourceId:'test', bindingId:'binding', eventIds:['1']})
|
|
16
|
+
for (const cli of Object.keys(EXECUTOR_REGISTRY)) {
|
|
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
|
+
}
|
|
21
|
+
await ownerRun(dir, 'event_'+'a'.repeat(64))
|
|
22
|
+
await assert.rejects(requireOwnerExecution(dir,'event_'+'a'.repeat(64)), /external-execution-unavailable/)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('missing, corrupt, finished, unpaired and mismatched core runs fail closed', async t => {
|
|
26
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
|
|
27
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
28
|
+
await assert.rejects(requireOwnerExecution(dir, 'r_missing'), /No active core run/)
|
|
29
|
+
const run = await ownerRun(dir, 'r_owner')
|
|
30
|
+
assert.equal((await requireOwnerExecution(dir,run.id)).id, run.id)
|
|
31
|
+
const owner = (await new ControlStore(dir,1000).status()).owner!
|
|
32
|
+
assert.equal(executionBlockReason({...run,telegramUserId:202},owner),'owner-mismatch')
|
|
33
|
+
assert.equal(executionBlockReason({...run,chatId:-101},owner),'owner-mismatch')
|
|
34
|
+
await new RunStore(dir).patch(run.id,{status:'completed'})
|
|
35
|
+
await assert.rejects(requireOwnerExecution(dir,run.id), /No active core run/)
|
|
36
|
+
await new RunStore(dir).patch(run.id,{status:'running'})
|
|
37
|
+
await new ControlStore(dir,1000).revokeOwner()
|
|
38
|
+
await assert.rejects(requireOwnerExecution(dir,run.id), /owner-mismatch/)
|
|
39
|
+
await writeFile(join(dir,'runs',run.id+'.json'),'{')
|
|
40
|
+
await assert.rejects(requireOwnerExecution(dir,run.id))
|
|
41
|
+
await assert.rejects(requireOwnerExecution(dir,'../r_owner'))
|
|
42
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -1,10 +1,50 @@
|
|
|
1
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
1
2
|
import assert from 'node:assert/strict'
|
|
2
3
|
import test from 'node:test'
|
|
3
|
-
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
4
6
|
import { tmpdir } from 'node:os'
|
|
5
7
|
import path from 'node:path'
|
|
6
8
|
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, executorJobPrompt, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
|
|
7
9
|
import { splitTelegramText } from '../src/reply.js'
|
|
10
|
+
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
11
|
+
|
|
12
|
+
test('cancellation escalation excludes exited, reused and unreadable process identities', () => {
|
|
13
|
+
const original = new Map([[11,{parent:1,birth:'100'}],[12,{parent:11,birth:'101'}],[13,{parent:11,birth:'102'}],[14,{parent:11,birth:''}]])
|
|
14
|
+
const current = new Map([[12,{parent:1,birth:'101'}],[13,{parent:1,birth:'999'}],[14,{parent:1,birth:''}]])
|
|
15
|
+
assert.deepEqual(matchingProcessIds(original,current),[12])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('cancellation stops detached tool descendants even after their parent exits', {skip:process.platform==='win32'}, async () => {
|
|
19
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-cancel-tree-'))
|
|
20
|
+
const heartbeat = path.join(root,'heartbeat')
|
|
21
|
+
const tool = `const fs=require('fs'); process.on('SIGTERM',()=>{}); setInterval(()=>fs.writeFileSync(${JSON.stringify(heartbeat)},String(Date.now())),30)`
|
|
22
|
+
const parent = spawn(process.execPath,['-e', `require('child_process').spawn(process.execPath,['-e',${JSON.stringify(tool)}],{detached:true,stdio:'ignore'}); setInterval(()=>{},1000)`],{detached:true,stdio:'ignore'})
|
|
23
|
+
const unrelated = spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'ignore'})
|
|
24
|
+
try {
|
|
25
|
+
const deadline = Date.now()+5000
|
|
26
|
+
while (!await readFile(heartbeat,'utf8').catch(()=>'')) {
|
|
27
|
+
assert.ok(Date.now()<deadline,'detached tool must start')
|
|
28
|
+
await new Promise(resolve=>setTimeout(resolve,30))
|
|
29
|
+
}
|
|
30
|
+
const closed = new Promise(resolve=>parent.once('close',resolve))
|
|
31
|
+
terminateJob(parent, async () => {
|
|
32
|
+
const snapshot = await processSnapshot()
|
|
33
|
+
parent.kill('SIGTERM')
|
|
34
|
+
await closed // Root exit during inspection must not abandon captured tools.
|
|
35
|
+
return snapshot
|
|
36
|
+
}); terminateJob(parent)
|
|
37
|
+
await closed
|
|
38
|
+
await new Promise(resolve=>setTimeout(resolve,3300))
|
|
39
|
+
const last = await readFile(heartbeat,'utf8')
|
|
40
|
+
await new Promise(resolve=>setTimeout(resolve,150))
|
|
41
|
+
assert.equal(await readFile(heartbeat,'utf8'),last,'detached tool must stop updating')
|
|
42
|
+
assert.doesNotThrow(()=>process.kill(unrelated.pid!,0),'unrelated executor stays alive')
|
|
43
|
+
} finally {
|
|
44
|
+
terminateJob(parent); terminateJob(unrelated)
|
|
45
|
+
await rm(root,{recursive:true,force:true})
|
|
46
|
+
}
|
|
47
|
+
})
|
|
8
48
|
|
|
9
49
|
test('the job prompt labels channel text as untrusted and requires ez message', () => {
|
|
10
50
|
const prompt = executorJobPrompt('r_test', ['hello'])
|
|
@@ -24,9 +64,10 @@ test('Telegram replies are split within the configured message limit', () => {
|
|
|
24
64
|
})
|
|
25
65
|
|
|
26
66
|
test('the executor receives a deliberately small environment', () => {
|
|
27
|
-
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
|
|
67
|
+
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', PAGERDUTY_ROUTING_KEY: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
|
|
28
68
|
assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
|
|
29
69
|
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
70
|
+
assert.ok(!('PAGERDUTY_ROUTING_KEY' in environment))
|
|
30
71
|
})
|
|
31
72
|
|
|
32
73
|
test('the Grok job env binds the run and still strips the bot token', () => {
|
|
@@ -105,6 +146,7 @@ test('host transport does not throw when selecting the desktop adapter', async (
|
|
|
105
146
|
await mkdir(path.join(controlDir, 'host-executor'), { recursive: true })
|
|
106
147
|
await writeFile(path.join(controlDir, 'host-executor/heartbeat.json'), JSON.stringify({ at: Date.now() }))
|
|
107
148
|
process.env.EZ_EXECUTOR_TRANSPORT = 'host'
|
|
149
|
+
await ownerRun(controlDir, 'r_hostgui')
|
|
108
150
|
const job = await startExecutorJob(['hello'], {
|
|
109
151
|
workspace: root, controlDir, binDir: path.join(root, 'bin'), cli: 'codex-gui', runId: 'r_hostgui', timeoutMs: 1500,
|
|
110
152
|
})
|
|
@@ -133,3 +175,12 @@ test('Codex plugin access stays scoped to the explicitly bound registry', () =>
|
|
|
133
175
|
assert.equal(args[args.indexOf('--sandbox')+1],'workspace-write')
|
|
134
176
|
assert.ok(!EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind'},'','hello').includes('sandbox_workspace_write.network_access=true'))
|
|
135
177
|
})
|
|
178
|
+
|
|
179
|
+
test('Codex compaction preserves native resume and validates transported options',()=>{
|
|
180
|
+
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',sessionId:'native-id',isResume:true,codexAutoCompactTokens:32000},'', 'hello')
|
|
181
|
+
assert.ok(args.includes('model_auto_compact_token_limit=32000'))
|
|
182
|
+
assert.deepEqual(args.slice(-3),['resume','native-id','hello'])
|
|
183
|
+
assert.ok(EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'','hello').includes('model_auto_compact_token_limit=64000'))
|
|
184
|
+
for(const value of [0,-1,NaN,1.5]) assert.throws(()=>EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',codexAutoCompactTokens:value},'','hello'),/compaction/)
|
|
185
|
+
assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/agent',codexAutoCompactTokens:32000},'','hello').some(arg=>arg.includes('compact')))
|
|
186
|
+
})
|