@runuai/host 0.9.120 → 0.9.121

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.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * ADR-133 sidecar mode: the task environment IS this machine.
3
+ *
4
+ * The runner runs on the task's own EC2 machine (`uai-host run --task`), so
5
+ * there is no container to exec into and no ssh hop to make: every request
6
+ * runs as a local child process, as user `node` (the runner itself is root
7
+ * so it can drop privileges, mount S3, and own the workspace), in the same
8
+ * shape the machine provider sends over ssh. `machine-task-up.ts` (clone,
9
+ * editor, browser) and `agents/machine-durable.ts` (durable agent sessions)
10
+ * take a handle, not a transport, so they run unchanged on top of this one.
11
+ *
12
+ * What is deliberately NOT here: launch/stop/terminate. The cloud owns the
13
+ * machine's lifecycle (hostless-implementation.md §7); `stop` and `teardown`
14
+ * are no-ops because a parked or closed task ends with the cloud stopping or
15
+ * terminating the instance this process lives on.
16
+ */
17
+ import { spawn as nodeSpawn } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import { readFile } from "node:fs/promises";
20
+
21
+ import { buildRemoteCommand } from "../machine-exec";
22
+ import { capturedCliExec, streamingCliExec } from "./docker";
23
+ import type { MachineTaskInput } from "./machine";
24
+ import {
25
+ assertTaskEnvironmentProcessRequest,
26
+ assertTaskEnvironmentSessionRequest,
27
+ assertWorkspaceScopedPath,
28
+ type TaskEnvironmentCopyRequest,
29
+ type TaskEnvironmentDescriptor,
30
+ type TaskEnvironmentDetachedSessionRequest,
31
+ type TaskEnvironmentExecRequest,
32
+ type TaskEnvironmentExecResult,
33
+ type TaskEnvironmentHandle,
34
+ type TaskEnvironmentLocator,
35
+ type TaskEnvironmentPort,
36
+ type TaskEnvironmentProcess,
37
+ type TaskEnvironmentProvider,
38
+ type TaskEnvironmentProvisionRequest,
39
+ type TaskEnvironmentProvisioned,
40
+ type TaskEnvironmentRecoveryContext,
41
+ type TaskEnvironmentRecoveryResult,
42
+ type TaskEnvironmentSessionRequest,
43
+ type TaskEnvironmentSpawnRequest,
44
+ type TaskEnvironmentStatus,
45
+ TASK_ENVIRONMENT_LOCATOR_SCHEMA_VERSION,
46
+ } from "./types";
47
+
48
+ export const SELF_TASK_ENVIRONMENT_PROVIDER = "self";
49
+
50
+ /** The task user every agent, editor, and shell runs as. */
51
+ export const SELF_TASK_USER = "node";
52
+
53
+ const SAFE_TASK_ID = /^[a-z0-9]{1,32}$/i;
54
+
55
+ export interface SelfTaskEnvironmentLocatorValue {
56
+ taskId: string;
57
+ /** Absolute workspace path on this machine. */
58
+ workspacePath: string;
59
+ }
60
+
61
+ export interface SelfTaskEnvironmentDeps {
62
+ spawn?: typeof nodeSpawn;
63
+ /** Whether this process may drop privileges with setpriv (root). Tests and
64
+ * a non-root dev run keep everything as the current user. */
65
+ privileged?: () => boolean;
66
+ }
67
+
68
+ export function selfTaskEnvironmentLocator(
69
+ value: SelfTaskEnvironmentLocatorValue,
70
+ ): TaskEnvironmentLocator {
71
+ return {
72
+ schemaVersion: TASK_ENVIRONMENT_LOCATOR_SCHEMA_VERSION,
73
+ provider: SELF_TASK_ENVIRONMENT_PROVIDER,
74
+ value: { taskId: value.taskId, workspacePath: value.workspacePath },
75
+ };
76
+ }
77
+
78
+ export function parseSelfTaskEnvironmentLocator(
79
+ locator: TaskEnvironmentLocator,
80
+ ): SelfTaskEnvironmentLocatorValue {
81
+ if (
82
+ locator.schemaVersion !== TASK_ENVIRONMENT_LOCATOR_SCHEMA_VERSION ||
83
+ locator.provider !== SELF_TASK_ENVIRONMENT_PROVIDER ||
84
+ typeof locator.value !== "object" ||
85
+ locator.value === null
86
+ ) {
87
+ throw new Error("not a self task environment locator");
88
+ }
89
+ const value = locator.value as Record<string, unknown>;
90
+ if (
91
+ typeof value.taskId !== "string" ||
92
+ !SAFE_TASK_ID.test(value.taskId) ||
93
+ typeof value.workspacePath !== "string" ||
94
+ !value.workspacePath.startsWith("/") ||
95
+ value.workspacePath.includes("\0")
96
+ ) {
97
+ throw new Error("malformed self task environment locator");
98
+ }
99
+ return { taskId: value.taskId, workspacePath: value.workspacePath };
100
+ }
101
+
102
+ /**
103
+ * The local command for a request: the same `cd … && exec env … argv` shell
104
+ * line the machine provider ships over ssh, run through setpriv when this
105
+ * process is root and the request wants a task user. Values ride argv here
106
+ * exactly as they ride the ssh command line on a machine task — the machine
107
+ * is one task's trust domain (machine-exec.ts).
108
+ */
109
+ export function selfCommand(
110
+ request: {
111
+ argv: readonly string[];
112
+ cwd?: string;
113
+ user?: string;
114
+ inheritEnv?: readonly string[];
115
+ env?: Readonly<Record<string, string>>;
116
+ },
117
+ mode: "interactive" | "detached",
118
+ privileged: boolean,
119
+ ): { command: string; args: string[] } {
120
+ const line = buildRemoteCommand({ ...request, detached: mode === "detached" });
121
+ const user = request.user ?? SELF_TASK_USER;
122
+ if (privileged && user !== "root") {
123
+ return {
124
+ command: "/usr/bin/setpriv",
125
+ args: [
126
+ `--reuid=${user}`,
127
+ `--regid=${user}`,
128
+ "--init-groups",
129
+ "--reset-env",
130
+ "--",
131
+ "/bin/sh",
132
+ "-lc",
133
+ line,
134
+ ],
135
+ };
136
+ }
137
+ return { command: "/bin/sh", args: ["-lc", line] };
138
+ }
139
+
140
+ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
141
+ readonly descriptor: TaskEnvironmentDescriptor;
142
+ readonly durableIdentity: string;
143
+ readonly #value: SelfTaskEnvironmentLocatorValue;
144
+ readonly #spawn: typeof nodeSpawn;
145
+ readonly #privileged: () => boolean;
146
+
147
+ constructor(locator: TaskEnvironmentLocator, deps: SelfTaskEnvironmentDeps) {
148
+ this.#value = parseSelfTaskEnvironmentLocator(locator);
149
+ this.#spawn = deps.spawn ?? nodeSpawn;
150
+ this.#privileged =
151
+ deps.privileged ?? (() => typeof process.getuid === "function" && process.getuid() === 0);
152
+ this.durableIdentity = `self:${this.#value.taskId}`;
153
+ this.descriptor = {
154
+ taskId: this.#value.taskId,
155
+ locator,
156
+ workspacePath: this.#value.workspacePath,
157
+ };
158
+ }
159
+
160
+ async readWorkspaceFile(path: string): Promise<Uint8Array | null> {
161
+ assertWorkspaceScopedPath(this.descriptor, path);
162
+ try {
163
+ return new Uint8Array(await readFile(path));
164
+ } catch (error) {
165
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ async writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void> {
171
+ assertWorkspaceScopedPath(this.descriptor, path);
172
+ // Through the task user, so the file is owned like everything else the
173
+ // agents touch (a root-owned file inside the workspace is a trap).
174
+ const result = await this.exec({
175
+ argv: ["/bin/sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "write", path],
176
+ stdin: bytes,
177
+ timeoutMs: 120_000,
178
+ maxOutputBytes: 1024 * 1024,
179
+ });
180
+ if (result.exitCode !== 0) {
181
+ throw new Error(
182
+ `workspace write failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
183
+ );
184
+ }
185
+ }
186
+
187
+ async exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
188
+ assertTaskEnvironmentProcessRequest(request);
189
+ const { command, args } = selfCommand(request, "interactive", this.#privileged());
190
+ return capturedCliExec(this.#spawn, command, args, request);
191
+ }
192
+
193
+ async spawn(request: TaskEnvironmentSpawnRequest): Promise<TaskEnvironmentProcess> {
194
+ assertTaskEnvironmentProcessRequest(request);
195
+ const { command, args } = selfCommand(request, "interactive", this.#privileged());
196
+ return streamingCliExec(this.#spawn, command, args, request);
197
+ }
198
+
199
+ async spawnSession(request: TaskEnvironmentSessionRequest): Promise<TaskEnvironmentProcess> {
200
+ assertTaskEnvironmentSessionRequest(request);
201
+ const session = { ...request, cwd: request.cwd ?? this.#value.workspacePath };
202
+ const { command, args } = selfCommand(session, "interactive", this.#privileged());
203
+ return streamingCliExec(this.#spawn, command, args, session);
204
+ }
205
+
206
+ async launchDetachedSession(
207
+ request: TaskEnvironmentDetachedSessionRequest,
208
+ ): Promise<TaskEnvironmentExecResult> {
209
+ assertTaskEnvironmentSessionRequest(request);
210
+ const session = { ...request, cwd: request.cwd ?? this.#value.workspacePath };
211
+ const { command, args } = selfCommand(session, "detached", this.#privileged());
212
+ return capturedCliExec(this.#spawn, command, args, {
213
+ ...session,
214
+ timeoutMs: session.launchTimeoutMs,
215
+ });
216
+ }
217
+
218
+ async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
219
+ if (
220
+ !request.source.startsWith("/") ||
221
+ !request.destination.startsWith("/") ||
222
+ request.source.includes("\0") ||
223
+ request.destination.includes("\0")
224
+ ) {
225
+ throw new Error("self copy paths must be absolute and NUL-free");
226
+ }
227
+ // Both sides are this machine. `cp -a` keeps directory trees intact
228
+ // (engine-account homes), and the result is chowned to the task user so
229
+ // the agents can read what was copied in for them.
230
+ const script =
231
+ request.direction === "into"
232
+ ? 'mkdir -p "$(dirname "$2")" && cp -a "$1" "$2" && chown -R node:node "$2"'
233
+ : 'mkdir -p "$(dirname "$2")" && cp -a "$1" "$2"';
234
+ const result = await this.exec({
235
+ argv: ["/bin/sh", "-c", script, "copy", request.source, request.destination],
236
+ user: "root",
237
+ timeoutMs: 5 * 60_000,
238
+ maxOutputBytes: 64 * 1024,
239
+ });
240
+ if (result.exitCode !== 0) {
241
+ throw new Error(
242
+ `self copy failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
243
+ );
244
+ }
245
+ }
246
+
247
+ async ports(): Promise<TaskEnvironmentPort[]> {
248
+ return [];
249
+ }
250
+
251
+ /** Every tunnel dials loopback: the editor and preview servers listen on
252
+ * this machine, and the runner that answers the tunnel is this process. */
253
+ async inspectRoute(): Promise<{ kind: "running"; ipv4Address: string }> {
254
+ return { kind: "running", ipv4Address: "127.0.0.1" };
255
+ }
256
+
257
+ async status(): Promise<TaskEnvironmentStatus> {
258
+ return {
259
+ state: "running",
260
+ instances: [this.durableIdentity],
261
+ workspacePresent: existsSync(this.#value.workspacePath),
262
+ };
263
+ }
264
+
265
+ async recover(
266
+ context: TaskEnvironmentRecoveryContext,
267
+ ): Promise<TaskEnvironmentRecoveryResult> {
268
+ await context.maintenanceReady;
269
+ if (!context.isCurrent()) {
270
+ return { outcome: "deferred", detail: "runtime generation superseded" };
271
+ }
272
+ context.allowConsumers?.();
273
+ await context.ensureConsumersStarted?.();
274
+ if (!context.isCurrent()) {
275
+ await context.quarantineConsumers?.();
276
+ return { outcome: "deferred", detail: "runtime generation superseded" };
277
+ }
278
+ return { outcome: "recovered", status: await this.status() };
279
+ }
280
+
281
+ /** Parking is the cloud stopping this instance; nothing to do locally. */
282
+ async stop(): Promise<void> {}
283
+
284
+ /** Closing is the cloud terminating this instance; the workspace dies
285
+ * with it. Nothing is deleted here so a close that races a late read (a
286
+ * final diff, a transcript flush) still finds the files. */
287
+ async teardown(): Promise<void> {}
288
+ }
289
+
290
+ export function createSelfTaskEnvironmentProvider(
291
+ deps: SelfTaskEnvironmentDeps = {},
292
+ ): TaskEnvironmentProvider<MachineTaskInput, void, TaskEnvironmentDescriptor> {
293
+ return {
294
+ kind: SELF_TASK_ENVIRONMENT_PROVIDER,
295
+
296
+ // Takes the machine input shape so `createMachineHostTaskEnvironmentProvider`
297
+ // can sit on top unchanged; image/cpus/memory describe a machine the cloud
298
+ // already launched and are ignored here.
299
+ async provision(
300
+ request: TaskEnvironmentProvisionRequest<MachineTaskInput, void>,
301
+ ): Promise<TaskEnvironmentProvisioned<TaskEnvironmentDescriptor>> {
302
+ const { taskId, input } = request;
303
+ if (!SAFE_TASK_ID.test(taskId)) {
304
+ throw new Error("self provisioning refused an unsafe task id");
305
+ }
306
+ const workspacePath = input.workspacePath ?? "/workspace";
307
+ const locator = selfTaskEnvironmentLocator({ taskId, workspacePath });
308
+ await request.onPrepared?.(locator);
309
+ const handle = new SelfTaskEnvironmentHandle(locator, deps);
310
+ const prepare = await handle.exec({
311
+ argv: ["/bin/sh", "-c", 'mkdir -p "$1" && chown node:node "$1"', "prepare", workspacePath],
312
+ user: "root",
313
+ timeoutMs: 30_000,
314
+ maxOutputBytes: 64 * 1024,
315
+ });
316
+ if (prepare.exitCode !== 0) {
317
+ throw new Error(
318
+ `workspace preparation failed: ${Buffer.from(prepare.stderr).toString("utf8").trim()}`,
319
+ );
320
+ }
321
+ if (input.repo) {
322
+ const clone = await handle.exec({
323
+ argv: [
324
+ "/usr/bin/git",
325
+ "clone",
326
+ ...(input.repo.branch ? ["--branch", input.repo.branch] : []),
327
+ input.repo.url,
328
+ workspacePath,
329
+ ],
330
+ timeoutMs: 10 * 60_000,
331
+ maxOutputBytes: 4 * 1024 * 1024,
332
+ });
333
+ if (clone.exitCode !== 0) {
334
+ throw new Error(
335
+ `clone failed: ${Buffer.from(clone.stderr).toString("utf8").trim()}`,
336
+ );
337
+ }
338
+ }
339
+ return { handle, result: handle.descriptor };
340
+ },
341
+
342
+ async reconstruct(locator: TaskEnvironmentLocator): Promise<TaskEnvironmentHandle> {
343
+ return new SelfTaskEnvironmentHandle(locator, deps);
344
+ },
345
+ };
346
+ }
@@ -446,3 +446,16 @@ function assertJsonValue(value: unknown, depth: number): void {
446
446
  }
447
447
  throw new Error("task environment locator is not JSON-serializable");
448
448
  }
449
+
450
+ /**
451
+ * Providers whose workspace is reached only through the environment
452
+ * transport — a machine over ssh (ADR-121) or this very machine in sidecar
453
+ * mode (ADR-133) — as opposed to a container the host can `docker exec` into
454
+ * or bind-mount. Every "is this a machine task?" branch in the host means
455
+ * exactly this, so it asks here rather than comparing one provider name.
456
+ */
457
+ export function isEnvironmentTransportProvider(
458
+ provider: string | null | undefined,
459
+ ): boolean {
460
+ return provider === "machine" || provider === "self";
461
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.120",
3
+ "version": "0.9.121",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/cli.ts CHANGED
@@ -132,7 +132,7 @@ async function main(): Promise<void> {
132
132
  const follow = rest.includes("--follow") || rest.includes("-f");
133
133
  switch (cmd) {
134
134
  case "run":
135
- return cmdRun();
135
+ return cmdRun(rest);
136
136
  case "status":
137
137
  return cmdStatus();
138
138
  case "open":
@@ -190,7 +190,12 @@ function timestampServiceLogs(): void {
190
190
  }
191
191
  }
192
192
 
193
- async function cmdRun(): Promise<void> {
193
+ async function cmdRun(rest: string[] = []): Promise<void> {
194
+ // ADR-133 sidecar mode: `uai-host run --task <id>` on the task's own
195
+ // machine. The flag becomes the environment the rest of the process asks
196
+ // (lib/sidecar.ts) — set before main.ts loads, since its boot reads it.
197
+ const sidecarTask = flagValue(rest, "--task");
198
+ if (sidecarTask) process.env.UAI_SIDECAR_TASK = sidecarTask;
194
199
  // Timestamped service logs: the ADR-106 live smoke burned an hour dating
195
200
  // undated scrollback. Applies to the service process only, not one-shot CLI
196
201
  // commands.
package/src/index.ts CHANGED
@@ -184,6 +184,7 @@ export type {
184
184
  PreviewPortDeclaration,
185
185
  PreviewPortRuntime,
186
186
  } from "../lib/preview-ports";
187
+ import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
187
188
 
188
189
  export const hostEvents: HostEventStream = {
189
190
  subscribe(handler) {
@@ -479,7 +480,7 @@ export const hostCommands: HostCommands = {
479
480
  }
480
481
  } else if (
481
482
  existingTask?.statusMirror === "running" &&
482
- existingTask.environmentProvider === "machine"
483
+ isEnvironmentTransportProvider(existingTask.environmentProvider)
483
484
  ) {
484
485
  // ADR-121: a machine-backed task revalidates through its own
485
486
  // provider — the compose fast path below speaks docker and would
@@ -894,7 +895,7 @@ export const hostCommands: HostCommands = {
894
895
  // the compose reconciler would probe for an app container, find none,
895
896
  // and silently skip. Reconnect re-injection for machines is a
896
897
  // follow-up alongside the machine recovery pass.
897
- if (getHostTask(input.task.id)?.environmentProvider === "machine") {
898
+ if (isEnvironmentTransportProvider(getHostTask(input.task.id)?.environmentProvider)) {
898
899
  void machineTaskGithubAuth(
899
900
  input.task.id,
900
901
  input.task.ownerUserId,
@@ -1005,7 +1006,7 @@ export const hostCommands: HostCommands = {
1005
1006
  // with no consumer (owner, 2026-08-29 — 16 parked disks after one
1006
1007
  // evening of testing). The task branch lives on the remote; the
1007
1008
  // machine's disk is not a preservation surface once the task ends.
1008
- if (environment.descriptor.locator.provider === "machine") {
1009
+ if (isEnvironmentTransportProvider(environment.descriptor.locator.provider)) {
1009
1010
  stopMachineGatewayTunnel(input.taskId);
1010
1011
  }
1011
1012
  return taskDownResultForInput(input, await environment.teardown());
@@ -1437,7 +1438,7 @@ export const hostCommands: HostCommands = {
1437
1438
  // tasks keep the direct host-FS write (their workspace is a bind
1438
1439
  // mount and the sync path is cheaper than a docker exec per message).
1439
1440
  const task = getHostTask(taskId);
1440
- if (task?.environmentProvider === "machine") {
1441
+ if (isEnvironmentTransportProvider(task?.environmentProvider)) {
1441
1442
  const environment = await reconstructHostTaskEnvironment(taskId);
1442
1443
  if (environment) {
1443
1444
  await appendTranscriptViaEnvironment(
package/src/main.ts CHANGED
@@ -248,6 +248,10 @@ import {
248
248
  isEngineAccountLabel,
249
249
  isEngineConnectKind,
250
250
  } from "./protocol";
251
+ import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
252
+ import { LeaseManager } from "../lib/lease";
253
+ import { sidecarMode, sidecarTaskId } from "../lib/sidecar";
254
+ import { declareAgentClisReady } from "../lib/standard-image";
251
255
 
252
256
  setContainerRuntimeOperationalCheck(() => containerRuntimeProblem() === null);
253
257
 
@@ -457,24 +461,53 @@ migrateHostDb();
457
461
  // credential store. Import what this host lacks and upload what was never
458
462
  // mirrored, off the startup path — task-up resolves locally either way.
459
463
  configureEngineAccountStore({ hostId });
460
- void reconcileEngineAccountsFromStore({ hostId })
461
- .then((r) => {
462
- if (r.skipped) return;
463
- console.log(
464
- `[engine-accounts] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
465
- );
466
- })
467
- .catch((error: unknown) => {
468
- console.warn(
469
- `[engine-accounts] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
470
- );
471
- });
464
+ /** The credential-store reconciles. At boot on a host with its own AWS
465
+ * identity; after the first lease on a sidecar (ADR-133), which has no
466
+ * credentials before that. */
467
+ function reconcileStores(): void {
468
+ void reconcileEngineAccountsFromStore({ hostId })
469
+ .then((r) => {
470
+ if (r.skipped) return;
471
+ console.log(
472
+ `[engine-accounts] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
473
+ );
474
+ })
475
+ .catch((error: unknown) => {
476
+ console.warn(
477
+ `[engine-accounts] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
478
+ );
479
+ });
480
+ void reconcileUserCredentialsFromStore()
481
+ .then((r) => {
482
+ if (r.skipped) return;
483
+ console.log(
484
+ `[user-credentials] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
485
+ );
486
+ })
487
+ .catch((error: unknown) => {
488
+ console.warn(
489
+ `[user-credentials] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
490
+ );
491
+ });
492
+ void reconcileProjectEnvFromStore()
493
+ .then((r) => {
494
+ if (r.skipped) return;
495
+ console.log(
496
+ `[project-env] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
497
+ );
498
+ })
499
+ .catch((error: unknown) => {
500
+ console.warn(
501
+ `[project-env] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
502
+ );
503
+ });
504
+ }
472
505
  // Image + agent-CLI maintenance runs where the compute is owned: an owned
473
506
  // host keeps its own image current on an hourly schedule (a task-up reuses
474
507
  // the last pass instead of blocking on one); a Uai-operated host sets
475
508
  // UAI_IMAGE_MAINTENANCE=external — a cloud job keeps the payload AMI fresh —
476
- // and reconciles only once, at start.
477
- if (env.UAI_IMAGE_MAINTENANCE === "host") {
509
+ // and reconciles only once, at start. A sidecar (ADR-133) has no image.
510
+ if (env.UAI_IMAGE_MAINTENANCE === "host" && !sidecarMode()) {
478
511
  const maintenanceTimer = setInterval(() => {
479
512
  void ensureStandardImage().catch(() => undefined);
480
513
  }, MAINTENANCE_INTERVAL_MS);
@@ -489,38 +522,50 @@ const repushBrokeredCopies = async (kind: string): Promise<void> => {
489
522
  await getOrchestrator().refreshEngineAccountCopies(kind);
490
523
  };
491
524
  configureTokenBroker({ repush: repushBrokeredCopies });
492
- startTokenBrokerLoop();
493
- startCredentialStorePickup({ repush: repushBrokeredCopies });
494
- void reconcileUserCredentialsFromStore()
495
- .then((r) => {
496
- if (r.skipped) return;
497
- console.log(
498
- `[user-credentials] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
499
- );
500
- })
501
- .catch((error: unknown) => {
502
- console.warn(
503
- `[user-credentials] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
504
- );
505
- });
506
- void reconcileProjectEnvFromStore()
507
- .then((r) => {
508
- if (r.skipped) return;
509
- console.log(
510
- `[project-env] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
511
- );
512
- })
513
- .catch((error: unknown) => {
514
- console.warn(
515
- `[project-env] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
516
- );
517
- });
525
+ // ADR-133: a sidecar never refreshes sign-ins (the broker Lambda does), and
526
+ // reads the store only once it holds a lease — see leaseManager below.
527
+ if (!sidecarMode()) {
528
+ startTokenBrokerLoop();
529
+ startCredentialStorePickup({ repush: repushBrokeredCopies });
530
+ reconcileStores();
531
+ }
532
+ /**
533
+ * ADR-133 sidecar: the task-scoped AWS lease. Requested when the bridge is
534
+ * ready, refreshed before expiry; the first grant is what lets the store
535
+ * reconciles and the S3 file mounts run. A denial means the task is no
536
+ * longer ours to run — exit 3 and let the cloud's sweep reclaim the machine.
537
+ */
538
+ const leaseManager: LeaseManager | null = sidecarMode()
539
+ ? new LeaseManager({
540
+ taskId: sidecarTaskId()!,
541
+ send: (frame) => {
542
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
543
+ send(ws, frame);
544
+ return true;
545
+ },
546
+ onFirstGrant: () => {
547
+ console.log("[lease] granted — reconciling the credential store");
548
+ reconcileStores();
549
+ startCredentialStorePickup({ repush: repushBrokeredCopies });
550
+ },
551
+ onGrant: (lease) => {
552
+ console.log(
553
+ `[lease] holds until ${new Date(lease.expiresAt).toISOString()}`,
554
+ );
555
+ },
556
+ onDenied: (error) => {
557
+ console.error(`[lease] denied: ${error} — this machine no longer runs its task`);
558
+ process.exit(3);
559
+ },
560
+ })
561
+ : null;
562
+ if (sidecarMode()) declareAgentClisReady();
518
563
  // Reconcile a durable retention-off setting before recovery/connect can make
519
564
  // crash-left owner-facing terminal history observable again. Correctness
520
565
  // tombstones remain intact inside applyTerminalHistoryRetention.
521
566
  applyTerminalHistoryRetention();
522
567
  try {
523
- await configureBundledContainerRuntime();
568
+ if (!sidecarMode()) await configureBundledContainerRuntime();
524
569
  } catch {
525
570
  // A malformed/tampered active generation must never fall back to PATH or an
526
571
  // inferred asset layout. Keep Docker-first operation available and surface
@@ -529,7 +574,17 @@ try {
529
574
  "[host-agent] signed bundled runtime validation failed; the bundled runtime is disabled",
530
575
  );
531
576
  }
532
- const initialRuntime = await initializeContainerRuntime();
577
+ const initialRuntime: Awaited<ReturnType<typeof initializeContainerRuntime>> =
578
+ sidecarMode()
579
+ ? // ADR-133: a task machine runs its agents directly; nothing here ever
580
+ // dials a container runtime.
581
+ {
582
+ status: "no-runtime",
583
+ preference: "auto",
584
+ message: "sidecar mode — no container runtime on a task machine",
585
+ action: "none: a task machine runs its agents directly",
586
+ }
587
+ : await initializeContainerRuntime();
533
588
  // Do not touch the selected daemon merely because detection succeeded. The
534
589
  // activation gate below must first prove that every durable TaskEnvironment
535
590
  // belongs to this exact provider/endpoint. The manager's first admitted login
@@ -612,12 +667,16 @@ const stopAgentClisReadySubscription = onAgentClisReady(() =>
612
667
  void connect();
613
668
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
614
669
  // Best-effort: a UI bind failure must not take the host service down.
615
- void startLocalUi();
670
+ // A task machine has no operator sitting at it (ADR-133): no local UI, no
671
+ // Mac vnode watch.
672
+ if (!sidecarMode()) void startLocalUi();
616
673
  // ADR-118 follow-up: vnode-pressure observability (macOS/apple-container
617
674
  // hosts). Logs only on band transitions; the sensor caches its sysctl read.
618
- const vnodePressureTimer = setInterval(() => void logVnodePressure(), 60_000);
619
- vnodePressureTimer.unref?.();
620
- void logVnodePressure();
675
+ if (!sidecarMode()) {
676
+ const vnodePressureTimer = setInterval(() => void logVnodePressure(), 60_000);
677
+ vnodePressureTimer.unref?.();
678
+ void logVnodePressure();
679
+ }
621
680
 
622
681
  async function startLocalUi(): Promise<void> {
623
682
  try {
@@ -717,6 +776,7 @@ function buildCapabilities(): HostCapabilities {
717
776
  },
718
777
  }
719
778
  : {}),
779
+ ...(sidecarTaskId() ? { sidecar: { taskId: sidecarTaskId()! } } : {}),
720
780
  maintenanceReady: areAgentClisReady(),
721
781
  mcpGateway: mcpGateway.state(),
722
782
  engineLogins: engineLoginManager.capabilities(),
@@ -1206,6 +1266,7 @@ async function connect(): Promise<void> {
1206
1266
  reconnectAttempt = 0;
1207
1267
  markConnected();
1208
1268
  console.log(`[host-agent] connected as ${hostId}`);
1269
+ leaseManager?.reconnected();
1209
1270
  setHostObsTag(hostId);
1210
1271
  addHostBreadcrumb("bridge", "connected");
1211
1272
 
@@ -1405,6 +1466,10 @@ async function connect(): Promise<void> {
1405
1466
  case "tunnel.close":
1406
1467
  closeTunnel(socket, frame.tunnelId, frame.reason);
1407
1468
  break;
1469
+ case "lease.grant":
1470
+ case "lease.deny":
1471
+ leaseManager?.handle(frame);
1472
+ break;
1408
1473
  case "gh.connect.set": {
1409
1474
  const runtimeProblem = containerRuntimeProblem();
1410
1475
  if (runtimeProblem) {
@@ -2141,7 +2206,7 @@ async function resolveTunnelTarget(
2141
2206
  // operational. After a daemon restart an unrelated local process could bind
2142
2207
  // a stale port before recovery refreshes the task's mappings.
2143
2208
  const machineTask = getHostTask(frame.taskId);
2144
- if (machineTask?.environmentProvider === "machine") {
2209
+ if (machineTask && isEnvironmentTransportProvider(machineTask.environmentProvider)) {
2145
2210
  // ADR-121: a machine's address is orchestrator-routable (bridge IP on
2146
2211
  // the Linux guinea pig, VPC-private on EC2) — editor and previews dial
2147
2212
  // it straight via the handle's ownership-proven route, independent of
@@ -3100,6 +3165,9 @@ function rejectFrameDuringShutdown(
3100
3165
  case "tunnel.data":
3101
3166
  case "tunnel.requestEnd":
3102
3167
  case "tunnel.close":
3168
+ // A lease answer is housekeeping the sidecar still wants during a drain.
3169
+ case "lease.grant":
3170
+ case "lease.deny":
3103
3171
  return false;
3104
3172
  }
3105
3173
  }