@phnx-labs/agents-cli 1.20.62 → 1.20.63

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +41 -0
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +58 -18
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/daemon.js +2 -0
  32. package/dist/lib/devices/fleet.d.ts +62 -0
  33. package/dist/lib/devices/fleet.js +128 -0
  34. package/dist/lib/funnel.d.ts +5 -0
  35. package/dist/lib/funnel.js +23 -0
  36. package/dist/lib/git.d.ts +21 -5
  37. package/dist/lib/git.js +64 -14
  38. package/dist/lib/hosts/credentials.d.ts +28 -0
  39. package/dist/lib/hosts/credentials.js +48 -0
  40. package/dist/lib/hosts/dispatch.d.ts +25 -0
  41. package/dist/lib/hosts/dispatch.js +68 -2
  42. package/dist/lib/hosts/passthrough.d.ts +13 -10
  43. package/dist/lib/hosts/passthrough.js +119 -29
  44. package/dist/lib/mailbox-gc.js +4 -16
  45. package/dist/lib/migrate.d.ts +12 -0
  46. package/dist/lib/migrate.js +55 -1
  47. package/dist/lib/routines.d.ts +29 -10
  48. package/dist/lib/routines.js +47 -15
  49. package/dist/lib/session/sync/agents.d.ts +2 -0
  50. package/dist/lib/session/sync/agents.js +39 -1
  51. package/dist/lib/session/sync/config.d.ts +8 -0
  52. package/dist/lib/session/sync/config.js +6 -1
  53. package/dist/lib/session/sync/provision.d.ts +49 -0
  54. package/dist/lib/session/sync/provision.js +91 -0
  55. package/dist/lib/session/sync/sync.d.ts +3 -0
  56. package/dist/lib/session/sync/sync.js +26 -6
  57. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  58. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  59. package/dist/lib/startup/command-registry.d.ts +2 -0
  60. package/dist/lib/startup/command-registry.js +11 -0
  61. package/dist/lib/state.d.ts +10 -2
  62. package/dist/lib/state.js +14 -2
  63. package/dist/lib/triggers/webhook.d.ts +70 -27
  64. package/dist/lib/triggers/webhook.js +264 -43
  65. package/package.json +1 -1
@@ -1,29 +1,34 @@
1
1
  /**
2
- * GitHub webhook trigger receiver for routines.
2
+ * Public webhook trigger receiver for routines.
3
3
  *
4
4
  * A routine may declare a `trigger` block instead of (or alongside) a cron
5
5
  * `schedule` (see `JobConfig.trigger` in `../routines.ts`). This module turns
6
- * an incoming GitHub webhook into the set of routines it should fire, and
7
- * dispatches those routines through the exact same path a cron fire uses
6
+ * incoming GitHub or Linear webhooks into the set of routines they should fire,
7
+ * and dispatches those routines through the exact same path a cron fire uses
8
8
  * (`executeJobDetached`).
9
9
  *
10
- * The matching logic (`matchJobsToWebhook`) is a pure, side-effect-free
11
- * function so it can be unit-tested without a running daemon or http server.
12
- * The optional http listener (`startWebhookServer`) is a thin adapter over it.
10
+ * The matching logic is pure, so it can be unit-tested without a daemon or HTTP
11
+ * server. The listener adds the public-ingress requirements: raw-body HMAC
12
+ * verification, idempotency, source allow-listing, and rate limiting.
13
13
  */
14
14
  import * as http from 'http';
15
+ import type { IncomingHttpHeaders } from 'http';
15
16
  import type { JobConfig, RunMeta } from '../routines.js';
16
- /**
17
- * A parsed GitHub webhook: the event name (from the `X-GitHub-Event` HTTP
18
- * header) plus the decoded JSON body. Kept deliberately loose (`payload` is an
19
- * arbitrary object) because callers may hand us any of GitHub's event shapes.
20
- */
21
- export interface GithubWebhook {
22
- /** The GitHub event name, e.g. `pull_request`, `push`, `issue_comment`. */
17
+ export type WebhookSource = 'github' | 'linear';
18
+ export interface IncomingWebhook {
19
+ /** Delivery source, derived from `/hooks/<source>` or one-shot command flags. */
20
+ source: WebhookSource;
21
+ /** Source event name: GitHub header event or Linear payload `type`. */
23
22
  event: string;
24
- /** The decoded JSON request body. */
23
+ /** Decoded JSON request body. */
25
24
  payload: Record<string, unknown>;
26
25
  }
26
+ export type GithubWebhook = IncomingWebhook & {
27
+ source: 'github';
28
+ };
29
+ export type LinearWebhook = IncomingWebhook & {
30
+ source: 'linear';
31
+ };
27
32
  /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
28
33
  export declare function webhookRepo(payload: Record<string, unknown>): string | null;
29
34
  /**
@@ -37,14 +42,13 @@ export declare function webhookRepo(payload: Record<string, unknown>): string |
37
42
  */
38
43
  export declare function webhookBranches(event: string, payload: Record<string, unknown>): string[];
39
44
  /** True when a single job's trigger matches the given webhook. Pure. */
40
- export declare function jobMatchesWebhook(job: JobConfig, webhook: GithubWebhook): boolean;
45
+ export declare function jobMatchesWebhook(job: JobConfig, webhook: IncomingWebhook): boolean;
41
46
  /**
42
47
  * Pure matcher: given a set of jobs and an incoming webhook, return the jobs
43
- * whose `trigger` matches (event + optional repo + optional branch). Jobs
44
- * without a trigger (schedule-only routines) are never selected proving
45
- * time-based jobs are unaffected by webhook delivery.
48
+ * whose `trigger` matches. Jobs without a trigger (schedule-only routines) are
49
+ * never selected proving time-based jobs are unaffected by webhook delivery.
46
50
  */
47
- export declare function matchJobsToWebhook(jobs: JobConfig[], webhook: GithubWebhook): JobConfig[];
51
+ export declare function matchJobsToWebhook(jobs: JobConfig[], webhook: IncomingWebhook): JobConfig[];
48
52
  /** Options for firing webhook-matched jobs (dispatch is injectable for tests). */
49
53
  export interface FireWebhookOptions {
50
54
  /** Job source. Defaults to all persisted routines (`listJobs()`). */
@@ -55,31 +59,70 @@ export interface FireWebhookOptions {
55
59
  * matching without spawning real agent processes.
56
60
  */
57
61
  dispatch?: (config: JobConfig) => Promise<RunMeta>;
62
+ /** Matched job names that already completed for this delivery. */
63
+ skipJobNames?: ReadonlySet<string>;
64
+ /** Called immediately after a single matched job dispatch succeeds. */
65
+ onJobFired?: (job: JobConfig, fired: FiredJob) => void;
58
66
  }
59
67
  /** Result of firing one matched job. */
60
68
  export interface FiredJob {
61
69
  jobName: string;
62
70
  runId: string;
63
71
  }
72
+ export declare class WebhookDispatchError extends Error {
73
+ readonly fired: FiredJob[];
74
+ readonly failures: {
75
+ jobName: string;
76
+ error: Error;
77
+ }[];
78
+ constructor(message: string, fired: FiredJob[], failures: {
79
+ jobName: string;
80
+ error: Error;
81
+ }[]);
82
+ }
64
83
  /**
65
84
  * Match an incoming webhook against the persisted routines and fire each match
66
85
  * through the cron dispatch path. Returns one entry per fired job.
67
86
  */
68
- export declare function fireWebhookJobs(webhook: GithubWebhook, options?: FireWebhookOptions): Promise<FiredJob[]>;
87
+ export declare function fireWebhookJobs(webhook: IncomingWebhook, options?: FireWebhookOptions): Promise<FiredJob[]>;
88
+ export declare function verifyGithubSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string): boolean;
89
+ export declare function verifyLinearSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string): boolean;
90
+ export declare function verifyLinearTimestamp(payload: Record<string, unknown>, now?: number, toleranceMs?: number): boolean;
91
+ export interface WebhookSecrets {
92
+ github?: string;
93
+ linear?: string;
94
+ }
95
+ export interface DeliveryStore {
96
+ seen(id: string): boolean;
97
+ mark(id: string): void;
98
+ completedJobs(id: string): ReadonlySet<string>;
99
+ markJob(id: string, jobName: string): void;
100
+ }
101
+ export declare function createMemoryDeliveryStore(maxEntries?: number): DeliveryStore;
102
+ export interface RateLimiter {
103
+ take(key: string): boolean;
104
+ }
105
+ export declare function createMemoryRateLimiter(limit: number, windowMs: number): RateLimiter;
69
106
  /** Options for the local webhook http listener. */
70
107
  export interface WebhookServerOptions {
71
108
  port?: number;
72
109
  host?: string;
110
+ /** HMAC signing secrets keyed by source. */
111
+ secrets: WebhookSecrets;
73
112
  /** Override the fire options (mainly for tests). */
74
113
  fire?: FireWebhookOptions;
75
114
  /** Called after each delivery is handled (mainly for tests/observability). */
76
- onDelivery?: (event: string, fired: FiredJob[]) => void;
115
+ onDelivery?: (webhook: IncomingWebhook, fired: FiredJob[]) => void;
116
+ deliveryStore?: DeliveryStore;
117
+ rateLimiter?: RateLimiter;
118
+ rateLimitPerMinute?: number;
119
+ maxBodyBytes?: number;
77
120
  }
78
121
  /**
79
- * Start a minimal local webhook receiver. POSTs are read as JSON, the GitHub
80
- * event is taken from the `X-GitHub-Event` header, and matching routines are
81
- * fired via {@link fireWebhookJobs}. Returns the underlying server so callers
82
- * can `close()` it. The heavy lifting is the pure matcher above; this is only
83
- * the transport.
122
+ * Start a localhost-bound receiver. It accepts only:
123
+ * POST /hooks/github with X-Hub-Signature-256
124
+ * POST /hooks/linear with Linear-Signature + fresh webhookTimestamp
125
+ *
126
+ * Returns the underlying server so callers can `close()` it.
84
127
  */
85
- export declare function startWebhookServer(options?: WebhookServerOptions): http.Server;
128
+ export declare function startWebhookServer(options: WebhookServerOptions): http.Server;
@@ -1,18 +1,19 @@
1
1
  /**
2
- * GitHub webhook trigger receiver for routines.
2
+ * Public webhook trigger receiver for routines.
3
3
  *
4
4
  * A routine may declare a `trigger` block instead of (or alongside) a cron
5
5
  * `schedule` (see `JobConfig.trigger` in `../routines.ts`). This module turns
6
- * an incoming GitHub webhook into the set of routines it should fire, and
7
- * dispatches those routines through the exact same path a cron fire uses
6
+ * incoming GitHub or Linear webhooks into the set of routines they should fire,
7
+ * and dispatches those routines through the exact same path a cron fire uses
8
8
  * (`executeJobDetached`).
9
9
  *
10
- * The matching logic (`matchJobsToWebhook`) is a pure, side-effect-free
11
- * function so it can be unit-tested without a running daemon or http server.
12
- * The optional http listener (`startWebhookServer`) is a thin adapter over it.
10
+ * The matching logic is pure, so it can be unit-tested without a daemon or HTTP
11
+ * server. The listener adds the public-ingress requirements: raw-body HMAC
12
+ * verification, idempotency, source allow-listing, and rate limiting.
13
13
  */
14
+ import * as crypto from 'crypto';
14
15
  import * as http from 'http';
15
- import { listJobs, jobRunsOnThisDevice } from '../routines.js';
16
+ import { jobRunsOnThisDevice, listJobs } from '../routines.js';
16
17
  import { executeJobDetached } from '../runner.js';
17
18
  /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
18
19
  export function webhookRepo(payload) {
@@ -59,10 +60,30 @@ export function webhookBranches(event, payload) {
59
60
  }
60
61
  return [...branches];
61
62
  }
62
- /** True when a single job's trigger matches the given webhook. Pure. */
63
- export function jobMatchesWebhook(job, webhook) {
64
- const trigger = job.trigger;
65
- if (!trigger || trigger.type !== 'github_event')
63
+ function linearAction(payload) {
64
+ return typeof payload.action === 'string' ? payload.action : null;
65
+ }
66
+ function linearTeamKey(payload) {
67
+ const data = payload.data;
68
+ const identifier = data?.identifier;
69
+ if (typeof identifier === 'string') {
70
+ const match = /^([A-Z][A-Z0-9]*)-\d+$/.exec(identifier);
71
+ if (match)
72
+ return match[1];
73
+ }
74
+ const team = data?.team;
75
+ return typeof team?.key === 'string' ? team.key : null;
76
+ }
77
+ function linearLabels(payload) {
78
+ const data = payload.data;
79
+ const labels = data?.labels;
80
+ const nodes = Array.isArray(labels?.nodes) ? labels.nodes : [];
81
+ return nodes
82
+ .map((n) => n.name)
83
+ .filter((n) => typeof n === 'string' && n.length > 0);
84
+ }
85
+ function githubTriggerMatches(trigger, webhook) {
86
+ if (webhook.source !== 'github')
66
87
  return false;
67
88
  if (trigger.event !== webhook.event)
68
89
  return false;
@@ -78,15 +99,51 @@ export function jobMatchesWebhook(job, webhook) {
78
99
  }
79
100
  return true;
80
101
  }
102
+ function linearTriggerMatches(trigger, webhook) {
103
+ if (webhook.source !== 'linear')
104
+ return false;
105
+ if (trigger.event !== webhook.event)
106
+ return false;
107
+ if (trigger.action && linearAction(webhook.payload) !== trigger.action)
108
+ return false;
109
+ if (trigger.teamKey && linearTeamKey(webhook.payload) !== trigger.teamKey)
110
+ return false;
111
+ if (trigger.label) {
112
+ const expected = trigger.label.toLowerCase();
113
+ if (!linearLabels(webhook.payload).some((name) => name.toLowerCase() === expected))
114
+ return false;
115
+ }
116
+ return true;
117
+ }
118
+ /** True when a single job's trigger matches the given webhook. Pure. */
119
+ export function jobMatchesWebhook(job, webhook) {
120
+ const trigger = job.trigger;
121
+ if (!trigger)
122
+ return false;
123
+ if (trigger.type === 'github_event')
124
+ return githubTriggerMatches(trigger, webhook);
125
+ if (trigger.type === 'linear_event')
126
+ return linearTriggerMatches(trigger, webhook);
127
+ return false;
128
+ }
81
129
  /**
82
130
  * Pure matcher: given a set of jobs and an incoming webhook, return the jobs
83
- * whose `trigger` matches (event + optional repo + optional branch). Jobs
84
- * without a trigger (schedule-only routines) are never selected proving
85
- * time-based jobs are unaffected by webhook delivery.
131
+ * whose `trigger` matches. Jobs without a trigger (schedule-only routines) are
132
+ * never selected proving time-based jobs are unaffected by webhook delivery.
86
133
  */
87
134
  export function matchJobsToWebhook(jobs, webhook) {
88
135
  return jobs.filter((job) => job.enabled !== false && jobRunsOnThisDevice(job) && jobMatchesWebhook(job, webhook));
89
136
  }
137
+ export class WebhookDispatchError extends Error {
138
+ fired;
139
+ failures;
140
+ constructor(message, fired, failures) {
141
+ super(message);
142
+ this.fired = fired;
143
+ this.failures = failures;
144
+ this.name = 'WebhookDispatchError';
145
+ }
146
+ }
90
147
  /**
91
148
  * Match an incoming webhook against the persisted routines and fire each match
92
149
  * through the cron dispatch path. Returns one entry per fired job.
@@ -94,47 +151,211 @@ export function matchJobsToWebhook(jobs, webhook) {
94
151
  export async function fireWebhookJobs(webhook, options = {}) {
95
152
  const jobs = options.jobs ?? listJobs();
96
153
  const dispatch = options.dispatch ?? executeJobDetached;
154
+ const skipJobNames = options.skipJobNames ?? new Set();
97
155
  const matched = matchJobsToWebhook(jobs, webhook);
98
156
  const fired = [];
157
+ const failures = [];
99
158
  for (const job of matched) {
100
- const meta = await dispatch(job);
101
- fired.push({ jobName: job.name, runId: meta.runId });
159
+ if (skipJobNames.has(job.name))
160
+ continue;
161
+ try {
162
+ const meta = await dispatch(job);
163
+ const firedJob = { jobName: job.name, runId: meta.runId };
164
+ fired.push(firedJob);
165
+ options.onJobFired?.(job, firedJob);
166
+ }
167
+ catch (err) {
168
+ failures.push({ jobName: job.name, error: err });
169
+ }
170
+ }
171
+ if (failures.length > 0) {
172
+ throw new WebhookDispatchError(`failed to dispatch ${failures.length} webhook routine(s): ${failures.map((f) => f.jobName).join(', ')}`, fired, failures);
102
173
  }
103
174
  return fired;
104
175
  }
176
+ function header(headers, name) {
177
+ const value = headers[name.toLowerCase()];
178
+ if (Array.isArray(value))
179
+ return value[0];
180
+ return value;
181
+ }
182
+ function timingSafeHexEqual(received, expected) {
183
+ if (!received || !/^[a-f0-9]+$/i.test(received))
184
+ return false;
185
+ const a = Buffer.from(received, 'hex');
186
+ const b = Buffer.from(expected, 'hex');
187
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
188
+ }
189
+ function hmacHex(secret, rawBody) {
190
+ return crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
191
+ }
192
+ export function verifyGithubSignature(headers, rawBody, secret) {
193
+ const received = header(headers, 'x-hub-signature-256');
194
+ const signature = received?.startsWith('sha256=') ? received.slice('sha256='.length) : undefined;
195
+ return timingSafeHexEqual(signature, hmacHex(secret, rawBody));
196
+ }
197
+ export function verifyLinearSignature(headers, rawBody, secret) {
198
+ return timingSafeHexEqual(header(headers, 'linear-signature'), hmacHex(secret, rawBody));
199
+ }
200
+ export function verifyLinearTimestamp(payload, now = Date.now(), toleranceMs = 60_000) {
201
+ const ts = payload.webhookTimestamp;
202
+ return typeof ts === 'number' && Math.abs(now - ts) <= toleranceMs;
203
+ }
204
+ export function createMemoryDeliveryStore(maxEntries = 1000) {
205
+ const seen = new Map();
206
+ const touch = (id) => {
207
+ let current = seen.get(id);
208
+ if (!current) {
209
+ current = { complete: false, jobs: new Set(), updatedAt: Date.now() };
210
+ seen.set(id, current);
211
+ }
212
+ current.updatedAt = Date.now();
213
+ while (seen.size > maxEntries) {
214
+ let oldestId = null;
215
+ let oldestAt = Number.POSITIVE_INFINITY;
216
+ for (const [key, value] of seen) {
217
+ if (value.updatedAt < oldestAt) {
218
+ oldestAt = value.updatedAt;
219
+ oldestId = key;
220
+ }
221
+ }
222
+ if (!oldestId)
223
+ break;
224
+ seen.delete(oldestId);
225
+ }
226
+ return current;
227
+ };
228
+ return {
229
+ seen: (id) => seen.get(id)?.complete === true,
230
+ mark: (id) => {
231
+ touch(id).complete = true;
232
+ },
233
+ completedJobs: (id) => new Set(seen.get(id)?.jobs ?? []),
234
+ markJob: (id, jobName) => {
235
+ touch(id).jobs.add(jobName);
236
+ },
237
+ };
238
+ }
239
+ export function createMemoryRateLimiter(limit, windowMs) {
240
+ const buckets = new Map();
241
+ return {
242
+ take: (key) => {
243
+ const now = Date.now();
244
+ const current = buckets.get(key);
245
+ if (!current || now >= current.resetAt) {
246
+ buckets.set(key, { resetAt: now + windowMs, count: 1 });
247
+ return true;
248
+ }
249
+ if (current.count >= limit)
250
+ return false;
251
+ current.count += 1;
252
+ return true;
253
+ },
254
+ };
255
+ }
256
+ function deliveryId(source, headers, rawBody) {
257
+ const named = source === 'github'
258
+ ? header(headers, 'x-github-delivery')
259
+ : header(headers, 'linear-delivery');
260
+ return `${source}:${named ?? crypto.createHash('sha256').update(rawBody).digest('hex')}`;
261
+ }
262
+ function sourceFromPath(pathname) {
263
+ const match = /^\/hooks\/(github|linear)\/?$/.exec(pathname ?? '');
264
+ return match ? match[1] : null;
265
+ }
266
+ async function readRawBody(req, maxBytes) {
267
+ const chunks = [];
268
+ let total = 0;
269
+ for await (const chunk of req) {
270
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
271
+ total += buf.length;
272
+ if (total > maxBytes)
273
+ throw new Error(`payload exceeds ${maxBytes} bytes`);
274
+ chunks.push(buf);
275
+ }
276
+ return Buffer.concat(chunks);
277
+ }
105
278
  /**
106
- * Start a minimal local webhook receiver. POSTs are read as JSON, the GitHub
107
- * event is taken from the `X-GitHub-Event` header, and matching routines are
108
- * fired via {@link fireWebhookJobs}. Returns the underlying server so callers
109
- * can `close()` it. The heavy lifting is the pure matcher above; this is only
110
- * the transport.
279
+ * Start a localhost-bound receiver. It accepts only:
280
+ * POST /hooks/github with X-Hub-Signature-256
281
+ * POST /hooks/linear with Linear-Signature + fresh webhookTimestamp
282
+ *
283
+ * Returns the underlying server so callers can `close()` it.
111
284
  */
112
- export function startWebhookServer(options = {}) {
285
+ export function startWebhookServer(options) {
286
+ const deliveryStore = options.deliveryStore ?? createMemoryDeliveryStore();
287
+ const rateLimiter = options.rateLimiter ?? createMemoryRateLimiter(options.rateLimitPerMinute ?? 60, 60_000);
288
+ const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
113
289
  const server = http.createServer((req, res) => {
114
- if (req.method !== 'POST') {
115
- res.writeHead(405, { 'content-type': 'text/plain' });
116
- res.end('method not allowed');
117
- return;
118
- }
119
- const event = req.headers['x-github-event'] ?? '';
120
- const chunks = [];
121
- req.on('data', (c) => chunks.push(c));
122
- req.on('end', () => {
123
- void (async () => {
124
- try {
125
- const raw = Buffer.concat(chunks).toString('utf-8');
126
- const payload = raw ? JSON.parse(raw) : {};
127
- const fired = await fireWebhookJobs({ event, payload }, options.fire);
128
- options.onDelivery?.(event, fired);
290
+ void (async () => {
291
+ if (req.method !== 'POST') {
292
+ res.writeHead(405, { 'content-type': 'text/plain' });
293
+ res.end('method not allowed');
294
+ return;
295
+ }
296
+ const source = sourceFromPath(req.url?.split('?')[0]);
297
+ if (!source) {
298
+ res.writeHead(404, { 'content-type': 'text/plain' });
299
+ res.end('not found');
300
+ return;
301
+ }
302
+ const secret = options.secrets[source];
303
+ if (!secret) {
304
+ res.writeHead(503, { 'content-type': 'application/json' });
305
+ res.end(JSON.stringify({ ok: false, error: `missing ${source} webhook secret` }));
306
+ return;
307
+ }
308
+ try {
309
+ const rawBody = await readRawBody(req, maxBodyBytes);
310
+ const valid = source === 'github'
311
+ ? verifyGithubSignature(req.headers, rawBody, secret)
312
+ : verifyLinearSignature(req.headers, rawBody, secret);
313
+ if (!valid) {
314
+ res.writeHead(401, { 'content-type': 'application/json' });
315
+ res.end(JSON.stringify({ ok: false, error: 'invalid signature' }));
316
+ return;
317
+ }
318
+ const id = deliveryId(source, req.headers, rawBody);
319
+ if (deliveryStore.seen(id)) {
129
320
  res.writeHead(200, { 'content-type': 'application/json' });
130
- res.end(JSON.stringify({ ok: true, fired: fired.map((f) => f.jobName) }));
321
+ res.end(JSON.stringify({ ok: true, duplicate: true, fired: [] }));
322
+ return;
323
+ }
324
+ const payload = rawBody.length > 0 ? JSON.parse(rawBody.toString('utf-8')) : {};
325
+ if (source === 'linear' && !verifyLinearTimestamp(payload)) {
326
+ res.writeHead(401, { 'content-type': 'application/json' });
327
+ res.end(JSON.stringify({ ok: false, error: 'stale linear webhook timestamp' }));
328
+ return;
131
329
  }
132
- catch (err) {
133
- res.writeHead(400, { 'content-type': 'application/json' });
134
- res.end(JSON.stringify({ ok: false, error: err.message }));
330
+ if (!rateLimiter.take(source)) {
331
+ res.writeHead(429, { 'content-type': 'application/json' });
332
+ res.end(JSON.stringify({ ok: false, error: 'rate limit exceeded' }));
333
+ return;
135
334
  }
136
- })();
137
- });
335
+ const webhook = {
336
+ source,
337
+ event: source === 'github' ? (header(req.headers, 'x-github-event') ?? '') : String(payload.type ?? ''),
338
+ payload,
339
+ };
340
+ const fireOptions = options.fire ?? {};
341
+ const fired = await fireWebhookJobs(webhook, {
342
+ ...fireOptions,
343
+ skipJobNames: deliveryStore.completedJobs(id),
344
+ onJobFired: (job, firedJob) => {
345
+ deliveryStore.markJob(id, job.name);
346
+ fireOptions.onJobFired?.(job, firedJob);
347
+ },
348
+ });
349
+ deliveryStore.mark(id);
350
+ options.onDelivery?.(webhook, fired);
351
+ res.writeHead(200, { 'content-type': 'application/json' });
352
+ res.end(JSON.stringify({ ok: true, fired: fired.map((f) => f.jobName), runs: fired }));
353
+ }
354
+ catch (err) {
355
+ res.writeHead(400, { 'content-type': 'application/json' });
356
+ res.end(JSON.stringify({ ok: false, error: err.message }));
357
+ }
358
+ })();
138
359
  });
139
360
  server.listen(options.port ?? 0, options.host ?? '127.0.0.1');
140
361
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.62",
3
+ "version": "1.20.63",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",