zooid 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ import type { Context, Hono } from 'hono'
2
+ import type { MatchContext, TriggerConfig, WebhookTriggerConfig } from '@zooid/core'
3
+ import { evaluateMatch, renderTemplate } from '@zooid/core'
4
+ import { fireTrigger, type FireTriggerDeps } from './trigger-runner.js'
5
+ import { verifySignature, verifyCustomSignature, type CustomVerifier } from './webhook-verify.js'
6
+ import { DeliveryCache } from './delivery-cache.js'
7
+
8
+ // GitHub's delivery times out at 10s; a 1MB cap leaves headroom to hash and
9
+ // respond well inside that even on a slow box. Checked before hashing, per
10
+ // [[ZOD082]] §Design 4 rule 5.
11
+ const MAX_BODY = 1_000_000
12
+ // `${output}` is a chat message body, not a blob store — cap it well under
13
+ // Matrix's ~64KiB event-size ceiling once the surrounding `text:` is added.
14
+ const MAX_OUTPUT_CHARS = 60_000
15
+ const TRUNCATION_MARKER = '\n\n… (truncated)'
16
+ // Comfortably past any provider's redelivery window.
17
+ const DELIVERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
18
+
19
+ const EVENT_HEADER_BY_PROVIDER: Partial<Record<WebhookTriggerConfig['provider'], string>> = {
20
+ github: 'x-github-event',
21
+ }
22
+
23
+ const DELIVERY_ID_HEADER_BY_PROVIDER: Partial<Record<WebhookTriggerConfig['provider'], string>> = {
24
+ github: 'x-github-delivery',
25
+ standard: 'webhook-id',
26
+ }
27
+
28
+ // Headers any supported provider might send, gathered once per request.
29
+ const RELEVANT_HEADERS = [
30
+ 'x-hub-signature-256',
31
+ 'x-github-event',
32
+ 'x-github-delivery',
33
+ 'stripe-signature',
34
+ 'x-slack-signature',
35
+ 'x-slack-request-timestamp',
36
+ 'webhook-signature',
37
+ 'webhook-id',
38
+ 'webhook-timestamp',
39
+ ] as const
40
+
41
+ export interface WebhookDeps {
42
+ triggers: Record<string, TriggerConfig>
43
+ /**
44
+ * Verifier function per `provider: custom` trigger name, imported at
45
+ * daemon start by `loadCustomVerifiers`. A custom trigger with no entry
46
+ * here rejects every delivery — fail closed.
47
+ */
48
+ customVerifiers?: Record<string, CustomVerifier>
49
+ agentUserIds: Record<string, string>
50
+ resolveRoom: FireTriggerDeps['resolveRoom']
51
+ ensureBot: FireTriggerDeps['ensureBot']
52
+ sendMessage: FireTriggerDeps['sendMessage']
53
+ }
54
+
55
+ /**
56
+ * Gather the headers verification might need. Named providers read a fixed
57
+ * set; `provider: custom` gets every header, lower-cased, since only the
58
+ * operator's verifier knows which ones its service sends.
59
+ */
60
+ function headersOf(
61
+ c: { req: { header: (name: string) => string | undefined; raw: Request } },
62
+ all: boolean,
63
+ ): Record<string, string | undefined> {
64
+ const out: Record<string, string | undefined> = {}
65
+ if (all) {
66
+ c.req.raw.headers.forEach((value, key) => {
67
+ out[key.toLowerCase()] = value
68
+ })
69
+ return out
70
+ }
71
+ for (const h of RELEVANT_HEADERS) out[h] = c.req.header(h)
72
+ return out
73
+ }
74
+
75
+ /** Headers as a dense record, for handing to an operator's verifier. */
76
+ function definedHeaders(headers: Record<string, string | undefined>): Record<string, string> {
77
+ const out: Record<string, string> = {}
78
+ for (const [k, v] of Object.entries(headers)) {
79
+ if (v !== undefined) out[k] = v
80
+ }
81
+ return out
82
+ }
83
+
84
+ function renderPayload(raw: string): string {
85
+ let pretty: string
86
+ try {
87
+ pretty = JSON.stringify(JSON.parse(raw), null, 2)
88
+ } catch {
89
+ pretty = raw
90
+ }
91
+ if (pretty.length <= MAX_OUTPUT_CHARS) return pretty
92
+ return pretty.slice(0, MAX_OUTPUT_CHARS) + TRUNCATION_MARKER
93
+ }
94
+
95
+ async function handleDelivery(
96
+ deps: WebhookDeps,
97
+ name: string,
98
+ trigger: TriggerConfig,
99
+ raw: string,
100
+ headers: Record<string, string | undefined>,
101
+ cache: DeliveryCache,
102
+ customDeliveryId: string | undefined,
103
+ ): Promise<void> {
104
+ try {
105
+ const webhook = trigger.webhook
106
+ if (!webhook) return
107
+
108
+ const idHeader = DELIVERY_ID_HEADER_BY_PROVIDER[webhook.provider]
109
+ // A custom verifier reports its own delivery id, since only it knows
110
+ // where the service puts one.
111
+ const deliveryId = customDeliveryId ?? (idHeader ? headers[idHeader] : undefined)
112
+ if (deliveryId !== undefined && cache.seen(`${name}:${deliveryId}`)) return
113
+
114
+ const eventHeader = EVENT_HEADER_BY_PROVIDER[webhook.provider]
115
+ const event = eventHeader ? headers[eventHeader] : undefined
116
+
117
+ let body: unknown
118
+ try {
119
+ body = JSON.parse(raw)
120
+ } catch {
121
+ body = undefined
122
+ }
123
+
124
+ const ctx: MatchContext = {
125
+ event,
126
+ body,
127
+ headers: definedHeaders(headers),
128
+ output: renderPayload(raw),
129
+ }
130
+
131
+ for (const message of trigger.messages) {
132
+ if (message.match !== undefined && !evaluateMatch(message.match, ctx)) continue
133
+
134
+ const agentUserId = deps.agentUserIds[message.mention]
135
+ if (!agentUserId) {
136
+ console.warn(`[webhook:${name}] unknown agent "${message.mention}" — skipping`)
137
+ continue
138
+ }
139
+
140
+ await fireTrigger({
141
+ name,
142
+ as: trigger.as,
143
+ message: { ...message, text: renderTemplate(message.text, ctx) },
144
+ agentUserId,
145
+ resolveRoom: deps.resolveRoom,
146
+ ensureBot: deps.ensureBot,
147
+ sendMessage: deps.sendMessage,
148
+ })
149
+ }
150
+ } catch (err) {
151
+ // Never throw: the response has already been sent, and one bad delivery
152
+ // must not take down the daemon.
153
+ console.warn(`[webhook:${name}] failed:`, (err as Error).message)
154
+ }
155
+ }
156
+
157
+ export const WEBHOOK_ROUTE_PREFIX = '/_zooid/webhooks'
158
+
159
+ export function mountWebhookRoutes(app: Hono, deps: WebhookDeps): void {
160
+ const cache = new DeliveryCache(DELIVERY_CACHE_TTL_MS)
161
+
162
+ const receive = async (c: Context<any, '/:name'>) => {
163
+ const name = c.req.param('name')
164
+ const trigger = deps.triggers[name]
165
+
166
+ // Read the RAW body first. Parsing and re-serializing changes the bytes
167
+ // and breaks every signature — the classic webhook bug.
168
+ const raw = await c.req.text()
169
+ if (raw.length > MAX_BODY) return c.text('too large', 413)
170
+
171
+ // Unknown trigger and bad signature return the identical response, so
172
+ // the endpoint cannot be probed to discover which triggers exist.
173
+ if (!trigger?.webhook) return c.text('unauthorized', 401)
174
+ const webhook = trigger.webhook
175
+ const headers = headersOf(c, webhook.provider === 'custom')
176
+
177
+ let customDeliveryId: string | undefined
178
+ if (webhook.provider === 'custom') {
179
+ const v = await verifyCustomSignature(deps.customVerifiers?.[name], {
180
+ rawBody: raw,
181
+ headers: definedHeaders(headers),
182
+ secret: webhook.secret,
183
+ })
184
+ if (!v.ok) return c.text('unauthorized', 401)
185
+ customDeliveryId = v.deliveryId
186
+ } else {
187
+ const v = verifySignature(webhook.provider, {
188
+ rawBody: raw,
189
+ headers,
190
+ secret: webhook.secret,
191
+ })
192
+ if (!v.ok) return c.text('unauthorized', 401)
193
+ }
194
+
195
+ // Accepted. Everything below is fire-and-forget: GitHub times out at
196
+ // 10s and an agent turn does not fit in that.
197
+ void handleDelivery(deps, name, trigger, raw, headers, cache, customDeliveryId)
198
+ return c.text('accepted', 202)
199
+ }
200
+
201
+ app.post(`${WEBHOOK_ROUTE_PREFIX}/:name`, receive)
202
+ }
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createHmac } from 'node:crypto'
3
+ import { verifySignature, verifyCustomSignature, type CustomVerifierInput } from './webhook-verify.js'
4
+
5
+ const secret = 'shh'
6
+ const body = '{"action":"closed","number":42}'
7
+
8
+ const githubSig = (b = body, s = secret) =>
9
+ `sha256=${createHmac('sha256', s).update(b).digest('hex')}`
10
+
11
+ describe('verifySignature — github', () => {
12
+ it('accepts a correct signature over the raw body', () => {
13
+ expect(
14
+ verifySignature('github', { rawBody: body, headers: { 'x-hub-signature-256': githubSig() }, secret }),
15
+ ).toEqual({ ok: true })
16
+ })
17
+
18
+ it('rejects a signature computed over different bytes', () => {
19
+ expect(
20
+ verifySignature('github', {
21
+ rawBody: '{"action":"opened","number":42}',
22
+ headers: { 'x-hub-signature-256': githubSig() },
23
+ secret,
24
+ }).ok,
25
+ ).toBe(false)
26
+ })
27
+
28
+ it('rejects a signature made with a different secret', () => {
29
+ expect(
30
+ verifySignature('github', {
31
+ rawBody: body,
32
+ headers: { 'x-hub-signature-256': githubSig(body, 'wrong') },
33
+ secret,
34
+ }).ok,
35
+ ).toBe(false)
36
+ })
37
+
38
+ it('rejects a missing signature header — fail closed', () => {
39
+ expect(verifySignature('github', { rawBody: body, headers: {}, secret }).ok).toBe(false)
40
+ })
41
+
42
+ it('rejects a malformed header without throwing', () => {
43
+ for (const h of ['', 'sha256=', 'garbage', 'sha256=zzzz', 'sha1=abcd']) {
44
+ expect(() =>
45
+ verifySignature('github', { rawBody: body, headers: { 'x-hub-signature-256': h }, secret }),
46
+ ).not.toThrow()
47
+ expect(
48
+ verifySignature('github', { rawBody: body, headers: { 'x-hub-signature-256': h }, secret }).ok,
49
+ ).toBe(false)
50
+ }
51
+ })
52
+
53
+ it('rejects a signature of the wrong length without throwing (timingSafeEqual throws on length mismatch)', () => {
54
+ expect(
55
+ verifySignature('github', {
56
+ rawBody: body,
57
+ headers: { 'x-hub-signature-256': 'sha256=abcd' },
58
+ secret,
59
+ }).ok,
60
+ ).toBe(false)
61
+ })
62
+ })
63
+
64
+ describe('verifyCustomSignature', () => {
65
+ const shopifyish = ({ rawBody, headers, secret: sec }: CustomVerifierInput) => {
66
+ const expected = createHmac('sha256', sec).update(rawBody).digest('base64')
67
+ return { ok: headers['x-shopify-hmac-sha256'] === expected, deliveryId: headers['x-delivery'] }
68
+ }
69
+ const input = (headers: Record<string, string>) => ({ rawBody: body, headers, secret })
70
+ const sigOf = (b = body, s = secret) => createHmac('sha256', s).update(b).digest('base64')
71
+
72
+ it('accepts what the operator function accepts, and passes back its delivery id', async () => {
73
+ expect(
74
+ await verifyCustomSignature(shopifyish, input({ 'x-shopify-hmac-sha256': sigOf(), 'x-delivery': 'd9' })),
75
+ ).toEqual({ ok: true, deliveryId: 'd9' })
76
+ })
77
+
78
+ it('rejects what the operator function rejects', async () => {
79
+ expect(
80
+ (await verifyCustomSignature(shopifyish, input({ 'x-shopify-hmac-sha256': sigOf(body, 'wrong') }))).ok,
81
+ ).toBe(false)
82
+ })
83
+
84
+ it('accepts a verifier that returns a bare boolean', async () => {
85
+ expect(await verifyCustomSignature(() => true, input({}))).toEqual({ ok: true })
86
+ expect(await verifyCustomSignature(() => false, input({}))).toEqual({ ok: false })
87
+ })
88
+
89
+ it('awaits an async verifier', async () => {
90
+ expect(await verifyCustomSignature(async () => ({ ok: true, deliveryId: 'a1' }), input({}))).toEqual({
91
+ ok: true,
92
+ deliveryId: 'a1',
93
+ })
94
+ })
95
+
96
+ it('fails closed when no verifier is loaded — never "no verifier, so accept"', async () => {
97
+ expect(await verifyCustomSignature(undefined, input({}))).toEqual({ ok: false })
98
+ })
99
+
100
+ it('fails closed when the verifier throws or rejects', async () => {
101
+ expect(
102
+ (
103
+ await verifyCustomSignature(() => {
104
+ throw new Error('boom')
105
+ }, input({}))
106
+ ).ok,
107
+ ).toBe(false)
108
+ expect((await verifyCustomSignature(async () => Promise.reject(new Error('boom')), input({}))).ok).toBe(
109
+ false,
110
+ )
111
+ })
112
+
113
+ it('fails closed on a junk return value, rather than coercing it to true', async () => {
114
+ for (const junk of ['yes', 1, {}, { ok: 'true' }, null, undefined]) {
115
+ expect((await verifyCustomSignature((() => junk) as never, input({}))).ok).toBe(false)
116
+ }
117
+ })
118
+ })
119
+
120
+ describe('verifySignature — providers with a timestamp', () => {
121
+ const slackSig = (ts: string, b = body, s = secret) =>
122
+ `v0=${createHmac('sha256', s).update(`v0:${ts}:${b}`).digest('hex')}`
123
+
124
+ it('accepts a fresh slack signature over v0:ts:body', () => {
125
+ const ts = String(Math.floor(Date.now() / 1000))
126
+ expect(
127
+ verifySignature('slack', {
128
+ rawBody: body,
129
+ headers: { 'x-slack-signature': slackSig(ts), 'x-slack-request-timestamp': ts },
130
+ secret,
131
+ }),
132
+ ).toEqual({ ok: true })
133
+ })
134
+
135
+ it('rejects a slack signature outside the freshness window — replay defence', () => {
136
+ const old = String(Math.floor(Date.now() / 1000) - 60 * 60)
137
+ expect(
138
+ verifySignature('slack', {
139
+ rawBody: body,
140
+ headers: { 'x-slack-signature': slackSig(old), 'x-slack-request-timestamp': old },
141
+ secret,
142
+ }).ok,
143
+ ).toBe(false)
144
+ })
145
+
146
+ it('rejects a slack request with no timestamp header', () => {
147
+ expect(
148
+ verifySignature('slack', {
149
+ rawBody: body,
150
+ headers: { 'x-slack-signature': slackSig('123') },
151
+ secret,
152
+ }).ok,
153
+ ).toBe(false)
154
+ })
155
+ })
@@ -0,0 +1,155 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+ import type { WebhookTriggerConfig } from '@zooid/core'
3
+
4
+ const FRESHNESS_S = 300
5
+
6
+ /**
7
+ * What an operator's `verify:` module is called with. `headers` carries every
8
+ * request header, lower-cased — a named provider reads a fixed set, but a
9
+ * custom verifier is the only thing that knows which ones its service sends.
10
+ */
11
+ export interface CustomVerifierInput {
12
+ rawBody: string
13
+ headers: Record<string, string>
14
+ secret: string
15
+ }
16
+
17
+ /**
18
+ * An operator-supplied verifier. Returns a bare boolean, or a result that
19
+ * also carries the provider's unique delivery id — the id is what replay
20
+ * dedupe keys on, and only the verifier knows where the service puts it.
21
+ * Throwing counts as rejection; nothing it does can turn into an accept.
22
+ */
23
+ export type CustomVerifier = (
24
+ input: CustomVerifierInput,
25
+ ) =>
26
+ | boolean
27
+ | { ok: boolean; deliveryId?: string }
28
+ | Promise<boolean | { ok: boolean; deliveryId?: string }>
29
+
30
+ /** Providers whose signing scheme is built in. `custom` is verified by the operator's own function. */
31
+ export type NamedProvider = Exclude<WebhookTriggerConfig['provider'], 'custom'>
32
+
33
+ export interface VerifyInput {
34
+ rawBody: string
35
+ headers: Record<string, string | undefined>
36
+ secret: string
37
+ }
38
+
39
+ export type VerifyResult = { ok: true } | { ok: false }
40
+
41
+ // Length-check first: timingSafeEqual throws when buffers differ in length.
42
+ function safeEqual(a: string, b: string): boolean {
43
+ const ab = Buffer.from(a, 'utf8')
44
+ const bb = Buffer.from(b, 'utf8')
45
+ return ab.length === bb.length && timingSafeEqual(ab, bb)
46
+ }
47
+
48
+ function hmacHex(secret: string, baseString: string): string {
49
+ return createHmac('sha256', secret).update(baseString).digest('hex')
50
+ }
51
+
52
+ function verifyGithub(input: VerifyInput): VerifyResult {
53
+ const header = input.headers['x-hub-signature-256']
54
+ if (!header) return { ok: false }
55
+ const [scheme, sig] = header.split('=')
56
+ if (scheme !== 'sha256' || !sig) return { ok: false }
57
+ const expected = hmacHex(input.secret, input.rawBody)
58
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false }
59
+ }
60
+
61
+ function verifyStripe(input: VerifyInput): VerifyResult {
62
+ const header = input.headers['stripe-signature']
63
+ if (!header) return { ok: false }
64
+ const parts = Object.fromEntries(
65
+ header
66
+ .split(',')
67
+ .map((p) => p.split('=', 2) as [string, string | undefined])
68
+ .filter(([, v]) => v !== undefined),
69
+ )
70
+ const ts = parts.t
71
+ const sig = parts.v1
72
+ if (!ts || !sig) return { ok: false }
73
+ if (!isFresh(ts)) return { ok: false }
74
+ const expected = hmacHex(input.secret, `${ts}.${input.rawBody}`)
75
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false }
76
+ }
77
+
78
+ function verifySlack(input: VerifyInput): VerifyResult {
79
+ const header = input.headers['x-slack-signature']
80
+ const ts = input.headers['x-slack-request-timestamp']
81
+ if (!header || !ts) return { ok: false }
82
+ if (!header.startsWith('v0=')) return { ok: false }
83
+ const sig = header.slice('v0='.length)
84
+ if (!isFresh(ts)) return { ok: false }
85
+ const expected = hmacHex(input.secret, `v0:${ts}:${input.rawBody}`)
86
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false }
87
+ }
88
+
89
+ function verifyStandard(input: VerifyInput): VerifyResult {
90
+ const header = input.headers['webhook-signature']
91
+ const id = input.headers['webhook-id']
92
+ const ts = input.headers['webhook-timestamp']
93
+ if (!header || !id || !ts) return { ok: false }
94
+ if (!isFresh(ts)) return { ok: false }
95
+ const candidate = header
96
+ .split(' ')
97
+ .map((p) => (p.startsWith('v1,') ? p.slice('v1,'.length) : undefined))
98
+ .find((v) => v !== undefined)
99
+ if (!candidate) return { ok: false }
100
+ const expected = createHmac('sha256', input.secret)
101
+ .update(`${id}.${ts}.${input.rawBody}`)
102
+ .digest('base64')
103
+ return safeEqual(candidate, expected) ? { ok: true } : { ok: false }
104
+ }
105
+
106
+ function isFresh(tsRaw: string): boolean {
107
+ const ts = Number(tsRaw)
108
+ if (!Number.isFinite(ts)) return false
109
+ const nowS = Date.now() / 1000
110
+ return Math.abs(nowS - ts) <= FRESHNESS_S
111
+ }
112
+
113
+ const VERIFIERS: Record<NamedProvider, (input: VerifyInput) => VerifyResult> = {
114
+ github: verifyGithub,
115
+ stripe: verifyStripe,
116
+ slack: verifySlack,
117
+ standard: verifyStandard,
118
+ }
119
+
120
+ /**
121
+ * Verify a webhook delivery's signature. Every failure path returns
122
+ * `{ ok: false }` — nothing throws, and no reason is returned to the
123
+ * caller, since the route must not explain *why* it rejected a request.
124
+ */
125
+ export function verifySignature(provider: NamedProvider, input: VerifyInput): VerifyResult {
126
+ try {
127
+ return VERIFIERS[provider](input)
128
+ } catch {
129
+ return { ok: false }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Run an operator-supplied verifier for `provider: custom`. Fails closed on
135
+ * every abnormal path — no verifier loaded, a throw, a rejected promise, or
136
+ * a return value that is not a recognised shape. A verifier can only ever
137
+ * *grant* acceptance by explicitly returning true.
138
+ */
139
+ export async function verifyCustomSignature(
140
+ verifier: CustomVerifier | undefined,
141
+ input: CustomVerifierInput,
142
+ ): Promise<{ ok: boolean; deliveryId?: string }> {
143
+ if (typeof verifier !== 'function') return { ok: false }
144
+ try {
145
+ const result = await verifier(input)
146
+ if (result === true) return { ok: true }
147
+ if (result === false || result === null || typeof result !== 'object') return { ok: false }
148
+ if (result.ok !== true) return { ok: false }
149
+ return typeof result.deliveryId === 'string'
150
+ ? { ok: true, deliveryId: result.deliveryId }
151
+ : { ok: true }
152
+ } catch {
153
+ return { ok: false }
154
+ }
155
+ }
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { installPiExtension, resolvePiAgentDir } from './pi-extension-install.js'
6
+
7
+ const dirs: string[] = []
8
+ const scratch = () => { const dir = mkdtempSync(join(tmpdir(), 'zooid-pi-')); dirs.push(dir); return dir }
9
+ afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })))
10
+
11
+ describe('resolvePiAgentDir', () => {
12
+ it('resolves a relative PI_CODING_AGENT_DIR against each agent workdir', () => {
13
+ expect(
14
+ resolvePiAgentDir({
15
+ agentWorkdir: '/proj/agents/alice',
16
+ daemonHome: '/home/op',
17
+ env: { PI_CODING_AGENT_DIR: '.pi-agent' },
18
+ }),
19
+ ).toEqual({ dir: '/proj/agents/alice/.pi-agent', scope: 'project' })
20
+ })
21
+
22
+ it('honours an absolute override and falls back to the operator home', () => {
23
+ expect(
24
+ resolvePiAgentDir({
25
+ agentWorkdir: '/proj/agents/alice',
26
+ daemonHome: '/home/op',
27
+ env: { PI_CODING_AGENT_DIR: '/shared/pi' },
28
+ }),
29
+ ).toEqual({ dir: '/shared/pi', scope: 'project' })
30
+ expect(
31
+ resolvePiAgentDir({ agentWorkdir: '/proj/agents/alice', daemonHome: '/home/op', env: {} }),
32
+ ).toEqual({ dir: '/home/op/.pi/agent', scope: 'home' })
33
+ })
34
+ })
35
+
36
+ describe('installPiExtension', () => {
37
+ it('only installs into an existing Pi home and preserves other extensions', () => {
38
+ const home = scratch(); const source = join(scratch(), 'bundle.js')
39
+ writeFileSync(source, '// bundle')
40
+ const agentDir = join(home, '.pi', 'agent')
41
+ expect(installPiExtension({ agentDir, bundlePath: source })).toMatchObject({ status: 'skipped' })
42
+ expect(existsSync(join(home, '.pi'))).toBe(false)
43
+ const extensions = join(agentDir, 'extensions')
44
+ mkdirSync(extensions, { recursive: true }); writeFileSync(join(extensions, 'user.js'), '// user')
45
+ expect(installPiExtension({ agentDir, bundlePath: source })).toMatchObject({ status: 'installed' })
46
+ expect(readFileSync(join(extensions, 'zooid-tasks.js'), 'utf8')).toBe('// bundle')
47
+ expect(readFileSync(join(extensions, 'user.js'), 'utf8')).toBe('// user')
48
+ expect(installPiExtension({ agentDir, bundlePath: source })).toMatchObject({ status: 'unchanged' })
49
+ })
50
+
51
+ it('creates a project agent dir that does not exist yet', () => {
52
+ const workdir = scratch(); const source = join(scratch(), 'bundle.js')
53
+ writeFileSync(source, '// bundle')
54
+ const { dir } = resolvePiAgentDir({
55
+ agentWorkdir: workdir,
56
+ daemonHome: '/home/op',
57
+ env: { PI_CODING_AGENT_DIR: '.pi-agent' },
58
+ })
59
+ expect(installPiExtension({ agentDir: dir, bundlePath: source, createMissing: true })).toMatchObject({
60
+ status: 'installed',
61
+ target: join(workdir, '.pi-agent', 'extensions', 'zooid-tasks.js'),
62
+ })
63
+ expect(readFileSync(join(workdir, '.pi-agent', 'extensions', 'zooid-tasks.js'), 'utf8')).toBe('// bundle')
64
+ })
65
+ })
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
3
+
4
+ export interface InstallPiExtensionResult {
5
+ status: 'installed' | 'unchanged' | 'skipped'
6
+ reason?: string
7
+ target?: string
8
+ }
9
+
10
+ export interface PiAgentDir {
11
+ dir: string
12
+ /** `project` dirs are ours to create; `home` is the operator's ~/.pi. */
13
+ scope: 'project' | 'home'
14
+ }
15
+
16
+ /**
17
+ * Resolve pi's agent dir the way pi does: PI_CODING_AGENT_DIR when set,
18
+ * otherwise ~/.pi/agent. `zooid init` writes that variable as a *relative*
19
+ * path on purpose (ZOD075) so one value is correct under both runtimes — it
20
+ * resolves against the agent's cwd, and `agents/<name>` locally and
21
+ * `/workspace` in a container are the same directory. We always resolve
22
+ * against the host workdir, which is the host side of that same mount.
23
+ */
24
+ export function resolvePiAgentDir(opts: {
25
+ agentWorkdir: string
26
+ daemonHome: string
27
+ env?: { PI_CODING_AGENT_DIR?: string | undefined }
28
+ }): PiAgentDir {
29
+ const override = opts.env?.PI_CODING_AGENT_DIR
30
+ if (override) {
31
+ return {
32
+ dir: isAbsolute(override) ? override : resolve(opts.agentWorkdir, override),
33
+ scope: 'project',
34
+ }
35
+ }
36
+ return { dir: join(opts.daemonHome, '.pi', 'agent'), scope: 'home' }
37
+ }
38
+
39
+ /** Install our bundle without replacing an operator's existing Pi extensions. */
40
+ export function installPiExtension(opts: {
41
+ agentDir: string
42
+ bundlePath: string
43
+ /** Project dirs belong to the workspace, so we may create them. ~/.pi is the
44
+ * operator's: install into it only if they already use pi. */
45
+ createMissing?: boolean
46
+ }): InstallPiExtensionResult {
47
+ if (!opts.createMissing && !existsSync(opts.agentDir)) {
48
+ return { status: 'skipped', reason: 'no Pi home' }
49
+ }
50
+ const target = join(opts.agentDir, 'extensions', 'zooid-tasks.js')
51
+ mkdirSync(dirname(target), { recursive: true })
52
+ const source = readFileSync(opts.bundlePath)
53
+ if (existsSync(target) && readFileSync(target).equals(source)) return { status: 'unchanged', target }
54
+ writeFileSync(target, source)
55
+ return { status: 'installed', target }
56
+ }