@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.
- package/lib/env-schema.ts +101 -0
- package/lib/machine-exec.ts +89 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-provider-aws.ts +437 -0
- package/lib/machine-provider-local.ts +58 -7
- package/lib/machine-provider.ts +10 -0
- package/lib/task-environment/apple-container.ts +21 -0
- package/lib/task-environment/docker.ts +21 -0
- package/lib/task-environment/machine.ts +728 -0
- package/lib/task-environment/types.ts +32 -0
- package/lib/task-environment/workspace-files.ts +51 -0
- package/package.json +1 -1
- package/src/index.ts +88 -2
|
@@ -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
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
|
|
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,
|