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.
- package/LICENSE +1 -1
- package/README.md +36 -444
- package/dist/bin.js +1797 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-N5POSZX5.js +31000 -0
- package/dist/chunk-N5POSZX5.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +4 -3788
- 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-BT_v3DKu.js +295 -0
- package/dist/web/assets/index-DJTghnF-.css +1 -0
- package/dist/web/assets/index-JZMMlqDP.js +118066 -0
- package/dist/web/assets/reaction-picker-emoji-Kh7emgFa.js +716 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +42 -22
- package/src/bin.ts +112 -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 +142 -0
- package/src/build-registry.zod043.test.ts +64 -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 +308 -0
- package/src/commands/init/generators.test.ts +97 -0
- package/src/commands/init/generators.ts +119 -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 +100 -0
- package/src/daemon/start-daemon.test.ts +67 -0
- package/src/daemon/start-daemon.ts +243 -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/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 @@
|
|
|
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.
|
|
4
|
-
"description": "Open-source
|
|
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
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
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/
|
|
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
|
-
"@
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
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
|
-
"@
|
|
33
|
-
"
|
|
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:
|
|
42
|
-
"
|
|
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
|
+
})
|