@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
package/lib/codex-auth.ts
CHANGED
|
@@ -59,6 +59,7 @@ import { dirname, join } from "node:path";
|
|
|
59
59
|
|
|
60
60
|
import { isNull } from "drizzle-orm";
|
|
61
61
|
|
|
62
|
+
import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
62
63
|
import { getDb, schema } from "./db";
|
|
63
64
|
import { dockerCli } from "./docker-exec";
|
|
64
65
|
import { brokeredFilesFor } from "./token-broker";
|
|
@@ -237,7 +238,9 @@ export async function injectCodexIntoContainer(
|
|
|
237
238
|
const exists = deps.fileExists ?? existsSync;
|
|
238
239
|
if (!exists(join(ownerCodexDir(), "auth.json"))) return true; // nothing to inject
|
|
239
240
|
try {
|
|
240
|
-
|
|
241
|
+
// ADR-154: no default slot once the runner begins leaving its own
|
|
242
|
+
// credentials; leaving waits for a copy under way.
|
|
243
|
+
await whileOnOwnCredentials(() => copyCodexInto(container, deps));
|
|
241
244
|
return true;
|
|
242
245
|
} catch (err) {
|
|
243
246
|
console.error(
|
|
@@ -273,6 +276,9 @@ export async function injectCodexIntoMachine(
|
|
|
273
276
|
},
|
|
274
277
|
deps: CodexDeps = {},
|
|
275
278
|
): Promise<boolean> {
|
|
279
|
+
// ADR-154: no default slot once the runner begins leaving its own
|
|
280
|
+
// credentials (a start on them holds its permit through this).
|
|
281
|
+
if (!onOwnCredentials()) return true;
|
|
276
282
|
const exists = deps.fileExists ?? existsSync;
|
|
277
283
|
const dir = ownerCodexDir();
|
|
278
284
|
if (!exists(join(dir, "auth.json"))) return true; // nothing to inject
|
|
@@ -317,6 +323,13 @@ export async function injectCodexIntoMachine(
|
|
|
317
323
|
* failures are surfaced and don't stop the sweep.
|
|
318
324
|
*/
|
|
319
325
|
export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
|
|
326
|
+
// ADR-154: no default slot once the runner begins leaving its own
|
|
327
|
+
// credentials. Leaving waits for a sweep under way, and no copy starts
|
|
328
|
+
// after it began.
|
|
329
|
+
await whileOnOwnCredentials(() => reinjectOwnCodex(deps));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function reinjectOwnCodex(deps: CodexDeps): Promise<void> {
|
|
320
333
|
const exists = deps.fileExists ?? existsSync;
|
|
321
334
|
if (!exists(join(ownerCodexDir(), "auth.json"))) return;
|
|
322
335
|
const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
|
|
@@ -338,6 +351,7 @@ export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<v
|
|
|
338
351
|
if (targets.length === 0) return;
|
|
339
352
|
console.log(`[codex] refreshing auth in ${targets.length} running task(s)`);
|
|
340
353
|
for (const container of targets) {
|
|
354
|
+
if (!onOwnCredentials()) return;
|
|
341
355
|
try {
|
|
342
356
|
await copyCodexInto(container, deps, LIVE_CODEX_ITEMS);
|
|
343
357
|
} catch (err) {
|
|
@@ -394,6 +408,11 @@ export function stopWatchingCodexAuth(): void {
|
|
|
394
408
|
* Same sweep shape as the Codex reinject; only auth.json travels.
|
|
395
409
|
*/
|
|
396
410
|
export async function reinjectGrokRunningTasks(deps: CodexDeps = {}): Promise<void> {
|
|
411
|
+
// ADR-154: as for Codex, only while the runner is on its own credentials.
|
|
412
|
+
await whileOnOwnCredentials(() => reinjectOwnGrok(deps));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function reinjectOwnGrok(deps: CodexDeps): Promise<void> {
|
|
397
416
|
const exists = deps.fileExists ?? existsSync;
|
|
398
417
|
const src = join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".grok", "auth.json");
|
|
399
418
|
if (!exists(src)) return;
|
|
@@ -417,6 +436,7 @@ export async function reinjectGrokRunningTasks(deps: CodexDeps = {}): Promise<vo
|
|
|
417
436
|
try {
|
|
418
437
|
writeFileSync(tmp, brokered, { mode: 0o600 });
|
|
419
438
|
for (const container of active.filter((c) => running.has(c))) {
|
|
439
|
+
if (!onOwnCredentials()) break;
|
|
420
440
|
const steps: Array<[string[], string]> = [
|
|
421
441
|
[["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/mkdir", "-p", "/home/node/.grok"], "mkdir"],
|
|
422
442
|
[["cp", tmp, `${container}:/home/node/.grok/auth.json`], "docker cp auth.json (brokered)"],
|
package/lib/container-runtime.ts
CHANGED
|
@@ -466,6 +466,82 @@ export async function claimContainerRuntimeForTask(
|
|
|
466
466
|
);
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
+
let recheckRequester: ((reason: string) => void) | null = null;
|
|
470
|
+
|
|
471
|
+
/** Who rechecks the runtime when work outside its activation asks: the
|
|
472
|
+
* operator's recheck (main), which fences new work, re-probes, and runs
|
|
473
|
+
* activation again, image maintenance (whose wedge sensor recycles a wedged
|
|
474
|
+
* Apple runtime) and every persisted task's recovery. */
|
|
475
|
+
export function setContainerRuntimeRecheckRequester(requester: ((reason: string) => void) | null): void {
|
|
476
|
+
recheckRequester = requester;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The requester main installs (#p2jdax). A forced recheck is never started
|
|
481
|
+
* over an activation under way: that would only make it stale. So:
|
|
482
|
+
*
|
|
483
|
+
* - an ask while an activation (with its retries) is under way is kept, and
|
|
484
|
+
* asked again once that activation is done, since it may come after its
|
|
485
|
+
* maintenance looked for a wedge; asks kept meanwhile become one;
|
|
486
|
+
* - an ask while only a forced recheck is under way joins it: the
|
|
487
|
+
* activation it begins runs its maintenance after this ask;
|
|
488
|
+
* - otherwise it starts the operator's forced recheck.
|
|
489
|
+
*/
|
|
490
|
+
export function containerRuntimeRecheckRequester(deps: {
|
|
491
|
+
activationUnderWay: () => boolean;
|
|
492
|
+
activationSettled: () => Promise<void>;
|
|
493
|
+
recheck: () => Promise<void>;
|
|
494
|
+
warn?: (line: string) => void;
|
|
495
|
+
}): (reason: string) => void {
|
|
496
|
+
const warn = deps.warn ?? ((line: string) => console.warn(line));
|
|
497
|
+
let underWay: Promise<void> | null = null;
|
|
498
|
+
let kept: string | null = null;
|
|
499
|
+
const ask = (reason: string): void => {
|
|
500
|
+
if (deps.activationUnderWay()) {
|
|
501
|
+
if (kept !== null) {
|
|
502
|
+
warn(`[host-agent] a container runtime recheck is already kept for after the activation under way (${reason})`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
kept = reason;
|
|
506
|
+
warn(`[host-agent] a container runtime activation is under way; rechecking once it is done (${reason})`);
|
|
507
|
+
void deps.activationSettled().then(() => {
|
|
508
|
+
const next = kept;
|
|
509
|
+
kept = null;
|
|
510
|
+
if (next !== null) ask(next);
|
|
511
|
+
});
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (underWay) {
|
|
515
|
+
warn(`[host-agent] a container runtime recheck is under way (${reason})`);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
warn(`[host-agent] rechecking the container runtime: ${reason}`);
|
|
519
|
+
underWay = deps
|
|
520
|
+
.recheck()
|
|
521
|
+
.catch((error: unknown) => {
|
|
522
|
+
warn(
|
|
523
|
+
`[host-agent] the requested container runtime recheck failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
524
|
+
);
|
|
525
|
+
})
|
|
526
|
+
.finally(() => {
|
|
527
|
+
underWay = null;
|
|
528
|
+
});
|
|
529
|
+
};
|
|
530
|
+
return ask;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Ask the runtime's owner to recheck it (#p2jdax): what work outside
|
|
535
|
+
* activation does instead of restarting the runtime's services itself,
|
|
536
|
+
* which would stop every task's VM with nothing to recover them. Returns
|
|
537
|
+
* whether anyone was there to ask.
|
|
538
|
+
*/
|
|
539
|
+
export function requestContainerRuntimeRecheck(reason: string): boolean {
|
|
540
|
+
if (!recheckRequester) return false;
|
|
541
|
+
recheckRequester(reason);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
469
545
|
/** Explicit operator recovery: invalidate even a stale ready verdict before
|
|
470
546
|
* probing so a healthy ready-to-ready result still requires a full activation
|
|
471
547
|
* and persisted-task recovery pass. */
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154's durable, non-secret inventory of task-local credential paths.
|
|
3
|
+
*
|
|
4
|
+
* Writers remember a path before placing credential material there. Cleanup
|
|
5
|
+
* later removes the row only after it has independently verified the path is
|
|
6
|
+
* absent. This module records the witness; it deliberately does not perform
|
|
7
|
+
* cleanup or inspect credential contents.
|
|
8
|
+
*/
|
|
9
|
+
import { and, asc, eq, lte, sql } from "drizzle-orm";
|
|
10
|
+
|
|
11
|
+
import { idPathSegment } from "../src/cloud-credentials";
|
|
12
|
+
import type {
|
|
13
|
+
HostCredentialCleanupPath,
|
|
14
|
+
HostCredentialCleanupPending,
|
|
15
|
+
HostLeasedEngineRuntime,
|
|
16
|
+
} from "../db/schema";
|
|
17
|
+
import { getDb, schema, type Db } from "./db";
|
|
18
|
+
import { assertSafeHostTaskId } from "./task-identity";
|
|
19
|
+
import {
|
|
20
|
+
LEASE_AWS_CONFIG_FILE,
|
|
21
|
+
LEASE_CREDENTIALS_FILE,
|
|
22
|
+
} from "./lease";
|
|
23
|
+
import {
|
|
24
|
+
serializeTaskEnvironmentLocator,
|
|
25
|
+
type TaskEnvironmentLocator,
|
|
26
|
+
} from "./task-environment/types";
|
|
27
|
+
|
|
28
|
+
type InventoryExecutor = Pick<Db, "select" | "insert" | "delete">;
|
|
29
|
+
|
|
30
|
+
export interface CredentialCleanupPathKey {
|
|
31
|
+
taskId: string;
|
|
32
|
+
runEpoch: number;
|
|
33
|
+
runtime: TaskEnvironmentLocator;
|
|
34
|
+
lane: CredentialCleanupLane;
|
|
35
|
+
path: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CredentialCleanupLane =
|
|
39
|
+
| "engine-auth"
|
|
40
|
+
| "github"
|
|
41
|
+
| "ssh"
|
|
42
|
+
| "files-session";
|
|
43
|
+
|
|
44
|
+
export type CredentialCleanupPendingReason =
|
|
45
|
+
| "removed"
|
|
46
|
+
| "denied"
|
|
47
|
+
| "expired"
|
|
48
|
+
| "assignment"
|
|
49
|
+
| "restart"
|
|
50
|
+
| "stop"
|
|
51
|
+
| "park"
|
|
52
|
+
| "teardown";
|
|
53
|
+
|
|
54
|
+
const CLEANUP_PENDING_REASONS = new Set<CredentialCleanupPendingReason>([
|
|
55
|
+
"removed",
|
|
56
|
+
"denied",
|
|
57
|
+
"expired",
|
|
58
|
+
"assignment",
|
|
59
|
+
"restart",
|
|
60
|
+
"stop",
|
|
61
|
+
"park",
|
|
62
|
+
"teardown",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
export interface CredentialCleanupPendingInput {
|
|
66
|
+
taskId: string;
|
|
67
|
+
runEpoch: number;
|
|
68
|
+
reason: CredentialCleanupPendingReason;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CredentialCleanupPathInput extends CredentialCleanupPathKey {
|
|
72
|
+
credentialId: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function positiveRunEpoch(value: number): void {
|
|
76
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
77
|
+
throw new Error("credential cleanup run epoch must be a positive safe integer");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizedPending(input: CredentialCleanupPendingInput): void {
|
|
82
|
+
assertSafeHostTaskId(input.taskId);
|
|
83
|
+
positiveRunEpoch(input.runEpoch);
|
|
84
|
+
if (!CLEANUP_PENDING_REASONS.has(input.reason)) {
|
|
85
|
+
throw new Error("credential cleanup reason is invalid");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Durable fail-closed witness written before cleanup closes any gate or starts
|
|
91
|
+
* any asynchronous work. A newer assignment can only raise the recorded
|
|
92
|
+
* epoch; a delayed older cleanup must never move the boot-recovery floor back.
|
|
93
|
+
*/
|
|
94
|
+
export function rememberCredentialCleanupPending(
|
|
95
|
+
input: CredentialCleanupPendingInput,
|
|
96
|
+
db: InventoryExecutor = getDb(),
|
|
97
|
+
now = Date.now(),
|
|
98
|
+
): void {
|
|
99
|
+
normalizedPending(input);
|
|
100
|
+
db.insert(schema.hostCredentialCleanupPending)
|
|
101
|
+
.values({
|
|
102
|
+
taskId: input.taskId,
|
|
103
|
+
runEpoch: input.runEpoch,
|
|
104
|
+
reason: input.reason,
|
|
105
|
+
createdAt: now,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
})
|
|
108
|
+
.onConflictDoUpdate({
|
|
109
|
+
target: schema.hostCredentialCleanupPending.taskId,
|
|
110
|
+
set: {
|
|
111
|
+
runEpoch: sql`max(${schema.hostCredentialCleanupPending.runEpoch}, excluded.run_epoch)`,
|
|
112
|
+
reason: sql`CASE WHEN excluded.run_epoch >= ${schema.hostCredentialCleanupPending.runEpoch} THEN excluded.reason ELSE ${schema.hostCredentialCleanupPending.reason} END`,
|
|
113
|
+
updatedAt: now,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
.run();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function listCredentialCleanupPending(
|
|
120
|
+
db: InventoryExecutor = getDb(),
|
|
121
|
+
): HostCredentialCleanupPending[] {
|
|
122
|
+
return db
|
|
123
|
+
.select()
|
|
124
|
+
.from(schema.hostCredentialCleanupPending)
|
|
125
|
+
.orderBy(asc(schema.hostCredentialCleanupPending.taskId))
|
|
126
|
+
.all();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Clear only a marker no newer than the cleanup that just succeeded. */
|
|
130
|
+
export function forgetCredentialCleanupPending(
|
|
131
|
+
taskId: string,
|
|
132
|
+
throughRunEpoch: number,
|
|
133
|
+
db: InventoryExecutor = getDb(),
|
|
134
|
+
): void {
|
|
135
|
+
assertSafeHostTaskId(taskId);
|
|
136
|
+
positiveRunEpoch(throughRunEpoch);
|
|
137
|
+
db.delete(schema.hostCredentialCleanupPending)
|
|
138
|
+
.where(
|
|
139
|
+
and(
|
|
140
|
+
eq(schema.hostCredentialCleanupPending.taskId, taskId),
|
|
141
|
+
lte(schema.hostCredentialCleanupPending.runEpoch, throughRunEpoch),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
.run();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** An engine account's file, or the intermediate its writer stages
|
|
148
|
+
* (`.uai-tmp`) before renaming it into place. */
|
|
149
|
+
const ENGINE_AUTH_PATH = /^\/home\/node\/(?:\.codex(?:-acct-[A-Za-z0-9_-]+)?(?:-agent-[A-Za-z0-9_-]+)?|\.grok(?:-acct-[A-Za-z0-9_-]+)?|\.local\/share\/opencode(?:-acct-[A-Za-z0-9_-]+)?)\/auth\.json(?:\.uai-tmp)?$/;
|
|
150
|
+
/** A file a writer stages (`.uai-tmp`) before renaming it into place. */
|
|
151
|
+
function andStaged(...paths: string[]): string[] {
|
|
152
|
+
return paths.flatMap((path) => [path, `${path}.uai-tmp`]);
|
|
153
|
+
}
|
|
154
|
+
const GITHUB_PATHS = new Set(
|
|
155
|
+
andStaged("/home/node/.config/gh/hosts.yml", "/home/node/.config/gh/uai.gitconfig"),
|
|
156
|
+
);
|
|
157
|
+
const SSH_PATHS = new Set(
|
|
158
|
+
andStaged(
|
|
159
|
+
"/home/node/.ssh/id_ed25519",
|
|
160
|
+
"/home/node/.ssh/id_ed25519.pub",
|
|
161
|
+
"/home/node/.ssh/allowed_signers",
|
|
162
|
+
"/home/node/.ssh/uai-signing.gitconfig",
|
|
163
|
+
"/home/node/.ssh/uai-transport.gitconfig",
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
const FILES_SESSION_PATHS = new Set([
|
|
167
|
+
LEASE_CREDENTIALS_FILE,
|
|
168
|
+
LEASE_AWS_CONFIG_FILE,
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
/** Destructive cleanup accepts only lane-owned files. The executor repeats
|
|
172
|
+
* this check after reconstructing the runtime and resolves every parent
|
|
173
|
+
* symlink-safely before deletion; persistence never turns an arbitrary task
|
|
174
|
+
* path into cleanup authority. */
|
|
175
|
+
export function assertCredentialCleanupPath(
|
|
176
|
+
lane: CredentialCleanupLane,
|
|
177
|
+
value: string,
|
|
178
|
+
): void {
|
|
179
|
+
if (
|
|
180
|
+
typeof value !== "string" ||
|
|
181
|
+
value.length === 0 ||
|
|
182
|
+
value.length > 4096 ||
|
|
183
|
+
value.includes("\0") ||
|
|
184
|
+
value.split("/").some((part) => part === "." || part === "..")
|
|
185
|
+
) {
|
|
186
|
+
throw new Error("credential cleanup path is invalid");
|
|
187
|
+
}
|
|
188
|
+
const allowed =
|
|
189
|
+
(lane === "engine-auth" && ENGINE_AUTH_PATH.test(value)) ||
|
|
190
|
+
(lane === "github" && GITHUB_PATHS.has(value)) ||
|
|
191
|
+
(lane === "ssh" && SSH_PATHS.has(value)) ||
|
|
192
|
+
(lane === "files-session" && FILES_SESSION_PATHS.has(value));
|
|
193
|
+
if (!allowed) {
|
|
194
|
+
throw new Error("credential cleanup path is outside its managed lane");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizedKey(input: CredentialCleanupPathKey): {
|
|
199
|
+
taskId: string;
|
|
200
|
+
runEpoch: number;
|
|
201
|
+
runtimeLocator: string;
|
|
202
|
+
lane: CredentialCleanupLane;
|
|
203
|
+
path: string;
|
|
204
|
+
} {
|
|
205
|
+
assertSafeHostTaskId(input.taskId);
|
|
206
|
+
positiveRunEpoch(input.runEpoch);
|
|
207
|
+
assertCredentialCleanupPath(input.lane, input.path);
|
|
208
|
+
return {
|
|
209
|
+
taskId: input.taskId,
|
|
210
|
+
runEpoch: input.runEpoch,
|
|
211
|
+
runtimeLocator: serializeTaskEnvironmentLocator(input.runtime),
|
|
212
|
+
lane: input.lane,
|
|
213
|
+
path: input.path,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Record before writing the credential path. Re-recording the same exact
|
|
218
|
+
* runtime path is idempotent and preserves its first-seen timestamp. */
|
|
219
|
+
export function rememberCredentialCleanupPath(
|
|
220
|
+
input: CredentialCleanupPathInput,
|
|
221
|
+
db: InventoryExecutor = getDb(),
|
|
222
|
+
now = Date.now(),
|
|
223
|
+
): void {
|
|
224
|
+
// The shared path encoder has the contract's exact bounded id grammar.
|
|
225
|
+
idPathSegment(input.credentialId);
|
|
226
|
+
const key = normalizedKey(input);
|
|
227
|
+
db.insert(schema.hostCredentialCleanupPaths)
|
|
228
|
+
.values({
|
|
229
|
+
...key,
|
|
230
|
+
credentialId: input.credentialId,
|
|
231
|
+
createdAt: now,
|
|
232
|
+
updatedAt: now,
|
|
233
|
+
})
|
|
234
|
+
.onConflictDoUpdate({
|
|
235
|
+
target: [
|
|
236
|
+
schema.hostCredentialCleanupPaths.taskId,
|
|
237
|
+
schema.hostCredentialCleanupPaths.runEpoch,
|
|
238
|
+
schema.hostCredentialCleanupPaths.runtimeLocator,
|
|
239
|
+
schema.hostCredentialCleanupPaths.path,
|
|
240
|
+
],
|
|
241
|
+
set: { credentialId: input.credentialId, updatedAt: now },
|
|
242
|
+
})
|
|
243
|
+
.run();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** All outstanding cleanup work for a task, oldest assignment first. */
|
|
247
|
+
export function listCredentialCleanupPaths(
|
|
248
|
+
taskId: string,
|
|
249
|
+
db: InventoryExecutor = getDb(),
|
|
250
|
+
): HostCredentialCleanupPath[] {
|
|
251
|
+
assertSafeHostTaskId(taskId);
|
|
252
|
+
return db
|
|
253
|
+
.select()
|
|
254
|
+
.from(schema.hostCredentialCleanupPaths)
|
|
255
|
+
.where(eq(schema.hostCredentialCleanupPaths.taskId, taskId))
|
|
256
|
+
.orderBy(
|
|
257
|
+
asc(schema.hostCredentialCleanupPaths.runEpoch),
|
|
258
|
+
asc(schema.hostCredentialCleanupPaths.runtimeLocator),
|
|
259
|
+
asc(schema.hostCredentialCleanupPaths.path),
|
|
260
|
+
)
|
|
261
|
+
.all();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Boot recovery must not depend on a current manifest or even a surviving
|
|
265
|
+
* host-task row. Return the entire non-secret worklist in deterministic order. */
|
|
266
|
+
export function listAllCredentialCleanupPaths(
|
|
267
|
+
db: InventoryExecutor = getDb(),
|
|
268
|
+
): HostCredentialCleanupPath[] {
|
|
269
|
+
return db
|
|
270
|
+
.select()
|
|
271
|
+
.from(schema.hostCredentialCleanupPaths)
|
|
272
|
+
.orderBy(
|
|
273
|
+
asc(schema.hostCredentialCleanupPaths.taskId),
|
|
274
|
+
asc(schema.hostCredentialCleanupPaths.runEpoch),
|
|
275
|
+
asc(schema.hostCredentialCleanupPaths.runtimeLocator),
|
|
276
|
+
asc(schema.hostCredentialCleanupPaths.path),
|
|
277
|
+
)
|
|
278
|
+
.all();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Value-free boot worklist. A marker keeps a task recoverable even when a
|
|
283
|
+
* crash happened before its first path was inventoried, or after every path
|
|
284
|
+
* was deleted but before in-memory material was dropped.
|
|
285
|
+
*/
|
|
286
|
+
export function listCredentialCleanupRecoveryTasks(
|
|
287
|
+
db: InventoryExecutor = getDb(),
|
|
288
|
+
): Array<{ taskId: string; runEpoch: number }> {
|
|
289
|
+
const tasks = new Map<string, number>();
|
|
290
|
+
for (const pending of listCredentialCleanupPending(db)) {
|
|
291
|
+
tasks.set(pending.taskId, pending.runEpoch);
|
|
292
|
+
}
|
|
293
|
+
for (const row of listAllCredentialCleanupPaths(db)) {
|
|
294
|
+
tasks.set(row.taskId, Math.max(tasks.get(row.taskId) ?? 0, row.runEpoch));
|
|
295
|
+
}
|
|
296
|
+
// A leased engine on an env-token account leaves no path: the record of
|
|
297
|
+
// where it ran is the evidence its task needs a cleanup after a crash.
|
|
298
|
+
for (const row of listAllLeasedEngineRuntimes(db)) {
|
|
299
|
+
tasks.set(row.taskId, Math.max(tasks.get(row.taskId) ?? 0, row.runEpoch));
|
|
300
|
+
}
|
|
301
|
+
return [...tasks]
|
|
302
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
303
|
+
.map(([taskId, runEpoch]) => ({ taskId, runEpoch }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Persistence primitive for the later cleanup executor. The executor is
|
|
307
|
+
* responsible for proving absence before it calls this. */
|
|
308
|
+
export function forgetCredentialCleanupPath(
|
|
309
|
+
input: CredentialCleanupPathKey,
|
|
310
|
+
db: InventoryExecutor = getDb(),
|
|
311
|
+
): void {
|
|
312
|
+
const key = normalizedKey(input);
|
|
313
|
+
db.delete(schema.hostCredentialCleanupPaths)
|
|
314
|
+
.where(
|
|
315
|
+
and(
|
|
316
|
+
eq(schema.hostCredentialCleanupPaths.taskId, key.taskId),
|
|
317
|
+
eq(schema.hostCredentialCleanupPaths.runEpoch, key.runEpoch),
|
|
318
|
+
eq(
|
|
319
|
+
schema.hostCredentialCleanupPaths.runtimeLocator,
|
|
320
|
+
key.runtimeLocator,
|
|
321
|
+
),
|
|
322
|
+
eq(schema.hostCredentialCleanupPaths.lane, key.lane),
|
|
323
|
+
eq(schema.hostCredentialCleanupPaths.path, key.path),
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
.run();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* ADR-154 §6: record, before a leased engine starts in it, the runtime it
|
|
331
|
+
* starts in for this run, so the cleanup proves its engines stopped there
|
|
332
|
+
* even when they left no file to inventory. Idempotent.
|
|
333
|
+
*/
|
|
334
|
+
export function rememberLeasedEngineRuntime(
|
|
335
|
+
input: { taskId: string; runEpoch: number; runtime: TaskEnvironmentLocator },
|
|
336
|
+
db: InventoryExecutor = getDb(),
|
|
337
|
+
now = Date.now(),
|
|
338
|
+
): void {
|
|
339
|
+
assertSafeHostTaskId(input.taskId);
|
|
340
|
+
positiveRunEpoch(input.runEpoch);
|
|
341
|
+
db.insert(schema.hostLeasedEngineRuntimes)
|
|
342
|
+
.values({
|
|
343
|
+
taskId: input.taskId,
|
|
344
|
+
runEpoch: input.runEpoch,
|
|
345
|
+
runtimeLocator: serializeTaskEnvironmentLocator(input.runtime),
|
|
346
|
+
createdAt: now,
|
|
347
|
+
})
|
|
348
|
+
.onConflictDoNothing()
|
|
349
|
+
.run();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Every runtime a leased engine of the task was started in, oldest run first. */
|
|
353
|
+
export function listLeasedEngineRuntimes(
|
|
354
|
+
taskId: string,
|
|
355
|
+
db: InventoryExecutor = getDb(),
|
|
356
|
+
): HostLeasedEngineRuntime[] {
|
|
357
|
+
assertSafeHostTaskId(taskId);
|
|
358
|
+
return db
|
|
359
|
+
.select()
|
|
360
|
+
.from(schema.hostLeasedEngineRuntimes)
|
|
361
|
+
.where(eq(schema.hostLeasedEngineRuntimes.taskId, taskId))
|
|
362
|
+
.orderBy(
|
|
363
|
+
asc(schema.hostLeasedEngineRuntimes.runEpoch),
|
|
364
|
+
asc(schema.hostLeasedEngineRuntimes.runtimeLocator),
|
|
365
|
+
)
|
|
366
|
+
.all();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Every runtime a leased engine of any task was started in. */
|
|
370
|
+
export function listAllLeasedEngineRuntimes(
|
|
371
|
+
db: InventoryExecutor = getDb(),
|
|
372
|
+
): HostLeasedEngineRuntime[] {
|
|
373
|
+
return db
|
|
374
|
+
.select()
|
|
375
|
+
.from(schema.hostLeasedEngineRuntimes)
|
|
376
|
+
.orderBy(
|
|
377
|
+
asc(schema.hostLeasedEngineRuntimes.taskId),
|
|
378
|
+
asc(schema.hostLeasedEngineRuntimes.runEpoch),
|
|
379
|
+
asc(schema.hostLeasedEngineRuntimes.runtimeLocator),
|
|
380
|
+
)
|
|
381
|
+
.all();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** After a whole-task cleanup proved every engine of these runs stopped in
|
|
385
|
+
* these runtimes: forget them, never a newer run's. */
|
|
386
|
+
export function forgetLeasedEngineRuntimes(
|
|
387
|
+
taskId: string,
|
|
388
|
+
throughRunEpoch: number,
|
|
389
|
+
runtimeLocators: readonly string[],
|
|
390
|
+
db: InventoryExecutor = getDb(),
|
|
391
|
+
): void {
|
|
392
|
+
assertSafeHostTaskId(taskId);
|
|
393
|
+
positiveRunEpoch(throughRunEpoch);
|
|
394
|
+
for (const runtimeLocator of runtimeLocators) {
|
|
395
|
+
db.delete(schema.hostLeasedEngineRuntimes)
|
|
396
|
+
.where(
|
|
397
|
+
and(
|
|
398
|
+
eq(schema.hostLeasedEngineRuntimes.taskId, taskId),
|
|
399
|
+
lte(schema.hostLeasedEngineRuntimes.runEpoch, throughRunEpoch),
|
|
400
|
+
eq(schema.hostLeasedEngineRuntimes.runtimeLocator, runtimeLocator),
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
.run();
|
|
404
|
+
}
|
|
405
|
+
}
|