@moondeck/station 0.0.1 → 1.0.1
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/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +230 -0
- package/db/schema.ts +871 -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 +1470 -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 +937 -0
- package/lib/env.ts +195 -0
- package/lib/first-turn-receipts.ts +62 -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/bridge-diagnostics.ts +39 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2585 -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/enrollment-next-steps.ts +24 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2907 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6363 -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 +5550 -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,3761 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# task-up <taskId> — bring up the task stack end-to-end (ADR-022).
|
|
3
|
+
#
|
|
4
|
+
# The "standard image" model: every host runs one prebuilt image,
|
|
5
|
+
# `moondeck-standard:dev`. A task selects 0..N projects (0 = empty workspace /
|
|
6
|
+
# scratchpad). Each selected project contributes a git worktree under the
|
|
7
|
+
# task's workspace, plus a union of preview ports and env keys.
|
|
8
|
+
#
|
|
9
|
+
# Steps:
|
|
10
|
+
# 1. Read task (-> branch) + the task's selected projects.
|
|
11
|
+
# 2. Acquire lock; mark status=starting.
|
|
12
|
+
# 3. Per project: ensure a bare mirror, fetch, add a worktree on the task
|
|
13
|
+
# branch, seed .tool-versions when missing.
|
|
14
|
+
# 4. Generate .uai/docker-compose.yml in bash (and a derived .uai/Dockerfile
|
|
15
|
+
# when any project declares `extra`).
|
|
16
|
+
# 5. Build the derived image when needed, then materialize every runtime and
|
|
17
|
+
# package-manager default needed by the selected projects through the
|
|
18
|
+
# exact image the task will run.
|
|
19
|
+
# 6. docker compose -p task-<id> up -d (without rebuilding).
|
|
20
|
+
# 7. docker cp AI auth into the container, then docker exec uai-init.
|
|
21
|
+
# 8. Discover code-server + preview ports; mark status=running; unlock.
|
|
22
|
+
#
|
|
23
|
+
# The AIs are NOT started here — the chat orchestrator drives them and
|
|
24
|
+
# delivers the task's initial prompt once the task is running.
|
|
25
|
+
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
28
|
+
# shellcheck source=_common.sh
|
|
29
|
+
. "$SCRIPT_DIR/_common.sh"
|
|
30
|
+
|
|
31
|
+
[ "$#" -eq 1 ] || emit_err "BAD_ARGS" "usage: task-up <taskId>" "args"
|
|
32
|
+
|
|
33
|
+
task_id="$1"
|
|
34
|
+
UAI_TASK_ID="$task_id"
|
|
35
|
+
setup_err_trap
|
|
36
|
+
|
|
37
|
+
# Pinned constants (all clusters must agree — see ADR-022).
|
|
38
|
+
STANDARD_IMAGE="moondeck-standard:dev"
|
|
39
|
+
ASDF_VOLUME="moondeck-asdf-data"
|
|
40
|
+
# Keep in sync with lib/standard-image.ts. Apple tasks execute the env
|
|
41
|
+
# forwarder from the shared volume's read-only lower mount, never from the
|
|
42
|
+
# node-writable overlay at /opt/asdf-data.
|
|
43
|
+
STANDARD_DEFAULT_NODE_VERSION="22.11.0"
|
|
44
|
+
TASK_NPM_PREFIX="/home/node/.local/share/uai-npm"
|
|
45
|
+
TASK_NPM_CACHE="/home/node/.cache/uai-npm"
|
|
46
|
+
# Host-proved shims and system controls always win. A task-global package with
|
|
47
|
+
# a new command name remains discoverable at the final path component, while a
|
|
48
|
+
# package named node/claude/git/sh cannot intercept managed or agent startup.
|
|
49
|
+
TASK_RUNTIME_PATH="/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$TASK_NPM_PREFIX/bin"
|
|
50
|
+
# ADR-053: host-wide Playwright browser cache (Chromium downloads once).
|
|
51
|
+
PW_VOLUME="uai-playwright"
|
|
52
|
+
# ADR-106: on the Apple runtime the cache is a host DIRECTORY instead. Apple
|
|
53
|
+
# block volumes allow exactly ONE writable attachment, and session start
|
|
54
|
+
# writes browsers from inside every task — a shared writable volume let the
|
|
55
|
+
# first task exclude every later `container run` (live 2026-08-17). Virtiofs
|
|
56
|
+
# directory shares carry no exclusivity, like /workspace itself.
|
|
57
|
+
PW_BROWSERS_DIR="$UAI_WORKSPACE_ROOT/pw-browsers"
|
|
58
|
+
DERIVED_IMAGE="moondeck-task-${task_id}"
|
|
59
|
+
# ADR-118 (apple only): the pnpm store rides a task-private block volume. On
|
|
60
|
+
# virtiofs every store file pins a host fd for the VM's lifetime — one
|
|
61
|
+
# monorepo install held ~130k host descriptors and two tasks exhausted the
|
|
62
|
+
# system file table (live 2026-08-25). A block volume costs one fd total.
|
|
63
|
+
# One volume per task, never shared: block volumes allow exactly ONE
|
|
64
|
+
# writable attachment, and the store was per-task on the old path anyway.
|
|
65
|
+
TASK_STORE_VOLUME=$(apple_store_volume_for_task "$task_id")
|
|
66
|
+
TASK_STORE_MOUNT="/opt/uai/store"
|
|
67
|
+
TASK_STORE_DIR="$TASK_STORE_MOUNT/pnpm"
|
|
68
|
+
|
|
69
|
+
# ADR-106: runtime selection is shared with task-down/task-status via
|
|
70
|
+
# _common.sh; `docker` keeps the historical Compose path byte-for-byte, while
|
|
71
|
+
# `apple-container` drives the bundled Apple CLI directly (no Compose, no
|
|
72
|
+
# published ports — tasks are reached at their vmnet IP).
|
|
73
|
+
validate_task_runtime_selection
|
|
74
|
+
# Per-container VM sizing (Apple runtime only; Docker tasks share the daemon
|
|
75
|
+
# VM). Overridable for constrained hosts; ADR-106 picked these defaults for a
|
|
76
|
+
# dev task with language servers + builds.
|
|
77
|
+
APPLE_TASK_MEMORY="${UAI_APPLE_TASK_MEMORY:-8192M}"
|
|
78
|
+
APPLE_TASK_CPUS="${UAI_APPLE_TASK_CPUS:-4}"
|
|
79
|
+
|
|
80
|
+
# ADR-153 makes the task's project-env delivery mode explicit. A loader task's
|
|
81
|
+
# stdin is exactly one base64-encoded task-generation forwarder secret; it is
|
|
82
|
+
# never put in argv, the environment, or a file. A legacy baked task retains
|
|
83
|
+
# the old JSON-object stdin contract until its container is recreated.
|
|
84
|
+
UAI_PROJECT_ENV_MODE=${UAI_PROJECT_ENV_MODE:-baked}
|
|
85
|
+
case "$UAI_PROJECT_ENV_MODE" in
|
|
86
|
+
baked | loader) ;;
|
|
87
|
+
*)
|
|
88
|
+
emit_err "BAD_ARGS" \
|
|
89
|
+
"task project env mode must be baked or loader" \
|
|
90
|
+
"read task environment mode"
|
|
91
|
+
;;
|
|
92
|
+
esac
|
|
93
|
+
|
|
94
|
+
task_stdin_payload=$(cat)
|
|
95
|
+
task_gateway_secret_b64=""
|
|
96
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
97
|
+
task_gateway_secret_b64=$task_stdin_payload
|
|
98
|
+
project_env_json='{}'
|
|
99
|
+
# A 32-byte value has one canonical standard-base64 shape. Validate that
|
|
100
|
+
# shape here without decoding it into a shell variable (which cannot carry
|
|
101
|
+
# NUL); the only decode happens later, directly into the forwarder's stdin.
|
|
102
|
+
if [[ ! "$task_gateway_secret_b64" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then
|
|
103
|
+
emit_err "BAD_ARGS" \
|
|
104
|
+
"loader task gateway secret must decode to exactly 32 bytes" \
|
|
105
|
+
"read task gateway secret"
|
|
106
|
+
fi
|
|
107
|
+
else
|
|
108
|
+
project_env_json=$task_stdin_payload
|
|
109
|
+
[ -n "$project_env_json" ] || project_env_json='{}'
|
|
110
|
+
if ! printf '%s' "$project_env_json" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
|
111
|
+
emit_err "BAD_ARGS" "task environment payload must be a JSON object" "read task environment"
|
|
112
|
+
fi
|
|
113
|
+
project_env_json=$(printf '%s' "$project_env_json" | jq -c '.')
|
|
114
|
+
fi
|
|
115
|
+
unset task_stdin_payload
|
|
116
|
+
|
|
117
|
+
# -----------------------------------------------------------------------------
|
|
118
|
+
# Paths (mirror host-agent/lib/env.ts).
|
|
119
|
+
# -----------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
task_dir="$UAI_WORKSPACE_ROOT/tasks/$task_id"
|
|
122
|
+
task_workspace="$task_dir/workspace"
|
|
123
|
+
# The HOST control tree. Deliberately not bind-mounted: the container sees only
|
|
124
|
+
# $task_workspace. Anything an agent must reach belongs under $task_workspace/.moondeck.
|
|
125
|
+
task_uai_dir="$task_dir/.uai"
|
|
126
|
+
projects_root="$UAI_WORKSPACE_ROOT/projects"
|
|
127
|
+
compose_project=$(compose_project_for_task "$task_id")
|
|
128
|
+
app_container=$(app_container_for_task "$task_id")
|
|
129
|
+
[ "$TASK_RUNTIME_MODE" != "apple-container" ] \
|
|
130
|
+
|| app_container=$(apple_app_container_for_task "$task_id")
|
|
131
|
+
|
|
132
|
+
reject_unsafe_host_directory() {
|
|
133
|
+
local path="$1" label="$2"
|
|
134
|
+
if [ -L "$path" ] || { [ -e "$path" ] && [ ! -d "$path" ]; }; then
|
|
135
|
+
emit_err "WORKTREE_FAILED" \
|
|
136
|
+
"Moondeck refused $label because it is not a trusted directory: $path" \
|
|
137
|
+
"validate task workspace paths"
|
|
138
|
+
fi
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
reject_unsafe_runtime_manifest() {
|
|
142
|
+
local path="$1" project_slug="$2" project_id="$3"
|
|
143
|
+
if [ -L "$path" ]; then
|
|
144
|
+
emit_err "WORKTREE_FAILED" \
|
|
145
|
+
"Moondeck refused a symbolic-link .tool-versions for $project_slug" \
|
|
146
|
+
"validate task worktree ($project_id)"
|
|
147
|
+
fi
|
|
148
|
+
# Never open an untrusted special file: `-f` is a metadata-only check, so a
|
|
149
|
+
# FIFO/socket/device cannot block the host while it validates a resumed
|
|
150
|
+
# container-writable workspace.
|
|
151
|
+
if [ -e "$path" ] && [ ! -f "$path" ]; then
|
|
152
|
+
emit_err "WORKTREE_FAILED" \
|
|
153
|
+
"Moondeck refused a non-regular .tool-versions for $project_slug" \
|
|
154
|
+
"validate task worktree ($project_id)"
|
|
155
|
+
fi
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
reject_workspace_runtime_shadow() {
|
|
159
|
+
local path="$task_workspace/.tool-versions"
|
|
160
|
+
# The workspace root is not a project. Any entry here would shadow the
|
|
161
|
+
# image defaults for a no-manifest project (and even a scratchpad command),
|
|
162
|
+
# while the trusted materializer proves only the selected project paths.
|
|
163
|
+
# Metadata-only predicates keep FIFO/device/socket cases nonblocking.
|
|
164
|
+
if [ -L "$path" ] || [ -e "$path" ]; then
|
|
165
|
+
emit_err "WORKTREE_FAILED" \
|
|
166
|
+
"Moondeck refused a workspace-root .tool-versions because it could override the runtimes proved for this task" \
|
|
167
|
+
"validate task workspace runtime authority"
|
|
168
|
+
fi
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# The first check of a task-up used to be the refusal above. A fresh task
|
|
172
|
+
# never trips it (the workspace is empty), but a RESUMED one can: the
|
|
173
|
+
# workspace persists across an idle park, agents' shells start in /workspace,
|
|
174
|
+
# and `asdf local nodejs 22` from there writes exactly this file — after
|
|
175
|
+
# which the task could never wake again without someone deleting the file on
|
|
176
|
+
# the host (client runner, 2026-09-14). The entry is the task's own
|
|
177
|
+
# container-writable content, so move it aside instead: a rename is
|
|
178
|
+
# metadata-only (never opens a FIFO/device/socket), the runtime proof is
|
|
179
|
+
# unchanged because nothing is left at the root, and the content survives
|
|
180
|
+
# under .moondeck/refused/ where the agent can re-pin it in a project. The
|
|
181
|
+
# post-materialization recheck below keeps refusing — by then the root must
|
|
182
|
+
# be empty by construction, so an entry there is a bug, not a user file.
|
|
183
|
+
quarantine_workspace_runtime_shadow() {
|
|
184
|
+
local path="$task_workspace/.tool-versions" aside_dir aside attempt note
|
|
185
|
+
if [ -L "$path" ] || [ -e "$path" ]; then
|
|
186
|
+
aside_dir="$task_workspace/.moondeck/refused"
|
|
187
|
+
reject_unsafe_host_directory "$task_workspace/.moondeck" "the task control directory"
|
|
188
|
+
reject_unsafe_host_directory "$aside_dir" "the refused-files directory"
|
|
189
|
+
mkdir -p "$aside_dir"
|
|
190
|
+
# Never onto an entry the task left there: mv moves into a directory, or
|
|
191
|
+
# one a link leads to, outside the workspace (#cb3j0z). The task's
|
|
192
|
+
# container is gone, so nothing races these checks.
|
|
193
|
+
aside="$aside_dir/tool-versions.$(date +%s)"
|
|
194
|
+
attempt=0
|
|
195
|
+
while [ -L "$aside" ] || [ -e "$aside" ]; do
|
|
196
|
+
attempt=$((attempt + 1))
|
|
197
|
+
aside="$aside_dir/tool-versions.$(date +%s).$attempt"
|
|
198
|
+
done
|
|
199
|
+
if ! mv -f "$path" "$aside" 2>/dev/null || [ -L "$path" ] || [ -e "$path" ]; then
|
|
200
|
+
reject_workspace_runtime_shadow
|
|
201
|
+
fi
|
|
202
|
+
# Nor written through a link or special file in the note's place: that
|
|
203
|
+
# is removed first (rm never follows a link), and the note is written
|
|
204
|
+
# only to nothing or a regular file.
|
|
205
|
+
note="$aside_dir/README.txt"
|
|
206
|
+
if [ -L "$note" ] || { [ -e "$note" ] && [ ! -f "$note" ]; }; then
|
|
207
|
+
rm -f "$note" 2>/dev/null || true
|
|
208
|
+
fi
|
|
209
|
+
if [ ! -L "$note" ] && { [ ! -e "$note" ] || [ -f "$note" ]; }; then
|
|
210
|
+
cat > "$note" <<'NOTE'
|
|
211
|
+
Moondeck moved a /workspace/.tool-versions aside before starting this task.
|
|
212
|
+
|
|
213
|
+
A .tool-versions at the workspace root is outside the runtime proof and would
|
|
214
|
+
override the runtimes proved for every project in the task, so the host refuses
|
|
215
|
+
to start a container while one exists. The file is kept here, untouched, under
|
|
216
|
+
its original content. Pin runtimes in a project's own .tool-versions instead
|
|
217
|
+
(/workspace/<project>/.tool-versions), which the materializer proves.
|
|
218
|
+
NOTE
|
|
219
|
+
fi
|
|
220
|
+
log "moved a workspace-root .tool-versions aside to ${aside#"$task_workspace"/} (it would override the runtimes proved for this task; pin per project instead)"
|
|
221
|
+
fi
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
mapped_host_port() {
|
|
225
|
+
local container="$1" container_port="$2"
|
|
226
|
+
docker port "$container" "$container_port" 2>/dev/null \
|
|
227
|
+
| awk -F: 'NR==1 {print $NF}' \
|
|
228
|
+
| tr -d '[:space:]' \
|
|
229
|
+
|| true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# A failed Docker call cannot be followed by an unbounded health probe: the
|
|
233
|
+
# same stuck socket would defeat the operation's own watchdog. This portable
|
|
234
|
+
# Bash 3.2 wrapper gives `docker info` 15 seconds, writes completion before its
|
|
235
|
+
# exact wrapper PID disappears, and reaps every helper on success or timeout.
|
|
236
|
+
docker_daemon_reachable_bounded() {
|
|
237
|
+
local probe_dir wrapper_pid watchdog_pid probe_rc=0 watchdog_sleep_pid
|
|
238
|
+
|
|
239
|
+
probe_dir=$(mktemp -d "$task_uai_dir/.docker-info-watchdog.XXXXXX")
|
|
240
|
+
(
|
|
241
|
+
runtime_probe_pid=""
|
|
242
|
+
finish_runtime_probe() {
|
|
243
|
+
local wrapper_rc=$?
|
|
244
|
+
trap - EXIT
|
|
245
|
+
: > "$probe_dir/completed"
|
|
246
|
+
exit "$wrapper_rc"
|
|
247
|
+
}
|
|
248
|
+
terminate_runtime_probe() {
|
|
249
|
+
local signal_rc="$1"
|
|
250
|
+
trap - TERM INT
|
|
251
|
+
if [ -n "$runtime_probe_pid" ]; then
|
|
252
|
+
kill -TERM "$runtime_probe_pid" >/dev/null 2>&1 || true
|
|
253
|
+
sleep 2
|
|
254
|
+
kill -KILL "$runtime_probe_pid" >/dev/null 2>&1 || true
|
|
255
|
+
wait "$runtime_probe_pid" 2>/dev/null || true
|
|
256
|
+
fi
|
|
257
|
+
exit "$signal_rc"
|
|
258
|
+
}
|
|
259
|
+
trap finish_runtime_probe EXIT
|
|
260
|
+
trap 'terminate_runtime_probe 143' TERM
|
|
261
|
+
trap 'terminate_runtime_probe 130' INT
|
|
262
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
263
|
+
"$CONTAINER_CLI" system status >/dev/null 2>&1 &
|
|
264
|
+
else
|
|
265
|
+
docker info --format '{{.ServerVersion}}' >/dev/null 2>&1 &
|
|
266
|
+
fi
|
|
267
|
+
runtime_probe_pid=$!
|
|
268
|
+
if wait "$runtime_probe_pid"; then exit 0; else exit $?; fi
|
|
269
|
+
) &
|
|
270
|
+
wrapper_pid=$!
|
|
271
|
+
|
|
272
|
+
(
|
|
273
|
+
watchdog_sleep_pid=""
|
|
274
|
+
cancel_probe_watchdog() {
|
|
275
|
+
trap - TERM INT
|
|
276
|
+
if [ -n "$watchdog_sleep_pid" ]; then
|
|
277
|
+
kill -TERM "$watchdog_sleep_pid" >/dev/null 2>&1 || true
|
|
278
|
+
wait "$watchdog_sleep_pid" 2>/dev/null || true
|
|
279
|
+
fi
|
|
280
|
+
exit 0
|
|
281
|
+
}
|
|
282
|
+
trap cancel_probe_watchdog TERM INT
|
|
283
|
+
sleep 15 &
|
|
284
|
+
watchdog_sleep_pid=$!
|
|
285
|
+
if ! wait "$watchdog_sleep_pid"; then exit 0; fi
|
|
286
|
+
watchdog_sleep_pid=""
|
|
287
|
+
if [ ! -f "$probe_dir/completed" ] \
|
|
288
|
+
&& kill -0 "$wrapper_pid" >/dev/null 2>&1; then
|
|
289
|
+
: > "$probe_dir/timed-out"
|
|
290
|
+
kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
|
|
291
|
+
sleep 5 &
|
|
292
|
+
watchdog_sleep_pid=$!
|
|
293
|
+
wait "$watchdog_sleep_pid" 2>/dev/null || true
|
|
294
|
+
watchdog_sleep_pid=""
|
|
295
|
+
if [ ! -f "$probe_dir/completed" ]; then
|
|
296
|
+
kill -KILL "$wrapper_pid" >/dev/null 2>&1 || true
|
|
297
|
+
fi
|
|
298
|
+
fi
|
|
299
|
+
) &
|
|
300
|
+
watchdog_pid=$!
|
|
301
|
+
|
|
302
|
+
if wait "$wrapper_pid"; then probe_rc=0; else probe_rc=$?; fi
|
|
303
|
+
kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
|
|
304
|
+
wait "$watchdog_pid" 2>/dev/null || true
|
|
305
|
+
[ ! -f "$probe_dir/timed-out" ] || probe_rc=124
|
|
306
|
+
rm -f "$probe_dir/completed" "$probe_dir/timed-out"
|
|
307
|
+
rmdir "$probe_dir" 2>/dev/null || true
|
|
308
|
+
return "$probe_rc"
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# A Docker-dependent step can fail for its own, degradable reason (for example,
|
|
312
|
+
# uai-init exits nonzero while the container remains healthy) or because the
|
|
313
|
+
# daemon disappeared after TypeScript admitted task-up. Only the latter is
|
|
314
|
+
# retryable host infrastructure state. Re-probe before translating a failed
|
|
315
|
+
# Docker step into an operation-specific error or warning so task-up never
|
|
316
|
+
# publishes status=running against an unknown backend.
|
|
317
|
+
require_docker_after_failed_step() {
|
|
318
|
+
local step_label="$1" runtime_name="Docker"
|
|
319
|
+
[ "$TASK_RUNTIME_MODE" != "apple-container" ] \
|
|
320
|
+
|| runtime_name="the Apple container runtime"
|
|
321
|
+
if ! docker_daemon_reachable_bounded; then
|
|
322
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
323
|
+
"The container runtime stopped responding during task startup. Retry after $runtime_name is running again." \
|
|
324
|
+
"$step_label"
|
|
325
|
+
fi
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Stop an app that failed a post-create authority proof and verify it is no
|
|
329
|
+
# longer running before any credential, dependency, editor, or agent work can
|
|
330
|
+
# begin. Preserve the container/worktree for diagnosis; a retry recreates the
|
|
331
|
+
# container from Uai's current Compose contract.
|
|
332
|
+
quarantine_untrusted_app_container() {
|
|
333
|
+
local authority_running=""
|
|
334
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
335
|
+
# `container stop` is idempotent; proven absence quarantines as well as a
|
|
336
|
+
# stopped state does. An inspect that fails WITHOUT naming this container
|
|
337
|
+
# as not-found proves nothing and must not read as stopped.
|
|
338
|
+
if ! crt stop --time 30 "$app_container" >/dev/null 2>&1; then
|
|
339
|
+
require_docker_after_failed_step "quarantine untrusted task container"
|
|
340
|
+
fi
|
|
341
|
+
apple_quarantine_proven=""
|
|
342
|
+
quarantine_state=$(crt inspect "$app_container" 2>/dev/null \
|
|
343
|
+
| jq -r '.[0].status.state // empty' 2>/dev/null) || quarantine_state=""
|
|
344
|
+
if [ -n "$quarantine_state" ] && [ "$quarantine_state" != "running" ]; then
|
|
345
|
+
apple_quarantine_proven=1
|
|
346
|
+
elif [ -z "$quarantine_state" ] \
|
|
347
|
+
&& apple_container_proven_absent "$app_container"; then
|
|
348
|
+
apple_quarantine_proven=1
|
|
349
|
+
fi
|
|
350
|
+
if [ -z "$apple_quarantine_proven" ]; then
|
|
351
|
+
crt stop --signal KILL "$app_container" >/dev/null 2>&1 || true
|
|
352
|
+
quarantine_state=$(crt inspect "$app_container" 2>/dev/null \
|
|
353
|
+
| jq -r '.[0].status.state // empty' 2>/dev/null) || quarantine_state=""
|
|
354
|
+
if [ -n "$quarantine_state" ] && [ "$quarantine_state" != "running" ]; then
|
|
355
|
+
apple_quarantine_proven=1
|
|
356
|
+
elif [ -z "$quarantine_state" ] \
|
|
357
|
+
&& apple_container_proven_absent "$app_container"; then
|
|
358
|
+
apple_quarantine_proven=1
|
|
359
|
+
fi
|
|
360
|
+
fi
|
|
361
|
+
[ -n "$apple_quarantine_proven" ]
|
|
362
|
+
return
|
|
363
|
+
fi
|
|
364
|
+
if ! docker stop --time 30 "$app_container" >/dev/null 2>&1; then
|
|
365
|
+
require_docker_after_failed_step "quarantine untrusted task container"
|
|
366
|
+
fi
|
|
367
|
+
authority_running=$(docker ps -q --filter "name=^/${app_container}$") \
|
|
368
|
+
|| require_docker_after_failed_step "verify task-container quarantine"
|
|
369
|
+
if [ -n "$authority_running" ]; then
|
|
370
|
+
docker kill "$app_container" >/dev/null 2>&1 || true
|
|
371
|
+
authority_running=$(docker ps -q --filter "name=^/${app_container}$") \
|
|
372
|
+
|| require_docker_after_failed_step "verify task-container quarantine"
|
|
373
|
+
fi
|
|
374
|
+
[ -z "$authority_running" ]
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
# Run the maintenance container under two independent deadlines. The inner GNU
|
|
378
|
+
# timeout bounds the actual writer (including the shared-volume lock wait); the
|
|
379
|
+
# host watchdog is slightly looser and covers a Docker CLI that hangs before it
|
|
380
|
+
# creates or starts the container. Bash 3.2 has no `wait -n` or `timeout`, so
|
|
381
|
+
# keep and reap both exact child PIDs explicitly.
|
|
382
|
+
run_runtime_materializer() {
|
|
383
|
+
local runtime_image="$1" wrapper_pid watchdog_pid materialization_rc=0
|
|
384
|
+
local watchdog_dir watchdog_sleep_pid
|
|
385
|
+
|
|
386
|
+
watchdog_dir=$(mktemp -d "$task_uai_dir/.runtime-materializer-watchdog.XXXXXX")
|
|
387
|
+
|
|
388
|
+
# The completion marker is written from the wrapper's EXIT trap before its
|
|
389
|
+
# PID can disappear. The watchdog consults it before every signal, closing
|
|
390
|
+
# the wait/reap/PID-reuse race around a just-finished Docker client.
|
|
391
|
+
(
|
|
392
|
+
runtime_docker_pid=""
|
|
393
|
+
finish_runtime_wrapper() {
|
|
394
|
+
local wrapper_rc=$?
|
|
395
|
+
trap - EXIT
|
|
396
|
+
: > "$watchdog_dir/completed"
|
|
397
|
+
exit "$wrapper_rc"
|
|
398
|
+
}
|
|
399
|
+
terminate_runtime_wrapper() {
|
|
400
|
+
local signal_rc="$1"
|
|
401
|
+
trap - TERM INT
|
|
402
|
+
if [ -n "$runtime_docker_pid" ]; then
|
|
403
|
+
# Do not reap before the grace period: the exact child PID therefore
|
|
404
|
+
# cannot be reused between TERM and KILL.
|
|
405
|
+
kill -TERM "$runtime_docker_pid" >/dev/null 2>&1 || true
|
|
406
|
+
sleep 10
|
|
407
|
+
kill -KILL "$runtime_docker_pid" >/dev/null 2>&1 || true
|
|
408
|
+
wait "$runtime_docker_pid" 2>/dev/null || true
|
|
409
|
+
fi
|
|
410
|
+
exit "$signal_rc"
|
|
411
|
+
}
|
|
412
|
+
trap finish_runtime_wrapper EXIT
|
|
413
|
+
trap 'terminate_runtime_wrapper 143' TERM
|
|
414
|
+
trap 'terminate_runtime_wrapper 130' INT
|
|
415
|
+
|
|
416
|
+
# ADR-108: on the Apple runtime this function is never called —
|
|
417
|
+
# materialization runs inside the app container against its in-guest
|
|
418
|
+
# overlay. This maintenance-container path is Docker-only.
|
|
419
|
+
docker run --rm --user node --group-add "$task_container_gid" \
|
|
420
|
+
--workdir /workspace \
|
|
421
|
+
-v "$task_workspace:/workspace:ro" \
|
|
422
|
+
-v "$ASDF_VOLUME:/opt/asdf-data:rw" \
|
|
423
|
+
-v "$RUNTIME_MATERIALIZER_SOURCE:/usr/local/bin/uai-materialize-runtimes:ro" \
|
|
424
|
+
-v "$COREPACK_VERSION_SOURCE:/usr/local/share/uai/corepack-version:ro" \
|
|
425
|
+
-v "$runtime_projects_file:/run/uai/runtime-projects:ro" \
|
|
426
|
+
--env LD_PRELOAD= \
|
|
427
|
+
--env LD_AUDIT= \
|
|
428
|
+
--env LD_LIBRARY_PATH= \
|
|
429
|
+
--env BASH_ENV= \
|
|
430
|
+
--env ENV= \
|
|
431
|
+
--entrypoint /usr/bin/env \
|
|
432
|
+
"$runtime_image" \
|
|
433
|
+
-i \
|
|
434
|
+
HOME=/home/node \
|
|
435
|
+
ASDF_DIR=/opt/asdf \
|
|
436
|
+
ASDF_DATA_DIR=/opt/asdf-data \
|
|
437
|
+
PATH=/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
|
438
|
+
/usr/bin/timeout --signal=TERM --kill-after=10s 600s \
|
|
439
|
+
/usr/local/bin/uai-materialize-runtimes >&2 &
|
|
440
|
+
runtime_docker_pid=$!
|
|
441
|
+
if wait "$runtime_docker_pid"; then
|
|
442
|
+
exit 0
|
|
443
|
+
else
|
|
444
|
+
exit $?
|
|
445
|
+
fi
|
|
446
|
+
) &
|
|
447
|
+
wrapper_pid=$!
|
|
448
|
+
|
|
449
|
+
(
|
|
450
|
+
watchdog_sleep_pid=""
|
|
451
|
+
cancel_runtime_watchdog() {
|
|
452
|
+
trap - TERM INT
|
|
453
|
+
if [ -n "$watchdog_sleep_pid" ]; then
|
|
454
|
+
kill -TERM "$watchdog_sleep_pid" >/dev/null 2>&1 || true
|
|
455
|
+
wait "$watchdog_sleep_pid" 2>/dev/null || true
|
|
456
|
+
fi
|
|
457
|
+
exit 0
|
|
458
|
+
}
|
|
459
|
+
trap cancel_runtime_watchdog TERM INT
|
|
460
|
+
|
|
461
|
+
sleep 620 &
|
|
462
|
+
watchdog_sleep_pid=$!
|
|
463
|
+
if ! wait "$watchdog_sleep_pid"; then
|
|
464
|
+
exit 0
|
|
465
|
+
fi
|
|
466
|
+
watchdog_sleep_pid=""
|
|
467
|
+
|
|
468
|
+
if [ ! -f "$watchdog_dir/completed" ] \
|
|
469
|
+
&& kill -0 "$wrapper_pid" >/dev/null 2>&1; then
|
|
470
|
+
: > "$watchdog_dir/timed-out"
|
|
471
|
+
kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
|
|
472
|
+
# The wrapper gives its exact Docker child 10 seconds before KILL. This
|
|
473
|
+
# slightly looser guard is only a backstop for a wedged signal handler.
|
|
474
|
+
sleep 15 &
|
|
475
|
+
watchdog_sleep_pid=$!
|
|
476
|
+
wait "$watchdog_sleep_pid" 2>/dev/null || true
|
|
477
|
+
watchdog_sleep_pid=""
|
|
478
|
+
if [ ! -f "$watchdog_dir/completed" ]; then
|
|
479
|
+
kill -KILL "$wrapper_pid" >/dev/null 2>&1 || true
|
|
480
|
+
fi
|
|
481
|
+
fi
|
|
482
|
+
) &
|
|
483
|
+
watchdog_pid=$!
|
|
484
|
+
|
|
485
|
+
cancel_runtime_materializer() {
|
|
486
|
+
local signal_rc="$1"
|
|
487
|
+
trap - TERM INT
|
|
488
|
+
if [ ! -f "$watchdog_dir/completed" ]; then
|
|
489
|
+
kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
|
|
490
|
+
fi
|
|
491
|
+
kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
|
|
492
|
+
wait "$watchdog_pid" 2>/dev/null || true
|
|
493
|
+
wait "$wrapper_pid" 2>/dev/null || true
|
|
494
|
+
rm -f "$watchdog_dir/completed" "$watchdog_dir/timed-out"
|
|
495
|
+
rmdir "$watchdog_dir" 2>/dev/null || true
|
|
496
|
+
exit "$signal_rc"
|
|
497
|
+
}
|
|
498
|
+
trap 'cancel_runtime_materializer 143' TERM
|
|
499
|
+
trap 'cancel_runtime_materializer 130' INT
|
|
500
|
+
|
|
501
|
+
if wait "$wrapper_pid"; then
|
|
502
|
+
materialization_rc=0
|
|
503
|
+
else
|
|
504
|
+
materialization_rc=$?
|
|
505
|
+
fi
|
|
506
|
+
kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
|
|
507
|
+
wait "$watchdog_pid" 2>/dev/null || true
|
|
508
|
+
|
|
509
|
+
trap - TERM INT
|
|
510
|
+
if [ -f "$watchdog_dir/timed-out" ]; then
|
|
511
|
+
materialization_rc=124
|
|
512
|
+
fi
|
|
513
|
+
rm -f "$watchdog_dir/completed" "$watchdog_dir/timed-out"
|
|
514
|
+
rmdir "$watchdog_dir" 2>/dev/null || true
|
|
515
|
+
return "$materialization_rc"
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
# -----------------------------------------------------------------------------
|
|
519
|
+
# 1. Read rows.
|
|
520
|
+
# -----------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
step "TASK_NOT_FOUND" "read task row"
|
|
523
|
+
task_json=$(db_get_task "$task_id")
|
|
524
|
+
[ "$(jq 'length' <<<"$task_json")" -ge 1 ] \
|
|
525
|
+
|| emit_err "TASK_NOT_FOUND" "no such task: $task_id" "read task row"
|
|
526
|
+
|
|
527
|
+
task_branch=$(jq -r '.[0].branch' <<<"$task_json")
|
|
528
|
+
UAI_RETRY_STATUS=$(jq -r '.[0].status // ""' <<<"$task_json")
|
|
529
|
+
|
|
530
|
+
# The standard container runs as node:node (1000:1000), while Docker Desktop
|
|
531
|
+
# hosts commonly run as 501:20 and Linux hosts may use another uid/gid. Share
|
|
532
|
+
# only this task's writable trees through the host's effective group. A root
|
|
533
|
+
# host is the exception: never add the unprivileged container user to gid 0;
|
|
534
|
+
# root can chgrp the task trees directly to node's gid instead.
|
|
535
|
+
task_container_gid=$(id -g)
|
|
536
|
+
if [ "$(id -u)" = "0" ]; then
|
|
537
|
+
task_container_gid=1000
|
|
538
|
+
elif [ "$task_container_gid" = "0" ]; then
|
|
539
|
+
task_container_gid=""
|
|
540
|
+
for candidate_gid in $(id -G); do
|
|
541
|
+
if [ "$candidate_gid" != "0" ]; then
|
|
542
|
+
task_container_gid="$candidate_gid"
|
|
543
|
+
break
|
|
544
|
+
fi
|
|
545
|
+
done
|
|
546
|
+
fi
|
|
547
|
+
case "$task_container_gid" in
|
|
548
|
+
''|*[!0-9]*)
|
|
549
|
+
emit_err "WORKTREE_FAILED" \
|
|
550
|
+
"Moondeck could not resolve a safe host group for the task container" \
|
|
551
|
+
"resolve task container filesystem group"
|
|
552
|
+
;;
|
|
553
|
+
esac
|
|
554
|
+
|
|
555
|
+
step "PROJECT_NOT_FOUND" "read task projects"
|
|
556
|
+
projects_json=$(db_get_projects_for_task "$task_id")
|
|
557
|
+
project_count=$(jq 'length' <<<"$projects_json")
|
|
558
|
+
if ! jq -e '
|
|
559
|
+
map(.slug) as $slugs
|
|
560
|
+
| all($slugs[];
|
|
561
|
+
type == "string"
|
|
562
|
+
and test("^[a-z0-9-]{1,64}$"))
|
|
563
|
+
and (($slugs | unique | length) == ($slugs | length))
|
|
564
|
+
' <<<"$projects_json" >/dev/null; then
|
|
565
|
+
emit_err "WORKTREE_FAILED" \
|
|
566
|
+
"Moondeck refused an unsafe or duplicate project workspace slug" \
|
|
567
|
+
"validate task projects"
|
|
568
|
+
fi
|
|
569
|
+
runtime_project_slugs=$(jq -r '.[].slug' <<<"$projects_json")
|
|
570
|
+
|
|
571
|
+
# -----------------------------------------------------------------------------
|
|
572
|
+
# 2. Lock + status=starting.
|
|
573
|
+
# -----------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
step "LOCKED" "acquire task lock"
|
|
576
|
+
lock_task "$task_id"
|
|
577
|
+
|
|
578
|
+
step "DB_UPDATE_FAILED" "mark task starting"
|
|
579
|
+
db_update_task "$task_id" \
|
|
580
|
+
"status='starting'" \
|
|
581
|
+
"started_at=(unixepoch()*1000)" \
|
|
582
|
+
"code_server_port=NULL" \
|
|
583
|
+
"preview_ports='[]'" \
|
|
584
|
+
"worktree_path='$(sql_escape "$task_dir")'" \
|
|
585
|
+
"compose_project='$(sql_escape "$compose_project")'"
|
|
586
|
+
|
|
587
|
+
# `.uai` is not container-mounted, but validate these host-owned parents before
|
|
588
|
+
# using the previous compose file. The workspace itself is checked after the
|
|
589
|
+
# old container is removed so it cannot race the check.
|
|
590
|
+
reject_unsafe_host_directory "$task_dir" "the task storage root"
|
|
591
|
+
reject_unsafe_host_directory "$task_uai_dir" "the task runtime directory"
|
|
592
|
+
|
|
593
|
+
# A previous task container can write both its workspace and its task-private
|
|
594
|
+
# Git repository. Remove it before the host parses either repository on resume.
|
|
595
|
+
# This also cuts off legacy containers that still have the old shared
|
|
596
|
+
# `repo.git` bind mount before we inspect their worktree layout below.
|
|
597
|
+
step "COMPOSE_DOWN_FAILED" "remove prior task containers"
|
|
598
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
599
|
+
# Selection fencing guarantees an apple-mode host has no Docker task rows,
|
|
600
|
+
# so the only possible prior container is this task's fixed name. `inspect`
|
|
601
|
+
# exiting nonzero afterwards is the absence proof.
|
|
602
|
+
crt stop --time 10 "$app_container" >/dev/null 2>&1 || true
|
|
603
|
+
crt delete --force "$app_container" >/dev/null 2>&1 || true
|
|
604
|
+
if crt inspect "$app_container" >/dev/null 2>&1; then
|
|
605
|
+
emit_err "COMPOSE_DOWN_FAILED" \
|
|
606
|
+
"the previous task container is still present; Moondeck will not let the host access its writable Git repository until that container is removed" \
|
|
607
|
+
"remove prior task containers"
|
|
608
|
+
fi
|
|
609
|
+
if ! apple_container_proven_absent "$app_container"; then
|
|
610
|
+
emit_err "COMPOSE_DOWN_FAILED" \
|
|
611
|
+
"could not verify removal of the previous task container" \
|
|
612
|
+
"remove prior task containers"
|
|
613
|
+
fi
|
|
614
|
+
else
|
|
615
|
+
uai_require_compose_plugin
|
|
616
|
+
if [ -f "$task_uai_dir/docker-compose.yml" ]; then
|
|
617
|
+
docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" \
|
|
618
|
+
down --remove-orphans >/dev/null 2>&1 || true
|
|
619
|
+
else
|
|
620
|
+
docker compose -p "$compose_project" down --remove-orphans \
|
|
621
|
+
>/dev/null 2>&1 || true
|
|
622
|
+
fi
|
|
623
|
+
prior_containers=""
|
|
624
|
+
if ! prior_containers=$(docker ps -aq \
|
|
625
|
+
--filter "label=com.docker.compose.project=$compose_project"); then
|
|
626
|
+
emit_err "COMPOSE_DOWN_FAILED" \
|
|
627
|
+
"could not verify removal of the previous task container" \
|
|
628
|
+
"remove prior task containers"
|
|
629
|
+
fi
|
|
630
|
+
if [ -n "$prior_containers" ]; then
|
|
631
|
+
emit_err "COMPOSE_DOWN_FAILED" \
|
|
632
|
+
"the previous task container is still running or stopped; Moondeck will not let the host access its writable Git repository until that container is removed" \
|
|
633
|
+
"remove prior task containers"
|
|
634
|
+
fi
|
|
635
|
+
fi
|
|
636
|
+
reject_unsafe_host_directory "$task_workspace" "the task workspace"
|
|
637
|
+
mkdir -p "$task_workspace"
|
|
638
|
+
quarantine_workspace_runtime_shadow
|
|
639
|
+
|
|
640
|
+
# Git resolves symlinked ancestors when it writes a linked worktree's absolute
|
|
641
|
+
# `.git` pointer. Keep the configured (lexical) task paths as Uai's stable host
|
|
642
|
+
# paths, but also remember their physical aliases for validation and bind-mount
|
|
643
|
+
# destinations. The task root itself was rejected above if it was a symlink;
|
|
644
|
+
# `pwd -P` therefore resolves only trusted ancestors such as a migrated
|
|
645
|
+
# UAI_WORKSPACE_ROOT (`~/.uai-host-app/workspace -> ~/.uai`).
|
|
646
|
+
step "WORKTREE_FAILED" "resolve task workspace paths"
|
|
647
|
+
if ! task_dir_physical=$(cd "$task_dir" && pwd -P) \
|
|
648
|
+
|| [ -z "$task_dir_physical" ]; then
|
|
649
|
+
emit_err "WORKTREE_FAILED" \
|
|
650
|
+
"Moondeck could not resolve the task workspace's physical host path" \
|
|
651
|
+
"resolve task workspace paths"
|
|
652
|
+
fi
|
|
653
|
+
task_workspace_physical="$task_dir_physical/workspace"
|
|
654
|
+
task_uai_dir_physical="$task_dir_physical/.uai"
|
|
655
|
+
|
|
656
|
+
# -----------------------------------------------------------------------------
|
|
657
|
+
# 3. Bare mirrors + worktrees, one per selected project.
|
|
658
|
+
# -----------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
# ADR-154 §12: a task on cloud credentials tells the runner where it is
|
|
661
|
+
# with an event file in this private directory (event-clone-done, then
|
|
662
|
+
# event-runtime-ready), and finds the runner's answer to runtime-ready there
|
|
663
|
+
# too. Never on stderr, which a subprocess shares; the path is unset here so
|
|
664
|
+
# no process task-up starts inherits it; nothing blocks.
|
|
665
|
+
uai_handshake_dir="${UAI_TASK_HANDSHAKE_DIR:-}"
|
|
666
|
+
unset UAI_TASK_HANDSHAKE_DIR
|
|
667
|
+
# A task on cloud credentials: every host Git operation runs with nothing of
|
|
668
|
+
# the runner's own (no home, so no .netrc, ~/.gitconfig or ~/.ssh/config),
|
|
669
|
+
# by exactly the route its lease names, and never falls back to another.
|
|
670
|
+
uai_leased_git=0
|
|
671
|
+
if [ -n "$uai_handshake_dir" ]; then
|
|
672
|
+
uai_leased_git=1
|
|
673
|
+
fi
|
|
674
|
+
# ADR-154 §6: no default slot. A task on cloud credentials gets none of the
|
|
675
|
+
# runner owner's own engine sign-ins: not their tokens in its env (the
|
|
676
|
+
# runner strips them from ours), not their config directories in its home.
|
|
677
|
+
uai_on_cloud_credentials=$uai_leased_git
|
|
678
|
+
|
|
679
|
+
# Prepare the optional SSH identity. Connected users fetch over HTTPS with
|
|
680
|
+
# their per-host GitHub credential; SSH is used for Git transport only when the
|
|
681
|
+
# task owner has not connected GitHub. `IdentitiesOnly=yes` +
|
|
682
|
+
# `IdentityAgent=none` keep that fallback from silently selecting a different
|
|
683
|
+
# personal key or blocking on an agent approval prompt.
|
|
684
|
+
#
|
|
685
|
+
# ADR-029: use only the TASK CREATOR's per-user key (written to
|
|
686
|
+
# $UAI_TASK_IDENTITY_DIR by the orchestrator) so clone + push run as them. A
|
|
687
|
+
# task must never borrow the operator's shared identity: that would authorize
|
|
688
|
+
# one user as a different principal.
|
|
689
|
+
#
|
|
690
|
+
# ADR-154 §12: a task on cloud credentials gets no key file. The runner
|
|
691
|
+
# holds the owner's key in its own memory behind a private agent socket for
|
|
692
|
+
# exactly as long as this clone ($UAI_TASK_SSH_AUTH_SOCK, with the public
|
|
693
|
+
# key naming the identity); ssh asks that agent and nothing else.
|
|
694
|
+
if [ -n "${UAI_TASK_IDENTITY_DIR:-}" ] && [ -f "${UAI_TASK_IDENTITY_DIR}/id_ed25519" ]; then
|
|
695
|
+
uai_identity_key="$UAI_TASK_IDENTITY_DIR/id_ed25519"
|
|
696
|
+
log "git over SSH using the task creator's per-user key"
|
|
697
|
+
else
|
|
698
|
+
uai_identity_key=""
|
|
699
|
+
fi
|
|
700
|
+
uai_task_ssh_auth_sock="${UAI_TASK_SSH_AUTH_SOCK:-}"
|
|
701
|
+
uai_task_ssh_public_key="${UAI_TASK_SSH_PUBLIC_KEY:-}"
|
|
702
|
+
unset UAI_TASK_SSH_AUTH_SOCK UAI_TASK_SSH_PUBLIC_KEY
|
|
703
|
+
uai_has_ssh_identity=0
|
|
704
|
+
uai_leased_ssh=0
|
|
705
|
+
uai_known_hosts="$UAI_DATA_DIR/identity/known_hosts"
|
|
706
|
+
if [ -n "$uai_identity_key" ] && [ -f "$uai_identity_key" ]; then
|
|
707
|
+
uai_has_ssh_identity=1
|
|
708
|
+
GIT_SSH_COMMAND="ssh -i $uai_identity_key -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
|
|
709
|
+
[ -f "$uai_known_hosts" ] && GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o UserKnownHostsFile=$uai_known_hosts"
|
|
710
|
+
export GIT_SSH_COMMAND
|
|
711
|
+
log "git over SSH using $uai_identity_key (1Password agent bypassed)"
|
|
712
|
+
elif [ -n "$uai_task_ssh_auth_sock" ] && [ -S "$uai_task_ssh_auth_sock" ] && [ -f "$uai_task_ssh_public_key" ]; then
|
|
713
|
+
uai_has_ssh_identity=1
|
|
714
|
+
uai_leased_ssh=1
|
|
715
|
+
GIT_SSH_COMMAND="ssh -F /dev/null -i $uai_task_ssh_public_key -o IdentitiesOnly=yes -o IdentityAgent=$uai_task_ssh_auth_sock -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
|
|
716
|
+
[ -f "$uai_known_hosts" ] && GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o UserKnownHostsFile=$uai_known_hosts"
|
|
717
|
+
export GIT_SSH_COMMAND
|
|
718
|
+
log "git over SSH using the task owner's key, held in the station's memory"
|
|
719
|
+
else
|
|
720
|
+
log "no SSH identity for the task owner — SSH transport fallback unavailable"
|
|
721
|
+
fi
|
|
722
|
+
|
|
723
|
+
# Only a non-secret path reaches this script. The task owner's token lives in
|
|
724
|
+
# a private per-task `git credential-cache` daemon, or, for a task on cloud
|
|
725
|
+
# credentials (ADR-154 §12), in the runner's own memory behind the helper
|
|
726
|
+
# $UAI_GITHUB_CREDENTIAL_HELPER asks for exactly as long as this clone. It is
|
|
727
|
+
# never placed in the environment, argv, remote URL, mirror config, Compose,
|
|
728
|
+
# or logs. Only the daemon's token is installed in the container below; a
|
|
729
|
+
# task on cloud credentials has its GitHub configuration written by the
|
|
730
|
+
# runner, which records it for cleanup.
|
|
731
|
+
github_credential_socket="${UAI_GITHUB_CREDENTIAL_SOCKET:-}"
|
|
732
|
+
github_credential_helper="${UAI_GITHUB_CREDENTIAL_HELPER:-}"
|
|
733
|
+
unset UAI_GITHUB_CREDENTIAL_SOCKET UAI_GITHUB_CREDENTIAL_HELPER
|
|
734
|
+
if [ -n "$github_credential_helper" ]; then
|
|
735
|
+
github_git_helper="$github_credential_helper"
|
|
736
|
+
elif [ -n "$github_credential_socket" ]; then
|
|
737
|
+
github_git_helper="cache --socket=$github_credential_socket"
|
|
738
|
+
else
|
|
739
|
+
github_git_helper=""
|
|
740
|
+
fi
|
|
741
|
+
if [ -n "$github_git_helper" ]; then
|
|
742
|
+
uai_git_transport="https"
|
|
743
|
+
else
|
|
744
|
+
# Refined after each GitHub fetch below: select SSH only when it was the
|
|
745
|
+
# mode that actually worked. A present-but-unregistered key must not turn a
|
|
746
|
+
# successfully anonymous public clone into broken SSH inside the container.
|
|
747
|
+
uai_git_transport="anonymous"
|
|
748
|
+
fi
|
|
749
|
+
uai_used_ssh_transport=0
|
|
750
|
+
|
|
751
|
+
# ADR-154 §12: a task on cloud credentials runs every host Git operation in an
|
|
752
|
+
# environment rebuilt from an allowlist (env -i), never the runner process's
|
|
753
|
+
# own: an ambient GIT_CONFIG_COUNT and its numbered pairs, GIT_EXEC_PATH, an
|
|
754
|
+
# askpass or an agent socket could otherwise redirect or answer its route.
|
|
755
|
+
# What passes is what finds git and ssh and reaches the network (the PATH, the
|
|
756
|
+
# temporary directory, proxies, the CA bundle), never a credential, a
|
|
757
|
+
# configuration or a route.
|
|
758
|
+
uai_git_passthrough=()
|
|
759
|
+
if [ "$uai_leased_git" = "1" ]; then
|
|
760
|
+
for uai_git_name in PATH TMPDIR http_proxy https_proxy all_proxy no_proxy \
|
|
761
|
+
HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY SSL_CERT_FILE SSL_CERT_DIR; do
|
|
762
|
+
if [ -n "${!uai_git_name:-}" ]; then
|
|
763
|
+
uai_git_passthrough+=("$uai_git_name=${!uai_git_name}")
|
|
764
|
+
fi
|
|
765
|
+
done
|
|
766
|
+
fi
|
|
767
|
+
|
|
768
|
+
# Runs git with the NAME=VALUE assignments before `--`, then `git "$@"`: for a
|
|
769
|
+
# task on cloud credentials in those, the allowlist and no home alone;
|
|
770
|
+
# otherwise on top of this process's environment, which the wrappers below
|
|
771
|
+
# narrow themselves.
|
|
772
|
+
uai_git() {
|
|
773
|
+
local uai_assign=()
|
|
774
|
+
while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do
|
|
775
|
+
uai_assign+=("$1")
|
|
776
|
+
shift
|
|
777
|
+
done
|
|
778
|
+
shift
|
|
779
|
+
if [ "$uai_leased_git" = "1" ]; then
|
|
780
|
+
env -i ${uai_git_passthrough[@]+"${uai_git_passthrough[@]}"} \
|
|
781
|
+
HOME=/nonexistent XDG_CONFIG_HOME=/nonexistent LC_ALL=C \
|
|
782
|
+
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \
|
|
783
|
+
${uai_assign[@]+"${uai_assign[@]}"} git "$@"
|
|
784
|
+
else
|
|
785
|
+
env ${uai_assign[@]+"${uai_assign[@]}"} git "$@"
|
|
786
|
+
fi
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
# ADR-154 §12: a task on cloud credentials that GitHub refused says which of
|
|
790
|
+
# its owner's credentials was refused (the lease's GitHub sign-in or SSH key)
|
|
791
|
+
# and where to fix that: Uai's Settings, never this host.
|
|
792
|
+
emit_leased_github_denial() {
|
|
793
|
+
case "$1" in
|
|
794
|
+
token)
|
|
795
|
+
emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
|
|
796
|
+
"GitHub denied the task owner's GitHub sign-in access to $2. Confirm that the Moondeck GitHub App is installed on the owning account or organization, this repository is included in its grant, and the owner can read it. Then start the task again." \
|
|
797
|
+
"$3"
|
|
798
|
+
;;
|
|
799
|
+
ssh)
|
|
800
|
+
emit_err "GITHUB_SSH_ACCESS_DENIED" \
|
|
801
|
+
"GitHub denied the task owner's SSH key access to $2. Register that key with an account that can read the repository (and authorize it for SAML SSO when required), or connect GitHub in Settings. Then start the task again." \
|
|
802
|
+
"$3"
|
|
803
|
+
;;
|
|
804
|
+
*)
|
|
805
|
+
emit_err "GITHUB_CONNECTION_REQUIRED" \
|
|
806
|
+
"The task owner has neither a GitHub sign-in nor an SSH key in Moondeck that can read $2. Connect GitHub in Settings, then start the task again." \
|
|
807
|
+
"$3"
|
|
808
|
+
;;
|
|
809
|
+
esac
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
git_with_github_credential() (
|
|
813
|
+
unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
|
|
814
|
+
GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
|
|
815
|
+
GIT_TRACE_PACKET GIT_TRACE_REDACT
|
|
816
|
+
export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
|
|
817
|
+
# If a container ever poisoned a shared mirror's URL config, fail closed
|
|
818
|
+
# instead of silently switching this operation to an SSH principal.
|
|
819
|
+
uai_git GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 -- \
|
|
820
|
+
-c core.hooksPath=/dev/null \
|
|
821
|
+
-c core.fsmonitor=false \
|
|
822
|
+
-c http.extraHeader= \
|
|
823
|
+
-c http.sslVerify=true \
|
|
824
|
+
-c credential.helper= \
|
|
825
|
+
-c "credential.helper=$github_git_helper" \
|
|
826
|
+
"$@"
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
# Command-scoped rewrite: the shared origin remains canonical HTTPS while this
|
|
830
|
+
# one task uses its SSH fallback identity.
|
|
831
|
+
git_with_github_ssh() (
|
|
832
|
+
unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
|
|
833
|
+
GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
|
|
834
|
+
GIT_TRACE_PACKET GIT_TRACE_REDACT
|
|
835
|
+
export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
|
|
836
|
+
# The SSH command chosen above: the task owner's identity, nothing else.
|
|
837
|
+
uai_git "GIT_SSH_COMMAND=${GIT_SSH_COMMAND:-/bin/false}" GIT_TERMINAL_PROMPT=0 -- \
|
|
838
|
+
-c core.hooksPath=/dev/null \
|
|
839
|
+
-c core.fsmonitor=false \
|
|
840
|
+
-c 'url.git@github.com:.insteadOf=https://github.com/' \
|
|
841
|
+
"$@"
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
# Clear every ambient credential helper so a public fallback cannot borrow the
|
|
845
|
+
# host operator's GitHub identity.
|
|
846
|
+
git_anonymous_https() (
|
|
847
|
+
unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
|
|
848
|
+
GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
|
|
849
|
+
GIT_TRACE_PACKET GIT_TRACE_REDACT
|
|
850
|
+
export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
|
|
851
|
+
uai_git GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 -- \
|
|
852
|
+
-c core.hooksPath=/dev/null \
|
|
853
|
+
-c core.fsmonitor=false \
|
|
854
|
+
-c http.extraHeader= \
|
|
855
|
+
-c http.sslVerify=true \
|
|
856
|
+
-c credential.helper= \
|
|
857
|
+
"$@"
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
# Host-only Git control plane. New task containers see only their self-contained
|
|
861
|
+
# private repo; the shared mirror is a setup-time fetch cache and is never
|
|
862
|
+
# mounted. Before every host operation we still rebuild its config from trusted
|
|
863
|
+
# project data and discard hooks, both to heal legacy tasks and to fail closed
|
|
864
|
+
# after a crash.
|
|
865
|
+
git_host_control() (
|
|
866
|
+
unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
|
|
867
|
+
GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
|
|
868
|
+
GIT_TRACE_PACKET GIT_TRACE_REDACT GIT_CONFIG_COUNT \
|
|
869
|
+
GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
|
|
870
|
+
export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
|
|
871
|
+
# This helper is for local repository bookkeeping and public/local remotes.
|
|
872
|
+
# It must never inherit the operator's SSH agent or credential helper. Every
|
|
873
|
+
# authenticated GitHub fetch goes through one of the explicit helpers above.
|
|
874
|
+
uai_git GIT_CONFIG_COUNT=3 \
|
|
875
|
+
GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null \
|
|
876
|
+
GIT_CONFIG_KEY_1=core.fsmonitor GIT_CONFIG_VALUE_1=false \
|
|
877
|
+
GIT_CONFIG_KEY_2=advice.defaultBranchName GIT_CONFIG_VALUE_2=false \
|
|
878
|
+
GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 -- \
|
|
879
|
+
-c credential.helper= \
|
|
880
|
+
"$@"
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# ADR-153 §4: record, once, the base a project's task worktree starts from, in
|
|
884
|
+
# the host control tree the container never sees, before anything in the task
|
|
885
|
+
# runs. The runner reads that commit's .env.schema from the host cache; a
|
|
886
|
+
# resume keeps the first record and never consults the worktree, which the
|
|
887
|
+
# task can rewrite. "oid <commit>" or "empty" (a branchless repository).
|
|
888
|
+
record_env_schema_base() {
|
|
889
|
+
local project_id="$1" base="$2" dir="$task_uai_dir/env-schemas" tmp
|
|
890
|
+
[ -e "$dir/$project_id.base" ] && return 0
|
|
891
|
+
mkdir -p "$dir" || return 1
|
|
892
|
+
chmod 700 "$dir" || return 1
|
|
893
|
+
tmp=$(mktemp "$dir/.base.XXXXXX") || return 1
|
|
894
|
+
if ! printf '%s\n' "$base" > "$tmp" || ! mv -f "$tmp" "$dir/$project_id.base"; then
|
|
895
|
+
rm -f "$tmp"
|
|
896
|
+
return 1
|
|
897
|
+
fi
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
assert_bare_repo_tree_safe() {
|
|
901
|
+
local repo="$1" code="$2" label="$3" bad_entry=""
|
|
902
|
+
if [ -L "$repo" ] || [ ! -d "$repo" ]; then
|
|
903
|
+
emit_err "$code" \
|
|
904
|
+
"Moondeck refused the $label because its repository root is missing or is a symbolic link: $repo" \
|
|
905
|
+
"validate $label"
|
|
906
|
+
fi
|
|
907
|
+
# Git should only ever create regular files and directories in these control
|
|
908
|
+
# trees. Reject links, FIFOs, sockets, and devices before any Git process can
|
|
909
|
+
# follow or block on a container-created entry.
|
|
910
|
+
if ! bad_entry=$(find "$repo" ! -type f ! -type d -print -quit 2>/dev/null); then
|
|
911
|
+
emit_err "$code" \
|
|
912
|
+
"Moondeck could not validate the $label before host Git access" \
|
|
913
|
+
"validate $label"
|
|
914
|
+
fi
|
|
915
|
+
if [ -n "$bad_entry" ]; then
|
|
916
|
+
emit_err "$code" \
|
|
917
|
+
"Moondeck refused the $label because it contains an unsafe non-file entry at $bad_entry. The task container has been removed; inspect or recreate the task before retrying." \
|
|
918
|
+
"validate $label"
|
|
919
|
+
fi
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
# Make a host-created task tree writable by the container's uid-1000 node user
|
|
923
|
+
# without making it world-writable or changing ownership away from the host.
|
|
924
|
+
# `find` deliberately does not follow repository symlinks. Setgid directories
|
|
925
|
+
# keep Git-created descendants in the shared group; core.sharedRepository
|
|
926
|
+
# below makes Git's own files group-writable as well.
|
|
927
|
+
set_task_entry_container_access() {
|
|
928
|
+
local entry="$1"
|
|
929
|
+
chgrp "$task_container_gid" "$entry" || return 1
|
|
930
|
+
if [ -d "$entry" ]; then
|
|
931
|
+
chmod ug+rwx,g+s "$entry" || return 1
|
|
932
|
+
else
|
|
933
|
+
chmod ug+rw "$entry" || return 1
|
|
934
|
+
fi
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
share_task_entry_with_container() {
|
|
938
|
+
local entry="$1" label="$2"
|
|
939
|
+
if ! set_task_entry_container_access "$entry"; then
|
|
940
|
+
emit_err "WORKTREE_FAILED" \
|
|
941
|
+
"Moondeck could not share the $label with the task container" \
|
|
942
|
+
"set task filesystem permissions"
|
|
943
|
+
fi
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
share_task_tree_with_container() {
|
|
947
|
+
local root="$1" label="$2" entry entry_list failed_entry=""
|
|
948
|
+
[ -d "$root" ] || return 0
|
|
949
|
+
|
|
950
|
+
entry_list=$(mktemp "/tmp/uai-task-tree.XXXXXX")
|
|
951
|
+
if ! find "$root" \( -type d -o -type f \) -print0 > "$entry_list"; then
|
|
952
|
+
rm -f "$entry_list"
|
|
953
|
+
emit_err "WORKTREE_FAILED" \
|
|
954
|
+
"Moondeck could not enumerate the complete $label before sharing it with the task container" \
|
|
955
|
+
"enumerate task filesystem"
|
|
956
|
+
fi
|
|
957
|
+
while IFS= read -r -d '' entry; do
|
|
958
|
+
if ! set_task_entry_container_access "$entry"; then
|
|
959
|
+
failed_entry="$entry"
|
|
960
|
+
break
|
|
961
|
+
fi
|
|
962
|
+
done < "$entry_list"
|
|
963
|
+
rm -f "$entry_list"
|
|
964
|
+
if [ -n "$failed_entry" ]; then
|
|
965
|
+
emit_err "WORKTREE_FAILED" \
|
|
966
|
+
"Moondeck could not share the complete $label with the task container" \
|
|
967
|
+
"set task filesystem permissions"
|
|
968
|
+
fi
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
sanitize_mirror_control_plane() {
|
|
972
|
+
local mirror="$1" origin="$2" config_tmp
|
|
973
|
+
assert_bare_repo_tree_safe "$mirror" "FETCH_FAILED" "host repository cache"
|
|
974
|
+
# Stage outside the bare repo so a predictable in-repo temporary path cannot
|
|
975
|
+
# be redirected even if a previous host operation crashed midway.
|
|
976
|
+
config_tmp=$(mktemp "$(dirname "$mirror")/.uai-config.XXXXXX")
|
|
977
|
+
git_host_control config --file "$config_tmp" core.repositoryformatversion 0
|
|
978
|
+
git_host_control config --file "$config_tmp" core.filemode true
|
|
979
|
+
git_host_control config --file "$config_tmp" core.bare true
|
|
980
|
+
git_host_control config --file "$config_tmp" remote.origin.url "$origin"
|
|
981
|
+
git_host_control config --file "$config_tmp" remote.origin.fetch \
|
|
982
|
+
'+refs/heads/*:refs/remotes/origin/*'
|
|
983
|
+
git_host_control config --file "$config_tmp" remote.pushDefault origin
|
|
984
|
+
git_host_control config --file "$config_tmp" push.autoSetupRemote true
|
|
985
|
+
mv -f "$config_tmp" "$mirror/config"
|
|
986
|
+
rm -rf "$mirror/hooks"
|
|
987
|
+
mkdir -p "$mirror/hooks"
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
sanitize_task_repo_control_plane() {
|
|
991
|
+
local repo="$1" origin="$2" branch="$3" bare="${4:-true}" config_tmp
|
|
992
|
+
local branch_remote="origin" branch_merge="refs/heads/$branch" candidate
|
|
993
|
+
assert_bare_repo_tree_safe "$repo" "WORKTREE_FAILED" \
|
|
994
|
+
"task-private repository"
|
|
995
|
+
|
|
996
|
+
# Preserve only the current branch's safe upstream from the container-
|
|
997
|
+
# writable config. Includes are disabled, the remote must remain `origin`,
|
|
998
|
+
# and the merge target must be a valid heads ref. Everything executable or
|
|
999
|
+
# host-sensitive (hooks, includes, helpers, fsmonitor) is rebuilt below.
|
|
1000
|
+
if [ -f "$repo/config" ]; then
|
|
1001
|
+
candidate=$(git_host_control config --no-includes --file "$repo/config" \
|
|
1002
|
+
--get "branch.$branch.remote" 2>/dev/null || true)
|
|
1003
|
+
[ "$candidate" = "origin" ] && branch_remote="$candidate"
|
|
1004
|
+
candidate=$(git_host_control config --no-includes --file "$repo/config" \
|
|
1005
|
+
--get "branch.$branch.merge" 2>/dev/null || true)
|
|
1006
|
+
case "$candidate" in
|
|
1007
|
+
refs/heads/*)
|
|
1008
|
+
if git_host_control check-ref-format "$candidate" >/dev/null 2>&1; then
|
|
1009
|
+
branch_merge="$candidate"
|
|
1010
|
+
fi
|
|
1011
|
+
;;
|
|
1012
|
+
esac
|
|
1013
|
+
fi
|
|
1014
|
+
|
|
1015
|
+
config_tmp=$(mktemp "$(dirname "$repo")/.uai-task-config.XXXXXX")
|
|
1016
|
+
git_host_control config --file "$config_tmp" core.repositoryformatversion 0
|
|
1017
|
+
git_host_control config --file "$config_tmp" core.filemode true
|
|
1018
|
+
git_host_control config --file "$config_tmp" core.bare "$bare"
|
|
1019
|
+
git_host_control config --file "$config_tmp" core.sharedRepository group
|
|
1020
|
+
git_host_control config --file "$config_tmp" remote.origin.url "$origin"
|
|
1021
|
+
git_host_control config --file "$config_tmp" remote.origin.fetch \
|
|
1022
|
+
'+refs/heads/*:refs/remotes/origin/*'
|
|
1023
|
+
git_host_control config --file "$config_tmp" remote.pushDefault origin
|
|
1024
|
+
git_host_control config --file "$config_tmp" push.autoSetupRemote true
|
|
1025
|
+
git_host_control config --file "$config_tmp" \
|
|
1026
|
+
"branch.$branch.remote" "$branch_remote"
|
|
1027
|
+
git_host_control config --file "$config_tmp" \
|
|
1028
|
+
"branch.$branch.merge" "$branch_merge"
|
|
1029
|
+
mv -f "$config_tmp" "$repo/config"
|
|
1030
|
+
rm -rf "$repo/hooks"
|
|
1031
|
+
mkdir -p "$repo/hooks"
|
|
1032
|
+
# mktemp is intentionally 0600. Once its trusted contents are atomically in
|
|
1033
|
+
# place, grant only the selected task group access; this must run on every
|
|
1034
|
+
# resume even after the one-time tree migration marker exists.
|
|
1035
|
+
share_task_entry_with_container "$repo/config" \
|
|
1036
|
+
"task-private Git config"
|
|
1037
|
+
share_task_entry_with_container "$repo/hooks" \
|
|
1038
|
+
"task-private Git hooks directory"
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
# ADR-062 shared files: two host-resident roots mounted into the container
|
|
1042
|
+
# when the task's mode isn't "off". Created here so Docker doesn't create
|
|
1043
|
+
# them root-owned at mount time. Mode defaults to ro; org mount only when
|
|
1044
|
+
# the cloud sent an org id (older clouds won't).
|
|
1045
|
+
shared_files_mode=$(jq -r '.[0].shared_files // "ro"' <<<"$task_json")
|
|
1046
|
+
shared_owner_org=$(jq -r '.[0].owner_org_id // ""' <<<"$task_json")
|
|
1047
|
+
shared_owner_user=$(jq -r '.[0].owner_user_id // ""' <<<"$task_json")
|
|
1048
|
+
# Colons break docker's src:dst:mode bind syntax, and personal org ids are
|
|
1049
|
+
# "personal:<userId>" — map ":" to "_". MUST match sharedDirName() in
|
|
1050
|
+
# host-agent/lib/shared-files.ts.
|
|
1051
|
+
shared_owner_org=$(printf '%s' "$shared_owner_org" | tr ':' '_')
|
|
1052
|
+
shared_owner_user=$(printf '%s' "$shared_owner_user" | tr ':' '_')
|
|
1053
|
+
shared_root="$UAI_WORKSPACE_ROOT/shared"
|
|
1054
|
+
# The workspace's own .moondeck is the only one the container can read (/workspace/.moondeck):
|
|
1055
|
+
# the CLI, the chat log and the attachments agents are told to write screenshots
|
|
1056
|
+
# into all live here. Create it unconditionally — it used to be made only when
|
|
1057
|
+
# shared files were on, and `attachments` was made in the unmounted control tree,
|
|
1058
|
+
# so every first browser_take_screenshot hit ENOENT (first seen 2026-08-24).
|
|
1059
|
+
reject_unsafe_host_directory "$task_workspace/.moondeck" \
|
|
1060
|
+
"the task workspace marker directory"
|
|
1061
|
+
mkdir -p "$task_workspace/.moondeck/attachments"
|
|
1062
|
+
if [ "$shared_files_mode" != "off" ]; then
|
|
1063
|
+
[ -n "$shared_owner_org" ] && mkdir -p "$shared_root/orgs/$shared_owner_org"
|
|
1064
|
+
[ -n "$shared_owner_user" ] && mkdir -p "$shared_root/users/$shared_owner_user"
|
|
1065
|
+
# Marker for the preamble: only containers that actually carry the mounts
|
|
1066
|
+
# get the "## Shared files" briefing (pre-feature containers don't).
|
|
1067
|
+
# Never written through a link or special file the task left in its place
|
|
1068
|
+
# (#cb3j0z): that is removed first (rm never follows a link), and nothing
|
|
1069
|
+
# is written where anything remains. The task's container is gone, so
|
|
1070
|
+
# nothing races this.
|
|
1071
|
+
files_mounted="$task_workspace/.moondeck/files-mounted"
|
|
1072
|
+
if [ -L "$files_mounted" ] || { [ -e "$files_mounted" ] && [ ! -f "$files_mounted" ]; }; then
|
|
1073
|
+
rm -f "$files_mounted" 2>/dev/null || true
|
|
1074
|
+
fi
|
|
1075
|
+
[ -L "$files_mounted" ] || [ -e "$files_mounted" ] || : > "$files_mounted"
|
|
1076
|
+
fi
|
|
1077
|
+
|
|
1078
|
+
# Iterate using jq -c so each project is a single JSON object per line.
|
|
1079
|
+
# Bash 3.2 friendly: a while-read loop rather than mapfile/readarray.
|
|
1080
|
+
while IFS= read -r project_obj; do
|
|
1081
|
+
[ -n "$project_obj" ] || continue
|
|
1082
|
+
|
|
1083
|
+
project_id=$(jq -r '.id' <<<"$project_obj")
|
|
1084
|
+
project_slug=$(jq -r '.slug' <<<"$project_obj")
|
|
1085
|
+
case "$project_slug" in
|
|
1086
|
+
''|.|..|*/*|*\\*)
|
|
1087
|
+
emit_err "WORKTREE_FAILED" \
|
|
1088
|
+
"Moondeck refused an invalid project workspace component: $project_slug" \
|
|
1089
|
+
"validate task worktree ($project_id)"
|
|
1090
|
+
;;
|
|
1091
|
+
esac
|
|
1092
|
+
acquire_project_lock "$projects_root" "$project_id"
|
|
1093
|
+
repo_url_input=$(jq -r '.repo_url' <<<"$project_obj")
|
|
1094
|
+
repo_is_github=0
|
|
1095
|
+
if is_github_hosted_url "$repo_url_input"; then
|
|
1096
|
+
repo_is_github=1
|
|
1097
|
+
fi
|
|
1098
|
+
# The mirror is fetched HERE, before the task container exists. GitHub URLs
|
|
1099
|
+
# stay canonical HTTPS: a connected user's private credential socket
|
|
1100
|
+
# authenticates them; users without a connection get the SSH fallback. The
|
|
1101
|
+
# token never becomes part of this shared mirror or its remote URL.
|
|
1102
|
+
repo_url=$(normalize_github_repo_url "$repo_url_input")
|
|
1103
|
+
github_https_url=$(github_https_repo_url "$repo_url")
|
|
1104
|
+
if [ "$repo_is_github" = "1" ] && [ -z "$github_https_url" ]; then
|
|
1105
|
+
emit_err "CLONE_FAILED" \
|
|
1106
|
+
"Moondeck refused the malformed GitHub repository URL '$repo_url_input'. Use https://github.com/OWNER/REPOSITORY.git and retry." \
|
|
1107
|
+
"validate GitHub repository URL ($project_id)"
|
|
1108
|
+
fi
|
|
1109
|
+
tool_versions=$(jq -r '.tool_versions // ""' <<<"$project_obj")
|
|
1110
|
+
|
|
1111
|
+
# `repo.git` was historically bind-mounted RW into every task container.
|
|
1112
|
+
# Never reuse it as trusted host input: running legacy tasks can still hold
|
|
1113
|
+
# that old bind mount. The new cache path has never entered a container.
|
|
1114
|
+
mirror_dir="$projects_root/$project_id/host-cache.git"
|
|
1115
|
+
task_repo_dir="$task_uai_dir/repos/$project_id.git"
|
|
1116
|
+
task_repo_dir_physical="$task_uai_dir_physical/repos/$project_id.git"
|
|
1117
|
+
worktree_target="$task_workspace/$project_slug"
|
|
1118
|
+
reject_unsafe_host_directory "$worktree_target" \
|
|
1119
|
+
"the project worktree for $project_slug"
|
|
1120
|
+
# Check before remote/cache Git: a dangling symlink makes `-e` false and the
|
|
1121
|
+
# later seed write would otherwise follow it outside the workspace.
|
|
1122
|
+
reject_unsafe_runtime_manifest "$worktree_target/.tool-versions" \
|
|
1123
|
+
"$project_slug" "$project_id"
|
|
1124
|
+
worktree_exists=0
|
|
1125
|
+
standalone_worktree=0
|
|
1126
|
+
if [ -e "$worktree_target/.git" ] || [ -L "$worktree_target/.git" ]; then
|
|
1127
|
+
worktree_exists=1
|
|
1128
|
+
if [ -L "$worktree_target/.git" ]; then
|
|
1129
|
+
emit_err "WORKTREE_FAILED" \
|
|
1130
|
+
"Moondeck refused to resume $project_slug because its .git path is a symbolic link. The previous container was removed; inspect or recreate the task before retrying." \
|
|
1131
|
+
"validate task worktree ($project_id)"
|
|
1132
|
+
elif [ -d "$worktree_target/.git" ]; then
|
|
1133
|
+
# Empty remotes use a standalone repository inside the workspace.
|
|
1134
|
+
standalone_worktree=1
|
|
1135
|
+
assert_bare_repo_tree_safe "$worktree_target/.git" "WORKTREE_FAILED" \
|
|
1136
|
+
"standalone task repository"
|
|
1137
|
+
elif [ -f "$worktree_target/.git" ]; then
|
|
1138
|
+
gitdir_lines=$(awk 'END { print NR }' "$worktree_target/.git")
|
|
1139
|
+
gitdir_value=$(sed -n 's/^gitdir: //p' "$worktree_target/.git")
|
|
1140
|
+
case "$gitdir_value" in
|
|
1141
|
+
"$task_repo_dir"/worktrees/*)
|
|
1142
|
+
gitdir_leaf=${gitdir_value#"$task_repo_dir"/worktrees/}
|
|
1143
|
+
;;
|
|
1144
|
+
"$task_repo_dir_physical"/worktrees/*)
|
|
1145
|
+
gitdir_leaf=${gitdir_value#"$task_repo_dir_physical"/worktrees/}
|
|
1146
|
+
;;
|
|
1147
|
+
*)
|
|
1148
|
+
emit_err "WORKTREE_FAILED" \
|
|
1149
|
+
"This task uses Moondeck's legacy shared Git layout. Its container has been removed so it can no longer write the shared mirror. Preserve any uncommitted changes from $worktree_target, then recreate the task to migrate safely." \
|
|
1150
|
+
"legacy task repository requires recreation ($project_id)"
|
|
1151
|
+
;;
|
|
1152
|
+
esac
|
|
1153
|
+
case "$gitdir_leaf" in
|
|
1154
|
+
''|.|..|*/*)
|
|
1155
|
+
emit_err "WORKTREE_FAILED" \
|
|
1156
|
+
"Moondeck refused an invalid task Git metadata path for $project_slug" \
|
|
1157
|
+
"validate task worktree ($project_id)"
|
|
1158
|
+
;;
|
|
1159
|
+
esac
|
|
1160
|
+
if [ "$gitdir_lines" != "1" ] || [ ! -d "$gitdir_value" ] \
|
|
1161
|
+
|| [ -L "$gitdir_value" ]; then
|
|
1162
|
+
emit_err "WORKTREE_FAILED" \
|
|
1163
|
+
"Moondeck refused invalid or missing task Git metadata for $project_slug. The previous container was removed; inspect or recreate the task before retrying." \
|
|
1164
|
+
"validate task worktree ($project_id)"
|
|
1165
|
+
fi
|
|
1166
|
+
assert_bare_repo_tree_safe "$task_repo_dir" "WORKTREE_FAILED" \
|
|
1167
|
+
"task-private repository"
|
|
1168
|
+
else
|
|
1169
|
+
emit_err "WORKTREE_FAILED" \
|
|
1170
|
+
"Moondeck refused an unsupported .git entry for $project_slug" \
|
|
1171
|
+
"validate task worktree ($project_id)"
|
|
1172
|
+
fi
|
|
1173
|
+
fi
|
|
1174
|
+
|
|
1175
|
+
# Ensure the bare mirror exists with **remote-tracking refs**. Plain
|
|
1176
|
+
# `git clone --bare` copies upstream heads straight into refs/heads/*
|
|
1177
|
+
# and never populates refs/remotes/origin/*, so `worktree add ...
|
|
1178
|
+
# origin/<defaultBranch>` would fail with "invalid reference". Init +
|
|
1179
|
+
# remote + standard refspec + fetch gives a proper bare repo whose
|
|
1180
|
+
# origin/* refs match the remote tip.
|
|
1181
|
+
needs_initial_fetch=0
|
|
1182
|
+
if [ ! -d "$mirror_dir" ]; then
|
|
1183
|
+
step "CLONE_FAILED" "bare-clone ($project_id)"
|
|
1184
|
+
mkdir -p "$(dirname "$mirror_dir")"
|
|
1185
|
+
git_host_control init --bare "$mirror_dir" >/dev/null
|
|
1186
|
+
needs_initial_fetch=1
|
|
1187
|
+
fi
|
|
1188
|
+
|
|
1189
|
+
# Replace all container-writable/legacy config before host Git reads the
|
|
1190
|
+
# mirror. This also heals SSH origins to the canonical credential-free URL.
|
|
1191
|
+
sanitize_mirror_control_plane "$mirror_dir" "$repo_url"
|
|
1192
|
+
|
|
1193
|
+
github_fetch_mode=""
|
|
1194
|
+
|
|
1195
|
+
if [ "$needs_initial_fetch" = "1" ]; then
|
|
1196
|
+
# First fetch on a brand-new mirror — required. Bail and clean up so
|
|
1197
|
+
# the next attempt starts fresh rather than re-using a half-init.
|
|
1198
|
+
fetch_err=""
|
|
1199
|
+
fetch_failed=0
|
|
1200
|
+
if [ -n "$github_https_url" ] && [ -n "$github_git_helper" ]; then
|
|
1201
|
+
github_fetch_mode="token"
|
|
1202
|
+
if ! fetch_err=$(git_with_github_credential \
|
|
1203
|
+
-C "$mirror_dir" fetch origin 2>&1); then
|
|
1204
|
+
fetch_failed=1
|
|
1205
|
+
fi
|
|
1206
|
+
elif [ -n "$github_https_url" ]; then
|
|
1207
|
+
if [ "$uai_has_ssh_identity" = "1" ]; then
|
|
1208
|
+
github_fetch_mode="ssh"
|
|
1209
|
+
if ! fetch_err=$(git_with_github_ssh \
|
|
1210
|
+
-C "$mirror_dir" fetch origin 2>&1); then
|
|
1211
|
+
fetch_failed=1
|
|
1212
|
+
# github.com requires an authenticated SSH handshake even for public
|
|
1213
|
+
# repositories. Preserve the no-credential public-repo path, except
|
|
1214
|
+
# for a key a task's lease names: its route fails closed.
|
|
1215
|
+
if [ "$uai_leased_ssh" != "1" ] && git_anonymous_https -C "$mirror_dir" fetch origin \
|
|
1216
|
+
>/dev/null 2>&1; then
|
|
1217
|
+
github_fetch_mode="anonymous"
|
|
1218
|
+
fetch_failed=0
|
|
1219
|
+
fetch_err=""
|
|
1220
|
+
fi
|
|
1221
|
+
fi
|
|
1222
|
+
else
|
|
1223
|
+
github_fetch_mode="none"
|
|
1224
|
+
if ! fetch_err=$(git_anonymous_https \
|
|
1225
|
+
-C "$mirror_dir" fetch origin 2>&1); then
|
|
1226
|
+
fetch_failed=1
|
|
1227
|
+
else
|
|
1228
|
+
github_fetch_mode="anonymous"
|
|
1229
|
+
fi
|
|
1230
|
+
fi
|
|
1231
|
+
elif ! fetch_err=$(git_host_control -C "$mirror_dir" fetch origin 2>&1); then
|
|
1232
|
+
fetch_failed=1
|
|
1233
|
+
fi
|
|
1234
|
+
|
|
1235
|
+
if [ "$fetch_failed" = "1" ]; then
|
|
1236
|
+
[ -n "$fetch_err" ] || fetch_err="git fetch failed without details"
|
|
1237
|
+
rm -rf "$mirror_dir"
|
|
1238
|
+
# GitHub deliberately uses the same 404 for a missing private repository
|
|
1239
|
+
# and a credential that cannot see it. Name the credential path Uai
|
|
1240
|
+
# actually chose so the recovery action is truthful.
|
|
1241
|
+
case "$fetch_err" in
|
|
1242
|
+
*"Repository not found"*|*"repository not found"*|\
|
|
1243
|
+
*"Permission denied"*|*"access rights"*|*"could not read Username"*|\
|
|
1244
|
+
*"Authentication failed"*|*"Invalid username or password"*|*"HTTP 401"*|\
|
|
1245
|
+
*"HTTP 403"*|*"HTTP 404"*|*"requested URL returned error: 401"*|\
|
|
1246
|
+
*"requested URL returned error: 403"*|*"requested URL returned error: 404"*)
|
|
1247
|
+
if [ "$uai_leased_git" = "1" ]; then
|
|
1248
|
+
emit_leased_github_denial "$github_fetch_mode" "$repo_url" "bare-clone ($project_id)"
|
|
1249
|
+
fi
|
|
1250
|
+
if [ "$github_fetch_mode" = "token" ]; then
|
|
1251
|
+
emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
|
|
1252
|
+
"GitHub denied the connected user's access to $repo_url. Confirm that the Moondeck GitHub App is installed on the owning account or organization, this repository is included in its grant, and the connected user can write to it. Then retry the task." \
|
|
1253
|
+
"bare-clone ($project_id)"
|
|
1254
|
+
elif [ "$github_fetch_mode" = "ssh" ]; then
|
|
1255
|
+
emit_err "GITHUB_SSH_ACCESS_DENIED" \
|
|
1256
|
+
"GitHub is not connected on this host, so Moondeck tried its SSH fallback for $repo_url, but GitHub denied that key. Connect GitHub on this host, or register its public SSH key with an account that can read the repository (and authorize it for SAML SSO when required)." \
|
|
1257
|
+
"bare-clone ($project_id)"
|
|
1258
|
+
elif [ "$github_fetch_mode" = "none" ]; then
|
|
1259
|
+
emit_err "GITHUB_CONNECTION_REQUIRED" \
|
|
1260
|
+
"This host has neither a GitHub connection nor an SSH fallback key that can read $repo_url. Connect GitHub on this host, or configure its optional SSH key, then retry the task." \
|
|
1261
|
+
"bare-clone ($project_id)"
|
|
1262
|
+
else
|
|
1263
|
+
case "$repo_url" in
|
|
1264
|
+
git@*|ssh://*)
|
|
1265
|
+
emit_err "CLONE_FAILED" \
|
|
1266
|
+
"The git server denied this host's SSH key access to $repo_url. Confirm the repository exists and add that host's public key to an account that can read it." \
|
|
1267
|
+
"bare-clone ($project_id)"
|
|
1268
|
+
;;
|
|
1269
|
+
*)
|
|
1270
|
+
emit_err "CLONE_FAILED" \
|
|
1271
|
+
"Access to $repo_url was denied. Configure the matching credential on this host, then retry the task." \
|
|
1272
|
+
"bare-clone ($project_id)"
|
|
1273
|
+
;;
|
|
1274
|
+
esac
|
|
1275
|
+
fi
|
|
1276
|
+
;;
|
|
1277
|
+
*)
|
|
1278
|
+
emit_err "CLONE_FAILED" \
|
|
1279
|
+
"initial fetch from $repo_url failed: $fetch_err" \
|
|
1280
|
+
"bare-clone ($project_id)"
|
|
1281
|
+
;;
|
|
1282
|
+
esac
|
|
1283
|
+
fi
|
|
1284
|
+
else
|
|
1285
|
+
# Existing mirror — refresh best-effort. Offline / network blips should not
|
|
1286
|
+
# destroy a task that already has cached refs, but never switch principals.
|
|
1287
|
+
step "FETCH_FAILED" "git fetch origin ($project_id)"
|
|
1288
|
+
fetch_ok=0
|
|
1289
|
+
fetch_err=""
|
|
1290
|
+
if [ -n "$github_https_url" ] && [ -n "$github_git_helper" ]; then
|
|
1291
|
+
github_fetch_mode="token"
|
|
1292
|
+
if fetch_err=$(git_with_github_credential \
|
|
1293
|
+
-C "$mirror_dir" fetch origin 2>&1); then
|
|
1294
|
+
fetch_ok=1
|
|
1295
|
+
fi
|
|
1296
|
+
elif [ -n "$github_https_url" ]; then
|
|
1297
|
+
if [ "$uai_has_ssh_identity" = "1" ]; then
|
|
1298
|
+
github_fetch_mode="ssh"
|
|
1299
|
+
if fetch_err=$(git_with_github_ssh \
|
|
1300
|
+
-C "$mirror_dir" fetch origin 2>&1); then
|
|
1301
|
+
fetch_ok=1
|
|
1302
|
+
fi
|
|
1303
|
+
else
|
|
1304
|
+
github_fetch_mode="none"
|
|
1305
|
+
fi
|
|
1306
|
+
# A key a task's lease names never falls back to another principal.
|
|
1307
|
+
if [ "$fetch_ok" != "1" ] && [ "$uai_leased_ssh" != "1" ]; then
|
|
1308
|
+
anonymous_err=""
|
|
1309
|
+
if anonymous_err=$(git_anonymous_https \
|
|
1310
|
+
-C "$mirror_dir" fetch origin 2>&1); then
|
|
1311
|
+
github_fetch_mode="anonymous"
|
|
1312
|
+
fetch_ok=1
|
|
1313
|
+
fetch_err=""
|
|
1314
|
+
elif [ "$github_fetch_mode" = "none" ]; then
|
|
1315
|
+
fetch_err="$anonymous_err"
|
|
1316
|
+
fi
|
|
1317
|
+
fi
|
|
1318
|
+
elif fetch_err=$(git_host_control -C "$mirror_dir" fetch origin 2>&1); then
|
|
1319
|
+
fetch_ok=1
|
|
1320
|
+
fi
|
|
1321
|
+
if [ "$fetch_ok" != "1" ]; then
|
|
1322
|
+
case "$fetch_err" in
|
|
1323
|
+
*"Repository not found"*|*"repository not found"*|\
|
|
1324
|
+
*"Permission denied"*|*"access rights"*|*"could not read Username"*|\
|
|
1325
|
+
*"Authentication failed"*|*"Invalid username or password"*|*"HTTP 401"*|\
|
|
1326
|
+
*"HTTP 403"*|*"HTTP 404"*|*"requested URL returned error: 401"*|\
|
|
1327
|
+
*"requested URL returned error: 403"*|*"requested URL returned error: 404"*)
|
|
1328
|
+
if [ "$uai_leased_git" = "1" ]; then
|
|
1329
|
+
emit_leased_github_denial "$github_fetch_mode" "$repo_url" "git fetch origin ($project_id)"
|
|
1330
|
+
fi
|
|
1331
|
+
case "$github_fetch_mode" in
|
|
1332
|
+
token)
|
|
1333
|
+
emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
|
|
1334
|
+
"GitHub denied the connected user's access to $repo_url. Confirm that the Moondeck GitHub App grant still includes this repository and the connected user can write to it, then retry the task." \
|
|
1335
|
+
"git fetch origin ($project_id)"
|
|
1336
|
+
;;
|
|
1337
|
+
ssh)
|
|
1338
|
+
emit_err "GITHUB_SSH_ACCESS_DENIED" \
|
|
1339
|
+
"GitHub is not connected on this host, and it denied the SSH fallback for $repo_url. Connect GitHub or fix this host's SSH key access, then retry the task." \
|
|
1340
|
+
"git fetch origin ($project_id)"
|
|
1341
|
+
;;
|
|
1342
|
+
none)
|
|
1343
|
+
emit_err "GITHUB_CONNECTION_REQUIRED" \
|
|
1344
|
+
"This host has neither a GitHub connection nor an SSH fallback key that can read $repo_url. Connect GitHub on this host, or configure its optional SSH key, then retry the task." \
|
|
1345
|
+
"git fetch origin ($project_id)"
|
|
1346
|
+
;;
|
|
1347
|
+
*)
|
|
1348
|
+
emit_err "FETCH_FAILED" \
|
|
1349
|
+
"The configured credential no longer has access to $repo_url. Restore access, then retry the task." \
|
|
1350
|
+
"git fetch origin ($project_id)"
|
|
1351
|
+
;;
|
|
1352
|
+
esac
|
|
1353
|
+
;;
|
|
1354
|
+
*)
|
|
1355
|
+
if [ "$worktree_exists" = "1" ]; then
|
|
1356
|
+
# Resume is the only safe stale-cache case: this same task already
|
|
1357
|
+
# owns the checkout. A new task/user must prove current access
|
|
1358
|
+
# before any shared cached code is materialized into its workspace.
|
|
1359
|
+
log "warning: git fetch failed for $project_id ($repo_url); resuming this task's existing worktree with cached refs"
|
|
1360
|
+
else
|
|
1361
|
+
emit_err "FETCH_FAILED" \
|
|
1362
|
+
"could not verify the task creator's current access to $repo_url: ${fetch_err:-git fetch failed}. Retry when the remote is reachable." \
|
|
1363
|
+
"git fetch origin ($project_id)"
|
|
1364
|
+
fi
|
|
1365
|
+
;;
|
|
1366
|
+
esac
|
|
1367
|
+
fi
|
|
1368
|
+
fi
|
|
1369
|
+
|
|
1370
|
+
if [ "$github_fetch_mode" = "ssh" ]; then
|
|
1371
|
+
if [ "$needs_initial_fetch" = "1" ] && [ "$fetch_failed" = "0" ]; then
|
|
1372
|
+
uai_used_ssh_transport=1
|
|
1373
|
+
elif [ "$needs_initial_fetch" != "1" ] && [ "$fetch_ok" = "1" ]; then
|
|
1374
|
+
uai_used_ssh_transport=1
|
|
1375
|
+
fi
|
|
1376
|
+
fi
|
|
1377
|
+
|
|
1378
|
+
# Resolve the remote default branch with the same task credential
|
|
1379
|
+
# (best-effort) — fall back to main.
|
|
1380
|
+
case "$github_fetch_mode" in
|
|
1381
|
+
token)
|
|
1382
|
+
git_with_github_credential -C "$mirror_dir" remote set-head origin -a \
|
|
1383
|
+
>/dev/null 2>&1 || true
|
|
1384
|
+
;;
|
|
1385
|
+
ssh)
|
|
1386
|
+
git_with_github_ssh -C "$mirror_dir" remote set-head origin -a \
|
|
1387
|
+
>/dev/null 2>&1 || true
|
|
1388
|
+
;;
|
|
1389
|
+
anonymous)
|
|
1390
|
+
git_anonymous_https -C "$mirror_dir" remote set-head origin -a \
|
|
1391
|
+
>/dev/null 2>&1 || true
|
|
1392
|
+
;;
|
|
1393
|
+
none)
|
|
1394
|
+
git_anonymous_https -C "$mirror_dir" remote set-head origin -a \
|
|
1395
|
+
>/dev/null 2>&1 || true
|
|
1396
|
+
;;
|
|
1397
|
+
*)
|
|
1398
|
+
git_host_control -C "$mirror_dir" remote set-head origin -a >/dev/null 2>&1 || true
|
|
1399
|
+
;;
|
|
1400
|
+
esac
|
|
1401
|
+
project_default=$(git_host_control -C "$mirror_dir" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \
|
|
1402
|
+
| sed 's#^origin/##' || true)
|
|
1403
|
+
[ -n "$project_default" ] || project_default="main"
|
|
1404
|
+
|
|
1405
|
+
# Each linked worktree gets its own writable Git control plane (config,
|
|
1406
|
+
# hooks, refs, metadata, and objects). It borrows the host-only cache only
|
|
1407
|
+
# while being prepared, then repacks every reachable object locally and
|
|
1408
|
+
# removes the alternate before the container starts.
|
|
1409
|
+
worktree_repo_dir="$task_repo_dir"
|
|
1410
|
+
if [ "$standalone_worktree" = "1" ]; then
|
|
1411
|
+
worktree_repo_dir="$worktree_target/.git"
|
|
1412
|
+
sanitize_task_repo_control_plane "$worktree_repo_dir" "$repo_url" \
|
|
1413
|
+
"$task_branch" false
|
|
1414
|
+
else
|
|
1415
|
+
if [ ! -d "$task_repo_dir" ]; then
|
|
1416
|
+
mkdir -p "$(dirname "$task_repo_dir")"
|
|
1417
|
+
git_host_control init --bare "$task_repo_dir" >/dev/null
|
|
1418
|
+
fi
|
|
1419
|
+
sanitize_task_repo_control_plane "$task_repo_dir" "$repo_url" \
|
|
1420
|
+
"$task_branch" true
|
|
1421
|
+
rm -rf "$task_repo_dir/objects/info"
|
|
1422
|
+
mkdir -p "$task_repo_dir/objects/info"
|
|
1423
|
+
printf '%s\n' "$mirror_dir/objects" \
|
|
1424
|
+
> "$task_repo_dir/objects/info/alternates"
|
|
1425
|
+
# Copy only refs/objects from the trusted host mirror. The task repo's
|
|
1426
|
+
# remote remains canonical GitHub HTTPS from the sanitizer above.
|
|
1427
|
+
if ! git_host_control -C "$task_repo_dir" fetch "$mirror_dir" \
|
|
1428
|
+
'+refs/remotes/origin/*:refs/remotes/origin/*' >/dev/null 2>&1; then
|
|
1429
|
+
emit_err "WORKTREE_FAILED" \
|
|
1430
|
+
"could not copy the fetched refs into the task repository for $repo_url" \
|
|
1431
|
+
"copy task repository refs ($project_id)"
|
|
1432
|
+
fi
|
|
1433
|
+
if ! git_host_control -C "$task_repo_dir" repack -a -d >/dev/null; then
|
|
1434
|
+
emit_err "WORKTREE_FAILED" \
|
|
1435
|
+
"could not make the task repository self-contained for $repo_url" \
|
|
1436
|
+
"copy task repository objects ($project_id)"
|
|
1437
|
+
fi
|
|
1438
|
+
rm -f "$task_repo_dir/objects/info/alternates"
|
|
1439
|
+
fi
|
|
1440
|
+
|
|
1441
|
+
mkdir -p "$(dirname "$worktree_target")"
|
|
1442
|
+
if [ -e "$worktree_target/.git" ]; then
|
|
1443
|
+
# Resume (ADR-028): the task worktree survived the stop — reuse it as-is
|
|
1444
|
+
# (a linked worktree's .git is a FILE, hence -e). Re-running `git
|
|
1445
|
+
# worktree add` would refuse ("already exists" / branch exists) and
|
|
1446
|
+
# errored EVERY resume of a worktree-preserving stop within seconds
|
|
1447
|
+
# (found live 2026-07-21). The task branch and any uncommitted work are
|
|
1448
|
+
# exactly what the user expects back.
|
|
1449
|
+
log "worktree for $project_slug already present — resuming with it"
|
|
1450
|
+
if [ "$standalone_worktree" != "1" ]; then
|
|
1451
|
+
# Repair only after the task-private repository and its gitdir pointer
|
|
1452
|
+
# have passed the host boundary checks above.
|
|
1453
|
+
git_host_control -C "$worktree_repo_dir" worktree repair \
|
|
1454
|
+
"$worktree_target" 2>/dev/null || true
|
|
1455
|
+
fi
|
|
1456
|
+
elif [ -n "$task_branch" ] && [ "$task_branch" != "null" ] \
|
|
1457
|
+
&& [ "$task_branch" != "$project_default" ] \
|
|
1458
|
+
&& ! git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
|
|
1459
|
+
"refs/heads/$task_branch" >/dev/null 2>&1 \
|
|
1460
|
+
&& git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
|
|
1461
|
+
"refs/remotes/origin/$task_branch^{commit}" >/dev/null 2>&1; then
|
|
1462
|
+
# The worktree is gone but the task's branch is on the remote (a lost
|
|
1463
|
+
# task directory, 2026-10-20: Resume rebuilt from the base, putting the
|
|
1464
|
+
# agents on the wrong code with the pushed commits out of sight). The
|
|
1465
|
+
# fetch above is fresh — a missing worktree makes a failed fetch fatal —
|
|
1466
|
+
# so continue from the pushed branch, never the base.
|
|
1467
|
+
step "WORKTREE_FAILED" "git worktree add from origin/$task_branch ($project_id)"
|
|
1468
|
+
log "worktree for $project_slug missing — restoring it from origin/$task_branch"
|
|
1469
|
+
# The base this task branched from, as nearly as the remote can tell:
|
|
1470
|
+
# where the task branch leaves the default branch.
|
|
1471
|
+
env_schema_oid=$(git_host_control -C "$mirror_dir" merge-base \
|
|
1472
|
+
"refs/remotes/origin/$task_branch" "refs/remotes/origin/$project_default" \
|
|
1473
|
+
2>/dev/null || true)
|
|
1474
|
+
if [ -n "$env_schema_oid" ]; then
|
|
1475
|
+
record_env_schema_base "$project_id" "oid $env_schema_oid" \
|
|
1476
|
+
|| log "warning: could not record the .env.schema base for $project_id; its keys without a binding are withheld"
|
|
1477
|
+
fi
|
|
1478
|
+
# --no-track: the sanitizer above already wrote this branch's upstream.
|
|
1479
|
+
if ! git_host_control -C "$worktree_repo_dir" worktree add --no-track \
|
|
1480
|
+
"$worktree_target" -b "$task_branch" "refs/remotes/origin/$task_branch"; then
|
|
1481
|
+
emit_err "WORKTREE_FAILED" \
|
|
1482
|
+
"could not restore the task worktree from origin/$task_branch for $repo_url" \
|
|
1483
|
+
"git worktree add from origin/$task_branch ($project_id)"
|
|
1484
|
+
fi
|
|
1485
|
+
elif git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
|
|
1486
|
+
"refs/remotes/origin/$project_default" >/dev/null 2>&1; then
|
|
1487
|
+
# Normal case: branch the task worktree off origin/<defaultBranch>.
|
|
1488
|
+
# Only when the remote has no branch of this task's yet.
|
|
1489
|
+
step "WORKTREE_FAILED" "git worktree add ($project_id)"
|
|
1490
|
+
# The same commit the task repository just copied, read where the task
|
|
1491
|
+
# never writes: the host cache, under this project's lock.
|
|
1492
|
+
env_schema_oid=$(git_host_control -C "$mirror_dir" rev-parse --verify --quiet \
|
|
1493
|
+
"refs/remotes/origin/$project_default^{commit}" 2>/dev/null || true)
|
|
1494
|
+
if [ -n "$env_schema_oid" ]; then
|
|
1495
|
+
record_env_schema_base "$project_id" "oid $env_schema_oid" \
|
|
1496
|
+
|| log "warning: could not record the .env.schema base for $project_id; its keys without a binding are withheld"
|
|
1497
|
+
fi
|
|
1498
|
+
if ! git_host_control -C "$worktree_repo_dir" worktree add "$worktree_target" \
|
|
1499
|
+
-b "$task_branch" "origin/$project_default"; then
|
|
1500
|
+
emit_err "WORKTREE_FAILED" \
|
|
1501
|
+
"could not create the task worktree for $repo_url" \
|
|
1502
|
+
"git worktree add ($project_id)"
|
|
1503
|
+
fi
|
|
1504
|
+
else
|
|
1505
|
+
# Empty / branchless remote — there's no origin/<default> to branch from.
|
|
1506
|
+
# Don't fail the whole task over it (the old behaviour: "invalid reference"
|
|
1507
|
+
# -> WORKTREE_FAILED -> respawn loop). Start an empty, git-initialised
|
|
1508
|
+
# workspace so the task still comes up and a first commit can populate the
|
|
1509
|
+
# repo. Leave a note (git-excluded so it isn't accidentally committed).
|
|
1510
|
+
step "WORKTREE_FAILED" "init empty workspace ($project_id)"
|
|
1511
|
+
log "warning: $repo_url has no '$project_default' branch (empty repo?); starting an empty workspace for $project_slug"
|
|
1512
|
+
# Only a cache with no ref at all proves the repository empty. A missing
|
|
1513
|
+
# default branch alone (say, a default that could not be resolved) is
|
|
1514
|
+
# recorded as nothing, so its keys without a binding are withheld rather
|
|
1515
|
+
# than read as declaring nothing.
|
|
1516
|
+
if env_schema_refs=$(git_host_control -C "$mirror_dir" for-each-ref --count=1 2>/dev/null) \
|
|
1517
|
+
&& [ -z "$env_schema_refs" ]; then
|
|
1518
|
+
record_env_schema_base "$project_id" "empty" \
|
|
1519
|
+
|| log "warning: could not record the .env.schema base for $project_id; its keys without a binding are withheld"
|
|
1520
|
+
fi
|
|
1521
|
+
rm -rf "$worktree_target"
|
|
1522
|
+
mkdir -p "$worktree_target"
|
|
1523
|
+
git_host_control -C "$worktree_target" init -q -b "$project_default" 2>/dev/null \
|
|
1524
|
+
|| { git_host_control -C "$worktree_target" init -q \
|
|
1525
|
+
&& git_host_control -C "$worktree_target" symbolic-ref HEAD "refs/heads/$project_default"; }
|
|
1526
|
+
git_host_control -C "$worktree_target" remote add origin "$repo_url" 2>/dev/null || true
|
|
1527
|
+
sanitize_task_repo_control_plane "$worktree_target/.git" "$repo_url" \
|
|
1528
|
+
"$project_default" false
|
|
1529
|
+
printf '%s\n' \
|
|
1530
|
+
"# Empty repository" \
|
|
1531
|
+
"" \
|
|
1532
|
+
"\`$repo_url\` has no commits yet, so Moondeck started an empty workspace on" \
|
|
1533
|
+
"branch \`$project_default\`. Make your first commit and run:" \
|
|
1534
|
+
"" \
|
|
1535
|
+
" git push -u origin $project_default" \
|
|
1536
|
+
"" \
|
|
1537
|
+
"to initialise the repository." \
|
|
1538
|
+
> "$worktree_target/UAI_EMPTY_REPO.md"
|
|
1539
|
+
mkdir -p "$worktree_target/.git/info" 2>/dev/null || true
|
|
1540
|
+
printf '%s\n' "/UAI_EMPTY_REPO.md" \
|
|
1541
|
+
>> "$worktree_target/.git/info/exclude" 2>/dev/null || true
|
|
1542
|
+
fi
|
|
1543
|
+
|
|
1544
|
+
# Tool-version seeding: only when the worktree has no checked-in
|
|
1545
|
+
# .tool-versions AND the project declares one. Never overwrite a file
|
|
1546
|
+
# the repo already ships.
|
|
1547
|
+
reject_unsafe_runtime_manifest "$worktree_target/.tool-versions" \
|
|
1548
|
+
"$project_slug" "$project_id"
|
|
1549
|
+
if [ ! -e "$worktree_target/.tool-versions" ] \
|
|
1550
|
+
&& [ -n "$tool_versions" ] && [ "$tool_versions" != "null" ]; then
|
|
1551
|
+
printf '%s\n' "$tool_versions" > "$worktree_target/.tool-versions"
|
|
1552
|
+
fi
|
|
1553
|
+
if [ -d "$worktree_target/.git" ] && [ ! -L "$worktree_target/.git" ]; then
|
|
1554
|
+
share_task_tree_with_container "$worktree_target/.git" \
|
|
1555
|
+
"standalone task Git repository"
|
|
1556
|
+
fi
|
|
1557
|
+
|
|
1558
|
+
release_project_lock
|
|
1559
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
1560
|
+
|
|
1561
|
+
# Every host Git operation is over: a task on cloud credentials stops being
|
|
1562
|
+
# served its owner's GitHub token and SSH key now.
|
|
1563
|
+
if [ -n "$uai_handshake_dir" ]; then
|
|
1564
|
+
: > "$uai_handshake_dir/event-clone-done"
|
|
1565
|
+
fi
|
|
1566
|
+
|
|
1567
|
+
# Git and the checked-out files were created by the host. Reconcile their
|
|
1568
|
+
# group/modes only after every host Git operation and before Compose exposes
|
|
1569
|
+
# them to uid 1000. The host-only marker avoids recursively walking a large
|
|
1570
|
+
# workspace (for example node_modules) on every resume, while its gid payload
|
|
1571
|
+
# forces a fresh pass if the task moves to a differently-grouped host. This
|
|
1572
|
+
# first pass self-heals 0.9.2 task repos whose mktemp config was mode 0600 on a
|
|
1573
|
+
# uid-501 macOS host. The sanitizer above repairs its rewritten control files
|
|
1574
|
+
# on every later resume.
|
|
1575
|
+
step "WORKTREE_FAILED" "share task files with container"
|
|
1576
|
+
mkdir -p "$task_uai_dir"
|
|
1577
|
+
# The shared group may be a broad host group (for example macOS `staff`). Keep
|
|
1578
|
+
# its writable leaves behind host-only parents; Docker bind-mounts the exact
|
|
1579
|
+
# workspace/repository roots, so node does not need host-path traversal here.
|
|
1580
|
+
chmod 700 "$task_dir" "$task_uai_dir"
|
|
1581
|
+
filesystem_group_marker="$task_uai_dir/container-filesystem-group-v1"
|
|
1582
|
+
marked_container_gid=""
|
|
1583
|
+
if [ -L "$filesystem_group_marker" ]; then
|
|
1584
|
+
emit_err "WORKTREE_FAILED" \
|
|
1585
|
+
"Moondeck refused a symbolic-link task filesystem marker" \
|
|
1586
|
+
"share task files with container"
|
|
1587
|
+
elif [ -f "$filesystem_group_marker" ]; then
|
|
1588
|
+
marked_container_gid=$(cat "$filesystem_group_marker" 2>/dev/null || true)
|
|
1589
|
+
elif [ -e "$filesystem_group_marker" ]; then
|
|
1590
|
+
emit_err "WORKTREE_FAILED" \
|
|
1591
|
+
"Moondeck refused a non-file task filesystem marker" \
|
|
1592
|
+
"share task files with container"
|
|
1593
|
+
fi
|
|
1594
|
+
if [ "$marked_container_gid" != "$task_container_gid" ]; then
|
|
1595
|
+
share_task_tree_with_container "$task_workspace" "task workspace"
|
|
1596
|
+
filesystem_group_marker_tmp=$(mktemp \
|
|
1597
|
+
"$task_uai_dir/.container-filesystem-group.XXXXXX")
|
|
1598
|
+
printf '%s\n' "$task_container_gid" > "$filesystem_group_marker_tmp"
|
|
1599
|
+
chmod 600 "$filesystem_group_marker_tmp"
|
|
1600
|
+
mv -f "$filesystem_group_marker_tmp" "$filesystem_group_marker"
|
|
1601
|
+
else
|
|
1602
|
+
# A container may have changed a mount root's mode. These two O(1) repairs
|
|
1603
|
+
# keep entry into the already-normalized trees reliable without walking them.
|
|
1604
|
+
share_task_entry_with_container "$task_workspace" "task workspace root"
|
|
1605
|
+
fi
|
|
1606
|
+
# Task repos are repacked, so this tree stays small; normalize it every time.
|
|
1607
|
+
# Unlike the workspace pass, this covers Git metadata created during the host
|
|
1608
|
+
# refresh and any loose objects/locks written by the prior container.
|
|
1609
|
+
share_task_tree_with_container "$task_uai_dir/repos" \
|
|
1610
|
+
"task-private Git repositories"
|
|
1611
|
+
|
|
1612
|
+
if [ -z "$github_git_helper" ] && [ "$uai_used_ssh_transport" = "1" ]; then
|
|
1613
|
+
uai_git_transport="ssh"
|
|
1614
|
+
fi
|
|
1615
|
+
|
|
1616
|
+
# -----------------------------------------------------------------------------
|
|
1617
|
+
# 4. Generate .uai/docker-compose.yml (bash). Union preview ports + env keys
|
|
1618
|
+
# across the selected projects; one app service. Derived Dockerfile only
|
|
1619
|
+
# when a project declares `extra`.
|
|
1620
|
+
# -----------------------------------------------------------------------------
|
|
1621
|
+
|
|
1622
|
+
step "RENDER_FAILED" "generate compose"
|
|
1623
|
+
|
|
1624
|
+
mkdir -p "$task_uai_dir"
|
|
1625
|
+
|
|
1626
|
+
STANDARD_CONTAINER_DIR="${UAI_STANDARD_CONTAINER_ASSETS_DIR:-$SCRIPT_DIR/../../images/standard/container}"
|
|
1627
|
+
if ! STANDARD_CONTAINER_DIR=$(cd "$STANDARD_CONTAINER_DIR" && pwd -P); then
|
|
1628
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1629
|
+
"The station's packaged container asset directory is missing. Repair or update the station installation, then retry." \
|
|
1630
|
+
"resolve packaged container assets"
|
|
1631
|
+
fi
|
|
1632
|
+
UAI_INIT_SOURCE="$STANDARD_CONTAINER_DIR/uai-init"
|
|
1633
|
+
RUNTIME_MATERIALIZER_SOURCE="$STANDARD_CONTAINER_DIR/uai-materialize-runtimes"
|
|
1634
|
+
COREPACK_VERSION_SOURCE="$STANDARD_CONTAINER_DIR/corepack-version"
|
|
1635
|
+
ENV_HOOK_SOURCE="$SCRIPT_DIR/env-hook.sh"
|
|
1636
|
+
UAI_ENV_EXEC_SOURCE="$SCRIPT_DIR/uai-env-exec.sh"
|
|
1637
|
+
# The `moondeck` command (Moondeck plan §5 row 7): this task's agent CLI on
|
|
1638
|
+
# PATH, mounted read-only at /usr/local/bin/moondeck. There is no `uai` one.
|
|
1639
|
+
MOONDECK_CLI_SOURCE="$SCRIPT_DIR/moondeck-cli.sh"
|
|
1640
|
+
if [ -L "$MOONDECK_CLI_SOURCE" ] || [ ! -f "$MOONDECK_CLI_SOURCE" ] \
|
|
1641
|
+
|| [ ! -r "$MOONDECK_CLI_SOURCE" ]; then
|
|
1642
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1643
|
+
"Moondeck's packaged agent command is missing or unsafe. Repair or update the station installation, then retry." \
|
|
1644
|
+
"resolve packaged container assets"
|
|
1645
|
+
fi
|
|
1646
|
+
TASK_FORWARDER_SOURCE="$STANDARD_CONTAINER_DIR/uai-task-forwarder.mjs"
|
|
1647
|
+
if [ -L "$UAI_INIT_SOURCE" ] || [ ! -f "$UAI_INIT_SOURCE" ] \
|
|
1648
|
+
|| [ ! -x "$UAI_INIT_SOURCE" ]; then
|
|
1649
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1650
|
+
"The station's packaged task initializer is missing or unsafe. Repair or update the station installation, then retry." \
|
|
1651
|
+
"resolve packaged container assets"
|
|
1652
|
+
fi
|
|
1653
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
1654
|
+
for loader_asset in \
|
|
1655
|
+
"$ENV_HOOK_SOURCE" \
|
|
1656
|
+
"$UAI_ENV_EXEC_SOURCE" \
|
|
1657
|
+
"$TASK_FORWARDER_SOURCE"
|
|
1658
|
+
do
|
|
1659
|
+
if [ -L "$loader_asset" ] || [ ! -f "$loader_asset" ] \
|
|
1660
|
+
|| [ ! -r "$loader_asset" ]; then
|
|
1661
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1662
|
+
"The station's packaged task env loader is missing or unsafe. Repair or update the station installation, then retry." \
|
|
1663
|
+
"resolve packaged env loader assets"
|
|
1664
|
+
fi
|
|
1665
|
+
done
|
|
1666
|
+
fi
|
|
1667
|
+
if [ "$project_count" -gt 0 ]; then
|
|
1668
|
+
if [ -L "$RUNTIME_MATERIALIZER_SOURCE" ] \
|
|
1669
|
+
|| [ ! -f "$RUNTIME_MATERIALIZER_SOURCE" ] \
|
|
1670
|
+
|| [ ! -x "$RUNTIME_MATERIALIZER_SOURCE" ]; then
|
|
1671
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1672
|
+
"The station's packaged runtime materializer is missing or unsafe. Repair or update the station installation, then retry." \
|
|
1673
|
+
"resolve packaged container assets"
|
|
1674
|
+
fi
|
|
1675
|
+
if [ -L "$COREPACK_VERSION_SOURCE" ] \
|
|
1676
|
+
|| [ ! -f "$COREPACK_VERSION_SOURCE" ] \
|
|
1677
|
+
|| [ ! -r "$COREPACK_VERSION_SOURCE" ]; then
|
|
1678
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1679
|
+
"The station's packaged Corepack version pin is missing or unsafe. Repair or update the station installation, then retry." \
|
|
1680
|
+
"resolve packaged container assets"
|
|
1681
|
+
fi
|
|
1682
|
+
fi
|
|
1683
|
+
|
|
1684
|
+
# Containers bind these assets for their whole life, but the packaged
|
|
1685
|
+
# sources live in the VERSIONED runtime tree and the updater keeps only
|
|
1686
|
+
# current+previous — a container surviving two updates found its mounts
|
|
1687
|
+
# pointing at deleted files (uai-init exec 127, recovery wedged the host in
|
|
1688
|
+
# "checking"; live 2026-08-26). Copy the assets into the task's own control
|
|
1689
|
+
# tree and mount THOSE: asset lifetime becomes task lifetime, and a Resume
|
|
1690
|
+
# refreshes the copies to the current version. Atomic rename so a resumed
|
|
1691
|
+
# container can never observe a half-written initializer.
|
|
1692
|
+
task_assets_dir="$task_uai_dir/assets"
|
|
1693
|
+
mkdir -p "$task_assets_dir"
|
|
1694
|
+
copy_task_asset() {
|
|
1695
|
+
copy_src="$1"
|
|
1696
|
+
copy_name="$2"
|
|
1697
|
+
copy_mode="$3"
|
|
1698
|
+
copy_tmp=$(mktemp "$task_assets_dir/.$copy_name.XXXXXX") || return 1
|
|
1699
|
+
cp "$copy_src" "$copy_tmp" || { rm -f "$copy_tmp"; return 1; }
|
|
1700
|
+
chmod "$copy_mode" "$copy_tmp" || { rm -f "$copy_tmp"; return 1; }
|
|
1701
|
+
mv -f "$copy_tmp" "$task_assets_dir/$copy_name"
|
|
1702
|
+
}
|
|
1703
|
+
if ! copy_task_asset "$UAI_INIT_SOURCE" uai-init 0755; then
|
|
1704
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1705
|
+
"Moondeck could not stage the task initializer into this task's control tree. Check disk space and permissions under the station's data directory, then retry." \
|
|
1706
|
+
"stage task container assets"
|
|
1707
|
+
fi
|
|
1708
|
+
UAI_INIT_SOURCE="$task_assets_dir/uai-init"
|
|
1709
|
+
if ! copy_task_asset "$MOONDECK_CLI_SOURCE" moondeck 0555; then
|
|
1710
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1711
|
+
"Moondeck could not stage the agent command into this task's control tree. Check disk space and permissions under the Moondeck data directory, then retry." \
|
|
1712
|
+
"stage task container assets"
|
|
1713
|
+
fi
|
|
1714
|
+
MOONDECK_CLI_SOURCE="$task_assets_dir/moondeck"
|
|
1715
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
1716
|
+
if ! copy_task_asset "$ENV_HOOK_SOURCE" env-hook.sh 0555 \
|
|
1717
|
+
|| ! copy_task_asset "$UAI_ENV_EXEC_SOURCE" uai-env-exec 0555 \
|
|
1718
|
+
|| ! copy_task_asset "$TASK_FORWARDER_SOURCE" uai-task-forwarder.mjs 0444; then
|
|
1719
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1720
|
+
"Moondeck could not stage the task env loader into this task's control tree. Check disk space and permissions under the station's data directory, then retry." \
|
|
1721
|
+
"stage task env loader assets"
|
|
1722
|
+
fi
|
|
1723
|
+
ENV_HOOK_SOURCE="$task_assets_dir/env-hook.sh"
|
|
1724
|
+
UAI_ENV_EXEC_SOURCE="$task_assets_dir/uai-env-exec"
|
|
1725
|
+
TASK_FORWARDER_SOURCE="$task_assets_dir/uai-task-forwarder.mjs"
|
|
1726
|
+
fi
|
|
1727
|
+
if [ -f "$RUNTIME_MATERIALIZER_SOURCE" ]; then
|
|
1728
|
+
if ! copy_task_asset "$RUNTIME_MATERIALIZER_SOURCE" uai-materialize-runtimes 0755; then
|
|
1729
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1730
|
+
"Moondeck could not stage the runtime materializer into this task's control tree. Check disk space and permissions under the station's data directory, then retry." \
|
|
1731
|
+
"stage task container assets"
|
|
1732
|
+
fi
|
|
1733
|
+
RUNTIME_MATERIALIZER_SOURCE="$task_assets_dir/uai-materialize-runtimes"
|
|
1734
|
+
fi
|
|
1735
|
+
if [ -f "$COREPACK_VERSION_SOURCE" ]; then
|
|
1736
|
+
if ! copy_task_asset "$COREPACK_VERSION_SOURCE" corepack-version 0644; then
|
|
1737
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1738
|
+
"Moondeck could not stage the Corepack version pin into this task's control tree. Check disk space and permissions under the station's data directory, then retry." \
|
|
1739
|
+
"stage task container assets"
|
|
1740
|
+
fi
|
|
1741
|
+
COREPACK_VERSION_SOURCE="$task_assets_dir/corepack-version"
|
|
1742
|
+
fi
|
|
1743
|
+
|
|
1744
|
+
# The host-authored allowlist is the single project-set authority shared by
|
|
1745
|
+
# pre-start runtime proof and task-local dependency installation. It lives in
|
|
1746
|
+
# the container-unmounted .uai control tree and is exposed only as this one
|
|
1747
|
+
# read-only file. Atomic replacement keeps resumptions from observing a
|
|
1748
|
+
# partially written list.
|
|
1749
|
+
runtime_projects_file="$task_uai_dir/runtime-projects"
|
|
1750
|
+
runtime_projects_tmp=$(mktemp "$task_uai_dir/.runtime-projects.XXXXXX")
|
|
1751
|
+
if [ -n "$runtime_project_slugs" ]; then
|
|
1752
|
+
printf '%s\n' "$runtime_project_slugs" > "$runtime_projects_tmp"
|
|
1753
|
+
else
|
|
1754
|
+
: > "$runtime_projects_tmp"
|
|
1755
|
+
fi
|
|
1756
|
+
chmod 0644 "$runtime_projects_tmp"
|
|
1757
|
+
mv -f "$runtime_projects_tmp" "$runtime_projects_file"
|
|
1758
|
+
|
|
1759
|
+
# Union of preview ports across projects, de-duped by containerPort
|
|
1760
|
+
# (first-in-position wins the name). Validated: name non-empty, 1..65535.
|
|
1761
|
+
union_preview_ports_json=$(jq -c '
|
|
1762
|
+
[
|
|
1763
|
+
.[]
|
|
1764
|
+
| (.preview_ports // "[]") as $raw
|
|
1765
|
+
| (if ($raw | type) == "string" then (try ($raw | fromjson) catch [])
|
|
1766
|
+
elif ($raw | type) == "array" then $raw
|
|
1767
|
+
else [] end)
|
|
1768
|
+
| .[]?
|
|
1769
|
+
| {
|
|
1770
|
+
name: (.name // ""),
|
|
1771
|
+
containerPort: (((.containerPort // 0) | tonumber?) // 0)
|
|
1772
|
+
}
|
|
1773
|
+
| select(.name != "" and .containerPort > 0 and .containerPort <= 65535)
|
|
1774
|
+
]
|
|
1775
|
+
| reduce .[] as $p ({seen: [], out: []};
|
|
1776
|
+
if (.seen | index($p.containerPort)) then .
|
|
1777
|
+
else {seen: (.seen + [$p.containerPort]), out: (.out + [$p])} end)
|
|
1778
|
+
| .out
|
|
1779
|
+
' <<<"$projects_json")
|
|
1780
|
+
|
|
1781
|
+
# Union of declared env keys across projects (env JSON is an array of
|
|
1782
|
+
# {key,...} objects per ADR-015). De-duped, first occurrence wins.
|
|
1783
|
+
union_env_keys_json=$(jq -c '
|
|
1784
|
+
[
|
|
1785
|
+
.[]
|
|
1786
|
+
| (.env // "[]") as $raw
|
|
1787
|
+
| (if ($raw | type) == "string" then (try ($raw | fromjson) catch [])
|
|
1788
|
+
elif ($raw | type) == "array" then $raw
|
|
1789
|
+
else [] end)
|
|
1790
|
+
| .[]?
|
|
1791
|
+
| (.key // "")
|
|
1792
|
+
| select(. != "")
|
|
1793
|
+
]
|
|
1794
|
+
| reduce .[] as $k ([]; if (index($k)) then . else . + [$k] end)
|
|
1795
|
+
' <<<"$projects_json")
|
|
1796
|
+
|
|
1797
|
+
# Concatenated `extra` RUN snippets across projects (in position order).
|
|
1798
|
+
extra_block=$(jq -r '
|
|
1799
|
+
[ .[] | (.extra // "") | select(. != "" and . != "null") ]
|
|
1800
|
+
| join("\n")
|
|
1801
|
+
' <<<"$projects_json")
|
|
1802
|
+
|
|
1803
|
+
has_derived=0
|
|
1804
|
+
if [ -n "$extra_block" ]; then
|
|
1805
|
+
has_derived=1
|
|
1806
|
+
fi
|
|
1807
|
+
|
|
1808
|
+
# One fail-closed authority for names projects must never control, whether the
|
|
1809
|
+
# value arrived as an ordinary declared env key or as a preview urlEnv literal.
|
|
1810
|
+
# UAI_EDITOR_THEME is the sole preview exception below: it is computed by the
|
|
1811
|
+
# cloud from the task palette, not sourced from a project.
|
|
1812
|
+
is_reserved_task_env_name() {
|
|
1813
|
+
case "$1" in
|
|
1814
|
+
GH_TOKEN | GITHUB_TOKEN | GH_CONFIG_DIR | GH_HOST | \
|
|
1815
|
+
GIT_CONFIG* | GIT_DIR | GIT_WORK_TREE | GIT_COMMON_DIR | \
|
|
1816
|
+
GIT_OBJECT_DIRECTORY | GIT_ALTERNATE_OBJECT_DIRECTORIES | \
|
|
1817
|
+
GIT_INDEX_FILE | GIT_EXEC_PATH | GIT_ASKPASS | SSH_ASKPASS | \
|
|
1818
|
+
GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | XDG_DATA_HOME | \
|
|
1819
|
+
XDG_CACHE_HOME | \
|
|
1820
|
+
PATH | SHELL | LD_PRELOAD | LD_AUDIT | LD_LIBRARY_PATH | NODE_OPTIONS | \
|
|
1821
|
+
NODE_PATH | SHELLOPTS | BASHOPTS | PS4 | BASH_ENV | ENV | \
|
|
1822
|
+
__UAI_ENV_* | __CURSOR_SANDBOX_ENV_RESTORE | GROK_LOGIN_ENV | \
|
|
1823
|
+
NODE_EXTRA_CA_CERTS | SSL_CERT_FILE | REQUESTS_CA_BUNDLE | \
|
|
1824
|
+
[Aa][Ss][Dd][Ff]_* | [Cc][Oo][Rr][Ee][Pp][Aa][Cc][Kk]_* | \
|
|
1825
|
+
[Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Pp][Rr][Ee][Ff][Ii][Xx] | \
|
|
1826
|
+
[Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Cc][Aa][Cc][Hh][Ee] | \
|
|
1827
|
+
[Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Ll][Oo][Gg][Ss][_-][Dd][Ii][Rr] | \
|
|
1828
|
+
[Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Uu][Mm][Aa][Ss][Kk] | UAI_* | \
|
|
1829
|
+
CLAUDE_CODE_OAUTH_TOKEN | \
|
|
1830
|
+
ANTHROPIC_API_KEY | ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | CURSOR_API_KEY | \
|
|
1831
|
+
PLAYWRIGHT_BROWSERS_PATH)
|
|
1832
|
+
return 0
|
|
1833
|
+
;;
|
|
1834
|
+
esac
|
|
1835
|
+
return 1
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
# A baked task's project env, as both runtimes receive it (Docker's Compose
|
|
1839
|
+
# override, Apple's env file): values JSON on stdin; $1 the declared keys
|
|
1840
|
+
# (JSON array), $2 the preview URL env (JSON object). Declared env names
|
|
1841
|
+
# only, minus every name is_reserved_task_env_name reserves (the one
|
|
1842
|
+
# authority, never a second list) and the preview URL names.
|
|
1843
|
+
filter_baked_project_env() {
|
|
1844
|
+
local declared="$1" preview="$2" values reserved='[]' key
|
|
1845
|
+
values=$(cat)
|
|
1846
|
+
while IFS= read -r key; do
|
|
1847
|
+
if is_reserved_task_env_name "$key"; then
|
|
1848
|
+
reserved=$(jq -c --arg key "$key" '. + [$key]' <<<"$reserved")
|
|
1849
|
+
fi
|
|
1850
|
+
done < <(printf '%s' "$values" | jq -r 'keys[] | select(test("^[A-Za-z_][A-Za-z0-9_]*$"))')
|
|
1851
|
+
printf '%s' "$values" | jq -c \
|
|
1852
|
+
--argjson declared "$declared" \
|
|
1853
|
+
--argjson preview "$preview" \
|
|
1854
|
+
--argjson reserved "$reserved" '
|
|
1855
|
+
with_entries(.key as $key | select($declared | index($key)))
|
|
1856
|
+
| with_entries(select(.key | test("^[A-Za-z_][A-Za-z0-9_]*$")))
|
|
1857
|
+
| delpaths($reserved | map([.]))
|
|
1858
|
+
| delpaths($preview | keys | map([.]))
|
|
1859
|
+
'
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
# Names that the loader owns are non-secret metadata. Mount the exact launch
|
|
1863
|
+
# set read-only so uai-init can remove even the empty clears inherited from a
|
|
1864
|
+
# derived image before it starts code-server. The editor daemon itself never
|
|
1865
|
+
# loads project env; each terminal shell fetches independently.
|
|
1866
|
+
managed_env_keys_file="$task_uai_dir/managed-env-keys"
|
|
1867
|
+
managed_env_keys_tmp=$(mktemp "$task_uai_dir/.managed-env-keys.XXXXXX")
|
|
1868
|
+
# macOS still ships Bash 3.2, which has indexed arrays but no associative
|
|
1869
|
+
# arrays. Names are validated before this delimiter set is consulted, so `|`
|
|
1870
|
+
# cannot collide with an env name.
|
|
1871
|
+
managed_env_seen='|'
|
|
1872
|
+
{
|
|
1873
|
+
while IFS= read -r managed_name; do
|
|
1874
|
+
[[ "$managed_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
|
1875
|
+
is_reserved_task_env_name "$managed_name" && continue
|
|
1876
|
+
case "$managed_env_seen" in *"|$managed_name|"*) continue ;; esac
|
|
1877
|
+
managed_env_seen="${managed_env_seen}${managed_name}|"
|
|
1878
|
+
printf '%s\n' "$managed_name"
|
|
1879
|
+
done < <(jq -r '.[]' <<<"$union_env_keys_json")
|
|
1880
|
+
preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
|
|
1881
|
+
while IFS= read -r managed_name; do
|
|
1882
|
+
[[ "$managed_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
|
1883
|
+
if [ "$managed_name" != "UAI_EDITOR_THEME" ] \
|
|
1884
|
+
&& is_reserved_task_env_name "$managed_name"; then
|
|
1885
|
+
continue
|
|
1886
|
+
fi
|
|
1887
|
+
case "$managed_env_seen" in *"|$managed_name|"*) continue ;; esac
|
|
1888
|
+
managed_env_seen="${managed_env_seen}${managed_name}|"
|
|
1889
|
+
printf '%s\n' "$managed_name"
|
|
1890
|
+
done < <(jq -r 'keys[]?' <<<"$preview_env_raw")
|
|
1891
|
+
} > "$managed_env_keys_tmp"
|
|
1892
|
+
chmod 0444 "$managed_env_keys_tmp"
|
|
1893
|
+
mv -f "$managed_env_keys_tmp" "$managed_env_keys_file"
|
|
1894
|
+
|
|
1895
|
+
# Write the compose file. No top-level name: (the -p flag sets the project),
|
|
1896
|
+
# no container_name:. Env values live host-side (ADR-015): we emit only
|
|
1897
|
+
# pass-through references `${KEY:-}`.
|
|
1898
|
+
{
|
|
1899
|
+
printf 'services:\n'
|
|
1900
|
+
printf ' app:\n'
|
|
1901
|
+
# The station's mark on everything of this task's stack (Moondeck plan §4):
|
|
1902
|
+
# the frozen @runuai/host labels nothing of a Compose stack, so a `task-*`
|
|
1903
|
+
# project without it is the old host's (uninstall-legacy removes those).
|
|
1904
|
+
printf ' labels:\n'
|
|
1905
|
+
printf ' io.moondeck.task: "%s"\n' "$task_id"
|
|
1906
|
+
if [ "$has_derived" = "1" ]; then
|
|
1907
|
+
printf ' build:\n'
|
|
1908
|
+
printf ' context: .\n'
|
|
1909
|
+
printf ' dockerfile: Dockerfile\n'
|
|
1910
|
+
printf ' image: %s\n' "$DERIVED_IMAGE"
|
|
1911
|
+
else
|
|
1912
|
+
printf ' image: %s\n' "$STANDARD_IMAGE"
|
|
1913
|
+
# Never pull: the standard image is built LOCALLY by the host, not hosted
|
|
1914
|
+
# on any registry. Without this, an absent image makes compose attempt a
|
|
1915
|
+
# doomed registry pull ("pull access denied for uai-standard") instead of
|
|
1916
|
+
# failing clearly (live 2026-07-22, a fresh host mid standard-image build).
|
|
1917
|
+
printf ' pull_policy: never\n'
|
|
1918
|
+
fi
|
|
1919
|
+
printf ' init: true\n'
|
|
1920
|
+
# Keep the host MCP gateway reachable through Docker's ordinary
|
|
1921
|
+
# host-gateway address. (The Apple runtime has no extra_hosts equivalent
|
|
1922
|
+
# and none is rendered for it; its gateway route is the vmnet gateway IP.)
|
|
1923
|
+
printf ' extra_hosts:\n'
|
|
1924
|
+
printf ' - "host.docker.internal:host-gateway"\n'
|
|
1925
|
+
# Give node the one host group that owns this task's bind-mounted files.
|
|
1926
|
+
# Numeric group_add works even when the group has a different name (or no
|
|
1927
|
+
# name) in the image. Root hosts use node's existing gid 1000 above.
|
|
1928
|
+
printf ' group_add:\n'
|
|
1929
|
+
printf ' - "%s"\n' "$task_container_gid"
|
|
1930
|
+
printf ' working_dir: /workspace\n'
|
|
1931
|
+
printf ' volumes:\n'
|
|
1932
|
+
printf ' - "%s:/workspace"\n' "$task_workspace"
|
|
1933
|
+
# Identical-host-path binds keep each linked worktree's absolute gitdir
|
|
1934
|
+
# pointer valid in-container (ADR-014). Only the self-contained task-private
|
|
1935
|
+
# repository is mounted; the host cache never enters a container. Legacy
|
|
1936
|
+
# worktrees fail closed before Compose rendering and must be recreated.
|
|
1937
|
+
printf ' - "%s:%s"\n' "$task_workspace" "$task_workspace"
|
|
1938
|
+
if [ "$task_workspace_physical" != "$task_workspace" ]; then
|
|
1939
|
+
# Git canonicalizes a symlinked workspace root in both directions: the
|
|
1940
|
+
# worktree's `.git` points at the physical repository, and the repository's
|
|
1941
|
+
# worktree metadata points back at the physical workspace. Mount the same
|
|
1942
|
+
# already-approved workspace at that alias without exposing its `.uai`
|
|
1943
|
+
# sibling or any broader host directory.
|
|
1944
|
+
printf ' - "%s:%s"\n' \
|
|
1945
|
+
"$task_workspace" "$task_workspace_physical"
|
|
1946
|
+
fi
|
|
1947
|
+
while IFS= read -r vol_obj; do
|
|
1948
|
+
[ -n "$vol_obj" ] || continue
|
|
1949
|
+
vol_pid=$(jq -r '.id' <<<"$vol_obj")
|
|
1950
|
+
printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
|
|
1951
|
+
"$task_uai_dir" "$vol_pid" "$task_uai_dir" "$vol_pid"
|
|
1952
|
+
if [ "$task_uai_dir_physical" != "$task_uai_dir" ]; then
|
|
1953
|
+
printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
|
|
1954
|
+
"$task_uai_dir" "$vol_pid" "$task_uai_dir_physical" "$vol_pid"
|
|
1955
|
+
fi
|
|
1956
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
1957
|
+
printf ' - "%s:/usr/local/bin/uai-init:ro"\n' "$UAI_INIT_SOURCE"
|
|
1958
|
+
printf ' - "%s:/usr/local/bin/moondeck:ro"\n' "$MOONDECK_CLI_SOURCE"
|
|
1959
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
1960
|
+
printf ' - "%s:/usr/local/share/uai/env-hook.sh:ro"\n' \
|
|
1961
|
+
"$ENV_HOOK_SOURCE"
|
|
1962
|
+
printf ' - "%s:/usr/local/bin/uai-env-exec:ro"\n' \
|
|
1963
|
+
"$UAI_ENV_EXEC_SOURCE"
|
|
1964
|
+
printf ' - "%s:/usr/local/share/uai/uai-task-forwarder.mjs:ro"\n' \
|
|
1965
|
+
"$TASK_FORWARDER_SOURCE"
|
|
1966
|
+
fi
|
|
1967
|
+
printf ' - "%s:/run/uai/runtime-projects:ro"\n' \
|
|
1968
|
+
"$runtime_projects_file"
|
|
1969
|
+
printf ' - "%s:/run/uai/managed-env-keys:ro"\n' \
|
|
1970
|
+
"$managed_env_keys_file"
|
|
1971
|
+
# Task processes consume only the exact runtimes/package managers proved by
|
|
1972
|
+
# pre-start maintenance. `:ro` is explicit because Docker's omitted default
|
|
1973
|
+
# is read-write and a legacy Compose file must never look equivalent.
|
|
1974
|
+
printf ' - "%s:/opt/asdf-data:ro"\n' "$ASDF_VOLUME"
|
|
1975
|
+
# ADR-053: host-wide Playwright browser cache. Always mounted (harmless
|
|
1976
|
+
# when browser testing is off); Chromium downloads once per HOST.
|
|
1977
|
+
printf ' - "%s:/opt/pw-browsers"\n' "$PW_VOLUME"
|
|
1978
|
+
# ADR-062 shared files (org + personal) at /workspace/files/{org,me};
|
|
1979
|
+
# ro/rw fixed here for the container's lifetime.
|
|
1980
|
+
if [ "$shared_files_mode" != "off" ]; then
|
|
1981
|
+
shared_suffix=""
|
|
1982
|
+
[ "$shared_files_mode" = "ro" ] && shared_suffix=":ro"
|
|
1983
|
+
if [ -n "$shared_owner_org" ]; then
|
|
1984
|
+
printf ' - "%s/orgs/%s:/workspace/files/org%s"\n' \
|
|
1985
|
+
"$shared_root" "$shared_owner_org" "$shared_suffix"
|
|
1986
|
+
fi
|
|
1987
|
+
if [ -n "$shared_owner_user" ]; then
|
|
1988
|
+
printf ' - "%s/users/%s:/workspace/files/me%s"\n' \
|
|
1989
|
+
"$shared_root" "$shared_owner_user" "$shared_suffix"
|
|
1990
|
+
fi
|
|
1991
|
+
fi
|
|
1992
|
+
printf ' ports:\n'
|
|
1993
|
+
# code-server (the Editor tunnel) is the only auto-published port. Preview
|
|
1994
|
+
# ports are NOT published at task-up (ADR-043 Stage 2: opt-in previews) —
|
|
1995
|
+
# each preview the user enables is reached lazily through a per-(task,preview)
|
|
1996
|
+
# sidecar attached to the compose network, so nothing is exposed by default.
|
|
1997
|
+
printf ' - "127.0.0.1::8080"\n'
|
|
1998
|
+
printf ' environment:\n'
|
|
1999
|
+
# Non-secret transport marker for uai-init. The actual GitHub token is
|
|
2000
|
+
# injected later into gh's private config and never enters Compose.
|
|
2001
|
+
printf ' UAI_GIT_TRANSPORT: "%s"\n' "$uai_git_transport"
|
|
2002
|
+
# Uai owns GitHub auth inside the task container. Keep gh's config outside
|
|
2003
|
+
# the bind-mounted workspace and independent of project-defined XDG/HOME
|
|
2004
|
+
# values so its credential helper always resolves the connected user.
|
|
2005
|
+
printf ' GH_CONFIG_DIR: "/home/node/.config/gh"\n'
|
|
2006
|
+
# Runtime selection/cache authority is host-owned. These values override
|
|
2007
|
+
# both project-declared keys and a derived image's ENV so uai-init cannot be
|
|
2008
|
+
# redirected into an unproved runtime or a different shared-cache writer.
|
|
2009
|
+
printf ' PATH: "%s"\n' "$TASK_RUNTIME_PATH"
|
|
2010
|
+
printf ' HOME: "/home/node"\n'
|
|
2011
|
+
printf ' XDG_CONFIG_HOME: "/home/node/.config"\n'
|
|
2012
|
+
printf ' XDG_DATA_HOME: "/home/node/.local/share"\n'
|
|
2013
|
+
printf ' XDG_CACHE_HOME: "/home/node/.cache"\n'
|
|
2014
|
+
printf ' ASDF_DIR: "/opt/asdf"\n'
|
|
2015
|
+
printf ' ASDF_DATA_DIR: "/opt/asdf-data"\n'
|
|
2016
|
+
printf ' ASDF_CONFIG_FILE: "/dev/null"\n'
|
|
2017
|
+
printf ' ASDF_DEFAULT_TOOL_VERSIONS_FILENAME: ".tool-versions"\n'
|
|
2018
|
+
printf ' ASDF_NODEJS_VERSION: ""\n'
|
|
2019
|
+
printf ' ASDF_PYTHON_VERSION: ""\n'
|
|
2020
|
+
printf ' ASDF_GOLANG_VERSION: ""\n'
|
|
2021
|
+
printf ' ASDF_RUBY_VERSION: ""\n'
|
|
2022
|
+
printf ' ASDF_RUST_VERSION: ""\n'
|
|
2023
|
+
printf ' ASDF_SKIP_RESHIM: "1"\n'
|
|
2024
|
+
printf ' COREPACK_HOME: "/opt/asdf-data/corepack"\n'
|
|
2025
|
+
printf ' COREPACK_DEFAULT_TO_LATEST: "0"\n'
|
|
2026
|
+
printf ' COREPACK_ENV_FILE: "0"\n'
|
|
2027
|
+
printf ' COREPACK_NPM_REGISTRY: "https://registry.npmjs.org"\n'
|
|
2028
|
+
printf ' COREPACK_ENABLE_NETWORK: "0"\n'
|
|
2029
|
+
printf ' COREPACK_ENABLE_AUTO_PIN: "0"\n'
|
|
2030
|
+
printf ' COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"\n'
|
|
2031
|
+
printf ' COREPACK_ENABLE_PROJECT_SPEC: "1"\n'
|
|
2032
|
+
printf ' COREPACK_ENABLE_STRICT: "1"\n'
|
|
2033
|
+
printf ' COREPACK_ENABLE_UNSAFE_CUSTOM_URLS: "0"\n'
|
|
2034
|
+
printf ' COREPACK_INTEGRITY_CHECK: "1"\n'
|
|
2035
|
+
# npm injects lowercase config names into lifecycle scripts, so pin both
|
|
2036
|
+
# cases. These task-private paths are never part of the shared asdf volume.
|
|
2037
|
+
printf ' NPM_CONFIG_PREFIX: "%s"\n' "$TASK_NPM_PREFIX"
|
|
2038
|
+
printf ' npm_config_prefix: "%s"\n' "$TASK_NPM_PREFIX"
|
|
2039
|
+
printf ' NPM_CONFIG_CACHE: "%s"\n' "$TASK_NPM_CACHE"
|
|
2040
|
+
printf ' npm_config_cache: "%s"\n' "$TASK_NPM_CACHE"
|
|
2041
|
+
printf ' NPM_CONFIG_LOGS_DIR: "%s/_logs"\n' "$TASK_NPM_CACHE"
|
|
2042
|
+
printf ' npm_config_logs_dir: "%s/_logs"\n' "$TASK_NPM_CACHE"
|
|
2043
|
+
printf ' NPM_CONFIG_UMASK: "0002"\n'
|
|
2044
|
+
printf ' npm_config_umask: "0002"\n'
|
|
2045
|
+
printf ' UAI_WORKSPACE: "/workspace"\n'
|
|
2046
|
+
printf ' UAI_RUNTIME_PROJECTS: "/run/uai/runtime-projects"\n'
|
|
2047
|
+
printf ' UAI_ENV_MODE: "%s"\n' "$UAI_PROJECT_ENV_MODE"
|
|
2048
|
+
printf ' UAI_ENV_HOOK: "0"\n'
|
|
2049
|
+
printf ' SHELL: "/usr/bin/zsh"\n'
|
|
2050
|
+
printf ' LD_PRELOAD: ""\n'
|
|
2051
|
+
printf ' LD_AUDIT: ""\n'
|
|
2052
|
+
printf ' LD_LIBRARY_PATH: ""\n'
|
|
2053
|
+
printf ' NODE_OPTIONS: ""\n'
|
|
2054
|
+
printf ' NODE_PATH: ""\n'
|
|
2055
|
+
# SHELLOPTS/BASHOPTS are deliberately ABSENT, never empty: bash exports its
|
|
2056
|
+
# live options into any child when these names were present in its startup
|
|
2057
|
+
# environment, so an "empty clear" let set -euo scripts stamp nounset into
|
|
2058
|
+
# code-server and break every asdf shim in the Editor terminal (found live
|
|
2059
|
+
# 2026-08-16, on BOTH engines; the reserved-name filter still strips any
|
|
2060
|
+
# project-declared value).
|
|
2061
|
+
printf ' PS4: ""\n'
|
|
2062
|
+
printf ' BASH_ENV: ""\n'
|
|
2063
|
+
printf ' ENV: ""\n'
|
|
2064
|
+
# Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
|
|
2065
|
+
# uses the interactive subscription/keychain path, so it needs a token from
|
|
2066
|
+
# `claude setup-token`. Interpolated from the host-agent env at up-time, so
|
|
2067
|
+
# the literal token is never written to the compose file on disk. Putting it
|
|
2068
|
+
# in the container env (rather than the host's ~/.claude mount, which broke
|
|
2069
|
+
# with EROFS on session-env writes) means both the orchestrator's headless
|
|
2070
|
+
# claude and the code-server terminal's interactive claude authenticate.
|
|
2071
|
+
if [ "$uai_on_cloud_credentials" != "1" ]; then
|
|
2072
|
+
printf ' %s: "${%s:-}"\n' \
|
|
2073
|
+
"CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN"
|
|
2074
|
+
# Pasted engine API keys (ADR-070) ride the same at-up interpolation, so the
|
|
2075
|
+
# terminal CLIs authenticate like the adapters' exec-time env forwarding.
|
|
2076
|
+
printf ' %s: "${%s:-}"\n' "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY"
|
|
2077
|
+
printf ' %s: "${%s:-}"\n' "ANTHROPIC_AUTH_TOKEN" "ANTHROPIC_AUTH_TOKEN"
|
|
2078
|
+
printf ' %s: "${%s:-}"\n' "XAI_API_KEY" "XAI_API_KEY"
|
|
2079
|
+
fi
|
|
2080
|
+
# ADR-053: point Playwright at the shared browser cache volume.
|
|
2081
|
+
printf ' PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers"\n'
|
|
2082
|
+
# No GH_TOKEN/GITHUB_TOKEN in the container env (ADR-027). `gh` prefers such
|
|
2083
|
+
# an env var over its stored credentials, which blocks the per-user
|
|
2084
|
+
# `gh auth login --with-token` the host runs (and would re-attribute every PR
|
|
2085
|
+
# to the env token — breaking multi-user). The host's GitHub token reaches
|
|
2086
|
+
# `gh` through its config file instead (lib/github-tokens.ts), and gh serves
|
|
2087
|
+
# as Git's HTTPS credential helper. SSH remains a disconnected-user fallback.
|
|
2088
|
+
# Projects may declare managed names, so skip them here rather than trust
|
|
2089
|
+
# callers. They are also removed from the in-memory Compose override below.
|
|
2090
|
+
#
|
|
2091
|
+
# UAI_EDITOR_THEME is reserved in BOTH places for the case that is easy to
|
|
2092
|
+
# miss: a task whose stored palette is null, on a project that happens to
|
|
2093
|
+
# declare that name. The cloud sends no theme, so the `preview_env` entry
|
|
2094
|
+
# that normally shadows a declared key is absent too — and without these two
|
|
2095
|
+
# lines a project would get to pick what the Editor pane looks like. Reserved,
|
|
2096
|
+
# the name is not emitted at all and uai-init seeds the image's own default.
|
|
2097
|
+
#
|
|
2098
|
+
# Project values never enter the host environment. Emit an explicit empty
|
|
2099
|
+
# default for every declared key; the stdin-only Compose override supplies
|
|
2100
|
+
# decrypted values at `compose up` time.
|
|
2101
|
+
while IFS= read -r ekey; do
|
|
2102
|
+
[ -n "$ekey" ] || continue
|
|
2103
|
+
is_reserved_task_env_name "$ekey" && continue
|
|
2104
|
+
printf ' %s: ""\n' "$ekey"
|
|
2105
|
+
done < <(jq -r '.[]' <<<"$union_env_keys_json")
|
|
2106
|
+
# Cloud-computed env (ADR-025): a JSON object {VAR: value} on the task row.
|
|
2107
|
+
# A legacy baked task writes the literals as before. A loader task emits only
|
|
2108
|
+
# empty clears (to defeat a derived image's ENV); the loopback endpoint is
|
|
2109
|
+
# the sole source of the current values. Two producers, both cloud-side:
|
|
2110
|
+
# - PUBLIC preview URLs under operator-chosen names (EXPO_PACKAGER_PROXY_URL
|
|
2111
|
+
# and friends), one per preview port that set `urlEnv`;
|
|
2112
|
+
# - UAI_EDITOR_THEME (ADR-091), the Editor pane's colour theme as a VS Code
|
|
2113
|
+
# theme LABEL ("Quiet Light"), already resolved from the palette the
|
|
2114
|
+
# creating browser snapshotted. The host never maps a Uai palette id.
|
|
2115
|
+
# UAI_EDITOR_THEME is absent for every task created before it shipped and for
|
|
2116
|
+
# any created outside a browser; uai-init then seeds the image's own default,
|
|
2117
|
+
# so the column staying null is a supported state, not a value to fill in.
|
|
2118
|
+
preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
|
|
2119
|
+
while IFS= read -r pe_entry; do
|
|
2120
|
+
[ -n "$pe_entry" ] || continue
|
|
2121
|
+
pe_key=$(jq -r '.key' <<<"$pe_entry")
|
|
2122
|
+
pe_val=$(jq -r '.value' <<<"$pe_entry")
|
|
2123
|
+
[ -n "$pe_key" ] || continue
|
|
2124
|
+
if [ "$pe_key" != "UAI_EDITOR_THEME" ] \
|
|
2125
|
+
&& is_reserved_task_env_name "$pe_key"; then
|
|
2126
|
+
continue
|
|
2127
|
+
fi
|
|
2128
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
2129
|
+
printf ' %s: ""\n' "$pe_key"
|
|
2130
|
+
else
|
|
2131
|
+
printf ' %s: "%s"\n' "$pe_key" "$pe_val"
|
|
2132
|
+
fi
|
|
2133
|
+
done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
|
|
2134
|
+
printf 'networks:\n'
|
|
2135
|
+
printf ' default:\n'
|
|
2136
|
+
printf ' labels:\n'
|
|
2137
|
+
printf ' io.moondeck.task: "%s"\n' "$task_id"
|
|
2138
|
+
printf 'volumes:\n'
|
|
2139
|
+
printf ' %s:\n' "$ASDF_VOLUME"
|
|
2140
|
+
printf ' external: true\n'
|
|
2141
|
+
printf ' %s:\n' "$PW_VOLUME"
|
|
2142
|
+
printf ' external: true\n'
|
|
2143
|
+
} > "$task_uai_dir/docker-compose.yml"
|
|
2144
|
+
|
|
2145
|
+
if [ "$has_derived" = "1" ]; then
|
|
2146
|
+
# Run `extra` as root so apt/system installs work (the standard image ends
|
|
2147
|
+
# as USER node), then drop back to node for the running container.
|
|
2148
|
+
{
|
|
2149
|
+
printf 'FROM %s\n' "$STANDARD_IMAGE"
|
|
2150
|
+
printf 'USER root\n'
|
|
2151
|
+
printf '%s\n' "$extra_block"
|
|
2152
|
+
printf 'USER node\n'
|
|
2153
|
+
} > "$task_uai_dir/Dockerfile"
|
|
2154
|
+
fi
|
|
2155
|
+
|
|
2156
|
+
# -----------------------------------------------------------------------------
|
|
2157
|
+
# 5. Build (when derived) + pre-materialize project runtimes.
|
|
2158
|
+
# -----------------------------------------------------------------------------
|
|
2159
|
+
|
|
2160
|
+
# The shared asdf cache volume is host-wide and declared external — make
|
|
2161
|
+
# sure it exists before compose references it. Idempotent under Docker; the
|
|
2162
|
+
# Apple CLI errors on an existing name, so tolerate creation and then demand
|
|
2163
|
+
# the volume actually exists.
|
|
2164
|
+
crt volume create "$ASDF_VOLUME" >/dev/null 2>&1 || true
|
|
2165
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2166
|
+
# Browsers ride a host directory here, never a block volume — see
|
|
2167
|
+
# PW_BROWSERS_DIR above for why.
|
|
2168
|
+
if ! /bin/mkdir -p "$PW_BROWSERS_DIR"; then
|
|
2169
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2170
|
+
"Moondeck could not create the shared Playwright browsers directory $PW_BROWSERS_DIR." \
|
|
2171
|
+
"create shared volumes"
|
|
2172
|
+
fi
|
|
2173
|
+
if ! crt volume inspect "$ASDF_VOLUME" >/dev/null 2>&1; then
|
|
2174
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2175
|
+
"Moondeck could not create the shared $ASDF_VOLUME volume on the Apple container runtime. Retry after the runtime is healthy." \
|
|
2176
|
+
"create shared volumes"
|
|
2177
|
+
fi
|
|
2178
|
+
# ADR-118: create or adopt this task's private store volume. Adoption
|
|
2179
|
+
# requires the same exact-identity + ownership-label proof as container
|
|
2180
|
+
# destruction: exactly one record, whose id is the requested name,
|
|
2181
|
+
# carrying io.moondeck.task=<this task>. A volume merely OCCUPYING the
|
|
2182
|
+
# reserved name is not this task's store and must never be mounted into
|
|
2183
|
+
# it — foreign bytes at the package store are an integrity boundary.
|
|
2184
|
+
step "CONTAINER_INIT_FAILED" "create task store volume"
|
|
2185
|
+
if store_volume_json=$(crt volume inspect "$TASK_STORE_VOLUME" 2>/dev/null); then
|
|
2186
|
+
store_volume_ok=$(printf '%s' "$store_volume_json" \
|
|
2187
|
+
| jq -r --arg name "$TASK_STORE_VOLUME" --arg tid "$task_id" '
|
|
2188
|
+
if (type == "array" and length == 1
|
|
2189
|
+
and (.[0] | type == "object")
|
|
2190
|
+
and .[0].id == $name
|
|
2191
|
+
and ((.[0].configuration.labels["io.moondeck.task"] // "") == $tid))
|
|
2192
|
+
then "ok" else "mismatch" end' 2>/dev/null) \
|
|
2193
|
+
|| store_volume_ok="mismatch"
|
|
2194
|
+
if [ "$store_volume_ok" != "ok" ]; then
|
|
2195
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2196
|
+
"A volume named $TASK_STORE_VOLUME exists but could not be proven as this task's own (exact identity + ownership label); Moondeck will not attach a store volume it cannot prove it owns. Remove or rename it, then retry." \
|
|
2197
|
+
"create task store volume"
|
|
2198
|
+
fi
|
|
2199
|
+
else
|
|
2200
|
+
if ! crt volume create --label "io.moondeck.task=$task_id" \
|
|
2201
|
+
"$TASK_STORE_VOLUME" >/dev/null 2>&1; then
|
|
2202
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2203
|
+
"Moondeck could not create this task's store volume $TASK_STORE_VOLUME on the Apple container runtime. Retry after the runtime is healthy." \
|
|
2204
|
+
"create task store volume"
|
|
2205
|
+
fi
|
|
2206
|
+
fi
|
|
2207
|
+
else
|
|
2208
|
+
crt volume create "$PW_VOLUME" >/dev/null 2>&1 || true
|
|
2209
|
+
fi
|
|
2210
|
+
|
|
2211
|
+
# The standard base image is built by the host (ensureStandardImage), never
|
|
2212
|
+
# pulled from a registry — and derived tasks FROM it too. If it's absent (the
|
|
2213
|
+
# host started before Docker was ready, or the build failed) compose would try
|
|
2214
|
+
# a doomed registry pull; fail with an actionable message instead (the host
|
|
2215
|
+
# also (re)builds it on demand before task-up — this is the belt-and-suspenders
|
|
2216
|
+
# check for a still-in-progress or failed build). Live 2026-07-22.
|
|
2217
|
+
if ! crt image inspect "$STANDARD_IMAGE" >/dev/null 2>&1; then
|
|
2218
|
+
emit_err "STANDARD_IMAGE_MISSING" \
|
|
2219
|
+
"the host base image $STANDARD_IMAGE is not built yet — the host builds it at startup (a few minutes on first run, and after Docker Desktop finishes starting). Wait for the host log line 'built standard image', or restart the host, then retry. If it keeps failing the host log shows the build error (often Docker Desktop low on memory/disk)." \
|
|
2220
|
+
"check standard image"
|
|
2221
|
+
fi
|
|
2222
|
+
|
|
2223
|
+
# A derived task must materialize through the exact filesystem it will run:
|
|
2224
|
+
# project `extra` may add runtime build dependencies. Split build from up so a
|
|
2225
|
+
# failed materialization can never start the task container, and so a later up
|
|
2226
|
+
# cannot silently rebuild a different image after the proof succeeds.
|
|
2227
|
+
runtime_image="$STANDARD_IMAGE"
|
|
2228
|
+
if [ "$has_derived" = "1" ]; then
|
|
2229
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2230
|
+
step "COMPOSE_UP_FAILED" "build derived task image"
|
|
2231
|
+
if ! "$CONTAINER_CLI" build -t "$DERIVED_IMAGE" \
|
|
2232
|
+
-f "$task_uai_dir/Dockerfile" "$task_uai_dir" >&2; then
|
|
2233
|
+
require_docker_after_failed_step "build derived task image"
|
|
2234
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2235
|
+
"Moondeck could not build this task's derived image. Check the project's extra setup commands, then retry." \
|
|
2236
|
+
"build derived task image"
|
|
2237
|
+
fi
|
|
2238
|
+
else
|
|
2239
|
+
step "COMPOSE_UP_FAILED" "docker compose build app"
|
|
2240
|
+
if ! docker compose -p "$compose_project" \
|
|
2241
|
+
-f "$task_uai_dir/docker-compose.yml" build app >&2; then
|
|
2242
|
+
require_docker_after_failed_step "docker compose build app"
|
|
2243
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2244
|
+
"Moondeck could not build this task's derived image. Check the project's extra setup commands, then retry." \
|
|
2245
|
+
"docker compose build app"
|
|
2246
|
+
fi
|
|
2247
|
+
fi
|
|
2248
|
+
runtime_image="$DERIVED_IMAGE"
|
|
2249
|
+
fi
|
|
2250
|
+
|
|
2251
|
+
# Resolve the mutable service tag once and use only its immutable local image
|
|
2252
|
+
# ID for both maintenance and app creation. The on-disk Compose file keeps its
|
|
2253
|
+
# tag so task-down can still identify/remove a derived image, but no concurrent
|
|
2254
|
+
# rebuild can swap code between runtime proof and execution.
|
|
2255
|
+
step "CONTAINER_INIT_FAILED" "resolve task image identity"
|
|
2256
|
+
runtime_image_id=""
|
|
2257
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2258
|
+
# One snapshot supplies identity, environment, and entrypoint together, so
|
|
2259
|
+
# every image-authority decision below describes the same bytes. The Apple
|
|
2260
|
+
# CLI cannot run an image by its 64-hex id, so the id is re-checked against
|
|
2261
|
+
# the tag after the container is created instead of run directly.
|
|
2262
|
+
apple_image_snapshot=""
|
|
2263
|
+
if ! apple_image_snapshot=$("$CONTAINER_CLI" image inspect \
|
|
2264
|
+
"$runtime_image" 2>/dev/null); then
|
|
2265
|
+
require_docker_after_failed_step "resolve task image identity"
|
|
2266
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2267
|
+
"Moondeck could not resolve the exact local image for this task. Retry after the host image is available." \
|
|
2268
|
+
"resolve task image identity"
|
|
2269
|
+
fi
|
|
2270
|
+
runtime_image_id=$(printf '%s\n' "$apple_image_snapshot" \
|
|
2271
|
+
| jq -r '.[0].id // empty' 2>/dev/null) || runtime_image_id=""
|
|
2272
|
+
if ! printf '%s\n' "$runtime_image_id" \
|
|
2273
|
+
| grep -Eq '^[0-9a-f]{64}$'; then
|
|
2274
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2275
|
+
"The Apple container runtime returned an invalid image identity for $runtime_image; Moondeck refused to start an unpinned task container." \
|
|
2276
|
+
"resolve task image identity"
|
|
2277
|
+
fi
|
|
2278
|
+
# Without a --entrypoint override flag, an image-declared ENTRYPOINT would
|
|
2279
|
+
# prefix every argv this script passes (including the materializer's
|
|
2280
|
+
# `env -i`). Refuse such an image before anything executes from it.
|
|
2281
|
+
if ! printf '%s\n' "$apple_image_snapshot" | jq -e '
|
|
2282
|
+
(.[0].variants | type == "array" and length == 1)
|
|
2283
|
+
and ((.[0].variants[0].config.config.Entrypoint // []) | length == 0)
|
|
2284
|
+
' >/dev/null 2>&1; then
|
|
2285
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2286
|
+
"Moondeck refused this task image because it declares an ENTRYPOINT or multiple platform variants, which the Apple container runtime cannot safely override. Remove the ENTRYPOINT from the project's extra setup, then retry." \
|
|
2287
|
+
"resolve task image identity"
|
|
2288
|
+
fi
|
|
2289
|
+
else
|
|
2290
|
+
if ! runtime_image_id=$(docker image inspect --format '{{.Id}}' \
|
|
2291
|
+
"$runtime_image" 2>/dev/null); then
|
|
2292
|
+
require_docker_after_failed_step "resolve task image identity"
|
|
2293
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2294
|
+
"Moondeck could not resolve the exact local image for this task. Retry after the host image is available." \
|
|
2295
|
+
"resolve task image identity"
|
|
2296
|
+
fi
|
|
2297
|
+
if ! printf '%s\n' "$runtime_image_id" \
|
|
2298
|
+
| grep -Eq '^sha256:[0-9a-f]{64}$'; then
|
|
2299
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2300
|
+
"Docker returned an invalid image identity for $runtime_image; Moondeck refused to start an unpinned task container." \
|
|
2301
|
+
"resolve task image identity"
|
|
2302
|
+
fi
|
|
2303
|
+
fi
|
|
2304
|
+
|
|
2305
|
+
# Docker keeps image Config.Env entries whose names differ only by case from
|
|
2306
|
+
# later `docker exec -e` overrides. npm treats those spellings
|
|
2307
|
+
# case-insensitively and, depending on environment order, a mixed-case value
|
|
2308
|
+
# can beat both canonical values. Reject such an image before maintenance or
|
|
2309
|
+
# app creation; ordinary registry/auth npm config remains allowed, while the
|
|
2310
|
+
# four path/permission authorities must use one of the two spellings Uai can
|
|
2311
|
+
# deterministically replace.
|
|
2312
|
+
step "CONTAINER_INIT_FAILED" "verify task image npm authority"
|
|
2313
|
+
runtime_image_env_json=""
|
|
2314
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2315
|
+
# From the same snapshot the identity pin came from — the single variant
|
|
2316
|
+
# was already proven above.
|
|
2317
|
+
runtime_image_env_json=$(printf '%s\n' "$apple_image_snapshot" \
|
|
2318
|
+
| jq -c '.[0].variants[0].config.config.Env // []' 2>/dev/null) \
|
|
2319
|
+
|| runtime_image_env_json=""
|
|
2320
|
+
if [ -z "$runtime_image_env_json" ]; then
|
|
2321
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2322
|
+
"Moondeck could not inspect this task image's npm environment authority. Retry after the Apple container runtime is stable." \
|
|
2323
|
+
"verify task image npm authority"
|
|
2324
|
+
fi
|
|
2325
|
+
elif ! runtime_image_env_json=$(docker image inspect \
|
|
2326
|
+
--format '{{json .Config.Env}}' "$runtime_image_id" 2>/dev/null); then
|
|
2327
|
+
require_docker_after_failed_step "verify task image npm authority"
|
|
2328
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2329
|
+
"Moondeck could not inspect this task image's npm environment authority. Retry after Docker is stable." \
|
|
2330
|
+
"verify task image npm authority"
|
|
2331
|
+
fi
|
|
2332
|
+
if ! printf '%s\n' "$runtime_image_env_json" | jq -e '
|
|
2333
|
+
def is_owned_npm_config($owned):
|
|
2334
|
+
ascii_downcase as $folded
|
|
2335
|
+
| ($folded | startswith("npm_config_"))
|
|
2336
|
+
and (($owned | index($folded[11:] | gsub("_"; "-"))) != null);
|
|
2337
|
+
(. // []) as $env
|
|
2338
|
+
| [
|
|
2339
|
+
"NPM_CONFIG_PREFIX", "npm_config_prefix",
|
|
2340
|
+
"NPM_CONFIG_CACHE", "npm_config_cache",
|
|
2341
|
+
"NPM_CONFIG_LOGS_DIR", "npm_config_logs_dir",
|
|
2342
|
+
"NPM_CONFIG_UMASK", "npm_config_umask"
|
|
2343
|
+
] as $canonical
|
|
2344
|
+
| [
|
|
2345
|
+
"prefix", "cache", "logs-dir", "umask"
|
|
2346
|
+
] as $owned
|
|
2347
|
+
| ($env | type == "array")
|
|
2348
|
+
and all($env[]; type == "string")
|
|
2349
|
+
and all($env[];
|
|
2350
|
+
(split("=")[0]) as $key
|
|
2351
|
+
| (($key | is_owned_npm_config($owned)) | not)
|
|
2352
|
+
or (($canonical | index($key)) != null)
|
|
2353
|
+
)
|
|
2354
|
+
' >/dev/null 2>&1; then
|
|
2355
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2356
|
+
"Moondeck refused this task image because a mixed-case npm prefix/cache/log setting could redirect global package writes. Remove that image ENV setting (unrelated registry/auth settings remain supported) and retry." \
|
|
2357
|
+
"verify task image npm authority"
|
|
2358
|
+
fi
|
|
2359
|
+
|
|
2360
|
+
# Runtime installation is a host-maintenance phase, not task-container init.
|
|
2361
|
+
# Give the one-shot container only the immutable workspace view and the
|
|
2362
|
+
# explicitly writable host-wide asdf volume. Run it for every selected-project
|
|
2363
|
+
# task: projects without .tool-versions still use the image's proven root
|
|
2364
|
+
# defaults and may request a Corepack-managed package manager. A true
|
|
2365
|
+
# zero-project scratchpad has nothing to prepare and remains the only skip.
|
|
2366
|
+
# GNU timeout lives in the Debian standard image (and therefore every derived
|
|
2367
|
+
# image); it bounds the shared lock wait plus all installs and positive
|
|
2368
|
+
# verification without leaving an orphaned volume writer when the cloud's
|
|
2369
|
+
# 15-minute RPC deadline expires.
|
|
2370
|
+
if [ "$project_count" -gt 0 ]; then
|
|
2371
|
+
step "CONTAINER_INIT_FAILED" "materialize task runtimes"
|
|
2372
|
+
materialization_rc=0
|
|
2373
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2374
|
+
# ADR-108: the shared volume is never written on the task path. Apple
|
|
2375
|
+
# materialization runs INSIDE the app container after the in-guest
|
|
2376
|
+
# overlay is established (section 6a below) — a missing toolchain
|
|
2377
|
+
# installs into the task's own upper layer with zero shared-volume
|
|
2378
|
+
# coordination, at any task count.
|
|
2379
|
+
log "apple runtime materialization runs in-container after the overlay is established"
|
|
2380
|
+
else
|
|
2381
|
+
run_runtime_materializer "$runtime_image_id" || materialization_rc=$?
|
|
2382
|
+
fi
|
|
2383
|
+
if [ "$materialization_rc" -ne 0 ]; then
|
|
2384
|
+
require_docker_after_failed_step "materialize task runtimes"
|
|
2385
|
+
case "$materialization_rc" in
|
|
2386
|
+
124|137)
|
|
2387
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2388
|
+
"Runtime materialization did not finish inside the station's 10-minute startup budget. Retry the task: Moondeck will re-check every required runtime and package manager before it starts any task container, and will not trust a partial cache." \
|
|
2389
|
+
"materialize task runtimes"
|
|
2390
|
+
;;
|
|
2391
|
+
*)
|
|
2392
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2393
|
+
"Moondeck could not install and verify every runtime and package manager needed by this task. Check the project's .tool-versions and packageManager entries and the host's network and disk availability, then retry." \
|
|
2394
|
+
"materialize task runtimes"
|
|
2395
|
+
;;
|
|
2396
|
+
esac
|
|
2397
|
+
fi
|
|
2398
|
+
else
|
|
2399
|
+
log "zero-project scratchpad — skipping runtime materialization"
|
|
2400
|
+
fi
|
|
2401
|
+
|
|
2402
|
+
# -----------------------------------------------------------------------------
|
|
2403
|
+
# 6. Compose up.
|
|
2404
|
+
# -----------------------------------------------------------------------------
|
|
2405
|
+
|
|
2406
|
+
step "COMPOSE_UP_FAILED" "start task container"
|
|
2407
|
+
# Limit the supplied values to keys the selected projects actually declare,
|
|
2408
|
+
# then remove Uai-managed names and preview URL names. Both runtimes consume
|
|
2409
|
+
# the same filtered object: Docker merges it as a Compose stdin override, and
|
|
2410
|
+
# the Apple path serializes it into a transient env-file below. JSON is valid
|
|
2411
|
+
# YAML, so Compose can merge this secret-bearing override directly from stdin
|
|
2412
|
+
# without a temporary env/YAML file and without exporting any value to host
|
|
2413
|
+
# commands.
|
|
2414
|
+
preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
|
|
2415
|
+
filtered_project_env='{}'
|
|
2416
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "baked" ]; then
|
|
2417
|
+
filtered_project_env=$(printf '%s' "$project_env_json" \
|
|
2418
|
+
| filter_baked_project_env "$union_env_keys_json" "$preview_env_raw")
|
|
2419
|
+
fi
|
|
2420
|
+
compose_env_override=""
|
|
2421
|
+
if [ "$TASK_RUNTIME_MODE" = "docker" ]; then
|
|
2422
|
+
compose_env_override=$(printf '%s\n' "$filtered_project_env" | jq -c \
|
|
2423
|
+
--arg image "$runtime_image_id" \
|
|
2424
|
+
'{services:{app:{environment:.,image:$image,pull_policy:"never"}}}')
|
|
2425
|
+
fi
|
|
2426
|
+
|
|
2427
|
+
# Detect a tag replacement that completed during materialization and fail with
|
|
2428
|
+
# a clear retry instead of starting a task under a stale generation. The
|
|
2429
|
+
# immutable stdin override below also closes the remaining recheck→up window.
|
|
2430
|
+
step "CONTAINER_INIT_FAILED" "verify task image identity"
|
|
2431
|
+
current_runtime_image_id=""
|
|
2432
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2433
|
+
if ! current_runtime_image_id=$("$CONTAINER_CLI" image inspect \
|
|
2434
|
+
"$runtime_image" 2>/dev/null | jq -r '.[0].id // empty' 2>/dev/null); then
|
|
2435
|
+
require_docker_after_failed_step "verify task image identity"
|
|
2436
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2437
|
+
"Moondeck could not re-check this task's image after runtime materialization. Retry after the Apple container runtime is stable." \
|
|
2438
|
+
"verify task image identity"
|
|
2439
|
+
fi
|
|
2440
|
+
elif ! current_runtime_image_id=$(docker image inspect --format '{{.Id}}' \
|
|
2441
|
+
"$runtime_image" 2>/dev/null); then
|
|
2442
|
+
require_docker_after_failed_step "verify task image identity"
|
|
2443
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2444
|
+
"Moondeck could not re-check this task's image after runtime materialization. Retry after Docker is stable." \
|
|
2445
|
+
"verify task image identity"
|
|
2446
|
+
fi
|
|
2447
|
+
if [ "$current_runtime_image_id" != "$runtime_image_id" ]; then
|
|
2448
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2449
|
+
"This task's image changed while Moondeck was materializing runtimes. Retry so the new image receives a complete runtime proof before any task container starts." \
|
|
2450
|
+
"verify task image identity"
|
|
2451
|
+
fi
|
|
2452
|
+
reject_workspace_runtime_shadow
|
|
2453
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2454
|
+
step "COMPOSE_UP_FAILED" "start task container"
|
|
2455
|
+
# The Apple CLI takes environment through an env-file whose values are read
|
|
2456
|
+
# verbatim from the first `=` to the newline. A value containing a newline
|
|
2457
|
+
# cannot survive that framing — refuse it rather than truncate a secret.
|
|
2458
|
+
if ! printf '%s\n' "$filtered_project_env" | jq -e \
|
|
2459
|
+
'to_entries | all(.value | type == "string" and (test("[\r\n]") | not))' \
|
|
2460
|
+
>/dev/null 2>&1; then
|
|
2461
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2462
|
+
"A project environment value contains a newline, which the Apple container runtime cannot pass through its env-file. Store the value single-line (for example base64) or run this task on a Docker host." \
|
|
2463
|
+
"start task container"
|
|
2464
|
+
fi
|
|
2465
|
+
task_env_file=$(mktemp "$task_uai_dir/.task-env.XXXXXX")
|
|
2466
|
+
chmod 0600 "$task_env_file"
|
|
2467
|
+
{
|
|
2468
|
+
# Mirrors the Compose service environment exactly (see the rendered
|
|
2469
|
+
# docker-compose.yml for the rationale on each block). Later lines win in
|
|
2470
|
+
# both dialects, so ordering here preserves the Compose precedence:
|
|
2471
|
+
# static authority, engine tokens, declared project values, preview env.
|
|
2472
|
+
printf 'UAI_GIT_TRANSPORT=%s\n' "$uai_git_transport"
|
|
2473
|
+
printf 'GH_CONFIG_DIR=/home/node/.config/gh\n'
|
|
2474
|
+
printf 'PATH=%s\n' "$TASK_RUNTIME_PATH"
|
|
2475
|
+
printf 'HOME=/home/node\n'
|
|
2476
|
+
printf 'XDG_CONFIG_HOME=/home/node/.config\n'
|
|
2477
|
+
printf 'XDG_DATA_HOME=/home/node/.local/share\n'
|
|
2478
|
+
printf 'XDG_CACHE_HOME=/home/node/.cache\n'
|
|
2479
|
+
printf 'ASDF_DIR=/opt/asdf\n'
|
|
2480
|
+
printf 'ASDF_DATA_DIR=/opt/asdf-data\n'
|
|
2481
|
+
printf 'ASDF_CONFIG_FILE=/dev/null\n'
|
|
2482
|
+
printf 'ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions\n'
|
|
2483
|
+
printf 'ASDF_NODEJS_VERSION=\n'
|
|
2484
|
+
printf 'ASDF_PYTHON_VERSION=\n'
|
|
2485
|
+
printf 'ASDF_GOLANG_VERSION=\n'
|
|
2486
|
+
printf 'ASDF_RUBY_VERSION=\n'
|
|
2487
|
+
printf 'ASDF_RUST_VERSION=\n'
|
|
2488
|
+
printf 'ASDF_SKIP_RESHIM=1\n'
|
|
2489
|
+
printf 'COREPACK_HOME=/opt/asdf-data/corepack\n'
|
|
2490
|
+
printf 'COREPACK_DEFAULT_TO_LATEST=0\n'
|
|
2491
|
+
printf 'COREPACK_ENV_FILE=0\n'
|
|
2492
|
+
printf 'COREPACK_NPM_REGISTRY=https://registry.npmjs.org\n'
|
|
2493
|
+
printf 'COREPACK_ENABLE_NETWORK=0\n'
|
|
2494
|
+
printf 'COREPACK_ENABLE_AUTO_PIN=0\n'
|
|
2495
|
+
printf 'COREPACK_ENABLE_DOWNLOAD_PROMPT=0\n'
|
|
2496
|
+
printf 'COREPACK_ENABLE_PROJECT_SPEC=1\n'
|
|
2497
|
+
printf 'COREPACK_ENABLE_STRICT=1\n'
|
|
2498
|
+
printf 'COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0\n'
|
|
2499
|
+
printf 'COREPACK_INTEGRITY_CHECK=1\n'
|
|
2500
|
+
printf 'NPM_CONFIG_PREFIX=%s\n' "$TASK_NPM_PREFIX"
|
|
2501
|
+
printf 'npm_config_prefix=%s\n' "$TASK_NPM_PREFIX"
|
|
2502
|
+
printf 'NPM_CONFIG_CACHE=%s\n' "$TASK_NPM_CACHE"
|
|
2503
|
+
printf 'npm_config_cache=%s\n' "$TASK_NPM_CACHE"
|
|
2504
|
+
printf 'NPM_CONFIG_LOGS_DIR=%s/_logs\n' "$TASK_NPM_CACHE"
|
|
2505
|
+
printf 'npm_config_logs_dir=%s/_logs\n' "$TASK_NPM_CACHE"
|
|
2506
|
+
printf 'NPM_CONFIG_UMASK=0002\n'
|
|
2507
|
+
printf 'npm_config_umask=0002\n'
|
|
2508
|
+
# ADR-118: pnpm's store must live on the task's block volume, never on
|
|
2509
|
+
# the virtiofs workspace (pnpm's same-filesystem fallback would drop it
|
|
2510
|
+
# at the workspace root). Env beats a project .npmrc, so repos need no
|
|
2511
|
+
# config. Cross-filesystem hard links are impossible; pnpm's
|
|
2512
|
+
# package-import-method=auto falls back to copying, which is correct.
|
|
2513
|
+
printf 'NPM_CONFIG_STORE_DIR=%s\n' "$TASK_STORE_DIR"
|
|
2514
|
+
printf 'npm_config_store_dir=%s\n' "$TASK_STORE_DIR"
|
|
2515
|
+
printf 'UAI_WORKSPACE=/workspace\n'
|
|
2516
|
+
printf 'UAI_RUNTIME_PROJECTS=/run/uai/runtime-projects\n'
|
|
2517
|
+
printf 'UAI_ENV_MODE=%s\n' "$UAI_PROJECT_ENV_MODE"
|
|
2518
|
+
printf 'UAI_ENV_HOOK=0\n'
|
|
2519
|
+
printf 'SHELL=/usr/bin/zsh\n'
|
|
2520
|
+
printf 'LD_PRELOAD=\n'
|
|
2521
|
+
printf 'LD_AUDIT=\n'
|
|
2522
|
+
printf 'LD_LIBRARY_PATH=\n'
|
|
2523
|
+
printf 'NODE_OPTIONS=\n'
|
|
2524
|
+
printf 'NODE_PATH=\n'
|
|
2525
|
+
printf 'PS4=\n'
|
|
2526
|
+
printf 'BASH_ENV=\n'
|
|
2527
|
+
printf 'ENV=\n'
|
|
2528
|
+
if [ "$uai_on_cloud_credentials" != "1" ]; then
|
|
2529
|
+
printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "${CLAUDE_CODE_OAUTH_TOKEN:-}"
|
|
2530
|
+
printf 'ANTHROPIC_API_KEY=%s\n' "${ANTHROPIC_API_KEY:-}"
|
|
2531
|
+
printf 'ANTHROPIC_AUTH_TOKEN=%s\n' "${ANTHROPIC_AUTH_TOKEN:-}"
|
|
2532
|
+
printf 'XAI_API_KEY=%s\n' "${XAI_API_KEY:-}"
|
|
2533
|
+
fi
|
|
2534
|
+
printf 'PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers\n'
|
|
2535
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
2536
|
+
while IFS= read -r ekey; do
|
|
2537
|
+
[ -n "$ekey" ] || continue
|
|
2538
|
+
is_reserved_task_env_name "$ekey" && continue
|
|
2539
|
+
printf '%s=\n' "$ekey"
|
|
2540
|
+
done < <(jq -r '.[]' <<<"$union_env_keys_json")
|
|
2541
|
+
else
|
|
2542
|
+
printf '%s\n' "$filtered_project_env" \
|
|
2543
|
+
| jq -r 'to_entries[] | "\(.key)=\(.value)"'
|
|
2544
|
+
fi
|
|
2545
|
+
while IFS= read -r pe_entry; do
|
|
2546
|
+
[ -n "$pe_entry" ] || continue
|
|
2547
|
+
pe_key=$(jq -r '.key' <<<"$pe_entry")
|
|
2548
|
+
pe_val=$(jq -r '.value' <<<"$pe_entry")
|
|
2549
|
+
[ -n "$pe_key" ] || continue
|
|
2550
|
+
if [ "$pe_key" != "UAI_EDITOR_THEME" ] \
|
|
2551
|
+
&& is_reserved_task_env_name "$pe_key"; then
|
|
2552
|
+
continue
|
|
2553
|
+
fi
|
|
2554
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
2555
|
+
printf '%s=\n' "$pe_key"
|
|
2556
|
+
else
|
|
2557
|
+
printf '%s=%s\n' "$pe_key" "$pe_val"
|
|
2558
|
+
fi
|
|
2559
|
+
done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
|
|
2560
|
+
} > "$task_env_file"
|
|
2561
|
+
# The mount set matches the rendered Compose file's app service one-for-one
|
|
2562
|
+
# (minus host-gateway and group_add, which have no Apple equivalent and are
|
|
2563
|
+
# not needed — ownership maps transparently across the VM boundary).
|
|
2564
|
+
apple_run_args=(
|
|
2565
|
+
-v "$task_workspace:/workspace"
|
|
2566
|
+
-v "$task_workspace:$task_workspace"
|
|
2567
|
+
)
|
|
2568
|
+
if [ "$task_workspace_physical" != "$task_workspace" ]; then
|
|
2569
|
+
apple_run_args+=( -v "$task_workspace:$task_workspace_physical" )
|
|
2570
|
+
fi
|
|
2571
|
+
while IFS= read -r vol_obj; do
|
|
2572
|
+
[ -n "$vol_obj" ] || continue
|
|
2573
|
+
vol_pid=$(jq -r '.id' <<<"$vol_obj")
|
|
2574
|
+
apple_run_args+=(
|
|
2575
|
+
-v "$task_uai_dir/repos/$vol_pid.git:$task_uai_dir/repos/$vol_pid.git"
|
|
2576
|
+
)
|
|
2577
|
+
if [ "$task_uai_dir_physical" != "$task_uai_dir" ]; then
|
|
2578
|
+
apple_run_args+=(
|
|
2579
|
+
-v "$task_uai_dir/repos/$vol_pid.git:$task_uai_dir_physical/repos/$vol_pid.git"
|
|
2580
|
+
)
|
|
2581
|
+
fi
|
|
2582
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
2583
|
+
apple_run_args+=(
|
|
2584
|
+
-v "$UAI_INIT_SOURCE:/usr/local/bin/uai-init:ro"
|
|
2585
|
+
-v "$MOONDECK_CLI_SOURCE:/usr/local/bin/moondeck:ro"
|
|
2586
|
+
-v "$runtime_projects_file:/run/uai/runtime-projects:ro"
|
|
2587
|
+
-v "$managed_env_keys_file:/run/uai/managed-env-keys:ro"
|
|
2588
|
+
# ADR-108: the shared volume attaches READ-ONLY at a staging path and an
|
|
2589
|
+
# in-guest overlay presents it writable at /opt/asdf-data (upper = the
|
|
2590
|
+
# task's own layer). The shared volume is never written on the task path,
|
|
2591
|
+
# so any number of tasks coexist and first-time installs need no window.
|
|
2592
|
+
-v "$ASDF_VOLUME:/run/uai/asdf-lower:ro"
|
|
2593
|
+
# In-container materialization (post-overlay) uses the packaged current
|
|
2594
|
+
# materializer + Corepack pin, exactly like the retired maintenance
|
|
2595
|
+
# container did.
|
|
2596
|
+
-v "$RUNTIME_MATERIALIZER_SOURCE:/usr/local/bin/uai-materialize-runtimes:ro"
|
|
2597
|
+
-v "$COREPACK_VERSION_SOURCE:/usr/local/share/uai/corepack-version:ro"
|
|
2598
|
+
-v "$PW_BROWSERS_DIR:/opt/pw-browsers"
|
|
2599
|
+
# ADR-118: task-private store volume, writable — created and
|
|
2600
|
+
# ownership-proven above; single-attach by runtime construction.
|
|
2601
|
+
-v "$TASK_STORE_VOLUME:$TASK_STORE_MOUNT"
|
|
2602
|
+
)
|
|
2603
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
2604
|
+
apple_run_args+=(
|
|
2605
|
+
-v "$ENV_HOOK_SOURCE:/usr/local/share/uai/env-hook.sh:ro"
|
|
2606
|
+
-v "$UAI_ENV_EXEC_SOURCE:/usr/local/bin/uai-env-exec:ro"
|
|
2607
|
+
-v "$TASK_FORWARDER_SOURCE:/usr/local/share/uai/uai-task-forwarder.mjs:ro"
|
|
2608
|
+
)
|
|
2609
|
+
fi
|
|
2610
|
+
if [ "$shared_files_mode" != "off" ]; then
|
|
2611
|
+
shared_suffix=""
|
|
2612
|
+
[ "$shared_files_mode" = "ro" ] && shared_suffix=":ro"
|
|
2613
|
+
if [ -n "$shared_owner_org" ]; then
|
|
2614
|
+
apple_run_args+=(
|
|
2615
|
+
-v "$shared_root/orgs/$shared_owner_org:/workspace/files/org$shared_suffix"
|
|
2616
|
+
)
|
|
2617
|
+
fi
|
|
2618
|
+
if [ -n "$shared_owner_user" ]; then
|
|
2619
|
+
apple_run_args+=(
|
|
2620
|
+
-v "$shared_root/users/$shared_owner_user:/workspace/files/me$shared_suffix"
|
|
2621
|
+
)
|
|
2622
|
+
fi
|
|
2623
|
+
fi
|
|
2624
|
+
apple_up_rc=0
|
|
2625
|
+
# CAP_SYS_ADMIN: required for the ADR-108 in-guest overlay mount.
|
|
2626
|
+
# CAP_NET_ADMIN: the ADR-153 secrets proxy's NAT chain, set by the runner's
|
|
2627
|
+
# root execs (node has no sudo). Both are confined by the VM boundary: the
|
|
2628
|
+
# guest kernel is the task's own, so they grant nothing on the host.
|
|
2629
|
+
"$CONTAINER_CLI" run -d --init \
|
|
2630
|
+
--name "$app_container" \
|
|
2631
|
+
-l "io.moondeck.task=$task_id" \
|
|
2632
|
+
--cap-add CAP_SYS_ADMIN \
|
|
2633
|
+
--cap-add CAP_NET_ADMIN \
|
|
2634
|
+
--workdir /workspace \
|
|
2635
|
+
--memory "$APPLE_TASK_MEMORY" \
|
|
2636
|
+
--cpus "$APPLE_TASK_CPUS" \
|
|
2637
|
+
--env-file "$task_env_file" \
|
|
2638
|
+
"${apple_run_args[@]}" \
|
|
2639
|
+
"$runtime_image" >/dev/null || apple_up_rc=$?
|
|
2640
|
+
rm -f "$task_env_file"
|
|
2641
|
+
if [ "$apple_up_rc" -ne 0 ]; then
|
|
2642
|
+
require_docker_after_failed_step "start task container"
|
|
2643
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2644
|
+
"Moondeck could not start this task's container on the Apple container runtime. Retry the task." \
|
|
2645
|
+
"start task container"
|
|
2646
|
+
fi
|
|
2647
|
+
# The runtime cannot start a container by immutable image id, so prove the
|
|
2648
|
+
# tag still resolves to the pinned bytes now that the container exists. A
|
|
2649
|
+
# concurrent retag loses either way: before create, the recheck above; here,
|
|
2650
|
+
# the container is quarantined before credentials are installed.
|
|
2651
|
+
apple_post_up_image_id=$("$CONTAINER_CLI" image inspect "$runtime_image" \
|
|
2652
|
+
2>/dev/null | jq -r '.[0].id // empty' 2>/dev/null) \
|
|
2653
|
+
|| apple_post_up_image_id=""
|
|
2654
|
+
if [ "$apple_post_up_image_id" != "$runtime_image_id" ]; then
|
|
2655
|
+
if ! quarantine_untrusted_app_container; then
|
|
2656
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2657
|
+
"This task's image changed while its container was being created, and Moondeck could not prove the container stopped. Stop it manually before retrying." \
|
|
2658
|
+
"verify task image identity"
|
|
2659
|
+
fi
|
|
2660
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2661
|
+
"This task's image changed while its container was being created. Retry so the new image receives a complete runtime proof first." \
|
|
2662
|
+
"verify task image identity"
|
|
2663
|
+
fi
|
|
2664
|
+
else
|
|
2665
|
+
step "COMPOSE_UP_FAILED" "docker compose up -d"
|
|
2666
|
+
compose_up_error=""
|
|
2667
|
+
# Keep the diagnostic in memory only, but bound it: a broken plugin or
|
|
2668
|
+
# daemon must not make task-up retain an arbitrarily large stderr stream.
|
|
2669
|
+
# Docker's allocation error is emitted at the end of Compose's failure.
|
|
2670
|
+
if ! compose_up_error=$(printf '%s\n' "$compose_env_override" \
|
|
2671
|
+
| docker compose -p "$compose_project" \
|
|
2672
|
+
-f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build \
|
|
2673
|
+
2>&1 >/dev/null | tail -c 65536); then
|
|
2674
|
+
[ -z "$compose_up_error" ] || printf '%s\n' "$compose_up_error" >&2
|
|
2675
|
+
require_docker_after_failed_step "docker compose up -d"
|
|
2676
|
+
if printf '%s\n' "$compose_up_error" \
|
|
2677
|
+
| grep -Fq 'all predefined address pools have been fully subnetted'; then
|
|
2678
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2679
|
+
"Docker has no subnet left for this task. Moondeck already reclaimed every empty task network it could prove safe. Close an unused stopped/error task, or enlarge Docker's non-overlapping default address pools and restart Docker, then press Resume." \
|
|
2680
|
+
"docker compose up -d"
|
|
2681
|
+
fi
|
|
2682
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2683
|
+
"Moondeck could not start this task's Docker Compose stack. Fix the Docker error above, then press Resume." \
|
|
2684
|
+
"docker compose up -d"
|
|
2685
|
+
fi
|
|
2686
|
+
unset compose_up_error
|
|
2687
|
+
fi
|
|
2688
|
+
|
|
2689
|
+
# Rendered YAML is not runtime evidence: Compose could reuse unexpected state,
|
|
2690
|
+
# or a compatibility bug could silently drop `:ro`. Inspect the exact created
|
|
2691
|
+
# app before it receives credentials. There must be one named volume at this
|
|
2692
|
+
# destination, with Docker itself reporting the mount read-only, and the same
|
|
2693
|
+
# shared volume must not be exposed again at any other destination.
|
|
2694
|
+
step "CONTAINER_INIT_FAILED" "verify task runtime isolation"
|
|
2695
|
+
app_mounts_json=""
|
|
2696
|
+
apple_app_snapshot=""
|
|
2697
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2698
|
+
# One container snapshot backs both the mount and environment proofs, so
|
|
2699
|
+
# they describe the same observed state.
|
|
2700
|
+
apple_app_snapshot=$(crt inspect "$app_container" 2>/dev/null) \
|
|
2701
|
+
|| apple_app_snapshot=""
|
|
2702
|
+
app_mounts_json=$(printf '%s\n' "$apple_app_snapshot" \
|
|
2703
|
+
| jq -c '.[0].configuration.mounts // empty' 2>/dev/null) \
|
|
2704
|
+
|| app_mounts_json=""
|
|
2705
|
+
fi
|
|
2706
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ] \
|
|
2707
|
+
&& [ -z "$app_mounts_json" ]; then
|
|
2708
|
+
require_docker_after_failed_step "inspect task runtime isolation"
|
|
2709
|
+
if ! quarantine_untrusted_app_container; then
|
|
2710
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2711
|
+
"Moondeck could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
|
|
2712
|
+
"verify task runtime isolation"
|
|
2713
|
+
fi
|
|
2714
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2715
|
+
"Moondeck could not inspect the task container's shared runtime mount. Retry to recreate it safely." \
|
|
2716
|
+
"verify task runtime isolation"
|
|
2717
|
+
elif [ "$TASK_RUNTIME_MODE" = "docker" ] \
|
|
2718
|
+
&& ! app_mounts_json=$(docker inspect --format '{{json .Mounts}}' \
|
|
2719
|
+
"$app_container" 2>/dev/null); then
|
|
2720
|
+
require_docker_after_failed_step "inspect task runtime isolation"
|
|
2721
|
+
if ! quarantine_untrusted_app_container; then
|
|
2722
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2723
|
+
"Moondeck could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
|
|
2724
|
+
"verify task runtime isolation"
|
|
2725
|
+
fi
|
|
2726
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2727
|
+
"Moondeck could not inspect the task container's shared runtime mount. Retry to recreate it safely." \
|
|
2728
|
+
"verify task runtime isolation"
|
|
2729
|
+
fi
|
|
2730
|
+
app_config_env_json=""
|
|
2731
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2732
|
+
app_config_env_json=$(printf '%s\n' "$apple_app_snapshot" \
|
|
2733
|
+
| jq -c '.[0].configuration.initProcess.environment // empty' \
|
|
2734
|
+
2>/dev/null) || app_config_env_json=""
|
|
2735
|
+
fi
|
|
2736
|
+
if { [ "$TASK_RUNTIME_MODE" = "apple-container" ] \
|
|
2737
|
+
&& [ -z "$app_config_env_json" ]; } \
|
|
2738
|
+
|| { [ "$TASK_RUNTIME_MODE" = "docker" ] \
|
|
2739
|
+
&& ! app_config_env_json=$(docker inspect \
|
|
2740
|
+
--format '{{json .Config.Env}}' "$app_container" 2>/dev/null); }; then
|
|
2741
|
+
require_docker_after_failed_step "inspect task runtime environment"
|
|
2742
|
+
if ! quarantine_untrusted_app_container; then
|
|
2743
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2744
|
+
"Moondeck could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
|
|
2745
|
+
"verify task runtime isolation"
|
|
2746
|
+
fi
|
|
2747
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2748
|
+
"Moondeck could not inspect the task container's runtime environment. Retry to recreate it safely." \
|
|
2749
|
+
"verify task runtime isolation"
|
|
2750
|
+
fi
|
|
2751
|
+
# The two runtimes report mounts in different shapes; each filter asserts the
|
|
2752
|
+
# same contract — exactly one /opt/asdf-data mount, it is the shared
|
|
2753
|
+
# moondeck-asdf-data volume, the runtime itself reports it read-only, and that
|
|
2754
|
+
# volume appears at no other destination.
|
|
2755
|
+
mounts_proof_filter='
|
|
2756
|
+
[ .[] | select(.Destination == "/opt/asdf-data") ] as $runtime
|
|
2757
|
+
| [ .[] | select(.Name? == "moondeck-asdf-data") ] as $shared
|
|
2758
|
+
| ($runtime | length) == 1
|
|
2759
|
+
and ($shared | length) == 1
|
|
2760
|
+
and $runtime[0].Type == "volume"
|
|
2761
|
+
and $runtime[0].Name == "moondeck-asdf-data"
|
|
2762
|
+
and $runtime[0].RW == false
|
|
2763
|
+
'
|
|
2764
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2765
|
+
# ADR-108: the shared volume attaches READ-ONLY at the overlay staging
|
|
2766
|
+
# path, appears nowhere else, and /opt/asdf-data is NOT a host mount at
|
|
2767
|
+
# all — the writable view there is the in-guest overlay, proven from
|
|
2768
|
+
# inside the guest right after this (host inspect cannot see guest
|
|
2769
|
+
# mounts).
|
|
2770
|
+
mounts_proof_filter='
|
|
2771
|
+
[ .[] | select(.destination == "/run/uai/asdf-lower") ] as $runtime
|
|
2772
|
+
| [ .[] | select((.type.volume.name? // "") == "moondeck-asdf-data") ] as $shared
|
|
2773
|
+
| [ .[] | select(.destination == $store_mount) ] as $store
|
|
2774
|
+
| [ .[] | select((.type.volume.name? // "") == $store_volume) ] as $store_vol
|
|
2775
|
+
| ($runtime | length) == 1
|
|
2776
|
+
and ($shared | length) == 1
|
|
2777
|
+
and (($runtime[0].type.volume.name? // "") == "moondeck-asdf-data")
|
|
2778
|
+
and ((($runtime[0].options // []) | index("ro")) != null)
|
|
2779
|
+
and ((($runtime[0].options // []) | index("rw")) == null)
|
|
2780
|
+
and (([ .[] | select(.destination == "/opt/asdf-data") ] | length) == 0)
|
|
2781
|
+
# ADR-118: the task-private store volume attaches WRITABLE at exactly
|
|
2782
|
+
# its one mount point, and no other volume occupies that path.
|
|
2783
|
+
and ($store | length) == 1
|
|
2784
|
+
and ($store_vol | length) == 1
|
|
2785
|
+
and (($store[0].type.volume.name? // "") == $store_volume)
|
|
2786
|
+
and ((($store[0].options // []) | index("ro")) == null)
|
|
2787
|
+
'
|
|
2788
|
+
fi
|
|
2789
|
+
if ! printf '%s\n' "$app_mounts_json" \
|
|
2790
|
+
| jq -e \
|
|
2791
|
+
--arg store_mount "$TASK_STORE_MOUNT" \
|
|
2792
|
+
--arg store_volume "$TASK_STORE_VOLUME" \
|
|
2793
|
+
"$mounts_proof_filter" >/dev/null 2>&1; then
|
|
2794
|
+
if ! quarantine_untrusted_app_container; then
|
|
2795
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2796
|
+
"Moondeck found an unsafe shared runtime mount and could not prove the task container stopped. Stop it manually before retrying." \
|
|
2797
|
+
"verify task runtime isolation"
|
|
2798
|
+
fi
|
|
2799
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2800
|
+
"Moondeck stopped this task because its shared runtime cache was not the exact read-only moondeck-asdf-data volume. Retry to recreate the container safely." \
|
|
2801
|
+
"verify task runtime isolation"
|
|
2802
|
+
fi
|
|
2803
|
+
# ADR-118: the store authority exists only on the apple path (Docker tasks
|
|
2804
|
+
# keep pnpm's default resolution); an empty $store keeps the historical
|
|
2805
|
+
# docker proof byte-for-byte.
|
|
2806
|
+
env_proof_store_dir=""
|
|
2807
|
+
[ "$TASK_RUNTIME_MODE" != "apple-container" ] \
|
|
2808
|
+
|| env_proof_store_dir="$TASK_STORE_DIR"
|
|
2809
|
+
if ! printf '%s\n' "$app_config_env_json" | jq -e \
|
|
2810
|
+
--arg path "$TASK_RUNTIME_PATH" \
|
|
2811
|
+
--arg prefix "$TASK_NPM_PREFIX" \
|
|
2812
|
+
--arg cache "$TASK_NPM_CACHE" \
|
|
2813
|
+
--arg store "$env_proof_store_dir" '
|
|
2814
|
+
def is_owned_npm_config($owned):
|
|
2815
|
+
ascii_downcase as $folded
|
|
2816
|
+
| ($folded | startswith("npm_config_"))
|
|
2817
|
+
and (($owned | index($folded[11:] | gsub("_"; "-"))) != null);
|
|
2818
|
+
(. // []) as $env
|
|
2819
|
+
| ([
|
|
2820
|
+
"NPM_CONFIG_PREFIX=\($prefix)",
|
|
2821
|
+
"npm_config_prefix=\($prefix)",
|
|
2822
|
+
"NPM_CONFIG_CACHE=\($cache)",
|
|
2823
|
+
"npm_config_cache=\($cache)",
|
|
2824
|
+
"NPM_CONFIG_LOGS_DIR=\($cache)/_logs",
|
|
2825
|
+
"npm_config_logs_dir=\($cache)/_logs",
|
|
2826
|
+
"NPM_CONFIG_UMASK=0002",
|
|
2827
|
+
"npm_config_umask=0002"
|
|
2828
|
+
]
|
|
2829
|
+
+ (if $store != "" then [
|
|
2830
|
+
"NPM_CONFIG_STORE_DIR=\($store)",
|
|
2831
|
+
"npm_config_store_dir=\($store)"
|
|
2832
|
+
] else [] end)) as $required
|
|
2833
|
+
| ([
|
|
2834
|
+
"prefix", "cache", "logs-dir", "umask"
|
|
2835
|
+
]
|
|
2836
|
+
+ (if $store != "" then ["store-dir"] else [] end)) as $owned
|
|
2837
|
+
| ($env | type == "array")
|
|
2838
|
+
and all($env[]; type == "string")
|
|
2839
|
+
and all($required[];
|
|
2840
|
+
. as $required_value
|
|
2841
|
+
| ([ $env[] | select(. == $required_value) ] | length) == 1
|
|
2842
|
+
)
|
|
2843
|
+
and ([
|
|
2844
|
+
$env[]
|
|
2845
|
+
| split("=")[0]
|
|
2846
|
+
| select(is_owned_npm_config($owned))
|
|
2847
|
+
] | length) == ($required | length)
|
|
2848
|
+
and ([ $env[] | select(. == "PATH=\($path)") ] | length) == 1
|
|
2849
|
+
and ([ $env[] | select(. == "ASDF_DATA_DIR=/opt/asdf-data") ] | length) == 1
|
|
2850
|
+
and ([ $env[] | select(. == "ASDF_SKIP_RESHIM=1") ] | length) == 1
|
|
2851
|
+
and ([ $env[] | select(. == "COREPACK_HOME=/opt/asdf-data/corepack") ] | length) == 1
|
|
2852
|
+
and ([ $env[] | select(. == "COREPACK_ENABLE_NETWORK=0") ] | length) == 1
|
|
2853
|
+
' >/dev/null 2>&1; then
|
|
2854
|
+
if ! quarantine_untrusted_app_container; then
|
|
2855
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2856
|
+
"Moondeck found an unsafe task runtime environment and could not prove the container stopped. Stop it manually before retrying." \
|
|
2857
|
+
"verify task runtime isolation"
|
|
2858
|
+
fi
|
|
2859
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2860
|
+
"Moondeck stopped this task because its runtime/npm environment did not match the fixed private-prefix and read-only-cache authority. Retry to recreate the container safely." \
|
|
2861
|
+
"verify task runtime isolation"
|
|
2862
|
+
fi
|
|
2863
|
+
|
|
2864
|
+
# -----------------------------------------------------------------------------
|
|
2865
|
+
# 6a. ADR-108 (apple only): establish the in-guest runtime overlay, then
|
|
2866
|
+
# materialize runtimes INSIDE the container. The shared volume is never
|
|
2867
|
+
# written on the task path: the read-only lower proved above merges under
|
|
2868
|
+
# a task-private upper layer, so a missing toolchain installs into this
|
|
2869
|
+
# task's own layer with zero shared-volume coordination. Both steps run
|
|
2870
|
+
# before any credential, editor, or agent work; a failure quarantines the
|
|
2871
|
+
# container exactly like the isolation proofs above.
|
|
2872
|
+
# -----------------------------------------------------------------------------
|
|
2873
|
+
|
|
2874
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
2875
|
+
step "CONTAINER_INIT_FAILED" "establish task runtime overlay"
|
|
2876
|
+
overlay_rc=0
|
|
2877
|
+
"$CONTAINER_CLI" exec -u root "$app_container" /bin/sh -c '
|
|
2878
|
+
set -e
|
|
2879
|
+
/bin/mkdir -p /var/lib/uai/asdf-upper /var/lib/uai/asdf-work /opt/asdf-data
|
|
2880
|
+
# The merged root inherits the UPPER directory'"'"'s metadata; without this
|
|
2881
|
+
# the node user cannot write the merged view at all (live 2026-08-18).
|
|
2882
|
+
/bin/chown node:node /var/lib/uai/asdf-upper
|
|
2883
|
+
if ! /bin/grep -q "^overlay /opt/asdf-data overlay " /proc/mounts; then
|
|
2884
|
+
/bin/mount -t overlay overlay \
|
|
2885
|
+
-o lowerdir=/run/uai/asdf-lower,upperdir=/var/lib/uai/asdf-upper,workdir=/var/lib/uai/asdf-work \
|
|
2886
|
+
/opt/asdf-data
|
|
2887
|
+
fi
|
|
2888
|
+
# Prove the exact shape from the guest mount table, not from trust —
|
|
2889
|
+
# token-exact (comma-delimited; substring matching accepted
|
|
2890
|
+
# lowerdir=/run/uai/asdf-lower-evil), exactly ONE effective mount at the
|
|
2891
|
+
# merged path and at the lower, a read-only lower, and a real node-owned
|
|
2892
|
+
# upper directory.
|
|
2893
|
+
overlay_lines=$(/bin/grep -c "^overlay /opt/asdf-data overlay " /proc/mounts || true)
|
|
2894
|
+
[ "$overlay_lines" = "1" ] || { echo "expected exactly one /opt/asdf-data overlay, found $overlay_lines" >&2; exit 1; }
|
|
2895
|
+
any_lines=$(/bin/grep -c " /opt/asdf-data " /proc/mounts || true)
|
|
2896
|
+
[ "$any_lines" = "1" ] || { echo "unexpected extra mounts at /opt/asdf-data" >&2; exit 1; }
|
|
2897
|
+
opts=$(/bin/grep "^overlay /opt/asdf-data overlay " /proc/mounts | /usr/bin/cut -d" " -f4)
|
|
2898
|
+
case ",$opts," in *",lowerdir=/run/uai/asdf-lower,"*) ;; *) echo "overlay lower is foreign" >&2; exit 1 ;; esac
|
|
2899
|
+
case ",$opts," in *",upperdir=/var/lib/uai/asdf-upper,"*) ;; *) echo "overlay upper is foreign" >&2; exit 1 ;; esac
|
|
2900
|
+
case ",$opts," in *",workdir=/var/lib/uai/asdf-work,"*) ;; *) echo "overlay workdir is foreign" >&2; exit 1 ;; esac
|
|
2901
|
+
case ",$opts," in *",ro,"*) echo "overlay is unexpectedly read-only" >&2; exit 1 ;; esac
|
|
2902
|
+
lower_lines=$(/bin/grep -c " /run/uai/asdf-lower " /proc/mounts || true)
|
|
2903
|
+
[ "$lower_lines" = "1" ] || { echo "expected exactly one lower attachment, found $lower_lines" >&2; exit 1; }
|
|
2904
|
+
lopts=$(/bin/grep " /run/uai/asdf-lower " /proc/mounts | /usr/bin/cut -d" " -f4)
|
|
2905
|
+
case ",$lopts," in *",ro,"*) ;; *) echo "lower attachment is not read-only" >&2; exit 1 ;; esac
|
|
2906
|
+
[ ! -L /var/lib/uai/asdf-upper ] || { echo "upper is a symlink" >&2; exit 1; }
|
|
2907
|
+
[ -d /var/lib/uai/asdf-upper ] || { echo "upper is not a directory" >&2; exit 1; }
|
|
2908
|
+
[ "$(/usr/bin/stat -c %U /var/lib/uai/asdf-upper)" = "node" ] || { echo "upper is not node-owned" >&2; exit 1; }
|
|
2909
|
+
/usr/bin/test -d /opt/asdf-data/plugins
|
|
2910
|
+
# ADR-118: the store volume is a fresh ext4 with a root-owned root; hand
|
|
2911
|
+
# the pnpm store dir to node and prove the attachment from the guest
|
|
2912
|
+
# mount table — exactly one writable mount at the store path.
|
|
2913
|
+
store_lines=$(/bin/grep -c " /opt/uai/store " /proc/mounts || true)
|
|
2914
|
+
[ "$store_lines" = "1" ] || { echo "expected exactly one /opt/uai/store mount, found $store_lines" >&2; exit 1; }
|
|
2915
|
+
sopts=$(/bin/grep " /opt/uai/store " /proc/mounts | /usr/bin/cut -d" " -f4)
|
|
2916
|
+
case ",$sopts," in *",ro,"*) echo "store volume is unexpectedly read-only" >&2; exit 1 ;; esac
|
|
2917
|
+
/bin/mkdir -p /opt/uai/store/pnpm
|
|
2918
|
+
/bin/chown node:node /opt/uai/store /opt/uai/store/pnpm
|
|
2919
|
+
[ "$(/usr/bin/stat -c %U /opt/uai/store/pnpm)" = "node" ] || { echo "store dir is not node-owned" >&2; exit 1; }
|
|
2920
|
+
' >/dev/null || overlay_rc=$?
|
|
2921
|
+
if [ "$overlay_rc" -ne 0 ]; then
|
|
2922
|
+
require_docker_after_failed_step "establish task runtime overlay"
|
|
2923
|
+
if ! quarantine_untrusted_app_container; then
|
|
2924
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2925
|
+
"Moondeck could not establish this task's runtime overlay, and could not prove the container stopped. Stop it manually before retrying." \
|
|
2926
|
+
"establish task runtime overlay"
|
|
2927
|
+
fi
|
|
2928
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2929
|
+
"Moondeck could not establish this task's runtime overlay on the Apple container runtime. Retry the task." \
|
|
2930
|
+
"establish task runtime overlay"
|
|
2931
|
+
fi
|
|
2932
|
+
|
|
2933
|
+
if [ "$project_count" -gt 0 ]; then
|
|
2934
|
+
step "CONTAINER_INIT_FAILED" "materialize task runtimes in container"
|
|
2935
|
+
materialization_rc=0
|
|
2936
|
+
"$CONTAINER_CLI" exec -u node -w /workspace "$app_container" \
|
|
2937
|
+
/usr/bin/env -i \
|
|
2938
|
+
HOME=/home/node \
|
|
2939
|
+
ASDF_DIR=/opt/asdf \
|
|
2940
|
+
ASDF_DATA_DIR=/opt/asdf-data \
|
|
2941
|
+
PATH=/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
|
2942
|
+
/usr/bin/timeout --signal=TERM --kill-after=10s 600s \
|
|
2943
|
+
/usr/local/bin/uai-materialize-runtimes >&2 || materialization_rc=$?
|
|
2944
|
+
if [ "$materialization_rc" -ne 0 ]; then
|
|
2945
|
+
require_docker_after_failed_step "materialize task runtimes in container"
|
|
2946
|
+
if ! quarantine_untrusted_app_container; then
|
|
2947
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2948
|
+
"Runtime materialization failed and Moondeck could not prove the task container stopped. Stop it manually before retrying." \
|
|
2949
|
+
"materialize task runtimes in container"
|
|
2950
|
+
fi
|
|
2951
|
+
case "$materialization_rc" in
|
|
2952
|
+
124|137)
|
|
2953
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2954
|
+
"Runtime materialization did not finish inside the station's 10-minute startup budget. Retry the task: Moondeck re-checks every required runtime and package manager before agents start." \
|
|
2955
|
+
"materialize task runtimes in container"
|
|
2956
|
+
;;
|
|
2957
|
+
*)
|
|
2958
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
2959
|
+
"Moondeck could not install and verify every runtime and package manager needed by this task. Check the project's .tool-versions and packageManager entries and the host's network availability, then retry." \
|
|
2960
|
+
"materialize task runtimes in container"
|
|
2961
|
+
;;
|
|
2962
|
+
esac
|
|
2963
|
+
fi
|
|
2964
|
+
fi
|
|
2965
|
+
fi
|
|
2966
|
+
|
|
2967
|
+
# -----------------------------------------------------------------------------
|
|
2968
|
+
# 7. Container init — copy AI creds in, install deps, launch code-server.
|
|
2969
|
+
# -----------------------------------------------------------------------------
|
|
2970
|
+
|
|
2971
|
+
step "CONTAINER_INIT_FAILED" "uai-init (deps + code-server)"
|
|
2972
|
+
|
|
2973
|
+
# `docker exec` inherits the project environment from Compose. Override loader
|
|
2974
|
+
# and config variables before the first executable starts, then use `env -i`
|
|
2975
|
+
# plus absolute command paths so a project value cannot redirect this managed
|
|
2976
|
+
# credential operation to workspace code or another config directory.
|
|
2977
|
+
managed_gh_config_dir="/home/node/.config/gh"
|
|
2978
|
+
managed_exec_env=(
|
|
2979
|
+
-e "HOME=/home/node"
|
|
2980
|
+
-e "GH_CONFIG_DIR=$managed_gh_config_dir"
|
|
2981
|
+
-e "PATH=/usr/bin:/bin"
|
|
2982
|
+
-e "XDG_CONFIG_HOME="
|
|
2983
|
+
-e "XDG_DATA_HOME="
|
|
2984
|
+
-e "XDG_CACHE_HOME="
|
|
2985
|
+
-e "ASDF_DIR=/opt/asdf"
|
|
2986
|
+
-e "ASDF_DATA_DIR=/opt/asdf-data"
|
|
2987
|
+
-e "ASDF_CONFIG_FILE=/dev/null"
|
|
2988
|
+
-e "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions"
|
|
2989
|
+
-e "ASDF_NODEJS_VERSION="
|
|
2990
|
+
-e "ASDF_PYTHON_VERSION="
|
|
2991
|
+
-e "ASDF_GOLANG_VERSION="
|
|
2992
|
+
-e "ASDF_RUBY_VERSION="
|
|
2993
|
+
-e "ASDF_RUST_VERSION="
|
|
2994
|
+
-e "ASDF_SKIP_RESHIM=1"
|
|
2995
|
+
-e "COREPACK_HOME=/opt/asdf-data/corepack"
|
|
2996
|
+
-e "COREPACK_DEFAULT_TO_LATEST=0"
|
|
2997
|
+
-e "COREPACK_ENV_FILE=0"
|
|
2998
|
+
-e "COREPACK_NPM_REGISTRY=https://registry.npmjs.org"
|
|
2999
|
+
-e "COREPACK_ENABLE_NETWORK=0"
|
|
3000
|
+
-e "COREPACK_ENABLE_AUTO_PIN=0"
|
|
3001
|
+
-e "COREPACK_ENABLE_DOWNLOAD_PROMPT=0"
|
|
3002
|
+
-e "COREPACK_ENABLE_PROJECT_SPEC=1"
|
|
3003
|
+
-e "COREPACK_ENABLE_STRICT=1"
|
|
3004
|
+
-e "COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0"
|
|
3005
|
+
-e "COREPACK_INTEGRITY_CHECK=1"
|
|
3006
|
+
-e "NPM_CONFIG_PREFIX=$TASK_NPM_PREFIX"
|
|
3007
|
+
-e "npm_config_prefix=$TASK_NPM_PREFIX"
|
|
3008
|
+
-e "NPM_CONFIG_CACHE=$TASK_NPM_CACHE"
|
|
3009
|
+
-e "npm_config_cache=$TASK_NPM_CACHE"
|
|
3010
|
+
-e "NPM_CONFIG_LOGS_DIR=$TASK_NPM_CACHE/_logs"
|
|
3011
|
+
-e "npm_config_logs_dir=$TASK_NPM_CACHE/_logs"
|
|
3012
|
+
-e "NPM_CONFIG_UMASK=0002"
|
|
3013
|
+
-e "npm_config_umask=0002"
|
|
3014
|
+
-e "LD_PRELOAD="
|
|
3015
|
+
-e "LD_AUDIT="
|
|
3016
|
+
-e "LD_LIBRARY_PATH="
|
|
3017
|
+
-e "DYLD_INSERT_LIBRARIES="
|
|
3018
|
+
-e "DYLD_LIBRARY_PATH="
|
|
3019
|
+
-e "UAI_ENV_HOOK=0"
|
|
3020
|
+
-e "BASH_ENV="
|
|
3021
|
+
-e "ENV="
|
|
3022
|
+
-e "GIT_CONFIG="
|
|
3023
|
+
-e "GIT_CONFIG_GLOBAL="
|
|
3024
|
+
-e "GIT_CONFIG_SYSTEM="
|
|
3025
|
+
-e "GIT_CONFIG_NOSYSTEM="
|
|
3026
|
+
-e "GIT_CONFIG_COUNT=0"
|
|
3027
|
+
-e "GIT_EXEC_PATH="
|
|
3028
|
+
-e "GIT_SSH="
|
|
3029
|
+
-e "GIT_SSH_COMMAND="
|
|
3030
|
+
-e "GIT_ASKPASS="
|
|
3031
|
+
-e "SSH_ASKPASS="
|
|
3032
|
+
-e "GH_TOKEN="
|
|
3033
|
+
-e "GITHUB_TOKEN="
|
|
3034
|
+
)
|
|
3035
|
+
managed_clean_env=(
|
|
3036
|
+
/usr/bin/env -i
|
|
3037
|
+
"HOME=/home/node"
|
|
3038
|
+
"GH_CONFIG_DIR=$managed_gh_config_dir"
|
|
3039
|
+
"PATH=/usr/bin:/bin"
|
|
3040
|
+
)
|
|
3041
|
+
|
|
3042
|
+
# Runtime-bearing task initialization gets the same fixed environment as
|
|
3043
|
+
# Compose and agent launches. Unlike the root/control array above, this path
|
|
3044
|
+
# includes task-private global packages last so unique tools are usable while
|
|
3045
|
+
# they remain unable to shadow shared shims or system controls.
|
|
3046
|
+
task_runtime_exec_env=(
|
|
3047
|
+
-e "HOME=/home/node"
|
|
3048
|
+
-e "PATH=$TASK_RUNTIME_PATH"
|
|
3049
|
+
-e "XDG_CONFIG_HOME=/home/node/.config"
|
|
3050
|
+
-e "XDG_DATA_HOME=/home/node/.local/share"
|
|
3051
|
+
-e "XDG_CACHE_HOME=/home/node/.cache"
|
|
3052
|
+
-e "ASDF_DIR=/opt/asdf"
|
|
3053
|
+
-e "ASDF_DATA_DIR=/opt/asdf-data"
|
|
3054
|
+
-e "ASDF_CONFIG_FILE=/dev/null"
|
|
3055
|
+
-e "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions"
|
|
3056
|
+
-e "ASDF_NODEJS_VERSION="
|
|
3057
|
+
-e "ASDF_PYTHON_VERSION="
|
|
3058
|
+
-e "ASDF_GOLANG_VERSION="
|
|
3059
|
+
-e "ASDF_RUBY_VERSION="
|
|
3060
|
+
-e "ASDF_RUST_VERSION="
|
|
3061
|
+
-e "ASDF_SKIP_RESHIM=1"
|
|
3062
|
+
-e "COREPACK_HOME=/opt/asdf-data/corepack"
|
|
3063
|
+
-e "COREPACK_DEFAULT_TO_LATEST=0"
|
|
3064
|
+
-e "COREPACK_ENV_FILE=0"
|
|
3065
|
+
-e "COREPACK_NPM_REGISTRY=https://registry.npmjs.org"
|
|
3066
|
+
-e "COREPACK_ENABLE_NETWORK=0"
|
|
3067
|
+
-e "COREPACK_ENABLE_AUTO_PIN=0"
|
|
3068
|
+
-e "COREPACK_ENABLE_DOWNLOAD_PROMPT=0"
|
|
3069
|
+
-e "COREPACK_ENABLE_PROJECT_SPEC=1"
|
|
3070
|
+
-e "COREPACK_ENABLE_STRICT=1"
|
|
3071
|
+
-e "COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0"
|
|
3072
|
+
-e "COREPACK_INTEGRITY_CHECK=1"
|
|
3073
|
+
-e "NPM_CONFIG_PREFIX=$TASK_NPM_PREFIX"
|
|
3074
|
+
-e "npm_config_prefix=$TASK_NPM_PREFIX"
|
|
3075
|
+
-e "NPM_CONFIG_CACHE=$TASK_NPM_CACHE"
|
|
3076
|
+
-e "npm_config_cache=$TASK_NPM_CACHE"
|
|
3077
|
+
-e "NPM_CONFIG_LOGS_DIR=$TASK_NPM_CACHE/_logs"
|
|
3078
|
+
-e "npm_config_logs_dir=$TASK_NPM_CACHE/_logs"
|
|
3079
|
+
-e "NPM_CONFIG_UMASK=0002"
|
|
3080
|
+
-e "npm_config_umask=0002"
|
|
3081
|
+
-e "LD_PRELOAD="
|
|
3082
|
+
-e "LD_AUDIT="
|
|
3083
|
+
-e "LD_LIBRARY_PATH="
|
|
3084
|
+
-e "NODE_OPTIONS="
|
|
3085
|
+
-e "NODE_PATH="
|
|
3086
|
+
-e "PS4="
|
|
3087
|
+
-e "UAI_ENV_MODE=$UAI_PROJECT_ENV_MODE"
|
|
3088
|
+
-e "UAI_ENV_HOOK=0"
|
|
3089
|
+
-e "SHELL=/usr/bin/zsh"
|
|
3090
|
+
-e "BASH_ENV="
|
|
3091
|
+
-e "ENV="
|
|
3092
|
+
)
|
|
3093
|
+
editor_theme=$(jq -r '.UAI_EDITOR_THEME // empty' <<<"$preview_env_raw")
|
|
3094
|
+
if [ -n "$editor_theme" ]; then
|
|
3095
|
+
task_runtime_exec_env+=( -e "UAI_EDITOR_THEME=$editor_theme" )
|
|
3096
|
+
fi
|
|
3097
|
+
# ADR-118 (apple only): every runtime-bearing exec — uai-init's installs
|
|
3098
|
+
# above all — must aim pnpm at the task's block volume, exactly like the
|
|
3099
|
+
# container env-file. Docker execs keep the historical environment.
|
|
3100
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
3101
|
+
task_runtime_exec_env+=(
|
|
3102
|
+
-e "NPM_CONFIG_STORE_DIR=$TASK_STORE_DIR"
|
|
3103
|
+
-e "npm_config_store_dir=$TASK_STORE_DIR"
|
|
3104
|
+
)
|
|
3105
|
+
fi
|
|
3106
|
+
|
|
3107
|
+
# ADR-153 secrets proxy, first step: the CA bundle goes into the task's trust
|
|
3108
|
+
# store before anything else runs in it (the forwarder, uai-init, agents). The
|
|
3109
|
+
# runner wrote the bundle and its fixed install script to
|
|
3110
|
+
# UAI_SECRETS_PROXY_DIR (public material only); unset, the proxy is off and
|
|
3111
|
+
# nothing is installed. A failure leaves the task up without the proxy's
|
|
3112
|
+
# trust, and the runner withholds whatever would have needed it.
|
|
3113
|
+
secrets_proxy_trust=skipped
|
|
3114
|
+
secrets_proxy_pins=skipped
|
|
3115
|
+
secrets_proxy_dir=${UAI_SECRETS_PROXY_DIR:-}
|
|
3116
|
+
# Each outcome is also written back to the runner's directory the moment it
|
|
3117
|
+
# is known: uai-init's shells read the env before task-up returns, and the
|
|
3118
|
+
# runner gives the trust env only once the CA is really in place.
|
|
3119
|
+
secrets_proxy_report() {
|
|
3120
|
+
[ -z "$secrets_proxy_dir" ] || printf '%s\n' "$2" > "$secrets_proxy_dir/$1" || true
|
|
3121
|
+
}
|
|
3122
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ] && [ -n "$secrets_proxy_dir" ]; then
|
|
3123
|
+
if [ -f "$secrets_proxy_dir/ca-install.sh" ] && [ -f "$secrets_proxy_dir/ca-bundle.pem" ] \
|
|
3124
|
+
&& crt exec -i -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3125
|
+
/bin/sh -c "$(cat "$secrets_proxy_dir/ca-install.sh")" \
|
|
3126
|
+
< "$secrets_proxy_dir/ca-bundle.pem" >/dev/null; then
|
|
3127
|
+
secrets_proxy_trust=installed
|
|
3128
|
+
else
|
|
3129
|
+
secrets_proxy_trust=failed
|
|
3130
|
+
log "warning: could not install the secrets proxy CA in ${app_container}; its proxied keys stay withheld"
|
|
3131
|
+
fi
|
|
3132
|
+
secrets_proxy_report trust "$secrets_proxy_trust"
|
|
3133
|
+
fi
|
|
3134
|
+
|
|
3135
|
+
# Loader tasks have two pieces of root-owned bootstrap inside the task:
|
|
3136
|
+
# immutable shell entry-point stubs, and a generation-authenticated loopback
|
|
3137
|
+
# forwarder. Maintenance commands above and below keep UAI_ENV_HOOK=0, so the
|
|
3138
|
+
# stubs cannot make runner-owned execs consume project configuration.
|
|
3139
|
+
if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
|
|
3140
|
+
task_gateway_generation=${UAI_TASK_GATEWAY_GENERATION:-}
|
|
3141
|
+
task_gateway_host=${UAI_TASK_GATEWAY_HOST:-}
|
|
3142
|
+
task_gateway_port=${UAI_MCP_GATEWAY_PORT:-5877}
|
|
3143
|
+
if [[ ! "$task_gateway_generation" =~ ^[A-Za-z0-9_-]{16,64}$ ]] \
|
|
3144
|
+
|| [[ ! "$task_gateway_host" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$ ]] \
|
|
3145
|
+
|| [[ ! "$task_gateway_port" =~ ^[0-9]{1,5}$ ]] \
|
|
3146
|
+
|| [ "$task_gateway_port" -lt 1 ] \
|
|
3147
|
+
|| [ "$task_gateway_port" -gt 65535 ]; then
|
|
3148
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3149
|
+
"Moondeck could not establish this task's authenticated env channel. Retry the task after updating the host." \
|
|
3150
|
+
"validate task env gateway identity"
|
|
3151
|
+
fi
|
|
3152
|
+
|
|
3153
|
+
# A heredoc nested inside `$(cat ...)` containing `case` arms is parsed as
|
|
3154
|
+
# shell syntax by Bash 3.2 (the system Bash on macOS) and makes every task-up
|
|
3155
|
+
# fail before it runs. `read -d ''` assigns the same literal without a
|
|
3156
|
+
# command-substitution parser context.
|
|
3157
|
+
env_stub_installer=''
|
|
3158
|
+
IFS= read -r -d '' env_stub_installer <<'UAI_ENV_STUB_INSTALLER' || true
|
|
3159
|
+
set -eu
|
|
3160
|
+
ensure_block() {
|
|
3161
|
+
file=$1
|
|
3162
|
+
line=$2
|
|
3163
|
+
case "$file" in
|
|
3164
|
+
/etc/zsh/zshenv | /etc/zsh/zshrc | /etc/bash.bashrc | /etc/profile.d/uai-env.sh) ;;
|
|
3165
|
+
*) exit 1 ;;
|
|
3166
|
+
esac
|
|
3167
|
+
[ ! -L "$file" ] || exit 1
|
|
3168
|
+
[ ! -e "$file" ] || [ -f "$file" ] || exit 1
|
|
3169
|
+
directory=${file%/*}
|
|
3170
|
+
[ -d "$directory" ] && [ ! -L "$directory" ] || exit 1
|
|
3171
|
+
temporary=$(/usr/bin/mktemp "$directory/.uai-env-stub.XXXXXX")
|
|
3172
|
+
trap '/bin/rm -f -- "$temporary"' EXIT
|
|
3173
|
+
if [ -f "$file" ]; then
|
|
3174
|
+
/usr/bin/awk '
|
|
3175
|
+
$0 == "# >>> uai env loader >>>" { skipping = 1; next }
|
|
3176
|
+
$0 == "# <<< uai env loader <<<" { skipping = 0; next }
|
|
3177
|
+
!skipping { print }
|
|
3178
|
+
' "$file" > "$temporary"
|
|
3179
|
+
fi
|
|
3180
|
+
{
|
|
3181
|
+
printf '%s\n' '# >>> uai env loader >>>'
|
|
3182
|
+
printf '%s\n' "$line"
|
|
3183
|
+
printf '%s\n' '# <<< uai env loader <<<'
|
|
3184
|
+
} >> "$temporary"
|
|
3185
|
+
/bin/chown root:root "$temporary"
|
|
3186
|
+
/bin/chmod 0644 "$temporary"
|
|
3187
|
+
/bin/mv -f -- "$temporary" "$file"
|
|
3188
|
+
trap - EXIT
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
ensure_block /etc/zsh/zshenv '[ "${UAI_ENV_HOOK-0}" != 1 ] || { . /usr/local/share/uai/env-hook.sh; __uai_env_sync; }'
|
|
3192
|
+
ensure_block /etc/zsh/zshrc '[ "${UAI_ENV_HOOK-0}" != 1 ] || { type __uai_env_install_zsh_preexec >/dev/null 2>&1 || . /usr/local/share/uai/env-hook.sh; __uai_env_install_zsh_preexec; }'
|
|
3193
|
+
ensure_block /etc/bash.bashrc '[ "${UAI_ENV_HOOK-0}" != 1 ] || { . /usr/local/share/uai/env-hook.sh; __uai_env_sync; __uai_env_install_bash_preexec; }'
|
|
3194
|
+
ensure_block /etc/profile.d/uai-env.sh '[ "${UAI_ENV_HOOK-0}" != 1 ] || { . /usr/local/share/uai/env-hook.sh; if [ -z "${BASH_EXECUTION_STRING-}" ] || [ -z "${BASH_ENV-}" ]; then __uai_env_sync; fi; }'
|
|
3195
|
+
UAI_ENV_STUB_INSTALLER
|
|
3196
|
+
if ! crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3197
|
+
/bin/bash -c "$env_stub_installer" >/dev/null; then
|
|
3198
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3199
|
+
"Moondeck could not install the root-owned task env shell hooks. Retry the task; project env remains withheld until this succeeds." \
|
|
3200
|
+
"install task env shell hooks"
|
|
3201
|
+
fi
|
|
3202
|
+
|
|
3203
|
+
task_forwarder_node=/usr/local/bin/node
|
|
3204
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
3205
|
+
task_forwarder_node="/run/uai/asdf-lower/installs/nodejs/$STANDARD_DEFAULT_NODE_VERSION/bin/node"
|
|
3206
|
+
fi
|
|
3207
|
+
|
|
3208
|
+
task_forwarder_launcher=''
|
|
3209
|
+
IFS= read -r -d '' task_forwarder_launcher <<'UAI_TASK_FORWARDER_LAUNCHER' || true
|
|
3210
|
+
set -eu
|
|
3211
|
+
task_id=$1
|
|
3212
|
+
generation=$2
|
|
3213
|
+
gateway_host=$3
|
|
3214
|
+
gateway_port=$4
|
|
3215
|
+
node_path=$5
|
|
3216
|
+
case "$gateway_port" in ''|*[!0-9]*) exit 1 ;; esac
|
|
3217
|
+
[ "$gateway_port" -ge 1 ] && [ "$gateway_port" -le 65535 ] || exit 1
|
|
3218
|
+
case "$node_path" in
|
|
3219
|
+
/usr/local/bin/node) ;;
|
|
3220
|
+
/run/uai/asdf-lower/installs/nodejs/22.11.0/bin/node)
|
|
3221
|
+
[ ! -L "$node_path" ] && [ -f "$node_path" ] && [ -x "$node_path" ] \
|
|
3222
|
+
|| exit 1
|
|
3223
|
+
;;
|
|
3224
|
+
*) exit 1 ;;
|
|
3225
|
+
esac
|
|
3226
|
+
state_dir=/run/uai/task-forwarder
|
|
3227
|
+
pid_file=$state_dir/pid
|
|
3228
|
+
ready_file=$state_dir/ready
|
|
3229
|
+
error_file=$state_dir/error.log
|
|
3230
|
+
umask 077
|
|
3231
|
+
[ ! -L "$state_dir" ] || exit 1
|
|
3232
|
+
[ ! -e "$state_dir" ] || [ -d "$state_dir" ] || exit 1
|
|
3233
|
+
/usr/bin/install -d -o root -g root -m 0700 "$state_dir"
|
|
3234
|
+
|
|
3235
|
+
if [ -f "$pid_file" ] && [ ! -L "$pid_file" ]; then
|
|
3236
|
+
old_pid=$(/bin/cat "$pid_file" 2>/dev/null || true)
|
|
3237
|
+
case "$old_pid" in ''|*[!0-9]*) old_pid= ;; esac
|
|
3238
|
+
if [ -n "$old_pid" ] && /bin/kill -0 "$old_pid" 2>/dev/null; then
|
|
3239
|
+
old_uid=$(/usr/bin/awk '/^Uid:/ { print $2; exit }' "/proc/$old_pid/status" 2>/dev/null || true)
|
|
3240
|
+
old_args=$(/usr/bin/tr '\000' '\n' < "/proc/$old_pid/cmdline" 2>/dev/null || true)
|
|
3241
|
+
# /run survives a Docker stop/start. Its recorded pid may have been
|
|
3242
|
+
# reused by uai-init or another innocent process; treat that record as
|
|
3243
|
+
# stale and never signal a process unless both uid and argv prove it is
|
|
3244
|
+
# this task's prior forwarder.
|
|
3245
|
+
if [ "$old_uid" = 65532 ] \
|
|
3246
|
+
&& printf '%s\n' "$old_args" | /bin/grep -Fxq '/usr/local/share/uai/uai-task-forwarder.mjs' \
|
|
3247
|
+
&& printf '%s\n' "$old_args" | /bin/grep -Fxq "$task_id"; then
|
|
3248
|
+
/bin/kill -TERM "$old_pid"
|
|
3249
|
+
attempts=0
|
|
3250
|
+
while /bin/kill -0 "$old_pid" 2>/dev/null && [ "$attempts" -lt 50 ]; do
|
|
3251
|
+
/bin/sleep 0.1
|
|
3252
|
+
attempts=$((attempts + 1))
|
|
3253
|
+
done
|
|
3254
|
+
if /bin/kill -0 "$old_pid" 2>/dev/null; then
|
|
3255
|
+
/bin/kill -KILL "$old_pid"
|
|
3256
|
+
fi
|
|
3257
|
+
fi
|
|
3258
|
+
fi
|
|
3259
|
+
fi
|
|
3260
|
+
/bin/rm -f -- "$pid_file" "$ready_file" "$error_file"
|
|
3261
|
+
|
|
3262
|
+
# Non-interactive Bash otherwise gives an asynchronous command /dev/null as
|
|
3263
|
+
# stdin. Preserve the docker/container exec pipe explicitly, then close the
|
|
3264
|
+
# extra descriptor in both parent and child after fd 0 has been duplicated.
|
|
3265
|
+
exec 3<&0
|
|
3266
|
+
/usr/bin/env -i PATH=/usr/bin:/bin \
|
|
3267
|
+
/usr/bin/setsid \
|
|
3268
|
+
/usr/bin/setpriv \
|
|
3269
|
+
--reuid=65532 \
|
|
3270
|
+
--regid=65532 \
|
|
3271
|
+
--clear-groups \
|
|
3272
|
+
--bounding-set=-all \
|
|
3273
|
+
--inh-caps=-all \
|
|
3274
|
+
--ambient-caps=-all \
|
|
3275
|
+
--no-new-privs \
|
|
3276
|
+
"$node_path" \
|
|
3277
|
+
/usr/local/share/uai/uai-task-forwarder.mjs \
|
|
3278
|
+
--task-id "$task_id" \
|
|
3279
|
+
--generation "$generation" \
|
|
3280
|
+
--gateway-host "$gateway_host" \
|
|
3281
|
+
--gateway-port "$gateway_port" \
|
|
3282
|
+
--listen-port 5877 \
|
|
3283
|
+
<&3 3<&- >"$ready_file" 2>"$error_file" &
|
|
3284
|
+
forwarder_pid=$!
|
|
3285
|
+
exec 3<&-
|
|
3286
|
+
printf '%s\n' "$forwarder_pid" > "$pid_file"
|
|
3287
|
+
|
|
3288
|
+
attempts=0
|
|
3289
|
+
while [ "$attempts" -lt 50 ]; do
|
|
3290
|
+
/bin/kill -0 "$forwarder_pid" 2>/dev/null || exit 1
|
|
3291
|
+
if [ -f "$ready_file" ] \
|
|
3292
|
+
&& [ "$(/bin/cat "$ready_file" 2>/dev/null || true)" = READY ]; then
|
|
3293
|
+
exit 0
|
|
3294
|
+
fi
|
|
3295
|
+
/bin/sleep 0.1
|
|
3296
|
+
attempts=$((attempts + 1))
|
|
3297
|
+
done
|
|
3298
|
+
exit 1
|
|
3299
|
+
UAI_TASK_FORWARDER_LAUNCHER
|
|
3300
|
+
|
|
3301
|
+
base64_decode_args=()
|
|
3302
|
+
if printf '' | base64 --decode >/dev/null 2>&1; then
|
|
3303
|
+
base64_decode_args=(--decode)
|
|
3304
|
+
elif printf '' | base64 -D >/dev/null 2>&1; then
|
|
3305
|
+
base64_decode_args=(-D)
|
|
3306
|
+
else
|
|
3307
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3308
|
+
"Moondeck could not decode the task env gateway credential on this host." \
|
|
3309
|
+
"start task env forwarder"
|
|
3310
|
+
fi
|
|
3311
|
+
if ! printf '%s' "$task_gateway_secret_b64" \
|
|
3312
|
+
| base64 "${base64_decode_args[@]}" \
|
|
3313
|
+
| crt exec -i -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3314
|
+
/bin/bash -c "$task_forwarder_launcher" uai-task-forwarder \
|
|
3315
|
+
"$task_id" "$task_gateway_generation" "$task_gateway_host" \
|
|
3316
|
+
"$task_gateway_port" "$task_forwarder_node" \
|
|
3317
|
+
>/dev/null; then
|
|
3318
|
+
unset task_gateway_secret_b64
|
|
3319
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3320
|
+
"Moondeck could not start the authenticated task env forwarder. Retry the task; project env remains withheld until this succeeds." \
|
|
3321
|
+
"start task env forwarder"
|
|
3322
|
+
fi
|
|
3323
|
+
unset task_gateway_secret_b64
|
|
3324
|
+
fi
|
|
3325
|
+
|
|
3326
|
+
# ADR-153 secrets proxy, second step, now that the forwarder listens: the NAT
|
|
3327
|
+
# chain that sends the pin address's :443 to the forwarder, and the names
|
|
3328
|
+
# pinned to it. In the runner's order (routing-order): the chain before the
|
|
3329
|
+
# names when pinning, the names first when there is nothing to pin, so a
|
|
3330
|
+
# failure part-way never leaves a name pointing at a pin with nothing behind
|
|
3331
|
+
# it. The NAT step needs NET_ADMIN. A Docker container does not have it: the
|
|
3332
|
+
# step is the one privileged exec Uai makes, and only that process gets it.
|
|
3333
|
+
# An Apple task's VM was started with it, so there a root exec has it (and
|
|
3334
|
+
# the CLI has no --privileged).
|
|
3335
|
+
secrets_proxy_chain() {
|
|
3336
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
3337
|
+
set -- crt exec -u root
|
|
3338
|
+
else
|
|
3339
|
+
set -- docker exec --privileged -u root
|
|
3340
|
+
fi
|
|
3341
|
+
"$@" "${managed_exec_env[@]}" "$app_container" \
|
|
3342
|
+
/bin/sh -c "$(cat "$secrets_proxy_dir/chain.sh")" >/dev/null \
|
|
3343
|
+
|| { log "warning: could not redirect the secrets proxy's names in ${app_container}"; return 1; }
|
|
3344
|
+
}
|
|
3345
|
+
secrets_proxy_names() {
|
|
3346
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3347
|
+
/bin/sh -c "$(cat "$secrets_proxy_dir/pins.sh")" >/dev/null \
|
|
3348
|
+
|| { log "warning: could not pin the secrets proxy's names in ${app_container}"; return 1; }
|
|
3349
|
+
}
|
|
3350
|
+
if [ "$secrets_proxy_trust" = "installed" ]; then
|
|
3351
|
+
secrets_proxy_order=$(cat "$secrets_proxy_dir/routing-order" 2>/dev/null || true)
|
|
3352
|
+
# A missing script would run as an empty, successful program.
|
|
3353
|
+
if [ ! -f "$secrets_proxy_dir/pins.sh" ] || [ ! -f "$secrets_proxy_dir/chain.sh" ]; then
|
|
3354
|
+
secrets_proxy_pins=failed
|
|
3355
|
+
log "warning: the secrets proxy's pin scripts are missing for ${app_container}"
|
|
3356
|
+
elif [ "$secrets_proxy_order" = "pin" ]; then
|
|
3357
|
+
if secrets_proxy_chain && secrets_proxy_names; then secrets_proxy_pins=installed; else secrets_proxy_pins=failed; fi
|
|
3358
|
+
elif [ "$secrets_proxy_order" = "unpin" ]; then
|
|
3359
|
+
if secrets_proxy_names && secrets_proxy_chain; then secrets_proxy_pins=installed; else secrets_proxy_pins=failed; fi
|
|
3360
|
+
else
|
|
3361
|
+
secrets_proxy_pins=failed
|
|
3362
|
+
log "warning: the secrets proxy's routing order is missing for ${app_container}"
|
|
3363
|
+
fi
|
|
3364
|
+
secrets_proxy_report pins "$secrets_proxy_pins"
|
|
3365
|
+
fi
|
|
3366
|
+
|
|
3367
|
+
# OpenCode and code-server share ~/.local/share. OpenCode credentials are
|
|
3368
|
+
# copied as root below, so creating only its leaf with `mkdir -p` can leave the
|
|
3369
|
+
# shared parent root-owned (live 2026-08-04). Then node cannot create
|
|
3370
|
+
# code-server's User profile and the editor silently falls back to its light,
|
|
3371
|
+
# full-chrome defaults. Repair both explicitly on every start/resume; GNU
|
|
3372
|
+
# `install -d` also corrects an existing directory with the wrong owner.
|
|
3373
|
+
if ! crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3374
|
+
/usr/bin/install -d -o node -g node -m 0755 \
|
|
3375
|
+
/home/node/.local/share \
|
|
3376
|
+
/home/node/.local/share/opencode >/dev/null; then
|
|
3377
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3378
|
+
"Moondeck could not prepare the task's node-owned application data directory. Retry the task; the editor cannot load its settings until this succeeds." \
|
|
3379
|
+
"prepare container application data"
|
|
3380
|
+
fi
|
|
3381
|
+
|
|
3382
|
+
# Git rejects a repository owned by another uid even when its shared group is
|
|
3383
|
+
# writable. Trust only the selected worktrees in this task container—never `*`
|
|
3384
|
+
# and never the host cache. The task-private ~/.gitconfig makes these entries
|
|
3385
|
+
# local to this container, and the read-before-add keeps resumes idempotent.
|
|
3386
|
+
step "CONTAINER_INIT_FAILED" "trust task Git worktrees"
|
|
3387
|
+
container_safe_dirs=$(crt exec -u node "${managed_exec_env[@]}" \
|
|
3388
|
+
"$app_container" "${managed_clean_env[@]}" /usr/bin/git config --global \
|
|
3389
|
+
--get-all safe.directory 2>/dev/null || true)
|
|
3390
|
+
while IFS= read -r safe_project; do
|
|
3391
|
+
[ -n "$safe_project" ] || continue
|
|
3392
|
+
safe_project_id=$(jq -r '.id' <<<"$safe_project")
|
|
3393
|
+
safe_project_slug=$(jq -r '.slug' <<<"$safe_project")
|
|
3394
|
+
for safe_worktree in \
|
|
3395
|
+
"/workspace/$safe_project_slug" \
|
|
3396
|
+
"$task_workspace/$safe_project_slug" \
|
|
3397
|
+
"$task_uai_dir/repos/$safe_project_id.git" \
|
|
3398
|
+
"$task_workspace_physical/$safe_project_slug" \
|
|
3399
|
+
"$task_uai_dir_physical/repos/$safe_project_id.git"; do
|
|
3400
|
+
if ! printf '%s\n' "$container_safe_dirs" \
|
|
3401
|
+
| grep -Fqx -- "$safe_worktree"; then
|
|
3402
|
+
if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
|
|
3403
|
+
"${managed_clean_env[@]}" /usr/bin/git config --global --add \
|
|
3404
|
+
safe.directory "$safe_worktree" >/dev/null; then
|
|
3405
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3406
|
+
"Moondeck could not mark $safe_worktree as this task's trusted Git path" \
|
|
3407
|
+
"trust task Git worktrees"
|
|
3408
|
+
fi
|
|
3409
|
+
container_safe_dirs="${container_safe_dirs}"$'\n'"${safe_worktree}"
|
|
3410
|
+
fi
|
|
3411
|
+
done
|
|
3412
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
3413
|
+
|
|
3414
|
+
# A running container is not sufficient proof that its linked worktrees are
|
|
3415
|
+
# usable. In particular, Git may have canonicalized a symlinked host workspace
|
|
3416
|
+
# root while Compose mounted only its lexical spelling. Verify the actual path
|
|
3417
|
+
# agents use before credentials, dependency installation, or status=running.
|
|
3418
|
+
step "CONTAINER_INIT_FAILED" "verify task Git worktrees"
|
|
3419
|
+
while IFS= read -r verify_project; do
|
|
3420
|
+
[ -n "$verify_project" ] || continue
|
|
3421
|
+
verify_project_slug=$(jq -r '.slug' <<<"$verify_project")
|
|
3422
|
+
verify_is_worktree=""
|
|
3423
|
+
if ! verify_is_worktree=$(crt exec -u node "${managed_exec_env[@]}" \
|
|
3424
|
+
"$app_container" "${managed_clean_env[@]}" /usr/bin/git \
|
|
3425
|
+
-C "/workspace/$verify_project_slug" rev-parse \
|
|
3426
|
+
--is-inside-work-tree 2>/dev/null) \
|
|
3427
|
+
|| [ "$verify_is_worktree" != "true" ]; then
|
|
3428
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3429
|
+
"Moondeck could not open $verify_project_slug as a Git worktree inside the task container. Retry the task after checking the host workspace path." \
|
|
3430
|
+
"verify task Git worktree ($verify_project_slug)"
|
|
3431
|
+
fi
|
|
3432
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
3433
|
+
|
|
3434
|
+
# A failed/retried start can preserve a container that still holds a previous
|
|
3435
|
+
# account. Erase that Uai-managed state first for both connected and
|
|
3436
|
+
# disconnected starts; otherwise a no-credential resume could briefly reuse a
|
|
3437
|
+
# stale token before the asynchronous reconciler catches up.
|
|
3438
|
+
if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
|
|
3439
|
+
"${managed_clean_env[@]}" /bin/sh -c '
|
|
3440
|
+
set -eu
|
|
3441
|
+
/bin/rm -f "$GH_CONFIG_DIR/hosts.yml"
|
|
3442
|
+
for key in \
|
|
3443
|
+
credential.https://github.com.helper \
|
|
3444
|
+
credential.https://gist.github.com.helper
|
|
3445
|
+
do
|
|
3446
|
+
/usr/bin/git config --file "$HOME/.gitconfig" --unset-all "$key" \
|
|
3447
|
+
>/dev/null 2>&1 || true
|
|
3448
|
+
if /usr/bin/git config --file "$HOME/.gitconfig" --get-all "$key" \
|
|
3449
|
+
>/dev/null 2>&1; then
|
|
3450
|
+
exit 1
|
|
3451
|
+
fi
|
|
3452
|
+
done
|
|
3453
|
+
test ! -e "$GH_CONFIG_DIR/hosts.yml"
|
|
3454
|
+
' >/dev/null; then
|
|
3455
|
+
require_docker_after_failed_step "clear container GitHub credential"
|
|
3456
|
+
emit_err "GITHUB_AUTH_UNAVAILABLE" \
|
|
3457
|
+
"Moondeck could not clear the previous managed GitHub credential from the task container. Retry the task before doing GitHub work." \
|
|
3458
|
+
"clear container GitHub credential"
|
|
3459
|
+
fi
|
|
3460
|
+
|
|
3461
|
+
# The task container must have the same connected GitHub principal BEFORE
|
|
3462
|
+
# uai-init runs package managers: private Git submodules/dependencies may be
|
|
3463
|
+
# fetched during install. Read the token back from the private cache and stream
|
|
3464
|
+
# only its password field directly into `gh auth login`; it never enters argv,
|
|
3465
|
+
# environment, a shell variable, Compose, or disk.
|
|
3466
|
+
if [ -n "$github_credential_socket" ]; then
|
|
3467
|
+
if ! printf 'protocol=https\nhost=github.com\n\n' \
|
|
3468
|
+
| git_with_github_credential credential fill \
|
|
3469
|
+
| sed -n 's/^password=//p' \
|
|
3470
|
+
| crt exec -i -u node "${managed_exec_env[@]}" "$app_container" \
|
|
3471
|
+
"${managed_clean_env[@]}" /usr/bin/gh auth login --with-token \
|
|
3472
|
+
--hostname github.com --git-protocol https >/dev/null; then
|
|
3473
|
+
require_docker_after_failed_step "configure container GitHub credential"
|
|
3474
|
+
emit_err "GITHUB_AUTH_UNAVAILABLE" \
|
|
3475
|
+
"GitHub is connected on this host, but Moondeck could not install that credential in the task container. Retry the task; if it persists, reconnect GitHub on this host." \
|
|
3476
|
+
"configure container GitHub credential"
|
|
3477
|
+
fi
|
|
3478
|
+
if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
|
|
3479
|
+
"${managed_clean_env[@]}" /usr/bin/gh auth setup-git \
|
|
3480
|
+
--hostname github.com >/dev/null; then
|
|
3481
|
+
require_docker_after_failed_step "configure container Git credential helper"
|
|
3482
|
+
emit_err "GITHUB_AUTH_UNAVAILABLE" \
|
|
3483
|
+
"GitHub is connected on this host, but Moondeck could not configure Git HTTPS in the task container. Retry the task; if it persists, reconnect GitHub on this host." \
|
|
3484
|
+
"configure container Git credential helper"
|
|
3485
|
+
fi
|
|
3486
|
+
fi
|
|
3487
|
+
|
|
3488
|
+
# Copy `.claude.json` into the container instead of bind-mounting it.
|
|
3489
|
+
# Single-file bind mounts on Docker Desktop macOS are fragile — claude
|
|
3490
|
+
# CLI atomically rewrites the file on every start, which can invalidate
|
|
3491
|
+
# the bind mount and leave a stale dentry, after which claude refuses to
|
|
3492
|
+
# start. docker cp gives the container its own stable copy. Best-effort.
|
|
3493
|
+
if [ "$uai_on_cloud_credentials" != "1" ] && [ -f "$UAI_OWNER_HOME/.claude.json" ]; then
|
|
3494
|
+
crt cp "$UAI_OWNER_HOME/.claude.json" \
|
|
3495
|
+
"$app_container":/home/node/.claude.json >/dev/null 2>&1 \
|
|
3496
|
+
|| log "warning: crt cp of .claude.json failed; claude may need re-login"
|
|
3497
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3498
|
+
/usr/bin/chown node:node /home/node/.claude.json >/dev/null 2>&1 || true
|
|
3499
|
+
fi
|
|
3500
|
+
|
|
3501
|
+
# Copy Codex credentials/config into a task-private /home/node/.codex.
|
|
3502
|
+
# Do not mount the host ~/.codex writable: Codex stores live SQLite state
|
|
3503
|
+
# there, and concurrent host/container access can corrupt it.
|
|
3504
|
+
# Failures here are REAL errors (the container is running at task-up):
|
|
3505
|
+
# docker cp preserves the host uid/gid, so a skipped chown leaves 0600
|
|
3506
|
+
# files owned by e.g. macOS 501:20 that the container's node (1000:1000)
|
|
3507
|
+
# cannot read — Codex then dies at startup.
|
|
3508
|
+
if crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3509
|
+
/usr/bin/mkdir -p /home/node/.codex >/dev/null; then
|
|
3510
|
+
for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
|
|
3511
|
+
[ "$uai_on_cloud_credentials" = "1" ] && continue
|
|
3512
|
+
if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
|
|
3513
|
+
# ADR-135: the task's copy must not carry the rotating refresh token —
|
|
3514
|
+
# N copies racing to refresh it revoke the whole family. Replace it with
|
|
3515
|
+
# the broker placeholder (the field must exist; codex refuses a missing
|
|
3516
|
+
# one). The host is the only refresher and re-pushes on refresh.
|
|
3517
|
+
if [ "$codex_item" = auth.json ] && command -v jq >/dev/null 2>&1; then
|
|
3518
|
+
brokered_auth="$(mktemp "${TMPDIR:-/tmp}/uai-codex-auth.XXXXXX")"
|
|
3519
|
+
if jq 'if (.tokens|type) == "object" and (.tokens.refresh_token|type) == "string" then .tokens.refresh_token = "uai-broker-managed-no-refresh" else . end' \
|
|
3520
|
+
"$UAI_OWNER_HOME/.codex/auth.json" > "$brokered_auth" 2>/dev/null && [ -s "$brokered_auth" ]; then
|
|
3521
|
+
crt cp "$brokered_auth" "$app_container":/home/node/.codex/auth.json >/dev/null \
|
|
3522
|
+
|| log "ERROR: crt cp of brokered .codex/auth.json failed; Codex in this task may not authenticate"
|
|
3523
|
+
rm -f "$brokered_auth"
|
|
3524
|
+
continue
|
|
3525
|
+
fi
|
|
3526
|
+
rm -f "$brokered_auth"
|
|
3527
|
+
log "warning: could not broker .codex/auth.json; copying it verbatim"
|
|
3528
|
+
fi
|
|
3529
|
+
crt cp "$UAI_OWNER_HOME/.codex/$codex_item" \
|
|
3530
|
+
"$app_container":/home/node/.codex/ >/dev/null \
|
|
3531
|
+
|| log "ERROR: crt cp of .codex/$codex_item failed; Codex in this task may not authenticate"
|
|
3532
|
+
fi
|
|
3533
|
+
done
|
|
3534
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3535
|
+
/usr/bin/chown -R node:node /home/node/.codex >/dev/null \
|
|
3536
|
+
|| log "ERROR: chown of /home/node/.codex failed — 0600 credentials remain unreadable by node. Fix (docker mode; use the bundled container CLI on Apple hosts): docker exec -u root -e PATH=/usr/bin:/bin $app_container /usr/bin/chown -R node:node /home/node/.codex"
|
|
3537
|
+
else
|
|
3538
|
+
log "ERROR: mkdir /home/node/.codex failed in $app_container; Codex credentials were not injected"
|
|
3539
|
+
fi
|
|
3540
|
+
|
|
3541
|
+
# Copy Kimi Code subscription/config into a task-private /home/node/.kimi-code.
|
|
3542
|
+
# The Linux `kimi` binary is baked into the image; here we copy ONLY the
|
|
3543
|
+
# arch-independent config + credential (never the host's macOS bin/, nor live
|
|
3544
|
+
# session state) so the container's kimi runs on the operator's subscription.
|
|
3545
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3546
|
+
/usr/bin/mkdir -p /home/node/.kimi-code/credentials >/dev/null 2>&1 || true
|
|
3547
|
+
if [ "$uai_on_cloud_credentials" != "1" ] && [ -e "$UAI_OWNER_HOME/.kimi-code/config.toml" ]; then
|
|
3548
|
+
crt cp "$UAI_OWNER_HOME/.kimi-code/config.toml" \
|
|
3549
|
+
"$app_container":/home/node/.kimi-code/config.toml >/dev/null 2>&1 \
|
|
3550
|
+
|| log "warning: crt cp of .kimi-code/config.toml failed; kimi may need re-login"
|
|
3551
|
+
fi
|
|
3552
|
+
if [ "$uai_on_cloud_credentials" != "1" ] && [ -e "$UAI_OWNER_HOME/.kimi-code/device_id" ]; then
|
|
3553
|
+
crt cp "$UAI_OWNER_HOME/.kimi-code/device_id" \
|
|
3554
|
+
"$app_container":/home/node/.kimi-code/device_id >/dev/null 2>&1 || true
|
|
3555
|
+
fi
|
|
3556
|
+
if [ "$uai_on_cloud_credentials" != "1" ] && [ -e "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" ]; then
|
|
3557
|
+
crt cp "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" \
|
|
3558
|
+
"$app_container":/home/node/.kimi-code/credentials/kimi-code.json >/dev/null 2>&1 \
|
|
3559
|
+
|| log "warning: crt cp of .kimi-code credential failed; kimi may need re-login"
|
|
3560
|
+
fi
|
|
3561
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3562
|
+
/usr/bin/chown -R node:node /home/node/.kimi-code >/dev/null 2>&1 || true
|
|
3563
|
+
|
|
3564
|
+
# Copy Grok subscription/config into a task-private /home/node/.grok. The Linux
|
|
3565
|
+
# `grok` binary is baked into the image; here we copy the arch-independent
|
|
3566
|
+
# auth + config. auth.json is an OIDC token: the task's copy gets the broker
|
|
3567
|
+
# placeholder in every entry's refresh_token (ADR-135) so it can never rotate
|
|
3568
|
+
# the family; the host is the only refresher.
|
|
3569
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3570
|
+
/usr/bin/mkdir -p /home/node/.grok >/dev/null 2>&1 || true
|
|
3571
|
+
for grok_item in auth.json config.toml models_cache.json agent_id; do
|
|
3572
|
+
[ "$uai_on_cloud_credentials" = "1" ] && continue
|
|
3573
|
+
if [ -e "$UAI_OWNER_HOME/.grok/$grok_item" ]; then
|
|
3574
|
+
if [ "$grok_item" = auth.json ] && command -v jq >/dev/null 2>&1; then
|
|
3575
|
+
brokered_auth="$(mktemp "${TMPDIR:-/tmp}/uai-grok-auth.XXXXXX")"
|
|
3576
|
+
if jq 'with_entries(if (.value|type) == "object" and (.value.refresh_token|type) == "string" then .value.refresh_token = "uai-broker-managed-no-refresh" else . end)' \
|
|
3577
|
+
"$UAI_OWNER_HOME/.grok/auth.json" > "$brokered_auth" 2>/dev/null && [ -s "$brokered_auth" ]; then
|
|
3578
|
+
crt cp "$brokered_auth" "$app_container":/home/node/.grok/auth.json >/dev/null 2>&1 \
|
|
3579
|
+
|| log "warning: crt cp of brokered .grok/auth.json failed; grok may need re-login"
|
|
3580
|
+
rm -f "$brokered_auth"
|
|
3581
|
+
continue
|
|
3582
|
+
fi
|
|
3583
|
+
rm -f "$brokered_auth"
|
|
3584
|
+
log "warning: could not broker .grok/auth.json; copying it verbatim"
|
|
3585
|
+
fi
|
|
3586
|
+
crt cp "$UAI_OWNER_HOME/.grok/$grok_item" \
|
|
3587
|
+
"$app_container":/home/node/.grok/ >/dev/null 2>&1 \
|
|
3588
|
+
|| log "warning: crt cp of .grok/$grok_item failed; grok may need re-login"
|
|
3589
|
+
fi
|
|
3590
|
+
done
|
|
3591
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3592
|
+
/usr/bin/chown -R node:node /home/node/.grok >/dev/null 2>&1 || true
|
|
3593
|
+
|
|
3594
|
+
# Copy OpenCode provider auth into a task-private /home/node/.local/share/opencode
|
|
3595
|
+
# (ADR-077), same per-task-writable-copy pattern as Codex. The Linux `opencode`
|
|
3596
|
+
# binary is baked into the image; here we copy the arch-independent auth+config.
|
|
3597
|
+
# ADR-076 EXTRA accounts (isolated dirs) are copied later from the host-agent
|
|
3598
|
+
# (provisionEngineAccounts) since they live in the sealed DB, not on disk here.
|
|
3599
|
+
# The shared parent and this leaf were created with node ownership above.
|
|
3600
|
+
for oc_item in auth.json config.json; do
|
|
3601
|
+
[ "$uai_on_cloud_credentials" = "1" ] && continue
|
|
3602
|
+
if [ -e "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" ]; then
|
|
3603
|
+
crt cp "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" \
|
|
3604
|
+
"$app_container":/home/node/.local/share/opencode/ >/dev/null 2>&1 \
|
|
3605
|
+
|| log "warning: crt cp of opencode/$oc_item failed; opencode may need re-login"
|
|
3606
|
+
fi
|
|
3607
|
+
done
|
|
3608
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3609
|
+
/usr/bin/chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
|
|
3610
|
+
|
|
3611
|
+
# Copy the resolved SSH identity into the container for commit/tag signing and
|
|
3612
|
+
# as the no-GitHub-connection transport fallback. Connected users clone, fetch,
|
|
3613
|
+
# and push over HTTPS through their gh credential instead.
|
|
3614
|
+
# Best-effort: signing + the SSH fallback stay off if the identity is absent —
|
|
3615
|
+
# but every failure is LOGGED (a silent miss here reads as a GitHub
|
|
3616
|
+
# permissions problem hours later; live 2026-07-21). The channel-ensure path
|
|
3617
|
+
# (ensureTaskSshIdentity) re-asserts all of this on reconnects.
|
|
3618
|
+
if [ -f "$uai_identity_key" ]; then
|
|
3619
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3620
|
+
/usr/bin/mkdir -p /home/node/.ssh >/dev/null 2>&1 \
|
|
3621
|
+
|| log "warning: ssh setup: mkdir ~/.ssh failed in $app_container"
|
|
3622
|
+
crt cp "$uai_identity_key" \
|
|
3623
|
+
"$app_container":/home/node/.ssh/id_ed25519 >/dev/null 2>&1 \
|
|
3624
|
+
|| log "warning: ssh setup: key copy failed — signing and SSH fallback unavailable"
|
|
3625
|
+
crt cp "${uai_identity_key}.pub" \
|
|
3626
|
+
"$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 \
|
|
3627
|
+
|| log "warning: ssh setup: pubkey copy failed (signing will be off)"
|
|
3628
|
+
crt exec -u root "${managed_exec_env[@]}" "$app_container" /bin/sh -c \
|
|
3629
|
+
'/usr/bin/chown -R node:node /home/node/.ssh && /usr/bin/chmod 700 /home/node/.ssh && /usr/bin/chmod 600 /home/node/.ssh/id_ed25519' \
|
|
3630
|
+
>/dev/null 2>&1 \
|
|
3631
|
+
|| log "warning: ssh setup: chown/chmod failed — key may be unreadable by node"
|
|
3632
|
+
fi
|
|
3633
|
+
|
|
3634
|
+
# uai-init (workspace deps + code-server) is re-runnable, and its death must
|
|
3635
|
+
# not error a task whose container is already up (graceful degradation): a
|
|
3636
|
+
# SIGKILLed exec session (observed live 2026-07-21, exit 137 with no timeout
|
|
3637
|
+
# anywhere on this path) used to fail the whole task over a step anyone can
|
|
3638
|
+
# re-run. Retry once, then come up DEGRADED with a warning the host surfaces
|
|
3639
|
+
# as a system note in the task feed.
|
|
3640
|
+
quarantine_runtime_authority_violation() {
|
|
3641
|
+
quarantine_untrusted_app_container
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
# ADR-154 §12: the runner writes a task on cloud credentials its owner's
|
|
3645
|
+
# GitHub configuration and SSH key, recorded for cleanup, now: after the
|
|
3646
|
+
# managed-auth scrub, before uai-init installs anything that may need them.
|
|
3647
|
+
# task-up waits for its answer, bounded, and stops on anything but continue.
|
|
3648
|
+
if [ -n "$uai_handshake_dir" ]; then
|
|
3649
|
+
: > "$uai_handshake_dir/event-runtime-ready"
|
|
3650
|
+
uai_waited=0
|
|
3651
|
+
while [ ! -f "$uai_handshake_dir/runtime-ready" ] && [ "$uai_waited" -lt 3000 ]; do
|
|
3652
|
+
sleep 0.2
|
|
3653
|
+
uai_waited=$((uai_waited + 1))
|
|
3654
|
+
done
|
|
3655
|
+
uai_runtime_ready=$(cat "$uai_handshake_dir/runtime-ready" 2>/dev/null || true)
|
|
3656
|
+
if [ "$uai_runtime_ready" != "continue" ]; then
|
|
3657
|
+
emit_err "CREDENTIALS_UNAVAILABLE" \
|
|
3658
|
+
"Moondeck could not write the task's GitHub sign-in and SSH key into it. Start the task again." \
|
|
3659
|
+
"write the task's credentials"
|
|
3660
|
+
fi
|
|
3661
|
+
fi
|
|
3662
|
+
|
|
3663
|
+
init_warning=""
|
|
3664
|
+
init_rc=0
|
|
3665
|
+
crt exec "${task_runtime_exec_env[@]}" "$app_container" \
|
|
3666
|
+
/usr/local/bin/uai-init >/dev/null || init_rc=$?
|
|
3667
|
+
if [ "$init_rc" -ne 0 ]; then
|
|
3668
|
+
require_docker_after_failed_step "uai-init (deps + code-server)"
|
|
3669
|
+
if [ "$init_rc" -eq 78 ]; then
|
|
3670
|
+
# Exit 78 is reserved for a host-authority violation, not an ordinary
|
|
3671
|
+
# best-effort dependency failure. Keep the already-created app unavailable
|
|
3672
|
+
# and prove it stopped before returning an actionable task-up failure.
|
|
3673
|
+
if ! quarantine_runtime_authority_violation; then
|
|
3674
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3675
|
+
"A workspace runtime-authority violation was detected, and Moondeck could not prove the task container stopped. Stop it manually before retrying." \
|
|
3676
|
+
"quarantine runtime-authority violation"
|
|
3677
|
+
fi
|
|
3678
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3679
|
+
"Moondeck stopped this task because its runtime isolation changed after startup (for example, a writable shared cache, an unsafe private npm path, or a workspace-root .tool-versions). Retry after removing the reported redirect; project runtime manifests belong only inside selected project directories." \
|
|
3680
|
+
"uai-init runtime authority"
|
|
3681
|
+
fi
|
|
3682
|
+
log "warning: uai-init failed — retrying once"
|
|
3683
|
+
sleep 2
|
|
3684
|
+
init_rc=0
|
|
3685
|
+
crt exec "${task_runtime_exec_env[@]}" "$app_container" \
|
|
3686
|
+
/usr/local/bin/uai-init >/dev/null || init_rc=$?
|
|
3687
|
+
if [ "$init_rc" -ne 0 ]; then
|
|
3688
|
+
require_docker_after_failed_step "uai-init (deps + code-server)"
|
|
3689
|
+
if [ "$init_rc" -eq 78 ]; then
|
|
3690
|
+
if ! quarantine_runtime_authority_violation; then
|
|
3691
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3692
|
+
"A workspace runtime-authority violation was detected, and Moondeck could not prove the task container stopped. Stop it manually before retrying." \
|
|
3693
|
+
"quarantine runtime-authority violation"
|
|
3694
|
+
fi
|
|
3695
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3696
|
+
"Moondeck stopped this task because its runtime isolation changed after startup (for example, a writable shared cache, an unsafe private npm path, or a workspace-root .tool-versions). Retry after removing the reported redirect." \
|
|
3697
|
+
"uai-init runtime authority"
|
|
3698
|
+
fi
|
|
3699
|
+
init_warning="Workspace init (deps + editor) failed twice — the task is up, but the editor and installed dependencies may be missing. Retry from a task terminal with: /usr/local/bin/uai-init"
|
|
3700
|
+
log "warning: $init_warning"
|
|
3701
|
+
fi
|
|
3702
|
+
fi
|
|
3703
|
+
|
|
3704
|
+
# -----------------------------------------------------------------------------
|
|
3705
|
+
# 8. Discover code-server + preview ports, mark running, unlock.
|
|
3706
|
+
# -----------------------------------------------------------------------------
|
|
3707
|
+
|
|
3708
|
+
# code-server: container exposes 8080; Docker auto-maps to a random host
|
|
3709
|
+
# port. Discover via `docker port`. Best-effort — if it fails the Editor
|
|
3710
|
+
# pane is just unavailable.
|
|
3711
|
+
code_server_port=""
|
|
3712
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
3713
|
+
# No ports are published on the Apple runtime: the container's vmnet IP is
|
|
3714
|
+
# host-routable, so the Editor tunnel dials <container-ip>:8080 directly
|
|
3715
|
+
# (resolved per-request from the provider's route inspection).
|
|
3716
|
+
:
|
|
3717
|
+
else
|
|
3718
|
+
code_server_port=$(mapped_host_port "$app_container" 8080)
|
|
3719
|
+
if [ -z "$code_server_port" ]; then
|
|
3720
|
+
require_docker_after_failed_step "discover code-server port"
|
|
3721
|
+
log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
|
|
3722
|
+
fi
|
|
3723
|
+
fi
|
|
3724
|
+
|
|
3725
|
+
# ADR-043 Stage 2: preview ports are no longer published at task-up, so there is
|
|
3726
|
+
# nothing to discover here. `preview_ports` stays empty and every enabled preview
|
|
3727
|
+
# is reached through its sidecar (started lazily on first access via the cloud
|
|
3728
|
+
# tunnel). `union_preview_ports_json` is still surfaced to the cloud (GET task
|
|
3729
|
+
# lists the declared/ad-hoc names the user can turn on).
|
|
3730
|
+
preview_ports_runtime_json="[]"
|
|
3731
|
+
|
|
3732
|
+
step "DB_UPDATE_FAILED" "mark task running"
|
|
3733
|
+
cs_sql="code_server_port=NULL"
|
|
3734
|
+
if [ -n "$code_server_port" ]; then
|
|
3735
|
+
cs_sql="code_server_port=${code_server_port}"
|
|
3736
|
+
fi
|
|
3737
|
+
db_update_task "$task_id" \
|
|
3738
|
+
"status='running'" \
|
|
3739
|
+
"$cs_sql" \
|
|
3740
|
+
"preview_ports='$(sql_escape "$preview_ports_runtime_json")'"
|
|
3741
|
+
|
|
3742
|
+
unlock_task "$task_id"
|
|
3743
|
+
|
|
3744
|
+
emit_event "$task_id" "status_changed" "$(jq -nc '{from:"starting",to:"running"}')"
|
|
3745
|
+
|
|
3746
|
+
# NOTE: `codeServerPort` is added only when discovered. Do NOT inline a
|
|
3747
|
+
# `select(.!="")` into the object literal — when it filters to empty, jq
|
|
3748
|
+
# collapses the *entire* object to empty and emit_ok then defaults to
|
|
3749
|
+
# `{}`, producing AGENT_BAD_OUTPUT. Add the key with object-merge instead.
|
|
3750
|
+
emit_ok "$(jq -nc \
|
|
3751
|
+
--arg cp "$compose_project" \
|
|
3752
|
+
--arg wp "$task_dir" \
|
|
3753
|
+
--arg csport "${code_server_port:-}" \
|
|
3754
|
+
--arg iw "${init_warning}" \
|
|
3755
|
+
--arg spt "$secrets_proxy_trust" \
|
|
3756
|
+
--arg spp "$secrets_proxy_pins" \
|
|
3757
|
+
--argjson previewPorts "$preview_ports_runtime_json" \
|
|
3758
|
+
'{composeProject:$cp,worktreePath:$wp}
|
|
3759
|
+
+ (if $csport == "" then {} else {codeServerPort:($csport|tonumber)} end)
|
|
3760
|
+
+ (if $iw == "" then {} else {initWarning:$iw} end)
|
|
3761
|
+
+ {previewPorts:$previewPorts, secretsProxyTrust:$spt, secretsProxyPins:$spp}')"
|