@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,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the `aws` MachineProvider — task machines as EC2 instances.
|
|
3
|
+
*
|
|
4
|
+
* Shell-out to the `aws` CLI with strict JSON parsing, matching every other
|
|
5
|
+
* backend integration (docker, container, ssh): no SDK dependency rides the
|
|
6
|
+
* OTA channel, and tests inject the runner.
|
|
7
|
+
*
|
|
8
|
+
* Identity model: EC2 invents instance ids, but the environment layer's
|
|
9
|
+
* locator-first invariant needs a DERIVED id. The machine id is therefore
|
|
10
|
+
* the logical `uai-machine-<taskId>`; the provider resolves it internally
|
|
11
|
+
* through the `com.uai.machine` tag on every call, and `--client-token`
|
|
12
|
+
* makes launch idempotent so a crashed provision retried cannot mint a
|
|
13
|
+
* second instance. Two live instances carrying one machine's tag is a
|
|
14
|
+
* confusing answer and maps to `unknown` — never to either instance.
|
|
15
|
+
*
|
|
16
|
+
* Absence: a SUCCESSFUL tag query with zero non-terminated matches proves
|
|
17
|
+
* absence (the filter asked AWS directly and AWS answered "none");
|
|
18
|
+
* `terminated` counts as absent — the id is unreachable and its disk is
|
|
19
|
+
* gone. A failed query proves nothing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
import type { DockerResult } from "./docker-exec";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
MachineInfo,
|
|
29
|
+
MachineProvider,
|
|
30
|
+
MachineSpec,
|
|
31
|
+
MachineState,
|
|
32
|
+
} from "./machine-provider";
|
|
33
|
+
|
|
34
|
+
export const AWS_MACHINE_TAG = "com.uai.machine";
|
|
35
|
+
|
|
36
|
+
/** ADR-125 BYO-AWS: the Uai account that publishes the public payload AMIs,
|
|
37
|
+
* and the name prefix build-ami.sh stamps on them. A host with no
|
|
38
|
+
* UAI_MACHINE_IMAGE resolves the newest published payload in its region. */
|
|
39
|
+
export const PAYLOAD_AMI_OWNER = "277057415208";
|
|
40
|
+
export const PAYLOAD_AMI_NAME_PREFIX = "uai-machine-payload";
|
|
41
|
+
/** Regions the payload AMI is published to (build-ami.sh bakes in the first
|
|
42
|
+
* and copies to the rest). A BYO-AWS runner elsewhere would find no image
|
|
43
|
+
* and fail every launch, so setup refuses other regions up front (owner,
|
|
44
|
+
* 2026-09-10: "only show the available ones for now"). */
|
|
45
|
+
export const PAYLOAD_AMI_REGIONS: readonly string[] = ["us-west-2", "us-east-1"];
|
|
46
|
+
/** Name of the security group auto-created in the default VPC when the host
|
|
47
|
+
* config names none. Inbound: all TCP from the HOST's egress /32 only —
|
|
48
|
+
* code-server runs auth-none and preview ports are plain HTTP, so exposure
|
|
49
|
+
* wider than the orchestrator would hand the editor to the internet. */
|
|
50
|
+
const MANAGED_SECURITY_GROUP = "uai-machines";
|
|
51
|
+
|
|
52
|
+
export interface AwsMachineConfig {
|
|
53
|
+
region: string;
|
|
54
|
+
/** AMI id used when the spec's image is not already an ami-*. */
|
|
55
|
+
subnetId?: string;
|
|
56
|
+
securityGroupId?: string;
|
|
57
|
+
iamInstanceProfileArn?: string;
|
|
58
|
+
/** Override the size ladder; the default picks the smallest Graviton
|
|
59
|
+
* type satisfying both cpu and memory. */
|
|
60
|
+
instanceType?: (spec: MachineSpec) => string;
|
|
61
|
+
/** Root EBS volume size. The AMI snapshot default (8 GiB Debian) starves
|
|
62
|
+
* real work — a monorepo install plus a browser left ~36 MB free live
|
|
63
|
+
* (2026-08-28). cloud-init's growpart expands the filesystem on boot. */
|
|
64
|
+
diskGiB?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DEFAULT_DISK_GIB = 50;
|
|
68
|
+
|
|
69
|
+
type Runner = (args: string[]) => Promise<DockerResult>;
|
|
70
|
+
|
|
71
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
72
|
+
|
|
73
|
+
function defaultRunner(region: string): Runner {
|
|
74
|
+
return (args) =>
|
|
75
|
+
new Promise<DockerResult>((resolve) => {
|
|
76
|
+
let stdout = "";
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let settled = false;
|
|
79
|
+
const child = spawn(
|
|
80
|
+
"aws",
|
|
81
|
+
["--region", region, "--output", "json", ...args],
|
|
82
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
83
|
+
);
|
|
84
|
+
const settle = (status: number | null): void => {
|
|
85
|
+
if (settled) return;
|
|
86
|
+
settled = true;
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolve({ status, stdout, stderr });
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
child.kill("SIGKILL");
|
|
92
|
+
const grace = setTimeout(() => settle(null), 2_000);
|
|
93
|
+
grace.unref?.();
|
|
94
|
+
}, DEFAULT_TIMEOUT_MS);
|
|
95
|
+
timer.unref?.();
|
|
96
|
+
child.stdout.setEncoding("utf8");
|
|
97
|
+
child.stderr.setEncoding("utf8");
|
|
98
|
+
child.stdout.on("data", (chunk: string) => (stdout += chunk));
|
|
99
|
+
child.stderr.on("data", (chunk: string) => (stderr += chunk));
|
|
100
|
+
child.once("error", () => settle(null));
|
|
101
|
+
child.once("close", (code) => settle(code));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function awsMachineName(taskId: string): string {
|
|
106
|
+
return `uai-machine-${taskId.toLowerCase()}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Smallest Graviton type satisfying both dimensions.
|
|
110
|
+
*
|
|
111
|
+
* FLOORS AT 16GiB (owner decision 2026-08-31, priced at $0.25/machine-up
|
|
112
|
+
* hour): an 8GiB machine thrashed to death twice in one day under the
|
|
113
|
+
* ordinary heavy shape — a 5-agent crew + Turbopack dev server + editor +
|
|
114
|
+
* VNC browser — and a memory wall reads as "the product is broken", which
|
|
115
|
+
* costs more than the larger instance. m7g (sustained) over t4g (burstable)
|
|
116
|
+
* deliberately: compile bursts on a drained CPU-credit balance are the same
|
|
117
|
+
* wall wearing a different mask. The floor is AWS-only — local VM sizing
|
|
118
|
+
* has its own constraints and keeps the spec-driven path. */
|
|
119
|
+
export function defaultInstanceType(spec: MachineSpec): string {
|
|
120
|
+
const ladder: Array<[string, number, number]> = [
|
|
121
|
+
["m7g.xlarge", 4, 16384],
|
|
122
|
+
["m7g.2xlarge", 8, 32768],
|
|
123
|
+
["m7g.4xlarge", 16, 65536],
|
|
124
|
+
];
|
|
125
|
+
for (const [type, cpus, memoryMiB] of ladder) {
|
|
126
|
+
if (spec.cpus <= cpus && spec.memoryMiB <= memoryMiB) return type;
|
|
127
|
+
}
|
|
128
|
+
return "m7g.4xlarge";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** cloud-init: install the orchestrator key for node (and root for the
|
|
132
|
+
* provisioning path), creating the node user when the AMI lacks it. */
|
|
133
|
+
export function awsUserData(authorizedPublicKey: string): string {
|
|
134
|
+
const script = [
|
|
135
|
+
"#!/bin/sh",
|
|
136
|
+
"set -e",
|
|
137
|
+
// uid 1000 is often TAKEN on stock cloud images (Debian's `admin`) —
|
|
138
|
+
// insisting on it aborted the whole script under set -e and left no key
|
|
139
|
+
// installed for anyone (live 2026-08-27, first EC2 smoke). Prefer 1000,
|
|
140
|
+
// fall back to auto: on a machine the task owns the whole filesystem,
|
|
141
|
+
// so the exact uid is convention, not correctness.
|
|
142
|
+
"id node >/dev/null 2>&1 || useradd -m -u 1000 -s /bin/bash node 2>/dev/null || useradd -m -s /bin/bash node",
|
|
143
|
+
"for account in node root; do",
|
|
144
|
+
' home=$(getent passwd "$account" | cut -d: -f6)',
|
|
145
|
+
' mkdir -p "$home/.ssh"',
|
|
146
|
+
` printf '%s\\n' '${authorizedPublicKey.replace(/'/g, "")}' > "$home/.ssh/authorized_keys"`,
|
|
147
|
+
' chown -R "$account" "$home/.ssh"',
|
|
148
|
+
' chmod 700 "$home/.ssh"',
|
|
149
|
+
' chmod 600 "$home/.ssh/authorized_keys"',
|
|
150
|
+
"done",
|
|
151
|
+
// This user-data is emitted only by the task-machine provider. Owned
|
|
152
|
+
// runners use the host installer and never execute it, so their OS swap
|
|
153
|
+
// remains untouched.
|
|
154
|
+
//
|
|
155
|
+
// Swap BEFORE any workload: an Opus agent + a bundler + Chromium on a
|
|
156
|
+
// swapless machine hits the memory wall. The backing file is encrypted
|
|
157
|
+
// by a plain dm-crypt mapping whose key is read from /dev/urandom every
|
|
158
|
+
// time the mapping is created. /run's marker makes this idempotent within
|
|
159
|
+
// one boot; crypttab recreates the mapping (and therefore the key) after
|
|
160
|
+
// every boot. The persistent marker only says the backing file has been
|
|
161
|
+
// scrubbed once and has never subsequently held plaintext swap.
|
|
162
|
+
"if ! command -v cryptsetup >/dev/null 2>&1 || [ ! -x /lib/systemd/system-generators/systemd-cryptsetup-generator ]; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends cryptsetup; fi",
|
|
163
|
+
"mkdir -p /run/uai /var/lib/uai",
|
|
164
|
+
"chmod 700 /run/uai /var/lib/uai",
|
|
165
|
+
"touch /etc/crypttab /etc/fstab",
|
|
166
|
+
"crypttab_tmp=$(mktemp /etc/crypttab.uai.XXXXXX)",
|
|
167
|
+
"awk 'BEGIN { managed=0 } $0 == \"# BEGIN UAI TASK MACHINE SWAP\" { managed=1; next } $0 == \"# END UAI TASK MACHINE SWAP\" { managed=0; next } managed { next } /^[[:space:]]*#/ { print; next } $1 == \"uai-swap\" || $4 ~ /(^|,)swap(,|$)/ { print \"# uai-replaced-swap \" $0; next } { print }' /etc/crypttab > \"$crypttab_tmp\"",
|
|
168
|
+
"printf '%s\\n' '# BEGIN UAI TASK MACHINE SWAP' 'uai-swap /swapfile /dev/urandom swap,cipher=aes-xts-plain64,size=512' '# END UAI TASK MACHINE SWAP' >> \"$crypttab_tmp\"",
|
|
169
|
+
"chown root:root \"$crypttab_tmp\" && chmod 600 \"$crypttab_tmp\" && mv -f -- \"$crypttab_tmp\" /etc/crypttab",
|
|
170
|
+
"fstab_tmp=$(mktemp /etc/fstab.uai.XXXXXX)",
|
|
171
|
+
"awk 'BEGIN { managed=0 } $0 == \"# BEGIN UAI TASK MACHINE SWAP\" { managed=1; next } $0 == \"# END UAI TASK MACHINE SWAP\" { managed=0; next } managed { next } /^[[:space:]]*#/ { print; next } NF >= 3 && $3 == \"swap\" { print \"# uai-disabled-raw-swap \" $0; next } { print }' /etc/fstab > \"$fstab_tmp\"",
|
|
172
|
+
"printf '%s\\n' '# BEGIN UAI TASK MACHINE SWAP' '/dev/mapper/uai-swap none swap sw 0 0' '# END UAI TASK MACHINE SWAP' >> \"$fstab_tmp\"",
|
|
173
|
+
"chown root:root \"$fstab_tmp\" && chmod 644 \"$fstab_tmp\" && mv -f -- \"$fstab_tmp\" /etc/fstab",
|
|
174
|
+
"active_swap=$(awk 'NR > 1 { print $1 }' /proc/swaps)",
|
|
175
|
+
"if [ ! -f /run/uai/swap-ready-v1 ] || [ \"$active_swap\" != /dev/mapper/uai-swap ]; then",
|
|
176
|
+
// Failing to page every old raw-swap page back into RAM is fatal: the
|
|
177
|
+
// sidecar must not start beside durable plaintext merely to keep booting.
|
|
178
|
+
" swapoff -a",
|
|
179
|
+
" cryptsetup close uai-swap 2>/dev/null || true",
|
|
180
|
+
" if [ ! -f /var/lib/uai/swap-backing-initialized-v1 ]; then",
|
|
181
|
+
" if [ -L /swapfile ] || { [ -e /swapfile ] && [ ! -f /swapfile ]; }; then echo 'unsafe /swapfile type' >&2; exit 1; fi",
|
|
182
|
+
// An older task-machine release used this file as raw swap. One complete
|
|
183
|
+
// zero pass removes any pages it left before the file is reused solely as
|
|
184
|
+
// ciphertext backing. Crashes are safe: without the marker the next run
|
|
185
|
+
// repeats the scrub.
|
|
186
|
+
" if [ -f /swapfile ]; then shred --iterations=0 --zero -- /swapfile; fi",
|
|
187
|
+
" rm -f -- /swapfile",
|
|
188
|
+
" fi",
|
|
189
|
+
// 16G (owner decision 2026-08-31): the graceful-degradation floor has to
|
|
190
|
+
// absorb the whole heavy shape. Every command is mandatory; failure keeps
|
|
191
|
+
// cloud-init and therefore the task machine unavailable.
|
|
192
|
+
" fallocate -l 16G /swapfile",
|
|
193
|
+
" chown root:root /swapfile && chmod 600 /swapfile",
|
|
194
|
+
" cryptsetup open --batch-mode --type plain --cipher aes-xts-plain64 --key-size 512 --key-file /dev/urandom /swapfile uai-swap",
|
|
195
|
+
" mkswap /dev/mapper/uai-swap >/dev/null",
|
|
196
|
+
" swapon /dev/mapper/uai-swap",
|
|
197
|
+
" touch /var/lib/uai/swap-backing-initialized-v1 /run/uai/swap-ready-v1",
|
|
198
|
+
" chmod 600 /var/lib/uai/swap-backing-initialized-v1 /run/uai/swap-ready-v1",
|
|
199
|
+
"fi",
|
|
200
|
+
// The task OWNS its machine (ADR-121 — per-customer kernel isolation is
|
|
201
|
+
// the whole point); denying sudo just breaks project setups that
|
|
202
|
+
// container tasks handle fine (live 2026-08-31: a Hightop machine task
|
|
203
|
+
// could not install the Infisical CLI its repo's config expects).
|
|
204
|
+
"echo 'node ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/node && chmod 440 /etc/sudoers.d/node",
|
|
205
|
+
// Infisical CLI: baseline parity with the standard container image —
|
|
206
|
+
// pnpm-secrets-style project setups expect it on PATH. Best-effort.
|
|
207
|
+
"(V=0.43.110; case \$(uname -m) in aarch64|arm64) T=arm64;; *) T=amd64;; esac; curl -fsSL -o /tmp/inf.tgz https://github.com/Infisical/cli/releases/download/v\${V}/cli_\${V}_linux_\${T}.tar.gz && tar -xzf /tmp/inf.tgz -C /tmp infisical && install -m 0755 /tmp/infisical /usr/local/bin/infisical) || true",
|
|
208
|
+
"",
|
|
209
|
+
].join("\n");
|
|
210
|
+
// RAW script, not base64: AWS CLI v2 base64-encodes --user-data ITSELF.
|
|
211
|
+
// Pre-encoding double-encoded it, cloud-init saw garbage and silently ran
|
|
212
|
+
// nothing — no key installed for anyone (live 2026-08-27, second EC2
|
|
213
|
+
// smoke failure; the uid-1000 collision fixed just before was real too,
|
|
214
|
+
// but masked by this).
|
|
215
|
+
return script;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type LiveInstance = {
|
|
219
|
+
instanceId: string;
|
|
220
|
+
state: string;
|
|
221
|
+
privateIp: string | null;
|
|
222
|
+
taskLabel: string | null;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Strict reservations parse. Any malformed instance voids the answer. */
|
|
226
|
+
function parseReservations(stdout: string): LiveInstance[] | null {
|
|
227
|
+
let parsed: unknown;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(stdout);
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const reservations = (parsed as { Reservations?: unknown }).Reservations;
|
|
234
|
+
if (!Array.isArray(reservations)) return null;
|
|
235
|
+
const instances: LiveInstance[] = [];
|
|
236
|
+
for (const reservation of reservations) {
|
|
237
|
+
const list = (reservation as { Instances?: unknown }).Instances;
|
|
238
|
+
if (!Array.isArray(list)) return null;
|
|
239
|
+
for (const instance of list) {
|
|
240
|
+
if (typeof instance !== "object" || instance === null) return null;
|
|
241
|
+
const record = instance as {
|
|
242
|
+
InstanceId?: unknown;
|
|
243
|
+
State?: { Name?: unknown };
|
|
244
|
+
PrivateIpAddress?: unknown;
|
|
245
|
+
PublicIpAddress?: unknown;
|
|
246
|
+
Tags?: Array<{ Key?: unknown; Value?: unknown }>;
|
|
247
|
+
};
|
|
248
|
+
if (typeof record.InstanceId !== "string") return null;
|
|
249
|
+
const state = record.State?.Name;
|
|
250
|
+
if (typeof state !== "string") return null;
|
|
251
|
+
let taskLabel: string | null = null;
|
|
252
|
+
if (record.Tags !== undefined) {
|
|
253
|
+
if (!Array.isArray(record.Tags)) return null;
|
|
254
|
+
for (const tag of record.Tags) {
|
|
255
|
+
if (tag?.Key === AWS_MACHINE_TAG) {
|
|
256
|
+
if (typeof tag.Value !== "string") return null;
|
|
257
|
+
taskLabel = tag.Value;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
instances.push({
|
|
262
|
+
instanceId: record.InstanceId,
|
|
263
|
+
state,
|
|
264
|
+
// Prefer the public address when the subnet assigns one: an
|
|
265
|
+
// orchestrator OUTSIDE the VPC (the local smoke, a dev box) can only
|
|
266
|
+
// dial that, while the production in-VPC orchestrator sees no public
|
|
267
|
+
// IP on its machines and keeps dialing private, unchanged.
|
|
268
|
+
privateIp:
|
|
269
|
+
typeof record.PublicIpAddress === "string"
|
|
270
|
+
? record.PublicIpAddress
|
|
271
|
+
: typeof record.PrivateIpAddress === "string"
|
|
272
|
+
? record.PrivateIpAddress
|
|
273
|
+
: null,
|
|
274
|
+
taskLabel,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return instances;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function stateFromAws(state: string): MachineState {
|
|
282
|
+
switch (state) {
|
|
283
|
+
case "running":
|
|
284
|
+
return "running";
|
|
285
|
+
case "pending":
|
|
286
|
+
return "pending";
|
|
287
|
+
case "stopping":
|
|
288
|
+
case "stopped":
|
|
289
|
+
return "stopped";
|
|
290
|
+
case "shutting-down":
|
|
291
|
+
case "terminated":
|
|
292
|
+
return "absent";
|
|
293
|
+
default:
|
|
294
|
+
return "unknown";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function unknown(id: string, detail: string): MachineInfo {
|
|
299
|
+
return { id, taskLabel: null, state: "unknown", address: null, detail };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The host's public egress address, for scoping the auto-created security
|
|
303
|
+
* group. Only consulted when the group must be CREATED — an existing group
|
|
304
|
+
* (explicit config or a prior run) never re-resolves. Overridable for hosts
|
|
305
|
+
* whose egress the checker cannot see (e.g. VPN'd). */
|
|
306
|
+
async function hostEgressIp(): Promise<string> {
|
|
307
|
+
const configured = process.env.UAI_HOST_EGRESS_IP;
|
|
308
|
+
if (configured && /^\d{1,3}(\.\d{1,3}){3}$/.test(configured)) {
|
|
309
|
+
return configured;
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const res = await fetch("https://checkip.amazonaws.com", {
|
|
313
|
+
signal: AbortSignal.timeout(5_000),
|
|
314
|
+
});
|
|
315
|
+
const ip = (await res.text()).trim();
|
|
316
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip;
|
|
317
|
+
} catch {
|
|
318
|
+
// fall through to the instruction below
|
|
319
|
+
}
|
|
320
|
+
throw new Error(
|
|
321
|
+
"could not determine this host's public address to scope the machine " +
|
|
322
|
+
"security group — set UAI_HOST_EGRESS_IP or UAI_AWS_SECURITY_GROUP_ID",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** How long a resolved payload AMI is trusted before the newest published
|
|
327
|
+
* image is looked up again. Long enough that a burst of launches shares one
|
|
328
|
+
* describe-images; short enough that a freshly published image reaches every
|
|
329
|
+
* RUNNING host — shared and BYO — on its next task without a host restart
|
|
330
|
+
* (before 0.9.106 the answer was kept for the process lifetime). */
|
|
331
|
+
export const PAYLOAD_AMI_TTL_MS = 5 * 60_000;
|
|
332
|
+
|
|
333
|
+
export function createAwsMachineProvider(
|
|
334
|
+
config: AwsMachineConfig,
|
|
335
|
+
runner: Runner = defaultRunner(config.region),
|
|
336
|
+
deps: { now?: () => number } = {},
|
|
337
|
+
): MachineProvider {
|
|
338
|
+
const now = deps.now ?? (() => Date.now());
|
|
339
|
+
const taskIdOf = (machineId: string): string => {
|
|
340
|
+
if (!machineId.startsWith("uai-machine-")) {
|
|
341
|
+
throw new Error(`not a uai machine id: ${machineId}`);
|
|
342
|
+
}
|
|
343
|
+
return machineId.slice("uai-machine-".length);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** Root device name per AMI, resolved once per process. A failure throws:
|
|
347
|
+
* guessing the device would silently attach a blank second volume while
|
|
348
|
+
* the root stays at its 8 GiB snapshot size. */
|
|
349
|
+
const rootDeviceByImage = new Map<string, string>();
|
|
350
|
+
async function rootDeviceName(image: string): Promise<string> {
|
|
351
|
+
const cached = rootDeviceByImage.get(image);
|
|
352
|
+
if (cached) return cached;
|
|
353
|
+
const res = await runner([
|
|
354
|
+
"ec2",
|
|
355
|
+
"describe-images",
|
|
356
|
+
"--image-ids",
|
|
357
|
+
image,
|
|
358
|
+
"--query",
|
|
359
|
+
"Images[0].RootDeviceName",
|
|
360
|
+
]);
|
|
361
|
+
if (res.status !== 0) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`could not resolve the root device of ${image}: ${
|
|
364
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
365
|
+
}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
let device: unknown;
|
|
369
|
+
try {
|
|
370
|
+
device = JSON.parse(res.stdout);
|
|
371
|
+
} catch {
|
|
372
|
+
device = null;
|
|
373
|
+
}
|
|
374
|
+
if (typeof device !== "string" || !device.startsWith("/dev/")) {
|
|
375
|
+
throw new Error(`image ${image} reports no root device name`);
|
|
376
|
+
}
|
|
377
|
+
rootDeviceByImage.set(image, device);
|
|
378
|
+
return device;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// -------------------------------------------------------------------------
|
|
382
|
+
// ADR-125 BYO-AWS zero-config discovery. Every resolver prefers explicit
|
|
383
|
+
// host config, resolves once per process, and fails with an instruction
|
|
384
|
+
// rather than a guess — a wrong subnet or security group is a machine the
|
|
385
|
+
// orchestrator can never reach.
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
let resolvedImage: { id: string; at: number } | null = null;
|
|
389
|
+
async function resolveImage(specImage: string): Promise<string> {
|
|
390
|
+
if (specImage.startsWith("ami-")) return specImage;
|
|
391
|
+
if (resolvedImage && now() - resolvedImage.at < PAYLOAD_AMI_TTL_MS) {
|
|
392
|
+
return resolvedImage.id;
|
|
393
|
+
}
|
|
394
|
+
const res = await runner([
|
|
395
|
+
"ec2",
|
|
396
|
+
"describe-images",
|
|
397
|
+
"--owners",
|
|
398
|
+
PAYLOAD_AMI_OWNER,
|
|
399
|
+
"--filters",
|
|
400
|
+
`Name=name,Values=${PAYLOAD_AMI_NAME_PREFIX}-*`,
|
|
401
|
+
"Name=state,Values=available",
|
|
402
|
+
"--query",
|
|
403
|
+
"sort_by(Images, &CreationDate)[-1].ImageId",
|
|
404
|
+
]);
|
|
405
|
+
let image: unknown = null;
|
|
406
|
+
if (res.status === 0) {
|
|
407
|
+
try {
|
|
408
|
+
image = JSON.parse(res.stdout);
|
|
409
|
+
} catch {
|
|
410
|
+
image = null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (typeof image !== "string" || !image.startsWith("ami-")) {
|
|
414
|
+
// A stale-but-known image beats a failed launch when the lookup itself
|
|
415
|
+
// hiccups; only a host that never resolved one has nothing to offer.
|
|
416
|
+
if (resolvedImage) return resolvedImage.id;
|
|
417
|
+
throw new Error(
|
|
418
|
+
`no uai machine payload AMI is published in ${config.region} — set ` +
|
|
419
|
+
"UAI_MACHINE_IMAGE to an AMI id, or ask Moondeck to publish this region",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (resolvedImage?.id !== image) {
|
|
423
|
+
// The journal line that tells an operator which payload a task got —
|
|
424
|
+
// an exit 127 for a missing CLI is otherwise the only symptom of a
|
|
425
|
+
// region that never received the latest publish.
|
|
426
|
+
console.log(
|
|
427
|
+
`[machine-aws] payload image for ${config.region}: ${image}` +
|
|
428
|
+
(resolvedImage ? ` (was ${resolvedImage.id})` : ""),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
resolvedImage = { id: image, at: now() };
|
|
432
|
+
return image;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let resolvedSubnet: string | null = null;
|
|
436
|
+
async function resolveSubnet(): Promise<string> {
|
|
437
|
+
if (config.subnetId) return config.subnetId;
|
|
438
|
+
if (resolvedSubnet) return resolvedSubnet;
|
|
439
|
+
const res = await runner([
|
|
440
|
+
"ec2",
|
|
441
|
+
"describe-subnets",
|
|
442
|
+
"--filters",
|
|
443
|
+
"Name=default-for-az,Values=true",
|
|
444
|
+
"--query",
|
|
445
|
+
"sort_by(Subnets, &AvailabilityZone)[].{id: SubnetId, public: MapPublicIpOnLaunch, vpc: VpcId}",
|
|
446
|
+
]);
|
|
447
|
+
let subnets: Array<{ id?: unknown; public?: unknown }> = [];
|
|
448
|
+
if (res.status === 0) {
|
|
449
|
+
try {
|
|
450
|
+
const parsed = JSON.parse(res.stdout);
|
|
451
|
+
if (Array.isArray(parsed)) subnets = parsed;
|
|
452
|
+
} catch {
|
|
453
|
+
subnets = [];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const chosen =
|
|
457
|
+
subnets.find((s) => s.public === true && typeof s.id === "string") ??
|
|
458
|
+
subnets.find((s) => typeof s.id === "string");
|
|
459
|
+
if (!chosen || typeof chosen.id !== "string") {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`no default-VPC subnet found in ${config.region} — set ` +
|
|
462
|
+
"UAI_AWS_SUBNET_ID to a public subnet",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
resolvedSubnet = chosen.id;
|
|
466
|
+
return chosen.id;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let resolvedSecurityGroup: string | null = null;
|
|
470
|
+
async function resolveSecurityGroup(subnetId: string): Promise<string> {
|
|
471
|
+
if (config.securityGroupId) return config.securityGroupId;
|
|
472
|
+
if (resolvedSecurityGroup) return resolvedSecurityGroup;
|
|
473
|
+
const vpcRes = await runner([
|
|
474
|
+
"ec2",
|
|
475
|
+
"describe-subnets",
|
|
476
|
+
"--subnet-ids",
|
|
477
|
+
subnetId,
|
|
478
|
+
"--query",
|
|
479
|
+
"Subnets[0].VpcId",
|
|
480
|
+
]);
|
|
481
|
+
const vpcId = vpcRes.status === 0 ? JSON.parse(vpcRes.stdout) : null;
|
|
482
|
+
if (typeof vpcId !== "string") {
|
|
483
|
+
throw new Error(`could not resolve the VPC of subnet ${subnetId}`);
|
|
484
|
+
}
|
|
485
|
+
const found = await runner([
|
|
486
|
+
"ec2",
|
|
487
|
+
"describe-security-groups",
|
|
488
|
+
"--filters",
|
|
489
|
+
`Name=group-name,Values=${MANAGED_SECURITY_GROUP}`,
|
|
490
|
+
`Name=vpc-id,Values=${vpcId}`,
|
|
491
|
+
"--query",
|
|
492
|
+
"SecurityGroups[0].GroupId",
|
|
493
|
+
]);
|
|
494
|
+
if (found.status === 0) {
|
|
495
|
+
try {
|
|
496
|
+
const existing = JSON.parse(found.stdout);
|
|
497
|
+
if (typeof existing === "string" && existing.startsWith("sg-")) {
|
|
498
|
+
// The group admits only host addresses — and it may predate THIS
|
|
499
|
+
// host (a second managed host in the fleet account, or this host
|
|
500
|
+
// rebooting onto a new public IP). Ensure our egress /32 is
|
|
501
|
+
// present; a duplicate rule is AWS's problem to dedupe, not ours
|
|
502
|
+
// to pre-check. Without this, every machine of a newer host sat
|
|
503
|
+
// unreachable behind the first host's rule ("machine never became
|
|
504
|
+
// reachable over ssh", live 2026-08-31).
|
|
505
|
+
const ensureEgress = await hostEgressIp();
|
|
506
|
+
const ensured = await runner([
|
|
507
|
+
"ec2",
|
|
508
|
+
"authorize-security-group-ingress",
|
|
509
|
+
"--group-id",
|
|
510
|
+
existing,
|
|
511
|
+
"--ip-permissions",
|
|
512
|
+
JSON.stringify([
|
|
513
|
+
{
|
|
514
|
+
IpProtocol: "tcp",
|
|
515
|
+
FromPort: 0,
|
|
516
|
+
ToPort: 65535,
|
|
517
|
+
IpRanges: [
|
|
518
|
+
{
|
|
519
|
+
CidrIp: `${ensureEgress}/32`,
|
|
520
|
+
Description: "uai orchestrating host",
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
]),
|
|
525
|
+
]);
|
|
526
|
+
if (
|
|
527
|
+
ensured.status !== 0 &&
|
|
528
|
+
!/InvalidPermission\.Duplicate/.test(ensured.stderr)
|
|
529
|
+
) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`could not admit this host into ${MANAGED_SECURITY_GROUP}: ${
|
|
532
|
+
ensured.stderr.trim() || `exit ${ensured.status ?? "killed"}`
|
|
533
|
+
}`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
resolvedSecurityGroup = existing;
|
|
537
|
+
return existing;
|
|
538
|
+
}
|
|
539
|
+
} catch (err) {
|
|
540
|
+
if (err instanceof Error && /could not admit this host/.test(err.message)) {
|
|
541
|
+
throw err;
|
|
542
|
+
}
|
|
543
|
+
// fall through to creation
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const egress = await hostEgressIp();
|
|
547
|
+
const created = await runner([
|
|
548
|
+
"ec2",
|
|
549
|
+
"create-security-group",
|
|
550
|
+
"--group-name",
|
|
551
|
+
MANAGED_SECURITY_GROUP,
|
|
552
|
+
"--description",
|
|
553
|
+
"uai task machines: reachable only from the orchestrating host",
|
|
554
|
+
"--vpc-id",
|
|
555
|
+
vpcId,
|
|
556
|
+
"--query",
|
|
557
|
+
"GroupId",
|
|
558
|
+
]);
|
|
559
|
+
const groupId = created.status === 0 ? JSON.parse(created.stdout) : null;
|
|
560
|
+
if (typeof groupId !== "string") {
|
|
561
|
+
throw new Error(
|
|
562
|
+
`could not create the ${MANAGED_SECURITY_GROUP} security group: ${
|
|
563
|
+
created.stderr.trim() || `exit ${created.status ?? "killed"}`
|
|
564
|
+
}`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
const authorized = await runner([
|
|
568
|
+
"ec2",
|
|
569
|
+
"authorize-security-group-ingress",
|
|
570
|
+
"--group-id",
|
|
571
|
+
groupId,
|
|
572
|
+
"--ip-permissions",
|
|
573
|
+
JSON.stringify([
|
|
574
|
+
{
|
|
575
|
+
IpProtocol: "tcp",
|
|
576
|
+
FromPort: 0,
|
|
577
|
+
ToPort: 65535,
|
|
578
|
+
IpRanges: [
|
|
579
|
+
{
|
|
580
|
+
CidrIp: `${egress}/32`,
|
|
581
|
+
Description: "uai orchestrating host",
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
},
|
|
585
|
+
]),
|
|
586
|
+
]);
|
|
587
|
+
if (authorized.status !== 0) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`created ${groupId} but could not authorize the host's address: ${
|
|
590
|
+
authorized.stderr.trim() || `exit ${authorized.status ?? "killed"}`
|
|
591
|
+
}`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
console.log(
|
|
595
|
+
`[machine-aws] created security group ${groupId} (${MANAGED_SECURITY_GROUP}) allowing ${egress}/32`,
|
|
596
|
+
);
|
|
597
|
+
resolvedSecurityGroup = groupId;
|
|
598
|
+
return groupId;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Resolve the logical machine id to its live instances via the tag. */
|
|
602
|
+
async function resolveLive(
|
|
603
|
+
machineId: string,
|
|
604
|
+
): Promise<{ instances: LiveInstance[] } | { failure: string }> {
|
|
605
|
+
const res = await runner([
|
|
606
|
+
"ec2",
|
|
607
|
+
"describe-instances",
|
|
608
|
+
"--filters",
|
|
609
|
+
`Name=tag:${AWS_MACHINE_TAG},Values=${taskIdOf(machineId)}`,
|
|
610
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped",
|
|
611
|
+
]);
|
|
612
|
+
if (res.status !== 0) {
|
|
613
|
+
return {
|
|
614
|
+
failure: res.stderr.trim() || `describe exited ${res.status ?? "killed"}`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
const instances = parseReservations(res.stdout);
|
|
618
|
+
if (instances === null) {
|
|
619
|
+
return { failure: "describe answered with a confusing shape" };
|
|
620
|
+
}
|
|
621
|
+
return { instances };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function describe(machineId: string): Promise<MachineInfo> {
|
|
625
|
+
let resolved: Awaited<ReturnType<typeof resolveLive>>;
|
|
626
|
+
try {
|
|
627
|
+
resolved = await resolveLive(machineId);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return unknown(
|
|
630
|
+
machineId,
|
|
631
|
+
error instanceof Error ? error.message : String(error),
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
if ("failure" in resolved) return unknown(machineId, resolved.failure);
|
|
635
|
+
if (resolved.instances.length === 0) {
|
|
636
|
+
// A successful tag query answering "none live" IS the absence proof.
|
|
637
|
+
return { id: machineId, taskLabel: null, state: "absent", address: null };
|
|
638
|
+
}
|
|
639
|
+
if (resolved.instances.length > 1) {
|
|
640
|
+
return unknown(
|
|
641
|
+
machineId,
|
|
642
|
+
`multiple live instances carry this machine's tag`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
const instance = resolved.instances[0]!;
|
|
646
|
+
const state = stateFromAws(instance.state);
|
|
647
|
+
return {
|
|
648
|
+
id: machineId,
|
|
649
|
+
taskLabel: instance.taskLabel,
|
|
650
|
+
state,
|
|
651
|
+
// Still stopping: its processes run until the instance is stopped.
|
|
652
|
+
...(instance.state === "stopping" ? { frozen: true as const } : {}),
|
|
653
|
+
address: state === "running" ? instance.privateIp : null,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
kind: "aws",
|
|
659
|
+
|
|
660
|
+
async launch(spec: MachineSpec): Promise<MachineInfo> {
|
|
661
|
+
const machineId = awsMachineName(spec.taskId);
|
|
662
|
+
const type = (config.instanceType ?? defaultInstanceType)(spec);
|
|
663
|
+
// The idempotency token binds the machine id TO these exact launch
|
|
664
|
+
// arguments: EC2 remembers a client token's arguments for ~24h (even
|
|
665
|
+
// past termination) and rejects reuse with different ones
|
|
666
|
+
// (IdempotentParameterMismatch — live 2026-08-27: a Resume after a
|
|
667
|
+
// provider fix changed the user-data and the retry was refused). An
|
|
668
|
+
// identical retry still cannot mint a second instance; a CHANGED
|
|
669
|
+
// launch legitimately gets a fresh token, and the resume-aware
|
|
670
|
+
// provision's describe-first step remains the guard against doubling
|
|
671
|
+
// The AMI snapshot's root volume (8 GiB on the Debian payload) starves
|
|
672
|
+
// real work; request a task-sized gp3 root instead. The mapping must
|
|
673
|
+
// name the AMI's actual root device — a wrong name would ADD a blank
|
|
674
|
+
// volume while the root stays small — so resolve it per image.
|
|
675
|
+
const diskGiB = config.diskGiB ?? DEFAULT_DISK_GIB;
|
|
676
|
+
// ADR-125 BYO-AWS: explicit config wins; otherwise resolve the newest
|
|
677
|
+
// published payload AMI and the default-VPC network per process.
|
|
678
|
+
const image = await resolveImage(spec.image);
|
|
679
|
+
const subnetId = await resolveSubnet();
|
|
680
|
+
const securityGroupId = await resolveSecurityGroup(subnetId);
|
|
681
|
+
const rootDevice = await rootDeviceName(image);
|
|
682
|
+
// a live machine.
|
|
683
|
+
const launchFingerprint = createHash("sha256")
|
|
684
|
+
.update(
|
|
685
|
+
JSON.stringify([
|
|
686
|
+
image,
|
|
687
|
+
type,
|
|
688
|
+
spec.authorizedPublicKey ?? null,
|
|
689
|
+
subnetId,
|
|
690
|
+
securityGroupId,
|
|
691
|
+
config.iamInstanceProfileArn ?? null,
|
|
692
|
+
diskGiB,
|
|
693
|
+
]),
|
|
694
|
+
)
|
|
695
|
+
.digest("hex")
|
|
696
|
+
.slice(0, 16);
|
|
697
|
+
const args = [
|
|
698
|
+
"ec2",
|
|
699
|
+
"run-instances",
|
|
700
|
+
"--image-id",
|
|
701
|
+
image,
|
|
702
|
+
"--instance-type",
|
|
703
|
+
type,
|
|
704
|
+
"--count",
|
|
705
|
+
"1",
|
|
706
|
+
// Idempotency across crashed/retried provisions: the same token can
|
|
707
|
+
// never mint a second instance.
|
|
708
|
+
"--client-token",
|
|
709
|
+
`${machineId}-${launchFingerprint}`.slice(0, 64),
|
|
710
|
+
"--tag-specifications",
|
|
711
|
+
`ResourceType=instance,Tags=[{Key=${AWS_MACHINE_TAG},Value=${spec.taskId}},{Key=Name,Value=${machineId}}]`,
|
|
712
|
+
"--block-device-mappings",
|
|
713
|
+
JSON.stringify([
|
|
714
|
+
{
|
|
715
|
+
DeviceName: rootDevice,
|
|
716
|
+
Ebs: {
|
|
717
|
+
VolumeSize: diskGiB,
|
|
718
|
+
VolumeType: "gp3",
|
|
719
|
+
DeleteOnTermination: true,
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
]),
|
|
723
|
+
...(spec.authorizedPublicKey
|
|
724
|
+
? ["--user-data", awsUserData(spec.authorizedPublicKey)]
|
|
725
|
+
: []),
|
|
726
|
+
"--subnet-id",
|
|
727
|
+
subnetId,
|
|
728
|
+
"--security-group-ids",
|
|
729
|
+
securityGroupId,
|
|
730
|
+
...(config.iamInstanceProfileArn
|
|
731
|
+
? ["--iam-instance-profile", `Arn=${config.iamInstanceProfileArn}`]
|
|
732
|
+
: []),
|
|
733
|
+
];
|
|
734
|
+
const res = await runner(args);
|
|
735
|
+
if (res.status !== 0) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`machine launch failed for ${machineId}: ${
|
|
738
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
739
|
+
}`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
// DescribeInstances TAG FILTERS are eventually consistent: the query
|
|
743
|
+
// can miss an instance RunInstances just created (live 2026-08-28 —
|
|
744
|
+
// the machine was running the whole time while launch reported
|
|
745
|
+
// "absent"). Absence immediately after a successful launch is
|
|
746
|
+
// therefore not evidence yet; give the filter a bounded window.
|
|
747
|
+
let info = await describe(machineId);
|
|
748
|
+
for (
|
|
749
|
+
let attempt = 0;
|
|
750
|
+
(info.state === "absent" || info.state === "unknown") && attempt < 5;
|
|
751
|
+
attempt += 1
|
|
752
|
+
) {
|
|
753
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
|
754
|
+
info = await describe(machineId);
|
|
755
|
+
}
|
|
756
|
+
if (info.state === "absent" || info.state === "unknown") {
|
|
757
|
+
throw new Error(
|
|
758
|
+
`machine ${machineId} launched but could not be described (${info.state}${
|
|
759
|
+
info.detail ? `: ${info.detail}` : ""
|
|
760
|
+
})`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
return info;
|
|
764
|
+
},
|
|
765
|
+
|
|
766
|
+
async stop(machineId: string): Promise<void> {
|
|
767
|
+
const instance = await requireSingleLive(machineId);
|
|
768
|
+
const res = await runner([
|
|
769
|
+
"ec2",
|
|
770
|
+
"stop-instances",
|
|
771
|
+
"--instance-ids",
|
|
772
|
+
instance.instanceId,
|
|
773
|
+
]);
|
|
774
|
+
if (res.status !== 0) {
|
|
775
|
+
throw new Error(
|
|
776
|
+
`machine stop failed for ${machineId}: ${
|
|
777
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
778
|
+
}`,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
// EC2 sits in `stopping` for tens of seconds, and StartInstances
|
|
782
|
+
// refuses it with IncorrectInstanceState — an immediate stop→resume
|
|
783
|
+
// (a user pressing Resume right after Stop; the live 2026-08-27
|
|
784
|
+
// smoke) failed exactly there because our state map folds `stopping`
|
|
785
|
+
// into "stopped". Stop resolves only once the instance can actually
|
|
786
|
+
// be started again, mirroring terminate's poll-to-absent.
|
|
787
|
+
const deadline = Date.now() + 180_000;
|
|
788
|
+
for (;;) {
|
|
789
|
+
const raw = await runner([
|
|
790
|
+
"ec2",
|
|
791
|
+
"describe-instances",
|
|
792
|
+
"--instance-ids",
|
|
793
|
+
instance.instanceId,
|
|
794
|
+
"--query",
|
|
795
|
+
"Reservations[].Instances[].State.Name",
|
|
796
|
+
"--output",
|
|
797
|
+
"text",
|
|
798
|
+
]);
|
|
799
|
+
const state = raw.status === 0 ? raw.stdout.trim() : "";
|
|
800
|
+
if (state === "stopped") return;
|
|
801
|
+
if (state === "terminated" || state === "shutting-down") {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`machine ${machineId} terminated while stopping (${state})`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (Date.now() >= deadline) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
`machine ${machineId} did not reach stopped within the stop deadline (${state || "unknown"})`,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 5_000));
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
async start(machineId: string): Promise<MachineInfo> {
|
|
816
|
+
const instance = await requireSingleLive(machineId);
|
|
817
|
+
const res = await runner([
|
|
818
|
+
"ec2",
|
|
819
|
+
"start-instances",
|
|
820
|
+
"--instance-ids",
|
|
821
|
+
instance.instanceId,
|
|
822
|
+
]);
|
|
823
|
+
if (res.status !== 0) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`machine start failed for ${machineId}: ${
|
|
826
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
827
|
+
}`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
return describe(machineId);
|
|
831
|
+
},
|
|
832
|
+
|
|
833
|
+
async terminate(machineId: string): Promise<void> {
|
|
834
|
+
const resolved = await resolveLive(machineId);
|
|
835
|
+
if ("failure" in resolved) {
|
|
836
|
+
throw new Error(
|
|
837
|
+
`machine ${machineId} could not be resolved for terminate: ${resolved.failure}`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
// Terminate EVERY live instance carrying the tag: a duplicate from a
|
|
841
|
+
// pathological double-launch must not survive its sibling's teardown.
|
|
842
|
+
for (const instance of resolved.instances) {
|
|
843
|
+
const res = await runner([
|
|
844
|
+
"ec2",
|
|
845
|
+
"terminate-instances",
|
|
846
|
+
"--instance-ids",
|
|
847
|
+
instance.instanceId,
|
|
848
|
+
]);
|
|
849
|
+
if (res.status !== 0) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`machine terminate failed for ${machineId} (${instance.instanceId}): ${
|
|
852
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
853
|
+
}`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Absence is not best-effort: poll until the tag query answers "none
|
|
858
|
+
// live" (shutting-down/terminated fall out of the live filter).
|
|
859
|
+
const deadline = Date.now() + 120_000;
|
|
860
|
+
for (;;) {
|
|
861
|
+
const after = await describe(machineId);
|
|
862
|
+
if (after.state === "absent") return;
|
|
863
|
+
if (Date.now() >= deadline) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
`machine ${machineId} could not be proven absent after terminate (${after.state}${
|
|
866
|
+
after.detail ? `: ${after.detail}` : ""
|
|
867
|
+
})`,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 5_000));
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
|
|
874
|
+
describe,
|
|
875
|
+
|
|
876
|
+
async list(): Promise<MachineInfo[]> {
|
|
877
|
+
const res = await runner([
|
|
878
|
+
"ec2",
|
|
879
|
+
"describe-instances",
|
|
880
|
+
"--filters",
|
|
881
|
+
`Name=tag-key,Values=${AWS_MACHINE_TAG}`,
|
|
882
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped",
|
|
883
|
+
]);
|
|
884
|
+
if (res.status !== 0) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
`machine listing failed: ${
|
|
887
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
888
|
+
}`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
const instances = parseReservations(res.stdout);
|
|
892
|
+
if (instances === null) {
|
|
893
|
+
throw new Error("machine inventory was unparseable");
|
|
894
|
+
}
|
|
895
|
+
return instances.map((instance) => {
|
|
896
|
+
const state = stateFromAws(instance.state);
|
|
897
|
+
return {
|
|
898
|
+
id:
|
|
899
|
+
instance.taskLabel !== null
|
|
900
|
+
? awsMachineName(instance.taskLabel)
|
|
901
|
+
: instance.instanceId,
|
|
902
|
+
taskLabel: instance.taskLabel,
|
|
903
|
+
state,
|
|
904
|
+
...(instance.state === "stopping" ? { frozen: true as const } : {}),
|
|
905
|
+
address: state === "running" ? instance.privateIp : null,
|
|
906
|
+
};
|
|
907
|
+
});
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
async function requireSingleLive(machineId: string): Promise<LiveInstance> {
|
|
912
|
+
const resolved = await resolveLive(machineId);
|
|
913
|
+
if ("failure" in resolved) {
|
|
914
|
+
throw new Error(`machine ${machineId} could not be resolved: ${resolved.failure}`);
|
|
915
|
+
}
|
|
916
|
+
if (resolved.instances.length !== 1) {
|
|
917
|
+
throw new Error(
|
|
918
|
+
`machine ${machineId} resolution expected one live instance, found ${resolved.instances.length}`,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
return resolved.instances[0]!;
|
|
922
|
+
}
|
|
923
|
+
}
|