@jc_stack/ez-agents 0.1.0-beta.51 → 0.1.0-beta.52
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 +6 -0
- package/docker/isolated-executor-smoke.mjs +35 -0
- package/docs/releasing.md +1 -0
- package/docs/upgrades.md +14 -1
- package/package.json +1 -1
- package/src/updates/runtime.mjs +2 -1
- package/src/updates/supervisor.mjs +42 -12
- package/test/updates.test.mjs +28 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.52
|
|
4
|
+
|
|
5
|
+
- Restore isolated executor startup with existing named volumes and a private, root-owned read-only container binding. Host file ownership and permissions remain unchanged.
|
|
6
|
+
- Start a missing isolated executor when native requests are queued, while retaining the live-run drain and single-worker ownership checks.
|
|
7
|
+
|
|
8
|
+
|
|
3
9
|
## 0.1.0-beta.51
|
|
4
10
|
|
|
5
11
|
- Keep native runs alive during handoff-compatible core upgrades. The retiring executor finishes existing work while its replacement accepts new requests; the relay adopts runs without replay and preserves schedule holds, cancellation and settled delivery receipts. Isolated agents migrate once while idle to a separate, unprivileged executor container; later compatible upgrades preserve its running generation. Older runtimes without the handoff contract retain the full native-work drain.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Synthetic private binding and existing-volume acceptance; no provider access.
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {randomUUID} from 'node:crypto';
|
|
6
|
+
import {execute} from '../src/updates/runtime.mjs';
|
|
7
|
+
import {executorBindingArchive,executorVolumes} from '../src/updates/supervisor.mjs';
|
|
8
|
+
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-smoke-'));
|
|
9
|
+
const project='ez-executor-smoke-'+randomUUID().slice(0,8),volume=project+'-existing';
|
|
10
|
+
const file=path.join(root,'compose.json'),image=process.env.EZ_RELAY_IMAGE||'ezenciel-agents:local';
|
|
11
|
+
const binding={agents:[{workspace:'/synthetic',binDir:'/app/bin'}]};
|
|
12
|
+
const code=`const fs=require('fs'),assert=require('assert/strict');const p='/run/executor.json',s=fs.statSync(p),d=fs.statSync('/run');assert.equal(process.getuid(),1001);assert.equal(s.uid,0);assert.equal(s.gid,1000);assert.equal(s.mode&511,288);assert.equal(d.uid,0);assert.equal(d.mode&18,0);assert.deepEqual(JSON.parse(fs.readFileSync(p)),${JSON.stringify(binding)});for(const fn of [()=>fs.writeFileSync(p,'changed'),()=>fs.chmodSync(p,384),()=>fs.unlinkSync(p)])assert.throws(fn,e=>['EACCES','EPERM','EROFS'].includes(e.code));assert.equal(fs.readFileSync('/ipc/marker','utf8'),'retained');console.log('Private readonly binding and retained volume passed');`;
|
|
13
|
+
const worker={image,user:'1001:1000',network_mode:'none',cap_drop:['ALL'],security_opt:['no-new-privileges:true'],entrypoint:['node','-e',code],volumes:[{type:'volume',source:'ipc',target:'/ipc'}]};
|
|
14
|
+
const args=['compose','--project-name',project,'--file',file];
|
|
15
|
+
try {
|
|
16
|
+
await execute('docker',['volume','create',volume]);
|
|
17
|
+
await execute('docker',['run','--rm','--network','none','--user','0:0','--mount',`type=volume,source=${volume},target=/ipc`,'--entrypoint','node',image,'-e',"require('fs').writeFileSync('/ipc/marker','retained')"]);
|
|
18
|
+
await fs.writeFile(file,JSON.stringify({services:{executor:worker},volumes:executorVolumes({volumes:{ipc:{name:volume}}},worker)}),{mode:0o600});
|
|
19
|
+
await execute('docker',[...args,'create','--no-build','executor']);
|
|
20
|
+
const cid=(await execute('docker',[...args,'ps','-aq','executor'],{stdoutOnly:true})).trim();
|
|
21
|
+
await execute('docker',['cp','-',`${cid}:/run`],{input:executorBindingArchive(binding,1000)});
|
|
22
|
+
console.log(await execute('docker',['start','-a',cid]));
|
|
23
|
+
const exit=(await execute('docker',['inspect','--format','{{.State.ExitCode}}',cid],{stdoutOnly:true})).trim();
|
|
24
|
+
if(exit!=='0')throw Error('Executor binding acceptance failed');
|
|
25
|
+
await execute('docker',[...args,'down']);
|
|
26
|
+
const retained=(await execute('docker',['volume','inspect','--format','{{.Name}}',volume],{stdoutOnly:true})).trim();
|
|
27
|
+
if(retained!==volume)throw Error('External volume identity changed');
|
|
28
|
+
// Rejected archive input must fail cleanly, with no uncaught stdin EPIPE.
|
|
29
|
+
await execute('docker',['cp','-',`${project}-missing:/run`],{input:Buffer.alloc(1024*1024)}).then(()=>{throw Error('Missing container copy succeeded');},()=>{});
|
|
30
|
+
console.log('PASS: unprivileged executor reads only its private binding; writes, chmod and unlink fail; external volume preserved.');
|
|
31
|
+
} finally {
|
|
32
|
+
await execute('docker',[...args,'down']).catch(()=>{});
|
|
33
|
+
await execute('docker',['volume','rm',volume]).catch(()=>{});
|
|
34
|
+
await fs.rm(root,{recursive:true,force:true});
|
|
35
|
+
}
|
package/docs/releasing.md
CHANGED
|
@@ -22,6 +22,7 @@ separate release bot is not required.
|
|
|
22
22
|
audit advances. Baseline CI does not validate a newly published native version.
|
|
23
23
|
Managed installs and upgrades keep their existing native version selection.
|
|
24
24
|
Main relay: `EZ_RELAY_IMAGE=ez-release-runtime node docker/smoke.mjs`.
|
|
25
|
+
Isolated executor: `EZ_RELAY_IMAGE=ez-release-runtime node docker/isolated-executor-smoke.mjs`.
|
|
25
26
|
The WhatsApp Compose overlay has no separate relay image; verify its
|
|
26
27
|
provider integration with the synthetic plugin smoke in `docs/plugins.md`.
|
|
27
28
|
4. Create an artifact with `npm pack --ignore-scripts`. Inspect its file list,
|
package/docs/upgrades.md
CHANGED
|
@@ -16,7 +16,7 @@ automatic channel for core and plugins without a saved policy. Existing explicit
|
|
|
16
16
|
stable or manual policies are preserved. The owner may select either per target.
|
|
17
17
|
The main target and installed plugins version independently.
|
|
18
18
|
|
|
19
|
-
Handoff-compatible core upgrades build while native work continues and pause admission only for activation. The current native executor retires from claiming new work; the replacement claims new requests while the retiring generation finishes its existing runs. The relay adopts those runs without replay, preserving their schedule holds, cancellation and settled delivery receipts. The supervisor reloads only after native work is idle, so OS service process-group cleanup cannot kill a retiring generation. Plugin upgrades build while native work continues. Activation closes the existing registry admission lock, drains in-flight plugin command leases, and replaces the plugin under that lock. New native plugin calls wait for admission; an outdated revision fails before provider execution and must be rediscovered. No admitted provider operation is replayed. The shared broker and native sessions remain running. Older runtimes without the handoff contract retain the full native-work drain. Isolated agents migrate once
|
|
19
|
+
Handoff-compatible core upgrades build while native work continues and pause admission only for activation. The current native executor retires from claiming new work; the replacement claims new requests while the retiring generation finishes its existing runs. The relay adopts those runs without replay, preserving their schedule holds, cancellation and settled delivery receipts. The supervisor reloads only after native work is idle, so OS service process-group cleanup cannot kill a retiring generation. Plugin upgrades build while native work continues. Activation closes the existing registry admission lock, drains in-flight plugin command leases, and replaces the plugin under that lock. New native plugin calls wait for admission; an outdated revision fails before provider execution and must be rediscovered. No admitted provider operation is replayed. The shared broker and native sessions remain running. Older runtimes without the handoff contract retain the full native-work drain. Isolated agents migrate once after live native work finishes to a separate, unprivileged executor container. Pending requests remain queued for that executor; its private binding is copied into the container as root-owned, group-readable and not writable by the configured runtime user. Existing named volumes are attached as external volumes. It uses the same file transport; later relay/runtime upgrades preserve that executor generation. No native CLI or provider credentials move to the host. A plugin update also retains that drain if an operator replaced the broker’s single-file host binding (for example to approve new network pins), because that case still requires broker recreation.
|
|
20
20
|
|
|
21
21
|
Compatible main upgrades also finish their advisory workspace/native-client
|
|
22
22
|
backup before closing admission or stopping the relay. Large native histories do
|
|
@@ -63,6 +63,19 @@ restart and verify. Do not recreate the agent or its registry. Subsequent
|
|
|
63
63
|
compatible upgrades are agent-owned. Noncanonical/multi-agent host configuration
|
|
64
64
|
files require migration to one canonical deployment per service first.
|
|
65
65
|
|
|
66
|
+
## Recovering an isolated-executor startup defect
|
|
67
|
+
|
|
68
|
+
If an older supervisor cannot start its isolated executor, queued requests can
|
|
69
|
+
prevent that same supervisor from installing its correction. Use an exact,
|
|
70
|
+
reviewed newer package's `src/updates/supervisor.mjs DEPLOYMENT_DIRECTORY` as the
|
|
71
|
+
existing OS service's temporary entry point. First verify that no native run is
|
|
72
|
+
active. Keep the same service user, environment, registry, deployment and queued
|
|
73
|
+
requests; do not edit published package files or delete claims. This CLI runs the
|
|
74
|
+
same supervisor and retains the active package binding until normal upgrade
|
|
75
|
+
activation. Let existing queued jobs finish, then prepare and apply the corrected
|
|
76
|
+
release through `ez updates`. Restore the retained bootstrap entry point after
|
|
77
|
+
the active package and running services verify the corrected version.
|
|
78
|
+
|
|
66
79
|
## Package-manager preflight and repair
|
|
67
80
|
|
|
68
81
|
Before installing candidate dependencies or stopping services, the supervisor
|
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.52",
|
|
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/src/updates/runtime.mjs
CHANGED
|
@@ -19,13 +19,14 @@ export function execute(command,args,options={}) {
|
|
|
19
19
|
return new Promise((resolve,reject)=>{
|
|
20
20
|
const invocation=process.env.EZ_DOCKER_COMPOSE==='standalone'&&command==='docker'&&args[0]==='compose'
|
|
21
21
|
? {command:'docker-compose',args:args.slice(1)} : {command,args};
|
|
22
|
-
const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='',stdout='';
|
|
22
|
+
const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:[options.input===undefined?'ignore':'pipe','pipe','pipe']});let output='',stdout='';
|
|
23
23
|
const timer=setTimeout(()=>child.kill('SIGKILL'),options.timeout||600000);
|
|
24
24
|
const file=options.outputFile?createWriteStream(options.outputFile,{flags:'wx',mode:0o600}):null;
|
|
25
25
|
const written=file?new Promise((resolve,reject)=>{file.once('finish',resolve);file.once('error',reject);}):Promise.resolve();
|
|
26
26
|
if(file)child.stdout.pipe(file);else child.stdout.on('data',b=>{output=(output+b).slice(-16000);if(options.stdoutOnly)stdout=(stdout+b).slice(-16000);});
|
|
27
27
|
child.stderr.on('data',b=>{output=(output+b).slice(-16000);});
|
|
28
28
|
void written.catch(()=>child.kill('SIGKILL'));
|
|
29
|
+
if(options.input!==undefined){child.stdin.on('error',error=>{child.kill('SIGKILL');reject(error);});child.stdin.end(options.input);}
|
|
29
30
|
child.once('error',error=>{clearTimeout(timer);reject(error);});
|
|
30
31
|
child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${invocation.command} failed (${code}): ${output}`);resolve(options.stdoutOnly?stdout:output);}catch(error){reject(error);}});
|
|
31
32
|
});
|
|
@@ -30,9 +30,9 @@ export async function waitForHostHeartbeat(controlDir,child,failure,{attempts=30
|
|
|
30
30
|
}
|
|
31
31
|
throw Error('Host transport heartbeat timeout');
|
|
32
32
|
}
|
|
33
|
-
export async function idle(control) {
|
|
33
|
+
export async function idle(control,{pendingRequests=true}={}) {
|
|
34
34
|
const host=await fs.readdir(path.join(control,'host-executor')).catch(e=>{if(e.code==='ENOENT')return [];throw e;});
|
|
35
|
-
if(host.some(f=>f.endsWith('.running.json')||f.endsWith('.request.json')))return false;
|
|
35
|
+
if(host.some(f=>f.endsWith('.running.json')||(pendingRequests&&f.endsWith('.request.json'))))return false;
|
|
36
36
|
// Run records live in relay memory: the live socket status is the only
|
|
37
37
|
// cross-process view of active work. A down relay cannot own a run.
|
|
38
38
|
const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
|
|
@@ -73,6 +73,26 @@ export async function queueAutomatic(home,available) {
|
|
|
73
73
|
}
|
|
74
74
|
return null;
|
|
75
75
|
}
|
|
76
|
+
// Docker archive copy keeps the binding private and readonly without changing
|
|
77
|
+
// host ownership, which may differ from the unprivileged container identity.
|
|
78
|
+
export function executorBindingArchive(binding,gid) {
|
|
79
|
+
if(!Number.isSafeInteger(gid)||gid<0||gid>0o7777777)throw Error('Invalid executor group');
|
|
80
|
+
const data=Buffer.from(JSON.stringify(binding)),header=Buffer.alloc(512);
|
|
81
|
+
const oct=(offset,width,value)=>header.write(value.toString(8).padStart(width-1,'0')+'\0',offset);
|
|
82
|
+
header.write('executor.json');oct(100,8,0o440);oct(108,8,0);oct(116,8,gid);
|
|
83
|
+
oct(124,12,data.length);oct(136,12,0);header.fill(32,148,156);
|
|
84
|
+
header.write('0',156);header.write('ustar\0',257);header.write('00',263);
|
|
85
|
+
header.write(header.reduce((sum,byte)=>sum+byte,0).toString(8).padStart(6,'0')+'\0 ',148);
|
|
86
|
+
return Buffer.concat([header,data,Buffer.alloc((512-data.length%512)%512+1024)]);
|
|
87
|
+
}
|
|
88
|
+
export function executorVolumes(deployment,worker) {
|
|
89
|
+
const names=[...new Set(worker.volumes.filter(m=>m.type==='volume').map(m=>m.source))];
|
|
90
|
+
return Object.fromEntries(names.map(name=>{
|
|
91
|
+
const actual=deployment.volumes?.[name]?.name;
|
|
92
|
+
if(typeof actual!=='string'||!actual)throw Error('Missing executor volume binding');
|
|
93
|
+
return [name,{name:actual,external:true}];
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
76
96
|
export async function supervise(deployment,signal,{discover=check}={}) {
|
|
77
97
|
const host=await read(path.join(deployment,'host-executor.json'));
|
|
78
98
|
if(host.agents.length!==1||!host.agents[0].toolsHome)throw Error('Initialize a single deployment plugin registry before starting the supervisor');
|
|
@@ -90,7 +110,7 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
90
110
|
const generations=new Map();
|
|
91
111
|
const removeContainer=async id=>{
|
|
92
112
|
const generation=generations.get(id);
|
|
93
|
-
if(generation){await execute('docker',['compose','--project-name',generation.project,'--file',generation.file,'down']);await fs.rm(generation.file,{force:true});
|
|
113
|
+
if(generation){await execute('docker',['compose','--project-name',generation.project,'--file',generation.file,'down']);await fs.rm(generation.file,{force:true});generations.delete(id);}
|
|
94
114
|
else await execute('docker',['rm',id]);
|
|
95
115
|
};
|
|
96
116
|
const stopHost=async()=>{
|
|
@@ -121,20 +141,28 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
121
141
|
const generation=path.basename(root).replace(/[^a-zA-Z0-9_.-]/g,'-')+'-'+Date.now();
|
|
122
142
|
const project='ez-executor-'+generation.toLowerCase();
|
|
123
143
|
const file=path.join(directory,project+'.json');
|
|
124
|
-
const
|
|
125
|
-
await atomic(configFile,{...host,agents:host.agents.map(a=>({...a,binDir:'/app/bin'}))});
|
|
144
|
+
const binding={...host,agents:host.agents.map(a=>({...a,binDir:'/app/bin'}))};
|
|
126
145
|
const worker={image:relay.image,init:true,restart:'no',user:`${relay.environment.EZ_RUNTIME_UID||1000}:${relay.environment.EZ_RUNTIME_GID||1000}`,
|
|
127
146
|
cap_drop:['ALL'],security_opt:['no-new-privileges:true'],entrypoint:['node','--import','/app/node_modules/tsx/dist/loader.mjs','/app/src/host-executor.ts','/run/executor.json'],
|
|
128
147
|
environment:{...relay.environment,EZ_CONTAINER_EXECUTOR:'1',EZ_EXECUTOR_TRANSPORT:'local'},
|
|
129
|
-
volumes:
|
|
148
|
+
volumes:relay.volumes,tmpfs:['/tmp'],
|
|
130
149
|
...Object.fromEntries(['networks','network_mode','extra_hosts','dns','dns_search'].filter(key=>relay[key]!==undefined).map(key=>[key,relay[key]]))};
|
|
131
150
|
if(worker.networks&&!Array.isArray(worker.networks))worker.networks=Object.fromEntries(Object.keys(worker.networks).map(name=>[name,{}]));
|
|
132
151
|
const networks=worker.networks?Object.fromEntries(Object.entries(deployment.networks||{}).map(([name,binding])=>[name,{name:binding.name,external:true}])):undefined;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
152
|
+
const volumes=executorVolumes(deployment,worker);
|
|
153
|
+
const archive=executorBindingArchive(binding,Number(worker.user.split(':')[1]));
|
|
154
|
+
await atomic(file,{services:{executor:worker},...(networks?{networks}:{}),...(Object.keys(volumes).length?{volumes}:{})});
|
|
155
|
+
try {
|
|
156
|
+
await execute('docker',['compose','--project-name',project,'--file',file,'create','--no-build','executor']);
|
|
157
|
+
container=(await execute('docker',['compose','--project-name',project,'--file',file,'ps','-aq','executor'],{stdoutOnly:true})).trim();
|
|
158
|
+
if(!/^[a-f0-9]{12,64}$/.test(container))throw Error('Missing isolated executor');
|
|
159
|
+
} catch(error) {
|
|
160
|
+
await execute('docker',['compose','--project-name',project,'--file',file,'down']);
|
|
161
|
+
await fs.rm(file,{force:true});container=undefined;throw error;
|
|
162
|
+
}
|
|
163
|
+
generations.set(container,{project,file});
|
|
164
|
+
await execute('docker',['cp','-',`${container}:/run`],{input:archive});
|
|
165
|
+
await execute('docker',['start',container]);
|
|
138
166
|
for(let n=0;n<300;n++){
|
|
139
167
|
const running=await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true});
|
|
140
168
|
if(running.trim()!=='true')throw Error('Isolated executor exited during startup');
|
|
@@ -206,7 +234,9 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
206
234
|
if(!isolated||container)return;
|
|
207
235
|
const root=(await state(home)).config.packageRoot;
|
|
208
236
|
if((await read(path.join(root,'package.json'))).ezRelease?.containerExecutor!==1)return;
|
|
209
|
-
|
|
237
|
+
// Queued requests need this worker to drain. Existing native runs still
|
|
238
|
+
// block migration; the worker lock continues to enforce single ownership.
|
|
239
|
+
await withIdleUpgrade(agent.controlDir,{id:'executor-migration'},signal,()=>startHost(root),{isIdle:()=>idle(agent.controlDir,{pendingRequests:false})});
|
|
210
240
|
};
|
|
211
241
|
await initializeIsolated();
|
|
212
242
|
let nextCheck=0;
|
package/test/updates.test.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manage
|
|
|
14
14
|
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
15
15
|
import { TOOLS_LINE } from '../src/tools-line.mjs';
|
|
16
16
|
import { status as runtimeStatus } from '../src/updates/status.mjs';
|
|
17
|
-
import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
|
|
17
|
+
import { idle, executorBindingArchive, executorVolumes, providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
|
|
18
18
|
const exec=promisify(execFile);
|
|
19
19
|
|
|
20
20
|
test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
|
|
@@ -794,3 +794,30 @@ test('failed handoff-compatible activation retires the candidate and rolls back
|
|
|
794
794
|
assert.equal(r.calls.filter(c=>c[0]==='retireHost').length,2)
|
|
795
795
|
assert.ok(!r.calls.some(c=>c[0]==='stopHost'))
|
|
796
796
|
})
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
test('missing executor startup ignores queued requests but never live native work',async t=>{
|
|
800
|
+
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-admission-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
801
|
+
const dir=path.join(root,'host-executor');await fs.mkdir(dir);
|
|
802
|
+
await fs.writeFile(path.join(dir,'queued.request.json'),'{}');
|
|
803
|
+
assert.equal(await idle(root),false);assert.equal(await idle(root,{pendingRequests:false}),true);
|
|
804
|
+
await fs.writeFile(path.join(dir,'live.running.json'),'{}');assert.equal(await idle(root,{pendingRequests:false}),false);
|
|
805
|
+
assert.equal(await fs.readFile(path.join(dir,'queued.request.json'),'utf8'),'{}');
|
|
806
|
+
await fs.rm(path.join(dir,'live.running.json'));
|
|
807
|
+
const server=createServer(socket=>socket.on('data',chunk=>{const req=JSON.parse(chunk.toString());socket.end(JSON.stringify({id:req.id,ok:true,result:{running:1}})+'\n');}));
|
|
808
|
+
await new Promise(resolve=>server.listen(path.join(root,'delivery.sock'),resolve));t.after(()=>new Promise(resolve=>server.close(resolve)));
|
|
809
|
+
assert.equal(await idle(root,{pendingRequests:false}),false);
|
|
810
|
+
});
|
|
811
|
+
test('executor preserves exact named volume identities and rejects missing declarations',()=>{
|
|
812
|
+
const worker={volumes:[{type:'volume',source:'registered-whatsapp-ipc'},{type:'bind',source:'/private/workspace'}]};
|
|
813
|
+
assert.deepEqual(executorVolumes({volumes:{'registered-whatsapp-ipc':{name:'existing-private-ipc'}}},worker),{'registered-whatsapp-ipc':{name:'existing-private-ipc',external:true}});
|
|
814
|
+
assert.throws(()=>executorVolumes({},worker),/Missing executor volume/);
|
|
815
|
+
});
|
|
816
|
+
test('executor archive extracts its exact private binding without host ownership changes',async t=>{
|
|
817
|
+
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-binding-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
818
|
+
const binding={agents:[{workspace:'/private/work',binDir:'/app/bin'}]},file=path.join(root,'binding.tar');
|
|
819
|
+
await fs.writeFile(file,executorBindingArchive(binding,1000),{mode:0o600});
|
|
820
|
+
await exec('tar',['-xf',file,'-C',root]);
|
|
821
|
+
const extracted=path.join(root,'executor.json');assert.deepEqual(await read(extracted),binding);assert.equal((await fs.stat(extracted)).mode&0o777,0o440);
|
|
822
|
+
for(const invalid of [-1,NaN,1.5,'1000',0o10000000])assert.throws(()=>executorBindingArchive(binding,invalid),/Invalid executor group/);
|
|
823
|
+
});
|