zooid 0.12.0 → 0.13.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/README.md +2 -0
- package/dist/bin.js +359 -123
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-3Q4BPAZD.js → chunk-YZ4IO5MR.js} +56 -10
- package/dist/{chunk-3Q4BPAZD.js.map → chunk-YZ4IO5MR.js.map} +1 -1
- package/dist/index.js +1 -1
- package/package.json +11 -9
- package/src/bin.test.ts +63 -0
- package/src/bin.ts +52 -3
- package/src/bootstrap/configs.test.ts +7 -0
- package/src/bootstrap/configs.ts +14 -0
- package/src/commands/dev.ts +37 -7
- package/src/commands/status.test.ts +15 -0
- package/src/commands/status.ts +27 -2
- package/src/daemon/start-daemon.ts +15 -1
- package/src/push-gateway/gateway.test.ts +150 -0
- package/src/push-gateway/gateway.ts +78 -0
- package/src/push-gateway/index.ts +17 -0
- package/src/push-gateway/payload.test.ts +88 -0
- package/src/push-gateway/payload.ts +39 -0
- package/src/push-gateway/types.ts +37 -0
- package/src/push-gateway/vapid.test.ts +45 -0
- package/src/push-gateway/vapid.ts +34 -0
- package/src/services/tuwunel.ts +21 -2
- package/src/version.test.ts +67 -0
- package/src/version.ts +30 -0
- package/src/web/static.test.ts +22 -0
- package/src/web/static.ts +9 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zooid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "An open-source, self-hostable chat app for collaborating with AI agents alongside your team. Any model, any CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"zooid": {
|
|
28
|
-
"webVersion": "0.
|
|
28
|
+
"webVersion": "0.11.0"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=22"
|
|
@@ -40,19 +40,21 @@
|
|
|
40
40
|
"marked": "^18.0.4",
|
|
41
41
|
"sanitize-html": "^2.17.4",
|
|
42
42
|
"tar": "^7.5.16",
|
|
43
|
+
"web-push": "^3.6.7",
|
|
43
44
|
"yaml": "^2.5.0",
|
|
44
|
-
"@zooid/
|
|
45
|
-
"@zooid/
|
|
46
|
-
"@zooid/
|
|
47
|
-
"@zooid/
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/runtime-
|
|
50
|
-
"@zooid/transport-
|
|
45
|
+
"@zooid/acp-client": "^0.13.0",
|
|
46
|
+
"@zooid/transport-matrix": "^0.13.0",
|
|
47
|
+
"@zooid/context-mcp": "^0.13.0",
|
|
48
|
+
"@zooid/core": "^0.13.0",
|
|
49
|
+
"@zooid/runtime-local": "^0.13.0",
|
|
50
|
+
"@zooid/runtime-docker": "^0.13.0",
|
|
51
|
+
"@zooid/transport-http": "^0.13.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@agentclientprotocol/sdk": "^0.21.0",
|
|
54
55
|
"@playwright/test": "^1.49.0",
|
|
55
56
|
"@types/node": "^22.0.0",
|
|
57
|
+
"@types/web-push": "^3.6.4",
|
|
56
58
|
"release-it": "^19.2.4",
|
|
57
59
|
"tsup": "^8.5.1",
|
|
58
60
|
"tsx": "^4.19.0",
|
package/src/bin.test.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const TSX = join(HERE, '..', 'node_modules', '.bin', 'tsx')
|
|
8
|
+
const BIN = join(HERE, 'bin.ts')
|
|
9
|
+
|
|
10
|
+
function runCli(args: string[]) {
|
|
11
|
+
const result = spawnSync(TSX, [BIN, ...args], { encoding: 'utf8' })
|
|
12
|
+
return { stdout: result.stdout, stderr: result.stderr, status: result.status }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('zooid help', () => {
|
|
16
|
+
it('lists every registered command with no args', () => {
|
|
17
|
+
const { stdout, status } = runCli([])
|
|
18
|
+
expect(status).toBe(1)
|
|
19
|
+
for (const name of ['start', 'dev', 'logs', 'status', 'init', 'help']) {
|
|
20
|
+
expect(stdout).toContain(name)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('--help prints the same command list and exits 0', () => {
|
|
25
|
+
const { stdout, status } = runCli(['--help'])
|
|
26
|
+
expect(status).toBe(0)
|
|
27
|
+
expect(stdout).toContain('Commands:')
|
|
28
|
+
expect(stdout).toContain('init [dir]')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('`zooid help` mirrors `zooid --help`', () => {
|
|
32
|
+
const { stdout, status } = runCli(['help'])
|
|
33
|
+
expect(status).toBe(0)
|
|
34
|
+
expect(stdout).toContain('Commands:')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('`zooid help <command>` shows that command\'s own help', () => {
|
|
38
|
+
const { stdout, status } = runCli(['help', 'init'])
|
|
39
|
+
expect(status).toBe(0)
|
|
40
|
+
expect(stdout).toContain('$ zooid init [dir]')
|
|
41
|
+
expect(stdout).toContain('--preset <name>')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('`zooid <command> --help` still works directly', () => {
|
|
45
|
+
const { stdout, status } = runCli(['logs', '--help'])
|
|
46
|
+
expect(status).toBe(0)
|
|
47
|
+
expect(stdout).toContain('$ zooid logs [source]')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects an unknown subcommand of help', () => {
|
|
51
|
+
const { stdout, stderr, status } = runCli(['help', 'bogus'])
|
|
52
|
+
expect(status).toBe(1)
|
|
53
|
+
expect(stderr).toContain('Unknown command: bogus')
|
|
54
|
+
expect(stdout).toContain('Commands:')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects an unknown top-level command instead of exiting silently', () => {
|
|
58
|
+
const { stdout, stderr, status } = runCli(['frobnicate'])
|
|
59
|
+
expect(status).toBe(1)
|
|
60
|
+
expect(stderr).toContain('Unknown command: frobnicate')
|
|
61
|
+
expect(stdout).toContain('Commands:')
|
|
62
|
+
})
|
|
63
|
+
})
|
package/src/bin.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveOptions } from './commands/init/prompts.js'
|
|
|
6
6
|
import { runLogs } from './commands/logs.js'
|
|
7
7
|
import { runStart } from './commands/start.js'
|
|
8
8
|
import { runStatus } from './commands/status.js'
|
|
9
|
+
import { CLI_VERSION } from './version.js'
|
|
9
10
|
|
|
10
11
|
const cli = cac('zooid')
|
|
11
12
|
|
|
@@ -15,6 +16,8 @@ cli
|
|
|
15
16
|
.option('--runtime <local|docker|podman>', 'Agent runtime')
|
|
16
17
|
.option('--image <ref>', 'Agent container image')
|
|
17
18
|
.option('--print-token', 'Print a 32-byte hex token and exit')
|
|
19
|
+
.example('$ zooid start --data ./data')
|
|
20
|
+
.example('$ zooid start --runtime docker --image ghcr.io/zooid-ai/agent:latest')
|
|
18
21
|
.action(async (flags) => {
|
|
19
22
|
await runStart({
|
|
20
23
|
dataDir: flags.data,
|
|
@@ -35,6 +38,8 @@ cli
|
|
|
35
38
|
'--watch-web [path]',
|
|
36
39
|
'Run vite build --watch on @zooid/web. Path defaults to sibling ../zooid-clients/packages/web.',
|
|
37
40
|
)
|
|
41
|
+
.example('$ zooid dev')
|
|
42
|
+
.example('$ zooid dev --engine podman --ui-port 5174')
|
|
38
43
|
.action(async (flags) => {
|
|
39
44
|
await runDev({
|
|
40
45
|
dataDir: flags.data,
|
|
@@ -56,6 +61,9 @@ cli
|
|
|
56
61
|
.option('--turn <id>', 'Filter ACP taps to a single turn id')
|
|
57
62
|
.option('-f, --follow', 'Tail the file (not yet implemented)')
|
|
58
63
|
.option('--keep <n>', 'For `logs prune`: days to retain', { default: 14 })
|
|
64
|
+
.example('$ zooid logs daemon')
|
|
65
|
+
.example('$ zooid logs agent-support.acp --turn 3f9c1a --day 2026-09-06')
|
|
66
|
+
.example('$ zooid logs prune --keep 7')
|
|
59
67
|
.action(async (source, flags) => {
|
|
60
68
|
if (source === 'prune') {
|
|
61
69
|
await runLogs({
|
|
@@ -78,6 +86,7 @@ cli
|
|
|
78
86
|
.command('status', 'Print Tuwunel + daemon health')
|
|
79
87
|
.option('--data <dir>', 'Persistent data root dir', { default: './data' })
|
|
80
88
|
.option('--port <n>', 'Tuwunel host port (defaults to zooid.yaml)')
|
|
89
|
+
.example('$ zooid status')
|
|
81
90
|
.action(async (flags) => {
|
|
82
91
|
await runStatus({
|
|
83
92
|
dataDir: flags.data,
|
|
@@ -95,6 +104,9 @@ cli
|
|
|
95
104
|
.option('--force', 'Allow scaffolding into a non-empty directory')
|
|
96
105
|
.option('--overwrite', 'With --force, overwrite existing files')
|
|
97
106
|
.option('--no-interactive', 'Disable prompts; require all flags up front')
|
|
107
|
+
.example('$ zooid init')
|
|
108
|
+
.example('$ zooid init my-workforce --preset opencode --provider anthropic')
|
|
109
|
+
.example('$ zooid init --preset claude --auth api-key --api-key sk-... --no-interactive')
|
|
98
110
|
.action(async (dir: string | undefined, flags) => {
|
|
99
111
|
const resolved = await resolveOptions({
|
|
100
112
|
dir: dir ?? process.cwd(),
|
|
@@ -110,6 +122,43 @@ cli
|
|
|
110
122
|
await runInit(resolved)
|
|
111
123
|
})
|
|
112
124
|
|
|
113
|
-
cli
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
cli
|
|
126
|
+
.command('help [command]', 'Display help for zooid, or for a specific command')
|
|
127
|
+
.example('$ zooid help')
|
|
128
|
+
.example('$ zooid help init')
|
|
129
|
+
.action((commandName?: string) => {
|
|
130
|
+
if (!commandName) {
|
|
131
|
+
cli.globalCommand.outputHelp()
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const target = cli.commands.find((c) => c.isMatched(commandName))
|
|
135
|
+
if (!target) {
|
|
136
|
+
console.error(`Unknown command: ${commandName}\n`)
|
|
137
|
+
cli.globalCommand.outputHelp()
|
|
138
|
+
process.exitCode = 1
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
target.outputHelp()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
cli.help((sections) => {
|
|
145
|
+
sections.push({
|
|
146
|
+
title: 'Docs',
|
|
147
|
+
body: ' https://zooid.dev/docs',
|
|
148
|
+
})
|
|
149
|
+
return sections
|
|
150
|
+
})
|
|
151
|
+
cli.version(CLI_VERSION)
|
|
152
|
+
|
|
153
|
+
cli.on('command:*', () => {
|
|
154
|
+
console.error(`Unknown command: ${cli.args.join(' ')}\n`)
|
|
155
|
+
cli.outputHelp()
|
|
156
|
+
process.exitCode = 1
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
if (process.argv.slice(2).length === 0) {
|
|
160
|
+
cli.outputHelp()
|
|
161
|
+
process.exitCode = 1
|
|
162
|
+
} else {
|
|
163
|
+
cli.parse()
|
|
164
|
+
}
|
|
@@ -15,6 +15,13 @@ describe('renderTuwunelToml', () => {
|
|
|
15
15
|
)
|
|
16
16
|
expect(toml).toContain('allow_local_presence = true')
|
|
17
17
|
expect(toml).toContain('port = [8448]')
|
|
18
|
+
// Presence-based push suppression stays OFF — sw.js suppresses per-room
|
|
19
|
+
// on visibility instead, and presence lingers long past a closed tab.
|
|
20
|
+
expect(toml).not.toContain('suppress_push_when_active')
|
|
21
|
+
// DEV ONLY: Tuwunel is in Podman, the push gateway runs on the host, and
|
|
22
|
+
// the default ip_range_denylist (127/8, 10/8, 172.16/12, 192.168/16, ::1)
|
|
23
|
+
// would silently drop every pusher delivery to it.
|
|
24
|
+
expect(toml).toContain('ip_range_denylist = []')
|
|
18
25
|
})
|
|
19
26
|
|
|
20
27
|
it('honors a non-default server_name', () => {
|
package/src/bootstrap/configs.ts
CHANGED
|
@@ -26,6 +26,20 @@ export function renderTuwunelToml(opts: TuwunelTomlOpts): string {
|
|
|
26
26
|
'allow_local_presence = true',
|
|
27
27
|
'address = ["0.0.0.0"]',
|
|
28
28
|
`port = [${TUWUNEL_INTERNAL_PORT}]`,
|
|
29
|
+
// NOT `suppress_push_when_active` ([[ZNC025]]). It reads Matrix presence,
|
|
30
|
+
// which is both too coarse and too slow for this: coarse because it is
|
|
31
|
+
// per-user, so reading room A kills the push for room B; slow because
|
|
32
|
+
// `currently_active` lingers for minutes after the last sync, so closing
|
|
33
|
+
// the tab and waiting for an agent to finish still delivers nothing —
|
|
34
|
+
// exactly the case this feature exists for. `public/sw.js` already does
|
|
35
|
+
// the suppression we actually want, precisely: it drops a push only when
|
|
36
|
+
// a *visible* window is on *that* room.
|
|
37
|
+
// DEV ONLY — disables an SSRF guard. Tuwunel is in Podman and the push
|
|
38
|
+
// gateway runs on the host, so the default ip_range_denylist (127/8,
|
|
39
|
+
// 10/8, 172.16/12, 192.168/16, ::1) silently drops every pusher delivery.
|
|
40
|
+
// Never set on a box: there the gateway is reached at the public
|
|
41
|
+
// hostname through Caddy.
|
|
42
|
+
'ip_range_denylist = []',
|
|
29
43
|
'',
|
|
30
44
|
].join('\n')
|
|
31
45
|
}
|
package/src/commands/dev.ts
CHANGED
|
@@ -248,7 +248,22 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
248
248
|
t.output = msg
|
|
249
249
|
},
|
|
250
250
|
}))
|
|
251
|
-
const app = webStatic({
|
|
251
|
+
const app = webStatic({
|
|
252
|
+
webRoot,
|
|
253
|
+
homeserverUrl: homeserver,
|
|
254
|
+
...(ctx.daemon?.vapidPublicKey
|
|
255
|
+
? {
|
|
256
|
+
// Tuwunel runs in a container; `localhost` here would
|
|
257
|
+
// resolve to the container, not the daemon. Same shorthand
|
|
258
|
+
// as the AS registration url
|
|
259
|
+
// (bootstrap/registration-url.ts). This URL is stored in
|
|
260
|
+
// the pusher and fetched by the homeserver — the browser
|
|
261
|
+
// never requests it.
|
|
262
|
+
pushGatewayUrl: `http://host.docker.internal:${ctx.daemon.port}/_matrix/push/v1/notify`,
|
|
263
|
+
vapidPublicKey: ctx.daemon.vapidPublicKey,
|
|
264
|
+
}
|
|
265
|
+
: {}),
|
|
266
|
+
})
|
|
252
267
|
ctx.uiServer = serve({ fetch: app.fetch, port: flags.uiPort })
|
|
253
268
|
},
|
|
254
269
|
},
|
|
@@ -287,13 +302,28 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
287
302
|
})
|
|
288
303
|
|
|
289
304
|
if (flags.installSignalHandlers !== false) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
305
|
+
// buildShutdown is idempotent, so a repeat Ctrl-C used to print again and
|
|
306
|
+
// await the same promise, with no way to give up on a step taking too
|
|
307
|
+
// long. Escalate on repeats instead.
|
|
308
|
+
let interrupts = 0
|
|
309
|
+
const onSignal = (): void => {
|
|
310
|
+
interrupts += 1
|
|
311
|
+
if (interrupts === 1) {
|
|
312
|
+
process.stdout.write(chalk.dim('\nStopping…\n'))
|
|
313
|
+
void shutdown().then(() => process.exit(0))
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if (interrupts === 2) {
|
|
317
|
+
process.stdout.write(
|
|
318
|
+
chalk.dim('Still stopping — press Ctrl-C again to force quit.\n'),
|
|
319
|
+
)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
process.stdout.write(chalk.dim('Forced.\n'))
|
|
323
|
+
process.exit(130)
|
|
294
324
|
}
|
|
295
|
-
process.on('SIGINT',
|
|
296
|
-
process.on('SIGTERM',
|
|
325
|
+
process.on('SIGINT', onSignal)
|
|
326
|
+
process.on('SIGTERM', onSignal)
|
|
297
327
|
}
|
|
298
328
|
|
|
299
329
|
process.stdout.write(
|
|
@@ -54,6 +54,21 @@ describe('collectStatus', () => {
|
|
|
54
54
|
])
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
it('reports the VAPID public key when vapid.json exists in the data dir', async () => {
|
|
58
|
+
writeFileSync(join(dir, 'zooid.yaml'), yaml)
|
|
59
|
+
writeFileSync(join(dir, 'vapid.json'), JSON.stringify({ publicKey: 'BPk', privateKey: 'priv' }))
|
|
60
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
|
|
61
|
+
const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
|
|
62
|
+
expect(s.vapidPublicKey).toBe('BPk')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('omits the VAPID key when no daemon has run yet', async () => {
|
|
66
|
+
writeFileSync(join(dir, 'zooid.yaml'), yaml)
|
|
67
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
|
|
68
|
+
const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
|
|
69
|
+
expect(s.vapidPublicKey).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
57
72
|
it('reports daemon down when the AS callback port refuses the connection', async () => {
|
|
58
73
|
writeFileSync(join(dir, 'zooid.yaml'), yaml)
|
|
59
74
|
vi.stubGlobal(
|
package/src/commands/status.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
3
|
import chalk from 'chalk'
|
|
4
4
|
import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
|
|
5
5
|
import { deriveHomeserverShape } from '../bootstrap/derive.js'
|
|
6
|
+
import { VAPID_FILENAME } from '../push-gateway/vapid.js'
|
|
6
7
|
|
|
7
8
|
export interface StatusFlags {
|
|
8
9
|
cwd?: string
|
|
@@ -15,6 +16,24 @@ export interface StatusReport {
|
|
|
15
16
|
tuwunel: { status: 'up' | 'down'; url: string }
|
|
16
17
|
daemon: { status: 'up' | 'down'; url: string } | { status: 'unknown'; reason: string }
|
|
17
18
|
agents: { name: string; userId: string; trigger: string }[]
|
|
19
|
+
/** VAPID public key read from `<dataDir>/vapid.json`, when it exists. */
|
|
20
|
+
vapidPublicKey?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the daemon's VAPID public key straight off disk. Operators
|
|
25
|
+
* hand-editing a box's config.json need it and can't start a daemon just to
|
|
26
|
+
* ask, so this reads the persisted file rather than starting one.
|
|
27
|
+
*/
|
|
28
|
+
export function readVapidPublicKey(dataDir: string): string | undefined {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(join(dataDir, VAPID_FILENAME), 'utf8')) as {
|
|
31
|
+
publicKey?: string
|
|
32
|
+
}
|
|
33
|
+
return typeof parsed.publicKey === 'string' ? parsed.publicKey : undefined
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
18
37
|
}
|
|
19
38
|
|
|
20
39
|
async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
|
|
@@ -29,12 +48,14 @@ async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
|
|
|
29
48
|
export async function collectStatus(opts: {
|
|
30
49
|
cwd: string
|
|
31
50
|
tuwunelUrl: string
|
|
51
|
+
dataDir?: string
|
|
32
52
|
}): Promise<StatusReport> {
|
|
33
53
|
const tuwunelUp = await probe(`${opts.tuwunelUrl}/_matrix/client/versions`)
|
|
34
54
|
const tuwunel: StatusReport['tuwunel'] = {
|
|
35
55
|
status: tuwunelUp ? 'up' : 'down',
|
|
36
56
|
url: opts.tuwunelUrl,
|
|
37
57
|
}
|
|
58
|
+
const vapidPublicKey = opts.dataDir ? readVapidPublicKey(opts.dataDir) : undefined
|
|
38
59
|
|
|
39
60
|
const found = findConfigFile(opts.cwd)
|
|
40
61
|
if (!found) {
|
|
@@ -42,6 +63,7 @@ export async function collectStatus(opts: {
|
|
|
42
63
|
tuwunel,
|
|
43
64
|
daemon: { status: 'unknown', reason: 'no zooid.yaml' },
|
|
44
65
|
agents: [],
|
|
66
|
+
...(vapidPublicKey ? { vapidPublicKey } : {}),
|
|
45
67
|
}
|
|
46
68
|
}
|
|
47
69
|
const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
|
|
@@ -67,6 +89,7 @@ export async function collectStatus(opts: {
|
|
|
67
89
|
tuwunel,
|
|
68
90
|
daemon: { status: daemonUp ? 'up' : 'down', url: daemonUrl },
|
|
69
91
|
agents,
|
|
92
|
+
...(vapidPublicKey ? { vapidPublicKey } : {}),
|
|
70
93
|
}
|
|
71
94
|
}
|
|
72
95
|
|
|
@@ -89,7 +112,8 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
|
|
|
89
112
|
}
|
|
90
113
|
}
|
|
91
114
|
const tuwunelUrl = `http://localhost:${port ?? 8448}`
|
|
92
|
-
const
|
|
115
|
+
const dataDir = resolve(cwd, flags.dataDir ?? './data')
|
|
116
|
+
const s = await collectStatus({ cwd, tuwunelUrl, dataDir })
|
|
93
117
|
const fmt = (st: 'up' | 'down' | 'unknown'): string =>
|
|
94
118
|
st === 'up' ? chalk.green('up') : st === 'down' ? chalk.red('down') : chalk.yellow('unknown')
|
|
95
119
|
process.stdout.write(
|
|
@@ -99,6 +123,7 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
|
|
|
99
123
|
...s.agents.map(
|
|
100
124
|
(a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`,
|
|
101
125
|
),
|
|
126
|
+
...(s.vapidPublicKey ? [`vapid public key: ${s.vapidPublicKey}`] : []),
|
|
102
127
|
'',
|
|
103
128
|
].join('\n'),
|
|
104
129
|
)
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from '@zooid/context-mcp'
|
|
34
34
|
import { buildAcpRegistry } from '../build-registry.js'
|
|
35
35
|
import { prepullImages } from '../prepull-images.js'
|
|
36
|
+
import { mountPushGateway } from '../push-gateway/index.js'
|
|
36
37
|
import { makeSyncCursorStore } from './sync-cursors.js'
|
|
37
38
|
import { shouldBindHttpListener } from './pull-wiring.js'
|
|
38
39
|
|
|
@@ -79,6 +80,8 @@ export interface StartDaemonOpts {
|
|
|
79
80
|
export interface DaemonHandle {
|
|
80
81
|
port: number
|
|
81
82
|
agentNames: string[]
|
|
83
|
+
/** VAPID public key for web push, when the gateway bound (appservice mode with a data dir). */
|
|
84
|
+
vapidPublicKey?: string
|
|
82
85
|
stop(): Promise<void>
|
|
83
86
|
whenStopped: Promise<void>
|
|
84
87
|
}
|
|
@@ -166,6 +169,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
166
169
|
|
|
167
170
|
const matrix = findMatrixTransport(config)
|
|
168
171
|
let port: number
|
|
172
|
+
let vapidPublicKey: string | undefined
|
|
169
173
|
|
|
170
174
|
if (matrix) {
|
|
171
175
|
const mode = matrix.transport.mode ?? 'appservice'
|
|
@@ -232,6 +236,16 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
232
236
|
})
|
|
233
237
|
if (shouldBindHttpListener(mode)) {
|
|
234
238
|
const requestedPort = matrix.transport.port ?? 9000
|
|
239
|
+
// The gateway rides the appservice listener, not webStatic: webStatic
|
|
240
|
+
// exists only under `zooid dev`, and on a deployed box Caddy serves the
|
|
241
|
+
// dist directly with the daemon out of the serving path. This is the
|
|
242
|
+
// one HTTP surface bound in both modes.
|
|
243
|
+
if (dataDir) {
|
|
244
|
+
vapidPublicKey = mountPushGateway(transport.app, {
|
|
245
|
+
dataDir,
|
|
246
|
+
subject: `https://${serverName}`,
|
|
247
|
+
}).publicKey
|
|
248
|
+
}
|
|
235
249
|
// Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
|
|
236
250
|
// macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
|
|
237
251
|
// events back to host.docker.internal:<port>.
|
|
@@ -347,5 +361,5 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
347
361
|
process.on('SIGTERM', () => handler('SIGTERM'))
|
|
348
362
|
}
|
|
349
363
|
|
|
350
|
-
return { port, agentNames, stop, whenStopped }
|
|
364
|
+
return { port, agentNames, vapidPublicKey, stop, whenStopped }
|
|
351
365
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const sendNotification = vi.fn()
|
|
4
|
+
class WebPushError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
public statusCode: number,
|
|
8
|
+
) {
|
|
9
|
+
super(message)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
vi.mock('web-push', () => ({
|
|
13
|
+
default: { sendNotification, WebPushError },
|
|
14
|
+
sendNotification,
|
|
15
|
+
WebPushError,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const { pushGateway } = await import('./gateway.js')
|
|
19
|
+
|
|
20
|
+
const KEYS = { publicKey: 'pub', privateKey: 'priv' }
|
|
21
|
+
|
|
22
|
+
function device(over: Record<string, unknown> = {}) {
|
|
23
|
+
return {
|
|
24
|
+
app_id: 'dev.zooid.web',
|
|
25
|
+
pushkey: 'BPk_device_one',
|
|
26
|
+
data: { endpoint: 'https://fcm.example/one', auth: 'auth1' },
|
|
27
|
+
...over,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function notification(devices: unknown[]) {
|
|
32
|
+
return {
|
|
33
|
+
notification: {
|
|
34
|
+
event_id: '$e:example.org',
|
|
35
|
+
room_id: '!r:example.org',
|
|
36
|
+
room_name: 'general',
|
|
37
|
+
sender_display_name: 'Alice',
|
|
38
|
+
type: 'm.room.message',
|
|
39
|
+
content: { msgtype: 'm.text', body: 'hello' },
|
|
40
|
+
counts: { unread: 1 },
|
|
41
|
+
devices,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function notify(body: unknown) {
|
|
47
|
+
const app = pushGateway({ keys: KEYS, subject: 'mailto:ops@example.org' })
|
|
48
|
+
return app.request('/_matrix/push/v1/notify', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
sendNotification.mockReset()
|
|
57
|
+
sendNotification.mockResolvedValue({ statusCode: 201 })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('pushGateway', () => {
|
|
61
|
+
it('encrypts to the device subscription reassembled from pushkey + data', async () => {
|
|
62
|
+
const res = await notify(notification([device()]))
|
|
63
|
+
expect(res.status).toBe(200)
|
|
64
|
+
expect(await res.json()).toEqual({ rejected: [] })
|
|
65
|
+
|
|
66
|
+
const [subscription, payload, options] = sendNotification.mock.calls[0]!
|
|
67
|
+
expect(subscription).toEqual({
|
|
68
|
+
endpoint: 'https://fcm.example/one',
|
|
69
|
+
keys: { p256dh: 'BPk_device_one', auth: 'auth1' },
|
|
70
|
+
})
|
|
71
|
+
expect(JSON.parse(payload as string).room_name).toBe('general')
|
|
72
|
+
expect((options as { vapidDetails: unknown }).vapidDetails).toEqual({
|
|
73
|
+
subject: 'mailto:ops@example.org',
|
|
74
|
+
publicKey: 'pub',
|
|
75
|
+
privateKey: 'priv',
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('fans out to every matching device', async () => {
|
|
80
|
+
await notify(
|
|
81
|
+
notification([
|
|
82
|
+
device(),
|
|
83
|
+
device({ pushkey: 'BPk_two', data: { endpoint: 'https://fcm.example/two', auth: 'a2' } }),
|
|
84
|
+
]),
|
|
85
|
+
)
|
|
86
|
+
expect(sendNotification).toHaveBeenCalledTimes(2)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('skips a foreign app_id silently — never rejects it', async () => {
|
|
90
|
+
const res = await notify(notification([device({ app_id: 'im.vector.app.ios' })]))
|
|
91
|
+
expect(sendNotification).not.toHaveBeenCalled()
|
|
92
|
+
// Rejecting would make the homeserver permanently delete another client's pusher.
|
|
93
|
+
expect(await res.json()).toEqual({ rejected: [] })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('rejects a pushkey on 410 Gone so the homeserver garbage-collects it', async () => {
|
|
97
|
+
sendNotification.mockRejectedValueOnce(new WebPushError('gone', 410))
|
|
98
|
+
const res = await notify(notification([device()]))
|
|
99
|
+
expect(await res.json()).toEqual({ rejected: ['BPk_device_one'] })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rejects a pushkey on 404 too', async () => {
|
|
103
|
+
sendNotification.mockRejectedValueOnce(new WebPushError('not found', 404))
|
|
104
|
+
expect(await (await notify(notification([device()]))).json()).toEqual({
|
|
105
|
+
rejected: ['BPk_device_one'],
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does NOT reject on a transient 429 or 5xx', async () => {
|
|
110
|
+
sendNotification.mockRejectedValueOnce(new WebPushError('slow down', 429))
|
|
111
|
+
expect(await (await notify(notification([device()]))).json()).toEqual({ rejected: [] })
|
|
112
|
+
|
|
113
|
+
sendNotification.mockRejectedValueOnce(new WebPushError('bad gateway', 502))
|
|
114
|
+
expect(await (await notify(notification([device()]))).json()).toEqual({ rejected: [] })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does not let one dead device stop delivery to a live one', async () => {
|
|
118
|
+
sendNotification
|
|
119
|
+
.mockRejectedValueOnce(new WebPushError('gone', 410))
|
|
120
|
+
.mockResolvedValueOnce({ statusCode: 201 })
|
|
121
|
+
const res = await notify(
|
|
122
|
+
notification([
|
|
123
|
+
device(),
|
|
124
|
+
device({ pushkey: 'BPk_two', data: { endpoint: 'https://fcm.example/two', auth: 'a2' } }),
|
|
125
|
+
]),
|
|
126
|
+
)
|
|
127
|
+
expect(sendNotification).toHaveBeenCalledTimes(2)
|
|
128
|
+
expect(await res.json()).toEqual({ rejected: ['BPk_device_one'] })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('skips a device missing endpoint or auth without rejecting it', async () => {
|
|
132
|
+
const res = await notify(notification([device({ data: { endpoint: 'https://x' } })]))
|
|
133
|
+
expect(sendNotification).not.toHaveBeenCalled()
|
|
134
|
+
expect(await res.json()).toEqual({ rejected: [] })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('400s on a malformed body instead of throwing', async () => {
|
|
138
|
+
expect((await notify({ nope: true })).status).toBe(400)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('logs receipt and outcome — the only way to see a Tuwunel call in the daemon log', async () => {
|
|
142
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
143
|
+
await notify(notification([device()]))
|
|
144
|
+
expect(log).toHaveBeenCalledWith(
|
|
145
|
+
expect.stringContaining('notify room=!r:example.org type=m.room.message devices=1'),
|
|
146
|
+
)
|
|
147
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining('delivered=1 rejected=0'))
|
|
148
|
+
log.mockRestore()
|
|
149
|
+
})
|
|
150
|
+
})
|