@adhdev/daemon-core 0.9.82-rc.311 → 0.9.82-rc.312

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.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Log redactor — mask secrets before a raw daemon log line leaves the machine.
3
+ *
4
+ * Daemon logs can incidentally contain credentials: ADHDev API keys (adk_*),
5
+ * machine secrets (adm_*), provider keys (adp_*), bearer tokens, JWTs, TURN
6
+ * `username:credential` pairs, and `SECRET=...` style env dumps. The mesh
7
+ * `get_mesh_node_logs` command ships a log tail over P2P to the coordinator, so
8
+ * every line MUST pass through redactLogLine() first — otherwise a secret in a
9
+ * remote daemon's log is exfiltrated to whoever is driving the coordinator.
10
+ *
11
+ * Patterns are intentionally conservative: each masks the secret material while
12
+ * preserving enough surrounding shape that the line stays useful for debugging
13
+ * (e.g. `adk_••••1234`, `Bearer ••••redacted`). When in doubt, mask.
14
+ */
15
+ /**
16
+ * Mask secrets in a single log line. Idempotent-ish: re-running over an
17
+ * already-masked line leaves the MASK token in place (it contains no secret
18
+ * shape). Never throws — a redaction failure must not crash the log path.
19
+ */
20
+ export declare function redactLogLine(line: string): string;
21
+ /** Redact an array of log lines in place-safe fashion (returns a new array). */
22
+ export declare function redactLogLines(lines: string[]): string[];
23
+ /** Exposed for tests/introspection: the rule names applied, in order. */
24
+ export declare const LOG_REDACTION_RULE_NAMES: readonly string[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Daemon log tail reader — read the last N bytes of a daemon log file, newest
3
+ * bytes first, bounded so the result is safe to ship over a mesh P2P channel.
4
+ *
5
+ * Used by the mesh `get_mesh_node_logs` command: the coordinator asks a (possibly
6
+ * remote) daemon for its recent log tail instead of having to open a session and
7
+ * grep the file by hand. Because the mesh RPC envelope is sent as a single
8
+ * datachannel message (~256KB SCTP ceiling, no chunking), the returned tail is
9
+ * HARD-bounded by `tailBytes` (default 64KB, capped at MAX_TAIL_BYTES=128KB) and
10
+ * flags `truncated:true` when the file was larger.
11
+ *
12
+ * Boundary-safe: lines are cut on the newline byte (0x0A) only, which never
13
+ * appears inside a multibyte UTF-8 sequence, so decoding each complete byte
14
+ * segment never splits a multibyte char.
15
+ */
16
+ export declare const DEFAULT_TAIL_BYTES: number;
17
+ export declare const MAX_TAIL_BYTES: number;
18
+ export interface ReadDaemonLogTailArgs {
19
+ /** Date of the log file to read (defaults to today). YYYY-MM-DD string or Date. */
20
+ date?: string | Date;
21
+ /** Max bytes of tail to return. Clamped to (0, MAX_TAIL_BYTES]. Default 64KB. */
22
+ tailBytes?: number;
23
+ /** Optional regex source string; only lines matching (case-insensitive) are kept. */
24
+ grep?: string;
25
+ /** Optional epoch-ms floor; only lines whose leading [HH:MM:SS...] / ISO ts >= this are kept. */
26
+ sinceMs?: number;
27
+ }
28
+ export interface DaemonLogTailResult {
29
+ success: boolean;
30
+ error?: string;
31
+ lines: string[];
32
+ truncated: boolean;
33
+ logPath: string;
34
+ platform: NodeJS.Platform;
35
+ bytesReturned: number;
36
+ /** True when a grep/since filter dropped lines from the raw tail window. */
37
+ filtered: boolean;
38
+ /** The grep source actually applied (echoed back for clarity). */
39
+ grep?: string;
40
+ }
41
+ /**
42
+ * Read the daemon log tail for `date` (default today), bounded to `tailBytes`,
43
+ * with optional grep (regex source) and sinceMs filters. Falls back to the
44
+ * size-rotation backup (`*.1.log`) when the primary file does not exist.
45
+ */
46
+ export declare function readDaemonLogTail(args?: ReadDaemonLogTailArgs): DaemonLogTailResult;
@@ -500,6 +500,12 @@ export interface RepoMeshPeerConnectionStatus {
500
500
  transport: RepoMeshPeerConnectionTransport;
501
501
  reported: boolean;
502
502
  reason?: string;
503
+ /**
504
+ * Round-trip time in ms for the selected candidate pair, as sampled by the
505
+ * coordinator daemon when connected. Optional — older daemons and not_reported
506
+ * fallbacks omit it; the dashboard must treat it as best-effort telemetry.
507
+ */
508
+ rttMs?: number;
503
509
  lastStateChangeAt?: string;
504
510
  lastConnectedAt?: string;
505
511
  lastCommandAt?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.311",
3
+ "version": "0.9.82-rc.312",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.311",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.312",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -40,6 +40,8 @@ import { logCommand } from '../logging/command-log.js';
40
40
  import type { CommandLogEntry } from '../logging/command-log.js';
41
41
  import * as yaml from 'js-yaml';
42
42
  import { getRecentLogs, LOG_PATH } from '../logging/logger.js';
43
+ import { readDaemonLogTail, MAX_TAIL_BYTES } from '../logging/log-tail-reader.js';
44
+ import { redactLogLines } from '../logging/log-redactor.js';
43
45
  import { createInteractionId, getRecentDebugTrace, recordDebugTrace } from '../logging/debug-trace.js';
44
46
  import { getSessionHostSurfaceKind, partitionSessionHostRecords } from '../session-host/runtime-surface.js';
45
47
  import { createHermesManualMeshCoordinatorSetup, resolveMeshCoordinatorSetup } from './mesh-coordinator.js';
@@ -513,6 +515,21 @@ function readInlineMeshNodeId(node: any): string {
513
515
  return normalizeMeshNodeId(node) ?? '';
514
516
  }
515
517
 
518
+ // A local worktree node whose workspace directory has been deleted from disk.
519
+ // The worktree was removed (or the machine pruned it) but the node still lingers
520
+ // in the inline mesh cache. Such a node has no live truth to confirm and must
521
+ // never be probed or counted toward direct-peer-truth — doing so blocks the
522
+ // graph with a permanent `direct_peer_truth_unavailable`. Deliberately narrow:
523
+ // it only fires for `isLocalWorktree === true` nodes with a recorded workspace
524
+ // that does not exist. Remote nodes and nodes whose workspace is present on disk
525
+ // are never matched, so a slow remote peer is still classified unavailable.
526
+ function isDeadLocalWorktreeNode(node: any): boolean {
527
+ if (node?.isLocalWorktree !== true) return false;
528
+ const workspace = readStringValue(node?.workspace);
529
+ if (!workspace) return false;
530
+ return !fs.existsSync(workspace);
531
+ }
532
+
516
533
  // Boundary normalization: reconcile a node's identity so `id` and `nodeId` both
517
534
  // carry the same canonical value (any incoming form — id / nodeId / node_id — is
518
535
  // absorbed by normalizeMeshNodeId, and the SQLite `node_id` leak is dropped).
@@ -1152,6 +1169,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1152
1169
  peerConfirmedCount: number;
1153
1170
  standingEvidenceCount: number;
1154
1171
  unavailableNodeIds: string[];
1172
+ deadNodeIds: string[];
1155
1173
  }> {
1156
1174
  const nodes = Array.isArray(args.mesh?.nodes) ? args.mesh.nodes : [];
1157
1175
  if (!nodes.length) {
@@ -1162,6 +1180,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1162
1180
  peerConfirmedCount: 0,
1163
1181
  standingEvidenceCount: 0,
1164
1182
  unavailableNodeIds: [],
1183
+ deadNodeIds: [],
1165
1184
  };
1166
1185
  }
1167
1186
 
@@ -1176,6 +1195,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1176
1195
  let peerConfirmedCount = 0;
1177
1196
  let standingEvidenceCount = 0;
1178
1197
  const unavailableNodeIds: string[] = [];
1198
+ const deadNodeIds: string[] = [];
1179
1199
 
1180
1200
  for (const [nodeIndex, node] of nodes.entries()) {
1181
1201
  const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
@@ -1187,6 +1207,22 @@ async function hydrateInlineMeshDirectTruth(args: {
1187
1207
  daemonId && (daemonId === args.localMachineId || daemonId === args.statusInstanceId),
1188
1208
  ) || Boolean(args.meshSource !== 'local_config' && nodeIndex === 0);
1189
1209
 
1210
+ // A dead local worktree owned by this coordinator (isLocalWorktree, the
1211
+ // node's daemon is us, workspace path gone) has no live truth and cannot
1212
+ // be probed — the directory it would self-probe no longer exists. Exclude
1213
+ // it entirely from direct-peer-truth accounting: do not probe it, do not
1214
+ // attempt it, do not push it to unavailableNodeIds (which would otherwise
1215
+ // wedge the graph in a permanent direct_peer_truth_unavailable). This is
1216
+ // strictly self + isLocalWorktree + absent-path; remote peers and nodes
1217
+ // whose workspace still exists are unaffected and stay classifiable.
1218
+ const isSelfDaemonNode = Boolean(
1219
+ daemonId && (daemonId === args.localMachineId || daemonId === args.statusInstanceId),
1220
+ );
1221
+ if ((isSelfNode || isSelfDaemonNode) && isDeadLocalWorktreeNode(node)) {
1222
+ deadNodeIds.push(nodeId);
1223
+ continue;
1224
+ }
1225
+
1190
1226
  if (!workspace) {
1191
1227
  if (!isSelfNode && daemonId) unavailableNodeIds.push(nodeId);
1192
1228
  continue;
@@ -1269,6 +1305,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1269
1305
  peerConfirmedCount,
1270
1306
  standingEvidenceCount,
1271
1307
  unavailableNodeIds,
1308
+ deadNodeIds,
1272
1309
  };
1273
1310
  }
1274
1311
 
@@ -3233,6 +3270,15 @@ export class DaemonCommandRouter {
3233
3270
  * Allows the MCP server to query mesh data via get_mesh even when
3234
3271
  * the mesh doesn't exist in the local meshes.json file. */
3235
3272
  private inlineMeshCache = new Map<string, any>();
3273
+ /** Tombstones for inline mesh nodes removed via remove_mesh_node, keyed by
3274
+ * meshId → set of removed nodeIds. The dashboard keeps echoing the removed
3275
+ * node in the inlineMesh it attaches to every command; without a tombstone,
3276
+ * reconcileInlineMeshCache MERGEs it straight back (resurrection). A
3277
+ * tombstoned node is skipped during reconcile only while its workspace is
3278
+ * absent from disk — a genuine re-registration (same nodeId, workspace back
3279
+ * on disk) clears the tombstone and merges normally, preserving clone
3280
+ * worktree visibility and legitimate node re-creation. */
3281
+ private removedInlineMeshNodeIds = new Map<string, Set<string>>();
3236
3282
  /** Coordinator-owned whole-mesh aggregate status snapshots. Browser callers read this by default. */
3237
3283
  private aggregateMeshStatusCache = new Map<string, { builtAt: number; snapshot: any; queueRevision: string }>();
3238
3284
  /** Shared per-peer git_status probe dedup + recently-probed reuse gate.
@@ -3270,10 +3316,33 @@ export class DaemonCommandRouter {
3270
3316
  const unavailableNodeIds = new Set<string>();
3271
3317
  const sourceOfTruth = readObjectRecord(snapshot.sourceOfTruth);
3272
3318
  const directPeerTruth = readObjectRecord(sourceOfTruth.directPeerTruth);
3319
+ // Dead local worktree nodes (isLocalWorktree, workspace deleted from disk)
3320
+ // carry no live truth and must never gate the aggregate as unavailable.
3321
+ // A cached snapshot built before the worktree was removed can still list
3322
+ // such a node in unavailableNodeIds, which would wedge the graph in a
3323
+ // permanent direct_peer_truth_unavailable; drop them here so the held
3324
+ // standing-state truth for the surviving nodes satisfies the aggregate.
3325
+ const deadNodeIds = new Set<string>();
3326
+ for (const node of mesh.nodes) {
3327
+ if (!isDeadLocalWorktreeNode(node)) continue;
3328
+ const deadId = readInlineMeshNodeId(node);
3329
+ if (deadId) deadNodeIds.add(deadId);
3330
+ }
3331
+ let droppedDeadUnavailable = false;
3273
3332
  for (const entry of Array.isArray(directPeerTruth.unavailableNodeIds) ? directPeerTruth.unavailableNodeIds : []) {
3274
3333
  const nodeId = readStringValue(entry);
3275
- if (nodeId) unavailableNodeIds.add(nodeId);
3334
+ if (!nodeId) continue;
3335
+ if (deadNodeIds.has(nodeId)) {
3336
+ droppedDeadUnavailable = true;
3337
+ continue;
3338
+ }
3339
+ unavailableNodeIds.add(nodeId);
3276
3340
  }
3341
+ // Force a rewrite when a dead worktree was filtered out of a previously
3342
+ // built unavailable set, even if no live git was re-hydrated this pass —
3343
+ // otherwise the early-return below would hand back the stale snapshot that
3344
+ // still says direct_peer_truth_unavailable.
3345
+ if (droppedDeadUnavailable) changed = true;
3277
3346
 
3278
3347
  const nodes = snapshot.nodes.map((statusNode: any) => {
3279
3348
  const nodeId = normalizeMeshNodeId(statusNode);
@@ -3407,7 +3476,10 @@ export class DaemonCommandRouter {
3407
3476
  // Save-boundary node-id normalization: reconcile each node's identity so
3408
3477
  // `id` and `nodeId` agree before it enters the cache, so reconcile keys
3409
3478
  // and the round-trip through the status serializer stay form-stable.
3410
- const sanitizedInlineMesh = sanitizeInlineMesh(normalizeInlineMeshNodeIdentity(inlineMesh as any));
3479
+ const sanitizedInlineMesh = this.applyInlineMeshNodeTombstones(
3480
+ meshId,
3481
+ sanitizeInlineMesh(normalizeInlineMeshNodeIdentity(inlineMesh as any)),
3482
+ );
3411
3483
  const cached = this.inlineMeshCache.get(meshId);
3412
3484
  if (cached) {
3413
3485
  const merged = reconcileInlineMeshCache(cached, sanitizedInlineMesh);
@@ -3428,7 +3500,10 @@ export class DaemonCommandRouter {
3428
3500
  const cached = this.getCachedInlineMesh(meshId);
3429
3501
  if (cached) {
3430
3502
  if (inlineMeshCarriesTransientNodeTruth(inlineMesh)) {
3431
- const merged = reconcileInlineMeshCache(cached, inlineMesh as any);
3503
+ const merged = reconcileInlineMeshCache(
3504
+ cached,
3505
+ this.applyInlineMeshNodeTombstones(meshId, inlineMesh as any),
3506
+ );
3432
3507
  this.inlineMeshCache.set(meshId, sanitizeInlineMesh(normalizeInlineMeshNodeIdentity(merged)));
3433
3508
  return { mesh: merged, inline: true, source: 'inline_cache' };
3434
3509
  }
@@ -3488,13 +3563,56 @@ export class DaemonCommandRouter {
3488
3563
  if (!mesh || !Array.isArray(mesh.nodes)) return false;
3489
3564
  const idx = mesh.nodes.findIndex((entry: any) => meshNodeIdMatches(entry, nodeId));
3490
3565
  if (idx === -1) return false;
3566
+ const canonicalNodeId = readInlineMeshNodeId(mesh.nodes[idx]) || nodeId;
3491
3567
  mesh.nodes.splice(idx, 1);
3492
3568
  mesh.updatedAt = new Date().toISOString();
3493
3569
  this.inlineMeshCache.set(meshId, mesh);
3570
+ // Tombstone the removed node so the dashboard's stale inlineMesh echo does
3571
+ // not MERGE it back on the next command (see removedInlineMeshNodeIds).
3572
+ this.tombstoneRemovedInlineMeshNode(meshId, canonicalNodeId);
3573
+ if (canonicalNodeId !== nodeId) this.tombstoneRemovedInlineMeshNode(meshId, nodeId);
3494
3574
  this.invalidateAggregateMeshStatus(meshId);
3495
3575
  return true;
3496
3576
  }
3497
3577
 
3578
+ private tombstoneRemovedInlineMeshNode(meshId: string, nodeId: string): void {
3579
+ if (!nodeId) return;
3580
+ let set = this.removedInlineMeshNodeIds.get(meshId);
3581
+ if (!set) {
3582
+ set = new Set<string>();
3583
+ this.removedInlineMeshNodeIds.set(meshId, set);
3584
+ }
3585
+ set.add(nodeId);
3586
+ }
3587
+
3588
+ /** Filter an incoming inline mesh against this mesh's tombstones before it is
3589
+ * reconciled into the cache. A tombstoned node is dropped only while its
3590
+ * workspace is still absent from disk; if the workspace is back (genuine
3591
+ * re-registration), the tombstone is cleared and the node merges normally. */
3592
+ private applyInlineMeshNodeTombstones(meshId: string, incoming: any): any {
3593
+ const tombstones = this.removedInlineMeshNodeIds.get(meshId);
3594
+ if (!tombstones?.size || !incoming || typeof incoming !== 'object' || !Array.isArray(incoming.nodes)) {
3595
+ return incoming;
3596
+ }
3597
+ let dropped = false;
3598
+ const nodes = incoming.nodes.filter((node: any) => {
3599
+ const nodeId = readInlineMeshNodeId(node);
3600
+ if (!nodeId || !tombstones.has(nodeId)) return true;
3601
+ const workspace = readStringValue(node?.workspace);
3602
+ // Genuine re-registration: same nodeId, workspace back on disk →
3603
+ // clear the tombstone and let the node merge normally.
3604
+ if (workspace && fs.existsSync(workspace)) {
3605
+ tombstones.delete(nodeId);
3606
+ return true;
3607
+ }
3608
+ dropped = true;
3609
+ return false;
3610
+ });
3611
+ if (tombstones.size === 0) this.removedInlineMeshNodeIds.delete(meshId);
3612
+ if (!dropped) return incoming;
3613
+ return { ...incoming, nodes };
3614
+ }
3615
+
3498
3616
  private normalizeMeshSessionCleanupMode(value: unknown): RepoMeshSessionCleanupMode {
3499
3617
  return value === 'stop'
3500
3618
  || value === 'delete_stopped'
@@ -7370,6 +7488,68 @@ export class DaemonCommandRouter {
7370
7488
  return result as CommandRouterResult;
7371
7489
  }
7372
7490
 
7491
+ case 'get_mesh_node_logs': {
7492
+ // Coordinator-driven remote log fetch: read a (possibly remote)
7493
+ // daemon's recent log tail over P2P instead of opening a session
7494
+ // and grepping the file by hand. Mirrors fast_forward_mesh_node's
7495
+ // forward pattern — resolve the node, forward to its owning daemon
7496
+ // when remote, otherwise read locally. The reply tail is HARD
7497
+ // byte-bounded and secret-redacted before it leaves the machine.
7498
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
7499
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
7500
+ let nodeDaemonId: string | undefined;
7501
+ if (meshId && nodeId) {
7502
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
7503
+ const node = meshRecord?.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
7504
+ nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
7505
+ }
7506
+ // _meshDirectDispatch prevents re-forwarding (and P2P self-dial)
7507
+ // once the call lands on the owning daemon — that daemon then reads
7508
+ // its own logs even if the stored daemonId uses a legacy form.
7509
+ const selfDaemonId = this.deps.statusInstanceId;
7510
+ const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
7511
+ if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
7512
+ const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId!, 'get_mesh_node_logs', {
7513
+ ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
7514
+ _meshDirectDispatch: true,
7515
+ });
7516
+ return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
7517
+ }
7518
+
7519
+ // Local read on the owning daemon.
7520
+ const rawTailBytes = Number(args?.tailBytes);
7521
+ const tail = readDaemonLogTail({
7522
+ date: typeof args?.date === 'string' ? args.date : undefined,
7523
+ tailBytes: Number.isFinite(rawTailBytes) ? Math.min(rawTailBytes, MAX_TAIL_BYTES) : undefined,
7524
+ grep: typeof args?.grep === 'string' ? args.grep : undefined,
7525
+ sinceMs: Number.isFinite(Number(args?.sinceMs)) ? Number(args?.sinceMs) : undefined,
7526
+ });
7527
+ if (!tail.success) {
7528
+ return {
7529
+ success: false,
7530
+ error: tail.error || 'failed to read daemon log tail',
7531
+ nodeId,
7532
+ logPath: tail.logPath,
7533
+ platform: tail.platform,
7534
+ } as CommandRouterResult;
7535
+ }
7536
+ // SECURITY: redact secrets from every line before returning over P2P.
7537
+ const redactedLines = redactLogLines(tail.lines);
7538
+ return {
7539
+ success: true,
7540
+ nodeId,
7541
+ daemonId: selfDaemonId,
7542
+ logPath: tail.logPath,
7543
+ platform: tail.platform,
7544
+ lines: redactedLines,
7545
+ lineCount: redactedLines.length,
7546
+ truncated: tail.truncated,
7547
+ filtered: tail.filtered,
7548
+ bytesReturned: tail.bytesReturned,
7549
+ ...(tail.grep ? { grep: tail.grep } : {}),
7550
+ } as CommandRouterResult;
7551
+ }
7552
+
7373
7553
  case 'refine_mesh_node': {
7374
7554
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
7375
7555
  const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
@@ -8642,6 +8822,7 @@ export class DaemonCommandRouter {
8642
8822
  peerConfirmedCount: 0,
8643
8823
  standingEvidenceCount: 0,
8644
8824
  unavailableNodeIds: [] as string[],
8825
+ deadNodeIds: [] as string[],
8645
8826
  };
8646
8827
  // Default/cached loads may not attempt a remote peer probe yet; do not surface that as
8647
8828
  // a direct mesh truth failure until an explicit probe attempt actually fails.
package/src/index.ts CHANGED
@@ -122,6 +122,9 @@ export type {
122
122
  LocalMeshNodeEntry,
123
123
  RepoMeshStatus,
124
124
  RepoMeshNodeStatus,
125
+ RepoMeshPeerConnectionStatus,
126
+ RepoMeshPeerConnectionState,
127
+ RepoMeshPeerConnectionTransport,
125
128
  RepoMeshSessionStatus,
126
129
  RepoMeshQueueTask,
127
130
  RepoMeshQueueTaskStatus,
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Log redactor — mask secrets before a raw daemon log line leaves the machine.
3
+ *
4
+ * Daemon logs can incidentally contain credentials: ADHDev API keys (adk_*),
5
+ * machine secrets (adm_*), provider keys (adp_*), bearer tokens, JWTs, TURN
6
+ * `username:credential` pairs, and `SECRET=...` style env dumps. The mesh
7
+ * `get_mesh_node_logs` command ships a log tail over P2P to the coordinator, so
8
+ * every line MUST pass through redactLogLine() first — otherwise a secret in a
9
+ * remote daemon's log is exfiltrated to whoever is driving the coordinator.
10
+ *
11
+ * Patterns are intentionally conservative: each masks the secret material while
12
+ * preserving enough surrounding shape that the line stays useful for debugging
13
+ * (e.g. `adk_••••1234`, `Bearer ••••redacted`). When in doubt, mask.
14
+ */
15
+
16
+ const MASK = '••••redacted';
17
+
18
+ /** Keep the last 4 chars of a token so logs stay correlatable without leaking it. */
19
+ function maskKeepTail(token: string): string {
20
+ if (token.length <= 8) return MASK;
21
+ return `${MASK}${token.slice(-4)}`;
22
+ }
23
+
24
+ interface RedactionRule {
25
+ readonly name: string;
26
+ readonly pattern: RegExp;
27
+ readonly replace: (match: string, ...groups: string[]) => string;
28
+ }
29
+
30
+ // NOTE: order matters — more specific rules (key=value, Bearer, TURN) run before
31
+ // the bare-token rules so the structured forms aren't half-masked by a greedy
32
+ // generic rule.
33
+ const RULES: RedactionRule[] = [
34
+ // `JWT_SECRET=...`, `TOKEN=...`, `API_KEY=...`, `password: ...` env/config dumps.
35
+ // Captures the key + delimiter and masks only the value.
36
+ {
37
+ name: 'key_value_secret',
38
+ pattern: /\b([A-Z0-9_]*(?:SECRET|TOKEN|API[_-]?KEY|PASSWORD|PASSWD|PRIVATE[_-]?KEY|CREDENTIAL|CLIENT[_-]?SECRET)[A-Z0-9_]*)(\s*[:=]\s*)(["']?)([^\s"',;]+)\3/gi,
39
+ replace: (_m, key: string, delim: string, quote: string) => `${key}${delim}${quote}${MASK}${quote}`,
40
+ },
41
+ // Authorization: Bearer <token>
42
+ {
43
+ name: 'bearer_token',
44
+ pattern: /\b(Bearer\s+)([A-Za-z0-9._\-+/=]{8,})/g,
45
+ replace: (_m, prefix: string, token: string) => `${prefix}${maskKeepTail(token)}`,
46
+ },
47
+ // ADHDev credential prefixes: API key (adk_), machine secret (adm_), provider key (adp_).
48
+ {
49
+ name: 'adhdev_prefixed_secret',
50
+ pattern: /\b(ad[kmp]_)([A-Za-z0-9]{6,})/g,
51
+ replace: (_m, prefix: string, token: string) => `${prefix}${maskKeepTail(prefix + token)}`,
52
+ },
53
+ // JWT: three base64url segments separated by dots, header starts with eyJ.
54
+ {
55
+ name: 'jwt',
56
+ pattern: /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g,
57
+ replace: () => MASK,
58
+ },
59
+ // TURN credential: a long credential value following a `credential` key in
60
+ // any common shape — `credential: x`, `credential=x`, or `credential "x"`.
61
+ // Mask the credential value only, preserving the key + delimiter/quote.
62
+ {
63
+ name: 'turn_credential',
64
+ pattern: /\b(credential["']?\s*(?:[:=]\s*)?["']?)([^\s"',;]{6,})/gi,
65
+ replace: (_m, prefix: string) => `${prefix}${MASK}`,
66
+ },
67
+ // TURN REST username:credential of the form `<expiry-ts>:<base64hmac>`,
68
+ // where the hmac part is long base64. Mask the hmac.
69
+ {
70
+ name: 'turn_rest_pair',
71
+ pattern: /\b(\d{10,}:)([A-Za-z0-9+/]{20,}={0,2})\b/g,
72
+ replace: (_m, prefix: string) => `${prefix}${MASK}`,
73
+ },
74
+ ];
75
+
76
+ /**
77
+ * Mask secrets in a single log line. Idempotent-ish: re-running over an
78
+ * already-masked line leaves the MASK token in place (it contains no secret
79
+ * shape). Never throws — a redaction failure must not crash the log path.
80
+ */
81
+ export function redactLogLine(line: string): string {
82
+ if (!line) return line;
83
+ let out = line;
84
+ for (const rule of RULES) {
85
+ try {
86
+ out = out.replace(rule.pattern, rule.replace as (substring: string, ...args: any[]) => string);
87
+ } catch {
88
+ // A pathological line must never break log shipping — skip this rule.
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** Redact an array of log lines in place-safe fashion (returns a new array). */
95
+ export function redactLogLines(lines: string[]): string[] {
96
+ return lines.map((line) => redactLogLine(line));
97
+ }
98
+
99
+ /** Exposed for tests/introspection: the rule names applied, in order. */
100
+ export const LOG_REDACTION_RULE_NAMES: readonly string[] = RULES.map((r) => r.name);