@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.
- package/CHANGELOG.md +45 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +41 -0
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +58 -18
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/package.json +1 -1
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
44
|
-
*
|
|
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:
|
|
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:
|
|
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?: (
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* the
|
|
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
|
|
128
|
+
export declare function startWebhookServer(options: WebhookServerOptions): http.Server;
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
84
|
-
*
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* the
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
|
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
|
-
|
|
133
|
-
res.writeHead(
|
|
134
|
-
res.end(JSON.stringify({ ok: false, error:
|
|
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.
|
|
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",
|