@runuai/host 0.9.128 → 0.9.130

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) {
@@ -293,6 +293,12 @@ interface Channel {
293
293
  /** Agents with a reconcile-spawn in flight (ADR-049 mid-task adds) — guards
294
294
  * against a concurrent ensure double-spawning the same new agent. */
295
295
  spawning: Set<string>;
296
+ /** Roster agents that have bound at least once on this channel. The launch
297
+ * screen's `ready` counts these, not the live `sessions` map: a one-shot
298
+ * engine (grok, cursor) exits seconds after its bind, so "every roster
299
+ * agent bound at this instant" was never true for a mixed roster after a
300
+ * wake — live 2026-09-10, task machine, 4 agents, screen up for good. */
301
+ everBound: Set<string>;
296
302
  /** Factory creates are externally side-effecting before they bind. Teardown
297
303
  * drains them so an old channel cannot resume against a reused container
298
304
  * name and overwrite the replacement generation's durable transport. */
@@ -400,6 +406,25 @@ export function isClaudeAuthRevoked(message: string): boolean {
400
406
  return CLAUDE_AUTH_REVOKED_PATTERNS.some((re) => re.test(message));
401
407
  }
402
408
 
409
+ /**
410
+ * ADR-121/133: on a machine or sidecar task the SSH signing identity is
411
+ * installed over the task environment (no container to exec into or
412
+ * `docker cp` to); a container task gets no environment and keeps the docker
413
+ * path. Recovery and resume have only the persisted task, so the environment
414
+ * is reconstructed here.
415
+ */
416
+ async function sshIdentityDepsFor(task: {
417
+ taskId: string;
418
+ environmentProvider: string | null;
419
+ }): Promise<{ environment: () => Pick<TaskEnvironmentHandle<unknown>, "exec"> | null }> {
420
+ if (!isEnvironmentTransportProvider(task.environmentProvider)) {
421
+ return { environment: () => null };
422
+ }
423
+ const persisted = getHostTask(task.taskId);
424
+ const environment = persisted ? await reconstructPersistedTaskEnvironment(persisted) : null;
425
+ return { environment: () => environment };
426
+ }
427
+
403
428
  export class Orchestrator {
404
429
  private readonly channels = new Map<string, Channel>();
405
430
  private readonly channelSpecs = new Map<string, ChannelEnsureInput>();
@@ -698,6 +723,7 @@ export class Orchestrator {
698
723
  mcpConfigAppliedEpoch: 0,
699
724
  configGeneration: 0,
700
725
  spawning: new Set(),
726
+ everBound: new Set(),
701
727
  factoryOperations: new Set(),
702
728
  eventOperations: new Set(),
703
729
  accountByAgent: new Map(),
@@ -1605,24 +1631,37 @@ export class Orchestrator {
1605
1631
  environmentProvider: string | null;
1606
1632
  projectIds?: string | null;
1607
1633
  }): Record<string, string> {
1608
- if (task.environmentProvider !== "machine") return {};
1634
+ // Machine OR sidecar (ADR-133): neither has a compose env to ride.
1635
+ if (!isEnvironmentTransportProvider(task.environmentProvider)) return {};
1609
1636
  return projectEnvForTask(hostTaskProjectIds(task));
1610
1637
  }
1611
1638
 
1639
+
1612
1640
  /** Install a session with an event handler scoped to this exact generation. */
1641
+ /** Every roster agent has bound at least once — the launch screen lifts. */
1642
+ private rosterReady(channel: Channel): boolean {
1643
+ return channel.roster.every(
1644
+ (agent) => channel.sessions.has(agent.id) || channel.everBound.has(agent.id),
1645
+ );
1646
+ }
1647
+
1613
1648
  private bindSession(
1614
1649
  channel: Channel,
1615
1650
  agentId: string,
1616
1651
  session: AgentSession,
1617
1652
  ): void {
1618
1653
  channel.sessions.set(agentId, session);
1654
+ channel.everBound.add(agentId);
1619
1655
  // The launch screen's `ready` rides the bind, not the tail of the start
1620
1656
  // loop: whichever path brings the last roster agent up — the initial
1621
1657
  // start, a reconcile that won the race with it, a respawn after Resume —
1622
1658
  // is the moment the task can take a message. Live 2026-09-09: `agents`
1623
1659
  // was stored, Jonah was up and idle, and no `ready` ever followed, so
1624
1660
  // the page stayed covered for minutes. Idempotent on the cloud side.
1625
- if (channel.roster.every((agent) => channel.sessions.has(agent.id))) {
1661
+ // Counted on `everBound`, not `sessions`: an agent whose engine exits
1662
+ // after each turn leaves the map seconds after binding and must not hold
1663
+ // the screen up for the rest of the roster (live 2026-09-10).
1664
+ if (this.rosterReady(channel)) {
1626
1665
  this.emitTaskStage(channel.taskId, "ready");
1627
1666
  }
1628
1667
  // Adapters may synchronously emit `error` followed by `turn_complete` (or
@@ -1898,7 +1937,11 @@ export class Orchestrator {
1898
1937
  // SSH signing identity, re-asserted independently of transport. Connected
1899
1938
  // users still push through gh over HTTPS; disconnected users select the
1900
1939
  // SSH transport fallback inside the reconcile above.
1901
- void ensureTaskSshIdentity(channel.taskId, task.ownerUserId).catch((err) =>
1940
+ void ensureTaskSshIdentity(channel.taskId, task.ownerUserId, undefined, {
1941
+ // A machine or sidecar task installs the key over its environment; a
1942
+ // container task keeps the docker path (a null handle).
1943
+ environment: () => channel.agentHandle,
1944
+ }).catch((err) =>
1902
1945
  console.warn(
1903
1946
  `[ssh] task ${channel.taskId}: signing identity reconciliation failed: ${
1904
1947
  err instanceof Error ? err.message : String(err)
@@ -1965,7 +2008,9 @@ export class Orchestrator {
1965
2008
  // this unconditional `agents` then overwrote it — the page stayed
1966
2009
  // covered for good. Only announce the wake-up when there is one to do.
1967
2010
  const allBound = (): boolean =>
1968
- initialRoster.every((agent) => channel.sessions.has(agent.id));
2011
+ initialRoster.every(
2012
+ (agent) => channel.sessions.has(agent.id) || channel.everBound.has(agent.id),
2013
+ );
1969
2014
  if (!allBound()) this.emitTaskStage(channel.taskId, "agents");
1970
2015
  for (const agent of initialRoster) {
1971
2016
  if (!this.isActiveChannel(channel)) return false;
@@ -2030,7 +2075,11 @@ export class Orchestrator {
2030
2075
  // already bound when this loop began — every bind then predates this
2031
2076
  // pass and could have been overwritten by an `agents` from a start
2032
2077
  // that lost the race.
2033
- if (allBound()) this.emitTaskStage(channel.taskId, "ready");
2078
+ // Every roster agent was attempted above; if anything is up the task can
2079
+ // take a message, whether or not a one-shot engine has already exited.
2080
+ if (allBound() || channel.sessions.size > 0) {
2081
+ this.emitTaskStage(channel.taskId, "ready");
2082
+ }
2034
2083
  return true;
2035
2084
  }
2036
2085
 
@@ -6820,7 +6869,9 @@ async function recoverOneDockerTask(
6820
6869
  ),
6821
6870
  );
6822
6871
  }
6823
- void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
6872
+ void sshIdentityDepsFor(task)
6873
+ .then((deps) => ensureTaskSshIdentity(task.taskId, task.ownerUserId, undefined, deps))
6874
+ .catch((err) =>
6824
6875
  console.warn(
6825
6876
  `[ssh] task ${task.taskId}: running recovery reconciliation failed: ${
6826
6877
  err instanceof Error ? err.message : String(err)
@@ -7087,7 +7138,9 @@ async function recoverOneDockerTask(
7087
7138
  db_setRuntime(task.taskId, {
7088
7139
  codeServerPort: port,
7089
7140
  });
7090
- void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
7141
+ void sshIdentityDepsFor(task)
7142
+ .then((deps) => ensureTaskSshIdentity(task.taskId, task.ownerUserId, undefined, deps))
7143
+ .catch((err) =>
7091
7144
  console.warn(
7092
7145
  `[ssh] task ${task.taskId}: resumed signing identity reconciliation failed: ${
7093
7146
  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.130",
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>",