@jc_stack/ez-agents 0.1.0-beta.21 → 0.1.0-beta.24
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 +22 -0
- package/docs/plugins.md +24 -3
- package/docs/trusted-publishing.md +38 -2
- package/package.json +1 -1
- package/scripts/trusted-beta.mjs +9 -4
- package/src/host-executor.ts +2 -1
- package/src/plugins/manager.mjs +71 -10
- package/src/updates/binding.mjs +1 -1
- package/src/updates/control.mjs +6 -2
- package/src/updates/runtime.mjs +3 -3
- package/test/plugin-manager.test.mjs +42 -10
- package/test/trusted-beta.test.mjs +60 -0
- package/test/updates.test.mjs +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.24
|
|
4
|
+
|
|
5
|
+
- Preserve host-owned private plugin networks across generated Compose services,
|
|
6
|
+
one-shot commands and compatible updates. Bind routes to reviewed plugin
|
|
7
|
+
revisions and retain plugin-local connectivity.
|
|
8
|
+
- Support existing deployments on their first upgrade. Network authorization
|
|
9
|
+
remains outside the agent-writable registry. Existing beta acceptance limits remain.
|
|
10
|
+
|
|
11
|
+
## 0.1.0-beta.23
|
|
12
|
+
|
|
13
|
+
- Identify failed publisher API reads and missing release tags without exposing
|
|
14
|
+
credentials or signed asset URLs. Document verified tag/artifact staging and
|
|
15
|
+
recovery before a fresh publication attempt.
|
|
16
|
+
- Include beta.22 incomplete-update-receipt recovery; the earlier unpublished
|
|
17
|
+
candidate remains preserved. Existing beta acceptance limits remain.
|
|
18
|
+
|
|
19
|
+
## 0.1.0-beta.22
|
|
20
|
+
|
|
21
|
+
- Ignore incomplete or malformed update-receipt directories until a valid,
|
|
22
|
+
atomically committed `job.json` exists. They have no activation authority and
|
|
23
|
+
must not take the host executor offline.
|
|
24
|
+
|
|
3
25
|
## 0.1.0-beta.21
|
|
4
26
|
|
|
5
27
|
- Initialize fresh Telegram bots before reading their identity. Beta.19 could
|
package/docs/plugins.md
CHANGED
|
@@ -198,12 +198,33 @@ Secrets persist privately across reinstall and are never included in registry
|
|
|
198
198
|
responses. Twenty provides a complete v2 backend example.
|
|
199
199
|
|
|
200
200
|
Compose JSON is generated, not accepted from untrusted arbitrary Compose input.
|
|
201
|
-
No ports, host
|
|
202
|
-
Docker socket or raw Compose args can be supplied.
|
|
203
|
-
of the canonical registry path;
|
|
201
|
+
No ports, host-network mode, privileged services, host env inheritance, arbitrary bind mounts,
|
|
202
|
+
Docker socket or raw Compose args can be supplied. Package descriptors cannot select
|
|
203
|
+
Docker networks. Project names include a hash of the canonical registry path;
|
|
204
|
+
networks and volumes inherit that namespace.
|
|
204
205
|
Images use release-specific names. All containers drop capabilities and run as
|
|
205
206
|
UID 1000 by default; v2 can declare another non-root UID:GID. Plugins sharing a profile remain in the same owning deployment.
|
|
206
207
|
|
|
208
|
+
### Host-owned private networks
|
|
209
|
+
|
|
210
|
+
Some private deployments need a reviewed plugin service to reach another private
|
|
211
|
+
Compose project. The installation host may save `pluginNetworkBindings` on that
|
|
212
|
+
agent's entry in its host-owned `host-executor.json`, keyed by plugin id. Each
|
|
213
|
+
route records the explicitly approved reviewed plugin `revisions` and literal
|
|
214
|
+
`{service, network}` entries. The manager re-hashes the installed snapshot
|
|
215
|
+
before accepting a route, then emits it for both persistent services and
|
|
216
|
+
one-shot command containers. Bound services keep their plugin's default network
|
|
217
|
+
as well, so they retain access to plugin-local dependencies. Routes therefore
|
|
218
|
+
survive command-time Compose regeneration and restarts. Before a routed plugin
|
|
219
|
+
update, the host adds the inspected candidate revision to that route; both the
|
|
220
|
+
current and candidate revisions may remain authorized during the transition.
|
|
221
|
+
|
|
222
|
+
This is deployment configuration, not a package-descriptor field or an `ez`
|
|
223
|
+
command: agents and plugins cannot request or alter host networks. The host
|
|
224
|
+
installer owns the binding and must ensure the selected service is stopped
|
|
225
|
+
before changing its network topology. The executor's writable tool registry is
|
|
226
|
+
never a source of network attachment authority.
|
|
227
|
+
|
|
207
228
|
Commands run in one-shot client containers against their own service's volumes;
|
|
208
229
|
stdin, stdout, stderr, literal arguments and exit codes are preserved. SIGINT/
|
|
209
230
|
SIGTERM cancel the Docker call and remove its unique client container. The manager
|
|
@@ -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.24",
|
|
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/host-executor.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { taskWorkspace } from './task-workspace.js'
|
|
|
13
13
|
import { packageVersion } from './version.js'
|
|
14
14
|
import { installedPluginVersions } from './software-status.js'
|
|
15
15
|
|
|
16
|
-
export type
|
|
16
|
+
export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
|
|
17
|
+
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
|
|
17
18
|
export type HostInstallation = { cli: string; agents: HostBinding[] }
|
|
18
19
|
|
|
19
20
|
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -5,9 +5,11 @@ import path from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { createHash, randomUUID, randomBytes } from 'node:crypto';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
8
9
|
|
|
9
10
|
const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
|
|
10
11
|
const id = value => { if(typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(value)) throw Error('Invalid identifier'); return value; };
|
|
12
|
+
const dockerNetwork = value => { if(typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(value)) throw Error('Invalid Docker network'); return value; };
|
|
11
13
|
const hash = data => createHash('sha256').update(data).digest('hex');
|
|
12
14
|
const json = async file => JSON.parse(await fs.readFile(file,'utf8'));
|
|
13
15
|
const emit = value => console.log(JSON.stringify(value));
|
|
@@ -142,9 +144,67 @@ export async function checkFolders(config, record) {
|
|
|
142
144
|
throw Error('Folder source must remain an existing real directory');
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
const childOf = (candidate, parent) => candidate === parent || candidate.startsWith(parent + path.sep);
|
|
148
|
+
// Host-owned private network bindings remain separate from portable package descriptors.
|
|
149
|
+
// The executor may write toolsHome, so the binding source must stay in its host config.
|
|
150
|
+
export async function hostNetworkBindings(config, record, home) {
|
|
151
|
+
const configuredHost = config.hostConfig ?? (typeof config.deploymentDir === 'string' && path.isAbsolute(config.deploymentDir) ? path.join(config.deploymentDir, 'host-executor.json') : undefined);
|
|
152
|
+
if (configuredHost === undefined) return [];
|
|
153
|
+
if (!home || typeof configuredHost !== 'string' || !path.isAbsolute(configuredHost)) throw Error('Invalid host network binding');
|
|
154
|
+
home = realpathSync(home);
|
|
155
|
+
const hostConfig = realpathSync(configuredHost);
|
|
156
|
+
if (hostConfig !== configuredHost || path.basename(hostConfig) !== 'host-executor.json' || childOf(hostConfig, home))
|
|
157
|
+
throw Error('Host network bindings require an external host config');
|
|
158
|
+
const host = JSON.parse(readFileSync(hostConfig, 'utf8'));
|
|
159
|
+
if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host network bindings');
|
|
160
|
+
const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
|
|
161
|
+
if (candidates.length !== 1) throw Error('Host network binding does not belong to this registry');
|
|
162
|
+
const agent = candidates[0], deployment = path.dirname(hostConfig);
|
|
163
|
+
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
164
|
+
realpathSync(agent.workspace) !== config.workspace || realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
165
|
+
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir) ||
|
|
166
|
+
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
167
|
+
throw Error('Invalid host network binding deployment');
|
|
168
|
+
const configured = agent.pluginNetworkBindings;
|
|
169
|
+
if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
|
|
170
|
+
throw Error('Invalid host network bindings');
|
|
171
|
+
const route = configured?.[record.manifest?.id];
|
|
172
|
+
if (route === undefined) return [];
|
|
173
|
+
keys(route, ['revisions', 'bindings']);
|
|
174
|
+
const trusted = await snapshot(record.source);
|
|
175
|
+
if (record.revision !== trusted.revision || record.source !== trusted.source ||
|
|
176
|
+
JSON.stringify(record.manifest) !== JSON.stringify(trusted.manifest) ||
|
|
177
|
+
JSON.stringify(record.deployment) !== JSON.stringify(trusted.deployment))
|
|
178
|
+
throw Error('Registry plugin identity is not the reviewed source');
|
|
179
|
+
if (record.manifest.id !== trusted.manifest.id) throw Error('Registry plugin identity is not the reviewed source');
|
|
180
|
+
if (!Array.isArray(route.revisions) || route.revisions.some(revision => typeof revision !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(revision)) ||
|
|
181
|
+
!route.revisions.includes(trusted.revision)) throw Error('Host network binding is not pinned to the reviewed plugin revision');
|
|
182
|
+
const bindings = route.bindings;
|
|
183
|
+
if (!Array.isArray(bindings)) throw Error('Invalid host network bindings');
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
for (const binding of bindings) {
|
|
186
|
+
keys(binding, ['service', 'network']);
|
|
187
|
+
id(binding.service); dockerNetwork(binding.network);
|
|
188
|
+
if (!record.deployment.services[binding.service]) throw Error('Unknown host network service');
|
|
189
|
+
const key = `${binding.service}\0${binding.network}`;
|
|
190
|
+
if (seen.has(key)) throw Error('Duplicate host network binding');
|
|
191
|
+
seen.add(key);
|
|
192
|
+
}
|
|
193
|
+
return bindings;
|
|
194
|
+
}
|
|
195
|
+
export async function compose(config, record, secrets={}, home) {
|
|
196
|
+
const services={}, volumes={}, networks={}, serviceNetworks=new Map();
|
|
147
197
|
const folders = folderMounts(config, record);
|
|
198
|
+
for (const binding of await hostNetworkBindings(config, record, home)) {
|
|
199
|
+
// A service with an explicit network list loses Compose's implicit default.
|
|
200
|
+
// Keep the plugin's own network so bound services retain sibling access.
|
|
201
|
+
networks.default = {};
|
|
202
|
+
const key = `host-${hash(binding.network).slice(0,16)}`;
|
|
203
|
+
networks[key] = { external: true, name: binding.network };
|
|
204
|
+
const attached = serviceNetworks.get(binding.service) || ['default'];
|
|
205
|
+
if (!attached.includes(key)) attached.push(key);
|
|
206
|
+
serviceNetworks.set(binding.service, attached);
|
|
207
|
+
}
|
|
148
208
|
for(const [name,s] of Object.entries(record.deployment.services)) {
|
|
149
209
|
const mounts=[];
|
|
150
210
|
for(const [volume,target] of Object.entries(s.volumes||{})) { volumes[volume]={};mounts.push({type:'volume',source:volume,target}); }
|
|
@@ -156,13 +216,14 @@ export function compose(config, record, secrets={}) {
|
|
|
156
216
|
...(s.dependsOn?{depends_on:Object.fromEntries(s.dependsOn.map(dep=>[dep,{condition:'service_healthy'}]))}:{}),
|
|
157
217
|
...(s.memoryMiB?{mem_limit:`${s.memoryMiB}m`}:{}),
|
|
158
218
|
...(s.cpus?{cpus:s.cpus}:{}),
|
|
219
|
+
...(serviceNetworks.get(name)?.length?{networks:serviceNetworks.get(name)}:{}),
|
|
159
220
|
...(s.environment?{environment:Object.fromEntries(Object.entries(s.environment).map(([key,value])=>{
|
|
160
221
|
if(typeof value==='string')return [key,value];
|
|
161
222
|
if(!/^[a-f0-9]{64}$/.test(secrets[value.secret]||''))throw Error('Missing or invalid private deployment secret');
|
|
162
223
|
return [key,(value.prefix||'')+secrets[value.secret]+(value.suffix||'')];
|
|
163
224
|
}))}:{}),...(s.command?{command:s.command}:{})};
|
|
164
225
|
}
|
|
165
|
-
return attachShared({name:record.project,services,volumes}, record);
|
|
226
|
+
return attachShared({name:record.project,services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
|
|
166
227
|
}
|
|
167
228
|
function dockerEnv() {
|
|
168
229
|
return Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
|
|
@@ -205,14 +266,14 @@ async function registry(home) {
|
|
|
205
266
|
export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
|
|
206
267
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
207
268
|
if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
|
|
208
|
-
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);
|
|
269
|
+
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
|
|
209
270
|
if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
|
|
210
271
|
catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
|
|
211
272
|
const sources=await json(catalogFile);
|
|
212
273
|
const catalog={};
|
|
213
274
|
for(const [name,source] of Object.entries(sources)) {id(name);if(typeof source!=='string'||!source)throw Error('Catalog source must be a nonempty path');const resolved=path.resolve(path.dirname(catalogFile),source);const p=await snapshot(resolved).catch(error=>{throw Error(`Cannot load reviewed plugin ${name} from ${resolved}: ${error.message}. Supply its checkout or an explicit --catalog file.`)});if(p.manifest.id!==name) throw Error('Catalog ID mismatch');catalog[name]={source:p.source,revision:p.revision};}
|
|
214
275
|
await locked(home,async()=>{
|
|
215
|
-
await atomic(path.join(home,'config.json'),{schemaVersion:1,workspace,catalog});
|
|
276
|
+
await atomic(path.join(home,'config.json'),{schemaVersion:1,workspace,catalog,...(hostConfig&&path.basename(hostConfig)==='host-executor.json'?{hostConfig}:{})});
|
|
216
277
|
await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:{},commands:{}});
|
|
217
278
|
const bin=path.join(home,'bin');await privateDir(bin);
|
|
218
279
|
await fs.writeFile(path.join(bin,'ez'),`#!${process.execPath}\nimport(${JSON.stringify(new URL('./manager.mjs',import.meta.url).href)}).then(m=>m.main(['--home',${JSON.stringify(home)},...process.argv.slice(2)])).catch(e=>{console.error(e.message);process.exitCode=1});\n`,{mode:0o700,flag:'wx'});
|
|
@@ -262,7 +323,7 @@ export async function install(home,config,name,source,revision) {
|
|
|
262
323
|
let secrets;try{secrets=await json(secretsFile);}catch(error){if(error.code!=='ENOENT')throw error;secrets={};}
|
|
263
324
|
for(const name of p.deployment.secrets||[])if(secrets[name]===undefined)secrets[name]=randomBytes(32).toString('hex');
|
|
264
325
|
if(p.deployment.secrets?.length)await atomic(secretsFile,secrets);
|
|
265
|
-
await atomic(record.compose,compose(config,record,secrets));
|
|
326
|
+
await atomic(record.compose,await compose(config,record,secrets,home));
|
|
266
327
|
// Build/pull is explicit installation mechanics. No services start and no onboarding is executed.
|
|
267
328
|
for(const [service,s] of Object.entries(record.deployment.services)) await checked([...composeArgs(record),s.image?'pull':'build',service]);
|
|
268
329
|
r.plugins[name]=record;for(const alias of Object.keys(p.manifest.commands)) r.commands[alias]=name;
|
|
@@ -327,7 +388,7 @@ export async function main(args) {
|
|
|
327
388
|
settings.folders={...settings.folders,[name]:folders};
|
|
328
389
|
await checkFolders(settings,latest);
|
|
329
390
|
const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
330
|
-
const generated=compose(settings,latest,secrets);
|
|
391
|
+
const generated=await compose(settings,latest,secrets,home);
|
|
331
392
|
await atomic(path.join(home,'config.json'),settings);
|
|
332
393
|
await atomic(latest.compose,generated);
|
|
333
394
|
return emit({ok:true,plugin:name,folders,readOnly:true,started:false});
|
|
@@ -345,7 +406,7 @@ export async function main(args) {
|
|
|
345
406
|
const result = action === 'shared-enable' ? await sharedService(latest, key, 'enable', run) : { state: 'detached' };
|
|
346
407
|
latest.sharedEnabled = [...new Set([...(latest.sharedEnabled || []).filter(k => k !== key), ...(action === 'shared-enable' ? [key] : [])])];
|
|
347
408
|
const secrets = await json(path.join(home, 'packages', name, 'secrets.json')).catch(e => { if (e.code === 'ENOENT') return {}; throw e; });
|
|
348
|
-
await atomic(latest.compose, compose(currentConfig, latest, secrets));
|
|
409
|
+
await atomic(latest.compose, await compose(currentConfig, latest, secrets, home));
|
|
349
410
|
// Persist the binding before recreating clients; start can recover an interrupted recreation.
|
|
350
411
|
await atomic(path.join(home, 'registry.json'), current);
|
|
351
412
|
await checked([...composeArgs(latest), 'up', '-d', '--wait']);
|
|
@@ -371,7 +432,7 @@ export async function main(args) {
|
|
|
371
432
|
const currentConfig=await json(path.join(home,'config.json'));
|
|
372
433
|
await checkFolders(currentConfig,current.plugins[name]);
|
|
373
434
|
const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
374
|
-
await atomic(record.compose,compose(currentConfig,current.plugins[name],secrets));
|
|
435
|
+
await atomic(record.compose,await compose(currentConfig,current.plugins[name],secrets,home));
|
|
375
436
|
}
|
|
376
437
|
await checked([...composeArgs(record),...(action==='start'?['up','-d','--wait']:action==='stop'?['stop']:['down'])]);
|
|
377
438
|
if(action==='uninstall') {delete current.plugins[name];for(const [alias,owner] of Object.entries(current.commands))if(owner===name)delete current.commands[alias];await atomic(path.join(home,'registry.json'),current);}
|
|
@@ -384,7 +445,7 @@ export async function main(args) {
|
|
|
384
445
|
if(!binding)throw Error('Unknown registered CLI');
|
|
385
446
|
await checkFolders(config,record);
|
|
386
447
|
const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
387
|
-
await atomic(record.compose,compose(config,record,secrets));
|
|
448
|
+
await atomic(record.compose,await compose(config,record,secrets,home));
|
|
388
449
|
// Docker exec does not reliably forward cancellation to the in-container process.
|
|
389
450
|
// Run each client as a one-shot Compose container; docker compose run forwards signals.
|
|
390
451
|
const name=`${record.project}-call-${randomUUID()}`;
|
package/src/updates/binding.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
|
|
|
11
11
|
if(await fs.realpath(path.join(deploymentDir,'mind'))!==config.workspace||await fs.realpath(path.join(deploymentDir,'control'))!==host.agents[0].controlDir)throw Error('Noncanonical deployment state paths');
|
|
12
12
|
packageRoot=await fs.realpath(packageRoot);
|
|
13
13
|
await fs.mkdir(path.join(home,'updates'),{recursive:true,mode:0o700});
|
|
14
|
-
await atomic(path.join(home,'config.json'),{...config,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
|
|
14
|
+
await atomic(path.join(home,'config.json'),{...config,hostConfig,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
|
|
15
15
|
const pkg=await read(path.join(packageRoot,'package.json')),bin=path.join(home,'bin');
|
|
16
16
|
// Every invocation resolves the active root. Already-running workers retain
|
|
17
17
|
// their old code; the next worker receives the new package after activation.
|
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'));
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -58,7 +58,7 @@ function envValue(text,key,value) {
|
|
|
58
58
|
}
|
|
59
59
|
async function backupVolume(run,record,name,directory) {
|
|
60
60
|
const services=Object.entries(record.deployment.services),service=services.find(([,s])=>s.volumes?.[name])?.[0];
|
|
61
|
-
const c=compose({workspace:'/unused'},record),image=c.services[service].image;
|
|
61
|
+
const c=await compose({workspace:'/unused'},record),image=c.services[service].image;
|
|
62
62
|
// No writable profile mount, network, Docker socket, provider command or secrets.
|
|
63
63
|
const found=(await run('docker',['volume','ls','--format','{{.Name}}','--filter',`name=^${record.project}_${name}$`])).trim();
|
|
64
64
|
if(!found)return; // Installed but never started: no profile exists yet.
|
|
@@ -112,7 +112,7 @@ export async function perform(home,job,hooks) {
|
|
|
112
112
|
const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
|
|
113
113
|
for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
|
|
114
114
|
await checkFolders(config,candidate);
|
|
115
|
-
const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,compose(config,stage,secrets));
|
|
115
|
+
const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
|
|
116
116
|
for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
|
|
117
117
|
const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
|
|
118
118
|
job.rollback={record:old,registry:r,compose:await read(old.compose),running};await save();
|
|
@@ -120,7 +120,7 @@ export async function perform(home,job,hooks) {
|
|
|
120
120
|
const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
|
|
121
121
|
const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
|
|
122
122
|
for(const name of volumes)await backupVolume(run,old,name,backup);
|
|
123
|
-
await atomic(old.compose,compose(config,candidate,secrets));
|
|
123
|
+
await atomic(old.compose,await compose(config,candidate,secrets,home));
|
|
124
124
|
if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
125
125
|
r.plugins[job.target]=candidate;await atomic(path.join(home,'registry.json'),r);
|
|
126
126
|
job.runtimeVerified=running;
|
|
@@ -30,8 +30,8 @@ for(const cleanup of ['success','failure','already-removed']) test(`cancel remov
|
|
|
30
30
|
});
|
|
31
31
|
async function fixture(t) {
|
|
32
32
|
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
33
|
-
const source=path.join(root,'source'),home=path.join(root,'tools'),
|
|
34
|
-
for(const d of [source,workspace,fake])await fs.mkdir(d);
|
|
33
|
+
const source=path.join(root,'source'),home=path.join(root,'tools'),deploymentDir=path.join(root,'deployment'),workspace=path.join(deploymentDir,'mind'),control=path.join(deploymentDir,'control'),hostConfig=path.join(deploymentDir,'host-executor.json'),fake=path.join(root,'fake');
|
|
34
|
+
for(const d of [source,workspace,control,fake])await fs.mkdir(d,{recursive:true});
|
|
35
35
|
const manifest={schemaVersion:1,id:'sample',version:'0.1.0',description:'Synthetic',commands:{sample:{executable:'client.mjs',args:[]}},skills:['SKILL.md']};
|
|
36
36
|
const deployment={schemaVersion:1,services:{sample:{buildTarget:'runtime',volumes:{data:'/data'},workspace:true,healthcheck:['node','--version']}},commands:{sample:{service:'sample',argv:['node','/app/client.mjs'],suffix:[]}}};
|
|
37
37
|
for(const [name,value]of Object.entries({'package.json':JSON.stringify({files:['client.mjs','SKILL.md']}),'ez-plugin.json':JSON.stringify(manifest),'ez-deployment.json':JSON.stringify(deployment),'Dockerfile':'FROM scratch AS runtime','.dockerignore':'','client.mjs':'','SKILL.md':'Synthetic'}))await fs.writeFile(path.join(source,name),value);
|
|
@@ -39,7 +39,7 @@ async function fixture(t) {
|
|
|
39
39
|
await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nrequire('fs').appendFileSync(${JSON.stringify(log)},JSON.stringify({argv:process.argv.slice(2),secret:process.env.TELEGRAM_BOT_TOKEN})+'\\n');\nif(process.argv.includes('run')){console.log(JSON.stringify(process.argv.slice(process.argv.indexOf('/app/client.mjs')+1)));process.exit(process.argv.includes('fail')?17:0)}\n`,{mode:0o700});
|
|
40
40
|
const env={...process.env,PATH:fake+path.delimiter+process.env.PATH,TELEGRAM_BOT_TOKEN:'must-not-leak'};
|
|
41
41
|
const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
|
|
42
|
-
return {root,source,home,workspace,fake,log,manifest,deployment,env,call};
|
|
42
|
+
return {root,source,home,deploymentDir,workspace,control,hostConfig,fake,log,manifest,deployment,env,call};
|
|
43
43
|
}
|
|
44
44
|
test('catalog paths resolve relative to the catalog and pin each new agent independently',async t=>{
|
|
45
45
|
const f=await fixture(t),catalog=path.join(f.root,'defaults.json');
|
|
@@ -95,13 +95,45 @@ test('registry corruption, active writer lock and alias collision fail closed',a
|
|
|
95
95
|
});
|
|
96
96
|
test('Compose resources are namespaced, private, and restricted to owning workspace',async t=>{
|
|
97
97
|
const f=await fixture(t),p=await snapshot(f.source);
|
|
98
|
-
const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
|
|
98
|
+
const c=await compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
|
|
99
99
|
assert.equal(c.name,'ezp-synthetic');assert.equal(c.services.sample.ports,undefined);assert.equal(c.services.sample.privileged,undefined);assert.equal(c.services.sample.volumes[1].read_only,true);assert.deepEqual(c.services.sample.cap_drop,['ALL']);
|
|
100
100
|
});
|
|
101
|
+
test('host-owned private networks survive registered-call Compose regeneration',async t=>{
|
|
102
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
103
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
104
|
+
const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
|
|
105
|
+
const binding={service:'sample',network:'stocks-vm_default'};
|
|
106
|
+
const host={cli:'synthetic',agents:[{name:'sample',workspace:f.workspace,controlDir:f.control,binDir:path.join(f.home,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[binding]}}}]};
|
|
107
|
+
await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
108
|
+
config.hostConfig=await fs.realpath(f.hostConfig);
|
|
109
|
+
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify(config));
|
|
110
|
+
const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
|
|
111
|
+
await f.call('sample','read');
|
|
112
|
+
const c=JSON.parse(await fs.readFile(file,'utf8'));
|
|
113
|
+
assert.deepEqual(c.networks.default,{});
|
|
114
|
+
const [key,network]=Object.entries(c.networks).find(([name])=>name!=='default');
|
|
115
|
+
assert.equal(network.name,'stocks-vm_default');assert.equal(network.external,true);
|
|
116
|
+
assert.deepEqual(c.services.sample.networks,['default',key]);
|
|
117
|
+
const compromised={...config,hostNetworkBindings:{sample:[{service:'sample',network:'untrusted_default'}]}};
|
|
118
|
+
assert.deepEqual((await compose(compromised,p,{},f.home)).services.sample.networks,['default',key]);
|
|
119
|
+
const upgraded={...config,hostConfig:undefined,deploymentDir:path.dirname(config.hostConfig)};
|
|
120
|
+
assert.deepEqual((await compose(upgraded,p,{},f.home)).services.sample.networks,['default',key]);
|
|
121
|
+
const unsafeHost=path.join(f.home,'host-executor.json');await fs.writeFile(unsafeHost,JSON.stringify(host));
|
|
122
|
+
await assert.rejects(compose({...config,hostConfig:unsafeHost},p,{},f.home),/external host config/);
|
|
123
|
+
const forged=structuredClone(p);forged.manifest.id='forged';
|
|
124
|
+
host.agents[0].pluginNetworkBindings={forged:{revisions:[p.revision],bindings:[binding]}};await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
125
|
+
await assert.rejects(compose(config,forged,{},f.home),/reviewed source/);
|
|
126
|
+
for(const bindings of [
|
|
127
|
+
{sample:[{service:'missing',network:'stocks-vm_default'}]},
|
|
128
|
+
{sample:[{service:'sample',network:'bad/network'}]},
|
|
129
|
+
{sample:[{service:'sample',network:'stocks-vm_default'},{service:'sample',network:'stocks-vm_default'}]},
|
|
130
|
+
'not-a-binding-map'
|
|
131
|
+
]) { host.agents[0].pluginNetworkBindings=bindings==='not-a-binding-map'?bindings:{sample:{revisions:[p.revision],bindings}};await fs.writeFile(f.hostConfig,JSON.stringify(host));await assert.rejects(compose(config,{source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment},{},f.home),/host network|Docker network/); }
|
|
132
|
+
});
|
|
101
133
|
test('host onboarding binds local ez without replacing global commands',async t=>{
|
|
102
134
|
const f=await fixture(t),native=path.join(f.root,'native'),config=path.join(f.root,'host.json');await fs.mkdir(native);await fs.writeFile(path.join(native,'native-tool'),'hello');
|
|
103
135
|
await fs.writeFile(config,JSON.stringify({cli:'synthetic',agents:[{name:'demo',workspace:f.workspace,binDir:native}]}));
|
|
104
|
-
await init(f.home,f.workspace,undefined,config);const host=JSON.parse(await fs.readFile(config));assert.equal(host.agents[0].binDir,path.join(await fs.realpath(f.home),'bin'));assert.equal(host.agents[0].toolsHome,await fs.realpath(f.home));assert.equal(await fs.readFile(path.join(host.agents[0].binDir,'native-tool'),'utf8'),'hello');
|
|
136
|
+
await init(f.home,f.workspace,undefined,config);const host=JSON.parse(await fs.readFile(config));assert.equal(host.agents[0].binDir,path.join(await fs.realpath(f.home),'bin'));assert.equal(host.agents[0].toolsHome,await fs.realpath(f.home));assert.equal((JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'))).hostConfig,undefined);assert.equal(await fs.readFile(path.join(host.agents[0].binDir,'native-tool'),'utf8'),'hello');
|
|
105
137
|
await assert.rejects(init(f.home,f.workspace),/exists/);
|
|
106
138
|
});
|
|
107
139
|
test('host install exposes runnable public commands without source aliases',async t=>{
|
|
@@ -128,10 +160,10 @@ test('v2 supports bounded dependency graphs and generated private secrets',async
|
|
|
128
160
|
d.services.database={image:'example/database@sha256:'+'a'.repeat(64),user:'999:999',healthcheck:['check'],memoryMiB:128,cpus:2,environment:{PASSWORD:{secret:'db-password'}}};
|
|
129
161
|
d.services.sample.dependsOn=['database'];d.services.sample.environment={URL:{secret:'db-password',prefix:'db://',suffix:'@database'}};
|
|
130
162
|
validate(f.manifest,d,p.files);
|
|
131
|
-
const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d},{'db-password':'b'.repeat(64)});
|
|
163
|
+
const c=await compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d},{'db-password':'b'.repeat(64)});
|
|
132
164
|
assert.equal(c.services.database.user,'999:999');assert.equal(c.services.sample.depends_on.database.condition,'service_healthy');assert.equal(c.services.sample.environment.URL,'db://'+'b'.repeat(64)+'@database');
|
|
133
|
-
assert.equal(c.services.database.mem_limit,'128m');assert.
|
|
134
|
-
assert.equal(c.services.database.cpus,2);assert.
|
|
165
|
+
assert.equal(c.services.database.mem_limit,'128m');await assert.rejects(compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
|
|
166
|
+
assert.equal(c.services.database.cpus,2);await assert.rejects(compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
|
|
135
167
|
for(const change of [x=>x.services.database.user='0:0',x=>x.services.database.cpus=0.01,x=>x.services.database.cpus=9,x=>x.services.database.environment.PASSWORD={secret:'undeclared'},x=>x.services.database.environment.PASSWORD='${HOST_SECRET}',x=>x.services.database.dependsOn=['sample'],x=>x.services.sample.dependsOn=['missing'],x=>x.services.sample.ports=['9999:9999']]){const bad=structuredClone(d);change(bad);assert.throws(()=>validate(f.manifest,bad,p.files));}
|
|
136
168
|
const old=structuredClone(d);old.schemaVersion=1;assert.throws(()=>validate(f.manifest,old,p.files));
|
|
137
169
|
});
|
|
@@ -252,9 +284,9 @@ test('folder bindings survive compatible descriptors and reject incompatible upd
|
|
|
252
284
|
const f=await fixture(t);const p=await snapshot(f.source);
|
|
253
285
|
const record={...p,project:'ezp-test-sample'};
|
|
254
286
|
const config={workspace:f.workspace,folders:{sample:[{service:'sample',source:f.workspace,target:'/data/files'}]}};
|
|
255
|
-
assert.equal(compose(config,record).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
287
|
+
assert.equal((await compose(config,record)).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
256
288
|
const next=structuredClone(record);next.deployment.services.sample.volumes={data:'/new-state'};
|
|
257
|
-
assert.
|
|
289
|
+
await assert.rejects(compose(config,next),/child of a declared volume/);
|
|
258
290
|
});
|
|
259
291
|
|
|
260
292
|
test('folder rebind rejects every live project container including one-shots',async t=>{
|
|
@@ -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';
|
|
@@ -44,7 +44,7 @@ async function fixture(t,kind='main') {
|
|
|
44
44
|
if(kind==='plugin') {
|
|
45
45
|
const s=await snapshot(old),base=path.join(home,'packages',target);await fs.mkdir(base,{recursive:true});
|
|
46
46
|
record={revision:s.revision,source:old,project:`ezp-${digest(home).slice(0,16)}-${target}`,manifest:s.manifest,deployment:s.deployment,compose:path.join(base,'compose.json')};
|
|
47
|
-
await atomic(record.compose,compose(config,record));
|
|
47
|
+
await atomic(record.compose,await compose(config,record,{},home));
|
|
48
48
|
}
|
|
49
49
|
await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:record?{sample:record}:{},commands:record?{sample:'sample'}:{}});
|
|
50
50
|
await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);
|
|
@@ -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'),'');
|