@jc_stack/ez-agents 0.1.0-beta.36 → 0.1.0-beta.37

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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.37
4
+
5
+ - List installed OpenCode models with their provider variants as selectable
6
+ efforts in Choose AI, chat and scheduled tasks. The relay spawns the
7
+ installed `opencode` CLI with the whitelisted executor environment, so the
8
+ menu reflects that CLI's reported catalog; variants pass through verbatim as
9
+ `run --variant` on every platform. Restricted messaging tasks stay on the
10
+ audited Codex. The relay image now ships pinned OpenCode 1.18.32 for
11
+ isolated agents; host-capable agents reuse the host installation.
12
+ - Provision authenticated OpenCode providers per agent through
13
+ `control/cli/opencode/auth.json`: isolated runs resolve it via an
14
+ agent-bound data home. Paid models may appear in the catalog; execution
15
+ depends on valid provider authorization. Host runs keep the installer login
16
+ and the free tier stays the fallback.
17
+ - Scope selectable OpenCode catalog entries with `EZ_OPENCODE_PROVIDERS` (e.g.
18
+ Go only): unlisted providers, including the free tier, leave the menu.
19
+ Isolated relays read it from their environment; host transports take
20
+ `opencodeProviders` on the agent's host binding instead. An existing
21
+ model-less default still follows the native client's provider choice.
22
+ Existing deployments need an installer-owned bootstrap for the new Compose
23
+ environment field before their automatic main updater accepts this release.
24
+ - Allow unbound plugins to run through the isolated broker when a different
25
+ installed plugin declares a host network binding; bound plugins still fail
26
+ closed on an isolated deployment.
27
+ - Add a Pi native executor with the exact conversation session ID. It offers
28
+ its native default when no provider scope is set. Owner-curated model choices
29
+ require Pi's own provider credentials and are filtered by scope. The relay
30
+ image installs pinned Pi 0.87.1.
31
+
3
32
  ## 0.1.0-beta.36
4
33
 
5
34
  - Move relay run/outbox/inbox bookkeeping into relay memory behind the control
package/Dockerfile CHANGED
@@ -9,10 +9,21 @@ COPY . .
9
9
  FROM dependencies AS test
10
10
  RUN pnpm verify
11
11
  FROM dependencies AS runtime
12
- # Keep this pin aligned with TASK_CODEX_VERSION. Isolated agents run this CLI
13
- # in the relay; do not bind-mount the operator's ~/.codex.
12
+ # Isolated agents run this CLI in the relay; do not bind-mount the
13
+ # operator's ~/.codex. Pin the reviewed CLI for repeatable release images.
14
14
  ARG CODEX_CLI_VERSION=0.153.4
15
15
  RUN npm install -g @openai/codex@${CODEX_CLI_VERSION} && command -v codex
16
+ # Isolated agents run the selected CLI in the relay; host-capable agents reuse
17
+ # the host installation instead. OpenCode carries no task-runner pin because
18
+ # restricted messaging tasks stay on the audited Codex above.
19
+ # Pin the reviewed CLI for repeatable release images.
20
+ ARG OPENCODE_CLI_VERSION=1.18.32
21
+ RUN npm install -g opencode-ai@${OPENCODE_CLI_VERSION} && command -v opencode && opencode --version
22
+ # Isolated agents select pi from the relay menu; the binary must resolve on
23
+ # the relay PATH just like codex/opencode above.
24
+ # Pin the reviewed CLI for repeatable release images.
25
+ ARG PI_CLI_VERSION=0.87.1
26
+ RUN npm install -g @earendil-works/pi-coding-agent@${PI_CLI_VERSION} && command -v pi
16
27
  RUN chmod +x docker/entrypoint.sh bin/ez bin/ezenciel-agents* && mkdir -p /state/control /state/home /workspace && chown node:node /state/control /state/home /workspace
17
28
  RUN node -e 'for (const [name, target] of Object.entries(require("./package.json").bin)) require("node:fs").symlinkSync("/app/" + target, "/usr/local/bin/" + name); require("node:fs").symlinkSync("/app/bin/ez", "/usr/local/bin/ez")'
18
29
  # Build identity for relay status (package version stays release-owned).
@@ -8,7 +8,10 @@ The container is built locally; no bundled third-party binary image is promised.
8
8
 
9
9
  Direct runtime dependencies: grammy (MIT), tsx (MIT). Development dependencies
10
10
  include TypeScript (Apache-2.0) and Node type definitions (MIT). The runtime
11
- image installs ffmpeg, Debian packages, and pinned `@openai/codex` (Apache-2.0):
12
- redistributing that image requires review of its exact package build, notices and
13
- corresponding-source obligations. Docker and a host-capable AI CLI remain
14
- separately installed products, not covered by this repository's MIT license.
11
+ image installs ffmpeg, Debian packages, and these reviewed CLI versions:
12
+ `@openai/codex` 0.153.4 (Apache-2.0), `opencode-ai` 1.18.32 (MIT), and
13
+ `@earendil-works/pi-coding-agent` 0.87.1 (MIT). Redistributing the image
14
+ requires review of its exact package build, notices and corresponding-source
15
+ obligations. Docker and
16
+ host-capable AI CLIs remain separately installed products, not covered by this
17
+ repository's MIT license.
package/compose.yaml CHANGED
@@ -28,6 +28,7 @@ services:
28
28
  EZ_CONTROL_DIR: ${EZ_CONTROL_DIR:?Set this agent control directory}
29
29
  EZ_EXECUTOR_CLI: ${EZ_EXECUTOR_CLI:?Set the host installation CLI}
30
30
  EZ_CODEX_SANDBOX: ${EZ_CODEX_SANDBOX:-}
31
+ EZ_OPENCODE_PROVIDERS: ${EZ_OPENCODE_PROVIDERS:-}
31
32
  EZ_CODEX_AUTO_COMPACT_TOKENS: ${EZ_CODEX_AUTO_COMPACT_TOKENS:-64000}
32
33
  EZ_EXECUTOR_TIMEOUT_SECONDS: ${EZ_EXECUTOR_TIMEOUT_SECONDS:-300}
33
34
  secrets: [relay_env]
@@ -34,8 +34,16 @@ References used for the adapter contract:
34
34
  - [OpenCode native run events](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/cli/cmd/run.ts)
35
35
 
36
36
  The catalog is local metadata, not an authentication or billing health check.
37
- Grok/Codex support explicit listed model/effort choices. Other installed clients
38
- offer their own default only in this slice. Refresh by opening the native client;
37
+ Grok/Codex/OpenCode support explicit listed model/effort choices. Other installed clients
38
+ offer their own default only in this slice. OpenCode lists the installed `opencode models`
39
+ catalog with the whitelisted executor environment, so only models the relay can actually
40
+ run are offered; provider variants become selectable efforts (`--variant`).
41
+ Authenticated providers appear only when the agent-bound
42
+ `control/cli/opencode/auth.json` binding exists (host runs keep the installer
43
+ login); otherwise the free tier lists. `EZ_OPENCODE_PROVIDERS` optionally
44
+ restricts the catalog to named providers (e.g. `opencode-go`); a set
45
+ allowlist that matches nothing offers no OpenCode choice rather than falling
46
+ back outside it. Refresh by opening the native client;
39
47
  the relay does not install models, manage subscriptions or guess aliases.
40
48
 
41
49
  Setup initialization and relay startup seed one default choice per installed client.
@@ -239,6 +239,25 @@ workspace (a tenant farm), while allowing explicitly shared and additional
239
239
  workspace grants and leaving `$HOME` readable for the host CLI.
240
240
  Codex then uses `danger-full-access` so it does not apply a nested Seatbelt.
241
241
 
242
+ ## OpenCode auth
243
+
244
+ Isolated agents run the image OpenCode with `XDG_DATA_HOME` set to the
245
+ agent-bound `control/cli` directory only when the owner provisioned
246
+ `control/cli/opencode/auth.json` there (mode `600`, copied once from the host
247
+ `opencode auth login`). Authenticated providers then list and run; otherwise
248
+ only the free tier is offered and paid turns fail closed with the provider
249
+ error. Only the directory path crosses into the executor environment, never a
250
+ secret. Host-capable agents reuse the host login and ignore the binding.
251
+
252
+ `EZ_OPENCODE_PROVIDERS` optionally restricts the catalog to named providers,
253
+ e.g. `opencode-go` for Go credentials only. Isolated relays read it from
254
+ their environment (`docker.env` through the relay `environment`); the host
255
+ transport is spawned with a scrubbed environment, so host-capable agents set
256
+ `opencodeProviders` on the agent's `host-executor.json` binding instead
257
+ (validated the same way, unknown fields ignored by older releases). Unset or
258
+ absent means unfiltered; a set scope that matches nothing offers no OpenCode
259
+ choice rather than falling back outside it.
260
+
242
261
  ## Chat latency
243
262
 
244
263
  Interactive Codex CLI turns resume the existing native session and use native
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.36",
3
+ "version": "0.1.0-beta.37",
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.",
package/src/ai-cli.ts CHANGED
@@ -12,7 +12,7 @@ if(values.help){
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
- .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR!,'cli','codex')))
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
17
  else if(positionals[0]==='select'){
18
18
  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}
package/src/ai.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  import { assertEffort, allowedEffort } from './model-policy.js'
2
2
  import { access, readFile } from 'node:fs/promises'
3
3
  import { constants } from 'node:fs'
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
4
6
  import { homedir } from 'node:os'
5
7
  import { join, delimiter } from 'node:path'
6
- import { executorKey, resolveExecutor } from './executor.js'
8
+ import { executorEnvironment, executorKey, resolveExecutor } from './executor.js'
7
9
  import { desktopCodexPath } from './desktop-bridge.js'
8
10
 
9
11
  export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
10
12
  export type ExecutionChoice = { sessionId: string; preset: AiPreset }
11
13
  export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
12
14
  const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
15
+ const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy', 'pi']
16
+ const nativeEffort = (cli: string, effort: string) =>
17
+ cli === 'pi' ? ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort) : true
13
18
  export const isPreset = (p: unknown): p is AiPreset => {
14
19
  if (!p || typeof p !== 'object') return false
15
20
  const v = p as AiPreset
16
21
  return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
17
- ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy'].includes(v.cli) &&
22
+ knownClis.includes(v.cli) &&
18
23
  (v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
19
24
  }
20
25
  export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
@@ -45,7 +50,81 @@ export const installed = async (cli: string): Promise<boolean> => {
45
50
 
46
51
  // Read only metadata from native client catalogs. Never import prompts, credentials,
47
52
  // provider configuration, or model instructions into relay context.
48
- export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex')): Promise<ModelChoice[]> => {
53
+ // The optional runner injects `opencode models` output in tests; production
54
+ // spawns the installed CLI with the whitelisted executor environment.
55
+ export const readOpencodeModels = async (run?: (args: string[]) => Promise<string>, dataHome?: string): Promise<ModelChoice[]> => {
56
+ const exec = run ?? (async (args: string[]) =>
57
+ (await promisify(execFile)('opencode', args, { env: { ...executorEnvironment(), ...(dataHome ? { XDG_DATA_HOME: dataHome } : {}) }, timeout: 8000, maxBuffer: 4 * 1024 * 1024 })).stdout)
58
+ const record = (value: unknown): Record<string, unknown> =>
59
+ value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
60
+ try {
61
+ const stdout = await exec(['models', '--verbose'])
62
+ // Verbose output alternates a `provider/model` identifier line with its
63
+ // pretty-printed JSON metadata (name, variants). Split on identifier lines
64
+ // so variant keys can be projected as selectable efforts.
65
+ const segments = stdout.split(/^([A-Za-z0-9_.\-]+\/[A-Za-z0-9_./:~\-]+)$/m)
66
+ const entries: ModelChoice[] = []
67
+ for (let index = 1; index + 1 < segments.length; index += 2) {
68
+ const id = segments[index].trim()
69
+ if (!safe(id)) continue
70
+ let meta: Record<string, unknown>
71
+ try { meta = record(JSON.parse(segments[index + 1])) } catch { continue }
72
+ const friendly = typeof meta.name === 'string' ? meta.name.trim() : ''
73
+ const efforts = Object.keys(record(meta.variants))
74
+ .filter((effort) => safe(effort) && allowedEffort(effort, id, 'opencode'))
75
+ entries.push({ cli: 'opencode', model: id,
76
+ name: (friendly ? `${friendly} · ${id}` : id).slice(0, 80), efforts })
77
+ }
78
+ if (entries.length) return entries
79
+ } catch { /* fall through to the plain list, then the client default */ }
80
+ try {
81
+ const stdout = await exec(['models'])
82
+ const entries: ModelChoice[] = []
83
+ for (const line of stdout.split(/\r?\n/)) {
84
+ const id = line.trim()
85
+ if (!id || !safe(id)) continue
86
+ entries.push({ cli: 'opencode', model: id, name: id.slice(0, 80), efforts: [] })
87
+ }
88
+ if (entries.length) return entries
89
+ } catch { /* unavailable metadata stays client default */ }
90
+ return []
91
+ }
92
+
93
+ // User-curated entries live in the agent's own setup (control/ai-models.json),
94
+ // never in the repo. They pin models the native catalogs may not currently
95
+ // serve (e.g. paid tiers) and merge ahead of discovered entries, so selection
96
+ // validation accepts them.
97
+ export const readCuratedModels = async (controlDir?: string): Promise<ModelChoice[]> => {
98
+ if (!controlDir) return []
99
+ const curated: ModelChoice[] = []
100
+ const seen = new Set<string>()
101
+ try {
102
+ const raw = JSON.parse(await readFile(join(controlDir, 'ai-models.json'), 'utf8'))
103
+ if (!Array.isArray(raw)) return []
104
+ for (const candidate of raw) {
105
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue
106
+ const entry = candidate as Record<string, unknown>
107
+ if (typeof entry.cli !== 'string' || !knownClis.includes(entry.cli)) continue
108
+ if (entry.provider !== undefined && !safe(entry.provider)) continue
109
+ if (entry.model !== undefined && !safe(entry.model)) continue
110
+ const key = `${entry.cli}‖${entry.provider ?? ''}‖${entry.model ?? ''}`
111
+ if (seen.has(key)) continue
112
+ seen.add(key)
113
+ curated.push({
114
+ cli: entry.cli,
115
+ ...(entry.provider !== undefined ? { provider: entry.provider as string } : {}),
116
+ ...(entry.model !== undefined ? { model: entry.model as string } : {}),
117
+ name: (typeof entry.name === 'string' && entry.name ? entry.name : String(entry.model ?? entry.cli)).slice(0, 80),
118
+ efforts: (Array.isArray(entry.efforts) ? entry.efforts : [])
119
+ .filter((effort): effort is string => safe(effort))
120
+ .filter((effort) => allowedEffort(effort, typeof entry.model === 'string' ? entry.model : undefined, entry.cli as string) && nativeEffort(entry.cli as string, effort)),
121
+ })
122
+ }
123
+ } catch { return [] }
124
+ return curated
125
+ }
126
+
127
+ export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string): Promise<ModelChoice[]> => {
49
128
  const models: ModelChoice[] = []
50
129
  const record = (value: unknown): Record<string, unknown> =>
51
130
  value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
@@ -79,9 +158,56 @@ export const readModels = async (home = homedir(), available = installed, codexH
79
158
  models.push(...(desktop.length ? desktop : [{ cli: 'codex-gui', name: 'codex-gui · desktop', efforts: [] }]))
80
159
  }
81
160
  // Other adapters expose the authenticated client's default, not a guessed catalog.
82
- for (const cli of ['claude', 'opencode', 'agy'])
161
+ for (const cli of ['claude', 'agy'])
83
162
  if (await available(cli)) models.push({ cli, name: `${cli} · client default`, efforts: [] })
84
- return models
163
+ const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
164
+ if (await available('opencode')) models.push(...await opencodeCatalogModels(opencodeRunner, opencodeDataHome, allow))
165
+ // Pi has separate provider configuration. OpenCode's catalog cannot
166
+ // establish which models Pi can execute.
167
+ if (!allow && await available('pi')) models.push({ cli: 'pi', name: 'Pi · client default', efforts: [] })
168
+ const curated = await readCuratedModels(curationDir)
169
+ if (!curated.length) return models
170
+ const scoped: ModelChoice[] = []
171
+ for (const entry of curated) {
172
+ if (!(await available(entry.cli))) continue
173
+ if (['opencode', 'pi'].includes(entry.cli) && allow &&
174
+ (entry.model === undefined || !allow.includes(entry.model.split('/')[0]))) continue
175
+ scoped.push(entry)
176
+ }
177
+ const key = (m: { cli: string; provider?: string; model?: string }) => `${m.cli}‖${m.provider ?? ''}‖${m.model ?? ''}`
178
+ const curatedKeys = new Set(scoped.map(key))
179
+ return [...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))]
180
+ }
181
+ export const opencodeCatalogModels = async (
182
+ opencodeRunner?: (args: string[]) => Promise<string>,
183
+ opencodeDataHome?: string,
184
+ opencodeAllowlist?: string[],
185
+ ): Promise<ModelChoice[]> => {
186
+ const discovered = await readOpencodeModels(opencodeRunner, opencodeDataHome)
187
+ const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
188
+ const scoped = allow ? discovered.filter((m) => m.model && allow.includes(m.model.split('/')[0])) : discovered
189
+ if (scoped.length) return scoped
190
+ // A set allowlist that matches nothing offers no choice rather than falling
191
+ // back outside the allowed providers.
192
+ if (allow) return []
193
+ return [{ cli: 'opencode', name: 'opencode · client default', efforts: [] as string[] }]
194
+ }
195
+
196
+ // Optional deployment-scoped restriction of the OpenCode catalog to named
197
+ // providers (e.g. EZ_OPENCODE_PROVIDERS=opencode-go). Unset means unfiltered.
198
+ // Reads process env directly like OPENCODE_MODEL; malformed values fail fast
199
+ // so /ai reports the misconfiguration instead of a silently wrong list.
200
+ const providerId = (p: unknown): p is string => typeof p === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(p)
201
+ export const validateOpencodeProviders = (value: unknown): string[] | undefined => {
202
+ if (value === undefined) return undefined
203
+ if (!Array.isArray(value) || !value.length || value.length > 16 || new Set(value).size !== value.length || value.some((p) => !providerId(p)))
204
+ throw new Error('opencode provider allowlist must be one to sixteen unique provider IDs')
205
+ return value as string[]
206
+ }
207
+ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env): string[] | undefined => {
208
+ const raw = env.EZ_OPENCODE_PROVIDERS?.trim()
209
+ if (!raw) return undefined
210
+ return validateOpencodeProviders([...new Set(raw.split(',').map((p) => p.trim()).filter(Boolean))])
85
211
  }
86
212
 
87
213
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
package/src/executor.ts CHANGED
@@ -103,7 +103,6 @@ export const resolveHostCommand = (command: string, pathValue = process.env.PATH
103
103
  }
104
104
  throw new Error(`Native CLI ${command} is not executable on the host PATH`)
105
105
  }
106
-
107
106
  export type CliAdapter = {
108
107
  name: string
109
108
  command: string
@@ -210,6 +209,30 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
210
209
  return args
211
210
  },
212
211
  },
212
+ pi: {
213
+ name: 'pi',
214
+ command: 'pi',
215
+ description: 'Pi coding agent',
216
+ buildArgs: (opts, _promptFile, promptText) => {
217
+ if (!opts.controlDir) throw new Error('Pi runs require a bound control directory for sessions')
218
+ if (opts.effort && !['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(opts.effort)) throw new Error('Invalid Pi thinking level')
219
+ const args = ['-p', '--mode', 'json', '--session-dir', path.join(opts.controlDir, 'cli', 'pi', 'sessions')]
220
+ // Owner-provisioned delivery skill (control/cli/pi/skills/ez-delivery).
221
+ // Loaded by path, never by prompt text, so the model cannot miss it.
222
+ try {
223
+ const skill = path.join(opts.controlDir, 'cli', 'pi', 'skills', 'ez-delivery', 'SKILL.md')
224
+ accessSync(skill, fsConstants.R_OK)
225
+ args.push('--skill', path.dirname(skill))
226
+ } catch { /* runs without the skill exactly as before */ }
227
+ // Bind Pi to this conversation, not the most recently used session in
228
+ // the shared directory. --session-id creates or resumes that exact ID.
229
+ if (opts.sessionId) args.push('--session-id', opts.sessionId)
230
+ if (opts.model) args.push('--model', opts.model)
231
+ if (opts.effort) args.push('--thinking', opts.effort)
232
+ args.push('--', promptText)
233
+ return args
234
+ },
235
+ },
213
236
  'codex-gui': {
214
237
  name: 'codex-gui',
215
238
  command: 'codex',
@@ -262,6 +285,27 @@ export const validateCodexProviderEnvironmentKey = (value: unknown): string => {
262
285
 
263
286
  export const resolveExecutor = (name?: string): CliAdapter => EXECUTOR_REGISTRY[executorKey(name)]
264
287
 
288
+ // Agent-bound OpenCode credentials live at cli/opencode/auth.json, mirroring
289
+ // the Codex binding. Returns the XDG data home (cli/) only when that binding
290
+ // exists; otherwise the host login (host transport) or the unauthenticated
291
+ // free tier (isolated relay) applies. Only a path is ever returned, never a
292
+ // secret.
293
+ export const opencodeDataHome = (controlDir: string): string | undefined => {
294
+ try {
295
+ accessSync(path.join(controlDir, 'cli', 'opencode', 'auth.json'), fsConstants.R_OK)
296
+ return path.join(controlDir, 'cli')
297
+ } catch { return undefined }
298
+ }
299
+
300
+ // Pi resolves its provider/model catalog from an agent dir (settings.json,
301
+ // auth.json). Only a path is ever returned, never a secret.
302
+ export const piAgentDir = (controlDir: string): string | undefined => {
303
+ try {
304
+ accessSync(path.join(controlDir, 'cli', 'pi', 'agent', 'auth.json'), fsConstants.R_OK)
305
+ return path.join(controlDir, 'cli', 'pi', 'agent')
306
+ } catch { return undefined }
307
+ }
308
+
265
309
  export const grokInvocation = (
266
310
  options: Pick<ExecutorOptions, 'workspace' | 'isResume'>,
267
311
  promptFile: string,
@@ -363,6 +407,22 @@ export const startExecutorJob = async (
363
407
  }
364
408
  environment.CODEX_HOME = home
365
409
  }
410
+ if (!host && !gui && key === 'opencode') {
411
+ // Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
412
+ // when the owner provisioned it. Host runs keep the installer login;
413
+ // without a binding the CLI falls back to its free tier. Fail closed at
414
+ // runtime with the provider error, never with a guessed credential.
415
+ const dataHome = opencodeDataHome(options.controlDir)
416
+ if (dataHome) environment.XDG_DATA_HOME = dataHome
417
+ }
418
+ if (!host && !gui && key === 'pi') {
419
+ // Point Pi at the agent-bound config dir (cli/pi/agent/auth.json) when
420
+ // the owner provisioned it. Without a binding Pi falls back to the relay
421
+ // home config. Fail closed at runtime with the provider error, never with
422
+ // a guessed credential.
423
+ const agentDir = piAgentDir(options.controlDir)
424
+ if (agentDir) environment.PI_CODING_AGENT_DIR = agentDir
425
+ }
366
426
  if (denies.length) {
367
427
  const profile = path.join(outputDirectory, 'workspace.sb')
368
428
  await writeFile(profile, macosWorkspaceProfile(denies, [
@@ -9,7 +9,7 @@ import { isHostRunId } from './host-executor-protocol.js'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
11
11
  import { parseIsolationClass, type IsolationClass } from './isolation.js'
12
- import { readModels, validateSelection } from './ai.js'
12
+ import { readModels, validateSelection, validateOpencodeProviders } from './ai.js'
13
13
  import type { ChildProcess } from 'node:child_process'
14
14
  import { taskWorkspace } from './task-workspace.js'
15
15
  import { packageVersion } from './version.js'
@@ -17,7 +17,7 @@ import { installedPluginVersions } from './software-status.js'
17
17
  import { processSnapshot } from './process-tree.js'
18
18
 
19
19
  export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
20
- export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[] }
20
+ export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[]; opencodeProviders?: string[] }
21
21
  export type HostInstallation = { cli: string; isolation?: IsolationClass; agents: HostBinding[] }
22
22
 
23
23
  const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
@@ -35,7 +35,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
35
35
  const sharedWorkspaces = new Map<HostBinding, string>()
36
36
  const additionalWorkspaces = new Map<HostBinding, string[]>()
37
37
  const catalog = async (agent: HostBinding) => {
38
- const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
38
+ const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
39
+ undefined, undefined, validateOpencodeProviders(agent.opencodeProviders), agent.controlDir)
39
40
  const providers = (agent.codexProviders ?? []).map(validateCodexProvider)
40
41
  const declared = providers.flatMap(provider => provider.models.map(model => {
41
42
  const native = discovered.find(candidate => candidate.cli === 'codex' && !candidate.provider && candidate.model === model)
@@ -51,6 +52,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
51
52
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
52
53
  const providers=(agent.codexProviders ?? []).map(validateCodexProvider)
53
54
  if (new Set(providers.map(provider=>provider.id)).size !== providers.length) throw new Error('Codex provider IDs must be unique')
55
+ validateOpencodeProviders(agent.opencodeProviders)
54
56
  if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
55
57
  if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
56
58
  if (agent.additionalWorkspaces) {
package/src/index.ts CHANGED
@@ -26,7 +26,7 @@ import { InboxStore, type IncomingItem } from './inbox.js'
26
26
  import { loadConfig, type Config } from './config.js'
27
27
  import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
28
28
  import { ApprovalStore } from './approval.js'
29
- import { startExecutorJob, terminateJob } from './executor.js'
29
+ import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
30
30
  import { RunStore, type RunRecord } from './runs.js'
31
31
  import { splitTelegramText } from './reply.js'
32
32
  import { markdownToTelegramHtml, escapeHtml } from './format.js'
@@ -71,7 +71,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
71
71
  let drainTimer: ReturnType<typeof setInterval> | undefined
72
72
  const codexHome = join(config.controlDir, 'cli', 'codex')
73
73
  const conversationMenu = createConversationMenu(control)
74
- const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
74
+ const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome,
75
+ undefined, opencodeDataHome(config.controlDir))
75
76
  const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
76
77
  const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
77
78
 
package/src/menu.ts CHANGED
@@ -30,14 +30,14 @@ const matchesModel = (preset: AiPreset, model: ModelChoice) =>
30
30
  // Short-lived opaque button IDs: no model names or executable arguments from callbacks.
31
31
  // These are operational settings, not a second conversational/agent loop.
32
32
  export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
33
- isInstalled = installed) => {
33
+ isInstalled = installed, opencodeDataHome?: string) => {
34
34
  const initial = chatPreset(cli)
35
35
  const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
36
36
  if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
37
37
  // Local execution runs Codex with CODEX_HOME set to the agent-bound control
38
38
  // directory, but the default catalog probed the process home instead. Bind it
39
39
  // to the same agent-bound home so /ai lists the models Codex can actually run.
40
- else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome)
40
+ else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
41
41
  const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome }))
42
42
  const validate = async (preset: AiPreset) => {
43
43
  assertEffort(preset.effort, preset.model, preset.cli)
@@ -278,15 +278,14 @@ export async function hostNetworkBindings(config, record, home) {
278
278
  const configured = agent.pluginNetworkBindings;
279
279
  if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
280
280
  throw Error('Invalid host network bindings');
281
+ const route = configured?.[record.manifest?.id];
282
+ if (route === undefined) return [];
281
283
  if (process.env.EZ_DOCKER_COMPOSE === 'standalone') {
282
- if (configured && Object.keys(configured).length) throw Error('Isolated broker cannot use host network bindings');
283
- return [];
284
+ throw Error('Isolated broker cannot use host network bindings');
284
285
  }
285
286
  if (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
286
287
  realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir))
287
288
  throw Error('Invalid host network binding deployment');
288
- const route = configured?.[record.manifest?.id];
289
- if (route === undefined) return [];
290
289
  keys(route, ['revisions', 'bindings']);
291
290
  const trusted = await snapshot(record.source);
292
291
  if (record.revision !== trusted.revision || record.source !== trusted.source ||
package/src/setup.ts CHANGED
@@ -105,6 +105,7 @@ const printStatus = (status: { active: string; items: ExecutorStatus[] }): void
105
105
  console.log(' pnpm run setup agy')
106
106
  console.log(' pnpm run setup grok')
107
107
  console.log(' pnpm run setup opencode')
108
+ console.log(' pnpm run setup pi')
108
109
  console.log(' pnpm run setup codex-gui\n')
109
110
  }
110
111
 
package/test/ai.test.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { ControlStore } from '../src/control-state.js'
7
- import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
7
+ import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
8
8
  import { createAiMenu } from '../src/menu.js'
9
9
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
10
10
  import { InboxStore } from '../src/inbox.js'
@@ -287,6 +287,12 @@ test('native executor flags carry the exact model and effort; only structured me
287
287
  const codex = EXECUTOR_REGISTRY.codex.buildArgs(opts, '', 'fixture')
288
288
  assert.ok(codex.includes('model_reasoning_effort="medium"'))
289
289
  assert.deepEqual(codex.slice(-3), ['resume', opts.sessionId, '-'])
290
+ // Opencode variants pass through verbatim on every platform; there is no
291
+ // OS-specific effort mapping between the catalog and `run --variant`.
292
+ const opencode = EXECUTOR_REGISTRY.opencode.buildArgs(
293
+ { workspace: '/tmp/fixture', model: 'opencode/muse-spark-1.3-contributor-free', effort: 'xhigh' }, '', 'fixture')
294
+ assert.equal(opencode[opencode.indexOf('-m') + 1], 'opencode/muse-spark-1.3-contributor-free')
295
+ assert.equal(opencode[opencode.indexOf('--variant') + 1], 'xhigh')
290
296
  assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'thread.started', thread_id: opts.sessionId })), opts.sessionId)
291
297
  assert.equal(nativeSessionId('opencode', JSON.stringify({ type: 'step_start', sessionID: 'ses_fixture' })), 'ses_fixture')
292
298
  assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'text', thread_id: opts.sessionId })), undefined)
@@ -353,3 +359,180 @@ test('upgrades expose responsive chat without replacing an existing default or q
353
359
  assert.ok(state.presets.some(p => p.id === 'chat-default'))
354
360
  } finally { await rm(dir, { recursive: true, force: true }) }
355
361
  })
362
+
363
+ test('opencode catalog projects installed models with variants as efforts', async () => {
364
+ const verbose = [
365
+ 'opencode/big-pickle',
366
+ JSON.stringify({ id: 'big-pickle', providerID: 'opencode', name: 'Big Pickle', variants: {} }),
367
+ 'opencode/ling-free',
368
+ JSON.stringify({ id: 'ling-free', providerID: 'opencode', name: 'Ling Free',
369
+ variants: { low: { reasoningEffort: 'low' }, medium: { reasoningEffort: 'medium' }, 'bad option': {} } }),
370
+ 'openrouter/~aliased/model',
371
+ JSON.stringify({ id: 'model', providerID: 'openrouter', name: 'Aliased', variants: {} }),
372
+ ].join('\n')
373
+ const catalog = await readOpencodeModels(async (args) => {
374
+ assert.deepEqual(args, ['models', '--verbose'])
375
+ return verbose
376
+ })
377
+ assert.deepEqual(catalog, [
378
+ { cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
379
+ { cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
380
+ ])
381
+ })
382
+
383
+ test('opencode catalog falls back to the plain list, then to the client default', async () => {
384
+ const plain = await readOpencodeModels(async (args) => {
385
+ if (args.includes('--verbose')) throw new Error('no verbose metadata')
386
+ return 'opencode/alpha\nopenrouter/~aliased/model\nnot a model line\n'
387
+ })
388
+ assert.deepEqual(plain, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
389
+ assert.deepEqual(await readOpencodeModels(async () => { throw new Error('opencode unavailable') }), [])
390
+ const fallback = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
391
+ async () => { throw new Error('opencode unavailable') })
392
+ assert.deepEqual(fallback, [{ cli: 'opencode', name: 'opencode · client default', efforts: [] }])
393
+ const listed = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
394
+ async () => 'opencode/alpha\n')
395
+ assert.deepEqual(listed, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
396
+ })
397
+
398
+ test('opencode provider allowlist scopes the catalog to Go and never falls back outside it', async () => {
399
+ assert.equal(opencodeProviderAllowlist({}), undefined)
400
+ assert.equal(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' ' }), undefined)
401
+ assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: 'opencode-go' }), ['opencode-go'])
402
+ assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' opencode-go ,openrouter,opencode-go ' }), ['opencode-go', 'openrouter'])
403
+ for (const bad of ['Bad Name!', 'has space', 'UPPER', 'a'.repeat(33), ',,,', 'ok,,bad name'])
404
+ assert.throws(() => opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: bad }), /one to sixteen unique provider IDs/)
405
+ const verbose = [
406
+ 'opencode/mimo-free',
407
+ JSON.stringify({ id: 'mimo-free', providerID: 'opencode', name: 'Mimo Free', variants: {} }),
408
+ 'opencode-go/muse-spark-1.3-contributor',
409
+ JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor',
410
+ variants: { high: {}, xhigh: {} } }),
411
+ 'openrouter/some/model',
412
+ JSON.stringify({ id: 'some/model', providerID: 'openrouter', name: 'Some', variants: {} }),
413
+ ].join('\n')
414
+ const runner = async () => verbose
415
+ const prior = process.env.EZ_OPENCODE_PROVIDERS
416
+ try {
417
+ process.env.EZ_OPENCODE_PROVIDERS = 'opencode-go'
418
+ const scoped = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)
419
+ assert.deepEqual(scoped.map((m) => m.model), ['opencode-go/muse-spark-1.3-contributor'])
420
+ await assert.rejects(
421
+ validateSelection({ id: 'free', name: 'Free', cli: 'opencode', model: 'opencode/mimo-free' }, scoped, async () => true),
422
+ /installed client catalog/)
423
+ process.env.EZ_OPENCODE_PROVIDERS = 'missing-provider'
424
+ assert.deepEqual(await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner), [])
425
+ delete process.env.EZ_OPENCODE_PROVIDERS
426
+ assert.equal((await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)).length, 3)
427
+ } finally {
428
+ if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
429
+ else process.env.EZ_OPENCODE_PROVIDERS = prior
430
+ }
431
+ })
432
+ test('explicit host binding allowlist overrides the process environment', async () => {
433
+ const runner = async () => 'opencode/a\nopencode-go/b\n'
434
+ const only = (models: { cli: string; model?: string }[]) => models.filter((m) => m.cli === 'opencode').map((m) => m.model)
435
+ const prior = process.env.EZ_OPENCODE_PROVIDERS
436
+ try {
437
+ process.env.EZ_OPENCODE_PROVIDERS = 'opencode'
438
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner)), ['opencode/a'])
439
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, ['opencode-go'])), ['opencode-go/b'])
440
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, undefined)), ['opencode/a'])
441
+ } finally {
442
+ if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
443
+ else process.env.EZ_OPENCODE_PROVIDERS = prior
444
+ }
445
+ })
446
+
447
+ test('opencode model and variant selections validate against the installed catalog', async () => {
448
+ const catalog = [
449
+ { cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
450
+ { cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
451
+ ]
452
+ const available = async (cli: string) => cli === 'opencode'
453
+ await validateSelection({ id: 'choice', name: 'Ling', cli: 'opencode', model: 'opencode/ling-free', effort: 'low' }, catalog, available)
454
+ await validateSelection({ id: 'default', name: 'Default', cli: 'opencode', model: 'opencode/big-pickle' }, catalog, available)
455
+ await assert.rejects(
456
+ validateSelection({ id: 'bad', name: 'Bad', cli: 'opencode', model: 'opencode/ling-free', effort: 'max' }, catalog, available),
457
+ /installed client catalog/)
458
+ await assert.rejects(
459
+ validateSelection({ id: 'missing', name: 'Missing', cli: 'opencode', model: 'opencode/unknown' }, catalog, available),
460
+ /installed client catalog/)
461
+ })
462
+
463
+ test('pi does not borrow an unrelated OpenCode catalog', async () => {
464
+ const runner = async () => [
465
+ 'opencode-go/muse-spark-1.3-contributor',
466
+ JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor', variants: { high: {}, xhigh: {} } }),
467
+ ].join('\n')
468
+ const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never, runner)
469
+ assert.deepEqual(catalog, [{ cli: 'pi', name: 'Pi · client default', efforts: [] }])
470
+ assert.equal(isPreset({ id: 'x', name: 'Pi', cli: 'pi' }), true)
471
+ assert.equal(isPreset({ id: 'x', name: 'Unavailable', cli: 'unreal-agent' }), false)
472
+ await validateSelection({ id: 'choice', name: 'Default', cli: 'pi' }, catalog, async (cli) => cli === 'pi')
473
+ await assert.rejects(
474
+ validateSelection(
475
+ { id: 'bad', name: 'Bad', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'max' },
476
+ catalog, async (cli) => cli === 'pi'),
477
+ /installed client catalog/)
478
+ })
479
+
480
+ test('model catalog merges user-curated entries ahead of discovered ones', async () => {
481
+ const control = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-'))
482
+ const home = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-home-'))
483
+ try {
484
+ await mkdir(join(home, '.codex'))
485
+ await writeFile(join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
486
+ { slug: 'fixture-model', display_name: 'Fixture', visibility: 'list',
487
+ supported_reasoning_levels: [{ effort: 'medium' }] },
488
+ ] }))
489
+ await writeFile(join(control, 'ai-models.json'), JSON.stringify([
490
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
491
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Dupe', efforts: [] },
492
+ { cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
493
+ { cli: 'nope', model: 'x' },
494
+ 'not-an-entry',
495
+ ]))
496
+ const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
497
+ const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
498
+ const catalog = await readModels(home, installedOnly, join(home, '.codex'),
499
+ noRunner, undefined, undefined, control)
500
+ assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
501
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
502
+ { cli: 'pi', name: 'Pi · client default', efforts: [] },
503
+ ])
504
+ // Curated entries win over the same discovered id and validate for selection.
505
+ assert.deepEqual(catalog.filter((m) => m.cli === 'codex'), [
506
+ { cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
507
+ ])
508
+ await validateSelection(
509
+ { id: 'choice', name: 'Spark', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
510
+ catalog, async (cli) => cli === 'pi')
511
+ // Missing or malformed curation files fall back to discovery alone.
512
+ assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
513
+ await writeFile(join(control, 'ai-models.json'), 'not json')
514
+ assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
515
+ noRunner, undefined, undefined, control), [
516
+ { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
517
+ ])
518
+ } finally {
519
+ await rm(control, { recursive: true, force: true })
520
+ await rm(home, { recursive: true, force: true })
521
+ }
522
+ })
523
+
524
+ test('curated models respect installed clients and provider scope', async () => {
525
+ const control = await mkdtemp(join(tmpdir(), 'ez-catalog-scope-'))
526
+ try {
527
+ await writeFile(join(control, 'ai-models.json'), JSON.stringify([
528
+ { cli: 'pi', model: 'opencode-go/allowed', efforts: ['xhigh'] },
529
+ { cli: 'pi', model: 'other/blocked' },
530
+ { cli: 'opencode', model: 'other/blocked' },
531
+ { cli: 'claude', model: 'uninstalled' },
532
+ ]))
533
+ const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never,
534
+ async () => { throw new Error('no OpenCode catalog') }, undefined, ['opencode-go'], control)
535
+ assert.deepEqual(catalog.filter(entry => entry.model).map(entry => entry.model), ['opencode-go/allowed'])
536
+ assert.equal(catalog.some(entry => entry.cli === 'pi' && !entry.model), false)
537
+ } finally { await rm(control, { recursive: true, force: true }) }
538
+ })
@@ -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, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
9
+ import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
10
10
  import { requireOwnerExecution } from '../src/execution-authority.js'
11
11
  import { splitTelegramText } from '../src/reply.js'
12
12
  import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
@@ -125,6 +125,7 @@ test('resolveExecutor correctly maps keys and aliases', () => {
125
125
  assert.equal(resolveExecutor('grok').command, 'grok')
126
126
  assert.equal(resolveExecutor('opencode').command, 'opencode')
127
127
  assert.equal(resolveExecutor('oc').command, 'opencode')
128
+ assert.throws(() => resolveExecutor('unreal-agent'), /Unsupported executor CLI/)
128
129
  assert.equal(resolveExecutor('codex-gui').name, 'codex-gui')
129
130
  assert.notEqual(resolveExecutor('codex-gui').name, resolveExecutor('codex').name)
130
131
  assert.equal(resolveExecutor(undefined).command, 'agy')
@@ -177,6 +178,114 @@ test('Codex custom providers use declared runtime state and an environment key r
177
178
  assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
178
179
  })
179
180
 
181
+ test('opencode jobs use the agent-bound data home only when its auth binding exists', async t => {
182
+ const root=await mkdtemp(path.join(tmpdir(),'ez-opencode-auth-'));t.after(()=>rm(root,{recursive:true,force:true}))
183
+ const bin=path.join(root,'bin');await mkdir(bin)
184
+ await writeFile(path.join(bin,'opencode'),`#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),xdg:process.env.XDG_DATA_HOME,telegram:process.env.TELEGRAM_BOT_TOKEN}));`,{mode:0o755})
185
+ assert.equal(opencodeDataHome(root),undefined)
186
+ await mkdir(path.join(root,'cli','opencode'),{recursive:true})
187
+ assert.equal(opencodeDataHome(root),undefined)
188
+ await writeFile(path.join(root,'cli','opencode','auth.json'),'{}',{mode:0o600})
189
+ assert.equal(opencodeDataHome(root),path.join(root,'cli'))
190
+ const prior={path:process.env.PATH,telegram:process.env.TELEGRAM_BOT_TOKEN,transport:process.env.EZ_EXECUTOR_TRANSPORT}
191
+ const launch=async(runId:string)=>{
192
+ await ownerRun(root,runId)
193
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.TELEGRAM_BOT_TOKEN='relay-secret';delete process.env.EZ_EXECUTOR_TRANSPORT
194
+ const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId,timeoutMs:0,cli:'opencode',model:'opencode-go/muse-spark-1.3-contributor',effort:'xhigh'})
195
+ assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
196
+ return JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
197
+ }
198
+ try {
199
+ const bound=await launch('r_ocbound')
200
+ assert.equal(bound.xdg,path.join(root,'cli'))
201
+ assert.equal(bound.telegram,undefined)
202
+ assert.equal(bound.args[bound.args.indexOf('-m')+1],'opencode-go/muse-spark-1.3-contributor')
203
+ assert.equal(bound.args[bound.args.indexOf('--variant')+1],'xhigh')
204
+ await rm(path.join(root,'cli','opencode','auth.json'))
205
+ const free=await launch('r_ocfree')
206
+ assert.equal(free.xdg,undefined)
207
+ assert.equal(free.telegram,undefined)
208
+ } finally {
209
+ if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
210
+ if(prior.telegram===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=prior.telegram
211
+ if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
212
+ }
213
+ })
214
+
215
+ test('pi jobs use the agent-bound config dir only when its auth binding exists', async t => {
216
+ const root=await mkdtemp(path.join(tmpdir(),'ez-pi-auth-'));t.after(()=>rm(root,{recursive:true,force:true}))
217
+ const bin=path.join(root,'bin');await mkdir(bin)
218
+ await writeFile(path.join(bin,'pi'),`#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),agentDir:process.env.PI_CODING_AGENT_DIR,telegram:process.env.TELEGRAM_BOT_TOKEN}));`,{mode:0o755})
219
+ assert.equal(piAgentDir(root),undefined)
220
+ await mkdir(path.join(root,'cli','pi','agent'),{recursive:true})
221
+ assert.equal(piAgentDir(root),undefined)
222
+ await writeFile(path.join(root,'cli','pi','agent','auth.json'),'{}',{mode:0o600})
223
+ assert.equal(piAgentDir(root),path.join(root,'cli','pi','agent'))
224
+ const prior={path:process.env.PATH,telegram:process.env.TELEGRAM_BOT_TOKEN,transport:process.env.EZ_EXECUTOR_TRANSPORT,agentDir:process.env.PI_CODING_AGENT_DIR}
225
+ const launch=async(runId:string)=>{
226
+ await ownerRun(root,runId)
227
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.TELEGRAM_BOT_TOKEN='relay-secret';delete process.env.EZ_EXECUTOR_TRANSPORT;delete process.env.PI_CODING_AGENT_DIR
228
+ const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId,timeoutMs:0,cli:'pi',model:'opencode-go/muse-spark-1.3-contributor',effort:'xhigh'})
229
+ assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
230
+ return JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
231
+ }
232
+ try {
233
+ const bound=await launch('r_pibound')
234
+ assert.equal(bound.agentDir,path.join(root,'cli','pi','agent'))
235
+ assert.equal(bound.telegram,undefined)
236
+ assert.equal(bound.args[bound.args.indexOf('--model')+1],'opencode-go/muse-spark-1.3-contributor')
237
+ assert.equal(bound.args[bound.args.indexOf('--thinking')+1],'xhigh')
238
+ await rm(path.join(root,'cli','pi','agent','auth.json'))
239
+ const free=await launch('r_pifree')
240
+ assert.equal(free.agentDir,undefined)
241
+ assert.equal(free.telegram,undefined)
242
+ // Owner-provisioned delivery skill loads by path; absence keeps prior args.
243
+ assert.ok(!bound.args.includes('--skill'))
244
+ await mkdir(path.join(root,'cli','pi','skills','ez-delivery'),{recursive:true})
245
+ await writeFile(path.join(root,'cli','pi','skills','ez-delivery','SKILL.md'),'# delivery',{mode:0o644})
246
+ const skilled=await launch('r_piskilled')
247
+ assert.equal(skilled.args[skilled.args.indexOf('--skill')+1],path.join(root,'cli','pi','skills','ez-delivery'))
248
+ } finally {
249
+ if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
250
+ if(prior.telegram===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=prior.telegram
251
+ if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
252
+ if(prior.agentDir===undefined)delete process.env.PI_CODING_AGENT_DIR;else process.env.PI_CODING_AGENT_DIR=prior.agentDir
253
+ }
254
+ })
255
+
256
+ test('the pi invocation pins control sessions, model, and thinking level', () => {
257
+ assert.equal(resolveExecutor('pi').command, 'pi')
258
+ const args = EXECUTOR_REGISTRY.pi.buildArgs(
259
+ { workspace: '/agent/mind', controlDir: '/agent/control', sessionId: 'ez-session', isResume: true, model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
260
+ '/prompt', 'review CAMT')
261
+ assert.deepEqual(args, ['-p', '--mode', 'json', '--session-dir', '/agent/control/cli/pi/sessions', '--session-id', 'ez-session',
262
+ '--model', 'opencode-go/muse-spark-1.3-contributor', '--thinking', 'xhigh', '--', 'review CAMT'])
263
+ const other = EXECUTOR_REGISTRY.pi.buildArgs({ workspace: '/agent/mind', controlDir: '/agent/control', sessionId: 'other-session', isResume: true }, '/prompt', 'other')
264
+ assert.equal(other[other.indexOf('--session-id') + 1], 'other-session')
265
+ assert.ok(!other.includes('--continue'))
266
+ assert.throws(() => EXECUTOR_REGISTRY.pi.buildArgs({ workspace: '/agent' }, '/prompt', 'hi'), /bound control directory/)
267
+ })
268
+
269
+ test('pi jobs run with control-bound sessions and no secret leakage', async t => {
270
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-pi-auth-')); t.after(() => rm(root, { recursive: true, force: true }))
271
+ const bin = path.join(root, 'bin'); await mkdir(bin)
272
+ await writeFile(path.join(bin, 'pi'), `#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root, 'observed.json'))},JSON.stringify({args:process.argv.slice(2),telegram:process.env.TELEGRAM_BOT_TOKEN}));`, { mode: 0o755 })
273
+ const prior = { path: process.env.PATH, telegram: process.env.TELEGRAM_BOT_TOKEN, transport: process.env.EZ_EXECUTOR_TRANSPORT }
274
+ try {
275
+ await ownerRun(root, 'r_pibound')
276
+ process.env.PATH = bin + path.delimiter + prior.path; process.env.TELEGRAM_BOT_TOKEN = 'relay-secret'; delete process.env.EZ_EXECUTOR_TRANSPORT
277
+ const job = await startExecutorJob(['review CAMT'], { workspace: root, controlDir: root, binDir: bin, runId: 'r_pibound', timeoutMs: 0, cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' })
278
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
279
+ const observed = JSON.parse(await readFile(path.join(root, 'observed.json'), 'utf8'))
280
+ assert.deepEqual(observed.args, ['-p', '--mode', 'json', '--session-dir', path.join(root, 'cli', 'pi', 'sessions'),
281
+ '--model', 'opencode-go/muse-spark-1.3-contributor', '--thinking', 'xhigh', '--', 'review CAMT'])
282
+ assert.equal(observed.telegram, undefined)
283
+ } finally {
284
+ if (prior.path === undefined) delete process.env.PATH; else process.env.PATH = prior.path
285
+ if (prior.telegram === undefined) delete process.env.TELEGRAM_BOT_TOKEN; else process.env.TELEGRAM_BOT_TOKEN = prior.telegram
286
+ if (prior.transport === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT; else process.env.EZ_EXECUTOR_TRANSPORT = prior.transport
287
+ }
288
+ })
180
289
  test('a configured Codex provider receives only its declared key and runtime selection', async t => {
181
290
  const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
182
291
  const bin=path.join(root,'bin');await mkdir(bin)
@@ -22,7 +22,7 @@ test('host restart replaces a lock whose PID was reused by another process',asyn
22
22
  await mkdir(workspace);await mkdir(directory,{recursive:true})
23
23
  await writeFile(path.join(directory,'worker.lock'),JSON.stringify({pid:process.pid,started:'reused-pid'}))
24
24
  server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
25
- for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
25
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
26
26
  await readFile(path.join(directory,'heartbeat.json'))
27
27
  const lock=JSON.parse(await readFile(path.join(directory,'worker.lock'),'utf8'))
28
28
  assert.equal(lock.pid,process.pid)
@@ -47,6 +47,16 @@ test('isolated installations cannot start host transport', async () => {
47
47
  abort.abort()
48
48
  })
49
49
 
50
+ test('host bindings reject invalid opencode provider scopes before touching state', async () => {
51
+ const abort=new AbortController()
52
+ for (const opencodeProviders of [['BAD NAME'], [], ['ok', 'ok'], new Array(17).fill('ok')]) {
53
+ await assert.rejects(serveHostExecutor({cli:'grok',agents:[{
54
+ name:'t',workspace:'/tmp/ez-scope-x',controlDir:'/tmp/ez-scope-y',binDir:'/tmp/ez-scope-z',opencodeProviders} as never]},abort.signal),
55
+ /one to sixteen unique provider IDs/)
56
+ }
57
+ abort.abort()
58
+ })
59
+
50
60
  test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
51
61
  const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
52
62
  const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
@@ -63,7 +73,7 @@ test('host restart clears dead native lease only after proving previous CLI stop
63
73
  await readFile(path.join(toolsHome,'workspace-writer.lock'));
64
74
  await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:deadPid}));
65
75
  server=serveHostExecutor(installation,abort.signal);
66
- for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
76
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
67
77
  await readFile(path.join(directory,'heartbeat.json'));
68
78
  await assert.rejects(readFile(path.join(toolsHome,'workspace-writer.lock')),{code:'ENOENT'});
69
79
  }finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
@@ -173,7 +183,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
173
183
  server=serveHostExecutor({cli:'grok',agents},abort.signal)
174
184
  for(const agent of agents){
175
185
  const dir=path.join(agent.controlDir,'host-executor')
176
- for(let n=0;n<100;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
186
+ for(let n=0;n<300;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
177
187
  assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
178
188
  const release = await workspaceLease(agent.toolsHome)
179
189
  await ownerRun(agent.controlDir, `r_${agent.name}`)
@@ -276,6 +276,23 @@ test('isolated broker host binding validates exact mounts without deployment sym
276
276
  if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
277
277
  }
278
278
  });
279
+ test('isolated broker allows unbound plugins when another plugin has host binding',async t=>{
280
+ const f=await fixture(t),p=await snapshot(f.source),isolated=path.join(f.root,'isolated-other'),workspace=path.join(isolated,'workspace'),control=path.join(isolated,'control'),hostConfig=path.join(isolated,'host-executor.json'),previous=process.env.EZ_DOCKER_COMPOSE;
281
+ await fs.mkdir(f.home,{recursive:true});await fs.mkdir(workspace,{recursive:true});await fs.mkdir(control,{recursive:true});
282
+ await fs.writeFile(hostConfig,JSON.stringify({cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir:control,binDir:path.join(f.root,'bin'),toolsHome:f.home}]}));
283
+ const record={source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment};
284
+ const config={workspace:await fs.realpath(workspace),hostConfig:await fs.realpath(hostConfig)};
285
+ await fs.writeFile(hostConfig,JSON.stringify({cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir:control,binDir:path.join(f.root,'bin'),toolsHome:f.home,pluginNetworkBindings:{voice:{revisions:[p.revision],bindings:[{service:'sample',network:'voice_default'}]}}}]}));
286
+ process.env.EZ_DOCKER_COMPOSE='standalone';
287
+ try {
288
+ const c=await compose(config,record,{},f.home);
289
+ assert.equal(c.version,'3.8');assert.equal(c.networks,undefined);
290
+ await fs.writeFile(hostConfig,JSON.stringify({cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir:control,binDir:path.join(f.root,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[{service:'sample',network:'sample_default'}]}}}]}));
291
+ await assert.rejects(compose(config,record,{},f.home),/Isolated broker cannot use host network bindings/);
292
+ } finally {
293
+ if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
294
+ }
295
+ });
279
296
  test('host-owned private networks survive registered-call Compose regeneration',async t=>{
280
297
  const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
281
298
  await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);