@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,1227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the machine-backed task-up adapter.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the host's task contract (TaskLaunchInput → TaskUpResult) onto the
|
|
5
|
+
* pure machine environment provider (MachineTaskInput → descriptor). This is
|
|
6
|
+
* where task semantics live for machines: project clones happen IN the
|
|
7
|
+
* machine's world with a one-shot credential over stdin, the task branch is
|
|
8
|
+
* created off the clone's default HEAD, and repo problems degrade into an
|
|
9
|
+
* initWarning system note instead of failing the task (a machine with an
|
|
10
|
+
* empty workspace and a clear message beats a respawn loop).
|
|
11
|
+
*
|
|
12
|
+
* Compose-era result fields are satisfied honestly rather than faked:
|
|
13
|
+
* `composeProject` carries the machine id (the one durable runtime name this
|
|
14
|
+
* task owns) and `worktreePath` carries the host-side task control dir (the
|
|
15
|
+
* directory that really does hold this task's host-side state — its machine
|
|
16
|
+
* key). Consumers that need the workspace go through the environment handle.
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { promisify } from "node:util";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
AgentError,
|
|
26
|
+
assertLeasedTaskUp,
|
|
27
|
+
type TaskDownResult,
|
|
28
|
+
type TaskLaunchInput,
|
|
29
|
+
type TaskUpCredentials,
|
|
30
|
+
type TaskUpResult,
|
|
31
|
+
} from "../agent";
|
|
32
|
+
import { injectCodexIntoMachine } from "../codex-auth";
|
|
33
|
+
import {
|
|
34
|
+
ensureMachineGatewayTunnelReady,
|
|
35
|
+
MACHINE_GATEWAY_TUNNEL_PORT,
|
|
36
|
+
stopMachineGatewayTunnel,
|
|
37
|
+
} from "../machine-gateway-tunnel";
|
|
38
|
+
import {
|
|
39
|
+
installMachineProjectEnvLoader,
|
|
40
|
+
installMachineTaskForwarderAsset,
|
|
41
|
+
} from "../machine-project-env-loader";
|
|
42
|
+
import {
|
|
43
|
+
MACHINE_RUNTIME_AUTHORITY_ENV,
|
|
44
|
+
PROJECT_ENV_HOOK_PATH,
|
|
45
|
+
} from "../runtime-authority";
|
|
46
|
+
import { sharedRoot } from "../shared-files";
|
|
47
|
+
import { launchTaskEnvForwarder } from "../task-env-forwarder";
|
|
48
|
+
import { MAX_ENV_SCHEMA_BYTES } from "../env-schema";
|
|
49
|
+
import {
|
|
50
|
+
captureTaskEnvSchemaFromClone,
|
|
51
|
+
captureTaskEnvSchemas,
|
|
52
|
+
taskEnvSchemaObservation,
|
|
53
|
+
type RemoteGit,
|
|
54
|
+
} from "../project-env-schema-capture";
|
|
55
|
+
import { activeSecretsProxy } from "../secrets-proxy/host";
|
|
56
|
+
import { reconcileSidecarTaskProxy } from "../secrets-proxy/task-trust";
|
|
57
|
+
import { TASK_FORWARDER_SECRET_BYTES } from "../task-gateway-protocol";
|
|
58
|
+
import {
|
|
59
|
+
MACHINE_TASK_ENVIRONMENT_PROVIDER,
|
|
60
|
+
parseMachineTaskEnvironmentLocator,
|
|
61
|
+
type MachineTaskInput,
|
|
62
|
+
} from "./machine";
|
|
63
|
+
import type {
|
|
64
|
+
TaskEnvironmentCallOptions,
|
|
65
|
+
ProjectEnvMode,
|
|
66
|
+
TaskEnvironmentDescriptor,
|
|
67
|
+
TaskEnvironmentHandle,
|
|
68
|
+
TaskEnvironmentProcess,
|
|
69
|
+
TaskEnvironmentProvider,
|
|
70
|
+
TaskEnvironmentProvisionRequest,
|
|
71
|
+
TaskEnvironmentProvisioned,
|
|
72
|
+
} from "./types";
|
|
73
|
+
import { TASK_SSH_KEY_PATH } from "../credential-consumers/github";
|
|
74
|
+
|
|
75
|
+
/** In-machine clone budget per project. */
|
|
76
|
+
const CLONE_TIMEOUT_MS = 10 * 60_000;
|
|
77
|
+
/** The machine port code-server listens on (dialed directly by the editor
|
|
78
|
+
* tunnel via inspectRoute; never published). */
|
|
79
|
+
export const MACHINE_CODE_SERVER_PORT = 8080;
|
|
80
|
+
const GIT_STEP_TIMEOUT_MS = 60_000;
|
|
81
|
+
const STEP_OUTPUT_BYTES = 256 * 1024;
|
|
82
|
+
|
|
83
|
+
export interface MachineHostTaskUpDeps {
|
|
84
|
+
/** The pure machine environment provider (createMachineTaskEnvironmentProvider). */
|
|
85
|
+
environment: TaskEnvironmentProvider<
|
|
86
|
+
MachineTaskInput,
|
|
87
|
+
void,
|
|
88
|
+
TaskEnvironmentDescriptor
|
|
89
|
+
>;
|
|
90
|
+
/** Host-side control dir for the task (holds the machine key). */
|
|
91
|
+
taskControlDir(taskId: string): string;
|
|
92
|
+
machineImage(): string;
|
|
93
|
+
machineCpus(): number;
|
|
94
|
+
machineMemoryMiB(): number;
|
|
95
|
+
/** macOS-local machines need loopback-published ssh; cloud machines don't. */
|
|
96
|
+
publishSsh(): boolean;
|
|
97
|
+
/** The task owner's GitHub access token, or null when GitHub is not
|
|
98
|
+
* connected (public/anonymous clone fallback). */
|
|
99
|
+
githubToken(userId: string): Promise<{ accessToken: string } | null>;
|
|
100
|
+
/** ADR-133: how this environment gets its shared files. Default = the
|
|
101
|
+
* point-in-time copy from the host's shared directory (machines over
|
|
102
|
+
* ssh); the sidecar mounts them from S3 instead. */
|
|
103
|
+
sharedFiles?: (
|
|
104
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
105
|
+
workspacePath: string,
|
|
106
|
+
task: { ownerOrgId?: string; ownerUserId: string; sharedFiles?: string },
|
|
107
|
+
) => Promise<string | null>;
|
|
108
|
+
/** Test seam for the host-side persistent reverse-tunnel supervisor. */
|
|
109
|
+
ensureGatewayTunnel?: typeof ensureMachineGatewayTunnelReady;
|
|
110
|
+
/** How long a start that is not one waits for the stages it left running
|
|
111
|
+
* (tests). */
|
|
112
|
+
abandonJoinMs?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function startMachineTaskForwarder(
|
|
116
|
+
taskId: string,
|
|
117
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
118
|
+
credentials: TaskUpCredentials,
|
|
119
|
+
ensureGatewayTunnel: typeof ensureMachineGatewayTunnelReady,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const gatewayTunnelSshArgs = handle.gatewayTunnelSshArgs?.bind(handle);
|
|
122
|
+
if (!gatewayTunnelSshArgs) {
|
|
123
|
+
// The sidecar gateway is in this root process and assigns loopback
|
|
124
|
+
// traffic to its one fixed task. Binding another listener would collide.
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const identity = credentials.taskGateway;
|
|
128
|
+
if (
|
|
129
|
+
!identity ||
|
|
130
|
+
!/^[A-Za-z0-9_-]{16,64}$/.test(identity.generation) ||
|
|
131
|
+
identity.secret.byteLength !== TASK_FORWARDER_SECRET_BYTES
|
|
132
|
+
) {
|
|
133
|
+
throw new Error("machine task env gateway identity is unavailable");
|
|
134
|
+
}
|
|
135
|
+
await installMachineTaskForwarderAsset(handle);
|
|
136
|
+
|
|
137
|
+
await launchTaskEnvForwarder(
|
|
138
|
+
handle,
|
|
139
|
+
{ taskId, generation: identity.generation },
|
|
140
|
+
Uint8Array.from(identity.secret),
|
|
141
|
+
{
|
|
142
|
+
gatewayHost: "127.0.0.1",
|
|
143
|
+
gatewayPort: MACHINE_GATEWAY_TUNNEL_PORT,
|
|
144
|
+
nodePath: "/usr/bin/node",
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Start this during task-up rather than waiting for the first agent. The
|
|
149
|
+
// editor can open a terminal before any roster process exists, and its
|
|
150
|
+
// first command must already be able to refresh through the forwarder.
|
|
151
|
+
await ensureGatewayTunnel(
|
|
152
|
+
taskId,
|
|
153
|
+
{ gatewayTunnelSshArgs },
|
|
154
|
+
MACHINE_GATEWAY_TUNNEL_PORT,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Mirrors src/index.ts isGithubRepo — host-side predicate only. */
|
|
159
|
+
export function isGithubRepoUrl(input: string): boolean {
|
|
160
|
+
const value = input.trim();
|
|
161
|
+
if (/^(?:[^@/]+@)?(?:www\.)?github\.com:/i.test(value)) return true;
|
|
162
|
+
if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) return true;
|
|
163
|
+
try {
|
|
164
|
+
const url = new URL(value);
|
|
165
|
+
return /^(?:www\.)?github\.com$/i.test(url.hostname);
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Canonical HTTPS form for GitHub repos (shorthand and ssh forms included);
|
|
172
|
+
* non-GitHub URLs pass through untouched. */
|
|
173
|
+
export function machineCloneUrl(repoUrl: string): string {
|
|
174
|
+
const value = repoUrl.trim();
|
|
175
|
+
const ssh = /^(?:[^@/]+@)?(?:www\.)?github\.com:(.+)$/i.exec(value);
|
|
176
|
+
if (ssh?.[1]) {
|
|
177
|
+
return `https://github.com/${ssh[1].replace(/\.git$/i, "")}.git`;
|
|
178
|
+
}
|
|
179
|
+
if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) {
|
|
180
|
+
return `https://github.com/${value.replace(/\/+$/, "").replace(/\.git$/i, "")}.git`;
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Workspace-safe project directory name. The slug is cloud-normalized, but
|
|
186
|
+
* the machine path re-checks: a path-capable slug must never reach argv. */
|
|
187
|
+
function projectDirectoryName(slug: string): string {
|
|
188
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug) || slug.includes("..")) {
|
|
189
|
+
throw new Error(`unsafe project slug for machine clone: ${slug}`);
|
|
190
|
+
}
|
|
191
|
+
// Files v1 publishes the task's leased S3 roots at this fixed workspace
|
|
192
|
+
// entry. Refuse a project with the same name before clone can follow the
|
|
193
|
+
// published link and write repository data into the Files bucket.
|
|
194
|
+
if (slug === "files") {
|
|
195
|
+
throw new Error("the project slug files is reserved for shared files");
|
|
196
|
+
}
|
|
197
|
+
return slug;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface StepResult {
|
|
201
|
+
exitCode: number | null;
|
|
202
|
+
stderr: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One-shot exec with stdin: spawn, deliver the secret, drain, await exit.
|
|
206
|
+
* The secret rides the ssh stream only — never argv, never the persisted
|
|
207
|
+
* env, never machine disk. */
|
|
208
|
+
async function execWithStdin(
|
|
209
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
210
|
+
argv: readonly [string, ...string[]],
|
|
211
|
+
stdin: string,
|
|
212
|
+
timeoutMs: number,
|
|
213
|
+
/** Aborted: the process is ended at once (ADR-154 §12, a withdrawn start). */
|
|
214
|
+
signal?: AbortSignal,
|
|
215
|
+
user?: string,
|
|
216
|
+
): Promise<StepResult> {
|
|
217
|
+
if (signal?.aborted) throw new Error(WITHDRAWN);
|
|
218
|
+
const proc = await handle.spawn({
|
|
219
|
+
argv,
|
|
220
|
+
...(user === undefined ? {} : { user }),
|
|
221
|
+
projectEnvProfile: "maintenance",
|
|
222
|
+
inheritEnv: [],
|
|
223
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
224
|
+
timeoutMs,
|
|
225
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
226
|
+
});
|
|
227
|
+
const onAbort = (): void => void proc.terminate("SIGKILL").catch(() => {});
|
|
228
|
+
if (signal?.aborted) onAbort();
|
|
229
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
230
|
+
try {
|
|
231
|
+
return await drainStep(proc, stdin);
|
|
232
|
+
} finally {
|
|
233
|
+
signal?.removeEventListener("abort", onAbort);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function drainStep(proc: TaskEnvironmentProcess, stdin: string): Promise<StepResult> {
|
|
238
|
+
let stderr = "";
|
|
239
|
+
const drainOut = (async () => {
|
|
240
|
+
for await (const chunk of proc.stdout) {
|
|
241
|
+
void chunk; // drained; clone progress is not retained
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
const drainErr = (async () => {
|
|
245
|
+
for await (const chunk of proc.stderr) {
|
|
246
|
+
stderr = (stderr + Buffer.from(chunk).toString("utf8")).slice(-8192);
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
await proc.write(Buffer.from(stdin));
|
|
250
|
+
await proc.closeInput();
|
|
251
|
+
const exit = await proc.completion;
|
|
252
|
+
await Promise.allSettled([drainOut, drainErr]);
|
|
253
|
+
return { exitCode: exit.exitCode, stderr: stderr.trim() };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function execStep(
|
|
257
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
258
|
+
argv: readonly [string, ...string[]],
|
|
259
|
+
timeoutMs: number,
|
|
260
|
+
): Promise<StepResult> {
|
|
261
|
+
const result = await handle.exec({
|
|
262
|
+
argv,
|
|
263
|
+
projectEnvProfile: "maintenance",
|
|
264
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
265
|
+
timeoutMs,
|
|
266
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
exitCode: result.exitCode,
|
|
270
|
+
stderr: Buffer.from(result.stderr).toString("utf8").trim().slice(-8192),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The remote clone script for the credentialed path. The token is read from
|
|
275
|
+
* stdin into the clone process env; /usr/local/bin/uai-git-askpass (baked in
|
|
276
|
+
* the machine image) answers git's prompts from it. */
|
|
277
|
+
const CREDENTIALED_CLONE_SCRIPT =
|
|
278
|
+
'IFS= read -r UAI_GIT_TOKEN && export UAI_GIT_TOKEN GIT_ASKPASS=/usr/local/bin/uai-git-askpass GIT_TERMINAL_PROMPT=0 && exec git clone -- "$1" "$2"';
|
|
279
|
+
|
|
280
|
+
const WITHDRAWN = "the task's credentials were withdrawn while it was starting; start the task again";
|
|
281
|
+
|
|
282
|
+
/** The task's user on a machine, and in sidecar mode. */
|
|
283
|
+
const MACHINE_TASK_USER = "node";
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* ADR-154 §12: a task on cloud credentials clones by exactly the route its
|
|
287
|
+
* lease names, with nothing of the machine's own and nothing the task could
|
|
288
|
+
* have put in its own login:
|
|
289
|
+
* - the clone starts as root, whose login is not the task's: a profile the
|
|
290
|
+
* task wrote could otherwise read the token off stdin before anything
|
|
291
|
+
* else, or put its own `env`, `git`, `GIT_EXEC_PATH` or `LD_PRELOAD` in
|
|
292
|
+
* the clone's way;
|
|
293
|
+
* - in an environment rebuilt from nothing (`env -i`): a fixed PATH and no
|
|
294
|
+
* home (so no .netrc, ~/.gitconfig or ~/.ssh/config), no global or system
|
|
295
|
+
* git configuration;
|
|
296
|
+
* - then as the task's user (`setpriv`, when root), in a shell that reads no
|
|
297
|
+
* profile. It alone reads the token from stdin, and runs git and ssh by
|
|
298
|
+
* absolute path, with no credential helper and no agent.
|
|
299
|
+
*/
|
|
300
|
+
const LEASED_CLONE_ENV = [
|
|
301
|
+
"PATH=/usr/bin:/bin",
|
|
302
|
+
"HOME=/nonexistent",
|
|
303
|
+
"XDG_CONFIG_HOME=/nonexistent",
|
|
304
|
+
"LC_ALL=C",
|
|
305
|
+
"GIT_CONFIG_NOSYSTEM=1",
|
|
306
|
+
"GIT_CONFIG_GLOBAL=/dev/null",
|
|
307
|
+
"GIT_TERMINAL_PROMPT=0",
|
|
308
|
+
] as const;
|
|
309
|
+
/** Root drops to the task's user; anyone else (a non-root dev sidecar, whose
|
|
310
|
+
* user is the task's) stays. The route's script arrives as $1, never on
|
|
311
|
+
* stdin, which only it reads. */
|
|
312
|
+
const LEASED_CLONE_DROP =
|
|
313
|
+
'if [ "$(/usr/bin/id -u)" = 0 ]; then ' +
|
|
314
|
+
`exec /usr/bin/setpriv --reuid=${MACHINE_TASK_USER} --regid=${MACHINE_TASK_USER} --init-groups -- /bin/sh -c "$1" clone "$2" "$3"; ` +
|
|
315
|
+
'fi; exec /bin/sh -c "$1" clone "$2" "$3"';
|
|
316
|
+
const LEASED_GIT = "exec /usr/bin/git -c credential.helper= -c core.hooksPath=/dev/null";
|
|
317
|
+
const LEASED_TOKEN_CLONE_SCRIPT =
|
|
318
|
+
'IFS= read -r UAI_GIT_TOKEN && export UAI_GIT_TOKEN GIT_ASKPASS=/usr/local/bin/uai-git-askpass && ' +
|
|
319
|
+
`${LEASED_GIT} clone -- "$1" "$2"`;
|
|
320
|
+
const LEASED_SSH_CLONE_SCRIPT =
|
|
321
|
+
`export GIT_SSH_COMMAND="/usr/bin/ssh -F /dev/null -i ${TASK_SSH_KEY_PATH} -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/home/node/.ssh/known_hosts" && ` +
|
|
322
|
+
`${LEASED_GIT} -c url.git@github.com:.insteadOf=https://github.com/ clone -- "$1" "$2"`;
|
|
323
|
+
const LEASED_ANONYMOUS_CLONE_SCRIPT = `${LEASED_GIT} clone -- "$1" "$2"`;
|
|
324
|
+
|
|
325
|
+
/** The leased clone's command, run as root (see above). */
|
|
326
|
+
export function leasedCloneArgv(script: string, url: string, destination: string): [string, ...string[]] {
|
|
327
|
+
return ["/usr/bin/env", "-i", ...LEASED_CLONE_ENV, "/bin/sh", "-c", LEASED_CLONE_DROP, "drop", script, url, destination];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** How a task on cloud credentials clones: its lease's token, its SSH key,
|
|
331
|
+
* or neither (it names neither). */
|
|
332
|
+
interface LeasedCloneRoute {
|
|
333
|
+
ssh: boolean;
|
|
334
|
+
signal: AbortSignal;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const DENIED =
|
|
338
|
+
/repository not found|permission denied|access rights|could not read username|authentication failed|invalid username or password|returned error: 40[134]|HTTP 40[134]/i;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A fresh clone by a leased route that failed: the start fails and the
|
|
342
|
+
* machine goes (unlike the legacy route's warning, ADR-121), saying which of
|
|
343
|
+
* the owner's credentials GitHub refused and what to do about it.
|
|
344
|
+
*/
|
|
345
|
+
function leasedCloneFailure(repoUrl: string, route: "token" | "ssh" | "none", stderr: string): AgentError {
|
|
346
|
+
const step = "clone";
|
|
347
|
+
if (isGithubRepoUrl(repoUrl) && DENIED.test(stderr)) {
|
|
348
|
+
if (route === "token") {
|
|
349
|
+
return new AgentError(
|
|
350
|
+
"GITHUB_TOKEN_ACCESS_DENIED",
|
|
351
|
+
`GitHub denied the task owner's GitHub sign-in access to ${repoUrl}. 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.`,
|
|
352
|
+
{ step },
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (route === "ssh") {
|
|
356
|
+
return new AgentError(
|
|
357
|
+
"GITHUB_SSH_ACCESS_DENIED",
|
|
358
|
+
`GitHub denied the task owner's SSH key access to ${repoUrl}. 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.`,
|
|
359
|
+
{ step },
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return new AgentError(
|
|
363
|
+
"GITHUB_CONNECTION_REQUIRED",
|
|
364
|
+
`The task owner has neither a GitHub sign-in nor an SSH key in Moondeck that can read ${repoUrl}. Connect GitHub in Settings, then start the task again.`,
|
|
365
|
+
{ step },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return new AgentError("CLONE_FAILED", `could not clone ${repoUrl}: ${stderr || "clone failed"}`, { step });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
type ProjectEnvSchemaObservation = NonNullable<TaskUpResult["envSchemas"]>[number];
|
|
372
|
+
|
|
373
|
+
/** What a project's clone step did: cloned it now, found the task's own
|
|
374
|
+
* clone already there (a resume), or could not clone it; and what to say. */
|
|
375
|
+
interface CloneOutcome {
|
|
376
|
+
clone: "fresh" | "existing" | "failed";
|
|
377
|
+
warning: string | null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function cloneProject(
|
|
381
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
382
|
+
destination: string,
|
|
383
|
+
project: { slug: string; repoUrl: string },
|
|
384
|
+
taskBranch: string,
|
|
385
|
+
owner: { name?: string; email?: string },
|
|
386
|
+
token: string | null,
|
|
387
|
+
/** A task on cloud credentials: its exact route, and its withdrawal. */
|
|
388
|
+
leased?: LeasedCloneRoute,
|
|
389
|
+
): Promise<CloneOutcome> {
|
|
390
|
+
const url = machineCloneUrl(project.repoUrl);
|
|
391
|
+
const useToken = token !== null && isGithubRepoUrl(project.repoUrl);
|
|
392
|
+
|
|
393
|
+
// Resume/retry idempotency: a repository that already exists is the task's
|
|
394
|
+
// live work — never re-clone over it, and leave its branch state alone.
|
|
395
|
+
const already = await execStep(
|
|
396
|
+
handle,
|
|
397
|
+
["/bin/sh", "-c", 'test -e "$1/.git"', "check", destination],
|
|
398
|
+
GIT_STEP_TIMEOUT_MS,
|
|
399
|
+
);
|
|
400
|
+
if (already.exitCode === 0) return { clone: "existing", warning: null };
|
|
401
|
+
// Withdrawn while the probe ran: the clone never starts.
|
|
402
|
+
if (leased?.signal.aborted) throw new Error(WITHDRAWN);
|
|
403
|
+
|
|
404
|
+
const route = useToken ? "token" : leased?.ssh && isGithubRepoUrl(project.repoUrl) ? "ssh" : "none";
|
|
405
|
+
const clone = leased
|
|
406
|
+
? await execWithStdin(
|
|
407
|
+
handle,
|
|
408
|
+
leasedCloneArgv(
|
|
409
|
+
route === "token" ? LEASED_TOKEN_CLONE_SCRIPT : route === "ssh" ? LEASED_SSH_CLONE_SCRIPT : LEASED_ANONYMOUS_CLONE_SCRIPT,
|
|
410
|
+
url,
|
|
411
|
+
destination,
|
|
412
|
+
),
|
|
413
|
+
useToken ? `${token}\n` : "",
|
|
414
|
+
CLONE_TIMEOUT_MS,
|
|
415
|
+
leased.signal,
|
|
416
|
+
"root",
|
|
417
|
+
)
|
|
418
|
+
: useToken
|
|
419
|
+
? await execWithStdin(
|
|
420
|
+
handle,
|
|
421
|
+
["/bin/sh", "-c", CREDENTIALED_CLONE_SCRIPT, "clone", url, destination],
|
|
422
|
+
`${token}\n`,
|
|
423
|
+
CLONE_TIMEOUT_MS,
|
|
424
|
+
)
|
|
425
|
+
: await execStep(
|
|
426
|
+
handle,
|
|
427
|
+
["git", "clone", "--", url, destination],
|
|
428
|
+
CLONE_TIMEOUT_MS,
|
|
429
|
+
);
|
|
430
|
+
if (leased?.signal.aborted) throw new Error(WITHDRAWN);
|
|
431
|
+
if (clone.exitCode !== 0 && leased) throw leasedCloneFailure(project.repoUrl, route, clone.stderr);
|
|
432
|
+
if (clone.exitCode !== 0) {
|
|
433
|
+
// The commonest failure by far is an ANONYMOUS clone of a private GitHub
|
|
434
|
+
// repo because this host has no GitHub credential for the task owner —
|
|
435
|
+
// git's own message ("could not read Username … terminal prompts
|
|
436
|
+
// disabled") explains none of that. Say the actionable thing.
|
|
437
|
+
if (token === null && isGithubRepoUrl(project.repoUrl)) {
|
|
438
|
+
return {
|
|
439
|
+
clone: "failed",
|
|
440
|
+
warning:
|
|
441
|
+
`could not clone ${project.repoUrl} for ${project.slug}: GitHub is ` +
|
|
442
|
+
"not connected on this host. Connect GitHub on the host's page " +
|
|
443
|
+
"(Connections → GitHub), then Resume.",
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return { clone: "failed", warning: `could not clone ${project.repoUrl} for ${project.slug}: ${clone.stderr || "clone failed"}` };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Task branch off the clone's default HEAD (matches the compose path's
|
|
450
|
+
// `-b <branch> origin/<default>`). -B keeps an empty-repo clone (unborn
|
|
451
|
+
// HEAD) and a re-run both working; failure degrades, never fails the task.
|
|
452
|
+
const branch = await execStep(
|
|
453
|
+
handle,
|
|
454
|
+
["git", "-C", destination, "checkout", "-B", taskBranch],
|
|
455
|
+
GIT_STEP_TIMEOUT_MS,
|
|
456
|
+
);
|
|
457
|
+
if (branch.exitCode !== 0) {
|
|
458
|
+
return { clone: "fresh", warning: `cloned ${project.slug} but could not create branch ${taskBranch}: ${branch.stderr}` };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Repo-local commit identity so agent commits work out of the box.
|
|
462
|
+
if (owner.name) {
|
|
463
|
+
await execStep(
|
|
464
|
+
handle,
|
|
465
|
+
["git", "-C", destination, "config", "user.name", owner.name],
|
|
466
|
+
GIT_STEP_TIMEOUT_MS,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (owner.email) {
|
|
470
|
+
await execStep(
|
|
471
|
+
handle,
|
|
472
|
+
["git", "-C", destination, "config", "user.email", owner.email],
|
|
473
|
+
GIT_STEP_TIMEOUT_MS,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return { clone: "fresh", warning: null };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Git, read-only, in a fresh clone on the machine, as the task's user (the
|
|
480
|
+
* way the clone ran) but with nothing of that user's environment: no
|
|
481
|
+
* profile variable, PATH entry or global config can point it elsewhere. */
|
|
482
|
+
function gitInClone(handle: TaskEnvironmentHandle<unknown>, destination: string): RemoteGit {
|
|
483
|
+
return async (args) => {
|
|
484
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
485
|
+
const result = await handle.exec({
|
|
486
|
+
argv: [
|
|
487
|
+
"/usr/bin/env",
|
|
488
|
+
"-i",
|
|
489
|
+
"PATH=/usr/bin:/bin",
|
|
490
|
+
"HOME=/nonexistent",
|
|
491
|
+
"LC_ALL=C",
|
|
492
|
+
"GIT_CONFIG_NOSYSTEM=1",
|
|
493
|
+
"GIT_CONFIG_GLOBAL=/dev/null",
|
|
494
|
+
"GIT_TERMINAL_PROMPT=0",
|
|
495
|
+
"/usr/bin/git",
|
|
496
|
+
"--no-replace-objects",
|
|
497
|
+
"-C",
|
|
498
|
+
destination,
|
|
499
|
+
"-c",
|
|
500
|
+
"core.hooksPath=/dev/null",
|
|
501
|
+
"-c",
|
|
502
|
+
"core.fsmonitor=false",
|
|
503
|
+
...args,
|
|
504
|
+
],
|
|
505
|
+
projectEnvProfile: "maintenance",
|
|
506
|
+
timeoutMs: GIT_STEP_TIMEOUT_MS,
|
|
507
|
+
maxOutputBytes: MAX_ENV_SCHEMA_BYTES + 64 * 1024,
|
|
508
|
+
});
|
|
509
|
+
// A process that did not run to its end says nothing about the clone.
|
|
510
|
+
if (result.exitCode !== null) {
|
|
511
|
+
return result.exitCode === 0 && !result.stdoutTruncated ? Buffer.from(result.stdout) : null;
|
|
512
|
+
}
|
|
513
|
+
if (attempt >= 3) return null;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* ADR-153 §4, "Machine and sidecar": each project's schema observation,
|
|
520
|
+
* stored where the task cannot write before anything in it starts, and the
|
|
521
|
+
* same one reported by every task-up of the task (the cloud keeps the first).
|
|
522
|
+
* Only a clone made now, on a machine where nothing of the task has run, is
|
|
523
|
+
* read. A resumed clone is the task's live work, and a clone that failed is
|
|
524
|
+
* retried on a resume after the task's processes have run, so neither is
|
|
525
|
+
* ever read: with nothing stored, the base is missing, for the task's life.
|
|
526
|
+
* A failure here withholds that project's unbound keys, never fails the
|
|
527
|
+
* start.
|
|
528
|
+
*/
|
|
529
|
+
async function captureMachineEnvSchemas(
|
|
530
|
+
taskId: string,
|
|
531
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
532
|
+
clones: ReadonlyArray<{ projectId: string; destination: string; clone: CloneOutcome["clone"] }>,
|
|
533
|
+
): Promise<ProjectEnvSchemaObservation[]> {
|
|
534
|
+
const observations: ProjectEnvSchemaObservation[] = [];
|
|
535
|
+
for (const { projectId, destination, clone } of clones) {
|
|
536
|
+
try {
|
|
537
|
+
const stored = taskEnvSchemaObservation(taskId, projectId);
|
|
538
|
+
observations.push(
|
|
539
|
+
stored ??
|
|
540
|
+
(clone === "fresh"
|
|
541
|
+
? await captureTaskEnvSchemaFromClone(taskId, projectId, gitInClone(handle, destination))
|
|
542
|
+
: // No base is ever recorded on a machine: this stores it missing.
|
|
543
|
+
captureTaskEnvSchemas(taskId, [projectId])[0]!),
|
|
544
|
+
);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.warn(
|
|
547
|
+
`[machine] task ${taskId}: .env.schema capture failed for ${projectId}: ${
|
|
548
|
+
error instanceof Error ? error.message : String(error)
|
|
549
|
+
}`,
|
|
550
|
+
);
|
|
551
|
+
observations.push(captureTaskEnvSchemas(taskId, [projectId])[0]!);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return observations;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The curated code-server defaults (slim chrome, telemetry off, trust
|
|
558
|
+
* off) — the SAME seed the standard image bakes; resolved from this package
|
|
559
|
+
* so machine and container editors can never diverge. Machines adjust two
|
|
560
|
+
* keys host-side: the task's ADR-091 theme, and bash for the terminal (the
|
|
561
|
+
* machine payload carries no zsh). */
|
|
562
|
+
function editorSettingsSeed(editorTheme: string | undefined): string {
|
|
563
|
+
const seedPath = resolve(
|
|
564
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
565
|
+
"..",
|
|
566
|
+
"..",
|
|
567
|
+
"images",
|
|
568
|
+
"standard",
|
|
569
|
+
"container",
|
|
570
|
+
"code-server-settings.json",
|
|
571
|
+
);
|
|
572
|
+
const seed = JSON.parse(readFileSync(seedPath, "utf8")) as Record<
|
|
573
|
+
string,
|
|
574
|
+
unknown
|
|
575
|
+
>;
|
|
576
|
+
if (editorTheme) seed["workbench.colorTheme"] = editorTheme;
|
|
577
|
+
seed["terminal.integrated.defaultProfile.linux"] = "bash";
|
|
578
|
+
return `${JSON.stringify(seed, null, 2)}\n`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Seed the editor settings exactly once (an existing settings.json is the
|
|
582
|
+
* user's own state and is never touched — same contract as uai-init). */
|
|
583
|
+
async function seedEditorSettings(
|
|
584
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
585
|
+
editorTheme: string | undefined,
|
|
586
|
+
): Promise<void> {
|
|
587
|
+
const destination =
|
|
588
|
+
"/home/node/.local/share/code-server/User/settings.json";
|
|
589
|
+
await handle.exec({
|
|
590
|
+
argv: [
|
|
591
|
+
"/bin/sh",
|
|
592
|
+
"-c",
|
|
593
|
+
'[ -e "$1" ] && exit 0; mkdir -p "$(dirname "$1")" && cat > "$1"',
|
|
594
|
+
"seed",
|
|
595
|
+
destination,
|
|
596
|
+
],
|
|
597
|
+
projectEnvProfile: "maintenance",
|
|
598
|
+
stdin: Buffer.from(editorSettingsSeed(editorTheme)),
|
|
599
|
+
timeoutMs: GIT_STEP_TIMEOUT_MS,
|
|
600
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const execFileAsync = promisify(execFile);
|
|
605
|
+
/** Shared-files snapshots are reference docs, not repos; a tree past this
|
|
606
|
+
* is skipped with a warning rather than ballooning host memory. */
|
|
607
|
+
const SHARED_FILES_TAR_MAX_BYTES = 256 * 1024 * 1024;
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* ADR-062 shared files on machines: containers get live bind mounts at
|
|
611
|
+
* /workspace/files/{org,me}; a machine shares no filesystem, so it gets a
|
|
612
|
+
* POINT-IN-TIME COPY at provision (tar streamed over the transport). The
|
|
613
|
+
* managed-hosting answer for live semantics is EFS (ADR-121); until then a
|
|
614
|
+
* snapshot with an honest note beats silently absent files.
|
|
615
|
+
* Returns a warning string when rw mode was requested (writes cannot sync
|
|
616
|
+
* back) or a scope was skipped; null when clean.
|
|
617
|
+
*/
|
|
618
|
+
async function copySharedFiles(
|
|
619
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
620
|
+
workspacePath: string,
|
|
621
|
+
task: { ownerOrgId?: string; ownerUserId: string; sharedFiles?: string },
|
|
622
|
+
): Promise<string | null> {
|
|
623
|
+
const mode = task.sharedFiles ?? "ro";
|
|
624
|
+
if (mode === "off") return null;
|
|
625
|
+
const scopes: Array<{ host: string; destination: string }> = [];
|
|
626
|
+
if (task.ownerOrgId) {
|
|
627
|
+
scopes.push({
|
|
628
|
+
host: sharedRoot("org", task.ownerOrgId, task.ownerUserId),
|
|
629
|
+
destination: `${workspacePath}/files/org`,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
scopes.push({
|
|
633
|
+
host: sharedRoot("me", task.ownerOrgId ?? "", task.ownerUserId),
|
|
634
|
+
destination: `${workspacePath}/files/me`,
|
|
635
|
+
});
|
|
636
|
+
const warnings: string[] = [];
|
|
637
|
+
let copiedAny = false;
|
|
638
|
+
for (const scope of scopes) {
|
|
639
|
+
if (!existsSync(scope.host) || readdirSync(scope.host).length === 0) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
const tarball = await execFileAsync(
|
|
644
|
+
"tar",
|
|
645
|
+
["-C", scope.host, "-cf", "-", "."],
|
|
646
|
+
{ encoding: "buffer", maxBuffer: SHARED_FILES_TAR_MAX_BYTES },
|
|
647
|
+
);
|
|
648
|
+
const result = await handle.exec({
|
|
649
|
+
argv: [
|
|
650
|
+
"/bin/sh",
|
|
651
|
+
"-c",
|
|
652
|
+
'mkdir -p "$1" && tar -C "$1" -xf -',
|
|
653
|
+
"shared",
|
|
654
|
+
scope.destination,
|
|
655
|
+
],
|
|
656
|
+
projectEnvProfile: "maintenance",
|
|
657
|
+
stdin: tarball.stdout,
|
|
658
|
+
timeoutMs: 5 * 60_000,
|
|
659
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
660
|
+
});
|
|
661
|
+
if (result.exitCode !== 0) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
`extract exited ${result.exitCode}: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
copiedAny = true;
|
|
667
|
+
} catch (error) {
|
|
668
|
+
warnings.push(
|
|
669
|
+
`shared files: could not copy ${scope.destination}: ${
|
|
670
|
+
error instanceof Error ? error.message : String(error)
|
|
671
|
+
}`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (copiedAny || warnings.length === 0) {
|
|
676
|
+
// Marker gates the "## Shared files" preamble briefing, mirroring the
|
|
677
|
+
// container path's task-up marker.
|
|
678
|
+
await handle
|
|
679
|
+
.writeWorkspaceFile(`${workspacePath}/.moondeck/files-mounted`, Buffer.from(""))
|
|
680
|
+
.catch(() => {});
|
|
681
|
+
}
|
|
682
|
+
if (mode === "rw") {
|
|
683
|
+
warnings.push(
|
|
684
|
+
"shared files on this machine task are a point-in-time copy — " +
|
|
685
|
+
"changes under /workspace/files will NOT sync back to the host yet",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return warnings.length > 0 ? warnings.join("\n") : null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Launch code-server for the Editor pane, exactly once. The pgrep guard
|
|
692
|
+
* makes re-provision and resume idempotent; the exec chain leaves only
|
|
693
|
+
* code-server holding the detached job. */
|
|
694
|
+
export async function ensureCodeServer(
|
|
695
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
696
|
+
workspacePath: string,
|
|
697
|
+
editorTheme: string | undefined,
|
|
698
|
+
envMode: ProjectEnvMode = handle.descriptor.envMode ?? "baked",
|
|
699
|
+
): Promise<void> {
|
|
700
|
+
// Settings before process: code-server reads them at startup. A failed
|
|
701
|
+
// seed still launches — a themed editor is worth less than one that starts.
|
|
702
|
+
await seedEditorSettings(handle, editorTheme).catch((error: unknown) => {
|
|
703
|
+
console.warn(
|
|
704
|
+
`[machine] editor settings seed failed: ${
|
|
705
|
+
error instanceof Error ? error.message : String(error)
|
|
706
|
+
}`,
|
|
707
|
+
);
|
|
708
|
+
});
|
|
709
|
+
// The guard and the launch are SEPARATE execs on purpose: any guard that
|
|
710
|
+
// shares a cmdline with the launch text matches itself — the bracketed
|
|
711
|
+
// pattern alone did not save the first combined script, because the plain
|
|
712
|
+
// launch line later in the same argv still contained code-server +
|
|
713
|
+
// --bind-addr (live 2026-08-27, second silent skip).
|
|
714
|
+
const running = await handle.exec({
|
|
715
|
+
argv: [
|
|
716
|
+
"/bin/sh",
|
|
717
|
+
"-c",
|
|
718
|
+
'pgrep -f "[c]ode-server.*--bind-addr" >/dev/null 2>&1',
|
|
719
|
+
],
|
|
720
|
+
projectEnvProfile: "maintenance",
|
|
721
|
+
timeoutMs: GIT_STEP_TIMEOUT_MS,
|
|
722
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
723
|
+
});
|
|
724
|
+
if (running.exitCode === 0) return;
|
|
725
|
+
// Editor processes are task processes, but code-server must not inherit a
|
|
726
|
+
// real project value that a fixed-task sidecar's outer login shell loaded.
|
|
727
|
+
// Start it from an explicit empty environment and give its descendants only
|
|
728
|
+
// the host-owned runtime baseline plus the loader controls editor terminals
|
|
729
|
+
// need. The maintenance profile also prevents the provider-added outer
|
|
730
|
+
// shell from fetching project env before `env -i` gets control.
|
|
731
|
+
const editorEnvironment = {
|
|
732
|
+
...MACHINE_RUNTIME_AUTHORITY_ENV,
|
|
733
|
+
...(envMode === "loader"
|
|
734
|
+
? { BASH_ENV: PROJECT_ENV_HOOK_PATH, UAI_ENV_HOOK: "1" }
|
|
735
|
+
: {}),
|
|
736
|
+
LOGNAME: "node",
|
|
737
|
+
PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers",
|
|
738
|
+
SHELL: "/bin/bash",
|
|
739
|
+
USER: "node",
|
|
740
|
+
} as const;
|
|
741
|
+
const result = await handle.launchDetachedSession({
|
|
742
|
+
argv: [
|
|
743
|
+
"/usr/bin/env",
|
|
744
|
+
"-i",
|
|
745
|
+
...Object.entries(editorEnvironment)
|
|
746
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
747
|
+
.map(([name, value]) => `${name}=${value}`),
|
|
748
|
+
"/home/node/.local/bin/code-server",
|
|
749
|
+
"--user-data-dir",
|
|
750
|
+
"/home/node/.local/share/code-server",
|
|
751
|
+
"--disable-workspace-trust",
|
|
752
|
+
"--auth",
|
|
753
|
+
"none",
|
|
754
|
+
"--disable-telemetry",
|
|
755
|
+
"--disable-update-check",
|
|
756
|
+
"--bind-addr",
|
|
757
|
+
`0.0.0.0:${MACHINE_CODE_SERVER_PORT}`,
|
|
758
|
+
workspacePath,
|
|
759
|
+
],
|
|
760
|
+
inheritEnv: [],
|
|
761
|
+
env: {},
|
|
762
|
+
projectEnvProfile: "maintenance",
|
|
763
|
+
launchTimeoutMs: 30_000,
|
|
764
|
+
maxOutputBytes: 64 * 1024,
|
|
765
|
+
});
|
|
766
|
+
if (result.exitCode !== 0) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
`code-server launch exited ${result.exitCode}: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** How long a start that is not one waits for the stages it left running. */
|
|
774
|
+
const ABANDON_JOIN_MS = 15_000;
|
|
775
|
+
|
|
776
|
+
/** Until every one of `work` is settled, or `ms` has passed. */
|
|
777
|
+
async function joinBounded(work: readonly Promise<unknown>[], ms: number): Promise<void> {
|
|
778
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
779
|
+
await Promise.race([
|
|
780
|
+
Promise.allSettled(work),
|
|
781
|
+
new Promise<void>((resolve) => {
|
|
782
|
+
timer = setTimeout(resolve, ms);
|
|
783
|
+
timer.unref?.();
|
|
784
|
+
}),
|
|
785
|
+
]);
|
|
786
|
+
if (timer) clearTimeout(timer);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* A start's own view of its machine: every call through it, and every write
|
|
791
|
+
* to a process started through it, is refused once `over()` holds. Work the
|
|
792
|
+
* start left behind therefore never reaches the machine again, nor a
|
|
793
|
+
* replacement that reuses its name.
|
|
794
|
+
*/
|
|
795
|
+
function fencedHandle(handle: TaskEnvironmentHandle<unknown>, over: () => boolean): TaskEnvironmentHandle<unknown> {
|
|
796
|
+
const admit = (): void => {
|
|
797
|
+
if (over()) throw new Error(WITHDRAWN);
|
|
798
|
+
};
|
|
799
|
+
// Checked again by the provider at its last moment, after anything it
|
|
800
|
+
// awaits first (a machine looks up its address): a call admitted before
|
|
801
|
+
// the start ended never starts after it.
|
|
802
|
+
const lastly = (inner?: () => void) => (): void => {
|
|
803
|
+
admit();
|
|
804
|
+
inner?.();
|
|
805
|
+
};
|
|
806
|
+
const fenced = (proc: TaskEnvironmentProcess): TaskEnvironmentProcess => ({
|
|
807
|
+
stdout: proc.stdout,
|
|
808
|
+
stderr: proc.stderr,
|
|
809
|
+
completion: proc.completion,
|
|
810
|
+
write: async (chunk) => {
|
|
811
|
+
admit();
|
|
812
|
+
await proc.write(chunk);
|
|
813
|
+
},
|
|
814
|
+
closeInput: () => proc.closeInput(),
|
|
815
|
+
terminate: (signal) => proc.terminate(signal),
|
|
816
|
+
});
|
|
817
|
+
return {
|
|
818
|
+
descriptor: handle.descriptor,
|
|
819
|
+
durableIdentity: handle.durableIdentity,
|
|
820
|
+
...(handle.anchors === undefined ? {} : { anchors: handle.anchors }),
|
|
821
|
+
...(handle.credentialDelivery === undefined ? {} : { credentialDelivery: handle.credentialDelivery }),
|
|
822
|
+
readWorkspaceFile: async (path, options) => {
|
|
823
|
+
admit();
|
|
824
|
+
return handle.readWorkspaceFile(path, { ...options, admit: lastly(options?.admit) });
|
|
825
|
+
},
|
|
826
|
+
writeWorkspaceFile: async (path, bytes, options) => {
|
|
827
|
+
admit();
|
|
828
|
+
await handle.writeWorkspaceFile(path, bytes, { ...options, admit: lastly(options?.admit) });
|
|
829
|
+
},
|
|
830
|
+
exec: async (request) => {
|
|
831
|
+
admit();
|
|
832
|
+
return handle.exec({ ...request, admit: lastly(request.admit) });
|
|
833
|
+
},
|
|
834
|
+
spawn: async (request) => {
|
|
835
|
+
admit();
|
|
836
|
+
return fenced(await handle.spawn({ ...request, admit: lastly(request.admit) }));
|
|
837
|
+
},
|
|
838
|
+
spawnSession: async (request) => {
|
|
839
|
+
admit();
|
|
840
|
+
return fenced(await handle.spawnSession({ ...request, admit: lastly(request.admit) }));
|
|
841
|
+
},
|
|
842
|
+
launchDetachedSession: async (request) => {
|
|
843
|
+
admit();
|
|
844
|
+
return handle.launchDetachedSession({ ...request, admit: lastly(request.admit) });
|
|
845
|
+
},
|
|
846
|
+
copy: async (request) => {
|
|
847
|
+
admit();
|
|
848
|
+
await handle.copy({ ...request, admit: lastly(request.admit) });
|
|
849
|
+
},
|
|
850
|
+
ports: async () => {
|
|
851
|
+
admit();
|
|
852
|
+
return handle.ports();
|
|
853
|
+
},
|
|
854
|
+
status: async () => {
|
|
855
|
+
admit();
|
|
856
|
+
return handle.status();
|
|
857
|
+
},
|
|
858
|
+
recover: async (context) => {
|
|
859
|
+
admit();
|
|
860
|
+
return handle.recover(context);
|
|
861
|
+
},
|
|
862
|
+
stop: async () => {
|
|
863
|
+
admit();
|
|
864
|
+
await handle.stop();
|
|
865
|
+
},
|
|
866
|
+
teardown: async () => {
|
|
867
|
+
admit();
|
|
868
|
+
await handle.teardown();
|
|
869
|
+
},
|
|
870
|
+
...(handle.inspectRoute
|
|
871
|
+
? {
|
|
872
|
+
inspectRoute: async () => {
|
|
873
|
+
admit();
|
|
874
|
+
return handle.inspectRoute!();
|
|
875
|
+
},
|
|
876
|
+
}
|
|
877
|
+
: {}),
|
|
878
|
+
...(handle.gatewayTunnelSshArgs
|
|
879
|
+
? {
|
|
880
|
+
gatewayTunnelSshArgs: async (machinePort: number, hostPort: number, options?: TaskEnvironmentCallOptions) => {
|
|
881
|
+
admit();
|
|
882
|
+
return handle.gatewayTunnelSshArgs!(machinePort, hostPort, { ...options, admit: lastly(options?.admit) });
|
|
883
|
+
},
|
|
884
|
+
}
|
|
885
|
+
: {}),
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The identity the host records for the task's runtime: the machine id for
|
|
890
|
+
* a machine over ssh (what recovery and orphan sweeps key on), the handle's
|
|
891
|
+
* durable identity for anything else (sidecar mode: `self:<task>`). */
|
|
892
|
+
function environmentRuntimeIdentity(handle: TaskEnvironmentHandle<unknown>): string {
|
|
893
|
+
return handle.descriptor.locator.provider === MACHINE_TASK_ENVIRONMENT_PROVIDER
|
|
894
|
+
? parseMachineTaskEnvironmentLocator(handle.descriptor.locator).machineId
|
|
895
|
+
: handle.durableIdentity;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Wrap the void-teardown machine handle so registry consumers get the
|
|
899
|
+
* TaskDownResult contract the compose providers speak. */
|
|
900
|
+
function withTaskDownResult(
|
|
901
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
902
|
+
): TaskEnvironmentHandle<TaskDownResult> {
|
|
903
|
+
// Explicit delegation, never Object.create: the machine handle is a class
|
|
904
|
+
// with private fields, and a prototype-borrowed method invoked on a foreign
|
|
905
|
+
// receiver throws on its first #member access.
|
|
906
|
+
return {
|
|
907
|
+
descriptor: handle.descriptor,
|
|
908
|
+
durableIdentity: handle.durableIdentity,
|
|
909
|
+
...(handle.anchors === undefined ? {} : { anchors: handle.anchors }),
|
|
910
|
+
...(handle.credentialDelivery === undefined ? {} : { credentialDelivery: handle.credentialDelivery }),
|
|
911
|
+
readWorkspaceFile: (path, options) => handle.readWorkspaceFile(path, options),
|
|
912
|
+
writeWorkspaceFile: (path, bytes, options) => handle.writeWorkspaceFile(path, bytes, options),
|
|
913
|
+
exec: (request) => handle.exec(request),
|
|
914
|
+
spawn: (request) => handle.spawn(request),
|
|
915
|
+
spawnSession: (request) => handle.spawnSession(request),
|
|
916
|
+
launchDetachedSession: (request) => handle.launchDetachedSession(request),
|
|
917
|
+
copy: (request) => handle.copy(request),
|
|
918
|
+
ports: () => handle.ports(),
|
|
919
|
+
status: () => handle.status(),
|
|
920
|
+
recover: (context) => handle.recover(context),
|
|
921
|
+
stop: () => handle.stop(),
|
|
922
|
+
teardown: async (): Promise<TaskDownResult> => {
|
|
923
|
+
await handle.teardown();
|
|
924
|
+
return { status: "terminated" };
|
|
925
|
+
},
|
|
926
|
+
...(handle.inspectRoute
|
|
927
|
+
? { inspectRoute: () => handle.inspectRoute!() }
|
|
928
|
+
: {}),
|
|
929
|
+
...(handle.gatewayTunnelSshArgs
|
|
930
|
+
? {
|
|
931
|
+
gatewayTunnelSshArgs: (machinePort: number, hostPort: number, options?: TaskEnvironmentCallOptions) =>
|
|
932
|
+
handle.gatewayTunnelSshArgs!(machinePort, hostPort, options),
|
|
933
|
+
}
|
|
934
|
+
: {}),
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export function createMachineHostTaskEnvironmentProvider(
|
|
939
|
+
deps: MachineHostTaskUpDeps,
|
|
940
|
+
): TaskEnvironmentProvider<
|
|
941
|
+
TaskLaunchInput,
|
|
942
|
+
TaskUpCredentials,
|
|
943
|
+
TaskUpResult,
|
|
944
|
+
TaskDownResult
|
|
945
|
+
> {
|
|
946
|
+
return {
|
|
947
|
+
// The provider kind is whichever environment sits underneath: a machine
|
|
948
|
+
// over ssh, or this machine in sidecar mode (ADR-133).
|
|
949
|
+
kind: deps.environment.kind,
|
|
950
|
+
|
|
951
|
+
async provision(
|
|
952
|
+
request: TaskEnvironmentProvisionRequest<TaskLaunchInput, TaskUpCredentials>,
|
|
953
|
+
): Promise<TaskEnvironmentProvisioned<TaskUpResult, TaskDownResult>> {
|
|
954
|
+
const { input } = request;
|
|
955
|
+
const envMode = request.envMode ?? "loader";
|
|
956
|
+
// Validate every task-controlled workspace directory before the
|
|
957
|
+
// provider is allowed to create a machine or a leased runtime-ready
|
|
958
|
+
// writer can publish anything into it. Validating lazily in the clone
|
|
959
|
+
// loop is too late: an invalid later project would leave all of those
|
|
960
|
+
// earlier effects behind.
|
|
961
|
+
const projects = [...input.projects]
|
|
962
|
+
.sort((a, b) => a.position - b.position)
|
|
963
|
+
.map((project) => ({
|
|
964
|
+
project,
|
|
965
|
+
directoryName: projectDirectoryName(project.slug),
|
|
966
|
+
}));
|
|
967
|
+
// ADR-154 §12: a task on cloud credentials holds its grant for the
|
|
968
|
+
// whole start; withdrawn (its gate closed, a cleanup reached it), the
|
|
969
|
+
// start stops wherever it is, its clone included, and the machine goes.
|
|
970
|
+
const leased = request.credentials.leased;
|
|
971
|
+
assertLeasedTaskUp(leased, input.task);
|
|
972
|
+
const clone = leased?.clone;
|
|
973
|
+
const withdrawal = (): Error => new Error(WITHDRAWN);
|
|
974
|
+
if (clone?.signal.aborted) throw withdrawal();
|
|
975
|
+
const untilWithdrawn = <T>(work: Promise<T>): Promise<T> => {
|
|
976
|
+
if (!clone) return work;
|
|
977
|
+
return new Promise<T>((resolve, reject) => {
|
|
978
|
+
const onAbort = (): void => reject(withdrawal());
|
|
979
|
+
if (clone.signal.aborted) {
|
|
980
|
+
onAbort();
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
clone.signal.addEventListener("abort", onAbort, { once: true });
|
|
984
|
+
work.then(
|
|
985
|
+
(value) => {
|
|
986
|
+
clone.signal.removeEventListener("abort", onAbort);
|
|
987
|
+
resolve(value);
|
|
988
|
+
},
|
|
989
|
+
(error: unknown) => {
|
|
990
|
+
clone.signal.removeEventListener("abort", onAbort);
|
|
991
|
+
reject(error);
|
|
992
|
+
},
|
|
993
|
+
);
|
|
994
|
+
});
|
|
995
|
+
};
|
|
996
|
+
const provisioning = deps.environment.provision({
|
|
997
|
+
taskId: request.taskId,
|
|
998
|
+
input: {
|
|
999
|
+
image: deps.machineImage(),
|
|
1000
|
+
cpus: deps.machineCpus(),
|
|
1001
|
+
memoryMiB: deps.machineMemoryMiB(),
|
|
1002
|
+
...(deps.publishSsh() ? { publishSsh: true } : {}),
|
|
1003
|
+
},
|
|
1004
|
+
credentials: undefined,
|
|
1005
|
+
envMode,
|
|
1006
|
+
onPrepared: request.onPrepared,
|
|
1007
|
+
});
|
|
1008
|
+
let provisioned: Awaited<typeof provisioning>;
|
|
1009
|
+
try {
|
|
1010
|
+
provisioned = await untilWithdrawn(provisioning);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if (clone?.signal.aborted) {
|
|
1013
|
+
// Withdrawn while the machine came up: the start returns only once
|
|
1014
|
+
// that is over and the machine is gone again (the provider's own
|
|
1015
|
+
// timeouts bound the wait). A teardown left for later could take a
|
|
1016
|
+
// replacement, which reuses the task's machine name.
|
|
1017
|
+
const late = await provisioning.catch(() => null);
|
|
1018
|
+
// A reused machine holds the task's work: stopped, never destroyed.
|
|
1019
|
+
if (late) await (late.reused ? late.handle.stop() : late.handle.teardown()).catch(() => {});
|
|
1020
|
+
}
|
|
1021
|
+
throw error;
|
|
1022
|
+
}
|
|
1023
|
+
const handle = provisioned.handle;
|
|
1024
|
+
const workspacePath = handle.descriptor.workspacePath;
|
|
1025
|
+
const warnings: string[] = [];
|
|
1026
|
+
const clones: Array<{ projectId: string; destination: string; clone: CloneOutcome["clone"] }> = [];
|
|
1027
|
+
// The start's own view of the machine: once the start is withdrawn or
|
|
1028
|
+
// failed, every call through it is refused, so work it began and left
|
|
1029
|
+
// behind never reaches the machine again, nor the replacement that
|
|
1030
|
+
// reuses its name: no exec, write, secret or teardown.
|
|
1031
|
+
let over = false;
|
|
1032
|
+
const own = fencedHandle(handle, () => over || clone?.signal.aborted === true);
|
|
1033
|
+
// Every later stage races the withdrawal, through the start's last
|
|
1034
|
+
// step: withdrawn, the start stops wherever it is. What it began is
|
|
1035
|
+
// fenced off, the machine goes, and the stages still running get a
|
|
1036
|
+
// bounded chance to end before the start returns.
|
|
1037
|
+
const begun: Array<Promise<unknown>> = [];
|
|
1038
|
+
const stage = <T>(work: Promise<T>): Promise<T> => {
|
|
1039
|
+
begun.push(work);
|
|
1040
|
+
work.catch(() => {});
|
|
1041
|
+
return untilWithdrawn(work);
|
|
1042
|
+
};
|
|
1043
|
+
let abandoning: Promise<void> | null = null;
|
|
1044
|
+
const abandon = (): Promise<void> => {
|
|
1045
|
+
over = true;
|
|
1046
|
+
abandoning ??= (async () => {
|
|
1047
|
+
// Only a machine this start launched is destroyed. One it reused (a
|
|
1048
|
+
// Resume of a stopped machine) holds the task's work on its disk, so
|
|
1049
|
+
// a failed start stops it again and the next Resume tries afresh.
|
|
1050
|
+
await (provisioned.reused ? handle.stop() : handle.teardown()).catch(() => {});
|
|
1051
|
+
await joinBounded(begun, deps.abandonJoinMs ?? ABANDON_JOIN_MS);
|
|
1052
|
+
})();
|
|
1053
|
+
return abandoning;
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
try {
|
|
1057
|
+
if (clone?.signal.aborted) throw withdrawal();
|
|
1058
|
+
// ADR-154 §12: the runtime exists and nothing in it has run yet: the
|
|
1059
|
+
// runner's writes (the owner's GitHub configuration and SSH key,
|
|
1060
|
+
// recorded for cleanup) come first, so the clones below authenticate
|
|
1061
|
+
// with them, over SSH too.
|
|
1062
|
+
if (leased) {
|
|
1063
|
+
const writes = leased.runtimeReady(own, leased.clone.signal);
|
|
1064
|
+
try {
|
|
1065
|
+
await untilWithdrawn(writes);
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
// Joined before anything else happens: nothing still writes into
|
|
1068
|
+
// the machine once the start has stopped.
|
|
1069
|
+
over = true;
|
|
1070
|
+
await writes.catch(() => {});
|
|
1071
|
+
throw error;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (envMode === "loader") {
|
|
1075
|
+
await stage(installMachineProjectEnvLoader(own));
|
|
1076
|
+
// ADR-153, on a machine's own task (the sidecar's): the proxy's CA
|
|
1077
|
+
// and pins before anything in the task loads its env. Never fails
|
|
1078
|
+
// the start; its proxied keys stay withheld until they are in.
|
|
1079
|
+
const proxyWarning = await stage(
|
|
1080
|
+
reconcileSidecarTaskProxy(own, activeSecretsProxy()?.runtime ?? null, { start: true }),
|
|
1081
|
+
);
|
|
1082
|
+
if (proxyWarning !== null) console.warn(`[machine] task ${request.taskId}: ${proxyWarning}`);
|
|
1083
|
+
}
|
|
1084
|
+
const wantsGithub = projects.some(({ project }) =>
|
|
1085
|
+
isGithubRepoUrl(project.repoUrl),
|
|
1086
|
+
);
|
|
1087
|
+
// ADR-154 §12: a task on cloud credentials clones with the token its
|
|
1088
|
+
// lease holds for this start, never the runner's own store.
|
|
1089
|
+
const token = !wantsGithub
|
|
1090
|
+
? null
|
|
1091
|
+
: clone
|
|
1092
|
+
? clone.github && { accessToken: clone.github.token }
|
|
1093
|
+
: await deps.githubToken(input.task.ownerUserId);
|
|
1094
|
+
for (const { project, directoryName } of projects) {
|
|
1095
|
+
if (clone?.signal.aborted) throw withdrawal();
|
|
1096
|
+
const destination = `${workspacePath}/${directoryName}`;
|
|
1097
|
+
const outcome = await untilWithdrawn(
|
|
1098
|
+
cloneProject(
|
|
1099
|
+
own,
|
|
1100
|
+
destination,
|
|
1101
|
+
project,
|
|
1102
|
+
input.task.branch,
|
|
1103
|
+
{ name: input.ownerName, email: input.ownerEmail },
|
|
1104
|
+
token?.accessToken ?? null,
|
|
1105
|
+
clone ? { ssh: clone.ssh !== null, signal: clone.signal } : undefined,
|
|
1106
|
+
),
|
|
1107
|
+
);
|
|
1108
|
+
clones.push({ projectId: project.id, destination, clone: outcome.clone });
|
|
1109
|
+
if (outcome.warning) warnings.push(outcome.warning);
|
|
1110
|
+
}
|
|
1111
|
+
if (clone?.signal.aborted) throw withdrawal();
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
// Provisioning-infrastructure failures (ssh died, unsafe slug) tear
|
|
1114
|
+
// the machine down rather than stranding an orphan, and so does a
|
|
1115
|
+
// leased clone that failed; a legacy route's per-repo problems were
|
|
1116
|
+
// already converted to warnings above and do not land here.
|
|
1117
|
+
await abandon();
|
|
1118
|
+
throw error;
|
|
1119
|
+
}
|
|
1120
|
+
let envSchemas: ProjectEnvSchemaObservation[];
|
|
1121
|
+
try {
|
|
1122
|
+
// ADR-153 §4: right after the clones, before shared files, the
|
|
1123
|
+
// forwarder, the editor or any agent.
|
|
1124
|
+
envSchemas = await stage(captureMachineEnvSchemas(request.taskId, own, clones));
|
|
1125
|
+
|
|
1126
|
+
// ADR-062 shared files: point-in-time copy (see copySharedFiles).
|
|
1127
|
+
const sharedWarning = leased?.filesV1
|
|
1128
|
+
? null
|
|
1129
|
+
: await stage(
|
|
1130
|
+
(deps.sharedFiles ?? copySharedFiles)(own, workspacePath, {
|
|
1131
|
+
...(input.task.ownerOrgId ? { ownerOrgId: input.task.ownerOrgId } : {}),
|
|
1132
|
+
ownerUserId: input.task.ownerUserId,
|
|
1133
|
+
...(input.task.sharedFiles ? { sharedFiles: input.task.sharedFiles } : {}),
|
|
1134
|
+
}).catch((error: unknown) => {
|
|
1135
|
+
console.warn(
|
|
1136
|
+
`[machine] task ${request.taskId}: shared files copy failed: ${
|
|
1137
|
+
error instanceof Error ? error.message : String(error)
|
|
1138
|
+
}`,
|
|
1139
|
+
);
|
|
1140
|
+
return null;
|
|
1141
|
+
}),
|
|
1142
|
+
);
|
|
1143
|
+
if (sharedWarning) warnings.push(sharedWarning);
|
|
1144
|
+
|
|
1145
|
+
// Codex default-slot credentials: the legacy delivery paths are all
|
|
1146
|
+
// docker grammar; machines get theirs over the transport. Best-effort
|
|
1147
|
+
// — a failed inject degrades codex agents, never the task. A task on
|
|
1148
|
+
// cloud credentials gets its accounts from its lease, never the
|
|
1149
|
+
// runner's own (ADR-154 §6).
|
|
1150
|
+
if (!clone && input.task.agents.some((agent) => agent.kind === "codex")) {
|
|
1151
|
+
await injectCodexIntoMachine(own).catch((error: unknown) => {
|
|
1152
|
+
console.warn(
|
|
1153
|
+
`[machine] task ${request.taskId}: codex inject failed: ${
|
|
1154
|
+
error instanceof Error ? error.message : String(error)
|
|
1155
|
+
}`,
|
|
1156
|
+
);
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (envMode === "loader") {
|
|
1161
|
+
try {
|
|
1162
|
+
await stage(
|
|
1163
|
+
startMachineTaskForwarder(
|
|
1164
|
+
request.taskId,
|
|
1165
|
+
own,
|
|
1166
|
+
request.credentials,
|
|
1167
|
+
deps.ensureGatewayTunnel ?? ensureMachineGatewayTunnelReady,
|
|
1168
|
+
),
|
|
1169
|
+
);
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
// Unlike editor/auth conveniences, the loader is the only project
|
|
1172
|
+
// env delivery path. A task without it must not start and silently
|
|
1173
|
+
// hand consumers an incomplete or legacy environment.
|
|
1174
|
+
stopMachineGatewayTunnel(request.taskId);
|
|
1175
|
+
await abandon();
|
|
1176
|
+
throw error;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Editor pane: code-server on the machine (same flags as uai-init's
|
|
1181
|
+
// container launch), idempotent across provision re-runs and resume.
|
|
1182
|
+
// Best-effort — a missing binary degrades the Editor tab, never the task.
|
|
1183
|
+
await stage(
|
|
1184
|
+
ensureCodeServer(own, workspacePath, input.task.previewEnv?.UAI_EDITOR_THEME, envMode).catch(
|
|
1185
|
+
(error: unknown) => {
|
|
1186
|
+
console.warn(
|
|
1187
|
+
`[machine] task ${request.taskId}: code-server launch failed: ${
|
|
1188
|
+
error instanceof Error ? error.message : String(error)
|
|
1189
|
+
}`,
|
|
1190
|
+
);
|
|
1191
|
+
},
|
|
1192
|
+
),
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
// Withdrawn while the rest came up: the start is not one.
|
|
1196
|
+
if (clone?.signal.aborted) throw withdrawal();
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
if (clone?.signal.aborted) {
|
|
1199
|
+
stopMachineGatewayTunnel(request.taskId);
|
|
1200
|
+
await abandon();
|
|
1201
|
+
throw withdrawal();
|
|
1202
|
+
}
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1205
|
+
return {
|
|
1206
|
+
handle: withTaskDownResult(handle),
|
|
1207
|
+
result: {
|
|
1208
|
+
// Recorded as the task's runtime identity (the compose-project
|
|
1209
|
+
// column): the machine id over ssh, `self:<task>` in sidecar mode.
|
|
1210
|
+
composeProject: environmentRuntimeIdentity(handle),
|
|
1211
|
+
worktreePath: deps.taskControlDir(request.taskId),
|
|
1212
|
+
codeServerPort: MACHINE_CODE_SERVER_PORT,
|
|
1213
|
+
...(envSchemas.length > 0 ? { envSchemas } : {}),
|
|
1214
|
+
...(warnings.length > 0
|
|
1215
|
+
? { initWarning: warnings.join("\n") }
|
|
1216
|
+
: {}),
|
|
1217
|
+
},
|
|
1218
|
+
};
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1221
|
+
async reconstruct(locator, envMode = "baked") {
|
|
1222
|
+
return withTaskDownResult(
|
|
1223
|
+
await deps.environment.reconstruct(locator, envMode),
|
|
1224
|
+
);
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
}
|