@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -63,6 +63,7 @@ export interface UmbrellaResult {
63
63
  pushed: number;
64
64
  pulled: number;
65
65
  merged: number;
66
+ error?: string;
66
67
  };
67
68
  devices?: {
68
69
  synced: number;
@@ -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
- const r = await syncSessions();
122
- result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
123
- log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
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
- * 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;
@@ -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
@@ -1,7 +1,7 @@
1
- import type { AccountInfo } from './agents.js';
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
- /** Load Claude OAuth credentials from the system keychain/keyring. */
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.