@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.
Files changed (70) hide show
  1. package/.env.example +4 -0
  2. package/AGENTS.md +46 -31
  3. package/CHANGELOG.md +33 -0
  4. package/CONTRIBUTING.md +29 -26
  5. package/Dockerfile +4 -0
  6. package/README.md +26 -18
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/compose.yaml +2 -1
  9. package/docker/smoke.mjs +6 -3
  10. package/docs/application-channel.md +6 -0
  11. package/docs/architecture/ai-selection.md +3 -1
  12. package/docs/docker-runtime.md +27 -10
  13. package/docs/host-service.md +17 -6
  14. package/docs/plugin-contributions.md +4 -1
  15. package/docs/plugins.md +15 -0
  16. package/docs/repair.md +1 -1
  17. package/docs/setup.md +88 -14
  18. package/docs/standalone-cli.md +6 -5
  19. package/docs/upgrades.md +6 -4
  20. package/package.json +1 -1
  21. package/scripts/smoke-scheduler.ts +2 -1
  22. package/src/agent-install.ts +20 -6
  23. package/src/ai-cli.ts +9 -7
  24. package/src/ai.ts +7 -7
  25. package/src/application-channel.ts +10 -10
  26. package/src/codex-session.ts +5 -3
  27. package/src/config.ts +6 -1
  28. package/src/control-state.ts +12 -1
  29. package/src/executor.ts +91 -9
  30. package/src/host-executor.ts +25 -6
  31. package/src/index.ts +1 -0
  32. package/src/install-config.ts +2 -2
  33. package/src/install-tools.mjs +3 -1
  34. package/src/isolation.ts +25 -0
  35. package/src/menu.ts +9 -6
  36. package/src/plugins/manager.mjs +49 -6
  37. package/src/reply-context.ts +0 -1
  38. package/src/setup.ts +11 -7
  39. package/src/task-executor.ts +4 -1
  40. package/src/updates/control.mjs +11 -1
  41. package/src/updates/runtime.mjs +4 -2
  42. package/src/updates/supervisor.mjs +17 -3
  43. package/src/workspace-confine.ts +38 -0
  44. package/src/workspace.ts +23 -13
  45. package/templates/agent/AGENTS.md +4 -14
  46. package/templates/agent-guidance.md +13 -110
  47. package/templates/agent-purpose.md +1 -0
  48. package/test/agent-guidance.test.ts +8 -12
  49. package/test/agent-install.test.ts +34 -0
  50. package/test/ai-cli.test.ts +18 -0
  51. package/test/ai.test.ts +48 -0
  52. package/test/application-channel.test.ts +5 -2
  53. package/test/application-controls.test.ts +56 -3
  54. package/test/application-only.test.ts +2 -0
  55. package/test/codex-context.test.ts +61 -1
  56. package/test/codex-session.test.ts +6 -1
  57. package/test/config.test.ts +17 -2
  58. package/test/engine-handoff.test.ts +5 -2
  59. package/test/executor.test.ts +38 -1
  60. package/test/failure.test.ts +6 -0
  61. package/test/host-executor.test.ts +6 -0
  62. package/test/install-config.test.ts +17 -6
  63. package/test/install-tools.test.mjs +6 -0
  64. package/test/plugin-manager.test.mjs +24 -3
  65. package/test/scheduler.test.ts +2 -2
  66. package/test/updates.test.mjs +34 -2
  67. package/test/workspace-confine.test.ts +47 -0
  68. package/test/workspace.test.ts +23 -10
  69. package/templates/agent/SOUL.md +0 -11
  70. 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 result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',additionalWorkspaces:['/agent/mind/work'],prompt,
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)
@@ -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
- for (const override of [{EZ_EXECUTOR_TRANSPORT:'host'}, {EZ_EXECUTOR_TRANSPORT:''}, {EZ_CODEX_SANDBOX:'danger-full-access'}])
69
- assert.throws(() => loadConfig({...env,...override}), /sandbox|SANDBOX/)
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')assert.equal(captured.args.at(-1),'-')
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))
@@ -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'])
@@ -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 soul = path.join(result.workspace, 'SOUL.md')
28
- await writeFile(soul, 'Customized purpose')
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.equal(await readFile(soul, 'utf8'), 'Customized purpose')
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.equal(JSON.parse(result.stdout).created.length, 3)
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.call('sample','read');
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'};
@@ -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,'SOUL.md'),'Owner context')
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,'SOUL.md'),'utf8'),{code:'ENOENT'})
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)
@@ -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
+ })
@@ -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
- assert.equal((await initializeWorkspace(workspace)).length, 3)
42
- assert.equal((await stat(path.join(workspace, 'SOUL.md'))).mode & 0o777, 0o600)
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, 'SOUL.md'), 'A customized research partner')
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.equal(await readFile(path.join(workspace, 'SOUL.md'), 'utf8'), 'A customized research partner')
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, 'SOUL.md'))
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 their own purpose once and preserve subsequent mind edits', async () => {
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, 'Family shopping assistant\n')
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
- assert.equal(await readFile(path.join(workspace, 'SOUL.md'), 'utf8'), 'Family shopping assistant\n')
75
- await writeFile(path.join(workspace, 'SOUL.md'), 'My evolving purpose\n')
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.equal(await readFile(path.join(workspace, 'SOUL.md'), 'utf8'), 'My evolving purpose\n')
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
  })
@@ -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.
@@ -1,5 +0,0 @@
1
- # Owner context
2
-
3
- No personal details have been provided yet. Record only relevant information
4
- the owner supplies or confirms: preferences, purpose, and working context.
5
- Keep detailed project or organization knowledge with its work in work/.