@runuai/host 0.9.68 → 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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ADR-122 phase 1: a minimal, OWNED parser for the env-spec subset Uai
3
+ * reads — key names and the @required / @sensitive / @type decorators from
4
+ * comment lines above each declaration in a committed `.env.schema`.
5
+ *
6
+ * Deliberately dependency-free: this phase must stand alone even if the
7
+ * upstream tooling vanishes (the graduated-adoption ladder in ADR-122).
8
+ * Unknown decorators are preserved verbatim but unused; values in the
9
+ * schema file are IGNORED — a schema declares, it never supplies.
10
+ *
11
+ * First consumer: the post-task-up validation note. A project that
12
+ * declares `@required DATABASE_URL` with no value stored on this host gets
13
+ * one visible system note instead of a blank 500 hours later (the
14
+ * 2026-08-25 incident this phase exists to retire).
15
+ */
16
+
17
+ export interface EnvSchemaEntry {
18
+ name: string;
19
+ required: boolean;
20
+ sensitive: boolean;
21
+ type?: string;
22
+ /** Decorators Uai does not interpret, kept for diagnostics. */
23
+ otherDecorators: string[];
24
+ }
25
+
26
+ const KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)\s*=/;
27
+ const DECORATOR = /@([A-Za-z][A-Za-z0-9]*)(?:=((?:[^\s(]+)?(?:\([^)]*\))?))?/g;
28
+
29
+ export function parseEnvSchema(content: string): EnvSchemaEntry[] {
30
+ const entries: EnvSchemaEntry[] = [];
31
+ let pending: {
32
+ required: boolean;
33
+ sensitive: boolean;
34
+ type?: string;
35
+ other: string[];
36
+ } = { required: false, sensitive: false, other: [] };
37
+ for (const rawLine of content.split(/\r?\n/)) {
38
+ const line = rawLine.trim();
39
+ if (line.startsWith("#")) {
40
+ for (const match of line.matchAll(DECORATOR)) {
41
+ const name = match[1]!;
42
+ const value = match[2];
43
+ if (name === "required") pending.required = true;
44
+ else if (name === "sensitive") pending.sensitive = true;
45
+ else if (name === "type" && value) pending.type = value;
46
+ else pending.other.push(value ? `${name}=${value}` : name);
47
+ }
48
+ continue;
49
+ }
50
+ if (line === "") {
51
+ // A blank line ends a decorator block that never reached a key —
52
+ // env-spec decorators attach to the declaration directly below them.
53
+ pending = { required: false, sensitive: false, other: [] };
54
+ continue;
55
+ }
56
+ const key = KEY_LINE.exec(line);
57
+ if (key) {
58
+ entries.push({
59
+ name: key[1]!,
60
+ required: pending.required,
61
+ sensitive: pending.sensitive,
62
+ ...(pending.type !== undefined ? { type: pending.type } : {}),
63
+ otherDecorators: pending.other,
64
+ });
65
+ }
66
+ pending = { required: false, sensitive: false, other: [] };
67
+ }
68
+ return entries;
69
+ }
70
+
71
+ /** Required keys the provided value set does not cover. Presence is a
72
+ * KEY-level fact — an empty string is still a provided value; deciding
73
+ * emptiness policy belongs to later phases, not this one. */
74
+ export function missingRequiredKeys(
75
+ entries: EnvSchemaEntry[],
76
+ providedKeys: ReadonlySet<string>,
77
+ ): string[] {
78
+ return entries
79
+ .filter((entry) => entry.required && !providedKeys.has(entry.name))
80
+ .map((entry) => entry.name);
81
+ }
82
+
83
+ /** Compose the one visible note for a task whose projects declare required
84
+ * keys this host has no values for. Null when everything is satisfied. */
85
+ export function envSchemaNote(
86
+ perProject: Array<{ slug: string; missing: string[] }>,
87
+ ): string | null {
88
+ const affected = perProject.filter((project) => project.missing.length > 0);
89
+ if (affected.length === 0) return null;
90
+ const lines = affected.map(
91
+ (project) =>
92
+ `${project.slug}: ${project.missing.join(", ")}`,
93
+ );
94
+ return (
95
+ `This task's project${affected.length > 1 ? "s" : ""} declare required ` +
96
+ `env keys with no value set on this host — ` +
97
+ lines.join("; ") +
98
+ `. The app may fail until they are set on the project page; ` +
99
+ `Resume the task after setting them.`
100
+ );
101
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * ADR-121: command transport to a task machine — SSH within the private
3
+ * network (VPC for cloud backends, the daemon bridge on the Linux guinea
4
+ * pig). The recommended-and-recorded pick over SSM: it matches the exec
5
+ * plumbing every consumer already speaks, and nothing in it is
6
+ * cloud-specific.
7
+ *
8
+ * Mirrors `dockerCli`'s contract exactly — never rejects, resolves
9
+ * `{status, stdout, stderr}` with `status: null` on spawn error/timeout —
10
+ * so machine-backed call sites read like container-backed ones.
11
+ *
12
+ * Host-key policy is `accept-new`, the same posture task containers get for
13
+ * git (`core.sshCommand` in git-identity.ts): machines are freshly minted
14
+ * by US moments before first contact, so TOFU is sound — but a CHANGED key
15
+ * for a known address must fail loudly, never be silently re-accepted.
16
+ * BatchMode forbids prompts: an unreachable or unauthenticated machine is
17
+ * an error result, not a hang.
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+
22
+ import type { DockerResult } from "./docker-exec";
23
+
24
+ const DEFAULT_TIMEOUT_MS = 60_000;
25
+ const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
26
+ /** SIGKILLed ssh normally closes at once; an unreapable child must not hold
27
+ * the promise open past the caller's own deadline. */
28
+ const KILL_SETTLE_MS = 2_000;
29
+
30
+ export interface MachineExecTarget {
31
+ /** Reachable address from MachineInfo — VPC-private or bridge IP. */
32
+ address: string;
33
+ /** Path to the private key the machine's image authorizes. */
34
+ keyPath: string;
35
+ user?: string;
36
+ port?: number;
37
+ }
38
+
39
+ export interface MachineExecOptions {
40
+ input?: string;
41
+ timeoutMs?: number;
42
+ maxOutputBytes?: number;
43
+ }
44
+
45
+ /** POSIX single-quote escaping. The ONLY safe way to carry an arbitrary
46
+ * argv element through the remote login shell ssh interposes: everything
47
+ * between single quotes is literal, and an embedded quote becomes '\''. */
48
+ export function shellQuote(value: string): string {
49
+ return `'${value.replace(/'/g, `'\\''`)}'`;
50
+ }
51
+
52
+ /**
53
+ * Build the remote command for a TaskEnvironment-style request. SSH has no
54
+ * --workdir/--env/--user flags — cwd and env must be materialized INTO the
55
+ * remote command, each element quoted, and the user rides the ssh login
56
+ * instead (`-l`). Env values therefore appear in the machine-side process
57
+ * table; the machine is a single-task trust domain, and hardening to sshd
58
+ * SetEnv acceptlists is recorded ADR-121 follow-up work, not silently
59
+ * skipped.
60
+ */
61
+ export function buildRemoteCommand(request: {
62
+ argv: readonly string[];
63
+ cwd?: string;
64
+ inheritEnv?: readonly string[];
65
+ env?: Readonly<Record<string, string>>;
66
+ detached?: boolean;
67
+ }): string {
68
+ const parts: string[] = [];
69
+ if (request.cwd) {
70
+ parts.push(`cd ${shellQuote(request.cwd)} &&`);
71
+ }
72
+ const env: string[] = [];
73
+ for (const name of request.inheritEnv ?? []) {
74
+ const value = process.env[name];
75
+ if (value !== undefined) env.push(`${name}=${value}`);
76
+ }
77
+ for (const [name, value] of Object.entries(request.env ?? {}).sort(
78
+ ([left], [right]) => left.localeCompare(right),
79
+ )) {
80
+ env.push(`${name}=${value}`);
81
+ }
82
+ const envPrefix =
83
+ env.length > 0 ? `env ${env.map(shellQuote).join(" ")} ` : "";
84
+ const command = request.argv.map(shellQuote).join(" ");
85
+ if (request.detached) {
86
+ // Detached durable sessions manage their own transcript IO (runner.mjs);
87
+ // the launch just needs the process to survive this ssh connection:
88
+ // setsid detaches the controlling terminal, streams are severed, and the
89
+ // remote shell exits immediately with the launch verdict.
90
+ parts.push(`${envPrefix}setsid ${command} </dev/null >/dev/null 2>&1 &`);
91
+ parts.push("exit 0");
92
+ return parts.join(" ");
93
+ }
94
+ parts.push(`exec ${envPrefix}${command}`);
95
+ return parts.join(" ");
96
+ }
97
+
98
+ export function machineSshArgs(
99
+ target: MachineExecTarget,
100
+ argv: string[],
101
+ ): string[] {
102
+ return [
103
+ "-o",
104
+ "BatchMode=yes",
105
+ "-o",
106
+ "StrictHostKeyChecking=accept-new",
107
+ "-o",
108
+ "ConnectTimeout=10",
109
+ "-i",
110
+ target.keyPath,
111
+ "-p",
112
+ String(target.port ?? 22),
113
+ `${target.user ?? "node"}@${target.address}`,
114
+ "--",
115
+ ...argv,
116
+ ];
117
+ }
118
+
119
+ export function machineExec(
120
+ target: MachineExecTarget,
121
+ argv: string[],
122
+ opts: MachineExecOptions = {},
123
+ ): Promise<DockerResult> {
124
+ return runSsh(machineSshArgs(target, argv), opts);
125
+ }
126
+
127
+ /**
128
+ * Full ssh argv for a TaskEnvironment-style request: base transport options,
129
+ * the request's user as the ssh login, and the quoted remote command as the
130
+ * single trailing argument. Shared by captured, streaming, and detached
131
+ * paths so quoting can never diverge between them.
132
+ */
133
+ export function machineEnvironmentSshArgs(
134
+ target: Omit<MachineExecTarget, "user">,
135
+ request: {
136
+ argv: readonly string[];
137
+ cwd?: string;
138
+ user?: string;
139
+ inheritEnv?: readonly string[];
140
+ env?: Readonly<Record<string, string>>;
141
+ },
142
+ mode: "interactive" | "detached",
143
+ ): string[] {
144
+ return [
145
+ "-o",
146
+ "BatchMode=yes",
147
+ "-o",
148
+ "StrictHostKeyChecking=accept-new",
149
+ "-o",
150
+ "ConnectTimeout=10",
151
+ "-i",
152
+ target.keyPath,
153
+ "-p",
154
+ String(target.port ?? 22),
155
+ "-l",
156
+ request.user ?? "node",
157
+ target.address,
158
+ "--",
159
+ buildRemoteCommand({ ...request, detached: mode === "detached" }),
160
+ ];
161
+ }
162
+
163
+ function runSsh(
164
+ args: string[],
165
+ opts: MachineExecOptions,
166
+ ): Promise<DockerResult> {
167
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
168
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
169
+ return new Promise<DockerResult>((resolve) => {
170
+ let stdout = "";
171
+ let stderr = "";
172
+ let settled = false;
173
+ let killedForCause = false;
174
+ const child = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"] });
175
+ const settle = (result: DockerResult): void => {
176
+ if (settled) return;
177
+ settled = true;
178
+ clearTimeout(timer);
179
+ resolve(result);
180
+ };
181
+ const killAndSettle = (): void => {
182
+ killedForCause = true;
183
+ child.kill("SIGKILL");
184
+ setTimeout(() => {
185
+ settle({ status: null, stdout, stderr, outputTruncated: true });
186
+ }, KILL_SETTLE_MS).unref?.();
187
+ };
188
+ const timer = setTimeout(killAndSettle, timeoutMs);
189
+ timer.unref?.();
190
+ const capOutput = (): void => {
191
+ if (stdout.length + stderr.length > maxOutputBytes) killAndSettle();
192
+ };
193
+ child.stdout.setEncoding("utf8");
194
+ child.stderr.setEncoding("utf8");
195
+ child.stdout.on("data", (chunk: string) => {
196
+ stdout += chunk;
197
+ capOutput();
198
+ });
199
+ child.stderr.on("data", (chunk: string) => {
200
+ stderr += chunk;
201
+ capOutput();
202
+ });
203
+ child.once("error", () => {
204
+ settle({ status: null, stdout, stderr });
205
+ });
206
+ child.once("close", (code) => {
207
+ settle({
208
+ status: killedForCause ? null : code,
209
+ stdout,
210
+ stderr,
211
+ ...(killedForCause ? { outputTruncated: true } : {}),
212
+ });
213
+ });
214
+ if (opts.input !== undefined) {
215
+ child.stdin.write(opts.input);
216
+ }
217
+ child.stdin.end();
218
+ });
219
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ADR-121: per-machine SSH identity. Minted host-side at provision, the
3
+ * public half rides the machine spec (user-data / entrypoint env), the
4
+ * private half lives in the task's control tree — task lifetime, deleted
5
+ * with the task dir, never shared between tasks. `ssh-keygen` is used
6
+ * rather than a JS implementation: the host already shells out for every
7
+ * other crypto-bearing operation, and OpenSSH's own keygen is the one
8
+ * implementation `ssh` is guaranteed to agree with.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+
15
+ export interface MachineKeyPair {
16
+ privateKeyPath: string;
17
+ publicKey: string;
18
+ }
19
+
20
+ export function machineKeyDir(taskControlDir: string): string {
21
+ return resolve(taskControlDir, "machine");
22
+ }
23
+
24
+ /**
25
+ * Mint (or reuse) the task's machine keypair. Reuse is deliberate: a
26
+ * re-provision after a crash must produce the same identity the persisted
27
+ * locator's machine may already authorize.
28
+ */
29
+ export async function ensureMachineKeyPair(
30
+ taskControlDir: string,
31
+ ): Promise<MachineKeyPair> {
32
+ const dir = machineKeyDir(taskControlDir);
33
+ const privateKeyPath = resolve(dir, "id_ed25519");
34
+ const publicKeyPath = `${privateKeyPath}.pub`;
35
+ if (existsSync(privateKeyPath) && existsSync(publicKeyPath)) {
36
+ return {
37
+ privateKeyPath,
38
+ publicKey: readFileSync(publicKeyPath, "utf8").trim(),
39
+ };
40
+ }
41
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
42
+ rmSync(privateKeyPath, { force: true });
43
+ rmSync(publicKeyPath, { force: true });
44
+ await new Promise<void>((resolveKeygen, reject) => {
45
+ const child = spawn(
46
+ "ssh-keygen",
47
+ ["-t", "ed25519", "-N", "", "-C", "uai-machine", "-f", privateKeyPath],
48
+ { stdio: ["ignore", "ignore", "pipe"] },
49
+ );
50
+ let stderr = "";
51
+ child.stderr.setEncoding("utf8");
52
+ child.stderr.on("data", (chunk: string) => {
53
+ stderr += chunk;
54
+ });
55
+ child.once("error", (error) => reject(error));
56
+ child.once("close", (code) => {
57
+ if (code === 0) resolveKeygen();
58
+ else reject(new Error(`ssh-keygen failed (${code}): ${stderr.trim()}`));
59
+ });
60
+ });
61
+ return {
62
+ privateKeyPath,
63
+ publicKey: readFileSync(publicKeyPath, "utf8").trim(),
64
+ };
65
+ }