@runuai/host 0.9.150 → 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 +1830 -224
- 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 +1061 -139
- 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,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154's single credential cleanup procedure.
|
|
3
|
+
*
|
|
4
|
+
* The coordinator owns ordering and failure semantics; credential consumers
|
|
5
|
+
* supply their lane-specific drain/quiesce/drop hooks. Durable inventory is
|
|
6
|
+
* removed only after an exact path is independently proven absent.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
import type { HostCredentialCleanupPath } from "../db/schema";
|
|
11
|
+
import {
|
|
12
|
+
assertCredentialCleanupPath,
|
|
13
|
+
forgetCredentialCleanupPending,
|
|
14
|
+
forgetCredentialCleanupPath,
|
|
15
|
+
listCredentialCleanupRecoveryTasks,
|
|
16
|
+
listCredentialCleanupPaths,
|
|
17
|
+
rememberCredentialCleanupPending,
|
|
18
|
+
type CredentialCleanupLane,
|
|
19
|
+
type CredentialCleanupPendingReason,
|
|
20
|
+
} from "./credential-cleanup-inventory";
|
|
21
|
+
import type { CredentialCleanupRequest as LeaseCredentialCleanupRequest } from "./cloud-credential-leases";
|
|
22
|
+
import type { Db } from "./db";
|
|
23
|
+
import { reconstructTaskEnvironment } from "./task-environment";
|
|
24
|
+
import {
|
|
25
|
+
parseTaskEnvironmentLocator,
|
|
26
|
+
type TaskEnvironmentLocator,
|
|
27
|
+
type TaskEnvironmentHandle,
|
|
28
|
+
} from "./task-environment/types";
|
|
29
|
+
|
|
30
|
+
const CLEANUP_TIMEOUT_MS = 10_000;
|
|
31
|
+
const CLEANUP_OUTPUT_BYTES = 4 * 1024;
|
|
32
|
+
|
|
33
|
+
export type TaskCredentialCleanupReason = CredentialCleanupPendingReason;
|
|
34
|
+
|
|
35
|
+
export interface TaskCredentialCleanupRequest {
|
|
36
|
+
taskId: string;
|
|
37
|
+
runEpoch: number;
|
|
38
|
+
reason: TaskCredentialCleanupReason;
|
|
39
|
+
/** Absent means every credential and every recovered epoch for the task. */
|
|
40
|
+
credentialIds?: readonly string[];
|
|
41
|
+
/** The lease registry's own request this one carries out, with its exact
|
|
42
|
+
* qualified identities (class, id, revision), reason and Files scope, for
|
|
43
|
+
* the hooks; absent for one the coordinator starts itself (boot
|
|
44
|
+
* recovery). The durable inventory keys on `credentialIds` alone. */
|
|
45
|
+
lease?: Readonly<LeaseCredentialCleanupRequest>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TaskCredentialCleanupHooks {
|
|
49
|
+
/** These two hooks must be synchronous and idempotent. */
|
|
50
|
+
refuseNewWork(request: TaskCredentialCleanupRequest): void;
|
|
51
|
+
closeGate(request: TaskCredentialCleanupRequest): void;
|
|
52
|
+
drainWriters(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
53
|
+
closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
54
|
+
quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
55
|
+
dropMemory(request: TaskCredentialCleanupRequest): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TaskCredentialCleanupDeps {
|
|
59
|
+
db?: Db;
|
|
60
|
+
hooks: TaskCredentialCleanupHooks;
|
|
61
|
+
removePath?: (row: HostCredentialCleanupPath) => Promise<void>;
|
|
62
|
+
/** Full cleanup only: destroy one exact runtime and prove it absent. */
|
|
63
|
+
destroyRuntime?: (request: {
|
|
64
|
+
taskId: string;
|
|
65
|
+
runEpoch: number;
|
|
66
|
+
runtime: TaskEnvironmentLocator;
|
|
67
|
+
}) => Promise<boolean>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class TaskCredentialCleanupError extends Error {
|
|
71
|
+
constructor(
|
|
72
|
+
readonly taskId: string,
|
|
73
|
+
readonly failedPaths: readonly string[],
|
|
74
|
+
) {
|
|
75
|
+
super(
|
|
76
|
+
`credential cleanup for task ${taskId} could not prove ${String(failedPaths.length)} path(s) absent`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function execCleanup(
|
|
82
|
+
environment: Pick<TaskEnvironmentHandle, "exec">,
|
|
83
|
+
argv: [string, ...string[]],
|
|
84
|
+
stdin?: Uint8Array,
|
|
85
|
+
): Promise<{ exitCode: number | null; stdout: string }> {
|
|
86
|
+
const result = await environment.exec({
|
|
87
|
+
argv,
|
|
88
|
+
user: "root",
|
|
89
|
+
env: {
|
|
90
|
+
LD_AUDIT: "",
|
|
91
|
+
LD_LIBRARY_PATH: "",
|
|
92
|
+
LD_PRELOAD: "",
|
|
93
|
+
PYTHONHOME: "",
|
|
94
|
+
PYTHONPATH: "",
|
|
95
|
+
},
|
|
96
|
+
projectEnvProfile: "maintenance",
|
|
97
|
+
timeoutMs: CLEANUP_TIMEOUT_MS,
|
|
98
|
+
maxOutputBytes: CLEANUP_OUTPUT_BYTES,
|
|
99
|
+
...(stdin === undefined ? {} : { stdin }),
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
exitCode: result.exitCode,
|
|
103
|
+
stdout: Buffer.from(result.stdout).toString("utf8"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One root process performs traversal, unlink and the absence proof through
|
|
109
|
+
* directory file descriptors. No task-owned parent is re-resolved after it is
|
|
110
|
+
* checked, so renaming it or replacing it with a symlink cannot redirect the
|
|
111
|
+
* root deletion. The program itself is streamed over stdin; no shell parses
|
|
112
|
+
* either it or the validated path.
|
|
113
|
+
*/
|
|
114
|
+
export const CREDENTIAL_UNLINK_HELPER = String.raw`
|
|
115
|
+
import os
|
|
116
|
+
import sys
|
|
117
|
+
|
|
118
|
+
root, absolute = sys.argv[1:3]
|
|
119
|
+
prefix = root.rstrip("/") + "/"
|
|
120
|
+
if not absolute.startswith(prefix):
|
|
121
|
+
raise RuntimeError("credential path escaped its root")
|
|
122
|
+
parts = absolute[len(prefix):].split("/")
|
|
123
|
+
if not parts or any(part in ("", ".", "..") for part in parts):
|
|
124
|
+
raise RuntimeError("credential path is not canonical")
|
|
125
|
+
|
|
126
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
127
|
+
opened = []
|
|
128
|
+
try:
|
|
129
|
+
try:
|
|
130
|
+
current = os.open(root, flags)
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
opened.append(current)
|
|
134
|
+
for component in parts[:-1]:
|
|
135
|
+
try:
|
|
136
|
+
current = os.open(component, flags, dir_fd=current)
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
opened.append(current)
|
|
140
|
+
leaf = parts[-1]
|
|
141
|
+
try:
|
|
142
|
+
os.unlink(leaf, dir_fd=current)
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
pass
|
|
145
|
+
try:
|
|
146
|
+
os.stat(leaf, dir_fd=current, follow_symlinks=False)
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
sys.exit(0)
|
|
149
|
+
raise RuntimeError("credential path still exists")
|
|
150
|
+
finally:
|
|
151
|
+
for descriptor in reversed(opened):
|
|
152
|
+
os.close(descriptor)
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
/** Host-side form of the same confined primitive. The fixed, isolated
|
|
156
|
+
* interpreter is a runner dependency (and is also baked into task machines);
|
|
157
|
+
* neither PATH, Python environment/site injection nor a shell participates. */
|
|
158
|
+
export async function removeHostCredentialFile(
|
|
159
|
+
root: string,
|
|
160
|
+
path: string,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const result = await new Promise<{ exitCode: number | null }>(
|
|
163
|
+
(resolve, reject) => {
|
|
164
|
+
const child = spawn(
|
|
165
|
+
"/usr/bin/python3",
|
|
166
|
+
["-I", "-", root, path],
|
|
167
|
+
{
|
|
168
|
+
cwd: "/",
|
|
169
|
+
env: {},
|
|
170
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
const timeout = setTimeout(() => {
|
|
174
|
+
child.kill("SIGKILL");
|
|
175
|
+
}, CLEANUP_TIMEOUT_MS);
|
|
176
|
+
child.once("error", (error) => {
|
|
177
|
+
clearTimeout(timeout);
|
|
178
|
+
reject(error);
|
|
179
|
+
});
|
|
180
|
+
child.once("close", (exitCode) => {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
resolve({ exitCode });
|
|
183
|
+
});
|
|
184
|
+
child.stdin.on("error", () => undefined);
|
|
185
|
+
child.stdin.end(Buffer.from(CREDENTIAL_UNLINK_HELPER, "utf8"));
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
if (result.exitCode !== 0) {
|
|
189
|
+
throw new Error("runner credential path could not be proven absent");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function removeLocalFile(path: string): Promise<void> {
|
|
194
|
+
if (!path.startsWith("/run/uai/")) {
|
|
195
|
+
throw new Error("runner credential path is outside the managed root");
|
|
196
|
+
}
|
|
197
|
+
await removeHostCredentialFile("/run/uai", path);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function removeRuntimeCredentialFile(
|
|
201
|
+
environment: Pick<TaskEnvironmentHandle, "exec">,
|
|
202
|
+
path: string,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
if (!path.startsWith("/home/node/")) {
|
|
205
|
+
throw new Error("runtime credential path is outside the task home");
|
|
206
|
+
}
|
|
207
|
+
const removed = await execCleanup(
|
|
208
|
+
environment,
|
|
209
|
+
["/usr/bin/python3", "-I", "-", "/home/node", path],
|
|
210
|
+
Buffer.from(CREDENTIAL_UNLINK_HELPER, "utf8"),
|
|
211
|
+
);
|
|
212
|
+
if (removed.exitCode !== 0) {
|
|
213
|
+
throw new Error("credential path could not be proven absent");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Exact, non-recursive deletion for one validated inventory row. */
|
|
218
|
+
export async function removeCredentialCleanupPath(
|
|
219
|
+
row: HostCredentialCleanupPath,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
assertCredentialCleanupPath(
|
|
222
|
+
row.lane as CredentialCleanupLane,
|
|
223
|
+
row.path,
|
|
224
|
+
);
|
|
225
|
+
if (row.lane === "files-session") {
|
|
226
|
+
await removeLocalFile(row.path);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const locator = parseTaskEnvironmentLocator(row.runtimeLocator);
|
|
230
|
+
const environment = await reconstructTaskEnvironment(locator, "loader");
|
|
231
|
+
if (environment.descriptor.taskId !== row.taskId) {
|
|
232
|
+
throw new Error("cleanup runtime belongs to a different task");
|
|
233
|
+
}
|
|
234
|
+
await removeRuntimeCredentialFile(environment, row.path);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function destroyCredentialRuntime(request: {
|
|
238
|
+
taskId: string;
|
|
239
|
+
runEpoch: number;
|
|
240
|
+
runtime: TaskEnvironmentLocator;
|
|
241
|
+
}): Promise<boolean> {
|
|
242
|
+
const environment = await reconstructTaskEnvironment(request.runtime, "loader");
|
|
243
|
+
if (environment.descriptor.taskId !== request.taskId) {
|
|
244
|
+
throw new Error("cleanup runtime belongs to a different task");
|
|
245
|
+
}
|
|
246
|
+
await environment.teardown();
|
|
247
|
+
const status = await environment.status();
|
|
248
|
+
return status.state === "absent";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function selectRows(
|
|
252
|
+
rows: readonly HostCredentialCleanupPath[],
|
|
253
|
+
request: TaskCredentialCleanupRequest,
|
|
254
|
+
): HostCredentialCleanupPath[] {
|
|
255
|
+
const throughEpoch = rows.filter((row) => row.runEpoch <= request.runEpoch);
|
|
256
|
+
if (request.credentialIds === undefined) return throughEpoch;
|
|
257
|
+
const ids = new Set(request.credentialIds);
|
|
258
|
+
if (ids.size !== request.credentialIds.length || ids.size === 0) {
|
|
259
|
+
throw new Error("selective credential cleanup ids must be unique and nonempty");
|
|
260
|
+
}
|
|
261
|
+
return throughEpoch.filter((row) => ids.has(row.credentialId));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isSamePhysicalPath(
|
|
265
|
+
left: HostCredentialCleanupPath,
|
|
266
|
+
right: HostCredentialCleanupPath,
|
|
267
|
+
): boolean {
|
|
268
|
+
// Files-session paths live on the host, so a recreated task runtime still
|
|
269
|
+
// points at the same /run/uai file. Every other managed path lives inside
|
|
270
|
+
// one exact runtime and is identified by that locator plus its path.
|
|
271
|
+
if (left.lane === "files-session" || right.lane === "files-session") {
|
|
272
|
+
return (
|
|
273
|
+
left.lane === "files-session" &&
|
|
274
|
+
right.lane === "files-session" &&
|
|
275
|
+
left.path === right.path
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return (
|
|
279
|
+
left.runtimeLocator === right.runtimeLocator && left.path === right.path
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function forgetRow(row: HostCredentialCleanupPath, db?: Db): void {
|
|
284
|
+
forgetCredentialCleanupPath(
|
|
285
|
+
{
|
|
286
|
+
taskId: row.taskId,
|
|
287
|
+
runEpoch: row.runEpoch,
|
|
288
|
+
runtime: parseTaskEnvironmentLocator(row.runtimeLocator),
|
|
289
|
+
lane: row.lane as CredentialCleanupLane,
|
|
290
|
+
path: row.path,
|
|
291
|
+
},
|
|
292
|
+
db,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Serializes cleanup per task and never reopens a gate on failure. */
|
|
297
|
+
export class TaskCredentialCleanupCoordinator {
|
|
298
|
+
/** The serialized state retains the highest failed epoch until an equal or
|
|
299
|
+
* newer full cleanup succeeds. This prevents a delayed lower-epoch cleanup,
|
|
300
|
+
* or an unrelated selective success, from clearing the crash witness. */
|
|
301
|
+
readonly #tails = new Map<
|
|
302
|
+
string,
|
|
303
|
+
Promise<{ failedEpoch: number; clearThroughEpoch: number }>
|
|
304
|
+
>();
|
|
305
|
+
readonly #deps: TaskCredentialCleanupDeps;
|
|
306
|
+
|
|
307
|
+
constructor(deps: TaskCredentialCleanupDeps) {
|
|
308
|
+
this.#deps = deps;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
cleanup(request: TaskCredentialCleanupRequest): Promise<void> {
|
|
312
|
+
if (!Number.isSafeInteger(request.runEpoch) || request.runEpoch < 1) {
|
|
313
|
+
return Promise.reject(new Error("cleanup run epoch must be positive"));
|
|
314
|
+
}
|
|
315
|
+
if (request.credentialIds !== undefined) {
|
|
316
|
+
const ids = new Set(request.credentialIds);
|
|
317
|
+
if (ids.size !== request.credentialIds.length || ids.size === 0) {
|
|
318
|
+
return Promise.reject(
|
|
319
|
+
new Error("selective credential cleanup ids must be unique and nonempty"),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const prior =
|
|
324
|
+
this.#tails.get(request.taskId) ??
|
|
325
|
+
Promise.resolve({ failedEpoch: 0, clearThroughEpoch: 0 });
|
|
326
|
+
let hooksFailed = false;
|
|
327
|
+
let hookError: unknown;
|
|
328
|
+
const recordFailure = (error: unknown): void => {
|
|
329
|
+
if (!hooksFailed) hookError = error;
|
|
330
|
+
hooksFailed = true;
|
|
331
|
+
};
|
|
332
|
+
try {
|
|
333
|
+
// This witness precedes even the synchronous gate hooks. A crash at any
|
|
334
|
+
// later instruction therefore makes boot recovery run the full cleanup.
|
|
335
|
+
rememberCredentialCleanupPending(request, this.#deps.db);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
recordFailure(error);
|
|
338
|
+
}
|
|
339
|
+
// Both fail-closed hooks get their chance even if the other hook (or the
|
|
340
|
+
// durable write) fails. Their first failure remains the returned cause.
|
|
341
|
+
try {
|
|
342
|
+
this.#deps.hooks.refuseNewWork(request);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
recordFailure(error);
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
this.#deps.hooks.closeGate(request);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
recordFailure(error);
|
|
350
|
+
}
|
|
351
|
+
const outcome = prior.then(async (state) => {
|
|
352
|
+
try {
|
|
353
|
+
if (hooksFailed) throw hookError;
|
|
354
|
+
await this.#run(request);
|
|
355
|
+
return {
|
|
356
|
+
state: {
|
|
357
|
+
failedEpoch:
|
|
358
|
+
request.credentialIds === undefined &&
|
|
359
|
+
request.runEpoch >= state.failedEpoch
|
|
360
|
+
? 0
|
|
361
|
+
: state.failedEpoch,
|
|
362
|
+
clearThroughEpoch: Math.max(
|
|
363
|
+
state.clearThroughEpoch,
|
|
364
|
+
request.runEpoch,
|
|
365
|
+
),
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return {
|
|
370
|
+
state: {
|
|
371
|
+
failedEpoch: Math.max(state.failedEpoch, request.runEpoch),
|
|
372
|
+
clearThroughEpoch: state.clearThroughEpoch,
|
|
373
|
+
},
|
|
374
|
+
error,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
const tail = outcome.then((result) => result.state);
|
|
379
|
+
this.#tails.set(request.taskId, tail);
|
|
380
|
+
const run = outcome.then((result) => {
|
|
381
|
+
if (
|
|
382
|
+
this.#tails.get(request.taskId) === tail &&
|
|
383
|
+
result.state.failedEpoch === 0
|
|
384
|
+
) {
|
|
385
|
+
forgetCredentialCleanupPending(
|
|
386
|
+
request.taskId,
|
|
387
|
+
result.state.clearThroughEpoch,
|
|
388
|
+
this.#deps.db,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if ("error" in result) throw result.error;
|
|
392
|
+
});
|
|
393
|
+
void tail.then((state) => {
|
|
394
|
+
if (
|
|
395
|
+
state.failedEpoch === 0 &&
|
|
396
|
+
this.#tails.get(request.taskId) === tail
|
|
397
|
+
) {
|
|
398
|
+
this.#tails.delete(request.taskId);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
return run;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async #run(request: TaskCredentialCleanupRequest): Promise<void> {
|
|
405
|
+
await this.#deps.hooks.drainWriters(request);
|
|
406
|
+
await this.#deps.hooks.closeConsumers(request);
|
|
407
|
+
await this.#deps.hooks.quiesceProcesses(request);
|
|
408
|
+
|
|
409
|
+
const allRows = listCredentialCleanupPaths(request.taskId, this.#deps.db);
|
|
410
|
+
const selectedRows = selectRows(allRows, request);
|
|
411
|
+
const selected = new Set(selectedRows);
|
|
412
|
+
const rows: HostCredentialCleanupPath[] = [];
|
|
413
|
+
for (const row of selectedRows) {
|
|
414
|
+
const transferred = allRows.some(
|
|
415
|
+
(candidate) =>
|
|
416
|
+
candidate.runEpoch > row.runEpoch &&
|
|
417
|
+
!selected.has(candidate) &&
|
|
418
|
+
isSamePhysicalPath(candidate, row),
|
|
419
|
+
);
|
|
420
|
+
if (!transferred) {
|
|
421
|
+
rows.push(row);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// Gates are closed and writers/processes have drained before this
|
|
425
|
+
// serialized inventory observation. The newer row is therefore the
|
|
426
|
+
// durable ownership witness for the reused physical path: retire only
|
|
427
|
+
// the obsolete row, never unlink the newer run's file.
|
|
428
|
+
forgetRow(row, this.#deps.db);
|
|
429
|
+
}
|
|
430
|
+
let failed: HostCredentialCleanupPath[] = [];
|
|
431
|
+
for (const row of rows) {
|
|
432
|
+
try {
|
|
433
|
+
await (this.#deps.removePath ?? removeCredentialCleanupPath)(row);
|
|
434
|
+
forgetRow(row, this.#deps.db);
|
|
435
|
+
} catch {
|
|
436
|
+
failed.push(row);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// A whole-task cleanup may prove absence by destroying the exact writable
|
|
441
|
+
// runtime instead. Selective removal must never take this broader action.
|
|
442
|
+
if (request.credentialIds === undefined && failed.length > 0) {
|
|
443
|
+
const byRuntime = new Map<string, HostCredentialCleanupPath[]>();
|
|
444
|
+
for (const row of failed) {
|
|
445
|
+
if (row.lane === "files-session") continue;
|
|
446
|
+
const group = byRuntime.get(row.runtimeLocator) ?? [];
|
|
447
|
+
group.push(row);
|
|
448
|
+
byRuntime.set(row.runtimeLocator, group);
|
|
449
|
+
}
|
|
450
|
+
const proven = new Set<HostCredentialCleanupPath>();
|
|
451
|
+
for (const [runtimeLocator, runtimeRows] of byRuntime) {
|
|
452
|
+
// A locator may have been reused by a later assignment. Destroying it
|
|
453
|
+
// for delayed cleanup would then erase the newer run's credential
|
|
454
|
+
// files. Exact deletion may still proceed; the broad fallback may not.
|
|
455
|
+
if (
|
|
456
|
+
allRows.some(
|
|
457
|
+
(row) =>
|
|
458
|
+
row.runtimeLocator === runtimeLocator &&
|
|
459
|
+
row.runEpoch > request.runEpoch,
|
|
460
|
+
)
|
|
461
|
+
) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
const runtime = parseTaskEnvironmentLocator(runtimeLocator);
|
|
466
|
+
const absent = await (
|
|
467
|
+
this.#deps.destroyRuntime ?? destroyCredentialRuntime
|
|
468
|
+
)({
|
|
469
|
+
taskId: request.taskId,
|
|
470
|
+
runEpoch: request.runEpoch,
|
|
471
|
+
runtime,
|
|
472
|
+
});
|
|
473
|
+
if (!absent) continue;
|
|
474
|
+
for (const row of runtimeRows) {
|
|
475
|
+
forgetRow(row, this.#deps.db);
|
|
476
|
+
proven.add(row);
|
|
477
|
+
}
|
|
478
|
+
} catch {
|
|
479
|
+
// The original exact-path failure remains authoritative.
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
failed = failed.filter((row) => !proven.has(row));
|
|
483
|
+
}
|
|
484
|
+
if (failed.length > 0) {
|
|
485
|
+
throw new TaskCredentialCleanupError(
|
|
486
|
+
request.taskId,
|
|
487
|
+
failed.map((row) => row.path),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
this.#deps.hooks.dropMemory(request);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Every cleanup under way, and any that registers meanwhile, settled
|
|
494
|
+
* (process shutdown). */
|
|
495
|
+
async whenIdle(): Promise<void> {
|
|
496
|
+
for (;;) {
|
|
497
|
+
const tails = [...this.#tails.values()];
|
|
498
|
+
await Promise.allSettled(tails);
|
|
499
|
+
const now = [...this.#tails.values()];
|
|
500
|
+
if (now.length === tails.length && now.every((tail, index) => tail === tails[index])) return;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Boot recovery sweeps every durable task before any grant may be asked.
|
|
505
|
+
* Every task is attempted, one failing never leaving the next unswept;
|
|
506
|
+
* then any failure, all of them, fails the sweep. */
|
|
507
|
+
async recoverAll(runEpochForTask: (taskId: string) => number): Promise<void> {
|
|
508
|
+
const tasks = listCredentialCleanupRecoveryTasks(this.#deps.db);
|
|
509
|
+
const failures: unknown[] = [];
|
|
510
|
+
for (const task of tasks) {
|
|
511
|
+
try {
|
|
512
|
+
await this.cleanup({
|
|
513
|
+
taskId: task.taskId,
|
|
514
|
+
runEpoch: Math.max(task.runEpoch, runEpochForTask(task.taskId)),
|
|
515
|
+
reason: "restart",
|
|
516
|
+
});
|
|
517
|
+
} catch (error) {
|
|
518
|
+
failures.push(error);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (failures.length > 0) {
|
|
522
|
+
throw new AggregateError(failures, `credential recovery failed for ${String(failures.length)} task(s)`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|