@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 +29 -0
- package/Dockerfile +13 -2
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/compose.yaml +1 -0
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/docker-runtime.md +19 -0
- package/package.json +1 -1
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- package/src/executor.ts +61 -1
- package/src/host-executor.ts +5 -3
- package/src/index.ts +3 -2
- package/src/menu.ts +2 -2
- package/src/plugins/manager.mjs +3 -4
- package/src/setup.ts +1 -0
- package/test/ai.test.ts +184 -1
- package/test/executor.test.ts +110 -1
- package/test/host-executor.test.ts +13 -3
- package/test/plugin-manager.test.mjs +17 -0
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
|
-
#
|
|
13
|
-
#
|
|
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).
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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.
|
package/docs/docker-runtime.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
161
|
+
for (const cli of ['claude', 'agy'])
|
|
83
162
|
if (await available(cli)) models.push({ cli, name: `${cli} · client default`, efforts: [] })
|
|
84
|
-
|
|
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, [
|
package/src/host-executor.ts
CHANGED
|
@@ -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)
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
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<
|
|
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);
|