@runuai/host 0.9.150 → 0.9.152

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.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1837 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +650 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,1006 @@
1
+ /**
2
+ * ADR-154 §13: the runner's local credential stores are removed.
3
+ *
4
+ * The activation authority calls `purgeLocalCredentialStores` once the cloud
5
+ * has committed this runner to cloud credentials, after the runner has left
6
+ * its own (cloud-credential-host.ts `leaveLegacy`), and before it advertises
7
+ * v1. It deletes what §13 lists:
8
+ * - project env values and records (`host_project_env*`), and the value
9
+ * tables of a task machine's replica (`host_project_env_replica*`);
10
+ * - AI accounts: `host_engine_accounts`, and the account directories Uai
11
+ * created under the owner's home;
12
+ * - MCP connections, their secrets and pending OAuth state;
13
+ * - GitHub tokens and their generation fence, SSH keys, and any per-task
14
+ * decrypted SSH key a crash left in the data dir;
15
+ * - the engine keys Uai wrote into `.env.local`, and this process's copies;
16
+ * - the broker's sidecars, and credential-store writes a dead process left
17
+ * staged with a value in them;
18
+ * and, beyond those stores, what still holds a credential: each task's
19
+ * `uai` CLI secret and MCP gateway token in the data dir, the GitHub PAT
20
+ * fallback (from `.env.local` and this process, as the engine keys), and the
21
+ * GitHub tokens this process holds in memory. The database's deletes run
22
+ * with `secure_delete` on, then it vacuums and truncates the WAL, so that no
23
+ * freed page keeps any of them.
24
+ *
25
+ * The operator's own sign-ins stay (`~/.codex`, `~/.grok`, OpenCode's data
26
+ * dir, `~/.kimi-code`, `~/.claude.json`, Claude's keychain): only the
27
+ * broker's sidecar leaves them. So do the runner's own secrets (its
28
+ * enrollment, `host-master.key`, the ADR-153 CA) and every task's worktree.
29
+ * An account's directory goes only from where Uai makes it; an account row
30
+ * naming any other path stays, and fails the purge. Nothing is removed
31
+ * through a symbolic link below the owner's home or the data dir: what the
32
+ * purge would remove, found only behind one, stays and fails the purge, as
33
+ * does a link there that leads to nothing. Nor does it remove one name of
34
+ * what has another: a link where it removes something, a link in what it
35
+ * removes that leads out of it, and a file with a second (hard) name each
36
+ * stay, and fail it, for the runner's own writes went through them. Absence
37
+ * behind a link proves nothing: an account row's directory missing behind
38
+ * one, and a store of Uai's own reached through one, fail it. Absence with no
39
+ * link on the way counts, so storage detached at its own mount point while
40
+ * the purge runs is not seen (accepted, ADR-154 §13). `.env.local`
41
+ * loses its keys only from a regular file of a settings file's size, and a
42
+ * link of it, or a directory above it, that leads to nothing fails the purge
43
+ * too.
44
+ *
45
+ * A crash anywhere is resumed by calling it again, and a call after a
46
+ * complete one removes nothing. Every step is attempted; if anything could
47
+ * not be removed it then throws, and the runner stays activation-pending.
48
+ * No value is logged, returned or thrown: only table names, account ids,
49
+ * paths, key names and counts.
50
+ */
51
+
52
+ import {
53
+ closeSync,
54
+ constants,
55
+ fstatSync,
56
+ lstatSync,
57
+ openSync,
58
+ readdirSync,
59
+ readlinkSync,
60
+ readSync,
61
+ realpathSync,
62
+ rmSync,
63
+ statSync,
64
+ type BigIntStats,
65
+ type Dirent,
66
+ type Stats,
67
+ } from "node:fs";
68
+ import { homedir, tmpdir } from "node:os";
69
+ import { dirname, join, resolve } from "node:path";
70
+ import { parseEnv } from "node:util";
71
+
72
+ import { eq, getTableName, notInArray } from "drizzle-orm";
73
+ import type { SQLiteTable } from "drizzle-orm/sqlite-core";
74
+
75
+ import { onOwnCredentials } from "./cloud-credential-host";
76
+ import { memoryBackedDir, removeStagedWritesLeftBehind } from "./credential-store";
77
+ import { getDb, schema, withHostSqlite } from "./db";
78
+ import { BROKER_SIDECAR_FILE } from "./engine-accounts";
79
+ import { env } from "./env";
80
+ import { keysQuotedPastTheirLine, serializeEnvFileMutation, withoutEnvKeys, writeDurableFileAtomic } from "./env-file";
81
+ import { assertNoAliases } from "./fs-aliases";
82
+ import { forgetGithubTokensInMemory } from "./github-tokens";
83
+ import { noteProjectEnvAuthorityChange } from "./host-env";
84
+
85
+ /** What `.env.local` and this process lose: exactly the engine keys §13
86
+ * names, and the GitHub PAT fallback (github-tokens.ts `setupTaskGithub`).
87
+ * Every other line stays, `UAI_GH_PAT_FALLBACK_USER_ID` too: a user id,
88
+ * inert without the PAT. */
89
+ const ENV_CREDENTIAL_KEYS = [
90
+ "CLAUDE_CODE_OAUTH_TOKEN",
91
+ "ANTHROPIC_API_KEY",
92
+ "ANTHROPIC_AUTH_TOKEN",
93
+ "XAI_API_KEY",
94
+ "CURSOR_API_KEY",
95
+ "UAI_GH_PAT_FALLBACK",
96
+ ] as const;
97
+
98
+ /** Where Uai makes a config-dir account's directory, by the row's kind
99
+ * (engine-accounts.ts `extraHostDir`): in the owner's home, the kind's
100
+ * prefix and then the row's own id. Both of Uai's writers of these rows (a
101
+ * new account, and one imported from the credential store) record exactly
102
+ * that path, and neither a row's kind nor its path changes after. */
103
+ const ACCOUNT_LAYOUTS: ReadonlyMap<string, { parent: readonly string[]; prefix: string }> = new Map<
104
+ string,
105
+ { parent: readonly string[]; prefix: string }
106
+ >([
107
+ ["codex", { parent: [], prefix: ".codex-acct-" }],
108
+ ["grok", { parent: [], prefix: ".grok-acct-" }],
109
+ ["opencode", { parent: [".local", "share"], prefix: "opencode-acct-" }],
110
+ ]);
111
+
112
+ /** An account id as it ends one of those names: a single path segment (a
113
+ * ULID, or a credential-store path segment). An id with a `/` in it would
114
+ * make the layout itself name a path out of the owner's home. */
115
+ const ACCOUNT_ID = /^[A-Za-z0-9_.-]+$/;
116
+
117
+ /** The operator's own engine dirs the broker kept a sidecar in
118
+ * (token-broker-local.ts `DEFAULT_SLOTS`). Only the sidecar leaves them. */
119
+ const OPERATOR_SIDECAR_DIRS = [".codex", ".grok"] as const;
120
+
121
+ export interface CredentialsPurgeReport {
122
+ /** Rows deleted, by table. */
123
+ rows: Record<string, number>;
124
+ /** Directories removed: AI accounts', per-task SSH identities, staged
125
+ * credential-store writes. */
126
+ dirs: string[];
127
+ /** Files removed: the broker's sidecars in the operator's own dirs, and
128
+ * each task's CLI secret and MCP gateway token. */
129
+ files: string[];
130
+ /** Credential keys removed from `.env.local` or from this process's env. */
131
+ envKeys: string[];
132
+ }
133
+
134
+ export interface CredentialsPurgeOptions {
135
+ /** The home Uai created account dirs in. Default: as engine-accounts.ts
136
+ * resolves it. */
137
+ ownerHome?: string;
138
+ /** Default: `.env.local` in the runner's home. */
139
+ envFile?: string;
140
+ /** Where per-task SSH identities, CLI secrets and MCP gateway tokens were
141
+ * written. Default: the runner's data dir. The database is `getDb()`'s
142
+ * whatever this says. */
143
+ dataDir?: string;
144
+ /** Tests only: where staged credential-store writes are looked for. */
145
+ stagingRoots?: readonly string[];
146
+ }
147
+
148
+ /** Not everything could be removed. `errors` say what, `report` what this
149
+ * attempt did remove; calling the purge again resumes it. */
150
+ export class CredentialsPurgeError extends AggregateError {
151
+ constructor(
152
+ errors: Error[],
153
+ readonly report: CredentialsPurgeReport,
154
+ ) {
155
+ super(errors, `the runner's local credential stores were not all removed (${errors.length} failed)`);
156
+ this.name = "CredentialsPurgeError";
157
+ }
158
+ }
159
+
160
+ type Fail = (what: string, error: unknown) => void;
161
+
162
+ export async function purgeLocalCredentialStores(
163
+ options: CredentialsPurgeOptions = {},
164
+ ): Promise<CredentialsPurgeReport> {
165
+ // Irreversible: a runner still on its own credentials would lose them
166
+ // from under its live tasks.
167
+ if (onOwnCredentials()) {
168
+ throw new Error("the runner is still on its own credentials; its stores are not purged");
169
+ }
170
+ const ownerHome = resolve(options.ownerHome ?? (process.env.UAI_OWNER_HOME?.trim() || homedir()));
171
+ const envFile = options.envFile ?? join(env.uaiHome, ".env.local");
172
+ const dataDir = options.dataDir ?? env.dataDir;
173
+ const report: CredentialsPurgeReport = { rows: {}, dirs: [], files: [], envKeys: [] };
174
+ const failures: Error[] = [];
175
+ const fail: Fail = (what, error) => {
176
+ const reason = error instanceof Error ? error.message : String(error);
177
+ failures.push(new Error(`${what}: ${reason}`, { cause: error }));
178
+ };
179
+
180
+ // The one step that waits, first: it queues behind any other writer of
181
+ // `.env.local`. What follows runs in a single turn, so nothing can add a
182
+ // row between the account rows read below and the transaction that
183
+ // deletes them.
184
+ await removeEnvCredentialKeys(envFile, report, fail);
185
+
186
+ const accounts = readAccountRows(fail);
187
+ const accountRowsKept = accounts === null ? null : removeAccountDirs(ownerHome, accounts, report, fail);
188
+ removeSidecars(ownerHome, report, fail);
189
+ removeTaskIdentities(dataDir, report, fail);
190
+ removeTaskSecrets(dataDir, report, fail);
191
+ removeStagedWrites(options.stagingRoots ?? defaultStagingRoots(), report, fail);
192
+ purgeDatabase(accountRowsKept, report, fail);
193
+ // After the rows: no exchange can start from one again.
194
+ forgetGithubTokens(fail);
195
+
196
+ if (failures.length > 0) throw new CredentialsPurgeError(failures, report);
197
+ return report;
198
+ }
199
+
200
+ /**
201
+ * The credential keys, from `.env.local` and from this process.
202
+ *
203
+ * The file loses them where it is. src/load-env.ts reads `.env.local`
204
+ * through any link, so a linked one (into a dotfiles checkout, say) has its
205
+ * keys in the file at the end of the links, and renaming a new file over
206
+ * the link, all a plain rewrite does, would replace the link and leave that
207
+ * file holding them. So the links are resolved, and the file at their end
208
+ * is rewritten atomically in its own directory (writeDurableFileAtomic: a
209
+ * temporary file beside it, then the rename over it); every link still
210
+ * names it. This rather than failing: a failure would keep the runner
211
+ * pending, running no task, until the operator made an edit the runner can
212
+ * make safely itself, dropping only these keys' lines from a file it reads
213
+ * at every boot. When it cannot (a directory it may not write, something
214
+ * that is not a settings file), it fails, naming both paths.
215
+ *
216
+ * A link that leads to nothing fails too, naming its target. No file holds
217
+ * the keys now, but the one it names may come back with them (a volume
218
+ * mounted again, a checkout restored), and the next boot would load them
219
+ * while nothing would run the purge again. So the purge stays failed until
220
+ * the operator removes the link or brings the file back. No `.env.local`
221
+ * at all, file or link, is nothing to do.
222
+ */
223
+ async function removeEnvCredentialKeys(
224
+ envFile: string,
225
+ report: CredentialsPurgeReport,
226
+ fail: Fail,
227
+ ): Promise<void> {
228
+ const removed = new Set<string>();
229
+ let where = envFile;
230
+ try {
231
+ // Every writer of `.env.local` queues on this path, link or not. The
232
+ // links are resolved in this turn: one before it may have replaced them.
233
+ const fromFile = await serializeEnvFileMutation(envFile, () => {
234
+ const file = fileAtEndOfLinks(envFile);
235
+ if (file === null) return [];
236
+ if (file !== envFile) where = `${envFile} (through its links, ${file})`;
237
+ return removeKeysFromSettingsFile(file);
238
+ });
239
+ for (const key of fromFile) removed.add(key);
240
+ } catch (error) {
241
+ fail(`credential keys in ${where}`, error);
242
+ }
243
+ // Loaded at boot (src/load-env.ts), and read from here as the runner's own
244
+ // sign-in whatever the file says now.
245
+ for (const key of ENV_CREDENTIAL_KEYS) {
246
+ if (process.env[key] === undefined) continue;
247
+ delete process.env[key];
248
+ removed.add(key);
249
+ }
250
+ report.envKeys = ENV_CREDENTIAL_KEYS.filter((key) => removed.has(key));
251
+ }
252
+
253
+ /** The file `path` is once every link is followed; null when there is no
254
+ * file there and no link either, in a directory that is there. A directory
255
+ * above it that is not (a link on the way that leads to nothing, a volume
256
+ * not mounted) proves nothing: it may come back holding the file. */
257
+ function fileAtEndOfLinks(path: string): string | null {
258
+ const file = realpathIfThere(path);
259
+ if (file !== null) return file;
260
+ if (lstatIfThere(path)?.isSymbolicLink()) {
261
+ throw new Error(
262
+ `a symbolic link to ${readlinkSync(path)} that leads to nothing; remove it, or bring back the file it names`,
263
+ );
264
+ }
265
+ if (realpathIfThere(dirname(path)) === null) {
266
+ throw new Error(
267
+ `${dirname(path)}, the directory it would be in, is not there (a link on the way that leads to nothing, ` +
268
+ "or a volume not mounted); bring it back",
269
+ );
270
+ }
271
+ return null;
272
+ }
273
+
274
+ /** The most `.env.local` may hold. A settings file is a few hundred bytes,
275
+ * and this one is read whole. */
276
+ const ENV_FILE_MAX_BYTES = 1024 * 1024;
277
+
278
+ /** Drops the credential keys from `file`, the file at the end of
279
+ * `.env.local`'s links, and returns those it held. */
280
+ function removeKeysFromSettingsFile(file: string): string[] {
281
+ const current = readSettingsFile(file);
282
+ // A value over several lines is never edited: the line that sets the key
283
+ // is not all of it, and which lines are depends on the parser (Node's has
284
+ // read them differently from one release to the next).
285
+ const runOn = keysQuotedPastTheirLine(current, ENV_CREDENTIAL_KEYS);
286
+ if (runOn.length > 0) {
287
+ throw new Error(
288
+ `${runOn.join(", ")} ${runOn.length === 1 ? "has a quoted value" : "have quoted values"} that ` +
289
+ "runs on past its line, whose rest dropping the line would leave; " +
290
+ `remove ${runOn.length === 1 ? "it" : "them"} by hand`,
291
+ );
292
+ }
293
+ const { body, removed } = withoutEnvKeys(current, ENV_CREDENTIAL_KEYS);
294
+ assertReadAsBefore(current, body, removed);
295
+ if (removed.length > 0) writeDurableFileAtomic(file, body);
296
+ return removed;
297
+ }
298
+
299
+ /**
300
+ * `file`'s text, when it is a regular file of a settings file's size. It is
301
+ * whatever the operator's links lead to, and the runner waits on this read:
302
+ * the purge runs synchronously. A FIFO with no writer would block the open
303
+ * forever, and a device such as /dev/zero would never end the read. So the
304
+ * open follows no link (one put there since the links were resolved fails
305
+ * it), does not wait (O_NONBLOCK: a FIFO opens at once) and takes no
306
+ * terminal as the runner's own (O_NOCTTY); what it opened is checked on the
307
+ * descriptor, so nothing can be swapped in between the check and the read;
308
+ * and the read stops past the limit, should the file have grown since.
309
+ * O_NOFOLLOW covers the file's own name only: a directory above it made a
310
+ * link in that instant would still be followed, by the open and by the
311
+ * rename after it, which Node has no openat to prevent.
312
+ */
313
+ function readSettingsFile(file: string): string {
314
+ const descriptor = openSync(
315
+ file,
316
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY,
317
+ );
318
+ try {
319
+ const stats = fstatSync(descriptor);
320
+ if (!stats.isFile()) throw new Error(`not a regular file but ${fileType(stats)}`);
321
+ // Rewritten, it is a new file under this name only: another name of the
322
+ // same file would keep the keys.
323
+ if (stats.nlink > 1) {
324
+ throw new Error(
325
+ `a file with ${stats.nlink} names (hard links); its keys would stay under the others: remove them, then the keys go`,
326
+ );
327
+ }
328
+ const tooLarge = () => new Error(`larger than ${ENV_FILE_MAX_BYTES} bytes, which no settings file is`);
329
+ if (stats.size > ENV_FILE_MAX_BYTES) throw tooLarge();
330
+ const buffer = Buffer.alloc(ENV_FILE_MAX_BYTES + 1);
331
+ let length = 0;
332
+ while (length < buffer.length) {
333
+ const read = readSync(descriptor, buffer, length, buffer.length - length, null);
334
+ if (read === 0) break;
335
+ length += read;
336
+ }
337
+ if (length > ENV_FILE_MAX_BYTES) throw tooLarge();
338
+ return buffer.toString("utf8", 0, length);
339
+ } finally {
340
+ closeSync(descriptor);
341
+ }
342
+ }
343
+
344
+ function fileType(stats: Stats): string {
345
+ if (stats.isDirectory()) return "a directory";
346
+ if (stats.isFIFO()) return "a FIFO";
347
+ if (stats.isCharacterDevice()) return "a character device";
348
+ if (stats.isBlockDevice()) return "a block device";
349
+ if (stats.isSocket()) return "a socket";
350
+ return "something else";
351
+ }
352
+
353
+ /**
354
+ * Throws, so that nothing is written, unless the runner will read `body` as
355
+ * it read `current` without the keys. src/load-env.ts loads the file with
356
+ * Node's own parser (process.loadEnvFile; util.parseEnv is that parser),
357
+ * which takes more than the one line, `KEY=value`, that withoutEnvKeys drops
358
+ * and Uai writes: `export KEY=value`, spaces about a key. A key set another
359
+ * way would stay, loaded at every boot while the purge counted it gone. (A
360
+ * key's quoted value over several lines was refused before this, by a rule
361
+ * of its own: what this parser makes of one has changed between releases.)
362
+ * Only the keys are named in the failure, never another setting: a line that
363
+ * is none is read as the start of the next setting's name, and may be a
364
+ * value.
365
+ */
366
+ function assertReadAsBefore(current: string, body: string, removed: readonly string[]): void {
367
+ const after = parseEnv(body);
368
+ const left = ENV_CREDENTIAL_KEYS.filter((key) => Object.hasOwn(after, key));
369
+ if (left.length > 0) {
370
+ throw new Error(
371
+ `the runner would still load ${left.join(", ")} from what is left (not a KEY=value line); ` +
372
+ `remove ${left.length === 1 ? "it" : "them"} by hand`,
373
+ );
374
+ }
375
+ const before = parseEnv(current);
376
+ const credential = new Set<string>(ENV_CREDENTIAL_KEYS);
377
+ for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
378
+ if (credential.has(name) || before[name] === after[name]) continue;
379
+ throw new Error(
380
+ `dropping the lines of ${removed.join(", ")} would change how the runner reads the rest of it; ` +
381
+ `remove ${removed.length === 1 ? "it" : "them"} by hand`,
382
+ );
383
+ }
384
+ }
385
+
386
+ interface AccountRow {
387
+ id: string;
388
+ kind: string;
389
+ configDir: string | null;
390
+ }
391
+
392
+ function readAccountRows(fail: Fail): AccountRow[] | null {
393
+ try {
394
+ return getDb()
395
+ .select({
396
+ id: schema.engineAccounts.id,
397
+ kind: schema.engineAccounts.kind,
398
+ configDir: schema.engineAccounts.configDir,
399
+ })
400
+ .from(schema.engineAccounts)
401
+ .all();
402
+ } catch (error) {
403
+ fail("AI account rows", error);
404
+ return null;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Every account directory Uai made. A row's directory goes, and the row
410
+ * with it, only when its path is exactly where Uai makes that account's
411
+ * (`accountDir`). Any other path, the operator's own `~/.codex` or a
412
+ * `.codex-acct-1` in some other directory alike, is left as it is: its row
413
+ * stays and the purge fails, naming both, so that no directory is forgotten
414
+ * that was not removed. Then any other directory in the owner's home named
415
+ * as Uai names one goes too, an orphan no row names. The directory a kind's
416
+ * are made in (the owner's home, or `.local/share` in it) is walked as
417
+ * `below` walks one: reached only through a link, nothing is removed from
418
+ * it, and each account directory behind the link fails, its row kept; a
419
+ * link on the way that leads to nothing keeps every row of that kind, and
420
+ * fails. Returns the ids of the rows that stay; the next call finds each
421
+ * again.
422
+ */
423
+ function removeAccountDirs(
424
+ ownerHome: string,
425
+ rows: readonly AccountRow[],
426
+ report: CredentialsPurgeReport,
427
+ fail: Fail,
428
+ ): string[] {
429
+ const kept: string[] = [];
430
+ const named = new Set<string>();
431
+ // The rows whose directory is where Uai makes it, by kind.
432
+ const placed = new Map<string, Array<{ id: string; dir: string }>>();
433
+ for (const row of rows) {
434
+ if (row.configDir === null) continue;
435
+ named.add(row.configDir);
436
+ const dir = accountDir(ownerHome, row);
437
+ if (row.configDir !== dir) {
438
+ kept.push(row.id);
439
+ fail(
440
+ `account ${row.id}`,
441
+ new Error(
442
+ dir === null
443
+ ? `${row.configDir} is not a directory Uai makes; the row is kept`
444
+ : `${row.configDir} is not where Uai makes its directory (${dir}); the row is kept`,
445
+ ),
446
+ );
447
+ continue;
448
+ }
449
+ const ofKind = placed.get(row.kind) ?? [];
450
+ ofKind.push({ id: row.id, dir });
451
+ placed.set(row.kind, ofKind);
452
+ }
453
+ for (const [kind, { parent, prefix }] of ACCOUNT_LAYOUTS) {
454
+ const parentDir = join(ownerHome, ...parent);
455
+ const rowsHere = placed.get(kind) ?? [];
456
+ let where: Below;
457
+ try {
458
+ where = below(ownerHome, parent);
459
+ } catch (error) {
460
+ fail(parentDir, error);
461
+ kept.push(...rowsHere.map((row) => row.id));
462
+ continue;
463
+ }
464
+ if (where.kind === "none") {
465
+ // Missing on the way: every row's directory with it, proven only when
466
+ // no link came before (behind one, it may come back).
467
+ if (where.via !== undefined) {
468
+ for (const row of rowsHere) {
469
+ fail(`account ${row.id} at ${row.dir}`, notThereBehindLink(where.via));
470
+ kept.push(row.id);
471
+ }
472
+ }
473
+ continue;
474
+ }
475
+ // Each directory to remove, and the row it is (null: an orphan).
476
+ const dirs = new Map<string, string | null>(rowsHere.map((row) => [row.dir, row.id]));
477
+ for (const entry of listDir(where.path, fail)) {
478
+ if (!isAccountDirName(prefix, entry.name)) continue;
479
+ const path = join(parentDir, entry.name);
480
+ if (named.has(path)) continue;
481
+ // readdir's types are lstat's. A link named as Uai names an account's
482
+ // directory is never followed, and never removed alone: what it leads
483
+ // to may be an account's that Uai wrote through it.
484
+ if (entry.isSymbolicLink()) {
485
+ fail(path, new Error("a symbolic link named as Uai names an account's directory; neither it nor what it points at is removed"));
486
+ continue;
487
+ }
488
+ if (entry.isDirectory()) dirs.set(path, null);
489
+ }
490
+ for (const [path, rowId] of dirs) {
491
+ const gone =
492
+ where.kind === "dir"
493
+ ? removeAccountDir(path, rowId, where, report.dirs, fail)
494
+ : absentBehindLink(path, rowId, where, fail);
495
+ if (!gone && rowId !== null) kept.push(rowId);
496
+ }
497
+ }
498
+ return kept;
499
+ }
500
+
501
+ /** Where Uai makes this row's directory; null when it makes none (a kind
502
+ * with no directory, or an id that is not one name). */
503
+ function accountDir(ownerHome: string, row: AccountRow): string | null {
504
+ const layout = ACCOUNT_LAYOUTS.get(row.kind);
505
+ if (layout === undefined || !ACCOUNT_ID.test(row.id)) return null;
506
+ return join(ownerHome, ...layout.parent, `${layout.prefix}${row.id}`);
507
+ }
508
+
509
+ function isAccountDirName(prefix: string, name: string): boolean {
510
+ return name.startsWith(prefix) && ACCOUNT_ID.test(name.slice(prefix.length));
511
+ }
512
+
513
+ /** Removes an account directory (`rowId`'s, or an orphan's) as lstat finds
514
+ * it, never through a link: the directories above it, in `where`, were
515
+ * walked without following one, and are checked again just before. True
516
+ * once it is gone. */
517
+ function removeAccountDir(
518
+ path: string,
519
+ rowId: string | null,
520
+ where: ProvenDir,
521
+ into: string[],
522
+ fail: Fail,
523
+ ): boolean {
524
+ const what = rowId === null ? path : `account ${rowId} at ${path}`;
525
+ let stats: BigIntStats | null;
526
+ try {
527
+ stats = lstatIfThere(path);
528
+ } catch (error) {
529
+ fail(what, error);
530
+ return false;
531
+ }
532
+ if (stats === null) {
533
+ // Gone, if the directories above it are still the ones the walk found.
534
+ try {
535
+ assertUnmoved(where.chain);
536
+ } catch (error) {
537
+ fail(what, error);
538
+ return false;
539
+ }
540
+ return true;
541
+ }
542
+ if (stats.isSymbolicLink()) {
543
+ // Uai makes a directory here, never a link, yet its own writes would
544
+ // have gone through one (a recursive mkdir takes a link to a directory
545
+ // as made, and the account's files are written through it), so what it
546
+ // points at may hold the account's credentials. Removing only the link
547
+ // would forget them along with the row, and following it would let a
548
+ // link aim this recursive delete anywhere. So neither: the link, what it
549
+ // points at and the row all stay, and the purge fails until the link is
550
+ // gone.
551
+ fail(what, new Error("a symbolic link where Uai makes a directory; neither it nor what it points at is removed"));
552
+ return false;
553
+ }
554
+ try {
555
+ assertNoAliases(path);
556
+ assertUnmoved(where.chain);
557
+ rmSync(path, { recursive: true, force: true });
558
+ } catch (error) {
559
+ fail(what, error);
560
+ return false;
561
+ }
562
+ into.push(path);
563
+ return true;
564
+ }
565
+
566
+ /** An account directory reached only through a link (`where`): looked for,
567
+ * never removed. A row's that is not there proves nothing behind a link,
568
+ * and fails too; an orphan is one found there. True only when it was
569
+ * never Uai's to look for. */
570
+ function absentBehindLink(path: string, rowId: string | null, where: LinkedDir, fail: Fail): boolean {
571
+ const what = rowId === null ? path : `account ${rowId} at ${path}`;
572
+ try {
573
+ if (lstatIfThere(path) !== null) fail(what, throughLink(where));
574
+ else if (rowId !== null) fail(what, notThereBehindLink(where));
575
+ else return true;
576
+ } catch (error) {
577
+ fail(what, error);
578
+ }
579
+ return false;
580
+ }
581
+
582
+ function removeSidecars(ownerHome: string, report: CredentialsPurgeReport, fail: Fail): void {
583
+ for (const dir of OPERATOR_SIDECAR_DIRS) {
584
+ const file = join(ownerHome, dir, BROKER_SIDECAR_FILE);
585
+ try {
586
+ const where = below(ownerHome, [dir]);
587
+ if (where.kind === "none") continue;
588
+ // The broker writes a file; a directory of that name is the
589
+ // operator's, like the rest of the dir.
590
+ const stats = lstatIfThere(join(where.path, BROKER_SIDECAR_FILE));
591
+ if (stats === null || stats.isDirectory()) continue;
592
+ if (where.kind === "dir") removeFrom(where, BROKER_SIDECAR_FILE, report.files, fail);
593
+ else fail(file, throughLink(where));
594
+ } catch (error) {
595
+ fail(file, error);
596
+ }
597
+ }
598
+ }
599
+
600
+ /** ssh.ts `writeTaskIdentity`: a task creator's decrypted key, removed after
601
+ * task-up unless a crash came first. The `known_hosts` beside them stays. */
602
+ function removeTaskIdentities(dataDir: string, report: CredentialsPurgeReport, fail: Fail): void {
603
+ removeEverythingIn(dataDir, ["identity", "tasks"], report.dirs, fail);
604
+ }
605
+
606
+ /**
607
+ * Each task's `uai` CLI secret, the key its agents' task tokens are signed
608
+ * with (agent-cli.ts `storeTaskCliSecret`), and its MCP gateway token, which
609
+ * alone admits a request to the task's gateway routes (mcp-gateway.ts
610
+ * `ensureTaskGatewayAcl`). Nothing needs either again: every task runtime
611
+ * was retired before this runs, and a Resume brings both back. The cloud
612
+ * keeps the CLI secret, the same one across resumes, and sends it with
613
+ * every task-up, which writes it here again; the first session after one
614
+ * mints a new gateway token, as after any Stop, which removes the old.
615
+ */
616
+ function removeTaskSecrets(dataDir: string, report: CredentialsPurgeReport, fail: Fail): void {
617
+ for (const dir of ["task-secrets", "mcp-gateway"]) removeEverythingIn(dataDir, [dir], report.files, fail);
618
+ }
619
+
620
+ /** Everything in the directory at `parts` below `root`, a directory Uai
621
+ * keeps for itself alone. Uai never makes a link on the way to it, so one
622
+ * there is the operator's, and fails the purge whatever is behind it now:
623
+ * nothing is removed through it, and nothing there, or nothing at all
624
+ * where it leads, proves nothing (an empty mount point, a volume not
625
+ * mounted, a link changed back). */
626
+ function removeEverythingIn(root: string, parts: readonly string[], into: string[], fail: Fail): void {
627
+ const dir = join(root, ...parts);
628
+ let where: Below;
629
+ try {
630
+ where = below(root, parts);
631
+ } catch (error) {
632
+ fail(dir, error);
633
+ return;
634
+ }
635
+ if (where.kind === "none") {
636
+ if (where.via !== undefined) fail(dir, notThereBehindLink(where.via));
637
+ return;
638
+ }
639
+ const entries = listDir(where.path, fail);
640
+ if (where.kind === "dir") {
641
+ // Nothing listed proves nothing unless it is still the directory the walk
642
+ // found: it may have gone, or been swapped, since.
643
+ if (entries.length === 0) {
644
+ try {
645
+ assertUnmoved(where.chain);
646
+ } catch (error) {
647
+ fail(dir, error);
648
+ }
649
+ }
650
+ for (const entry of entries) removeFrom(where, entry.name, into, fail);
651
+ } else if (entries.length > 0) {
652
+ fail(dir, throughLink(where, `${entries.length} ${entries.length === 1 ? "entry" : "entries"} `));
653
+ } else {
654
+ fail(
655
+ dir,
656
+ new Error(
657
+ `empty behind the symbolic link ${where.link} (to ${where.to}), which proves nothing; ` +
658
+ "replace the link with a directory",
659
+ ),
660
+ );
661
+ }
662
+ }
663
+
664
+ /** Where this build stages a store write (memory-backed), and where an older
665
+ * one staged it on disk. */
666
+ function defaultStagingRoots(): string[] {
667
+ const memory = memoryBackedDir();
668
+ return memory === null ? [tmpdir()] : [memory, tmpdir()];
669
+ }
670
+
671
+ function removeStagedWrites(
672
+ roots: readonly string[],
673
+ report: CredentialsPurgeReport,
674
+ fail: Fail,
675
+ ): void {
676
+ const staged = removeStagedWritesLeftBehind(roots);
677
+ report.dirs.push(...staged.removed);
678
+ for (const { path, error } of staged.failed) fail(path, error);
679
+ for (const path of staged.pending) {
680
+ fail(path, new Error("a staged credential-store write that may be under way; the purge finishes once it is gone"));
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Every credential row, in one transaction, and then the pages they freed.
686
+ * `secure_delete` comes first, so each delete zeroes the bytes it frees in
687
+ * the WAL frames it writes; VACUUM then rebuilds the file without the free
688
+ * pages earlier deletes left as they were, and the checkpoint moves it all
689
+ * into the database and truncates the WAL, whose stale frames could still
690
+ * hold an old copy of any page.
691
+ */
692
+ function purgeDatabase(
693
+ accountRowsKept: string[] | null,
694
+ report: CredentialsPurgeReport,
695
+ fail: Fail,
696
+ ): void {
697
+ try {
698
+ withHostSqlite((sqlite) => {
699
+ if (Number(sqlite.pragma("secure_delete = ON", { simple: true })) !== 1) {
700
+ throw new Error("SQLite did not turn secure_delete on");
701
+ }
702
+ });
703
+ } catch (error) {
704
+ fail("host.sqlite secure_delete", error);
705
+ }
706
+ try {
707
+ report.rows = deleteCredentialRows(accountRowsKept);
708
+ noteProjectEnvAuthorityChange();
709
+ } catch (error) {
710
+ fail("host.sqlite credential rows", error);
711
+ }
712
+ try {
713
+ withHostSqlite((sqlite) => {
714
+ sqlite.exec("VACUUM");
715
+ const [checkpoint] = sqlite.pragma("wal_checkpoint(TRUNCATE)") as Array<{ busy: number }>;
716
+ if (checkpoint?.busy !== 0) {
717
+ throw new Error("a reader kept the WAL from being checkpointed and truncated");
718
+ }
719
+ });
720
+ } catch (error) {
721
+ fail("host.sqlite freed pages", error);
722
+ }
723
+ }
724
+
725
+ function deleteCredentialRows(accountRowsKept: string[] | null): Record<string, number> {
726
+ const now = Date.now();
727
+ return getDb().transaction((tx) => {
728
+ const rows: Record<string, number> = {};
729
+ const drop = (table: SQLiteTable): void => {
730
+ rows[getTableName(table)] = tx.delete(table).run().changes;
731
+ };
732
+
733
+ // The owned runner's authority row (project-env-authority.ts) is reset,
734
+ // not deleted: its every reader takes a missing row for corruption, so a
735
+ // missing one is made again here. It closes: the gate shut and no
736
+ // reconciliation attempt left, so it serves nothing and applies no
737
+ // delivery until a new one begins. Its stream, cursor and legacy
738
+ // version name no key and hold no value, and only ever move forward, so
739
+ // they stay. Its `modern_write` fence goes up first, as for every
740
+ // modern write, so the permanent legacy triggers
741
+ // (0025_project_env_authority.sql) journal none of the value rows
742
+ // deleted next, and comes down in this same transaction.
743
+ tx.insert(schema.hostProjectEnvAuthority)
744
+ .values({ id: "host", gateOpen: false, modernWrite: true, updatedAt: now })
745
+ .onConflictDoUpdate({
746
+ target: schema.hostProjectEnvAuthority.id,
747
+ set: {
748
+ gateOpen: false,
749
+ attemptId: null,
750
+ attemptLegacyVersion: null,
751
+ modernWrite: true,
752
+ updatedAt: now,
753
+ },
754
+ })
755
+ .run();
756
+ drop(schema.hostProjectEnv);
757
+ // What an old writer journaled before: each row names a key.
758
+ drop(schema.hostProjectEnvLegacyJournal);
759
+ drop(schema.hostProjectEnvRecords);
760
+ drop(schema.hostProjectEnvProjects);
761
+ drop(schema.hostProjectEnvDeliveryReceipts);
762
+ tx.update(schema.hostProjectEnvAuthority)
763
+ .set({ modernWrite: false })
764
+ .where(eq(schema.hostProjectEnvAuthority.id, "host"))
765
+ .run();
766
+
767
+ // A task machine's replica (project-env-replica.ts) loses its values;
768
+ // its records, projects and receipts hold none and stay. Two changes
769
+ // keep it a replica its readers accept. A staged attempt goes whole: its
770
+ // values are gone, so it could never commit, and a closing connection
771
+ // drops one the same way. A live record that said its value is
772
+ // installed now says the value is not on this machine (`unavailable`,
773
+ // as for a head not yet fetched), since a reader takes an installed
774
+ // record without its value for corruption. Its gate closes, as at every
775
+ // sidecar start, until a reconciliation fetches the values again.
776
+ drop(schema.hostProjectEnvReplicaStageValues);
777
+ drop(schema.hostProjectEnvReplicaStageRecords);
778
+ drop(schema.hostProjectEnvReplicaStageProjects);
779
+ drop(schema.hostProjectEnvReplicaValues);
780
+ tx.update(schema.hostProjectEnvReplicaRecords)
781
+ .set({
782
+ materialization: "unavailable",
783
+ materializationReason: "materialization_pending",
784
+ updatedAt: now,
785
+ })
786
+ .where(eq(schema.hostProjectEnvReplicaRecords.materialization, "installed"))
787
+ .run();
788
+ tx.update(schema.hostProjectEnvReplicaAuthority)
789
+ .set({ gateOpen: false, attemptId: null, updatedAt: now })
790
+ .where(eq(schema.hostProjectEnvReplicaAuthority.id, "replica"))
791
+ .run();
792
+
793
+ if (accountRowsKept !== null) {
794
+ const accounts = getTableName(schema.engineAccounts);
795
+ rows[accounts] =
796
+ accountRowsKept.length === 0
797
+ ? tx.delete(schema.engineAccounts).run().changes
798
+ : tx
799
+ .delete(schema.engineAccounts)
800
+ .where(notInArray(schema.engineAccounts.id, accountRowsKept))
801
+ .run().changes;
802
+ }
803
+ drop(schema.mcpConnections);
804
+ drop(schema.githubTokens);
805
+ drop(schema.githubCredentialGenerations);
806
+ drop(schema.sshKeys);
807
+ return rows;
808
+ });
809
+ }
810
+
811
+ /** github-tokens.ts: the access tokens it caches, and the exchanges under
812
+ * way, which keep nothing when they end. */
813
+ function forgetGithubTokens(fail: Fail): void {
814
+ try {
815
+ forgetGithubTokensInMemory();
816
+ } catch (error) {
817
+ fail("GitHub tokens in memory", error);
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Nothing is removed through a symbolic link below the owner's home or the
823
+ * data dir. The roots themselves are taken as configured, links and all.
824
+ * Below them is where an operator's own layout lives (a dotfiles manager
825
+ * links `~/.codex` or `~/.local/share`; a data dir moved to another volume
826
+ * leaves a link behind), and a link there may lead anywhere: a recursive
827
+ * delete through a linked `task-secrets` would empty whatever it names. So
828
+ * each directory the purge lists or removes in is walked from its root with
829
+ * lstat, a component at a time, and what is reached only through a link is
830
+ * looked for there, never removed. Where the link leads:
831
+ * - to something the purge would remove: the purge fails, naming it, the
832
+ * link and where it leads, for the operator to remove it there (an
833
+ * account row behind one stays);
834
+ * - to nothing the purge would remove: there is nothing to do, and no
835
+ * failure. The link and what it leads to are the operator's own, and
836
+ * none of it is Uai's. Failing would keep the runner pending, running no
837
+ * task, until the operator undid their own layout (a symlinked
838
+ * `~/.local/share` holding no OpenCode account of Uai's, say) for
839
+ * nothing the purge has to remove;
840
+ * - to nothing at all: the purge fails, naming it and its target, as for a
841
+ * `.env.local` link to nothing. What is behind it cannot be seen, it may
842
+ * come back (a volume mounted again) holding what the purge removes, and
843
+ * once activation is done nothing would run the purge again. The
844
+ * operator removes the link or brings back what it names.
845
+ * A component that is something other than a directory or a link has
846
+ * nothing behind it.
847
+ *
848
+ * A walk with lstat does not hold on to what it walked. Node has no openat
849
+ * or unlinkat, and reads a directory only by its path, so a removal names
850
+ * its path from the root again and the kernel resolves it anew. Just before
851
+ * each removal, then, every component is checked again, the same directory
852
+ * (device and inode) and still not a link, and a change fails the purge.
853
+ * What that leaves open is the instant between the check and rmSync's own
854
+ * lookup, and, inside what rmSync removes, its own walk (an lstat, then a
855
+ * readdir). Only a process writing these directories while the purge runs
856
+ * could swap a link in there, and every task runtime was retired before.
857
+ */
858
+ type Below = NoDir | ProvenDir | LinkedDir;
859
+
860
+ /** Not there: a component missing, or not a directory. `via` is the first
861
+ * link the walk came through before it, if any: behind one, absence proves
862
+ * nothing. */
863
+ interface NoDir {
864
+ kind: "none";
865
+ via?: { link: string; to: string };
866
+ }
867
+
868
+ /** Every component a directory, none a link; `chain` holds each as the
869
+ * walk found it. */
870
+ interface ProvenDir {
871
+ kind: "dir";
872
+ path: string;
873
+ chain: readonly Component[];
874
+ }
875
+
876
+ /** Reached only through `link`, the first link on the way (as named below
877
+ * the root), which leads to `to`; `path` is the directory itself. */
878
+ interface LinkedDir {
879
+ kind: "linked";
880
+ path: string;
881
+ link: string;
882
+ to: string;
883
+ }
884
+
885
+ interface Component {
886
+ path: string;
887
+ dev: bigint;
888
+ ino: bigint;
889
+ }
890
+
891
+ /** The directory at `parts` below `root`, walked as above; "none" when a
892
+ * component is missing or is not a directory. Throws when the root is not
893
+ * a directory that is there (nothing below it can be seen), or a link on
894
+ * the way leads to nothing. */
895
+ function below(root: string, parts: readonly string[]): Below {
896
+ const real = realpathIfThere(root);
897
+ if (real === null || !statSync(real).isDirectory()) {
898
+ throw new Error(`${root} is not a directory that is there; nothing in it can be seen`);
899
+ }
900
+ const chain: Component[] = [];
901
+ let at = root;
902
+ let crossed: { link: string; to: string } | undefined;
903
+ const none = (): NoDir => (crossed === undefined ? { kind: "none" } : { kind: "none", via: crossed });
904
+ for (const part of parts) {
905
+ const path = join(at, part);
906
+ const stats = lstatIfThere(path);
907
+ if (stats === null) return none();
908
+ if (stats.isSymbolicLink()) {
909
+ const to = realpathIfThere(path);
910
+ if (to === null) {
911
+ throw new Error(
912
+ `the symbolic link ${path} (to ${readlinkSync(path)}) leads to nothing; remove it, or bring back what it names`,
913
+ );
914
+ }
915
+ crossed ??= { link: path, to };
916
+ // A real path names no link: the walk goes on from where this one leads.
917
+ if (!lstatSync(to).isDirectory()) return none();
918
+ at = to;
919
+ continue;
920
+ }
921
+ if (!stats.isDirectory()) return none();
922
+ if (crossed === undefined) chain.push({ path, dev: stats.dev, ino: stats.ino });
923
+ at = path;
924
+ }
925
+ return crossed === undefined ? { kind: "dir", path: at, chain } : { kind: "linked", path: at, ...crossed };
926
+ }
927
+
928
+ /** Throws unless each component is still the directory the walk found. */
929
+ function assertUnmoved(chain: readonly Component[]): void {
930
+ for (const { path, dev, ino } of chain) {
931
+ const stats = lstatSync(path, { bigint: true });
932
+ if (!stats.isDirectory() || stats.dev !== dev || stats.ino !== ino) {
933
+ throw new Error(`${path} changed while the purge ran; nothing below it is removed`);
934
+ }
935
+ }
936
+ }
937
+
938
+ /** Removes `name`, and all under it, from a directory the walk proved,
939
+ * noted in `into` when it was there. Never a link (removing it would leave
940
+ * what the runner wrote through it), nor what holds one (assertNoAliases). */
941
+ function removeFrom(where: ProvenDir, name: string, into: string[], fail: Fail): void {
942
+ const path = join(where.path, name);
943
+ try {
944
+ const stats = lstatIfThere(path);
945
+ if (stats === null) return;
946
+ if (stats.isSymbolicLink()) {
947
+ throw new Error(
948
+ `a symbolic link (to ${readlinkSync(path)}) where Uai writes; neither it nor what it leads to is removed`,
949
+ );
950
+ }
951
+ assertNoAliases(path);
952
+ assertUnmoved(where.chain);
953
+ rmSync(path, { recursive: true, force: true });
954
+ } catch (error) {
955
+ fail(path, error);
956
+ return;
957
+ }
958
+ into.push(path);
959
+ }
960
+
961
+ function throughLink(where: LinkedDir, what = ""): Error {
962
+ return new Error(`${what}behind the symbolic link ${where.link} (to ${where.to}); nothing is removed through a link`);
963
+ }
964
+
965
+ /** Something the purge must find gone, not there behind a link: it may come
966
+ * back there (a volume mounted again, the link changed back). */
967
+ function notThereBehindLink(via: { link: string; to: string }): Error {
968
+ return new Error(
969
+ `not there behind the symbolic link ${via.link} (to ${via.to}), which proves nothing; ` +
970
+ "bring back what it names, or replace the link with a directory",
971
+ );
972
+ }
973
+
974
+ function lstatIfThere(path: string): BigIntStats | null {
975
+ try {
976
+ return lstatSync(path, { bigint: true });
977
+ } catch (error) {
978
+ if (isMissing(error)) return null;
979
+ throw error;
980
+ }
981
+ }
982
+
983
+ function realpathIfThere(path: string): string | null {
984
+ try {
985
+ return realpathSync(path);
986
+ } catch (error) {
987
+ if (isMissing(error)) return null;
988
+ throw error;
989
+ }
990
+ }
991
+
992
+ /** A directory's entries; none when it does not exist. One that cannot be
993
+ * read is a failure: what it holds is unknown. */
994
+ function listDir(dir: string, fail: Fail): Dirent[] {
995
+ try {
996
+ return readdirSync(dir, { withFileTypes: true });
997
+ } catch (error) {
998
+ if (!isMissing(error)) fail(dir, error);
999
+ return [];
1000
+ }
1001
+ }
1002
+
1003
+ function isMissing(error: unknown): boolean {
1004
+ const code = (error as NodeJS.ErrnoException | null)?.code;
1005
+ return code === "ENOENT" || code === "ENOTDIR";
1006
+ }