@zooid/core 0.7.0 → 0.7.2
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 +84 -8
- package/dist/index.js +193 -21
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/acp-registry.ts +38 -2
- package/src/config.test.ts +88 -9
- package/src/config.ts +215 -21
- package/src/container-mounts.test.ts +292 -0
- package/src/index.ts +3 -0
- package/src/types.ts +57 -6
- package/src/zooid-yaml-sweep.test.ts +1 -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,23 @@ import type {
|
|
|
12
12
|
HttpTransportConfig,
|
|
13
13
|
MatrixBinding,
|
|
14
14
|
MatrixTransportConfig,
|
|
15
|
+
MountConfig,
|
|
16
|
+
RoomBinding,
|
|
15
17
|
TransportConfig,
|
|
16
18
|
ZooidConfig,
|
|
17
19
|
ZooidContainerConfig,
|
|
18
20
|
} from './types.js'
|
|
19
21
|
|
|
22
|
+
export interface LoadZooidConfigOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Directory containing zooid.yaml. Required when any agent uses a
|
|
25
|
+
* relative `container.mounts[].host` path; resolution happens at parse
|
|
26
|
+
* time so the resulting `MountConfig` always carries an absolute host
|
|
27
|
+
* path.
|
|
28
|
+
*/
|
|
29
|
+
configDir?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
const AGENT_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/
|
|
21
33
|
const MATRIX_USER_ID_RE = /^@[A-Za-z0-9._\-=/+]+:[A-Za-z0-9.\-]+$/
|
|
22
34
|
const MATRIX_USER_LOCALPART_RE = /^@[a-z0-9._=/+\-]+$/
|
|
@@ -117,6 +129,7 @@ function parseAgentContainer(
|
|
|
117
129
|
name: string,
|
|
118
130
|
raw: unknown,
|
|
119
131
|
processEnv: NodeJS.ProcessEnv,
|
|
132
|
+
configDir: string | undefined,
|
|
120
133
|
): ContainerConfig {
|
|
121
134
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
122
135
|
throw new Error(`agents.${name}.container must be a mapping`)
|
|
@@ -145,6 +158,145 @@ function parseAgentContainer(
|
|
|
145
158
|
}
|
|
146
159
|
out.env = interpolateEnv(stringEnv, processEnv, `agents.${name}.container.env`)
|
|
147
160
|
}
|
|
161
|
+
if (r.mounts !== undefined) {
|
|
162
|
+
out.mounts = parseMountList(name, r.mounts, processEnv, configDir)
|
|
163
|
+
}
|
|
164
|
+
if (r.disable_mounts !== undefined) {
|
|
165
|
+
out.disable_mounts = parseDisableMounts(name, r.disable_mounts)
|
|
166
|
+
}
|
|
167
|
+
return out
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseMountList(
|
|
171
|
+
agentName: string,
|
|
172
|
+
raw: unknown,
|
|
173
|
+
processEnv: NodeJS.ProcessEnv,
|
|
174
|
+
configDir: string | undefined,
|
|
175
|
+
): MountConfig[] {
|
|
176
|
+
if (!Array.isArray(raw)) {
|
|
177
|
+
throw new Error(`agents.${agentName}.container.mounts must be an array`)
|
|
178
|
+
}
|
|
179
|
+
const out: MountConfig[] = []
|
|
180
|
+
const seenIds = new Set<string>()
|
|
181
|
+
for (let i = 0; i < raw.length; i++) {
|
|
182
|
+
const entry = raw[i]
|
|
183
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
184
|
+
throw new Error(`agents.${agentName}.container.mounts[${i}] must be a mapping`)
|
|
185
|
+
}
|
|
186
|
+
const e = entry as Record<string, unknown>
|
|
187
|
+
if (e.host === undefined) {
|
|
188
|
+
throw new Error(`agents.${agentName}.container.mounts[${i}].host is required`)
|
|
189
|
+
}
|
|
190
|
+
if (e.target === undefined) {
|
|
191
|
+
throw new Error(`agents.${agentName}.container.mounts[${i}].target is required`)
|
|
192
|
+
}
|
|
193
|
+
if (typeof e.host !== 'string' || e.host.length === 0) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`agents.${agentName}.container.mounts[${i}].host must be a non-empty string`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
if (typeof e.target !== 'string' || e.target.length === 0) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`agents.${agentName}.container.mounts[${i}].target must be a non-empty string`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
const mode = e.mode ?? 'rw'
|
|
204
|
+
if (mode !== 'ro' && mode !== 'rw') {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`agents.${agentName}.container.mounts[${i}].mode must be "ro" or "rw" (got ${JSON.stringify(e.mode)})`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
let id: string | undefined
|
|
210
|
+
if (e.id !== undefined) {
|
|
211
|
+
if (typeof e.id !== 'string' || e.id.length === 0) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`agents.${agentName}.container.mounts[${i}].id must be a non-empty string`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
if (e.id === 'workspace') {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`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.`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
if (seenIds.has(e.id)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`agents.${agentName}.container.mounts: duplicate id "${e.id}"`,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
seenIds.add(e.id)
|
|
227
|
+
id = e.id
|
|
228
|
+
}
|
|
229
|
+
let create: boolean | undefined
|
|
230
|
+
if (e.create !== undefined) {
|
|
231
|
+
if (typeof e.create !== 'boolean') {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`agents.${agentName}.container.mounts[${i}].create must be a boolean`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
create = e.create
|
|
237
|
+
}
|
|
238
|
+
const host = resolveHostPath(
|
|
239
|
+
agentName,
|
|
240
|
+
i,
|
|
241
|
+
interpolateString(e.host, processEnv),
|
|
242
|
+
configDir,
|
|
243
|
+
)
|
|
244
|
+
const target = interpolateString(e.target, processEnv)
|
|
245
|
+
const m: MountConfig = { host, target, mode }
|
|
246
|
+
if (id !== undefined) m.id = id
|
|
247
|
+
if (create !== undefined) m.create = create
|
|
248
|
+
out.push(m)
|
|
249
|
+
}
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function resolveHostPath(
|
|
254
|
+
agentName: string,
|
|
255
|
+
index: number,
|
|
256
|
+
host: string,
|
|
257
|
+
configDir: string | undefined,
|
|
258
|
+
): string {
|
|
259
|
+
if (host.startsWith('~/')) {
|
|
260
|
+
const home = process.env.HOME
|
|
261
|
+
if (!home) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`agents.${agentName}.container.mounts[${index}].host: cannot expand ~ — $HOME is not set`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
return `${home}/${host.slice(2)}`
|
|
267
|
+
}
|
|
268
|
+
if (host === '~') {
|
|
269
|
+
const home = process.env.HOME
|
|
270
|
+
if (!home) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`agents.${agentName}.container.mounts[${index}].host: cannot expand ~ — $HOME is not set`,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
return home
|
|
276
|
+
}
|
|
277
|
+
if (isAbsolute(host)) return host
|
|
278
|
+
if (!configDir) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`agents.${agentName}.container.mounts[${index}]: relative host path "${host}" requires configDir (zooid.yaml directory) — pass it via loadZooidConfig(yaml, { configDir })`,
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
return pathResolve(configDir, host)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseDisableMounts(agentName: string, raw: unknown): string[] {
|
|
287
|
+
if (!Array.isArray(raw)) {
|
|
288
|
+
throw new Error(`agents.${agentName}.container.disable_mounts must be an array of strings`)
|
|
289
|
+
}
|
|
290
|
+
const out: string[] = []
|
|
291
|
+
for (let i = 0; i < raw.length; i++) {
|
|
292
|
+
const v = raw[i]
|
|
293
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`agents.${agentName}.container.disable_mounts[${i}] must be a non-empty string`,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
out.push(v)
|
|
299
|
+
}
|
|
148
300
|
return out
|
|
149
301
|
}
|
|
150
302
|
|
|
@@ -301,6 +453,40 @@ function parseTransport(
|
|
|
301
453
|
return { type: 'http', port }
|
|
302
454
|
}
|
|
303
455
|
|
|
456
|
+
function parseRoomBinding(path: string, raw: unknown, serverName: string): RoomBinding {
|
|
457
|
+
function normalizeAlias(alias: string): string {
|
|
458
|
+
if (alias.length === 0) {
|
|
459
|
+
throw new Error(`${path}: must be a non-empty alias`)
|
|
460
|
+
}
|
|
461
|
+
if (!MATRIX_ROOM_IDENT_RE.test(alias)) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`${path}: must start with '#' or '!' (got ${JSON.stringify(alias)})`,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
return alias.includes(':') ? alias : `${alias}:${serverName}`
|
|
467
|
+
}
|
|
468
|
+
if (typeof raw === 'string') {
|
|
469
|
+
return { alias: normalizeAlias(raw) }
|
|
470
|
+
}
|
|
471
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
472
|
+
throw new Error(`${path}: must be a string or { alias, power_level } object`)
|
|
473
|
+
}
|
|
474
|
+
const r = raw as Record<string, unknown>
|
|
475
|
+
if (typeof r.alias !== 'string' || r.alias.length === 0) {
|
|
476
|
+
throw new Error(`${path}.alias: must be a non-empty string`)
|
|
477
|
+
}
|
|
478
|
+
const out: RoomBinding = { alias: normalizeAlias(r.alias) }
|
|
479
|
+
if (r.power_level !== undefined) {
|
|
480
|
+
if (typeof r.power_level !== 'number' || !Number.isInteger(r.power_level)) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`${path}.power_level: must be an integer (got ${JSON.stringify(r.power_level)})`,
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
out.powerLevel = r.power_level
|
|
486
|
+
}
|
|
487
|
+
return out
|
|
488
|
+
}
|
|
489
|
+
|
|
304
490
|
function parseTransportBinding(
|
|
305
491
|
name: string,
|
|
306
492
|
entry: Record<string, unknown>,
|
|
@@ -384,17 +570,9 @@ function parseTransportBinding(
|
|
|
384
570
|
if (!Array.isArray(block.rooms) || block.rooms.length === 0) {
|
|
385
571
|
throw new Error(`agents.${name}.matrix.rooms is required and must be a non-empty array`)
|
|
386
572
|
}
|
|
387
|
-
const rooms:
|
|
388
|
-
for (
|
|
389
|
-
|
|
390
|
-
throw new Error(`agents.${name}.matrix.rooms[] must be a non-empty string`)
|
|
391
|
-
}
|
|
392
|
-
if (!MATRIX_ROOM_IDENT_RE.test(r)) {
|
|
393
|
-
throw new Error(
|
|
394
|
-
`agents.${name}.matrix.rooms[] must start with '#' or '!' (got ${JSON.stringify(r)})`,
|
|
395
|
-
)
|
|
396
|
-
}
|
|
397
|
-
rooms.push(r.includes(':') ? r : `${r}:${serverName}`)
|
|
573
|
+
const rooms: RoomBinding[] = []
|
|
574
|
+
for (let i = 0; i < block.rooms.length; i++) {
|
|
575
|
+
rooms.push(parseRoomBinding(`agents.${name}.matrix.rooms[${i}]`, block.rooms[i], serverName))
|
|
398
576
|
}
|
|
399
577
|
|
|
400
578
|
let displayName: string | undefined
|
|
@@ -439,6 +617,7 @@ function parseAgents(
|
|
|
439
617
|
transports: Record<string, TransportConfig>,
|
|
440
618
|
daemonHooks: { pre_turn?: string; post_turn?: string },
|
|
441
619
|
processEnv: NodeJS.ProcessEnv,
|
|
620
|
+
configDir: string | undefined,
|
|
442
621
|
): Record<string, AgentConfig> {
|
|
443
622
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
444
623
|
throw new Error('agents: must be a mapping')
|
|
@@ -523,14 +702,26 @@ function parseAgents(
|
|
|
523
702
|
let containerBlock: ContainerConfig | undefined
|
|
524
703
|
if (entry.container !== undefined && entry.container !== null) {
|
|
525
704
|
if (runtime === 'local') {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
705
|
+
// Under runtime: local, the parser only accepts mounts/disable_mounts
|
|
706
|
+
// (which the compose layer ignores). image/env stay rejected because
|
|
707
|
+
// they would silently lie: there's no container and the host inherits
|
|
708
|
+
// the daemon's full process.env regardless.
|
|
709
|
+
if (typeof entry.container !== 'object' || entry.container === null || Array.isArray(entry.container)) {
|
|
710
|
+
throw new Error(`agents.${name}.container must be a mapping`)
|
|
711
|
+
}
|
|
712
|
+
const c = entry.container as Record<string, unknown>
|
|
713
|
+
const disallowed = Object.keys(c).filter((k) => k !== 'mounts' && k !== 'disable_mounts')
|
|
714
|
+
if (disallowed.length > 0) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`agents.${name}.container.${disallowed[0]} is only valid when runtime is 'docker' or 'podman'. ` +
|
|
717
|
+
`runtime: local spawns agents as host child processes — there is no container, ` +
|
|
718
|
+
`so 'image' is inert and 'env' would silently lie (the agent inherits the daemon's ` +
|
|
719
|
+
`full process.env regardless). 'mounts' and 'disable_mounts' are accepted under ` +
|
|
720
|
+
`runtime: local but ignored at compose time.`,
|
|
721
|
+
)
|
|
722
|
+
}
|
|
532
723
|
}
|
|
533
|
-
containerBlock = parseAgentContainer(name, entry.container, processEnv)
|
|
724
|
+
containerBlock = parseAgentContainer(name, entry.container, processEnv, configDir)
|
|
534
725
|
}
|
|
535
726
|
|
|
536
727
|
const binding = parseTransportBinding(name, entry, transports)
|
|
@@ -568,7 +759,10 @@ function zooidHooks(raw: Record<string, unknown>): { pre_turn?: string; post_tur
|
|
|
568
759
|
return out
|
|
569
760
|
}
|
|
570
761
|
|
|
571
|
-
export function loadZooidConfig(
|
|
762
|
+
export function loadZooidConfig(
|
|
763
|
+
yamlText: string,
|
|
764
|
+
opts: LoadZooidConfigOptions = {},
|
|
765
|
+
): ZooidConfig {
|
|
572
766
|
const raw = parse(yamlText) ?? {}
|
|
573
767
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
574
768
|
throw new Error('zooid.yaml must be a YAML object')
|
|
@@ -604,7 +798,7 @@ export function loadZooidConfig(yamlText: string): ZooidConfig {
|
|
|
604
798
|
const processEnv = process.env
|
|
605
799
|
const transports = parseTransports(r.transports, processEnv)
|
|
606
800
|
const hooks = zooidHooks(r)
|
|
607
|
-
const agents = parseAgents(r.agents, runtime, transports, hooks, processEnv)
|
|
801
|
+
const agents = parseAgents(r.agents, runtime, transports, hooks, processEnv, opts.configDir)
|
|
608
802
|
|
|
609
803
|
const cfg: ZooidConfig = {
|
|
610
804
|
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,8 +33,10 @@ export type {
|
|
|
32
33
|
export type {
|
|
33
34
|
AgentConfig,
|
|
34
35
|
ContainerConfig,
|
|
36
|
+
MountConfig,
|
|
35
37
|
ZooidContainerConfig,
|
|
36
38
|
MatrixBinding,
|
|
39
|
+
RoomBinding,
|
|
37
40
|
HttpBinding,
|
|
38
41
|
ZooidConfig,
|
|
39
42
|
TransportConfig,
|