@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
@@ -31,11 +31,26 @@ export interface TunnelUpstream {
31
31
  }
32
32
 
33
33
  type TunnelOp = (upstream: TunnelUpstream) => void;
34
+ type TunnelRelease = () => Promise<void>;
35
+
36
+ interface OpenTunnel {
37
+ readonly upstream: TunnelUpstream;
38
+ readonly release: TunnelRelease;
39
+ }
40
+
41
+ interface OpeningTunnel {
42
+ readonly ops: TunnelOp[];
43
+ readonly settled: Promise<void>;
44
+ finish(): void;
45
+ aborted: boolean;
46
+ }
34
47
 
35
48
  export class TunnelRegistry {
36
- private readonly open = new Map<string, TunnelUpstream>();
49
+ private readonly open = new Map<string, OpenTunnel>();
37
50
  /** tunnelId -> ops that arrived while its `tunnel.open` was still resolving. */
38
- private readonly opening = new Map<string, TunnelOp[]>();
51
+ private readonly opening = new Map<string, OpeningTunnel>();
52
+ /** Async provider teardown is joined by connection replacement/shutdown. */
53
+ private readonly pendingReleases = new Set<Promise<void>>();
39
54
 
40
55
  /**
41
56
  * Mark a tunnel as opening. MUST be called synchronously when `tunnel.open`
@@ -44,29 +59,61 @@ export class TunnelRegistry {
44
59
  * whole bug this class exists for.
45
60
  */
46
61
  markOpening(tunnelId: string): void {
47
- this.opening.set(tunnelId, []);
62
+ const previous = this.opening.get(tunnelId);
63
+ if (previous) {
64
+ previous.aborted = true;
65
+ previous.finish();
66
+ }
67
+ let finish!: () => void;
68
+ const settled = new Promise<void>((resolve) => {
69
+ finish = resolve;
70
+ });
71
+ this.opening.set(tunnelId, {
72
+ ops: [],
73
+ settled,
74
+ finish,
75
+ aborted: false,
76
+ });
48
77
  }
49
78
 
50
- /** Target resolved: publish the upstream and replay what arrived meanwhile. */
51
- register(tunnelId: string, upstream: TunnelUpstream): void {
52
- this.open.set(tunnelId, upstream);
53
- const queued = this.opening.get(tunnelId);
79
+ /** Target resolved: publish the upstream and replay what arrived meanwhile.
80
+ * Returns whether this upstream now owns the tunnel — false means the open
81
+ * was canceled during resolution and the upstream was destroyed, so the
82
+ * caller must not write to it (a stale generation must never emit bytes). */
83
+ register(
84
+ tunnelId: string,
85
+ upstream: TunnelUpstream,
86
+ release: TunnelRelease = async () => {},
87
+ ): boolean {
88
+ const pending = this.opening.get(tunnelId);
54
89
  this.opening.delete(tunnelId);
55
- if (!queued) return;
56
- for (const op of queued) op(upstream);
90
+ pending?.finish();
91
+ const releaseOnce = onceAsync(release);
92
+ if (pending?.aborted) {
93
+ upstream.destroy();
94
+ this.trackRelease(releaseOnce());
95
+ return false;
96
+ }
97
+ this.open.set(tunnelId, { upstream, release: releaseOnce });
98
+ if (!pending) return true;
99
+ for (const op of pending.ops) op(upstream);
100
+ return true;
57
101
  }
58
102
 
59
103
  /** Open failed (no target, or it errored) — nothing will ever service these. */
60
104
  abandon(tunnelId: string): void {
105
+ this.opening.get(tunnelId)?.finish();
61
106
  this.opening.delete(tunnelId);
62
107
  }
63
108
 
64
109
  get(tunnelId: string): TunnelUpstream | undefined {
65
- return this.open.get(tunnelId);
110
+ return this.open.get(tunnelId)?.upstream;
66
111
  }
67
112
 
68
113
  delete(tunnelId: string): void {
114
+ const current = this.open.get(tunnelId);
69
115
  this.open.delete(tunnelId);
116
+ if (current) this.trackRelease(current.release());
70
117
  }
71
118
 
72
119
  /**
@@ -76,12 +123,13 @@ export class TunnelRegistry {
76
123
  * race.
77
124
  */
78
125
  apply(tunnelId: string, op: TunnelOp): void {
79
- const upstream = this.open.get(tunnelId);
80
- if (upstream) {
81
- op(upstream);
126
+ const current = this.open.get(tunnelId);
127
+ if (current) {
128
+ op(current.upstream);
82
129
  return;
83
130
  }
84
- this.opening.get(tunnelId)?.push(op);
131
+ const pending = this.opening.get(tunnelId);
132
+ if (pending && !pending.aborted) pending.ops.push(op);
85
133
  }
86
134
 
87
135
  /**
@@ -91,14 +139,82 @@ export class TunnelRegistry {
91
139
  * at worst an unhandled error on a socket nobody is listening to any more.
92
140
  */
93
141
  abort(tunnelId: string): void {
94
- const upstream = this.open.get(tunnelId);
142
+ const current = this.open.get(tunnelId);
95
143
  this.open.delete(tunnelId);
96
- if (upstream) {
97
- upstream.destroy();
144
+ if (current) {
145
+ current.upstream.destroy();
146
+ this.trackRelease(current.release());
98
147
  return;
99
148
  }
100
- if (this.opening.has(tunnelId)) {
101
- this.opening.set(tunnelId, [(u) => u.destroy()]);
149
+ const pending = this.opening.get(tunnelId);
150
+ if (pending) {
151
+ pending.ops.length = 0;
152
+ pending.aborted = true;
102
153
  }
103
154
  }
155
+
156
+ /** Destroy every upstream from one bridge generation and wait until opening
157
+ * targets have either failed or registered and their provider resources are
158
+ * released. No SSH forward may outlive the WebSocket generation that owns
159
+ * it — but reconnect must not be hostage to one wedged open, so the join is
160
+ * bounded: entries are already marked aborted, their eventual register()
161
+ * destroys the upstream and releases provider resources, and the releases
162
+ * stay tracked for the next generation's join. */
163
+ async abortAll(timeoutMs = 15_000): Promise<void> {
164
+ for (const pending of this.opening.values()) {
165
+ pending.ops.length = 0;
166
+ pending.aborted = true;
167
+ }
168
+ for (const [tunnelId, current] of this.open) {
169
+ this.open.delete(tunnelId);
170
+ current.upstream.destroy();
171
+ this.trackRelease(current.release());
172
+ }
173
+ let timer: NodeJS.Timeout | undefined;
174
+ const expired = new Promise<"timeout">((resolve) => {
175
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
176
+ timer.unref?.();
177
+ });
178
+ try {
179
+ while (this.opening.size > 0 || this.pendingReleases.size > 0) {
180
+ const opening = [...this.opening.values()].map((entry) => entry.settled);
181
+ const outcome = await Promise.race([
182
+ Promise.all([...opening, ...this.pendingReleases]).then(
183
+ () => "settled" as const,
184
+ ),
185
+ expired,
186
+ ]);
187
+ if (outcome === "timeout") {
188
+ console.warn(
189
+ `[tunnel] abortAll: ${this.opening.size} opening and ` +
190
+ `${this.pendingReleases.size} releasing tunnels did not settle ` +
191
+ `within ${timeoutMs}ms; continuing while they drain`,
192
+ );
193
+ return;
194
+ }
195
+ }
196
+ } finally {
197
+ clearTimeout(timer);
198
+ }
199
+ }
200
+
201
+ private trackRelease(release: Promise<void>): void {
202
+ // A rejected provider release must neither crash the process (a bare
203
+ // `.finally()` chain rethrows with no handler attached) nor poison the
204
+ // abortAll() join above — cleanup failure is logged, never fatal.
205
+ const settled = release.catch((error: unknown) => {
206
+ console.warn(
207
+ `[tunnel] upstream release failed: ${
208
+ error instanceof Error ? error.message : String(error)
209
+ }`,
210
+ );
211
+ });
212
+ this.pendingReleases.add(settled);
213
+ void settled.finally(() => this.pendingReleases.delete(settled));
214
+ }
215
+ }
216
+
217
+ function onceAsync(operation: TunnelRelease): TunnelRelease {
218
+ let result: Promise<void> | null = null;
219
+ return () => (result ??= operation());
104
220
  }
@@ -0,0 +1,235 @@
1
+ import { isIP } from "node:net";
2
+
3
+ import { dockerCli, type DockerResult } from "./docker-exec";
4
+ import { reprobeContainerRuntimeMachineIdentity } from "./container-runtime";
5
+
6
+ type DockerRunner = (
7
+ args: string[],
8
+ options?: { timeoutMs?: number },
9
+ ) => Promise<DockerResult>;
10
+
11
+ export type TunnelContainerProof =
12
+ | {
13
+ kind: "running";
14
+ containerId: string;
15
+ published: { host: string; port: number } | null;
16
+ containerIp: string | null;
17
+ network: string | null;
18
+ labels: Record<string, string>;
19
+ }
20
+ | { kind: "unavailable" }
21
+ | { kind: "daemon-unavailable"; detail: string };
22
+
23
+ interface DockerContainerInspection {
24
+ Id?: unknown;
25
+ Name?: unknown;
26
+ Config?: { Labels?: Record<string, unknown> | null };
27
+ State?: { Running?: unknown };
28
+ NetworkSettings?: {
29
+ Ports?: Record<
30
+ string,
31
+ Array<{ HostIp?: unknown; HostPort?: unknown }> | null
32
+ >;
33
+ Networks?: Record<string, { IPAddress?: unknown }>;
34
+ };
35
+ }
36
+
37
+ const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
38
+ const COMPOSE_SERVICE_LABEL = "com.docker.compose.service";
39
+ const CONTAINER_ID = /^[a-f0-9]{64}$/;
40
+
41
+ /**
42
+ * Prove that an exact container is running and resolve its current routes.
43
+ *
44
+ * Persisted host ports and cached container IPs are unsafe after an unnoticed
45
+ * daemon restart: Docker may remap the app, and an unrelated local process (or
46
+ * another container) can inherit the old address. Every tunnel open crosses
47
+ * this proof before bytes are forwarded.
48
+ */
49
+ export async function inspectTunnelContainer(
50
+ container: string,
51
+ containerPort: number,
52
+ deps: { docker?: DockerRunner } = {},
53
+ ): Promise<TunnelContainerProof> {
54
+ const run = deps.docker ?? dockerCli;
55
+ const inspected = await run(
56
+ ["inspect", "--format", "{{json .}}", container],
57
+ { timeoutMs: 5_000 },
58
+ );
59
+ if (inspected.status !== 0) {
60
+ if (!deps.docker) {
61
+ return (await reprobeContainerRuntimeMachineIdentity()) === null
62
+ ? { kind: "daemon-unavailable", detail: "docker did not answer" }
63
+ : { kind: "unavailable" };
64
+ }
65
+ const daemon = await run(
66
+ ["info", "--format", "{{.ServerVersion}}"],
67
+ { timeoutMs: 5_000 },
68
+ );
69
+ if (daemon.status !== 0 || !daemon.stdout.trim()) {
70
+ return {
71
+ kind: "daemon-unavailable",
72
+ // This reaches cloud-visible command errors. Docker's own diagnostic
73
+ // can contain the pinned unix socket (and therefore a host username),
74
+ // so keep endpoint details in Docker's local logs rather than the wire.
75
+ detail: "docker did not answer",
76
+ };
77
+ }
78
+ return { kind: "unavailable" };
79
+ }
80
+
81
+ let value: DockerContainerInspection;
82
+ try {
83
+ value = JSON.parse(inspected.stdout.trim()) as DockerContainerInspection;
84
+ } catch {
85
+ return { kind: "unavailable" };
86
+ }
87
+ if (value.State?.Running !== true) return { kind: "unavailable" };
88
+
89
+ const containerId = exactContainerId(value.Id);
90
+ if (!containerId) return { kind: "unavailable" };
91
+ const address = firstContainerAddress(value);
92
+
93
+ return {
94
+ kind: "running",
95
+ containerId,
96
+ published: publishedAddress(value, containerPort),
97
+ containerIp: address?.ip ?? null,
98
+ network: address?.network ?? null,
99
+ labels: stringLabels(value.Config?.Labels),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Stronger proof for editor/preview routing into a task's app. Docker's exact
105
+ * name lookup is necessary but not sufficient: a recreated or foreign
106
+ * container can reuse it. Bind the route to Compose's project/service labels,
107
+ * the full container ID, and only the expected project default network.
108
+ */
109
+ export async function inspectTaskAppTunnelContainer(
110
+ request: {
111
+ readonly container: string;
112
+ readonly composeProject: string;
113
+ readonly containerPort: number;
114
+ },
115
+ deps: { docker?: DockerRunner } = {},
116
+ ): Promise<TunnelContainerProof> {
117
+ const run = deps.docker ?? dockerCli;
118
+ const inspected = await run(
119
+ ["inspect", "--format", "{{json .}}", request.container],
120
+ { timeoutMs: 5_000 },
121
+ );
122
+ if (inspected.status !== 0) {
123
+ if (!deps.docker) {
124
+ return (await reprobeContainerRuntimeMachineIdentity()) === null
125
+ ? { kind: "daemon-unavailable", detail: "docker did not answer" }
126
+ : { kind: "unavailable" };
127
+ }
128
+ const daemon = await run(
129
+ ["info", "--format", "{{.ServerVersion}}"],
130
+ { timeoutMs: 5_000 },
131
+ );
132
+ if (daemon.status !== 0 || !daemon.stdout.trim()) {
133
+ return { kind: "daemon-unavailable", detail: "docker did not answer" };
134
+ }
135
+ return { kind: "unavailable" };
136
+ }
137
+
138
+ let value: DockerContainerInspection;
139
+ try {
140
+ value = JSON.parse(inspected.stdout.trim()) as DockerContainerInspection;
141
+ } catch {
142
+ return { kind: "unavailable" };
143
+ }
144
+ const labels = stringLabels(value.Config?.Labels);
145
+ const containerId = exactContainerId(value.Id);
146
+ const network = `${request.composeProject}_default`;
147
+ const ip = exactNetworkAddress(value, network);
148
+ if (
149
+ value.State?.Running !== true ||
150
+ value.Name !== `/${request.container}` ||
151
+ !containerId ||
152
+ labels[COMPOSE_PROJECT_LABEL] !== request.composeProject ||
153
+ labels[COMPOSE_SERVICE_LABEL] !== "app" ||
154
+ !ip
155
+ ) {
156
+ return { kind: "unavailable" };
157
+ }
158
+ return {
159
+ kind: "running",
160
+ containerId,
161
+ published: publishedAddress(value, request.containerPort),
162
+ containerIp: ip,
163
+ network,
164
+ labels,
165
+ };
166
+ }
167
+
168
+ function exactContainerId(value: unknown): string | null {
169
+ return typeof value === "string" && CONTAINER_ID.test(value) ? value : null;
170
+ }
171
+
172
+ function stringLabels(
173
+ labels: Record<string, unknown> | null | undefined,
174
+ ): Record<string, string> {
175
+ if (!labels) return {};
176
+ return Object.fromEntries(
177
+ Object.entries(labels).filter(
178
+ (entry): entry is [string, string] => typeof entry[1] === "string",
179
+ ),
180
+ );
181
+ }
182
+
183
+ function publishedAddress(
184
+ value: DockerContainerInspection,
185
+ containerPort: number,
186
+ ): { host: string; port: number } | null {
187
+ const bindings = value.NetworkSettings?.Ports?.[`${containerPort}/tcp`];
188
+ if (!Array.isArray(bindings)) return null;
189
+ const candidates = bindings.flatMap((binding) => {
190
+ const port = parsePort(binding.HostPort);
191
+ const host = loopbackForBinding(binding.HostIp);
192
+ return port && host ? [{ host, port }] : [];
193
+ });
194
+ return (
195
+ candidates.find((candidate) => candidate.host === "127.0.0.1") ??
196
+ candidates[0] ??
197
+ null
198
+ );
199
+ }
200
+
201
+ function loopbackForBinding(value: unknown): string | null {
202
+ if (value === "127.0.0.1" || value === "0.0.0.0" || value === "") {
203
+ return "127.0.0.1";
204
+ }
205
+ if (value === "::1" || value === "::") return "::1";
206
+ return null;
207
+ }
208
+
209
+ function parsePort(value: unknown): number | null {
210
+ if (typeof value !== "string" || !/^\d{1,5}$/.test(value)) return null;
211
+ const port = Number(value);
212
+ return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : null;
213
+ }
214
+
215
+ function firstContainerAddress(
216
+ value: DockerContainerInspection,
217
+ ): { network: string; ip: string } | null {
218
+ const networks = value.NetworkSettings?.Networks;
219
+ if (!networks || typeof networks !== "object") return null;
220
+ for (const [name, network] of Object.entries(networks)) {
221
+ const address = network?.IPAddress;
222
+ if (typeof address === "string" && isIP(address) !== 0) {
223
+ return { network: name, ip: address };
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function exactNetworkAddress(
230
+ value: DockerContainerInspection,
231
+ network: string,
232
+ ): string | null {
233
+ const address = value.NetworkSettings?.Networks?.[network]?.IPAddress;
234
+ return typeof address === "string" && isIP(address) !== 0 ? address : null;
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.13",
3
+ "version": "0.9.42",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -36,7 +36,14 @@ UAI_LOG_DIR="${UAI_LOG_DIR:-$UAI_DATA_DIR/logs}"
36
36
  mkdir -p "$UAI_LOG_DIR"
37
37
 
38
38
  # Optional dependencies — fail fast with a structured error if missing.
39
- for bin in sqlite3 jq docker git; do
39
+ # docker is only a dependency of the docker runtime mode: the Apple runtime
40
+ # path must work on a Docker-free Mac (ADR-106), and its own CLI is validated
41
+ # by validate_task_runtime_selection instead.
42
+ agent_required_bins="sqlite3 jq git"
43
+ if [ "${UAI_TASK_RUNTIME:-docker}" != "apple-container" ]; then
44
+ agent_required_bins="$agent_required_bins docker"
45
+ fi
46
+ for bin in $agent_required_bins; do
40
47
  if ! command -v "$bin" >/dev/null 2>&1; then
41
48
  printf '{"ok":false,"error":{"code":"MISSING_DEPENDENCY","message":"%s not found on PATH"}}\n' "$bin" >&2
42
49
  exit 127
@@ -72,7 +79,7 @@ emit_ok() {
72
79
  # logs an `error` event, prints structured JSON to stderr, and exits 1.
73
80
  emit_err() {
74
81
  local code="$1" msg="$2" step="${3:-${UAI_CURRENT_STEP:-}}"
75
- _uai_mark_error "$code" "$step"
82
+ _uai_record_failure "$code" "$step"
76
83
  local json
77
84
  if [ -n "$step" ]; then
78
85
  json=$(jq -nc --arg c "$code" --arg m "$msg" --arg s "$step" \
@@ -85,6 +92,42 @@ emit_err() {
85
92
  exit 1
86
93
  }
87
94
 
95
+ # Docker can disappear after the TypeScript admission check but before a
96
+ # minutes-long compose/init command finishes. These codes describe steps whose
97
+ # failure may be a daemon outage. Keep the command snapshot retryable here; the
98
+ # host process re-probes `docker info` and records a terminal error only when
99
+ # the daemon is positively reachable and the operation itself really failed.
100
+ _uai_is_runtime_sensitive_error() {
101
+ case "$1" in
102
+ STANDARD_IMAGE_MISSING|COMPOSE_UP_FAILED|COMPOSE_DOWN_FAILED|CONTAINER_INIT_FAILED|DOCKER_PS_FAILED)
103
+ return 0
104
+ ;;
105
+ *)
106
+ return 1
107
+ ;;
108
+ esac
109
+ }
110
+
111
+ _uai_mark_retryable() {
112
+ if [ -n "${UAI_TASK_ID:-}" ] && [ -n "${UAI_DB_PATH:-}" ]; then
113
+ local restore=""
114
+ if [ -n "${UAI_RETRY_STATUS:-}" ]; then
115
+ restore=", status='$(sql_escape "$UAI_RETRY_STATUS")'"
116
+ fi
117
+ sqlite_exec "UPDATE uai_tasks SET locked_at=NULL${restore}, updated_at=(unixepoch()*1000) WHERE id='$(sql_escape "$UAI_TASK_ID")'" 2>/dev/null || true
118
+ fi
119
+ UAI_LOCK_HELD=""
120
+ }
121
+
122
+ _uai_record_failure() {
123
+ local code="$1" step="$2"
124
+ if _uai_is_runtime_sensitive_error "$code"; then
125
+ _uai_mark_retryable
126
+ else
127
+ _uai_mark_error "$code" "$step"
128
+ fi
129
+ }
130
+
88
131
  # _uai_mark_error <code> <step> — best-effort persist of status=error.
89
132
  # Shared between emit_err (explicit) and the ERR trap (set -e).
90
133
  _uai_mark_error() {
@@ -204,6 +247,7 @@ UAI_CURRENT_STEP=""
204
247
  UAI_ERROR_CODE="UNKNOWN"
205
248
  UAI_TASK_ID=""
206
249
  UAI_LOCK_HELD=""
250
+ UAI_RETRY_STATUS=""
207
251
  UAI_PROJECT_LOCK_PATH=""
208
252
  UAI_PROJECT_LOCK_TOKEN=""
209
253
 
@@ -282,7 +326,7 @@ _uai_on_err() {
282
326
  local code="$UAI_ERROR_CODE"
283
327
  local stepname="$UAI_CURRENT_STEP"
284
328
 
285
- _uai_mark_error "$code" "$stepname"
329
+ _uai_record_failure "$code" "$stepname"
286
330
 
287
331
  local out
288
332
  out=$(jq -nc --arg c "$code" --arg m "agent step failed" --arg s "$stepname" --argjson rc "$rc" \
@@ -310,6 +354,82 @@ compose_project_for_task() { printf 'task-%s' "$(_uai_lower "$1")"; }
310
354
  # ever do scale, swap to discovering the container name via the
311
355
  # `com.docker.compose.project` + `com.docker.compose.service` labels.
312
356
  app_container_for_task() { printf 'task-%s-app-1' "$(_uai_lower "$1")"; }
357
+ # ADR-106: the Apple runtime has no Compose, so its app container drops the
358
+ # replica suffix. Must match appleTaskContainerName in
359
+ # lib/task-environment/apple-container.ts.
360
+ apple_app_container_for_task() { printf 'task-%s-app' "$(_uai_lower "$1")"; }
361
+
362
+ # -----------------------------------------------------------------------------
363
+ # ADR-106 container runtime selection. The host publishes the selected backend
364
+ # in UAI_TASK_RUNTIME (+ UAI_CONTAINER_CLI for the bundled Apple toolchain);
365
+ # `docker` keeps every historical path byte-for-byte.
366
+ # -----------------------------------------------------------------------------
367
+ TASK_RUNTIME_MODE="${UAI_TASK_RUNTIME:-docker}"
368
+ CONTAINER_CLI="${UAI_CONTAINER_CLI:-}"
369
+
370
+ validate_task_runtime_selection() {
371
+ case "$TASK_RUNTIME_MODE" in
372
+ docker|apple-container) ;;
373
+ *)
374
+ emit_err "BAD_ARGS" \
375
+ "unsupported UAI_TASK_RUNTIME '$TASK_RUNTIME_MODE'" \
376
+ "select container runtime"
377
+ ;;
378
+ esac
379
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
380
+ case "$CONTAINER_CLI" in
381
+ /*) ;;
382
+ *)
383
+ emit_err "BAD_ARGS" \
384
+ "UAI_CONTAINER_CLI must be an absolute path to the bundled container CLI" \
385
+ "select container runtime"
386
+ ;;
387
+ esac
388
+ if [ -L "$CONTAINER_CLI" ] || [ ! -f "$CONTAINER_CLI" ] \
389
+ || [ ! -x "$CONTAINER_CLI" ]; then
390
+ emit_err "BAD_ARGS" \
391
+ "the bundled container CLI at $CONTAINER_CLI is missing or not executable" \
392
+ "select container runtime"
393
+ fi
394
+ fi
395
+ }
396
+
397
+ # Single dispatcher for the verbs whose flags are identical across Docker and
398
+ # the Apple CLI (exec/cp/stop/inspect/image inspect/volume create). Verbs that
399
+ # differ (compose, run, build, port) branch explicitly at their call sites.
400
+ crt() {
401
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
402
+ "$CONTAINER_CLI" "$@"
403
+ else
404
+ docker "$@"
405
+ fi
406
+ }
407
+
408
+ # Absence proof for the Apple runtime. The CLI exits nonzero for BOTH a
409
+ # missing container and a broken apiserver; only the not-found error that
410
+ # names this exact container proves absence. Everything else proves nothing
411
+ # and must never authorize destructive work.
412
+ apple_container_proven_absent() {
413
+ apple_absent_name="$1"
414
+ # One CLI response supplies the whole proof: nonzero exit, empty stdout,
415
+ # and the complete not-found error line naming this exact container (never
416
+ # a substring — a not-found answer for "$name-shadow" must not prove the
417
+ # absence of "$name"). Two separate invocations could each satisfy half
418
+ # while no single response satisfies both.
419
+ apple_absent_dir=$(mktemp -d "${TMPDIR:-/tmp}/uai-absent.XXXXXX") \
420
+ || return 1
421
+ apple_absent_rc=0
422
+ "$CONTAINER_CLI" inspect "$apple_absent_name" \
423
+ >"$apple_absent_dir/out" 2>"$apple_absent_dir/err" \
424
+ || apple_absent_rc=$?
425
+ apple_absent_proven=1
426
+ [ "$apple_absent_rc" -ne 0 ] || apple_absent_proven=0
427
+ [ ! -s "$apple_absent_dir/out" ] || apple_absent_proven=0
428
+ grep -Fqx "Error: container not found: $apple_absent_name" \
429
+ "$apple_absent_dir/err" 2>/dev/null || apple_absent_proven=0
430
+ rm -rf "$apple_absent_dir"
431
+ [ "$apple_absent_proven" -eq 1 ]
432
+ }
313
433
 
314
434
  # Canonicalise every GitHub URL shape to credential-free HTTPS. task-up selects
315
435
  # the credential per task: the creator's connected GitHub token is primary;