@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.
- package/lib/git-identity.ts +70 -13
- package/lib/github-tokens.ts +6 -3
- package/lib/orchestrator.ts +33 -4
- package/package.json +1 -1
package/lib/git-identity.ts
CHANGED
|
@@ -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
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
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.
|
package/lib/github-tokens.ts
CHANGED
|
@@ -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 =
|
|
2098
|
-
deps.ensureSsh
|
|
2099
|
-
|
|
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) {
|
package/lib/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|