@runuai/host 0.9.149 → 0.9.151
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1847 -236
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1062 -140
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- 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
|
+
}
|