tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21
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/bin/cli.js +1979 -783
- package/package.json +4 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
- package/packages/contracts/src/daemon-events.ts +12 -12
- package/packages/contracts/src/daemon-wire.ts +30 -0
- package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
- package/packages/daemon/dist/command-center/diagnostics.js +65 -0
- package/packages/daemon/dist/command-center/log-stream.js +9 -0
- package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/doctor.js +62 -0
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
- package/packages/daemon/dist/lib/daemon-embed.js +24 -0
- package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
- package/packages/daemon/dist/lib/headless-daemon.js +1 -0
- package/packages/daemon/dist/lib/log-sanitize.js +199 -0
- package/packages/daemon/dist/lib/log.js +123 -13
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +472 -0
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
- package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
- package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
- package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
- package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
- package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
- package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
- package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
- package/packages/daemon/dist/tui/mirror/theme.js +4 -31
- package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
- package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
- package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
- package/packages/daemon/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/doctor.ts +70 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
- package/packages/daemon/src/lib/daemon-embed.ts +30 -0
- package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
- package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
- package/packages/daemon/src/lib/headless-daemon.ts +1 -0
- package/packages/daemon/src/lib/log-sanitize.ts +248 -0
- package/packages/daemon/src/lib/log.ts +165 -14
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +785 -0
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
- package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
- package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
- package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
- package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
- package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
- package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
- package/packages/daemon/src/tui/mirror/theme.ts +4 -39
- package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
- package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
- package/packages/tmux-bridge/src/index.ts +2 -0
- package/packages/tmux-bridge/src/runner.test.ts +41 -0
- package/packages/tmux-bridge/src/runner.ts +41 -2
|
@@ -348,6 +348,7 @@ async function runHeadlessDaemonGeneration(
|
|
|
348
348
|
for (let startAttempt = 0; startAttempt < 2 && !handle; startAttempt += 1) {
|
|
349
349
|
try {
|
|
350
350
|
handle = await deps.startEmbeddedDaemon({
|
|
351
|
+
launcher: "headless",
|
|
351
352
|
...(options.supervisionId
|
|
352
353
|
? { supervisionId: options.supervisionId, predecessor: lifecycle.predecessor }
|
|
353
354
|
: {}),
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE — credential redaction, byte budgets and reserved-metadata protection
|
|
3
|
+
* for the shared structured logger (`log.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Every record the daemon emits passes through `sanitizeLogPayload` before it
|
|
6
|
+
* reaches the ring buffer, subscribers or the wire. The rules are deliberately
|
|
7
|
+
* conservative: a value that looks like a credential is replaced, oversized
|
|
8
|
+
* payloads are truncated with an explicit marker, and caller data can never
|
|
9
|
+
* overwrite the identity fields (`ts`, `level`, `component`, `msg`, `pid`,
|
|
10
|
+
* `instanceId`, `version`) that incident triage relies on.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const REDACTED = "[redacted]";
|
|
14
|
+
|
|
15
|
+
/** Wire keys owned by the logger; caller payloads with these keys are renamed. */
|
|
16
|
+
export const RESERVED_LOG_KEYS = Object.freeze([
|
|
17
|
+
"ts",
|
|
18
|
+
"level",
|
|
19
|
+
"component",
|
|
20
|
+
"msg",
|
|
21
|
+
"pid",
|
|
22
|
+
"instanceId",
|
|
23
|
+
"version",
|
|
24
|
+
] as const);
|
|
25
|
+
const RESERVED = new Set<string>(RESERVED_LOG_KEYS);
|
|
26
|
+
|
|
27
|
+
/** Prefix applied to caller keys that collide with reserved metadata. */
|
|
28
|
+
export const RESERVED_COLLISION_PREFIX = "payload_";
|
|
29
|
+
|
|
30
|
+
export interface LogBudget {
|
|
31
|
+
/** Serialized-bytes ceiling for one record's `data` payload. */
|
|
32
|
+
readonly maxDataBytes: number;
|
|
33
|
+
/** Longest string value retained inside a payload (bytes). */
|
|
34
|
+
readonly maxStringBytes: number;
|
|
35
|
+
/** Longest `msg` retained (bytes). */
|
|
36
|
+
readonly maxMessageBytes: number;
|
|
37
|
+
/** Deepest nesting retained; deeper values become a marker. */
|
|
38
|
+
readonly maxDepth: number;
|
|
39
|
+
/** Most array elements / object keys retained per container. */
|
|
40
|
+
readonly maxContainerSize: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_LOG_BUDGET: LogBudget = Object.freeze({
|
|
44
|
+
maxDataBytes: 8 * 1024,
|
|
45
|
+
maxStringBytes: 2 * 1024,
|
|
46
|
+
maxMessageBytes: 4 * 1024,
|
|
47
|
+
maxDepth: 6,
|
|
48
|
+
maxContainerSize: 64,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Total serialized bytes the in-memory ring may retain across all entries. */
|
|
52
|
+
export const DEFAULT_LOG_RING_BYTES = 512 * 1024;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Keys whose values are credentials regardless of shape. Matched on the key
|
|
56
|
+
* with separators removed and case folded so `auth_token`, `AuthToken`,
|
|
57
|
+
* `X-API-Key` and `authorization` all hit.
|
|
58
|
+
*/
|
|
59
|
+
const SECRET_KEY =
|
|
60
|
+
/(token|secret|password|passwd|authorization|cookie|apikey|credential|capability|privatekey|bearer|ticket|lease)/u;
|
|
61
|
+
|
|
62
|
+
export function isSecretKey(key: string): boolean {
|
|
63
|
+
return SECRET_KEY.test(key.toLowerCase().replace(/[^a-z0-9]/gu, ""));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BEARER = /\bBearer\s+[^\s"',;]+/giu;
|
|
67
|
+
const BASIC = /\bBasic\s+[A-Za-z0-9+/=]{8,}/gu;
|
|
68
|
+
const URL_QUERY_SECRET =
|
|
69
|
+
/([?&](?:[A-Za-z0-9_-]*(?:token|ticket|lease|capability|password|secret|auth|key|signature)[A-Za-z0-9_-]*)=)[^&#\s"']*/giu;
|
|
70
|
+
const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s@"']+)@/giu;
|
|
71
|
+
// Values already handled by the header/URL passes (Bearer/Basic schemes, an
|
|
72
|
+
// earlier redaction marker) are skipped so a second pass cannot eat the
|
|
73
|
+
// surrounding text.
|
|
74
|
+
const KEY_VALUE_SECRET =
|
|
75
|
+
/\b((?:auth[-_]?token|access[-_]?token|refresh[-_]?token|authorization|capability|password|passwd|secret|token|ticket|lease|api[-_]?key)\s*[:=]\s*["']?)(?!Bearer\b|Basic\b|\[redacted\])[^\s,;"'}]+/giu;
|
|
76
|
+
|
|
77
|
+
/** Redact credential-shaped substrings inside free text (URLs, headers, k=v). */
|
|
78
|
+
export function redactText(text: string, secrets: ReadonlySet<string> = EMPTY): string {
|
|
79
|
+
let out = text;
|
|
80
|
+
for (const secret of secrets)
|
|
81
|
+
if (secret && out.includes(secret)) out = out.split(secret).join(REDACTED);
|
|
82
|
+
return out
|
|
83
|
+
.replace(BEARER, `Bearer ${REDACTED}`)
|
|
84
|
+
.replace(BASIC, `Basic ${REDACTED}`)
|
|
85
|
+
.replace(URL_USERINFO, `$1${REDACTED}@`)
|
|
86
|
+
.replace(URL_QUERY_SECRET, `$1${REDACTED}`)
|
|
87
|
+
.replace(KEY_VALUE_SECRET, `$1${REDACTED}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const EMPTY: ReadonlySet<string> = new Set();
|
|
91
|
+
|
|
92
|
+
function byteLength(text: string): number {
|
|
93
|
+
return Buffer.byteLength(text, "utf8");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function truncateString(text: string, maxBytes: number): string {
|
|
97
|
+
if (byteLength(text) <= maxBytes) return text;
|
|
98
|
+
// Trim on code points so a multi-byte character is never split.
|
|
99
|
+
let kept = "";
|
|
100
|
+
let used = 0;
|
|
101
|
+
for (const char of text) {
|
|
102
|
+
const size = byteLength(char);
|
|
103
|
+
if (used + size > maxBytes) break;
|
|
104
|
+
kept += char;
|
|
105
|
+
used += size;
|
|
106
|
+
}
|
|
107
|
+
return `${kept}…[truncated ${byteLength(text) - used} bytes]`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function describeError(error: Error): Record<string, unknown> {
|
|
111
|
+
const out: Record<string, unknown> = { name: error.name, message: error.message };
|
|
112
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
113
|
+
if (code !== undefined) out.code = code;
|
|
114
|
+
if (error.stack) out.stack = error.stack;
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface RedactOptions {
|
|
119
|
+
readonly budget: LogBudget;
|
|
120
|
+
readonly secrets: ReadonlySet<string>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function redactValue(
|
|
124
|
+
value: unknown,
|
|
125
|
+
depth: number,
|
|
126
|
+
options: RedactOptions,
|
|
127
|
+
seen: WeakSet<object>,
|
|
128
|
+
): unknown {
|
|
129
|
+
const { budget, secrets } = options;
|
|
130
|
+
if (value === null || value === undefined) return value;
|
|
131
|
+
if (typeof value === "string")
|
|
132
|
+
return truncateString(redactText(value, secrets), budget.maxStringBytes);
|
|
133
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
134
|
+
if (typeof value === "bigint") return value.toString();
|
|
135
|
+
if (typeof value === "symbol" || typeof value === "function") return `[${typeof value}]`;
|
|
136
|
+
if (value instanceof Date) return value.toISOString();
|
|
137
|
+
if (value instanceof Error) return redactValue(describeError(value), depth, options, seen);
|
|
138
|
+
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value))
|
|
139
|
+
return `[binary ${value.byteLength} bytes]`;
|
|
140
|
+
if (typeof value !== "object") return String(value);
|
|
141
|
+
if (seen.has(value)) return "[circular]";
|
|
142
|
+
if (depth >= budget.maxDepth) return "[depth exceeded]";
|
|
143
|
+
seen.add(value);
|
|
144
|
+
try {
|
|
145
|
+
if (Array.isArray(value)) {
|
|
146
|
+
const items = value
|
|
147
|
+
.slice(0, budget.maxContainerSize)
|
|
148
|
+
.map((item) => redactValue(item, depth + 1, options, seen));
|
|
149
|
+
if (value.length > budget.maxContainerSize)
|
|
150
|
+
items.push(`[+${value.length - budget.maxContainerSize} items]`);
|
|
151
|
+
return items;
|
|
152
|
+
}
|
|
153
|
+
if (value instanceof Map) return redactValue(Object.fromEntries(value), depth, options, seen);
|
|
154
|
+
if (value instanceof Set) return redactValue([...value], depth, options, seen);
|
|
155
|
+
const out: Record<string, unknown> = {};
|
|
156
|
+
const keys = Object.keys(value);
|
|
157
|
+
for (const key of keys.slice(0, budget.maxContainerSize)) {
|
|
158
|
+
const child = (value as Record<string, unknown>)[key];
|
|
159
|
+
out[key] =
|
|
160
|
+
isSecretKey(key) && child != null ? REDACTED : redactValue(child, depth + 1, options, seen);
|
|
161
|
+
}
|
|
162
|
+
if (keys.length > budget.maxContainerSize)
|
|
163
|
+
out["[+keys]"] = keys.length - budget.maxContainerSize;
|
|
164
|
+
return out;
|
|
165
|
+
} finally {
|
|
166
|
+
seen.delete(value);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Redact one arbitrary value (strings, nested objects, errors) under the default budget. */
|
|
171
|
+
export function redactLogValue(
|
|
172
|
+
value: unknown,
|
|
173
|
+
secrets: ReadonlySet<string> = EMPTY,
|
|
174
|
+
budget: LogBudget = DEFAULT_LOG_BUDGET,
|
|
175
|
+
): unknown {
|
|
176
|
+
return redactValue(value, 0, { budget, secrets }, new WeakSet());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface SanitizedLogPayload {
|
|
180
|
+
/** Redacted, budgeted payload; never contains reserved keys. */
|
|
181
|
+
readonly data: Record<string, unknown> | undefined;
|
|
182
|
+
/** Serialized bytes of `data` (0 when absent). */
|
|
183
|
+
readonly bytes: number;
|
|
184
|
+
/** True when the payload had to be summarized to fit `maxDataBytes`. */
|
|
185
|
+
readonly truncated: boolean;
|
|
186
|
+
/** Caller keys that collided with reserved metadata and were renamed. */
|
|
187
|
+
readonly renamedKeys: readonly string[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Redact, protect reserved keys and bound a caller payload. Deterministic:
|
|
192
|
+
* the same input always yields the same output, so tests can assert exact
|
|
193
|
+
* shapes.
|
|
194
|
+
*/
|
|
195
|
+
export function sanitizeLogPayload(
|
|
196
|
+
data: Record<string, unknown> | undefined,
|
|
197
|
+
options: { secrets?: ReadonlySet<string>; budget?: LogBudget } = {},
|
|
198
|
+
): SanitizedLogPayload {
|
|
199
|
+
if (!data) return { data: undefined, bytes: 0, truncated: false, renamedKeys: [] };
|
|
200
|
+
const budget = options.budget ?? DEFAULT_LOG_BUDGET;
|
|
201
|
+
const secrets = options.secrets ?? EMPTY;
|
|
202
|
+
const redacted = redactValue(data, 0, { budget, secrets }, new WeakSet()) as Record<
|
|
203
|
+
string,
|
|
204
|
+
unknown
|
|
205
|
+
>;
|
|
206
|
+
const renamedKeys: string[] = [];
|
|
207
|
+
const protectedData: Record<string, unknown> = {};
|
|
208
|
+
for (const [key, value] of Object.entries(redacted)) {
|
|
209
|
+
if (RESERVED.has(key)) {
|
|
210
|
+
renamedKeys.push(key);
|
|
211
|
+
protectedData[`${RESERVED_COLLISION_PREFIX}${key}`] = value;
|
|
212
|
+
} else {
|
|
213
|
+
protectedData[key] = value;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
let serialized = safeStringify(protectedData);
|
|
217
|
+
let bytes = byteLength(serialized);
|
|
218
|
+
if (bytes <= budget.maxDataBytes) {
|
|
219
|
+
return { data: protectedData, bytes, truncated: false, renamedKeys };
|
|
220
|
+
}
|
|
221
|
+
// Over budget even after per-string limits: keep the key list so the record
|
|
222
|
+
// stays diagnosable, and say how much was dropped.
|
|
223
|
+
const summary: Record<string, unknown> = {
|
|
224
|
+
truncated: true,
|
|
225
|
+
droppedBytes: bytes,
|
|
226
|
+
keys: Object.keys(protectedData).slice(0, budget.maxContainerSize),
|
|
227
|
+
};
|
|
228
|
+
serialized = safeStringify(summary);
|
|
229
|
+
bytes = byteLength(serialized);
|
|
230
|
+
return { data: summary, bytes, truncated: true, renamedKeys };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Bound a log message: redact and truncate to the message budget. */
|
|
234
|
+
export function sanitizeLogMessage(
|
|
235
|
+
message: string,
|
|
236
|
+
secrets: ReadonlySet<string> = EMPTY,
|
|
237
|
+
budget: LogBudget = DEFAULT_LOG_BUDGET,
|
|
238
|
+
): string {
|
|
239
|
+
return truncateString(redactText(String(message), secrets), budget.maxMessageBytes);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function safeStringify(value: unknown): string {
|
|
243
|
+
try {
|
|
244
|
+
return JSON.stringify(value) ?? "null";
|
|
245
|
+
} catch {
|
|
246
|
+
return '"[unserializable]"';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structured logger for tmux-ide daemon processes.
|
|
3
|
+
*
|
|
4
|
+
* Every record is sanitized centrally (`log-sanitize.ts`): credential-shaped
|
|
5
|
+
* keys and substrings are redacted, payloads are bounded per record and for
|
|
6
|
+
* the in-memory ring, and the identity metadata a triage relies on (`ts`,
|
|
7
|
+
* `level`, `component`, `msg`, `pid`, `instanceId`, `version`) can never be
|
|
8
|
+
* overwritten by caller data. Stream write failures (closed pipe, full disk)
|
|
9
|
+
* degrade to ring-only logging with one explicit warning; they never crash
|
|
10
|
+
* the process.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_LOG_BUDGET,
|
|
15
|
+
DEFAULT_LOG_RING_BYTES,
|
|
16
|
+
sanitizeLogMessage,
|
|
17
|
+
sanitizeLogPayload,
|
|
18
|
+
type LogBudget,
|
|
19
|
+
} from "./log-sanitize.ts";
|
|
2
20
|
|
|
3
21
|
type Level = "debug" | "info" | "warn" | "error";
|
|
4
22
|
|
|
@@ -10,13 +28,63 @@ export function setLogLevel(level: Level): void {
|
|
|
10
28
|
minLevel = level;
|
|
11
29
|
}
|
|
12
30
|
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Identity + secrets
|
|
33
|
+
//
|
|
34
|
+
// The daemon stamps its instance identity once at startup so every record it
|
|
35
|
+
// emits (ring, subscribers and wire) carries `pid`, `instanceId` and
|
|
36
|
+
// `version`. Registered secret literals are redacted wherever they appear.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export interface LogIdentity {
|
|
40
|
+
readonly instanceId?: string;
|
|
41
|
+
readonly version?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let identity: LogIdentity = {};
|
|
45
|
+
const secrets = new Set<string>();
|
|
46
|
+
|
|
47
|
+
/** Stamp reserved identity metadata onto every subsequent record. */
|
|
48
|
+
export function setLogIdentity(next: LogIdentity | null): void {
|
|
49
|
+
identity = next ? { ...next } : {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getLogIdentity(): LogIdentity {
|
|
53
|
+
return { ...identity };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Register a credential literal to redact wherever it appears in a record. */
|
|
57
|
+
export function registerLogSecret(secret: string | null | undefined): void {
|
|
58
|
+
if (typeof secret === "string" && secret.length >= 8) secrets.add(secret);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @internal test seam */
|
|
62
|
+
export function _resetLogStateForTests(): void {
|
|
63
|
+
identity = {};
|
|
64
|
+
secrets.clear();
|
|
65
|
+
logBuffer.length = 0;
|
|
66
|
+
ringBytes = 0;
|
|
67
|
+
streamState.stdout = { failed: false, listening: false };
|
|
68
|
+
streamState.stderr = { failed: false, listening: false };
|
|
69
|
+
budget = DEFAULT_LOG_BUDGET;
|
|
70
|
+
ringBudgetBytes = DEFAULT_LOG_RING_BYTES;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @internal test seam — shrink budgets to make bounds observable. */
|
|
74
|
+
export function _setLogBudgetForTests(next: Partial<LogBudget> & { ringBytes?: number }): void {
|
|
75
|
+
const { ringBytes, ...rest } = next;
|
|
76
|
+
budget = { ...DEFAULT_LOG_BUDGET, ...rest };
|
|
77
|
+
if (ringBytes !== undefined) ringBudgetBytes = ringBytes;
|
|
78
|
+
}
|
|
79
|
+
|
|
13
80
|
// ---------------------------------------------------------------------------
|
|
14
81
|
// Ring buffer + subscriber bus
|
|
15
82
|
//
|
|
16
83
|
// Every structured log entry is also captured in an in-memory ring (last
|
|
17
|
-
// `LOG_BUFFER_SIZE` entries
|
|
18
|
-
//
|
|
19
|
-
//
|
|
84
|
+
// `LOG_BUFFER_SIZE` entries, at most `ringBudgetBytes` serialized bytes) and
|
|
85
|
+
// broadcast to any subscriber. Used by the command-center's
|
|
86
|
+
// `/api/logs/:channel` SSE endpoint so the dashboard BottomPanel Output tab
|
|
87
|
+
// can stream daemon logs without scraping stderr.
|
|
20
88
|
// ---------------------------------------------------------------------------
|
|
21
89
|
|
|
22
90
|
export interface LogEntry {
|
|
@@ -24,11 +92,18 @@ export interface LogEntry {
|
|
|
24
92
|
level: Level;
|
|
25
93
|
component: string;
|
|
26
94
|
msg: string;
|
|
95
|
+
pid?: number;
|
|
96
|
+
instanceId?: string;
|
|
97
|
+
version?: string;
|
|
27
98
|
data?: Record<string, unknown>;
|
|
28
99
|
}
|
|
29
100
|
|
|
30
101
|
const LOG_BUFFER_SIZE = 1_000;
|
|
31
102
|
const logBuffer: LogEntry[] = [];
|
|
103
|
+
const entryBytes = new WeakMap<LogEntry, number>();
|
|
104
|
+
let ringBytes = 0;
|
|
105
|
+
let budget: LogBudget = DEFAULT_LOG_BUDGET;
|
|
106
|
+
let ringBudgetBytes = DEFAULT_LOG_RING_BYTES;
|
|
32
107
|
type Subscriber = (entry: LogEntry) => void;
|
|
33
108
|
const subscribers = new Set<Subscriber>();
|
|
34
109
|
|
|
@@ -37,6 +112,11 @@ export function getLogBuffer(): ReadonlyArray<LogEntry> {
|
|
|
37
112
|
return logBuffer.slice();
|
|
38
113
|
}
|
|
39
114
|
|
|
115
|
+
/** Ring occupancy for diagnostics: entry count and retained serialized bytes. */
|
|
116
|
+
export function getLogBufferStats(): { entries: number; bytes: number; budgetBytes: number } {
|
|
117
|
+
return { entries: logBuffer.length, bytes: ringBytes, budgetBytes: ringBudgetBytes };
|
|
118
|
+
}
|
|
119
|
+
|
|
40
120
|
/**
|
|
41
121
|
* Subscribe to live log entries. Returns an unsubscribe function. The
|
|
42
122
|
* handler runs synchronously inside the writer — keep it cheap; throw
|
|
@@ -50,6 +130,58 @@ export function subscribeLogs(handler: Subscriber): () => void {
|
|
|
50
130
|
};
|
|
51
131
|
}
|
|
52
132
|
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Stream degradation
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
type StreamName = "stdout" | "stderr";
|
|
138
|
+
interface StreamState {
|
|
139
|
+
failed: boolean;
|
|
140
|
+
listening: boolean;
|
|
141
|
+
}
|
|
142
|
+
const streamState: Record<StreamName, StreamState> = {
|
|
143
|
+
stdout: { failed: false, listening: false },
|
|
144
|
+
stderr: { failed: false, listening: false },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Which process streams the logger has given up on (diagnostics only). */
|
|
148
|
+
export function getLogStreamFailures(): readonly StreamName[] {
|
|
149
|
+
return (Object.keys(streamState) as StreamName[]).filter((name) => streamState[name].failed);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function markStreamFailed(name: StreamName, error: unknown): void {
|
|
153
|
+
if (streamState[name].failed) return;
|
|
154
|
+
streamState[name].failed = true;
|
|
155
|
+
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
156
|
+
const warning = `[log.ts] ${name} write failed (${detail}); further records are retained in memory only\n`;
|
|
157
|
+
const other: StreamName = name === "stdout" ? "stderr" : "stdout";
|
|
158
|
+
if (!streamState[other].failed) {
|
|
159
|
+
try {
|
|
160
|
+
process[other].write(warning);
|
|
161
|
+
} catch {
|
|
162
|
+
streamState[other].failed = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function writeToStream(name: StreamName, line: string): void {
|
|
168
|
+
const state = streamState[name];
|
|
169
|
+
if (state.failed) return;
|
|
170
|
+
const stream = process[name];
|
|
171
|
+
if (!state.listening) {
|
|
172
|
+
state.listening = true;
|
|
173
|
+
// A closed pipe (EPIPE) or full disk (ENOSPC) surfaces asynchronously as
|
|
174
|
+
// an 'error' event; without a listener Node raises it as an uncaught
|
|
175
|
+
// exception and the daemon dies mid-triage.
|
|
176
|
+
stream.on("error", (error: unknown) => markStreamFailed(name, error));
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
stream.write(line);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
markStreamFailed(name, error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
53
185
|
function writeStructuredLog(
|
|
54
186
|
level: Level,
|
|
55
187
|
component: string,
|
|
@@ -57,38 +189,57 @@ function writeStructuredLog(
|
|
|
57
189
|
data?: Record<string, unknown>,
|
|
58
190
|
): void {
|
|
59
191
|
if (LEVEL_RANK[level] < LEVEL_RANK[minLevel]) return;
|
|
192
|
+
const payload = sanitizeLogPayload(data, { secrets, budget });
|
|
60
193
|
const entry: LogEntry = {
|
|
61
194
|
ts: new Date().toISOString(),
|
|
62
195
|
level,
|
|
63
|
-
component,
|
|
64
|
-
msg: message,
|
|
65
|
-
|
|
196
|
+
component: sanitizeLogMessage(String(component), secrets, budget).slice(0, 128),
|
|
197
|
+
msg: sanitizeLogMessage(message, secrets, budget),
|
|
198
|
+
pid: process.pid,
|
|
199
|
+
...(identity.instanceId ? { instanceId: identity.instanceId } : {}),
|
|
200
|
+
...(identity.version ? { version: identity.version } : {}),
|
|
201
|
+
...(payload.data ? { data: payload.data } : {}),
|
|
66
202
|
};
|
|
67
|
-
|
|
203
|
+
const size = payload.bytes + Buffer.byteLength(entry.msg, "utf8") + 96;
|
|
204
|
+
entryBytes.set(entry, size);
|
|
205
|
+
// Push to ring + drop oldest while over either budget.
|
|
68
206
|
logBuffer.push(entry);
|
|
69
|
-
|
|
207
|
+
ringBytes += size;
|
|
208
|
+
while (
|
|
209
|
+
logBuffer.length > 0 &&
|
|
210
|
+
(logBuffer.length > LOG_BUFFER_SIZE || ringBytes > ringBudgetBytes)
|
|
211
|
+
) {
|
|
212
|
+
const evicted = logBuffer.shift()!;
|
|
213
|
+
ringBytes -= entryBytes.get(evicted) ?? 0;
|
|
214
|
+
if (logBuffer.length === 0) ringBytes = 0;
|
|
215
|
+
}
|
|
70
216
|
// Fan out to subscribers; isolate failures.
|
|
71
217
|
for (const sub of subscribers) {
|
|
72
218
|
try {
|
|
73
219
|
sub(entry);
|
|
74
220
|
} catch (err) {
|
|
75
221
|
// Avoid recursion via the logger; write directly to stderr.
|
|
76
|
-
|
|
222
|
+
writeToStream(
|
|
223
|
+
"stderr",
|
|
77
224
|
`[log.ts] subscriber threw: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
78
225
|
);
|
|
79
226
|
}
|
|
80
227
|
}
|
|
81
228
|
// Stream form for humans tailing the process. The data field is
|
|
82
|
-
// serialized inline (matches the previous wire shape)
|
|
229
|
+
// serialized inline (matches the previous wire shape); reserved metadata
|
|
230
|
+
// is written last so a caller payload can never shadow it.
|
|
83
231
|
const wire: Record<string, unknown> = {
|
|
232
|
+
...(entry.data ?? {}),
|
|
84
233
|
ts: entry.ts,
|
|
85
234
|
level: entry.level,
|
|
86
235
|
component: entry.component,
|
|
87
236
|
msg: entry.msg,
|
|
237
|
+
pid: entry.pid,
|
|
238
|
+
...(entry.instanceId ? { instanceId: entry.instanceId } : {}),
|
|
239
|
+
...(entry.version ? { version: entry.version } : {}),
|
|
88
240
|
};
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
out.write(JSON.stringify(wire) + "\n");
|
|
241
|
+
if (payload.renamedKeys.length > 0) wire.reservedKeysRenamed = payload.renamedKeys;
|
|
242
|
+
writeToStream(level === "error" ? "stderr" : "stdout", JSON.stringify(wire) + "\n");
|
|
92
243
|
}
|
|
93
244
|
|
|
94
245
|
export const logger = {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** Pure, allowlisted evidence projection. Never retain response bodies or errors. */
|
|
2
|
+
export const DIAGNOSTICS_MAX_BYTES = 16_384;
|
|
3
|
+
export const RESOURCE_KEYS = [
|
|
4
|
+
"Timeout",
|
|
5
|
+
"Immediate",
|
|
6
|
+
"TCPServerWrap",
|
|
7
|
+
"TCPSocketWrap",
|
|
8
|
+
"PipeWrap",
|
|
9
|
+
"ProcessWrap",
|
|
10
|
+
"FSEventWrap",
|
|
11
|
+
"other",
|
|
12
|
+
] as const;
|
|
13
|
+
export const MEMORY_KEYS = ["rss", "heapTotal", "heapUsed", "external", "arrayBuffers"] as const;
|
|
14
|
+
export interface DiagnosticsIdentity {
|
|
15
|
+
protocolVersion: number;
|
|
16
|
+
productVersion: string;
|
|
17
|
+
instanceId: string;
|
|
18
|
+
startedAt: string;
|
|
19
|
+
environmentId?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface DiagnosticsSample {
|
|
22
|
+
daemon: DiagnosticsIdentity;
|
|
23
|
+
pid: number;
|
|
24
|
+
uptimeMs: number;
|
|
25
|
+
sampledAtMs: number;
|
|
26
|
+
memory: Record<(typeof MEMORY_KEYS)[number], number>;
|
|
27
|
+
cpu: { user: number; system: number };
|
|
28
|
+
eventLoop: { idle: number; active: number; utilization: number };
|
|
29
|
+
activeResources: Record<(typeof RESOURCE_KEYS)[number], number> | null;
|
|
30
|
+
}
|
|
31
|
+
export type DiagnosticsResult =
|
|
32
|
+
| { status: "ok"; sample: DiagnosticsSample }
|
|
33
|
+
| {
|
|
34
|
+
status:
|
|
35
|
+
| "missing"
|
|
36
|
+
| "unsupported-endpoint"
|
|
37
|
+
| "http-error"
|
|
38
|
+
| "transport-error"
|
|
39
|
+
| "malformed"
|
|
40
|
+
| "identity-mismatch";
|
|
41
|
+
sample: null;
|
|
42
|
+
};
|
|
43
|
+
const object = (v: unknown): v is Record<string, unknown> =>
|
|
44
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
45
|
+
const number = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
46
|
+
const text = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 256;
|
|
47
|
+
function numeric<K extends string>(
|
|
48
|
+
v: unknown,
|
|
49
|
+
keys: readonly K[],
|
|
50
|
+
integer = false,
|
|
51
|
+
): Record<K, number> | null {
|
|
52
|
+
if (!object(v) || !keys.every((k) => number(v[k]) && (!integer || Number.isSafeInteger(v[k]))))
|
|
53
|
+
return null;
|
|
54
|
+
return Object.fromEntries(keys.map((k) => [k, v[k]])) as Record<K, number>;
|
|
55
|
+
}
|
|
56
|
+
export function parseSoakDiagnostics(
|
|
57
|
+
body: string | null,
|
|
58
|
+
status: number,
|
|
59
|
+
expected: DiagnosticsIdentity & { pid: number },
|
|
60
|
+
): DiagnosticsResult {
|
|
61
|
+
const fail = (status: Exclude<DiagnosticsResult["status"], "ok">): DiagnosticsResult => ({
|
|
62
|
+
status,
|
|
63
|
+
sample: null,
|
|
64
|
+
});
|
|
65
|
+
if (status === 404) return fail("unsupported-endpoint");
|
|
66
|
+
if (status !== 200) return fail("http-error");
|
|
67
|
+
if (body === null || body === "") return fail("missing");
|
|
68
|
+
if (new TextEncoder().encode(body).length > DIAGNOSTICS_MAX_BYTES) return fail("malformed");
|
|
69
|
+
let raw: unknown;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(body);
|
|
72
|
+
} catch {
|
|
73
|
+
return fail("malformed");
|
|
74
|
+
}
|
|
75
|
+
if (!object(raw) || !object(raw.daemon)) return fail("malformed");
|
|
76
|
+
const d = raw.daemon;
|
|
77
|
+
if (
|
|
78
|
+
!Number.isSafeInteger(d.protocolVersion) ||
|
|
79
|
+
!number(d.protocolVersion) ||
|
|
80
|
+
!text(d.productVersion) ||
|
|
81
|
+
!text(d.instanceId) ||
|
|
82
|
+
!text(d.startedAt) ||
|
|
83
|
+
!Number.isFinite(Date.parse(d.startedAt)) ||
|
|
84
|
+
(d.environmentId !== undefined && !text(d.environmentId)) ||
|
|
85
|
+
!Number.isSafeInteger(raw.pid) ||
|
|
86
|
+
!number(raw.pid) ||
|
|
87
|
+
raw.pid === 0
|
|
88
|
+
)
|
|
89
|
+
return fail("malformed");
|
|
90
|
+
if (
|
|
91
|
+
["protocolVersion", "productVersion", "instanceId", "startedAt", "environmentId"].some(
|
|
92
|
+
(k) => d[k] !== expected[k as keyof DiagnosticsIdentity],
|
|
93
|
+
) ||
|
|
94
|
+
raw.pid !== expected.pid
|
|
95
|
+
)
|
|
96
|
+
return fail("identity-mismatch");
|
|
97
|
+
const memory = numeric(raw.memory, MEMORY_KEYS, true);
|
|
98
|
+
const cpu = numeric(raw.cpu, ["user", "system"], true);
|
|
99
|
+
const eventLoop = numeric(raw.eventLoop, ["idle", "active", "utilization"]);
|
|
100
|
+
const activeResources =
|
|
101
|
+
raw.activeResources === null ? null : numeric(raw.activeResources, RESOURCE_KEYS, true);
|
|
102
|
+
if (
|
|
103
|
+
!memory ||
|
|
104
|
+
!cpu ||
|
|
105
|
+
!eventLoop ||
|
|
106
|
+
eventLoop.utilization > 1 ||
|
|
107
|
+
!number(raw.uptimeMs) ||
|
|
108
|
+
!number(raw.sampledAtMs) ||
|
|
109
|
+
(raw.activeResources !== null && !activeResources)
|
|
110
|
+
)
|
|
111
|
+
return fail("malformed");
|
|
112
|
+
return {
|
|
113
|
+
status: "ok",
|
|
114
|
+
sample: {
|
|
115
|
+
daemon: {
|
|
116
|
+
protocolVersion: d.protocolVersion,
|
|
117
|
+
productVersion: d.productVersion,
|
|
118
|
+
instanceId: d.instanceId,
|
|
119
|
+
startedAt: d.startedAt,
|
|
120
|
+
...(d.environmentId !== undefined ? { environmentId: d.environmentId } : {}),
|
|
121
|
+
},
|
|
122
|
+
pid: raw.pid,
|
|
123
|
+
uptimeMs: raw.uptimeMs,
|
|
124
|
+
sampledAtMs: raw.sampledAtMs,
|
|
125
|
+
memory,
|
|
126
|
+
cpu,
|
|
127
|
+
eventLoop,
|
|
128
|
+
activeResources,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export type DiagnosticsDelta =
|
|
133
|
+
| {
|
|
134
|
+
status: "ok";
|
|
135
|
+
intervalMs: number;
|
|
136
|
+
cpuUserMicros: number;
|
|
137
|
+
cpuSystemMicros: number;
|
|
138
|
+
cpuPercent: number;
|
|
139
|
+
eventLoopIdleMs: number;
|
|
140
|
+
eventLoopActiveMs: number;
|
|
141
|
+
eventLoopUtilization: number | null;
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
status: "missing-baseline" | "identity-mismatch" | "counter-regression" | "invalid-interval";
|
|
145
|
+
};
|
|
146
|
+
/** Uptime is monotonic; wall-clock jumps do not change interval rates. */
|
|
147
|
+
export function diagnosticsDelta(
|
|
148
|
+
previous: DiagnosticsSample | null,
|
|
149
|
+
current: DiagnosticsSample,
|
|
150
|
+
): DiagnosticsDelta {
|
|
151
|
+
if (!previous) return { status: "missing-baseline" };
|
|
152
|
+
if (
|
|
153
|
+
previous.pid !== current.pid ||
|
|
154
|
+
["protocolVersion", "productVersion", "instanceId", "startedAt", "environmentId"].some(
|
|
155
|
+
(key) =>
|
|
156
|
+
previous.daemon[key as keyof DiagnosticsIdentity] !==
|
|
157
|
+
current.daemon[key as keyof DiagnosticsIdentity],
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return { status: "identity-mismatch" };
|
|
161
|
+
const intervalMs = current.uptimeMs - previous.uptimeMs;
|
|
162
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return { status: "invalid-interval" };
|
|
163
|
+
const cpuUserMicros = current.cpu.user - previous.cpu.user;
|
|
164
|
+
const cpuSystemMicros = current.cpu.system - previous.cpu.system;
|
|
165
|
+
const eventLoopIdleMs = current.eventLoop.idle - previous.eventLoop.idle;
|
|
166
|
+
const eventLoopActiveMs = current.eventLoop.active - previous.eventLoop.active;
|
|
167
|
+
if (![cpuUserMicros, cpuSystemMicros, eventLoopIdleMs, eventLoopActiveMs].every(number))
|
|
168
|
+
return { status: "counter-regression" };
|
|
169
|
+
const cpuPercent = (cpuUserMicros + cpuSystemMicros) / (intervalMs * 10);
|
|
170
|
+
if (!Number.isFinite(cpuPercent)) return { status: "invalid-interval" };
|
|
171
|
+
const eventTotal = eventLoopIdleMs + eventLoopActiveMs;
|
|
172
|
+
if (!Number.isFinite(eventTotal)) return { status: "invalid-interval" };
|
|
173
|
+
return {
|
|
174
|
+
status: "ok",
|
|
175
|
+
intervalMs,
|
|
176
|
+
cpuUserMicros,
|
|
177
|
+
cpuSystemMicros,
|
|
178
|
+
cpuPercent,
|
|
179
|
+
eventLoopIdleMs,
|
|
180
|
+
eventLoopActiveMs,
|
|
181
|
+
eventLoopUtilization: eventTotal === 0 ? null : eventLoopActiveMs / eventTotal,
|
|
182
|
+
};
|
|
183
|
+
}
|