@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.
- package/.env.example +4 -0
- package/CHANGELOG.md +25 -0
- 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/plugins.md +15 -0
- package/docs/repair.md +1 -1
- package/docs/setup.md +88 -14
- package/docs/standalone-cli.md +6 -5
- 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/runtime.mjs +1 -1
- 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 +10 -1
- 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
|
@@ -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
|
@@ -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
|
+
})
|
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