@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/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
- throw new Error(
527
- `agents.${name}.container is only valid when runtime is 'docker' or 'podman'. ` +
528
- `runtime: local spawns agents as host child processes — there is no container, ` +
529
- `so 'image' is inert and 'env' would silently lie (the agent inherits the daemon's ` +
530
- `full process.env regardless).`,
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(yamlText: string): ZooidConfig {
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. Rejected at parse time when `runtime: local`.
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
  /**