@jc_stack/ez-agents 0.1.0-beta.19 → 0.1.0-beta.23

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.23
4
+
5
+ - Identify failed publisher API reads and missing release tags without exposing
6
+ credentials or signed asset URLs. Document verified tag/artifact staging and
7
+ recovery before a fresh publication attempt.
8
+ - Include beta.22 incomplete-update-receipt recovery; the earlier unpublished
9
+ candidate remains preserved. Existing beta acceptance limits remain.
10
+
11
+ ## 0.1.0-beta.22
12
+
13
+ - Ignore incomplete or malformed update-receipt directories until a valid,
14
+ atomically committed `job.json` exists. They have no activation authority and
15
+ must not take the host executor offline.
16
+
17
+ ## 0.1.0-beta.21
18
+
19
+ - Initialize fresh Telegram bots before reading their identity. Beta.19 could
20
+ retry forever before polling, causing main upgrades to fail their health gate.
21
+ - Restore the unchanged deployment layout required for upgrades from beta.18
22
+ and beta.19; extending health grace does not fix the initialization failure.
23
+ - Include beta.20 health diagnostics. Preserve the earlier immutable unpublished
24
+ candidate; existing beta acceptance limits remain.
25
+
26
+ ## 0.1.0-beta.20
27
+
28
+ - Retain a bounded, content-free health predicate in a failed main-upgrade
29
+ receipt before rollback replaces the candidate relay. This distinguishes relay
30
+ polling and host-executor heartbeat failures without persisting control-state,
31
+ provider, environment, or Docker diagnostic content.
32
+
3
33
  ## 0.1.0-beta.19
4
34
 
5
35
  - Preserve host-backed run completion and delivery evidence while the relay
@@ -1,8 +1,15 @@
1
1
  import { readFileSync } from 'node:fs';
2
- const value = JSON.parse(readFileSync('/state/control/heartbeat.json', 'utf8'));
3
- if (!value.polling || Date.now() - value.at > 20000) process.exit(1);
2
+ const relayControl = process.env.EZ_HEALTH_RELAY_CONTROL_DIR || '/state/control';
3
+ const fail = code => { process.stderr.write(`EZ_HEALTH_${code}\n`); process.exit(1); };
4
+ let value;
5
+ try { value = JSON.parse(readFileSync(relayControl + '/heartbeat.json', 'utf8')); }
6
+ catch { fail('RELAY_UNREADABLE'); }
7
+ if (!value.polling) fail('RELAY_NOT_POLLING');
8
+ if (!Number.isFinite(value.at) || Date.now() - value.at > 20000) fail('RELAY_STALE');
4
9
 
5
10
  if (process.env.EZ_EXECUTOR_TRANSPORT === 'host') {
6
- const host = JSON.parse(readFileSync(process.env.EZ_CONTROL_DIR + '/host-executor/heartbeat.json', 'utf8'));
7
- if (Date.now() - host.at > 15000) process.exit(1);
11
+ let host;
12
+ try { host = JSON.parse(readFileSync(process.env.EZ_CONTROL_DIR + '/host-executor/heartbeat.json', 'utf8')); }
13
+ catch { fail('HOST_UNREADABLE'); }
14
+ if (!Number.isFinite(host.at) || Date.now() - host.at > 15000) fail('HOST_STALE');
8
15
  }
@@ -28,8 +28,10 @@ offer their own default only in this slice. Refresh by opening the native client
28
28
  the relay does not install models, manage subscriptions or guess aliases.
29
29
 
30
30
  Setup initialization and relay startup seed one default choice per installed client.
31
- Settings Refresh available AIs repeats discovery. Active/default presets and
32
- queued snapshots are preserved; discovery only refreshes unused detected entries.
31
+ Choose AI opens the available installed-model catalog directly; Settings keeps
32
+ saved choices and Refresh available AIs repeats default discovery. Active/default
33
+ presets and queued snapshots are preserved; discovery only refreshes unused
34
+ detected entries.
33
35
  Codex uses its native `config/read` interface; Grok reads its documented user
34
36
  model/effort settings (or `models` for the default model). Claude reads user and
35
37
  workspace JSON settings; OpenCode reports resolved config. Unknown defaults and
@@ -61,11 +61,13 @@ Live smoke uses the same host CLI and requires an actual Telegram receipt.
61
61
  Restart preserves pairing and files. One kernel lock excludes relay/smoke
62
62
  writers; exit 73 means a writer is active. Do not delete its lock to bypass it.
63
63
  Health requires recent polling and host-transport heartbeats, not just a process.
64
- Fatal Telegram polling errors, including `409 Conflict`, stop intake and await
65
- worker cleanup plus in-flight task/outbox writes before exit. A conflict still
66
- requires the operator to stop the competing bot poller; the relay does not retry
67
- around that ownership error. Pending and uncertain deliveries keep their existing
68
- outbox/receipt semantics; executor stdout is not replayed as a reply.
64
+ Relay replacement retains the existing 30-second startup grace and deployment
65
+ layout, so compatible releases remain eligible for agent-owned upgrades.
66
+ An interrupted Telegram poller keeps authorized work and in-flight outbox writes
67
+ alive while it retries intake. A `409 Conflict` still requires the operator to
68
+ stop the competing poller; it remains unhealthy after the startup grace. Pending
69
+ and uncertain deliveries keep their existing outbox/receipt semantics; executor
70
+ stdout is not replayed as a reply.
69
71
  Signal and fatal-error paths share one shutdown; a secondary cleanup error is
70
72
  reported without replacing the original startup/polling failure.
71
73
 
@@ -65,8 +65,28 @@ jobs from its latest attempt. Tag, PR and other workflow runs cannot shadow it;
65
65
  failed, pending or incomplete main CI cannot fall back to an older success.
66
66
 
67
67
  Create the `vVERSION` tag at that exact source commit and a **draft prerelease**
68
- with these assets, using native `gh release create --draft --prerelease` and
69
- `gh release upload` under existing release authority:
68
+ with these assets under existing release authority. **Creating a draft release
69
+ with `--target` does not create a Git tag.** Create and push the tag explicitly,
70
+ then use `--verify-tag` to prevent staging against a missing remote tag:
71
+
72
+ ```sh
73
+ # VERSION and SOURCE_SHA identify the reviewed, current-main candidate.
74
+ git tag "v$VERSION" "$SOURCE_SHA"
75
+ git push origin "refs/tags/v$VERSION"
76
+ git ls-remote origin "refs/tags/v$VERSION"
77
+ # Copy the already tested bytes, without rebuilding, to this exact basename.
78
+ cp /absolute/tested-package.tgz /absolute/release/candidate.tgz
79
+ gh release create "v$VERSION" --repo OWNER/REPO --verify-tag --draft --prerelease \
80
+ --title "v$VERSION" --notes-file /absolute/release/notes.md \
81
+ /absolute/release/candidate.tgz /absolute/release/release-receipt.json
82
+ ```
83
+
84
+ If the tag already exists, read and verify its commit (peel annotated tags) rather
85
+ than recreating or force-pushing it. Read back the draft by numeric ID and check
86
+ asset names and downloaded SHA-256 before dispatch. `npm pack`'s default filename
87
+ is not the publisher's asset name; GitHub asset labels do not rename the asset.
88
+ Use `gh release upload` only to add a missing asset, never `--clobber` to replace
89
+ candidate bytes or receipts. The required assets are:
70
90
 
71
91
  - `candidate.tgz`: the exact Mac-tested bytes from `npm pack --ignore-scripts`.
72
92
  Do not rebuild it on Actions.
@@ -114,6 +134,22 @@ identity on the release PR. A failed command after the publish call may mean npm
114
134
  accepted it: inspect registry state first. A rerun may verify an existing exact
115
135
  version; if the version is absent it refuses a second write. Reconcile first,
116
136
  then create a fresh authorized dispatch if appropriate. Never repeat or overwrite that version or silently repair tags.
137
+ When validation fails, use the logged GitHub API path to identify the missing
138
+ input. A tag lookup 404 is not evidence of a draft-release permission problem.
139
+ A draft/asset 404 can mean absent evidence or insufficient access; verify with
140
+ the existing authorized identity before diagnosing credentials. Do not broaden
141
+ token permissions based on a generic 404.
142
+
143
+ For a failure before the publish job starts, confirm that job was skipped and
144
+ read npm for the exact version. If absent, repair missing staging inputs against
145
+ the same approved commit/bytes, verify tag, asset names and digest, and create a
146
+ fresh dispatch. If main or the package changes, prepare a new reviewed version;
147
+ preserve the old draft instead of moving its tag or replacing its artifact.
148
+ If any npm write may have started, use exact registry/artifact reconciliation
149
+ above first. Publication recovery never means rolling back a running agent.
150
+ Runtime upgrades and `failed`/`rolled-back` versus `recovery-required` recovery
151
+ follow [upgrades](upgrades.md) under the installation's saved policy.
152
+
117
153
  Missing trust or registry access is an external dependency, not a reason to use
118
154
  a token workaround.
119
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.19",
3
+ "version": "0.1.0-beta.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
@@ -87,8 +87,8 @@ export function tarManifest(path) {
87
87
  return JSON.parse(execFileSync('tar', ['-xOf', path, 'package/package.json'], { ...options, maxBuffer: 1024 * 1024 }));
88
88
  }
89
89
 
90
- async function responseBytes(response, max = MAX_ARTIFACT) {
91
- assert(response.ok, `HTTP ${response.status} while reading release evidence`);
90
+ async function responseBytes(response, max = MAX_ARTIFACT, context = 'release evidence') {
91
+ assert(response.ok, `HTTP ${response.status} while reading ${context}`);
92
92
  assert(Number(response.headers.get('content-length') || 0) <= max, 'Response too large');
93
93
  let size = 0; const chunks = [];
94
94
  for await (const chunk of response.body) { size += chunk.length; assert(size <= max, 'Response too large'); chunks.push(chunk); }
@@ -98,6 +98,8 @@ async function responseBytes(response, max = MAX_ARTIFACT) {
98
98
  export function githubClient(token, fetcher = fetch) {
99
99
  return async (path, binary = false) => {
100
100
  assert(path.startsWith('/repos/'), 'Invalid GitHub API path');
101
+ // Identify the failed read without logging tokens, response bodies or signed URLs.
102
+ const context = `GitHub GET ${path.split('?')[0]}`;
101
103
  const response = await fetcher(`https://api.github.com${path}`, {
102
104
  headers: { Accept: binary ? 'application/octet-stream' : 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}), 'X-GitHub-Api-Version': '2022-11-28' },
103
105
  redirect: 'manual', signal: AbortSignal.timeout(30_000),
@@ -105,9 +107,12 @@ export function githubClient(token, fetcher = fetch) {
105
107
  if (binary && [301, 302, 303, 307, 308].includes(response.status)) {
106
108
  const location = new URL(response.headers.get('location'));
107
109
  assert(location.protocol === 'https:' && (location.hostname === 'release-assets.githubusercontent.com' || location.hostname === 'objects.githubusercontent.com'), 'Unexpected asset redirect');
108
- return responseBytes(await fetcher(location, { signal: AbortSignal.timeout(60_000), redirect: 'error' }));
110
+ return responseBytes(await fetcher(location, { signal: AbortSignal.timeout(60_000), redirect: 'error' }), MAX_ARTIFACT, `${context} asset download`);
109
111
  }
110
- const bytes = await responseBytes(response, binary ? MAX_ARTIFACT : 8 * 1024 * 1024);
112
+ if (response.status === 404 && path.includes('/git/ref/tags/')) {
113
+ throw new Error(`HTTP 404 while reading ${context}: release tag is missing or inaccessible; a draft release does not create its Git tag. Verify the remote tag at the reviewed source before dispatch.`);
114
+ }
115
+ const bytes = await responseBytes(response, binary ? MAX_ARTIFACT : 8 * 1024 * 1024, context);
111
116
  return binary ? bytes : JSON.parse(bytes.toString());
112
117
  };
113
118
  }
package/src/index.ts CHANGED
@@ -1029,7 +1029,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1029
1029
  await bot.api.deleteWebhook({ drop_pending_updates: false })
1030
1030
  await bot.api.setMyCommands(commands)
1031
1031
  await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
1032
- if (!bot.botInfo) await bot.init()
1032
+ await bot.init()
1033
1033
  await bot.start({
1034
1034
  drop_pending_updates: false,
1035
1035
  onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
package/src/menu.ts CHANGED
@@ -53,9 +53,14 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
53
53
  : 'Selected for this conversation. Queued work unchanged.'}`)
54
54
  }
55
55
  const list = async (ctx: Context, settings = false) => {
56
+ if (!settings) {
57
+ const models = await catalog()
58
+ if (models.length) return available(ctx, 0, models)
59
+ }
56
60
  const state = await control.aiState(initial)
57
61
  const keyboard = new InlineKeyboard()
58
- for (const preset of state.presets) button(keyboard,
62
+ const presets = settings ? state.presets : state.presets.filter((preset) => preset.id === initial.id)
63
+ for (const preset of presets) button(keyboard,
59
64
  `${preset.id === (settings ? state.defaultId : state.selectedId) ? '✓ ' : ''}${preset.name}`,
60
65
  async (next) => {
61
66
  if (settings) {
@@ -64,16 +69,17 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
64
69
  await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
65
70
  } else await choose(next, preset)
66
71
  })
67
- button(keyboard, 'Add AI…', (next) => available(next))
68
- if (settings) button(keyboard, 'Refresh available AIs', async (next) => {
72
+ button(keyboard, 'Browse available models', (next) => available(next))
73
+ button(keyboard, 'Refresh available AIs', async (next) => {
69
74
  await refresh()
70
75
  await list(next, true)
71
76
  })
72
- await ctx.reply(settings ? 'Default for new conversations\nChoose a saved AI. Current work will not change.'
73
- : 'Choose AI\nChanging CLI starts a fresh conversation; files stay.', { reply_markup: keyboard })
77
+ await ctx.reply(settings
78
+ ? 'Default for new conversations\nChoose a saved AI. Current work will not change.'
79
+ : 'Choose AI\nNo client catalog available. Showing the current client setup only.', { reply_markup: keyboard })
74
80
  }
75
- const available = async (ctx: Context, page = 0) => {
76
- const models = await catalog()
81
+ const available = async (ctx: Context, page = 0, listed?: ModelChoice[]) => {
82
+ const models = listed ?? await catalog()
77
83
  const keyboard = new InlineKeyboard()
78
84
  for (const model of models.slice(page * 8, page * 8 + 8)) {
79
85
  button(keyboard, `${model.cli} · ${model.name}`, async (next) => {
@@ -86,7 +92,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
86
92
  if (page > 0) button(keyboard, 'Previous', (next) => available(next, page - 1))
87
93
  if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, page + 1))
88
94
  await ctx.reply(models.length
89
- ? 'Installed client choices. Grok/Codex use their local model catalog; other clients use their own default. Adding saves the choice; it does not switch AI.'
95
+ ? 'Choose AI\nAvailable models are populated automatically from the installed clients. Grok/Codex use their local catalog; other clients use their own default. Choosing one saves it; it does not switch AI.'
90
96
  : 'No client catalog available. Open the installed CLI once, then try again.', { reply_markup: keyboard })
91
97
  }
92
98
  const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
@@ -7,6 +7,7 @@ import { digest, extract, newer, compatible, version, releaseContract, registryV
7
7
  export const read = async file => JSON.parse(await fs.readFile(file,'utf8'));
8
8
  export const missing = error => {if(error.code!=='ENOENT')throw error;return null;};
9
9
  export const targetId = value => {if(value!=='main'&&!/^[a-z][a-z0-9-]{0,39}$/.test(value))throw Error('Invalid update target');return value;};
10
+ const jobId=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
10
11
  export const updateHome = home => path.join(home,'updates');
11
12
  export async function state(home) {
12
13
  const config=await read(path.join(home,'config.json'));
@@ -77,12 +78,15 @@ export async function prepare(home,target,{file,release}) {
77
78
  } catch(error){await fs.rm(dir,{recursive:true,force:true});throw error;}
78
79
  }
79
80
  export function jobPath(home,id) {
80
- if(!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id))throw Error('Invalid upgrade job ID');
81
+ if(!jobId.test(id))throw Error('Invalid upgrade job ID');
81
82
  return path.join(updateHome(home),id);
82
83
  }
83
84
  export async function jobs(home) {
84
85
  const entries=await fs.readdir(updateHome(home),{withFileTypes:true}).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
85
- return Promise.all(entries.filter(e=>e.isDirectory()&&/^[a-f0-9-]{36}$/.test(e.name)).map(e=>read(path.join(jobPath(home,e.name),'job.json'))));
86
+ // A job becomes visible only when its receipt is atomically committed. A crash
87
+ // before that point leaves no activation authority and must not stop the host.
88
+ const found=await Promise.all(entries.filter(e=>e.isDirectory()&&jobId.test(e.name)).map(e=>read(path.join(jobPath(home,e.name),'job.json')).catch(missing)));
89
+ return found.filter(Boolean);
86
90
  }
87
91
  async function requireSupervisor(directory) {
88
92
  const h=await read(path.join(directory,'supervisor.json'));
@@ -43,6 +43,14 @@ export async function packageManager(root,run=execute) {
43
43
  throw Error(`Upgrade prerequisite unavailable: ${required}. ${failures.join('; ')}. Check the host supervisor service PATH (shell aliases do not count). Reuse its installed pnpm or Corepack; expose the launcher directory to that service and restart it after the current turn. If neither exists, provision the pinned manager first. Do not substitute npm install or reinstall the agent. After repair, prepare/apply a new job when status is failed; recover is only for recovery-required.`);
44
44
  }
45
45
  export const relayArgs = config => ['compose','--env-file',path.join(config.deploymentDir,'docker.env')];
46
+ const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE']);
47
+ async function healthEvidence(config,run) {
48
+ const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
49
+ if(!/^[a-f0-9]{12,64}$/i.test(id))return '';
50
+ const raw=await run('docker',['inspect','--format','{{json .State.Health}}',id]);
51
+ const health=JSON.parse(raw),entry=health?.Log?.at(-1),match=typeof entry?.Output==='string'&&entry.Output.match(/^EZ_HEALTH_([A-Z_]+)\s*$/);
52
+ return match&&healthCodes.has(match[1])?` (health=${match[1].toLowerCase().replaceAll('_','-')})`:'';
53
+ }
46
54
  function envValue(text,key,value) {
47
55
  if(/[\r\n\0']/.test(value))throw Error('Unsafe deployment value');
48
56
  const line=`${key}='${value}'`;
@@ -118,7 +126,11 @@ export async function perform(home,job,hooks) {
118
126
  job.runtimeVerified=running;
119
127
  }
120
128
  job.status='completed';job.endedAt=new Date().toISOString();await save();return job;
121
- }catch(error){job.error=error.message;await save();return recover(home,job,hooks);}
129
+ }catch(error){
130
+ let message=error.message;
131
+ if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
132
+ job.error=message;await save();return recover(home,job,hooks);
133
+ }
122
134
  }
123
135
  export async function recover(home,job,hooks) {
124
136
  const run=hooks.execute||execute,{config}=await state(home);
@@ -12,6 +12,19 @@ delegation, model, and effort from the task—not a fixed routing rule. Keep one
12
12
  writer per workspace; the primary agent owns integration, verification, and
13
13
  external actions.
14
14
 
15
+ ## AI selection
16
+
17
+ An explicit owner request to change this conversation's AI or reasoning effort
18
+ is a supported Ez control, not a request to edit the host Codex configuration,
19
+ inspect a native session record, or restart the runtime. Run
20
+ `ezenciel-agents-ai list`, then select only a returned choice with
21
+ `ezenciel-agents-ai select --cli <cli> --model <model> --effort <effort>`.
22
+ This changes subsequent owner messages only; a running or queued job retains
23
+ its captured choice, and the installation default is unchanged. Switching CLI
24
+ starts a fresh native conversation while preserving the workspace. Report the
25
+ confirmed selected choice from the command output; do not infer it from a
26
+ host-level setting or the current native session.
27
+
15
28
  ## Telegram replies
16
29
 
17
30
  Use the messaging CLI for the current run's source chat, normally the paired
@@ -45,6 +45,19 @@ test('shared guidance teaches source-chat delivery and real Telegram line breaks
45
45
  assert.ok(shared.includes('ezenciel-agents-message --text-file ./work/reply.md'))
46
46
  })
47
47
 
48
+ test('shared guidance makes owner AI selection a relay control, not host configuration', async () => {
49
+ const shared = await readFile(sharedGuidancePath, 'utf8')
50
+ for (const prompt of [
51
+ executorJobPrompt('tg_owner', ['change to Terra medium']),
52
+ desktopJobPrompt('tg_owner_gui', ['change to Terra medium'], undefined, '/tmp/bin', '/tmp/control'),
53
+ ]) {
54
+ assert.ok(prompt.includes('`ezenciel-agents-ai list`'))
55
+ assert.ok(prompt.includes('`ezenciel-agents-ai select --cli <cli> --model <model> --effort <effort>`'))
56
+ assert.ok(prompt.includes('not a request to edit the host Codex configuration'))
57
+ assert.match(prompt, /a running or queued job retains\s+its captured choice/)
58
+ }
59
+ })
60
+
48
61
  test('package guidance resolution ignores a workspace shadow file', async () => {
49
62
  const root = path.join(tmpdir(), `ez-guidance-${randomUUID()}`)
50
63
  await mkdir(root, { recursive: true })
package/test/ai.test.ts CHANGED
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { ControlStore } from '../src/control-state.js'
7
7
  import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
8
+ import { createAiMenu } from '../src/menu.js'
8
9
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
9
10
  import { InboxStore } from '../src/inbox.js'
10
11
  import type { Update } from 'grammy/types'
@@ -92,6 +93,45 @@ test('model catalog projects native metadata only, excluding hidden entries and
92
93
  } finally { await rm(home, { recursive: true, force: true }) }
93
94
  })
94
95
 
96
+ test('Choose AI opens the available installed-model catalog without an Add AI step', async () => {
97
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-'))
98
+ try {
99
+ const menu = createAiMenu(new ControlStore(dir, 1000), 'grok', async () => [{
100
+ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'],
101
+ }])
102
+ let reply = ''
103
+ let keyboard: { inline_keyboard?: Array<Array<{ text: string }>> } | undefined
104
+ await menu.list({ reply: async (text: string, options?: { reply_markup?: unknown }) => {
105
+ reply = text
106
+ keyboard = options?.reply_markup as typeof keyboard
107
+ return {} as never
108
+ } } as never)
109
+ assert.match(reply, /Available models are populated automatically/)
110
+ assert.deepEqual(keyboard?.inline_keyboard?.flat().map((button) => button.text), ['codex · Fixture'])
111
+ } finally { await rm(dir, { recursive: true, force: true }) }
112
+ })
113
+
114
+ test('Choose AI does not expose saved model choices without a catalog to validate them', async () => {
115
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-empty-'))
116
+ try {
117
+ const store = new ControlStore(dir, 1000)
118
+ await store.aiState(initialPreset('grok'))
119
+ await store.savePreset({ id: 'saved', name: 'Saved model', cli: 'codex', model: 'fixture-model', effort: 'medium' })
120
+ await store.savePreset({ id: 'saved-default', name: 'Saved client default', cli: 'claude' })
121
+ const menu = createAiMenu(store, 'grok', async () => [])
122
+ let reply = ''
123
+ let keyboard: { inline_keyboard?: Array<Array<{ text: string }>> } | undefined
124
+ await menu.list({ reply: async (text: string, options?: { reply_markup?: unknown }) => {
125
+ reply = text
126
+ keyboard = options?.reply_markup as typeof keyboard
127
+ return {} as never
128
+ } } as never)
129
+ assert.match(reply, /current client setup only/)
130
+ assert.ok(!keyboard?.inline_keyboard?.flat().some((button) => button.text.includes('Saved model')))
131
+ assert.ok(!keyboard?.inline_keyboard?.flat().some((button) => button.text.includes('Saved client default')))
132
+ } finally { await rm(dir, { recursive: true, force: true }) }
133
+ })
134
+
95
135
  test('model catalog can read an agent-bound Codex home', async () => {
96
136
  const home = await mkdtemp(join(tmpdir(), 'ez-catalog-home-'))
97
137
  const codexHome = await mkdtemp(join(tmpdir(), 'ez-catalog-codex-'))
@@ -17,7 +17,10 @@ import { TelegramSource } from '../src/telegram-source.js'
17
17
  import { packageVersion } from '../src/version.js'
18
18
  import type { Update } from 'grammy/types'
19
19
  const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
20
- const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<200;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
20
+ // This integration test starts subprocesses, persists their receipts, and invokes
21
+ // the schedule CLI. Give a busy CI worker time to settle without changing the
22
+ // production recovery deadline.
23
+ const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
21
24
 
22
25
  test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
23
26
  const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
@@ -137,6 +140,29 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
137
140
  } finally {release();await relay.stop();await rm(dir,{recursive:true,force:true})}
138
141
  })
139
142
 
143
+ test('a fresh relay initializes its bot identity before polling', async () => {
144
+ const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
145
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
146
+ const methods:string[]=[]
147
+ relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
148
+ methods.push(method)
149
+ if(method==='getMe') return {ok:true,result:{id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'}} as any
150
+ if(method==='getUpdates') {
151
+ if(signal && !signal.aborted) await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
152
+ return {ok:true,result:[]} as any
153
+ }
154
+ return {ok:true,result:true} as any
155
+ })
156
+ const started=relay.start()
157
+ try {
158
+ await until(async()=>methods.includes('getUpdates'))
159
+ assert.equal(relay.bot.isRunning(),true)
160
+ assert.equal(methods.filter(method=>method==='getMe').length,1)
161
+ assert.ok(methods.indexOf('getMe')<methods.indexOf('getUpdates'))
162
+ } finally {await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
163
+ assert.equal(relay.bot.isRunning(),false)
164
+ })
165
+
140
166
  for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
141
167
  const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
142
168
  let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
@@ -201,7 +201,7 @@ test('owner group discovery routes only to the private chat and rechecks identit
201
201
  } finally { await f.close() }
202
202
  })
203
203
 
204
- test('four-item menu is owner-only; saved AI buttons work and forged/stale buttons cannot change settings', async () => {
204
+ test('four-item menu is owner-only; available AI choices work and forged/stale buttons cannot change settings', async () => {
205
205
  const f = await fixture()
206
206
  const callback = (id: number, data: string, user = 101): Update => ({
207
207
  update_id: id,
@@ -220,10 +220,18 @@ test('four-item menu is owner-only; saved AI buttons work and forged/stale butto
220
220
  await f.relay.bot.handleUpdate(callback(4, 'ai:forged'))
221
221
  assert.equal(await store.getActiveSession(), null)
222
222
  await f.relay.bot.handleUpdate(callback(5, pick))
223
+ if (!(await store.getActiveSession())) {
224
+ let useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')?.callback_data
225
+ if (!useNow) {
226
+ await f.relay.bot.handleUpdate(callback(6, f.keyboards.at(-1)!.flat()[0].callback_data))
227
+ useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')!.callback_data
228
+ }
229
+ await f.relay.bot.handleUpdate(callback(7, useNow))
230
+ }
223
231
  assert.ok(await store.getActiveSession())
224
- await f.relay.bot.handleUpdate(callback(6, pick))
232
+ await f.relay.bot.handleUpdate(callback(8, pick))
225
233
  assert.match(f.replies.at(-1)!, /Menu expired/)
226
- await f.relay.bot.handleUpdate(message(7, '/settings'))
234
+ await f.relay.bot.handleUpdate(message(9, '/settings'))
227
235
  assert.match(f.replies.at(-1)!, /Default for new conversations/)
228
236
  await f.relay.bot.handleUpdate(message(8, '/status'))
229
237
  assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
@@ -222,3 +222,63 @@ test('workflow runs and attempt jobs are paginated before selecting required evi
222
222
  [jobsPath.replace('&page=1', '&page=2')]: { jobs: [job] },
223
223
  }));
224
224
  });
225
+
226
+ test('GitHub errors identify the failed read without exposing credentials or signed URLs', async () => {
227
+ const missing = githubClient('private-token', async () => new Response('private-body', { status: 404 }));
228
+ await assert.rejects(missing('/repos/jdorado/ez-agents/git/ref/tags/v1.2.3-beta.1'), /GitHub GET .*\/git\/ref\/tags\/v1\.2\.3-beta\.1: release tag is missing or inaccessible/);
229
+ await assert.rejects(missing('/repos/jdorado/ez-agents/releases/123'), error => {
230
+ assert.match(error.message, /HTTP 404.*GitHub GET .*\/releases\/123/);
231
+ assert.doesNotMatch(error.message, /private-token|private-body|release tag/);
232
+ return true;
233
+ });
234
+ let request = 0;
235
+ const download = githubClient('private-token', async () => request++ === 0
236
+ ? new Response('', { status: 302, headers: { location: 'https://release-assets.githubusercontent.com/file?signature=private-signature' } })
237
+ : new Response('private-body', { status: 403 }));
238
+ await assert.rejects(download('/repos/jdorado/ez-agents/releases/assets/456', true), error => {
239
+ assert.match(error.message, /HTTP 403.*\/releases\/assets\/456 asset download/);
240
+ assert.doesNotMatch(error.message, /private-token|private-body|private-signature|signature=/);
241
+ return true;
242
+ });
243
+ });
244
+
245
+ test('staging recovery requires a real source tag and canonical asset before validating exact bytes', async t => {
246
+ const { validate } = await import('../scripts/trusted-beta.mjs');
247
+ const { readFile } = await import('node:fs/promises');
248
+ const dir = await mkdtemp(join(tmpdir(), 'beta-staging-recovery-'));
249
+ t.after(() => rm(dir, { recursive: true, force: true }));
250
+ await mkdir(join(dir, 'package'));
251
+ await writeFile(join(dir, 'package/package.json'), JSON.stringify(manifest));
252
+ execFileSync('tar', ['-czf', join(dir, 'candidate.tgz'), '-C', dir, 'package/package.json']);
253
+ const bytes = await readFile(join(dir, 'candidate.tgz'));
254
+ const candidateEnv = { ...env, RELEASE_SHA256: sha256(bytes) };
255
+ let tagExists = false, assetName = 'jc_stack-ez-agents-1.2.3-beta.1.tgz';
256
+ const reads = [];
257
+ const source = sourceApi();
258
+ const api = async path => {
259
+ reads.push(path);
260
+ if (path.includes('/git/ref/tags/') && !tagExists) {
261
+ return githubClient('test', async () => new Response('', { status: 404 }))(path);
262
+ }
263
+ if (path.endsWith('/releases/123')) return { id: 123, draft: true, prerelease: true, tag_name: `v${expected.version}`, assets: [
264
+ { id: 456, name: assetName, state: 'uploaded' }, { id: 457, name: 'release-receipt.json', state: 'uploaded' },
265
+ ] };
266
+ if (path.endsWith('/releases/assets/456')) return bytes;
267
+ if (path.endsWith('/releases/assets/457')) return Buffer.from(JSON.stringify({ ...receipt, sha256: candidateEnv.RELEASE_SHA256 }));
268
+ if (path.endsWith('/pulls/41')) return { merged: true, base: { repo: { full_name: expected.repository }, ref: 'main' }, merge_commit_sha: expected.sourceSha };
269
+ return source(path);
270
+ };
271
+ const output = join(dir, 'validated');
272
+ await assert.rejects(validate(output, candidateEnv, api), /release tag is missing/);
273
+ assert.ok(!reads.some(path => path.includes('/releases/')));
274
+ tagExists = true;
275
+ await assert.rejects(validate(output, candidateEnv, api), /Missing or ambiguous asset: candidate.tgz/);
276
+ assert.ok(!reads.some(path => path.includes('/releases/assets/')));
277
+ assetName = 'candidate.tgz';
278
+ await assert.rejects(validate(output, { ...candidateEnv, RELEASE_SHA256: '0'.repeat(64) }, api), /SHA256 mismatch/);
279
+ const result = await validate(output, candidateEnv, api);
280
+ assert.equal(result.sha256, sha256(bytes));
281
+ assert.deepEqual(await readFile(join(output, 'candidate.tgz')), bytes);
282
+ // Retry cannot silently replace an existing validated bundle.
283
+ await assert.rejects(validate(output, candidateEnv, api), /EEXIST/);
284
+ });
@@ -7,7 +7,7 @@ import { gzipSync } from 'node:zlib';
7
7
  import { spawn, execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
9
  import { extract, digest, version, newer, compatible } from '../src/updates/artifact.mjs';
10
- import { prepare, submit, command, read, jobPath, eligibility } from '../src/updates/control.mjs';
10
+ import { prepare, submit, command, read, jobPath, eligibility, jobs } from '../src/updates/control.mjs';
11
11
  import { perform, environment, packageManager } from '../src/updates/runtime.mjs';
12
12
  import { atomic, snapshot, compose } from '../src/plugins/manager.mjs';
13
13
  import { bindUpdates } from '../src/updates/binding.mjs';
@@ -86,6 +86,16 @@ test('status distinguishes installed, running, legacy and stale main versions wi
86
86
  const result=await exec(process.execPath,[new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname,'--home',f.home,'status']);
87
87
  assert.equal(JSON.parse(result.stdout).main.installedVersion,'0.1.0');
88
88
  });
89
+ test('relay healthcheck emits bounded predicate evidence without state contents',async t=>{
90
+ const root=await fs.realpath(await fs.mkdtemp(path.join(tmpdir(),'ez-healthcheck-')));t.after(()=>fs.rm(root,{recursive:true,force:true}));
91
+ const relay=path.join(root,'relay'),host=path.join(root,'host');await fs.mkdir(relay,{recursive:true});await fs.mkdir(path.join(host,'host-executor'),{recursive:true});
92
+ const check=async()=>exec(process.execPath,[new URL('../docker/healthcheck.mjs',import.meta.url).pathname],{env:{...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:relay,EZ_EXECUTOR_TRANSPORT:'host',EZ_CONTROL_DIR:host}});
93
+ await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()}));await check();
94
+ await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:false,at:Date.now(),private:'must-not-appear'}));
95
+ await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_RELAY_NOT_POLLING/);assert.doesNotMatch(error.stderr,/must-not-appear/);return true;});
96
+ await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()-20000}));
97
+ await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_HOST_STALE/);return true;});
98
+ });
89
99
  test('plugin status verifies images and reports stopped, mismatched and unreachable runtimes honestly',async t=>{
90
100
  const f=await fixture(t,'plugin'),id='a'.repeat(64),image='sha256:'+'b'.repeat(64);
91
101
  for(const mode of ['running','ndjson','stopped','missing','mismatched','offline']) {
@@ -157,6 +167,11 @@ test('failed preparation never stops runtime; failed activation rolls back code
157
167
  else assert((await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8')).includes('sha256:'+'a'.repeat(64)));
158
168
  }
159
169
  });
170
+ test('main health-gate rollback retains only the recognized sanitized predicate',async t=>{
171
+ const f=await fixture(t),job=await queued(f),base=runtime(f);let failed=false;
172
+ const r={...base,execute:async(c,a,o)=>{if(a.includes('up')&&!failed){failed=true;throw Error('container relay is unhealthy');}return a.includes('ps')?'a'.repeat(64):a[0]==='inspect'&&a[2]==='{{json .State.Health}}'?JSON.stringify({Log:[{Output:'EZ_HEALTH_HOST_STALE\n'}]}):base.execute(c,a,o);}};
173
+ const result=await perform(f.home,job,r);assert.equal(result.status,'rolled-back');assert.match(result.error,/health=host-stale/);assert.doesNotMatch(result.error,/private-test-token/);
174
+ });
160
175
  test('plugin transaction preserves named volumes, backs up stopped data and rolls back failed health',async t=>{
161
176
  for(const failed of [false,true]) {
162
177
  const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f,{fail:(_c,a)=>failed&&a.includes('up')});
@@ -237,6 +252,11 @@ test('missing or broken managers fail with repair guidance before installing or
237
252
 
238
253
  for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider} drains work, replaces host PID and recovers after restart`,async t=>{
239
254
  const f=await fixture(t),fake=path.join(f.root,'fake');await fs.mkdir(fake);
255
+ // A process may die after creating a job directory but before atomically
256
+ // committing its receipt. That directory must not block transport startup.
257
+ await fs.mkdir(path.join(f.home,'updates','d18e847a-bb59-49c6-96f3-27fdc43ca44f'));
258
+ await fs.mkdir(path.join(f.home,'updates','a'.repeat(36)));
259
+ assert.deepEqual(await jobs(f.home),[]);
240
260
  const hostCode=`import fs from 'node:fs';import path from 'node:path';const c=JSON.parse(fs.readFileSync(process.argv[2])).agents[0];const d=path.join(c.controlDir,'host-executor');fs.mkdirSync(d,{recursive:true});const beat=()=>{fs.writeFileSync(path.join(d,'heartbeat.json'),JSON.stringify({pid:process.pid,at:Date.now()}));};beat();const timer=setInterval(()=>{try{process.kill(Number(process.env.EZ_HOST_SUPERVISOR_PID),0)}catch{process.exit(0)}beat()},100);process.on('SIGTERM',()=>{clearInterval(timer);process.exit(0)});`;
241
261
  for(const dir of [f.old,f.source]) {
242
262
  await fs.mkdir(path.join(dir,'node_modules/tsx/dist'),{recursive:true});await fs.writeFile(path.join(dir,'node_modules/tsx/dist/loader.mjs'),'');