@runuai/host 0.9.119 → 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 +17 -6
- 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
|
@@ -67,6 +67,14 @@ export interface MachineHostTaskUpDeps {
|
|
|
67
67
|
/** The task owner's GitHub access token, or null when GitHub is not
|
|
68
68
|
* connected (public/anonymous clone fallback). */
|
|
69
69
|
githubToken(userId: string): Promise<{ accessToken: string } | null>;
|
|
70
|
+
/** ADR-133: how this environment gets its shared files. Default = the
|
|
71
|
+
* point-in-time copy from the host's shared directory (machines over
|
|
72
|
+
* ssh); the sidecar mounts them from S3 instead. */
|
|
73
|
+
sharedFiles?: (
|
|
74
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
75
|
+
workspacePath: string,
|
|
76
|
+
task: { ownerOrgId?: string; ownerUserId: string; sharedFiles?: string },
|
|
77
|
+
) => Promise<string | null>;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
/** Mirrors src/index.ts isGithubRepo — host-side predicate only. */
|
|
@@ -430,6 +438,15 @@ async function ensureCodeServer(
|
|
|
430
438
|
}
|
|
431
439
|
}
|
|
432
440
|
|
|
441
|
+
/** The identity the host records for the task's runtime: the machine id for
|
|
442
|
+
* a machine over ssh (what recovery and orphan sweeps key on), the handle's
|
|
443
|
+
* durable identity for anything else (sidecar mode: `self:<task>`). */
|
|
444
|
+
function environmentRuntimeIdentity(handle: TaskEnvironmentHandle<unknown>): string {
|
|
445
|
+
return handle.descriptor.locator.provider === MACHINE_TASK_ENVIRONMENT_PROVIDER
|
|
446
|
+
? parseMachineTaskEnvironmentLocator(handle.descriptor.locator).machineId
|
|
447
|
+
: handle.durableIdentity;
|
|
448
|
+
}
|
|
449
|
+
|
|
433
450
|
/** Wrap the void-teardown machine handle so registry consumers get the
|
|
434
451
|
* TaskDownResult contract the compose providers speak. */
|
|
435
452
|
function withTaskDownResult(
|
|
@@ -477,7 +494,9 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
477
494
|
TaskDownResult
|
|
478
495
|
> {
|
|
479
496
|
return {
|
|
480
|
-
kind:
|
|
497
|
+
// The provider kind is whichever environment sits underneath: a machine
|
|
498
|
+
// over ssh, or this machine in sidecar mode (ADR-133).
|
|
499
|
+
kind: deps.environment.kind,
|
|
481
500
|
|
|
482
501
|
async provision(
|
|
483
502
|
request: TaskEnvironmentProvisionRequest<TaskLaunchInput, TaskUpCredentials>,
|
|
@@ -528,7 +547,7 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
528
547
|
}
|
|
529
548
|
|
|
530
549
|
// ADR-062 shared files: point-in-time copy (see copySharedFiles).
|
|
531
|
-
const sharedWarning = await copySharedFiles(handle, workspacePath, {
|
|
550
|
+
const sharedWarning = await (deps.sharedFiles ?? copySharedFiles)(handle, workspacePath, {
|
|
532
551
|
...(input.task.ownerOrgId ? { ownerOrgId: input.task.ownerOrgId } : {}),
|
|
533
552
|
ownerUserId: input.task.ownerUserId,
|
|
534
553
|
...(input.task.sharedFiles ? { sharedFiles: input.task.sharedFiles } : {}),
|
|
@@ -570,13 +589,12 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
570
589
|
);
|
|
571
590
|
});
|
|
572
591
|
|
|
573
|
-
const machineId = parseMachineTaskEnvironmentLocator(
|
|
574
|
-
handle.descriptor.locator,
|
|
575
|
-
).machineId;
|
|
576
592
|
return {
|
|
577
593
|
handle: withTaskDownResult(handle),
|
|
578
594
|
result: {
|
|
579
|
-
|
|
595
|
+
// Recorded as the task's runtime identity (the compose-project
|
|
596
|
+
// column): the machine id over ssh, `self:<task>` in sidecar mode.
|
|
597
|
+
composeProject: environmentRuntimeIdentity(handle),
|
|
580
598
|
worktreePath: deps.taskControlDir(request.taskId),
|
|
581
599
|
codeServerPort: MACHINE_CODE_SERVER_PORT,
|
|
582
600
|
...(warnings.length > 0
|
|
@@ -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(
|