@runuai/host 0.9.69 → 0.9.70

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.69",
3
+ "version": "0.9.70",
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
@@ -26,6 +26,15 @@ import {
26
26
  type TaskGithubGitCredential,
27
27
  } from "../lib/github-git-auth";
28
28
  import { readAttachment, writeAttachment } from "../lib/attachments";
29
+ import { readFileSync } from "node:fs";
30
+ import { resolve } from "node:path";
31
+ import { taskProjectWorktree } from "../lib/env";
32
+ import { getDecryptedForProject } from "../lib/host-env";
33
+ import {
34
+ envSchemaNote,
35
+ missingRequiredKeys,
36
+ parseEnvSchema,
37
+ } from "../lib/env-schema";
29
38
  import { appendTranscript as writeTranscript } from "../lib/transcript";
30
39
  import { buildTaskDiff } from "../lib/task-diff";
31
40
  import {
@@ -786,6 +795,17 @@ export const hostCommands: HostCommands = {
786
795
  result.value.initWarning,
787
796
  );
788
797
  }
798
+ // ADR-122 phase 1: a project that declares @required env keys with
799
+ // no value on this host gets ONE visible note now — not a blank 500
800
+ // hours later (live 2026-08-25). Best-effort by design: a schema
801
+ // read hiccup must never fail a completed task-up.
802
+ void noteMissingRequiredEnvKeys(input).catch((err) =>
803
+ console.warn(
804
+ `[env-schema] task ${input.task.id}: validation note failed: ${
805
+ err instanceof Error ? err.message : String(err)
806
+ }`,
807
+ ),
808
+ );
789
809
  // GitHub auth for the container is best-effort (ADR-027) and runs in the
790
810
  // background — it must never block or fail task-up. Awaiting it here would
791
811
  // couple the command result to a network token-exchange: a slow/hung
@@ -1169,6 +1189,20 @@ export const hostCommands: HostCommands = {
1169
1189
  async attachmentWrite(ctx, input) {
1170
1190
  logCommand(ctx, "attachmentWrite", input.taskId, input.filename);
1171
1191
  try {
1192
+ // ADR-121 strangler: attachments go through the environment's file
1193
+ // ops, so this call site no longer knows where the workspace lives —
1194
+ // host-side providers implement them as the exact direct fs access
1195
+ // this used to be; machine providers reach over their transport. A
1196
+ // row without a reconstructible environment (legacy) keeps the
1197
+ // host-FS path unchanged.
1198
+ const environment = await reconstructHostTaskEnvironment(input.taskId);
1199
+ if (environment) {
1200
+ await environment.writeWorkspaceFile(
1201
+ attachmentEnvironmentPath(environment, input.filename),
1202
+ Buffer.from(input.dataBase64, "base64"),
1203
+ );
1204
+ return ok(undefined);
1205
+ }
1172
1206
  writeAttachment(
1173
1207
  input.taskId,
1174
1208
  input.filename,
@@ -1183,7 +1217,12 @@ export const hostCommands: HostCommands = {
1183
1217
  async attachmentRead(ctx, input) {
1184
1218
  logCommand(ctx, "attachmentRead", input.taskId, input.filename);
1185
1219
  try {
1186
- const bytes = readAttachment(input.taskId, input.filename);
1220
+ const environment = await reconstructHostTaskEnvironment(input.taskId);
1221
+ const bytes = environment
1222
+ ? await environment.readWorkspaceFile(
1223
+ attachmentEnvironmentPath(environment, input.filename),
1224
+ )
1225
+ : readAttachment(input.taskId, input.filename);
1187
1226
  if (!bytes) {
1188
1227
  return {
1189
1228
  ok: false,
@@ -1191,7 +1230,7 @@ export const hostCommands: HostCommands = {
1191
1230
  message: `no such attachment: ${input.filename}`,
1192
1231
  };
1193
1232
  }
1194
- return ok({ dataBase64: bytes.toString("base64") });
1233
+ return ok({ dataBase64: Buffer.from(bytes).toString("base64") });
1195
1234
  } catch (err) {
1196
1235
  return failFromUnknown(err);
1197
1236
  }
@@ -1267,6 +1306,53 @@ function taskDownResultForInput(
1267
1306
  };
1268
1307
  }
1269
1308
 
1309
+ /** Attachment location inside the environment — same shape every provider
1310
+ * serves (`<workspace>/.uai/attachments/<name>`), basename-hardened exactly
1311
+ * like the legacy host-side safePath. */
1312
+ function attachmentEnvironmentPath(
1313
+ environment: { descriptor: { workspacePath: string } },
1314
+ filename: string,
1315
+ ): string {
1316
+ const name = filename.split("/").pop() ?? "";
1317
+ if (name === "" || name === "." || name === ".." || name.includes("\0")) {
1318
+ throw new Error("invalid attachment filename");
1319
+ }
1320
+ return `${environment.descriptor.workspacePath}/.uai/attachments/${name}`;
1321
+ }
1322
+
1323
+ /** ADR-122 phase 1: read each project's committed .env.schema from the
1324
+ * fresh workspace, compare @required keys against the union of values this
1325
+ * host actually injected (env is merged across projects, so any project's
1326
+ * value covers the key), and post one note naming what's missing. */
1327
+ async function noteMissingRequiredEnvKeys(
1328
+ input: TaskLaunchInput,
1329
+ ): Promise<void> {
1330
+ const provided = new Set<string>();
1331
+ for (const project of input.projects) {
1332
+ for (const key of Object.keys(getDecryptedForProject(project.id))) {
1333
+ provided.add(key);
1334
+ }
1335
+ }
1336
+ const perProject: Array<{ slug: string; missing: string[] }> = [];
1337
+ for (const project of input.projects) {
1338
+ let content: string;
1339
+ try {
1340
+ content = readFileSync(
1341
+ resolve(taskProjectWorktree(input.task.id, project.slug), ".env.schema"),
1342
+ "utf8",
1343
+ );
1344
+ } catch {
1345
+ continue; // No schema, no opinion — today's behavior, unchanged.
1346
+ }
1347
+ perProject.push({
1348
+ slug: project.slug,
1349
+ missing: missingRequiredKeys(parseEnvSchema(content), provided),
1350
+ });
1351
+ }
1352
+ const note = envSchemaNote(perProject);
1353
+ if (note) getOrchestrator().emitSystemNote(input.task.id, note);
1354
+ }
1355
+
1270
1356
  function logCommand(
1271
1357
  ctx: CommandContext,
1272
1358
  command: keyof HostCommands,