@runuai/host 0.9.0 → 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.
- package/images/standard/container/uai-init +38 -19
- package/lib/agent.ts +42 -16
- package/lib/agents/claude.ts +86 -14
- package/lib/agents/factory.ts +9 -1
- package/lib/agents/registry.ts +27 -0
- package/lib/agents/types.ts +3 -0
- package/lib/git-identity.ts +349 -70
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-tokens.ts +756 -110
- package/lib/orchestrator.ts +303 -91
- package/lib/repo-clone.ts +12 -101
- package/lib/ssh.ts +11 -8
- package/lib/transcript.ts +17 -2
- package/package.json +1 -1
- package/scripts/agent/_common.sh +214 -0
- package/scripts/agent/task-down.sh +35 -59
- package/scripts/agent/task-up.sh +746 -72
- package/src/index.ts +81 -7
- package/src/main.ts +112 -31
- package/src/protocol.ts +51 -0
package/lib/git-identity.ts
CHANGED
|
@@ -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
|
|
8
|
-
* config the same way
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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 {
|
|
19
|
-
|
|
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
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"
|
|
86
|
-
"
|
|
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
|
-
["
|
|
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}:
|
|
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
|
-
|
|
164
|
+
interface SshEnsureInFlight {
|
|
165
|
+
fingerprint: string;
|
|
166
|
+
promise: Promise<boolean>;
|
|
167
|
+
}
|
|
134
168
|
|
|
135
|
-
|
|
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
|
-
"
|
|
146
|
-
"-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
295
|
+
SSH_SIGNING_CONFIG,
|
|
178
296
|
]);
|
|
179
|
-
|
|
180
|
-
|
|
297
|
+
return configured.status === 0;
|
|
298
|
+
}
|
|
181
299
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
}
|