zooid 0.6.0 → 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.
Files changed (78) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1797 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-N5POSZX5.js +31000 -0
  6. package/dist/chunk-N5POSZX5.js.map +1 -0
  7. package/dist/index.d.ts +40 -0
  8. package/dist/index.js +4 -3788
  9. package/dist/index.js.map +1 -0
  10. package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  11. package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  12. package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  13. package/dist/web/assets/index-BT_v3DKu.js +295 -0
  14. package/dist/web/assets/index-DJTghnF-.css +1 -0
  15. package/dist/web/assets/index-JZMMlqDP.js +118066 -0
  16. package/dist/web/assets/reaction-picker-emoji-Kh7emgFa.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +42 -22
  20. package/src/bin.ts +112 -0
  21. package/src/bootstrap/admin.test.ts +69 -0
  22. package/src/bootstrap/admin.ts +37 -0
  23. package/src/bootstrap/configs.test.ts +24 -0
  24. package/src/bootstrap/configs.ts +64 -0
  25. package/src/bootstrap/data-layout.test.ts +25 -0
  26. package/src/bootstrap/data-layout.ts +20 -0
  27. package/src/bootstrap/derive.test.ts +74 -0
  28. package/src/bootstrap/derive.ts +40 -0
  29. package/src/bootstrap/paths.test.ts +22 -0
  30. package/src/bootstrap/paths.ts +27 -0
  31. package/src/bootstrap/tokens.test.ts +44 -0
  32. package/src/bootstrap/tokens.ts +43 -0
  33. package/src/build-registry.context.test.ts +61 -0
  34. package/src/build-registry.test.ts +59 -0
  35. package/src/build-registry.ts +142 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/commands/dev-cascade.test.ts +38 -0
  38. package/src/commands/dev-cascade.ts +35 -0
  39. package/src/commands/dev-data-layout.test.ts +55 -0
  40. package/src/commands/dev.ts +308 -0
  41. package/src/commands/init/generators.test.ts +97 -0
  42. package/src/commands/init/generators.ts +119 -0
  43. package/src/commands/init/prompts.ts +123 -0
  44. package/src/commands/init/registry.test.ts +33 -0
  45. package/src/commands/init/registry.ts +75 -0
  46. package/src/commands/init/sniff.test.ts +41 -0
  47. package/src/commands/init/sniff.ts +30 -0
  48. package/src/commands/init.test.ts +131 -0
  49. package/src/commands/init.ts +158 -0
  50. package/src/commands/logs.test.ts +105 -0
  51. package/src/commands/logs.ts +108 -0
  52. package/src/commands/start.ts +28 -0
  53. package/src/commands/status.test.ts +73 -0
  54. package/src/commands/status.ts +100 -0
  55. package/src/daemon/start-daemon.test.ts +67 -0
  56. package/src/daemon/start-daemon.ts +243 -0
  57. package/src/index.ts +2 -0
  58. package/src/observability/capture-agent.ts +58 -0
  59. package/src/observability/capture-tuwunel.test.ts +57 -0
  60. package/src/observability/capture-tuwunel.ts +24 -0
  61. package/src/observability/file-sink.test.ts +68 -0
  62. package/src/observability/file-sink.ts +78 -0
  63. package/src/observability/observability.integration.test.ts +162 -0
  64. package/src/observability/paths.test.ts +104 -0
  65. package/src/observability/paths.ts +94 -0
  66. package/src/services/tuwunel.test.ts +64 -0
  67. package/src/services/tuwunel.ts +91 -0
  68. package/src/web/resolve.test.ts +50 -0
  69. package/src/web/resolve.ts +31 -0
  70. package/src/web/static.test.ts +51 -0
  71. package/src/web/static.ts +61 -0
  72. package/src/web/watch.ts +103 -0
  73. package/dist/chunk-67ZRMVHO.js +0 -174
  74. package/dist/chunk-AR456MHY.js +0 -29
  75. package/dist/chunk-VBGU2NST.js +0 -139
  76. package/dist/client-4VMFEFDX.js +0 -22
  77. package/dist/config-2KK5GX42.js +0 -27
  78. package/dist/template-T5IB4YWC.js +0 -92
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">🪸</text></svg>
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
+ <title>Zoon</title>
8
+ <script type="module" crossorigin src="/assets/index-BT_v3DKu.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DJTghnF-.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/package.json CHANGED
@@ -1,44 +1,64 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.6.0",
4
- "description": "Open-source pub/sub server for AI agents. Publish signals, subscribe via webhook, WebSocket, polling, or RSS.",
3
+ "version": "0.7.0",
4
+ "description": "Open-source tool for defining and running AI agents alongside you and your team. Any model, any CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "author": "Ori Ben",
8
- "repository": {
9
- "type": "git",
10
- "url": "https://github.com/zooid-ai/zooid",
11
- "directory": "packages/cli"
7
+ "private": false,
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.ts",
13
+ "import": "./src/index.ts"
14
+ }
12
15
  },
13
16
  "bin": {
14
- "zooid": "./dist/index.js"
15
- },
16
- "homepage": "https://zooid.dev",
17
- "publishConfig": {
18
- "access": "public"
17
+ "zooid": "./dist/bin.js"
19
18
  },
20
19
  "files": [
21
20
  "dist",
21
+ "src",
22
22
  "README.md"
23
23
  ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
24
30
  "dependencies": {
25
- "@inquirer/checkbox": "^5.0.7",
26
- "commander": "^14.0.3",
27
- "@zooid/server": "^0.6.0",
28
- "@zooid/types": "^0.6.0",
29
- "@zooid/sdk": "^0.6.0"
31
+ "@hono/node-server": "^1.13.0",
32
+ "@inquirer/prompts": "^8.4.3",
33
+ "cac": "^6.7.14",
34
+ "chalk": "^5.4.1",
35
+ "hono": "^4.6.0",
36
+ "listr2": "^8.2.5",
37
+ "marked": "^18.0.4",
38
+ "sanitize-html": "^2.17.4",
39
+ "yaml": "^2.5.0",
40
+ "@zooid/runtime-docker": "^0.7.0",
41
+ "@zooid/core": "^0.7.0",
42
+ "@zooid/runtime-local": "^0.7.0",
43
+ "@zooid/transport-matrix": "^0.7.0",
44
+ "@zooid/context-mcp": "^0.7.0",
45
+ "@zooid/transport-http": "^0.7.0"
30
46
  },
31
47
  "devDependencies": {
32
- "@cloudflare/vitest-pool-workers": "^0.12.21",
33
- "execa": "^9.5.3",
48
+ "@agentclientprotocol/sdk": "^0.21.0",
49
+ "@playwright/test": "^1.49.0",
50
+ "@types/node": "^22.0.0",
51
+ "release-it": "^19.2.4",
34
52
  "tsup": "^8.5.1",
53
+ "tsx": "^4.19.0",
54
+ "typescript": "^5.5.0",
35
55
  "vitest": "^3.2.0"
36
56
  },
37
57
  "scripts": {
38
58
  "build": "tsup",
39
- "dev": "tsup --watch",
40
59
  "test": "vitest run",
41
- "test:integration": "vitest run --config vitest.integration.config.ts",
42
- "test:e2e": "tsup && vitest run --config vitest.e2e.config.ts"
60
+ "test:watch": "vitest",
61
+ "typecheck": "tsc --noEmit",
62
+ "release": "release-it"
43
63
  }
44
64
  }
package/src/bin.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { resolve } from 'node:path'
2
+ import { cac } from 'cac'
3
+ import { runDev } from './commands/dev.js'
4
+ import { runInit } from './commands/init.js'
5
+ import { resolveOptions } from './commands/init/prompts.js'
6
+ import { runLogs } from './commands/logs.js'
7
+ import { runStart } from './commands/start.js'
8
+ import { runStatus } from './commands/status.js'
9
+
10
+ const cli = cac('zooid')
11
+
12
+ cli
13
+ .command('start', 'Run the daemon (production entry-point)')
14
+ .option('--data <dir>', 'Persistent data root dir', { default: './data' })
15
+ .option('--runtime <local|docker|podman>', 'Agent runtime')
16
+ .option('--image <ref>', 'Agent container image')
17
+ .option('--print-token', 'Print a 32-byte hex token and exit')
18
+ .action(async (flags) => {
19
+ await runStart({
20
+ dataDir: flags.data,
21
+ runtime: flags.runtime,
22
+ image: flags.image,
23
+ printToken: flags.printToken,
24
+ })
25
+ })
26
+
27
+ cli
28
+ .command('dev', 'Tuwunel + daemon + UI for local development')
29
+ .option('--data <dir>', 'Persistent data root dir', { default: './data' })
30
+ .option('--engine <docker|podman>', 'Container engine', { default: 'docker' })
31
+ .option('--ui-port <n>', 'UI HTTP port', { default: 5173 })
32
+ .option('--admin-user <name>', 'Admin username', { default: 'admin' })
33
+ .option('--admin-password <pw>', 'Admin password', { default: 'admin' })
34
+ .option('--watch-web', 'Run vite build --watch on @zoon/web (monorepo source only)')
35
+ .action(async (flags) => {
36
+ await runDev({
37
+ dataDir: flags.data,
38
+ engine: flags.engine,
39
+ uiPort: Number(flags.uiPort),
40
+ adminUser: flags.adminUser,
41
+ adminPassword: flags.adminPassword,
42
+ watchWeb: Boolean(flags.watchWeb),
43
+ })
44
+ })
45
+
46
+ cli
47
+ .command(
48
+ 'logs [source]',
49
+ 'Read captured logs. source=tuwunel|daemon|dev|agent-<name>[.acp], or "prune" to delete old days',
50
+ )
51
+ .option('--data <dir>', 'Persistent data root dir', { default: './data' })
52
+ .option('--day <YYYY-MM-DD>', 'Day partition (defaults to today)')
53
+ .option('--turn <id>', 'Filter ACP taps to a single turn id')
54
+ .option('-f, --follow', 'Tail the file (not yet implemented)')
55
+ .option('--keep <n>', 'For `logs prune`: days to retain', { default: 14 })
56
+ .action(async (source, flags) => {
57
+ if (source === 'prune') {
58
+ await runLogs({
59
+ dataDir: resolve(process.cwd(), flags.data),
60
+ subcommand: 'prune',
61
+ keep: Number(flags.keep),
62
+ })
63
+ return
64
+ }
65
+ await runLogs({
66
+ dataDir: resolve(process.cwd(), flags.data),
67
+ source,
68
+ day: flags.day,
69
+ turn: flags.turn,
70
+ follow: Boolean(flags.follow),
71
+ })
72
+ })
73
+
74
+ cli
75
+ .command('status', 'Print Tuwunel + daemon health')
76
+ .option('--data <dir>', 'Persistent data root dir', { default: './data' })
77
+ .option('--port <n>', 'Tuwunel host port (defaults to zooid.yaml)')
78
+ .action(async (flags) => {
79
+ await runStatus({
80
+ dataDir: flags.data,
81
+ port: flags.port !== undefined ? Number(flags.port) : undefined,
82
+ })
83
+ })
84
+
85
+ cli
86
+ .command('init [dir]', 'Scaffold a new zooid workforce in the current (or named) directory')
87
+ .option('--preset <name>', 'claude | codex | opencode')
88
+ .option('--auth <mode>', 'subscription | api-key (claude/codex only)')
89
+ .option('--model <id>', 'Model identifier')
90
+ .option('--provider <id>', 'opencode provider: opencode-go | opencode | anthropic | openrouter | custom')
91
+ .option('--api-key <value>', 'API key (api-key path; opencode always)')
92
+ .option('--force', 'Allow scaffolding into a non-empty directory')
93
+ .option('--overwrite', 'With --force, overwrite existing files')
94
+ .option('--no-interactive', 'Disable prompts; require all flags up front')
95
+ .action(async (dir: string | undefined, flags) => {
96
+ const resolved = await resolveOptions({
97
+ dir: dir ?? process.cwd(),
98
+ preset: flags.preset,
99
+ auth: flags.auth,
100
+ model: flags.model,
101
+ provider: flags.provider,
102
+ apiKey: flags.apiKey,
103
+ force: Boolean(flags.force),
104
+ overwrite: Boolean(flags.overwrite),
105
+ interactive: flags.interactive,
106
+ })
107
+ await runInit(resolved)
108
+ })
109
+
110
+ cli.help()
111
+ cli.version('0.0.1')
112
+ cli.parse()
@@ -0,0 +1,69 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { ensureAdminUser } from './admin.js'
3
+
4
+ const HS = 'http://localhost:8448'
5
+
6
+ afterEach(() => vi.restoreAllMocks())
7
+
8
+ describe('ensureAdminUser', () => {
9
+ it("registers when the user doesn't exist yet", async () => {
10
+ const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
11
+ expect(url).toBe(`${HS}/_matrix/client/v3/register`)
12
+ expect(init.method).toBe('POST')
13
+ const body = JSON.parse(init.body as string)
14
+ expect(body).toMatchObject({
15
+ username: 'admin',
16
+ password: 'admin',
17
+ auth: { type: 'm.login.dummy' },
18
+ inhibit_login: true,
19
+ })
20
+ return new Response(JSON.stringify({ user_id: '@admin:localhost' }), {
21
+ status: 200,
22
+ headers: { 'content-type': 'application/json' },
23
+ })
24
+ })
25
+ vi.stubGlobal('fetch', fetchMock)
26
+ const r = await ensureAdminUser({
27
+ homeserver: HS,
28
+ username: 'admin',
29
+ password: 'admin',
30
+ })
31
+ expect(r.created).toBe(true)
32
+ expect(r.userId).toBe('@admin:localhost')
33
+ expect(fetchMock).toHaveBeenCalledTimes(1)
34
+ })
35
+
36
+ it('treats M_USER_IN_USE as a no-op (idempotent on repeat runs)', async () => {
37
+ const fetchMock = vi.fn(
38
+ async () =>
39
+ new Response(
40
+ JSON.stringify({ errcode: 'M_USER_IN_USE', error: 'taken' }),
41
+ { status: 400, headers: { 'content-type': 'application/json' } },
42
+ ),
43
+ )
44
+ vi.stubGlobal('fetch', fetchMock)
45
+ const r = await ensureAdminUser({
46
+ homeserver: HS,
47
+ username: 'admin',
48
+ password: 'admin',
49
+ })
50
+ expect(r.created).toBe(false)
51
+ expect(r.userId).toBe('@admin:localhost')
52
+ })
53
+
54
+ it('throws on any other error code', async () => {
55
+ vi.stubGlobal(
56
+ 'fetch',
57
+ vi.fn(
58
+ async () =>
59
+ new Response(
60
+ JSON.stringify({ errcode: 'M_FORBIDDEN', error: 'no' }),
61
+ { status: 403, headers: { 'content-type': 'application/json' } },
62
+ ),
63
+ ),
64
+ )
65
+ await expect(
66
+ ensureAdminUser({ homeserver: HS, username: 'admin', password: 'admin' }),
67
+ ).rejects.toThrow(/M_FORBIDDEN/)
68
+ })
69
+ })
@@ -0,0 +1,37 @@
1
+ export interface EnsureAdminOpts {
2
+ homeserver: string
3
+ username: string
4
+ password: string
5
+ }
6
+
7
+ export interface EnsureAdminResult {
8
+ created: boolean
9
+ userId: string
10
+ }
11
+
12
+ export async function ensureAdminUser(
13
+ opts: EnsureAdminOpts,
14
+ ): Promise<EnsureAdminResult> {
15
+ const r = await fetch(`${opts.homeserver}/_matrix/client/v3/register`, {
16
+ method: 'POST',
17
+ headers: { 'content-type': 'application/json' },
18
+ body: JSON.stringify({
19
+ auth: { type: 'm.login.dummy' },
20
+ username: opts.username,
21
+ password: opts.password,
22
+ inhibit_login: true,
23
+ }),
24
+ })
25
+ if (r.ok) {
26
+ const j = (await r.json()) as { user_id: string }
27
+ return { created: true, userId: j.user_id }
28
+ }
29
+ const j = (await r.json()) as { errcode?: string; error?: string }
30
+ if (j.errcode === 'M_USER_IN_USE') {
31
+ const host = new URL(opts.homeserver).hostname
32
+ return { created: false, userId: `@${opts.username}:${host}` }
33
+ }
34
+ throw new Error(
35
+ `register ${opts.username}: ${r.status} ${j.errcode ?? ''} ${j.error ?? ''}`.trim(),
36
+ )
37
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderTuwunelToml } from './configs.js'
3
+
4
+ describe('renderTuwunelToml', () => {
5
+ it('emits the localhost dev profile with paranoid open registration', () => {
6
+ const toml = renderTuwunelToml({ serverName: 'localhost' })
7
+ expect(toml).toContain('[global]')
8
+ expect(toml).toContain('server_name = "localhost"')
9
+ expect(toml).toContain('database_path = "/var/lib/tuwunel/db"')
10
+ expect(toml).toContain('media_path = "/var/lib/tuwunel/media"')
11
+ expect(toml).toContain('appservice_dir = "/var/lib/tuwunel/registrations"')
12
+ expect(toml).toContain('allow_registration = true')
13
+ expect(toml).toContain(
14
+ 'yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true',
15
+ )
16
+ expect(toml).toContain('allow_local_presence = true')
17
+ expect(toml).toContain('port = [8448]')
18
+ })
19
+
20
+ it('honors a non-default server_name', () => {
21
+ const toml = renderTuwunelToml({ serverName: 'matrix.dev' })
22
+ expect(toml).toContain('server_name = "matrix.dev"')
23
+ })
24
+ })
@@ -0,0 +1,64 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { renderRegistration } from '@zooid/transport-matrix'
3
+ import type { Paths } from './paths.js'
4
+
5
+ export interface TuwunelTomlOpts {
6
+ serverName: string
7
+ }
8
+
9
+ /**
10
+ * Tuwunel binds 8448 inside the container; the host port is whatever
11
+ * `docker run -p <host>:8448` maps it to. So `port` here is fixed.
12
+ */
13
+ const TUWUNEL_INTERNAL_PORT = 8448
14
+
15
+ export function renderTuwunelToml(opts: TuwunelTomlOpts): string {
16
+ return [
17
+ '[global]',
18
+ `server_name = "${opts.serverName}"`,
19
+ 'database_path = "/var/lib/tuwunel/db"',
20
+ 'media_path = "/var/lib/tuwunel/media"',
21
+ 'appservice_dir = "/var/lib/tuwunel/registrations"',
22
+ 'allow_registration = true',
23
+ 'yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true',
24
+ 'allow_local_presence = true',
25
+ 'address = ["0.0.0.0"]',
26
+ `port = [${TUWUNEL_INTERNAL_PORT}]`,
27
+ '',
28
+ ].join('\n')
29
+ }
30
+
31
+ export interface BootstrapConfigsOpts {
32
+ paths: Paths
33
+ serverName: string
34
+ asToken: string
35
+ hsToken: string
36
+ senderLocalpart: string
37
+ userNamespace: string
38
+ }
39
+
40
+ export function writeBootstrapConfigs(opts: BootstrapConfigsOpts): void {
41
+ const { paths, serverName, asToken, hsToken, senderLocalpart, userNamespace } = opts
42
+ mkdirSync(paths.dbDir, { recursive: true })
43
+ mkdirSync(paths.mediaDir, { recursive: true })
44
+ mkdirSync(paths.registrationsDir, { recursive: true })
45
+
46
+ writeFileSync(paths.tuwunelTomlPath, renderTuwunelToml({ serverName }))
47
+
48
+ const yaml = renderRegistration({
49
+ id: 'zooid',
50
+ url: `http://host.docker.internal:9099`,
51
+ homeserver: `http://localhost:${TUWUNEL_INTERNAL_PORT}`,
52
+ asToken,
53
+ hsToken,
54
+ senderLocalpart,
55
+ userNamespace,
56
+ // BotPool.bootstrap creates `#alias:<server>` rooms when missing — the
57
+ // AS needs an aliases namespace to legally claim them.
58
+ aliasNamespace: `#.*:${serverName}`,
59
+ // zooid dev shares @.*:<server_name> between bots and the predefined
60
+ // admin human, so the AS cannot claim the namespace exclusively.
61
+ exclusive: false,
62
+ })
63
+ writeFileSync(paths.appserviceYamlPath, yaml)
64
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolveDataLayout } from './data-layout.js'
3
+
4
+ describe('resolveDataLayout', () => {
5
+ it('places matrix, logs, and agents as siblings under the data root', () => {
6
+ const l = resolveDataLayout('/abs/data')
7
+ expect(l.dataRoot).toBe('/abs/data')
8
+ expect(l.matrixDir).toBe('/abs/data/matrix')
9
+ expect(l.logsDir).toBe('/abs/data/logs')
10
+ expect(l.agentsDir).toBe('/abs/data/agents')
11
+ })
12
+
13
+ it('agentDir(id) returns `${root}/agents/${id}` — no .zooid/ subdir', () => {
14
+ const l = resolveDataLayout('/abs/data')
15
+ expect(l.agentDir('docs')).toBe('/abs/data/agents/docs')
16
+ expect(l.agentDir('triage-bot')).toBe('/abs/data/agents/triage-bot')
17
+ })
18
+
19
+ it('leaves relative roots relative (callers normalize)', () => {
20
+ const l = resolveDataLayout('./data')
21
+ expect(l.matrixDir).toBe('data/matrix')
22
+ expect(l.logsDir).toBe('data/logs')
23
+ expect(l.agentDir('a1')).toBe('data/agents/a1')
24
+ })
25
+ })
@@ -0,0 +1,20 @@
1
+ import { join } from 'node:path'
2
+
3
+ export interface DataLayout {
4
+ dataRoot: string
5
+ matrixDir: string
6
+ logsDir: string
7
+ agentsDir: string
8
+ agentDir: (agentId: string) => string
9
+ }
10
+
11
+ export function resolveDataLayout(dataRoot: string): DataLayout {
12
+ const agentsDir = join(dataRoot, 'agents')
13
+ return {
14
+ dataRoot,
15
+ matrixDir: join(dataRoot, 'matrix'),
16
+ logsDir: join(dataRoot, 'logs'),
17
+ agentsDir,
18
+ agentDir: (id) => join(agentsDir, id),
19
+ }
20
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { MatrixTransportConfig } from '@zooid/core'
3
+ import { deriveHomeserverShape } from './derive.js'
4
+
5
+ const base: MatrixTransportConfig = {
6
+ type: 'matrix',
7
+ homeserver: 'http://localhost:8448',
8
+ as_token: 'as-x',
9
+ hs_token: 'hs-x',
10
+ sender_localpart: 'zooid',
11
+ user_namespace: '@.*:localhost',
12
+ }
13
+
14
+ describe('deriveHomeserverShape', () => {
15
+ it('extracts host, port, and server_name from a localhost zooid.yaml', () => {
16
+ const shape = deriveHomeserverShape(base, [
17
+ '@assistant:localhost',
18
+ '@monitor:localhost',
19
+ ])
20
+ expect(shape).toEqual({
21
+ host: 'localhost',
22
+ port: 8448,
23
+ serverName: 'localhost',
24
+ })
25
+ })
26
+
27
+ it('honors a non-default port in the homeserver URL', () => {
28
+ const shape = deriveHomeserverShape(
29
+ { ...base, homeserver: 'http://localhost:9000' },
30
+ ['@assistant:localhost'],
31
+ )
32
+ expect(shape.port).toBe(9000)
33
+ })
34
+
35
+ it('extracts a non-localhost server_name when the user_namespace targets a domain', () => {
36
+ const shape = deriveHomeserverShape(
37
+ {
38
+ ...base,
39
+ homeserver: 'http://localhost:8448',
40
+ user_namespace: '@.*:matrix.dev',
41
+ },
42
+ ['@assistant:matrix.dev'],
43
+ )
44
+ expect(shape.serverName).toBe('matrix.dev')
45
+ })
46
+
47
+ it('refuses to start when an agent matrix_user_id does not end with the derived server_name', () => {
48
+ expect(() =>
49
+ deriveHomeserverShape(base, ['@assistant:other.example']),
50
+ ).toThrow(/server_name mismatch/i)
51
+ })
52
+
53
+ it('refuses to start when user_namespace regex is not the simple @.*:server form', () => {
54
+ expect(() =>
55
+ deriveHomeserverShape(
56
+ { ...base, user_namespace: '@(alice|bob):localhost' },
57
+ ['@assistant:localhost'],
58
+ ),
59
+ ).toThrow(/user_namespace/i)
60
+ })
61
+
62
+ it('defaults port to 80/443 when homeserver URL omits an explicit port', () => {
63
+ expect(
64
+ deriveHomeserverShape({ ...base, homeserver: 'http://hs.example', user_namespace: '@.*:hs.example' }, [
65
+ '@assistant:hs.example',
66
+ ]).port,
67
+ ).toBe(80)
68
+ expect(
69
+ deriveHomeserverShape({ ...base, homeserver: 'https://hs.example', user_namespace: '@.*:hs.example' }, [
70
+ '@assistant:hs.example',
71
+ ]).port,
72
+ ).toBe(443)
73
+ })
74
+ })
@@ -0,0 +1,40 @@
1
+ import type { MatrixTransportConfig } from '@zooid/core'
2
+
3
+ export interface HomeserverShape {
4
+ host: string
5
+ port: number
6
+ serverName: string
7
+ }
8
+
9
+ const NAMESPACE_RE = /^@\.\*:([A-Za-z0-9.-]+)$/
10
+
11
+ export function deriveHomeserverShape(
12
+ matrix: MatrixTransportConfig,
13
+ agentUserIds: readonly string[],
14
+ ): HomeserverShape {
15
+ const url = new URL(matrix.homeserver)
16
+ const host = url.hostname
17
+ const port = url.port
18
+ ? Number(url.port)
19
+ : url.protocol === 'https:'
20
+ ? 443
21
+ : 80
22
+
23
+ const m = NAMESPACE_RE.exec(matrix.user_namespace)
24
+ if (!m) {
25
+ throw new Error(
26
+ `user_namespace ${matrix.user_namespace!}: expected the simple form '@.*:<server_name>'`,
27
+ )
28
+ }
29
+ const serverName = m[1]!
30
+
31
+ for (const userId of agentUserIds) {
32
+ if (!userId.endsWith(`:${serverName}`)) {
33
+ throw new Error(
34
+ `server_name mismatch: agent ${userId} does not end with :${serverName}`,
35
+ )
36
+ }
37
+ }
38
+
39
+ return { host, port, serverName }
40
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolvePaths } from './paths.js'
3
+
4
+ // `resolvePaths` continues to take the *matrix-specific* dir.
5
+ // Callers now pass `${dataRoot}/matrix` (computed via resolveDataLayout).
6
+ describe('resolvePaths', () => {
7
+ it('builds the canonical layout under the matrix dir', () => {
8
+ const p = resolvePaths('/abs/data/matrix')
9
+ expect(p.dbDir).toBe('/abs/data/matrix/db')
10
+ expect(p.mediaDir).toBe('/abs/data/matrix/media')
11
+ expect(p.configDir).toBe('/abs/data/matrix/config')
12
+ expect(p.registrationsDir).toBe('/abs/data/matrix/config/registrations')
13
+ expect(p.tuwunelTomlPath).toBe('/abs/data/matrix/config/tuwunel.toml')
14
+ expect(p.appserviceYamlPath).toBe('/abs/data/matrix/config/registrations/zooid.yaml')
15
+ expect(p.envPath).toBe('/abs/data/matrix/config/.env')
16
+ })
17
+
18
+ it('handles relative paths by leaving them relative (caller normalizes)', () => {
19
+ const p = resolvePaths('./data/matrix')
20
+ expect(p.dbDir).toBe('data/matrix/db')
21
+ })
22
+ })
@@ -0,0 +1,27 @@
1
+ import { join } from 'node:path'
2
+
3
+ export interface Paths {
4
+ dataDir: string
5
+ dbDir: string
6
+ mediaDir: string
7
+ configDir: string
8
+ registrationsDir: string
9
+ tuwunelTomlPath: string
10
+ appserviceYamlPath: string
11
+ envPath: string
12
+ }
13
+
14
+ export function resolvePaths(dataDir: string): Paths {
15
+ const configDir = join(dataDir, 'config')
16
+ const registrationsDir = join(configDir, 'registrations')
17
+ return {
18
+ dataDir,
19
+ dbDir: join(dataDir, 'db'),
20
+ mediaDir: join(dataDir, 'media'),
21
+ configDir,
22
+ registrationsDir,
23
+ tuwunelTomlPath: join(configDir, 'tuwunel.toml'),
24
+ appserviceYamlPath: join(registrationsDir, 'zooid.yaml'),
25
+ envPath: join(configDir, '.env'),
26
+ }
27
+ }
@@ -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
+ })