@jc_stack/ez-agents 0.1.0-beta.32 → 0.1.0-beta.34
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/.env.example +4 -0
- package/AGENTS.md +46 -31
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +29 -26
- package/Dockerfile +4 -0
- package/README.md +26 -18
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/compose.yaml +2 -1
- package/docker/smoke.mjs +6 -3
- package/docs/application-channel.md +6 -0
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/docker-runtime.md +27 -10
- package/docs/host-service.md +17 -6
- package/docs/plugin-contributions.md +4 -1
- package/docs/plugins.md +15 -0
- package/docs/repair.md +1 -1
- package/docs/setup.md +88 -14
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +6 -4
- package/package.json +1 -1
- package/scripts/smoke-scheduler.ts +2 -1
- package/src/agent-install.ts +20 -6
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +10 -10
- package/src/codex-session.ts +5 -3
- package/src/config.ts +6 -1
- package/src/control-state.ts +12 -1
- package/src/executor.ts +91 -9
- package/src/host-executor.ts +25 -6
- package/src/index.ts +1 -0
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +3 -1
- package/src/isolation.ts +25 -0
- package/src/menu.ts +9 -6
- package/src/plugins/manager.mjs +49 -6
- package/src/reply-context.ts +0 -1
- package/src/setup.ts +11 -7
- package/src/task-executor.ts +4 -1
- package/src/updates/control.mjs +11 -1
- package/src/updates/runtime.mjs +4 -2
- package/src/updates/supervisor.mjs +17 -3
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +23 -13
- package/templates/agent/AGENTS.md +4 -14
- package/templates/agent-guidance.md +13 -110
- package/templates/agent-purpose.md +1 -0
- package/test/agent-guidance.test.ts +8 -12
- package/test/agent-install.test.ts +34 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +48 -0
- package/test/application-channel.test.ts +5 -2
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +2 -0
- package/test/codex-context.test.ts +61 -1
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +17 -2
- package/test/engine-handoff.test.ts +5 -2
- package/test/executor.test.ts +38 -1
- package/test/failure.test.ts +6 -0
- package/test/host-executor.test.ts +6 -0
- package/test/install-config.test.ts +17 -6
- package/test/install-tools.test.mjs +6 -0
- package/test/plugin-manager.test.mjs +24 -3
- package/test/scheduler.test.ts +2 -2
- package/test/updates.test.mjs +34 -2
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +23 -10
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
|
@@ -6,15 +6,67 @@ import {tmpdir} from 'node:os'
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import {startExecutorJob} from '../src/executor.js'
|
|
8
8
|
|
|
9
|
+
test('a package-bin Codex shim cannot retarget another agent home', async () => {
|
|
10
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-codex-host-cli-'))
|
|
11
|
+
const hostBin = path.join(root, 'host-bin')
|
|
12
|
+
const packageBin = path.join(root, 'package-bin')
|
|
13
|
+
const controlDir = path.join(root, 'agent')
|
|
14
|
+
const priorHome = process.env.HOME, priorPath = process.env.PATH
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(hostBin); await mkdir(packageBin); await mkdir(path.join(root, '.codex'))
|
|
17
|
+
await writeFile(path.join(root, '.codex/auth.json'), '{}')
|
|
18
|
+
await writeFile(path.join(packageBin, 'codex'), `#!${process.execPath}
|
|
19
|
+
require('fs').writeFileSync(require('path').join(process.env.HOME, 'hijacked'), 'yes'); process.exit(2)
|
|
20
|
+
`, { mode: 0o700 })
|
|
21
|
+
await writeFile(path.join(hostBin, 'codex'), `#!${process.execPath}\nconsole.log(process.env.CODEX_HOME)`, { mode: 0o700 })
|
|
22
|
+
process.env.HOME = root
|
|
23
|
+
process.env.PATH = hostBin + path.delimiter + (priorPath ?? '')
|
|
24
|
+
await ownerRun(controlDir, 'r_test')
|
|
25
|
+
const job = await startExecutorJob(['hello'], {
|
|
26
|
+
workspace: root, controlDir, binDir: packageBin, cli: 'codex', runId: 'r_test', timeoutMs: 5000,
|
|
27
|
+
})
|
|
28
|
+
let output = ''
|
|
29
|
+
job.child.stdout?.on('data', chunk => output += chunk)
|
|
30
|
+
assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0)
|
|
31
|
+
await job.cleanup()
|
|
32
|
+
assert.equal(output.trim(), path.join(controlDir, 'cli/codex'))
|
|
33
|
+
await assert.rejects(readFile(path.join(root, 'hijacked')))
|
|
34
|
+
} finally {
|
|
35
|
+
if (priorHome === undefined) delete process.env.HOME; else process.env.HOME = priorHome
|
|
36
|
+
if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
|
|
37
|
+
await rm(root, { recursive: true, force: true })
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('a package-bin Codex shim is not a fallback when the host CLI is missing', async () => {
|
|
42
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-codex-host-cli-missing-'))
|
|
43
|
+
const packageBin = path.join(root, 'package-bin')
|
|
44
|
+
const controlDir = path.join(root, 'agent')
|
|
45
|
+
const priorPath = process.env.PATH
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(packageBin)
|
|
48
|
+
await writeFile(path.join(packageBin, 'codex'), `#!${process.execPath}\nprocess.exit(91)\n`, { mode: 0o700 })
|
|
49
|
+
process.env.PATH = '/usr/bin:/bin'
|
|
50
|
+
await ownerRun(controlDir, 'r_test')
|
|
51
|
+
await assert.rejects(startExecutorJob(['hello'], {
|
|
52
|
+
workspace: root, controlDir, binDir: packageBin, cli: 'codex', runId: 'r_test', timeoutMs: 5000,
|
|
53
|
+
}), /Native CLI codex is not executable on the host PATH/)
|
|
54
|
+
} finally {
|
|
55
|
+
if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
|
|
56
|
+
await rm(root, { recursive: true, force: true })
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
9
60
|
test('Codex shares only auth through a link and keeps each agent runtime state separate',async()=>{
|
|
10
61
|
const root=await mkdtemp(path.join(tmpdir(),'ez-codex-context-'))
|
|
11
|
-
const priorHome=process.env.HOME,priorPath=process.env.PATH
|
|
62
|
+
const priorHome=process.env.HOME,priorPath=process.env.PATH,priorIsolation=process.env.EZ_ISOLATION
|
|
12
63
|
try{
|
|
13
64
|
await mkdir(path.join(root,'.codex'));await mkdir(path.join(root,'bin'))
|
|
14
65
|
await writeFile(path.join(root,'.codex/auth.json'),'{}')
|
|
15
66
|
await writeFile(path.join(root,'.codex/config.toml'),'# unrelated personal config')
|
|
16
67
|
await writeFile(path.join(root,'bin/codex'),`#!${process.execPath}\nconsole.log(process.env.CODEX_HOME)`,{mode:0o700})
|
|
17
68
|
process.env.HOME=root;process.env.PATH=path.join(root,'bin')+path.delimiter+priorPath
|
|
69
|
+
delete process.env.EZ_ISOLATION
|
|
18
70
|
for(const agent of ['one','two']){
|
|
19
71
|
const controlDir=path.join(root,agent)
|
|
20
72
|
await ownerRun(controlDir, 'r_test')
|
|
@@ -27,9 +79,17 @@ test('Codex shares only auth through a link and keeps each agent runtime state s
|
|
|
27
79
|
assert.deepEqual(await readdir(home),['auth.json'])
|
|
28
80
|
assert.equal(await readlink(path.join(home,'auth.json')),path.join(root,'.codex/auth.json'))
|
|
29
81
|
}
|
|
82
|
+
process.env.EZ_ISOLATION='isolated'
|
|
83
|
+
const isolated=path.join(root,'isolated')
|
|
84
|
+
await ownerRun(isolated, 'r_test')
|
|
85
|
+
const isolatedJob=await startExecutorJob(['hello'],{workspace:root,controlDir:isolated,binDir:path.join(root,'bin'),cli:'codex',runId:'r_test',timeoutMs:5000})
|
|
86
|
+
assert.equal(await new Promise(resolve=>isolatedJob.child.once('close',resolve)),0)
|
|
87
|
+
await isolatedJob.cleanup()
|
|
88
|
+
await assert.rejects(readlink(path.join(isolated,'cli/codex/auth.json')),{code:'ENOENT'})
|
|
30
89
|
}finally{
|
|
31
90
|
if(priorHome===undefined)delete process.env.HOME;else process.env.HOME=priorHome
|
|
32
91
|
if(priorPath===undefined)delete process.env.PATH;else process.env.PATH=priorPath
|
|
92
|
+
if(priorIsolation===undefined)delete process.env.EZ_ISOLATION;else process.env.EZ_ISOLATION=priorIsolation
|
|
33
93
|
await rm(root,{recursive:true,force:true})
|
|
34
94
|
}
|
|
35
95
|
})
|
|
@@ -43,7 +43,8 @@ if(q.method==='thread/goal/get'){
|
|
|
43
43
|
child.stdin.write=((chunk:any,...args:any[])=>{try{const q=JSON.parse(String(chunk));if(q.method==='turn/start'){turnPrompt=q.params.input[0].text;turnPolicy=q.params.sandboxPolicy}requests.push(q.method);if(q.method==='thread/start'){threadConfig=q.params.config;threadSandbox=q.params.sandbox}}catch{};return (write as any)(chunk,...args)}) as typeof child.stdin.write
|
|
44
44
|
return child
|
|
45
45
|
}
|
|
46
|
-
const
|
|
46
|
+
const codexProvider=mode==='tool-goal'?{id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['open/model']}:undefined
|
|
47
|
+
const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',additionalWorkspaces:['/agent/mind/work'],prompt,codexProvider,model:codexProvider?'open/model':undefined,
|
|
47
48
|
...(mode==='external'?{codexSandbox:'external' as const}:{})},{launch,emit:line=>output.push(line)})
|
|
48
49
|
assert.equal(threadSandbox!,mode==='external'?'danger-full-access':'workspace-write')
|
|
49
50
|
assert.deepEqual(turnPolicy,mode==='external'?{type:'externalSandbox',networkAccess:'enabled'}:undefined)
|
|
@@ -52,6 +53,10 @@ if(q.method==='thread/goal/get'){
|
|
|
52
53
|
if(mode==='long-goal')assert.ok(prompt.length>4000)
|
|
53
54
|
assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/canonical'))
|
|
54
55
|
assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/agent/mind/work'))
|
|
56
|
+
if(codexProvider){
|
|
57
|
+
assert.equal(threadConfig.model_provider,'openrouter')
|
|
58
|
+
assert.deepEqual(threadConfig['model_providers.openrouter'],{name:'OpenRouter',base_url:'https://openrouter.ai/api/v1',env_key:'OPENROUTER_API_KEY',wire_api:'responses',supports_websockets:false})
|
|
59
|
+
}
|
|
55
60
|
assert.equal(result,['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
|
|
56
61
|
assert.equal(requests.filter(x=>x==='turn/start').length,1,'transport must not send goal continuation prompts')
|
|
57
62
|
assert.equal(requests.filter(x=>x==='thread/goal/set').length,0)
|
package/test/config.test.ts
CHANGED
|
@@ -65,8 +65,23 @@ test('external Codex isolation requires explicit native local deployment', () =>
|
|
|
65
65
|
assert.equal(loadConfig(env).codexSandbox, 'external')
|
|
66
66
|
assert.equal(loadConfig({...env,EZ_TELEGRAM_ENABLED:'true',TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, 'external')
|
|
67
67
|
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, undefined)
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
assert.equal(loadConfig({...env,EZ_EXECUTOR_TRANSPORT:''}).isolation, 'isolated')
|
|
69
|
+
assert.throws(() => loadConfig({...env,EZ_EXECUTOR_TRANSPORT:'host'}), /sandbox|SANDBOX|Isolation/)
|
|
70
|
+
assert.throws(() => loadConfig({...env,EZ_CODEX_SANDBOX:'danger-full-access'}), /sandbox|SANDBOX/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('isolation class is isolated by default and rejects host transport mismatch', () => {
|
|
74
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).isolation, 'isolated')
|
|
75
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'local'}).isolation, 'isolated')
|
|
76
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
|
|
77
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
|
|
78
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'host'}), /Isolation isolated requires EZ_EXECUTOR_TRANSPORT=local/)
|
|
79
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'local'}), /Isolation host-capable requires EZ_EXECUTOR_TRANSPORT=host/)
|
|
80
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'seatbelt'}), /isolated or host-capable/)
|
|
81
|
+
assert.throws(() => loadConfig({
|
|
82
|
+
TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host',
|
|
83
|
+
EZ_CHANNEL_BACKEND_URL:'https://backend.example',EZ_CHANNEL_BACKEND_TOKEN:'secret',
|
|
84
|
+
}), /Host-capable isolation requires the host native CLI/)
|
|
70
85
|
})
|
|
71
86
|
|
|
72
87
|
test('optional web launcher preserves reserved commands and accepts only HTTPS without secrets', () => {
|
|
@@ -13,7 +13,7 @@ test('owner, resumed and native scheduled subprocesses receive literal input and
|
|
|
13
13
|
const root=await mkdtemp(path.join(tmpdir(),'ez-literal-'))
|
|
14
14
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
15
15
|
const bin=path.join(root,'bin'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
|
|
16
|
-
await mkdir(bin);await initializeWorkspace(workspace)
|
|
16
|
+
await mkdir(bin);const purpose=path.join(root,'purpose.md');await writeFile(purpose,'Fixture agent\n');await initializeWorkspace(workspace,purpose)
|
|
17
17
|
const fixture=`#!${process.execPath}
|
|
18
18
|
const fs=require('fs'), args=process.argv.slice(2);
|
|
19
19
|
const capture=prompt=>fs.writeFileSync('capture.json',JSON.stringify({prompt,args,env:{run:process.env.EZ_RUN_ID,control:process.env.EZ_CONTROL_DIR,repair:process.env.EZ_REPAIR_ENABLED,secret:process.env.TELEGRAM_BOT_TOKEN}}));
|
|
@@ -44,7 +44,10 @@ if(args[0]==='app-server'){
|
|
|
44
44
|
const captured=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
|
|
45
45
|
assert.equal(captured.prompt,text)
|
|
46
46
|
assert.deepEqual(captured.env,{run:runId,control:controlDir,repair:'false'})
|
|
47
|
-
if(cli==='codex')
|
|
47
|
+
if(cli==='codex'){
|
|
48
|
+
assert.equal(captured.args.at(-1),'-')
|
|
49
|
+
if(process.platform==='darwin')assert.equal(captured.args[captured.args.indexOf('--sandbox')+1],'danger-full-access')
|
|
50
|
+
}
|
|
48
51
|
if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
|
|
49
52
|
if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
|
|
50
53
|
if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
|
package/test/executor.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
|
|
9
|
+
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
10
10
|
import { splitTelegramText } from '../src/reply.js'
|
|
11
11
|
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
12
12
|
|
|
@@ -54,6 +54,11 @@ test('Telegram replies are split within the configured message limit', () => {
|
|
|
54
54
|
assert.ok(chunks.every((chunk) => chunk.length <= 10))
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
test('native CLIs resolve from the host PATH, not a relative package name', () => {
|
|
58
|
+
assert.equal(resolveHostCommand('/usr/bin/codex'), '/usr/bin/codex')
|
|
59
|
+
assert.throws(() => resolveHostCommand('missing-cli-xyz', '/usr/bin'), /not executable on the host PATH/)
|
|
60
|
+
})
|
|
61
|
+
|
|
57
62
|
test('the executor receives a deliberately small environment', () => {
|
|
58
63
|
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', PAGERDUTY_ROUTING_KEY: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
|
|
59
64
|
assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
|
|
@@ -158,6 +163,38 @@ test('Codex jobs disable global memory and host skill discovery', () => {
|
|
|
158
163
|
assert.equal(args[args.indexOf('skip_host_skill_discovery')-1],'--enable')
|
|
159
164
|
})
|
|
160
165
|
|
|
166
|
+
test('Codex custom providers use declared runtime state and an environment key reference', () => {
|
|
167
|
+
const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1/',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
|
|
168
|
+
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',model:'google/gemini-3.8-flash',codexProvider:provider},'','hello')
|
|
169
|
+
assert.ok(args.includes('model_provider="openrouter"'))
|
|
170
|
+
assert.ok(args.some(value=>value.includes('env_key="OPENROUTER_API_KEY"')))
|
|
171
|
+
assert.ok(args.includes('google/gemini-3.8-flash'))
|
|
172
|
+
assert.equal(JSON.stringify(args).includes('provider-secret'),false)
|
|
173
|
+
assert.throws(()=>validateCodexProvider({...provider,envKey:'TELEGRAM-BOT'}),/environment key/)
|
|
174
|
+
for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN','PAGERDUTY_ROUTING_KEY'])
|
|
175
|
+
assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
|
|
176
|
+
assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('a configured Codex provider receives only its declared key and runtime selection', async t => {
|
|
180
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
|
|
181
|
+
const bin=path.join(root,'bin');await mkdir(bin)
|
|
182
|
+
await writeFile(path.join(bin,'codex'),`#!${process.execPath}\nconst fs=require('fs');let text='';process.stdin.on('data',b=>text+=b);process.stdin.on('end',()=>fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN,text})));`,{mode:0o755})
|
|
183
|
+
const prior={path:process.env.PATH,key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN}
|
|
184
|
+
const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
|
|
185
|
+
await ownerRun(root,'r_provider')
|
|
186
|
+
try {
|
|
187
|
+
process.env.PATH=bin+path.delimiter+prior.path;process.env.OPENROUTER_API_KEY='provider-secret';process.env.TELEGRAM_BOT_TOKEN='relay-secret'
|
|
188
|
+
const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId:'r_provider',timeoutMs:0,cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',codexProvider:provider})
|
|
189
|
+
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
|
|
190
|
+
const observed=JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
|
|
191
|
+
assert.equal(observed.key,'provider-secret');assert.equal(observed.telegram,undefined);assert.equal(observed.text,'hello')
|
|
192
|
+
assert.ok(observed.args.includes('model_provider="openrouter"'));assert.ok(observed.args.includes('google/gemini-3.8-flash'))
|
|
193
|
+
} finally {
|
|
194
|
+
for(const [key,value] of [['PATH',prior.path],['OPENROUTER_API_KEY',prior.key],['TELEGRAM_BOT_TOKEN',prior.telegram]]) if(value===undefined)delete process.env[key!];else process.env[key!]=value
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
161
198
|
test('Codex plugin access stays scoped to the explicitly bound registry', () => {
|
|
162
199
|
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind',controlDir:'/agent/control',toolsHome:'/agent/tools',sharedWorkspace:'/app',additionalWorkspaces:['/agent/mind/work']},'', 'install a plugin')
|
|
163
200
|
assert.deepEqual(args.flatMap((arg,i)=>arg==='--add-dir'?[args[i+1]]:[]),['/agent/control','/app','/agent/mind/work','/agent/tools'])
|
package/test/failure.test.ts
CHANGED
|
@@ -15,12 +15,14 @@ import { initialPreset } from '../src/ai.js'
|
|
|
15
15
|
import { createRelay } from '../src/index.js'
|
|
16
16
|
import { TelegramSource } from '../src/telegram-source.js'
|
|
17
17
|
import { packageVersion } from '../src/version.js'
|
|
18
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
18
19
|
import type { Update } from 'grammy/types'
|
|
19
20
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
20
21
|
// This integration test starts subprocesses, persists their receipts, and invokes
|
|
21
22
|
// the schedule CLI. Give a busy CI worker time to settle without changing the
|
|
22
23
|
// production recovery deadline.
|
|
23
24
|
const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
|
|
25
|
+
const seedWorkspace=async(dir:string)=>{const purpose=join(dir,'purpose.md');await writeFile(purpose,'Fixture relay agent\n');await initializeWorkspace(dir,purpose)}
|
|
24
26
|
|
|
25
27
|
test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
|
|
26
28
|
const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
|
|
@@ -142,6 +144,7 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
|
|
|
142
144
|
|
|
143
145
|
test('a fresh relay initializes its bot identity before polling', async () => {
|
|
144
146
|
const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
|
|
147
|
+
await seedWorkspace(dir)
|
|
145
148
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
|
|
146
149
|
const methods:string[]=[]
|
|
147
150
|
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
@@ -165,6 +168,7 @@ test('a fresh relay initializes its bot identity before polling', async () => {
|
|
|
165
168
|
|
|
166
169
|
for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
|
|
167
170
|
const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
171
|
+
await seedWorkspace(dir)
|
|
168
172
|
let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
|
|
169
173
|
const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
|
|
170
174
|
const deliveryGate=new Promise<void>(resolve=>{releaseDelivery=resolve}),deliveryStarted=new Promise<void>(resolve=>{sending=resolve})
|
|
@@ -285,6 +289,7 @@ test('group members can inspect failures and wake review without exposing other
|
|
|
285
289
|
|
|
286
290
|
for (const failure of [400,401,503,'programming'] as const) test(`setup failure ${failure} retries only transient errors`,async t=>{
|
|
287
291
|
const dir=await mkdtemp(join(tmpdir(),'ez-setup-failure-'))
|
|
292
|
+
await seedWorkspace(dir)
|
|
288
293
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
|
|
289
294
|
let commands=0,polls=0
|
|
290
295
|
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
@@ -310,6 +315,7 @@ for (const failure of [400,401,503,'programming'] as const) test(`setup failure
|
|
|
310
315
|
|
|
311
316
|
test('shutdown aborts a pending bot initialization without starting polling',async()=>{
|
|
312
317
|
const dir=await mkdtemp(join(tmpdir(),'ez-init-stop-'))
|
|
318
|
+
await seedWorkspace(dir)
|
|
313
319
|
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
|
|
314
320
|
let initSignal:Parameters<typeof relay.bot.init>[0],polls=0
|
|
315
321
|
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
@@ -41,6 +41,12 @@ test('legacy host lock fails closed while its PID is alive',async()=>{
|
|
|
41
41
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
test('isolated installations cannot start host transport', async () => {
|
|
45
|
+
const abort=new AbortController()
|
|
46
|
+
await assert.rejects(serveHostExecutor({cli:'grok',isolation:'isolated',agents:[]},abort.signal),/Isolated agents run the native CLI in the relay/)
|
|
47
|
+
abort.abort()
|
|
48
|
+
})
|
|
49
|
+
|
|
44
50
|
test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
|
|
45
51
|
const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
|
|
46
52
|
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
|
|
@@ -14,8 +14,10 @@ test('configure preserves custom paths and unknown settings, keeps token private
|
|
|
14
14
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-'))
|
|
15
15
|
try {
|
|
16
16
|
await writeFile(path.join(root, '.env'), 'EZ_AGENT_WORKSPACE="my mind"\nEZ_CONTROL_DIR="private control"\nEXTRA="keep me"\n')
|
|
17
|
+
const purpose = path.join(root, 'purpose.md')
|
|
18
|
+
await writeFile(purpose, 'Configured assistant\n')
|
|
17
19
|
const token = '123456789:' + 'a'.repeat(30)
|
|
18
|
-
const result = await configureInstallation(root, 'codex', token)
|
|
20
|
+
const result = await configureInstallation(root, 'codex', token, purpose)
|
|
19
21
|
assert.equal(result.tokenConfigured, true)
|
|
20
22
|
assert.equal(JSON.stringify(result).includes(token), false)
|
|
21
23
|
const env = parseEnv(await readFile(result.envFile, 'utf8'))
|
|
@@ -24,12 +26,12 @@ test('configure preserves custom paths and unknown settings, keeps token private
|
|
|
24
26
|
assert.equal(env.EZ_AGENT_WORKSPACE, path.join(root, 'my mind'))
|
|
25
27
|
assert.equal((await stat(result.envFile)).mode & 0o777, 0o600)
|
|
26
28
|
assert.equal(await readActiveExecutor(result.envFile), 'codex')
|
|
27
|
-
const
|
|
28
|
-
await writeFile(
|
|
29
|
+
const agents = path.join(result.workspace, 'AGENTS.md')
|
|
30
|
+
await writeFile(agents, 'Customized purpose')
|
|
29
31
|
const repeated = await configureInstallation(root, 'codex')
|
|
30
32
|
assert.deepEqual(repeated.created, [])
|
|
31
33
|
assert.equal(repeated.tokenConfigured, true)
|
|
32
|
-
assert.
|
|
34
|
+
assert.match(await readFile(agents, 'utf8'), /Customized purpose/)
|
|
33
35
|
await assert.rejects(stat(result.controlDir), { code: 'ENOENT' }) // No control/owner mutation.
|
|
34
36
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
35
37
|
})
|
|
@@ -56,15 +58,24 @@ test('packaged configure accepts token through stdin without echo or extra initi
|
|
|
56
58
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-cli-'))
|
|
57
59
|
try {
|
|
58
60
|
const token = '123456789:' + 'b'.repeat(30)
|
|
61
|
+
const purpose = path.join(root, 'purpose.md')
|
|
62
|
+
await writeFile(purpose, 'Packaged assistant\n')
|
|
59
63
|
const bin = fileURLToPath(new URL('../bin/ezenciel-agents-setup.mjs', import.meta.url))
|
|
60
|
-
const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
|
|
64
|
+
const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--purpose-file', purpose, '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
|
|
61
65
|
assert.equal(result.status, 0, result.stderr)
|
|
62
66
|
assert.equal((result.stdout + result.stderr).includes(token), false)
|
|
63
|
-
assert.
|
|
67
|
+
assert.deepEqual(JSON.parse(result.stdout).created, ['AGENTS.md'])
|
|
64
68
|
assert.equal(parseEnv(await readFile(path.join(root, '.env'), 'utf8')).TELEGRAM_BOT_TOKEN, token)
|
|
65
69
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
66
70
|
})
|
|
67
71
|
|
|
72
|
+
test('fresh configure refuses to invent a generic purpose', async () => {
|
|
73
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-purpose-'))
|
|
74
|
+
try {
|
|
75
|
+
await assert.rejects(configureInstallation(root, 'codex'), /purpose file is required/)
|
|
76
|
+
} finally { await rm(root, { recursive: true, force: true }) }
|
|
77
|
+
})
|
|
78
|
+
|
|
68
79
|
test('Docker startup uses explicit deployment binding and literal paths without secrets or host supervisors', () => {
|
|
69
80
|
const definition = serviceDefinition('/tmp/my agent $literal')
|
|
70
81
|
assert.equal(definition.command, 'docker')
|
|
@@ -24,6 +24,12 @@ test('installation status separates files, pairing and current-owner Telegram de
|
|
|
24
24
|
const write=(name,obj)=>fs.writeFile(path.join(control,name),JSON.stringify(obj));
|
|
25
25
|
await write('heartbeat.json',{at:Date.now(),polling:true});await write('host-executor/heartbeat.json',{at:Date.now()});
|
|
26
26
|
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
27
|
+
await fs.writeFile(path.join(root,'agent.json'),JSON.stringify({isolation:'isolated'}));
|
|
28
|
+
await fs.rm(path.join(control,'host-executor/heartbeat.json'));
|
|
29
|
+
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
30
|
+
await fs.writeFile(path.join(root,'agent.json'),'private');
|
|
31
|
+
await write('host-executor/heartbeat.json',{at:Date.now()});
|
|
32
|
+
assert.equal((await installationStatus(root)).stage,'awaiting-owner');
|
|
27
33
|
const owner={telegramUserId:123,telegramChatId:123,pairedAt:new Date(Date.now()-10000).toISOString()};await write('control-state.json',{owner});
|
|
28
34
|
assert.equal((await installationStatus(root)).stage,'awaiting-telegram-reply');
|
|
29
35
|
const item={runId:'tg_12',chatId:123,receipt:{messageIds:[42],deliveredAt:new Date().toISOString()}};
|
|
@@ -169,7 +169,7 @@ test('host-owned private networks survive registered-call Compose regeneration',
|
|
|
169
169
|
config.hostConfig=await fs.realpath(f.hostConfig);
|
|
170
170
|
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify(config));
|
|
171
171
|
const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
|
|
172
|
-
await f.
|
|
172
|
+
await exec(path.join(f.home,'bin','ez'),['sample','read'],{cwd:f.workspace,env:f.env});
|
|
173
173
|
const c=JSON.parse(await fs.readFile(file,'utf8'));
|
|
174
174
|
assert.deepEqual(c.networks.default,{});
|
|
175
175
|
const [key,network]=Object.entries(c.networks).find(([name])=>name!=='default');
|
|
@@ -323,8 +323,8 @@ test('standalone rejects relay binding and preserves literal plugin arguments ac
|
|
|
323
323
|
}
|
|
324
324
|
const log=(await fs.readFile(f.log,'utf8')).trim().split('\n').map(JSON.parse);
|
|
325
325
|
assert(log.every(call=>call.secret===undefined));
|
|
326
|
-
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:f.workspace,catalog:{},deploymentDir:'/missing'}));
|
|
327
|
-
await assert.rejects(exec(launcher,['status']),/deployment-bound/); // never hide a broken relay binding
|
|
326
|
+
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:await fs.realpath(f.workspace),catalog:{},deploymentDir:'/missing'}));
|
|
327
|
+
await assert.rejects(exec(launcher,['status'],{cwd:f.workspace,env:f.env}),/deployment-bound/); // never hide a broken relay binding
|
|
328
328
|
});
|
|
329
329
|
|
|
330
330
|
test('existing folders are read-only, persistent and fail closed when missing', async t => {
|
|
@@ -349,6 +349,27 @@ test('existing folders are read-only, persistent and fail closed when missing',
|
|
|
349
349
|
assert.deepEqual(JSON.parse((await f.call('plugins','folders','sample')).stdout),[]);
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
+
test('relay registry rejects foreign callers and ungranted host folders', async t => {
|
|
353
|
+
const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
|
|
354
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
355
|
+
await fs.mkdir(path.join(f.root,'allowed'));await fs.mkdir(path.join(f.root,'denied'));
|
|
356
|
+
const allowed=await fs.realpath(path.join(f.root,'allowed')),denied=await fs.realpath(path.join(f.root,'denied'));
|
|
357
|
+
const host={cli:'synthetic',agents:[{name:'sample',workspace:f.workspace,controlDir:f.control,binDir:path.join(f.home,'bin'),toolsHome:f.home,pluginFolderRoots:{sample:[allowed]}}]};
|
|
358
|
+
await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
359
|
+
const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
|
|
360
|
+
config.hostConfig=await fs.realpath(f.hostConfig);await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify(config));
|
|
361
|
+
const launcher=path.join(f.home,'bin','ez'),foreign={cwd:f.source,env:f.env};
|
|
362
|
+
const before=await fs.readFile(f.log,'utf8');
|
|
363
|
+
await assert.rejects(exec(launcher,['sample','read'],foreign),/Registry belongs/);
|
|
364
|
+
await assert.rejects(exec(launcher,['--for-workspace',f.workspace,'sample','read'],foreign),/Registry belongs/);
|
|
365
|
+
assert.equal(await fs.readFile(f.log,'utf8'),before);
|
|
366
|
+
assert.deepEqual(JSON.parse((await exec(launcher,['sample','read'],{cwd:f.workspace,env:f.env})).stdout),['read']);
|
|
367
|
+
await exec(launcher,['plugins','folder-bind','sample','--service','sample','--source',allowed,'--target','/data/files'],{cwd:f.workspace,env:f.env});
|
|
368
|
+
const saved=await fs.readFile(path.join(f.home,'config.json'),'utf8');
|
|
369
|
+
await assert.rejects(exec(launcher,['plugins','folder-bind','sample','--service','sample','--source',denied,'--target','/data/files'],{cwd:f.workspace,env:f.env}),/not granted/);
|
|
370
|
+
assert.equal(await fs.readFile(path.join(f.home,'config.json'),'utf8'),saved);
|
|
371
|
+
});
|
|
372
|
+
|
|
352
373
|
test('folder bindings survive compatible descriptors and reject incompatible updates',async t=>{
|
|
353
374
|
const f=await fixture(t);const p=await snapshot(f.source);
|
|
354
375
|
const record={...p,project:'ezp-test-sample'};
|
package/test/scheduler.test.ts
CHANGED
|
@@ -78,10 +78,10 @@ test('pause, edit, removal, owner revocation, corrupt records and traversal fail
|
|
|
78
78
|
})
|
|
79
79
|
test('task workspaces are distinct and cannot escape through symlinks',async t=>{
|
|
80
80
|
const f=await fixture(t)
|
|
81
|
-
await writeFile(join(f.dir,'
|
|
81
|
+
await writeFile(join(f.dir,'private.md'),'Owner context')
|
|
82
82
|
const first=await taskWorkspace(f.dir,'r_one'),second=await taskWorkspace(f.dir,'r_two')
|
|
83
83
|
assert.notEqual(first,second)
|
|
84
|
-
await assert.rejects(readFile(join(first,'
|
|
84
|
+
await assert.rejects(readFile(join(first,'private.md'),'utf8'),{code:'ENOENT'})
|
|
85
85
|
await assert.rejects(readFile(join(first,'AGENTS.md'),'utf8'),{code:'ENOENT'})
|
|
86
86
|
await assert.rejects(taskWorkspace(f.dir,'../escape'))
|
|
87
87
|
const other=join(f.dir,'other');await mkdir(other)
|
package/test/updates.test.mjs
CHANGED
|
@@ -9,10 +9,19 @@ import { promisify } from 'node:util';
|
|
|
9
9
|
import { extract, digest, version, newer, compatible } from '../src/updates/artifact.mjs';
|
|
10
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
|
-
import { atomic, snapshot, compose } from '../src/plugins/manager.mjs';
|
|
12
|
+
import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
|
|
13
13
|
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
14
14
|
import { status as runtimeStatus } from '../src/updates/status.mjs';
|
|
15
|
+
import { providerEnvironment } from '../src/updates/supervisor.mjs';
|
|
15
16
|
const exec=promisify(execFile);
|
|
17
|
+
|
|
18
|
+
test('supervisor forwards only provider keys declared by the agent installation',()=>{
|
|
19
|
+
const host={agents:[{codexProviders:[{envKey:'OPENROUTER_API_KEY'}]}]};
|
|
20
|
+
assert.deepEqual(providerEnvironment(host,{OPENROUTER_API_KEY:'provider-secret',TELEGRAM_BOT_TOKEN:'relay-secret'}),{OPENROUTER_API_KEY:'provider-secret'});
|
|
21
|
+
assert.throws(()=>providerEnvironment({agents:[{codexProviders:[{envKey:'bad-key'}]}]},{}),/environment key/);
|
|
22
|
+
for(const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN','PAGERDUTY_ROUTING_KEY'])
|
|
23
|
+
assert.throws(()=>providerEnvironment({agents:[{codexProviders:[{envKey}]}]},{}),/Reserved Codex provider environment key/);
|
|
24
|
+
});
|
|
16
25
|
const contract=kind=>({protocol:1,kind,stateSchema:1,mainProtocol:1});
|
|
17
26
|
function tar(entries) {
|
|
18
27
|
const chunks=[];
|
|
@@ -83,7 +92,7 @@ test('status distinguishes installed, running, legacy and stale main versions wi
|
|
|
83
92
|
}
|
|
84
93
|
await fs.writeFile(relay,'broken');s=await command(f.home,['status']);assert.equal(s.main.state,'unknown');
|
|
85
94
|
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']);
|
|
95
|
+
const result=await exec(process.execPath,[new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname,'--home',f.home,'status'],{cwd:f.agent.workspace});
|
|
87
96
|
assert.equal(JSON.parse(result.stdout).main.installedVersion,'0.1.0');
|
|
88
97
|
});
|
|
89
98
|
test('relay healthcheck emits bounded predicate evidence without state contents',async t=>{
|
|
@@ -191,6 +200,29 @@ test('stopped plugins remain stopped; removed plugins and expanded mounts reject
|
|
|
191
200
|
const reg=await read(path.join(f.home,'registry.json'));delete reg.plugins.sample;await atomic(path.join(f.home,'registry.json'),reg);
|
|
192
201
|
await assert.rejects(prepare(f.home,'sample',{file:await f.pack()}),/not installed/);
|
|
193
202
|
});
|
|
203
|
+
test('plugin updates register additive command routes on an unchanged deployment',async t=>{
|
|
204
|
+
const f=await fixture(t,'plugin'),manifest=await read(path.join(f.source,'ez-plugin.json')),deployment=await read(path.join(f.source,'ez-deployment.json'));
|
|
205
|
+
manifest.commands.query={executable:'bin/example.mjs',args:[],channelQuery:true,exposure:{receivesExternalContent:true,sendsExternally:false,changesRecords:false,requiresReview:false}};
|
|
206
|
+
deployment.commands.query={service:'sample',argv:['node','/app/bin/example.mjs','query']};
|
|
207
|
+
await atomic(path.join(f.source,'ez-plugin.json'),manifest);await atomic(path.join(f.source,'ez-deployment.json'),deployment);
|
|
208
|
+
assert.equal((await eligibility(f.home,'sample',f.source,false)).pkg.version,'0.1.1');
|
|
209
|
+
const job=await queued(f),r=runtime(f);assert.equal((await perform(f.home,job,r)).status,'completed');
|
|
210
|
+
const registry=await read(path.join(f.home,'registry.json'));assert.equal(registry.commands.query,'sample');
|
|
211
|
+
const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
|
|
212
|
+
assert(r.calls.some(call=>call.includes('up')));
|
|
213
|
+
});
|
|
214
|
+
test('plugin command additions ignore object key order but reject collisions and changed routes',async t=>{
|
|
215
|
+
const f=await fixture(t,'plugin'),deployment=await read(path.join(f.source,'ez-deployment.json'));
|
|
216
|
+
deployment.services.sample={healthcheck:deployment.services.sample.healthcheck,volumes:deployment.services.sample.volumes,buildTarget:deployment.services.sample.buildTarget};
|
|
217
|
+
await atomic(path.join(f.source,'ez-deployment.json'),deployment);assert.equal((await eligibility(f.home,'sample',f.source,false)).pkg.version,'0.1.1');
|
|
218
|
+
const manifest=await read(path.join(f.source,'ez-plugin.json'));manifest.commands.other={executable:'bin/example.mjs',args:[]};deployment.commands.other={service:'sample',argv:['node','/app/bin/example.mjs','other']};
|
|
219
|
+
await atomic(path.join(f.source,'ez-plugin.json'),manifest);await atomic(path.join(f.source,'ez-deployment.json'),deployment);
|
|
220
|
+
const registry=await read(path.join(f.home,'registry.json'));registry.commands.other='different';await atomic(path.join(f.home,'registry.json'),registry);
|
|
221
|
+
await assert.rejects(eligibility(f.home,'sample',f.source,false),/alias collision/);
|
|
222
|
+
delete registry.commands.other;await atomic(path.join(f.home,'registry.json'),registry);
|
|
223
|
+
deployment.commands.sample.argv.push('--changed');await atomic(path.join(f.source,'ez-deployment.json'),deployment);
|
|
224
|
+
await assert.rejects(eligibility(f.home,'sample',f.source,false),/deployment/i);
|
|
225
|
+
});
|
|
194
226
|
test('interrupted activation recovers previous code; rollback failure is explicit and blocks further jobs',async t=>{
|
|
195
227
|
const f=await fixture(t),job=await queued(f),r=runtime(f);await perform(f.home,job,r);
|
|
196
228
|
const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { spawn } from 'node:child_process'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { macosWorkspaceProfile, workspaceSiblingDenies } from '../src/workspace-confine.js'
|
|
8
|
+
|
|
9
|
+
test('sibling confinement skips $HOME and filesystem root', () => {
|
|
10
|
+
assert.deepEqual(workspaceSiblingDenies('/Users/ada/agent', '/Users/ada'), [])
|
|
11
|
+
assert.deepEqual(workspaceSiblingDenies('/', '/Users/ada'), [])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('sibling confinement denies the tenant farm, not the bound workspace', () => {
|
|
15
|
+
assert.deepEqual(workspaceSiblingDenies('/data/tenants/juan', '/Users/ada'), [
|
|
16
|
+
{ deny: '/data/tenants', allow: '/data/tenants/juan' },
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('macos profile blocks sibling reads and writes', async (t) => {
|
|
21
|
+
if (process.platform !== 'darwin') { t.skip(); return }
|
|
22
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-confine-'))
|
|
23
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
24
|
+
const mine = path.join(root, 'juan')
|
|
25
|
+
const other = path.join(root, 'other')
|
|
26
|
+
const shared = path.join(root, 'shared')
|
|
27
|
+
const additional = path.join(root, 'additional')
|
|
28
|
+
await mkdir(mine); await mkdir(other); await mkdir(shared); await mkdir(additional)
|
|
29
|
+
await writeFile(path.join(mine, 'own.txt'), 'own')
|
|
30
|
+
await writeFile(path.join(other, 'secret.txt'), 'secret')
|
|
31
|
+
await writeFile(path.join(shared, 'shared.txt'), 'shared')
|
|
32
|
+
await writeFile(path.join(additional, 'additional.txt'), 'additional')
|
|
33
|
+
const profile = path.join(root, 'workspace.sb')
|
|
34
|
+
await writeFile(profile, macosWorkspaceProfile(workspaceSiblingDenies(mine, '/Users/ada'), [shared, additional]))
|
|
35
|
+
const run = (file: string) => new Promise<{ code: number | null; stderr: string }>(resolve => {
|
|
36
|
+
const child = spawn('sandbox-exec', ['-f', profile, '/bin/cat', file], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
37
|
+
let stderr = ''
|
|
38
|
+
child.stderr.on('data', chunk => { stderr += chunk })
|
|
39
|
+
child.on('close', code => resolve({ code, stderr }))
|
|
40
|
+
})
|
|
41
|
+
assert.equal((await run(path.join(mine, 'own.txt'))).code, 0)
|
|
42
|
+
assert.equal((await run(path.join(shared, 'shared.txt'))).code, 0)
|
|
43
|
+
assert.equal((await run(path.join(additional, 'additional.txt'))).code, 0)
|
|
44
|
+
const blocked = await run(path.join(other, 'secret.txt'))
|
|
45
|
+
assert.notEqual(blocked.code, 0)
|
|
46
|
+
assert.match(blocked.stderr, /not permitted|Operation not permitted/i)
|
|
47
|
+
})
|
package/test/workspace.test.ts
CHANGED
|
@@ -38,14 +38,18 @@ test('fresh mind is private; repeat initialization preserves customization and o
|
|
|
38
38
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-mind-'))
|
|
39
39
|
const workspace = path.join(root, 'agent')
|
|
40
40
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const purpose = path.join(root, 'purpose.md')
|
|
42
|
+
await writeFile(purpose, 'Research partner\n')
|
|
43
|
+
assert.deepEqual(await initializeWorkspace(workspace, purpose), ['AGENTS.md'])
|
|
44
|
+
assert.equal((await stat(path.join(workspace, 'AGENTS.md'))).mode & 0o777, 0o600)
|
|
45
|
+
assert.ok(!(await readdir(workspace)).includes('SOUL.md'))
|
|
46
|
+
assert.ok(!(await readdir(workspace)).includes('USER.md'))
|
|
43
47
|
assert.ok(!(await readdir(workspace)).includes('MEMORY.md'))
|
|
44
|
-
await writeFile(path.join(workspace, '
|
|
48
|
+
await writeFile(path.join(workspace, 'AGENTS.md'), 'A customized research partner')
|
|
45
49
|
await writeFile(path.join(workspace, 'MEMORY.md'), 'Existing knowledge')
|
|
46
50
|
await writeFile(path.join(workspace, 'AGENT.md'), 'Legacy custom guidance')
|
|
47
51
|
assert.deepEqual(await initializeWorkspace(workspace), [])
|
|
48
|
-
assert.
|
|
52
|
+
assert.match(await readFile(path.join(workspace, 'AGENTS.md'), 'utf8'), /A customized research partner/)
|
|
49
53
|
assert.equal(await readFile(path.join(workspace, 'MEMORY.md'), 'utf8'), 'Existing knowledge')
|
|
50
54
|
assert.equal(await readFile(path.join(workspace, 'AGENT.md'), 'utf8'), 'Legacy custom guidance')
|
|
51
55
|
assert.ok(!(await readdir(workspace)).some(name => name.endsWith('.tmp')))
|
|
@@ -53,27 +57,36 @@ test('fresh mind is private; repeat initialization preserves customization and o
|
|
|
53
57
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
54
58
|
})
|
|
55
59
|
|
|
60
|
+
test('fresh workspace refuses to invent a generic purpose', async () => {
|
|
61
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-purpose-required-'))
|
|
62
|
+
try {
|
|
63
|
+
await assert.rejects(initializeWorkspace(path.join(root, 'mind'), undefined), /purpose file is required/)
|
|
64
|
+
} finally { await rm(root, {recursive:true, force:true}) }
|
|
65
|
+
})
|
|
66
|
+
|
|
56
67
|
test('seed refuses a symlink without overwriting its target', async () => {
|
|
57
68
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-mind-'))
|
|
58
69
|
try {
|
|
59
70
|
const target = path.join(root, 'outside.md')
|
|
60
71
|
await writeFile(target, 'untouched')
|
|
61
|
-
await symlink(target, path.join(root, '
|
|
72
|
+
await symlink(target, path.join(root, 'AGENTS.md'))
|
|
62
73
|
await assert.rejects(initializeWorkspace(root), /regular file/)
|
|
63
74
|
assert.equal(await readFile(target, 'utf8'), 'untouched')
|
|
64
75
|
} finally { await rm(root, { recursive: true, force: true }) }
|
|
65
76
|
})
|
|
66
77
|
|
|
67
|
-
test('new agents seed
|
|
78
|
+
test('new agents seed a purpose-scoped AGENTS.md once and preserve subsequent edits', async () => {
|
|
68
79
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-purpose-'))
|
|
69
80
|
try {
|
|
70
81
|
const purpose = path.join(root, 'purpose.md')
|
|
71
82
|
const workspace = path.join(root, 'mind')
|
|
72
|
-
await writeFile(purpose, '
|
|
83
|
+
await writeFile(purpose, 'You are the shopping assistant for this family.\n\n### Responsibilities\n\nOrganize confirmed household shopping requests.\n')
|
|
73
84
|
await initializeWorkspace(workspace, purpose)
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
const agents = await readFile(path.join(workspace, 'AGENTS.md'), 'utf8')
|
|
86
|
+
assert.match(agents, /## Purpose\n\nYou are the shopping assistant for this family\./)
|
|
87
|
+
assert.match(agents, /### Responsibilities\n\nOrganize confirmed household shopping requests\./)
|
|
88
|
+
await writeFile(path.join(workspace, 'AGENTS.md'), 'My evolving purpose\n')
|
|
76
89
|
await initializeWorkspace(workspace, purpose)
|
|
77
|
-
assert.
|
|
90
|
+
assert.match(await readFile(path.join(workspace, 'AGENTS.md'), 'utf8'), /My evolving purpose/)
|
|
78
91
|
} finally { await rm(root, {recursive:true, force:true}) }
|
|
79
92
|
})
|
package/templates/agent/SOUL.md
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# Identity and purpose
|
|
2
|
-
|
|
3
|
-
You are one person-shaped assistant serving one owner. Start as a practical
|
|
4
|
-
personal assistant until the owner supplies a more specific purpose.
|
|
5
|
-
|
|
6
|
-
Be concise, resourceful, and candid. Take useful reversible steps within the
|
|
7
|
-
owner's request. Ask only when missing information changes what you should do.
|
|
8
|
-
Verify real outcomes before claiming success. Get approval for purchases,
|
|
9
|
-
spending, destructive actions, and other actions requiring owner permission.
|
|
10
|
-
|
|
11
|
-
Adapt your purpose and working style from the owner's confirmed needs.
|
package/templates/agent/USER.md
DELETED