@runuai/host 0.9.14 → 0.9.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +33 -2
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3060 -218
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1405 -107
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +766 -42
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -0,0 +1,962 @@
1
+ /**
2
+ * Bounded remote reader for the host service's own logs (ADR-100).
3
+ *
4
+ * The manager is deliberately connection-generation scoped. `stop(opId)` is
5
+ * the wire-visible operator action and emits a `stopped` terminal frame;
6
+ * `stopAll()` is generation teardown and synchronously detaches timers and
7
+ * process listeners, signals children, and initiates file-handle closes
8
+ * without calling emitters that belong to the dead generation.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { constants } from "node:fs";
13
+ import { lstat, open } from "node:fs/promises";
14
+ import { StringDecoder } from "node:string_decoder";
15
+
16
+ import {
17
+ isHostOpId,
18
+ MAX_HOST_LOG_CHUNK_BYTES,
19
+ MAX_HOST_LOG_CHUNK_LINES,
20
+ MAX_HOST_LOG_LINE_BYTES,
21
+ MAX_HOST_LOG_LINES,
22
+ MAX_REMOTE_HOST_MESSAGE_BYTES,
23
+ type HostLogsChunkFrame,
24
+ type HostLogsEndFrame,
25
+ } from "../src/protocol";
26
+ import { serviceLogPath } from "../src/paths";
27
+ import { scrubHostString } from "./obs";
28
+
29
+ export const DEFAULT_HOST_LOG_LINES = 200;
30
+ export const MAX_HOST_LOG_STREAMS = 4;
31
+ export const MAX_HOST_LOG_PENDING_LINES = 1_000;
32
+ export const MAX_HOST_LOG_PENDING_BYTES = 256 * 1_024;
33
+ export const DEFAULT_HOST_LOG_POLL_MS = 250;
34
+
35
+ const FILE_READ_BLOCK_BYTES = 64 * 1_024;
36
+ const MAX_FILE_BYTES_PER_POLL = 1024 * 1024;
37
+ // Enough for 1,000 maximum-size lines plus headroom for CRLF. This keeps a
38
+ // malicious unterminated log from turning an initial tail into an unbounded
39
+ // read. If the boundary lands inside a line, that fragment is discarded.
40
+ const MAX_INITIAL_TAIL_SCAN_BYTES = 8 * 1024 * 1024;
41
+ const TRUNCATION_MARKER = "…[truncated]";
42
+ const EMIT_RETRY_MS = 25;
43
+
44
+ export type HostLogsFrame = HostLogsChunkFrame | HostLogsEndFrame;
45
+ export type HostLogsEmit = (frame: HostLogsFrame) => boolean | void;
46
+
47
+ export class HostLogStartError extends Error {
48
+ readonly code: "invalid_op_id" | "duplicate_op" | "too_many_streams";
49
+
50
+ constructor(
51
+ code: HostLogStartError["code"],
52
+ message: string,
53
+ ) {
54
+ super(message);
55
+ this.name = "HostLogStartError";
56
+ this.code = code;
57
+ }
58
+ }
59
+
60
+ export interface HostLogFileStat {
61
+ size: number;
62
+ dev: number | bigint;
63
+ ino: number | bigint;
64
+ isFile(): boolean;
65
+ }
66
+
67
+ export interface HostLogFileHandle {
68
+ stat(): Promise<HostLogFileStat>;
69
+ read(
70
+ buffer: Buffer,
71
+ offset: number,
72
+ length: number,
73
+ position: number,
74
+ ): Promise<{ bytesRead: number }>;
75
+ close(): Promise<void>;
76
+ }
77
+
78
+ export interface HostLogFs {
79
+ open(path: string): Promise<HostLogFileHandle>;
80
+ stat(path: string): Promise<HostLogFileStat>;
81
+ }
82
+
83
+ export interface HostLogReadable {
84
+ setEncoding?(encoding: BufferEncoding): unknown;
85
+ on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
86
+ off(event: "data", listener: (chunk: Buffer | string) => void): unknown;
87
+ destroy?(): unknown;
88
+ }
89
+
90
+ export interface HostLogChild {
91
+ stdout: HostLogReadable;
92
+ stderr: HostLogReadable;
93
+ on(event: "error", listener: (error: Error) => void): unknown;
94
+ on(event: "close", listener: (code: number | null) => void): unknown;
95
+ off(event: "error", listener: (error: Error) => void): unknown;
96
+ off(event: "close", listener: (code: number | null) => void): unknown;
97
+ kill(signal?: NodeJS.Signals): boolean;
98
+ }
99
+
100
+ export interface HostLogSpawnOptions {
101
+ stdio: ["ignore", "pipe", "pipe"];
102
+ shell: false;
103
+ }
104
+
105
+ export type HostLogSpawn = (
106
+ command: string,
107
+ args: readonly string[],
108
+ options: HostLogSpawnOptions,
109
+ ) => HostLogChild;
110
+
111
+ export interface HostLogTimers {
112
+ setTimeout(callback: () => void, delayMs: number): unknown;
113
+ clearTimeout(timer: unknown): void;
114
+ }
115
+
116
+ export interface HostLogManagerOptions {
117
+ platform?: NodeJS.Platform;
118
+ logPath?: string;
119
+ fs?: HostLogFs;
120
+ spawn?: HostLogSpawn;
121
+ timers?: HostLogTimers;
122
+ pollMs?: number;
123
+ }
124
+
125
+ interface PendingLine {
126
+ value: string;
127
+ bytes: number;
128
+ }
129
+
130
+ interface BaseStream {
131
+ readonly opId: string;
132
+ readonly emit: HostLogsEmit;
133
+ readonly follow: boolean;
134
+ active: boolean;
135
+ pending: PendingLine[];
136
+ pendingBytes: number;
137
+ dropped: number;
138
+ flushTimer: unknown | null;
139
+ terminal: HostLogsEndFrame | null;
140
+ accumulator: BoundedLineAccumulator;
141
+ cleanupSource(): void;
142
+ }
143
+
144
+ interface FileStream extends BaseStream {
145
+ kind: "file";
146
+ handle: HostLogFileHandle | null;
147
+ identity: string | null;
148
+ position: number;
149
+ pollTimer: unknown | null;
150
+ polling: boolean;
151
+ }
152
+
153
+ interface JournalStream extends BaseStream {
154
+ kind: "journal";
155
+ child: HostLogChild | null;
156
+ stderr: BoundedText;
157
+ onStdoutData: (chunk: Buffer | string) => void;
158
+ onStderrData: (chunk: Buffer | string) => void;
159
+ onError: (error: Error) => void;
160
+ onClose: (code: number | null) => void;
161
+ }
162
+
163
+ type ActiveStream = FileStream | JournalStream;
164
+
165
+ const defaultFs: HostLogFs = {
166
+ open: async (path) =>
167
+ (await open(
168
+ path,
169
+ constants.O_RDONLY | constants.O_NOFOLLOW,
170
+ )) as HostLogFileHandle,
171
+ // The host log is an exact service-owned file. Never follow a replacement
172
+ // symlink and accidentally relay an unrelated host file to the cloud.
173
+ stat: async (path) => (await lstat(path)) as HostLogFileStat,
174
+ };
175
+
176
+ const defaultSpawn: HostLogSpawn = (command, args, options) =>
177
+ spawn(command, [...args], options) as unknown as HostLogChild;
178
+
179
+ const defaultTimers: HostLogTimers = {
180
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
181
+ clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
182
+ };
183
+
184
+ function unrefTimer(timer: unknown): void {
185
+ if (
186
+ typeof timer === "object" &&
187
+ timer !== null &&
188
+ "unref" in timer &&
189
+ typeof (timer as { unref?: unknown }).unref === "function"
190
+ ) {
191
+ (timer as { unref(): void }).unref();
192
+ }
193
+ }
194
+
195
+ function normalizeTailLines(lines: number | undefined): number {
196
+ if (lines === undefined || !Number.isFinite(lines)) {
197
+ return DEFAULT_HOST_LOG_LINES;
198
+ }
199
+ return Math.max(1, Math.min(MAX_HOST_LOG_LINES, Math.trunc(lines)));
200
+ }
201
+
202
+ function safeUtf8Prefix(value: string, maxBytes: number): string {
203
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
204
+ const bytes = Buffer.from(value, "utf8");
205
+ let end = Math.min(maxBytes, bytes.length);
206
+ while (end > 0) {
207
+ const prefix = bytes.subarray(0, end).toString("utf8");
208
+ if (!prefix.endsWith("�")) return prefix;
209
+ end -= 1;
210
+ }
211
+ return "";
212
+ }
213
+
214
+ function truncateUtf8(
215
+ value: string,
216
+ maxBytes: number,
217
+ marker = TRUNCATION_MARKER,
218
+ ): string {
219
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
220
+ const markerBytes = Buffer.byteLength(marker, "utf8");
221
+ if (markerBytes >= maxBytes) return safeUtf8Prefix(marker, maxBytes);
222
+ return `${safeUtf8Prefix(value, maxBytes - markerBytes)}${marker}`;
223
+ }
224
+
225
+ function boundedErrorMessage(error: unknown, prefix: string): string {
226
+ const detail = error instanceof Error ? error.message : String(error);
227
+ const scrubbed = scrubHostString(`${prefix}: ${detail || "unknown error"}`);
228
+ return truncateUtf8(scrubbed, MAX_REMOTE_HOST_MESSAGE_BYTES);
229
+ }
230
+
231
+ function errorCode(error: unknown): string | undefined {
232
+ if (typeof error !== "object" || error === null || !("code" in error)) {
233
+ return undefined;
234
+ }
235
+ const code = (error as { code?: unknown }).code;
236
+ return typeof code === "string" ? code : undefined;
237
+ }
238
+
239
+ function fileIdentity(value: HostLogFileStat): string {
240
+ return `${String(value.dev)}:${String(value.ino)}`;
241
+ }
242
+
243
+ function validRegularFile(value: HostLogFileStat): boolean {
244
+ return (
245
+ Number.isSafeInteger(value.size) && value.size >= 0 && value.isFile()
246
+ );
247
+ }
248
+
249
+ /** A line decoder whose retained raw partial can never exceed 4 KiB. */
250
+ class BoundedLineAccumulator {
251
+ private decoder = new StringDecoder("utf8");
252
+ private partial = "";
253
+ private partialBytes = 0;
254
+ private truncated = false;
255
+
256
+ constructor(private readonly onLine: (line: string) => void) {}
257
+
258
+ push(chunk: Buffer | string): void {
259
+ const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
260
+ let cursor = 0;
261
+ for (;;) {
262
+ const newline = text.indexOf("\n", cursor);
263
+ if (newline === -1) {
264
+ this.append(text.slice(cursor));
265
+ return;
266
+ }
267
+ this.append(text.slice(cursor, newline));
268
+ this.finishLine();
269
+ cursor = newline + 1;
270
+ }
271
+ }
272
+
273
+ end(emitPartial: boolean): void {
274
+ const last = this.decoder.end();
275
+ if (last) this.push(last);
276
+ if (emitPartial && (this.partialBytes > 0 || this.truncated)) {
277
+ this.finishLine();
278
+ }
279
+ }
280
+
281
+ reset(): void {
282
+ this.decoder = new StringDecoder("utf8");
283
+ this.partial = "";
284
+ this.partialBytes = 0;
285
+ this.truncated = false;
286
+ }
287
+
288
+ private append(value: string): void {
289
+ if (!value || this.truncated) return;
290
+ const available = MAX_HOST_LOG_LINE_BYTES - this.partialBytes;
291
+ if (available <= 0) {
292
+ this.truncated = true;
293
+ return;
294
+ }
295
+ const bytes = Buffer.byteLength(value, "utf8");
296
+ if (bytes <= available) {
297
+ this.partial += value;
298
+ this.partialBytes += bytes;
299
+ return;
300
+ }
301
+ const prefix = safeUtf8Prefix(value, available);
302
+ this.partial += prefix;
303
+ this.partialBytes += Buffer.byteLength(prefix, "utf8");
304
+ this.truncated = true;
305
+ }
306
+
307
+ private finishLine(): void {
308
+ let raw = this.partial;
309
+ if (raw.endsWith("\r")) raw = raw.slice(0, -1);
310
+ let line = scrubHostString(raw);
311
+ if (this.truncated) {
312
+ line = truncateUtf8(`${line}${TRUNCATION_MARKER}`, MAX_HOST_LOG_LINE_BYTES);
313
+ } else {
314
+ line = truncateUtf8(line, MAX_HOST_LOG_LINE_BYTES);
315
+ }
316
+ this.onLine(line);
317
+ this.partial = "";
318
+ this.partialBytes = 0;
319
+ this.truncated = false;
320
+ }
321
+ }
322
+
323
+ /** Small stderr collector for an eventual bounded, scrubbed error message. */
324
+ class BoundedText {
325
+ private value = "";
326
+ private bytes = 0;
327
+ private truncated = false;
328
+
329
+ push(chunk: Buffer | string): void {
330
+ if (this.truncated) return;
331
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
332
+ const limit = MAX_REMOTE_HOST_MESSAGE_BYTES * 4;
333
+ const available = limit - this.bytes;
334
+ if (available <= 0) {
335
+ this.truncated = true;
336
+ return;
337
+ }
338
+ const prefix = safeUtf8Prefix(text, available);
339
+ this.value += prefix;
340
+ this.bytes += Buffer.byteLength(prefix, "utf8");
341
+ if (prefix !== text) this.truncated = true;
342
+ }
343
+
344
+ read(): string {
345
+ return this.truncated ? `${this.value}${TRUNCATION_MARKER}` : this.value;
346
+ }
347
+ }
348
+
349
+ export class HostLogManager {
350
+ private readonly platform: NodeJS.Platform;
351
+ private readonly logPath: string;
352
+ private readonly fs: HostLogFs;
353
+ private readonly spawn: HostLogSpawn;
354
+ private readonly timers: HostLogTimers;
355
+ private readonly pollMs: number;
356
+ private readonly streams = new Map<string, ActiveStream>();
357
+
358
+ constructor(options: HostLogManagerOptions = {}) {
359
+ this.platform = options.platform ?? process.platform;
360
+ this.logPath = options.logPath ?? serviceLogPath();
361
+ this.fs = options.fs ?? defaultFs;
362
+ this.spawn = options.spawn ?? defaultSpawn;
363
+ this.timers = options.timers ?? defaultTimers;
364
+ this.pollMs = options.pollMs ?? DEFAULT_HOST_LOG_POLL_MS;
365
+ if (!Number.isSafeInteger(this.pollMs) || this.pollMs <= 0) {
366
+ throw new Error("host log poll interval is invalid");
367
+ }
368
+ }
369
+
370
+ get activeCount(): number {
371
+ return this.streams.size;
372
+ }
373
+
374
+ start(
375
+ opId: string,
376
+ lines: number | undefined,
377
+ follow: boolean,
378
+ emit: HostLogsEmit,
379
+ ): void {
380
+ if (!isHostOpId(opId)) {
381
+ throw new HostLogStartError("invalid_op_id", "invalid host log operation id");
382
+ }
383
+ if (this.streams.has(opId)) {
384
+ throw new HostLogStartError(
385
+ "duplicate_op",
386
+ "host log operation is already active",
387
+ );
388
+ }
389
+ if (this.streams.size >= MAX_HOST_LOG_STREAMS) {
390
+ throw new HostLogStartError(
391
+ "too_many_streams",
392
+ "too many host log streams are active",
393
+ );
394
+ }
395
+
396
+ const count = normalizeTailLines(lines);
397
+ if (this.platform === "linux") {
398
+ this.startJournal(opId, count, follow === true, emit);
399
+ return;
400
+ }
401
+ if (this.platform === "darwin" || this.platform === "win32") {
402
+ this.startFile(opId, count, follow === true, emit);
403
+ return;
404
+ }
405
+
406
+ const stream = this.newBaseStream(opId, follow === true, emit) as FileStream;
407
+ stream.kind = "file";
408
+ stream.handle = null;
409
+ stream.identity = null;
410
+ stream.position = 0;
411
+ stream.pollTimer = null;
412
+ stream.polling = false;
413
+ stream.cleanupSource = () => {};
414
+ this.streams.set(opId, stream);
415
+ this.requestEnd(
416
+ stream,
417
+ "error",
418
+ `host logs are unsupported on platform ${this.platform}`,
419
+ );
420
+ }
421
+
422
+ /** Explicit operator stop. Pending chunks are discarded before the end. */
423
+ stop(opId: string): boolean {
424
+ const stream = this.streams.get(opId);
425
+ if (!stream) return false;
426
+ this.teardown(stream, true);
427
+ return true;
428
+ }
429
+
430
+ /**
431
+ * Connection-generation teardown. No end frames are emitted. Every timer
432
+ * and listener is detached, every journal child is signalled, and every
433
+ * file close is initiated before this method returns.
434
+ */
435
+ stopAll(): void {
436
+ for (const stream of [...this.streams.values()]) {
437
+ this.teardown(stream, false);
438
+ }
439
+ }
440
+
441
+ private newBaseStream(
442
+ opId: string,
443
+ follow: boolean,
444
+ emit: HostLogsEmit,
445
+ ): BaseStream {
446
+ const stream = {
447
+ opId,
448
+ emit,
449
+ follow,
450
+ active: true,
451
+ pending: [],
452
+ pendingBytes: 0,
453
+ dropped: 0,
454
+ flushTimer: null,
455
+ terminal: null,
456
+ accumulator: undefined as unknown as BoundedLineAccumulator,
457
+ cleanupSource: () => {},
458
+ } satisfies BaseStream;
459
+ stream.accumulator = new BoundedLineAccumulator((line) =>
460
+ this.enqueue(stream, line),
461
+ );
462
+ return stream;
463
+ }
464
+
465
+ private startFile(
466
+ opId: string,
467
+ lines: number,
468
+ follow: boolean,
469
+ emit: HostLogsEmit,
470
+ ): void {
471
+ const stream = this.newBaseStream(opId, follow, emit) as FileStream;
472
+ stream.kind = "file";
473
+ stream.handle = null;
474
+ stream.identity = null;
475
+ stream.position = 0;
476
+ stream.pollTimer = null;
477
+ stream.polling = false;
478
+ stream.cleanupSource = () => {
479
+ if (stream.pollTimer !== null) {
480
+ this.timers.clearTimeout(stream.pollTimer);
481
+ stream.pollTimer = null;
482
+ }
483
+ const handle = stream.handle;
484
+ stream.handle = null;
485
+ if (handle) void handle.close().catch(() => {});
486
+ };
487
+ this.streams.set(opId, stream);
488
+ void this.initializeFile(stream, lines);
489
+ }
490
+
491
+ private async initializeFile(stream: FileStream, lines: number): Promise<void> {
492
+ try {
493
+ const pathStat = await this.fs.stat(this.logPath);
494
+ if (!validRegularFile(pathStat)) {
495
+ throw new Error("host log is not a regular file");
496
+ }
497
+ if (!stream.active) return;
498
+ const handle = await this.fs.open(this.logPath);
499
+ if (!stream.active) {
500
+ void handle.close().catch(() => {});
501
+ return;
502
+ }
503
+ stream.handle = handle;
504
+ const current = await handle.stat();
505
+ if (!validRegularFile(current)) {
506
+ throw new Error("host log is not a regular file");
507
+ }
508
+ if (fileIdentity(current) !== fileIdentity(pathStat)) {
509
+ throw new Error("host log changed while it was being opened");
510
+ }
511
+ if (!stream.active) return;
512
+ stream.identity = fileIdentity(current);
513
+ stream.position = current.size;
514
+ const initial = await this.readInitialTail(handle, current.size, lines);
515
+ if (!stream.active) return;
516
+ for (const line of initial.complete) stream.accumulator.push(`${line}\n`);
517
+ if (initial.partial !== null) stream.accumulator.push(initial.partial);
518
+
519
+ if (!stream.follow) {
520
+ stream.accumulator.end(true);
521
+ this.requestEnd(stream, "complete");
522
+ return;
523
+ }
524
+ this.schedulePoll(stream, this.pollMs);
525
+ } catch (error) {
526
+ if (stream.active) {
527
+ this.requestEnd(
528
+ stream,
529
+ "error",
530
+ boundedErrorMessage(error, "could not read host log"),
531
+ );
532
+ }
533
+ }
534
+ }
535
+
536
+ private async readInitialTail(
537
+ handle: HostLogFileHandle,
538
+ size: number,
539
+ lines: number,
540
+ ): Promise<{ complete: string[]; partial: string | null }> {
541
+ if (size === 0) return { complete: [], partial: null };
542
+ const length = Math.min(size, MAX_INITIAL_TAIL_SCAN_BYTES);
543
+ const start = size - length;
544
+ const buffer = Buffer.allocUnsafe(length);
545
+ let read = 0;
546
+ while (read < length) {
547
+ const result = await handle.read(
548
+ buffer,
549
+ read,
550
+ Math.min(FILE_READ_BLOCK_BYTES, length - read),
551
+ start + read,
552
+ );
553
+ if (result.bytesRead <= 0) break;
554
+ read += result.bytesRead;
555
+ }
556
+ let bytes = buffer.subarray(0, read);
557
+ if (start > 0) {
558
+ const newline = bytes.indexOf(0x0a);
559
+ if (newline === -1) {
560
+ // The scan window is one giant line. Retain only its bounded suffix
561
+ // and make the loss explicit; never fabricate it as a full line.
562
+ const suffixBytes = Math.max(0, MAX_HOST_LOG_LINE_BYTES - 32);
563
+ bytes = Buffer.concat([
564
+ Buffer.from(`${TRUNCATION_MARKER} `),
565
+ bytes.subarray(Math.max(0, bytes.length - suffixBytes)),
566
+ ]);
567
+ } else {
568
+ bytes = bytes.subarray(newline + 1);
569
+ }
570
+ }
571
+ if (bytes.length === 0) return { complete: [], partial: null };
572
+
573
+ // Select the last N lines while the bytes are still compact. Splitting the
574
+ // entire scan window first would let an 8 MiB file of only newlines create
575
+ // millions of array entries despite the 1,000-line wire limit.
576
+ const terminalNewline = bytes.at(-1) === 0x0a;
577
+ let separatorCount = 0;
578
+ let selectedStart = 0;
579
+ for (
580
+ let index = bytes.length - (terminalNewline ? 2 : 1);
581
+ index >= 0;
582
+ index -= 1
583
+ ) {
584
+ if (bytes[index] !== 0x0a) continue;
585
+ separatorCount += 1;
586
+ if (separatorCount === lines) {
587
+ selectedStart = index + 1;
588
+ break;
589
+ }
590
+ }
591
+ bytes = bytes.subarray(selectedStart);
592
+ const text = bytes.toString("utf8");
593
+ const terminated = text.endsWith("\n");
594
+ const physical = text.split("\n");
595
+ if (terminated) physical.pop();
596
+ const selected = physical.slice(-lines);
597
+ if (!terminated && selected.length > 0) {
598
+ return {
599
+ complete: selected.slice(0, -1),
600
+ partial: selected.at(-1) ?? null,
601
+ };
602
+ }
603
+ return { complete: selected, partial: null };
604
+ }
605
+
606
+ private schedulePoll(stream: FileStream, delayMs: number): void {
607
+ if (!stream.active || stream.terminal || stream.pollTimer !== null) return;
608
+ stream.pollTimer = this.timers.setTimeout(() => {
609
+ stream.pollTimer = null;
610
+ void this.pollFile(stream);
611
+ }, delayMs);
612
+ unrefTimer(stream.pollTimer);
613
+ }
614
+
615
+ private async pollFile(stream: FileStream): Promise<void> {
616
+ if (!stream.active || stream.polling) return;
617
+ stream.polling = true;
618
+ let catchUp = false;
619
+ try {
620
+ const handle = stream.handle;
621
+ if (!handle) throw new Error("host log file is not open");
622
+ const current = await handle.stat();
623
+ if (!validRegularFile(current)) {
624
+ throw new Error("host log is not a regular file");
625
+ }
626
+ if (!stream.active) return;
627
+ if (current.size < stream.position) {
628
+ // copytruncate: an old partial must never be joined to the new file.
629
+ stream.position = 0;
630
+ stream.accumulator.reset();
631
+ }
632
+ catchUp = await this.readFileAppend(
633
+ stream,
634
+ handle,
635
+ current.size,
636
+ MAX_FILE_BYTES_PER_POLL,
637
+ );
638
+ if (!stream.active) return;
639
+
640
+ let pathStat: HostLogFileStat | null = null;
641
+ try {
642
+ pathStat = await this.fs.stat(this.logPath);
643
+ } catch (error) {
644
+ if (errorCode(error) !== "ENOENT") throw error;
645
+ }
646
+ if (!stream.active) return;
647
+ if (pathStat && fileIdentity(pathStat) !== stream.identity) {
648
+ await this.rotateFile(stream, pathStat);
649
+ catchUp = true;
650
+ }
651
+ } catch (error) {
652
+ if (stream.active) {
653
+ this.requestEnd(
654
+ stream,
655
+ "error",
656
+ boundedErrorMessage(error, "host log follow failed"),
657
+ );
658
+ }
659
+ } finally {
660
+ stream.polling = false;
661
+ if (stream.active && !stream.terminal) {
662
+ this.schedulePoll(stream, catchUp ? 0 : this.pollMs);
663
+ }
664
+ }
665
+ }
666
+
667
+ /** Returns true while bytes remain so catch-up continues without poll delay. */
668
+ private async readFileAppend(
669
+ stream: FileStream,
670
+ handle: HostLogFileHandle,
671
+ targetSize: number,
672
+ maxBytes: number,
673
+ ): Promise<boolean> {
674
+ const cappedTarget = Math.min(targetSize, stream.position + maxBytes);
675
+ const buffer = Buffer.allocUnsafe(FILE_READ_BLOCK_BYTES);
676
+ while (stream.active && stream.position < cappedTarget) {
677
+ const wanted = Math.min(buffer.length, cappedTarget - stream.position);
678
+ const result = await handle.read(buffer, 0, wanted, stream.position);
679
+ if (result.bytesRead <= 0) break;
680
+ stream.position += result.bytesRead;
681
+ stream.accumulator.push(buffer.subarray(0, result.bytesRead));
682
+ }
683
+ return stream.position < targetSize;
684
+ }
685
+
686
+ private async rotateFile(
687
+ stream: FileStream,
688
+ expected: HostLogFileStat,
689
+ ): Promise<void> {
690
+ const previous = stream.handle;
691
+ stream.handle = null;
692
+ if (previous) await previous.close().catch(() => {});
693
+ stream.accumulator.reset();
694
+ const replacement = await this.fs.open(this.logPath);
695
+ if (!stream.active) {
696
+ void replacement.close().catch(() => {});
697
+ return;
698
+ }
699
+ const replacementStat = await replacement.stat();
700
+ if (!validRegularFile(replacementStat)) {
701
+ void replacement.close().catch(() => {});
702
+ throw new Error("replacement host log is not a regular file");
703
+ }
704
+ if (fileIdentity(replacementStat) !== fileIdentity(expected)) {
705
+ void replacement.close().catch(() => {});
706
+ throw new Error("replacement host log changed while it was being opened");
707
+ }
708
+ stream.handle = replacement;
709
+ stream.identity = fileIdentity(replacementStat);
710
+ stream.position = 0;
711
+ await this.readFileAppend(
712
+ stream,
713
+ replacement,
714
+ replacementStat.size,
715
+ MAX_FILE_BYTES_PER_POLL,
716
+ );
717
+ }
718
+
719
+ private startJournal(
720
+ opId: string,
721
+ lines: number,
722
+ follow: boolean,
723
+ emit: HostLogsEmit,
724
+ ): void {
725
+ const stream = this.newBaseStream(opId, follow, emit) as JournalStream;
726
+ stream.kind = "journal";
727
+ stream.child = null;
728
+ stream.stderr = new BoundedText();
729
+ stream.onStdoutData = (chunk) => {
730
+ if (stream.active) stream.accumulator.push(chunk);
731
+ };
732
+ stream.onStderrData = (chunk) => {
733
+ if (stream.active) stream.stderr.push(chunk);
734
+ };
735
+ stream.onError = (error) => {
736
+ if (!stream.active) return;
737
+ this.requestEnd(
738
+ stream,
739
+ "error",
740
+ boundedErrorMessage(error, "journalctl failed"),
741
+ );
742
+ };
743
+ stream.onClose = (code) => {
744
+ if (!stream.active) return;
745
+ // The process has already exited. Detach without signalling it again;
746
+ // requestEnd's generic source cleanup then becomes a no-op.
747
+ this.detachJournal(stream, false);
748
+ stream.accumulator.end(!follow);
749
+ if (code === 0 && !follow) {
750
+ this.requestEnd(stream, "complete");
751
+ return;
752
+ }
753
+ const stderr = stream.stderr.read().trim();
754
+ const detail = follow
755
+ ? `journalctl exited while following${code === null ? "" : ` (code ${code})`}`
756
+ : `journalctl exited${code === null ? "" : ` with code ${code}`}`;
757
+ this.requestEnd(
758
+ stream,
759
+ "error",
760
+ boundedErrorMessage(stderr || "no diagnostic output", detail),
761
+ );
762
+ };
763
+ stream.cleanupSource = () => this.detachJournal(stream, true);
764
+ this.streams.set(opId, stream);
765
+
766
+ try {
767
+ const args = [
768
+ "--user",
769
+ "-u",
770
+ "uai-host",
771
+ "--no-pager",
772
+ "-o",
773
+ "cat",
774
+ "-n",
775
+ String(lines),
776
+ ];
777
+ if (follow) args.push("-f");
778
+ const child = this.spawn("/usr/bin/journalctl", args, {
779
+ stdio: ["ignore", "pipe", "pipe"],
780
+ shell: false,
781
+ });
782
+ if (!stream.active) {
783
+ child.kill("SIGTERM");
784
+ return;
785
+ }
786
+ stream.child = child;
787
+ child.stdout.setEncoding?.("utf8");
788
+ child.stderr.setEncoding?.("utf8");
789
+ child.stdout.on("data", stream.onStdoutData);
790
+ child.stderr.on("data", stream.onStderrData);
791
+ child.on("error", stream.onError);
792
+ child.on("close", stream.onClose);
793
+ } catch (error) {
794
+ this.requestEnd(
795
+ stream,
796
+ "error",
797
+ boundedErrorMessage(error, "could not start journalctl"),
798
+ );
799
+ }
800
+ }
801
+
802
+ private detachJournal(stream: JournalStream, terminate: boolean): void {
803
+ const child = stream.child;
804
+ stream.child = null;
805
+ if (!child) return;
806
+ child.stdout.off("data", stream.onStdoutData);
807
+ child.stderr.off("data", stream.onStderrData);
808
+ child.off("error", stream.onError);
809
+ child.off("close", stream.onClose);
810
+ if (terminate) {
811
+ try {
812
+ child.kill("SIGTERM");
813
+ } catch {
814
+ // Best effort: listeners are already detached and cannot resurrect it.
815
+ }
816
+ child.stdout.destroy?.();
817
+ child.stderr.destroy?.();
818
+ }
819
+ }
820
+
821
+ private enqueue(stream: BaseStream, value: string): void {
822
+ if (!stream.active) return;
823
+ // BoundedLineAccumulator scrubbed and line-bounded this before it crossed
824
+ // into the outbound buffer.
825
+ const bytes = Buffer.byteLength(value, "utf8");
826
+ stream.pending.push({ value, bytes });
827
+ stream.pendingBytes += bytes;
828
+ while (
829
+ stream.pending.length > MAX_HOST_LOG_PENDING_LINES ||
830
+ stream.pendingBytes > MAX_HOST_LOG_PENDING_BYTES
831
+ ) {
832
+ const removed = stream.pending.shift();
833
+ if (!removed) break;
834
+ stream.pendingBytes -= removed.bytes;
835
+ stream.dropped = Math.min(Number.MAX_SAFE_INTEGER, stream.dropped + 1);
836
+ }
837
+ this.scheduleFlush(stream, 0);
838
+ }
839
+
840
+ private scheduleFlush(stream: BaseStream, delayMs: number): void {
841
+ if (!stream.active || stream.flushTimer !== null) return;
842
+ stream.flushTimer = this.timers.setTimeout(() => {
843
+ stream.flushTimer = null;
844
+ this.flush(stream);
845
+ }, delayMs);
846
+ unrefTimer(stream.flushTimer);
847
+ }
848
+
849
+ private flush(stream: BaseStream): void {
850
+ if (!stream.active) return;
851
+ if (stream.pending.length === 0) {
852
+ if (stream.terminal) this.emitTerminal(stream);
853
+ return;
854
+ }
855
+
856
+ const lines: string[] = [];
857
+ let bytes = 0;
858
+ for (const pending of stream.pending) {
859
+ if (lines.length >= MAX_HOST_LOG_CHUNK_LINES) break;
860
+ if (lines.length > 0 && bytes + pending.bytes > MAX_HOST_LOG_CHUNK_BYTES) {
861
+ break;
862
+ }
863
+ // A single line is <=4 KiB, so it always fits an empty 64 KiB chunk.
864
+ lines.push(pending.value);
865
+ bytes += pending.bytes;
866
+ }
867
+ const frame: HostLogsChunkFrame = {
868
+ kind: "host.logs.chunk",
869
+ opId: stream.opId,
870
+ lines,
871
+ ...(stream.dropped > 0 ? { dropped: stream.dropped } : {}),
872
+ };
873
+
874
+ let accepted: boolean | void;
875
+ try {
876
+ accepted = stream.emit(frame);
877
+ } catch {
878
+ this.teardown(stream, false);
879
+ return;
880
+ }
881
+ if (accepted === false) {
882
+ this.scheduleFlush(stream, EMIT_RETRY_MS);
883
+ return;
884
+ }
885
+
886
+ for (let index = 0; index < lines.length; index += 1) {
887
+ const removed = stream.pending.shift();
888
+ if (removed) stream.pendingBytes -= removed.bytes;
889
+ }
890
+ stream.dropped = 0;
891
+ if (stream.pending.length > 0 || stream.terminal) {
892
+ this.scheduleFlush(stream, 0);
893
+ }
894
+ }
895
+
896
+ private requestEnd(
897
+ stream: BaseStream,
898
+ reason: "complete" | "error",
899
+ message?: string,
900
+ ): void {
901
+ if (!stream.active || stream.terminal) return;
902
+ stream.cleanupSource();
903
+ stream.terminal =
904
+ reason === "error"
905
+ ? {
906
+ kind: "host.logs.end",
907
+ opId: stream.opId,
908
+ reason,
909
+ message: truncateUtf8(
910
+ scrubHostString(message || "host log operation failed"),
911
+ MAX_REMOTE_HOST_MESSAGE_BYTES,
912
+ ),
913
+ }
914
+ : { kind: "host.logs.end", opId: stream.opId, reason };
915
+ this.scheduleFlush(stream, 0);
916
+ }
917
+
918
+ private emitTerminal(stream: BaseStream): void {
919
+ const terminal = stream.terminal;
920
+ if (!terminal || !stream.active) return;
921
+ this.streams.delete(stream.opId);
922
+ stream.active = false;
923
+ stream.terminal = null;
924
+ try {
925
+ stream.emit(terminal);
926
+ } catch {
927
+ // The connection generation is already gone; cleanup is complete.
928
+ }
929
+ }
930
+
931
+ private teardown(stream: BaseStream, emitStopped: boolean): void {
932
+ if (!stream.active) return;
933
+ stream.active = false;
934
+ this.streams.delete(stream.opId);
935
+ if (stream.flushTimer !== null) {
936
+ this.timers.clearTimeout(stream.flushTimer);
937
+ stream.flushTimer = null;
938
+ }
939
+ stream.cleanupSource();
940
+ stream.pending.length = 0;
941
+ stream.pendingBytes = 0;
942
+ stream.dropped = 0;
943
+ stream.terminal = null;
944
+ if (emitStopped) {
945
+ try {
946
+ stream.emit({
947
+ kind: "host.logs.end",
948
+ opId: stream.opId,
949
+ reason: "stopped",
950
+ });
951
+ } catch {
952
+ // Explicit stop is still complete even if its generation disappeared.
953
+ }
954
+ }
955
+ }
956
+ }
957
+
958
+ export function createHostLogManager(
959
+ options: HostLogManagerOptions = {},
960
+ ): HostLogManager {
961
+ return new HostLogManager(options);
962
+ }