@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.
- package/lib/agents/transport.ts +2 -1
- package/lib/browser-testing.ts +2 -1
- package/lib/credential-store.ts +4 -0
- package/lib/lease.ts +216 -0
- package/lib/mcp-gateway.ts +2 -1
- package/lib/orchestrator.ts +4 -3
- package/lib/shared-files-s3.ts +153 -0
- package/lib/sidecar.ts +58 -0
- package/lib/standard-image.ts +7 -0
- package/lib/task-environment/index.ts +27 -6
- package/lib/task-environment/legacy-adoption.ts +2 -1
- package/lib/task-environment/machine-task-up.ts +24 -6
- package/lib/task-environment/self.ts +346 -0
- package/lib/task-environment/types.ts +13 -0
- package/package.json +1 -1
- package/src/cli.ts +7 -2
- package/src/index.ts +9 -4
- package/src/main.ts +121 -47
- package/src/protocol.ts +29 -1
package/lib/agents/transport.ts
CHANGED
|
@@ -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
|
|
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.
|
package/lib/browser-testing.ts
CHANGED
|
@@ -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
|
|
1116
|
+
isEnvironmentTransportProvider(environment?.descriptor?.locator.provider)
|
|
1116
1117
|
? MACHINE_RUNTIME_AUTHORITY_ENV
|
|
1117
1118
|
: RUNTIME_AUTHORITY_ENV;
|
|
1118
1119
|
let ready = false;
|
package/lib/credential-store.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -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
|
|
1102
|
+
isEnvironmentTransportProvider(environment?.descriptor?.locator.provider)
|
|
1102
1103
|
? "127.0.0.1"
|
|
1103
1104
|
: await taskGatewayHost();
|
|
1104
1105
|
const urlFor = (slug: string): string =>
|
package/lib/orchestrator.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/standard-image.ts
CHANGED
|
@@ -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 =
|
|
191
|
-
?
|
|
192
|
-
:
|
|
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
|
|
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
|
|
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
|
|
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;
|