@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.
Files changed (101) hide show
  1. package/.dockerignore +3 -0
  2. package/.env.example +15 -0
  3. package/AGENTS.md +6 -3
  4. package/CHANGELOG.md +49 -0
  5. package/CONTRIBUTING.md +34 -4
  6. package/README.md +3 -0
  7. package/compose.yaml +8 -1
  8. package/docker/run.ts +1 -1
  9. package/docs/architecture/ai-selection.md +8 -0
  10. package/docs/architecture/authority-boundaries.md +24 -1
  11. package/docs/architecture/telegram-intake.md +1 -1
  12. package/docs/docker-runtime.md +35 -0
  13. package/docs/host-service.md +19 -0
  14. package/docs/pagerduty.md +42 -0
  15. package/docs/plugin-catalog.md +27 -10
  16. package/docs/plugin-contributions.md +9 -0
  17. package/docs/plugins.md +12 -1
  18. package/docs/releasing.md +20 -9
  19. package/docs/repair.md +41 -0
  20. package/docs/scheduling.md +30 -4
  21. package/docs/selective-monitoring.md +12 -4
  22. package/docs/setup.md +39 -0
  23. package/docs/trusted-publishing.md +140 -0
  24. package/docs/upgrades.md +24 -4
  25. package/package.json +6 -3
  26. package/scripts/generate-publish-caller.mjs +60 -0
  27. package/scripts/smoke-busy-reply.ts +58 -0
  28. package/scripts/trusted-beta.mjs +289 -0
  29. package/src/agent-guidance.ts +5 -0
  30. package/src/ai-cli.ts +2 -1
  31. package/src/ai.ts +15 -5
  32. package/src/client-defaults.ts +29 -13
  33. package/src/codex-session.ts +4 -2
  34. package/src/config.ts +29 -1
  35. package/src/control-state.ts +24 -7
  36. package/src/desktop-bridge.ts +8 -1
  37. package/src/event-sources.ts +2 -1
  38. package/src/execution-authority.ts +2 -1
  39. package/src/executor.ts +31 -6
  40. package/src/failure.ts +32 -0
  41. package/src/host-executor.ts +22 -13
  42. package/src/identity.ts +8 -3
  43. package/src/inbox.ts +7 -3
  44. package/src/index.ts +207 -79
  45. package/src/install-tools.mjs +2 -2
  46. package/src/menu.ts +6 -4
  47. package/src/model-policy.ts +15 -0
  48. package/src/owner.ts +3 -3
  49. package/src/pagerduty.ts +109 -0
  50. package/src/plugins/manager.mjs +47 -8
  51. package/src/plugins/shared.mjs +76 -0
  52. package/src/repair-policy.ts +13 -0
  53. package/src/reply-context.ts +67 -0
  54. package/src/reply-executor.ts +54 -0
  55. package/src/reply-mcp.ts +23 -0
  56. package/src/runs.ts +15 -4
  57. package/src/schedule-cli.ts +36 -7
  58. package/src/scheduler.ts +12 -3
  59. package/src/setup.ts +2 -1
  60. package/src/software-status.ts +5 -5
  61. package/src/task-cli.ts +3 -3
  62. package/src/task-executor.ts +7 -5
  63. package/src/tasks.ts +35 -17
  64. package/src/telegram-source.ts +94 -0
  65. package/src/updates/artifact.mjs +16 -0
  66. package/src/updates/binding.mjs +3 -1
  67. package/src/updates/control.mjs +4 -4
  68. package/src/updates/runtime.mjs +3 -1
  69. package/templates/agent/AGENTS.md +10 -2
  70. package/templates/agent/TOOLS.md +6 -0
  71. package/templates/agent-guidance.md +13 -0
  72. package/templates/failure-review.md +9 -0
  73. package/templates/maintainer-purpose.md +15 -0
  74. package/templates/updates.md +2 -2
  75. package/test/agent-guidance.test.ts +110 -0
  76. package/test/ai-cli.test.ts +7 -6
  77. package/test/ai.test.ts +41 -0
  78. package/test/busy-reply-relay.test.ts +41 -0
  79. package/test/client-defaults.test.ts +37 -5
  80. package/test/codex-context.test.ts +5 -2
  81. package/test/codex-session.test.ts +4 -2
  82. package/test/config.test.ts +29 -0
  83. package/test/executor.test.ts +11 -1
  84. package/test/failure.test.ts +250 -0
  85. package/test/group-owner.test.ts +36 -0
  86. package/test/host-executor.test.ts +38 -7
  87. package/test/intake-relay.test.ts +141 -4
  88. package/test/model-policy.test.ts +61 -0
  89. package/test/pagerduty.test.ts +104 -0
  90. package/test/plugin-manager.test.mjs +3 -2
  91. package/test/relay.test.ts +2 -2
  92. package/test/repair-policy.test.ts +23 -0
  93. package/test/reply.test.ts +131 -0
  94. package/test/schedule-cli.test.ts +8 -2
  95. package/test/shared-services.test.mjs +98 -0
  96. package/test/software-status.test.ts +5 -5
  97. package/test/task-native.test.ts +2 -2
  98. package/test/tasks.test.ts +14 -6
  99. package/test/telegram-source.test.ts +75 -0
  100. package/test/trusted-beta.test.mjs +224 -0
  101. 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
+ })
@@ -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
- await mkdir(path.join(root,'.codex'));await mkdir(path.join(root,'bin'))
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(root,'.codex/models_cache.json'),JSON.stringify({models:[{slug:'test-model',visibility:'list',supported_reasoning_levels:[{effort:'high'}]}]}))
17
- const control=new ControlStore(path.join(root,'control'),900000);await control.aiState(initialPreset('grok'))
18
- const env={...process.env,HOME:root,PATH:path.join(root,'bin')+path.delimiter+process.env.PATH,EZ_CONTROL_DIR:path.join(root,'control')}
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 control.status();assert.equal(state.ai?.defaultId,'initial')
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 control.status(),state)
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 () => ({ model: 'fixture-codex', effort: 'low', apiKey: 'fixture-secret' }),
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: 'fixture-codex', effort: 'low' },
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(root,'.codex/auth.json'))
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)
@@ -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
+ })
@@ -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
+ })