@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.
- package/lib/git-identity.ts +70 -13
- package/lib/github-tokens.ts +6 -3
- package/lib/orchestrator.ts +60 -7
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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)
|