@zooid/core 0.12.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/dist/index.d.ts +222 -5
- package/dist/index.js +262 -8
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/acp-registry.test.ts +14 -0
- package/src/acp-registry.ts +14 -17
- package/src/acp-types.ts +2 -1
- package/src/config.test.ts +1 -0
- package/src/config.ts +314 -0
- package/src/index.ts +12 -11
- package/src/match-expression.test.ts +61 -0
- package/src/match-expression.ts +33 -0
- package/src/render-template.test.ts +38 -0
- package/src/render-template.ts +32 -0
- package/src/task-actions.ts +84 -0
- package/src/transport-context.test.ts +11 -4
- package/src/transport-context.ts +18 -2
- package/src/triggers-config.test.ts +168 -0
- package/src/triggers-messages-config.test.ts +146 -0
- package/src/triggers-webhook-config.test.ts +143 -0
- package/src/types.ts +65 -0
|
@@ -4,7 +4,9 @@ import type {
|
|
|
4
4
|
HistoryPage,
|
|
5
5
|
Message,
|
|
6
6
|
Member,
|
|
7
|
-
|
|
7
|
+
RoomInfo,
|
|
8
|
+
SendMessageInput,
|
|
9
|
+
SendMessageResult,
|
|
8
10
|
ThreadOverviewPage,
|
|
9
11
|
} from './transport-context.js'
|
|
10
12
|
|
|
@@ -20,11 +22,16 @@ describe('TransportContextProvider', () => {
|
|
|
20
22
|
>()
|
|
21
23
|
expectTypeOf<TransportContextProvider['getThreadHistory']>().returns.resolves.toEqualTypeOf<HistoryPage>()
|
|
22
24
|
expectTypeOf<TransportContextProvider['getChannelMembers']>().returns.resolves.toEqualTypeOf<Member[]>()
|
|
23
|
-
expectTypeOf<TransportContextProvider['
|
|
25
|
+
expectTypeOf<TransportContextProvider['getRoomInfo']>().returns.resolves.toEqualTypeOf<RoomInfo>()
|
|
26
|
+
expectTypeOf<TransportContextProvider['getRooms']>().returns.resolves.toEqualTypeOf<RoomInfo[]>()
|
|
27
|
+
expectTypeOf<TransportContextProvider['sendMessage']>().parameters.toEqualTypeOf<
|
|
28
|
+
[SendMessageInput]
|
|
29
|
+
>()
|
|
30
|
+
expectTypeOf<TransportContextProvider['sendMessage']>().returns.resolves.toEqualTypeOf<SendMessageResult>()
|
|
24
31
|
})
|
|
25
32
|
|
|
26
|
-
it('
|
|
27
|
-
expectTypeOf<
|
|
33
|
+
it('RoomInfo.transport is the two-transport MVP union', () => {
|
|
34
|
+
expectTypeOf<RoomInfo['transport']>().toEqualTypeOf<'http' | 'matrix'>()
|
|
28
35
|
})
|
|
29
36
|
|
|
30
37
|
it('Message and Member carry the is_agent flag', () => {
|
package/src/transport-context.ts
CHANGED
|
@@ -27,12 +27,24 @@ export interface Member {
|
|
|
27
27
|
agent_name?: string
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/** Renamed from ChannelInfo — "channel" is not a Matrix primitive ([[ZOD084]]). */
|
|
31
|
+
export interface RoomInfo {
|
|
31
32
|
id: string
|
|
32
33
|
name: string
|
|
33
34
|
transport: 'http' | 'matrix'
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export interface SendMessageInput {
|
|
38
|
+
room: string
|
|
39
|
+
thread_id?: string
|
|
40
|
+
text: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SendMessageResult {
|
|
44
|
+
event_id: string
|
|
45
|
+
thread_id?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export interface HistoryPage {
|
|
37
49
|
messages: Message[]
|
|
38
50
|
next_before?: string
|
|
@@ -87,5 +99,9 @@ export interface TransportContextProvider {
|
|
|
87
99
|
opts: HistoryOptions,
|
|
88
100
|
): Promise<HistoryPage>
|
|
89
101
|
getChannelMembers(channelId: string): Promise<Member[]>
|
|
90
|
-
getChannelInfo
|
|
102
|
+
/** Renamed from getChannelInfo. */
|
|
103
|
+
getRoomInfo(channelId: string): Promise<RoomInfo>
|
|
104
|
+
/** Rooms this agent is bound to. Targets for sendMessage. */
|
|
105
|
+
getRooms(): Promise<RoomInfo[]>
|
|
106
|
+
sendMessage(input: SendMessageInput): Promise<SendMessageResult>
|
|
91
107
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { loadZooidConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
const base = `
|
|
5
|
+
runtime: local
|
|
6
|
+
transports:
|
|
7
|
+
matrix:
|
|
8
|
+
type: matrix
|
|
9
|
+
homeserver: http://localhost:8448
|
|
10
|
+
as_token: t
|
|
11
|
+
hs_token: h
|
|
12
|
+
user_namespace: '@.*:example.org'
|
|
13
|
+
agents:
|
|
14
|
+
architect:
|
|
15
|
+
acp: { preset: opencode }
|
|
16
|
+
matrix:
|
|
17
|
+
rooms: ["#ops:example.org"]
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
const baseTwoAgents = `
|
|
21
|
+
runtime: local
|
|
22
|
+
transports:
|
|
23
|
+
matrix:
|
|
24
|
+
type: matrix
|
|
25
|
+
homeserver: http://localhost:8448
|
|
26
|
+
as_token: t
|
|
27
|
+
hs_token: h
|
|
28
|
+
user_namespace: '@.*:example.org'
|
|
29
|
+
agents:
|
|
30
|
+
architect:
|
|
31
|
+
acp: { preset: opencode }
|
|
32
|
+
matrix:
|
|
33
|
+
rooms: ["#ops:example.org"]
|
|
34
|
+
product-manager:
|
|
35
|
+
acp: { preset: opencode }
|
|
36
|
+
matrix:
|
|
37
|
+
rooms: ["#ops:example.org"]
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
const baseWithHttpAgent = `
|
|
41
|
+
runtime: local
|
|
42
|
+
transports:
|
|
43
|
+
matrix:
|
|
44
|
+
type: matrix
|
|
45
|
+
homeserver: http://localhost:8448
|
|
46
|
+
as_token: t
|
|
47
|
+
hs_token: h
|
|
48
|
+
user_namespace: '@.*:example.org'
|
|
49
|
+
http-local:
|
|
50
|
+
type: http
|
|
51
|
+
port: 8080
|
|
52
|
+
agents:
|
|
53
|
+
architect:
|
|
54
|
+
acp: { preset: opencode }
|
|
55
|
+
matrix:
|
|
56
|
+
rooms: ["#ops:example.org"]
|
|
57
|
+
worker:
|
|
58
|
+
acp: { preset: opencode }
|
|
59
|
+
http: {}
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
const withTriggers = (block: string) => `${base}\ntriggers:\n${block}`
|
|
63
|
+
|
|
64
|
+
const ok = `
|
|
65
|
+
image-currency:
|
|
66
|
+
schedule: "0 6 * * 1"
|
|
67
|
+
as: "@cron:example.org"
|
|
68
|
+
room: "#ops:example.org"
|
|
69
|
+
mention: architect
|
|
70
|
+
text: "Check the pinned agent CLI versions."
|
|
71
|
+
`
|
|
72
|
+
|
|
73
|
+
describe('triggers: config', () => {
|
|
74
|
+
it('is optional — a config with no triggers loads and yields an empty map', () => {
|
|
75
|
+
expect(loadZooidConfig(base).triggers).toEqual({})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('parses a schedule trigger', () => {
|
|
79
|
+
const t = loadZooidConfig(withTriggers(ok)).triggers['image-currency']
|
|
80
|
+
expect(t).toEqual({
|
|
81
|
+
schedule: '0 6 * * 1',
|
|
82
|
+
as: '@cron:example.org',
|
|
83
|
+
messages: [
|
|
84
|
+
{ room: '#ops:example.org', mention: 'architect', text: 'Check the pinned agent CLI versions.' },
|
|
85
|
+
],
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('accepts a room id as well as an alias', () => {
|
|
90
|
+
const cfg = loadZooidConfig(withTriggers(ok.replace('"#ops:example.org"', '"!abc:example.org"')))
|
|
91
|
+
expect(cfg.triggers['image-currency'].messages[0].room).toBe('!abc:example.org')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('rejects run: — the emitter tier was dropped, and a stale config must fail loudly', () => {
|
|
95
|
+
expect(() =>
|
|
96
|
+
loadZooidConfig(withTriggers(`${ok} run: "echo hi"\n`)),
|
|
97
|
+
).toThrow(/triggers\.image-currency\.run: is not supported/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('rejects a mention that names no configured agent', () => {
|
|
101
|
+
expect(() =>
|
|
102
|
+
loadZooidConfig(withTriggers(ok.replace('mention: architect', 'mention: nobody'))),
|
|
103
|
+
).toThrow(/triggers\.image-currency\.messages\[0\]\.mention: unknown agent "nobody"/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('rejects an invalid cron expression at load time, not at first fire', () => {
|
|
107
|
+
expect(() =>
|
|
108
|
+
loadZooidConfig(withTriggers(ok.replace('"0 6 * * 1"', '"not a cron"'))),
|
|
109
|
+
).toThrow(/triggers\.image-currency\.schedule/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('expands a bare localpart in as: to a full MXID via the sole matrix transport', () => {
|
|
113
|
+
const cfg = loadZooidConfig(withTriggers(ok.replace('"@cron:example.org"', '"cron"')))
|
|
114
|
+
expect(cfg.triggers['image-currency'].as).toBe('@cron:example.org')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('expands a short-form @localpart in as: the same way', () => {
|
|
118
|
+
const cfg = loadZooidConfig(withTriggers(ok.replace('"@cron:example.org"', '"@cron"')))
|
|
119
|
+
expect(cfg.triggers['image-currency'].as).toBe('@cron:example.org')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('rejects an as: with invalid localpart characters', () => {
|
|
123
|
+
expect(() =>
|
|
124
|
+
loadZooidConfig(withTriggers(ok.replace('"@cron:example.org"', '"Not Valid!"'))),
|
|
125
|
+
).toThrow(/triggers\.image-currency\.as: must be a full MXID/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("rejects an as: that resolves to the mentioned agent's own MXID — a self-post never routes back", () => {
|
|
129
|
+
const block = ok.replace('"@cron:example.org"', '"architect"')
|
|
130
|
+
expect(() => loadZooidConfig(withTriggers(block))).toThrow(
|
|
131
|
+
/triggers\.image-currency\.as: must not equal the mentioned agent/,
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("allows as: to impersonate a different agent's own identity — cron-as-a-role", () => {
|
|
136
|
+
const block = ok.replace('"@cron:example.org"', '"product-manager"')
|
|
137
|
+
const cfg = loadZooidConfig(`${baseTwoAgents}\ntriggers:\n${block}`)
|
|
138
|
+
expect(cfg.triggers['image-currency'].as).toBe('@product-manager:example.org')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('rejects mention: of an agent with no matrix: binding', () => {
|
|
142
|
+
const block = ok.replace('mention: architect', 'mention: worker')
|
|
143
|
+
expect(() => loadZooidConfig(`${baseWithHttpAgent}\ntriggers:\n${block}`)).toThrow(
|
|
144
|
+
/triggers\.image-currency\.messages\[0\]\.mention: agent "worker" has no matrix: binding/,
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('requires as, room, mention and text', () => {
|
|
149
|
+
const indexed = new Set(['room', 'mention', 'text'])
|
|
150
|
+
for (const field of ['as', 'room', 'mention', 'text']) {
|
|
151
|
+
const block = ok
|
|
152
|
+
.split('\n')
|
|
153
|
+
.filter((l) => !l.trim().startsWith(`${field}:`))
|
|
154
|
+
.join('\n')
|
|
155
|
+
const label = indexed.has(field)
|
|
156
|
+
? `triggers\\.image-currency\\.messages\\[0\\]\\.${field}`
|
|
157
|
+
: `triggers\\.image-currency\\.${field}`
|
|
158
|
+
expect(() => loadZooidConfig(withTriggers(block))).toThrow(new RegExp(label))
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('rejects a trigger with no schedule', () => {
|
|
163
|
+
const block = ok.split('\n').filter((l) => !l.trim().startsWith('schedule:')).join('\n')
|
|
164
|
+
expect(() => loadZooidConfig(withTriggers(block))).toThrow(
|
|
165
|
+
/triggers\.image-currency: must specify schedule:/,
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { loadZooidConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
const head = `
|
|
5
|
+
runtime: local
|
|
6
|
+
transports:
|
|
7
|
+
matrix:
|
|
8
|
+
type: matrix
|
|
9
|
+
homeserver: http://localhost:8448
|
|
10
|
+
as_token: t
|
|
11
|
+
hs_token: h
|
|
12
|
+
user_namespace: '@.*:example.org'
|
|
13
|
+
agents:
|
|
14
|
+
product:
|
|
15
|
+
acp: { preset: opencode }
|
|
16
|
+
matrix:
|
|
17
|
+
rooms: ["#product:example.org"]
|
|
18
|
+
architect:
|
|
19
|
+
acp: { preset: opencode }
|
|
20
|
+
matrix:
|
|
21
|
+
rooms: ["#dev:example.org"]
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const flat = `${head}
|
|
25
|
+
triggers:
|
|
26
|
+
triage:
|
|
27
|
+
webhook:
|
|
28
|
+
provider: github
|
|
29
|
+
secret: "s"
|
|
30
|
+
as: "@hook:example.org"
|
|
31
|
+
room: "#product:example.org"
|
|
32
|
+
mention: product
|
|
33
|
+
match: 'event == "issues" && body.action == "opened"'
|
|
34
|
+
text: 'Triage \${body.repository.full_name}#\${body.issue.number}.'
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
const plural = `${head}
|
|
38
|
+
triggers:
|
|
39
|
+
github:
|
|
40
|
+
webhook:
|
|
41
|
+
provider: github
|
|
42
|
+
secret: "s"
|
|
43
|
+
as: "@hook:example.org"
|
|
44
|
+
messages:
|
|
45
|
+
- room: "#product:example.org"
|
|
46
|
+
mention: product
|
|
47
|
+
match: 'event == "issues" && body.action == "opened"'
|
|
48
|
+
text: 'Triage it.'
|
|
49
|
+
- room: "#dev:example.org"
|
|
50
|
+
mention: architect
|
|
51
|
+
match: 'event == "pull_request" && body.action == "closed"'
|
|
52
|
+
text: 'Reconcile it.'
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
describe('flat form', () => {
|
|
56
|
+
it('desugars into a single-entry messages list', () => {
|
|
57
|
+
const t = loadZooidConfig(flat).triggers.triage
|
|
58
|
+
expect(t.messages).toHaveLength(1)
|
|
59
|
+
expect(t.messages[0].room).toBe('#product:example.org')
|
|
60
|
+
expect(t.messages[0].mention).toBe('product')
|
|
61
|
+
expect(t.messages[0].match).toBe('event == "issues" && body.action == "opened"')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Nothing downstream may branch on which spelling was used.
|
|
65
|
+
it('leaves no flat room/mention/text on the parsed trigger', () => {
|
|
66
|
+
const t = loadZooidConfig(flat).triggers.triage as Record<string, unknown>
|
|
67
|
+
expect(t.room).toBeUndefined()
|
|
68
|
+
expect(t.mention).toBeUndefined()
|
|
69
|
+
expect(t.text).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('messages form', () => {
|
|
74
|
+
it('parses several messages', () => {
|
|
75
|
+
const t = loadZooidConfig(plural).triggers.github
|
|
76
|
+
expect(t.messages.map((m) => m.mention)).toEqual(['product', 'architect'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('exposes every declared room, so the bot pool can join them at start', () => {
|
|
80
|
+
const t = loadZooidConfig(plural).triggers.github
|
|
81
|
+
expect(t.messages.map((m) => m.room)).toEqual(['#product:example.org', '#dev:example.org'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('validates each message the same way the flat form is validated', () => {
|
|
85
|
+
expect(() => loadZooidConfig(plural.replace('mention: architect', 'mention: nobody'))).toThrow(
|
|
86
|
+
/triggers\.github\.messages\[1\]\.mention: unknown agent "nobody"/,
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects an empty messages list', () => {
|
|
91
|
+
expect(() => loadZooidConfig(`${head}
|
|
92
|
+
triggers:
|
|
93
|
+
github:
|
|
94
|
+
webhook: { provider: github, secret: "s" }
|
|
95
|
+
as: "@hook:example.org"
|
|
96
|
+
messages: []
|
|
97
|
+
`)).toThrow(/triggers\.github\.messages: must not be empty/)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('mutual exclusion and validation', () => {
|
|
102
|
+
it('rejects flat keys together with messages, rather than picking one silently', () => {
|
|
103
|
+
expect(() => loadZooidConfig(plural.replace(' messages:', ' room: "#product:example.org"\n messages:'))).toThrow(
|
|
104
|
+
/triggers\.github: specify either room:\/mention:\/text: or messages:, not both/,
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('rejects match: on a schedule trigger — there is no payload to evaluate', () => {
|
|
109
|
+
expect(() => loadZooidConfig(`${head}
|
|
110
|
+
triggers:
|
|
111
|
+
weekly:
|
|
112
|
+
schedule: "0 6 * * 1"
|
|
113
|
+
as: "@cron:example.org"
|
|
114
|
+
room: "#dev:example.org"
|
|
115
|
+
mention: architect
|
|
116
|
+
match: 'true'
|
|
117
|
+
text: 'go'
|
|
118
|
+
`)).toThrow(/triggers\.weekly\.match: only applies to a webhook: trigger/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('allows messages: on a schedule trigger — one cron, several agents', () => {
|
|
122
|
+
const cfg = loadZooidConfig(`${head}
|
|
123
|
+
triggers:
|
|
124
|
+
weekly:
|
|
125
|
+
schedule: "0 6 * * 1"
|
|
126
|
+
as: "@cron:example.org"
|
|
127
|
+
messages:
|
|
128
|
+
- { room: "#product:example.org", mention: product, text: 'a' }
|
|
129
|
+
- { room: "#dev:example.org", mention: architect, text: 'b' }
|
|
130
|
+
`)
|
|
131
|
+
expect(cfg.triggers.weekly.messages).toHaveLength(2)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// A typo must fail the daemon at boot, not silently never match.
|
|
135
|
+
it('rejects a malformed match expression at config load', () => {
|
|
136
|
+
expect(() => loadZooidConfig(flat.replace("match: 'event == \"issues\" && body.action == \"opened\"'", "match: 'body.action =='"))).toThrow(
|
|
137
|
+
/triggers\.triage\.messages\[0\]\.match: /,
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('no longer accepts event:', () => {
|
|
142
|
+
expect(() => loadZooidConfig(flat.replace(' secret: "s"', ' event: issues\n secret: "s"'))).toThrow(
|
|
143
|
+
/triggers\.triage\.webhook\.event: no longer supported — use match:/,
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { loadZooidConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
const base = `
|
|
5
|
+
runtime: local
|
|
6
|
+
transports:
|
|
7
|
+
matrix:
|
|
8
|
+
type: matrix
|
|
9
|
+
homeserver: http://localhost:8448
|
|
10
|
+
as_token: t
|
|
11
|
+
hs_token: h
|
|
12
|
+
user_namespace: '@.*:example.org'
|
|
13
|
+
agents:
|
|
14
|
+
product:
|
|
15
|
+
acp: { preset: opencode }
|
|
16
|
+
matrix:
|
|
17
|
+
rooms: ["#product:example.org"]
|
|
18
|
+
triggers:
|
|
19
|
+
reconcile:
|
|
20
|
+
webhook:
|
|
21
|
+
provider: github
|
|
22
|
+
secret: "topsecret"
|
|
23
|
+
as: "@hook:example.org"
|
|
24
|
+
room: "#product:example.org"
|
|
25
|
+
mention: product
|
|
26
|
+
text: |
|
|
27
|
+
A PR merged.
|
|
28
|
+
|
|
29
|
+
\${output}
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
describe('webhook triggers: config', () => {
|
|
33
|
+
it('parses a webhook trigger', () => {
|
|
34
|
+
const t = loadZooidConfig(base).triggers.reconcile
|
|
35
|
+
expect(t.webhook).toEqual({ provider: 'github', secret: 'topsecret' })
|
|
36
|
+
expect(t.schedule).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('no longer accepts event: — use match: instead', () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
loadZooidConfig(base.replace('provider: github', 'provider: github\n event: pull_request')),
|
|
42
|
+
).toThrow(/triggers\.reconcile\.webhook\.event: no longer supported — use match:/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('rejects a trigger with both schedule and webhook', () => {
|
|
46
|
+
expect(() =>
|
|
47
|
+
loadZooidConfig(base.replace(' as: "@hook', ' schedule: "0 6 * * 1"\n as: "@hook')),
|
|
48
|
+
).toThrow(/triggers\.reconcile: specify either schedule: or webhook:, not both/)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('rejects an unknown provider — per-provider verification must exist', () => {
|
|
52
|
+
expect(() => loadZooidConfig(base.replace('provider: github', 'provider: acme'))).toThrow(
|
|
53
|
+
/triggers\.reconcile\.webhook\.provider: unknown provider "acme"/,
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('requires a secret — fail closed, never "no secret configured so accept"', () => {
|
|
58
|
+
expect(() =>
|
|
59
|
+
loadZooidConfig(base.split('\n').filter((l) => !l.includes('secret:')).join('\n')),
|
|
60
|
+
).toThrow(/triggers\.reconcile\.webhook\.secret: is required/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('interpolates the secret from the environment', () => {
|
|
64
|
+
process.env.TEST_WH_SECRET = 'from-env'
|
|
65
|
+
try {
|
|
66
|
+
const cfg = loadZooidConfig(base.replace('"topsecret"', '${TEST_WH_SECRET}'))
|
|
67
|
+
expect(cfg.triggers.reconcile.webhook?.secret).toBe('from-env')
|
|
68
|
+
} finally {
|
|
69
|
+
delete process.env.TEST_WH_SECRET
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `custom` is the escape hatch for any signing scheme without a named
|
|
76
|
+
* provider — ed25519, SHA-1 over sorted params, bespoke timestamped base
|
|
77
|
+
* strings. It takes a function rather than declarative fields, because a
|
|
78
|
+
* config language for signing schemes is a mini-DSL that still would not
|
|
79
|
+
* cover them all.
|
|
80
|
+
*/
|
|
81
|
+
describe('webhook triggers: the custom provider', () => {
|
|
82
|
+
const custom = (block: string) =>
|
|
83
|
+
base.replace(
|
|
84
|
+
` provider: github
|
|
85
|
+
secret: "topsecret"`,
|
|
86
|
+
block,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
it('resolves the verifier path against the zooid.yaml directory', () => {
|
|
90
|
+
const t = loadZooidConfig(
|
|
91
|
+
custom(` provider: custom
|
|
92
|
+
verify: ./verifiers/shopify.js
|
|
93
|
+
secret: "topsecret"`),
|
|
94
|
+
{ configDir: '/srv/workforce' },
|
|
95
|
+
).triggers.reconcile
|
|
96
|
+
expect(t.webhook).toEqual({
|
|
97
|
+
provider: 'custom',
|
|
98
|
+
verify: '/srv/workforce/verifiers/shopify.js',
|
|
99
|
+
secret: 'topsecret',
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('leaves an absolute verifier path alone', () => {
|
|
104
|
+
const t = loadZooidConfig(
|
|
105
|
+
custom(` provider: custom
|
|
106
|
+
verify: /opt/zooid/verify.js
|
|
107
|
+
secret: "topsecret"`),
|
|
108
|
+
{ configDir: '/srv/workforce' },
|
|
109
|
+
).triggers.reconcile
|
|
110
|
+
expect(t.webhook?.verify).toBe('/opt/zooid/verify.js')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('requires verify: — there is no built-in scheme to fall back on', () => {
|
|
114
|
+
expect(() =>
|
|
115
|
+
loadZooidConfig(
|
|
116
|
+
custom(` provider: custom
|
|
117
|
+
secret: "topsecret"`),
|
|
118
|
+
{ configDir: '/srv/workforce' },
|
|
119
|
+
),
|
|
120
|
+
).toThrow(/triggers\.reconcile\.webhook\.verify: is required when provider: custom/)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('rejects a relative verifier path with no configDir to resolve against', () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
loadZooidConfig(
|
|
126
|
+
custom(` provider: custom
|
|
127
|
+
verify: ./verify.js
|
|
128
|
+
secret: "topsecret"`),
|
|
129
|
+
),
|
|
130
|
+
).toThrow(/triggers\.reconcile\.webhook\.verify: relative path/)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('rejects verify: on a named provider, rather than ignoring it', () => {
|
|
134
|
+
expect(() =>
|
|
135
|
+
loadZooidConfig(
|
|
136
|
+
custom(` provider: github
|
|
137
|
+
verify: ./verify.js
|
|
138
|
+
secret: "topsecret"`),
|
|
139
|
+
{ configDir: '/srv/workforce' },
|
|
140
|
+
),
|
|
141
|
+
).toThrow(/triggers\.reconcile\.webhook\.verify: only applies to provider: custom/)
|
|
142
|
+
})
|
|
143
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -277,6 +277,8 @@ export interface ZooidConfig {
|
|
|
277
277
|
pre_turn?: string
|
|
278
278
|
post_turn?: string
|
|
279
279
|
}
|
|
280
|
+
/** Optional. Map of trigger name → trigger. Empty map when the block is absent. */
|
|
281
|
+
triggers: Record<string, TriggerConfig>
|
|
280
282
|
}
|
|
281
283
|
|
|
282
284
|
export interface CliFlags {
|
|
@@ -284,3 +286,66 @@ export interface CliFlags {
|
|
|
284
286
|
/** Container image override (shorthand for container.image). */
|
|
285
287
|
image?: string
|
|
286
288
|
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A webhook trigger: exposes `POST /_zooid/webhooks/<name>`, verifies an HMAC
|
|
292
|
+
* signature over the raw request body, and fires like a schedule trigger
|
|
293
|
+
* once accepted. See [[ZOD082]] §Design 4 and [[ZOD086]].
|
|
294
|
+
*/
|
|
295
|
+
export interface WebhookTriggerConfig {
|
|
296
|
+
provider: 'github' | 'stripe' | 'slack' | 'standard' | 'custom'
|
|
297
|
+
/** Required. Interpolated from env — see [[ZOD081]] §Design 4, Secrets. */
|
|
298
|
+
secret: string
|
|
299
|
+
/**
|
|
300
|
+
* `custom` only, required: path to a module exporting the verifier
|
|
301
|
+
* function, resolved to an absolute path against the zooid.yaml directory
|
|
302
|
+
* at parse time. The escape hatch for any scheme without a named provider
|
|
303
|
+
* — ed25519, SHA-1 over sorted params, bespoke timestamped base strings.
|
|
304
|
+
* Code rather than declarative fields, because a config language for
|
|
305
|
+
* signing schemes is a security-critical mini-DSL that still would not
|
|
306
|
+
* cover them all. The daemon imports it at startup — see
|
|
307
|
+
* `loadCustomVerifiers` in the CLI.
|
|
308
|
+
*/
|
|
309
|
+
verify?: string
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* One message a trigger can post: a room, the agent to mention, the body
|
|
314
|
+
* template, and — webhook triggers only — a CEL `match:` predicate gating
|
|
315
|
+
* whether this message fires for a given delivery. See [[ZOD085]] §Design 4.
|
|
316
|
+
*/
|
|
317
|
+
export interface TriggerMessage {
|
|
318
|
+
/** Room id or alias the message goes to. */
|
|
319
|
+
room: string
|
|
320
|
+
/** Agent name (key in `agents`) to mention. Structural, never templated — §Design 3. */
|
|
321
|
+
mention: string
|
|
322
|
+
/** The message body. Literal for a scheduled trigger; `${...}` is CEL-interpolated for a webhook trigger. */
|
|
323
|
+
text: string
|
|
324
|
+
/**
|
|
325
|
+
* CEL predicate over the delivery (`event`, `body`, `headers`, `output`).
|
|
326
|
+
* Absent means always fire. Only valid on a `webhook:` trigger — a
|
|
327
|
+
* schedule trigger has no delivery to evaluate.
|
|
328
|
+
*/
|
|
329
|
+
match?: string
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A trigger: fires on a cron or a webhook delivery and posts one or more
|
|
334
|
+
* `messages`, each structurally mentioning its agent so the turn starts
|
|
335
|
+
* through the ordinary message path. No emitter/command tier — see
|
|
336
|
+
* [[ZOD081]] §Concept.
|
|
337
|
+
*/
|
|
338
|
+
export interface TriggerConfig {
|
|
339
|
+
/** Cron expression. Mutually exclusive with `webhook:` — exactly one is required. */
|
|
340
|
+
schedule?: string
|
|
341
|
+
/** Webhook ingress config. Mutually exclusive with `schedule:` — [[ZOD082]]. */
|
|
342
|
+
webhook?: WebhookTriggerConfig
|
|
343
|
+
/** Full MXID the trigger posts as, e.g. `@cron:example.org`. */
|
|
344
|
+
as: string
|
|
345
|
+
/**
|
|
346
|
+
* The messages this trigger can post. A flat `room:`/`mention:`/`text:`/
|
|
347
|
+
* `match:` at the trigger level desugars into a single-entry list here at
|
|
348
|
+
* config load — nothing downstream branches on which spelling was used.
|
|
349
|
+
*/
|
|
350
|
+
messages: TriggerMessage[]
|
|
351
|
+
}
|