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.
- package/README.md +6 -1
- package/dist/bin.js +674 -171
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-YZ4IO5MR.js → chunk-R5S26T7B.js} +20523 -507
- package/dist/chunk-R5S26T7B.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/package.json +11 -9
- package/src/build-registry.context.test.ts +3 -3
- package/src/build-registry.ts +67 -24
- package/src/daemon/delivery-cache.test.ts +34 -0
- package/src/daemon/delivery-cache.ts +36 -0
- package/src/daemon/load-custom-verifiers.ts +43 -0
- package/src/daemon/start-daemon.ts +162 -38
- package/src/daemon/task-journal.test.ts +19 -0
- package/src/daemon/task-journal.ts +32 -0
- package/src/daemon/trigger-rooms.test.ts +60 -0
- package/src/daemon/trigger-rooms.ts +0 -0
- package/src/daemon/trigger-runner.test.ts +71 -0
- package/src/daemon/trigger-runner.ts +41 -0
- package/src/daemon/trigger-scheduler.ts +55 -0
- package/src/daemon/webhook-routes.test.ts +74 -0
- package/src/daemon/webhook-routes.ts +202 -0
- package/src/daemon/webhook-verify.test.ts +155 -0
- package/src/daemon/webhook-verify.ts +155 -0
- package/src/pi-extension-install.test.ts +65 -0
- package/src/pi-extension-install.ts +56 -0
- package/dist/chunk-YZ4IO5MR.js.map +0 -1
|
@@ -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
|
+
}
|