@jc_stack/ez-agents 0.1.0-beta.12
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/.dockerignore +24 -0
- package/.env.example +26 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +73 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SECURITY.md +26 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/ezenciel-agents +2 -0
- package/bin/ezenciel-agents-ai +2 -0
- package/bin/ezenciel-agents-ai.mjs +5 -0
- package/bin/ezenciel-agents-approval +2 -0
- package/bin/ezenciel-agents-approval.mjs +16 -0
- package/bin/ezenciel-agents-create +12 -0
- package/bin/ezenciel-agents-docker +6 -0
- package/bin/ezenciel-agents-host +5 -0
- package/bin/ezenciel-agents-install +2 -0
- package/bin/ezenciel-agents-message +2 -0
- package/bin/ezenciel-agents-message.mjs +16 -0
- package/bin/ezenciel-agents-owner +2 -0
- package/bin/ezenciel-agents-owner.mjs +18 -0
- package/bin/ezenciel-agents-react +2 -0
- package/bin/ezenciel-agents-react.mjs +16 -0
- package/bin/ezenciel-agents-setup.mjs +18 -0
- package/bin/ezenciel-agents-source +2 -0
- package/bin/ezenciel-agents-source.mjs +16 -0
- package/bin/ezenciel-agents-tools.mjs +3 -0
- package/bin/ezenciel-agents.mjs +37 -0
- package/compose.whatsapp.yaml +12 -0
- package/compose.yaml +40 -0
- package/default-plugins.json +1 -0
- package/docker/entrypoint.sh +15 -0
- package/docker/healthcheck.mjs +8 -0
- package/docker/plugin-smoke.mjs +48 -0
- package/docker/pnpm-lock.yaml +415 -0
- package/docker/recovery.ts +11 -0
- package/docker/run.ts +52 -0
- package/docker/smoke.mjs +47 -0
- package/docker/status-smoke.mjs +30 -0
- package/docker/upgrade-smoke.mjs +58 -0
- package/docs/architecture/ai-selection.md +37 -0
- package/docs/architecture/authority-boundaries.md +14 -0
- package/docs/architecture/event-sources.md +34 -0
- package/docs/architecture/telegram-intake.md +29 -0
- package/docs/development-and-testing.md +18 -0
- package/docs/docker-runtime.md +118 -0
- package/docs/host-service.md +80 -0
- package/docs/plugin-contributions.md +34 -0
- package/docs/plugins.md +181 -0
- package/docs/releasing.md +71 -0
- package/docs/setup.md +234 -0
- package/docs/upgrades.md +193 -0
- package/package.json +106 -0
- package/scripts/assert-local-registry.mjs +22 -0
- package/scripts/release-check.mjs +14 -0
- package/scripts/smoke.ts +102 -0
- package/src/agent-install.ts +96 -0
- package/src/ai-cli.ts +22 -0
- package/src/ai.ts +88 -0
- package/src/approval-cli.ts +59 -0
- package/src/approval.ts +119 -0
- package/src/audio.ts +184 -0
- package/src/client-defaults.ts +101 -0
- package/src/config.ts +48 -0
- package/src/control-state.ts +350 -0
- package/src/desktop-bridge.ts +284 -0
- package/src/event-sources.ts +112 -0
- package/src/executor.ts +335 -0
- package/src/files.ts +75 -0
- package/src/format.ts +57 -0
- package/src/host-executor-client.ts +46 -0
- package/src/host-executor-protocol.ts +2 -0
- package/src/host-executor.ts +129 -0
- package/src/identity.ts +17 -0
- package/src/inbox.ts +171 -0
- package/src/index.ts +812 -0
- package/src/install-config.ts +56 -0
- package/src/install-tools.mjs +98 -0
- package/src/menu.ts +123 -0
- package/src/message-send.ts +57 -0
- package/src/message.ts +68 -0
- package/src/owner-args.ts +4 -0
- package/src/owner.ts +30 -0
- package/src/plugins/manager.mjs +272 -0
- package/src/react.ts +33 -0
- package/src/reaction.ts +32 -0
- package/src/read-request.ts +72 -0
- package/src/reply.ts +13 -0
- package/src/runs.ts +445 -0
- package/src/service.ts +28 -0
- package/src/setup.ts +180 -0
- package/src/software-status.ts +23 -0
- package/src/source-cli.ts +18 -0
- package/src/update-attention.ts +18 -0
- package/src/updates/artifact.mjs +83 -0
- package/src/updates/binding.mjs +27 -0
- package/src/updates/control.mjs +131 -0
- package/src/updates/launch.mjs +13 -0
- package/src/updates/runtime.mjs +140 -0
- package/src/updates/status.mjs +49 -0
- package/src/updates/supervisor.mjs +102 -0
- package/src/version.ts +4 -0
- package/src/workspace.ts +32 -0
- package/templates/agent/AGENTS.md +49 -0
- package/templates/agent/SOUL.md +11 -0
- package/templates/agent/TOOLS.md +46 -0
- package/templates/agent/USER.md +5 -0
- package/templates/updates.md +45 -0
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +28 -0
- package/test/ai.test.ts +105 -0
- package/test/approval.test.ts +40 -0
- package/test/audio.test.ts +77 -0
- package/test/client-defaults.test.ts +64 -0
- package/test/codex-context.test.ts +33 -0
- package/test/config.test.ts +32 -0
- package/test/control-state.test.ts +58 -0
- package/test/desktop-bridge.test.ts +159 -0
- package/test/docker-runtime.test.ts +23 -0
- package/test/event-sources.test.ts +113 -0
- package/test/executor.test.ts +135 -0
- package/test/files.test.ts +50 -0
- package/test/format.test.ts +41 -0
- package/test/host-executor.test.ts +149 -0
- package/test/inbox-burst.test.ts +66 -0
- package/test/inbox.test.ts +102 -0
- package/test/install-config.test.ts +75 -0
- package/test/install-tools.test.mjs +59 -0
- package/test/intake-relay.test.ts +337 -0
- package/test/owner-help.test.mjs +10 -0
- package/test/plugin-manager.test.mjs +157 -0
- package/test/publish-guard.test.ts +21 -0
- package/test/reaction.test.ts +122 -0
- package/test/read-request.test.ts +134 -0
- package/test/relay.test.ts +254 -0
- package/test/release-entrypoints.test.mjs +26 -0
- package/test/runs.test.ts +116 -0
- package/test/security.test.ts +85 -0
- package/test/setup.test.ts +68 -0
- package/test/software-status.test.ts +31 -0
- package/test/update-attention.test.ts +21 -0
- package/test/updates.test.mjs +282 -0
- package/test/upgrade-pause.test.ts +70 -0
- package/test/workspace.test.ts +80 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { installedPluginVersions, softwareStatus } from '../src/software-status.js'
|
|
7
|
+
import { packageVersion } from '../src/version.js'
|
|
8
|
+
|
|
9
|
+
test('Telegram software status uses loaded version and only fresh host plugin metadata', async t => {
|
|
10
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-software-status-'))
|
|
11
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
12
|
+
await mkdir(path.join(root,'host-executor'))
|
|
13
|
+
await writeFile(path.join(root,'registry.json'),JSON.stringify({plugins:{whatsapp:{manifest:{id:'whatsapp',version:'0.1.0-beta.3'},secrets:'never share',source:'/private/source'}}}))
|
|
14
|
+
const plugins=await installedPluginVersions(root)
|
|
15
|
+
assert.deepEqual(plugins,[{id:'whatsapp',version:'0.1.0-beta.3'}])
|
|
16
|
+
const heartbeat=path.join(root,'host-executor/heartbeat.json')
|
|
17
|
+
await writeFile(heartbeat,JSON.stringify({at:Date.now(),version:'0.1.0-beta.4',plugins}))
|
|
18
|
+
const lines=await softwareStatus(root)
|
|
19
|
+
assert.equal(lines[0],`Ez relay: ${packageVersion} (running)`)
|
|
20
|
+
assert(lines.includes('Host transport: 0.1.0-beta.4 (running)'))
|
|
21
|
+
assert(lines.includes('Plugins (installed): whatsapp 0.1.0-beta.3'))
|
|
22
|
+
assert(!JSON.stringify(lines).includes('/private'))
|
|
23
|
+
for(const h of [{at:Date.now()-60000,plugins},{at:Date.now()+60000,plugins},{}]) {
|
|
24
|
+
await writeFile(heartbeat,JSON.stringify(h))
|
|
25
|
+
assert((await softwareStatus(root)).includes('Plugins (installed): unknown'))
|
|
26
|
+
}
|
|
27
|
+
await writeFile(heartbeat,JSON.stringify({at:Date.now()}))
|
|
28
|
+
assert((await softwareStatus(root)).includes('Host transport: version unknown (running)'))
|
|
29
|
+
await writeFile(path.join(root,'registry.json'),'broken')
|
|
30
|
+
assert.equal(await installedPluginVersions(root),null)
|
|
31
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {mkdtemp,writeFile,rm} from 'node:fs/promises'
|
|
4
|
+
import {tmpdir} from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import {queueUpdateAttention} from '../src/update-attention.js'
|
|
7
|
+
import {RunStore} from '../src/runs.js'
|
|
8
|
+
import {executorJobPrompt} from '../src/executor.js'
|
|
9
|
+
|
|
10
|
+
test('maintenance requires an owner, deduplicates wakeups and never grants owner authority',async t=>{
|
|
11
|
+
const dir=await mkdtemp(path.join(tmpdir(),'ez-maintenance-'));t.after(()=>rm(dir,{recursive:true,force:true}));const runs=new RunStore(dir)
|
|
12
|
+
await writeFile(path.join(dir,'update-attention.json'),JSON.stringify({id:'a'.repeat(64)}))
|
|
13
|
+
await queueUpdateAttention(dir,null,runs);assert.equal((await runs.list()).length,0)
|
|
14
|
+
const owner={telegramUserId:12,telegramChatId:12,pairedAt:new Date().toISOString()}
|
|
15
|
+
await queueUpdateAttention(dir,owner,runs);await queueUpdateAttention(dir,owner,runs);assert.equal((await runs.list()).length,1)
|
|
16
|
+
const run=(await runs.list())[0];assert.equal(run.telegramUserId,12);assert.equal(run.status,'queued')
|
|
17
|
+
assert.match(executorJobPrompt(run.id,run.texts),/NOT a new owner instruction/)
|
|
18
|
+
await queueUpdateAttention(dir,{...owner,telegramUserId:13,telegramChatId:13},runs);assert.equal((await runs.list()).length,2)
|
|
19
|
+
await writeFile(path.join(dir,'update-attention.json'),'{');await assert.rejects(queueUpdateAttention(dir,owner,runs))
|
|
20
|
+
await writeFile(path.join(dir,'update-attention.json'),JSON.stringify({id:'../escape'}));await assert.rejects(queueUpdateAttention(dir,owner,runs))
|
|
21
|
+
})
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { gzipSync } from 'node:zlib';
|
|
7
|
+
import { spawn, execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
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';
|
|
11
|
+
import { perform, environment, packageManager } from '../src/updates/runtime.mjs';
|
|
12
|
+
import { atomic, snapshot, compose } from '../src/plugins/manager.mjs';
|
|
13
|
+
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
14
|
+
import { status as runtimeStatus } from '../src/updates/status.mjs';
|
|
15
|
+
const exec=promisify(execFile);
|
|
16
|
+
const contract=kind=>({protocol:1,kind,stateSchema:1,mainProtocol:1});
|
|
17
|
+
function tar(entries) {
|
|
18
|
+
const chunks=[];
|
|
19
|
+
for(const [name,body='',type='0'] of entries) {
|
|
20
|
+
const data=Buffer.from(body),h=Buffer.alloc(512);h.write(name);h.write('0000644\0',100);h.write('0000000\0',108);h.write('0000000\0',116);h.write(data.length.toString(8).padStart(11,'0')+'\0',124);h.write('00000000000\0',136);h.fill(32,148,156);h.write(type,156);h.write('ustar\0',257);h.write('00',263);
|
|
21
|
+
const sum=h.reduce((a,b)=>a+b,0);h.write(sum.toString(8).padStart(6,'0')+'\0 ',148);chunks.push(h,data,Buffer.alloc((512-data.length%512)%512));
|
|
22
|
+
}
|
|
23
|
+
return gzipSync(Buffer.concat([...chunks,Buffer.alloc(1024)]));
|
|
24
|
+
}
|
|
25
|
+
async function fixture(t,kind='main') {
|
|
26
|
+
const root=await fs.realpath(await fs.mkdtemp(path.join(tmpdir(),'ez-update-')));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
27
|
+
const deployment=path.join(root,'agent'),home=path.join(deployment,'tools'),old=path.join(root,'old'),source=path.join(root,'candidate');
|
|
28
|
+
for(const d of [home,old,source,path.join(home,'bin'),path.join(home,'updates'),path.join(deployment,'mind'),path.join(deployment,'control')])await fs.mkdir(d,{recursive:true});
|
|
29
|
+
const pkg={name:'@ez-test/example',version:'0.1.0',packageManager:'pnpm@10.30.3',type:'module',files:['bin','src','docker','compose.yaml','compose.whatsapp.yaml','.dockerignore','Dockerfile'],bin:{example:'bin/example.mjs'},ezRelease:contract(kind)};
|
|
30
|
+
const files={'package.json':JSON.stringify(pkg),'compose.yaml':'services: {}\n','compose.whatsapp.yaml':'services: {}\n','.dockerignore':'','Dockerfile':'FROM scratch AS runtime','docker/pnpm-lock.yaml':'lockfileVersion: 9.0\n','src/host-executor.ts':'','bin/example.mjs':'#!/usr/bin/env node\nconsole.log("example")','bin/ezenciel-agents.mjs':'console.log("0.1.1")'};
|
|
31
|
+
let record,target='main';
|
|
32
|
+
if(kind==='plugin') {
|
|
33
|
+
target='sample';files['ez-plugin.json']=JSON.stringify({schemaVersion:1,id:'sample',version:pkg.version,commands:{sample:{executable:'bin/example.mjs',args:[]}},skills:[]});
|
|
34
|
+
files['ez-deployment.json']=JSON.stringify({schemaVersion:1,services:{sample:{buildTarget:'runtime',volumes:{profile:'/state'},healthcheck:['node','--version']}},commands:{sample:{service:'sample',argv:['node','/app/bin/example.mjs']}},exports:{}});
|
|
35
|
+
pkg.files.push('ez-plugin.json','ez-deployment.json');files['package.json']=JSON.stringify(pkg);
|
|
36
|
+
}
|
|
37
|
+
for(const [name,text]of Object.entries(files)){await fs.mkdir(path.dirname(path.join(old,name)),{recursive:true});await fs.writeFile(path.join(old,name),text,{mode:name.startsWith('bin/')?0o755:0o644});}
|
|
38
|
+
const agent={name:'agent',workspace:path.join(deployment,'mind'),controlDir:path.join(deployment,'control'),binDir:path.join(home,'bin'),toolsHome:home};
|
|
39
|
+
await atomic(path.join(deployment,'host-executor.json'),{cli:'grok',agents:[agent]});
|
|
40
|
+
const config={schemaVersion:1,workspace:agent.workspace,catalog:{},deploymentDir:deployment,packageRoot:old};await atomic(path.join(home,'config.json'),config);
|
|
41
|
+
for(const name of ['agent.json','purpose.md','relay.env'])await fs.writeFile(path.join(deployment,name),name==='relay.env'?'TELEGRAM_BOT_TOKEN=private-test-token':'{}',{mode:0o600});
|
|
42
|
+
await fs.writeFile(path.join(deployment,'docker.env'),`COMPOSE_FILE='${old}/compose.yaml'\nCOMPOSE_PROJECT_NAME='ez-agent-fixture'\n`);
|
|
43
|
+
await fs.writeFile(path.join(agent.workspace,'TOOLS.md'),'My notes\n');
|
|
44
|
+
if(kind==='plugin') {
|
|
45
|
+
const s=await snapshot(old),base=path.join(home,'packages',target);await fs.mkdir(base,{recursive:true});
|
|
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));
|
|
48
|
+
}
|
|
49
|
+
await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:record?{sample:record}:{},commands:record?{sample:'sample'}:{}});
|
|
50
|
+
await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);
|
|
51
|
+
if(kind==='plugin'){const m=await read(path.join(source,'ez-plugin.json'));m.version=pkg.version;await atomic(path.join(source,'ez-plugin.json'),m);}
|
|
52
|
+
const pack=async()=>{const entries=[];async function walk(dir,prefix=''){for(const e of await fs.readdir(dir,{withFileTypes:true})){const rel=prefix+e.name;if(e.isDirectory())await walk(path.join(dir,e.name),rel+'/');else entries.push(['package/'+rel,await fs.readFile(path.join(dir,e.name))]);}}await walk(source);const file=path.join(root,'candidate.tgz');await fs.writeFile(file,tar(entries));return file;};
|
|
53
|
+
return {root,home,old,source,agent,config,record,target,pack};
|
|
54
|
+
}
|
|
55
|
+
function runtime(f,{fail,stopped=false}={}) {
|
|
56
|
+
const calls=[];let failed=false;
|
|
57
|
+
const execute=async(command,args,opts)=>{
|
|
58
|
+
calls.push([command,...args]);if(fail&&!failed&&fail(command,args)){failed=true;throw Error('Synthetic failure');}
|
|
59
|
+
if(args.at(-1)==='--version')return '10.30.3';
|
|
60
|
+
if(args.includes('ps'))return stopped?'':'container-id';
|
|
61
|
+
if(args[0]==='inspect')return 'sha256:'+'a'.repeat(64);
|
|
62
|
+
if(args[0]==='volume'&&args[1]==='ls')return 'existing';
|
|
63
|
+
if(args[0]==='volume')return JSON.stringify([{Name:args[2]}]);
|
|
64
|
+
if(args[0]==='run')await fs.writeFile(opts.outputFile,'retained-private-state',{mode:0o600});
|
|
65
|
+
return '';
|
|
66
|
+
};
|
|
67
|
+
return {calls,execute,stopHost:async()=>calls.push(['stopHost']),startHost:async root=>calls.push(['startHost',root])};
|
|
68
|
+
}
|
|
69
|
+
async function queued(f) {
|
|
70
|
+
const job=await prepare(f.home,f.target,{file:await f.pack()});await atomic(path.join(f.home,'updates/supervisor.json'),{at:Date.now()});
|
|
71
|
+
return submit(f.home,job.id,false);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test('status distinguishes installed, running, legacy and stale main versions without update jobs',async t=>{
|
|
75
|
+
const f=await fixture(t),relay=path.join(f.agent.controlDir,'heartbeat.json'),host=path.join(f.agent.controlDir,'host-executor/heartbeat.json');
|
|
76
|
+
await fs.mkdir(path.dirname(host),{recursive:true});
|
|
77
|
+
await atomic(relay,{at:Date.now(),polling:true,version:'0.0.9'});await atomic(host,{at:Date.now(),version:'0.0.8'});
|
|
78
|
+
let s=await command(f.home,['status']);
|
|
79
|
+
assert.equal(s.main.installedVersion,'0.1.0');assert.equal(s.main.runningVersion,'0.0.9');assert.equal(s.main.host.runningVersion,'0.0.8');
|
|
80
|
+
assert.deepEqual(s.plugins,[]);assert.deepEqual(s.jobs,[]);
|
|
81
|
+
for(const value of [{at:Date.now()-60000,polling:true,version:'0.0.9'},{at:Date.now()+60000,polling:true,version:'0.0.9'},{at:Date.now(),polling:false,version:'0.0.9'},{at:Date.now(),polling:true}]) {
|
|
82
|
+
await atomic(relay,value);s=await command(f.home,['status']);assert.equal(s.main.runningVersion,null);
|
|
83
|
+
}
|
|
84
|
+
await fs.writeFile(relay,'broken');s=await command(f.home,['status']);assert.equal(s.main.state,'unknown');
|
|
85
|
+
await fs.rm(relay);s=await command(f.home,['status']);assert.equal(s.main.state,'offline');
|
|
86
|
+
const result=await exec(process.execPath,[new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname,'--home',f.home,'status']);
|
|
87
|
+
assert.equal(JSON.parse(result.stdout).main.installedVersion,'0.1.0');
|
|
88
|
+
});
|
|
89
|
+
test('plugin status verifies images and reports stopped, mismatched and unreachable runtimes honestly',async t=>{
|
|
90
|
+
const f=await fixture(t,'plugin'),id='a'.repeat(64),image='sha256:'+'b'.repeat(64);
|
|
91
|
+
for(const mode of ['running','ndjson','stopped','missing','mismatched','offline']) {
|
|
92
|
+
const calls=[];
|
|
93
|
+
const run=async(c,args)=>{
|
|
94
|
+
calls.push([c,...args]);assert.equal(c,'docker');
|
|
95
|
+
if(mode==='offline')throw Error('synthetic secret must not escape');
|
|
96
|
+
if(args.includes('ps')) {const rows=mode==='missing'?[]:[{Service:'sample',State:mode==='stopped'?'exited':'running',Health:'healthy',ID:id}];return mode==='ndjson'?rows.map(r=>JSON.stringify(r)).join('\n'):JSON.stringify(rows);}
|
|
97
|
+
assert(args.includes('inspect'));return mode==='mismatched'&&args[0]==='image'?'sha256:'+'c'.repeat(64):image;
|
|
98
|
+
};
|
|
99
|
+
const s=await runtimeStatus(f.home,run),p=s.plugins[0];
|
|
100
|
+
assert.equal(p.installedVersion,'0.1.0');assert.equal(p.runningVersion,['running','ndjson'].includes(mode)?'0.1.0':null);
|
|
101
|
+
assert.equal(p.state,mode==='offline'?'unknown':['stopped','missing'].includes(mode)?'stopped':'running');
|
|
102
|
+
assert(!JSON.stringify(s).includes('synthetic secret'));assert(calls.every(c=>!c.includes('exec')&&!c.includes('start')&&!c.includes('up')));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
test('SemVer ordering and compatibility reject ranges, malformed values and downgrades',()=>{
|
|
106
|
+
for(const s of ['latest','../1','1.0','01.0.0','1.0.0-01'])assert.throws(()=>version(s));
|
|
107
|
+
assert(newer('0.1.0-beta.10','0.1.0-beta.2'));assert(newer('0.1.0','0.1.0-beta.10'));assert(!newer('0.1.0-beta.2','0.1.0'));assert(!newer('1.0.0','1.0.0'));
|
|
108
|
+
assert(compatible('0.1.9','0.1.0'));assert(!compatible('0.2.0','0.1.0'));assert(compatible('1.9.0','1.0.0'));assert(!compatible('2.0.0','1.0.0'));
|
|
109
|
+
});
|
|
110
|
+
test('archive admission rejects traversal, links, special files, duplicates and corrupt bytes before writes',async t=>{
|
|
111
|
+
const f=await fixture(t);
|
|
112
|
+
for(const entries of [[['package/../escape','x']],[['/tmp/escape','x']],[['package/link','','2']],[['package/node_modules/x','x']],[['package/package.json','{}'],['package/package.json','{}']],[['package/x','','3']]])await assert.rejects(extract(tar(entries),path.join(f.root,'bad')));
|
|
113
|
+
await assert.rejects(extract(Buffer.from('not gzip'),path.join(f.root,'bad')));
|
|
114
|
+
await assert.rejects(fs.access(path.join(f.root,'bad')));
|
|
115
|
+
});
|
|
116
|
+
test('policy defaults stable; prepared local candidates need explicit authority and a live supervisor',async t=>{
|
|
117
|
+
const f=await fixture(t),job=await prepare(f.home,'main',{file:await f.pack()});
|
|
118
|
+
assert.deepEqual(await command(f.home,['policy','main']),{automatic:true,channel:'stable'});
|
|
119
|
+
await assert.rejects(submit(f.home,job.id,false));
|
|
120
|
+
await atomic(path.join(f.home,'updates/supervisor.json'),{at:Date.now()});
|
|
121
|
+
await assert.rejects(submit(f.home,job.id,true),/Local/);
|
|
122
|
+
await command(f.home,['policy','main','manual']);await assert.rejects(eligibility(f.home,'main',f.source,true),/policy/);
|
|
123
|
+
await submit(f.home,job.id,false);await assert.rejects(submit(f.home,job.id,false),/not prepared/);
|
|
124
|
+
await assert.rejects(command(f.home,['status','extra']));await assert.rejects(command(f.home,['policy','../escape']));
|
|
125
|
+
});
|
|
126
|
+
test('identity, state migration, deployment changes and stale candidates fail before replacement',async t=>{
|
|
127
|
+
const f=await fixture(t);
|
|
128
|
+
const mutate=async change=>{const p=await read(path.join(f.source,'package.json'));await atomic(path.join(f.source,'package.json'),{...p,...change});};
|
|
129
|
+
await mutate({name:'@attacker/package'});await assert.rejects(prepare(f.home,'main',{file:await f.pack()}),/identity/);
|
|
130
|
+
await mutate({name:'@ez-test/example',ezRelease:{...contract('main'),stateSchema:2}});await assert.rejects(prepare(f.home,'main',{file:await f.pack()}),/migration/);
|
|
131
|
+
await mutate({ezRelease:contract('main'),version:'0.0.1'});await assert.rejects(prepare(f.home,'main',{file:await f.pack()}),/newer/);
|
|
132
|
+
await mutate({version:'0.1.1'});await fs.writeFile(path.join(f.source,'compose.yaml'),'privileged: true');await assert.rejects(prepare(f.home,'main',{file:await f.pack()}),/deployment/);
|
|
133
|
+
});
|
|
134
|
+
test('main transaction stages before stopping, pins rollback image, preserves state and rebinds root',async t=>{
|
|
135
|
+
const f=await fixture(t),job=await queued(f),r=runtime(f);
|
|
136
|
+
await fs.writeFile(path.join(f.agent.workspace,'memory.md'),'retain me');
|
|
137
|
+
// Mutable preparation files cannot alter the verified archive executed later.
|
|
138
|
+
await fs.writeFile(path.join(jobPath(f.home,job.id),'package/bin/example.mjs'),'tampered');
|
|
139
|
+
const result=await perform(f.home,job,r);assert.equal(result.status,'completed');
|
|
140
|
+
assert.equal(await fs.readFile(path.join(f.agent.workspace,'memory.md'),'utf8'),'retain me');
|
|
141
|
+
const active=(await read(path.join(f.home,'config.json'))).packageRoot;assert(active.endsWith('/runtime'));
|
|
142
|
+
assert.notEqual(await fs.readFile(path.join(active,'bin/example.mjs'),'utf8'),'tampered');
|
|
143
|
+
assert(r.calls.findIndex(c=>c.includes('build'))<r.calls.findIndex(c=>c[0]==='stopHost'));
|
|
144
|
+
const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
|
|
145
|
+
assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
|
|
146
|
+
});
|
|
147
|
+
test('failed preparation never stops runtime; failed activation rolls back code without rewinding state',async t=>{
|
|
148
|
+
for(const stage of ['build','health']) {
|
|
149
|
+
const f=await fixture(t),job=await queued(f),r=runtime(f,{fail:(_c,a)=>stage==='build'?a[0]==='build':a.includes('up')});
|
|
150
|
+
const result=await perform(f.home,job,r);assert.equal(result.status,stage==='build'?'failed':'rolled-back');
|
|
151
|
+
assert.equal((await read(path.join(f.home,'config.json'))).packageRoot,f.old);
|
|
152
|
+
if(stage==='build')assert(!r.calls.some(c=>c[0]==='stopHost'));
|
|
153
|
+
else assert((await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8')).includes('sha256:'+'a'.repeat(64)));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
test('plugin transaction preserves named volumes, backs up stopped data and rolls back failed health',async t=>{
|
|
157
|
+
for(const failed of [false,true]) {
|
|
158
|
+
const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f,{fail:(_c,a)=>failed&&a.includes('up')});
|
|
159
|
+
const result=await perform(f.home,job,r);assert.equal(result.status,failed?'rolled-back':'completed');
|
|
160
|
+
const installed=(await read(path.join(f.home,'registry.json'))).plugins.sample;
|
|
161
|
+
assert.equal(installed.project,f.record.project);assert.equal(installed.manifest.version,failed?'0.1.0':'0.1.1');
|
|
162
|
+
assert(r.calls.some(c=>c.includes('readonly')||c.some(a=>a.includes?.('target=/data,readonly'))));
|
|
163
|
+
assert(!r.calls.some(c=>c.includes('down')||c.includes('-v')||c[0]==='stopHost'));
|
|
164
|
+
assert.equal(await fs.readFile(path.join(jobPath(f.home,job.id),'backup/profile.tar'),'utf8'),'retained-private-state');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
test('stopped plugins remain stopped; removed plugins and expanded mounts reject updates',async t=>{
|
|
168
|
+
const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f,{stopped:true});
|
|
169
|
+
const result=await perform(f.home,job,r);assert.equal(result.status,'completed');assert.equal(result.runtimeVerified,false);assert(!r.calls.some(c=>c.includes('up')));
|
|
170
|
+
const d=await read(path.join(f.source,'ez-deployment.json'));d.services.sample.volumes.other='/more';await atomic(path.join(f.source,'ez-deployment.json'),d);
|
|
171
|
+
await assert.rejects(eligibility(f.home,'sample',f.source,false));
|
|
172
|
+
const reg=await read(path.join(f.home,'registry.json'));delete reg.plugins.sample;await atomic(path.join(f.home,'registry.json'),reg);
|
|
173
|
+
await assert.rejects(prepare(f.home,'sample',{file:await f.pack()}),/not installed/);
|
|
174
|
+
});
|
|
175
|
+
test('interrupted activation recovers previous code; rollback failure is explicit and blocks further jobs',async t=>{
|
|
176
|
+
const f=await fixture(t),job=await queued(f),r=runtime(f);await perform(f.home,job,r);
|
|
177
|
+
const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
|
|
178
|
+
const result=await perform(f.home,interrupted,r);assert.equal(result.status,'rolled-back');assert.equal((await read(path.join(f.home,'config.json'))).packageRoot,f.old);
|
|
179
|
+
interrupted.status='applying';const broken={...r,execute:async()=>{throw Error('Docker offline');}};
|
|
180
|
+
assert.equal((await perform(f.home,interrupted,broken)).status,'recovery-required');
|
|
181
|
+
const another=await prepare(f.home,'main',{file:await f.pack()});await assert.rejects(submit(f.home,another.id,false),/pending/);
|
|
182
|
+
await command(f.home,['recover',interrupted.id]);
|
|
183
|
+
const retried=await read(path.join(jobPath(f.home,interrupted.id),'job.json'));assert.equal((await perform(f.home,retried,r)).status,'rolled-back');
|
|
184
|
+
});
|
|
185
|
+
test('bound dispatch follows active package root and retains private scope',async t=>{
|
|
186
|
+
const f=await fixture(t);await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
|
|
187
|
+
const config=await read(path.join(f.home,'config.json'));config.packageRoot=f.source;await atomic(path.join(f.home,'config.json'),config);
|
|
188
|
+
// A native launcher from the real package looks up its entry point in the active root.
|
|
189
|
+
await fs.writeFile(path.join(f.source,'bin/ezenciel-agents.mjs'),'#!/usr/bin/env node\nconsole.log(process.env.EZ_DEPLOYMENT_DIR)',{mode:0o755});
|
|
190
|
+
const result=await exec(path.join(f.home,'bin/ezenciel-agents'),['--version']);assert.equal(result.stdout.trim(),f.config.deploymentDir);
|
|
191
|
+
assert((await fs.readFile(path.join(f.agent.workspace,'TOOLS.md'),'utf8')).startsWith('My notes'));
|
|
192
|
+
});
|
|
193
|
+
test('upgrade subprocess environment never inherits relay/provider secrets',()=>{
|
|
194
|
+
process.env.TELEGRAM_BOT_TOKEN='synthetic';process.env.OPENAI_API_KEY='synthetic';
|
|
195
|
+
try {assert.equal(environment().TELEGRAM_BOT_TOKEN,undefined);assert.equal(environment().OPENAI_API_KEY,undefined);}finally{delete process.env.TELEGRAM_BOT_TOKEN;delete process.env.OPENAI_API_KEY;}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('package manager selection reuses pnpm, then exact Corepack; never substitutes npm',async t=>{
|
|
199
|
+
const f=await fixture(t);
|
|
200
|
+
for(const unavailable of ['none','missing','wrong-version']) {
|
|
201
|
+
const calls=[];
|
|
202
|
+
const chosen=await packageManager(f.source,async(c,a)=>{
|
|
203
|
+
calls.push([c,...a]);
|
|
204
|
+
if(c==='pnpm'&&unavailable==='missing')throw Object.assign(Error('spawn pnpm ENOENT'),{code:'ENOENT'});
|
|
205
|
+
return c==='pnpm'&&unavailable==='wrong-version'?'9.0.0':'10.30.3';
|
|
206
|
+
});
|
|
207
|
+
assert.equal(chosen.command,unavailable==='none'?'pnpm':'corepack');
|
|
208
|
+
assert.deepEqual(calls,unavailable==='none'?[['pnpm','--version']]:[['pnpm','--version'],['corepack','pnpm@10.30.3','--version']]);
|
|
209
|
+
}
|
|
210
|
+
const p=await read(path.join(f.source,'package.json'));
|
|
211
|
+
for(const value of ['npm@10.0.0','pnpm@latest','pnpm@https://example.invalid/x',undefined]) {
|
|
212
|
+
await atomic(path.join(f.source,'package.json'),{...p,packageManager:value});
|
|
213
|
+
await assert.rejects(packageManager(f.source,async()=>assert.fail('must not execute')),/exact pnpm/);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
test('missing or broken managers fail with repair guidance before installing or stopping anything',async t=>{
|
|
217
|
+
for(const reason of ['ENOENT','signature verification failed','wrong-version']) {
|
|
218
|
+
const f=await fixture(t),job=await queued(f),r=runtime(f);
|
|
219
|
+
r.execute=async(c,a)=>{r.calls.push([c,...a]);assert(['pnpm','corepack'].includes(c));assert.equal(a.at(-1),'--version');if(reason==='wrong-version')return '9.0.0';throw Error(reason);};
|
|
220
|
+
const result=await perform(f.home,job,r);
|
|
221
|
+
assert.equal(result.status,'failed');assert.equal(result.rollback,undefined);
|
|
222
|
+
assert.match(result.error,/service PATH/);assert.match(result.error,/prepare\/apply a new job/);
|
|
223
|
+
assert.equal((await read(path.join(f.home,'config.json'))).packageRoot,f.old);
|
|
224
|
+
assert.equal(r.calls.length,2);assert(!r.calls.some(c=>c.includes('install')||c[0]==='stopHost'));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider} drains work, replaces host PID and recovers after restart`,async t=>{
|
|
229
|
+
const f=await fixture(t),fake=path.join(f.root,'fake');await fs.mkdir(fake);
|
|
230
|
+
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)});`;
|
|
231
|
+
for(const dir of [f.old,f.source]) {
|
|
232
|
+
await fs.mkdir(path.join(dir,'node_modules/tsx/dist'),{recursive:true});await fs.writeFile(path.join(dir,'node_modules/tsx/dist/loader.mjs'),'');
|
|
233
|
+
await fs.writeFile(path.join(dir,'src/host-executor.ts'),hostCode);
|
|
234
|
+
}
|
|
235
|
+
// Package archives never contain node_modules; the fake pnpm below provisions the fixture loader.
|
|
236
|
+
await fs.rm(path.join(f.source,'node_modules'),{recursive:true});
|
|
237
|
+
const log=path.join(f.root,'commands.jsonl');
|
|
238
|
+
await fs.writeFile(path.join(fake,provider),`#!${process.execPath}\nif(${JSON.stringify(provider)}==='corepack'&&process.argv[2]!=='pnpm@10.30.3')throw Error('Unpinned manager');if(process.argv.includes('--version')){console.log('10.30.3');process.exit(0)}const fs=require('fs');fs.mkdirSync('node_modules/tsx/dist',{recursive:true});fs.writeFileSync('node_modules/tsx/dist/loader.mjs','');`,{mode:0o755});
|
|
239
|
+
await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nconst fs=require('fs');const a=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(log)},JSON.stringify(a)+'\\n');if(a.includes('ps'))console.log('cid');if(a[0]==='inspect')console.log('sha256:'+'a'.repeat(64));`,{mode:0o755});
|
|
240
|
+
const wrapper=path.join(f.root,'supervisor.mjs'),module=new URL('../src/updates/supervisor.mjs',import.meta.url).href;
|
|
241
|
+
await fs.writeFile(wrapper,`import {supervise} from ${JSON.stringify(module)};const a=new AbortController();process.on('SIGTERM',()=>a.abort());await supervise(${JSON.stringify(f.config.deploymentDir)},a.signal,{discover:async()=>[]});`);
|
|
242
|
+
const start=()=>{const p=spawn(process.execPath,[wrapper],{env:{...process.env,PATH:fake},stdio:['ignore','pipe','pipe']});let output='';p.stdout.on('data',b=>output+=b);p.stderr.on('data',b=>output+=b);return {p,output:()=>output};};
|
|
243
|
+
const wait=async fn=>{for(let i=0;i<150;i++){const result=await fn();if(result)return result;await new Promise(r=>setTimeout(r,100));}throw Error('Timed out');};
|
|
244
|
+
const first=start();t.after(()=>{first.p.kill('SIGTERM');});
|
|
245
|
+
const heartbeat=()=>read(path.join(f.agent.controlDir,'host-executor/heartbeat.json')).catch(()=>null);
|
|
246
|
+
const oldBeat=await wait(heartbeat);
|
|
247
|
+
const running=path.join(f.agent.controlDir,'host-executor/r_request.running.json');await fs.writeFile(running,'{}');
|
|
248
|
+
const job=await prepare(f.home,'main',{file:await f.pack()});
|
|
249
|
+
const requester=await exec(process.execPath,['--input-type=module','-e',`import {submit} from ${JSON.stringify(new URL('../src/updates/control.mjs',import.meta.url).href)};console.log(JSON.stringify(await submit(${JSON.stringify(f.home)},${JSON.stringify(job.id)},false)));`]);
|
|
250
|
+
assert.equal(JSON.parse(requester.stdout).status,'queued');
|
|
251
|
+
await new Promise(r=>setTimeout(r,1800));assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).status,'queued');
|
|
252
|
+
await fs.rm(running);
|
|
253
|
+
await wait(async()=>{const j=await read(path.join(jobPath(f.home,job.id),'job.json'));if(j.status==='failed'||j.status==='rolled-back')throw Error(JSON.stringify(j)+first.output());return j.status==='completed';});
|
|
254
|
+
const newBeat=await heartbeat();assert.notEqual(newBeat.pid,oldBeat.pid);assert(first.p.exitCode===null);
|
|
255
|
+
assert((await read(path.join(f.agent.controlDir,'update-attention.json'))).id);
|
|
256
|
+
const closed=new Promise(r=>first.p.once('close',r));first.p.kill('SIGTERM');await closed;
|
|
257
|
+
const active=(await read(path.join(f.home,'config.json'))).packageRoot;assert(active.endsWith('/runtime'));
|
|
258
|
+
assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).packageManager.command,provider);
|
|
259
|
+
const second=start();t.after(()=>second.p.kill('SIGTERM'));
|
|
260
|
+
await wait(async()=>{const h=await heartbeat();return h?.pid!==newBeat.pid&&h?.at>newBeat.at;});
|
|
261
|
+
const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';await atomic(path.join(jobPath(f.home,job.id),'job.json'),interrupted);
|
|
262
|
+
await atomic(path.join(f.home,'registry.lock'),{pid:second.p.pid});
|
|
263
|
+
const closed2=new Promise(r=>second.p.once('close',r));second.p.kill('SIGKILL');await closed2;
|
|
264
|
+
const third=start();t.after(()=>third.p.kill('SIGTERM'));
|
|
265
|
+
await wait(async()=>{const j=await read(path.join(jobPath(f.home,job.id),'job.json'));return j.status==='rolled-back';});
|
|
266
|
+
assert.equal((await read(path.join(f.home,'config.json'))).packageRoot,f.old);
|
|
267
|
+
const closed3=new Promise(r=>third.p.once('close',r));third.p.kill('SIGTERM');assert.equal(await closed3,0,third.output());
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('npm candidates verify exact version and integrity; automatic policy is enforced again at execution',async t=>{
|
|
271
|
+
const f=await fixture(t),data=await fs.readFile(await f.pack()),original=globalThis.fetch;
|
|
272
|
+
const {createHash}=await import('node:crypto');
|
|
273
|
+
const pkg={name:'@ez-test/example',version:'0.1.1',dist:{tarball:'https://registry.npmjs.org/@ez-test/example/-/example-0.1.1.tgz',integrity:'sha512-'+createHash('sha512').update(data).digest('base64')}};
|
|
274
|
+
globalThis.fetch=async url=>new Response(String(url).endsWith('.tgz')?data:JSON.stringify(pkg));t.after(()=>globalThis.fetch=original);
|
|
275
|
+
const job=await prepare(f.home,'main',{release:'0.1.1'});assert.equal(job.origin.type,'npm');
|
|
276
|
+
await atomic(path.join(f.home,'updates/supervisor.json'),{});await assert.rejects(submit(f.home,job.id,true),/heartbeat/);
|
|
277
|
+
await atomic(path.join(f.home,'updates/supervisor.json'),{at:Date.now()});await submit(f.home,job.id,true);
|
|
278
|
+
await command(f.home,['policy','main','manual']);const r=runtime(f);
|
|
279
|
+
await assert.rejects(perform(f.home,await read(path.join(jobPath(f.home,job.id),'job.json')),r),/policy/);assert.equal(r.calls.length,0);
|
|
280
|
+
pkg.dist.integrity='sha512-bad';await assert.rejects(prepare(f.home,'main',{release:'0.1.1'}),/integrity/);
|
|
281
|
+
pkg.version='0.1.2';await assert.rejects(prepare(f.home,'main',{release:'0.1.1'}),/version mismatch/);
|
|
282
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { access, chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { createRelay } from '../src/index.js'
|
|
9
|
+
import { ControlStore } from '../src/control-state.js'
|
|
10
|
+
import { RunStore } from '../src/runs.js'
|
|
11
|
+
import { initialPreset } from '../src/ai.js'
|
|
12
|
+
|
|
13
|
+
test('private control directory preserves pause, fails closed on errors, and resumes queued work', async () => {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-upgrade-pause-'))
|
|
15
|
+
await chmod(dir, 0o700)
|
|
16
|
+
const pause = join(dir, 'upgrade-pause.json')
|
|
17
|
+
let launched = 0
|
|
18
|
+
const relay = createRelay({
|
|
19
|
+
controlDir: dir, workspace: dir, pairingTtlMs: 1000,
|
|
20
|
+
executorTimeoutMs: 1000, executorCli: 'grok', telegramBotToken: 'fixture',
|
|
21
|
+
}, async () => { launched++; throw new Error('Fixture executor reached') })
|
|
22
|
+
relay.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
|
|
23
|
+
try {
|
|
24
|
+
if (process.env.EZ_TEST_SPLIT_UID === '1') {
|
|
25
|
+
assert.equal(process.getuid!(), 1001)
|
|
26
|
+
assert.equal(process.geteuid!(), 1000)
|
|
27
|
+
// Prove the old access check fails even though the marker is absent.
|
|
28
|
+
await assert.rejects(access(pause), { code: 'EACCES' })
|
|
29
|
+
await assert.rejects(stat(pause), { code: 'ENOENT' })
|
|
30
|
+
}
|
|
31
|
+
const control = new ControlStore(dir, 1000)
|
|
32
|
+
await control.requestPairing(101, 101)
|
|
33
|
+
await control.approveOwner(101)
|
|
34
|
+
const runs = new RunStore(dir)
|
|
35
|
+
const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['queued'],
|
|
36
|
+
execution: await control.captureChoice(initialPreset('grok')) })
|
|
37
|
+
await writeFile(pause, '{}', { mode: 0o600 })
|
|
38
|
+
await relay.drainSources()
|
|
39
|
+
assert.equal(launched, 0)
|
|
40
|
+
assert.equal((await runs.get(run.id))?.status, 'queued')
|
|
41
|
+
await rm(pause)
|
|
42
|
+
await symlink(pause, pause)
|
|
43
|
+
await assert.rejects(relay.drainSources(), { code: 'ELOOP' })
|
|
44
|
+
assert.equal(launched, 0)
|
|
45
|
+
assert.equal((await runs.get(run.id))?.status, 'queued')
|
|
46
|
+
await rm(pause)
|
|
47
|
+
await relay.drainSources()
|
|
48
|
+
assert.equal(launched, 1)
|
|
49
|
+
assert.equal((await stat(dir)).mode & 0o777, 0o700)
|
|
50
|
+
} finally {
|
|
51
|
+
await relay.stop()
|
|
52
|
+
// Allow the failed fixture launch's next-queue callback to settle.
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
54
|
+
await rm(dir, { recursive: true, force: true })
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('Linux relay real/effective UID regression (Docker test target)', {
|
|
59
|
+
skip: process.platform !== 'linux' || process.getuid?.() !== 0,
|
|
60
|
+
}, () => {
|
|
61
|
+
const env: NodeJS.ProcessEnv = { ...process.env, EZ_TEST_SPLIT_UID: '1' }
|
|
62
|
+
delete env.NODE_TEST_CONTEXT // Run an independent test runner, not the parent's IPC protocol.
|
|
63
|
+
const result = spawnSync('setpriv', [
|
|
64
|
+
'--ruid=1001', '--euid=1000', '--regid=1000', '--clear-groups',
|
|
65
|
+
'--bounding-set=-all', '--no-new-privs', process.execPath, '--import', 'tsx',
|
|
66
|
+
'--test', fileURLToPath(import.meta.url),
|
|
67
|
+
], { encoding: 'utf8', timeout: 15000, env })
|
|
68
|
+
assert.equal(result.status, 0, result.stdout + result.stderr)
|
|
69
|
+
assert.match(result.stdout, /ok 1 - private control directory/)
|
|
70
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
9
|
+
import { executorJobPrompt } from '../src/executor.js'
|
|
10
|
+
|
|
11
|
+
test('packaged launcher help and invalid arguments never start the relay', () => {
|
|
12
|
+
const bin = fileURLToPath(new URL('../bin/ezenciel-agents.mjs', import.meta.url))
|
|
13
|
+
const help = spawnSync(process.execPath, [bin, '--help'], { encoding: 'utf8', cwd: tmpdir(), timeout: 5000 })
|
|
14
|
+
assert.equal(help.status, 0, help.stderr)
|
|
15
|
+
assert.match(help.stdout, /setup init/)
|
|
16
|
+
const invalid = spawnSync(process.execPath, [bin, 'unknown'], { encoding: 'utf8', cwd: tmpdir(), timeout: 5000 })
|
|
17
|
+
assert.equal(invalid.status, 1)
|
|
18
|
+
assert.match(invalid.stderr, /Usage:/)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('agent-facing binaries run when socket listeners are forbidden', async () => {
|
|
22
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-no-listen-'))
|
|
23
|
+
try {
|
|
24
|
+
const preload = path.join(root, 'no-listen.cjs')
|
|
25
|
+
await writeFile(preload, "require('node:net').Server.prototype.listen = function () { throw new Error('listen forbidden') }\n")
|
|
26
|
+
for (const name of ['message', 'react', 'approval']) {
|
|
27
|
+
const bin = fileURLToPath(new URL(`../bin/ezenciel-agents-${name}.mjs`, import.meta.url))
|
|
28
|
+
const result = spawnSync(process.execPath, [bin, '--help'], {
|
|
29
|
+
cwd: root, encoding: 'utf8', timeout: 10000,
|
|
30
|
+
env: { ...process.env, NODE_OPTIONS: `--require=${preload}` },
|
|
31
|
+
})
|
|
32
|
+
assert.equal(result.status, 0, result.stderr)
|
|
33
|
+
assert.match(result.stdout, /Usage:/)
|
|
34
|
+
}
|
|
35
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('fresh mind is private; repeat initialization preserves customization and optional memory', async () => {
|
|
39
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-mind-'))
|
|
40
|
+
const workspace = path.join(root, 'agent')
|
|
41
|
+
try {
|
|
42
|
+
assert.equal((await initializeWorkspace(workspace)).length, 4)
|
|
43
|
+
assert.equal((await stat(path.join(workspace, 'SOUL.md'))).mode & 0o777, 0o600)
|
|
44
|
+
assert.ok(!(await readdir(workspace)).includes('MEMORY.md'))
|
|
45
|
+
await writeFile(path.join(workspace, 'SOUL.md'), 'A customized research partner')
|
|
46
|
+
await writeFile(path.join(workspace, 'MEMORY.md'), 'Existing knowledge')
|
|
47
|
+
await writeFile(path.join(workspace, 'AGENT.md'), 'Legacy custom guidance')
|
|
48
|
+
assert.deepEqual(await initializeWorkspace(workspace), [])
|
|
49
|
+
assert.equal(await readFile(path.join(workspace, 'SOUL.md'), 'utf8'), 'A customized research partner')
|
|
50
|
+
assert.equal(await readFile(path.join(workspace, 'MEMORY.md'), 'utf8'), 'Existing knowledge')
|
|
51
|
+
assert.equal(await readFile(path.join(workspace, 'AGENT.md'), 'utf8'), 'Legacy custom guidance')
|
|
52
|
+
assert.ok(!(await readdir(workspace)).some(name => name.endsWith('.tmp')))
|
|
53
|
+
assert.match(executorJobPrompt('test', ['start']), /Read AGENTS.md/)
|
|
54
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('seed refuses a symlink without overwriting its target', async () => {
|
|
58
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-mind-'))
|
|
59
|
+
try {
|
|
60
|
+
const target = path.join(root, 'outside.md')
|
|
61
|
+
await writeFile(target, 'untouched')
|
|
62
|
+
await symlink(target, path.join(root, 'SOUL.md'))
|
|
63
|
+
await assert.rejects(initializeWorkspace(root), /regular file/)
|
|
64
|
+
assert.equal(await readFile(target, 'utf8'), 'untouched')
|
|
65
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('new agents seed their own purpose once and preserve subsequent mind edits', async () => {
|
|
69
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-purpose-'))
|
|
70
|
+
try {
|
|
71
|
+
const purpose = path.join(root, 'purpose.md')
|
|
72
|
+
const workspace = path.join(root, 'mind')
|
|
73
|
+
await writeFile(purpose, 'Family shopping assistant\n')
|
|
74
|
+
await initializeWorkspace(workspace, purpose)
|
|
75
|
+
assert.equal(await readFile(path.join(workspace, 'SOUL.md'), 'utf8'), 'Family shopping assistant\n')
|
|
76
|
+
await writeFile(path.join(workspace, 'SOUL.md'), 'My evolving purpose\n')
|
|
77
|
+
await initializeWorkspace(workspace, purpose)
|
|
78
|
+
assert.equal(await readFile(path.join(workspace, 'SOUL.md'), 'utf8'), 'My evolving purpose\n')
|
|
79
|
+
} finally { await rm(root, {recursive:true, force:true}) }
|
|
80
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"types": [
|
|
10
|
+
"node"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"src/**/*.ts",
|
|
15
|
+
"test/**/*.ts",
|
|
16
|
+
"scripts/**/*.ts",
|
|
17
|
+
"docker/**/*.ts"
|
|
18
|
+
]
|
|
19
|
+
}
|