@runuai/host 0.9.138 → 0.9.140

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.
@@ -0,0 +1,465 @@
1
+ /**
2
+ * ADR-136 sign-in box, the user and project credentials (host release 3b).
3
+ *
4
+ * The shared "Uai Cloud" row is where the cloud sends every workspace's
5
+ * GitHub connects, SSH key requests, MCP connection probes and project env
6
+ * writes for hostless tasks — the box is the writer for all of them. Same
7
+ * contract as engine accounts (lib/engine-accounts.ts, store-only accounts):
8
+ *
9
+ * - GitHub tokens: the store first, a row with no ciphertext, the token in
10
+ * a bounded in-memory cache only while the box needs it (the repo picker's
11
+ * installation listing reads it back under a policy scoped to
12
+ * `/uai/users/*\/github`). lib/github-tokens.ts.
13
+ * - SSH keys: the keypair to the store first, only the public half stays
14
+ * (tagged, so a replacement box lists it without a read). lib/ssh.ts.
15
+ * - MCP connections: header / OAuth tokens to the store first, a row with
16
+ * every secret column null; a half-finished OAuth flow lives in memory.
17
+ * lib/mcp-connections.ts.
18
+ * - Project env: no rows at all — set/delete/list are store calls
19
+ * (put + org tag, delete, describe names).
20
+ *
21
+ * This module owns what cuts across them: the start-time purge of whatever a
22
+ * previous release left, the names-and-tags reconcile that rebuilds the
23
+ * metadata rows (reading a value only where the policy allows it — GitHub
24
+ * always, SSH and MCP only during the transitional window that lets the box
25
+ * tag legacy parameters once), and the project env passthrough.
26
+ */
27
+
28
+ import { eq } from "drizzle-orm";
29
+
30
+ import {
31
+ configuredCredentialStore,
32
+ credentialStorePrefix,
33
+ isParameterPathSegment,
34
+ } from "./credential-store";
35
+ import { getDb, schema } from "./db";
36
+ import {
37
+ claimGithubCredentialGeneration,
38
+ deleteGithubTokenRowLocal,
39
+ GITHUB_TOKEN_TAGS,
40
+ githubTokenMetadataTags,
41
+ insertStoreHeldGithubTokenRow,
42
+ isStoreHeldGithubRow,
43
+ } from "./github-tokens";
44
+ import {
45
+ projectEnvParameterName,
46
+ projectEnvTags,
47
+ type StoredProjectEnv,
48
+ } from "./host-env";
49
+ import {
50
+ deleteMcpConnectionRowLocal,
51
+ MCP_CONNECTION_TAGS,
52
+ mcpConnectionMetadataTags,
53
+ upsertStoreHeldMcpConnectionRow,
54
+ } from "./mcp-connections";
55
+ import {
56
+ deleteSshKeyRowLocal,
57
+ insertStoreHeldSshKeyRow,
58
+ isStoreHeldSshRow,
59
+ SSH_KEY_TAGS,
60
+ sshKeyMetadataTags,
61
+ } from "./ssh";
62
+ import {
63
+ parseStoredGithubToken,
64
+ parseStoredMcpConnection,
65
+ parseStoredSshKey,
66
+ parseUserParameterName,
67
+ } from "./user-credentials-store";
68
+
69
+ function warn(line: string): void {
70
+ console.warn(`[signin-box] ${line}`);
71
+ }
72
+
73
+ function fromB64url(value: string | undefined): string | null {
74
+ if (!value) return null;
75
+ try {
76
+ return Buffer.from(value, "base64url").toString("utf8");
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function intTag(tags: Record<string, string>, key: string): number | null {
83
+ const raw = tags[key];
84
+ if (raw === undefined || raw === "") return null;
85
+ const n = Number.parseInt(raw, 10);
86
+ return Number.isFinite(n) ? n : null;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Project env: a store passthrough.
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export async function setProjectEnvStoreOnly(
94
+ projectId: string,
95
+ key: string,
96
+ value: string,
97
+ orgId?: string,
98
+ ): Promise<void> {
99
+ const store = configuredCredentialStore();
100
+ if (!store) throw new Error("the sign-in box has no credential store to hold project env");
101
+ const name = projectEnvParameterName(projectId, key);
102
+ if (!name) throw new Error("this key cannot be stored (not a legal parameter name)");
103
+ const stored: StoredProjectEnv = {
104
+ v: 1,
105
+ kind: "project-env",
106
+ projectId,
107
+ key,
108
+ value,
109
+ updatedAt: Date.now(),
110
+ ...(orgId ? { orgId } : {}),
111
+ };
112
+ await store.put(name, JSON.stringify(stored));
113
+ // The tag is what lets a task machine's lease read the value (ADR-133);
114
+ // without it the value is stored but unreachable, so this failure is the
115
+ // caller's.
116
+ if (orgId) await store.tag(name, projectEnvTags(projectId, orgId));
117
+ }
118
+
119
+ export async function deleteProjectEnvStoreOnly(projectId: string, key: string): Promise<void> {
120
+ const store = configuredCredentialStore();
121
+ if (!store) throw new Error("the sign-in box has no credential store to hold project env");
122
+ const name = projectEnvParameterName(projectId, key);
123
+ if (!name) return;
124
+ await store.delete(name);
125
+ }
126
+
127
+ /** The project's KEY NAMES from the store — names only, never a value. */
128
+ export async function listProjectEnvKeysStoreOnly(projectId: string): Promise<string[]> {
129
+ const store = configuredCredentialStore();
130
+ if (!store) throw new Error("the sign-in box has no credential store to hold project env");
131
+ if (!isParameterPathSegment(projectId)) return [];
132
+ const base = `${credentialStorePrefix()}/projects/${projectId}/env`;
133
+ const names = await store.describeNames(base);
134
+ return names
135
+ .map((name) => name.slice(base.length + 1))
136
+ .filter((key) => key.length > 0 && !key.includes("/"))
137
+ .sort();
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Start-time purge.
142
+ // ---------------------------------------------------------------------------
143
+
144
+ export interface PurgeLocalUserCredentialSecretsResult {
145
+ githubStripped: number;
146
+ sshStripped: number;
147
+ mcpStripped: number;
148
+ projectEnvRemoved: number;
149
+ /** Rows deleted outright: never mirrored, so the store has no copy and a
150
+ * secret with no copy elsewhere still may not live here. */
151
+ unmirroredRemoved: number;
152
+ }
153
+
154
+ /**
155
+ * Nothing a previous release left on this machine may hold a credential.
156
+ * Rows keep their metadata and lose their material; rows the store never
157
+ * received are deleted; project env rows go entirely (the store is listed
158
+ * by name instead).
159
+ */
160
+ export function purgeLocalUserCredentialSecrets(): PurgeLocalUserCredentialSecretsResult {
161
+ const result: PurgeLocalUserCredentialSecretsResult = {
162
+ githubStripped: 0,
163
+ sshStripped: 0,
164
+ mcpStripped: 0,
165
+ projectEnvRemoved: 0,
166
+ unmirroredRemoved: 0,
167
+ };
168
+ const db = getDb();
169
+ const empty = { ct: Buffer.alloc(0), nonce: Buffer.alloc(0) };
170
+
171
+ for (const row of db.select().from(schema.githubTokens).all()) {
172
+ if (!row.storeRef) {
173
+ deleteGithubTokenRowLocal(row.userId);
174
+ result.unmirroredRemoved++;
175
+ continue;
176
+ }
177
+ if (isStoreHeldGithubRow(row)) continue;
178
+ db.update(schema.githubTokens)
179
+ .set({ refreshTokenCt: empty.ct, refreshTokenNonce: empty.nonce })
180
+ .where(eq(schema.githubTokens.userId, row.userId))
181
+ .run();
182
+ result.githubStripped++;
183
+ }
184
+ for (const row of db.select().from(schema.sshKeys).all()) {
185
+ if (!row.storeRef) {
186
+ deleteSshKeyRowLocal(row.userId);
187
+ result.unmirroredRemoved++;
188
+ continue;
189
+ }
190
+ if (isStoreHeldSshRow(row)) continue;
191
+ db.update(schema.sshKeys)
192
+ .set({ privateKeyCt: empty.ct, privateKeyNonce: empty.nonce })
193
+ .where(eq(schema.sshKeys.userId, row.userId))
194
+ .run();
195
+ result.sshStripped++;
196
+ }
197
+ for (const row of db.select().from(schema.mcpConnections).all()) {
198
+ if (!row.storeRef) {
199
+ deleteMcpConnectionRowLocal(row.id);
200
+ result.unmirroredRemoved++;
201
+ continue;
202
+ }
203
+ if (row.secretEnc === null && row.clientSecretEnc === null && row.pkceVerifierEnc === null) {
204
+ continue;
205
+ }
206
+ db.update(schema.mcpConnections)
207
+ .set({ secretEnc: null, clientSecretEnc: null, pkceVerifierEnc: null })
208
+ .where(eq(schema.mcpConnections.id, row.id))
209
+ .run();
210
+ result.mcpStripped++;
211
+ }
212
+ result.projectEnvRemoved = db.delete(schema.hostProjectEnv).run().changes;
213
+ return result;
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Start-time metadata reconcile: names + tags, a value only where allowed.
218
+ // ---------------------------------------------------------------------------
219
+
220
+ export interface ReconcileUserCredentialMetadataResult {
221
+ skipped: boolean;
222
+ imported: number;
223
+ removed: number;
224
+ /** Legacy parameters the box could read and has now tagged. */
225
+ tagged: number;
226
+ /** Parameters neither tagged nor readable — listed nowhere until an
227
+ * operator runs the bootstrap with the transitional read once. */
228
+ unreadable: number;
229
+ errors: number;
230
+ }
231
+
232
+ export async function reconcileUserCredentialMetadataFromStore(): Promise<ReconcileUserCredentialMetadataResult> {
233
+ const result: ReconcileUserCredentialMetadataResult = {
234
+ skipped: false,
235
+ imported: 0,
236
+ removed: 0,
237
+ tagged: 0,
238
+ unreadable: 0,
239
+ errors: 0,
240
+ };
241
+ const store = configuredCredentialStore();
242
+ if (!store) return { ...result, skipped: true };
243
+ const prefix = credentialStorePrefix();
244
+ let names: string[];
245
+ try {
246
+ names = await store.describeNames(`${prefix}/users`);
247
+ } catch (err) {
248
+ warn(`listing ${prefix}/users failed: ${err instanceof Error ? err.message : String(err)}`);
249
+ return { ...result, errors: 1 };
250
+ }
251
+ const db = getDb();
252
+ const github = new Map(db.select().from(schema.githubTokens).all().map((r) => [r.storeRef, r]));
253
+ const ssh = new Map(db.select().from(schema.sshKeys).all().map((r) => [r.storeRef, r]));
254
+ const mcp = new Map(db.select().from(schema.mcpConnections).all().map((r) => [r.storeRef, r]));
255
+ const seen = new Set<string>();
256
+
257
+ for (const name of names) {
258
+ const at = parseUserParameterName(name, prefix);
259
+ if (!at) continue;
260
+ seen.add(name);
261
+ const known =
262
+ at.kind === "github" ? github.has(name) : at.kind === "ssh" ? ssh.has(name) : mcp.has(name);
263
+ if (known) continue;
264
+ let tags: Record<string, string> = {};
265
+ try {
266
+ tags = await store.listTags(name);
267
+ } catch (err) {
268
+ result.errors++;
269
+ warn(`tags of ${name} unreadable: ${err instanceof Error ? err.message : String(err)}`);
270
+ }
271
+ try {
272
+ if (at.kind === "github") {
273
+ await importGithubMetadata(store, name, at.userId, tags, result);
274
+ } else if (at.kind === "ssh") {
275
+ await importSshMetadata(store, name, at.userId, tags, result);
276
+ } else {
277
+ await importMcpMetadata(store, name, at.userId, at.id, tags, result);
278
+ }
279
+ } catch (err) {
280
+ result.errors++;
281
+ warn(`${name}: ${err instanceof Error ? err.message : String(err)}`);
282
+ }
283
+ }
284
+
285
+ for (const [ref, row] of github) {
286
+ if (ref && seen.has(ref)) continue;
287
+ deleteGithubTokenRowLocal(row.userId);
288
+ result.removed++;
289
+ }
290
+ for (const [ref, row] of ssh) {
291
+ if (ref && seen.has(ref)) continue;
292
+ deleteSshKeyRowLocal(row.userId);
293
+ result.removed++;
294
+ }
295
+ for (const [ref, row] of mcp) {
296
+ if (ref && seen.has(ref)) continue;
297
+ // A pending OAuth row this box created has no ref yet; keep it.
298
+ if (!ref && row.status === "pending") continue;
299
+ deleteMcpConnectionRowLocal(row.id);
300
+ result.removed++;
301
+ }
302
+ return result;
303
+ }
304
+
305
+ type Store = NonNullable<ReturnType<typeof configuredCredentialStore>>;
306
+
307
+ /** Read a legacy (untagged) parameter where the policy allows it; null when
308
+ * it does not (AccessDenied or absent) — never thrown. */
309
+ async function readIfAllowed(store: Store, name: string): Promise<string | null> {
310
+ try {
311
+ return await store.get(name);
312
+ } catch (err) {
313
+ const message = err instanceof Error ? err.message : String(err);
314
+ if (/AccessDenied/i.test(message)) return null;
315
+ throw err;
316
+ }
317
+ }
318
+
319
+ async function importGithubMetadata(
320
+ store: Store,
321
+ name: string,
322
+ userId: string,
323
+ tags: Record<string, string>,
324
+ result: ReconcileUserCredentialMetadataResult,
325
+ ): Promise<void> {
326
+ let meta: {
327
+ installationId: number;
328
+ generation: number;
329
+ tokenKind: string;
330
+ expiresAt: number | null;
331
+ updatedAt: number;
332
+ } | null = null;
333
+ const installationId = intTag(tags, GITHUB_TOKEN_TAGS.installationId);
334
+ const generation = intTag(tags, GITHUB_TOKEN_TAGS.generation);
335
+ const tokenKind = tags[GITHUB_TOKEN_TAGS.tokenKind];
336
+ if (installationId !== null && generation !== null && tokenKind) {
337
+ meta = {
338
+ installationId,
339
+ generation,
340
+ tokenKind,
341
+ expiresAt: intTag(tags, GITHUB_TOKEN_TAGS.expiresAt),
342
+ updatedAt: intTag(tags, GITHUB_TOKEN_TAGS.updatedAt) ?? Date.now(),
343
+ };
344
+ } else {
345
+ // The box may read GitHub tokens (the repo picker needs them anyway):
346
+ // read once, tag, and keep only the metadata.
347
+ const raw = await readIfAllowed(store, name);
348
+ if (raw === null) {
349
+ result.unreadable++;
350
+ warn(`${name}: no tags and not readable — the user shows as disconnected on this box`);
351
+ return;
352
+ }
353
+ const value = parseStoredGithubToken(raw);
354
+ if (value.userId !== userId) throw new Error("payload names another user");
355
+ meta = {
356
+ installationId: value.installationId,
357
+ generation: value.generation,
358
+ tokenKind: value.tokenKind,
359
+ expiresAt: value.expiresAt,
360
+ updatedAt: value.updatedAt,
361
+ };
362
+ try {
363
+ await store.tag(name, githubTokenMetadataTags(meta));
364
+ result.tagged++;
365
+ } catch (err) {
366
+ warn(`${name}: tagging failed: ${err instanceof Error ? err.message : String(err)}`);
367
+ }
368
+ }
369
+ // The same fence an import claims on any host: a generation this box has
370
+ // already moved past (a later clear) is not resurrected.
371
+ if (!claimGithubCredentialGeneration(userId, meta.generation, "set")) return;
372
+ insertStoreHeldGithubTokenRow(userId, {
373
+ installationId: meta.installationId,
374
+ generation: meta.generation,
375
+ refreshTokenExpiresAt: meta.expiresAt,
376
+ kind: meta.tokenKind,
377
+ updatedAt: meta.updatedAt,
378
+ storeRef: name,
379
+ });
380
+ result.imported++;
381
+ }
382
+
383
+ async function importSshMetadata(
384
+ store: Store,
385
+ name: string,
386
+ userId: string,
387
+ tags: Record<string, string>,
388
+ result: ReconcileUserCredentialMetadataResult,
389
+ ): Promise<void> {
390
+ let publicKey = tags[SSH_KEY_TAGS.publicKey] ?? "";
391
+ let createdAt = intTag(tags, SSH_KEY_TAGS.createdAt);
392
+ if (!publicKey) {
393
+ const raw = await readIfAllowed(store, name);
394
+ if (raw === null) {
395
+ result.unreadable++;
396
+ warn(`${name}: no public-key tag and not readable — kept as a row with an unknown public key`);
397
+ insertStoreHeldSshKeyRow({ userId, publicKey: "", createdAt: Date.now(), updatedAt: Date.now(), storeRef: name });
398
+ return;
399
+ }
400
+ const value = parseStoredSshKey(raw);
401
+ if (value.userId !== userId) throw new Error("payload names another user");
402
+ publicKey = value.publicKey;
403
+ createdAt = value.createdAt;
404
+ try {
405
+ await store.tag(name, sshKeyMetadataTags({ publicKey, createdAt }));
406
+ result.tagged++;
407
+ } catch (err) {
408
+ warn(`${name}: tagging failed: ${err instanceof Error ? err.message : String(err)}`);
409
+ }
410
+ }
411
+ const now = Date.now();
412
+ if (insertStoreHeldSshKeyRow({ userId, publicKey, createdAt: createdAt ?? now, updatedAt: now, storeRef: name })) {
413
+ result.imported++;
414
+ }
415
+ }
416
+
417
+ async function importMcpMetadata(
418
+ store: Store,
419
+ name: string,
420
+ userId: string,
421
+ id: string,
422
+ tags: Record<string, string>,
423
+ result: ReconcileUserCredentialMetadataResult,
424
+ ): Promise<void> {
425
+ const url = fromB64url(tags[MCP_CONNECTION_TAGS.urlB64]);
426
+ const authKind = tags[MCP_CONNECTION_TAGS.authKind];
427
+ const status = tags[MCP_CONNECTION_TAGS.status];
428
+ if (url && authKind && status) {
429
+ upsertStoreHeldMcpConnectionRow(
430
+ {
431
+ id,
432
+ userId,
433
+ url,
434
+ authKind,
435
+ tokenEndpoint: null,
436
+ redirectUri: null,
437
+ clientId: null,
438
+ status,
439
+ scopes: fromB64url(tags[MCP_CONNECTION_TAGS.scopesB64]) || null,
440
+ updatedAt: intTag(tags, MCP_CONNECTION_TAGS.updatedAt) ?? Date.now(),
441
+ },
442
+ name,
443
+ );
444
+ result.imported++;
445
+ return;
446
+ }
447
+ const raw = await readIfAllowed(store, name);
448
+ if (raw === null) {
449
+ result.unreadable++;
450
+ warn(`${name}: no tags and not readable — not listed on this box`);
451
+ return;
452
+ }
453
+ const value = parseStoredMcpConnection(raw);
454
+ if (value.userId !== userId || value.id !== id) {
455
+ throw new Error("payload does not describe the connection its path names");
456
+ }
457
+ try {
458
+ await store.tag(name, mcpConnectionMetadataTags(value));
459
+ result.tagged++;
460
+ } catch (err) {
461
+ warn(`${name}: tagging failed: ${err instanceof Error ? err.message : String(err)}`);
462
+ }
463
+ upsertStoreHeldMcpConnectionRow(value, name);
464
+ result.imported++;
465
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * ADR-136 sign-in box: the small always-on machine behind the "Uai Cloud"
3
+ * runner row (docs/shared-host-retirement.md). Selected by
4
+ * `uai-host run --signin-box` (env `UAI_SIGNIN_BOX=1`), which cli.ts sets
5
+ * before main.ts loads. Same package as every runner, one flag.
6
+ *
7
+ * The box has exactly one job — run engine sign-ins and connects for
8
+ * workspaces and write the result into the credential store — and holds no
9
+ * credential at rest: captures go to the store first and only a metadata row
10
+ * stays (lib/engine-accounts.ts, store-only accounts), nothing is imported
11
+ * from the store at start, the default engine slots are scrubbed on every
12
+ * start, and its store identity is write-only. Everything else a runner does
13
+ * is refused here: task commands and tunnels get a definite non-retryable
14
+ * answer, the local UI and the pickup/broker loops never start.
15
+ */
16
+
17
+ import { existsSync, readdirSync, rmSync, type Dirent } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import { removeEnvValuesAtomic } from "./env-file";
21
+
22
+ export function signInBoxMode(): boolean {
23
+ return process.env.UAI_SIGNIN_BOX === "1";
24
+ }
25
+
26
+ export const SIGN_IN_BOX_TASK_REFUSAL =
27
+ "This runner is the Uai Cloud sign-in box; tasks run on their own machines.";
28
+
29
+ /** Host commands the box still serves: its own managed lifecycle and the
30
+ * account management the engines pane drives. Every task-shaped command
31
+ * (taskUp, channels, files, previews, attachments) is refused. */
32
+ const ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
33
+ "hostUpdate",
34
+ "hostRestart",
35
+ "engineAccountRemove",
36
+ "engineAccountRename",
37
+ "engineAccountReorder",
38
+ "engineDisconnect",
39
+ ]);
40
+
41
+ export function signInBoxAllowsCommand(command: string): boolean {
42
+ return ALLOWED_COMMANDS.has(command);
43
+ }
44
+
45
+ /** The env keys the adapters read as the host's OWN engine credential
46
+ * (lib/engines.ts `detect`). None may survive on the box. */
47
+ export const ENGINE_CREDENTIAL_ENV_KEYS = [
48
+ "CLAUDE_CODE_OAUTH_TOKEN",
49
+ "ANTHROPIC_API_KEY",
50
+ "ANTHROPIC_AUTH_TOKEN",
51
+ "OPENAI_API_KEY",
52
+ "XAI_API_KEY",
53
+ "CURSOR_API_KEY",
54
+ ] as const;
55
+
56
+ /** Default-slot credential files under the owner's home (engine-accounts
57
+ * KINDS `defaultHostDir`), and the isolated per-account dirs
58
+ * (`extraHostDir`) — swept as strays in case no row references them. */
59
+ const DEFAULT_SLOT_FILES: readonly (readonly string[])[] = [
60
+ [".codex", "auth.json"],
61
+ [".grok", "auth.json"],
62
+ [".local", "share", "opencode", "auth.json"],
63
+ ];
64
+ const ACCOUNT_DIR_PREFIXES: readonly { parent: readonly string[]; prefix: string }[] = [
65
+ { parent: [], prefix: ".codex-acct-" },
66
+ { parent: [], prefix: ".grok-acct-" },
67
+ { parent: [".local", "share"], prefix: "opencode-acct-" },
68
+ ];
69
+
70
+ export interface PurgeDefaultEngineSlotsResult {
71
+ files: string[];
72
+ dirs: string[];
73
+ envKeys: string[];
74
+ }
75
+
76
+ /**
77
+ * Remove every host-level engine credential from this machine: the default
78
+ * slot files, stray isolated account dirs, and the credential keys in
79
+ * `.env.local` plus the live process env. Idempotent; a fresh box removes
80
+ * nothing.
81
+ */
82
+ export function purgeDefaultEngineSlots(opts: {
83
+ ownerHome: string;
84
+ envFile: string;
85
+ procEnv?: NodeJS.ProcessEnv;
86
+ }): PurgeDefaultEngineSlotsResult {
87
+ const files: string[] = [];
88
+ const dirs: string[] = [];
89
+ for (const parts of DEFAULT_SLOT_FILES) {
90
+ const file = join(opts.ownerHome, ...parts);
91
+ if (!existsSync(file)) continue;
92
+ rmSync(file, { force: true });
93
+ files.push(file);
94
+ }
95
+ for (const { parent, prefix } of ACCOUNT_DIR_PREFIXES) {
96
+ const dir = join(opts.ownerHome, ...parent);
97
+ let entries: Dirent[];
98
+ try {
99
+ entries = readdirSync(dir, { withFileTypes: true });
100
+ } catch {
101
+ continue;
102
+ }
103
+ for (const entry of entries) {
104
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
105
+ const path = join(dir, entry.name);
106
+ rmSync(path, { recursive: true, force: true });
107
+ dirs.push(path);
108
+ }
109
+ }
110
+ const envKeys = removeEnvValuesAtomic(opts.envFile, ENGINE_CREDENTIAL_ENV_KEYS);
111
+ const procEnv = opts.procEnv ?? process.env;
112
+ for (const key of ENGINE_CREDENTIAL_ENV_KEYS) delete procEnv[key];
113
+ return { files, dirs, envKeys };
114
+ }