@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
@@ -15,6 +15,15 @@ export interface InstallContext {
15
15
  args: string[];
16
16
  /** Absolute working directory for the service. */
17
17
  cwd: string;
18
+ /** PATH captured for the service. Managed installs prepend the stable shim. */
19
+ envPath?: string;
20
+ /** Register the service without launching it (fresh, unenrolled hosts). */
21
+ startOnInstall?: boolean;
22
+ /** Public install transaction which owns this definition replacement. */
23
+ definitionInstallIntent?: {
24
+ readonly nonce: string;
25
+ readonly ownerPid: number;
26
+ };
18
27
  /**
19
28
  * When true, print the unit file + the load commands instead of executing.
20
29
  * Used by CI tests (no launchd/systemd/winsw side effects).
@@ -22,6 +31,20 @@ export interface InstallContext {
22
31
  dryRun: boolean;
23
32
  }
24
33
 
34
+ export interface ServiceManagerState {
35
+ /** The canonical job/unit is currently loaded by the service manager. */
36
+ readonly loaded: boolean;
37
+ /** A daemon is active or the manager has an in-flight activation/restart. */
38
+ readonly active: boolean;
39
+ /** The job/unit is enabled for a future login or reboot. */
40
+ readonly enabled: boolean;
41
+ }
42
+
43
+ export interface ServiceDefinitionState {
44
+ readonly ownership: "missing" | "managed" | "legacy";
45
+ readonly manager: ServiceManagerState;
46
+ }
47
+
25
48
  export interface Installer {
26
49
  /** Write the unit/plist/xml and load it. */
27
50
  install(ctx: InstallContext): Promise<void>;
@@ -29,7 +52,27 @@ export interface Installer {
29
52
  uninstall(dryRun?: boolean): Promise<void>;
30
53
  start(dryRun?: boolean): Promise<void>;
31
54
  stop(dryRun?: boolean): Promise<void>;
55
+ /** Owner-authorized stop used before a first managed conversion. */
56
+ stopForInstall(
57
+ installIntent: string,
58
+ priorManager: ServiceManagerState,
59
+ ): Promise<void>;
60
+ /** Owner-authorized idempotent resume after command-graph rollback. */
61
+ resumeServiceForInstall(
62
+ installIntent: string,
63
+ priorManager: ServiceManagerState,
64
+ ): Promise<void>;
32
65
  restart(dryRun?: boolean): Promise<void>;
33
66
  /** Human-readable one-line status (e.g. "running (pid 1234)"). */
34
67
  status(): Promise<string>;
68
+ /** Resume/rollback an interrupted transactional definition replacement. */
69
+ reconcileDefinitionInstall(installIntent?: string): Promise<void>;
70
+ /** Restore definition/static manager state without launching the prior command. */
71
+ prepareDefinitionInstallRollback(installIntent: string): Promise<void>;
72
+ /** Resume the recorded prior service after runtime commands have been restored. */
73
+ resumeDefinitionInstallRollback(installIntent: string): Promise<void>;
74
+ /** Commit a definition replacement after the enclosing install attests. */
75
+ completeDefinitionInstall(installIntent?: string): Promise<void>;
76
+ /** Classify the canonical definition and snapshot its manager lifecycle state. */
77
+ definitionState(managedCommand: string): Promise<ServiceDefinitionState>;
35
78
  }
@@ -5,23 +5,230 @@
5
5
  */
6
6
 
7
7
  import { spawnSync } from "node:child_process";
8
+ import { randomBytes } from "node:crypto";
9
+ import {
10
+ closeSync,
11
+ constants,
12
+ fchmodSync,
13
+ fstatSync,
14
+ fsyncSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readSync,
18
+ readFileSync,
19
+ renameSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { dirname, basename, join } from "node:path";
8
24
 
9
- export function step(
25
+ export interface StepResult {
26
+ status: number | null;
27
+ stdout: string;
28
+ stderr: string;
29
+ error?: Error;
30
+ }
31
+
32
+ export type StepExecutor = (
10
33
  dryRun: boolean,
11
34
  cmd: string,
12
35
  args: string[],
13
- opts: { ignoreError?: boolean } = {},
14
- ): string {
36
+ ) => StepResult;
37
+
38
+ export type DefinitionSnapshot =
39
+ | { readonly exists: false }
40
+ | { readonly exists: true; readonly bytes: Buffer; readonly mode: number };
41
+
42
+ export const MAX_SERVICE_DEFINITION_BYTES = 1024 * 1024;
43
+ export const MAX_SERVICE_INSTALL_JOURNAL_BYTES = 2 * 1024 * 1024;
44
+
45
+ export type DirectorySync = (directory: string) => void;
46
+
47
+ function readBounded(fd: number, maximumBytes: number, description: string): Buffer {
48
+ const bytes = Buffer.allocUnsafe(maximumBytes + 1);
49
+ let length = 0;
50
+ while (length < bytes.length) {
51
+ const read = readSync(fd, bytes, length, bytes.length - length, null);
52
+ if (read === 0) break;
53
+ length += read;
54
+ }
55
+ if (length > maximumBytes) {
56
+ throw new Error(`${description} exceeds ${maximumBytes} bytes`);
57
+ }
58
+ return bytes.subarray(0, length);
59
+ }
60
+
61
+ /** Read a service definition without following a surprising symlink. */
62
+ export function snapshotDefinition(file: string): DefinitionSnapshot {
63
+ let fd: number;
64
+ try {
65
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
66
+ } catch (error) {
67
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
68
+ throw error;
69
+ }
70
+ try {
71
+ const stat = fstatSync(fd);
72
+ if (!stat.isFile()) {
73
+ throw new Error(`service definition is not a regular file: ${file}`);
74
+ }
75
+ if (stat.size > MAX_SERVICE_DEFINITION_BYTES) {
76
+ throw new Error(`service definition exceeds ${MAX_SERVICE_DEFINITION_BYTES} bytes: ${file}`);
77
+ }
78
+ const bytes = readBounded(
79
+ fd,
80
+ MAX_SERVICE_DEFINITION_BYTES,
81
+ `service definition ${file}`,
82
+ );
83
+ return {
84
+ exists: true,
85
+ bytes,
86
+ mode: stat.mode & 0o7777,
87
+ };
88
+ } finally {
89
+ closeSync(fd);
90
+ }
91
+ }
92
+
93
+ /** Atomically replace a definition and sync both its bytes and directory entry. */
94
+ export function writeDefinitionDurable(
95
+ file: string,
96
+ contents: string | Buffer,
97
+ mode = 0o644,
98
+ syncDirectoryEntry: DirectorySync = syncDirectoryDurable,
99
+ ): void {
100
+ const directory = dirname(file);
101
+ mkdirSync(directory, { recursive: true });
102
+ const temporary = join(
103
+ directory,
104
+ `.${basename(file)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
105
+ );
106
+ let fd: number | undefined;
107
+ try {
108
+ fd = openSync(
109
+ temporary,
110
+ constants.O_WRONLY |
111
+ constants.O_CREAT |
112
+ constants.O_EXCL |
113
+ (constants.O_NOFOLLOW ?? 0),
114
+ 0o600,
115
+ );
116
+ writeFileSync(fd, contents);
117
+ fchmodSync(fd, mode);
118
+ fsyncSync(fd);
119
+ closeSync(fd);
120
+ fd = undefined;
121
+ renameSync(temporary, file);
122
+ syncDirectoryEntry(directory);
123
+ } finally {
124
+ if (fd !== undefined) closeSync(fd);
125
+ rmSync(temporary, { force: true });
126
+ }
127
+ }
128
+
129
+ export function restoreDefinitionDurable(
130
+ file: string,
131
+ snapshot: DefinitionSnapshot,
132
+ syncDirectoryEntry: DirectorySync = syncDirectoryDurable,
133
+ ): void {
134
+ if (snapshot.exists) {
135
+ writeDefinitionDurable(file, snapshot.bytes, snapshot.mode, syncDirectoryEntry);
136
+ return;
137
+ }
138
+ rmSync(file, { force: true });
139
+ syncDirectoryEntry(dirname(file));
140
+ }
141
+
142
+ export function readPrivateJournal(file: string): string | null {
143
+ let fd: number;
144
+ try {
145
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
146
+ } catch (error) {
147
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
148
+ throw error;
149
+ }
150
+ try {
151
+ const stat = fstatSync(fd);
152
+ if (!stat.isFile()) throw new Error(`service install journal is not regular: ${file}`);
153
+ if ((stat.mode & 0o077) !== 0) {
154
+ throw new Error(`service install journal permissions are too broad: ${file}`);
155
+ }
156
+ if (stat.size > MAX_SERVICE_INSTALL_JOURNAL_BYTES) {
157
+ throw new Error(
158
+ `service install journal exceeds ${MAX_SERVICE_INSTALL_JOURNAL_BYTES} bytes: ${file}`,
159
+ );
160
+ }
161
+ return readBounded(
162
+ fd,
163
+ MAX_SERVICE_INSTALL_JOURNAL_BYTES,
164
+ `service install journal ${file}`,
165
+ ).toString("utf8");
166
+ } finally {
167
+ closeSync(fd);
168
+ }
169
+ }
170
+
171
+ export function removeFileDurable(file: string): void {
172
+ rmSync(file, { force: true });
173
+ syncDirectoryDurable(dirname(file));
174
+ }
175
+
176
+ export function syncDirectoryDurable(directory: string): void {
177
+ const fd = openSync(directory, constants.O_RDONLY);
178
+ try {
179
+ fsyncSync(fd);
180
+ } finally {
181
+ closeSync(fd);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Run a service-manager command without deciding whether its failure is safe.
187
+ * Stop/uninstall need the complete result so they can distinguish an absent
188
+ * unit from a manager failure or a daemon which is still live.
189
+ */
190
+ export const executeStep: StepExecutor = (dryRun, cmd, args) => {
15
191
  if (dryRun) {
16
192
  console.log(`$ ${cmd} ${args.join(" ")}`);
17
- return "";
193
+ return { status: 0, stdout: "", stderr: "" };
18
194
  }
19
195
  const res = spawnSync(cmd, args, { encoding: "utf8" });
20
- if (res.status !== 0 && !opts.ignoreError) {
21
- const detail = (res.stderr || res.stdout || res.error?.message || "").trim();
22
- throw new Error(`\`${cmd} ${args.join(" ")}\` failed: ${detail}`);
196
+ return {
197
+ status: res.status,
198
+ stdout: res.stdout ?? "",
199
+ stderr: res.stderr ?? "",
200
+ error: res.error,
201
+ };
202
+ };
203
+
204
+ export function stepFailure(
205
+ cmd: string,
206
+ args: string[],
207
+ result: StepResult,
208
+ context?: string,
209
+ ): Error {
210
+ const detail = (
211
+ result.stderr ||
212
+ result.stdout ||
213
+ result.error?.message ||
214
+ `exit status ${String(result.status)}`
215
+ ).trim();
216
+ return new Error(
217
+ `\`${cmd} ${args.join(" ")}\` failed: ${detail}${context ? ` (${context})` : ""}`,
218
+ );
219
+ }
220
+
221
+ export function step(
222
+ dryRun: boolean,
223
+ cmd: string,
224
+ args: string[],
225
+ opts: { ignoreError?: boolean } = {},
226
+ ): string {
227
+ const result = executeStep(dryRun, cmd, args);
228
+ if (result.status !== 0 && !opts.ignoreError) {
229
+ throw stepFailure(cmd, args, result);
23
230
  }
24
- return res.stdout ?? "";
231
+ return result.stdout;
25
232
  }
26
233
 
27
234
  /** Print a unit/plist/xml file's path + contents (dry-run). */
@@ -125,6 +125,18 @@ export const installer: Installer = {
125
125
  uninstall,
126
126
  start,
127
127
  stop,
128
+ stopForInstall: async () => undefined,
129
+ resumeServiceForInstall: async () => undefined,
128
130
  restart,
129
131
  status,
132
+ // Standalone managed runtimes currently reject Windows before service
133
+ // installation, so there is no Windows definition journal to reconcile.
134
+ reconcileDefinitionInstall: async () => undefined,
135
+ prepareDefinitionInstallRollback: async () => undefined,
136
+ resumeDefinitionInstallRollback: async () => undefined,
137
+ completeDefinitionInstall: async () => undefined,
138
+ definitionState: async () => ({
139
+ ownership: "missing",
140
+ manager: { loaded: false, active: false, enabled: false },
141
+ }),
130
142
  };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ADR-106 Apple tunnel routing, extracted from main.ts so its two safety
3
+ * properties are directly testable: the identity proof and the TCP connect
4
+ * both happen inside the task's lifecycle slot (a concurrent
5
+ * taskDown/recreate cannot free and reuse the address between proof and
6
+ * connection), and a readiness-epoch change during the awaited connect
7
+ * destroys the socket rather than carrying a dead generation's proof out.
8
+ */
9
+
10
+ export interface AppleRouteSocket {
11
+ connect(port: number, host: string): void;
12
+ once(event: "connect" | "error", listener: () => void): void;
13
+ destroy(): void;
14
+ }
15
+
16
+ export interface AppleTunnelRouteDeps {
17
+ getTask(taskId: string): { statusMirror: string | null } | undefined;
18
+ reconstruct(task: unknown): Promise<{
19
+ inspectRoute?: () => Promise<
20
+ | { kind: "running"; ipv4Address: string }
21
+ | { kind: "unavailable" }
22
+ | { kind: "unknown"; detail: string }
23
+ >;
24
+ } | null>;
25
+ runTaskLifecycle<T>(taskId: string, work: () => Promise<T>): Promise<T>;
26
+ runtimeProblem(): unknown;
27
+ readinessEpoch(): number;
28
+ epochIsCurrent(epoch: number): boolean;
29
+ poison(detail: string): void;
30
+ createSocket(): AppleRouteSocket;
31
+ connectTimeoutMs?: number;
32
+ }
33
+
34
+ export interface AppleTunnelRouteRequest {
35
+ taskId: string;
36
+ target: string;
37
+ containerPort?: number;
38
+ }
39
+
40
+ export async function resolveAppleTunnelRoute(
41
+ request: AppleTunnelRouteRequest,
42
+ deps: AppleTunnelRouteDeps,
43
+ ): Promise<{ host: string; port: number; socket: AppleRouteSocket } | null> {
44
+ const applePort =
45
+ request.target === "editor" ? 8080 : request.containerPort;
46
+ if (!applePort) return null;
47
+ if (deps.getTask(request.taskId)?.statusMirror !== "running") return null;
48
+ return deps.runTaskLifecycle(request.taskId, async () => {
49
+ const current = deps.getTask(request.taskId);
50
+ if (!current || current.statusMirror !== "running") return null;
51
+ let route:
52
+ | { kind: "running"; ipv4Address: string }
53
+ | { kind: "unavailable" }
54
+ | { kind: "unknown"; detail: string }
55
+ | null = null;
56
+ try {
57
+ const handle = await deps.reconstruct(current);
58
+ if (handle === null || handle.inspectRoute === undefined) return null;
59
+ route = await handle.inspectRoute();
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (route === null) return null;
64
+ if (route.kind === "unknown") {
65
+ // The CLI answered with neither presence nor absence — the same signal
66
+ // class as a Docker daemon that stopped responding mid-tunnel.
67
+ deps.poison(route.detail);
68
+ return null;
69
+ }
70
+ if (route.kind !== "running") return null;
71
+ if (deps.runtimeProblem()) return null;
72
+ const routeEpoch = deps.readinessEpoch();
73
+ const upstream = deps.createSocket();
74
+ const connected = await new Promise<boolean>((resolveConnect) => {
75
+ let settled = false;
76
+ const settle = (ok: boolean): void => {
77
+ if (settled) return;
78
+ settled = true;
79
+ clearTimeout(deadline);
80
+ resolveConnect(ok);
81
+ };
82
+ const deadline = setTimeout(() => {
83
+ upstream.destroy();
84
+ settle(false);
85
+ }, deps.connectTimeoutMs ?? 10_000);
86
+ deadline.unref?.();
87
+ upstream.once("connect", () => settle(true));
88
+ upstream.once("error", () => settle(false));
89
+ upstream.connect(applePort, route.ipv4Address);
90
+ });
91
+ if (
92
+ !connected ||
93
+ // The awaited connect is a window: a forced recheck can invalidate the
94
+ // runtime mid-wait, and a proof from a dead generation must not carry
95
+ // a live socket out of the slot.
96
+ !deps.epochIsCurrent(routeEpoch) ||
97
+ deps.runtimeProblem()
98
+ ) {
99
+ upstream.destroy();
100
+ return null;
101
+ }
102
+ return { host: route.ipv4Address, port: applePort, socket: upstream };
103
+ });
104
+ }