@runuai/host 0.9.1 → 0.9.2

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.
@@ -4,19 +4,25 @@
4
4
  * identity. Best-effort, mirrors the SSH setup: a failure logs but never aborts
5
5
  * task-up. Name falls back to the email when no display name is known.
6
6
  *
7
- * ensureTaskSshIdentity re-asserts the SSH push identity + its DEPENDENT git
8
- * config the same way gh tokens are re-asserted at channel ensure — the
9
- * one-shot copy at task-up proved fragile (live 2026-07-21: a recreated
10
- * container lost the key silently; a later repair restored the key WITHOUT
11
- * the config and pushes stayed on HTTPS → 403). Key and config are a unit.
7
+ * ensureTaskSshIdentity re-asserts the SSH signing identity and its dependent
8
+ * signing config the same way GitHub tokens are re-asserted at channel ensure.
9
+ * Git transport is separate: connected users push over HTTPS through gh; SSH
10
+ * is only the explicit no-connection fallback.
12
11
  */
13
12
 
14
- import { existsSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
15
14
  import { resolve } from "node:path";
16
15
 
16
+ import { eq } from "drizzle-orm";
17
+
18
+ import { getDb, schema } from "./db";
17
19
  import { dockerCli } from "./docker-exec";
18
- import { env } from "./env";
19
- import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
20
+ import {
21
+ deleteKey,
22
+ getPublicKey,
23
+ removeTaskIdentity,
24
+ writeTaskIdentity,
25
+ } from "./ssh";
20
26
 
21
27
  const EXEC_TIMEOUT_MS = 10_000;
22
28
 
@@ -24,11 +30,11 @@ const EXEC_TIMEOUT_MS = 10_000;
24
30
  * doesn't block the host event loop (this runs on every channel ensure). */
25
31
  export type DockerExec = (
26
32
  args: string[],
27
- ) => Promise<{ status: number | null; stderr: string }>;
33
+ ) => Promise<{ status: number | null; stdout?: string; stderr: string }>;
28
34
 
29
35
  const defaultExec: DockerExec = async (args) => {
30
36
  const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
31
- return { status: res.status, stderr: res.stderr };
37
+ return { status: res.status, stdout: res.stdout, stderr: res.stderr };
32
38
  };
33
39
 
34
40
  export async function setupTaskGitIdentity(
@@ -55,7 +61,7 @@ export async function setupTaskGitIdentity(
55
61
  "-u",
56
62
  "node",
57
63
  container,
58
- "git",
64
+ "/usr/bin/git",
59
65
  "config",
60
66
  "--global",
61
67
  key,
@@ -72,18 +78,43 @@ export async function setupTaskGitIdentity(
72
78
  return true;
73
79
  }
74
80
 
75
- // The key's dependent git config, applied as one unit with the key itself:
76
- // SSH routing for every GitHub remote (mirrors are born with the project's
77
- // clone URL, usually https) + SSH commit/tag signing. Same statements as
78
- // uai-init's key-gated block — which silently self-skips when the key is
79
- // missing at ITS run time, hence re-applying here.
80
- const SSH_GIT_CONFIG =
81
- 'git config --global url."git@github.com:".insteadOf "https://github.com/"' +
82
- ' && git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"' +
83
- " && git config --global gpg.format ssh" +
84
- " && git config --global user.signingkey /home/node/.ssh/id_ed25519.pub" +
85
- " && git config --global commit.gpgsign true" +
86
- " && git config --global tag.gpgsign true";
81
+ // The key's dependent signing config, applied as one unit with the key itself.
82
+ // Deliberately no URL rewrite: doing that here would override a connected
83
+ // user's gh HTTPS credential and make SSH mandatory again.
84
+ const SSH_SIGNING_CONFIG =
85
+ "/usr/bin/git config --global gpg.format ssh" +
86
+ " && /usr/bin/git config --global user.signingkey /home/node/.ssh/id_ed25519.pub" +
87
+ " && /usr/bin/git config --global commit.gpgsign true" +
88
+ " && /usr/bin/git config --global tag.gpgsign true";
89
+
90
+ const SSH_TRANSPORT_CONFIG =
91
+ "/usr/bin/git config --global --unset-all 'url.https://github.com/.insteadOf' >/dev/null 2>&1 || true; " +
92
+ "/usr/bin/git config --global --replace-all 'url.git@github.com:.insteadOf' 'https://github.com/'; " +
93
+ '/usr/bin/git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"';
94
+
95
+ /** Select SSH transport inside a disconnected task container. */
96
+ export async function configureTaskSshTransport(
97
+ taskId: string,
98
+ exec: DockerExec = defaultExec,
99
+ ): Promise<boolean> {
100
+ const res = await exec([
101
+ "exec",
102
+ "-u",
103
+ "node",
104
+ `task-${taskId}-app-1`,
105
+ "/bin/sh",
106
+ "-c",
107
+ SSH_TRANSPORT_CONFIG,
108
+ ]);
109
+ if (res.status !== 0) {
110
+ console.warn(
111
+ `[ssh] task ${taskId}: SSH transport fallback setup failed: ${res.stderr.trim()}`,
112
+ );
113
+ return false;
114
+ }
115
+ console.log(`[ssh] task ${taskId}: SSH Git transport fallback selected`);
116
+ return true;
117
+ }
87
118
 
88
119
  /**
89
120
  * Copy the SSH identity at `keyPath` into the task container and apply its
@@ -98,17 +129,17 @@ export async function injectSshIdentity(
98
129
  ): Promise<boolean> {
99
130
  const container = `task-${taskId}-app-1`;
100
131
  const steps: ReadonlyArray<readonly [string, string[]]> = [
101
- ["mkdir ~/.ssh", ["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.ssh"]],
132
+ ["mkdir ~/.ssh", ["exec", "-u", "root", container, "/bin/mkdir", "-p", "/home/node/.ssh"]],
102
133
  ["copy key", ["cp", keyPath, `${container}:/home/node/.ssh/id_ed25519`]],
103
134
  ["copy pubkey", ["cp", `${keyPath}.pub`, `${container}:/home/node/.ssh/id_ed25519.pub`]],
104
135
  [
105
136
  "own + chmod",
106
137
  [
107
- "exec", "-u", "root", container, "sh", "-c",
108
- "chown -R node:node /home/node/.ssh && chmod 700 /home/node/.ssh && chmod 600 /home/node/.ssh/id_ed25519",
138
+ "exec", "-u", "root", container, "/bin/sh", "-c",
139
+ "/bin/chown -R node:node /home/node/.ssh && /bin/chmod 700 /home/node/.ssh && /bin/chmod 600 /home/node/.ssh/id_ed25519",
109
140
  ],
110
141
  ],
111
- ["ssh routing + signing config", ["exec", "-u", "node", container, "sh", "-c", SSH_GIT_CONFIG]],
142
+ ["SSH signing config", ["exec", "-u", "node", container, "/bin/sh", "-c", SSH_SIGNING_CONFIG]],
112
143
  ];
113
144
  for (const [label, args] of steps) {
114
145
  const res = await exec(args);
@@ -119,7 +150,7 @@ export async function injectSshIdentity(
119
150
  return false;
120
151
  }
121
152
  }
122
- console.log(`[ssh] task ${taskId}: push identity + git SSH config asserted`);
153
+ console.log(`[ssh] task ${taskId}: signing identity + git signing config asserted`);
123
154
  return true;
124
155
  }
125
156
 
@@ -130,11 +161,33 @@ export async function injectSshIdentity(
130
161
  // UNREADABLE by node inside the container ("Load key: Permission denied",
131
162
  // live 2026-07-21). Serialize, and skip entirely when the key is already
132
163
  // good, so the steady state does zero docker work.
133
- const sshEnsureInFlight = new Set<string>();
164
+ interface SshEnsureInFlight {
165
+ fingerprint: string;
166
+ promise: Promise<boolean>;
167
+ }
134
168
 
135
- /** True when node can already read a correctly-owned key in the container. */
169
+ const sshEnsureInFlight = new Map<string, SshEnsureInFlight>();
170
+
171
+ function sshPublicKeyFingerprint(publicKey: string): string | null {
172
+ const [, encoded] = publicKey.trim().split(/\s+/, 3);
173
+ if (!encoded) return null;
174
+ try {
175
+ const material = Buffer.from(encoded, "base64");
176
+ if (material.length === 0) return null;
177
+ return createHash("sha256")
178
+ .update(material)
179
+ .digest("base64")
180
+ .replace(/=+$/, "");
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /** True only when the readable container key is the current DB-authoritative
187
+ * key for this owner. A copied key is not authority after ssh.key.delete. */
136
188
  async function sshKeyHealthy(
137
189
  taskId: string,
190
+ expectedPublicKey: string,
138
191
  exec: DockerExec,
139
192
  ): Promise<boolean> {
140
193
  const res = await exec([
@@ -142,16 +195,27 @@ async function sshKeyHealthy(
142
195
  "-u",
143
196
  "node",
144
197
  `task-${taskId}-app-1`,
145
- "test",
146
- "-r",
147
- "/home/node/.ssh/id_ed25519",
198
+ "/bin/sh",
199
+ "-c",
200
+ "test -r /home/node/.ssh/id_ed25519 && /bin/cat /home/node/.ssh/id_ed25519.pub",
148
201
  ]);
149
- return res.status === 0;
202
+ const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
203
+ const actualFingerprint = sshPublicKeyFingerprint(res.stdout ?? "");
204
+ return (
205
+ res.status === 0 &&
206
+ expectedFingerprint !== null &&
207
+ actualFingerprint === expectedFingerprint
208
+ );
209
+ }
210
+
211
+ interface EnsureTaskSshIdentityDeps {
212
+ publicKeyForUser?: (userId: string) => string | null;
213
+ writeIdentity?: typeof writeTaskIdentity;
150
214
  }
151
215
 
152
216
  /**
153
- * Ensure the task container has a node-readable SSH push identity + git
154
- * config. Idempotent and race-free: skips the copy when the key is already
217
+ * Ensure the task container has a node-readable SSH signing identity + git
218
+ * signing config. Idempotent and race-free: skips the copy when the key is already
155
219
  * healthy (the common case), and never runs two injects for one task at
156
220
  * once. Only (re)materializes + copies the key when it is missing or
157
221
  * unreadable. Safe to call on every channel ensure.
@@ -160,49 +224,264 @@ export async function ensureTaskSshIdentity(
160
224
  taskId: string,
161
225
  ownerUserId: string | null | undefined,
162
226
  exec: DockerExec = defaultExec,
227
+ deps: EnsureTaskSshIdentityDeps = {},
163
228
  ): Promise<boolean> {
164
- if (sshEnsureInFlight.has(taskId)) return true; // another ensure owns it
165
- sshEnsureInFlight.add(taskId);
229
+ const publicKeyForUser = deps.publicKeyForUser ?? getPublicKey;
230
+ const expectedPublicKey = ownerUserId
231
+ ? publicKeyForUser(ownerUserId)
232
+ : null;
233
+ // Consult authority before joining an older in-flight ensure. A deleted key
234
+ // must turn fallback off immediately even if its prior copy is still readable.
235
+ if (!ownerUserId || !expectedPublicKey) return false;
236
+ const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
237
+ if (!expectedFingerprint) return false;
238
+ const existing = sshEnsureInFlight.get(taskId);
239
+ if (existing) {
240
+ const result = await existing.promise;
241
+ if (existing.fingerprint !== expectedFingerprint) {
242
+ // An older key finished copying while authority rotated. Replace its map
243
+ // entry and run once more for the current key; never bless A as B.
244
+ if (sshEnsureInFlight.get(taskId) === existing) {
245
+ sshEnsureInFlight.delete(taskId);
246
+ }
247
+ return ensureTaskSshIdentity(taskId, ownerUserId, exec, deps);
248
+ }
249
+ return (
250
+ result &&
251
+ sshPublicKeyFingerprint(publicKeyForUser(ownerUserId) ?? "") ===
252
+ sshPublicKeyFingerprint(expectedPublicKey)
253
+ );
254
+ }
255
+ const pending = ensureTaskSshIdentityOnce(
256
+ taskId,
257
+ ownerUserId,
258
+ expectedPublicKey,
259
+ exec,
260
+ deps.writeIdentity ?? writeTaskIdentity,
261
+ );
262
+ const entry = { fingerprint: expectedFingerprint, promise: pending };
263
+ sshEnsureInFlight.set(taskId, entry);
166
264
  try {
167
- // Fast path: key already good → re-assert only the (cheap, idempotent)
168
- // git config and skip the docker cp churn that causes the ownership race.
169
- if (await sshKeyHealthy(taskId, exec)) {
170
- await exec([
265
+ const result = await pending;
266
+ return (
267
+ result &&
268
+ sshPublicKeyFingerprint(publicKeyForUser(ownerUserId) ?? "") ===
269
+ sshPublicKeyFingerprint(expectedPublicKey)
270
+ );
271
+ } finally {
272
+ if (sshEnsureInFlight.get(taskId) === entry) {
273
+ sshEnsureInFlight.delete(taskId);
274
+ }
275
+ }
276
+ }
277
+
278
+ async function ensureTaskSshIdentityOnce(
279
+ taskId: string,
280
+ ownerUserId: string,
281
+ expectedPublicKey: string,
282
+ exec: DockerExec,
283
+ writeIdentity: typeof writeTaskIdentity,
284
+ ): Promise<boolean> {
285
+ // Fast path: the DB-authoritative key is already good → re-assert only the
286
+ // cheap signing config and skip docker cp churn.
287
+ if (await sshKeyHealthy(taskId, expectedPublicKey, exec)) {
288
+ const configured = await exec([
171
289
  "exec",
172
290
  "-u",
173
291
  "node",
174
292
  `task-${taskId}-app-1`,
175
- "sh",
293
+ "/bin/sh",
176
294
  "-c",
177
- SSH_GIT_CONFIG,
295
+ SSH_SIGNING_CONFIG,
178
296
  ]);
179
- return true;
180
- }
297
+ return configured.status === 0;
298
+ }
181
299
 
182
- let keyPath: string | null = null;
183
- let perTask = false;
184
- const dir = writeTaskIdentity(taskId, ownerUserId);
185
- if (dir) {
186
- keyPath = resolve(dir, "id_ed25519");
187
- perTask = true;
188
- } else {
189
- const operatorKey = resolve(env.dataDir, "identity", "id_ed25519");
190
- if (existsSync(operatorKey)) keyPath = operatorKey;
191
- }
192
- if (!keyPath) {
193
- console.log(
194
- `[ssh] task ${taskId}: no SSH identity for owner or operator — git pushes stay on HTTPS`,
195
- );
196
- return false;
197
- }
198
- try {
199
- return await injectSshIdentity(taskId, keyPath, exec);
200
- } finally {
201
- // Host hygiene (same as task-up): the materialized private key never
202
- // outlives the injection.
203
- if (perTask) removeTaskIdentity(taskId);
204
- }
300
+ const dir = writeIdentity(taskId, ownerUserId);
301
+ if (!dir) {
302
+ console.log(`[ssh] task ${taskId}: no SSH signing identity for its owner`);
303
+ return false;
304
+ }
305
+ const keyPath = resolve(dir, "id_ed25519");
306
+ try {
307
+ return await injectSshIdentity(taskId, keyPath, exec);
205
308
  } finally {
206
- sshEnsureInFlight.delete(taskId);
309
+ // Host hygiene (same as task-up): the materialized private key never
310
+ // outlives the injection.
311
+ removeTaskIdentity(taskId);
312
+ }
313
+ }
314
+
315
+ const REMOVE_MANAGED_SSH_IDENTITY = [
316
+ "set -eu",
317
+ "/usr/bin/rm -f -- /home/node/.ssh/id_ed25519 /home/node/.ssh/id_ed25519.pub",
318
+ "/usr/bin/git config --global --unset-all user.signingkey >/dev/null 2>&1 || true",
319
+ "/usr/bin/git config --global --unset-all commit.gpgsign >/dev/null 2>&1 || true",
320
+ "/usr/bin/git config --global --unset-all tag.gpgsign >/dev/null 2>&1 || true",
321
+ "/usr/bin/git config --global --unset-all gpg.format >/dev/null 2>&1 || true",
322
+ "/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true",
323
+ "/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true",
324
+ "/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'",
325
+ "/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
326
+ "test ! -e /home/node/.ssh/id_ed25519",
327
+ "test ! -e /home/node/.ssh/id_ed25519.pub",
328
+ "test -z \"$(/usr/bin/git config --global --get-all user.signingkey 2>/dev/null || true)\"",
329
+ ].join("; ");
330
+
331
+ const SANITIZED_SSH_EXEC_ENV = [
332
+ "-e",
333
+ "HOME=/home/node",
334
+ "-e",
335
+ "PATH=/usr/bin:/bin",
336
+ "-e",
337
+ "XDG_CONFIG_HOME=",
338
+ "-e",
339
+ "LD_PRELOAD=",
340
+ "-e",
341
+ "LD_LIBRARY_PATH=",
342
+ "-e",
343
+ "GIT_CONFIG=",
344
+ "-e",
345
+ "GIT_CONFIG_GLOBAL=",
346
+ "-e",
347
+ "GIT_CONFIG_SYSTEM=",
348
+ "-e",
349
+ "GIT_CONFIG_NOSYSTEM=",
350
+ "-e",
351
+ "GIT_CONFIG_COUNT=0",
352
+ "-e",
353
+ "GIT_EXEC_PATH=",
354
+ "-e",
355
+ "GIT_SSH=",
356
+ "-e",
357
+ "GIT_SSH_COMMAND=",
358
+ ] as const;
359
+
360
+ async function checkedSshDocker(
361
+ exec: DockerExec,
362
+ args: string[],
363
+ label: string,
364
+ ): Promise<{ status: number | null; stdout?: string; stderr: string }> {
365
+ const result = await exec(args);
366
+ if (result.status !== 0) {
367
+ throw new Error(
368
+ `${label} failed: ${result.stderr.trim() || `exit ${result.status}`}`,
369
+ );
370
+ }
371
+ return result;
372
+ }
373
+
374
+ /** Remove a copied SSH key and every config entry that depends on it. A
375
+ * non-running container is disposable and is removed without executing its
376
+ * credential-bearing filesystem; Compose recreates it on Resume. */
377
+ export async function removeTaskSshIdentityFromContainer(
378
+ taskId: string,
379
+ exec: DockerExec = defaultExec,
380
+ ): Promise<void> {
381
+ const container = `task-${taskId}-app-1`;
382
+ const inspectArgs = [
383
+ "ps",
384
+ "--all",
385
+ "--filter",
386
+ `name=^/${container}$`,
387
+ "--format",
388
+ "{{.State}}",
389
+ ];
390
+ const listed = await checkedSshDocker(
391
+ exec,
392
+ inspectArgs,
393
+ `inspect ${container}`,
394
+ );
395
+ const states = (listed.stdout ?? "")
396
+ .split(/\r?\n/)
397
+ .map((state) => state.trim())
398
+ .filter(Boolean);
399
+ if (states.length === 0) return;
400
+ if (states.length !== 1) {
401
+ throw new Error(`inspect ${container} returned ${states.length} containers`);
402
+ }
403
+ if (states[0] !== "running") {
404
+ await checkedSshDocker(
405
+ exec,
406
+ ["rm", "--force", container],
407
+ `remove SSH credential-bearing ${container}`,
408
+ );
409
+ const remaining = await checkedSshDocker(
410
+ exec,
411
+ inspectArgs,
412
+ `verify removal of ${container}`,
413
+ );
414
+ if ((remaining.stdout ?? "").trim()) {
415
+ throw new Error(`remove ${container} was not verified`);
416
+ }
417
+ return;
207
418
  }
419
+
420
+ await checkedSshDocker(
421
+ exec,
422
+ [
423
+ "exec",
424
+ "-u",
425
+ "node",
426
+ ...SANITIZED_SSH_EXEC_ENV,
427
+ container,
428
+ "/usr/bin/env",
429
+ "-i",
430
+ "HOME=/home/node",
431
+ "PATH=/usr/bin:/bin",
432
+ "/bin/sh",
433
+ "-c",
434
+ REMOVE_MANAGED_SSH_IDENTITY,
435
+ ],
436
+ `remove managed SSH identity from ${container}`,
437
+ );
438
+ }
439
+
440
+ function sshCredentialTaskIdsForUser(userId: string): string[] {
441
+ return getDb()
442
+ .select({
443
+ taskId: schema.hostTasks.taskId,
444
+ endedAt: schema.hostTasks.endedAt,
445
+ composeProject: schema.hostTasks.composeProject,
446
+ statusMirror: schema.hostTasks.statusMirror,
447
+ })
448
+ .from(schema.hostTasks)
449
+ .where(eq(schema.hostTasks.ownerUserId, userId))
450
+ .all()
451
+ .filter(
452
+ (task) =>
453
+ task.endedAt == null ||
454
+ task.composeProject != null ||
455
+ task.statusMirror === "error",
456
+ )
457
+ .map((task) => task.taskId);
458
+ }
459
+
460
+ export interface DeleteUserSshIdentityDeps {
461
+ deleteStored?: (userId: string) => void;
462
+ taskIds?: (userId: string) => string[];
463
+ removeFromContainer?: (taskId: string) => Promise<void>;
464
+ }
465
+
466
+ /** Delete authority first, then drain any older ensure and remove all copied
467
+ * material before acknowledging ssh.key.delete. */
468
+ export async function deleteUserSshIdentity(
469
+ userId: string,
470
+ deps: DeleteUserSshIdentityDeps = {},
471
+ ): Promise<void> {
472
+ (deps.deleteStored ?? deleteKey)(userId);
473
+ const taskIds = (deps.taskIds ?? sshCredentialTaskIdsForUser)(userId);
474
+ const removeFromContainer =
475
+ deps.removeFromContainer ?? removeTaskSshIdentityFromContainer;
476
+ const settled = await Promise.allSettled(
477
+ taskIds.map(async (taskId) => {
478
+ await sshEnsureInFlight.get(taskId)?.promise.catch(() => {});
479
+ removeTaskIdentity(taskId);
480
+ await removeFromContainer(taskId);
481
+ }),
482
+ );
483
+ const failed = settled.find(
484
+ (result): result is PromiseRejectedResult => result.status === "rejected",
485
+ );
486
+ if (failed) throw failed.reason;
208
487
  }