@runuai/host 0.9.120 → 0.9.122

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.
@@ -55,6 +55,7 @@ import {
55
55
  type ExitHandler,
56
56
  type LineHandler,
57
57
  } from "./proc";
58
+ import { isEnvironmentTransportProvider } from "../task-environment/types";
58
59
 
59
60
  /** The shared surface adapters program against (LineProcess's shape). */
60
61
  export interface LineTransport {
@@ -124,7 +125,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
124
125
  // the container-runtime preflight is a docker/apple concern a machine task
125
126
  // must not trip over, and the host-FS durable flow below polls files a
126
127
  // machine does not share. Machine durability uses the ssh-tail backend.
127
- if (opts.environment.descriptor.locator.provider === "machine") {
128
+ if (isEnvironmentTransportProvider(opts.environment.descriptor.locator.provider)) {
128
129
  // The session env was assembled with the CONTAINER runtime authority
129
130
  // (asdf/corepack pins to shared read-only volumes) — on a machine those
130
131
  // paths do not exist and the pins actively break package managers.
@@ -56,6 +56,7 @@ import {
56
56
  } from "./runtime-authority";
57
57
  import { STANDARD_DEFAULT_NODE_VERSION } from "./standard-image";
58
58
  import type { TaskEnvironmentHandle } from "./task-environment/types";
59
+ import { isEnvironmentTransportProvider } from "./task-environment/types";
59
60
 
60
61
  const SERVER_DISPLAY = ":99";
61
62
  /**
@@ -1112,7 +1113,7 @@ export async function setupBrowserTesting(
1112
1113
  // browser (and every npx-launched server) for the session's whole lifetime
1113
1114
  // while Claude's 30s survives — live 2026-08-28, first codex machine task.
1114
1115
  const authorityEnv =
1115
- environment?.descriptor?.locator.provider === "machine"
1116
+ isEnvironmentTransportProvider(environment?.descriptor?.locator.provider)
1116
1117
  ? MACHINE_RUNTIME_AUTHORITY_ENV
1117
1118
  : RUNTIME_AUTHORITY_ENV;
1118
1119
  let ready = false;
@@ -22,6 +22,7 @@ import { tmpdir } from "node:os";
22
22
  import { join } from "node:path";
23
23
 
24
24
  import { env } from "./env";
25
+ import { leaseAwsEnv } from "./lease";
25
26
 
26
27
  const DEFAULT_TIMEOUT_MS = 30_000;
27
28
 
@@ -90,6 +91,9 @@ export function defaultAwsCliRunner(region: string): AwsCliRunner {
90
91
  let settled = false;
91
92
  const child = spawn("aws", ["--region", region, "--output", "json", ...args], {
92
93
  stdio: ["ignore", "pipe", "pipe"],
94
+ // ADR-133: a task machine acts under its lease (empty elsewhere — an
95
+ // owned or Uai-operated host keeps its own instance role).
96
+ env: { ...process.env, ...leaseAwsEnv() },
93
97
  });
94
98
  const settle = (status: number | null): void => {
95
99
  if (settled) return;
package/lib/lease.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * ADR-133 task leases — the sidecar's AWS credentials.
3
+ *
4
+ * A task machine holds no long-lived AWS credential. At connect (and ten
5
+ * minutes before every expiry) the sidecar sends `lease.request` over the
6
+ * bridge; the cloud checks that this host row is the machine of a running
7
+ * task, assumes the `uai-task-machine` role with session tags for the task's
8
+ * workspace and owner, and answers `lease.grant` with the STS session. The
9
+ * session is held here in memory and handed to exactly two consumers:
10
+ *
11
+ * - the credential store's `aws` CLI calls (Parameter Store reads for engine
12
+ * accounts, GitHub tokens, MCP secrets, project env) through `leaseAwsEnv()`;
13
+ * - Mountpoint for Amazon S3 (shared files) through a `credential_process`
14
+ * that prints the current session — so a refreshed lease reaches a mount
15
+ * that is already up without remounting.
16
+ *
17
+ * Revocation is the next refresh being denied: `onDenied` fires and the
18
+ * sidecar exits, and the cloud's sweep terminates the machine. A lease is
19
+ * bound to one task and one machine; nothing here can ask for another's.
20
+ */
21
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
22
+ import { dirname } from "node:path";
23
+
24
+ import type { TaskLease } from "../src/protocol";
25
+
26
+ /** Re-request this long before the session expires. */
27
+ export const LEASE_REFRESH_LEAD_MS = 10 * 60_000;
28
+ /** A denied or unanswered request is retried this often until it lands. */
29
+ export const LEASE_RETRY_MS = 30_000;
30
+ /** The cloud must answer within this window or the request is retried. */
31
+ export const LEASE_REQUEST_TIMEOUT_MS = 20_000;
32
+
33
+ /** Where the `credential_process` helper reads the live session. Root-only:
34
+ * the agents run as `node` and never see it. */
35
+ export const LEASE_CREDENTIALS_FILE = "/run/uai/lease-credentials.json";
36
+ /** The AWS config Mountpoint is pointed at (`AWS_CONFIG_FILE`). */
37
+ export const LEASE_AWS_CONFIG_FILE = "/run/uai/aws-config";
38
+ /** The helper itself — a shell script so it needs nothing but `cat`. */
39
+ export const LEASE_CREDENTIAL_PROCESS = "/run/uai/lease-credential-process";
40
+
41
+ export interface LeaseSender {
42
+ (frame: { kind: "lease.request"; opId: string; taskId: string }): boolean;
43
+ }
44
+
45
+ export interface LeaseManagerOptions {
46
+ taskId: string;
47
+ send: LeaseSender;
48
+ /** First grant — the store reconciles and the S3 mount wait for this. */
49
+ onFirstGrant?: (lease: TaskLease) => void;
50
+ /** Every grant, including refreshes. */
51
+ onGrant?: (lease: TaskLease) => void;
52
+ /** The cloud refused: the task is no longer ours. */
53
+ onDenied?: (error: string) => void;
54
+ /** Test seam: skip the /run/uai files. */
55
+ writeFiles?: boolean;
56
+ now?: () => number;
57
+ setTimer?: typeof setTimeout;
58
+ clearTimer?: typeof clearTimeout;
59
+ }
60
+
61
+ let current: TaskLease | null = null;
62
+
63
+ /** The AWS environment for a child process that should act under the lease
64
+ * (the credential store's CLI runner). Empty when no lease is held — the
65
+ * caller then runs with whatever the process has, which on a task machine
66
+ * is nothing. */
67
+ export function leaseAwsEnv(): Record<string, string> {
68
+ if (!current) return {};
69
+ return {
70
+ AWS_ACCESS_KEY_ID: current.accessKeyId,
71
+ AWS_SECRET_ACCESS_KEY: current.secretAccessKey,
72
+ AWS_SESSION_TOKEN: current.sessionToken,
73
+ AWS_REGION: current.region,
74
+ AWS_DEFAULT_REGION: current.region,
75
+ };
76
+ }
77
+
78
+ export function currentLease(): TaskLease | null {
79
+ return current;
80
+ }
81
+
82
+ /** Test seam. */
83
+ export function setCurrentLeaseForTesting(lease: TaskLease | null): void {
84
+ current = lease;
85
+ }
86
+
87
+ /** The JSON shape the AWS SDK credential_process contract expects. */
88
+ export function credentialProcessOutput(lease: TaskLease): string {
89
+ return JSON.stringify({
90
+ Version: 1,
91
+ AccessKeyId: lease.accessKeyId,
92
+ SecretAccessKey: lease.secretAccessKey,
93
+ SessionToken: lease.sessionToken,
94
+ Expiration: new Date(lease.expiresAt).toISOString(),
95
+ });
96
+ }
97
+
98
+ function writeLeaseFiles(lease: TaskLease): void {
99
+ mkdirSync(dirname(LEASE_CREDENTIALS_FILE), { recursive: true, mode: 0o700 });
100
+ writeFileSync(LEASE_CREDENTIALS_FILE, credentialProcessOutput(lease), { mode: 0o600 });
101
+ chmodSync(LEASE_CREDENTIALS_FILE, 0o600);
102
+ writeFileSync(
103
+ LEASE_CREDENTIAL_PROCESS,
104
+ `#!/bin/sh\nexec cat ${LEASE_CREDENTIALS_FILE}\n`,
105
+ { mode: 0o700 },
106
+ );
107
+ chmodSync(LEASE_CREDENTIAL_PROCESS, 0o700);
108
+ writeFileSync(
109
+ LEASE_AWS_CONFIG_FILE,
110
+ `[default]\nregion = ${lease.region}\ncredential_process = ${LEASE_CREDENTIAL_PROCESS}\n`,
111
+ { mode: 0o600 },
112
+ );
113
+ chmodSync(LEASE_AWS_CONFIG_FILE, 0o600);
114
+ }
115
+
116
+ export class LeaseManager {
117
+ readonly #opts: Required<Pick<LeaseManagerOptions, "now" | "setTimer" | "clearTimer">> &
118
+ LeaseManagerOptions;
119
+ #pending: { opId: string; timer: ReturnType<typeof setTimeout> } | null = null;
120
+ #refresh: ReturnType<typeof setTimeout> | null = null;
121
+ #granted = false;
122
+ #stopped = false;
123
+ #sequence = 0;
124
+
125
+ constructor(opts: LeaseManagerOptions) {
126
+ this.#opts = {
127
+ ...opts,
128
+ now: opts.now ?? Date.now,
129
+ setTimer: opts.setTimer ?? setTimeout,
130
+ clearTimer: opts.clearTimer ?? clearTimeout,
131
+ };
132
+ }
133
+
134
+ /** Ask now. Idempotent while a request is in flight. */
135
+ request(): void {
136
+ if (this.#stopped || this.#pending) return;
137
+ const opId = `lease-${this.#opts.taskId}-${++this.#sequence}-${this.#opts.now()}`;
138
+ const sent = this.#opts.send({ kind: "lease.request", opId, taskId: this.#opts.taskId });
139
+ const timer = this.#opts.setTimer(() => {
140
+ // Unanswered (the socket dropped, the cloud was mid-deploy): try again.
141
+ this.#pending = null;
142
+ this.#armRetry();
143
+ }, LEASE_REQUEST_TIMEOUT_MS);
144
+ timer.unref?.();
145
+ this.#pending = { opId, timer };
146
+ if (!sent) {
147
+ // Nothing on the wire; retry once the socket is back.
148
+ this.#opts.clearTimer(timer);
149
+ this.#pending = null;
150
+ this.#armRetry();
151
+ }
152
+ }
153
+
154
+ /** Bridge dispatch hands every `lease.*` frame here. */
155
+ handle(frame: { kind: "lease.grant"; opId: string; lease: TaskLease } | { kind: "lease.deny"; opId: string; error: string }): void {
156
+ if (!this.#pending || this.#pending.opId !== frame.opId) return;
157
+ this.#opts.clearTimer(this.#pending.timer);
158
+ this.#pending = null;
159
+ if (frame.kind === "lease.deny") {
160
+ current = null;
161
+ this.#opts.onDenied?.(frame.error);
162
+ return;
163
+ }
164
+ current = frame.lease;
165
+ if (this.#opts.writeFiles !== false) {
166
+ try {
167
+ writeLeaseFiles(frame.lease);
168
+ } catch (error) {
169
+ console.warn(
170
+ `[lease] could not write the credential_process files: ${error instanceof Error ? error.message : String(error)}`,
171
+ );
172
+ }
173
+ }
174
+ const first = !this.#granted;
175
+ this.#granted = true;
176
+ this.#armRefresh(frame.lease);
177
+ if (first) this.#opts.onFirstGrant?.(frame.lease);
178
+ this.#opts.onGrant?.(frame.lease);
179
+ }
180
+
181
+ /** The socket reconnected: whatever was pending is void; ask again. */
182
+ reconnected(): void {
183
+ if (this.#pending) {
184
+ this.#opts.clearTimer(this.#pending.timer);
185
+ this.#pending = null;
186
+ }
187
+ this.request();
188
+ }
189
+
190
+ stop(): void {
191
+ this.#stopped = true;
192
+ if (this.#pending) this.#opts.clearTimer(this.#pending.timer);
193
+ if (this.#refresh) this.#opts.clearTimer(this.#refresh);
194
+ this.#pending = null;
195
+ this.#refresh = null;
196
+ }
197
+
198
+ #armRefresh(lease: TaskLease): void {
199
+ if (this.#refresh) this.#opts.clearTimer(this.#refresh);
200
+ const delay = Math.max(1_000, lease.expiresAt - LEASE_REFRESH_LEAD_MS - this.#opts.now());
201
+ this.#refresh = this.#opts.setTimer(() => {
202
+ this.#refresh = null;
203
+ this.request();
204
+ }, delay);
205
+ this.#refresh.unref?.();
206
+ }
207
+
208
+ #armRetry(): void {
209
+ if (this.#stopped || this.#refresh) return;
210
+ this.#refresh = this.#opts.setTimer(() => {
211
+ this.#refresh = null;
212
+ this.request();
213
+ }, LEASE_RETRY_MS);
214
+ this.#refresh.unref?.();
215
+ }
216
+ }
@@ -55,6 +55,7 @@ import { authHeaderFor, getConnection } from "./mcp-connections";
55
55
  import { agentClisReady } from "./standard-image";
56
56
  import { isSafeHostTaskId, assertSafeHostTaskId } from "./task-identity";
57
57
  import type { McpGatewayCapability } from "../src/protocol";
58
+ import { isEnvironmentTransportProvider } from "./task-environment/types";
58
59
 
59
60
  export type { McpGatewayCapability } from "../src/protocol";
60
61
 
@@ -1098,7 +1099,7 @@ export async function setupMcpTaskConfig(
1098
1099
  // (machine-gateway-tunnel.ts). host.docker.internal / vmnet addresses
1099
1100
  // are container topology a machine can never route.
1100
1101
  const gatewayHost =
1101
- environment?.descriptor?.locator.provider === "machine"
1102
+ isEnvironmentTransportProvider(environment?.descriptor?.locator.provider)
1102
1103
  ? "127.0.0.1"
1103
1104
  : await taskGatewayHost();
1104
1105
  const urlFor = (slug: string): string =>
@@ -142,6 +142,7 @@ import {
142
142
  MAX_SECRETARY_DISPATCH_ID_CHARS,
143
143
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
144
144
  } from "../src/protocol";
145
+ import { isEnvironmentTransportProvider } from "./task-environment/types";
145
146
 
146
147
  export type HostEventSubscriber = (event: HostEvent) => void;
147
148
 
@@ -1426,7 +1427,7 @@ export class Orchestrator {
1426
1427
  // ADR-121: machine workspaces are only reachable through the
1427
1428
  // environment handle — the docker-exec cold-start default would
1428
1429
  // dial a container that does not exist. Settle the handle first.
1429
- if (task.environmentProvider === "machine") {
1430
+ if (isEnvironmentTransportProvider(task.environmentProvider)) {
1430
1431
  await this.agentEnvironment(channel).catch(() => {});
1431
1432
  if (!this.isActiveChannel(channel)) return;
1432
1433
  }
@@ -1859,7 +1860,7 @@ export class Orchestrator {
1859
1860
  // handle. Settle it before any materialization step below, so identity,
1860
1861
  // skills, browser, and MCP writes all ride the provider surface instead
1861
1862
  // of dialing a compose container that does not exist.
1862
- if (task.environmentProvider === "machine") {
1863
+ if (isEnvironmentTransportProvider(task.environmentProvider)) {
1863
1864
  await this.agentEnvironment(channel).catch(() => {});
1864
1865
  if (!this.isActiveChannel(channel)) return false;
1865
1866
  }
@@ -5927,7 +5928,7 @@ async function materializeAgentCli(
5927
5928
  apiUrl: string | null,
5928
5929
  allowPermissionless: boolean,
5929
5930
  ): Promise<boolean> {
5930
- if (task.environmentProvider === "machine") {
5931
+ if (isEnvironmentTransportProvider(task.environmentProvider)) {
5931
5932
  try {
5932
5933
  const environment = await reconstructPersistedTaskEnvironment(task);
5933
5934
  if (environment === null) return false;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * ADR-133 shared files on a task machine: S3 prefixes mounted with Mountpoint
3
+ * for Amazon S3 (`mount-s3`) at /workspace/files/{org,me}.
4
+ *
5
+ * Containers bind-mount a host directory; a machine over ssh got a
6
+ * point-in-time tar copy; the sidecar mounts the live prefix, so any number
7
+ * of task machines see one workspace's files at once and writes land in the
8
+ * bucket as they happen. Credentials come from the task lease through the
9
+ * `credential_process` config lib/lease.ts maintains — a refreshed lease
10
+ * reaches a mount that is already up.
11
+ *
12
+ * Mountpoint's semantics (whole-file writes, no rename) are the documented
13
+ * caveat of hostless-implementation.md decision 4; EFS is the swap if they
14
+ * bite, and this module is the only place that would change.
15
+ */
16
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
17
+ import { LEASE_AWS_CONFIG_FILE, currentLease } from "./lease";
18
+
19
+ export const MOUNT_S3_BIN = "/usr/bin/mount-s3";
20
+ export const SHARED_FILES_UID = 1000;
21
+ export const SHARED_FILES_GID = 1000;
22
+
23
+ export type SharedFilesMode = "off" | "ro" | "rw";
24
+
25
+ export interface SharedFilesMountPlan {
26
+ bucket: string;
27
+ prefix: string;
28
+ directory: string;
29
+ mode: "ro" | "rw";
30
+ region: string;
31
+ }
32
+
33
+ /** The mount-s3 argv for one prefix. Exported for the test that pins it. */
34
+ export function mountS3Argv(plan: SharedFilesMountPlan): [string, ...string[]] {
35
+ return [
36
+ MOUNT_S3_BIN,
37
+ "--prefix",
38
+ plan.prefix,
39
+ "--region",
40
+ plan.region,
41
+ // Owned by the task user so agents and the editor can use it; the mount
42
+ // is performed by the root sidecar.
43
+ "--allow-other",
44
+ "--uid",
45
+ String(SHARED_FILES_UID),
46
+ "--gid",
47
+ String(SHARED_FILES_GID),
48
+ "--dir-mode",
49
+ "0755",
50
+ "--file-mode",
51
+ "0644",
52
+ ...(plan.mode === "ro" ? ["--read-only"] : ["--allow-delete", "--allow-overwrite"]),
53
+ plan.bucket,
54
+ plan.directory,
55
+ ];
56
+ }
57
+
58
+ /** The mounts a task wants, from its shared-files mode and lease. */
59
+ export function sharedFilesMountPlans(
60
+ workspacePath: string,
61
+ mode: SharedFilesMode,
62
+ lease: { region: string; files: { bucket: string; orgPrefix: string | null; userPrefix: string } },
63
+ ): SharedFilesMountPlan[] {
64
+ if (mode === "off") return [];
65
+ const plans: SharedFilesMountPlan[] = [];
66
+ if (lease.files.orgPrefix) {
67
+ plans.push({
68
+ bucket: lease.files.bucket,
69
+ prefix: lease.files.orgPrefix,
70
+ directory: `${workspacePath}/files/org`,
71
+ mode,
72
+ region: lease.region,
73
+ });
74
+ }
75
+ plans.push({
76
+ bucket: lease.files.bucket,
77
+ prefix: lease.files.userPrefix,
78
+ directory: `${workspacePath}/files/me`,
79
+ mode,
80
+ region: lease.region,
81
+ });
82
+ return plans;
83
+ }
84
+
85
+ /**
86
+ * Mount the task's shared files. Returns a warning string when something
87
+ * could not be mounted (the task still starts, like the machine copy path),
88
+ * null when clean or when there is nothing to mount.
89
+ */
90
+ export async function mountSharedFilesFromLease(
91
+ handle: TaskEnvironmentHandle<unknown>,
92
+ workspacePath: string,
93
+ task: { sharedFiles?: string },
94
+ ): Promise<string | null> {
95
+ const mode: SharedFilesMode =
96
+ task.sharedFiles === "rw" ? "rw" : task.sharedFiles === "off" ? "off" : "ro";
97
+ if (mode === "off") return null;
98
+ const lease = currentLease();
99
+ if (!lease) {
100
+ return "shared files: no lease yet — /workspace/files is not mounted";
101
+ }
102
+ const warnings: string[] = [];
103
+ let mountedAny = false;
104
+ for (const plan of sharedFilesMountPlans(workspacePath, mode, lease)) {
105
+ const result = await handle.exec({
106
+ argv: [
107
+ "/bin/sh",
108
+ "-c",
109
+ // Idempotent: an already-mounted directory is left alone.
110
+ 'mkdir -p "$1" && chown 1000:1000 "$1"; if mountpoint -q "$1"; then exit 0; fi; shift; exec "$@"',
111
+ "mount",
112
+ plan.directory,
113
+ ...mountS3Argv(plan),
114
+ ],
115
+ user: "root",
116
+ env: { AWS_CONFIG_FILE: LEASE_AWS_CONFIG_FILE },
117
+ timeoutMs: 60_000,
118
+ maxOutputBytes: 64 * 1024,
119
+ });
120
+ if (result.exitCode === 0) {
121
+ mountedAny = true;
122
+ } else {
123
+ warnings.push(
124
+ `shared files: could not mount ${plan.directory}: ${Buffer.from(result.stderr).toString("utf8").trim() || `exit ${result.exitCode}`}`,
125
+ );
126
+ }
127
+ }
128
+ if (mountedAny) {
129
+ // The same marker the container and machine paths write: it gates the
130
+ // "## Shared files" briefing in every agent's preamble.
131
+ await handle
132
+ .writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(""))
133
+ .catch(() => {});
134
+ }
135
+ return warnings.length > 0 ? warnings.join("\n") : null;
136
+ }
137
+
138
+ /** Unmount both directories; tolerant of never-mounted paths. */
139
+ export async function unmountSharedFiles(
140
+ handle: TaskEnvironmentHandle<unknown>,
141
+ workspacePath: string,
142
+ ): Promise<void> {
143
+ for (const directory of [`${workspacePath}/files/org`, `${workspacePath}/files/me`]) {
144
+ await handle
145
+ .exec({
146
+ argv: ["/bin/sh", "-c", 'mountpoint -q "$1" && umount "$1" || true', "umount", directory],
147
+ user: "root",
148
+ timeoutMs: 30_000,
149
+ maxOutputBytes: 4 * 1024,
150
+ })
151
+ .catch(() => {});
152
+ }
153
+ }
package/lib/sidecar.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ADR-133 sidecar mode: this runner process lives ON the task's machine and
3
+ * is that one task's host. Selected by the environment the machine image's
4
+ * `uai-sidecar.service` writes from EC2 user-data (hostless-implementation.md
5
+ * §8/§9). Everything the coordinator shape needed and a single-task machine
6
+ * does not — the local UI, the container runtime, host-level engine accounts,
7
+ * the machine provider, recovery sweeps over many tasks — asks here and
8
+ * stays off. Owned runners never set it.
9
+ */
10
+
11
+ export function sidecarTaskId(): string | null {
12
+ const value = process.env.UAI_SIDECAR_TASK?.trim();
13
+ return value && /^[a-z0-9]{1,32}$/i.test(value) ? value : null;
14
+ }
15
+
16
+ export function sidecarMode(): boolean {
17
+ return sidecarTaskId() !== null;
18
+ }
19
+
20
+ /**
21
+ * A sidecar is ready to run its task only once it holds a lease AND the
22
+ * credential store has been reconciled under it — before that there is no
23
+ * engine account, GitHub token, or project env on the machine, and a task-up
24
+ * that raced the first grant would clone without credentials and spawn
25
+ * agents that cannot sign in (the dispatch arrives the moment the bridge
26
+ * connects; the lease answer is one round trip behind it). Resolved by
27
+ * main.ts after the first grant's reconciles settle; never on an owned host.
28
+ */
29
+ let markReady: (() => void) | null = null;
30
+ let ready = false;
31
+ export const sidecarReady: Promise<void> = new Promise<void>((resolve) => {
32
+ markReady = resolve;
33
+ });
34
+ export function markSidecarReady(): void {
35
+ if (ready) return;
36
+ ready = true;
37
+ markReady?.();
38
+ markReady = null;
39
+ }
40
+ export function isSidecarReady(): boolean {
41
+ return ready;
42
+ }
43
+ /** How long a task-up waits for the lease before giving up. */
44
+ export const SIDECAR_READY_TIMEOUT_MS = 3 * 60_000;
45
+ export function awaitSidecarReady(timeoutMs = SIDECAR_READY_TIMEOUT_MS): Promise<void> {
46
+ if (!sidecarMode() || ready) return Promise.resolve();
47
+ return new Promise<void>((resolve, reject) => {
48
+ const timer = setTimeout(
49
+ () => reject(new Error("the task's machine has no credentials yet (no lease granted)")),
50
+ timeoutMs,
51
+ );
52
+ timer.unref?.();
53
+ void sidecarReady.then(() => {
54
+ clearTimeout(timer);
55
+ resolve();
56
+ });
57
+ });
58
+ }
@@ -1107,6 +1107,13 @@ function notifyAgentClisReadyListener(listener: AgentClisReadyListener): void {
1107
1107
  }
1108
1108
  }
1109
1109
 
1110
+ /** ADR-133 sidecar mode: the agent CLIs are baked into the task machine's
1111
+ * image, so there is no standard-image pass to wait on — boot declares them
1112
+ * ready. Never called on a host that owns a container runtime. */
1113
+ export function declareAgentClisReady(): void {
1114
+ markAgentClisReady();
1115
+ }
1116
+
1110
1117
  function markAgentClisReady(): void {
1111
1118
  if (agentClisReadyLatched) return;
1112
1119
  // Publish the synchronous capability state before resolving the promise:
@@ -28,7 +28,6 @@ import { ensureMachineKeyPair } from "../machine-keys";
28
28
  import { requestAccessToken } from "../github-tokens";
29
29
  import {
30
30
  createMachineTaskEnvironmentProvider,
31
- MACHINE_TASK_ENVIRONMENT_PROVIDER,
32
31
  } from "./machine";
33
32
  import { createMachineHostTaskEnvironmentProvider } from "./machine-task-up";
34
33
  import { TaskEnvironmentRegistry } from "./registry";
@@ -37,6 +36,7 @@ import {
37
36
  type TaskEnvironmentHandle,
38
37
  type TaskEnvironmentLocator,
39
38
  type TaskEnvironmentProvisioned,
39
+ isEnvironmentTransportProvider,
40
40
  } from "./types";
41
41
 
42
42
  export {
@@ -44,6 +44,9 @@ export {
44
44
  type LegacyTaskEnvironmentAdoptionDeps,
45
45
  type LegacyTaskEnvironmentAdoptionVerdict,
46
46
  } from "./legacy-adoption";
47
+ import { sidecarMode } from "../sidecar";
48
+ import { mountSharedFilesFromLease } from "../shared-files-s3";
49
+ import { createSelfTaskEnvironmentProvider } from "./self";
47
50
 
48
51
  const registry = new TaskEnvironmentRegistry<
49
52
  TaskLaunchInput,
@@ -171,6 +174,22 @@ const machineProvider = createMachineHostTaskEnvironmentProvider({
171
174
 
172
175
  registry.register(machineProvider);
173
176
 
177
+ // ADR-133 sidecar mode: the environment is this machine. Same task-up on top
178
+ // (clone, editor, browser), a local-exec handle underneath; shared files
179
+ // are mounted from S3 rather than copied from a host directory.
180
+ const selfProvider = createMachineHostTaskEnvironmentProvider({
181
+ environment: createSelfTaskEnvironmentProvider(),
182
+ taskControlDir: (taskId) => taskDir(taskId),
183
+ machineImage: () => "self",
184
+ machineCpus: () => 0,
185
+ machineMemoryMiB: () => 0,
186
+ publishSsh: () => false,
187
+ githubToken: (userId) => requestAccessToken(userId),
188
+ sharedFiles: mountSharedFilesFromLease,
189
+ });
190
+
191
+ registry.register(selfProvider);
192
+
174
193
  /**
175
194
  * Bind the Docker provider's private recovery implementation. Kept as a
176
195
  * replaceable module seam so HMR can reload the orchestrator without leaving
@@ -187,9 +206,11 @@ export function provisionTaskEnvironment(
187
206
  credentials: TaskUpCredentials = {},
188
207
  onPrepared?: (locator: TaskEnvironmentLocator) => Promise<void>,
189
208
  ): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
190
- const provider = machineTasksEnabled()
191
- ? machineProvider
192
- : requireMachineIdentity().backend === "apple-container"
209
+ const provider = sidecarMode()
210
+ ? selfProvider
211
+ : machineTasksEnabled()
212
+ ? machineProvider
213
+ : requireMachineIdentity().backend === "apple-container"
193
214
  ? appleProvider
194
215
  : dockerProvider;
195
216
  return provider.provision({
@@ -314,7 +335,7 @@ export function persistedTaskEnvironmentsMatchMachine(
314
335
  if (locator === null) continue;
315
336
  // ADR-121: machine locators name an ephemeral VM, not a container
316
337
  // daemon — no container-runtime identity claim to verify.
317
- if (locator.provider === MACHINE_TASK_ENVIRONMENT_PROVIDER) continue;
338
+ if (isEnvironmentTransportProvider(locator.provider)) continue;
318
339
  try {
319
340
  const persisted = locatorMachineIdentity(locator);
320
341
  if (
@@ -384,7 +405,7 @@ export function hostTaskEnvironmentsAllowMachineSelection(
384
405
  throw new Error("task environment provider does not match its locator");
385
406
  }
386
407
  // ADR-121: machine rows make no container-runtime claim.
387
- if (locator.provider === MACHINE_TASK_ENVIRONMENT_PROVIDER) continue;
408
+ if (isEnvironmentTransportProvider(locator.provider)) continue;
388
409
  const persisted = locatorMachineIdentity(locator);
389
410
  if (
390
411
  persisted.backend !== machine.backend ||
@@ -21,6 +21,7 @@ import {
21
21
  parseTaskEnvironmentLocator,
22
22
  serializeTaskEnvironmentLocator,
23
23
  type TaskEnvironmentLocator,
24
+ isEnvironmentTransportProvider,
24
25
  } from "./types";
25
26
 
26
27
  const INSPECT_OUTPUT_LIMIT = 128 * 1024;
@@ -171,7 +172,7 @@ function legacyCandidate(
171
172
  // They must never wedge container-runtime selection (live 2026-08-27:
172
173
  // the first machine task quarantined the whole VM runtime on reconnect,
173
174
  // collapsing every host command into a retry storm).
174
- if (locator.provider === "machine") return null;
175
+ if (isEnvironmentTransportProvider(locator.provider)) return null;
175
176
  let persistedMachine: HostMachineIdentity;
176
177
  if (locator.provider === "docker-compose") {
177
178
  persistedMachine = parseDockerTaskEnvironmentLocator(locator).machine;