@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.
- package/dist/commands/router.d.ts +15 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +549 -213
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +556 -220
- package/dist/index.mjs.map +1 -1
- package/dist/logging/log-redactor.d.ts +24 -0
- package/dist/logging/log-tail-reader.d.ts +46 -0
- package/dist/repo-mesh-types.d.ts +6 -0
- package/package.json +2 -2
- package/src/commands/router.ts +184 -3
- package/src/index.ts +3 -0
- package/src/logging/log-redactor.ts +100 -0
- package/src/logging/log-tail-reader.ts +220 -0
- package/src/mesh/coordinator-prompt.ts +1 -0
- package/src/repo-mesh-types.ts +6 -0
|
@@ -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.
|
|
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.
|
|
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",
|
package/src/commands/router.ts
CHANGED
|
@@ -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)
|
|
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 =
|
|
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(
|
|
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);
|