@jc_stack/ez-agents 0.1.0-beta.42 → 0.1.0-beta.44

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 (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/Dockerfile +3 -1
  3. package/bin/ez +3 -1
  4. package/compose.yaml +2 -0
  5. package/docker/entrypoint.sh +4 -1
  6. package/docker/smoke.mjs +92 -1
  7. package/docs/application-channel.md +6 -2
  8. package/docs/architecture/ai-selection.md +7 -4
  9. package/docs/architecture/authority-boundaries.md +15 -4
  10. package/docs/docker-runtime.md +25 -2
  11. package/docs/local-qa.md +5 -0
  12. package/docs/plugin-guide.md +10 -2
  13. package/docs/plugins.md +22 -2
  14. package/docs/selective-monitoring.md +26 -12
  15. package/docs/upgrades.md +5 -1
  16. package/package.json +1 -1
  17. package/src/agent-install.ts +5 -3
  18. package/src/ai-cli.ts +13 -4
  19. package/src/ai.ts +7 -3
  20. package/src/audio.ts +4 -2
  21. package/src/client-defaults.ts +7 -4
  22. package/src/control-state.ts +12 -8
  23. package/src/conversation-menu.ts +5 -2
  24. package/src/executor.ts +5 -5
  25. package/src/menu.ts +9 -7
  26. package/src/plugin-broker.mjs +2 -0
  27. package/src/plugins/manager.mjs +43 -9
  28. package/src/plugins/native-tasks.d.mts +1 -0
  29. package/src/plugins/native-tasks.mjs +13 -0
  30. package/src/task-cli.ts +1 -1
  31. package/src/task-executor.ts +7 -4
  32. package/src/tasks.ts +54 -16
  33. package/src/updates/control.mjs +5 -3
  34. package/src/updates/runtime.mjs +27 -7
  35. package/test/agent-install.test.ts +6 -0
  36. package/test/ai-cli.test.ts +6 -1
  37. package/test/ai.test.ts +69 -14
  38. package/test/application-controls.test.ts +2 -1
  39. package/test/conversation-menu.test.ts +2 -0
  40. package/test/conversations.test.ts +26 -0
  41. package/test/plugin-broker.test.mjs +13 -1
  42. package/test/plugin-manager.test.mjs +58 -1
  43. package/test/scheduler-host.test.ts +1 -0
  44. package/test/task-native.test.ts +2 -4
  45. package/test/tasks.test.ts +107 -3
  46. package/test/updates.test.mjs +55 -3
  47. package/test/upgrade-pause.test.ts +10 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.44
4
+
5
+ - Let isolated native agents read declared installed plugin skills through the
6
+ existing authorized broker. Discovery supplies literal skill-read commands,
7
+ so tools such as EzStudy work without copying package instructions into the
8
+ agent workspace or rebuilding native context.
9
+ - Keep skill reads bound to the installed revision and active authorized run;
10
+ reject undeclared paths, traversal, symlinks and oversized files.
11
+
12
+ ## 0.1.0-beta.43
13
+
14
+ - Keep model and reasoning settings with each named conversation and preserve
15
+ native history across supported model changes. Save the selected AI as the
16
+ default for new conversations.
17
+ - Keep private application-channel AI choices separate from owner preset slots.
18
+ - Honor current-owner-authorized messaging tasks without a second approval;
19
+ retain bounded task authority across restart without replaying uncertain sends.
20
+ - Expose installed native controls through Ez help and preserve operator CLI pins,
21
+ isolated credentials, plugin volumes and private broker ownership on Linux.
22
+ - Reuse the exact healthy pinned relay during failed-update recovery.
23
+ - Legacy deployments need the reviewed installer migration in
24
+ docs/application-channel.md for the trusted broker capability change; ordinary
25
+ updater compatibility guards remain in force.
26
+
3
27
  ## 0.1.0-beta.42
4
28
 
5
29
  - Keep recurring schedule edits at their next pending or future occurrence, so
package/Dockerfile CHANGED
@@ -1,6 +1,8 @@
1
+ FROM docker/compose-bin:v5.5.1@sha256:14162382692aae43977f79effa64a499d61de19d4645461881daaf88773f7e74 AS compose-bin
1
2
  FROM node:22.22.0-bookworm-slim@sha256:dd9d21971ec4395903fa6143c2b9267d048ae01ca6d3ea96f16cb30df6187d94 AS dependencies
2
3
  WORKDIR /app
3
- RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git util-linux ffmpeg docker.io docker-compose && rm -rf /var/lib/apt/lists/*
4
+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git util-linux ffmpeg docker.io && rm -rf /var/lib/apt/lists/*
5
+ COPY --from=compose-bin /docker-compose /usr/local/bin/docker-compose
4
6
  RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
5
7
  COPY package.json ./
6
8
  COPY docker/pnpm-lock.yaml ./pnpm-lock.yaml
package/bin/ez CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createConnection } from 'node:net';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { installedNativeHelp } from '../src/plugins/native-tasks.mjs';
4
6
 
5
7
  const MAX_STDIN_BYTES = 1024 * 1024;
6
8
  const RUN_ID = /^[A-Za-z0-9_-]{1,160}$/;
@@ -86,7 +88,7 @@ const manager = async args => {
86
88
  const main = async () => {
87
89
  const args = process.argv.slice(2);
88
90
  if (!args.length || args[0] === '--help' || args[0] === '-h') {
89
- process.stdout.write('Use `ez tools list` to discover installed plugins, then `ez <alias> ...` for a declared command.\n');
91
+ process.stdout.write(JSON.stringify({commands:['tools list [--details]','tools skill <plugin> <declared-path>','<registered CLI> ...'],native:await installedNativeHelp(fileURLToPath(new URL('.',import.meta.url)))})+'\n');
90
92
  return;
91
93
  }
92
94
  const stdin = ['plugins', 'tools', 'updates', 'status'].includes(args[0]) ? '' : await readStdin();
package/compose.yaml CHANGED
@@ -58,6 +58,8 @@ services:
58
58
  stop_grace_period: 10s
59
59
  user: "0:0"
60
60
  cap_drop: [ALL]
61
+ # The trusted broker maintains private files owned by the installer UID.
62
+ cap_add: [DAC_OVERRIDE, CHOWN]
61
63
  security_opt: [no-new-privileges:true]
62
64
  network_mode: none
63
65
  environment:
@@ -21,7 +21,10 @@ if [ "$(id -u)" = 0 ]; then
21
21
  chmod 700 /run/secrets
22
22
  chown "$runtime_uid:$runtime_gid" /state/control /state/home /workspace
23
23
  if [ -f /run/secrets/relay_env ]; then exec 3</run/secrets/relay_env; else exec 3</dev/null; fi
24
- exec setpriv --ruid="$relay_uid" --euid="$runtime_uid" --regid="$runtime_gid" --clear-groups --bounding-set=-all --no-new-privs /app/docker/entrypoint.sh "$@"
24
+ # Some Linux security modules normalize differing real/effective UIDs during
25
+ # an exec under no_new_privs. Preserve the relay's split identity here; the
26
+ # executor applies no_new_privs after normalizing its own IDs.
27
+ exec setpriv --ruid="$relay_uid" --euid="$runtime_uid" --regid="$runtime_gid" --clear-groups --bounding-set=-all /app/docker/entrypoint.sh "$@"
25
28
  fi
26
29
  # Reserve the private environment descriptor before Node can use it for libuv.
27
30
  # Root startup already opened it; preserve that inherited secret descriptor.
package/docker/smoke.mjs CHANGED
@@ -8,6 +8,10 @@ const dir = mkdtempSync(join(tmpdir(), 'ez-docker-qa-'));
8
8
  const holder = `ez-main-lock-qa-${process.pid}`;
9
9
  const volume = `${holder}-control`;
10
10
  const application = `${holder}-application`;
11
+ const privateVolume = `${holder}-private`;
12
+ const boundProject = `${holder}-bound`;
13
+ const boundVolume = `${boundProject}_data`;
14
+ const broker = `${holder}-broker`;
11
15
  const marker = 'qa-private-secret-never-in-executor';
12
16
  writeFileSync(join(dir, 'relay.env'), `TELEGRAM_BOT_TOKEN=${marker}\n`, { mode: 0o600 });
13
17
  writeFileSync(join(dir, 'purpose.md'), 'Verify the packaged Ez runtime.\n', { mode: 0o644 });
@@ -48,6 +52,7 @@ try {
48
52
  assert.throws(() => fs.openSync('/proc/'+process.ppid+'/mem', 'r'), {code:'EACCES'});
49
53
  assert.throws(() => fs.readFileSync('/run/secrets/relay_env'), {code:'EACCES'});
50
54
  assert.equal(fs.existsSync('/var/run/docker.sock'), false);
55
+ assert.match(fs.readFileSync('/proc/self/status','utf8'), /^NoNewPrivs:\\s+1$/m);
51
56
  for (const pid of ['1', String(process.ppid)]) {
52
57
  try { assert.ok(!fs.readFileSync('/proc/'+pid+'/environ','utf8').includes('${marker}')); }
53
58
  catch (e) { if (e.code !== 'EACCES') throw e; }
@@ -70,6 +75,92 @@ try {
70
75
  '--mount',`type=bind,src=${join(dir,'relay.env')},dst=/run/secrets/relay_env,readonly`,image,'exec','node','-e',customProbe]);
71
76
  assert.equal(custom.status,0,custom.stderr);
72
77
  assert.equal(JSON.parse(custom.stdout).uid,20001);
78
+ // Real Linux ownership, including pre-existing mode-600 control/registry
79
+ // files. Host bind mounts on desktop Docker can mask these failures.
80
+ const seed = run(['run','--rm','-v',`${privateVolume}:/qa`,'--entrypoint','node',image,'-e',`
81
+ const fs=require('fs');
82
+ for(const name of ['home','control','mind']) {
83
+ fs.mkdirSync('/qa/'+name,{recursive:true,mode:0o700});
84
+ fs.chownSync('/qa/'+name,20001,20002);
85
+ }
86
+ const files={
87
+ '/qa/control/control-state.json':{version:1,owner:null,pending:[]},
88
+ '/qa/home/config.json':{schemaVersion:1,workspace:'/qa/mind',hostConfig:'/qa/host-executor.json'},
89
+ '/qa/home/registry.json':{schemaVersion:1,owner:'/qa/home',plugins:{},commands:{}},
90
+ '/qa/host-executor.json':{isolation:'isolated',agents:[{name:'qa',toolsHome:'/qa/home',workspace:'/qa/mind',controlDir:'/qa/control'}]},
91
+ };
92
+ for(const [file,value] of Object.entries(files)) {
93
+ fs.writeFileSync(file,JSON.stringify(value),{mode:0o600});fs.chownSync(file,20001,20002);
94
+ }
95
+ `]);
96
+ assert.equal(seed.status,0,seed.stderr);
97
+ // Compose must reuse an existing operator-owned bind volume without
98
+ // recreating it or rejecting its driver options (legacy Compose v1 did).
99
+ const mountpoint = run(['volume','inspect','--format','{{.Mountpoint}}',privateVolume]).stdout.trim();
100
+ assert.ok(mountpoint.startsWith('/'));
101
+ const bind = run(['volume','create','--driver','local','--opt','type=none','--opt','o=bind','--opt',`device=${mountpoint}`,boundVolume]);
102
+ assert.equal(bind.status,0,bind.stderr);
103
+ const boundConfig = {services:{probe:{image,network_mode:'none',entrypoint:['node','-e',
104
+ `const fs=require('fs'),assert=require('assert/strict');assert.equal(JSON.parse(fs.readFileSync('/state/host-executor.json')).isolation,'isolated');console.log('bound-volume-preserved');`],
105
+ volumes:['data:/state:ro']}},volumes:{data:{}}};
106
+ const reused = spawnSync('docker',['run','--rm','-i','--network','none','-v','/var/run/docker.sock:/var/run/docker.sock',
107
+ '--entrypoint','docker-compose',image,'--project-name',boundProject,'--file','-','run','--rm','--no-deps','probe'],
108
+ {encoding:'utf8',timeout:60000,input:JSON.stringify(boundConfig)});
109
+ assert.equal(reused.status,0,reused.stderr);
110
+ assert.match(reused.stdout,/bound-volume-preserved/);
111
+ assert.equal(run(['volume','inspect','--format','{{index .Options "device"}}',boundVolume]).stdout.trim(),mountpoint);
112
+ const privateState = run(['run','--rm','-v',`${privateVolume}:/qa`,
113
+ '-e','EZ_CONTROL_DIR=/qa/control','-e','EZ_RUNTIME_UID=20001','-e','EZ_RUNTIME_GID=20002','-e','EZ_RELAY_UID=20003',
114
+ image,'owner','status']);
115
+ assert.equal(privateState.status,0,privateState.stderr);
116
+ assert.equal(JSON.parse(privateState.stdout).owner,null);
117
+ const brokerStarted = run(['run','-d','--name',broker,'--user','0:0','--cap-drop','ALL',
118
+ '--cap-add','DAC_OVERRIDE','--cap-add','CHOWN','--security-opt','no-new-privileges','--network','none',
119
+ '-v',`${privateVolume}:/qa`,'--entrypoint','node',image,'--import','/app/node_modules/tsx/dist/loader.mjs',
120
+ '/app/src/plugin-broker.mjs','--home','/qa/home','--workspace','/qa/mind','--control-dir','/qa/control',
121
+ '--socket','/qa/control/plugin-broker.sock','--host-config','/qa/host-executor.json']);
122
+ assert.equal(brokerStarted.status,0,brokerStarted.stderr);
123
+ let brokerReady=false;
124
+ for(let n=0;n<30;n++) {
125
+ const check=run(['exec','--user','20001:20002',broker,'node','-e',`
126
+ const fs=require('fs'),assert=require('assert/strict');
127
+ assert.ok(fs.statSync('/qa/control/plugin-broker.sock').isSocket());
128
+ const file='/qa/control/plugin-broker-plugins.json';
129
+ assert.equal(fs.statSync(file).uid,20001);assert.deepEqual(JSON.parse(fs.readFileSync(file)).plugins,[]);
130
+ `]);
131
+ if(check.status===0){brokerReady=true;break;}
132
+ await new Promise(resolve=>setTimeout(resolve,100));
133
+ }
134
+ assert.ok(brokerReady,run(['logs',broker]).stderr);
135
+ const prepare = run(['exec',broker,'node','--input-type=module','-e',`
136
+ import fs from 'node:fs/promises';
137
+ import {snapshot,install,prepareCommand} from '/app/src/plugins/manager.mjs';
138
+ const home='/qa/home',source='/qa/source';
139
+ await fs.mkdir(source);
140
+ const manifest={schemaVersion:1,id:'sample',version:'0.1.0',description:'Synthetic',commands:{sample:{executable:'client.mjs',args:[]}},skills:['SKILL.md']};
141
+ const deployment={schemaVersion:1,services:{sample:{buildTarget:'runtime',volumes:{},healthcheck:['node','--version']}},commands:{sample:{service:'sample',argv:['node','/app/client.mjs']}}};
142
+ for(const [name,value] of Object.entries({'package.json':JSON.stringify({files:['client.mjs','SKILL.md']}),'ez-plugin.json':JSON.stringify(manifest),'ez-deployment.json':JSON.stringify(deployment),'Dockerfile':'FROM scratch AS runtime','.dockerignore':'','client.mjs':'','SKILL.md':'Synthetic'}))await fs.writeFile(source+'/'+name,value);
143
+ // Only the Docker build effect is stubbed; real installation and private
144
+ // filesystem operations execute with the production broker capabilities.
145
+ await fs.writeFile('/qa/docker','#!/bin/sh\\nexit 0\\n',{mode:0o755});
146
+ process.env.PATH='/qa:'+process.env.PATH;
147
+ delete process.env.EZ_DOCKER_COMPOSE;
148
+ const pkg=await snapshot(source),config=JSON.parse(await fs.readFile(home+'/config.json'));
149
+ await install(home,config,'sample',source,pkg.revision);
150
+ const command=await prepareCommand(home,'sample',[],{invocation:true,environment:{}});
151
+ await command.release();
152
+ `]);
153
+ assert.equal(prepare.status,0,prepare.stderr);
154
+ const steward = run(['exec','--user','20001:20002',broker,'node','-e',`
155
+ const fs=require('fs'),assert=require('assert/strict');
156
+ for(const dir of ['packages','packages/sample','command-invocations']) {
157
+ const stat=fs.statSync('/qa/home/'+dir);
158
+ assert.equal(stat.uid,20001);assert.equal(stat.gid,20002);assert.equal(stat.mode&511,448);
159
+ fs.readdirSync('/qa/home/'+dir);
160
+ }
161
+ fs.readFileSync('/qa/home/packages/sample/compose.json');
162
+ `]);
163
+ assert.equal(steward.status,0,steward.stderr);
73
164
  const failed = run(['run','--rm',image,'exec','node','-e','process.exit(23)']);
74
165
  assert.equal(failed.status, 23, failed.stderr);
75
166
  // The live delivery socket is the single-relay guard (it replaced the kernel
@@ -108,4 +199,4 @@ try {
108
199
  }
109
200
  assert.equal(released,0,'a dead relay must release the delivery socket');
110
201
  console.log('Docker smoke passed: direct non-root application help/start/health/stop, inherited private descriptor, non-root executor, isolated Codex on PATH, private secret isolation, literal argv, exit code, no Docker socket, live delivery-socket guard and crash release.');
111
- } finally { run(['rm','-f',holder,application]); run(['volume','rm',volume]); rmSync(dir, {recursive:true, force:true}); }
202
+ } finally { run(['rm','-f',holder,application,broker]); run(['volume','rm',boundVolume,volume,privateVolume]); rmSync(dir, {recursive:true, force:true}); }
@@ -296,8 +296,9 @@ separate CLI histories. A retry cannot change an admitted turn's AI choice.
296
296
 
297
297
  Register owner-chat channels with `--share-owner` and submit `followOwner:true`.
298
298
  Web, phone and Telegram then use the same selected native conversation and AI.
299
- A later same-client provider or model change starts a new native conversation for the next
300
- admitted turn; already-queued work keeps its original engine.
299
+ Model and reasoning changes on the same client and provider preserve the native
300
+ conversation; client or provider changes start a separate conversation.
301
+ Already-queued work keeps its captured AI choice.
301
302
  This works without Telegram and requires no transcript replay or new runner.
302
303
  Scoped application conversations remain separate when the flag is omitted.
303
304
  Owner identity does not automatically merge histories or grant learner access.
@@ -444,3 +445,6 @@ partial request. Separate owner runtimes must retain separate workspace mounts.
444
445
 
445
446
  This is inbound file support. Application replies currently deliver text only;
446
447
  outgoing file downloads remain unsupported by this HTTP channel.
448
+
449
+ Private scope model selections are stored on that scope’s native-session binding.
450
+ They do not add entries to the owner’s saved AI menu or consume its preset limit.
@@ -7,10 +7,13 @@ No provider SDK, model fallback, history migration, or second execution loop.
7
7
  At intake each journal entry receives an immutable preset and conversation ID.
8
8
  Batches cannot cross that boundary. The run copies that choice and invokes the
9
9
  native adapter with its exact model/effort. A later menu change cannot reroute it.
10
- Old conversation metadata is retained only so accepted work can finish; selecting
11
- a different CLI never restores that CLI's old history. Selecting a different provider
12
- or model on the same client does not resume the previous selection's native thread; the next
13
- admitted turn starts a new native conversation.
10
+ Each named conversation saves its model and reasoning level. Switching back through
11
+ `/chats` restores that choice and the exact native session, including after restart.
12
+ `/ai` shows the current choice and saves changes immediately. Changing model or
13
+ reasoning level on the same client and provider preserves the conversation's name
14
+ and native history; subsequent turns pass the new settings to the native engine.
15
+ Changing client or provider starts a separate native conversation. Old bindings
16
+ remain available so accepted work can finish and owners can reopen their chats.
14
17
 
15
18
  Grok/Claude accept caller-selected native UUIDs. Codex/OpenCode generate IDs, so
16
19
  the adapter reads only their typed JSONL session metadata; stdout never becomes a
@@ -9,13 +9,17 @@ marking it internal does not confer owner authority.
9
9
  ## Messaging v1
10
10
 
11
11
  The owner asks the agent to contact one person for a bounded purpose. The owner
12
- agent prepares a proposal with `ezenciel-agents-task propose`: registered source,
12
+ agent uses `ezenciel-agents-task start`: registered source,
13
13
  exact canonical contact, purpose, explicitly shareable context, and expiry (up to
14
- 72 hours). Telegram displays that exact proposal for approval. The core binds it
14
+ 72 hours). A current authenticated owner turn can start work already authorized
15
+ by that request, without another approval click. The native engine interprets
16
+ the request; core does not infer authority from keywords. `propose` remains for
17
+ scope needing a new owner decision. Scheduled and external runs cannot use either.
18
+ The core binds the grant
15
19
  to the verified owner, current source registration, and connected account. The
16
20
  owner does not edit JSON. The agent uses `list` and `revoke` when asked.
17
21
 
18
- After approval the relay starts a restricted task, including the initial outgoing
22
+ After activation the relay starts a restricted task, including the initial outgoing
19
23
  message. Incoming-only tasks instead wait for new correspondence and never create
20
24
  an opening run. Their task records use version 2 so older task readers fail closed. Matching new correspondence resumes that task in a fresh native session.
21
25
  Other contacts remain blocked unless an owner-approved any-conversation grant is
@@ -25,6 +29,13 @@ settings changes, or access to owner memory. A contact can have one active or
25
29
  pending task at a time. Completed, revoked, expired, replaced-source, and changed-
26
30
  account grants cannot dispatch further messages.
27
31
 
32
+ Activated grants persist in the protected task record with a digest of all
33
+ immutable scope fields. Transient approval requests are not the continuing
34
+ authority store. Owner replacement, scope mutation, expiry and revocation still
35
+ invalidate authorization. The initial dispatch marker also persists: relay
36
+ restart does not replay an uncertain opener. Pending or legacy records without
37
+ a grant need their original live approval and are never auto-authorized.
38
+
28
39
  The worker receives only the approved dossier, its notes, its operation receipts,
29
40
  and rechecked correspondence for its contact. All dossier contents may be shared
30
41
  with that contact. The agent judges how to pursue the purpose; code does not prove
@@ -55,7 +66,7 @@ exact registered `ez` command plus fixed arguments ending in literal
55
66
  `-- {input}`. The restricted broker replaces only `{input}` with the
56
67
  correspondent's string; the model cannot select a command, prepend flags, choose
57
68
  a recipient, access a shell or change the fixed arguments. The full command
58
- vector and disclosure scope appear in the immutable owner approval. Core rechecks
69
+ vector and disclosure scope are bound by the immutable owner grant. Core rechecks
59
70
  the grant immediately before and after invocation and before every reply. The
60
71
  installed alias must declare `channelQuery: true` and explicitly declare that it
61
72
  accepts external input without external sends, record changes, or interactive
@@ -22,6 +22,8 @@ on the same CLI. Each agent gets its own purpose and native conversation IDs.
22
22
  `isolated` sets `EZ_EXECUTOR_TRANSPORT=local` and runs the native CLI in the
23
23
  relay. `host-capable` sets `host` and reuses the installer UID; unlabeled
24
24
  existing host transports stay host-capable so they are not flipped.
25
+ Only isolated deployments configure the plugin-broker socket and Compose profile;
26
+ host-capable deployments use their bound host transport and registry.
25
27
 
26
28
  On Docker Desktop/OrbStack the control volume's Unix socket is not connectable
27
29
  from the macOS host, so a host-capable agent also publishes an authenticated
@@ -84,6 +86,22 @@ only service allowed to launch plugin containers. The relay never receives the
84
86
  Docker socket or a tools registry mount. Host-capable agents keep the existing
85
87
  host executor path instead.
86
88
 
89
+ The trusted broker has only `DAC_OVERRIDE` and `CHOWN` in addition to its
90
+ Docker socket, so it can read private installer-owned registry files and preserve
91
+ their ownership. The relay drops all capabilities before loading its secrets.
92
+ It retains distinct real/effective UIDs; `no_new_privs` is applied to the native
93
+ executor after normalizing those IDs. Applying it earlier can cause Linux
94
+ security modules to discard the relay's effective UID during exec.
95
+ Private native credentials and skills are checked using that effective UID;
96
+ `access(2)` checks the distinct real UID and can incorrectly hide a valid binding.
97
+ Runtime updates preserve explicit relay build arguments from the operator's
98
+ Compose deployment, including native CLI version pins.
99
+ New private broker directories and update archives inherit the installer's
100
+ ownership. Existing installations need the reviewed deployment migration in
101
+ [application-channel.md](application-channel.md#reviewed-deployment-migration)
102
+ for this Compose capability change; ordinary updates retain their compatibility
103
+ guard. Preserve the existing runtime UID/GID and private state during migration.
104
+
87
105
  ## Operate and verify
88
106
 
89
107
  Root-started containers default to executor UID/GID `1000:1000` and relay real
@@ -164,6 +182,9 @@ an already connected account. Monitoring and sends require their own authority.
164
182
 
165
183
  ### Isolated plugin commands
166
184
 
185
+ The image pins modern Docker Compose, including its standalone executable, so
186
+ existing operator-created volumes retain their data and driver bindings.
187
+
167
188
  The relay-side `ez` client sends only declared aliases, literal argument arrays,
168
189
  bounded stdin, and the active run ID to the broker. The broker re-reads owner
169
190
  authority and the registry before every call, pins the discovered revision, and
@@ -176,8 +197,10 @@ Source-based `plugins inspect`, `catalog-add`, and `install` management calls ar
176
197
  accepted only for an existing path inside that agent's mounted workspace, with a
177
198
  literal SHA-256 revision where the command requires one. The same management
178
199
  surface permits data-preserving `plugins uninstall` and local candidate
179
- `updates prepare/apply/recover`; candidate archives must also be inside the
180
- workspace. Public update discovery stays with the host supervisor because the
200
+ plugin `updates prepare/apply/recover`; plugin candidate archives must also be inside the
201
+ workspace. Local main-runtime archives require operator preparation outside the
202
+ agent, because the runtime contains the privileged broker. The agent can apply
203
+ an operator-prepared candidate. Public update discovery stays with the host supervisor because the
181
204
  broker has no network. The broker never turns an arbitrary host path into a
182
205
  plugin mount.
183
206
 
package/docs/local-qa.md CHANGED
@@ -6,6 +6,11 @@ an immutable candidate in the PA's local tools directory. The agent reads its
6
6
  manifest and uses `ez updates prepare main --file ...` and `apply` on the owner's
7
7
  request. Automatic public update policy stays unchanged.
8
8
 
9
+ For isolated agents, the operator prepares main-runtime archives through the
10
+ host-bound launcher outside the agent. The isolated agent may apply that prepared
11
+ job; its broker rejects agent-supplied main-runtime archives. Plugin candidates
12
+ retain the workspace-scoped preparation flow below.
13
+
9
14
  Commit, push and open a PR first — then stage only from that PR's exact commit. The staging script refuses dirty checkouts, so uncommitted work can never become a candidate. If QA finds issues, fix forward in the same PR (or a new PR) and stage a new versioned candidate; never leave a QA-only copy lingering outside the PR:
10
15
 
11
16
  ```sh
@@ -57,7 +57,16 @@ At minimum, provide:
57
57
  machine-readable commands, stable exit codes, and readback for writes.
58
58
 
59
59
  The manager snapshots the package according to `package.json.files` plus the
60
- required manifests and Docker inputs. Keep one canonical checkout; do not keep
60
+ required manifests and Docker inputs. `ez tools list --details` includes
61
+ `skillReads`: literal command argument arrays for reading each installed skill.
62
+ Use `ez tools skill <plugin> <declared-path>` to return its text and pinned
63
+ revision. The isolated executor cannot open private host package paths directly;
64
+ the authorized broker reads only manifest-declared regular skill files, without
65
+ copying them into the agent workspace. Skill reads reject symlinks and files over
66
+ 64 KiB. The existing generic discovery footer remains accurate; no plugin-specific
67
+ usage snippet or per-turn instruction is needed for this read contract.
68
+
69
+ Keep one canonical checkout; do not keep
61
70
  permanent packaged copies or a second registry. See the full
62
71
  [plugin contribution requirements](plugin-contributions.md) and the
63
72
  [plugin manager contract](plugins.md#deployment-descriptors).
@@ -199,4 +208,3 @@ hash.
199
208
 
200
209
  For contribution, security, and removal details, follow the links above and the
201
210
  plugin's own `CONTRIBUTING.md`, `SECURITY.md`, and README.
202
-
package/docs/plugins.md CHANGED
@@ -29,7 +29,14 @@ initial main onboarding; no sibling repository, broker or provider account is
29
29
  needed. Finish owner pairing and verify an actual Telegram agent reply first.
30
30
  Init creates a private registry and `tools/bin/ez`, adds a
31
31
  managed AGENTS.md registry locator, and binds the matching host executor to that bin
32
- folder. Native binaries are linked through; an existing `ez` collision fails.
32
+ folder. Native binaries are linked through; the package's own `bin/ez` is replaced
33
+ by that bound launcher, while an unrelated existing `ez` collision fails.
34
+ Both host-bound and isolated `ez --help` list installed native scheduling and delivery helpers
35
+ under `native`, with literal `argv` entries for their help. These are existing
36
+ native executables, not plugin aliases; discovery does not grant authority.
37
+ Standalone registries omit helpers they do not have installed. The managed
38
+ workspace locator remains unchanged: core help discovers native controls and
39
+ `tools list --details` discovers registered plugins.
33
40
  Run before starting the host executor. For an already running installation,
34
41
  place a symlink to the returned launcher in that agent's existing private bin
35
42
  directory and set that binding's `toolsHome` to the absolute registry directory.
@@ -52,6 +59,10 @@ For an isolated Codex agent, this binding is consumed by the per-agent
52
59
  `plugin-broker` service, not by a host executor. Start the deployment only after
53
60
  initialization with `COMPOSE_PROFILES=isolated`; Compose then starts the relay
54
61
  and broker together. The relay-side `ez` command has no tools-home or Docker
62
+ access. Operator-owned private network grants also work through the broker:
63
+ each grant binds an exact reviewed plugin revision and declared service, and
64
+ never attaches the relay or broker itself to that network. The host binding
65
+ must remain outside the agent's writable mounts. The client has no Docker
55
66
  socket mount. It sends a declared alias, literal arguments, bounded stdin and
56
67
  the active run ID over the private control-directory Unix socket. The broker
57
68
  revalidates the exact registry/host/workspace ownership, pinned revision and
@@ -68,7 +79,10 @@ across agents unless an operator adds an explicit reviewed binding.
68
79
  The same broker accepts source-based `plugins inspect`, `catalog-add`, and
69
80
  `install` only when the source is an existing path under the bound workspace and
70
81
  the revision is literal and pinned. It also exposes data-preserving
71
- `plugins uninstall` and the local-file `updates prepare/apply/recover` lifecycle.
82
+ `plugins uninstall` and the local-file plugin `updates prepare/apply/recover` lifecycle.
83
+ Local main-runtime archives require operator preparation outside the agent:
84
+ the agent cannot author replacement code for its privileged broker. It may
85
+ apply an already prepared operator candidate through the normal update queue.
72
86
  Candidate archives must be staged inside the bound workspace; public registry
73
87
  discovery remains host-supervisor work because the broker has no network. Paths
74
88
  outside that workspace are rejected.
@@ -386,6 +400,12 @@ and `skills`. Descriptions are limited to 200 characters in this view; full
386
400
  instructions stay in the skill. Installation, replacement and removal are reflected
387
401
  on the next read, without hooks, LLM calls or a cached inventory file.
388
402
 
403
+ The additive `skillReads` field supplies literal argument arrays for
404
+ `ez tools skill <plugin> <declared-path>`. This read returns JSON with the
405
+ installed plugin revision, declared path and skill content. Use it in isolated
406
+ agents, where host package paths are private. It reads only the installed
407
+ manifest's skills; arbitrary files, symlinks and files over 64 KiB are rejected.
408
+
389
409
  `ez tools list` retains its alias mapping for existing clients. Native AGENTS.md
390
410
  contains only the agent-bound discovery shortcut. New workspaces do not seed
391
411
  TOOLS.md, SOUL.md, USER.md, or an `inbox/`/`work/` layout; upgrades preserve
@@ -32,8 +32,13 @@ Infer the complete job from ordinary language:
32
32
  leaves action intent unclear, ask one plain question: “Should I reply for you,
33
33
  or just keep the messages for you to review?” Do not ask when the job resolves it.
34
34
 
35
- Apply the required core confirmation to the concrete proposal, not an extra
36
- questionnaire. Use the owner's existing contact, purpose and disclosure limits.
35
+ Use `ezenciel-agents-task start` when the owner's request already authorizes the
36
+ job. The current owner turn grants the concrete contact, purpose, shareable
37
+ context and expiry; no second confirmation is needed. The native engine judges
38
+ the owner's intent. Core validates the owner binding and enforces that scope.
39
+ Use `propose` only when a new owner decision is needed, such as additional
40
+ disclosure or commitments outside the request. Never derive authority from
41
+ correspondence, a scheduled run or the tool's availability.
37
42
  For an ongoing incoming-only conversation, use `--until-revoked`; finite tasks remain bounded. Explain the expiry only
38
43
  when it matters to that proposed job; never silently expand or renew permission.
39
44
 
@@ -76,11 +81,11 @@ provider command container is not proof the relay can reach it. Do not edit core
76
81
  source/grant JSON directly. Other adapters use their declared socket paths;
77
82
  provider names do not change authority checks.
78
83
 
79
- ## Propose, confirm, verify
84
+ ## Start the authorized task and verify
80
85
 
81
- For an outbound job such as a booking, use `ezenciel-agents-task propose` WITHOUT
86
+ For an outbound job such as a booking, use `ezenciel-agents-task start` WITHOUT
82
87
  `--incoming-only`: it starts the inquiry and then watches replies. For “answer if
83
- they message,” use `ezenciel-agents-task propose --incoming-only` with the
88
+ they message,” use `ezenciel-agents-task start --incoming-only` with the
84
89
  registered source, canonical contact, purpose, explicitly shareable context file
85
90
  and expiry. No need to invent a booking objective: “conversational replies to
86
91
  this contact, no private disclosures or commitments” is a legitimate purpose.
@@ -99,7 +104,7 @@ separate from private PA memory. Group text is supported; media is not yet.
99
104
 
100
105
  ## Everyone on an enabled channel
101
106
 
102
- When the owner explicitly asks to answer anyone on one channel, propose one
107
+ When the owner explicitly asks to answer anyone on one channel, start one
103
108
  incoming-only `--any-conversation` grant for that registered source. The flag
104
109
  also selects `--until-revoked` and contact `*`. Do not widen “this group,” “this
105
110
  person,” or “customers in this list” into everyone. The source must support a
@@ -129,16 +134,16 @@ Library source:
129
134
  The registered alias must declare `channelQuery: true` with explicit exposure
130
135
  metadata showing that it accepts external input but cannot send, change records,
131
136
  or require interactive review. Core verifies that installed declaration on every
132
- invocation. Verify the selected source and query alias first, then propose:
137
+ invocation. Verify the selected source and query alias first, then start:
133
138
 
134
139
  ```sh
135
- ezenciel-agents-task propose --source telegram --any-conversation \
140
+ ezenciel-agents-task start --source telegram --any-conversation \
136
141
  --purpose "Answer questions using approved public knowledge" \
137
142
  --context-file work/public-answer-policy.txt \
138
143
  --capability-file work/public-answer-capabilities.json
139
144
  ```
140
145
 
141
- The approval names the audience, disclosure context and exact command vectors.
146
+ The grant binds the audience, disclosure context and exact command vectors.
142
147
  Command output may be shown to anyone in that audience. External writes need
143
148
  their own bounded tool contract and receipts. Revocation stops new capture,
144
149
  cancels queued replies and rechecks every tool/send. Public Telegram groups must
@@ -147,17 +152,26 @@ and the source-wide public backlog is bounded. Core admits at most 60 public
147
152
  conversations and 60 replies per hour. A grant revokes after 1,000 conversations
148
153
  or replies so the owner must review continued broad access.
149
154
 
150
- The core presents the exact proposal for owner confirmation. Ordinary messages
151
- inside that grant need no repeated confirmations. Incoming-only grants create
155
+ `start` returns active only after the provider accepts the watch and core saves
156
+ the scoped grant. `propose` instead presents the exact scope for owner confirmation.
157
+ Ordinary messages inside an active grant need no repeated confirmations. Incoming-only grants create
152
158
  no initial run or opening message. They remain active across replies until expiry
153
159
  or owner revocation; the worker cannot close a watch by calling `complete`.
154
- After handling a message, save a task note and finish the run. After approval, verify active core state,
160
+ After handling a message, save a task note and finish the run. After activation, verify active core state,
155
161
  contact and expiry, source reachability, and the provider's task watch. With
156
162
  `--incoming-only`, an empty conversation is correctly idle until a new message.
157
163
  If approval is still pending, say pending; if setup failed, name the actual
158
164
  failure and continue repair within scope. Never report “active” based on notes,
159
165
  installation, connected status or subscription alone.
160
166
 
167
+ Active grants survive relay restarts independently of transient approval messages.
168
+ An initial outgoing run is dispatched at most once; a restart may lose an in-flight
169
+ run, and core never blindly repeats its opener. New matching correspondence can
170
+ still resume the task. Legacy tasks without a persisted grant still require their
171
+ original live approval; if that was lost on restart, revoke and recreate within
172
+ the owner's existing authority. Pending proposals never become active merely
173
+ because their approval record disappeared.
174
+
161
175
  After an authorized test message, inspect the corresponding task run and send
162
176
  receipt and compare recipient readback. An accepted send is not proof of delivery.
163
177
  After revocation/expiry no subsequent reply may dispatch. Keep these checks
package/docs/upgrades.md CHANGED
@@ -164,7 +164,11 @@ applying job before resuming normal work. A failed build leaves the old runtime
164
164
  running. Failed activation restores the previous code/configuration and checks it.
165
165
  If recovery itself fails, inspect and fix the reported infrastructure problem,
166
166
  then use `ez updates recover <job-id>` and finish the turn. This retries only the
167
- saved code/configuration recovery, not provider operations.
167
+ saved code/configuration recovery, not provider operations. If the original relay
168
+ is already running and healthy with the exact saved immutable image, recovery
169
+ keeps it while restoring the host and bindings. This also works when Docker
170
+ has removed that image index but retained the original container. Other images
171
+ and unhealthy relays still require the normal Compose replacement.
168
172
  An update never wakes the agent or injects wrapper state into its session; the
169
173
  owner or agent inspects receipts with `ez updates` when asked.
170
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.42",
3
+ "version": "0.1.0-beta.44",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
@@ -79,9 +79,11 @@ export const createAgent = async (options: {
79
79
  EZ_AGENT_WORKSPACE: join(deploymentDir, 'mind'),
80
80
  EZ_CONTROL_DIR: join(deploymentDir, 'control'),
81
81
  EZ_TOOLS_HOME: join(deploymentDir, 'tools'),
82
- EZ_PLUGIN_BROKER_SOCKET: join(deploymentDir, 'control', 'plugin-broker.sock'),
83
- EZ_PLUGIN_BROKER_HOST_CONFIG: join(deploymentDir, 'host-executor.json'),
84
- ...(isolation === 'isolated' ? { COMPOSE_PROFILES: 'isolated' } : {}),
82
+ ...(isolation === 'isolated' ? {
83
+ EZ_PLUGIN_BROKER_SOCKET: join(deploymentDir, 'control', 'plugin-broker.sock'),
84
+ EZ_PLUGIN_BROKER_HOST_CONFIG: join(deploymentDir, 'host-executor.json'),
85
+ COMPOSE_PROFILES: 'isolated',
86
+ } : {}),
85
87
  EZ_WHATSAPP_IPC_VOLUME: `${project}-whatsapp-ipc`,
86
88
  EZ_WHATSAPP_CLIENT_VOLUME: `${project}-whatsapp-client`,
87
89
  }
package/src/ai-cli.ts CHANGED
@@ -2,18 +2,27 @@ import { parseArgs } from 'node:util'
2
2
  import { randomBytes } from 'node:crypto'
3
3
  import { join } from 'node:path'
4
4
  import { readFile } from 'node:fs/promises'
5
- import { readModels, validateSelection, type AiPreset } from './ai.js'
5
+ import { readModels, presetProvider, validateSelection, type AiPreset } from './ai.js'
6
6
  import { ControlStore } from './control-state.js'
7
7
 
8
8
  const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
9
9
  if(values.help){
10
- console.log('Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>]\nChoose only values returned by list. Selection affects subsequent messages; queued work and the installation default are unchanged.')
10
+ console.log('Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>] | default\nChoose only values returned by list. Selection affects subsequent messages; default makes the selected AI the choice for new conversations. Queued work is unchanged.')
11
11
  process.exit(0)
12
12
  }
13
13
  if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
14
14
  const catalog=await readFile(join(process.env.EZ_CONTROL_DIR,'host-executor','models.json'),'utf8')
15
15
  .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR!,'cli','codex'),undefined,undefined,undefined,process.env.EZ_CONTROL_DIR))
16
16
  if(positionals[0]==='list')console.log(JSON.stringify(catalog))
17
+ else if(positionals[0]==='default'){
18
+ const control=new ControlStore(process.env.EZ_CONTROL_DIR,900000)
19
+ const state=await control.status()
20
+ const selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
21
+ if(!selected)throw new Error('No selected AI')
22
+ await validateSelection(selected,catalog)
23
+ await control.defaultPreset(selected.id)
24
+ console.log(JSON.stringify({default:selected,applies:'new conversations; current and queued work unchanged'}))
25
+ }
17
26
  else if(positionals[0]==='select'){
18
27
  const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',provider:values.provider,model:values.model,effort:values.effort}
19
28
  await validateSelection(preset,catalog)
@@ -24,6 +33,6 @@ else if(positionals[0]==='select'){
24
33
  const selected=existing||preset
25
34
  if(!existing)await control.savePreset(selected)
26
35
  const current=state.ai.presets.find(p=>p.id===state.ai!.selectedId)!
27
- await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||current.provider!==selected.provider||Boolean(state.activeSession&&!state.activeSession.cli))
36
+ await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||presetProvider(current)!==presetProvider(selected)||Boolean(state.activeSession&&!state.activeSession.cli))
28
37
  console.log(JSON.stringify({selected,defaultUnchanged:true,applies:'subsequent messages; queued work keeps its captured choice'}))
29
- }else throw new Error('Use list or select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>]')
38
+ }else throw new Error('Use list, select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>], or default')