zooid 0.6.1 → 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/LICENSE +1 -1
- package/README.md +36 -444
- package/dist/bin.js +1891 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-SUPTPSN3.js +31434 -0
- package/dist/chunk-SUPTPSN3.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +4 -3790
- package/dist/index.js.map +1 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/dist/web/assets/index-C2MAIew3.css +1 -0
- package/dist/web/assets/index-CqfWXqvO.js +118066 -0
- package/dist/web/assets/index-Cz5fEZBc.js +295 -0
- package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +43 -22
- package/src/bin.ts +115 -0
- package/src/bootstrap/admin.test.ts +69 -0
- package/src/bootstrap/admin.ts +37 -0
- package/src/bootstrap/configs.test.ts +24 -0
- package/src/bootstrap/configs.ts +64 -0
- package/src/bootstrap/data-layout.test.ts +25 -0
- package/src/bootstrap/data-layout.ts +20 -0
- package/src/bootstrap/derive.test.ts +74 -0
- package/src/bootstrap/derive.ts +40 -0
- package/src/bootstrap/paths.test.ts +22 -0
- package/src/bootstrap/paths.ts +27 -0
- package/src/bootstrap/tokens.test.ts +44 -0
- package/src/bootstrap/tokens.ts +43 -0
- package/src/build-registry.context.test.ts +61 -0
- package/src/build-registry.test.ts +59 -0
- package/src/build-registry.ts +343 -0
- package/src/build-registry.zod043.test.ts +64 -0
- package/src/build-registry.zod044.test.ts +261 -0
- package/src/commands/dev-cascade.test.ts +38 -0
- package/src/commands/dev-cascade.ts +35 -0
- package/src/commands/dev-data-layout.test.ts +55 -0
- package/src/commands/dev.ts +316 -0
- package/src/commands/init/generators.test.ts +97 -0
- package/src/commands/init/generators.ts +124 -0
- package/src/commands/init/prompts.ts +123 -0
- package/src/commands/init/registry.test.ts +33 -0
- package/src/commands/init/registry.ts +75 -0
- package/src/commands/init/sniff.test.ts +41 -0
- package/src/commands/init/sniff.ts +30 -0
- package/src/commands/init.test.ts +131 -0
- package/src/commands/init.ts +158 -0
- package/src/commands/logs.test.ts +105 -0
- package/src/commands/logs.ts +108 -0
- package/src/commands/start.ts +28 -0
- package/src/commands/status.test.ts +73 -0
- package/src/commands/status.ts +105 -0
- package/src/daemon/start-daemon.test.ts +67 -0
- package/src/daemon/start-daemon.ts +270 -0
- package/src/index.ts +2 -0
- package/src/observability/capture-agent.ts +58 -0
- package/src/observability/capture-tuwunel.test.ts +57 -0
- package/src/observability/capture-tuwunel.ts +24 -0
- package/src/observability/file-sink.test.ts +68 -0
- package/src/observability/file-sink.ts +78 -0
- package/src/observability/observability.integration.test.ts +162 -0
- package/src/observability/paths.test.ts +104 -0
- package/src/observability/paths.ts +94 -0
- package/src/prepull-images.test.ts +149 -0
- package/src/prepull-images.ts +100 -0
- package/src/services/tuwunel.test.ts +64 -0
- package/src/services/tuwunel.ts +91 -0
- package/src/web/resolve.test.ts +50 -0
- package/src/web/resolve.ts +31 -0
- package/src/web/static.test.ts +51 -0
- package/src/web/static.ts +61 -0
- package/src/web/watch.ts +103 -0
- package/dist/chunk-67ZRMVHO.js +0 -174
- package/dist/chunk-AR456MHY.js +0 -29
- package/dist/chunk-VBGU2NST.js +0 -139
- package/dist/client-4VMFEFDX.js +0 -22
- package/dist/config-2KK5GX42.js +0 -27
- package/dist/template-T5IB4YWC.js +0 -92
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { ensureTokens, readTokens } from './tokens.js'
|
|
6
|
+
|
|
7
|
+
let dir: string
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
dir = join(tmpdir(), `zooid-tokens-${Math.random().toString(36).slice(2)}`)
|
|
10
|
+
mkdirSync(dir, { recursive: true })
|
|
11
|
+
})
|
|
12
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
13
|
+
|
|
14
|
+
describe('ensureTokens', () => {
|
|
15
|
+
it('generates two distinct hex tokens on first call and persists them', () => {
|
|
16
|
+
const envPath = join(dir, '.env')
|
|
17
|
+
const t = ensureTokens(envPath)
|
|
18
|
+
expect(t.asToken).toMatch(/^as-[0-9a-f]{32,}$/)
|
|
19
|
+
expect(t.hsToken).toMatch(/^hs-[0-9a-f]{32,}$/)
|
|
20
|
+
expect(t.asToken).not.toBe(t.hsToken)
|
|
21
|
+
const env = readFileSync(envPath, 'utf8')
|
|
22
|
+
expect(env).toContain(`MATRIX_AS_TOKEN=${t.asToken}`)
|
|
23
|
+
expect(env).toContain(`MATRIX_HS_TOKEN=${t.hsToken}`)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('reuses tokens on subsequent calls (idempotent)', () => {
|
|
27
|
+
const envPath = join(dir, '.env')
|
|
28
|
+
const a = ensureTokens(envPath)
|
|
29
|
+
const b = ensureTokens(envPath)
|
|
30
|
+
expect(a).toEqual(b)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('errors with a clear message if .env exists but is missing tokens', () => {
|
|
34
|
+
const envPath = join(dir, '.env')
|
|
35
|
+
writeFileSync(envPath, 'MATRIX_AS_TOKEN=as-abc\n', 'utf8')
|
|
36
|
+
expect(() => ensureTokens(envPath)).toThrow(
|
|
37
|
+
/missing MATRIX_AS_TOKEN or MATRIX_HS_TOKEN/i,
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('readTokens returns null when file does not exist', () => {
|
|
42
|
+
expect(readTokens(join(dir, 'nope.env'))).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export interface Tokens {
|
|
6
|
+
asToken: string
|
|
7
|
+
hsToken: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const AS_RE = /^MATRIX_AS_TOKEN=(.+)$/m
|
|
11
|
+
const HS_RE = /^MATRIX_HS_TOKEN=(.+)$/m
|
|
12
|
+
|
|
13
|
+
function genToken(prefix: string): string {
|
|
14
|
+
return `${prefix}-${randomBytes(24).toString('hex')}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readTokens(envPath: string): Tokens | null {
|
|
18
|
+
if (!existsSync(envPath)) return null
|
|
19
|
+
const text = readFileSync(envPath, 'utf8')
|
|
20
|
+
const as = text.match(AS_RE)?.[1]
|
|
21
|
+
const hs = text.match(HS_RE)?.[1]
|
|
22
|
+
if (!as && !hs) return null
|
|
23
|
+
if (!as || !hs) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`${envPath}: missing MATRIX_AS_TOKEN or MATRIX_HS_TOKEN. Delete the file to regenerate, or restore the missing value.`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
return { asToken: as, hsToken: hs }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ensureTokens(envPath: string): Tokens {
|
|
32
|
+
const existing = readTokens(envPath)
|
|
33
|
+
if (existing) return existing
|
|
34
|
+
const tokens: Tokens = { asToken: genToken('as'), hsToken: genToken('hs') }
|
|
35
|
+
mkdirSync(dirname(envPath), { recursive: true })
|
|
36
|
+
const previous = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''
|
|
37
|
+
const next =
|
|
38
|
+
previous.trimEnd() +
|
|
39
|
+
(previous ? '\n' : '') +
|
|
40
|
+
`MATRIX_AS_TOKEN=${tokens.asToken}\nMATRIX_HS_TOKEN=${tokens.hsToken}\n`
|
|
41
|
+
writeFileSync(envPath, next, { mode: 0o600 })
|
|
42
|
+
return tokens
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { buildAcpRegistry } from './build-registry.js'
|
|
3
|
+
import type { ZooidConfig } from '@zooid/core'
|
|
4
|
+
|
|
5
|
+
const matrixCfg: ZooidConfig = {
|
|
6
|
+
runtime: 'local',
|
|
7
|
+
transports: {
|
|
8
|
+
mx: {
|
|
9
|
+
type: 'matrix',
|
|
10
|
+
homeserver: 'https://hs',
|
|
11
|
+
as_token: 'as',
|
|
12
|
+
hs_token: 'hs',
|
|
13
|
+
user_namespace: '@.*:hs',
|
|
14
|
+
sender_localpart: '_zooid',
|
|
15
|
+
space: 'dev',
|
|
16
|
+
port: 0,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
agents: {
|
|
20
|
+
architect: {
|
|
21
|
+
name: 'architect',
|
|
22
|
+
workdir: '/tmp',
|
|
23
|
+
hooks: {},
|
|
24
|
+
acp: { command: 'fake', args: [] },
|
|
25
|
+
approval_timeout_ms: 0,
|
|
26
|
+
matrix: {
|
|
27
|
+
transport: 'mx',
|
|
28
|
+
user_id: '@architect:hs',
|
|
29
|
+
rooms: ['!r:hs'],
|
|
30
|
+
trigger: 'mention',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
hooks: {},
|
|
35
|
+
} as unknown as ZooidConfig
|
|
36
|
+
|
|
37
|
+
describe('buildAcpRegistry — context provider wiring', () => {
|
|
38
|
+
it('attaches a Matrix-backed context provider to agents bound to a matrix transport', () => {
|
|
39
|
+
const registry = buildAcpRegistry(matrixCfg, {
|
|
40
|
+
approvals: { register: vi.fn(), on: vi.fn() } as never,
|
|
41
|
+
contextSpawnRegistry: {} as never,
|
|
42
|
+
daemonSockPath: '/tmp/zooid-test.sock',
|
|
43
|
+
})
|
|
44
|
+
expect(registry.hasContextSpawn('architect')).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not attach a context provider to http-bound agents', () => {
|
|
48
|
+
const httpCfg = structuredClone(matrixCfg)
|
|
49
|
+
httpCfg.transports = {
|
|
50
|
+
h: { type: 'http', port: 0 } as never,
|
|
51
|
+
}
|
|
52
|
+
httpCfg.agents.architect.matrix = undefined as never
|
|
53
|
+
;(httpCfg.agents.architect as { http?: unknown }).http = { transport: 'h' }
|
|
54
|
+
const registry = buildAcpRegistry(httpCfg, {
|
|
55
|
+
approvals: { register: vi.fn(), on: vi.fn() } as never,
|
|
56
|
+
contextSpawnRegistry: {} as never,
|
|
57
|
+
daemonSockPath: '/tmp/zooid-test.sock',
|
|
58
|
+
})
|
|
59
|
+
expect(registry.hasContextSpawn('architect')).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { LocalAcpRuntime } from '@zooid/runtime-local'
|
|
3
|
+
import { DockerAcpRuntime } from '@zooid/runtime-docker'
|
|
4
|
+
import type { ZooidConfig } from '@zooid/core'
|
|
5
|
+
import { buildAcpRegistry } from './build-registry.js'
|
|
6
|
+
|
|
7
|
+
function cfg(overrides: Partial<ZooidConfig> & Pick<ZooidConfig, 'runtime'>): ZooidConfig {
|
|
8
|
+
return {
|
|
9
|
+
runtime: overrides.runtime,
|
|
10
|
+
transports: overrides.transports ?? { 'http-local': { type: 'http', port: 8080 } },
|
|
11
|
+
agents: overrides.agents ?? {
|
|
12
|
+
a: {
|
|
13
|
+
name: 'a',
|
|
14
|
+
workdir: '.',
|
|
15
|
+
hooks: {},
|
|
16
|
+
acp: { preset: 'claude' },
|
|
17
|
+
approval_timeout_ms: 0,
|
|
18
|
+
http: { transport: 'http-local' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
hooks: {},
|
|
22
|
+
container: overrides.container,
|
|
23
|
+
} as ZooidConfig
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('buildAcpRegistry', () => {
|
|
27
|
+
it('constructs with LocalAcpRuntime for runtime: local', () => {
|
|
28
|
+
const reg = buildAcpRegistry(cfg({ runtime: 'local' }))
|
|
29
|
+
expect((reg as unknown as { opts: { runtime: unknown } }).opts.runtime).toBeInstanceOf(LocalAcpRuntime)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('constructs with DockerAcpRuntime (docker engine) for runtime: docker', () => {
|
|
33
|
+
const reg = buildAcpRegistry(
|
|
34
|
+
cfg({ runtime: 'docker', container: { image: 'img:latest' } }),
|
|
35
|
+
)
|
|
36
|
+
const rt = (reg as unknown as { opts: { runtime: DockerAcpRuntime } }).opts.runtime
|
|
37
|
+
expect(rt).toBeInstanceOf(DockerAcpRuntime)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('constructs with DockerAcpRuntime (podman engine) for runtime: podman', () => {
|
|
41
|
+
const reg = buildAcpRegistry(
|
|
42
|
+
cfg({ runtime: 'podman', container: { image: 'img:latest' } }),
|
|
43
|
+
)
|
|
44
|
+
const rt = (reg as unknown as { opts: { runtime: DockerAcpRuntime } }).opts.runtime
|
|
45
|
+
expect(rt).toBeInstanceOf(DockerAcpRuntime)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('throws if any agent has no acp block (defense in depth)', () => {
|
|
49
|
+
const c: ZooidConfig = {
|
|
50
|
+
runtime: 'local',
|
|
51
|
+
transports: { 'http-local': { type: 'http', port: 8080 } },
|
|
52
|
+
agents: {
|
|
53
|
+
a: { name: 'a', workdir: '.', hooks: {}, http: { transport: 'http-local' } } as never,
|
|
54
|
+
},
|
|
55
|
+
hooks: {},
|
|
56
|
+
}
|
|
57
|
+
expect(() => buildAcpRegistry(c)).toThrow(/agents\.a.*acp/i)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { isAbsolute, resolve as pathResolve } from 'node:path'
|
|
2
|
+
import { LocalAcpRuntime } from '@zooid/runtime-local'
|
|
3
|
+
import { DockerAcpRuntime } from '@zooid/runtime-docker'
|
|
4
|
+
import {
|
|
5
|
+
AcpAgentRegistry,
|
|
6
|
+
type AcpMount,
|
|
7
|
+
type AcpRuntime,
|
|
8
|
+
type AgentConfig,
|
|
9
|
+
type ApprovalCorrelator,
|
|
10
|
+
type ContextSpawnFactory,
|
|
11
|
+
type MountConfig,
|
|
12
|
+
type TapEvent,
|
|
13
|
+
type ZooidConfig,
|
|
14
|
+
type TransportContextProvider,
|
|
15
|
+
} from '@zooid/core'
|
|
16
|
+
import { MatrixClient, MatrixContextProvider } from '@zooid/transport-matrix'
|
|
17
|
+
import { SpawnRegistry, buildContextServerSpec } from '@zooid/context-mcp'
|
|
18
|
+
import { PRESETS, type PresetMount, type PresetName } from '@zooid/acp-client'
|
|
19
|
+
|
|
20
|
+
export interface BuildAcpRegistryOptions {
|
|
21
|
+
/** Override the runtime selection (tests). */
|
|
22
|
+
runtime?: AcpRuntime
|
|
23
|
+
/** When set, the registry's approval handler routes through this correlator. */
|
|
24
|
+
approvals?: ApprovalCorrelator
|
|
25
|
+
/** Observability tap forwarded to each AcpClient. */
|
|
26
|
+
onTap?: (agentName: string, event: TapEvent) => void
|
|
27
|
+
/**
|
|
28
|
+
* Per-agent state root (`<dataRoot>/agents/`). When set, each AcpClient
|
|
29
|
+
* persists its `(threadId → sessionId)` map under
|
|
30
|
+
* `<agentsDir>/<agentName>/sessions.json` so threads survive daemon restarts.
|
|
31
|
+
*/
|
|
32
|
+
agentsDir?: string
|
|
33
|
+
/**
|
|
34
|
+
* Per-spawn binding store for the zooid-context MCP server. When set
|
|
35
|
+
* together with `daemonSockPath`, agents bound to a transport that owns
|
|
36
|
+
* conversation context get a `contextSpawn` factory threaded into their
|
|
37
|
+
* AcpClient so `session/new mcpServers` includes the zooid-context entry.
|
|
38
|
+
*/
|
|
39
|
+
contextSpawnRegistry?: SpawnRegistry
|
|
40
|
+
/** Path to the daemon's context Unix socket. Passed to the MCP server bin. */
|
|
41
|
+
daemonSockPath?: string
|
|
42
|
+
/**
|
|
43
|
+
* Directory containing zooid.yaml. Required when any agent uses the
|
|
44
|
+
* workspace auto-mount (the default under `runtime: docker | podman`):
|
|
45
|
+
* relative `agent.workdir` paths are resolved against this dir.
|
|
46
|
+
*/
|
|
47
|
+
configDir?: string
|
|
48
|
+
/**
|
|
49
|
+
* Daemon data root. Each agent's preset mounts target
|
|
50
|
+
* `<dataDir>/agents/<agentName>` on the host.
|
|
51
|
+
*/
|
|
52
|
+
dataDir?: string
|
|
53
|
+
/**
|
|
54
|
+
* Daemon user's `$HOME`. Sourced by the v1 preset `home` / `data` /
|
|
55
|
+
* `config` mounts. Tests inject a stub; production threads
|
|
56
|
+
* `process.env.HOME` from `start-daemon`.
|
|
57
|
+
*/
|
|
58
|
+
daemonHome?: string
|
|
59
|
+
/**
|
|
60
|
+
* Sink for the per-agent "resolved image + mounts" startup log lines.
|
|
61
|
+
* Defaults to `console.log`. Tests pass a capture array.
|
|
62
|
+
*/
|
|
63
|
+
log?: (line: string) => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ImageProvenance = 'agent' | 'workforce' | `preset:${PresetName}` | 'unresolved'
|
|
67
|
+
|
|
68
|
+
interface ResolvedImage {
|
|
69
|
+
image: string | undefined
|
|
70
|
+
source: ImageProvenance
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const CONTAINER_WORKDIR = '/workspace'
|
|
74
|
+
|
|
75
|
+
function presetOf(agent: AgentConfig): PresetName | undefined {
|
|
76
|
+
const spec = agent.acp as { preset?: string } | undefined
|
|
77
|
+
if (spec?.preset && spec.preset in PRESETS) return spec.preset as PresetName
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveAgentImage(
|
|
82
|
+
agent: AgentConfig,
|
|
83
|
+
cfg: ZooidConfig,
|
|
84
|
+
): ResolvedImage {
|
|
85
|
+
if (agent.container?.image) return { image: agent.container.image, source: 'agent' }
|
|
86
|
+
if (cfg.container?.image) return { image: cfg.container.image, source: 'workforce' }
|
|
87
|
+
const preset = presetOf(agent)
|
|
88
|
+
const presetImage = preset ? PRESETS[preset]?.image : undefined
|
|
89
|
+
if (presetImage && preset) return { image: presetImage, source: `preset:${preset}` }
|
|
90
|
+
return { image: undefined, source: 'unresolved' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ComposedMounts {
|
|
94
|
+
mounts: PresetMount[]
|
|
95
|
+
mkdirs: string[]
|
|
96
|
+
cwd: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function composeAgentMounts(
|
|
100
|
+
name: string,
|
|
101
|
+
agent: AgentConfig,
|
|
102
|
+
cfg: ZooidConfig,
|
|
103
|
+
opts: BuildAcpRegistryOptions,
|
|
104
|
+
): ComposedMounts {
|
|
105
|
+
if (cfg.runtime === 'local') {
|
|
106
|
+
return { mounts: [], mkdirs: [], cwd: agent.workdir }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const disabled = new Set(agent.container?.disable_mounts ?? [])
|
|
110
|
+
const knownIds = new Set<string>(['workspace'])
|
|
111
|
+
const composed: PresetMount[] = []
|
|
112
|
+
let workspaceActive = false
|
|
113
|
+
|
|
114
|
+
// Layer 1: workspace auto-mount. Skip when explicitly disabled, or when
|
|
115
|
+
// configDir is missing and the workdir is relative — the daemon path always
|
|
116
|
+
// passes configDir; tests that exercise other concerns (image resolution,
|
|
117
|
+
// env) can leave it off without the workspace mount intruding.
|
|
118
|
+
if (!disabled.has('workspace')) {
|
|
119
|
+
let host = agent.workdir
|
|
120
|
+
if (isAbsolute(host)) {
|
|
121
|
+
composed.push({
|
|
122
|
+
id: 'workspace',
|
|
123
|
+
host,
|
|
124
|
+
target: CONTAINER_WORKDIR,
|
|
125
|
+
mode: 'rw',
|
|
126
|
+
create: true,
|
|
127
|
+
})
|
|
128
|
+
workspaceActive = true
|
|
129
|
+
} else if (opts.configDir) {
|
|
130
|
+
host = pathResolve(opts.configDir, host)
|
|
131
|
+
composed.push({
|
|
132
|
+
id: 'workspace',
|
|
133
|
+
host,
|
|
134
|
+
target: CONTAINER_WORKDIR,
|
|
135
|
+
mode: 'rw',
|
|
136
|
+
create: true,
|
|
137
|
+
})
|
|
138
|
+
workspaceActive = true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Layer 2: preset mounts.
|
|
143
|
+
const preset = presetOf(agent)
|
|
144
|
+
if (preset && PRESETS[preset]?.mounts) {
|
|
145
|
+
const dataDir = opts.dataDir ?? process.cwd()
|
|
146
|
+
const ctx = {
|
|
147
|
+
agentName: name,
|
|
148
|
+
agentDataDir: pathResolve(dataDir, 'agents', name),
|
|
149
|
+
containerWorkdir: CONTAINER_WORKDIR,
|
|
150
|
+
daemonHome: opts.daemonHome ?? process.env.HOME ?? '',
|
|
151
|
+
}
|
|
152
|
+
const presetMounts = PRESETS[preset].mounts!(ctx)
|
|
153
|
+
for (const m of presetMounts) {
|
|
154
|
+
knownIds.add(m.id)
|
|
155
|
+
if (!disabled.has(m.id)) composed.push(m)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validate disable_mounts ids — every entry must be either 'workspace' or a
|
|
160
|
+
// preset-declared id. Unknown ids are typos that would silently no-op.
|
|
161
|
+
for (const id of disabled) {
|
|
162
|
+
if (!knownIds.has(id)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`agents.${name}.container.disable_mounts: unknown id "${id}". ` +
|
|
165
|
+
`Known ids for this agent: ${[...knownIds].sort().join(', ')}`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Layer 3: user mounts. Auto-id any without one.
|
|
171
|
+
const userMounts: MountConfig[] = agent.container?.mounts ?? []
|
|
172
|
+
let userIdx = 0
|
|
173
|
+
for (const m of userMounts) {
|
|
174
|
+
const id = m.id ?? `user-${userIdx}`
|
|
175
|
+
userIdx++
|
|
176
|
+
const out: PresetMount = {
|
|
177
|
+
id,
|
|
178
|
+
host: m.host,
|
|
179
|
+
target: m.target,
|
|
180
|
+
mode: m.mode,
|
|
181
|
+
}
|
|
182
|
+
if (m.create !== undefined) out.create = m.create
|
|
183
|
+
composed.push(out)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mkdirs: string[] = composed
|
|
187
|
+
.filter((m) => m.create)
|
|
188
|
+
.map((m) => m.host)
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
mounts: composed,
|
|
192
|
+
mkdirs,
|
|
193
|
+
cwd: workspaceActive ? CONTAINER_WORKDIR : agent.workdir,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build an `AcpAgentRegistry` from a parsed workforce config.
|
|
199
|
+
*
|
|
200
|
+
* - `runtime: local` → `LocalAcpRuntime`
|
|
201
|
+
* - `runtime: docker` → `DockerAcpRuntime` (engine: docker)
|
|
202
|
+
* - `runtime: podman` → `DockerAcpRuntime` (engine: podman)
|
|
203
|
+
*
|
|
204
|
+
* Compose layers (docker/podman only): workspace auto-mount → preset-declared
|
|
205
|
+
* canonical-id mounts (filtered by `container.disable_mounts`) → user mounts.
|
|
206
|
+
* Image resolution: agent.container.image > workforce container.image >
|
|
207
|
+
* preset.image. Under `runtime: docker | podman`, throws at startup if any
|
|
208
|
+
* agent has no resolvable image.
|
|
209
|
+
*/
|
|
210
|
+
export function buildAcpRegistry(
|
|
211
|
+
cfg: ZooidConfig,
|
|
212
|
+
opts: BuildAcpRegistryOptions = {},
|
|
213
|
+
): AcpAgentRegistry {
|
|
214
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
215
|
+
if (!agent.acp) {
|
|
216
|
+
throw new Error(`agents.${name}: missing acp block (parser should have caught this)`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Startup validation: every docker/podman agent must resolve to an image.
|
|
221
|
+
if (cfg.runtime !== 'local') {
|
|
222
|
+
const missing: Array<{ name: string; preset?: string }> = []
|
|
223
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
224
|
+
const { image } = resolveAgentImage(agent, cfg)
|
|
225
|
+
if (!image) {
|
|
226
|
+
missing.push({ name, preset: presetOf(agent) })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (missing.length > 0) {
|
|
230
|
+
const lines = missing.map(({ name, preset }) =>
|
|
231
|
+
` - ${name}${preset ? ` (preset: ${preset})` : ''} — no preset-default image`,
|
|
232
|
+
)
|
|
233
|
+
throw new Error(
|
|
234
|
+
`runtime: ${cfg.runtime} requires a container image for each agent. Unresolved:\n` +
|
|
235
|
+
lines.join('\n') +
|
|
236
|
+
`\nSet agents.<name>.container.image, top-level container.image, or use a ` +
|
|
237
|
+
`preset that ships a default image (claude, codex, opencode).`,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const runtime = opts.runtime ?? defaultRuntimeFor(cfg)
|
|
243
|
+
const env: Record<string, Record<string, string>> = {}
|
|
244
|
+
const image: Record<string, string | undefined> = {}
|
|
245
|
+
const mountsByAgent: Record<string, AcpMount[]> = {}
|
|
246
|
+
const mkdirByAgent: Record<string, string[]> = {}
|
|
247
|
+
const cwdByAgent: Record<string, string> = {}
|
|
248
|
+
const log = opts.log ?? ((line: string) => console.log(line))
|
|
249
|
+
|
|
250
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
251
|
+
env[name] = agent.container?.env ?? {}
|
|
252
|
+
const { image: resolvedImage, source } = resolveAgentImage(agent, cfg)
|
|
253
|
+
image[name] = resolvedImage
|
|
254
|
+
const composed = composeAgentMounts(name, agent, cfg, opts)
|
|
255
|
+
mountsByAgent[name] = composed.mounts.map((m) => ({
|
|
256
|
+
path: m.host,
|
|
257
|
+
target: m.target,
|
|
258
|
+
mode: m.mode,
|
|
259
|
+
}))
|
|
260
|
+
mkdirByAgent[name] = composed.mkdirs
|
|
261
|
+
cwdByAgent[name] = composed.cwd
|
|
262
|
+
if (resolvedImage && cfg.runtime !== 'local') {
|
|
263
|
+
const mountSummary = composed.mounts.length === 0
|
|
264
|
+
? 'mounts=[]'
|
|
265
|
+
: `mounts=[${composed.mounts.map((m) => m.id ?? m.target).join(',')}]`
|
|
266
|
+
log(
|
|
267
|
+
`[zooid] agent ${name.padEnd(12)} image=${resolvedImage} source=${source} ${mountSummary}`,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const contextSpawns = buildContextSpawns(cfg, opts)
|
|
273
|
+
|
|
274
|
+
return new AcpAgentRegistry({
|
|
275
|
+
runtime,
|
|
276
|
+
agents: cfg.agents,
|
|
277
|
+
env,
|
|
278
|
+
image,
|
|
279
|
+
mounts: mountsByAgent,
|
|
280
|
+
mkdirOnSpawn: mkdirByAgent,
|
|
281
|
+
cwd: cwdByAgent,
|
|
282
|
+
approvals: opts.approvals,
|
|
283
|
+
onTap: opts.onTap,
|
|
284
|
+
agentsDir: opts.agentsDir,
|
|
285
|
+
contextSpawns,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function buildContextSpawns(
|
|
290
|
+
cfg: ZooidConfig,
|
|
291
|
+
opts: BuildAcpRegistryOptions,
|
|
292
|
+
): Record<string, ContextSpawnFactory | undefined> | undefined {
|
|
293
|
+
if (!opts.contextSpawnRegistry || !opts.daemonSockPath) return undefined
|
|
294
|
+
const registry = opts.contextSpawnRegistry
|
|
295
|
+
const sockPath = opts.daemonSockPath
|
|
296
|
+
|
|
297
|
+
const matrixClients = new Map<string, MatrixClient>()
|
|
298
|
+
const agentBots = new Map<string, string>()
|
|
299
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
300
|
+
if (agent.matrix?.user_id) agentBots.set(agent.matrix.user_id, name)
|
|
301
|
+
}
|
|
302
|
+
for (const [tname, tcfg] of Object.entries(cfg.transports)) {
|
|
303
|
+
if (tcfg.type !== 'matrix') continue
|
|
304
|
+
matrixClients.set(
|
|
305
|
+
tname,
|
|
306
|
+
new MatrixClient({ homeserver: tcfg.homeserver, asToken: tcfg.as_token }),
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const result: Record<string, ContextSpawnFactory | undefined> = {}
|
|
311
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
312
|
+
if (agent.matrix && matrixClients.has(agent.matrix.transport)) {
|
|
313
|
+
const client = matrixClients.get(agent.matrix.transport)!
|
|
314
|
+
const provider: TransportContextProvider = new MatrixContextProvider({
|
|
315
|
+
client,
|
|
316
|
+
asUserId: agent.matrix.user_id,
|
|
317
|
+
agentBots,
|
|
318
|
+
})
|
|
319
|
+
result[name] = async (threadId: string, channelId?: string) => {
|
|
320
|
+
const spawnId = registry.register({
|
|
321
|
+
agentName: name,
|
|
322
|
+
threadRef: { channelId: channelId ?? threadId, threadId },
|
|
323
|
+
provider,
|
|
324
|
+
})
|
|
325
|
+
return buildContextServerSpec({ spawnId, sockPath })
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
result[name] = undefined
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return result
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function defaultRuntimeFor(cfg: ZooidConfig): AcpRuntime {
|
|
335
|
+
if (cfg.runtime === 'local') return new LocalAcpRuntime()
|
|
336
|
+
if (cfg.runtime === 'docker') {
|
|
337
|
+
return new DockerAcpRuntime({ defaultImage: cfg.container?.image, engine: 'docker' })
|
|
338
|
+
}
|
|
339
|
+
if (cfg.runtime === 'podman') {
|
|
340
|
+
return new DockerAcpRuntime({ defaultImage: cfg.container?.image, engine: 'podman' })
|
|
341
|
+
}
|
|
342
|
+
throw new Error(`unsupported runtime: ${cfg.runtime}`)
|
|
343
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildAcpRegistry } from './build-registry.js'
|
|
3
|
+
import type { ZooidConfig } from '@zooid/core'
|
|
4
|
+
|
|
5
|
+
const baseTransports = {
|
|
6
|
+
m1: {
|
|
7
|
+
type: 'matrix' as const,
|
|
8
|
+
homeserver: 'http://localhost:8448',
|
|
9
|
+
as_token: 't',
|
|
10
|
+
hs_token: 'h',
|
|
11
|
+
sender_localpart: 'z',
|
|
12
|
+
user_namespace: '@.*:localhost',
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeCfg(over: Partial<ZooidConfig> = {}): ZooidConfig {
|
|
17
|
+
return {
|
|
18
|
+
runtime: 'docker',
|
|
19
|
+
container: { image: 'workforce-default:1' },
|
|
20
|
+
transports: baseTransports,
|
|
21
|
+
agents: {
|
|
22
|
+
alice: {
|
|
23
|
+
name: 'alice',
|
|
24
|
+
workdir: './alice',
|
|
25
|
+
hooks: {},
|
|
26
|
+
acp: { preset: 'claude' },
|
|
27
|
+
approval_timeout_ms: 0,
|
|
28
|
+
matrix: {
|
|
29
|
+
transport: 'm1',
|
|
30
|
+
user_id: '@alice:localhost',
|
|
31
|
+
rooms: ['!r:localhost'],
|
|
32
|
+
trigger: 'mention',
|
|
33
|
+
},
|
|
34
|
+
container: { env: { LOG_LEVEL: 'info' } },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
hooks: {},
|
|
38
|
+
...over,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('buildAcpRegistry — ZOD043', () => {
|
|
43
|
+
it('passes agent.container.env through as the spawn env (no forward_env layer)', () => {
|
|
44
|
+
const cfg = makeCfg()
|
|
45
|
+
const reg = buildAcpRegistry(cfg)
|
|
46
|
+
expect(reg.resolveSpawnEnv('alice')).toEqual({ LOG_LEVEL: 'info' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('resolves image with per-agent override beating workforce default', () => {
|
|
50
|
+
const cfg = makeCfg()
|
|
51
|
+
cfg.agents.alice!.container = {
|
|
52
|
+
...cfg.agents.alice!.container,
|
|
53
|
+
image: 'alice:2',
|
|
54
|
+
}
|
|
55
|
+
const reg = buildAcpRegistry(cfg)
|
|
56
|
+
expect(reg.resolveSpawnImage('alice')).toBe('alice:2')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('falls back to workforce container.image when no per-agent image', () => {
|
|
60
|
+
const cfg = makeCfg()
|
|
61
|
+
const reg = buildAcpRegistry(cfg)
|
|
62
|
+
expect(reg.resolveSpawnImage('alice')).toBe('workforce-default:1')
|
|
63
|
+
})
|
|
64
|
+
})
|