@moondeck/station 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,752 @@
|
|
|
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
|
+
forgetCredentialCleanupRestop,
|
|
16
|
+
listCredentialCleanupRecoveryTasks,
|
|
17
|
+
listCredentialCleanupPaths,
|
|
18
|
+
listCredentialCleanupRestops,
|
|
19
|
+
rememberCredentialCleanupPending,
|
|
20
|
+
rememberCredentialCleanupRestop,
|
|
21
|
+
type CredentialCleanupLane,
|
|
22
|
+
type CredentialCleanupPendingReason,
|
|
23
|
+
} from "./credential-cleanup-inventory";
|
|
24
|
+
import type { CredentialCleanupRequest as LeaseCredentialCleanupRequest } from "./cloud-credential-leases";
|
|
25
|
+
import type { Db } from "./db";
|
|
26
|
+
import { reconstructTaskEnvironment } from "./task-environment";
|
|
27
|
+
import {
|
|
28
|
+
parseTaskEnvironmentLocator,
|
|
29
|
+
type TaskEnvironmentLocator,
|
|
30
|
+
type TaskEnvironmentHandle,
|
|
31
|
+
} from "./task-environment/types";
|
|
32
|
+
|
|
33
|
+
const CLEANUP_TIMEOUT_MS = 10_000;
|
|
34
|
+
const CLEANUP_OUTPUT_BYTES = 4 * 1024;
|
|
35
|
+
|
|
36
|
+
export type TaskCredentialCleanupReason = CredentialCleanupPendingReason;
|
|
37
|
+
|
|
38
|
+
export interface TaskCredentialCleanupRequest {
|
|
39
|
+
taskId: string;
|
|
40
|
+
runEpoch: number;
|
|
41
|
+
reason: TaskCredentialCleanupReason;
|
|
42
|
+
/** Absent means every credential and every recovered epoch for the task. */
|
|
43
|
+
credentialIds?: readonly string[];
|
|
44
|
+
/** The lease registry's own request this one carries out, with its exact
|
|
45
|
+
* qualified identities (class, id, revision), reason and Files scope, for
|
|
46
|
+
* the hooks; absent for one the coordinator starts itself (boot
|
|
47
|
+
* recovery). The durable inventory keys on `credentialIds` alone. */
|
|
48
|
+
lease?: Readonly<LeaseCredentialCleanupRequest>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TaskCredentialCleanupHooks {
|
|
52
|
+
/** These two hooks must be synchronous and idempotent. */
|
|
53
|
+
refuseNewWork(request: TaskCredentialCleanupRequest): void;
|
|
54
|
+
closeGate(request: TaskCredentialCleanupRequest): void;
|
|
55
|
+
drainWriters(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
56
|
+
quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
57
|
+
/** Retire process-backed consumers after leased engine agents have
|
|
58
|
+
* quiesced. Other task processes may still hold Files; a strict unmount
|
|
59
|
+
* which cannot prove them gone is terminal machine retirement. Their
|
|
60
|
+
* synchronous gate-close hook has already made Files unavailable to new
|
|
61
|
+
* work. */
|
|
62
|
+
closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
|
|
63
|
+
dropMemory(request: TaskCredentialCleanupRequest): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TaskCredentialCleanupDeps {
|
|
67
|
+
db?: Db;
|
|
68
|
+
hooks: TaskCredentialCleanupHooks;
|
|
69
|
+
removePath?: (row: HostCredentialCleanupPath) => Promise<void>;
|
|
70
|
+
/** Full cleanup under the cloud's taskDown (`reason: "teardown"`) only:
|
|
71
|
+
* destroy one exact runtime and prove it absent. */
|
|
72
|
+
destroyRuntime?: (request: {
|
|
73
|
+
taskId: string;
|
|
74
|
+
runEpoch: number;
|
|
75
|
+
runtime: TaskEnvironmentLocator;
|
|
76
|
+
}) => Promise<boolean>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class TaskCredentialCleanupError extends Error {
|
|
80
|
+
constructor(
|
|
81
|
+
readonly taskId: string,
|
|
82
|
+
readonly failedPaths: readonly string[],
|
|
83
|
+
/** Why the runtime could not be reached for them, when known (a stopped
|
|
84
|
+
* container refused for cleanup, say): host.log's only clue. */
|
|
85
|
+
reason?: unknown,
|
|
86
|
+
) {
|
|
87
|
+
super(
|
|
88
|
+
`credential cleanup for task ${taskId} could not prove ${String(failedPaths.length)} path(s) absent` +
|
|
89
|
+
(reason === undefined
|
|
90
|
+
? ""
|
|
91
|
+
: `: ${(reason instanceof Error ? reason.message : String(reason)).slice(0, 300)}`),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function execCleanup(
|
|
97
|
+
environment: Pick<TaskEnvironmentHandle, "exec">,
|
|
98
|
+
argv: [string, ...string[]],
|
|
99
|
+
stdin?: Uint8Array,
|
|
100
|
+
): Promise<{ exitCode: number | null; stdout: string }> {
|
|
101
|
+
const result = await environment.exec({
|
|
102
|
+
argv,
|
|
103
|
+
user: "root",
|
|
104
|
+
env: {
|
|
105
|
+
LD_AUDIT: "",
|
|
106
|
+
LD_LIBRARY_PATH: "",
|
|
107
|
+
LD_PRELOAD: "",
|
|
108
|
+
PYTHONHOME: "",
|
|
109
|
+
PYTHONPATH: "",
|
|
110
|
+
},
|
|
111
|
+
projectEnvProfile: "maintenance",
|
|
112
|
+
timeoutMs: CLEANUP_TIMEOUT_MS,
|
|
113
|
+
maxOutputBytes: CLEANUP_OUTPUT_BYTES,
|
|
114
|
+
...(stdin === undefined ? {} : { stdin }),
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
exitCode: result.exitCode,
|
|
118
|
+
stdout: Buffer.from(result.stdout).toString("utf8"),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One root process performs traversal, unlink and the absence proof through
|
|
124
|
+
* directory file descriptors. No task-owned parent is re-resolved after it is
|
|
125
|
+
* checked, so renaming it or replacing it with a symlink cannot redirect the
|
|
126
|
+
* root deletion. The program itself is streamed over stdin; no shell parses
|
|
127
|
+
* either it or the validated path.
|
|
128
|
+
*/
|
|
129
|
+
export const CREDENTIAL_UNLINK_HELPER = String.raw`
|
|
130
|
+
import os
|
|
131
|
+
import sys
|
|
132
|
+
import shutil
|
|
133
|
+
import stat
|
|
134
|
+
|
|
135
|
+
root, absolute = sys.argv[1:3]
|
|
136
|
+
directory = len(sys.argv) == 4 and sys.argv[3] == "directory"
|
|
137
|
+
prefix = root.rstrip("/") + "/"
|
|
138
|
+
if not absolute.startswith(prefix):
|
|
139
|
+
raise RuntimeError("credential path escaped its root")
|
|
140
|
+
parts = absolute[len(prefix):].split("/")
|
|
141
|
+
if not parts or any(part in ("", ".", "..") for part in parts):
|
|
142
|
+
raise RuntimeError("credential path is not canonical")
|
|
143
|
+
|
|
144
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
145
|
+
opened = []
|
|
146
|
+
try:
|
|
147
|
+
try:
|
|
148
|
+
current = os.open(root, flags)
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
sys.exit(0)
|
|
151
|
+
opened.append(current)
|
|
152
|
+
for component in parts[:-1]:
|
|
153
|
+
try:
|
|
154
|
+
current = os.open(component, flags, dir_fd=current)
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
sys.exit(0)
|
|
157
|
+
opened.append(current)
|
|
158
|
+
leaf = parts[-1]
|
|
159
|
+
try:
|
|
160
|
+
if directory:
|
|
161
|
+
info = os.stat(leaf, dir_fd=current, follow_symlinks=False)
|
|
162
|
+
if not stat.S_ISDIR(info.st_mode) or not shutil.rmtree.avoids_symlink_attacks:
|
|
163
|
+
raise RuntimeError("credential directory cannot be traversed safely")
|
|
164
|
+
shutil.rmtree(leaf, dir_fd=current)
|
|
165
|
+
else:
|
|
166
|
+
os.unlink(leaf, dir_fd=current)
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
pass
|
|
169
|
+
try:
|
|
170
|
+
os.stat(leaf, dir_fd=current, follow_symlinks=False)
|
|
171
|
+
except FileNotFoundError:
|
|
172
|
+
sys.exit(0)
|
|
173
|
+
raise RuntimeError("credential path still exists")
|
|
174
|
+
finally:
|
|
175
|
+
for descriptor in reversed(opened):
|
|
176
|
+
os.close(descriptor)
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
/** Host-side form of the same confined primitive. The fixed, isolated
|
|
180
|
+
* interpreter is a runner dependency (and is also baked into task machines);
|
|
181
|
+
* neither PATH, Python environment/site injection nor a shell participates. */
|
|
182
|
+
export async function removeHostCredentialFile(
|
|
183
|
+
root: string,
|
|
184
|
+
path: string,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const result = await new Promise<{ exitCode: number | null }>(
|
|
187
|
+
(resolve, reject) => {
|
|
188
|
+
const child = spawn(
|
|
189
|
+
"/usr/bin/python3",
|
|
190
|
+
["-I", "-", root, path],
|
|
191
|
+
{
|
|
192
|
+
cwd: "/",
|
|
193
|
+
env: {},
|
|
194
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
const timeout = setTimeout(() => {
|
|
198
|
+
child.kill("SIGKILL");
|
|
199
|
+
}, CLEANUP_TIMEOUT_MS);
|
|
200
|
+
child.once("error", (error) => {
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
reject(error);
|
|
203
|
+
});
|
|
204
|
+
child.once("close", (exitCode) => {
|
|
205
|
+
clearTimeout(timeout);
|
|
206
|
+
resolve({ exitCode });
|
|
207
|
+
});
|
|
208
|
+
child.stdin.on("error", () => undefined);
|
|
209
|
+
child.stdin.end(Buffer.from(CREDENTIAL_UNLINK_HELPER, "utf8"));
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
if (result.exitCode !== 0) {
|
|
213
|
+
throw new Error("station credential path could not be proven absent");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function removeLocalFile(path: string): Promise<void> {
|
|
218
|
+
if (!path.startsWith("/run/uai/")) {
|
|
219
|
+
throw new Error("station credential path is outside the managed root");
|
|
220
|
+
}
|
|
221
|
+
await removeHostCredentialFile("/run/uai", path);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function removeRuntimeCredentialFile(
|
|
225
|
+
environment: Pick<TaskEnvironmentHandle, "exec">,
|
|
226
|
+
path: string,
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
if (!path.startsWith("/home/node/")) {
|
|
229
|
+
throw new Error("runtime credential path is outside the task home");
|
|
230
|
+
}
|
|
231
|
+
const removed = await execCleanup(
|
|
232
|
+
environment,
|
|
233
|
+
["/usr/bin/python3", "-I", "-", "/home/node", path,
|
|
234
|
+
...(/^\/home\/node\/\.uai-connection\/[a-f0-9]{64}$/.test(path) ? ["directory"] : [])],
|
|
235
|
+
Buffer.from(CREDENTIAL_UNLINK_HELPER, "utf8"),
|
|
236
|
+
);
|
|
237
|
+
if (removed.exitCode !== 0) {
|
|
238
|
+
throw new Error("credential path could not be proven absent");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Delete one validated file, or the confined connection home owning it. */
|
|
243
|
+
export async function removeCredentialCleanupPath(
|
|
244
|
+
row: HostCredentialCleanupPath,
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
assertCredentialCleanupPath(
|
|
247
|
+
row.lane as CredentialCleanupLane,
|
|
248
|
+
row.path,
|
|
249
|
+
);
|
|
250
|
+
if (row.lane === "files-session") {
|
|
251
|
+
await removeLocalFile(row.path);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const environment = await reconstructCleanupRuntime(row.taskId, row.runtimeLocator);
|
|
255
|
+
await removeRuntimeCredentialFile(environment, row.path);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function reconstructCleanupRuntime(
|
|
259
|
+
taskId: string,
|
|
260
|
+
runtimeLocator: string,
|
|
261
|
+
): Promise<TaskEnvironmentHandle<unknown>> {
|
|
262
|
+
const locator = parseTaskEnvironmentLocator(runtimeLocator);
|
|
263
|
+
const environment = await reconstructTaskEnvironment(locator, "loader");
|
|
264
|
+
if (environment.descriptor.taskId !== taskId) {
|
|
265
|
+
throw new Error("cleanup runtime belongs to a different task");
|
|
266
|
+
}
|
|
267
|
+
return environment;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The exact paths of one runtime whose removal failed, tried once more
|
|
272
|
+
* against that runtime as it is found (never teardown's: its destroy
|
|
273
|
+
* fallback is its own). Returns the rows now proven absent.
|
|
274
|
+
* - `absent`, exactly: the runtime, its writable layer with it, is gone, and
|
|
275
|
+
* so is every path in it. Any other answer, `unknown` above all, is no
|
|
276
|
+
* proof (a failed call never proves absence, 2026-10-20).
|
|
277
|
+
* - `stopped`, inside the boot sweep only: `docker exec` cannot reach a
|
|
278
|
+
* stopped container, and after a reboot every one is, so the sweep failed
|
|
279
|
+
* for good and the runner stayed in `checking` (2026-10-30). The runtime
|
|
280
|
+
* is started for the removal alone and stopped again
|
|
281
|
+
* (`runStoppedForCleanup`), its stop owed durably before the start
|
|
282
|
+
* (`host_credential_cleanup_restops`, settled by `#settleOwedRestops`).
|
|
283
|
+
* Only the boot sweep may. While it runs, nothing else starts a task's
|
|
284
|
+
* runtime: TaskUp and Stop refuse until the runtime is operational, and
|
|
285
|
+
* the task's other cleanups queue on the coordinator's per-task tail. A
|
|
286
|
+
* cloud taskDown is the one that does not wait: it needs only a pinned
|
|
287
|
+
* backend, skips this coordinator while credentials are pending, and may
|
|
288
|
+
* remove the started container mid-cleanup. That fails closed: an exec
|
|
289
|
+
* into a removed container proves nothing, and a container proven gone
|
|
290
|
+
* settles its owed stop.
|
|
291
|
+
* - anything else (`running`, frozen, `unknown`, a provider that cannot
|
|
292
|
+
* start one for cleanup): the failure stands.
|
|
293
|
+
*/
|
|
294
|
+
async function retryFailedRuntimePaths(
|
|
295
|
+
taskId: string,
|
|
296
|
+
runtimeLocator: string,
|
|
297
|
+
rows: readonly HostCredentialCleanupPath[],
|
|
298
|
+
startStopped: boolean,
|
|
299
|
+
oweStop: (containerId: string) => void,
|
|
300
|
+
): Promise<{ proven: HostCredentialCleanupPath[]; problem?: unknown }> {
|
|
301
|
+
const environment = await reconstructCleanupRuntime(taskId, runtimeLocator);
|
|
302
|
+
const status = await environment.status();
|
|
303
|
+
if (status.state === "absent") return { proven: [...rows] };
|
|
304
|
+
if (
|
|
305
|
+
!startStopped ||
|
|
306
|
+
status.state !== "stopped" ||
|
|
307
|
+
status.frozen === true ||
|
|
308
|
+
status.instances.length === 0 ||
|
|
309
|
+
environment.runStoppedForCleanup === undefined
|
|
310
|
+
) {
|
|
311
|
+
return { proven: [] };
|
|
312
|
+
}
|
|
313
|
+
const proven: HostCredentialCleanupPath[] = [];
|
|
314
|
+
try {
|
|
315
|
+
await environment.runStoppedForCleanup(async () => {
|
|
316
|
+
for (const row of rows) {
|
|
317
|
+
try {
|
|
318
|
+
assertCredentialCleanupPath(row.lane as CredentialCleanupLane, row.path);
|
|
319
|
+
await removeRuntimeCredentialFile(environment, row.path);
|
|
320
|
+
proven.push(row);
|
|
321
|
+
} catch {
|
|
322
|
+
// This path's failure stands; the others are still tried.
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}, oweStop);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
// Not started, or not proven stopped again. What the helper proved
|
|
328
|
+
// absent is still absent; the cleanup fails all the same.
|
|
329
|
+
return { proven, problem: error };
|
|
330
|
+
}
|
|
331
|
+
return { proven };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function destroyCredentialRuntime(request: {
|
|
335
|
+
taskId: string;
|
|
336
|
+
runEpoch: number;
|
|
337
|
+
runtime: TaskEnvironmentLocator;
|
|
338
|
+
}): Promise<boolean> {
|
|
339
|
+
const environment = await reconstructTaskEnvironment(request.runtime, "loader");
|
|
340
|
+
if (environment.descriptor.taskId !== request.taskId) {
|
|
341
|
+
throw new Error("cleanup runtime belongs to a different task");
|
|
342
|
+
}
|
|
343
|
+
await environment.teardown();
|
|
344
|
+
const status = await environment.status();
|
|
345
|
+
return status.state === "absent";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function selectRows(
|
|
349
|
+
rows: readonly HostCredentialCleanupPath[],
|
|
350
|
+
request: TaskCredentialCleanupRequest,
|
|
351
|
+
): HostCredentialCleanupPath[] {
|
|
352
|
+
const throughEpoch = rows.filter((row) => row.runEpoch <= request.runEpoch);
|
|
353
|
+
if (request.credentialIds === undefined) return throughEpoch;
|
|
354
|
+
const ids = new Set(request.credentialIds);
|
|
355
|
+
if (ids.size !== request.credentialIds.length || ids.size === 0) {
|
|
356
|
+
throw new Error("selective credential cleanup ids must be unique and nonempty");
|
|
357
|
+
}
|
|
358
|
+
return throughEpoch.filter((row) => ids.has(row.credentialId));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function isSamePhysicalPath(
|
|
362
|
+
left: HostCredentialCleanupPath,
|
|
363
|
+
right: HostCredentialCleanupPath,
|
|
364
|
+
): boolean {
|
|
365
|
+
// Files-session paths live on the host, so a recreated task runtime still
|
|
366
|
+
// points at the same /run/uai file. Every other managed path lives inside
|
|
367
|
+
// one exact runtime and is identified by that locator plus its path.
|
|
368
|
+
if (left.lane === "files-session" || right.lane === "files-session") {
|
|
369
|
+
return (
|
|
370
|
+
left.lane === "files-session" &&
|
|
371
|
+
right.lane === "files-session" &&
|
|
372
|
+
left.path === right.path
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return (
|
|
376
|
+
left.runtimeLocator === right.runtimeLocator && left.path === right.path
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function forgetRow(row: HostCredentialCleanupPath, db?: Db): void {
|
|
381
|
+
forgetCredentialCleanupPath(
|
|
382
|
+
{
|
|
383
|
+
taskId: row.taskId,
|
|
384
|
+
runEpoch: row.runEpoch,
|
|
385
|
+
runtime: parseTaskEnvironmentLocator(row.runtimeLocator),
|
|
386
|
+
lane: row.lane as CredentialCleanupLane,
|
|
387
|
+
path: row.path,
|
|
388
|
+
},
|
|
389
|
+
db,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Serializes cleanup per task and never reopens a gate on failure. */
|
|
394
|
+
export class TaskCredentialCleanupCoordinator {
|
|
395
|
+
/** The serialized state retains the highest failed epoch until an equal or
|
|
396
|
+
* newer full cleanup succeeds. This prevents a delayed lower-epoch cleanup,
|
|
397
|
+
* or an unrelated selective success, from clearing the crash witness. */
|
|
398
|
+
readonly #tails = new Map<
|
|
399
|
+
string,
|
|
400
|
+
Promise<{ failedEpoch: number; clearThroughEpoch: number }>
|
|
401
|
+
>();
|
|
402
|
+
readonly #deps: TaskCredentialCleanupDeps;
|
|
403
|
+
|
|
404
|
+
constructor(deps: TaskCredentialCleanupDeps) {
|
|
405
|
+
this.#deps = deps;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
cleanup(request: TaskCredentialCleanupRequest): Promise<void> {
|
|
409
|
+
return this.#cleanup(request, false);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** `startStopped`: the boot sweep's own cleanups (recoverAll) alone may
|
|
413
|
+
* start a stopped runtime to clean it (retryFailedRuntimePaths). */
|
|
414
|
+
#cleanup(request: TaskCredentialCleanupRequest, startStopped: boolean): Promise<void> {
|
|
415
|
+
if (!Number.isSafeInteger(request.runEpoch) || request.runEpoch < 1) {
|
|
416
|
+
return Promise.reject(new Error("cleanup run epoch must be positive"));
|
|
417
|
+
}
|
|
418
|
+
if (request.credentialIds !== undefined) {
|
|
419
|
+
const ids = new Set(request.credentialIds);
|
|
420
|
+
if (ids.size !== request.credentialIds.length || ids.size === 0) {
|
|
421
|
+
return Promise.reject(
|
|
422
|
+
new Error("selective credential cleanup ids must be unique and nonempty"),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const prior =
|
|
427
|
+
this.#tails.get(request.taskId) ??
|
|
428
|
+
Promise.resolve({ failedEpoch: 0, clearThroughEpoch: 0 });
|
|
429
|
+
let hooksFailed = false;
|
|
430
|
+
let hookError: unknown;
|
|
431
|
+
const recordFailure = (error: unknown): void => {
|
|
432
|
+
if (!hooksFailed) hookError = error;
|
|
433
|
+
hooksFailed = true;
|
|
434
|
+
};
|
|
435
|
+
try {
|
|
436
|
+
// This witness precedes even the synchronous gate hooks. A crash at any
|
|
437
|
+
// later instruction therefore makes boot recovery run the full cleanup.
|
|
438
|
+
rememberCredentialCleanupPending(request, this.#deps.db);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
recordFailure(error);
|
|
441
|
+
}
|
|
442
|
+
// Both fail-closed hooks get their chance even if the other hook (or the
|
|
443
|
+
// durable write) fails. Their first failure remains the returned cause.
|
|
444
|
+
try {
|
|
445
|
+
this.#deps.hooks.refuseNewWork(request);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
recordFailure(error);
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
this.#deps.hooks.closeGate(request);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
recordFailure(error);
|
|
453
|
+
}
|
|
454
|
+
const outcome = prior.then(async (state) => {
|
|
455
|
+
try {
|
|
456
|
+
if (hooksFailed) throw hookError;
|
|
457
|
+
await this.#run(request, startStopped);
|
|
458
|
+
return {
|
|
459
|
+
state: {
|
|
460
|
+
failedEpoch:
|
|
461
|
+
request.credentialIds === undefined &&
|
|
462
|
+
request.runEpoch >= state.failedEpoch
|
|
463
|
+
? 0
|
|
464
|
+
: state.failedEpoch,
|
|
465
|
+
clearThroughEpoch: Math.max(
|
|
466
|
+
state.clearThroughEpoch,
|
|
467
|
+
request.runEpoch,
|
|
468
|
+
),
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
} catch (error) {
|
|
472
|
+
return {
|
|
473
|
+
state: {
|
|
474
|
+
failedEpoch: Math.max(state.failedEpoch, request.runEpoch),
|
|
475
|
+
clearThroughEpoch: state.clearThroughEpoch,
|
|
476
|
+
},
|
|
477
|
+
error,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
const tail = outcome.then((result) => result.state);
|
|
482
|
+
this.#tails.set(request.taskId, tail);
|
|
483
|
+
const run = outcome.then((result) => {
|
|
484
|
+
if (
|
|
485
|
+
this.#tails.get(request.taskId) === tail &&
|
|
486
|
+
result.state.failedEpoch === 0
|
|
487
|
+
) {
|
|
488
|
+
forgetCredentialCleanupPending(
|
|
489
|
+
request.taskId,
|
|
490
|
+
result.state.clearThroughEpoch,
|
|
491
|
+
this.#deps.db,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if ("error" in result) throw result.error;
|
|
495
|
+
});
|
|
496
|
+
void tail.then((state) => {
|
|
497
|
+
if (
|
|
498
|
+
state.failedEpoch === 0 &&
|
|
499
|
+
this.#tails.get(request.taskId) === tail
|
|
500
|
+
) {
|
|
501
|
+
this.#tails.delete(request.taskId);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
return run;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async #run(request: TaskCredentialCleanupRequest, startStopped: boolean): Promise<void> {
|
|
508
|
+
await this.#deps.hooks.drainWriters(request);
|
|
509
|
+
await this.#deps.hooks.quiesceProcesses(request);
|
|
510
|
+
let consumersFailed = false;
|
|
511
|
+
let consumerError: unknown;
|
|
512
|
+
try {
|
|
513
|
+
await this.#deps.hooks.closeConsumers(request);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
// A strict Files unmount can fail (for example, EBUSY) after every gate
|
|
516
|
+
// and writer has closed. The root-only session material must still be
|
|
517
|
+
// removed below. Keep its inventory rows and the pending-cleanup row as
|
|
518
|
+
// durable mount witnesses, then return this original proof failure so a
|
|
519
|
+
// later process retries the unmount from the recorded runtime locator.
|
|
520
|
+
consumersFailed = true;
|
|
521
|
+
consumerError = error;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// A container an earlier sweep started for cleanup and never proved
|
|
525
|
+
// stopped again (a failed stop, a crash after the start, a start that
|
|
526
|
+
// timed out yet started it) is stopped, or proven stopped or gone,
|
|
527
|
+
// before this cleanup clears anything; until then nothing is.
|
|
528
|
+
const owed = await this.#settleOwedRestops(request.taskId);
|
|
529
|
+
if (owed.unsettled) throw owed.problem;
|
|
530
|
+
|
|
531
|
+
const allRows = listCredentialCleanupPaths(request.taskId, this.#deps.db);
|
|
532
|
+
const selectedRows = selectRows(allRows, request);
|
|
533
|
+
const selected = new Set(selectedRows);
|
|
534
|
+
const rows: HostCredentialCleanupPath[] = [];
|
|
535
|
+
for (const row of selectedRows) {
|
|
536
|
+
const transferred = allRows.some(
|
|
537
|
+
(candidate) =>
|
|
538
|
+
candidate.runEpoch > row.runEpoch &&
|
|
539
|
+
!selected.has(candidate) &&
|
|
540
|
+
isSamePhysicalPath(candidate, row),
|
|
541
|
+
);
|
|
542
|
+
if (!transferred) {
|
|
543
|
+
rows.push(row);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
// Gates are closed and writers/processes have drained before this
|
|
547
|
+
// serialized inventory observation. The newer row is therefore the
|
|
548
|
+
// durable ownership witness for the reused physical path: retire only
|
|
549
|
+
// the obsolete row, never unlink the newer run's file.
|
|
550
|
+
forgetRow(row, this.#deps.db);
|
|
551
|
+
}
|
|
552
|
+
let failed: HostCredentialCleanupPath[] = [];
|
|
553
|
+
// A consumer failure means some non-Files process may still hold its
|
|
554
|
+
// credential. Preserve every such path and its witness for the retry.
|
|
555
|
+
// Files is the exception: its root-only session must be revoked even
|
|
556
|
+
// when the already-hidden mount cannot yet be proven absent.
|
|
557
|
+
const removableRows = consumersFailed
|
|
558
|
+
? rows.filter((row) => row.lane === "files-session")
|
|
559
|
+
: rows;
|
|
560
|
+
for (const row of removableRows) {
|
|
561
|
+
try {
|
|
562
|
+
await (this.#deps.removePath ?? removeCredentialCleanupPath)(row);
|
|
563
|
+
if (!consumersFailed || row.lane !== "files-session") {
|
|
564
|
+
forgetRow(row, this.#deps.db);
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
failed.push(row);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// A path that could not be removed in its runtime as it is: once more,
|
|
572
|
+
// against that runtime as found (absent, or stopped in the boot sweep),
|
|
573
|
+
// never for teardown, whose destroy fallback below is its own.
|
|
574
|
+
let runtimeProblem: unknown;
|
|
575
|
+
let runtimeProblemSeen = false;
|
|
576
|
+
if (!consumersFailed && request.reason !== "teardown" && failed.length > 0) {
|
|
577
|
+
const byRuntime = new Map<string, HostCredentialCleanupPath[]>();
|
|
578
|
+
for (const row of failed) {
|
|
579
|
+
if (row.lane === "files-session") continue;
|
|
580
|
+
const group = byRuntime.get(row.runtimeLocator) ?? [];
|
|
581
|
+
group.push(row);
|
|
582
|
+
byRuntime.set(row.runtimeLocator, group);
|
|
583
|
+
}
|
|
584
|
+
const proven = new Set<HostCredentialCleanupPath>();
|
|
585
|
+
for (const [runtimeLocator, runtimeRows] of byRuntime) {
|
|
586
|
+
try {
|
|
587
|
+
const retried = await retryFailedRuntimePaths(
|
|
588
|
+
request.taskId,
|
|
589
|
+
runtimeLocator,
|
|
590
|
+
runtimeRows,
|
|
591
|
+
startStopped,
|
|
592
|
+
(containerId) =>
|
|
593
|
+
rememberCredentialCleanupRestop(
|
|
594
|
+
{
|
|
595
|
+
taskId: request.taskId,
|
|
596
|
+
runEpoch: request.runEpoch,
|
|
597
|
+
runtimeLocator,
|
|
598
|
+
containerId,
|
|
599
|
+
},
|
|
600
|
+
this.#deps.db,
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
for (const row of retried.proven) {
|
|
604
|
+
forgetRow(row, this.#deps.db);
|
|
605
|
+
proven.add(row);
|
|
606
|
+
}
|
|
607
|
+
if ("problem" in retried && !runtimeProblemSeen) {
|
|
608
|
+
runtimeProblemSeen = true;
|
|
609
|
+
runtimeProblem = retried.problem;
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
// No status, or no runtime to ask: the exact-path failure stands.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
failed = failed.filter((row) => !proven.has(row));
|
|
616
|
+
// Each start above owed its stop before it happened; a stop not proven
|
|
617
|
+
// leaves it owed, and fails this cleanup.
|
|
618
|
+
const settled = await this.#settleOwedRestops(request.taskId);
|
|
619
|
+
if (settled.unsettled && !runtimeProblemSeen) {
|
|
620
|
+
runtimeProblemSeen = true;
|
|
621
|
+
runtimeProblem = settled.problem;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// A whole-task cleanup may prove absence by destroying the exact writable
|
|
626
|
+
// runtime instead, but ONLY inside the cloud's taskDown of this run (the
|
|
627
|
+
// durable reason `teardown` has no other source). Every other cleanup —
|
|
628
|
+
// boot recovery, a fence escalation, stop, park, assignment — defers: the
|
|
629
|
+
// gate stays closed, the pending witness and inventory rows stay, and the
|
|
630
|
+
// registry's retry tries the exact paths again later. A failed exec is
|
|
631
|
+
// not proof of absence; Docker briefly gone (an OrbStack update,
|
|
632
|
+
// 2026-10-20) once turned this fallback into task-down.sh on every live
|
|
633
|
+
// task, deleting their worktrees. Selective removal must never take this
|
|
634
|
+
// broader action either.
|
|
635
|
+
if (
|
|
636
|
+
!consumersFailed &&
|
|
637
|
+
request.reason === "teardown" &&
|
|
638
|
+
request.credentialIds === undefined &&
|
|
639
|
+
failed.length > 0
|
|
640
|
+
) {
|
|
641
|
+
const byRuntime = new Map<string, HostCredentialCleanupPath[]>();
|
|
642
|
+
for (const row of failed) {
|
|
643
|
+
if (row.lane === "files-session") continue;
|
|
644
|
+
const group = byRuntime.get(row.runtimeLocator) ?? [];
|
|
645
|
+
group.push(row);
|
|
646
|
+
byRuntime.set(row.runtimeLocator, group);
|
|
647
|
+
}
|
|
648
|
+
const proven = new Set<HostCredentialCleanupPath>();
|
|
649
|
+
for (const [runtimeLocator, runtimeRows] of byRuntime) {
|
|
650
|
+
// A locator may have been reused by a later assignment. Destroying it
|
|
651
|
+
// for delayed cleanup would then erase the newer run's credential
|
|
652
|
+
// files. Exact deletion may still proceed; the broad fallback may not.
|
|
653
|
+
if (
|
|
654
|
+
allRows.some(
|
|
655
|
+
(row) =>
|
|
656
|
+
row.runtimeLocator === runtimeLocator &&
|
|
657
|
+
row.runEpoch > request.runEpoch,
|
|
658
|
+
)
|
|
659
|
+
) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const runtime = parseTaskEnvironmentLocator(runtimeLocator);
|
|
664
|
+
const absent = await (
|
|
665
|
+
this.#deps.destroyRuntime ?? destroyCredentialRuntime
|
|
666
|
+
)({
|
|
667
|
+
taskId: request.taskId,
|
|
668
|
+
runEpoch: request.runEpoch,
|
|
669
|
+
runtime,
|
|
670
|
+
});
|
|
671
|
+
if (!absent) continue;
|
|
672
|
+
for (const row of runtimeRows) {
|
|
673
|
+
forgetRow(row, this.#deps.db);
|
|
674
|
+
proven.add(row);
|
|
675
|
+
}
|
|
676
|
+
} catch {
|
|
677
|
+
// The original exact-path failure remains authoritative.
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
failed = failed.filter((row) => !proven.has(row));
|
|
681
|
+
}
|
|
682
|
+
if (consumersFailed) throw consumerError;
|
|
683
|
+
if (failed.length > 0) {
|
|
684
|
+
throw new TaskCredentialCleanupError(
|
|
685
|
+
request.taskId,
|
|
686
|
+
failed.map((row) => row.path),
|
|
687
|
+
runtimeProblemSeen ? runtimeProblem : undefined,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
if (runtimeProblemSeen) throw runtimeProblem;
|
|
691
|
+
this.#deps.hooks.dropMemory(request);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Every re-stop the task is owed, tried; each proven retired. Only proof
|
|
695
|
+
* that the exact container is stopped or gone retires one: a removed
|
|
696
|
+
* credential path never does. */
|
|
697
|
+
async #settleOwedRestops(
|
|
698
|
+
taskId: string,
|
|
699
|
+
): Promise<{ unsettled: false } | { unsettled: true; problem: unknown }> {
|
|
700
|
+
let problem: { unsettled: true; problem: unknown } | null = null;
|
|
701
|
+
for (const owed of listCredentialCleanupRestops(taskId, this.#deps.db)) {
|
|
702
|
+
try {
|
|
703
|
+
const environment = await reconstructCleanupRuntime(taskId, owed.runtimeLocator);
|
|
704
|
+
if (environment.settleCleanupStart === undefined) {
|
|
705
|
+
throw new Error("the runtime cannot prove a container it started for cleanup stopped again");
|
|
706
|
+
}
|
|
707
|
+
if (!(await environment.settleCleanupStart(owed.containerId))) {
|
|
708
|
+
throw new Error(
|
|
709
|
+
`the container ${owed.containerId.slice(0, 12)} started for cleanup is owed its stop and could not be proven stopped`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
forgetCredentialCleanupRestop(owed, this.#deps.db);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
problem ??= { unsettled: true, problem: error };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return problem ?? { unsettled: false };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Every cleanup under way, and any that registers meanwhile, settled
|
|
721
|
+
* (process shutdown). */
|
|
722
|
+
async whenIdle(): Promise<void> {
|
|
723
|
+
for (;;) {
|
|
724
|
+
const tails = [...this.#tails.values()];
|
|
725
|
+
await Promise.allSettled(tails);
|
|
726
|
+
const now = [...this.#tails.values()];
|
|
727
|
+
if (now.length === tails.length && now.every((tail, index) => tail === tails[index])) return;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Boot recovery sweeps every durable task before any grant may be asked.
|
|
732
|
+
* Every task is attempted, one failing never leaving the next unswept;
|
|
733
|
+
* then any failure, all of them, fails the sweep. */
|
|
734
|
+
async recoverAll(runEpochForTask: (taskId: string) => number): Promise<void> {
|
|
735
|
+
const tasks = listCredentialCleanupRecoveryTasks(this.#deps.db);
|
|
736
|
+
const failures: unknown[] = [];
|
|
737
|
+
for (const task of tasks) {
|
|
738
|
+
try {
|
|
739
|
+
await this.#cleanup({
|
|
740
|
+
taskId: task.taskId,
|
|
741
|
+
runEpoch: Math.max(task.runEpoch, runEpochForTask(task.taskId)),
|
|
742
|
+
reason: "restart",
|
|
743
|
+
}, true);
|
|
744
|
+
} catch (error) {
|
|
745
|
+
failures.push(error);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (failures.length > 0) {
|
|
749
|
+
throw new AggregateError(failures, `credential recovery failed for ${String(failures.length)} task(s)`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|