@jc_stack/ez-agents 0.1.0-beta.13 → 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 +3 -0
- package/.env.example +15 -0
- package/AGENTS.md +6 -3
- package/CHANGELOG.md +49 -0
- package/CONTRIBUTING.md +34 -4
- package/README.md +3 -0
- package/compose.yaml +8 -1
- package/docker/run.ts +1 -1
- package/docs/architecture/ai-selection.md +8 -0
- package/docs/architecture/authority-boundaries.md +24 -1
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/docker-runtime.md +35 -0
- package/docs/host-service.md +19 -0
- package/docs/pagerduty.md +42 -0
- package/docs/plugin-catalog.md +27 -10
- package/docs/plugin-contributions.md +9 -0
- package/docs/plugins.md +12 -1
- package/docs/releasing.md +20 -9
- package/docs/repair.md +41 -0
- package/docs/scheduling.md +30 -4
- package/docs/selective-monitoring.md +12 -4
- package/docs/setup.md +39 -0
- package/docs/trusted-publishing.md +140 -0
- package/docs/upgrades.md +24 -4
- package/package.json +6 -3
- package/scripts/generate-publish-caller.mjs +60 -0
- package/scripts/smoke-busy-reply.ts +58 -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/client-defaults.ts +29 -13
- package/src/codex-session.ts +4 -2
- package/src/config.ts +29 -1
- package/src/control-state.ts +24 -7
- package/src/desktop-bridge.ts +8 -1
- package/src/event-sources.ts +2 -1
- package/src/execution-authority.ts +2 -1
- package/src/executor.ts +31 -6
- package/src/failure.ts +32 -0
- package/src/host-executor.ts +22 -13
- package/src/identity.ts +8 -3
- package/src/inbox.ts +7 -3
- package/src/index.ts +207 -79
- 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/manager.mjs +47 -8
- package/src/plugins/shared.mjs +76 -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 +15 -4
- package/src/schedule-cli.ts +36 -7
- package/src/scheduler.ts +12 -3
- package/src/setup.ts +2 -1
- package/src/software-status.ts +5 -5
- package/src/task-cli.ts +3 -3
- package/src/task-executor.ts +7 -5
- package/src/tasks.ts +35 -17
- package/src/telegram-source.ts +94 -0
- package/src/updates/artifact.mjs +16 -0
- package/src/updates/binding.mjs +3 -1
- package/src/updates/control.mjs +4 -4
- package/src/updates/runtime.mjs +3 -1
- package/templates/agent/AGENTS.md +10 -2
- package/templates/agent/TOOLS.md +6 -0
- package/templates/agent-guidance.md +13 -0
- package/templates/failure-review.md +9 -0
- package/templates/maintainer-purpose.md +15 -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/client-defaults.test.ts +37 -5
- package/test/codex-context.test.ts +5 -2
- package/test/codex-session.test.ts +4 -2
- package/test/config.test.ts +29 -0
- package/test/executor.test.ts +11 -1
- package/test/failure.test.ts +250 -0
- package/test/group-owner.test.ts +36 -0
- package/test/host-executor.test.ts +38 -7
- package/test/intake-relay.test.ts +141 -4
- package/test/model-policy.test.ts +61 -0
- package/test/pagerduty.test.ts +104 -0
- package/test/plugin-manager.test.mjs +3 -2
- 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 +8 -2
- package/test/shared-services.test.mjs +98 -0
- package/test/software-status.test.ts +5 -5
- package/test/task-native.test.ts +2 -2
- package/test/tasks.test.ts +14 -6
- 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,110 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
8
|
+
import { promisify } from 'node:util'
|
|
9
|
+
import test from 'node:test'
|
|
10
|
+
import { desktopJobPrompt } from '../src/desktop-bridge.js'
|
|
11
|
+
import { executorJobPrompt } from '../src/executor.js'
|
|
12
|
+
import { taskArguments } from '../src/task-executor.js'
|
|
13
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
14
|
+
|
|
15
|
+
const sharedGuidancePath = fileURLToPath(new URL('../templates/agent-guidance.md', import.meta.url))
|
|
16
|
+
const sharedLoaderPath = fileURLToPath(new URL('../src/agent-guidance.ts', import.meta.url))
|
|
17
|
+
const tsxLoaderPath = fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url))
|
|
18
|
+
const execFileAsync = promisify(execFile)
|
|
19
|
+
const runNode = (code: string, cwd: string) => execFileAsync(process.execPath, [
|
|
20
|
+
'--import', tsxLoaderPath, '--input-type=module', '-e', code,
|
|
21
|
+
], { cwd, encoding: 'utf8' })
|
|
22
|
+
|
|
23
|
+
test('CLI and desktop prompt builders use current package guidance', async () => {
|
|
24
|
+
const shared = (await readFile(sharedGuidancePath, 'utf8')).trim()
|
|
25
|
+
const prompts = [
|
|
26
|
+
['CLI', executorJobPrompt('tg_owner', ['owner request'])],
|
|
27
|
+
['desktop', desktopJobPrompt('tg_owner_gui', ['owner request'], undefined, '/tmp/bin', '/tmp/control')],
|
|
28
|
+
] as const
|
|
29
|
+
for (const [kind, prompt] of prompts)
|
|
30
|
+
assert.ok(prompt.includes(shared), `${kind} prompt is missing the current package guidance`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('package guidance resolution ignores a workspace shadow file', async () => {
|
|
34
|
+
const root = path.join(tmpdir(), `ez-guidance-${randomUUID()}`)
|
|
35
|
+
await mkdir(root, { recursive: true })
|
|
36
|
+
const workspace = path.join(root, 'agent')
|
|
37
|
+
const workspaceMarker = 'WORKSPACE_GUIDANCE_MUST_NOT_BE_IMPORTED'
|
|
38
|
+
try {
|
|
39
|
+
await mkdir(path.join(workspace, 'templates'), { recursive: true })
|
|
40
|
+
await writeFile(path.join(workspace, 'templates', 'agent-guidance.md'), workspaceMarker)
|
|
41
|
+
const result = await runNode(
|
|
42
|
+
`import { agentGuidance } from ${JSON.stringify(pathToFileURL(sharedLoaderPath).href)}; process.stdout.write(agentGuidance())`,
|
|
43
|
+
workspace,
|
|
44
|
+
)
|
|
45
|
+
assert.ok(result.stdout.includes((await readFile(sharedGuidancePath, 'utf8')).trim()))
|
|
46
|
+
assert.ok(!result.stdout.includes(workspaceMarker))
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(root, { recursive: true, force: true })
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('workspace initialization preserves a customized AGENTS.md', async () => {
|
|
53
|
+
const root = path.join(tmpdir(), `ez-guidance-workspace-${randomUUID()}`)
|
|
54
|
+
const workspace = path.join(root, 'agent')
|
|
55
|
+
try {
|
|
56
|
+
await initializeWorkspace(workspace)
|
|
57
|
+
const custom = '# Workspace-specific purpose\nKeep this local guidance unchanged.\n'
|
|
58
|
+
await writeFile(path.join(workspace, 'AGENTS.md'), custom)
|
|
59
|
+
await initializeWorkspace(workspace)
|
|
60
|
+
assert.equal(await readFile(path.join(workspace, 'AGENTS.md'), 'utf8'), custom)
|
|
61
|
+
} finally {
|
|
62
|
+
await rm(root, { recursive: true, force: true })
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('restricted task arguments retain bounded permissions and do not receive owner guidance', () => {
|
|
67
|
+
const directory = '/tmp/ez-restricted-task/workspace'
|
|
68
|
+
const args = taskArguments(directory, ['node', 'broker'], 'approved task')
|
|
69
|
+
assert.ok(args.includes('default_permissions="ez-task"'))
|
|
70
|
+
assert.ok(args.includes(`permissions.ez-task.filesystem={":root"="deny",":minimal"="read",${JSON.stringify(directory)}="write"}`))
|
|
71
|
+
assert.ok(args.includes('permissions.ez-task.network.enabled=false'))
|
|
72
|
+
assert.ok(args.includes('--disable') && args.includes('shell_tool'))
|
|
73
|
+
assert.ok(!args.some((arg) => arg.includes('# Shared Ez guidance')))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('copied package guidance refreshes on each call and missing guidance fails visibly', async () => {
|
|
77
|
+
const root = path.join(tmpdir(), `ez-guidance-loader-${randomUUID()}`)
|
|
78
|
+
const source = path.join(root, 'src')
|
|
79
|
+
const templates = path.join(root, 'templates')
|
|
80
|
+
const loaderPath = path.join(source, 'agent-guidance.ts')
|
|
81
|
+
const guidancePath = path.join(templates, 'agent-guidance.md')
|
|
82
|
+
await mkdir(source, { recursive: true })
|
|
83
|
+
await mkdir(templates, { recursive: true })
|
|
84
|
+
try {
|
|
85
|
+
await writeFile(path.join(root, 'package.json'), JSON.stringify({ type: 'module' }))
|
|
86
|
+
await writeFile(loaderPath, await readFile(sharedLoaderPath, 'utf8'))
|
|
87
|
+
await writeFile(guidancePath, 'fixture guidance one')
|
|
88
|
+
const code = `
|
|
89
|
+
import { renameSync, writeFileSync } from 'node:fs'
|
|
90
|
+
import { agentGuidance } from ${JSON.stringify(pathToFileURL(loaderPath).href)}
|
|
91
|
+
const guidancePath = ${JSON.stringify(guidancePath)}
|
|
92
|
+
if (agentGuidance() !== 'fixture guidance one') throw new Error('initial fixture was not loaded')
|
|
93
|
+
writeFileSync(guidancePath, 'fixture guidance two')
|
|
94
|
+
if (agentGuidance() !== 'fixture guidance two') throw new Error('guidance was cached')
|
|
95
|
+
renameSync(guidancePath, guidancePath + '.missing')
|
|
96
|
+
agentGuidance()
|
|
97
|
+
`
|
|
98
|
+
await assert.rejects(
|
|
99
|
+
() => runNode(code, root),
|
|
100
|
+
(error: any) => {
|
|
101
|
+
assert.notEqual(error.code, 0)
|
|
102
|
+
assert.match(error.stderr, /ENOENT/)
|
|
103
|
+
assert.match(error.stderr, /agent-guidance\.md/)
|
|
104
|
+
return true
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
} finally {
|
|
108
|
+
await rm(root, { recursive: true, force: true })
|
|
109
|
+
}
|
|
110
|
+
})
|
package/test/ai-cli.test.ts
CHANGED
|
@@ -11,18 +11,19 @@ import {initialPreset} from '../src/ai.js'
|
|
|
11
11
|
test('explicit CLI/model selection preserves installation default and rejects unavailable choices',async()=>{
|
|
12
12
|
const root=await mkdtemp(path.join(tmpdir(),'ez-ai-cli-'))
|
|
13
13
|
try{
|
|
14
|
-
|
|
14
|
+
const controlDir=path.join(root,'control')
|
|
15
|
+
await mkdir(path.join(controlDir,'cli','codex'),{recursive:true});await mkdir(path.join(root,'bin'))
|
|
15
16
|
await writeFile(path.join(root,'bin/codex'),'#!/bin/sh\nexit 0\n',{mode:0o700})
|
|
16
|
-
await writeFile(path.join(
|
|
17
|
-
const
|
|
18
|
-
const env={...process.env,HOME:root,PATH:path.join(root,'bin')+path.delimiter+process.env.PATH,EZ_CONTROL_DIR:
|
|
17
|
+
await writeFile(path.join(controlDir,'cli','codex','models_cache.json'),JSON.stringify({models:[{slug:'test-model',visibility:'list',supported_reasoning_levels:[{effort:'high'}]}]}))
|
|
18
|
+
const store=new ControlStore(controlDir,900000);await store.aiState(initialPreset('grok'))
|
|
19
|
+
const env={...process.env,HOME:root,PATH:path.join(root,'bin')+path.delimiter+process.env.PATH,EZ_CONTROL_DIR:controlDir}
|
|
19
20
|
const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
|
|
20
21
|
const call=(model:string)=>spawnSync(process.execPath,[bin,'select','--cli','codex','--model',model,'--effort','high'],{env,encoding:'utf8'})
|
|
21
22
|
const result=call('test-model');assert.equal(result.status,0,result.stderr)
|
|
22
|
-
const state=await
|
|
23
|
+
const state=await store.status();assert.equal(state.ai?.defaultId,'initial')
|
|
23
24
|
assert.equal(state.ai?.presets.find(p=>p.id===state.ai?.selectedId)?.cli,'codex')
|
|
24
25
|
assert.equal(state.activeSession?.cli,'codex')
|
|
25
26
|
assert.notEqual(call('unavailable').status,0)
|
|
26
|
-
assert.deepEqual(await
|
|
27
|
+
assert.deepEqual(await store.status(),state)
|
|
27
28
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
28
29
|
})
|
package/test/ai.test.ts
CHANGED
|
@@ -89,6 +89,23 @@ test('model catalog projects native metadata only, excluding hidden entries and
|
|
|
89
89
|
} finally { await rm(home, { recursive: true, force: true }) }
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
test('model catalog can read an agent-bound Codex home', async () => {
|
|
93
|
+
const home = await mkdtemp(join(tmpdir(), 'ez-catalog-home-'))
|
|
94
|
+
const codexHome = await mkdtemp(join(tmpdir(), 'ez-catalog-codex-'))
|
|
95
|
+
try {
|
|
96
|
+
await writeFile(join(codexHome, 'models_cache.json'), JSON.stringify({ models: [
|
|
97
|
+
{ slug: 'gpt-6-astra', display_name: 'GPT-6 Astra', visibility: 'list',
|
|
98
|
+
supported_reasoning_levels: [{ effort: 'low' }] },
|
|
99
|
+
] }))
|
|
100
|
+
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome), [
|
|
101
|
+
{ cli: 'codex', model: 'gpt-6-astra', name: 'GPT-6 Astra', efforts: ['low'] },
|
|
102
|
+
])
|
|
103
|
+
} finally {
|
|
104
|
+
await rm(home, { recursive: true, force: true })
|
|
105
|
+
await rm(codexHome, { recursive: true, force: true })
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
92
109
|
test('native executor flags carry the exact model and effort; only structured metadata binds sessions', () => {
|
|
93
110
|
const opts = { workspace: '/tmp/fixture', sessionId: crypto.randomUUID(), isResume: true,
|
|
94
111
|
model: 'fixture-model', effort: 'medium' }
|
|
@@ -103,3 +120,27 @@ test('native executor flags carry the exact model and effort; only structured me
|
|
|
103
120
|
assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'text', thread_id: opts.sessionId })), undefined)
|
|
104
121
|
assert.equal(nativeSessionId('codex', 'Please resume this other session'), undefined)
|
|
105
122
|
})
|
|
123
|
+
|
|
124
|
+
for (const cli of ['codex', 'codex-gui']) {
|
|
125
|
+
test(`${cli} initializes Terra high ahead of host defaults and preserves saved choices`, async () => {
|
|
126
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-default-'))
|
|
127
|
+
try {
|
|
128
|
+
const store = new ControlStore(dir, 1000)
|
|
129
|
+
const initial = initialPreset(cli)
|
|
130
|
+
const discovered = [{ id: 'detected_codex', name: 'Host default', cli,
|
|
131
|
+
model: 'host-model', effort: 'low' }]
|
|
132
|
+
await store.syncClientPresets(initial, discovered)
|
|
133
|
+
const first = await store.captureChoice(initial)
|
|
134
|
+
assert.equal(first.preset.model, 'gpt-5.6-terra')
|
|
135
|
+
assert.equal(first.preset.effort, 'high')
|
|
136
|
+
assert.equal(first.preset.cli, cli)
|
|
137
|
+
const saved = { id: 'custom', name: 'Custom', cli, model: 'custom-model', effort: 'medium' }
|
|
138
|
+
await store.savePreset(saved)
|
|
139
|
+
await store.defaultPreset(saved.id)
|
|
140
|
+
await store.resetSession()
|
|
141
|
+
const restarted = new ControlStore(dir, 1000)
|
|
142
|
+
await restarted.syncClientPresets(initial, discovered)
|
|
143
|
+
assert.deepEqual((await restarted.captureChoice(initial)).preset, saved)
|
|
144
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
145
|
+
})
|
|
146
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { once } from 'node:events'
|
|
8
|
+
import { createRelay } from '../src/index.js'
|
|
9
|
+
import { ControlStore } from '../src/control-state.js'
|
|
10
|
+
import { RunStore } from '../src/runs.js'
|
|
11
|
+
import type { Update } from 'grammy/types'
|
|
12
|
+
const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<150;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
|
|
13
|
+
test('busy owner replies serialize independently of the writer and reject other senders',async()=>{
|
|
14
|
+
const root=await mkdtemp(join(tmpdir(),'ez-busy-relay-')),runs=new RunStore(root),control=new ControlStore(root,1000),children:ReturnType<typeof spawn>[]=[]
|
|
15
|
+
const relay=createRelay({workspace:root,controlDir:root,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,opts)=>{
|
|
16
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true});children.push(child);await once(child,'spawn')
|
|
17
|
+
return {child,stdout:'',cleanup:async()=>{}}
|
|
18
|
+
})
|
|
19
|
+
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
20
|
+
relay.bot.api.config.use(async()=>({ok:true,result:true}) as any)
|
|
21
|
+
const message=(id:number,from=101,type='private'):Update=>({update_id:id,message:{message_id:id,date:0,text:'status',from:{id:from,is_bot:false,first_name:'Fixture'},chat:{id:from,type}}} as Update)
|
|
22
|
+
try{
|
|
23
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
24
|
+
await relay.bot.handleUpdate(message(1));await relay.drainInbox(true)
|
|
25
|
+
await relay.bot.handleUpdate(message(2));await relay.drainInbox(true)
|
|
26
|
+
await relay.bot.handleUpdate(message(3));await relay.drainInbox(true)
|
|
27
|
+
assert.equal((await runs.get('tg_2'))?.replyOnly,true)
|
|
28
|
+
assert.equal((await runs.get('tg_3'))?.status,'queued')
|
|
29
|
+
assert.equal(children.length,2)
|
|
30
|
+
await relay.bot.handleUpdate(message(4,202));await relay.drainInbox(true)
|
|
31
|
+
await relay.bot.handleUpdate(message(5,-42,'group'));await relay.drainInbox(true)
|
|
32
|
+
assert.equal(children.length,2)
|
|
33
|
+
children[1].kill()
|
|
34
|
+
await until(async()=>children.length===3)
|
|
35
|
+
assert.equal(children[0].exitCode,null)
|
|
36
|
+
assert.equal(children[0].signalCode,null)
|
|
37
|
+
assert.equal((await runs.get('tg_3'))?.replyOnly,true)
|
|
38
|
+
await relay.bot.handleUpdate({...message(6),message:{...message(6).message!,text:'/stop'}} as Update)
|
|
39
|
+
await until(async()=>children.every(c=>c.exitCode!==null || c.signalCode!==null))
|
|
40
|
+
}finally{await relay.stop();for(const c of children)c.kill();await until(async()=>!(await runs.list()).some(r=>r.status==='running'));await rm(root,{recursive:true,force:true})}
|
|
41
|
+
})
|
|
@@ -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 {
|
|
@@ -41,6 +41,7 @@ test('scheduled Codex sessions isolate native state and snapshot only agent conf
|
|
|
41
41
|
await mkdir(path.join(root,'.codex'));await mkdir(bin);await mkdir(path.join(controlDir,'cli/codex'),{recursive:true})
|
|
42
42
|
await writeFile(path.join(root,'.codex/auth.json'),'{}');await writeFile(path.join(root,'.codex/config.toml'),'# personal configuration')
|
|
43
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}')
|
|
44
45
|
await writeFile(path.join(bin,'codex'),`#!${process.execPath}
|
|
45
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}));
|
|
46
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;
|
|
@@ -50,6 +51,7 @@ send({id:q.id,result:q.method==='thread/goal/get'?{goal:null}:{}});});setInterva
|
|
|
50
51
|
`,{mode:0o700})
|
|
51
52
|
process.env.HOME=root;process.env.PATH=bin+path.delimiter+priorPath
|
|
52
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')
|
|
53
55
|
await Promise.all(['r_schedule_one','r_schedule_two'].map(async runId=>{
|
|
54
56
|
await ownerRun(controlDir, runId)
|
|
55
57
|
const job=await startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:0})
|
|
@@ -57,9 +59,10 @@ send({id:q.id,result:q.method==='thread/goal/get'?{goal:null}:{}});});setInterva
|
|
|
57
59
|
const home=path.join(controlDir,'cli/codex/tasks',runId)
|
|
58
60
|
assert.equal(JSON.parse(await readFile(path.join(home,'observed.json'),'utf8')).home,home)
|
|
59
61
|
assert.equal(await readFile(path.join(home,'config.toml'),'utf8'),'# agent configuration')
|
|
60
|
-
assert.equal(await readlink(path.join(home,'auth.json')),path.join(
|
|
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}')
|
|
61
64
|
}))
|
|
62
|
-
assert.deepEqual((await readdir(path.join(controlDir,'cli/codex'))).sort(),['config.toml','tasks'])
|
|
65
|
+
assert.deepEqual((await readdir(path.join(controlDir,'cli/codex'))).sort(),['auth.json','config.toml','tasks'])
|
|
63
66
|
}finally{
|
|
64
67
|
if(priorHome===undefined)delete process.env.HOME;else process.env.HOME=priorHome
|
|
65
68
|
if(priorPath===undefined)delete process.env.PATH;else process.env.PATH=priorPath
|
|
@@ -33,14 +33,16 @@ if(q.method==='thread/goal/get'){
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
});setInterval(()=>{},1000);`
|
|
36
|
+
let threadConfig:any
|
|
36
37
|
const launch=()=>{
|
|
37
38
|
const child=spawn(process.execPath,['-e',program],{stdio:['pipe','pipe','pipe'],detached:process.platform!=='win32'})
|
|
38
39
|
const write=child.stdin.write.bind(child.stdin)
|
|
39
|
-
child.stdin.write=((chunk:any,...args:any[])=>{try{requests.push(JSON.parse(String(chunk)).method)}catch{};return (write as any)(chunk,...args)}) as typeof child.stdin.write
|
|
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
|
|
40
41
|
return child
|
|
41
42
|
}
|
|
42
43
|
const plain=['plain','tool-goal'].includes(mode)
|
|
43
|
-
const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',prompt:'test',goal:!plain},{launch,emit:line=>output.push(line)})
|
|
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'))
|
|
44
46
|
assert.equal(result,['plain','goal','tool-goal'].includes(mode)?0:1)
|
|
45
47
|
assert.equal(requests.filter(x=>x==='turn/start').length,plain?1:0,'transport must not send goal continuation prompts')
|
|
46
48
|
assert.equal(requests.filter(x=>x==='thread/goal/set').length,plain?0:1)
|
package/test/config.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -64,9 +64,10 @@ test('Telegram replies are split within the configured message limit', () => {
|
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
test('the executor receives a deliberately small environment', () => {
|
|
67
|
-
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' })
|
|
68
68
|
assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
|
|
69
69
|
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
70
|
+
assert.ok(!('PAGERDUTY_ROUTING_KEY' in environment))
|
|
70
71
|
})
|
|
71
72
|
|
|
72
73
|
test('the Grok job env binds the run and still strips the bot token', () => {
|
|
@@ -174,3 +175,12 @@ test('Codex plugin access stays scoped to the explicitly bound registry', () =>
|
|
|
174
175
|
assert.equal(args[args.indexOf('--sandbox')+1],'workspace-write')
|
|
175
176
|
assert.ok(!EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind'},'','hello').includes('sandbox_workspace_write.network_access=true'))
|
|
176
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
|
+
})
|