@runuai/host 0.9.128 → 0.9.129

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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { createHash } from "node:crypto";
14
+ import { readFileSync } from "node:fs";
14
15
  import { resolve } from "node:path";
15
16
 
16
17
  import { eq } from "drizzle-orm";
@@ -62,6 +63,30 @@ function managedTaskExecArgs(
62
63
  ];
63
64
  }
64
65
 
66
+ /** A task environment we can exec in (machine or sidecar): the ssh steps run
67
+ * there as `node` under the runtime authority, key material over stdin. */
68
+ type EnvironmentExec = Pick<TaskEnvironmentHandle, "exec">;
69
+
70
+ async function environmentStep(
71
+ environment: EnvironmentExec,
72
+ argv: [string, ...string[]],
73
+ stdin?: Uint8Array,
74
+ ): Promise<{ status: number | null; stdout: string; stderr: string }> {
75
+ const result = await environment.exec({
76
+ argv,
77
+ user: "node",
78
+ env: RUNTIME_AUTHORITY_ENV,
79
+ ...(stdin ? { stdin } : {}),
80
+ timeoutMs: EXEC_TIMEOUT_MS,
81
+ maxOutputBytes: 64 * 1024,
82
+ });
83
+ return {
84
+ status: result.exitCode,
85
+ stdout: Buffer.from(result.stdout).toString("utf8"),
86
+ stderr: Buffer.from(result.stderr).toString("utf8"),
87
+ };
88
+ }
89
+
65
90
  export async function setupTaskGitIdentity(
66
91
  taskId: string,
67
92
  name: string | null,
@@ -164,7 +189,35 @@ export async function injectSshIdentity(
164
189
  taskId: string,
165
190
  keyPath: string,
166
191
  exec: DockerExec = defaultExec,
192
+ environment?: EnvironmentExec,
167
193
  ): Promise<boolean> {
194
+ if (environment) {
195
+ // ADR-121/133: no container and no `docker cp` on a machine — the key
196
+ // pair rides stdin into files node owns from the start, so there is no
197
+ // chown race and nothing for the machine's other users to read. Before
198
+ // this branch every task machine logged `mkdir ~/.ssh failed: spawn
199
+ // docker ENOENT` on session start and signed nothing (2026-09-10).
200
+ const privateKey = readFileSync(keyPath);
201
+ const publicKey = readFileSync(`${keyPath}.pub`);
202
+ const steps: ReadonlyArray<readonly [string, [string, ...string[]], Uint8Array?]> = [
203
+ ["mkdir ~/.ssh", ["/bin/sh", "-c", "/bin/mkdir -p /home/node/.ssh && /bin/chmod 700 /home/node/.ssh"]],
204
+ ["copy key", ["/bin/sh", "-c", "umask 077 && /bin/cat > /home/node/.ssh/id_ed25519"], privateKey],
205
+ ["copy pubkey", ["/bin/sh", "-c", "umask 022 && /bin/cat > /home/node/.ssh/id_ed25519.pub"], publicKey],
206
+ ["own + chmod", ["/bin/sh", "-c", "/bin/chmod 700 /home/node/.ssh && /bin/chmod 600 /home/node/.ssh/id_ed25519"]],
207
+ ["SSH signing config", ["/bin/sh", "-c", SSH_SIGNING_CONFIG]],
208
+ ];
209
+ for (const [label, argv, stdin] of steps) {
210
+ const res = await environmentStep(environment, argv, stdin);
211
+ if (res.status !== 0) {
212
+ console.warn(
213
+ `[ssh] task ${taskId}: ${label} failed: ${res.stderr.trim() || `exit ${res.status}`}`,
214
+ );
215
+ return false;
216
+ }
217
+ }
218
+ console.log(`[ssh] task ${taskId}: signing identity + git signing config asserted`);
219
+ return true;
220
+ }
168
221
  const container = taskAppContainerName(taskId);
169
222
  const steps: ReadonlyArray<readonly [string, string[]]> = [
170
223
  [
@@ -269,12 +322,16 @@ async function sshKeyHealthy(
269
322
  taskId: string,
270
323
  expectedPublicKey: string,
271
324
  exec: DockerExec,
325
+ environment?: EnvironmentExec,
272
326
  ): Promise<boolean> {
273
- const res = await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), [
327
+ const probe: [string, ...string[]] = [
274
328
  "/bin/sh",
275
329
  "-c",
276
330
  "test -r /home/node/.ssh/id_ed25519 && /bin/cat /home/node/.ssh/id_ed25519.pub",
277
- ]));
331
+ ];
332
+ const res = environment
333
+ ? await environmentStep(environment, probe)
334
+ : await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), probe));
278
335
  const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
279
336
  const actualFingerprint = sshPublicKeyFingerprint(res.stdout ?? "");
280
337
  return (
@@ -287,6 +344,9 @@ async function sshKeyHealthy(
287
344
  interface EnsureTaskSshIdentityDeps {
288
345
  publicKeyForUser?: (userId: string) => string | null;
289
346
  writeIdentity?: typeof writeTaskIdentity;
347
+ /** The task's environment when it is a machine or sidecar (no container to
348
+ * exec into or `docker cp` to). Null/undefined = the container path. */
349
+ environment?: () => EnvironmentExec | null | undefined;
290
350
  }
291
351
 
292
352
  /**
@@ -342,6 +402,7 @@ export async function ensureTaskSshIdentity(
342
402
  expectedPublicKey,
343
403
  exec,
344
404
  deps.writeIdentity ?? writeTaskIdentity,
405
+ deps.environment?.() ?? undefined,
345
406
  );
346
407
  const entry = { fingerprint: expectedFingerprint, promise: pending };
347
408
  sshEnsureInFlight.set(taskId, entry);
@@ -365,19 +426,15 @@ async function ensureTaskSshIdentityOnce(
365
426
  expectedPublicKey: string,
366
427
  exec: DockerExec,
367
428
  writeIdentity: typeof writeTaskIdentity,
429
+ environment?: EnvironmentExec,
368
430
  ): Promise<boolean> {
369
431
  // Fast path: the DB-authoritative key is already good → re-assert only the
370
432
  // cheap signing config and skip docker cp churn.
371
- if (await sshKeyHealthy(taskId, expectedPublicKey, exec)) {
372
- const configured = await exec(managedTaskExecArgs(
373
- "node",
374
- taskAppContainerName(taskId),
375
- [
376
- "/bin/sh",
377
- "-c",
378
- SSH_SIGNING_CONFIG,
379
- ],
380
- ));
433
+ if (await sshKeyHealthy(taskId, expectedPublicKey, exec, environment)) {
434
+ const config: [string, ...string[]] = ["/bin/sh", "-c", SSH_SIGNING_CONFIG];
435
+ const configured = environment
436
+ ? await environmentStep(environment, config)
437
+ : await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), config));
381
438
  return configured.status === 0;
382
439
  }
383
440
 
@@ -388,7 +445,7 @@ async function ensureTaskSshIdentityOnce(
388
445
  }
389
446
  const keyPath = resolve(dir, "id_ed25519");
390
447
  try {
391
- return await injectSshIdentity(taskId, keyPath, exec);
448
+ return await injectSshIdentity(taskId, keyPath, exec, environment);
392
449
  } finally {
393
450
  // Host hygiene (same as task-up): the materialized private key never
394
451
  // outlives the injection.
@@ -2094,9 +2094,12 @@ function enqueueTaskGitAuth(
2094
2094
  // ensuring the identity also keeps commit/tag signing available. Never
2095
2095
  // select SSH transport unless a usable identity was actually asserted.
2096
2096
  admitContainerWork();
2097
- const hasSshIdentity = await (
2098
- deps.ensureSsh ?? ensureTaskSshIdentity
2099
- )(taskId, userId);
2097
+ const hasSshIdentity = deps.ensureSsh
2098
+ ? await deps.ensureSsh(taskId, userId)
2099
+ : await ensureTaskSshIdentity(taskId, userId, undefined, {
2100
+ // Machine/sidecar tasks install the key over the environment.
2101
+ environment: () => invocation.setupDeps?.environment?.() ?? null,
2102
+ });
2100
2103
  if (!isCurrent()) return false;
2101
2104
  admitContainerWork();
2102
2105
  if (hasSshIdentity) {
@@ -400,6 +400,25 @@ export function isClaudeAuthRevoked(message: string): boolean {
400
400
  return CLAUDE_AUTH_REVOKED_PATTERNS.some((re) => re.test(message));
401
401
  }
402
402
 
403
+ /**
404
+ * ADR-121/133: on a machine or sidecar task the SSH signing identity is
405
+ * installed over the task environment (no container to exec into or
406
+ * `docker cp` to); a container task gets no environment and keeps the docker
407
+ * path. Recovery and resume have only the persisted task, so the environment
408
+ * is reconstructed here.
409
+ */
410
+ async function sshIdentityDepsFor(task: {
411
+ taskId: string;
412
+ environmentProvider: string | null;
413
+ }): Promise<{ environment: () => Pick<TaskEnvironmentHandle<unknown>, "exec"> | null }> {
414
+ if (!isEnvironmentTransportProvider(task.environmentProvider)) {
415
+ return { environment: () => null };
416
+ }
417
+ const persisted = getHostTask(task.taskId);
418
+ const environment = persisted ? await reconstructPersistedTaskEnvironment(persisted) : null;
419
+ return { environment: () => environment };
420
+ }
421
+
403
422
  export class Orchestrator {
404
423
  private readonly channels = new Map<string, Channel>();
405
424
  private readonly channelSpecs = new Map<string, ChannelEnsureInput>();
@@ -1605,10 +1624,12 @@ export class Orchestrator {
1605
1624
  environmentProvider: string | null;
1606
1625
  projectIds?: string | null;
1607
1626
  }): Record<string, string> {
1608
- if (task.environmentProvider !== "machine") return {};
1627
+ // Machine OR sidecar (ADR-133): neither has a compose env to ride.
1628
+ if (!isEnvironmentTransportProvider(task.environmentProvider)) return {};
1609
1629
  return projectEnvForTask(hostTaskProjectIds(task));
1610
1630
  }
1611
1631
 
1632
+
1612
1633
  /** Install a session with an event handler scoped to this exact generation. */
1613
1634
  private bindSession(
1614
1635
  channel: Channel,
@@ -1898,7 +1919,11 @@ export class Orchestrator {
1898
1919
  // SSH signing identity, re-asserted independently of transport. Connected
1899
1920
  // users still push through gh over HTTPS; disconnected users select the
1900
1921
  // SSH transport fallback inside the reconcile above.
1901
- void ensureTaskSshIdentity(channel.taskId, task.ownerUserId).catch((err) =>
1922
+ void ensureTaskSshIdentity(channel.taskId, task.ownerUserId, undefined, {
1923
+ // A machine or sidecar task installs the key over its environment; a
1924
+ // container task keeps the docker path (a null handle).
1925
+ environment: () => channel.agentHandle,
1926
+ }).catch((err) =>
1902
1927
  console.warn(
1903
1928
  `[ssh] task ${channel.taskId}: signing identity reconciliation failed: ${
1904
1929
  err instanceof Error ? err.message : String(err)
@@ -6820,7 +6845,9 @@ async function recoverOneDockerTask(
6820
6845
  ),
6821
6846
  );
6822
6847
  }
6823
- void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
6848
+ void sshIdentityDepsFor(task)
6849
+ .then((deps) => ensureTaskSshIdentity(task.taskId, task.ownerUserId, undefined, deps))
6850
+ .catch((err) =>
6824
6851
  console.warn(
6825
6852
  `[ssh] task ${task.taskId}: running recovery reconciliation failed: ${
6826
6853
  err instanceof Error ? err.message : String(err)
@@ -7087,7 +7114,9 @@ async function recoverOneDockerTask(
7087
7114
  db_setRuntime(task.taskId, {
7088
7115
  codeServerPort: port,
7089
7116
  });
7090
- void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
7117
+ void sshIdentityDepsFor(task)
7118
+ .then((deps) => ensureTaskSshIdentity(task.taskId, task.ownerUserId, undefined, deps))
7119
+ .catch((err) =>
7091
7120
  console.warn(
7092
7121
  `[ssh] task ${task.taskId}: resumed signing identity reconciliation failed: ${
7093
7122
  err instanceof Error ? err.message : String(err)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.128",
3
+ "version": "0.9.129",
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>",