@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 +14 -0
- package/docs/trusted-publishing.md +38 -2
- package/package.json +1 -1
- package/scripts/trusted-beta.mjs +9 -4
- package/src/updates/control.mjs +6 -2
- package/test/trusted-beta.test.mjs +60 -0
- package/test/updates.test.mjs +6 -1
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
|
|
69
|
-
`
|
|
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.
|
|
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.",
|
package/scripts/trusted-beta.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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/updates/control.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
});
|
package/test/updates.test.mjs
CHANGED
|
@@ -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'),'');
|