@zooid/core 0.7.0

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.
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { findMatrixTransport, findTransport } from './config.js'
3
+ import type { ZooidConfig } from './types.js'
4
+
5
+ const cfg: ZooidConfig = {
6
+ runtime: 'docker',
7
+ transports: {
8
+ 'matrix-local': {
9
+ type: 'matrix',
10
+ homeserver: 'http://localhost:8448',
11
+ as_token: 'as-x',
12
+ hs_token: 'hs-x',
13
+ sender_localpart: 'zooid',
14
+ user_namespace: '@.*:localhost',
15
+ },
16
+ },
17
+ agents: {},
18
+ hooks: {},
19
+ }
20
+
21
+ describe('findTransport / findMatrixTransport', () => {
22
+ it('findTransport returns the named transport', () => {
23
+ expect(findTransport(cfg, 'matrix-local')?.type).toBe('matrix')
24
+ })
25
+
26
+ it('findTransport returns undefined for unknown names', () => {
27
+ expect(findTransport(cfg, 'ghost')).toBeUndefined()
28
+ })
29
+
30
+ it('findMatrixTransport returns the (single) matrix transport', () => {
31
+ const t = findMatrixTransport(cfg)
32
+ expect(t?.transport.type).toBe('matrix')
33
+ expect(t?.name).toBe('matrix-local')
34
+ })
35
+
36
+ it('findMatrixTransport returns null when none exists', () => {
37
+ const httpOnly: ZooidConfig = {
38
+ ...cfg,
39
+ transports: { 'http-only': { type: 'http', port: 8080 } },
40
+ }
41
+ expect(findMatrixTransport(httpOnly)).toBeNull()
42
+ })
43
+
44
+ it('findMatrixTransport throws when more than one matrix transport exists', () => {
45
+ const dual: ZooidConfig = {
46
+ ...cfg,
47
+ transports: {
48
+ a: cfg.transports['matrix-local']!,
49
+ b: cfg.transports['matrix-local']!,
50
+ },
51
+ }
52
+ expect(() => findMatrixTransport(dual)).toThrow(/multiple matrix transports/i)
53
+ })
54
+ })
@@ -0,0 +1,389 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
5
+ import { loadZooidConfig } from './config.js'
6
+
7
+ const HERE = resolve(fileURLToPath(import.meta.url), '..')
8
+
9
+ const baseTransports = `
10
+ transports:
11
+ m1:
12
+ type: matrix
13
+ homeserver: http://localhost:8448
14
+ as_token: t
15
+ hs_token: h
16
+ sender_localpart: z
17
+ user_namespace: '@.*:localhost'
18
+ `
19
+
20
+ const matrixAgent = (extra = ''): string => `
21
+ alice:
22
+ workdir: ./alice
23
+ acp: { preset: claude }
24
+ matrix:
25
+ transport: m1
26
+ user_id: '@alice:localhost'
27
+ rooms: ['!r:localhost']${extra}
28
+ `
29
+
30
+ describe('container: block', () => {
31
+ it('accepts workforce-level container.image as the default', () => {
32
+ const yaml = `
33
+ runtime: docker
34
+ container:
35
+ image: zooid-agent-base:latest
36
+ ${baseTransports}
37
+ agents:${matrixAgent()}
38
+ `
39
+ const cfg = loadZooidConfig(yaml)
40
+ expect(cfg.container?.image).toBe('zooid-agent-base:latest')
41
+ })
42
+
43
+ it('accepts per-agent container.image overriding the workforce default', () => {
44
+ const yaml = `
45
+ runtime: docker
46
+ container:
47
+ image: base:1
48
+ ${baseTransports}
49
+ agents:${matrixAgent(`
50
+ container:
51
+ image: alice:2`)}
52
+ `
53
+ const cfg = loadZooidConfig(yaml)
54
+ expect(cfg.agents.alice!.container?.image).toBe('alice:2')
55
+ })
56
+
57
+ it('rejects container at workforce level when runtime: local', () => {
58
+ const yaml = `
59
+ runtime: local
60
+ container: { image: x:1 }
61
+ ${baseTransports}
62
+ agents:${matrixAgent()}
63
+ `
64
+ expect(() => loadZooidConfig(yaml)).toThrow(
65
+ /container.*only valid when runtime is 'docker' or 'podman'/i,
66
+ )
67
+ })
68
+
69
+ it('rejects per-agent container under runtime: local', () => {
70
+ const yaml = `
71
+ runtime: local
72
+ ${baseTransports}
73
+ agents:${matrixAgent(`
74
+ container:
75
+ image: x:1`)}
76
+ `
77
+ expect(() => loadZooidConfig(yaml)).toThrow(
78
+ /alice\.container.*only valid when runtime is 'docker' or 'podman'/i,
79
+ )
80
+ })
81
+ })
82
+
83
+ describe('container.env interpolation', () => {
84
+ let saved: NodeJS.ProcessEnv
85
+ beforeEach(() => {
86
+ saved = { ...process.env }
87
+ })
88
+ afterEach(() => {
89
+ for (const k of Object.keys(process.env)) delete process.env[k]
90
+ Object.assign(process.env, saved)
91
+ })
92
+
93
+ it('passes literal values through unchanged', () => {
94
+ const yaml = `
95
+ runtime: docker
96
+ ${baseTransports}
97
+ agents:${matrixAgent(`
98
+ container:
99
+ env:
100
+ LOG_LEVEL: info`)}
101
+ `
102
+ const cfg = loadZooidConfig(yaml)
103
+ expect(cfg.agents.alice!.container?.env).toEqual({ LOG_LEVEL: 'info' })
104
+ })
105
+
106
+ it('expands ${VAR} from process.env', () => {
107
+ process.env.ANTHROPIC_API_KEY = 'sk-test'
108
+ const yaml = `
109
+ runtime: docker
110
+ ${baseTransports}
111
+ agents:${matrixAgent(`
112
+ container:
113
+ env:
114
+ ANTHROPIC_API_KEY: \${ANTHROPIC_API_KEY}`)}
115
+ `
116
+ const cfg = loadZooidConfig(yaml)
117
+ expect(cfg.agents.alice!.container?.env?.ANTHROPIC_API_KEY).toBe('sk-test')
118
+ })
119
+
120
+ it('honours ${VAR:-default} when the var is unset', () => {
121
+ delete process.env.MODEL
122
+ const yaml = `
123
+ runtime: docker
124
+ ${baseTransports}
125
+ agents:${matrixAgent(`
126
+ container:
127
+ env:
128
+ MODEL: \${MODEL:-claude-opus-4-7}`)}
129
+ `
130
+ const cfg = loadZooidConfig(yaml)
131
+ expect(cfg.agents.alice!.container?.env?.MODEL).toBe('claude-opus-4-7')
132
+ })
133
+
134
+ it('resolves missing references to empty string (compose parity)', () => {
135
+ delete process.env.ABSENT
136
+ const yaml = `
137
+ runtime: docker
138
+ ${baseTransports}
139
+ agents:${matrixAgent(`
140
+ container:
141
+ env:
142
+ K: \${ABSENT}`)}
143
+ `
144
+ const cfg = loadZooidConfig(yaml)
145
+ expect(cfg.agents.alice!.container?.env?.K).toBe('')
146
+ })
147
+
148
+ it('rejects ${ZOOID_TOKEN} reference', () => {
149
+ process.env.ZOOID_TOKEN = 'leak'
150
+ const yaml = `
151
+ runtime: docker
152
+ ${baseTransports}
153
+ agents:${matrixAgent(`
154
+ container:
155
+ env:
156
+ TOKEN: \${ZOOID_TOKEN}`)}
157
+ `
158
+ expect(() => loadZooidConfig(yaml)).toThrow(/ZOOID_/i)
159
+ })
160
+
161
+ it('rejects ${ZOOID_X} reference inside a composed value', () => {
162
+ process.env.ZOOID_INTERNAL = 'leak'
163
+ const yaml = `
164
+ runtime: docker
165
+ ${baseTransports}
166
+ agents:${matrixAgent(`
167
+ container:
168
+ env:
169
+ K: 'prefix-\${ZOOID_INTERNAL}'`)}
170
+ `
171
+ expect(() => loadZooidConfig(yaml)).toThrow(/ZOOID_/i)
172
+ })
173
+
174
+ it('rejects a key in the ZOOID_* namespace', () => {
175
+ const yaml = `
176
+ runtime: docker
177
+ ${baseTransports}
178
+ agents:${matrixAgent(`
179
+ container:
180
+ env:
181
+ ZOOID_FOO: literal`)}
182
+ `
183
+ expect(() => loadZooidConfig(yaml)).toThrow(/ZOOID_/i)
184
+ })
185
+
186
+ it('rejects non-string env values (numbers, booleans)', () => {
187
+ const yaml = `
188
+ runtime: docker
189
+ ${baseTransports}
190
+ agents:${matrixAgent(`
191
+ container:
192
+ env:
193
+ PORT: 8080`)}
194
+ `
195
+ expect(() => loadZooidConfig(yaml)).toThrow(/string/i)
196
+ })
197
+ })
198
+
199
+ describe('agent transport-kind block', () => {
200
+ it('parses matrix block with all fields', () => {
201
+ const yaml = `
202
+ runtime: docker
203
+ ${baseTransports}
204
+ agents:
205
+ alice:
206
+ workdir: ./alice
207
+ acp: { preset: claude }
208
+ matrix:
209
+ transport: m1
210
+ user_id: '@alice:localhost'
211
+ rooms: ['!r:localhost']
212
+ trigger: any
213
+ `
214
+ const cfg = loadZooidConfig(yaml)
215
+ expect(cfg.agents.alice!.matrix).toEqual({
216
+ transport: 'm1',
217
+ user_id: '@alice:localhost',
218
+ rooms: ['!r:localhost'],
219
+ trigger: 'any',
220
+ })
221
+ expect(cfg.agents.alice!.http).toBeUndefined()
222
+ })
223
+
224
+ it('defaults trigger to "mention" when omitted', () => {
225
+ const yaml = `
226
+ runtime: docker
227
+ ${baseTransports}
228
+ agents:${matrixAgent()}
229
+ `
230
+ const cfg = loadZooidConfig(yaml)
231
+ expect(cfg.agents.alice!.matrix?.trigger).toBe('mention')
232
+ })
233
+
234
+ it('rejects an agent with zero transport-kind blocks', () => {
235
+ const yaml = `
236
+ runtime: docker
237
+ ${baseTransports}
238
+ agents:
239
+ alice:
240
+ workdir: ./alice
241
+ acp: { preset: claude }
242
+ `
243
+ expect(() => loadZooidConfig(yaml)).toThrow(/exactly one transport.*block/i)
244
+ })
245
+
246
+ it('rejects an agent with two transport-kind blocks', () => {
247
+ const yaml = `
248
+ runtime: docker
249
+ transports:
250
+ m1: { type: matrix, homeserver: x, as_token: t, hs_token: h, sender_localpart: z, user_namespace: '@.*:l' }
251
+ h1: { type: http, port: 8080 }
252
+ agents:
253
+ alice:
254
+ workdir: ./alice
255
+ acp: { preset: claude }
256
+ matrix:
257
+ transport: m1
258
+ user_id: '@alice:localhost'
259
+ rooms: ['!r:localhost']
260
+ http:
261
+ transport: h1
262
+ `
263
+ expect(() => loadZooidConfig(yaml)).toThrow(/exactly one transport.*block/i)
264
+ })
265
+
266
+ it('rejects matrix block whose transport ref points at an http transport', () => {
267
+ const yaml = `
268
+ runtime: docker
269
+ transports:
270
+ h1: { type: http, port: 8080 }
271
+ agents:
272
+ alice:
273
+ workdir: ./alice
274
+ acp: { preset: claude }
275
+ matrix:
276
+ transport: h1
277
+ user_id: '@alice:localhost'
278
+ rooms: ['!r:localhost']
279
+ `
280
+ expect(() => loadZooidConfig(yaml)).toThrow(
281
+ /matrix.*references transport.*type: http/i,
282
+ )
283
+ })
284
+
285
+ it('rejects matrix block whose transport ref does not exist', () => {
286
+ const yaml = `
287
+ runtime: docker
288
+ ${baseTransports}
289
+ agents:
290
+ alice:
291
+ workdir: ./alice
292
+ acp: { preset: claude }
293
+ matrix:
294
+ transport: nope
295
+ user_id: '@alice:localhost'
296
+ rooms: ['!r:localhost']
297
+ `
298
+ expect(() => loadZooidConfig(yaml)).toThrow(/transport "nope" is not declared/i)
299
+ })
300
+
301
+ it('parses http block', () => {
302
+ const yaml = `
303
+ runtime: docker
304
+ transports:
305
+ h1: { type: http, port: 8080 }
306
+ agents:
307
+ alice:
308
+ workdir: ./alice
309
+ acp: { preset: claude }
310
+ http:
311
+ transport: h1
312
+ `
313
+ const cfg = loadZooidConfig(yaml)
314
+ expect(cfg.agents.alice!.http).toEqual({ transport: 'h1' })
315
+ expect(cfg.agents.alice!.matrix).toBeUndefined()
316
+ })
317
+ })
318
+
319
+ describe('legacy field migration', () => {
320
+ it('rejects top-level docker: block with a pointer to ZOD043', () => {
321
+ const yaml = `
322
+ runtime: docker
323
+ docker:
324
+ image: x:1
325
+ ${baseTransports}
326
+ agents:${matrixAgent()}
327
+ `
328
+ expect(() => loadZooidConfig(yaml)).toThrow(/Top-level 'docker'.*ZOD043/i)
329
+ })
330
+
331
+ it('rejects per-agent docker: block', () => {
332
+ const yaml = `
333
+ runtime: docker
334
+ ${baseTransports}
335
+ agents:${matrixAgent(`
336
+ docker:
337
+ image: x:1`)}
338
+ `
339
+ expect(() => loadZooidConfig(yaml)).toThrow(/agents\.alice\.docker.*ZOD043/i)
340
+ })
341
+
342
+ it('rejects flat agent transport: string', () => {
343
+ const yaml = `
344
+ runtime: docker
345
+ ${baseTransports}
346
+ agents:
347
+ alice:
348
+ workdir: ./alice
349
+ acp: { preset: claude }
350
+ transport: m1
351
+ matrix:
352
+ transport: m1
353
+ user_id: '@alice:localhost'
354
+ rooms: ['!r:localhost']
355
+ `
356
+ expect(() => loadZooidConfig(yaml)).toThrow(/transport[\s\S]*ZOD043/i)
357
+ })
358
+
359
+ it('rejects flat matrix_user_id / rooms / trigger fields', () => {
360
+ const yaml = `
361
+ runtime: docker
362
+ ${baseTransports}
363
+ agents:
364
+ alice:
365
+ workdir: ./alice
366
+ acp: { preset: claude }
367
+ matrix_user_id: '@alice:localhost'
368
+ rooms: ['!r:localhost']
369
+ `
370
+ expect(() => loadZooidConfig(yaml)).toThrow(/matrix_user_id.*ZOD043/i)
371
+ })
372
+ })
373
+
374
+ describe('example fixtures parse cleanly', () => {
375
+ for (const rel of [
376
+ './__fixtures__/zooid-dev.yaml',
377
+ './__fixtures__/triage-agent.yaml',
378
+ './__fixtures__/opencode-vertex-gemini.yaml',
379
+ './__fixtures__/ec2-workspace.yaml',
380
+ ]) {
381
+ it(`parses ${rel}`, () => {
382
+ const path = resolve(HERE, rel)
383
+ const text = readFileSync(path, 'utf8')
384
+ process.env.MATRIX_AS_TOKEN ??= 'fixture-as'
385
+ process.env.MATRIX_HS_TOKEN ??= 'fixture-hs'
386
+ expect(() => loadZooidConfig(text)).not.toThrow()
387
+ })
388
+ }
389
+ })