@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.
- 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 +18 -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 +5 -4
- package/src/main.ts +115 -47
- package/src/protocol.ts +29 -1
|
@@ -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
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
vnodePressureTimer
|
|
620
|
-
|
|
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
|
|
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
|
}
|