@jc_stack/ez-agents 0.1.0-beta.21 → 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,19 @@
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
+
3
17
  ## 0.1.0-beta.21
4
18
 
5
19
  - Initialize fresh Telegram bots before reading their identity. Beta.19 could
@@ -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.21",
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
  }
@@ -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'));
@@ -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';
@@ -252,6 +252,11 @@ test('missing or broken managers fail with repair guidance before installing or
252
252
 
253
253
  for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider} drains work, replaces host PID and recovers after restart`,async t=>{
254
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),[]);
255
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)});`;
256
261
  for(const dir of [f.old,f.source]) {
257
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'),'');