@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.
@@ -186,6 +186,16 @@ export type TaskEnvironmentRecoveryResult =
186
186
  export interface TaskEnvironmentHandle<TTeardownResult = void>
187
187
  extends TaskEnvironmentAgentSessionSurface {
188
188
  readonly descriptor: TaskEnvironmentDescriptor;
189
+ /** ADR-121 strangler ops: task-scoped file IO through the provider, so
190
+ * call sites stop knowing where the workspace physically lives. Paths are
191
+ * ENVIRONMENT-absolute and must sit inside the descriptor's workspace
192
+ * (assertWorkspaceScopedPath). Providers whose workspace is host-side
193
+ * (docker, apple) implement these as direct filesystem access — zero
194
+ * behavior change; machine providers reach over their transport. Read
195
+ * resolves null for a missing file and rejects for any other failure —
196
+ * absence and error are different facts. */
197
+ readWorkspaceFile(path: string): Promise<Uint8Array | null>;
198
+ writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void>;
189
199
  exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult>;
190
200
  spawn(request: TaskEnvironmentSpawnRequest): Promise<TaskEnvironmentProcess>;
191
201
  copy(request: TaskEnvironmentCopyRequest): Promise<void>;
@@ -245,6 +255,28 @@ export interface TaskEnvironmentProvider<
245
255
  ): Promise<TaskEnvironmentHandle<TTeardownResult>>;
246
256
  }
247
257
 
258
+ /** Workspace-scope guard for the strangler file ops: the path must be an
259
+ * absolute, NUL-free, dot-segment-free environment path inside the
260
+ * descriptor's workspace. Escaping the workspace through the file ops would
261
+ * hand callers the environment's whole filesystem. */
262
+ export function assertWorkspaceScopedPath(
263
+ descriptor: TaskEnvironmentDescriptor,
264
+ path: string,
265
+ ): void {
266
+ if (!path.startsWith("/") || path.includes("\0")) {
267
+ throw new Error("workspace file path must be an absolute NUL-free path");
268
+ }
269
+ if (path.split("/").some((part) => part === "." || part === "..")) {
270
+ throw new Error("workspace file path must not contain dot segments");
271
+ }
272
+ const root = descriptor.workspacePath.endsWith("/")
273
+ ? descriptor.workspacePath
274
+ : `${descriptor.workspacePath}/`;
275
+ if (path !== descriptor.workspacePath && !path.startsWith(root)) {
276
+ throw new Error("workspace file path escapes the task workspace");
277
+ }
278
+ }
279
+
248
280
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
249
281
 
250
282
  /** Shared admission guard for provider implementations and fake providers. */
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ADR-121 strangler ops: the host-filesystem implementation of the
3
+ * workspace file operations, shared by every provider whose workspace
4
+ * physically lives on the host (docker, apple-container). Behavior is
5
+ * byte-identical to the direct fs access call sites used to perform — the
6
+ * point of the strangler's first phase is that call sites stop KNOWING the
7
+ * workspace is host-side, not that anything moves yet.
8
+ */
9
+
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+
13
+ import {
14
+ assertWorkspaceScopedPath,
15
+ type TaskEnvironmentDescriptor,
16
+ } from "./types";
17
+
18
+ /** Map an environment-absolute workspace path to its host location. */
19
+ function hostPathFor(
20
+ descriptor: TaskEnvironmentDescriptor,
21
+ hostWorkspaceRoot: string,
22
+ path: string,
23
+ ): string {
24
+ assertWorkspaceScopedPath(descriptor, path);
25
+ const relative = path.slice(descriptor.workspacePath.length);
26
+ return join(hostWorkspaceRoot, ...relative.split("/").filter(Boolean));
27
+ }
28
+
29
+ export function hostWorkspaceRead(
30
+ descriptor: TaskEnvironmentDescriptor,
31
+ hostWorkspaceRoot: string,
32
+ path: string,
33
+ ): Uint8Array | null {
34
+ try {
35
+ return readFileSync(hostPathFor(descriptor, hostWorkspaceRoot, path));
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ export function hostWorkspaceWrite(
43
+ descriptor: TaskEnvironmentDescriptor,
44
+ hostWorkspaceRoot: string,
45
+ path: string,
46
+ bytes: Uint8Array,
47
+ ): void {
48
+ const target = hostPathFor(descriptor, hostWorkspaceRoot, path);
49
+ mkdirSync(dirname(target), { recursive: true });
50
+ writeFileSync(target, bytes);
51
+ }
package/lib/transcript.ts CHANGED
@@ -14,6 +14,7 @@ import { resolve } from "node:path";
14
14
 
15
15
  import { taskWorkspaceDir } from "./env";
16
16
  import { rewriteAttachmentRefs } from "./orchestrator";
17
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
17
18
  import type { TranscriptTarget } from "../src/protocol";
18
19
 
19
20
  /** Container path agents are pointed at. */
@@ -26,20 +27,97 @@ const TARGET_FILENAME: Record<TranscriptTarget, string> = {
26
27
  "chat-front": "chat-front.md",
27
28
  };
28
29
 
30
+ function transcriptEntry(author: string, text: string): string | null {
31
+ // Rewrite cloud attachment URLs to the in-container path so an agent reading
32
+ // the transcript can open referenced files directly.
33
+ const body = rewriteAttachmentRefs(text).trim();
34
+ if (!body) return null;
35
+ return `## ${author}\n\n${body}\n\n`;
36
+ }
37
+
29
38
  export function appendTranscript(
30
39
  taskId: string,
31
40
  author: string,
32
41
  text: string,
33
42
  targets: TranscriptTarget[],
34
43
  ): void {
35
- // Rewrite cloud attachment URLs to the in-container path so an agent reading
36
- // the transcript can open referenced files directly.
37
- const body = rewriteAttachmentRefs(text).trim();
38
- if (!body) return;
44
+ const entry = transcriptEntry(author, text);
45
+ if (entry === null) return;
39
46
  const dir = resolve(taskWorkspaceDir(taskId), ".uai");
40
47
  mkdirSync(dir, { recursive: true });
41
- const entry = `## ${author}\n\n${body}\n\n`;
42
48
  for (const target of new Set(targets)) {
43
49
  appendFileSync(resolve(dir, TARGET_FILENAME[target]), entry);
44
50
  }
45
51
  }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // ADR-121: environment-side transcript for machine-backed tasks. The
55
+ // workspace lives in the machine's world, so the append travels over the
56
+ // environment transport (entry via stdin). A per-task promise chain
57
+ // preserves message order the way the sync host-FS path did implicitly.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const environmentAppendChains = new Map<string, Promise<void>>();
61
+
62
+ async function appendOneViaEnvironment(
63
+ environment: TaskEnvironmentHandle<unknown>,
64
+ target: TranscriptTarget,
65
+ entry: string,
66
+ ): Promise<void> {
67
+ const path = `${environment.descriptor.workspacePath}/.uai/${TARGET_FILENAME[target]}`;
68
+ const proc = await environment.spawn({
69
+ argv: [
70
+ "/bin/sh",
71
+ "-c",
72
+ 'mkdir -p "$(dirname "$1")" && cat >> "$1"',
73
+ "append",
74
+ path,
75
+ ],
76
+ inheritEnv: [],
77
+ env: {},
78
+ timeoutMs: 30_000,
79
+ maxOutputBytes: 8 * 1024,
80
+ });
81
+ const drains = [
82
+ (async () => {
83
+ for await (const chunk of proc.stdout) void chunk;
84
+ })(),
85
+ (async () => {
86
+ for await (const chunk of proc.stderr) void chunk;
87
+ })(),
88
+ ];
89
+ await proc.write(Buffer.from(entry));
90
+ await proc.closeInput();
91
+ const exit = await proc.completion;
92
+ await Promise.allSettled(drains);
93
+ if (exit.exitCode !== 0) {
94
+ throw new Error(`environment transcript append exited ${exit.exitCode}`);
95
+ }
96
+ }
97
+
98
+ export function appendTranscriptViaEnvironment(
99
+ taskId: string,
100
+ environment: TaskEnvironmentHandle<unknown>,
101
+ author: string,
102
+ text: string,
103
+ targets: TranscriptTarget[],
104
+ ): Promise<void> {
105
+ const entry = transcriptEntry(author, text);
106
+ if (entry === null) return Promise.resolve();
107
+ const previous = environmentAppendChains.get(taskId) ?? Promise.resolve();
108
+ const next = previous
109
+ .catch(() => {})
110
+ .then(async () => {
111
+ for (const target of new Set(targets)) {
112
+ await appendOneViaEnvironment(environment, target, entry);
113
+ }
114
+ });
115
+ environmentAppendChains.set(taskId, next);
116
+ // Bound the map: forget the chain once it settles as the latest entry.
117
+ void next.finally(() => {
118
+ if (environmentAppendChains.get(taskId) === next) {
119
+ environmentAppendChains.delete(taskId);
120
+ }
121
+ });
122
+ return next;
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.69",
3
+ "version": "0.9.71",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/index.ts CHANGED
@@ -19,14 +19,28 @@ import {
19
19
  } from "../lib/agent-cli";
20
20
  import {
21
21
  clearRefresh,
22
+ injectIntoContainer,
22
23
  reconcileTaskGitAuth,
24
+ requestAccessToken,
23
25
  } from "../lib/github-tokens";
24
26
  import {
25
27
  prepareTaskGithubGitCredential,
26
28
  type TaskGithubGitCredential,
27
29
  } from "../lib/github-git-auth";
28
30
  import { readAttachment, writeAttachment } from "../lib/attachments";
29
- import { appendTranscript as writeTranscript } from "../lib/transcript";
31
+ import { readFileSync } from "node:fs";
32
+ import { resolve } from "node:path";
33
+ import { taskProjectWorktree } from "../lib/env";
34
+ import { getDecryptedForProject } from "../lib/host-env";
35
+ import {
36
+ envSchemaNote,
37
+ missingRequiredKeys,
38
+ parseEnvSchema,
39
+ } from "../lib/env-schema";
40
+ import {
41
+ appendTranscript as writeTranscript,
42
+ appendTranscriptViaEnvironment,
43
+ } from "../lib/transcript";
30
44
  import { buildTaskDiff } from "../lib/task-diff";
31
45
  import {
32
46
  deleteHostTask,
@@ -439,6 +453,37 @@ export const hostCommands: HostCommands = {
439
453
  runtimeQuarantinedForRecreate = true;
440
454
  }
441
455
  }
456
+ } else if (
457
+ existingTask?.statusMirror === "running" &&
458
+ existingTask.environmentProvider === "machine"
459
+ ) {
460
+ // ADR-121: a machine-backed task revalidates through its own
461
+ // provider — the compose fast path below speaks docker and would
462
+ // misread a machine row. Running machine → persisted success;
463
+ // anything else falls through to provisionTaskEnvironment, whose
464
+ // machine provider owns recovery semantics.
465
+ try {
466
+ const environment = await reconstructPersistedTaskEnvironment(
467
+ existingTask,
468
+ );
469
+ if (environment) {
470
+ const machineStatus = await environment.status();
471
+ if (machineStatus.state === "running") {
472
+ orchestrator.allowChannel(input.task.id);
473
+ return {
474
+ ok: true,
475
+ value: {
476
+ composeProject: existingTask.composeProject ?? "",
477
+ worktreePath: existingTask.worktreePath ?? "",
478
+ },
479
+ };
480
+ }
481
+ }
482
+ } catch (error) {
483
+ console.warn(
484
+ `[machine] task ${input.task.id}: running-row revalidation failed, continuing to provision: ${error instanceof Error ? error.message : String(error)}`,
485
+ );
486
+ }
442
487
  } else if (existingTask?.statusMirror === "running") {
443
488
  if (!existingTask.composeProject || !existingTask.worktreePath) {
444
489
  return {
@@ -786,6 +831,17 @@ export const hostCommands: HostCommands = {
786
831
  result.value.initWarning,
787
832
  );
788
833
  }
834
+ // ADR-122 phase 1: a project that declares @required env keys with
835
+ // no value on this host gets ONE visible note now — not a blank 500
836
+ // hours later (live 2026-08-25). Best-effort by design: a schema
837
+ // read hiccup must never fail a completed task-up.
838
+ void noteMissingRequiredEnvKeys(input).catch((err) =>
839
+ console.warn(
840
+ `[env-schema] task ${input.task.id}: validation note failed: ${
841
+ err instanceof Error ? err.message : String(err)
842
+ }`,
843
+ ),
844
+ );
789
845
  // GitHub auth for the container is best-effort (ADR-027) and runs in the
790
846
  // background — it must never block or fail task-up. Awaiting it here would
791
847
  // couple the command result to a network token-exchange: a slow/hung
@@ -793,16 +849,33 @@ export const hostCommands: HostCommands = {
793
849
  // and mark a running task as errored. The reconciler injects + schedules
794
850
  // (or emits a system note on failure) on its own; the agents come up
795
851
  // regardless and the token lands well before the first `gh` call.
796
- void reconcileTaskGitAuth(
797
- input.task.id,
798
- input.task.ownerUserId,
799
- ).catch((err) =>
800
- console.warn(
801
- `[github] task ${input.task.id}: post-start reconciliation failed: ${
802
- err instanceof Error ? err.message : String(err)
803
- }`,
804
- ),
805
- );
852
+ // ADR-121: machine-backed tasks take a direct environment injection —
853
+ // the compose reconciler would probe for an app container, find none,
854
+ // and silently skip. Reconnect re-injection for machines is a
855
+ // follow-up alongside the machine recovery pass.
856
+ if (getHostTask(input.task.id)?.environmentProvider === "machine") {
857
+ void machineTaskGithubAuth(
858
+ input.task.id,
859
+ input.task.ownerUserId,
860
+ ).catch((err) =>
861
+ console.warn(
862
+ `[github] machine task ${input.task.id}: auth injection failed: ${
863
+ err instanceof Error ? err.message : String(err)
864
+ }`,
865
+ ),
866
+ );
867
+ } else {
868
+ void reconcileTaskGitAuth(
869
+ input.task.id,
870
+ input.task.ownerUserId,
871
+ ).catch((err) =>
872
+ console.warn(
873
+ `[github] task ${input.task.id}: post-start reconciliation failed: ${
874
+ err instanceof Error ? err.message : String(err)
875
+ }`,
876
+ ),
877
+ );
878
+ }
806
879
  } else if (result.code === HostErrorCode.HostUnavailable) {
807
880
  // A daemon outage is retryable infrastructure state, not proof that
808
881
  // the task itself failed. Undo the optimistic local `starting` mirror
@@ -1169,6 +1242,20 @@ export const hostCommands: HostCommands = {
1169
1242
  async attachmentWrite(ctx, input) {
1170
1243
  logCommand(ctx, "attachmentWrite", input.taskId, input.filename);
1171
1244
  try {
1245
+ // ADR-121 strangler: attachments go through the environment's file
1246
+ // ops, so this call site no longer knows where the workspace lives —
1247
+ // host-side providers implement them as the exact direct fs access
1248
+ // this used to be; machine providers reach over their transport. A
1249
+ // row without a reconstructible environment (legacy) keeps the
1250
+ // host-FS path unchanged.
1251
+ const environment = await reconstructHostTaskEnvironment(input.taskId);
1252
+ if (environment) {
1253
+ await environment.writeWorkspaceFile(
1254
+ attachmentEnvironmentPath(environment, input.filename),
1255
+ Buffer.from(input.dataBase64, "base64"),
1256
+ );
1257
+ return ok(undefined);
1258
+ }
1172
1259
  writeAttachment(
1173
1260
  input.taskId,
1174
1261
  input.filename,
@@ -1183,7 +1270,12 @@ export const hostCommands: HostCommands = {
1183
1270
  async attachmentRead(ctx, input) {
1184
1271
  logCommand(ctx, "attachmentRead", input.taskId, input.filename);
1185
1272
  try {
1186
- const bytes = readAttachment(input.taskId, input.filename);
1273
+ const environment = await reconstructHostTaskEnvironment(input.taskId);
1274
+ const bytes = environment
1275
+ ? await environment.readWorkspaceFile(
1276
+ attachmentEnvironmentPath(environment, input.filename),
1277
+ )
1278
+ : readAttachment(input.taskId, input.filename);
1187
1279
  if (!bytes) {
1188
1280
  return {
1189
1281
  ok: false,
@@ -1191,7 +1283,7 @@ export const hostCommands: HostCommands = {
1191
1283
  message: `no such attachment: ${input.filename}`,
1192
1284
  };
1193
1285
  }
1194
- return ok({ dataBase64: bytes.toString("base64") });
1286
+ return ok({ dataBase64: Buffer.from(bytes).toString("base64") });
1195
1287
  } catch (err) {
1196
1288
  return failFromUnknown(err);
1197
1289
  }
@@ -1226,6 +1318,24 @@ export const hostCommands: HostCommands = {
1226
1318
  async appendTranscript(_ctx, taskId, author, text, targets) {
1227
1319
  // Per-message + high-frequency, so no logCommand (avoid log spam).
1228
1320
  try {
1321
+ // ADR-121: a machine-backed workspace lives in the machine's world —
1322
+ // the append must travel over the environment transport. Container
1323
+ // tasks keep the direct host-FS write (their workspace is a bind
1324
+ // mount and the sync path is cheaper than a docker exec per message).
1325
+ const task = getHostTask(taskId);
1326
+ if (task?.environmentProvider === "machine") {
1327
+ const environment = await reconstructHostTaskEnvironment(taskId);
1328
+ if (environment) {
1329
+ await appendTranscriptViaEnvironment(
1330
+ taskId,
1331
+ environment,
1332
+ author,
1333
+ text,
1334
+ targets,
1335
+ );
1336
+ return ok(undefined);
1337
+ }
1338
+ }
1229
1339
  writeTranscript(taskId, author, text, targets);
1230
1340
  return ok(undefined);
1231
1341
  } catch (err) {
@@ -1238,6 +1348,30 @@ function normalizeChannelSpec(input: ChannelEnsureInput): ChannelEnsureInput {
1238
1348
  return { ...input, workspacePath: "/workspace" };
1239
1349
  }
1240
1350
 
1351
+ /**
1352
+ * ADR-121: gh auth for a machine-backed task, injected over the environment
1353
+ * transport (token via exec stdin — same `gh auth login --with-token` +
1354
+ * `setup-git` gesture as containers, no compose probing). Best-effort like
1355
+ * the compose reconciler: absence of a GitHub connection is a quiet no-op.
1356
+ */
1357
+ async function machineTaskGithubAuth(
1358
+ taskId: string,
1359
+ userId: string,
1360
+ ): Promise<void> {
1361
+ const environment = await reconstructHostTaskEnvironment(taskId);
1362
+ if (!environment) return;
1363
+ const token = await requestAccessToken(userId);
1364
+ if (!token) return;
1365
+ // Machine work is not container work: no docker/apple runtime admission.
1366
+ await injectIntoContainer(
1367
+ taskId,
1368
+ token.accessToken,
1369
+ undefined,
1370
+ () => {},
1371
+ environment,
1372
+ );
1373
+ }
1374
+
1241
1375
  async function reconstructHostTaskEnvironment(
1242
1376
  taskId: string,
1243
1377
  ): Promise<TaskEnvironmentHandle<TaskDownResult> | null> {
@@ -1267,6 +1401,53 @@ function taskDownResultForInput(
1267
1401
  };
1268
1402
  }
1269
1403
 
1404
+ /** Attachment location inside the environment — same shape every provider
1405
+ * serves (`<workspace>/.uai/attachments/<name>`), basename-hardened exactly
1406
+ * like the legacy host-side safePath. */
1407
+ function attachmentEnvironmentPath(
1408
+ environment: { descriptor: { workspacePath: string } },
1409
+ filename: string,
1410
+ ): string {
1411
+ const name = filename.split("/").pop() ?? "";
1412
+ if (name === "" || name === "." || name === ".." || name.includes("\0")) {
1413
+ throw new Error("invalid attachment filename");
1414
+ }
1415
+ return `${environment.descriptor.workspacePath}/.uai/attachments/${name}`;
1416
+ }
1417
+
1418
+ /** ADR-122 phase 1: read each project's committed .env.schema from the
1419
+ * fresh workspace, compare @required keys against the union of values this
1420
+ * host actually injected (env is merged across projects, so any project's
1421
+ * value covers the key), and post one note naming what's missing. */
1422
+ async function noteMissingRequiredEnvKeys(
1423
+ input: TaskLaunchInput,
1424
+ ): Promise<void> {
1425
+ const provided = new Set<string>();
1426
+ for (const project of input.projects) {
1427
+ for (const key of Object.keys(getDecryptedForProject(project.id))) {
1428
+ provided.add(key);
1429
+ }
1430
+ }
1431
+ const perProject: Array<{ slug: string; missing: string[] }> = [];
1432
+ for (const project of input.projects) {
1433
+ let content: string;
1434
+ try {
1435
+ content = readFileSync(
1436
+ resolve(taskProjectWorktree(input.task.id, project.slug), ".env.schema"),
1437
+ "utf8",
1438
+ );
1439
+ } catch {
1440
+ continue; // No schema, no opinion — today's behavior, unchanged.
1441
+ }
1442
+ perProject.push({
1443
+ slug: project.slug,
1444
+ missing: missingRequiredKeys(parseEnvSchema(content), provided),
1445
+ });
1446
+ }
1447
+ const note = envSchemaNote(perProject);
1448
+ if (note) getOrchestrator().emitSystemNote(input.task.id, note);
1449
+ }
1450
+
1270
1451
  function logCommand(
1271
1452
  ctx: CommandContext,
1272
1453
  command: keyof HostCommands,