@moondeck/station 0.0.1 → 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 +115 -8
- 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
- package/bin/moondeck-station.mjs +0 -3
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-133 shared files on a task machine: S3 prefixes mounted with Mountpoint
|
|
3
|
+
* for Amazon S3 (`mount-s3`) at /workspace/files/{org,me}.
|
|
4
|
+
*
|
|
5
|
+
* Containers bind-mount a host directory; a machine over ssh got a
|
|
6
|
+
* point-in-time tar copy; the sidecar mounts the live prefix, so any number
|
|
7
|
+
* of task machines see one workspace's files at once and writes land in the
|
|
8
|
+
* bucket as they happen. Credentials come from the task lease through the
|
|
9
|
+
* `credential_process` config lib/lease.ts maintains — a refreshed lease
|
|
10
|
+
* reaches a mount that is already up.
|
|
11
|
+
*
|
|
12
|
+
* Mountpoint's semantics (whole-file writes, no rename) are the documented
|
|
13
|
+
* caveat of hostless-implementation.md decision 4; EFS is the swap if they
|
|
14
|
+
* bite, and this module is the only place that would change.
|
|
15
|
+
*/
|
|
16
|
+
import type { TaskLeaseFilesAccess } from "./cloud-credential-leases";
|
|
17
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
18
|
+
import { LEASE_AWS_CONFIG_FILE, currentLease } from "./lease";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
export const MOUNT_S3_BIN = "/usr/bin/mount-s3";
|
|
22
|
+
/** Files v1 mounts only below this root-owned machine path. The task sees it
|
|
23
|
+
* through an atomic `/workspace/files` symlink. Root mount/umount operations
|
|
24
|
+
* therefore never resolve a component the task can replace. */
|
|
25
|
+
export const TASK_FILES_V1_MOUNT_ROOT = "/mnt/uai-files";
|
|
26
|
+
/** Boot-local, root-only latch armed before the first v1 Mountpoint process
|
|
27
|
+
* can start. Once armed, only retiring/rebooting the one-task machine can
|
|
28
|
+
* prove that no private namespace or detached descriptor retains Files. */
|
|
29
|
+
export const TASK_FILES_V1_MOUNT_ATTEMPTED = "/run/uai/files-mount-attempted";
|
|
30
|
+
/** The task user the mounts are owned by. Its ids are looked up on the
|
|
31
|
+
* machine, never assumed: the Debian cloud image the payload AMI is built
|
|
32
|
+
* from ships an `admin` user at uid 1000, so `node` lands on 1001 there
|
|
33
|
+
* and a hard-coded 1000 handed every mount to the wrong user (2026-09-10,
|
|
34
|
+
* smoke 6). */
|
|
35
|
+
export const SHARED_FILES_USER = "node";
|
|
36
|
+
/** What the lookup falls back to (the standard container image's ids). */
|
|
37
|
+
export const SHARED_FILES_FALLBACK_UID = 1000;
|
|
38
|
+
export const SHARED_FILES_FALLBACK_GID = 1000;
|
|
39
|
+
|
|
40
|
+
export interface TaskUserIds {
|
|
41
|
+
uid: number;
|
|
42
|
+
gid: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SharedFilesV1Options {
|
|
46
|
+
/**
|
|
47
|
+
* The exact run's last-moment admission fence. It rides on every call into
|
|
48
|
+
* the task environment, including the marker write, so a withdrawn run
|
|
49
|
+
* cannot start its next mount step after an awaited provider lookup.
|
|
50
|
+
*/
|
|
51
|
+
admit?: () => void;
|
|
52
|
+
/** Stage the mounts behind the root-only fixed directory without exposing
|
|
53
|
+
* them in the workspace. The Files consumer publishes synchronously from
|
|
54
|
+
* its exact gate-open callback. */
|
|
55
|
+
publish?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ResolveTaskUserIdsOptions extends SharedFilesV1Options {
|
|
59
|
+
/** A v1 admission refusal is an error, never a reason to use fallback ids. */
|
|
60
|
+
failOnExecError?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `id -u`/`id -g` of the task user on this machine, through the root exec. */
|
|
64
|
+
export async function resolveTaskUserIds(
|
|
65
|
+
handle: Pick<TaskEnvironmentHandle, "exec">,
|
|
66
|
+
user: string = SHARED_FILES_USER,
|
|
67
|
+
options: ResolveTaskUserIdsOptions = {},
|
|
68
|
+
): Promise<TaskUserIds> {
|
|
69
|
+
const fallback = { uid: SHARED_FILES_FALLBACK_UID, gid: SHARED_FILES_FALLBACK_GID };
|
|
70
|
+
try {
|
|
71
|
+
const result = await handle.exec({
|
|
72
|
+
projectEnvProfile: "maintenance",
|
|
73
|
+
argv: ["/bin/sh", "-c", 'id -u "$1" && id -g "$1"', "ids", user],
|
|
74
|
+
user: "root",
|
|
75
|
+
timeoutMs: 10_000,
|
|
76
|
+
maxOutputBytes: 1024,
|
|
77
|
+
...(options.admit ? { admit: options.admit } : {}),
|
|
78
|
+
});
|
|
79
|
+
if (result.exitCode !== 0) {
|
|
80
|
+
if (options.failOnExecError) {
|
|
81
|
+
throw new Error(`could not resolve the task user's ids (exit ${result.exitCode})`);
|
|
82
|
+
}
|
|
83
|
+
return fallback;
|
|
84
|
+
}
|
|
85
|
+
const [uid, gid] = Buffer.from(result.stdout)
|
|
86
|
+
.toString("utf8")
|
|
87
|
+
.trim()
|
|
88
|
+
.split(/\s+/)
|
|
89
|
+
.map((part) => Number.parseInt(part, 10));
|
|
90
|
+
if (!Number.isInteger(uid) || !Number.isInteger(gid) || uid! < 0 || gid! < 0) {
|
|
91
|
+
if (options.failOnExecError) {
|
|
92
|
+
throw new Error("could not resolve the task user's ids");
|
|
93
|
+
}
|
|
94
|
+
return fallback;
|
|
95
|
+
}
|
|
96
|
+
return { uid: uid!, gid: gid! };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (options.failOnExecError) throw error;
|
|
99
|
+
return fallback;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type SharedFilesMode = "off" | "ro" | "rw";
|
|
104
|
+
|
|
105
|
+
export interface SharedFilesMountPlan {
|
|
106
|
+
bucket: string;
|
|
107
|
+
prefix: string;
|
|
108
|
+
directory: string;
|
|
109
|
+
mode: "ro" | "rw";
|
|
110
|
+
region: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The mount-s3 argv for one prefix. Exported for the test that pins it. */
|
|
114
|
+
export function mountS3Argv(plan: SharedFilesMountPlan, owner: TaskUserIds): [string, ...string[]] {
|
|
115
|
+
return [
|
|
116
|
+
MOUNT_S3_BIN,
|
|
117
|
+
"--prefix",
|
|
118
|
+
plan.prefix,
|
|
119
|
+
"--region",
|
|
120
|
+
plan.region,
|
|
121
|
+
// Owned by the task user so agents and the editor can use it; the mount
|
|
122
|
+
// is performed by the root sidecar.
|
|
123
|
+
"--allow-other",
|
|
124
|
+
"--uid",
|
|
125
|
+
String(owner.uid),
|
|
126
|
+
"--gid",
|
|
127
|
+
String(owner.gid),
|
|
128
|
+
"--dir-mode",
|
|
129
|
+
"0755",
|
|
130
|
+
"--file-mode",
|
|
131
|
+
"0644",
|
|
132
|
+
...(plan.mode === "ro" ? ["--read-only"] : ["--allow-delete", "--allow-overwrite"]),
|
|
133
|
+
plan.bucket,
|
|
134
|
+
plan.directory,
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The mounts a task wants, from its shared-files mode and lease. */
|
|
139
|
+
export function sharedFilesMountPlans(
|
|
140
|
+
workspacePath: string,
|
|
141
|
+
mode: SharedFilesMode,
|
|
142
|
+
lease: { region: string; files: { bucket: string; orgPrefix: string | null; userPrefix: string } },
|
|
143
|
+
): SharedFilesMountPlan[] {
|
|
144
|
+
if (mode === "off") return [];
|
|
145
|
+
const plans: SharedFilesMountPlan[] = [];
|
|
146
|
+
if (lease.files.orgPrefix) {
|
|
147
|
+
plans.push({
|
|
148
|
+
bucket: lease.files.bucket,
|
|
149
|
+
prefix: lease.files.orgPrefix,
|
|
150
|
+
directory: `${workspacePath}/files/org`,
|
|
151
|
+
mode,
|
|
152
|
+
region: lease.region,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
plans.push({
|
|
156
|
+
bucket: lease.files.bucket,
|
|
157
|
+
prefix: lease.files.userPrefix,
|
|
158
|
+
directory: `${workspacePath}/files/me`,
|
|
159
|
+
mode,
|
|
160
|
+
region: lease.region,
|
|
161
|
+
});
|
|
162
|
+
return plans;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Mount the task's shared files. Returns a warning string when something
|
|
167
|
+
* could not be mounted (the task still starts, like the machine copy path),
|
|
168
|
+
* null when clean or when there is nothing to mount.
|
|
169
|
+
*/
|
|
170
|
+
/** One mount pass per workspace at a time. Task-up (the cloud's wake) and
|
|
171
|
+
* the sidecar's own resume pass both mount on a runner restart, and the
|
|
172
|
+
* shell's `mountpoint -q` guard is not atomic across two racers: live
|
|
173
|
+
* 2026-09-12 both ran in the same second — /files/org got two stacked
|
|
174
|
+
* mounts and the loser on /files/me failed with "fuse: mountpoint is not
|
|
175
|
+
* empty", surfaced to the human as a warning although the files were
|
|
176
|
+
* mounted. The second pass now waits and finds the healthy mount. */
|
|
177
|
+
const mountPasses = new Map<string, Promise<unknown>>();
|
|
178
|
+
|
|
179
|
+
async function serializedByWorkspace<T>(workspacePath: string, run: () => Promise<T>): Promise<T> {
|
|
180
|
+
const previous = mountPasses.get(workspacePath) ?? Promise.resolve();
|
|
181
|
+
const next = previous.catch(() => undefined).then(run);
|
|
182
|
+
mountPasses.set(workspacePath, next);
|
|
183
|
+
try {
|
|
184
|
+
return await next;
|
|
185
|
+
} finally {
|
|
186
|
+
if (mountPasses.get(workspacePath) === next) mountPasses.delete(workspacePath);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function mountSharedFilesFromLease(
|
|
191
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
192
|
+
workspacePath: string,
|
|
193
|
+
task: { sharedFiles?: string },
|
|
194
|
+
): Promise<string | null> {
|
|
195
|
+
return serializedByWorkspace(workspacePath, () =>
|
|
196
|
+
mountSharedFilesFromLeaseNow(handle, workspacePath, task),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function mountSharedFilesFromLeaseNow(
|
|
201
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
202
|
+
workspacePath: string,
|
|
203
|
+
task: { sharedFiles?: string },
|
|
204
|
+
): Promise<string | null> {
|
|
205
|
+
const mode: SharedFilesMode =
|
|
206
|
+
task.sharedFiles === "rw" ? "rw" : task.sharedFiles === "off" ? "off" : "ro";
|
|
207
|
+
if (mode === "off") return null;
|
|
208
|
+
const lease = currentLease();
|
|
209
|
+
if (!lease) {
|
|
210
|
+
return "shared files: no lease yet — /workspace/files is not mounted";
|
|
211
|
+
}
|
|
212
|
+
const warnings: string[] = [];
|
|
213
|
+
let mountedAny = false;
|
|
214
|
+
const owner = await resolveTaskUserIds(handle);
|
|
215
|
+
for (const plan of sharedFilesMountPlans(workspacePath, mode, lease)) {
|
|
216
|
+
const result = await handle.exec({
|
|
217
|
+
projectEnvProfile: "maintenance",
|
|
218
|
+
argv: [
|
|
219
|
+
"/bin/sh",
|
|
220
|
+
"-c",
|
|
221
|
+
// Idempotent: a healthy mount is left alone. A mount whose daemon is
|
|
222
|
+
// gone still shows as mounted but every access fails with "Transport
|
|
223
|
+
// endpoint is not connected" (a sidecar restart SIGKILLs mount-s3 —
|
|
224
|
+
// live 2026-09-10, smoke 10); it is lazily unmounted and mounted
|
|
225
|
+
// again. The mkdir/chown tolerate the broken mount too.
|
|
226
|
+
'mkdir -p "$1" 2>/dev/null; chown "$2:$3" "$1" 2>/dev/null; ' +
|
|
227
|
+
'if mountpoint -q "$1" 2>/dev/null; then if ls "$1" >/dev/null 2>&1; then exit 0; fi; ' +
|
|
228
|
+
'umount -l "$1" 2>/dev/null || true; chown "$2:$3" "$1" 2>/dev/null; fi; ' +
|
|
229
|
+
'shift 3; exec "$@"',
|
|
230
|
+
"mount",
|
|
231
|
+
plan.directory,
|
|
232
|
+
String(owner.uid),
|
|
233
|
+
String(owner.gid),
|
|
234
|
+
...mountS3Argv(plan, owner),
|
|
235
|
+
],
|
|
236
|
+
user: "root",
|
|
237
|
+
env: { AWS_CONFIG_FILE: LEASE_AWS_CONFIG_FILE },
|
|
238
|
+
timeoutMs: 60_000,
|
|
239
|
+
maxOutputBytes: 64 * 1024,
|
|
240
|
+
});
|
|
241
|
+
if (result.exitCode === 0) {
|
|
242
|
+
mountedAny = true;
|
|
243
|
+
} else {
|
|
244
|
+
warnings.push(
|
|
245
|
+
`shared files: could not mount ${plan.directory}: ${Buffer.from(result.stderr).toString("utf8").trim() || `exit ${result.exitCode}`}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (mountedAny) {
|
|
250
|
+
// The same marker the container and machine paths write: it gates the
|
|
251
|
+
// "## Shared files" briefing in every agent's preamble. The sidecar
|
|
252
|
+
// writes the MODE into it so a restart can mount again the same way
|
|
253
|
+
// (lib/sidecar-resume.ts); readers only test for the file.
|
|
254
|
+
await handle
|
|
255
|
+
.writeWorkspaceFile(`${workspacePath}/.moondeck/files-mounted`, Buffer.from(mode))
|
|
256
|
+
.catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
return warnings.length > 0 ? warnings.join("\n") : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Mount shared files from the exact task/run's v1 Files session.
|
|
263
|
+
*
|
|
264
|
+
* Unlike the transitional lease path above, this function never consults
|
|
265
|
+
* `currentLease`, never turns a requested mount failure into a warning, and
|
|
266
|
+
* does not publish the workspace marker until every requested prefix is up.
|
|
267
|
+
* The Files consumer owns writing the session's credential_process files;
|
|
268
|
+
* mount-s3 learns only their fixed config path here.
|
|
269
|
+
*/
|
|
270
|
+
export async function mountSharedFilesFromAccess(
|
|
271
|
+
handle: Pick<TaskEnvironmentHandle<unknown>, "exec">,
|
|
272
|
+
workspacePath: string,
|
|
273
|
+
mode: SharedFilesMode,
|
|
274
|
+
access: TaskLeaseFilesAccess,
|
|
275
|
+
options: SharedFilesV1Options = {},
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
if (workspacePath !== "/workspace") {
|
|
278
|
+
throw new Error("shared files: task-machine workspace is not canonical");
|
|
279
|
+
}
|
|
280
|
+
return serializedByWorkspace(TASK_FILES_V1_MOUNT_ROOT, () =>
|
|
281
|
+
mountSharedFilesFromAccessNow(handle, workspacePath, mode, access, options),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function mountSharedFilesFromAccessNow(
|
|
286
|
+
handle: Pick<TaskEnvironmentHandle<unknown>, "exec">,
|
|
287
|
+
workspacePath: string,
|
|
288
|
+
mode: SharedFilesMode,
|
|
289
|
+
access: TaskLeaseFilesAccess,
|
|
290
|
+
options: SharedFilesV1Options,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
if (mode === "off") return;
|
|
293
|
+
const owner = await resolveTaskUserIds(handle, SHARED_FILES_USER, {
|
|
294
|
+
...options,
|
|
295
|
+
failOnExecError: true,
|
|
296
|
+
});
|
|
297
|
+
// The legacy planner takes a workspace and appends `files/{org,me}`.
|
|
298
|
+
// Files v1's argument is already the fixed mount root, so rewrite only the
|
|
299
|
+
// resulting directory: `/workspace/files` exposes these exact children.
|
|
300
|
+
const plans = sharedFilesMountPlans(TASK_FILES_V1_MOUNT_ROOT, mode, access).map(
|
|
301
|
+
(plan) => ({
|
|
302
|
+
...plan,
|
|
303
|
+
directory: `${TASK_FILES_V1_MOUNT_ROOT}/${plan.directory.endsWith("/org") ? "org" : "me"}`,
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
const scopes = plans.map((plan) => plan.directory.slice(`${TASK_FILES_V1_MOUNT_ROOT}/`.length));
|
|
307
|
+
const prepared = await handle.exec({
|
|
308
|
+
projectEnvProfile: "maintenance",
|
|
309
|
+
argv: [
|
|
310
|
+
"/usr/bin/python3",
|
|
311
|
+
"-I",
|
|
312
|
+
"-c",
|
|
313
|
+
TASK_FILES_V1_PREPARE_HELPER,
|
|
314
|
+
TASK_FILES_V1_MOUNT_ROOT,
|
|
315
|
+
scopes.join(","),
|
|
316
|
+
],
|
|
317
|
+
user: "root",
|
|
318
|
+
env: {},
|
|
319
|
+
timeoutMs: 10_000,
|
|
320
|
+
maxOutputBytes: 4 * 1024,
|
|
321
|
+
...(options.admit ? { admit: options.admit } : {}),
|
|
322
|
+
});
|
|
323
|
+
if (prepared.exitCode !== 0) {
|
|
324
|
+
throw new Error(`shared files: could not prepare the fixed mount root (exit ${prepared.exitCode})`);
|
|
325
|
+
}
|
|
326
|
+
for (const plan of plans) {
|
|
327
|
+
const result = await handle.exec({
|
|
328
|
+
projectEnvProfile: "maintenance",
|
|
329
|
+
argv: [
|
|
330
|
+
"/usr/bin/env",
|
|
331
|
+
"-i",
|
|
332
|
+
"PATH=/usr/bin:/bin",
|
|
333
|
+
`AWS_CONFIG_FILE=${LEASE_AWS_CONFIG_FILE}`,
|
|
334
|
+
"AWS_EC2_METADATA_DISABLED=true",
|
|
335
|
+
"/bin/sh",
|
|
336
|
+
"-c",
|
|
337
|
+
'if /usr/bin/mountpoint -q "$1" 2>/dev/null; then exit 40; fi; shift; exec "$@"',
|
|
338
|
+
"mount-fixed",
|
|
339
|
+
plan.directory,
|
|
340
|
+
...mountS3Argv(plan, owner),
|
|
341
|
+
],
|
|
342
|
+
user: "root",
|
|
343
|
+
env: {},
|
|
344
|
+
timeoutMs: 60_000,
|
|
345
|
+
maxOutputBytes: 64 * 1024,
|
|
346
|
+
...(options.admit ? { admit: options.admit } : {}),
|
|
347
|
+
});
|
|
348
|
+
if (result.exitCode !== 0) {
|
|
349
|
+
const detail = Buffer.from(result.stderr).toString("utf8").trim();
|
|
350
|
+
throw new Error(
|
|
351
|
+
`shared files: could not mount ${plan.directory}: ${detail || `exit ${result.exitCode}`}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (options.publish === false) return;
|
|
357
|
+
const linked = await handle.exec({
|
|
358
|
+
projectEnvProfile: "maintenance",
|
|
359
|
+
argv: [
|
|
360
|
+
"/usr/bin/python3",
|
|
361
|
+
"-I",
|
|
362
|
+
"-c",
|
|
363
|
+
TASK_FILES_V1_LINK_HELPER,
|
|
364
|
+
workspacePath,
|
|
365
|
+
TASK_FILES_V1_MOUNT_ROOT,
|
|
366
|
+
mode,
|
|
367
|
+
scopes.join(","),
|
|
368
|
+
],
|
|
369
|
+
user: "root",
|
|
370
|
+
env: {},
|
|
371
|
+
timeoutMs: 10_000,
|
|
372
|
+
maxOutputBytes: 4 * 1024,
|
|
373
|
+
...(options.admit ? { admit: options.admit } : {}),
|
|
374
|
+
});
|
|
375
|
+
if (linked.exitCode !== 0) {
|
|
376
|
+
throw new Error(`shared files: could not publish the fixed mount root (exit ${linked.exitCode})`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Publish already-staged v1 mounts from the synchronous registry gate-open
|
|
382
|
+
* callback. Files v1 exists only on a root sidecar/self runtime, so this
|
|
383
|
+
* local fixed-interpreter operation is the final boundary before any other
|
|
384
|
+
* consumer gate opens. */
|
|
385
|
+
export function publishSharedFilesV1Sync(
|
|
386
|
+
workspacePath: string,
|
|
387
|
+
mode: SharedFilesMode,
|
|
388
|
+
scopes: readonly ("org" | "me")[],
|
|
389
|
+
): void {
|
|
390
|
+
if (mode === "off") return;
|
|
391
|
+
if (workspacePath !== "/workspace") {
|
|
392
|
+
throw new Error("shared files: task-machine workspace is not canonical");
|
|
393
|
+
}
|
|
394
|
+
const result = spawnSync(
|
|
395
|
+
"/usr/bin/python3",
|
|
396
|
+
[
|
|
397
|
+
"-I",
|
|
398
|
+
"-c",
|
|
399
|
+
TASK_FILES_V1_LINK_HELPER,
|
|
400
|
+
workspacePath,
|
|
401
|
+
TASK_FILES_V1_MOUNT_ROOT,
|
|
402
|
+
mode,
|
|
403
|
+
scopes.join(","),
|
|
404
|
+
],
|
|
405
|
+
{
|
|
406
|
+
cwd: "/",
|
|
407
|
+
env: {},
|
|
408
|
+
encoding: "utf8",
|
|
409
|
+
timeout: 10_000,
|
|
410
|
+
maxBuffer: 4 * 1024,
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
if (result.error || result.status !== 0 || result.signal !== null) {
|
|
414
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
415
|
+
throw new Error(
|
|
416
|
+
`shared files: could not publish the fixed mount root${
|
|
417
|
+
stderr ? `: ${stderr}` : ""
|
|
418
|
+
}`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Synchronous half of gate close: make the fixed root non-traversable and
|
|
424
|
+
* remove the task-visible link/marker before any asynchronous cleanup. Open
|
|
425
|
+
* file descriptors still require process quiescence + ordinary unmount. */
|
|
426
|
+
export function hideSharedFilesV1Sync(workspacePath: string): void {
|
|
427
|
+
if (workspacePath !== "/workspace") {
|
|
428
|
+
throw new Error("shared files: task-machine workspace is not canonical");
|
|
429
|
+
}
|
|
430
|
+
const result = spawnSync(
|
|
431
|
+
"/usr/bin/python3",
|
|
432
|
+
[
|
|
433
|
+
"-I",
|
|
434
|
+
"-c",
|
|
435
|
+
TASK_FILES_V1_HIDE_HELPER,
|
|
436
|
+
workspacePath,
|
|
437
|
+
TASK_FILES_V1_MOUNT_ROOT,
|
|
438
|
+
],
|
|
439
|
+
{
|
|
440
|
+
cwd: "/",
|
|
441
|
+
env: {},
|
|
442
|
+
encoding: "utf8",
|
|
443
|
+
timeout: 10_000,
|
|
444
|
+
maxBuffer: 4 * 1024,
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
if (result.error || result.status !== 0 || result.signal !== null) {
|
|
448
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
449
|
+
throw new Error(
|
|
450
|
+
`shared files: could not hide the fixed mount root${stderr ? `: ${stderr}` : ""}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Unmount both directories; tolerant of never-mounted paths. */
|
|
456
|
+
export async function unmountSharedFiles(
|
|
457
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
458
|
+
workspacePath: string,
|
|
459
|
+
): Promise<void> {
|
|
460
|
+
for (const directory of [`${workspacePath}/files/org`, `${workspacePath}/files/me`]) {
|
|
461
|
+
await handle
|
|
462
|
+
.exec({
|
|
463
|
+
argv: ["/bin/sh", "-c", 'mountpoint -q "$1" && umount "$1" || true', "umount", directory],
|
|
464
|
+
projectEnvProfile: "maintenance",
|
|
465
|
+
user: "root",
|
|
466
|
+
timeoutMs: 30_000,
|
|
467
|
+
maxOutputBytes: 4 * 1024,
|
|
468
|
+
})
|
|
469
|
+
.catch(() => {});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Strict v1 cleanup. Both mountpoints are attempted even when one fails, and
|
|
475
|
+
* success proves that neither remains mounted. It shares the mount queue, so
|
|
476
|
+
* cleanup cannot overtake a mount/marker pass for the same workspace.
|
|
477
|
+
*/
|
|
478
|
+
export async function unmountSharedFilesStrict(
|
|
479
|
+
handle: Pick<TaskEnvironmentHandle<unknown>, "exec">,
|
|
480
|
+
workspacePath: string,
|
|
481
|
+
options: SharedFilesV1Options = {},
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
if (workspacePath !== "/workspace") {
|
|
484
|
+
throw new Error("shared files: task-machine workspace is not canonical");
|
|
485
|
+
}
|
|
486
|
+
return serializedByWorkspace(TASK_FILES_V1_MOUNT_ROOT, async () => {
|
|
487
|
+
const result = await handle.exec({
|
|
488
|
+
argv: [
|
|
489
|
+
"/usr/bin/python3",
|
|
490
|
+
"-I",
|
|
491
|
+
"-c",
|
|
492
|
+
TASK_FILES_V1_UNMOUNT_HELPER,
|
|
493
|
+
workspacePath,
|
|
494
|
+
TASK_FILES_V1_MOUNT_ROOT,
|
|
495
|
+
],
|
|
496
|
+
projectEnvProfile: "maintenance",
|
|
497
|
+
user: "root",
|
|
498
|
+
env: {},
|
|
499
|
+
timeoutMs: 30_000,
|
|
500
|
+
maxOutputBytes: 4 * 1024,
|
|
501
|
+
...(options.admit ? { admit: options.admit } : {}),
|
|
502
|
+
});
|
|
503
|
+
if (result.exitCode !== 0) {
|
|
504
|
+
const detail = Buffer.from(result.stderr).toString("utf8").trim();
|
|
505
|
+
throw new Error(
|
|
506
|
+
`shared files: could not prove the fixed mounts absent: ${detail || `exit ${result.exitCode}`}`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Create only the fixed, root-controlled mount directories. Neither the
|
|
513
|
+
* workspace nor any task-owned path is resolved here. */
|
|
514
|
+
export const TASK_FILES_V1_PREPARE_HELPER = String.raw`
|
|
515
|
+
import os
|
|
516
|
+
import stat
|
|
517
|
+
import sys
|
|
518
|
+
|
|
519
|
+
root, encoded = sys.argv[1:3]
|
|
520
|
+
if root != "/mnt/uai-files":
|
|
521
|
+
raise RuntimeError("unexpected Files mount root")
|
|
522
|
+
scopes = [part for part in encoded.split(",") if part]
|
|
523
|
+
if any(part not in ("org", "me") for part in scopes) or "me" not in scopes:
|
|
524
|
+
raise RuntimeError("invalid Files mount scope")
|
|
525
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
526
|
+
|
|
527
|
+
def arm_mount_attempt():
|
|
528
|
+
runtime = os.open("/run/uai", flags)
|
|
529
|
+
try:
|
|
530
|
+
runtime_info = os.fstat(runtime)
|
|
531
|
+
if runtime_info.st_uid != 0:
|
|
532
|
+
raise RuntimeError("the Files runtime directory is not root-owned")
|
|
533
|
+
try:
|
|
534
|
+
attempted = os.open(
|
|
535
|
+
"files-mount-attempted",
|
|
536
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
537
|
+
0o600,
|
|
538
|
+
dir_fd=runtime,
|
|
539
|
+
)
|
|
540
|
+
except FileExistsError:
|
|
541
|
+
raise RuntimeError("a Files mount was already attempted in this boot; retire this task machine")
|
|
542
|
+
try:
|
|
543
|
+
marker_info = os.fstat(attempted)
|
|
544
|
+
if marker_info.st_uid != 0 or not stat.S_ISREG(marker_info.st_mode) or stat.S_IMODE(marker_info.st_mode) != 0o600:
|
|
545
|
+
raise RuntimeError("the Files mount-attempt marker is unsafe")
|
|
546
|
+
os.write(attempted, b"1\n")
|
|
547
|
+
os.fsync(attempted)
|
|
548
|
+
finally:
|
|
549
|
+
os.close(attempted)
|
|
550
|
+
finally:
|
|
551
|
+
os.close(runtime)
|
|
552
|
+
|
|
553
|
+
def decode_mount_path(value):
|
|
554
|
+
result = bytearray()
|
|
555
|
+
index = 0
|
|
556
|
+
while index < len(value):
|
|
557
|
+
if value[index:index + 1] == b"\\" and index + 3 < len(value) and all(48 <= part <= 55 for part in value[index + 1:index + 4]):
|
|
558
|
+
result.append(int(value[index + 1:index + 4], 8))
|
|
559
|
+
index += 4
|
|
560
|
+
else:
|
|
561
|
+
result.append(value[index])
|
|
562
|
+
index += 1
|
|
563
|
+
return os.fsdecode(bytes(result))
|
|
564
|
+
|
|
565
|
+
with open("/proc/self/mountinfo", "rb") as stream:
|
|
566
|
+
for line in stream:
|
|
567
|
+
before = line.rstrip(b"\n").split(b" - ", 1)[0].split(b" ")
|
|
568
|
+
path = decode_mount_path(before[4])
|
|
569
|
+
if path == root or path.startswith(root + "/"):
|
|
570
|
+
arm_mount_attempt()
|
|
571
|
+
raise RuntimeError("the Files mount root is not empty")
|
|
572
|
+
|
|
573
|
+
parent_path, root_name = os.path.split(root)
|
|
574
|
+
if not parent_path or not root_name:
|
|
575
|
+
raise RuntimeError("invalid Files mount root")
|
|
576
|
+
parent = os.open(parent_path, flags)
|
|
577
|
+
try:
|
|
578
|
+
try:
|
|
579
|
+
os.mkdir(root_name, 0o755, dir_fd=parent)
|
|
580
|
+
except FileExistsError:
|
|
581
|
+
pass
|
|
582
|
+
directory = os.open(root_name, flags, dir_fd=parent)
|
|
583
|
+
finally:
|
|
584
|
+
os.close(parent)
|
|
585
|
+
try:
|
|
586
|
+
info = os.fstat(directory)
|
|
587
|
+
if info.st_uid != 0:
|
|
588
|
+
raise RuntimeError("Files mount root is not root-owned")
|
|
589
|
+
# Staged mounts are deliberately unreachable to the task until the exact
|
|
590
|
+
# registry open publishes them.
|
|
591
|
+
os.fchmod(directory, 0o700)
|
|
592
|
+
for scope in scopes:
|
|
593
|
+
try:
|
|
594
|
+
os.mkdir(scope, 0o755, dir_fd=directory)
|
|
595
|
+
except FileExistsError:
|
|
596
|
+
pass
|
|
597
|
+
child = os.open(scope, flags, dir_fd=directory)
|
|
598
|
+
try:
|
|
599
|
+
if os.fstat(child).st_uid != 0:
|
|
600
|
+
raise RuntimeError("Files mountpoint is not root-owned")
|
|
601
|
+
os.fchmod(child, 0o755)
|
|
602
|
+
finally:
|
|
603
|
+
os.close(child)
|
|
604
|
+
for unused in set(("org", "me")) - set(scopes):
|
|
605
|
+
try:
|
|
606
|
+
os.rmdir(unused, dir_fd=directory)
|
|
607
|
+
except FileNotFoundError:
|
|
608
|
+
pass
|
|
609
|
+
finally:
|
|
610
|
+
os.close(directory)
|
|
611
|
+
|
|
612
|
+
# Arm the one-way boot latch only after the fixed root is ready, but before
|
|
613
|
+
# the caller can spawn mount-s3. A lost helper response is deliberately
|
|
614
|
+
# terminal: the caller cannot distinguish it from a mount launch that raced
|
|
615
|
+
# ahead in another attempt.
|
|
616
|
+
arm_mount_attempt()
|
|
617
|
+
`;
|
|
618
|
+
|
|
619
|
+
/** Publish the fixed root without ever asking root to follow the task's
|
|
620
|
+
* `files` entry. A stray task entry is atomically moved aside, never read or
|
|
621
|
+
* recursively deleted. */
|
|
622
|
+
export const TASK_FILES_V1_LINK_HELPER = String.raw`
|
|
623
|
+
import os
|
|
624
|
+
import stat
|
|
625
|
+
import sys
|
|
626
|
+
|
|
627
|
+
workspace, target, mode, encoded = sys.argv[1:5]
|
|
628
|
+
if workspace != "/workspace" or target != "/mnt/uai-files" or mode not in ("ro", "rw"):
|
|
629
|
+
raise RuntimeError("invalid Files link")
|
|
630
|
+
scopes = [part for part in encoded.split(",") if part]
|
|
631
|
+
if any(part not in ("org", "me") for part in scopes) or "me" not in scopes or len(set(scopes)) != len(scopes):
|
|
632
|
+
raise RuntimeError("invalid Files scopes")
|
|
633
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
634
|
+
|
|
635
|
+
def decode_mount_path(value):
|
|
636
|
+
result = bytearray()
|
|
637
|
+
index = 0
|
|
638
|
+
while index < len(value):
|
|
639
|
+
if value[index:index + 1] == b"\\" and index + 3 < len(value) and all(48 <= part <= 55 for part in value[index + 1:index + 4]):
|
|
640
|
+
result.append(int(value[index + 1:index + 4], 8))
|
|
641
|
+
index += 4
|
|
642
|
+
else:
|
|
643
|
+
result.append(value[index])
|
|
644
|
+
index += 1
|
|
645
|
+
return os.fsdecode(bytes(result))
|
|
646
|
+
|
|
647
|
+
expected = {(target + "/" + scope, "fuse", "mountpoint-s3") for scope in scopes}
|
|
648
|
+
|
|
649
|
+
def exact_topology():
|
|
650
|
+
observed = []
|
|
651
|
+
with open("/proc/self/mountinfo", "rb") as stream:
|
|
652
|
+
for line in stream:
|
|
653
|
+
before, after = line.rstrip(b"\n").split(b" - ", 1)
|
|
654
|
+
fields = before.split(b" ")
|
|
655
|
+
tail = after.split(b" ")
|
|
656
|
+
path = decode_mount_path(fields[4])
|
|
657
|
+
if path == target or path.startswith(target + "/"):
|
|
658
|
+
observed.append((path, tail[0].decode("ascii"), tail[1].decode("ascii")))
|
|
659
|
+
return len(observed) == len(expected) and set(observed) == expected
|
|
660
|
+
|
|
661
|
+
if not exact_topology():
|
|
662
|
+
raise RuntimeError("the staged Files mount topology is not exact")
|
|
663
|
+
|
|
664
|
+
mount_root = os.open(target, flags)
|
|
665
|
+
directory = os.open(workspace, flags)
|
|
666
|
+
try:
|
|
667
|
+
workspace_owner = os.fstat(directory)
|
|
668
|
+
# The files entry belongs to the task until this exact publication. Preserve any
|
|
669
|
+
# stray entry by moving it aside in the same directory; never traverse or
|
|
670
|
+
# recursively remove it. The bounded loop also prevents a task racing the
|
|
671
|
+
# name from holding the root helper forever.
|
|
672
|
+
for attempt in range(64):
|
|
673
|
+
try:
|
|
674
|
+
info = os.stat("files", dir_fd=directory, follow_symlinks=False)
|
|
675
|
+
except FileNotFoundError:
|
|
676
|
+
info = None
|
|
677
|
+
if info is not None and stat.S_ISLNK(info.st_mode):
|
|
678
|
+
try:
|
|
679
|
+
if os.readlink("files", dir_fd=directory) == target:
|
|
680
|
+
break
|
|
681
|
+
except OSError:
|
|
682
|
+
pass
|
|
683
|
+
if info is not None:
|
|
684
|
+
quarantine = ".uai-files-stray-" + os.urandom(16).hex()
|
|
685
|
+
try:
|
|
686
|
+
os.mkdir(quarantine, 0o700, dir_fd=directory)
|
|
687
|
+
except FileExistsError:
|
|
688
|
+
continue
|
|
689
|
+
quarantine_fd = os.open(quarantine, flags, dir_fd=directory)
|
|
690
|
+
try:
|
|
691
|
+
try:
|
|
692
|
+
os.rename("files", "entry", src_dir_fd=directory, dst_dir_fd=quarantine_fd)
|
|
693
|
+
except FileNotFoundError:
|
|
694
|
+
os.close(quarantine_fd)
|
|
695
|
+
quarantine_fd = None
|
|
696
|
+
os.rmdir(quarantine, dir_fd=directory)
|
|
697
|
+
continue
|
|
698
|
+
os.fchown(quarantine_fd, workspace_owner.st_uid, workspace_owner.st_gid)
|
|
699
|
+
os.fchmod(quarantine_fd, 0o700)
|
|
700
|
+
finally:
|
|
701
|
+
if quarantine_fd is not None:
|
|
702
|
+
os.close(quarantine_fd)
|
|
703
|
+
try:
|
|
704
|
+
os.symlink(target, "files", dir_fd=directory)
|
|
705
|
+
break
|
|
706
|
+
except FileExistsError:
|
|
707
|
+
continue
|
|
708
|
+
else:
|
|
709
|
+
raise RuntimeError("workspace Files link could not be published")
|
|
710
|
+
if os.readlink("files", dir_fd=directory) != target:
|
|
711
|
+
raise RuntimeError("workspace Files link was replaced")
|
|
712
|
+
try:
|
|
713
|
+
os.mkdir(".moondeck", 0o700, dir_fd=directory)
|
|
714
|
+
os.chown(".moondeck", workspace_owner.st_uid, workspace_owner.st_gid, dir_fd=directory, follow_symlinks=False)
|
|
715
|
+
except FileExistsError:
|
|
716
|
+
pass
|
|
717
|
+
# The marker is advisory task metadata, not part of the visibility proof.
|
|
718
|
+
# A task-controlled non-directory .moondeck, a wrong owner, or a non-regular
|
|
719
|
+
# marker must not turn a successfully hidden root into machine retirement.
|
|
720
|
+
try:
|
|
721
|
+
uai = os.open(".moondeck", flags, dir_fd=directory)
|
|
722
|
+
except OSError:
|
|
723
|
+
uai = None
|
|
724
|
+
if uai is not None:
|
|
725
|
+
try:
|
|
726
|
+
uai_owner = os.fstat(uai)
|
|
727
|
+
if (uai_owner.st_uid, uai_owner.st_gid) == (workspace_owner.st_uid, workspace_owner.st_gid):
|
|
728
|
+
try:
|
|
729
|
+
descriptor = os.open(
|
|
730
|
+
"files-mounted",
|
|
731
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
|
|
732
|
+
0o600,
|
|
733
|
+
dir_fd=uai,
|
|
734
|
+
)
|
|
735
|
+
except OSError:
|
|
736
|
+
descriptor = None
|
|
737
|
+
if descriptor is not None:
|
|
738
|
+
try:
|
|
739
|
+
marker_info = os.fstat(descriptor)
|
|
740
|
+
if stat.S_ISREG(marker_info.st_mode):
|
|
741
|
+
os.ftruncate(descriptor, 0)
|
|
742
|
+
os.write(descriptor, mode.encode("ascii"))
|
|
743
|
+
os.fchmod(descriptor, 0o600)
|
|
744
|
+
os.fchown(descriptor, uai_owner.st_uid, uai_owner.st_gid)
|
|
745
|
+
finally:
|
|
746
|
+
os.close(descriptor)
|
|
747
|
+
finally:
|
|
748
|
+
os.close(uai)
|
|
749
|
+
# The workspace mutations above are task-controlled and may take time.
|
|
750
|
+
# Prove every expected mount again at the final visibility boundary.
|
|
751
|
+
if not exact_topology():
|
|
752
|
+
raise RuntimeError("the staged Files mount topology changed before publish")
|
|
753
|
+
# Last: only now can the task traverse the fixed root, after the link and
|
|
754
|
+
# any safe advisory marker work are complete.
|
|
755
|
+
os.fchmod(mount_root, 0o755)
|
|
756
|
+
finally:
|
|
757
|
+
os.close(directory)
|
|
758
|
+
os.close(mount_root)
|
|
759
|
+
`;
|
|
760
|
+
|
|
761
|
+
/** Path visibility only. Physical mount retirement deliberately remains in
|
|
762
|
+
* the asynchronous strict helper, which quiesces exact task-user holders. */
|
|
763
|
+
export const TASK_FILES_V1_HIDE_HELPER = String.raw`
|
|
764
|
+
import os
|
|
765
|
+
import stat
|
|
766
|
+
import sys
|
|
767
|
+
|
|
768
|
+
workspace, root = sys.argv[1:3]
|
|
769
|
+
if workspace != "/workspace" or root != "/mnt/uai-files":
|
|
770
|
+
raise RuntimeError("invalid Files hide root")
|
|
771
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
772
|
+
try:
|
|
773
|
+
mount_root = os.open(root, flags)
|
|
774
|
+
except FileNotFoundError:
|
|
775
|
+
mount_root = None
|
|
776
|
+
if mount_root is not None:
|
|
777
|
+
try:
|
|
778
|
+
os.fchmod(mount_root, 0o700)
|
|
779
|
+
finally:
|
|
780
|
+
os.close(mount_root)
|
|
781
|
+
try:
|
|
782
|
+
directory = os.open(workspace, flags)
|
|
783
|
+
except FileNotFoundError:
|
|
784
|
+
directory = None
|
|
785
|
+
if directory is not None:
|
|
786
|
+
try:
|
|
787
|
+
try:
|
|
788
|
+
info = os.stat("files", dir_fd=directory, follow_symlinks=False)
|
|
789
|
+
except OSError:
|
|
790
|
+
info = None
|
|
791
|
+
if info is not None and stat.S_ISLNK(info.st_mode):
|
|
792
|
+
try:
|
|
793
|
+
if os.readlink("files", dir_fd=directory) == root:
|
|
794
|
+
os.unlink("files", dir_fd=directory)
|
|
795
|
+
except OSError:
|
|
796
|
+
pass
|
|
797
|
+
try:
|
|
798
|
+
uai = os.open(".moondeck", flags, dir_fd=directory)
|
|
799
|
+
except OSError:
|
|
800
|
+
uai = None
|
|
801
|
+
if uai is not None:
|
|
802
|
+
try:
|
|
803
|
+
try:
|
|
804
|
+
os.unlink("files-mounted", dir_fd=uai)
|
|
805
|
+
except OSError:
|
|
806
|
+
pass
|
|
807
|
+
finally:
|
|
808
|
+
os.close(uai)
|
|
809
|
+
finally:
|
|
810
|
+
os.close(directory)
|
|
811
|
+
`;
|
|
812
|
+
|
|
813
|
+
/** Detach only the fixed root-owned mounts. The workspace operation is an
|
|
814
|
+
* exact symlink unlink through its opened parent; no root unmount ever sees
|
|
815
|
+
* task-controlled path components. */
|
|
816
|
+
export const TASK_FILES_V1_UNMOUNT_HELPER = String.raw`
|
|
817
|
+
import os
|
|
818
|
+
import stat
|
|
819
|
+
import subprocess
|
|
820
|
+
import sys
|
|
821
|
+
|
|
822
|
+
workspace, root = sys.argv[1:3]
|
|
823
|
+
if workspace != "/workspace" or root != "/mnt/uai-files":
|
|
824
|
+
raise RuntimeError("invalid Files cleanup root")
|
|
825
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
826
|
+
# Hide the staged root before any awaited unmount. Open handles are dealt with
|
|
827
|
+
# by the ordinary unmount proof below; no lazy detach is accepted as cleanup.
|
|
828
|
+
try:
|
|
829
|
+
mount_root = os.open(root, flags)
|
|
830
|
+
except FileNotFoundError:
|
|
831
|
+
mount_root = None
|
|
832
|
+
if mount_root is not None:
|
|
833
|
+
try:
|
|
834
|
+
os.fchmod(mount_root, 0o700)
|
|
835
|
+
finally:
|
|
836
|
+
os.close(mount_root)
|
|
837
|
+
try:
|
|
838
|
+
directory = os.open(workspace, flags)
|
|
839
|
+
except FileNotFoundError:
|
|
840
|
+
directory = None
|
|
841
|
+
if directory is not None:
|
|
842
|
+
try:
|
|
843
|
+
try:
|
|
844
|
+
info = os.stat("files", dir_fd=directory, follow_symlinks=False)
|
|
845
|
+
except OSError:
|
|
846
|
+
info = None
|
|
847
|
+
if info is not None and stat.S_ISLNK(info.st_mode):
|
|
848
|
+
try:
|
|
849
|
+
if os.readlink("files", dir_fd=directory) == root:
|
|
850
|
+
os.unlink("files", dir_fd=directory)
|
|
851
|
+
except OSError:
|
|
852
|
+
pass
|
|
853
|
+
try:
|
|
854
|
+
uai = os.open(".moondeck", flags, dir_fd=directory)
|
|
855
|
+
except OSError:
|
|
856
|
+
uai = None
|
|
857
|
+
if uai is not None:
|
|
858
|
+
try:
|
|
859
|
+
try:
|
|
860
|
+
os.unlink("files-mounted", dir_fd=uai)
|
|
861
|
+
except OSError:
|
|
862
|
+
pass
|
|
863
|
+
finally:
|
|
864
|
+
os.close(uai)
|
|
865
|
+
finally:
|
|
866
|
+
os.close(directory)
|
|
867
|
+
|
|
868
|
+
def decode_mount_path(value):
|
|
869
|
+
result = bytearray()
|
|
870
|
+
index = 0
|
|
871
|
+
while index < len(value):
|
|
872
|
+
if value[index:index + 1] == b"\\" and index + 3 < len(value) and all(48 <= part <= 55 for part in value[index + 1:index + 4]):
|
|
873
|
+
result.append(int(value[index + 1:index + 4], 8))
|
|
874
|
+
index += 4
|
|
875
|
+
else:
|
|
876
|
+
result.append(value[index])
|
|
877
|
+
index += 1
|
|
878
|
+
return os.fsdecode(bytes(result))
|
|
879
|
+
|
|
880
|
+
def mounts(path="/proc/self/mountinfo"):
|
|
881
|
+
found = []
|
|
882
|
+
with open(path, "rb") as stream:
|
|
883
|
+
for line in stream:
|
|
884
|
+
before, after = line.rstrip(b"\n").split(b" - ", 1)
|
|
885
|
+
fields = before.split(b" ")
|
|
886
|
+
tail = after.split(b" ")
|
|
887
|
+
found.append((
|
|
888
|
+
int(fields[0]),
|
|
889
|
+
decode_mount_path(fields[4]),
|
|
890
|
+
tail[0].decode("ascii"),
|
|
891
|
+
tail[1].decode("ascii"),
|
|
892
|
+
tuple(int(part) for part in fields[2].split(b":")),
|
|
893
|
+
))
|
|
894
|
+
return found
|
|
895
|
+
|
|
896
|
+
targets = {root + "/org", root + "/me"}
|
|
897
|
+
legacy = {workspace.rstrip("/") + "/files/org", workspace.rstrip("/") + "/files/me"}
|
|
898
|
+
|
|
899
|
+
def mount_attempted(create=False):
|
|
900
|
+
runtime = os.open("/run/uai", flags)
|
|
901
|
+
try:
|
|
902
|
+
runtime_info = os.fstat(runtime)
|
|
903
|
+
if runtime_info.st_uid != 0:
|
|
904
|
+
raise RuntimeError("the Files runtime directory is not root-owned")
|
|
905
|
+
try:
|
|
906
|
+
info = os.stat("files-mount-attempted", dir_fd=runtime, follow_symlinks=False)
|
|
907
|
+
except FileNotFoundError:
|
|
908
|
+
if not create:
|
|
909
|
+
return False
|
|
910
|
+
try:
|
|
911
|
+
descriptor = os.open(
|
|
912
|
+
"files-mount-attempted",
|
|
913
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
914
|
+
0o600,
|
|
915
|
+
dir_fd=runtime,
|
|
916
|
+
)
|
|
917
|
+
except FileExistsError:
|
|
918
|
+
# Another strict-cleanup attempt armed the same one-way latch.
|
|
919
|
+
# Revalidate its directory entry and continue best-effort
|
|
920
|
+
# unmounting rather than losing the retirement witness.
|
|
921
|
+
info = os.stat("files-mount-attempted", dir_fd=runtime, follow_symlinks=False)
|
|
922
|
+
else:
|
|
923
|
+
try:
|
|
924
|
+
info = os.fstat(descriptor)
|
|
925
|
+
os.write(descriptor, b"1\n")
|
|
926
|
+
os.fsync(descriptor)
|
|
927
|
+
finally:
|
|
928
|
+
os.close(descriptor)
|
|
929
|
+
if info.st_uid != 0 or not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600:
|
|
930
|
+
raise RuntimeError("the Files mount-attempt marker is unsafe")
|
|
931
|
+
return True
|
|
932
|
+
finally:
|
|
933
|
+
os.close(runtime)
|
|
934
|
+
|
|
935
|
+
initial = mounts()
|
|
936
|
+
under_root = [(path, fs_type, source) for _mid, path, fs_type, source, _device in initial if path == root or path.startswith(root + "/")]
|
|
937
|
+
counts = {path: sum(1 for observed, _fs_type, _source in under_root if observed == path) for path in targets}
|
|
938
|
+
unexpected = [
|
|
939
|
+
path for path, fs_type, source in under_root
|
|
940
|
+
if path not in targets or fs_type != "fuse" or source != "mountpoint-s3"
|
|
941
|
+
]
|
|
942
|
+
unexpected += [path for path, count in counts.items() if count > 1]
|
|
943
|
+
unexpected += [
|
|
944
|
+
path for _mid, path, fs_type, source, _device in initial
|
|
945
|
+
if path in legacy or (fs_type == "fuse" and source == "mountpoint-s3" and path not in targets)
|
|
946
|
+
]
|
|
947
|
+
attempted = mount_attempted()
|
|
948
|
+
if unexpected or any(counts.values()):
|
|
949
|
+
# An older/lost attempt may have reached mount-s3 before its marker was
|
|
950
|
+
# observed. Latch retirement before changing the local mount topology.
|
|
951
|
+
attempted = mount_attempted(create=True)
|
|
952
|
+
if unexpected:
|
|
953
|
+
raise RuntimeError("legacy or unexpected Files mount remains; retire this task machine")
|
|
954
|
+
|
|
955
|
+
failures = []
|
|
956
|
+
for path in sorted(targets):
|
|
957
|
+
records = [record for record in mounts() if record[1] == path]
|
|
958
|
+
if not records:
|
|
959
|
+
continue
|
|
960
|
+
if len(records) != 1 or records[0][2:4] != ("fuse", "mountpoint-s3"):
|
|
961
|
+
failures.append(path + ":identity-changed")
|
|
962
|
+
continue
|
|
963
|
+
ended = subprocess.run(["/usr/bin/umount", path], env={}, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
964
|
+
if ended.returncode != 0:
|
|
965
|
+
failures.append(path + ":unmount")
|
|
966
|
+
if any(mount_path == path for _mid, mount_path, _fs_type, _source, _device in mounts()):
|
|
967
|
+
failures.append(path + ":still-mounted")
|
|
968
|
+
if attempted:
|
|
969
|
+
# A /proc scan cannot prove every private mount namespace or detached fd
|
|
970
|
+
# gone: a task can fork during the scan, retain a namespace fd, or become
|
|
971
|
+
# root through the task image's sudo. The boot-local latch makes runtime
|
|
972
|
+
# destruction the sole success proof once mount-s3 could have started.
|
|
973
|
+
failures.append("task-machine-retirement-required")
|
|
974
|
+
if failures:
|
|
975
|
+
raise RuntimeError(";".join(failures))
|
|
976
|
+
`;
|