@runuai/host 0.9.13 → 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 +69 -4
  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 +3070 -223
  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 +1463 -109
  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 +871 -50
  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
@@ -10,12 +10,30 @@
10
10
  * dedicated headless server a system unit + service user would be the move.
11
11
  */
12
12
 
13
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
14
- import { homedir } from "node:os";
15
- import { dirname, resolve } from "node:path";
13
+ import { lstatSync, mkdirSync, readlinkSync, rmSync } from "node:fs";
14
+ import { homedir, userInfo } from "node:os";
15
+ import { basename, dirname, resolve } from "node:path";
16
16
 
17
- import type { InstallContext, Installer } from "./types";
18
- import { printFile, step } from "./util";
17
+ import type {
18
+ InstallContext,
19
+ Installer,
20
+ ServiceDefinitionState,
21
+ ServiceManagerState,
22
+ } from "./types";
23
+ import {
24
+ executeStep,
25
+ printFile,
26
+ readPrivateJournal,
27
+ removeFileDurable,
28
+ restoreDefinitionDurable,
29
+ snapshotDefinition,
30
+ step,
31
+ stepFailure,
32
+ writeDefinitionDurable,
33
+ type DirectorySync,
34
+ type StepExecutor,
35
+ type StepResult,
36
+ } from "./util";
19
37
 
20
38
  export const UNIT = "uai-host.service";
21
39
 
@@ -23,6 +41,10 @@ export function unitPath(): string {
23
41
  return resolve(homedir(), ".config", "systemd", "user", UNIT);
24
42
  }
25
43
 
44
+ export function linuxInstallJournalPath(definition = unitPath()): string {
45
+ return resolve(dirname(definition), `.${basename(definition)}.install-journal.json`);
46
+ }
47
+
26
48
  export interface UnitInputs {
27
49
  execPath: string;
28
50
  args: string[];
@@ -30,8 +52,18 @@ export interface UnitInputs {
30
52
  path: string;
31
53
  }
32
54
 
55
+ function systemdQuote(value: string): string {
56
+ if (/[\n\r\0]/.test(value)) {
57
+ throw new Error("systemd service values must not contain control characters");
58
+ }
59
+ return `"${value
60
+ .replace(/%/g, "%%")
61
+ .replace(/\\/g, "\\\\")
62
+ .replace(/"/g, '\\"')}"`;
63
+ }
64
+
33
65
  export function renderUnit(p: UnitInputs): string {
34
- const exec = [p.execPath, ...p.args].join(" ");
66
+ const exec = [p.execPath, ...p.args].map(systemdQuote).join(" ");
35
67
  return `[Unit]
36
68
  Description=Uai host service (ADR-028)
37
69
  After=network-online.target
@@ -39,8 +71,8 @@ Wants=network-online.target
39
71
 
40
72
  [Service]
41
73
  Type=simple
42
- WorkingDirectory=${p.cwd}
43
- Environment=PATH=${p.path}
74
+ WorkingDirectory=${systemdQuote(p.cwd)}
75
+ Environment=${systemdQuote(`PATH=${p.path}`)}
44
76
  ExecStart=${exec}
45
77
  Restart=on-failure
46
78
  RestartSec=10
@@ -55,47 +87,805 @@ function inputs(ctx: InstallContext): UnitInputs {
55
87
  execPath: ctx.execPath,
56
88
  args: ctx.args,
57
89
  cwd: ctx.cwd,
58
- path: process.env.PATH ?? "",
90
+ path: ctx.envPath ?? process.env.PATH ?? "",
59
91
  };
60
92
  }
61
93
 
62
- async function install(ctx: InstallContext): Promise<void> {
94
+ export interface LinuxInstallDeps extends LinuxServiceDeps {
95
+ }
96
+
97
+ export async function installLinuxService(
98
+ ctx: InstallContext,
99
+ deps: LinuxInstallDeps = {},
100
+ ): Promise<void> {
101
+ const execute = deps.execute ?? executeStep;
63
102
  const unit = renderUnit(inputs(ctx));
103
+ const definition = deps.definitionPath ?? unitPath();
64
104
  if (ctx.dryRun) {
65
- printFile(unitPath(), unit);
66
- step(true, "systemctl", ["--user", "daemon-reload"]);
67
- step(true, "systemctl", ["--user", "enable", "--now", UNIT]);
105
+ await ensureLinuxLinger(true, deps);
106
+ printFile(definition, unit);
107
+ execute(true, "systemctl", ["--user", "daemon-reload"]);
108
+ execute(
109
+ true,
110
+ "systemctl",
111
+ ctx.startOnInstall === false
112
+ ? ["--user", "disable", "--now", UNIT]
113
+ : ["--user", "enable", "--now", UNIT],
114
+ );
68
115
  return;
69
116
  }
70
- mkdirSync(dirname(unitPath()), { recursive: true });
71
- writeFileSync(unitPath(), unit);
72
- step(false, "systemctl", ["--user", "daemon-reload"]);
73
- step(false, "systemctl", ["--user", "enable", "--now", UNIT]);
117
+ const journalPath = deps.journalPath ?? linuxInstallJournalPath(definition);
118
+ const installIntent = ctx.definitionInstallIntent;
119
+ assertDefinitionInstallIntent(installIntent);
120
+ await reconcileLinuxDefinitionInstall(
121
+ { ...deps, definitionPath: definition, journalPath },
122
+ installIntent?.nonce,
123
+ );
124
+ await ensureLinuxLinger(false, deps);
125
+ mkdirSync(dirname(definition), { recursive: true });
126
+ const previous = snapshotDefinition(definition);
127
+ const manager = inspectSystemdState(execute);
128
+ if (!previous.exists && (manager.active || manager.enabled)) {
129
+ throw new Error("systemd reports the host unit active/enabled without a recoverable unit file");
130
+ }
131
+ const journal: LinuxInstallJournal = {
132
+ schemaVersion: 1,
133
+ phase: "installed",
134
+ installIntent: installIntent ?? null,
135
+ prior: serializeDefinition(previous),
136
+ manager,
137
+ };
138
+ writeDefinitionDurable(journalPath, `${JSON.stringify(journal)}\n`, 0o600);
139
+ try {
140
+ writeDefinitionDurable(
141
+ definition,
142
+ unit,
143
+ 0o644,
144
+ deps.definitionDirectorySync,
145
+ );
146
+ strictStep(execute, "systemctl", ["--user", "daemon-reload"]);
147
+ strictStep(
148
+ execute,
149
+ "systemctl",
150
+ ctx.startOnInstall === false
151
+ ? ["--user", "disable", "--now", UNIT]
152
+ : ["--user", "enable", "--now", UNIT],
153
+ );
154
+ if (!installIntent) removeFileDurable(journalPath);
155
+ } catch (error) {
156
+ try {
157
+ const rollbackDeps = {
158
+ ...deps,
159
+ definitionPath: definition,
160
+ journalPath,
161
+ };
162
+ if (installIntent) {
163
+ await prepareLinuxDefinitionInstallRollback(
164
+ rollbackDeps,
165
+ installIntent.nonce,
166
+ );
167
+ } else {
168
+ await reconcileLinuxDefinitionInstall(rollbackDeps);
169
+ }
170
+ } catch (rollbackError) {
171
+ throw new Error(
172
+ `${errorMessage(error)}; service-definition rollback failed: ${errorMessage(rollbackError)}`,
173
+ { cause: error },
174
+ );
175
+ }
176
+ throw error;
177
+ }
74
178
  }
75
179
 
76
- async function uninstall(dryRun = false): Promise<void> {
77
- step(dryRun, "systemctl", ["--user", "disable", "--now", UNIT], {
78
- ignoreError: true,
79
- });
180
+ export interface LinuxServiceDeps {
181
+ execute?: StepExecutor;
182
+ removeFile?: (path: string) => void;
183
+ username?: string;
184
+ definitionPath?: string;
185
+ journalPath?: string;
186
+ definitionDirectorySync?: DirectorySync;
187
+ }
188
+
189
+ const STOPPED_SYSTEMD_STATES = new Set(["inactive", "failed", "unknown"]);
190
+ const ACTIVE_SYSTEMD_STATES = new Set([
191
+ "active",
192
+ "activating",
193
+ "reloading",
194
+ "deactivating",
195
+ ]);
196
+ const DISABLED_SYSTEMD_STATES = new Set([
197
+ "disabled",
198
+ "static",
199
+ "indirect",
200
+ "generated",
201
+ "transient",
202
+ "not-found",
203
+ "masked",
204
+ ]);
205
+
206
+ interface LinuxInstallJournal {
207
+ readonly schemaVersion: 1;
208
+ readonly phase: "installed" | "definition-restored";
209
+ readonly installIntent: {
210
+ readonly nonce: string;
211
+ readonly ownerPid: number;
212
+ } | null;
213
+ readonly prior:
214
+ | { readonly exists: false }
215
+ | {
216
+ readonly exists: true;
217
+ readonly bytesBase64: string;
218
+ readonly mode: number;
219
+ };
220
+ readonly manager: { readonly active: boolean; readonly enabled: boolean };
221
+ }
222
+
223
+ function assertDefinitionInstallIntent(
224
+ intent: InstallContext["definitionInstallIntent"],
225
+ ): void {
226
+ if (
227
+ intent &&
228
+ (!/^[a-f0-9]{64}$/.test(intent.nonce) ||
229
+ !Number.isSafeInteger(intent.ownerPid) ||
230
+ intent.ownerPid < 1)
231
+ ) {
232
+ throw new Error("service definition install intent is invalid");
233
+ }
234
+ }
235
+
236
+ function serializeDefinition(
237
+ snapshot: ReturnType<typeof snapshotDefinition>,
238
+ ): LinuxInstallJournal["prior"] {
239
+ return snapshot.exists
240
+ ? {
241
+ exists: true,
242
+ bytesBase64: snapshot.bytes.toString("base64"),
243
+ mode: snapshot.mode,
244
+ }
245
+ : { exists: false };
246
+ }
247
+
248
+ function parseLinuxInstallJournal(raw: string): LinuxInstallJournal {
249
+ let value: unknown;
250
+ try {
251
+ value = JSON.parse(raw);
252
+ } catch {
253
+ throw new Error("systemd service install journal is invalid");
254
+ }
255
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
256
+ throw new Error("systemd service install journal is invalid");
257
+ }
258
+ const record = value as Record<string, unknown>;
259
+ if (
260
+ Object.keys(record).sort().join("\0") !==
261
+ ["installIntent", "manager", "phase", "prior", "schemaVersion"].join(
262
+ "\0",
263
+ ) ||
264
+ record.schemaVersion !== 1 ||
265
+ (record.phase !== "installed" && record.phase !== "definition-restored") ||
266
+ !record.manager ||
267
+ typeof record.manager !== "object" ||
268
+ Array.isArray(record.manager) ||
269
+ !record.prior ||
270
+ typeof record.prior !== "object" ||
271
+ Array.isArray(record.prior) ||
272
+ (record.installIntent !== null &&
273
+ (typeof record.installIntent !== "object" || Array.isArray(record.installIntent)))
274
+ ) {
275
+ throw new Error("systemd service install journal is invalid");
276
+ }
277
+ const manager = record.manager as Record<string, unknown>;
278
+ const prior = record.prior as Record<string, unknown>;
279
+ const installIntent = record.installIntent as Record<string, unknown> | null;
280
+ if (
281
+ Object.keys(manager).sort().join("\0") !== ["active", "enabled"].join("\0") ||
282
+ typeof manager.active !== "boolean" ||
283
+ typeof manager.enabled !== "boolean" ||
284
+ typeof prior.exists !== "boolean" ||
285
+ (installIntent !== null &&
286
+ (Object.keys(installIntent).sort().join("\0") !== ["nonce", "ownerPid"].join("\0") ||
287
+ typeof installIntent.nonce !== "string" ||
288
+ !/^[a-f0-9]{64}$/.test(installIntent.nonce) ||
289
+ typeof installIntent.ownerPid !== "number" ||
290
+ !Number.isSafeInteger(installIntent.ownerPid) ||
291
+ installIntent.ownerPid < 1))
292
+ ) {
293
+ throw new Error("systemd service install journal is invalid");
294
+ }
295
+ if (prior.exists === false) {
296
+ if (
297
+ Object.keys(prior).join("\0") !== "exists" ||
298
+ manager.active ||
299
+ manager.enabled
300
+ ) {
301
+ throw new Error("systemd service install journal is invalid");
302
+ }
303
+ return {
304
+ schemaVersion: 1,
305
+ phase: record.phase,
306
+ installIntent: installIntent as LinuxInstallJournal["installIntent"],
307
+ prior: { exists: false },
308
+ manager: manager as LinuxInstallJournal["manager"],
309
+ };
310
+ }
311
+ if (
312
+ Object.keys(prior).sort().join("\0") !==
313
+ ["bytesBase64", "exists", "mode"].join("\0") ||
314
+ typeof prior.bytesBase64 !== "string" ||
315
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
316
+ prior.bytesBase64,
317
+ ) ||
318
+ typeof prior.mode !== "number" ||
319
+ !Number.isInteger(prior.mode) ||
320
+ prior.mode < 0 ||
321
+ prior.mode > 0o7777
322
+ ) {
323
+ throw new Error("systemd service install journal is invalid");
324
+ }
325
+ const bytes = Buffer.from(prior.bytesBase64, "base64");
326
+ if (bytes.toString("base64") !== prior.bytesBase64) {
327
+ throw new Error("systemd service install journal is invalid");
328
+ }
329
+ return {
330
+ schemaVersion: 1,
331
+ phase: record.phase,
332
+ installIntent: installIntent as LinuxInstallJournal["installIntent"],
333
+ prior: {
334
+ exists: true,
335
+ bytesBase64: prior.bytesBase64,
336
+ mode: prior.mode,
337
+ },
338
+ manager: manager as LinuxInstallJournal["manager"],
339
+ };
340
+ }
341
+
342
+ function inspectSystemdState(execute: StepExecutor): {
343
+ active: boolean;
344
+ enabled: boolean;
345
+ } {
346
+ const activeArgs = ["--user", "is-active", UNIT];
347
+ const activeResult = execute(false, "systemctl", activeArgs);
348
+ const activeState = activeResult.stdout.trim();
349
+ if (typeof activeResult.status !== "number") {
350
+ throw stepFailure(
351
+ "systemctl",
352
+ activeArgs,
353
+ activeResult,
354
+ "could not determine prior unit activity",
355
+ );
356
+ }
357
+ const active = ACTIVE_SYSTEMD_STATES.has(activeState);
358
+ if (!active && !STOPPED_SYSTEMD_STATES.has(activeState)) {
359
+ throw stepFailure(
360
+ "systemctl",
361
+ activeArgs,
362
+ activeResult,
363
+ "could not determine prior unit activity",
364
+ );
365
+ }
366
+
367
+ const enabledArgs = ["--user", "is-enabled", UNIT];
368
+ const enabledResult = execute(false, "systemctl", enabledArgs);
369
+ const enabledState = enabledResult.stdout.trim();
370
+ if (typeof enabledResult.status !== "number") {
371
+ throw stepFailure(
372
+ "systemctl",
373
+ enabledArgs,
374
+ enabledResult,
375
+ "could not determine prior unit enablement",
376
+ );
377
+ }
378
+ const enabled = enabledResult.status === 0 && enabledState === "enabled";
379
+ if (!enabled && !DISABLED_SYSTEMD_STATES.has(enabledState)) {
380
+ throw stepFailure(
381
+ "systemctl",
382
+ enabledArgs,
383
+ enabledResult,
384
+ "could not determine prior unit enablement",
385
+ );
386
+ }
387
+ return { active, enabled };
388
+ }
389
+
390
+ function strictStep(execute: StepExecutor, command: string, args: string[]): void {
391
+ const result = execute(false, command, args);
392
+ if (result.status !== 0) throw stepFailure(command, args, result);
393
+ }
394
+
395
+ function errorMessage(error: unknown): string {
396
+ return error instanceof Error ? error.message : String(error);
397
+ }
398
+
399
+ function lingerIsEnabled(result: StepResult): boolean {
400
+ return result.status === 0 && result.stdout.trim().toLowerCase() === "yes";
401
+ }
402
+
403
+ export async function ensureLinuxLinger(
404
+ dryRun = false,
405
+ deps: LinuxServiceDeps = {},
406
+ ): Promise<void> {
407
+ const execute = deps.execute ?? executeStep;
408
+ const username = deps.username ?? userInfo().username;
409
+ const showArgs = ["show-user", username, "-p", "Linger", "--value"];
410
+ const initial = execute(dryRun, "loginctl", showArgs);
411
+ if (dryRun) {
412
+ execute(true, "loginctl", ["enable-linger", username]);
413
+ execute(true, "loginctl", showArgs);
414
+ return;
415
+ }
416
+ if (lingerIsEnabled(initial)) return;
417
+
418
+ const enableArgs = ["enable-linger", username];
419
+ const enabled = execute(false, "loginctl", enableArgs);
420
+ const adminGuidance =
421
+ `ask an administrator to run: sudo loginctl enable-linger ${username}`;
422
+ if (enabled.status !== 0) {
423
+ throw stepFailure("loginctl", enableArgs, enabled, adminGuidance);
424
+ }
425
+
426
+ const verified = execute(false, "loginctl", showArgs);
427
+ if (!lingerIsEnabled(verified)) {
428
+ throw new Error(
429
+ `loginctl did not confirm reboot persistence for uai-host; ${adminGuidance}`,
430
+ );
431
+ }
432
+ }
433
+
434
+ function probeConfirmsStopped(result: StepResult): boolean {
435
+ return (
436
+ typeof result.status === "number" &&
437
+ result.status !== 0 &&
438
+ STOPPED_SYSTEMD_STATES.has(result.stdout.trim())
439
+ );
440
+ }
441
+
442
+ function assertSystemdStopped(
443
+ dryRun: boolean,
444
+ actionArgs: string[],
445
+ execute: StepExecutor,
446
+ ): void {
447
+ const result = execute(dryRun, "systemctl", actionArgs);
448
+ if (result.status === 0 || dryRun) return;
449
+
450
+ const probeArgs = ["--user", "is-active", UNIT];
451
+ const probe = execute(false, "systemctl", probeArgs);
452
+ if (probeConfirmsStopped(probe)) return;
453
+
454
+ const probeDetail = (
455
+ probe.stderr ||
456
+ probe.stdout ||
457
+ probe.error?.message ||
458
+ `exit status ${String(probe.status)}`
459
+ ).trim();
460
+ throw stepFailure(
461
+ "systemctl",
462
+ actionArgs,
463
+ result,
464
+ `could not verify that the service stopped: ${probeDetail}`,
465
+ );
466
+ }
467
+
468
+ export async function reconcileLinuxDefinitionInstall(
469
+ deps: LinuxInstallDeps = {},
470
+ installIntent?: string,
471
+ ): Promise<void> {
472
+ const definition = deps.definitionPath ?? unitPath();
473
+ const journalPath = deps.journalPath ?? linuxInstallJournalPath(definition);
474
+ const raw = readPrivateJournal(journalPath);
475
+ if (raw === null) return;
476
+ const journal = parseLinuxInstallJournal(raw);
477
+ if (journal.installIntent !== null) {
478
+ assertJournalAccess(journal.installIntent, installIntent);
479
+ throw new Error(
480
+ "public service definition rollback requires runtime command restoration between prepare and resume",
481
+ );
482
+ }
483
+ await prepareLinuxDefinitionInstallRollback(deps);
484
+ await resumeLinuxDefinitionInstallRollback(deps);
485
+ }
486
+
487
+ export async function prepareLinuxDefinitionInstallRollback(
488
+ deps: LinuxInstallDeps = {},
489
+ installIntent?: string,
490
+ ): Promise<void> {
491
+ const definition = deps.definitionPath ?? unitPath();
492
+ const journalPath = deps.journalPath ?? linuxInstallJournalPath(definition);
493
+ const raw = readPrivateJournal(journalPath);
494
+ if (raw === null) return;
495
+ const journal = parseLinuxInstallJournal(raw);
496
+ assertJournalAccess(journal.installIntent, installIntent);
497
+ const execute = deps.execute ?? executeStep;
498
+
499
+ assertSystemdStopped(false, ["--user", "stop", UNIT], execute);
500
+ const prior = journal.prior.exists
501
+ ? {
502
+ exists: true as const,
503
+ bytes: Buffer.from(journal.prior.bytesBase64, "base64"),
504
+ mode: journal.prior.mode,
505
+ }
506
+ : ({ exists: false } as const);
507
+ // Persist the disabled state before restoring a definition which may still
508
+ // point through a shared shim. A reboot in the restore/fsync window cannot
509
+ // launch it until runtime-command rollback has completed.
510
+ assertSystemdDisabledForRollback(execute, !prior.exists);
511
+ restoreDefinitionDurable(definition, prior, deps.definitionDirectorySync);
512
+ strictStep(execute, "systemctl", ["--user", "daemon-reload"]);
513
+
514
+ if (journal.phase !== "definition-restored") {
515
+ writeDefinitionDurable(
516
+ journalPath,
517
+ `${JSON.stringify({ ...journal, phase: "definition-restored" })}\n`,
518
+ 0o600,
519
+ );
520
+ }
521
+ }
522
+
523
+ function assertSystemdDisabledForRollback(
524
+ execute: StepExecutor,
525
+ allowMissing: boolean,
526
+ ): void {
527
+ const disableArgs = ["--user", "disable", UNIT];
528
+ const disabled = execute(false, "systemctl", disableArgs);
529
+ if (disabled.status === 0) return;
530
+ if (allowMissing) {
531
+ const probeArgs = ["--user", "is-enabled", UNIT];
532
+ const probe = execute(false, "systemctl", probeArgs);
533
+ if (
534
+ probe.status !== 0 &&
535
+ DISABLED_SYSTEMD_STATES.has(probe.stdout.trim())
536
+ ) {
537
+ return;
538
+ }
539
+ }
540
+ throw stepFailure(
541
+ "systemctl",
542
+ disableArgs,
543
+ disabled,
544
+ "could not durably disable the replacement service before rollback",
545
+ );
546
+ }
547
+
548
+ export async function resumeLinuxDefinitionInstallRollback(
549
+ deps: LinuxInstallDeps = {},
550
+ installIntent?: string,
551
+ ): Promise<void> {
552
+ const definition = deps.definitionPath ?? unitPath();
553
+ const journalPath = deps.journalPath ?? linuxInstallJournalPath(definition);
554
+ const raw = readPrivateJournal(journalPath);
555
+ if (raw === null) return;
556
+ const journal = parseLinuxInstallJournal(raw);
557
+ assertJournalAccess(journal.installIntent, installIntent);
558
+ if (journal.phase !== "definition-restored") {
559
+ throw new Error("systemd service definition rollback has not been prepared");
560
+ }
561
+ const execute = deps.execute ?? executeStep;
562
+
563
+ // Nothing may launch between restoring the command graph and resuming the
564
+ // recorded predecessor, so reassert the stopped boundary first.
565
+ assertSystemdStopped(false, ["--user", "stop", UNIT], execute);
566
+ const prior = journal.prior.exists
567
+ ? {
568
+ exists: true as const,
569
+ bytes: Buffer.from(journal.prior.bytesBase64, "base64"),
570
+ mode: journal.prior.mode,
571
+ }
572
+ : ({ exists: false } as const);
573
+ restoreDefinitionDurable(definition, prior, deps.definitionDirectorySync);
574
+ strictStep(execute, "systemctl", ["--user", "daemon-reload"]);
575
+ if (prior.exists) {
576
+ strictStep(execute, "systemctl", [
577
+ "--user",
578
+ journal.manager.enabled ? "enable" : "disable",
579
+ UNIT,
580
+ ]);
581
+ }
582
+ if (journal.manager.active) {
583
+ strictStep(execute, "systemctl", ["--user", "start", UNIT]);
584
+ }
585
+ removeFileDurable(journalPath);
586
+ }
587
+
588
+ export async function completeLinuxDefinitionInstall(
589
+ deps: LinuxInstallDeps = {},
590
+ installIntent?: string,
591
+ ): Promise<void> {
592
+ const definition = deps.definitionPath ?? unitPath();
593
+ const journalPath = deps.journalPath ?? linuxInstallJournalPath(definition);
594
+ const raw = readPrivateJournal(journalPath);
595
+ if (raw === null) return;
596
+ const journal = parseLinuxInstallJournal(raw);
597
+ assertJournalAccess(journal.installIntent, installIntent);
598
+ if (journal.phase !== "installed") {
599
+ throw new Error("cannot commit a service definition whose rollback was prepared");
600
+ }
601
+ removeFileDurable(journalPath);
602
+ }
603
+
604
+ function assertJournalAccess(
605
+ owner: LinuxInstallJournal["installIntent"],
606
+ installIntent?: string,
607
+ ): void {
608
+ if (owner === null) {
609
+ if (installIntent) {
610
+ throw new Error("service definition journal does not belong to this install intent");
611
+ }
612
+ return;
613
+ }
614
+ if (owner.nonce !== installIntent) {
615
+ throw new Error(
616
+ `service definition update is owned by managed installer PID ${owner.ownerPid}`,
617
+ );
618
+ }
619
+ }
620
+
621
+ export async function linuxDefinitionState(
622
+ managedCommand: string,
623
+ definition = unitPath(),
624
+ expectedManagedTarget = resolve(
625
+ homedir(),
626
+ ".uai-host",
627
+ "runtime",
628
+ "current",
629
+ "bin",
630
+ "uai-host",
631
+ ),
632
+ execute: StepExecutor = executeStep,
633
+ ): Promise<ServiceDefinitionState> {
634
+ const snapshot = snapshotDefinition(definition);
635
+ const loadedCommand = inspectSystemdLoadedCommand(execute, managedCommand);
636
+ if (!snapshot.exists && loadedCommand.loaded) {
637
+ throw new Error(
638
+ "systemd reports the host unit loaded without a recoverable unit file",
639
+ );
640
+ }
641
+ if (!snapshot.exists) {
642
+ return {
643
+ ownership: "missing",
644
+ manager: { loaded: false, active: false, enabled: false },
645
+ };
646
+ }
647
+ const managerState = inspectSystemdState(execute);
648
+ const manager: ServiceManagerState = {
649
+ loaded: loadedCommand.loaded,
650
+ active: managerState.active,
651
+ enabled: managerState.enabled,
652
+ };
653
+ const execStarts = snapshot.bytes
654
+ .toString("utf8")
655
+ .split(/\r?\n/)
656
+ .filter((line) => line.startsWith("ExecStart="));
657
+ const expected = `ExecStart=${[managedCommand, "run"].map(systemdQuote).join(" ")}`;
658
+ if (
659
+ execStarts.length === 1 &&
660
+ execStarts[0] === expected &&
661
+ managedCommandIsOwned(managedCommand, expectedManagedTarget) &&
662
+ loadedCommand.matches !== false
663
+ ) {
664
+ return { ownership: "managed", manager };
665
+ }
666
+ return { ownership: "legacy", manager };
667
+ }
668
+
669
+ export async function linuxDefinitionOwnership(
670
+ managedCommand: string,
671
+ definition = unitPath(),
672
+ expectedManagedTarget = resolve(
673
+ homedir(),
674
+ ".uai-host",
675
+ "runtime",
676
+ "current",
677
+ "bin",
678
+ "uai-host",
679
+ ),
680
+ execute: StepExecutor = executeStep,
681
+ ): Promise<"missing" | "managed" | "legacy"> {
682
+ return (
683
+ await linuxDefinitionState(
684
+ managedCommand,
685
+ definition,
686
+ expectedManagedTarget,
687
+ execute,
688
+ )
689
+ ).ownership;
690
+ }
691
+
692
+ function inspectSystemdLoadedCommand(
693
+ execute: StepExecutor,
694
+ managedCommand: string,
695
+ ): { loaded: boolean; matches: boolean | null } {
696
+ const loadArgs = ["--user", "show", UNIT, "--property=LoadState", "--value"];
697
+ const load = execute(false, "systemctl", loadArgs);
698
+ const loadState = load.stdout.trim();
699
+ if (typeof load.status !== "number") {
700
+ throw stepFailure(
701
+ "systemctl",
702
+ loadArgs,
703
+ load,
704
+ "could not determine the loaded host command",
705
+ );
706
+ }
707
+ if (load.status !== 0) {
708
+ if (/not[- ]found|could not be found|no such (?:file|unit)/i.test(
709
+ `${load.stdout}\n${load.stderr}`,
710
+ )) {
711
+ return { loaded: false, matches: null };
712
+ }
713
+ throw stepFailure(
714
+ "systemctl",
715
+ loadArgs,
716
+ load,
717
+ "could not determine the loaded host command",
718
+ );
719
+ }
720
+ if (loadState === "not-found") return { loaded: false, matches: null };
721
+ if (loadState !== "loaded") return { loaded: false, matches: false };
722
+
723
+ const execArgs = ["--user", "show", UNIT, "--property=ExecStart", "--value"];
724
+ const result = execute(false, "systemctl", execArgs);
725
+ if (typeof result.status !== "number") {
726
+ throw stepFailure(
727
+ "systemctl",
728
+ execArgs,
729
+ result,
730
+ "could not determine the loaded host command",
731
+ );
732
+ }
733
+ if (result.status !== 0) {
734
+ throw stepFailure(
735
+ "systemctl",
736
+ execArgs,
737
+ result,
738
+ "could not determine the loaded host command",
739
+ );
740
+ }
741
+ const records = [
742
+ ...result.stdout.matchAll(
743
+ /\{\s*path=([^;]*?)\s*;\s*argv\[\]=([^;]*?)\s*;/g,
744
+ ),
745
+ ];
746
+ return {
747
+ loaded: true,
748
+ matches:
749
+ records.length === 1 &&
750
+ records[0]?.[1] === managedCommand &&
751
+ records[0]?.[2] === `${managedCommand} run`,
752
+ };
753
+ }
754
+
755
+ function managedCommandIsOwned(command: string, expectedTarget: string): boolean {
756
+ try {
757
+ const stat = lstatSync(command);
758
+ if (!stat.isSymbolicLink()) return false;
759
+ const actual = resolve(dirname(command), readlinkSync(command));
760
+ return actual === expectedTarget;
761
+ } catch (error) {
762
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
763
+ throw error;
764
+ }
765
+ }
766
+
767
+ export async function uninstallLinuxService(
768
+ dryRun = false,
769
+ deps: LinuxServiceDeps = {},
770
+ ): Promise<void> {
771
+ if (!dryRun) await reconcileLinuxDefinitionInstall(deps);
772
+ const execute = deps.execute ?? executeStep;
773
+ assertSystemdStopped(
774
+ dryRun,
775
+ ["--user", "disable", "--now", UNIT],
776
+ execute,
777
+ );
80
778
  if (dryRun) {
81
779
  console.log(`$ rm -f ${unitPath()}`);
82
- step(true, "systemctl", ["--user", "daemon-reload"]);
780
+ execute(true, "systemctl", ["--user", "daemon-reload"]);
83
781
  return;
84
782
  }
85
- rmSync(unitPath(), { force: true });
86
- step(false, "systemctl", ["--user", "daemon-reload"], { ignoreError: true });
783
+ (deps.removeFile ?? ((path) => rmSync(path, { force: true })))(
784
+ deps.definitionPath ?? unitPath(),
785
+ );
786
+ const reloadArgs = ["--user", "daemon-reload"];
787
+ const reload = execute(false, "systemctl", reloadArgs);
788
+ if (reload.status !== 0) {
789
+ throw stepFailure("systemctl", reloadArgs, reload);
790
+ }
791
+ }
792
+
793
+ export async function startLinuxService(
794
+ dryRun = false,
795
+ deps: LinuxServiceDeps = {},
796
+ ): Promise<void> {
797
+ if (!dryRun) await reconcileLinuxDefinitionInstall(deps);
798
+ const args = ["--user", "enable", "--now", UNIT];
799
+ const result = (deps.execute ?? executeStep)(dryRun, "systemctl", args);
800
+ if (result.status !== 0) throw stepFailure("systemctl", args, result);
801
+ }
802
+
803
+ export async function stopLinuxService(
804
+ dryRun = false,
805
+ deps: LinuxServiceDeps = {},
806
+ ): Promise<void> {
807
+ if (!dryRun) await reconcileLinuxDefinitionInstall(deps);
808
+ assertSystemdStopped(
809
+ dryRun,
810
+ ["--user", "stop", UNIT],
811
+ deps.execute ?? executeStep,
812
+ );
813
+ }
814
+
815
+ export async function stopLinuxServiceForInstall(
816
+ installIntent: string,
817
+ priorManager: ServiceManagerState,
818
+ deps: LinuxServiceDeps = {},
819
+ ): Promise<void> {
820
+ assertServiceManagerState(priorManager);
821
+ const definition = deps.definitionPath ?? unitPath();
822
+ const journalPath = deps.journalPath ?? linuxInstallJournalPath(definition);
823
+ const raw = readPrivateJournal(journalPath);
824
+ if (raw !== null) {
825
+ const journal = parseLinuxInstallJournal(raw);
826
+ assertJournalAccess(journal.installIntent, installIntent);
827
+ }
828
+ // Disable before stopping so a pending automatic restart or reboot cannot
829
+ // relaunch the legacy definition after quiescence is durably acknowledged.
830
+ strictStep(deps.execute ?? executeStep, "systemctl", [
831
+ "--user",
832
+ "disable",
833
+ UNIT,
834
+ ]);
835
+ assertSystemdStopped(
836
+ false,
837
+ ["--user", "stop", UNIT],
838
+ deps.execute ?? executeStep,
839
+ );
87
840
  }
88
841
 
89
- async function start(dryRun = false): Promise<void> {
90
- step(dryRun, "systemctl", ["--user", "start", UNIT]);
842
+ export async function resumeLinuxServiceForInstall(
843
+ installIntent: string,
844
+ priorManager: ServiceManagerState,
845
+ deps: LinuxServiceDeps = {},
846
+ ): Promise<void> {
847
+ assertServiceManagerState(priorManager);
848
+ const definition = deps.definitionPath ?? unitPath();
849
+ const journalPath = deps.journalPath ?? linuxInstallJournalPath(definition);
850
+ const raw = readPrivateJournal(journalPath);
851
+ if (raw !== null) {
852
+ const journal = parseLinuxInstallJournal(raw);
853
+ assertJournalAccess(journal.installIntent, installIntent);
854
+ if (journal.phase === "installed") {
855
+ throw new Error("cannot resume the prior service before definition rollback");
856
+ }
857
+ await resumeLinuxDefinitionInstallRollback(deps, installIntent);
858
+ }
859
+ const execute = deps.execute ?? executeStep;
860
+ strictStep(execute, "systemctl", [
861
+ "--user",
862
+ priorManager.enabled ? "enable" : "disable",
863
+ UNIT,
864
+ ]);
865
+ if (priorManager.active) {
866
+ strictStep(execute, "systemctl", ["--user", "start", UNIT]);
867
+ }
91
868
  }
92
869
 
93
- async function stop(dryRun = false): Promise<void> {
94
- step(dryRun, "systemctl", ["--user", "stop", UNIT], { ignoreError: true });
870
+ function assertServiceManagerState(state: ServiceManagerState): void {
871
+ if (
872
+ typeof state.loaded !== "boolean" ||
873
+ typeof state.active !== "boolean" ||
874
+ typeof state.enabled !== "boolean" ||
875
+ (state.active && !state.loaded)
876
+ ) {
877
+ throw new Error("managed service lifecycle snapshot is invalid");
878
+ }
95
879
  }
96
880
 
97
- async function restart(dryRun = false): Promise<void> {
98
- step(dryRun, "systemctl", ["--user", "restart", UNIT]);
881
+ export async function restartLinuxService(
882
+ dryRun = false,
883
+ deps: LinuxServiceDeps = {},
884
+ ): Promise<void> {
885
+ if (!dryRun) await reconcileLinuxDefinitionInstall(deps);
886
+ const args = ["--user", "restart", UNIT];
887
+ const result = (deps.execute ?? executeStep)(dryRun, "systemctl", args);
888
+ if (result.status !== 0) throw stepFailure("systemctl", args, result);
99
889
  }
100
890
 
101
891
  async function status(): Promise<string> {
@@ -106,10 +896,23 @@ async function status(): Promise<string> {
106
896
  }
107
897
 
108
898
  export const installer: Installer = {
109
- install,
110
- uninstall,
111
- start,
112
- stop,
113
- restart,
899
+ install: installLinuxService,
900
+ uninstall: uninstallLinuxService,
901
+ start: startLinuxService,
902
+ stop: stopLinuxService,
903
+ stopForInstall: (installIntent, priorManager) =>
904
+ stopLinuxServiceForInstall(installIntent, priorManager),
905
+ resumeServiceForInstall: (installIntent, priorManager) =>
906
+ resumeLinuxServiceForInstall(installIntent, priorManager),
907
+ restart: restartLinuxService,
114
908
  status,
909
+ reconcileDefinitionInstall: (installIntent) =>
910
+ reconcileLinuxDefinitionInstall({}, installIntent),
911
+ prepareDefinitionInstallRollback: (installIntent) =>
912
+ prepareLinuxDefinitionInstallRollback({}, installIntent),
913
+ resumeDefinitionInstallRollback: (installIntent) =>
914
+ resumeLinuxDefinitionInstallRollback({}, installIntent),
915
+ completeDefinitionInstall: (installIntent) =>
916
+ completeLinuxDefinitionInstall({}, installIntent),
917
+ definitionState: linuxDefinitionState,
115
918
  };