@phnx-labs/agents-cli 1.20.62 → 1.20.64
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 +62 -0
- package/README.md +19 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +96 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- 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/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +92 -29
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +12 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +170 -5
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +7 -2
- package/dist/lib/agents.d.ts +44 -0
- package/dist/lib/agents.js +85 -35
- package/dist/lib/browser/drivers/ssh.js +19 -2
- 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/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +8 -1
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -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/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +50 -12
- package/dist/lib/routines.js +82 -27
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +56 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- 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 +26 -2
- package/dist/lib/session/sync/sync.js +192 -69
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +16 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- package/package.json +1 -1
|
@@ -118,9 +118,20 @@ export async function runUmbrellaSync(args) {
|
|
|
118
118
|
const { isSyncConfigured } = await import('./session/sync/config.js');
|
|
119
119
|
if (isBetaEnabled('session-sync') && isSyncConfigured()) {
|
|
120
120
|
const { syncSessions } = await import('./session/sync/sync.js');
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// Record a failure here instead of letting it throw: like the fetchSecrets
|
|
122
|
+
// block above, a failure in one fetch stage must not abort the others or
|
|
123
|
+
// the reconcile below (see this function's doc comment). Without this,
|
|
124
|
+
// syncSessions() throwing — e.g. via the C1 containment reject on a
|
|
125
|
+
// malicious peer manifest — would skip `plan.reconcile` entirely.
|
|
126
|
+
try {
|
|
127
|
+
const r = await syncSessions();
|
|
128
|
+
result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
|
|
129
|
+
log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0, error: err.message };
|
|
133
|
+
log(`sessions: failed — ${err.message}`);
|
|
134
|
+
}
|
|
124
135
|
}
|
|
125
136
|
else {
|
|
126
137
|
result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0 };
|
|
@@ -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/dist/lib/types.d.ts
CHANGED
|
@@ -624,6 +624,15 @@ export interface ExtraRepoConfig {
|
|
|
624
624
|
export interface Meta {
|
|
625
625
|
agents?: Partial<Record<AgentId, string>>;
|
|
626
626
|
run?: RunConfig;
|
|
627
|
+
/**
|
|
628
|
+
* `agents run --lease` config. `secretsBundle` names the keychain secrets bundle
|
|
629
|
+
* whose provider token (e.g. `HCLOUD_TOKEN`) crabbox uses to reach the cloud API.
|
|
630
|
+
* When unset, the bundle is resolved by env (`AGENTS_LEASE_SECRETS_BUNDLE`) then
|
|
631
|
+
* auto-detected (the first bundle that declares a provider token key).
|
|
632
|
+
*/
|
|
633
|
+
lease?: {
|
|
634
|
+
secretsBundle?: string;
|
|
635
|
+
};
|
|
627
636
|
/** macOS secrets-agent config. `policy` is the default prompt policy for
|
|
628
637
|
* bundles without an explicit per-bundle policy: `daily` (the default) asks
|
|
629
638
|
* once per ~7 days, `always` asks every time. `auto` (default on) lets the
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AccountInfo } from './agents.js';
|
|
2
2
|
import type { AgentId } from './types.js';
|
|
3
3
|
/** Discriminator for usage window types. */
|
|
4
|
-
export type UsageWindowKey = 'session' | 'week' | 'sonnet_week';
|
|
4
|
+
export type UsageWindowKey = 'session' | 'week' | 'sonnet_week' | 'month';
|
|
5
5
|
/** A single rate-limit window with utilization percentage and reset time. */
|
|
6
6
|
export interface UsageWindow {
|
|
7
7
|
key: UsageWindowKey;
|
|
@@ -154,7 +154,46 @@ export interface KimiUsagesResponse {
|
|
|
154
154
|
export declare function normalizeKimiWindows(data: KimiUsagesResponse): UsageWindow[];
|
|
155
155
|
/** Derive a display plan label from Kimi's membership tier or subscription type. */
|
|
156
156
|
export declare function formatKimiPlan(data: KimiUsagesResponse): string | null;
|
|
157
|
-
/**
|
|
157
|
+
/** A single Droid token-rate-limit window from /api/billing/limits. */
|
|
158
|
+
interface DroidLimitWindow {
|
|
159
|
+
usedPercent?: number | null;
|
|
160
|
+
windowEnd?: string | null;
|
|
161
|
+
}
|
|
162
|
+
/** Response shape from Factory's billing limits endpoint (subset we render). */
|
|
163
|
+
export interface DroidBillingLimitsResponse {
|
|
164
|
+
usesTokenRateLimitsBilling?: boolean | null;
|
|
165
|
+
limits?: {
|
|
166
|
+
standard?: {
|
|
167
|
+
fiveHour?: DroidLimitWindow | null;
|
|
168
|
+
weekly?: DroidLimitWindow | null;
|
|
169
|
+
monthly?: DroidLimitWindow | null;
|
|
170
|
+
} | null;
|
|
171
|
+
} | null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Normalize the Factory billing-limits payload into the common UsageWindow
|
|
175
|
+
* shape. Orgs on the legacy (non token-rate-limit) billing model have no
|
|
176
|
+
* meaningful windows, so they render nothing — mirrors droid's own gate on
|
|
177
|
+
* `usesTokenRateLimitsBilling` before it reads `limits.standard`.
|
|
178
|
+
*/
|
|
179
|
+
export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse): UsageWindow[];
|
|
180
|
+
/**
|
|
181
|
+
* Load a version home's Claude OAuth credential from the two stores Claude Code
|
|
182
|
+
* uses, tried in order:
|
|
183
|
+
*
|
|
184
|
+
* 1. The OS keychain (`getKeychainToken`). Canonical on macOS — Claude Code
|
|
185
|
+
* writes the token to the login keychain and we read it via `/usr/bin/security`.
|
|
186
|
+
* 2. `<home>/.claude/.credentials.json`. On a headless Linux box (the
|
|
187
|
+
* `agents view --host <linux>` case) there is no reachable Secret Service, so
|
|
188
|
+
* the Claude CLI stores its OAuth token in this plaintext file instead. The
|
|
189
|
+
* keychain read above finds nothing on that platform, so we fall back to the
|
|
190
|
+
* file. Same wrapped `{ claudeAiOauth }` shape, so one parser handles both.
|
|
191
|
+
* Mirrors `readClaudeCredentialsBlob` (cloud/rush.ts), the proven pattern.
|
|
192
|
+
*
|
|
193
|
+
* Without step 2 the live usage fetch got no token on Linux, so `agents view`
|
|
194
|
+
* (run remotely over SSH by `--host`) rendered no usage bars even though the
|
|
195
|
+
* account + plan — read from the plaintext `.claude.json` — showed fine.
|
|
196
|
+
*/
|
|
158
197
|
export declare function loadClaudeOauth(home?: string): Promise<ClaudeOauthCredentials | null>;
|
|
159
198
|
/**
|
|
160
199
|
* Derive the Keychain service name for a Claude home directory.
|