@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -18
  3. package/src/commands/pulse/doctor.js +14 -0
  4. package/src/commands/pulse/hub-write.js +26 -0
  5. package/src/commands/pulse/install.js +14 -0
  6. package/src/commands/pulse/login.js +20 -0
  7. package/src/commands/pulse/mirror.js +27 -0
  8. package/src/commands/pulse/pair.js +38 -0
  9. package/src/commands/pulse/restart.js +14 -0
  10. package/src/commands/pulse/start.js +6 -18
  11. package/src/commands/pulse/status.js +6 -6
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +23 -23
  15. package/src/lib/agentSessions.js +279 -0
  16. package/src/lib/application.js +656 -0
  17. package/src/lib/backoff.js +8 -0
  18. package/src/lib/budgets.js +85 -0
  19. package/src/lib/cliDefaults.js +20 -0
  20. package/src/lib/cloudAuth.js +189 -0
  21. package/src/lib/contract.sha256 +2 -0
  22. package/src/lib/daemonComposition.js +338 -0
  23. package/src/lib/daemonLock.js +55 -0
  24. package/src/lib/daemonLoop.js +69 -0
  25. package/src/lib/daemonProcess.js +366 -0
  26. package/src/lib/daemonRuntime.js +820 -0
  27. package/src/lib/drivers.js +280 -0
  28. package/src/lib/errors.js +19 -0
  29. package/src/lib/graphWindows.js +149 -0
  30. package/src/lib/http.js +84 -0
  31. package/src/lib/hubActions.js +74 -0
  32. package/src/lib/identity.js +102 -0
  33. package/src/lib/intents.js +590 -0
  34. package/src/lib/ledger.js +535 -0
  35. package/src/lib/lifecycle.js +123 -0
  36. package/src/lib/localHub.js +217 -0
  37. package/src/lib/localHubWriter.js +69 -0
  38. package/src/lib/mirror-contract.json +4299 -0
  39. package/src/lib/mirrorContract.js +513 -0
  40. package/src/lib/mirrorDaemon.js +127 -0
  41. package/src/lib/mirrorService.js +358 -0
  42. package/src/lib/mirrorSource.js +455 -0
  43. package/src/lib/mirrorSync.js +353 -0
  44. package/src/lib/oauthLogin.js +132 -0
  45. package/src/lib/ownerLock.js +99 -0
  46. package/src/lib/pairedConfig.js +40 -0
  47. package/src/lib/pairingLifecycle.js +179 -0
  48. package/src/lib/pathRedaction.js +89 -0
  49. package/src/lib/planContract.js +141 -0
  50. package/src/lib/priorityLane.js +80 -0
  51. package/src/lib/processOwnership.js +252 -0
  52. package/src/lib/processSupervisor.js +376 -0
  53. package/src/lib/redaction.js +128 -0
  54. package/src/lib/rootPolicy.js +372 -0
  55. package/src/lib/rulePacks.js +162 -0
  56. package/src/lib/secretStore.js +169 -0
  57. package/src/lib/selfRestart.js +261 -0
  58. package/src/lib/serviceManager.js +358 -0
  59. package/src/lib/storage.js +131 -0
  60. package/src/lib/subprocess.js +45 -0
  61. package/src/lib/tokenStore.js +97 -0
  62. package/src/lib/transport.js +286 -0
  63. package/src/lib/transportContract.js +37 -0
  64. package/LICENSE +0 -21
  65. package/src/commands/pulse/gate.js +0 -25
  66. package/src/commands/pulse/next.js +0 -14
  67. package/src/commands/pulse/submit.js +0 -21
  68. package/src/commands/pulse/verify.js +0 -21
  69. package/src/runPulse.js +0 -58
@@ -0,0 +1,353 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { stat, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { HubError } from './errors.js';
5
+ import { CONTRACT_VERSION, MIRROR_LIMITS } from './mirrorContract.js';
6
+ import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
7
+
8
+ const STATE_VERSION = 1;
9
+ const RECENT_PASSES = 20;
10
+ const MAX_PRIORITY_CALLS = 10;
11
+ const BATCH_HEADROOM_BYTES = 4 * 1024;
12
+ // Read whole on every pass that reads them at all, so a targeted pass may retire their missing items too.
13
+ const WHOLE_FAMILIES = ['graph_window', 'brief'];
14
+ export const RESEND_REQUEST_FILE = 'mirror-resend.request';
15
+ // Contract keys already acted on; a key seen before never re-sends again, so a rolling deploy cannot loop.
16
+ const MAX_CONTRACT_KEYS = 20;
17
+
18
+ /**
19
+ * Asks the daemon to send every rejected item again on its next pass, through a file it takes once, so this never
20
+ * writes the state file the daemon holds in memory. Returns how many items are rejected now.
21
+ */
22
+ export async function requestResend(directory, { stateFile = path.join(directory, 'mirror-state.json') } = {}) {
23
+ const stored = await readOwnerOnlyJson(stateFile, null);
24
+ const rejected = Object.values(stored?.items || {}).filter((entry) => entry?.r).length;
25
+ await writeOwnerOnlyJson(path.join(directory, RESEND_REQUEST_FILE), { requestedAt: Date.now() });
26
+ return { rejected };
27
+ }
28
+
29
+ async function takeResendRequest(directory) {
30
+ const file = path.join(directory, RESEND_REQUEST_FILE);
31
+ try {
32
+ await stat(file);
33
+ await unlink(file);
34
+ return true;
35
+ } catch (error) {
36
+ if (error.code === 'ENOENT') return false;
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ function canonical(value) {
42
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
43
+ if (value && typeof value === 'object') {
44
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
45
+ }
46
+ return JSON.stringify(value);
47
+ }
48
+
49
+ export function itemHash(data) {
50
+ return createHash('sha256').update(canonical(data)).digest('hex');
51
+ }
52
+
53
+ function emptyState() {
54
+ return {
55
+ version: STATE_VERSION, items: {}, withheld: {}, graphDropped: {}, channelLastAt: {}, lastSyncAt: null, lastFullAt: null, lastCompleteAt: null, lastResult: null, lastPass: null, recentPasses: [], lastError: null, lastAbsent: {}, contractKeys: null,
56
+ redactedTotal: 0, redactedLastAt: null,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Where one batch's time went. networkMs is what is left once the lane wait, reading credentials, the backoff and the
62
+ * server's own time are taken out: the bytes on the wire both ways, TLS and the platform in front of the app.
63
+ */
64
+ function batchSplit(items, ms, trace) {
65
+ const laneWaitMs = trace.laneWaitMs ?? 0;
66
+ const credentialsMs = trace.credentialsMs ?? 0;
67
+ const backoffMs = trace.backoffMs ?? 0;
68
+ const serverMs = trace.server?.totalMs ?? null;
69
+ return {
70
+ items,
71
+ ms,
72
+ laneWaitMs,
73
+ laneCapped: trace.laneCapped ?? false,
74
+ heldBy: trace.heldBy ?? [],
75
+ credentialsMs,
76
+ attempts: trace.attempts ?? 1,
77
+ statuses: trace.statuses ?? [],
78
+ backoffMs,
79
+ bytes: trace.bytes ?? null,
80
+ sentBytes: trace.sentBytes ?? null,
81
+ serverMs,
82
+ dbMs: trace.server?.dbMs ?? null,
83
+ lockMs: trace.server?.lockMs ?? null,
84
+ networkMs: Math.max(0, ms - laneWaitMs - credentialsMs - backoffMs - (serverMs ?? 0)),
85
+ };
86
+ }
87
+
88
+ function kindOf(key) {
89
+ return key.slice(0, key.indexOf('\0'));
90
+ }
91
+
92
+ function familyOf(key) {
93
+ const kind = kindOf(key);
94
+ return (kind === 'channel' || kind === 'message') && key.startsWith('bus:', kind.length + 1) ? 'bus' : kind;
95
+ }
96
+
97
+ export function chunkItems(items, { maxItems = MIRROR_LIMITS.maxItems, maxBytes = MIRROR_LIMITS.bodyBytes - BATCH_HEADROOM_BYTES } = {}) {
98
+ const batches = [];
99
+ let batch = [];
100
+ let bytes = 0;
101
+ for (const item of items) {
102
+ const size = Buffer.byteLength(JSON.stringify(item), 'utf8') + 1;
103
+ if (batch.length && (batch.length >= maxItems || bytes + size > maxBytes)) {
104
+ batches.push(batch);
105
+ batch = [];
106
+ bytes = 0;
107
+ }
108
+ batch.push(item);
109
+ bytes += size;
110
+ }
111
+ if (batch.length) batches.push(batch);
112
+ return batches;
113
+ }
114
+
115
+ export class MirrorSync {
116
+ constructor({ source, transport, stateFile, now = Date.now, maxBatchItems = MIRROR_LIMITS.maxItems }) {
117
+ this.source = source;
118
+ this.transport = transport;
119
+ this.stateFile = stateFile;
120
+ this.now = now;
121
+ this.maxBatchItems = maxBatchItems;
122
+ this.state = null;
123
+ this.loading = null;
124
+ this.saving = Promise.resolve();
125
+ }
126
+
127
+ // A windows pass and a change pass run at once (see MirrorService), so both share one loaded state, and saves are
128
+ // written one after another, each from the state as it is then, so an older snapshot never lands last.
129
+ #load() {
130
+ this.loading ??= readOwnerOnlyJson(this.stateFile, null).then((stored) => {
131
+ this.state = stored?.version === STATE_VERSION ? { ...emptyState(), ...stored } : emptyState();
132
+ return this.state;
133
+ }, (error) => {
134
+ this.loading = null;
135
+ throw error;
136
+ });
137
+ return this.loading;
138
+ }
139
+
140
+ #save() {
141
+ const write = this.saving.then(() => writeOwnerOnlyJson(this.stateFile, this.state));
142
+ this.saving = write.catch(() => {});
143
+ return write;
144
+ }
145
+
146
+ async status() {
147
+ const state = await this.#load();
148
+ const mirrored = {};
149
+ const dropped = {};
150
+ let rejected = 0;
151
+ const drop = (key, error) => {
152
+ const kind = kindOf(key);
153
+ dropped[kind] ||= {};
154
+ dropped[kind][error] = (dropped[kind][error] || 0) + 1;
155
+ };
156
+ for (const [key, entry] of Object.entries(state.items)) {
157
+ if (entry.r) {
158
+ drop(key, entry.r);
159
+ rejected += 1;
160
+ } else mirrored[kindOf(key)] = (mirrored[kindOf(key)] || 0) + 1;
161
+ }
162
+ for (const [key, error] of Object.entries(state.withheld)) drop(key, error);
163
+ return {
164
+ mirrored,
165
+ dropped,
166
+ rejected,
167
+ graphDropped: state.graphDropped,
168
+ lastSyncAt: state.lastSyncAt,
169
+ lastFullAt: state.lastFullAt,
170
+ lastCompleteAt: state.lastCompleteAt,
171
+ lastResult: state.lastResult,
172
+ lastPass: state.lastPass,
173
+ recentPasses: state.recentPasses,
174
+ lastError: state.lastError,
175
+ lastAbsent: state.lastAbsent,
176
+ redactedTotal: state.redactedTotal,
177
+ redactedLastAt: state.redactedLastAt,
178
+ };
179
+ }
180
+
181
+ #recordWithheld(state, collected, full) {
182
+ const current = new Map(collected.withheld.map((entry) => [`${entry.kind}\0${entry.externalId}`, entry.error]));
183
+ for (const key of collected.items.keys()) delete state.withheld[key];
184
+ if (full) {
185
+ for (const key of Object.keys(state.withheld)) {
186
+ if (!current.has(key) && collected.complete.has(familyOf(key))) delete state.withheld[key];
187
+ }
188
+ }
189
+ for (const [key, error] of current) {
190
+ if (error === 'content_withheld' && state.withheld[key] !== error) {
191
+ state.redactedTotal = (state.redactedTotal || 0) + 1;
192
+ state.redactedLastAt = this.now();
193
+ }
194
+ state.withheld[key] = error;
195
+ }
196
+ }
197
+
198
+ async recordError(error, { retryAt } = {}) {
199
+ const state = await this.#load();
200
+ state.lastError = { code: error.code || 'HUB_MIRROR_FAILED', at: this.now(), ...(retryAt ? { retryAt } : {}) };
201
+ await this.#save();
202
+ }
203
+
204
+ #tombstoneKeys(state, collected, full) {
205
+ const whole = full ? collected.complete : new Set(WHOLE_FAMILIES.filter((family) => collected.complete.has(family)));
206
+ if (!whole.size) return [];
207
+ const keys = [];
208
+ const listed = full && collected.listedChannels ? new Set(collected.listedChannels) : null;
209
+ const refetched = new Set(collected.refetchedChannels || []);
210
+ for (const [key, entry] of Object.entries(state.items)) {
211
+ if (collected.items.has(key)) continue;
212
+ const family = familyOf(key);
213
+ if (whole.has(family)) {
214
+ keys.push(key);
215
+ } else if (family === 'message' && listed && (!listed.has(entry.c) || refetched.has(entry.c))) {
216
+ keys.push(key);
217
+ }
218
+ }
219
+ return keys;
220
+ }
221
+
222
+ /**
223
+ * A pass whose signal aborts (a change preempting a full pass) stops between reads or at the next batch boundary.
224
+ * The batches it uploaded stay recorded; the rest are sent by a later pass. It is no reconcile and no error.
225
+ */
226
+ async #preempted(state, { full, sources, startedAt, timing }) {
227
+ const pass = { full, sources: full ? null : [...(sources || [])].sort(), preempted: true, ms: this.now() - startedAt, ...timing, priority: this.#priorityCalls(startedAt) };
228
+ state.recentPasses = [...(state.recentPasses || []), { at: this.now(), ...pass }].slice(-RECENT_PASSES);
229
+ await this.#save();
230
+ return new HubError('HUB_MIRROR_PREEMPTED', 'A change preempted this mirror pass');
231
+ }
232
+
233
+ /**
234
+ * A rejected item keeps its hash, so it is not sent again until it changes. It loses the hash, and the next pass that
235
+ * reads it sends it once, when this connector's contract or the cloud's accepted range is one this state has never
236
+ * seen (an upgrade on either side), or when `pulse mirror resend-rejected` left a request.
237
+ */
238
+ async #releaseRejected(state) {
239
+ const key = `mirror/${CONTRACT_VERSION}|${this.transport?.contractAccepts ?? ''}`;
240
+ const seen = Array.isArray(state.contractKeys) ? state.contractKeys : [];
241
+ const fresh = !seen.includes(key);
242
+ if (fresh) state.contractKeys = [...seen, key].slice(-MAX_CONTRACT_KEYS);
243
+ const requested = await takeResendRequest(path.dirname(this.stateFile));
244
+ if (!fresh && !requested) return;
245
+ for (const [itemKey, entry] of Object.entries(state.items)) {
246
+ if (entry.r) state.items[itemKey] = { ...entry, h: null };
247
+ }
248
+ }
249
+
250
+ /** The heartbeat, session, renew, ack and events calls that overlapped a pass: what a batch's lane wait was behind. */
251
+ #priorityCalls(startedAt) {
252
+ return (this.transport.priorityCalls?.({ since: startedAt }) ?? []).slice(-MAX_PRIORITY_CALLS);
253
+ }
254
+
255
+ async run({ full = false, sources, signal } = {}) {
256
+ const state = await this.#load();
257
+ const startedAt = this.now();
258
+ await this.#releaseRejected(state);
259
+ let collected;
260
+ try {
261
+ collected = await this.source.collect({ full, sources, channelLastAt: new Map(Object.entries(state.channelLastAt)), ...(signal ? { signal } : {}) });
262
+ } catch (error) {
263
+ if (error.code === 'HUB_MIRROR_PREEMPTED') throw await this.#preempted(state, { full, sources, startedAt, timing: { collectMs: this.now() - startedAt } });
264
+ throw error;
265
+ }
266
+ const now = this.now();
267
+ const timing = { collectMs: now - startedAt, uploadMs: 0, batches: 0, slowestBatchMs: 0, bytes: 0, sentBytes: 0, slowestBatch: null };
268
+ if (signal?.aborted) throw await this.#preempted(state, { full, sources, startedAt, timing });
269
+ const pending = new Map();
270
+ const outgoing = [];
271
+ for (const [key, item] of collected.items) {
272
+ const hash = itemHash(item.data);
273
+ const previous = state.items[key];
274
+ if (previous && previous.h === hash) continue;
275
+ const updatedAt = Math.max(item.updatedAt, previous?.u ?? 0);
276
+ const entry = { h: hash, u: updatedAt };
277
+ if (item.kind === 'message') entry.c = item.data.channelId;
278
+ pending.set(key, entry);
279
+ outgoing.push({ kind: item.kind, externalId: item.externalId, updatedAt, data: item.data });
280
+ }
281
+ for (const key of this.#tombstoneKeys(state, collected, full)) {
282
+ const externalId = key.slice(key.indexOf('\0') + 1);
283
+ pending.set(key, null);
284
+ outgoing.push({ kind: kindOf(key), externalId, updatedAt: Math.max(now, state.items[key].u + 1), deleted: true });
285
+ }
286
+
287
+ const result = {
288
+ pushed: 0, tombstoned: 0, unchanged: 0, ignored: 0, redacted: 0, rejected: [], withheld: collected.withheld.length, readErrors: collected.errors, briefsPending: collected.briefsPending === true,
289
+ };
290
+ try {
291
+ for (const batch of chunkItems(outgoing, { maxItems: this.maxBatchItems })) {
292
+ if (signal?.aborted) throw await this.#preempted(state, { full, sources, startedAt, timing });
293
+ const sentAt = this.now();
294
+ const trace = {};
295
+ const response = await this.transport.mirror({ items: batch }, { trace });
296
+ const batchMs = trace.ms ?? this.now() - sentAt;
297
+ timing.uploadMs += batchMs;
298
+ timing.batches += 1;
299
+ timing.bytes += trace.bytes || 0;
300
+ timing.sentBytes += trace.sentBytes || 0;
301
+ if (batchMs >= timing.slowestBatchMs) {
302
+ timing.slowestBatchMs = batchMs;
303
+ timing.slowestBatch = batchSplit(batch.length, batchMs, trace);
304
+ }
305
+ const rejected = new Map((response.rejected || []).map((entry) => [`${entry.kind}\0${entry.externalId}`, entry.error]));
306
+ for (const item of batch) {
307
+ const key = `${item.kind}\0${item.externalId}`;
308
+ const entry = pending.get(key);
309
+ if (entry === null) {
310
+ delete state.items[key];
311
+ result.tombstoned += 1;
312
+ } else if (rejected.has(key)) {
313
+ state.items[key] = { ...entry, r: rejected.get(key) };
314
+ result.rejected.push({ kind: item.kind, externalId: item.externalId, error: rejected.get(key) });
315
+ } else {
316
+ state.items[key] = entry;
317
+ result.pushed += 1;
318
+ }
319
+ }
320
+ result.unchanged += response.unchanged || 0;
321
+ result.ignored += response.ignored || 0;
322
+ // The cloud counts only the items it wrote; a batch it wrote nothing of (a retry) adds nothing whatever it says.
323
+ const redacted = response.accepted > 0 ? response.redacted || 0 : 0;
324
+ result.redacted += redacted;
325
+ // Kept per batch, so a pass that fails later still counts what the cloud already redacted from this machine.
326
+ if (redacted > 0) {
327
+ state.redactedTotal = (state.redactedTotal || 0) + redacted;
328
+ state.redactedLastAt = this.now();
329
+ }
330
+ }
331
+ } catch (error) {
332
+ if (error.code === 'HUB_MIRROR_PREEMPTED') throw error;
333
+ state.lastError = { code: error.code || 'HUB_MIRROR_FAILED', at: now };
334
+ await this.#save();
335
+ throw error;
336
+ }
337
+ this.#recordWithheld(state, collected, full);
338
+ state.graphDropped = Object.fromEntries(Object.entries({ ...state.graphDropped, ...collected.graphDropped }).filter(([, sections]) => Object.keys(sections).length));
339
+ if (full || sources?.has('channels')) state.channelLastAt = Object.fromEntries(collected.channelLastAt);
340
+ state.lastSyncAt = now;
341
+ if (full) state.lastFullAt = now;
342
+ if (full) {
343
+ state.lastAbsent = collected.absent || {};
344
+ if (!collected.errors.length) state.lastCompleteAt = now;
345
+ }
346
+ state.lastResult = { pushed: result.pushed, tombstoned: result.tombstoned, rejected: result.rejected.length, withheld: result.withheld, ignored: result.ignored, redacted: result.redacted };
347
+ state.lastPass = { full, sources: full ? null : [...(sources || [])].sort(), ms: this.now() - startedAt, ...timing, priority: this.#priorityCalls(startedAt) };
348
+ state.recentPasses = [...(state.recentPasses || []), { at: now, ...state.lastPass }].slice(-RECENT_PASSES);
349
+ state.lastError = collected.errors.length ? { code: collected.errors[0].code, at: now } : null;
350
+ await this.#save();
351
+ return result;
352
+ }
353
+ }
@@ -0,0 +1,132 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import http from 'node:http';
3
+ import { HubError } from './errors.js';
4
+ import { cleanOrigin, decodeJwtClaims, fetchJson } from './http.js';
5
+ import { LOGIN_SCOPE, OAUTH_CLIENT_ID } from './transportContract.js';
6
+
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
8
+ const DONE_PAGE = '<!doctype html><meta charset="utf-8"><title>AgentPulse Pulse</title>'
9
+ + '<p>Pulse connector sign-in finished. You can close this tab.</p>';
10
+
11
+ export function createPkce(random = randomBytes) {
12
+ const verifier = random(32).toString('base64url');
13
+ return {
14
+ verifier,
15
+ challenge: createHash('sha256').update(verifier, 'ascii').digest('base64url'),
16
+ method: 'S256',
17
+ };
18
+ }
19
+
20
+ export function buildAuthorizeUrl({ platformUrl, redirectUri, state, challenge, scope = LOGIN_SCOPE }) {
21
+ return `${cleanOrigin(platformUrl, 'Platform URL')}/api/oauth/authorize?${new URLSearchParams({
22
+ response_type: 'code',
23
+ client_id: OAUTH_CLIENT_ID,
24
+ redirect_uri: redirectUri,
25
+ scope,
26
+ state,
27
+ code_challenge: challenge,
28
+ code_challenge_method: 'S256',
29
+ })}`;
30
+ }
31
+
32
+ export function waitForAuthorizationCode({ state, timeoutMs = 300_000 }) {
33
+ return new Promise((listening, failedToListen) => {
34
+ let settle;
35
+ const code = new Promise((resolve, reject) => {
36
+ settle = { resolve, reject };
37
+ });
38
+ let timer;
39
+ const server = http.createServer((req, res) => {
40
+ const url = new URL(req.url, 'http://127.0.0.1');
41
+ if (url.pathname !== '/callback') {
42
+ res.writeHead(404).end();
43
+ return;
44
+ }
45
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(DONE_PAGE);
46
+ clearTimeout(timer);
47
+ server.close();
48
+ if (url.searchParams.get('error')) {
49
+ settle.reject(new HubError('HUB_OAUTH_DENIED', 'Sign-in was denied in the browser'));
50
+ } else if (url.searchParams.get('state') !== state) {
51
+ settle.reject(new HubError('HUB_OAUTH_STATE_MISMATCH', 'Sign-in callback state did not match; aborting'));
52
+ } else if (!url.searchParams.get('code')) {
53
+ settle.reject(new HubError('HUB_OAUTH_DENIED', 'Sign-in callback carried no authorization code'));
54
+ } else {
55
+ settle.resolve(url.searchParams.get('code'));
56
+ }
57
+ });
58
+ server.on('error', failedToListen);
59
+ server.listen(0, '127.0.0.1', () => {
60
+ timer = setTimeout(() => {
61
+ server.close();
62
+ settle.reject(new HubError('HUB_OAUTH_TIMEOUT', 'Timed out waiting for browser sign-in (5 minutes)'));
63
+ }, timeoutMs);
64
+ listening({ redirectUri: `http://127.0.0.1:${server.address().port}/callback`, code });
65
+ });
66
+ });
67
+ }
68
+
69
+ export function acceptUserToken(tokenResponse, { platformUrl, now = Date.now } = {}) {
70
+ const claims = decodeJwtClaims(tokenResponse?.access_token);
71
+ if (
72
+ claims?.type !== 'user_delegated' ||
73
+ !UUID.test(String(claims.sub || '')) ||
74
+ !UUID.test(String(claims.team_id || ''))
75
+ ) {
76
+ throw new HubError(
77
+ 'HUB_OAUTH_TOKEN_INVALID',
78
+ 'The platform did not return a user-delegated token; run `agentpulse pulse login` again',
79
+ );
80
+ }
81
+ const expiresIn = Number(tokenResponse.expires_in);
82
+ return {
83
+ platformUrl: cleanOrigin(platformUrl, 'Platform URL'),
84
+ userId: claims.sub.toLowerCase(),
85
+ teamId: claims.team_id.toLowerCase(),
86
+ accessToken: tokenResponse.access_token,
87
+ refreshToken: typeof tokenResponse.refresh_token === 'string' ? tokenResponse.refresh_token : null,
88
+ expiresAt: new Date(now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) * 1000).toISOString(),
89
+ scopes: String(tokenResponse.scope || '').split(' ').filter(Boolean),
90
+ };
91
+ }
92
+
93
+ export async function loginWithBrowser({
94
+ platformUrl,
95
+ fetch = globalThis.fetch,
96
+ openBrowser,
97
+ onAuthUrl = () => {},
98
+ scope = LOGIN_SCOPE,
99
+ timeoutMs = 300_000,
100
+ now = Date.now,
101
+ random = randomBytes,
102
+ }) {
103
+ const origin = cleanOrigin(platformUrl, 'Platform URL');
104
+ const pkce = createPkce(random);
105
+ const state = random(16).toString('base64url');
106
+ const callback = await waitForAuthorizationCode({ state, timeoutMs });
107
+ const authorizeUrl = buildAuthorizeUrl({
108
+ platformUrl: origin,
109
+ redirectUri: callback.redirectUri,
110
+ state,
111
+ challenge: pkce.challenge,
112
+ scope,
113
+ });
114
+ onAuthUrl(authorizeUrl);
115
+ await openBrowser(authorizeUrl);
116
+ const code = await callback.code;
117
+ const exchanged = await fetchJson({
118
+ fetch,
119
+ url: `${origin}/api/oauth/token`,
120
+ body: {
121
+ grant_type: 'authorization_code',
122
+ client_id: OAUTH_CLIENT_ID,
123
+ code,
124
+ redirect_uri: callback.redirectUri,
125
+ code_verifier: pkce.verifier,
126
+ },
127
+ });
128
+ if (!exchanged.ok) {
129
+ throw new HubError('HUB_OAUTH_EXCHANGE_FAILED', `Sign-in token exchange failed with HTTP ${exchanged.status}`);
130
+ }
131
+ return acceptUserToken(exchanged.body, { platformUrl: origin, now });
132
+ }
@@ -0,0 +1,99 @@
1
+ import { lstat, open, readFile, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { ensureOwnerOnlyDirectory } from './storage.js';
5
+
6
+ const WRITER_GRACE_MS = 5_000;
7
+
8
+ async function readIfPresent(file) {
9
+ try {
10
+ return await readFile(file, 'utf8');
11
+ } catch (error) {
12
+ if (error.code === 'ENOENT') return null;
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ async function currentOwner(file) {
18
+ let info;
19
+ try {
20
+ info = await lstat(file);
21
+ } catch (error) {
22
+ if (error.code === 'ENOENT') return { state: 'free' };
23
+ throw error;
24
+ }
25
+ const text = await readIfPresent(file);
26
+ if (text === null) return { state: 'free' };
27
+ if (!text.trim()) {
28
+ return Date.now() - info.mtimeMs < WRITER_GRACE_MS ? { state: 'writing' } : { state: 'invalid', text };
29
+ }
30
+ try {
31
+ const owner = JSON.parse(text);
32
+ return owner && typeof owner === 'object' && Number.isInteger(owner.pid)
33
+ ? { state: 'owned', owner, text }
34
+ : { state: 'invalid', text };
35
+ } catch {
36
+ return { state: 'invalid', text };
37
+ }
38
+ }
39
+
40
+ async function removeIfUnchanged(file, observedText) {
41
+ const takeover = `${file}.takeover`;
42
+ let handle;
43
+ try {
44
+ handle = await open(takeover, 'wx', 0o600);
45
+ } catch (error) {
46
+ if (error.code !== 'EEXIST') throw error;
47
+ const info = await lstat(takeover).catch(() => null);
48
+ if (info && Date.now() - info.mtimeMs > WRITER_GRACE_MS) await rm(takeover, { force: true });
49
+ return;
50
+ }
51
+ try {
52
+ await handle.close();
53
+ if ((await readIfPresent(file)) === observedText) await rm(file, { force: true });
54
+ } finally {
55
+ await rm(takeover, { force: true });
56
+ }
57
+ }
58
+
59
+ export async function acquireOwnerLock({
60
+ file,
61
+ identity,
62
+ identityProvider,
63
+ timeoutMs = 0,
64
+ retryMs = 50,
65
+ heldError,
66
+ }) {
67
+ await ensureOwnerOnlyDirectory(path.dirname(file));
68
+ const serialized = `${JSON.stringify(identity)}\n`;
69
+ const deadline = Date.now() + timeoutMs;
70
+ for (;;) {
71
+ try {
72
+ const handle = await open(file, 'wx', 0o600);
73
+ try {
74
+ await handle.writeFile(serialized, 'utf8');
75
+ await handle.sync();
76
+ } finally {
77
+ await handle.close();
78
+ }
79
+ return async () => {
80
+ const text = await readFile(file, 'utf8').catch(() => null);
81
+ if (text === serialized) await rm(file, { force: true });
82
+ };
83
+ } catch (error) {
84
+ if (error.code !== 'EEXIST') throw error;
85
+ }
86
+ const { state, owner, text } = await currentOwner(file);
87
+ const held = state === 'writing' || (state === 'owned' && await identityProvider.matches(owner));
88
+ if (!held) {
89
+ if (state !== 'free') {
90
+ await removeIfUnchanged(file, text);
91
+ await sleep(Math.min(retryMs, 10));
92
+ }
93
+ if (Date.now() >= deadline + WRITER_GRACE_MS) throw heldError(owner);
94
+ continue;
95
+ }
96
+ if (Date.now() >= deadline) throw heldError(owner);
97
+ await sleep(retryMs);
98
+ }
99
+ }
@@ -0,0 +1,40 @@
1
+ import { HubError } from './errors.js';
2
+ import { cleanOrigin } from './http.js';
3
+
4
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5
+
6
+ function invalid(reason) {
7
+ return new HubError('HUB_PAIRED_CONFIG_INVALID', `Paired connector configuration is invalid (${reason}); pair this machine again`);
8
+ }
9
+
10
+ export function assertPairedConfig(config) {
11
+ const paired = config?.paired;
12
+ if (paired?.state !== 'active') throw invalid('not paired');
13
+ if (!UUID.test(String(paired.machineId || ''))) throw invalid('machine id');
14
+ if (!Number.isInteger(paired.policyVersion) || paired.policyVersion < 1) throw invalid('policy version');
15
+ if (!UUID.test(String(paired.userId || '')) || !UUID.test(String(paired.teamId || ''))) throw invalid('pairing user');
16
+ const target = paired.target;
17
+ if (target?.devOrg !== null && !(target?.devOrg?.id && target?.devOrg?.slug)) throw invalid('target dev-org');
18
+ try {
19
+ if (cleanOrigin(paired.baseUrl) !== paired.baseUrl) throw invalid('base URL');
20
+ if (cleanOrigin(target.platformUrl) !== target.platformUrl) throw invalid('platform URL');
21
+ } catch (error) {
22
+ if (error.code === 'HUB_PAIRED_CONFIG_INVALID') throw error;
23
+ throw invalid('URL');
24
+ }
25
+ return paired;
26
+ }
27
+
28
+ export function pairedStatus(config) {
29
+ try {
30
+ const paired = assertPairedConfig(config);
31
+ return {
32
+ ok: true,
33
+ machineId: paired.machineId,
34
+ policyVersion: paired.policyVersion,
35
+ target: paired.target.devOrg ? paired.target.devOrg.slug : paired.target.platformUrl,
36
+ };
37
+ } catch (error) {
38
+ return { ok: false, code: error.code || 'HUB_PAIRED_CONFIG_INVALID' };
39
+ }
40
+ }