@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,513 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ export const MIRROR_CONTRACT = Object.freeze(
4
+ JSON.parse(readFileSync(new URL('./mirror-contract.json', import.meta.url), 'utf8'))
5
+ );
6
+ export const MIRROR_LIMITS = MIRROR_CONTRACT.limits;
7
+ export const MIRROR_PATH = MIRROR_CONTRACT.route.path;
8
+ export const MIRROR_SCOPE = MIRROR_CONTRACT.route.scope;
9
+ export const MIRROR_KINDS = MIRROR_CONTRACT.request.properties.items.items.properties.kind.enum;
10
+
11
+ export const CONTRACT_VERSION = MIRROR_CONTRACT.contractVersion;
12
+ export const CONTRACT_HEADER = MIRROR_CONTRACT.handshake.requestHeader.toLowerCase();
13
+ export const CONTRACT_ACCEPTS_HEADER = MIRROR_CONTRACT.handshake.responseHeader.toLowerCase();
14
+
15
+ /** The contract's secret table, compiled: `pattern` replaces every match, `probe` tests for one. */
16
+ export const SECRET_RULES = Object.freeze(MIRROR_CONTRACT.privacy.secrets.map((rule) => Object.freeze({
17
+ id: rule.id,
18
+ replacement: rule.replacement,
19
+ pattern: new RegExp(rule.regex, rule.flags),
20
+ probe: new RegExp(rule.regex, rule.flags.replace('g', '')),
21
+ })));
22
+ export const CREDENTIAL_REPLACEMENT = '[redacted:credential]';
23
+
24
+ const SECRET_KEYS = MIRROR_CONTRACT.privacy.secretKeys;
25
+ const SECRET_KEY_FAMILIES = SECRET_KEYS.families.map((family) => ({
26
+ id: family.id,
27
+ replacement: family.replacement,
28
+ strict: family.strict === true,
29
+ pattern: new RegExp(family.regex, family.flags),
30
+ }));
31
+ const PLACEHOLDER = new RegExp(SECRET_KEYS.placeholder.regex, SECRET_KEYS.placeholder.flags);
32
+ const NON_SECRET_WORD = new RegExp(SECRET_KEYS.nonSecretWords.regex, SECRET_KEYS.nonSecretWords.flags);
33
+ const LINE_NET = MIRROR_CONTRACT.privacy.lineNet;
34
+ const LINE_NET_FAMILIES = new Set(LINE_NET.families);
35
+ const LINE_KEY = new RegExp(LINE_NET.key.regex, LINE_NET.key.flags);
36
+ const LINE_SCOPE = new RegExp(LINE_NET.scope.regex, LINE_NET.scope.flags);
37
+ const LINE_LITERAL = new RegExp(LINE_NET.literal.regex, LINE_NET.literal.flags);
38
+ const LINE_VALUE = new RegExp(LINE_NET.value.regex, LINE_NET.value.flags);
39
+ const BLOCK = LINE_NET.block;
40
+ const BLOCK_OPEN = new RegExp(BLOCK.open.regex, BLOCK.open.flags);
41
+ const BLOCK_RUN = new RegExp(BLOCK.run.regex, BLOCK.run.flags);
42
+ const BLOCK_FENCE = new RegExp(BLOCK.fence.regex);
43
+ export const WITHHELD_MARKER = BLOCK.withheld;
44
+ const V1_WITHHOLD = new RegExp(MIRROR_CONTRACT.privacy.v1Withhold.pattern, MIRROR_CONTRACT.privacy.v1Withhold.flags.replace('g', ''));
45
+ const GATE_MARKERS = new RegExp(MIRROR_CONTRACT.privacy.v1Gate.markers.regex, MIRROR_CONTRACT.privacy.v1Gate.markers.flags);
46
+ const LINE_SPLIT = /(\r\n?|\n)/;
47
+ const SPANS_LINES = /[\r\n]/;
48
+
49
+ function secretKeyFamily(key) {
50
+ if (typeof key !== 'string' || key.length > 200) return null;
51
+ const name = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
52
+ return SECRET_KEY_FAMILIES.find((entry) => entry.pattern.test(name)) ?? null;
53
+ }
54
+
55
+ /** The family's replacement for a non-empty string that is not a placeholder (nor, outside a strict family, `required`). */
56
+ export function secretLeafReplacement(family, value) {
57
+ if (typeof value !== 'string') return null;
58
+ const trimmed = value.trim();
59
+ if (!trimmed || PLACEHOLDER.test(trimmed) || (!family.strict && NON_SECRET_WORD.test(trimmed))) return null;
60
+ return family.replacement;
61
+ }
62
+
63
+ /**
64
+ * The replacement for an object entry whose key names a secret (DB_PASSWORD, apiKey, client_secret) and whose value is
65
+ * a non-empty string that is not a placeholder (nor, outside the strict password family, a word like `required`);
66
+ * null for any other entry.
67
+ */
68
+ export function secretKeyReplacement(key, value) {
69
+ if (typeof value !== 'string') return null;
70
+ const family = secretKeyFamily(key);
71
+ return family ? secretLeafReplacement(family, value) : null;
72
+ }
73
+
74
+ /** The family whose replacement every string leaf of an array or object under `key` takes (credentials: {user, pass}). */
75
+ export function secretSubtreeFamily(key, value) {
76
+ if (!value || typeof value !== 'object') return null;
77
+ const family = secretKeyFamily(key);
78
+ return family && LINE_NET_FAMILIES.has(family.id) ? family : null;
79
+ }
80
+
81
+ /**
82
+ * The array or object under a secret-style key with every string leaf through secretLeafReplacement, and any other
83
+ * string or key through `text`; null when `key` names no such family or the value is not an array or object.
84
+ */
85
+ export function redactSecretSubtree(key, value, text) {
86
+ const family = secretSubtreeFamily(key, value);
87
+ if (!family) return null;
88
+ const walk = (entry) => {
89
+ if (typeof entry === 'string') return secretLeafReplacement(family, entry) ?? text(entry);
90
+ if (Array.isArray(entry)) return entry.map(walk);
91
+ if (entry && typeof entry === 'object') return Object.fromEntries(Object.entries(entry).map(([name, item]) => [text(name), walk(item)]));
92
+ return entry;
93
+ };
94
+ return walk(value);
95
+ }
96
+
97
+ /**
98
+ * The contract's line net, after the table: on each line naming a secret-style key, every quoted literal that is not a
99
+ * placeholder, and every unquoted value after `=` or `:` in that key's statement, takes the net replacement.
100
+ */
101
+ export function redactSecretLines(input) {
102
+ if (!LINE_KEY.test(input)) return { text: input, redactions: 0 };
103
+ let redactions = 0;
104
+ const replace = (...match) => {
105
+ const groups = match.at(-1);
106
+ if (groups.skip !== undefined) return groups.skip;
107
+ redactions += 1;
108
+ return `${groups.keep}${LINE_NET.replacement}${groups.tail ?? ''}`;
109
+ };
110
+ const text = input.split(LINE_SPLIT).map((part, index) => (index % 2 === 0 && LINE_KEY.test(part)
111
+ ? part.replace(LINE_SCOPE, (scope) => scope.replace(LINE_VALUE, replace)).replace(LINE_LITERAL, replace)
112
+ : part)).join('');
113
+ return { text, redactions };
114
+ }
115
+
116
+ const indentOf = (line) => line.length - line.trimStart().length;
117
+
118
+ /** The line holding `at`: where it ends and where the next one starts (-1 on the last line); \r\n, \r and \n end a line. */
119
+ function lineAt(text, at) {
120
+ for (let end = at; end < text.length; end += 1) {
121
+ const code = text.charCodeAt(end);
122
+ if (code === 10) return { end, next: end + 1 };
123
+ if (code === 13) return { end, next: text.charCodeAt(end + 1) === 10 ? end + 2 : end + 1 };
124
+ }
125
+ return { end: text.length, next: -1 };
126
+ }
127
+
128
+ /** The line end just before `at`, the start of a line. */
129
+ const lineEndBefore = (text, at) => (text[at - 1] === '\n' && text[at - 2] === '\r' ? '\r\n' : text[at - 1]);
130
+
131
+ /** Where the line holding each offset starts, for offsets met in increasing order: one forward pass over the string. */
132
+ function lineStarts(text) {
133
+ let scanned = 0;
134
+ let start = 0;
135
+ return (at) => {
136
+ if (at < scanned) [scanned, start] = [0, 0];
137
+ for (; scanned < at; scanned += 1) {
138
+ const code = text.charCodeAt(scanned);
139
+ if (code === 10 || code === 13) start = scanned + 1;
140
+ }
141
+ return start;
142
+ };
143
+ }
144
+
145
+ /** Where the quote opened before `start` closes, a backslash escaping the next character in a single quote; -1 if not by `limit`. */
146
+ function closingQuote(text, start, quote, limit) {
147
+ if (quote.length === 3) {
148
+ const at = text.indexOf(quote, start);
149
+ return at > limit ? -1 : at;
150
+ }
151
+ for (let at = start; at < text.length && at <= limit; at += 1) {
152
+ if (text[at] === '\\') at += 1;
153
+ else if (text[at] === quote) return at;
154
+ }
155
+ return -1;
156
+ }
157
+
158
+ /**
159
+ * Whether `quote` opens an odd number of times in text[from, to): the key after it sits inside such a literal. `seen`
160
+ * carries the scan per quote between calls on one string, so keys met in order cost one pass over each line.
161
+ */
162
+ function insideLiteral(text, from, to, quote, seen) {
163
+ let state = seen.get(quote);
164
+ if (!state || state.from !== from || state.at > to) state = { from, at: from, open: false };
165
+ let { at, open } = state;
166
+ for (; at < to; at += 1) {
167
+ if (quote.length === 1 && text[at] === '\\') at += 1;
168
+ else if (text.startsWith(quote, at)) {
169
+ open = !open;
170
+ at += quote.length - 1;
171
+ }
172
+ }
173
+ seen.set(quote, { from, at, open });
174
+ return open;
175
+ }
176
+
177
+ /**
178
+ * The lines from `from` that belong to a block: up to (not including) the first line `ends` says closes it. Returns the
179
+ * offset of that line, or null when the block runs to the end of the string or past maxChars.
180
+ */
181
+ function blockLines(text, from, ends) {
182
+ for (let at = from; at < text.length;) {
183
+ const { end, next } = lineAt(text, at);
184
+ if (ends(text.slice(at, end))) return at;
185
+ if (end - from > BLOCK.maxChars || next === -1) return null;
186
+ at = next;
187
+ }
188
+ return null;
189
+ }
190
+
191
+ /**
192
+ * What a block opener spans: { from, to, replacement }; { skip } for a quote that closes on its line within maxInline;
193
+ * false when the value opens no block; null when the block has no end within maxChars or before the end of the string.
194
+ * `lineStart` gives the start of the key's line (lineStarts).
195
+ */
196
+ function blockSpan(text, match, literals, lineStart) {
197
+ const { groups } = match;
198
+ const start = match.index + match[0].length;
199
+ const limit = start + BLOCK.maxChars;
200
+ const span = (from, to, replacement) => (text.slice(from, to) === replacement ? false : { from, to, replacement });
201
+ const keyAt = match.index + (groups.lo?.length ?? 0);
202
+ const keyLine = lineStart(match.index);
203
+ const label = groups.lo !== undefined && (groups.lc !== undefined || groups.le !== undefined);
204
+ if (groups.close !== undefined) {
205
+ const keyQuoted = groups.kq && text[keyAt - 1] === groups.kq;
206
+ if (insideLiteral(text, keyLine, keyQuoted ? keyAt - 1 : keyAt, groups.close, literals)) return false;
207
+ const close = closingQuote(text, start, groups.close, limit);
208
+ if (close === -1) return null;
209
+ const inline = groups.close.length === 1 && close - start <= BLOCK.maxInline && !SPANS_LINES.test(text.slice(start, close));
210
+ return inline ? { skip: close + 1 } : span(start, close, BLOCK.replacement);
211
+ }
212
+ if (groups.run !== undefined) {
213
+ BLOCK_RUN.lastIndex = start;
214
+ const run = text.match(BLOCK_RUN)[0];
215
+ return run.length > BLOCK.maxChars ? null : span(start, start + run.length, BLOCK.replacement);
216
+ }
217
+ if (groups.inline !== undefined) {
218
+ if (!label) return false;
219
+ const { end } = lineAt(text, start);
220
+ return PLACEHOLDER.test(text.slice(start, end).trim()) ? false : span(start, end, BLOCK.replacement);
221
+ }
222
+ const from = lineAt(text, start).next;
223
+ if (from === -1 || from >= text.length) return false;
224
+ if (groups.cont !== undefined) {
225
+ for (let at = from; ;) {
226
+ const { end, next } = lineAt(text, at);
227
+ if (end - from > BLOCK.maxChars) return null;
228
+ if (end === at || text[end - 1] !== '\\') return span(from, end, BLOCK.replacement);
229
+ if (next === -1) return null;
230
+ at = next;
231
+ }
232
+ }
233
+ if (groups.heredoc !== undefined) {
234
+ const to = blockLines(text, from, (line) => line.trim() === groups.tag || line.trim() === `${groups.tag})`);
235
+ if (to === null) return null;
236
+ return to === from ? false : span(from, to, `${BLOCK.replacement}${lineEndBefore(text, to)}`);
237
+ }
238
+ const prefix = text.slice(keyLine, keyAt);
239
+ const depth = quoteDepth(prefix);
240
+ const content = lineContent(depth, indentOf(prefix), indentOf(depth ? afterQuotes(prefix, depth) : prefix));
241
+ let lead = null;
242
+ const to = blockLines(text, from, (line) => {
243
+ const { body, indent } = content(line);
244
+ if (!body.trim()) return false;
245
+ if (indentOf(body) <= indent) return true;
246
+ lead ??= line.slice(0, line.length - body.trimStart().length);
247
+ return false;
248
+ });
249
+ if (lead !== null) return to === null ? null : span(from, to, `${lead}${BLOCK.replacement}${lineEndBefore(text, to)}`);
250
+ return groups.yaml === '' ? nextLineValue(text, from, content, label, span) : false;
251
+ }
252
+
253
+ /**
254
+ * The text after the first `depth` blockquote markers of a line (each after at most 40 spaces or tabs); null when the
255
+ * line has fewer.
256
+ */
257
+ function afterQuotes(line, depth) {
258
+ let at = 0;
259
+ for (let found = 0; found < depth; found += 1) {
260
+ let next = at;
261
+ while (next - at < 40 && (line[next] === ' ' || line[next] === '\t')) next += 1;
262
+ if (line[next] !== '>') return null;
263
+ at = next + 1;
264
+ }
265
+ return line.slice(at);
266
+ }
267
+
268
+ /** How many blockquote markers open a line, up to maxQuoteDepth. */
269
+ function quoteDepth(line) {
270
+ let depth = 0;
271
+ while (depth < BLOCK.maxQuoteDepth && afterQuotes(line, depth + 1) !== null) depth += 1;
272
+ return depth;
273
+ }
274
+
275
+ /**
276
+ * For a key line opening with `depth` quote markers: a following line's body (after the same markers when it carries
277
+ * them) and the indentation it must exceed to belong to the key's block (the key line's, measured the same way).
278
+ */
279
+ const lineContent = (depth, rawIndent, quotedIndent) => (line) => {
280
+ const quoted = depth ? afterQuotes(line, depth) : null;
281
+ return quoted === null ? { body: line, indent: rawIndent } : { body: quoted, indent: quotedIndent };
282
+ };
283
+
284
+ /**
285
+ * After an empty value with no deeper line: a fenced block on the next non-blank line is the block, else a label takes
286
+ * that line's text; false when neither applies, null for a fence with no close.
287
+ */
288
+ function nextLineValue(text, from, content, label, span) {
289
+ for (let at = from; at < text.length;) {
290
+ const { end, next } = lineAt(text, at);
291
+ const line = text.slice(at, end);
292
+ const { body } = content(line);
293
+ const value = body.trim();
294
+ if (value) {
295
+ const lead = line.slice(0, line.length - body.trimStart().length);
296
+ const fence = value.match(BLOCK_FENCE);
297
+ if (fence) return fenceSpan(text, next, fence[1], content, lead, span);
298
+ return label && !PLACEHOLDER.test(value) ? span(at + lead.length, end, BLOCK.replacement) : false;
299
+ }
300
+ if (end - from > BLOCK.maxChars || next === -1) return false;
301
+ at = next;
302
+ }
303
+ return false;
304
+ }
305
+
306
+ /** The lines from `from` up to the fence that closes `marker`, replaced by one line with the fence's lead. */
307
+ function fenceSpan(text, from, marker, content, lead, span) {
308
+ if (from === -1 || from >= text.length) return null;
309
+ const to = blockLines(text, from, (line) => {
310
+ const value = content(line).body.trim();
311
+ return value.length >= marker.length && value[0] === marker[0] && [...value].every((char) => char === marker[0]);
312
+ });
313
+ if (to === null) return null;
314
+ return to === from ? false : span(from, to, `${lead}${BLOCK.replacement}${lineEndBefore(text, to)}`);
315
+ }
316
+
317
+ /**
318
+ * The v1 gate, last on every string: whether v1's withhold pattern matches the text once every marker (with the quotes
319
+ * around it) is deleted. When it does, the item holding the text is withheld, as v1 would have withheld it.
320
+ */
321
+ export function v1GateFires(text) {
322
+ return V1_WITHHOLD.test(text.replace(GATE_MARKERS, ''));
323
+ }
324
+
325
+ /**
326
+ * The contract's block pass, before the table: a secret-style key whose value opens a block (a YAML block scalar, a
327
+ * quote that spans lines or runs long, a triple quote, a heredoc, a backslash continuation, a long unquoted run) has the
328
+ * block replaced to its end. A block with no end found stops the text at the value with the withheld marker, and
329
+ * `withheld` says the item holding it is to be withheld.
330
+ */
331
+ export function redactSecretBlocks(input) {
332
+ if (!LINE_KEY.test(input)) return { text: input, redactions: 0, withheld: false };
333
+ let text = '';
334
+ let at = 0;
335
+ let skip = 0;
336
+ let redactions = 0;
337
+ const literals = new Map();
338
+ const lineStart = lineStarts(input);
339
+ for (const match of input.matchAll(BLOCK_OPEN)) {
340
+ if (match.index < at || match.index < skip) continue;
341
+ const span = blockSpan(input, match, literals, lineStart);
342
+ if (span === null) {
343
+ const value = match.index + match[0].length - match.groups.value.length;
344
+ return { text: `${text}${input.slice(at, value)}${BLOCK.withheld}`, redactions: redactions + 1, withheld: true };
345
+ }
346
+ if (!span) continue;
347
+ if (span.skip !== undefined) {
348
+ skip = span.skip;
349
+ continue;
350
+ }
351
+ text += `${input.slice(at, span.from)}${span.replacement}`;
352
+ at = span.to;
353
+ redactions += 1;
354
+ }
355
+ return { text: `${text}${input.slice(at)}`, redactions, withheld: false };
356
+ }
357
+
358
+ /** `mirror/1-2` as { min: 1, max: 2 }; anything else is null. */
359
+ export function parseAccepts(value) {
360
+ const match = String(value ?? '').trim().match(/^mirror\/([1-9][0-9]{0,3})(?:-([1-9][0-9]{0,3}))?$/);
361
+ if (!match) return null;
362
+ const min = Number(match[1]);
363
+ const max = Number(match[2] ?? match[1]);
364
+ return min <= max ? { min, max } : null;
365
+ }
366
+
367
+ const REJECT_KEYS = new Set(MIRROR_CONTRACT.privacy.rejectKeys);
368
+ const SERVER_PATH = new RegExp(MIRROR_CONTRACT.privacy.rejectPathPattern);
369
+ const NESTED_STRING_MAX = 2000;
370
+ const NESTED_PROPERTIES_MAX = 16;
371
+ const ENVELOPE_SLACK = 512;
372
+ const INVALID = Symbol('invalid');
373
+ const FREE = Object.freeze({ type: ['string', 'number', 'boolean', 'null', 'object', 'array'] });
374
+
375
+ export function pathRejected(text) {
376
+ return SERVER_PATH.test(text);
377
+ }
378
+
379
+ /**
380
+ * Whether anything the secret table or a held credential matches is left, or a block with no end was withheld;
381
+ * redaction runs first, so for the table this is a backstop.
382
+ */
383
+ export function contentWithheld(value, secrets = []) {
384
+ if (typeof value === 'string') {
385
+ return value.includes(WITHHELD_MARKER) || SECRET_RULES.some((rule) => rule.probe.test(value)) || secrets.some((secret) => secret && value.includes(secret));
386
+ }
387
+ if (Array.isArray(value)) return value.some((entry) => contentWithheld(entry, secrets));
388
+ if (value && typeof value === 'object') {
389
+ return Object.entries(value).some(([key, entry]) => contentWithheld(key, secrets) || contentWithheld(entry, secrets));
390
+ }
391
+ return false;
392
+ }
393
+
394
+ function typesOf(schema) {
395
+ if (Array.isArray(schema.type)) return schema.type;
396
+ return schema.type ? [schema.type] : FREE.type;
397
+ }
398
+
399
+ function truncate(value, max) {
400
+ if (value.length <= max) return value;
401
+ const points = Array.from(value);
402
+ return points.length <= max ? value : points.slice(0, max).join('');
403
+ }
404
+
405
+ function inRange(schema, value) {
406
+ return (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
407
+ }
408
+
409
+ function project(schema, value, depth) {
410
+ if (value === undefined || depth > MIRROR_LIMITS.maxDepth) return INVALID;
411
+ const types = typesOf(schema);
412
+ if (value === null) return types.includes('null') ? null : INVALID;
413
+ if (typeof value === 'string') {
414
+ if (!types.includes('string')) return INVALID;
415
+ const text = truncate(value, schema.maxLength ?? NESTED_STRING_MAX);
416
+ if (schema.enum && !schema.enum.includes(text)) return INVALID;
417
+ if (schema.pattern && !new RegExp(schema.pattern).test(text)) return INVALID;
418
+ return text;
419
+ }
420
+ if (typeof value === 'number') {
421
+ if (!Number.isFinite(value)) return INVALID;
422
+ let number;
423
+ if (types.includes('number')) number = value;
424
+ else if (types.includes('integer')) number = Math.round(value);
425
+ else return INVALID;
426
+ if (!inRange(schema, number) || (schema.enum && !schema.enum.includes(number))) return INVALID;
427
+ return number;
428
+ }
429
+ if (typeof value === 'boolean') return types.includes('boolean') ? value : INVALID;
430
+ if (Array.isArray(value)) {
431
+ if (!types.includes('array')) return INVALID;
432
+ return value
433
+ .slice(0, schema.maxItems ?? 100)
434
+ .map((item) => project(schema.items || FREE, item, depth + 1))
435
+ .filter((item) => item !== INVALID);
436
+ }
437
+ if (typeof value === 'object') {
438
+ if (!types.includes('object')) return INVALID;
439
+ const out = {};
440
+ if (schema.properties) {
441
+ for (const [key, child] of Object.entries(schema.properties)) {
442
+ if (!Object.hasOwn(value, key)) continue;
443
+ const projected = project(child, value[key], depth + 1);
444
+ if (projected !== INVALID) out[key] = projected;
445
+ }
446
+ return (schema.required || []).every((key) => Object.hasOwn(out, key)) ? out : INVALID;
447
+ }
448
+ const limit = schema.maxProperties ?? NESTED_PROPERTIES_MAX;
449
+ for (const [key, entry] of Object.entries(value)) {
450
+ if (Object.keys(out).length >= limit) break;
451
+ if (REJECT_KEYS.has(key)) continue;
452
+ const projected = project(FREE, entry, depth + 1);
453
+ if (projected !== INVALID) out[key] = projected;
454
+ }
455
+ return out;
456
+ }
457
+ return INVALID;
458
+ }
459
+
460
+ function leaksPath(value) {
461
+ if (typeof value === 'string') return pathRejected(value);
462
+ if (Array.isArray(value)) return value.some(leaksPath);
463
+ if (value && typeof value === 'object') return Object.entries(value).some(([key, entry]) => pathRejected(key) || leaksPath(entry));
464
+ return false;
465
+ }
466
+
467
+ function bytes(value) {
468
+ return Buffer.byteLength(JSON.stringify(value), 'utf8') + ENVELOPE_SLACK;
469
+ }
470
+
471
+ function fit(kind, data, limit) {
472
+ if (bytes(data) <= limit) return data;
473
+ if ((kind === 'message' || kind === 'inbox') && typeof data.body === 'string') {
474
+ let body = data.body;
475
+ while (body.length > 0 && bytes({ ...data, body }) > limit) body = Array.from(body).slice(0, Math.floor(Array.from(body).length * 0.8)).join('');
476
+ return { ...data, body };
477
+ }
478
+ if (kind === 'rule_pack' && Array.isArray(data.rules)) {
479
+ const rules = [...data.rules];
480
+ while (rules.length > 0 && bytes({ ...data, rules }) > limit) rules.pop();
481
+ return { ...data, rules };
482
+ }
483
+ return null;
484
+ }
485
+
486
+ export function itemBytes(data) {
487
+ return bytes(data);
488
+ }
489
+
490
+ /**
491
+ * One row of a graph_window section, or one nested object such as its manager, checked on its own so a single bad row
492
+ * is dropped and counted instead of withholding the whole chunk. `depth` is where the value sits inside item data.
493
+ */
494
+ export function sanitizePart(schema, value, { secrets = [], depth = 3 } = {}) {
495
+ const projected = project(schema, value, depth);
496
+ if (projected === INVALID) return { error: 'invalid_item' };
497
+ if (leaksPath(projected)) return { error: 'path_not_redacted' };
498
+ if (contentWithheld(projected, secrets)) return { error: 'content_withheld' };
499
+ return { data: projected };
500
+ }
501
+
502
+ export function sanitizeItem(kind, data, { secrets = [] } = {}) {
503
+ const schema = MIRROR_CONTRACT.kinds[kind];
504
+ if (!schema) return { error: 'invalid_item' };
505
+ const projected = project({ type: 'object', properties: schema.properties, required: schema.required }, data, 1);
506
+ if (projected === INVALID) return { error: 'invalid_item' };
507
+ if (leaksPath(projected)) return { error: 'path_not_redacted' };
508
+ if (contentWithheld(projected, secrets)) return { error: 'content_withheld' };
509
+ const limit = kind === 'rule_pack' ? MIRROR_LIMITS.maxRulePackBytes : MIRROR_LIMITS.maxItemBytes;
510
+ const fitted = fit(kind, projected, limit);
511
+ if (!fitted) return { error: 'item_too_large' };
512
+ return { data: fitted };
513
+ }
@@ -0,0 +1,127 @@
1
+ import path from 'node:path';
2
+ import { HubError } from './errors.js';
3
+ import { discoverLocalHub, LocalHubReader } from './localHub.js';
4
+ import { MIRROR_SCOPE } from './mirrorContract.js';
5
+ import { MirrorService } from './mirrorService.js';
6
+ import { MirrorSource } from './mirrorSource.js';
7
+ import { MirrorSync } from './mirrorSync.js';
8
+ import { pairedStatus } from './pairedConfig.js';
9
+
10
+ export const MIRROR_STATE_FILE = 'mirror-state.json';
11
+
12
+ export function mirrorEnabled(config) {
13
+ return config?.mirror?.localHub === true;
14
+ }
15
+
16
+ export function localScopeCheckApplies(config) {
17
+ return !config?.paired?.target?.devOrg;
18
+ }
19
+
20
+ export class MirrorController {
21
+ constructor({
22
+ directory,
23
+ transportFactory,
24
+ tokenStore,
25
+ discover = discoverLocalHub,
26
+ createReader = (options) => new LocalHubReader(options),
27
+ serviceOptions = {},
28
+ log = () => {},
29
+ }) {
30
+ this.directory = directory;
31
+ this.transportFactory = transportFactory;
32
+ this.tokenStore = tokenStore;
33
+ this.discover = discover;
34
+ this.createReader = createReader;
35
+ this.serviceOptions = serviceOptions;
36
+ this.log = log;
37
+ this.stateFile = path.join(directory, MIRROR_STATE_FILE);
38
+ this.service = null;
39
+ this.key = null;
40
+ this.blockedCode = null;
41
+ this.state = { state: 'off' };
42
+ this.wanted = null;
43
+ this.applying = null;
44
+ }
45
+
46
+ /**
47
+ * Hands the mirror the heartbeat's config without waiting on it. The newest config is applied in a loop of its own,
48
+ * so discovery, a restart or a pass that is slow to stop never holds the heartbeat, its claims or its presence.
49
+ * Returns the state of the last config applied.
50
+ */
51
+ update(config) {
52
+ this.wanted = { config };
53
+ if (!this.applying) {
54
+ this.applying = (async () => {
55
+ while (this.wanted) {
56
+ const { config: next } = this.wanted;
57
+ this.wanted = null;
58
+ try {
59
+ this.state = await this.apply(next);
60
+ } catch (error) {
61
+ this.state = { state: 'blocked', code: error.code || 'HUB_MIRROR_FAILED' };
62
+ }
63
+ }
64
+ this.applying = null;
65
+ })();
66
+ }
67
+ return this.state;
68
+ }
69
+
70
+ /** The state once every config handed to update() so far is applied. */
71
+ async settled() {
72
+ while (this.applying) await this.applying;
73
+ return this.state;
74
+ }
75
+
76
+ async apply(config) {
77
+ try {
78
+ if (!mirrorEnabled(config) || !pairedStatus(config).ok) {
79
+ await this.#stopService();
80
+ this.blockedCode = null;
81
+ return { state: 'off' };
82
+ }
83
+ if (localScopeCheckApplies(config) && !(await this.tokenStore.scopes()).includes(MIRROR_SCOPE)) {
84
+ throw new HubError('HUB_MIRROR_SCOPE_MISSING', 'The Pulse sign-in lacks the mirror scope; run `agentpulse pulse login`');
85
+ }
86
+ const hub = await this.discover({ config });
87
+ const roots = (config.localRoots || []).map((root) => root.canonicalRoot).filter(Boolean).sort();
88
+ const key = JSON.stringify([config.paired.machineId, config.paired.policyVersion, config.paired.baseUrl, hub.dir, hub.port, roots]);
89
+ this.blockedCode = null;
90
+ if (this.service && this.key === key) {
91
+ return this.service.blocked ? { state: 'blocked', code: this.service.blocked.code } : { state: 'running' };
92
+ }
93
+ await this.#stopService();
94
+ const reader = this.createReader({ dir: hub.dir, port: hub.port });
95
+ const transport = await this.transportFactory(config);
96
+ const heldSecrets = () => transport.auth?.heldSecrets?.() ?? [];
97
+ const source = new MirrorSource({ reader, workspace: path.dirname(hub.dir), approvedRoots: roots, heldSecrets });
98
+ const sync = new MirrorSync({ source, transport, stateFile: this.stateFile });
99
+ this.service = new MirrorService({ sync, reader, log: this.log, ...this.serviceOptions });
100
+ this.key = key;
101
+ this.service.start();
102
+ return { state: 'running' };
103
+ } catch (error) {
104
+ await this.#stopService();
105
+ const code = error.code || 'HUB_MIRROR_FAILED';
106
+ if (code !== this.blockedCode) {
107
+ this.blockedCode = code;
108
+ this.log('mirror_blocked', { code });
109
+ await new MirrorSync({ stateFile: this.stateFile }).recordError(error).catch(() => {});
110
+ }
111
+ return { state: 'blocked', code };
112
+ }
113
+ }
114
+
115
+ async stop() {
116
+ this.wanted = null;
117
+ await this.settled();
118
+ await this.#stopService();
119
+ }
120
+
121
+ async #stopService() {
122
+ const service = this.service;
123
+ this.service = null;
124
+ this.key = null;
125
+ if (service) await service.stop();
126
+ }
127
+ }