@runuai/host 0.9.69 → 0.9.71

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.
@@ -22,6 +22,12 @@ import {
22
22
  type DockerTaskEnvironmentRecoveryDriver,
23
23
  type DockerMachineIdentity,
24
24
  } from "./docker";
25
+ import { createAwsMachineProvider } from "../machine-provider-aws";
26
+ import { createLocalMachineProvider } from "../machine-provider-local";
27
+ import { ensureMachineKeyPair } from "../machine-keys";
28
+ import { requestAccessToken } from "../github-tokens";
29
+ import { createMachineTaskEnvironmentProvider } from "./machine";
30
+ import { createMachineHostTaskEnvironmentProvider } from "./machine-task-up";
25
31
  import { TaskEnvironmentRegistry } from "./registry";
26
32
  import {
27
33
  parseTaskEnvironmentLocator,
@@ -113,6 +119,53 @@ const appleProvider = createAppleContainerTaskEnvironmentProvider<
113
119
 
114
120
  registry.register(appleProvider);
115
121
 
122
+ // ---------------------------------------------------------------------------
123
+ // ADR-121: machine-backed tasks (ephemeral VM per task). Opt-in via
124
+ // UAI_MACHINE_TASKS=1 — the guinea-pig flag; selection stays coarse (whole
125
+ // host) until machine-backing becomes a per-task property.
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function machineTasksEnabled(): boolean {
129
+ return process.env.UAI_MACHINE_TASKS === "1";
130
+ }
131
+
132
+ function machineBackend() {
133
+ if (process.env.UAI_MACHINE_PROVIDER === "aws") {
134
+ const region = process.env.UAI_AWS_REGION;
135
+ if (!region) {
136
+ throw new Error("UAI_MACHINE_PROVIDER=aws requires UAI_AWS_REGION");
137
+ }
138
+ return createAwsMachineProvider({
139
+ region,
140
+ subnetId: process.env.UAI_AWS_SUBNET_ID,
141
+ securityGroupId: process.env.UAI_AWS_SECURITY_GROUP_ID,
142
+ iamInstanceProfileArn: process.env.UAI_AWS_INSTANCE_PROFILE_ARN,
143
+ });
144
+ }
145
+ return createLocalMachineProvider();
146
+ }
147
+
148
+ const machineEnvironmentProvider = createMachineTaskEnvironmentProvider({
149
+ machines: machineBackend(),
150
+ taskControlDir: (taskId) => taskDir(taskId),
151
+ mintKeyPair: ensureMachineKeyPair,
152
+ });
153
+
154
+ const machineProvider = createMachineHostTaskEnvironmentProvider({
155
+ environment: machineEnvironmentProvider,
156
+ taskControlDir: (taskId) => taskDir(taskId),
157
+ machineImage: () => process.env.UAI_MACHINE_IMAGE ?? "uai-machine:dev",
158
+ machineCpus: () => Number(process.env.UAI_MACHINE_CPUS ?? "2"),
159
+ machineMemoryMiB: () => Number(process.env.UAI_MACHINE_MEMORY_MIB ?? "4096"),
160
+ // Local machines on macOS need loopback-published ssh (bridge IPs are not
161
+ // host-routable there); cloud machines and Linux dial the address directly.
162
+ publishSsh: () =>
163
+ process.env.UAI_MACHINE_PROVIDER !== "aws" && process.platform === "darwin",
164
+ githubToken: (userId) => requestAccessToken(userId),
165
+ });
166
+
167
+ registry.register(machineProvider);
168
+
116
169
  /**
117
170
  * Bind the Docker provider's private recovery implementation. Kept as a
118
171
  * replaceable module seam so HMR can reload the orchestrator without leaving
@@ -129,8 +182,9 @@ export function provisionTaskEnvironment(
129
182
  credentials: TaskUpCredentials = {},
130
183
  onPrepared?: (locator: TaskEnvironmentLocator) => Promise<void>,
131
184
  ): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
132
- const provider =
133
- requireMachineIdentity().backend === "apple-container"
185
+ const provider = machineTasksEnabled()
186
+ ? machineProvider
187
+ : requireMachineIdentity().backend === "apple-container"
134
188
  ? appleProvider
135
189
  : dockerProvider;
136
190
  return provider.provision({
@@ -0,0 +1,333 @@
1
+ /**
2
+ * ADR-121: the machine-backed task-up adapter.
3
+ *
4
+ * Bridges the host's task contract (TaskLaunchInput → TaskUpResult) onto the
5
+ * pure machine environment provider (MachineTaskInput → descriptor). This is
6
+ * where task semantics live for machines: project clones happen IN the
7
+ * machine's world with a one-shot credential over stdin, the task branch is
8
+ * created off the clone's default HEAD, and repo problems degrade into an
9
+ * initWarning system note instead of failing the task (a machine with an
10
+ * empty workspace and a clear message beats a respawn loop).
11
+ *
12
+ * Compose-era result fields are satisfied honestly rather than faked:
13
+ * `composeProject` carries the machine id (the one durable runtime name this
14
+ * task owns) and `worktreePath` carries the host-side task control dir (the
15
+ * directory that really does hold this task's host-side state — its machine
16
+ * key). Consumers that need the workspace go through the environment handle.
17
+ */
18
+ import type {
19
+ TaskDownResult,
20
+ TaskLaunchInput,
21
+ TaskUpCredentials,
22
+ TaskUpResult,
23
+ } from "../agent";
24
+ import {
25
+ MACHINE_TASK_ENVIRONMENT_PROVIDER,
26
+ parseMachineTaskEnvironmentLocator,
27
+ type MachineTaskInput,
28
+ } from "./machine";
29
+ import type {
30
+ TaskEnvironmentDescriptor,
31
+ TaskEnvironmentHandle,
32
+ TaskEnvironmentProvider,
33
+ TaskEnvironmentProvisionRequest,
34
+ TaskEnvironmentProvisioned,
35
+ } from "./types";
36
+
37
+ /** In-machine clone budget per project. */
38
+ const CLONE_TIMEOUT_MS = 10 * 60_000;
39
+ const GIT_STEP_TIMEOUT_MS = 60_000;
40
+ const STEP_OUTPUT_BYTES = 256 * 1024;
41
+
42
+ export interface MachineHostTaskUpDeps {
43
+ /** The pure machine environment provider (createMachineTaskEnvironmentProvider). */
44
+ environment: TaskEnvironmentProvider<
45
+ MachineTaskInput,
46
+ void,
47
+ TaskEnvironmentDescriptor
48
+ >;
49
+ /** Host-side control dir for the task (holds the machine key). */
50
+ taskControlDir(taskId: string): string;
51
+ machineImage(): string;
52
+ machineCpus(): number;
53
+ machineMemoryMiB(): number;
54
+ /** macOS-local machines need loopback-published ssh; cloud machines don't. */
55
+ publishSsh(): boolean;
56
+ /** The task owner's GitHub access token, or null when GitHub is not
57
+ * connected (public/anonymous clone fallback). */
58
+ githubToken(userId: string): Promise<{ accessToken: string } | null>;
59
+ }
60
+
61
+ /** Mirrors src/index.ts isGithubRepo — host-side predicate only. */
62
+ export function isGithubRepoUrl(input: string): boolean {
63
+ const value = input.trim();
64
+ if (/^(?:[^@/]+@)?(?:www\.)?github\.com:/i.test(value)) return true;
65
+ if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) return true;
66
+ try {
67
+ const url = new URL(value);
68
+ return /^(?:www\.)?github\.com$/i.test(url.hostname);
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** Canonical HTTPS form for GitHub repos (shorthand and ssh forms included);
75
+ * non-GitHub URLs pass through untouched. */
76
+ export function machineCloneUrl(repoUrl: string): string {
77
+ const value = repoUrl.trim();
78
+ const ssh = /^(?:[^@/]+@)?(?:www\.)?github\.com:(.+)$/i.exec(value);
79
+ if (ssh?.[1]) {
80
+ return `https://github.com/${ssh[1].replace(/\.git$/i, "")}.git`;
81
+ }
82
+ if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) {
83
+ return `https://github.com/${value.replace(/\/+$/, "").replace(/\.git$/i, "")}.git`;
84
+ }
85
+ return value;
86
+ }
87
+
88
+ /** Workspace-safe project directory name. The slug is cloud-normalized, but
89
+ * the machine path re-checks: a path-capable slug must never reach argv. */
90
+ function projectDirectoryName(slug: string): string {
91
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug) || slug.includes("..")) {
92
+ throw new Error(`unsafe project slug for machine clone: ${slug}`);
93
+ }
94
+ return slug;
95
+ }
96
+
97
+ interface StepResult {
98
+ exitCode: number | null;
99
+ stderr: string;
100
+ }
101
+
102
+ /** One-shot exec with stdin: spawn, deliver the secret, drain, await exit.
103
+ * The secret rides the ssh stream only — never argv, never the persisted
104
+ * env, never machine disk. */
105
+ async function execWithStdin(
106
+ handle: TaskEnvironmentHandle<unknown>,
107
+ argv: readonly [string, ...string[]],
108
+ stdin: string,
109
+ timeoutMs: number,
110
+ ): Promise<StepResult> {
111
+ const proc = await handle.spawn({
112
+ argv,
113
+ inheritEnv: [],
114
+ env: { GIT_TERMINAL_PROMPT: "0" },
115
+ timeoutMs,
116
+ maxOutputBytes: STEP_OUTPUT_BYTES,
117
+ });
118
+ let stderr = "";
119
+ const drainOut = (async () => {
120
+ for await (const chunk of proc.stdout) {
121
+ void chunk; // drained; clone progress is not retained
122
+ }
123
+ })();
124
+ const drainErr = (async () => {
125
+ for await (const chunk of proc.stderr) {
126
+ stderr = (stderr + Buffer.from(chunk).toString("utf8")).slice(-8192);
127
+ }
128
+ })();
129
+ await proc.write(Buffer.from(stdin));
130
+ await proc.closeInput();
131
+ const exit = await proc.completion;
132
+ await Promise.allSettled([drainOut, drainErr]);
133
+ return { exitCode: exit.exitCode, stderr: stderr.trim() };
134
+ }
135
+
136
+ async function execStep(
137
+ handle: TaskEnvironmentHandle<unknown>,
138
+ argv: readonly [string, ...string[]],
139
+ timeoutMs: number,
140
+ ): Promise<StepResult> {
141
+ const result = await handle.exec({
142
+ argv,
143
+ env: { GIT_TERMINAL_PROMPT: "0" },
144
+ timeoutMs,
145
+ maxOutputBytes: STEP_OUTPUT_BYTES,
146
+ });
147
+ return {
148
+ exitCode: result.exitCode,
149
+ stderr: Buffer.from(result.stderr).toString("utf8").trim().slice(-8192),
150
+ };
151
+ }
152
+
153
+ /** The remote clone script for the credentialed path. The token is read from
154
+ * stdin into the clone process env; /usr/local/bin/uai-git-askpass (baked in
155
+ * the machine image) answers git's prompts from it. */
156
+ const CREDENTIALED_CLONE_SCRIPT =
157
+ 'IFS= read -r UAI_GIT_TOKEN && export UAI_GIT_TOKEN GIT_ASKPASS=/usr/local/bin/uai-git-askpass GIT_TERMINAL_PROMPT=0 && exec git clone -- "$1" "$2"';
158
+
159
+ async function cloneProject(
160
+ handle: TaskEnvironmentHandle<unknown>,
161
+ workspacePath: string,
162
+ project: { slug: string; repoUrl: string },
163
+ taskBranch: string,
164
+ owner: { name?: string; email?: string },
165
+ token: string | null,
166
+ ): Promise<string | null> {
167
+ const destination = `${workspacePath}/${projectDirectoryName(project.slug)}`;
168
+ const url = machineCloneUrl(project.repoUrl);
169
+ const useToken = token !== null && isGithubRepoUrl(project.repoUrl);
170
+
171
+ // Resume/retry idempotency: a repository that already exists is the task's
172
+ // live work — never re-clone over it, and leave its branch state alone.
173
+ const already = await execStep(
174
+ handle,
175
+ ["/bin/sh", "-c", 'test -e "$1/.git"', "check", destination],
176
+ GIT_STEP_TIMEOUT_MS,
177
+ );
178
+ if (already.exitCode === 0) return null;
179
+
180
+ const clone = useToken
181
+ ? await execWithStdin(
182
+ handle,
183
+ ["/bin/sh", "-c", CREDENTIALED_CLONE_SCRIPT, "clone", url, destination],
184
+ `${token}\n`,
185
+ CLONE_TIMEOUT_MS,
186
+ )
187
+ : await execStep(
188
+ handle,
189
+ ["git", "clone", "--", url, destination],
190
+ CLONE_TIMEOUT_MS,
191
+ );
192
+ if (clone.exitCode !== 0) {
193
+ return `could not clone ${project.repoUrl} for ${project.slug}: ${clone.stderr || "clone failed"}`;
194
+ }
195
+
196
+ // Task branch off the clone's default HEAD (matches the compose path's
197
+ // `-b <branch> origin/<default>`). -B keeps an empty-repo clone (unborn
198
+ // HEAD) and a re-run both working; failure degrades, never fails the task.
199
+ const branch = await execStep(
200
+ handle,
201
+ ["git", "-C", destination, "checkout", "-B", taskBranch],
202
+ GIT_STEP_TIMEOUT_MS,
203
+ );
204
+ if (branch.exitCode !== 0) {
205
+ return `cloned ${project.slug} but could not create branch ${taskBranch}: ${branch.stderr}`;
206
+ }
207
+
208
+ // Repo-local commit identity so agent commits work out of the box.
209
+ if (owner.name) {
210
+ await execStep(
211
+ handle,
212
+ ["git", "-C", destination, "config", "user.name", owner.name],
213
+ GIT_STEP_TIMEOUT_MS,
214
+ );
215
+ }
216
+ if (owner.email) {
217
+ await execStep(
218
+ handle,
219
+ ["git", "-C", destination, "config", "user.email", owner.email],
220
+ GIT_STEP_TIMEOUT_MS,
221
+ );
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /** Wrap the void-teardown machine handle so registry consumers get the
227
+ * TaskDownResult contract the compose providers speak. */
228
+ function withTaskDownResult(
229
+ handle: TaskEnvironmentHandle<unknown>,
230
+ ): TaskEnvironmentHandle<TaskDownResult> {
231
+ // Explicit delegation, never Object.create: the machine handle is a class
232
+ // with private fields, and a prototype-borrowed method invoked on a foreign
233
+ // receiver throws on its first #member access.
234
+ return {
235
+ descriptor: handle.descriptor,
236
+ durableIdentity: handle.durableIdentity,
237
+ readWorkspaceFile: (path) => handle.readWorkspaceFile(path),
238
+ writeWorkspaceFile: (path, bytes) => handle.writeWorkspaceFile(path, bytes),
239
+ exec: (request) => handle.exec(request),
240
+ spawn: (request) => handle.spawn(request),
241
+ spawnSession: (request) => handle.spawnSession(request),
242
+ launchDetachedSession: (request) => handle.launchDetachedSession(request),
243
+ copy: (request) => handle.copy(request),
244
+ ports: () => handle.ports(),
245
+ status: () => handle.status(),
246
+ recover: (context) => handle.recover(context),
247
+ stop: () => handle.stop(),
248
+ teardown: async (): Promise<TaskDownResult> => {
249
+ await handle.teardown();
250
+ return { status: "terminated" };
251
+ },
252
+ };
253
+ }
254
+
255
+ export function createMachineHostTaskEnvironmentProvider(
256
+ deps: MachineHostTaskUpDeps,
257
+ ): TaskEnvironmentProvider<
258
+ TaskLaunchInput,
259
+ TaskUpCredentials,
260
+ TaskUpResult,
261
+ TaskDownResult
262
+ > {
263
+ return {
264
+ kind: MACHINE_TASK_ENVIRONMENT_PROVIDER,
265
+
266
+ async provision(
267
+ request: TaskEnvironmentProvisionRequest<TaskLaunchInput, TaskUpCredentials>,
268
+ ): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
269
+ const { input } = request;
270
+ const provisioned = await deps.environment.provision({
271
+ taskId: request.taskId,
272
+ input: {
273
+ image: deps.machineImage(),
274
+ cpus: deps.machineCpus(),
275
+ memoryMiB: deps.machineMemoryMiB(),
276
+ ...(deps.publishSsh() ? { publishSsh: true } : {}),
277
+ },
278
+ credentials: undefined,
279
+ onPrepared: request.onPrepared,
280
+ });
281
+ const handle = provisioned.handle;
282
+ const workspacePath = handle.descriptor.workspacePath;
283
+ const warnings: string[] = [];
284
+
285
+ try {
286
+ const projects = [...input.projects].sort(
287
+ (a, b) => a.position - b.position,
288
+ );
289
+ const wantsGithub = projects.some((project) =>
290
+ isGithubRepoUrl(project.repoUrl),
291
+ );
292
+ const token = wantsGithub
293
+ ? await deps.githubToken(input.task.ownerUserId)
294
+ : null;
295
+ for (const project of projects) {
296
+ const warning = await cloneProject(
297
+ handle,
298
+ workspacePath,
299
+ project,
300
+ input.task.branch,
301
+ { name: input.ownerName, email: input.ownerEmail },
302
+ token?.accessToken ?? null,
303
+ );
304
+ if (warning) warnings.push(warning);
305
+ }
306
+ } catch (error) {
307
+ // Provisioning-infrastructure failures (ssh died, unsafe slug) tear
308
+ // the machine down rather than stranding an orphan; per-repo problems
309
+ // were already converted to warnings above and do not land here.
310
+ await handle.teardown().catch(() => {});
311
+ throw error;
312
+ }
313
+
314
+ const machineId = parseMachineTaskEnvironmentLocator(
315
+ handle.descriptor.locator,
316
+ ).machineId;
317
+ return {
318
+ handle: withTaskDownResult(handle),
319
+ result: {
320
+ composeProject: machineId,
321
+ worktreePath: deps.taskControlDir(request.taskId),
322
+ ...(warnings.length > 0
323
+ ? { initWarning: warnings.join("\n") }
324
+ : {}),
325
+ },
326
+ };
327
+ },
328
+
329
+ async reconstruct(locator) {
330
+ return withTaskDownResult(await deps.environment.reconstruct(locator));
331
+ },
332
+ };
333
+ }