@jc_stack/ez-agents 0.1.0-beta.33 → 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 (65) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +25 -0
  3. package/Dockerfile +4 -0
  4. package/README.md +26 -18
  5. package/THIRD_PARTY_NOTICES.md +4 -4
  6. package/compose.yaml +2 -1
  7. package/docker/smoke.mjs +6 -3
  8. package/docs/application-channel.md +6 -0
  9. package/docs/architecture/ai-selection.md +3 -1
  10. package/docs/docker-runtime.md +27 -10
  11. package/docs/host-service.md +17 -6
  12. package/docs/plugins.md +15 -0
  13. package/docs/repair.md +1 -1
  14. package/docs/setup.md +88 -14
  15. package/docs/standalone-cli.md +6 -5
  16. package/package.json +1 -1
  17. package/scripts/smoke-scheduler.ts +2 -1
  18. package/src/agent-install.ts +20 -6
  19. package/src/ai-cli.ts +9 -7
  20. package/src/ai.ts +7 -7
  21. package/src/application-channel.ts +10 -10
  22. package/src/codex-session.ts +5 -3
  23. package/src/config.ts +6 -1
  24. package/src/control-state.ts +12 -1
  25. package/src/executor.ts +91 -9
  26. package/src/host-executor.ts +25 -6
  27. package/src/index.ts +1 -0
  28. package/src/install-config.ts +2 -2
  29. package/src/install-tools.mjs +3 -1
  30. package/src/isolation.ts +25 -0
  31. package/src/menu.ts +9 -6
  32. package/src/plugins/manager.mjs +49 -6
  33. package/src/reply-context.ts +0 -1
  34. package/src/setup.ts +11 -7
  35. package/src/task-executor.ts +4 -1
  36. package/src/updates/runtime.mjs +1 -1
  37. package/src/updates/supervisor.mjs +17 -3
  38. package/src/workspace-confine.ts +38 -0
  39. package/src/workspace.ts +23 -13
  40. package/templates/agent/AGENTS.md +4 -14
  41. package/templates/agent-guidance.md +13 -110
  42. package/templates/agent-purpose.md +1 -0
  43. package/test/agent-guidance.test.ts +8 -12
  44. package/test/agent-install.test.ts +34 -0
  45. package/test/ai-cli.test.ts +18 -0
  46. package/test/ai.test.ts +48 -0
  47. package/test/application-channel.test.ts +5 -2
  48. package/test/application-controls.test.ts +56 -3
  49. package/test/application-only.test.ts +2 -0
  50. package/test/codex-context.test.ts +61 -1
  51. package/test/codex-session.test.ts +6 -1
  52. package/test/config.test.ts +17 -2
  53. package/test/engine-handoff.test.ts +5 -2
  54. package/test/executor.test.ts +38 -1
  55. package/test/failure.test.ts +6 -0
  56. package/test/host-executor.test.ts +6 -0
  57. package/test/install-config.test.ts +17 -6
  58. package/test/install-tools.test.mjs +6 -0
  59. package/test/plugin-manager.test.mjs +24 -3
  60. package/test/scheduler.test.ts +2 -2
  61. package/test/updates.test.mjs +10 -1
  62. package/test/workspace-confine.test.ts +47 -0
  63. package/test/workspace.test.ts +23 -10
  64. package/templates/agent/SOUL.md +0 -11
  65. package/templates/agent/USER.md +0 -5
@@ -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)
@@ -12,7 +12,16 @@ import { perform, environment, packageManager } from '../src/updates/runtime.mjs
12
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=>{
@@ -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/.