@zooid/core 0.7.0 → 0.7.1
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/dist/index.d.ts +53 -3
- package/dist/index.js +158 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/acp-registry.ts +38 -2
- package/src/config.ts +177 -10
- package/src/container-mounts.test.ts +292 -0
- package/src/index.ts +2 -0
- package/src/types.ts +25 -1
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { isAbsolute, join, resolve as pathResolve } from 'node:path'
|
|
3
3
|
import { parse } from 'yaml'
|
|
4
4
|
import type { AcpAgentSpec } from './acp-types.js'
|
|
5
5
|
import { isPreset } from '@zooid/acp-client'
|
|
@@ -12,11 +12,22 @@ import type {
|
|
|
12
12
|
HttpTransportConfig,
|
|
13
13
|
MatrixBinding,
|
|
14
14
|
MatrixTransportConfig,
|
|
15
|
+
MountConfig,
|
|
15
16
|
TransportConfig,
|
|
16
17
|
ZooidConfig,
|
|
17
18
|
ZooidContainerConfig,
|
|
18
19
|
} from './types.js'
|
|
19
20
|
|
|
21
|
+
export interface LoadZooidConfigOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Directory containing zooid.yaml. Required when any agent uses a
|
|
24
|
+
* relative `container.mounts[].host` path; resolution happens at parse
|
|
25
|
+
* time so the resulting `MountConfig` always carries an absolute host
|
|
26
|
+
* path.
|
|
27
|
+
*/
|
|
28
|
+
configDir?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
const AGENT_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/
|
|
21
32
|
const MATRIX_USER_ID_RE = /^@[A-Za-z0-9._\-=/+]+:[A-Za-z0-9.\-]+$/
|
|
22
33
|
const MATRIX_USER_LOCALPART_RE = /^@[a-z0-9._=/+\-]+$/
|
|
@@ -117,6 +128,7 @@ function parseAgentContainer(
|
|
|
117
128
|
name: string,
|
|
118
129
|
raw: unknown,
|
|
119
130
|
processEnv: NodeJS.ProcessEnv,
|
|
131
|
+
configDir: string | undefined,
|
|
120
132
|
): ContainerConfig {
|
|
121
133
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
122
134
|
throw new Error(`agents.${name}.container must be a mapping`)
|
|
@@ -145,6 +157,145 @@ function parseAgentContainer(
|
|
|
145
157
|
}
|
|
146
158
|
out.env = interpolateEnv(stringEnv, processEnv, `agents.${name}.container.env`)
|
|
147
159
|
}
|
|
160
|
+
if (r.mounts !== undefined) {
|
|
161
|
+
out.mounts = parseMountList(name, r.mounts, processEnv, configDir)
|
|
162
|
+
}
|
|
163
|
+
if (r.disable_mounts !== undefined) {
|
|
164
|
+
out.disable_mounts = parseDisableMounts(name, r.disable_mounts)
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseMountList(
|
|
170
|
+
agentName: string,
|
|
171
|
+
raw: unknown,
|
|
172
|
+
processEnv: NodeJS.ProcessEnv,
|
|
173
|
+
configDir: string | undefined,
|
|
174
|
+
): MountConfig[] {
|
|
175
|
+
if (!Array.isArray(raw)) {
|
|
176
|
+
throw new Error(`agents.${agentName}.container.mounts must be an array`)
|
|
177
|
+
}
|
|
178
|
+
const out: MountConfig[] = []
|
|
179
|
+
const seenIds = new Set<string>()
|
|
180
|
+
for (let i = 0; i < raw.length; i++) {
|
|
181
|
+
const entry = raw[i]
|
|
182
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
183
|
+
throw new Error(`agents.${agentName}.container.mounts[${i}] must be a mapping`)
|
|
184
|
+
}
|
|
185
|
+
const e = entry as Record<string, unknown>
|
|
186
|
+
if (e.host === undefined) {
|
|
187
|
+
throw new Error(`agents.${agentName}.container.mounts[${i}].host is required`)
|
|
188
|
+
}
|
|
189
|
+
if (e.target === undefined) {
|
|
190
|
+
throw new Error(`agents.${agentName}.container.mounts[${i}].target is required`)
|
|
191
|
+
}
|
|
192
|
+
if (typeof e.host !== 'string' || e.host.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`agents.${agentName}.container.mounts[${i}].host must be a non-empty string`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
if (typeof e.target !== 'string' || e.target.length === 0) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`agents.${agentName}.container.mounts[${i}].target must be a non-empty string`,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
const mode = e.mode ?? 'rw'
|
|
203
|
+
if (mode !== 'ro' && mode !== 'rw') {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`agents.${agentName}.container.mounts[${i}].mode must be "ro" or "rw" (got ${JSON.stringify(e.mode)})`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
let id: string | undefined
|
|
209
|
+
if (e.id !== undefined) {
|
|
210
|
+
if (typeof e.id !== 'string' || e.id.length === 0) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`agents.${agentName}.container.mounts[${i}].id must be a non-empty string`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if (e.id === 'workspace') {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`agents.${agentName}.container.mounts[${i}].id: "workspace" is a reserved id (set by the workspace auto-mount). Use a different id or rely on disable_mounts to subtract.`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
if (seenIds.has(e.id)) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`agents.${agentName}.container.mounts: duplicate id "${e.id}"`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
seenIds.add(e.id)
|
|
226
|
+
id = e.id
|
|
227
|
+
}
|
|
228
|
+
let create: boolean | undefined
|
|
229
|
+
if (e.create !== undefined) {
|
|
230
|
+
if (typeof e.create !== 'boolean') {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`agents.${agentName}.container.mounts[${i}].create must be a boolean`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
create = e.create
|
|
236
|
+
}
|
|
237
|
+
const host = resolveHostPath(
|
|
238
|
+
agentName,
|
|
239
|
+
i,
|
|
240
|
+
interpolateString(e.host, processEnv),
|
|
241
|
+
configDir,
|
|
242
|
+
)
|
|
243
|
+
const target = interpolateString(e.target, processEnv)
|
|
244
|
+
const m: MountConfig = { host, target, mode }
|
|
245
|
+
if (id !== undefined) m.id = id
|
|
246
|
+
if (create !== undefined) m.create = create
|
|
247
|
+
out.push(m)
|
|
248
|
+
}
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function resolveHostPath(
|
|
253
|
+
agentName: string,
|
|
254
|
+
index: number,
|
|
255
|
+
host: string,
|
|
256
|
+
configDir: string | undefined,
|
|
257
|
+
): string {
|
|
258
|
+
if (host.startsWith('~/')) {
|
|
259
|
+
const home = process.env.HOME
|
|
260
|
+
if (!home) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`agents.${agentName}.container.mounts[${index}].host: cannot expand ~ — $HOME is not set`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
return `${home}/${host.slice(2)}`
|
|
266
|
+
}
|
|
267
|
+
if (host === '~') {
|
|
268
|
+
const home = process.env.HOME
|
|
269
|
+
if (!home) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`agents.${agentName}.container.mounts[${index}].host: cannot expand ~ — $HOME is not set`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
return home
|
|
275
|
+
}
|
|
276
|
+
if (isAbsolute(host)) return host
|
|
277
|
+
if (!configDir) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`agents.${agentName}.container.mounts[${index}]: relative host path "${host}" requires configDir (zooid.yaml directory) — pass it via loadZooidConfig(yaml, { configDir })`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
return pathResolve(configDir, host)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseDisableMounts(agentName: string, raw: unknown): string[] {
|
|
286
|
+
if (!Array.isArray(raw)) {
|
|
287
|
+
throw new Error(`agents.${agentName}.container.disable_mounts must be an array of strings`)
|
|
288
|
+
}
|
|
289
|
+
const out: string[] = []
|
|
290
|
+
for (let i = 0; i < raw.length; i++) {
|
|
291
|
+
const v = raw[i]
|
|
292
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`agents.${agentName}.container.disable_mounts[${i}] must be a non-empty string`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
out.push(v)
|
|
298
|
+
}
|
|
148
299
|
return out
|
|
149
300
|
}
|
|
150
301
|
|
|
@@ -439,6 +590,7 @@ function parseAgents(
|
|
|
439
590
|
transports: Record<string, TransportConfig>,
|
|
440
591
|
daemonHooks: { pre_turn?: string; post_turn?: string },
|
|
441
592
|
processEnv: NodeJS.ProcessEnv,
|
|
593
|
+
configDir: string | undefined,
|
|
442
594
|
): Record<string, AgentConfig> {
|
|
443
595
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
444
596
|
throw new Error('agents: must be a mapping')
|
|
@@ -523,14 +675,26 @@ function parseAgents(
|
|
|
523
675
|
let containerBlock: ContainerConfig | undefined
|
|
524
676
|
if (entry.container !== undefined && entry.container !== null) {
|
|
525
677
|
if (runtime === 'local') {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
678
|
+
// Under runtime: local, the parser only accepts mounts/disable_mounts
|
|
679
|
+
// (which the compose layer ignores). image/env stay rejected because
|
|
680
|
+
// they would silently lie: there's no container and the host inherits
|
|
681
|
+
// the daemon's full process.env regardless.
|
|
682
|
+
if (typeof entry.container !== 'object' || entry.container === null || Array.isArray(entry.container)) {
|
|
683
|
+
throw new Error(`agents.${name}.container must be a mapping`)
|
|
684
|
+
}
|
|
685
|
+
const c = entry.container as Record<string, unknown>
|
|
686
|
+
const disallowed = Object.keys(c).filter((k) => k !== 'mounts' && k !== 'disable_mounts')
|
|
687
|
+
if (disallowed.length > 0) {
|
|
688
|
+
throw new Error(
|
|
689
|
+
`agents.${name}.container.${disallowed[0]} is only valid when runtime is 'docker' or 'podman'. ` +
|
|
690
|
+
`runtime: local spawns agents as host child processes — there is no container, ` +
|
|
691
|
+
`so 'image' is inert and 'env' would silently lie (the agent inherits the daemon's ` +
|
|
692
|
+
`full process.env regardless). 'mounts' and 'disable_mounts' are accepted under ` +
|
|
693
|
+
`runtime: local but ignored at compose time.`,
|
|
694
|
+
)
|
|
695
|
+
}
|
|
532
696
|
}
|
|
533
|
-
containerBlock = parseAgentContainer(name, entry.container, processEnv)
|
|
697
|
+
containerBlock = parseAgentContainer(name, entry.container, processEnv, configDir)
|
|
534
698
|
}
|
|
535
699
|
|
|
536
700
|
const binding = parseTransportBinding(name, entry, transports)
|
|
@@ -568,7 +732,10 @@ function zooidHooks(raw: Record<string, unknown>): { pre_turn?: string; post_tur
|
|
|
568
732
|
return out
|
|
569
733
|
}
|
|
570
734
|
|
|
571
|
-
export function loadZooidConfig(
|
|
735
|
+
export function loadZooidConfig(
|
|
736
|
+
yamlText: string,
|
|
737
|
+
opts: LoadZooidConfigOptions = {},
|
|
738
|
+
): ZooidConfig {
|
|
572
739
|
const raw = parse(yamlText) ?? {}
|
|
573
740
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
574
741
|
throw new Error('zooid.yaml must be a YAML object')
|
|
@@ -604,7 +771,7 @@ export function loadZooidConfig(yamlText: string): ZooidConfig {
|
|
|
604
771
|
const processEnv = process.env
|
|
605
772
|
const transports = parseTransports(r.transports, processEnv)
|
|
606
773
|
const hooks = zooidHooks(r)
|
|
607
|
-
const agents = parseAgents(r.agents, runtime, transports, hooks, processEnv)
|
|
774
|
+
const agents = parseAgents(r.agents, runtime, transports, hooks, processEnv, opts.configDir)
|
|
608
775
|
|
|
609
776
|
const cfg: ZooidConfig = {
|
|
610
777
|
runtime,
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { loadZooidConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
const baseTransports = `
|
|
5
|
+
transports:
|
|
6
|
+
m1:
|
|
7
|
+
type: matrix
|
|
8
|
+
homeserver: http://localhost:8448
|
|
9
|
+
as_token: t
|
|
10
|
+
hs_token: h
|
|
11
|
+
sender_localpart: z
|
|
12
|
+
user_namespace: '@.*:localhost'
|
|
13
|
+
`
|
|
14
|
+
|
|
15
|
+
const matrixAgent = (extra = ''): string => `
|
|
16
|
+
alice:
|
|
17
|
+
workdir: ./agents/alice
|
|
18
|
+
acp: { preset: claude }
|
|
19
|
+
matrix:
|
|
20
|
+
transport: m1
|
|
21
|
+
user_id: '@alice:localhost'
|
|
22
|
+
rooms: ['!r:localhost']${extra}
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const wrap = (extra: string): string => `
|
|
26
|
+
runtime: docker
|
|
27
|
+
${baseTransports}
|
|
28
|
+
agents:${matrixAgent(extra)}
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
describe('container.mounts — shape', () => {
|
|
32
|
+
it('parses a minimal mount with default mode rw', () => {
|
|
33
|
+
const cfg = loadZooidConfig(
|
|
34
|
+
wrap(`
|
|
35
|
+
container:
|
|
36
|
+
mounts:
|
|
37
|
+
- host: /var/run/docker.sock
|
|
38
|
+
target: /var/run/docker.sock`),
|
|
39
|
+
{ configDir: '/tmp' },
|
|
40
|
+
)
|
|
41
|
+
expect(cfg.agents.alice!.container?.mounts).toEqual([
|
|
42
|
+
{ host: '/var/run/docker.sock', target: '/var/run/docker.sock', mode: 'rw' },
|
|
43
|
+
])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('honours explicit mode: ro', () => {
|
|
47
|
+
const cfg = loadZooidConfig(
|
|
48
|
+
wrap(`
|
|
49
|
+
container:
|
|
50
|
+
mounts:
|
|
51
|
+
- host: /etc/hosts
|
|
52
|
+
target: /etc/hosts
|
|
53
|
+
mode: ro`),
|
|
54
|
+
{ configDir: '/tmp' },
|
|
55
|
+
)
|
|
56
|
+
expect(cfg.agents.alice!.container?.mounts?.[0]?.mode).toBe('ro')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('rejects mode other than ro/rw', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
loadZooidConfig(
|
|
62
|
+
wrap(`
|
|
63
|
+
container:
|
|
64
|
+
mounts:
|
|
65
|
+
- host: /a
|
|
66
|
+
target: /a
|
|
67
|
+
mode: rwx`),
|
|
68
|
+
{ configDir: '/tmp' },
|
|
69
|
+
),
|
|
70
|
+
).toThrow(/mode must be "ro" or "rw"/i)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('rejects entries missing host or target', () => {
|
|
74
|
+
expect(() =>
|
|
75
|
+
loadZooidConfig(
|
|
76
|
+
wrap(`
|
|
77
|
+
container:
|
|
78
|
+
mounts:
|
|
79
|
+
- target: /a`),
|
|
80
|
+
{ configDir: '/tmp' },
|
|
81
|
+
),
|
|
82
|
+
).toThrow(/host.*required/i)
|
|
83
|
+
expect(() =>
|
|
84
|
+
loadZooidConfig(
|
|
85
|
+
wrap(`
|
|
86
|
+
container:
|
|
87
|
+
mounts:
|
|
88
|
+
- host: /a`),
|
|
89
|
+
{ configDir: '/tmp' },
|
|
90
|
+
),
|
|
91
|
+
).toThrow(/target.*required/i)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('rejects duplicate user-declared ids', () => {
|
|
95
|
+
expect(() =>
|
|
96
|
+
loadZooidConfig(
|
|
97
|
+
wrap(`
|
|
98
|
+
container:
|
|
99
|
+
mounts:
|
|
100
|
+
- id: x
|
|
101
|
+
host: /a
|
|
102
|
+
target: /a
|
|
103
|
+
- id: x
|
|
104
|
+
host: /b
|
|
105
|
+
target: /b`),
|
|
106
|
+
{ configDir: '/tmp' },
|
|
107
|
+
),
|
|
108
|
+
).toThrow(/duplicate.*id.*"x"/i)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('rejects the reserved id "workspace" on user entries', () => {
|
|
112
|
+
expect(() =>
|
|
113
|
+
loadZooidConfig(
|
|
114
|
+
wrap(`
|
|
115
|
+
container:
|
|
116
|
+
mounts:
|
|
117
|
+
- id: workspace
|
|
118
|
+
host: /a
|
|
119
|
+
target: /a`),
|
|
120
|
+
{ configDir: '/tmp' },
|
|
121
|
+
),
|
|
122
|
+
).toThrow(/reserved id.*workspace/i)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('container.mounts — host path resolution', () => {
|
|
127
|
+
it('keeps absolute paths verbatim', () => {
|
|
128
|
+
const cfg = loadZooidConfig(
|
|
129
|
+
wrap(`
|
|
130
|
+
container:
|
|
131
|
+
mounts:
|
|
132
|
+
- host: /var/lib/foo
|
|
133
|
+
target: /opt/foo`),
|
|
134
|
+
{ configDir: '/some/where' },
|
|
135
|
+
)
|
|
136
|
+
expect(cfg.agents.alice!.container?.mounts?.[0]?.host).toBe('/var/lib/foo')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('expands ~/... against $HOME', () => {
|
|
140
|
+
const home = process.env.HOME!
|
|
141
|
+
const cfg = loadZooidConfig(
|
|
142
|
+
wrap(`
|
|
143
|
+
container:
|
|
144
|
+
mounts:
|
|
145
|
+
- host: ~/.cache/zooid
|
|
146
|
+
target: /cache`),
|
|
147
|
+
{ configDir: '/tmp' },
|
|
148
|
+
)
|
|
149
|
+
expect(cfg.agents.alice!.container?.mounts?.[0]?.host).toBe(`${home}/.cache/zooid`)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('resolves relative paths against configDir', () => {
|
|
153
|
+
const cfg = loadZooidConfig(
|
|
154
|
+
wrap(`
|
|
155
|
+
container:
|
|
156
|
+
mounts:
|
|
157
|
+
- host: ./shared
|
|
158
|
+
target: /shared`),
|
|
159
|
+
{ configDir: '/example/path' },
|
|
160
|
+
)
|
|
161
|
+
expect(cfg.agents.alice!.container?.mounts?.[0]?.host).toBe('/example/path/shared')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('throws on relative host path when configDir is omitted', () => {
|
|
165
|
+
expect(() =>
|
|
166
|
+
loadZooidConfig(
|
|
167
|
+
wrap(`
|
|
168
|
+
container:
|
|
169
|
+
mounts:
|
|
170
|
+
- host: ./shared
|
|
171
|
+
target: /shared`),
|
|
172
|
+
// no configDir
|
|
173
|
+
),
|
|
174
|
+
).toThrow(/relative.*host.*configDir/i)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('container.mounts — ${VAR} interpolation', () => {
|
|
179
|
+
let saved: NodeJS.ProcessEnv
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
saved = { ...process.env }
|
|
182
|
+
})
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
for (const k of Object.keys(process.env)) delete process.env[k]
|
|
185
|
+
Object.assign(process.env, saved)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('expands ${VAR} in host and target', () => {
|
|
189
|
+
process.env.SHARED = '/srv/shared'
|
|
190
|
+
process.env.MOUNTPT = '/mnt/shared'
|
|
191
|
+
const cfg = loadZooidConfig(
|
|
192
|
+
wrap(`
|
|
193
|
+
container:
|
|
194
|
+
mounts:
|
|
195
|
+
- host: \${SHARED}
|
|
196
|
+
target: \${MOUNTPT}`),
|
|
197
|
+
{ configDir: '/tmp' },
|
|
198
|
+
)
|
|
199
|
+
expect(cfg.agents.alice!.container?.mounts?.[0]).toMatchObject({
|
|
200
|
+
host: '/srv/shared',
|
|
201
|
+
target: '/mnt/shared',
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('container.disable_mounts', () => {
|
|
207
|
+
it('parses a list of strings', () => {
|
|
208
|
+
const cfg = loadZooidConfig(
|
|
209
|
+
wrap(`
|
|
210
|
+
container:
|
|
211
|
+
disable_mounts: [history, workspace]`),
|
|
212
|
+
{ configDir: '/tmp' },
|
|
213
|
+
)
|
|
214
|
+
expect(cfg.agents.alice!.container?.disable_mounts).toEqual(['history', 'workspace'])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('rejects empty strings', () => {
|
|
218
|
+
expect(() =>
|
|
219
|
+
loadZooidConfig(
|
|
220
|
+
wrap(`
|
|
221
|
+
container:
|
|
222
|
+
disable_mounts: ['', 'history']`),
|
|
223
|
+
{ configDir: '/tmp' },
|
|
224
|
+
),
|
|
225
|
+
).toThrow(/disable_mounts.*non-empty string/i)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('runtime: local + container.mounts', () => {
|
|
230
|
+
it('accepts mounts under runtime: local without error (silently ignored at compose time)', () => {
|
|
231
|
+
const cfg = loadZooidConfig(
|
|
232
|
+
`runtime: local
|
|
233
|
+
${baseTransports}
|
|
234
|
+
agents:
|
|
235
|
+
alice:
|
|
236
|
+
workdir: ./agents/alice
|
|
237
|
+
acp: { preset: claude }
|
|
238
|
+
matrix:
|
|
239
|
+
transport: m1
|
|
240
|
+
user_id: '@alice:localhost'
|
|
241
|
+
rooms: ['!r:localhost']
|
|
242
|
+
container:
|
|
243
|
+
mounts:
|
|
244
|
+
- host: /a
|
|
245
|
+
target: /a
|
|
246
|
+
disable_mounts: [memory]
|
|
247
|
+
`,
|
|
248
|
+
{ configDir: '/tmp' },
|
|
249
|
+
)
|
|
250
|
+
expect(cfg.agents.alice!.container?.mounts).toHaveLength(1)
|
|
251
|
+
expect(cfg.agents.alice!.container?.disable_mounts).toEqual(['memory'])
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('post-migration shape — relative workdir + image + env interpolation', () => {
|
|
256
|
+
it('parses an opencode reviewer agent with relative workdir and ${VAR} env', () => {
|
|
257
|
+
const yaml = `
|
|
258
|
+
runtime: docker
|
|
259
|
+
|
|
260
|
+
transports:
|
|
261
|
+
matrix:
|
|
262
|
+
type: matrix
|
|
263
|
+
homeserver: http://localhost:8448
|
|
264
|
+
as_token: \${MATRIX_AS_TOKEN}
|
|
265
|
+
hs_token: \${MATRIX_HS_TOKEN}
|
|
266
|
+
sender_localpart: zooid
|
|
267
|
+
user_namespace: '@.*:localhost'
|
|
268
|
+
port: 9099
|
|
269
|
+
|
|
270
|
+
agents:
|
|
271
|
+
reviewer:
|
|
272
|
+
workdir: ./agents/reviewer
|
|
273
|
+
acp:
|
|
274
|
+
preset: opencode
|
|
275
|
+
container:
|
|
276
|
+
image: zooid-opencode-vertex:smoke
|
|
277
|
+
env:
|
|
278
|
+
GOOGLE_VERTEX_API_KEY: \${GOOGLE_VERTEX_API_KEY}
|
|
279
|
+
matrix:
|
|
280
|
+
transport: matrix
|
|
281
|
+
user_id: '@reviewer:localhost'
|
|
282
|
+
rooms:
|
|
283
|
+
- '#review:localhost'
|
|
284
|
+
trigger: mention
|
|
285
|
+
`
|
|
286
|
+
process.env.MATRIX_AS_TOKEN ??= 'fixture-as'
|
|
287
|
+
process.env.MATRIX_HS_TOKEN ??= 'fixture-hs'
|
|
288
|
+
const cfg = loadZooidConfig(yaml, { configDir: '/example/path' })
|
|
289
|
+
expect(cfg.agents.reviewer!.workdir).toBe('./agents/reviewer')
|
|
290
|
+
expect(cfg.agents.reviewer!.container?.image).toBe('zooid-opencode-vertex:smoke')
|
|
291
|
+
})
|
|
292
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
findHttpTransport,
|
|
7
7
|
findConfigFile,
|
|
8
8
|
} from './config.js'
|
|
9
|
+
export type { LoadZooidConfigOptions } from './config.js'
|
|
9
10
|
export {
|
|
10
11
|
AcpAgentRegistry,
|
|
11
12
|
resolveAcpAgentSpec,
|
|
@@ -32,6 +33,7 @@ export type {
|
|
|
32
33
|
export type {
|
|
33
34
|
AgentConfig,
|
|
34
35
|
ContainerConfig,
|
|
36
|
+
MountConfig,
|
|
35
37
|
ZooidContainerConfig,
|
|
36
38
|
MatrixBinding,
|
|
37
39
|
HttpBinding,
|
package/src/types.ts
CHANGED
|
@@ -29,9 +29,26 @@ export interface Transport {
|
|
|
29
29
|
reply(thread: ThreadRef, message: string): Promise<void> | void
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* One bind mount. `id` is the handle used by `disable_mounts` to subtract
|
|
34
|
+
* a zooid- or preset-declared entry; user-declared entries that omit `id`
|
|
35
|
+
* are auto-assigned `user-N`. The reserved id `workspace` is rejected on
|
|
36
|
+
* user entries.
|
|
37
|
+
*/
|
|
38
|
+
export interface MountConfig {
|
|
39
|
+
id?: string
|
|
40
|
+
host: string
|
|
41
|
+
target: string
|
|
42
|
+
mode: 'ro' | 'rw'
|
|
43
|
+
/** mkdir -p the host path before bind-mounting. Default false. */
|
|
44
|
+
create?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
/**
|
|
33
48
|
* Per-agent container configuration. Holds runtime-neutral container
|
|
34
|
-
* concerns — image, env.
|
|
49
|
+
* concerns — image, env, mounts. `image` / `env` are rejected at parse time
|
|
50
|
+
* when `runtime: local`; `mounts` / `disable_mounts` are accepted under
|
|
51
|
+
* `runtime: local` but ignored at compose time.
|
|
35
52
|
*/
|
|
36
53
|
export interface ContainerConfig {
|
|
37
54
|
image?: string
|
|
@@ -42,6 +59,13 @@ export interface ContainerConfig {
|
|
|
42
59
|
* `ZOOID_*` references and `ZOOID_*` keys are rejected.
|
|
43
60
|
*/
|
|
44
61
|
env?: Record<string, string>
|
|
62
|
+
/** User-declared mounts, layered on top of workspace + preset. */
|
|
63
|
+
mounts?: MountConfig[]
|
|
64
|
+
/**
|
|
65
|
+
* Subtractive override by mount id. Built-in ids: `workspace` plus
|
|
66
|
+
* whatever each preset declares (canonical: `memory`, `history`, `config`).
|
|
67
|
+
*/
|
|
68
|
+
disable_mounts?: string[]
|
|
45
69
|
}
|
|
46
70
|
|
|
47
71
|
/**
|