@moondeck/station 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +115 -8
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
- package/bin/moondeck-station.mjs +0 -3
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121 walking skeleton: the machine-backed TaskEnvironmentProvider.
|
|
3
|
+
*
|
|
4
|
+
* A task's environment is a whole machine (EC2/GCP instance, or a local
|
|
5
|
+
* SSH-reachable container on the guinea-pig daemon), reached exclusively
|
|
6
|
+
* over SSH via the shared machine-exec transport. The workspace lives ON
|
|
7
|
+
* the machine (ADR-121's core principle) — provisioning clones in-env; the
|
|
8
|
+
* host holds only the locator, the SSH identity, and proofs.
|
|
9
|
+
*
|
|
10
|
+
* NOT yet registered with the production task flow: the provider is
|
|
11
|
+
* exercised by tests and the guinea-pig smoke driver until the strangler
|
|
12
|
+
* ops land (attachments/diff/transcripts still assume a host-side
|
|
13
|
+
* workspace). Registration is one line when that day comes; the contract
|
|
14
|
+
* is complete now so nothing about the shape is provisional.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFile, spawn as nodeSpawn } from "node:child_process";
|
|
18
|
+
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { promisify } from "node:util";
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
MachineInfo,
|
|
23
|
+
MachineProvider,
|
|
24
|
+
} from "../machine-provider";
|
|
25
|
+
import { machineEnvironmentSshArgs } from "../machine-exec";
|
|
26
|
+
import { applyProjectEnvProfile } from "../runtime-authority";
|
|
27
|
+
import { rootAnchoredRequest } from "./anchor";
|
|
28
|
+
import {
|
|
29
|
+
BASELINE_PATH,
|
|
30
|
+
ETC_ENVIRONMENT_PATH,
|
|
31
|
+
parseEtcEnvironment,
|
|
32
|
+
withoutEngineCredentials,
|
|
33
|
+
withoutLaunchSecrets,
|
|
34
|
+
} from "./machine-environment";
|
|
35
|
+
import { NotARegularFileError } from "./workspace-files";
|
|
36
|
+
import { capturedCliExec, streamingCliExec } from "./docker";
|
|
37
|
+
import {
|
|
38
|
+
assertTaskEnvironmentExecRequest,
|
|
39
|
+
assertTaskEnvironmentProcessRequest,
|
|
40
|
+
refusePrivilegedExec,
|
|
41
|
+
assertTaskEnvironmentSessionRequest,
|
|
42
|
+
assertWorkspaceScopedPath,
|
|
43
|
+
type TaskEnvironmentCallOptions,
|
|
44
|
+
type TaskEnvironmentReadOptions,
|
|
45
|
+
WORKSPACE_READ_MAX_BYTES,
|
|
46
|
+
type TaskEnvironmentCopyRequest,
|
|
47
|
+
type TaskEnvironmentDescriptor,
|
|
48
|
+
type TaskEnvironmentDetachedSessionRequest,
|
|
49
|
+
type TaskEnvironmentExecRequest,
|
|
50
|
+
type TaskEnvironmentExecResult,
|
|
51
|
+
type TaskEnvironmentHandle,
|
|
52
|
+
type TaskEnvironmentLocator,
|
|
53
|
+
type TaskEnvironmentPort,
|
|
54
|
+
type TaskEnvironmentProcess,
|
|
55
|
+
type ProjectEnvMode,
|
|
56
|
+
type TaskEnvironmentProvider,
|
|
57
|
+
type TaskEnvironmentProvisionRequest,
|
|
58
|
+
type TaskEnvironmentProvisioned,
|
|
59
|
+
type TaskEnvironmentRecoveryContext,
|
|
60
|
+
type TaskEnvironmentRecoveryResult,
|
|
61
|
+
type TaskEnvironmentSessionRequest,
|
|
62
|
+
type TaskEnvironmentSpawnRequest,
|
|
63
|
+
type TaskEnvironmentStatus,
|
|
64
|
+
} from "./types";
|
|
65
|
+
|
|
66
|
+
export const MACHINE_TASK_ENVIRONMENT_PROVIDER = "machine";
|
|
67
|
+
|
|
68
|
+
const SAFE_TASK_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
|
|
69
|
+
|
|
70
|
+
export interface MachineTaskEnvironmentLocatorValue {
|
|
71
|
+
taskId: string;
|
|
72
|
+
/** MachineProvider-native identifier (instance id / container name). */
|
|
73
|
+
machineId: string;
|
|
74
|
+
/** Which MachineProvider kind owns machineId — a locator must never be
|
|
75
|
+
* replayed against a different backend. */
|
|
76
|
+
machineKind: "local" | "aws" | "gcp";
|
|
77
|
+
/** Host path of the private key the machine authorizes. */
|
|
78
|
+
privateKeyPath: string;
|
|
79
|
+
/** Absolute workspace path on the machine. */
|
|
80
|
+
workspacePath: string;
|
|
81
|
+
sshUser: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A workspace read on a machine, as the task user: argv $1 the path, $2 the
|
|
86
|
+
* most bytes it takes, $3 "end" for the file's last bytes. Absence and
|
|
87
|
+
* error are different facts: 44 marks a MISSING file so a transport or
|
|
88
|
+
* permission failure can never masquerade as one; 45 a path that is not a
|
|
89
|
+
* regular file (a link in its place, a FIFO, a device), never read. A file
|
|
90
|
+
* swapped in after that check is bounded by the read's own timeout.
|
|
91
|
+
*/
|
|
92
|
+
export const MACHINE_WORKSPACE_READ_SCRIPT =
|
|
93
|
+
`if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 44; fi; ` +
|
|
94
|
+
`if [ -L "$1" ] || [ ! -f "$1" ]; then exit 45; fi; ` +
|
|
95
|
+
`if [ "$3" = end ]; then exec tail -c "$2" "$1"; else exec head -c "$2" "$1"; fi`;
|
|
96
|
+
|
|
97
|
+
export interface MachineTaskInput {
|
|
98
|
+
image: string;
|
|
99
|
+
cpus: number;
|
|
100
|
+
memoryMiB: number;
|
|
101
|
+
workspacePath?: string;
|
|
102
|
+
/** Optional initial clone performed in-env (ADR-121: clone happens in the
|
|
103
|
+
* task's world; the URL may embed short-lived credentials that never
|
|
104
|
+
* touch machine disk beyond git's own storage). */
|
|
105
|
+
repo?: { url: string; branch?: string };
|
|
106
|
+
/** macOS-local development only; see MachineSpec.publishSsh. */
|
|
107
|
+
publishSsh?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface MachineTaskEnvironmentDeps {
|
|
111
|
+
machines: MachineProvider;
|
|
112
|
+
/** Task control dir (host-side) for the SSH identity. */
|
|
113
|
+
taskControlDir(taskId: string): string;
|
|
114
|
+
mintKeyPair(taskControlDir: string): Promise<{
|
|
115
|
+
privateKeyPath: string;
|
|
116
|
+
publicKey: string;
|
|
117
|
+
}>;
|
|
118
|
+
spawn?: typeof nodeSpawn;
|
|
119
|
+
/** SSH-readiness budget after launch/start. */
|
|
120
|
+
readyTimeoutMs?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function machineTaskEnvironmentLocator(
|
|
124
|
+
value: MachineTaskEnvironmentLocatorValue,
|
|
125
|
+
): TaskEnvironmentLocator {
|
|
126
|
+
validateMachineLocatorValue(value);
|
|
127
|
+
return {
|
|
128
|
+
schemaVersion: 1,
|
|
129
|
+
provider: MACHINE_TASK_ENVIRONMENT_PROVIDER,
|
|
130
|
+
value: { ...value },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function parseMachineTaskEnvironmentLocator(
|
|
135
|
+
locator: TaskEnvironmentLocator,
|
|
136
|
+
): MachineTaskEnvironmentLocatorValue {
|
|
137
|
+
if (
|
|
138
|
+
locator.schemaVersion !== 1 ||
|
|
139
|
+
locator.provider !== MACHINE_TASK_ENVIRONMENT_PROVIDER
|
|
140
|
+
) {
|
|
141
|
+
throw new Error("task environment locator does not belong to machine");
|
|
142
|
+
}
|
|
143
|
+
return validateMachineLocatorValue(locator.value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateMachineLocatorValue(
|
|
147
|
+
value: unknown,
|
|
148
|
+
): MachineTaskEnvironmentLocatorValue {
|
|
149
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
150
|
+
throw new Error("machine locator value must be an object");
|
|
151
|
+
}
|
|
152
|
+
const record = value as Record<string, unknown>;
|
|
153
|
+
const {
|
|
154
|
+
taskId,
|
|
155
|
+
machineId,
|
|
156
|
+
machineKind,
|
|
157
|
+
privateKeyPath,
|
|
158
|
+
workspacePath,
|
|
159
|
+
sshUser,
|
|
160
|
+
} = record;
|
|
161
|
+
if (typeof taskId !== "string" || !SAFE_TASK_ID.test(taskId)) {
|
|
162
|
+
throw new Error("machine locator taskId is invalid");
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
typeof machineId !== "string" ||
|
|
166
|
+
machineId.length === 0 ||
|
|
167
|
+
machineId.includes("\0")
|
|
168
|
+
) {
|
|
169
|
+
throw new Error("machine locator machineId is invalid");
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
machineKind !== "local" &&
|
|
173
|
+
machineKind !== "aws" &&
|
|
174
|
+
machineKind !== "gcp"
|
|
175
|
+
) {
|
|
176
|
+
throw new Error("machine locator machineKind is invalid");
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
typeof privateKeyPath !== "string" ||
|
|
180
|
+
!privateKeyPath.startsWith("/") ||
|
|
181
|
+
privateKeyPath.includes("\0")
|
|
182
|
+
) {
|
|
183
|
+
throw new Error("machine locator privateKeyPath is invalid");
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
typeof workspacePath !== "string" ||
|
|
187
|
+
!workspacePath.startsWith("/") ||
|
|
188
|
+
workspacePath.includes("\0")
|
|
189
|
+
) {
|
|
190
|
+
throw new Error("machine locator workspacePath is invalid");
|
|
191
|
+
}
|
|
192
|
+
if (typeof sshUser !== "string" || sshUser.length === 0) {
|
|
193
|
+
throw new Error("machine locator sshUser is invalid");
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
taskId,
|
|
197
|
+
machineId,
|
|
198
|
+
machineKind,
|
|
199
|
+
privateKeyPath,
|
|
200
|
+
workspacePath,
|
|
201
|
+
sshUser,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
206
|
+
readonly descriptor: TaskEnvironmentDescriptor;
|
|
207
|
+
readonly durableIdentity: string;
|
|
208
|
+
/** ADR-154 §6: anchored requests start under the engine supervisor. */
|
|
209
|
+
readonly anchors = true;
|
|
210
|
+
/** ADR-154 §6: a request's environment rides the ssh command line, so a
|
|
211
|
+
* leased launch's engine credentials travel in its admission. */
|
|
212
|
+
readonly credentialDelivery = "admission";
|
|
213
|
+
readonly #value: MachineTaskEnvironmentLocatorValue;
|
|
214
|
+
readonly #deps: Required<
|
|
215
|
+
Pick<MachineTaskEnvironmentDeps, "machines" | "spawn" | "readyTimeoutMs">
|
|
216
|
+
> &
|
|
217
|
+
MachineTaskEnvironmentDeps;
|
|
218
|
+
/** The machine's own /etc/environment, read once (see #anchoredBaseline). */
|
|
219
|
+
#machineWide: Promise<Record<string, string>> | null = null;
|
|
220
|
+
|
|
221
|
+
constructor(
|
|
222
|
+
locator: TaskEnvironmentLocator,
|
|
223
|
+
deps: MachineTaskEnvironmentDeps,
|
|
224
|
+
envMode: ProjectEnvMode,
|
|
225
|
+
) {
|
|
226
|
+
this.#value = parseMachineTaskEnvironmentLocator(locator);
|
|
227
|
+
this.#deps = {
|
|
228
|
+
...deps,
|
|
229
|
+
machines: deps.machines,
|
|
230
|
+
spawn: deps.spawn ?? nodeSpawn,
|
|
231
|
+
readyTimeoutMs: deps.readyTimeoutMs ?? 120_000,
|
|
232
|
+
};
|
|
233
|
+
this.durableIdentity = this.#value.machineId;
|
|
234
|
+
this.descriptor = {
|
|
235
|
+
taskId: this.#value.taskId,
|
|
236
|
+
locator,
|
|
237
|
+
envMode,
|
|
238
|
+
workspacePath: this.#value.workspacePath,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Resolve the CURRENT dial target. Cloud machines change address across
|
|
243
|
+
* stop/start, so the locator never stores one — describe is the only
|
|
244
|
+
* source of truth, and a non-running machine has no target at all. */
|
|
245
|
+
async #target(): Promise<{
|
|
246
|
+
address: string;
|
|
247
|
+
keyPath: string;
|
|
248
|
+
port?: number;
|
|
249
|
+
}> {
|
|
250
|
+
const info = await this.#describeOwn();
|
|
251
|
+
if (info.state !== "running" || info.address === null) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`machine ${this.#value.machineId} is not reachable (${info.state})`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
address: info.address,
|
|
258
|
+
keyPath: this.#value.privateKeyPath,
|
|
259
|
+
...(info.sshPort !== undefined ? { port: info.sshPort } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Describe with the ownership check every mutation rides on: a machine
|
|
264
|
+
* that does not carry OUR task label is a foreign occupant of the id and
|
|
265
|
+
* is treated as unknown — never dialed, never stopped, never terminated. */
|
|
266
|
+
async #describeOwn(): Promise<MachineInfo> {
|
|
267
|
+
const info = await this.#deps.machines.describe(this.#value.machineId);
|
|
268
|
+
if (
|
|
269
|
+
(info.state === "running" || info.state === "stopped") &&
|
|
270
|
+
info.taskLabel !== this.#value.taskId
|
|
271
|
+
) {
|
|
272
|
+
return {
|
|
273
|
+
id: info.id,
|
|
274
|
+
taskLabel: info.taskLabel,
|
|
275
|
+
state: "unknown",
|
|
276
|
+
address: null,
|
|
277
|
+
detail: "machine does not carry this task's ownership label",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return info;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* ADR-154 §6: an anchored launch starts from nothing of its ssh session's
|
|
285
|
+
* environment (sshd's, PAM's, the login's: an older image may leave an
|
|
286
|
+
* owner's sign-in there), only from what it names: a PATH, the machine's
|
|
287
|
+
* /etc/environment as pam_env would apply it (never an engine's
|
|
288
|
+
* credential, nor an agent's task token), and its own env. Anything else
|
|
289
|
+
* is unchanged.
|
|
290
|
+
*/
|
|
291
|
+
async #anchoredBaseline<
|
|
292
|
+
T extends { anchor?: unknown; env?: Readonly<Record<string, string>> },
|
|
293
|
+
>(request: T): Promise<T & { clean?: true }> {
|
|
294
|
+
if (!request.anchor) return request;
|
|
295
|
+
const machineWide = await this.#machineWideEnvironment();
|
|
296
|
+
return {
|
|
297
|
+
...request,
|
|
298
|
+
// It rides the command line: no secret of the machine's on it.
|
|
299
|
+
env: { PATH: BASELINE_PATH, ...withoutLaunchSecrets(machineWide), ...request.env },
|
|
300
|
+
clean: true,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The machine's /etc/environment without any engine's credential, read
|
|
305
|
+
* once over ssh (absent: empty); read again after a failed read. */
|
|
306
|
+
#machineWideEnvironment(): Promise<Record<string, string>> {
|
|
307
|
+
if (this.#machineWide) return this.#machineWide;
|
|
308
|
+
const reading = (async () => {
|
|
309
|
+
const result = await this.exec({
|
|
310
|
+
argv: ["/bin/cat", ETC_ENVIRONMENT_PATH],
|
|
311
|
+
projectEnvProfile: "maintenance",
|
|
312
|
+
timeoutMs: 30_000,
|
|
313
|
+
maxOutputBytes: 64 * 1024,
|
|
314
|
+
});
|
|
315
|
+
if (result.exitCode === 1 && result.stdout.length === 0) return {};
|
|
316
|
+
if (result.exitCode !== 0 || result.stdoutTruncated) {
|
|
317
|
+
throw new Error(`machine ${this.#value.machineId}'s environment could not be read`);
|
|
318
|
+
}
|
|
319
|
+
return withoutEngineCredentials(parseEtcEnvironment(Buffer.from(result.stdout).toString("utf8")));
|
|
320
|
+
})();
|
|
321
|
+
this.#machineWide = reading;
|
|
322
|
+
reading.catch(() => {
|
|
323
|
+
if (this.#machineWide === reading) this.#machineWide = null;
|
|
324
|
+
});
|
|
325
|
+
return reading;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#maintenanceRequest<
|
|
329
|
+
T extends {
|
|
330
|
+
argv: readonly [string, ...string[]];
|
|
331
|
+
cwd?: string;
|
|
332
|
+
user?: string;
|
|
333
|
+
inheritEnv?: readonly string[];
|
|
334
|
+
env?: Readonly<Record<string, string>>;
|
|
335
|
+
},
|
|
336
|
+
>(
|
|
337
|
+
request: T,
|
|
338
|
+
): T & { projectEnvProfile: "maintenance" } {
|
|
339
|
+
return applyProjectEnvProfile(
|
|
340
|
+
{ ...request, projectEnvProfile: "maintenance" as const },
|
|
341
|
+
this.descriptor.envMode ?? "baked",
|
|
342
|
+
"/bin/bash",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async readWorkspaceFile(path: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
|
|
347
|
+
assertWorkspaceScopedPath(this.descriptor, path);
|
|
348
|
+
const maxBytes = Math.max(0, Math.floor(options?.maxBytes ?? WORKSPACE_READ_MAX_BYTES));
|
|
349
|
+
const target = await this.#target();
|
|
350
|
+
options?.admit?.();
|
|
351
|
+
const result = await capturedCliExec(
|
|
352
|
+
this.#deps.spawn,
|
|
353
|
+
"ssh",
|
|
354
|
+
machineEnvironmentSshArgs(
|
|
355
|
+
target,
|
|
356
|
+
this.#maintenanceRequest({
|
|
357
|
+
argv: [
|
|
358
|
+
"/bin/sh",
|
|
359
|
+
"-c",
|
|
360
|
+
MACHINE_WORKSPACE_READ_SCRIPT,
|
|
361
|
+
"read",
|
|
362
|
+
path,
|
|
363
|
+
String(maxBytes),
|
|
364
|
+
options?.from === "end" ? "end" : "start",
|
|
365
|
+
],
|
|
366
|
+
}),
|
|
367
|
+
"interactive",
|
|
368
|
+
),
|
|
369
|
+
{
|
|
370
|
+
argv: ["/bin/cat"],
|
|
371
|
+
projectEnvProfile: "maintenance",
|
|
372
|
+
timeoutMs: 60_000,
|
|
373
|
+
maxOutputBytes: 64 * 1024 * 1024,
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
if (result.exitCode === 44) return null;
|
|
377
|
+
if (result.exitCode === 45) throw new NotARegularFileError();
|
|
378
|
+
if (result.exitCode !== 0) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`machine workspace read failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return result.stdout;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async writeWorkspaceFile(path: string, bytes: Uint8Array, options?: TaskEnvironmentCallOptions): Promise<void> {
|
|
387
|
+
assertWorkspaceScopedPath(this.descriptor, path);
|
|
388
|
+
const target = await this.#target();
|
|
389
|
+
options?.admit?.();
|
|
390
|
+
const result = await capturedCliExec(
|
|
391
|
+
this.#deps.spawn,
|
|
392
|
+
"ssh",
|
|
393
|
+
machineEnvironmentSshArgs(
|
|
394
|
+
target,
|
|
395
|
+
this.#maintenanceRequest({
|
|
396
|
+
argv: [
|
|
397
|
+
"/bin/sh",
|
|
398
|
+
"-c",
|
|
399
|
+
`mkdir -p "$(dirname "$1")" && cat > "$1"`,
|
|
400
|
+
"write",
|
|
401
|
+
path,
|
|
402
|
+
],
|
|
403
|
+
}),
|
|
404
|
+
"interactive",
|
|
405
|
+
),
|
|
406
|
+
{
|
|
407
|
+
argv: ["/bin/sh"],
|
|
408
|
+
projectEnvProfile: "maintenance",
|
|
409
|
+
stdin: bytes,
|
|
410
|
+
timeoutMs: 120_000,
|
|
411
|
+
maxOutputBytes: 1024 * 1024,
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
if (result.exitCode !== 0) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`machine workspace write failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async exec(
|
|
422
|
+
request: TaskEnvironmentExecRequest,
|
|
423
|
+
): Promise<TaskEnvironmentExecResult> {
|
|
424
|
+
assertTaskEnvironmentExecRequest(request);
|
|
425
|
+
refusePrivilegedExec(request);
|
|
426
|
+
const profiled = applyProjectEnvProfile(
|
|
427
|
+
rootAnchoredRequest(await this.#anchoredBaseline(request)),
|
|
428
|
+
this.descriptor.envMode ?? "baked",
|
|
429
|
+
"/bin/bash",
|
|
430
|
+
);
|
|
431
|
+
const target = await this.#target();
|
|
432
|
+
request.admit?.();
|
|
433
|
+
return capturedCliExec(
|
|
434
|
+
this.#deps.spawn,
|
|
435
|
+
"ssh",
|
|
436
|
+
machineEnvironmentSshArgs(target, profiled, "interactive"),
|
|
437
|
+
profiled,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async spawn(
|
|
442
|
+
request: TaskEnvironmentSpawnRequest,
|
|
443
|
+
): Promise<TaskEnvironmentProcess> {
|
|
444
|
+
assertTaskEnvironmentProcessRequest(request);
|
|
445
|
+
const profiled = applyProjectEnvProfile(
|
|
446
|
+
rootAnchoredRequest(await this.#anchoredBaseline(request)),
|
|
447
|
+
this.descriptor.envMode ?? "baked",
|
|
448
|
+
"/bin/bash",
|
|
449
|
+
);
|
|
450
|
+
const target = await this.#target();
|
|
451
|
+
request.admit?.();
|
|
452
|
+
return streamingCliExec(
|
|
453
|
+
this.#deps.spawn,
|
|
454
|
+
"ssh",
|
|
455
|
+
machineEnvironmentSshArgs(target, profiled, "interactive"),
|
|
456
|
+
profiled,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async spawnSession(
|
|
461
|
+
request: TaskEnvironmentSessionRequest,
|
|
462
|
+
): Promise<TaskEnvironmentProcess> {
|
|
463
|
+
assertTaskEnvironmentSessionRequest(request);
|
|
464
|
+
// Sessions default to the workspace the way container images default to
|
|
465
|
+
// WORKDIR /workspace — an agent CLI must start in the task's world.
|
|
466
|
+
const sessionRequest = applyProjectEnvProfile(rootAnchoredRequest(await this.#anchoredBaseline({
|
|
467
|
+
...request,
|
|
468
|
+
cwd: request.cwd ?? this.#value.workspacePath,
|
|
469
|
+
})), this.descriptor.envMode ?? "baked", "/bin/bash");
|
|
470
|
+
const target = await this.#target();
|
|
471
|
+
request.admit?.();
|
|
472
|
+
return streamingCliExec(
|
|
473
|
+
this.#deps.spawn,
|
|
474
|
+
"ssh",
|
|
475
|
+
machineEnvironmentSshArgs(target, sessionRequest, "interactive"),
|
|
476
|
+
sessionRequest,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async launchDetachedSession(
|
|
481
|
+
request: TaskEnvironmentDetachedSessionRequest,
|
|
482
|
+
): Promise<TaskEnvironmentExecResult> {
|
|
483
|
+
assertTaskEnvironmentSessionRequest(request);
|
|
484
|
+
const sessionRequest = applyProjectEnvProfile(rootAnchoredRequest(await this.#anchoredBaseline({
|
|
485
|
+
...request,
|
|
486
|
+
cwd: request.cwd ?? this.#value.workspacePath,
|
|
487
|
+
})), this.descriptor.envMode ?? "baked", "/bin/bash");
|
|
488
|
+
const target = await this.#target();
|
|
489
|
+
request.admit?.();
|
|
490
|
+
return capturedCliExec(
|
|
491
|
+
this.#deps.spawn,
|
|
492
|
+
"ssh",
|
|
493
|
+
machineEnvironmentSshArgs(target, sessionRequest, "detached"),
|
|
494
|
+
{
|
|
495
|
+
...sessionRequest,
|
|
496
|
+
timeoutMs: sessionRequest.launchTimeoutMs,
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
|
|
502
|
+
if (
|
|
503
|
+
!request.source.startsWith("/") ||
|
|
504
|
+
!request.destination.startsWith("/") ||
|
|
505
|
+
request.source.includes("\0") ||
|
|
506
|
+
request.destination.includes("\0")
|
|
507
|
+
) {
|
|
508
|
+
throw new Error("machine copy paths must be absolute and NUL-free");
|
|
509
|
+
}
|
|
510
|
+
const target = await this.#target();
|
|
511
|
+
if (request.direction === "into") {
|
|
512
|
+
// Directory sources tar-stream over the transport (engine-account
|
|
513
|
+
// homes — live 2026-08-27: codex's auth.json never reached the
|
|
514
|
+
// machine because readFileSync(dir) threw EISDIR and the account
|
|
515
|
+
// materialization died). Single files keep the direct cat path.
|
|
516
|
+
if (statSync(request.source).isDirectory()) {
|
|
517
|
+
const tarball = await promisify(execFile)(
|
|
518
|
+
"tar",
|
|
519
|
+
["-C", request.source, "-cf", "-", "."],
|
|
520
|
+
{ encoding: "buffer", maxBuffer: 256 * 1024 * 1024 },
|
|
521
|
+
);
|
|
522
|
+
request.admit?.();
|
|
523
|
+
const extract = await capturedCliExec(
|
|
524
|
+
this.#deps.spawn,
|
|
525
|
+
"ssh",
|
|
526
|
+
machineEnvironmentSshArgs(
|
|
527
|
+
target,
|
|
528
|
+
this.#maintenanceRequest({
|
|
529
|
+
argv: [
|
|
530
|
+
"/bin/sh",
|
|
531
|
+
"-c",
|
|
532
|
+
'mkdir -p "$1" && tar -C "$1" -xf -',
|
|
533
|
+
"copy",
|
|
534
|
+
request.destination,
|
|
535
|
+
],
|
|
536
|
+
}),
|
|
537
|
+
"interactive",
|
|
538
|
+
),
|
|
539
|
+
{
|
|
540
|
+
argv: ["/bin/sh"],
|
|
541
|
+
projectEnvProfile: "maintenance",
|
|
542
|
+
stdin: tarball.stdout,
|
|
543
|
+
timeoutMs: 300_000,
|
|
544
|
+
maxOutputBytes: 1024 * 1024,
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
if (extract.exitCode !== 0) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`machine directory copy failed: ${Buffer.from(extract.stderr).toString("utf8").trim()}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const bytes = readFileSync(request.source);
|
|
555
|
+
request.admit?.();
|
|
556
|
+
const result = await capturedCliExec(
|
|
557
|
+
this.#deps.spawn,
|
|
558
|
+
"ssh",
|
|
559
|
+
machineEnvironmentSshArgs(
|
|
560
|
+
target,
|
|
561
|
+
this.#maintenanceRequest({
|
|
562
|
+
argv: [
|
|
563
|
+
"/bin/sh",
|
|
564
|
+
"-c",
|
|
565
|
+
`mkdir -p "$(dirname "$1")" && cat > "$1"`,
|
|
566
|
+
"copy",
|
|
567
|
+
request.destination,
|
|
568
|
+
],
|
|
569
|
+
}),
|
|
570
|
+
"interactive",
|
|
571
|
+
),
|
|
572
|
+
{
|
|
573
|
+
argv: ["/bin/sh"],
|
|
574
|
+
projectEnvProfile: "maintenance",
|
|
575
|
+
stdin: bytes,
|
|
576
|
+
timeoutMs: 120_000,
|
|
577
|
+
maxOutputBytes: 1024 * 1024,
|
|
578
|
+
},
|
|
579
|
+
);
|
|
580
|
+
if (result.exitCode !== 0) {
|
|
581
|
+
throw new Error(
|
|
582
|
+
`machine copy into failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
request.admit?.();
|
|
588
|
+
const result = await capturedCliExec(
|
|
589
|
+
this.#deps.spawn,
|
|
590
|
+
"ssh",
|
|
591
|
+
machineEnvironmentSshArgs(
|
|
592
|
+
target,
|
|
593
|
+
this.#maintenanceRequest({ argv: ["/bin/cat", request.source] }),
|
|
594
|
+
"interactive",
|
|
595
|
+
),
|
|
596
|
+
{
|
|
597
|
+
argv: ["/bin/cat"],
|
|
598
|
+
projectEnvProfile: "maintenance",
|
|
599
|
+
timeoutMs: 120_000,
|
|
600
|
+
maxOutputBytes: 64 * 1024 * 1024,
|
|
601
|
+
},
|
|
602
|
+
);
|
|
603
|
+
if (result.exitCode !== 0) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
`machine copy out failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
writeFileSync(request.destination, Buffer.from(result.stdout));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async ports(): Promise<TaskEnvironmentPort[]> {
|
|
612
|
+
// Direct-IP model, like the Apple provider: nothing is published on the
|
|
613
|
+
// host; routing resolves the machine address per connection below.
|
|
614
|
+
return [];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async inspectRoute(): Promise<
|
|
618
|
+
| { kind: "running"; ipv4Address: string }
|
|
619
|
+
| { kind: "unavailable" }
|
|
620
|
+
| { kind: "unknown"; detail: string }
|
|
621
|
+
> {
|
|
622
|
+
const info = await this.#describeOwn();
|
|
623
|
+
if (info.state === "running" && info.address !== null) {
|
|
624
|
+
return { kind: "running", ipv4Address: info.address };
|
|
625
|
+
}
|
|
626
|
+
if (info.state === "stopped" || info.state === "absent") {
|
|
627
|
+
return { kind: "unavailable" };
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
kind: "unknown",
|
|
631
|
+
detail: info.detail ?? `machine state ${info.state}`,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Full ssh argv for the persistent REVERSE gateway tunnel: the host's
|
|
636
|
+
* loopback gateway appears at the machine's own 127.0.0.1:<machinePort>.
|
|
637
|
+
* Resolved per call — cloud machines change address across stop/start, so
|
|
638
|
+
* each (re)spawn of the tunnel re-resolves the dial target. */
|
|
639
|
+
async gatewayTunnelSshArgs(
|
|
640
|
+
machinePort: number,
|
|
641
|
+
hostPort: number,
|
|
642
|
+
options?: TaskEnvironmentCallOptions,
|
|
643
|
+
): Promise<string[]> {
|
|
644
|
+
const target = await this.#target();
|
|
645
|
+
options?.admit?.();
|
|
646
|
+
return [
|
|
647
|
+
"-o", "BatchMode=yes",
|
|
648
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
649
|
+
"-o", "ConnectTimeout=10",
|
|
650
|
+
"-o", "ExitOnForwardFailure=yes",
|
|
651
|
+
"-o", "ServerAliveInterval=15",
|
|
652
|
+
"-o", "ServerAliveCountMax=3",
|
|
653
|
+
"-i", target.keyPath,
|
|
654
|
+
"-p", String(target.port ?? 22),
|
|
655
|
+
"-l", this.#value.sshUser,
|
|
656
|
+
target.address,
|
|
657
|
+
"-N",
|
|
658
|
+
"-R", `127.0.0.1:${machinePort}:127.0.0.1:${hostPort}`,
|
|
659
|
+
];
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async status(): Promise<TaskEnvironmentStatus> {
|
|
663
|
+
const info = await this.#describeOwn();
|
|
664
|
+
switch (info.state) {
|
|
665
|
+
case "running":
|
|
666
|
+
return {
|
|
667
|
+
state: "running",
|
|
668
|
+
instances: [this.#value.machineId],
|
|
669
|
+
workspacePresent: await this.#workspacePresent(),
|
|
670
|
+
};
|
|
671
|
+
case "pending":
|
|
672
|
+
// Pending is running-shaped for status purposes: resources exist.
|
|
673
|
+
return {
|
|
674
|
+
state: "running",
|
|
675
|
+
instances: [this.#value.machineId],
|
|
676
|
+
workspacePresent: false,
|
|
677
|
+
};
|
|
678
|
+
case "stopped":
|
|
679
|
+
// The workspace lives on the machine's own disk, which stop keeps
|
|
680
|
+
// by design (EBS / container filesystem). Not observable while
|
|
681
|
+
// stopped; asserted from the architecture, not probed.
|
|
682
|
+
return {
|
|
683
|
+
state: "stopped",
|
|
684
|
+
instances: [this.#value.machineId],
|
|
685
|
+
workspacePresent: true,
|
|
686
|
+
...(info.frozen ? { frozen: true as const } : {}),
|
|
687
|
+
};
|
|
688
|
+
case "absent":
|
|
689
|
+
return { state: "absent", instances: [], workspacePresent: false };
|
|
690
|
+
default:
|
|
691
|
+
return {
|
|
692
|
+
state: "unknown",
|
|
693
|
+
detail: info.detail ?? "machine state unknown",
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async #workspacePresent(): Promise<boolean> {
|
|
699
|
+
try {
|
|
700
|
+
const target = await this.#target();
|
|
701
|
+
const result = await capturedCliExec(
|
|
702
|
+
this.#deps.spawn,
|
|
703
|
+
"ssh",
|
|
704
|
+
machineEnvironmentSshArgs(
|
|
705
|
+
target,
|
|
706
|
+
this.#maintenanceRequest({
|
|
707
|
+
argv: ["/usr/bin/test", "-d", this.#value.workspacePath],
|
|
708
|
+
}),
|
|
709
|
+
"interactive",
|
|
710
|
+
),
|
|
711
|
+
{
|
|
712
|
+
argv: ["/usr/bin/test"],
|
|
713
|
+
projectEnvProfile: "maintenance",
|
|
714
|
+
timeoutMs: 15_000,
|
|
715
|
+
maxOutputBytes: 4 * 1024,
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
return result.exitCode === 0;
|
|
719
|
+
} catch {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async recover(
|
|
725
|
+
context: TaskEnvironmentRecoveryContext,
|
|
726
|
+
): Promise<TaskEnvironmentRecoveryResult> {
|
|
727
|
+
await context.maintenanceReady;
|
|
728
|
+
if (!context.isCurrent()) {
|
|
729
|
+
return { outcome: "deferred", detail: "runtime generation superseded" };
|
|
730
|
+
}
|
|
731
|
+
const info = await this.#describeOwn();
|
|
732
|
+
if (info.state === "unknown") {
|
|
733
|
+
return {
|
|
734
|
+
outcome: "deferred",
|
|
735
|
+
detail: info.detail ?? "machine state unknown",
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
if (info.state === "absent" || info.state === "stopped") {
|
|
739
|
+
// Durable truths recovery may settle without touching anything.
|
|
740
|
+
return { outcome: "recovered", status: await this.status() };
|
|
741
|
+
}
|
|
742
|
+
// Running (or pending): the machine must actually answer before
|
|
743
|
+
// consumers are re-admitted — a wedged guest that describes as running
|
|
744
|
+
// must not be exposed (this week's OOM lesson, applied to machines).
|
|
745
|
+
const answered = await this.#sshAnswers();
|
|
746
|
+
if (!answered) {
|
|
747
|
+
return {
|
|
748
|
+
outcome: "deferred",
|
|
749
|
+
detail: "machine describes as running but does not answer ssh",
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
if (!context.isCurrent()) {
|
|
753
|
+
return { outcome: "deferred", detail: "runtime generation superseded" };
|
|
754
|
+
}
|
|
755
|
+
await context.prepareConsumers?.();
|
|
756
|
+
context.allowConsumers?.();
|
|
757
|
+
await context.ensureConsumersStarted?.();
|
|
758
|
+
if (!context.isCurrent()) {
|
|
759
|
+
await context.quarantineConsumers?.();
|
|
760
|
+
return { outcome: "deferred", detail: "runtime generation superseded" };
|
|
761
|
+
}
|
|
762
|
+
return { outcome: "recovered", status: await this.status() };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async #sshAnswers(): Promise<boolean> {
|
|
766
|
+
try {
|
|
767
|
+
const target = await this.#target();
|
|
768
|
+
const result = await capturedCliExec(
|
|
769
|
+
this.#deps.spawn,
|
|
770
|
+
"ssh",
|
|
771
|
+
machineEnvironmentSshArgs(
|
|
772
|
+
target,
|
|
773
|
+
this.#maintenanceRequest({ argv: ["/bin/true"] }),
|
|
774
|
+
"interactive",
|
|
775
|
+
),
|
|
776
|
+
{
|
|
777
|
+
argv: ["/bin/true"],
|
|
778
|
+
projectEnvProfile: "maintenance",
|
|
779
|
+
timeoutMs: 15_000,
|
|
780
|
+
maxOutputBytes: 4 * 1024,
|
|
781
|
+
},
|
|
782
|
+
);
|
|
783
|
+
return result.exitCode === 0;
|
|
784
|
+
} catch {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async stop(): Promise<void> {
|
|
790
|
+
const info = await this.#describeOwn();
|
|
791
|
+
if (info.state === "stopped" || info.state === "absent") return;
|
|
792
|
+
if (info.state === "unknown") {
|
|
793
|
+
throw new Error(
|
|
794
|
+
`machine ${this.#value.machineId} cannot be stopped: ${
|
|
795
|
+
info.detail ?? "state unknown"
|
|
796
|
+
}`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
await this.#deps.machines.stop(this.#value.machineId);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async teardown(): Promise<void> {
|
|
803
|
+
const info = await this.#describeOwn();
|
|
804
|
+
if (info.state === "absent") return;
|
|
805
|
+
if (info.state === "unknown") {
|
|
806
|
+
// Terminating an id we cannot prove is ours would be destruction
|
|
807
|
+
// without authority — the same refusal task-down makes for containers.
|
|
808
|
+
throw new Error(
|
|
809
|
+
`machine ${this.#value.machineId} cannot be torn down: ${
|
|
810
|
+
info.detail ?? "ownership unprovable"
|
|
811
|
+
}`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
// terminate() resolves only after proven absence (provider contract).
|
|
815
|
+
await this.#deps.machines.terminate(this.#value.machineId);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function createMachineTaskEnvironmentProvider(
|
|
820
|
+
deps: MachineTaskEnvironmentDeps,
|
|
821
|
+
): TaskEnvironmentProvider<MachineTaskInput, void, TaskEnvironmentDescriptor> {
|
|
822
|
+
return {
|
|
823
|
+
kind: MACHINE_TASK_ENVIRONMENT_PROVIDER,
|
|
824
|
+
|
|
825
|
+
async provision(
|
|
826
|
+
request: TaskEnvironmentProvisionRequest<MachineTaskInput, void>,
|
|
827
|
+
): Promise<TaskEnvironmentProvisioned<TaskEnvironmentDescriptor>> {
|
|
828
|
+
const { taskId, input } = request;
|
|
829
|
+
if (!SAFE_TASK_ID.test(taskId)) {
|
|
830
|
+
throw new Error("machine provisioning refused an unsafe task id");
|
|
831
|
+
}
|
|
832
|
+
const workspacePath = input.workspacePath ?? "/workspace";
|
|
833
|
+
const keys = await deps.mintKeyPair(deps.taskControlDir(taskId));
|
|
834
|
+
// The locator is durable BEFORE any external resource exists: a crash
|
|
835
|
+
// after onPrepared is recoverable from the locator alone, and the
|
|
836
|
+
// machine id is derived (never invented post-launch) so the locator
|
|
837
|
+
// written here names exactly what launch will create.
|
|
838
|
+
const machineId = `uai-machine-${taskId.toLowerCase()}`;
|
|
839
|
+
const locator = machineTaskEnvironmentLocator({
|
|
840
|
+
taskId,
|
|
841
|
+
machineId,
|
|
842
|
+
machineKind: deps.machines.kind,
|
|
843
|
+
privateKeyPath: keys.privateKeyPath,
|
|
844
|
+
workspacePath,
|
|
845
|
+
sshUser: "node",
|
|
846
|
+
});
|
|
847
|
+
await request.onPrepared?.(locator);
|
|
848
|
+
|
|
849
|
+
// Resume-aware launch: a machine may already exist for this task
|
|
850
|
+
// (host restart mid-provision, task resume after stop). Only a
|
|
851
|
+
// machine PROVEN to carry this task's ownership label may be reused;
|
|
852
|
+
// an unlabeled or foreign occupant of the name fails the provision —
|
|
853
|
+
// it is never adopted and never terminated from here.
|
|
854
|
+
const existing = await deps.machines.describe(machineId);
|
|
855
|
+
if (existing.state !== "absent" && existing.taskLabel !== taskId) {
|
|
856
|
+
throw new Error(
|
|
857
|
+
`machine name ${machineId} is occupied by a resource not labeled for task ${taskId}`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
if (existing.state === "unknown") {
|
|
861
|
+
throw new Error(
|
|
862
|
+
`machine ${machineId} state is unknown: ${existing.detail ?? "no detail"}`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
const reused = existing.state !== "absent";
|
|
866
|
+
if (existing.state === "absent") {
|
|
867
|
+
const launched = await deps.machines.launch({
|
|
868
|
+
taskId,
|
|
869
|
+
image: input.image,
|
|
870
|
+
cpus: input.cpus,
|
|
871
|
+
memoryMiB: input.memoryMiB,
|
|
872
|
+
authorizedPublicKey: keys.publicKey,
|
|
873
|
+
...(input.publishSsh ? { publishSsh: true } : {}),
|
|
874
|
+
});
|
|
875
|
+
if (launched.id !== machineId) {
|
|
876
|
+
// The backend named the machine something other than what the
|
|
877
|
+
// durable locator promises — tear it down rather than strand an
|
|
878
|
+
// untracked machine, then fail the provision loudly.
|
|
879
|
+
await deps.machines.terminate(launched.id);
|
|
880
|
+
throw new Error(
|
|
881
|
+
`machine backend named ${launched.id}, locator promised ${machineId}`,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
} else if (existing.state === "stopped") {
|
|
885
|
+
await deps.machines.start(machineId);
|
|
886
|
+
}
|
|
887
|
+
// running/pending: fall through to the ssh-readiness proof below.
|
|
888
|
+
|
|
889
|
+
const handle = new MachineTaskEnvironmentHandle(
|
|
890
|
+
locator,
|
|
891
|
+
deps,
|
|
892
|
+
request.envMode ?? "loader",
|
|
893
|
+
);
|
|
894
|
+
await waitForSsh(handle, deps.readyTimeoutMs ?? 120_000);
|
|
895
|
+
|
|
896
|
+
// Workspace + optional in-env clone (ADR-121: the clone happens in the
|
|
897
|
+
// task's world; the host never materializes task code).
|
|
898
|
+
// As root: the workspace root is typically directly under a
|
|
899
|
+
// root-owned / (cloud images and the local image alike), then handed
|
|
900
|
+
// to the task user — the same gesture cloud user-data makes.
|
|
901
|
+
const prepare = await handle.exec({
|
|
902
|
+
argv: [
|
|
903
|
+
"/bin/sh",
|
|
904
|
+
"-c",
|
|
905
|
+
`mkdir -p "$1" && chown node:node "$1"`,
|
|
906
|
+
"prepare",
|
|
907
|
+
workspacePath,
|
|
908
|
+
],
|
|
909
|
+
user: "root",
|
|
910
|
+
projectEnvProfile: "maintenance",
|
|
911
|
+
timeoutMs: 30_000,
|
|
912
|
+
maxOutputBytes: 64 * 1024,
|
|
913
|
+
});
|
|
914
|
+
if (prepare.exitCode !== 0) {
|
|
915
|
+
throw new Error(
|
|
916
|
+
`machine workspace preparation failed: ${Buffer.from(prepare.stderr).toString("utf8").trim()}`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
if (input.repo) {
|
|
920
|
+
const cloneArgv: [string, ...string[]] = [
|
|
921
|
+
"/usr/bin/git",
|
|
922
|
+
"clone",
|
|
923
|
+
...(input.repo.branch ? ["--branch", input.repo.branch] : []),
|
|
924
|
+
input.repo.url,
|
|
925
|
+
workspacePath,
|
|
926
|
+
];
|
|
927
|
+
const clone = await handle.exec({
|
|
928
|
+
argv: cloneArgv,
|
|
929
|
+
projectEnvProfile: "maintenance",
|
|
930
|
+
timeoutMs: 10 * 60_000,
|
|
931
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
932
|
+
});
|
|
933
|
+
if (clone.exitCode !== 0) {
|
|
934
|
+
throw new Error(
|
|
935
|
+
`in-env clone failed: ${Buffer.from(clone.stderr).toString("utf8").trim()}`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return { handle, result: handle.descriptor, reused };
|
|
940
|
+
},
|
|
941
|
+
|
|
942
|
+
async reconstruct(
|
|
943
|
+
locator: TaskEnvironmentLocator,
|
|
944
|
+
envMode: ProjectEnvMode = "baked",
|
|
945
|
+
): Promise<TaskEnvironmentHandle> {
|
|
946
|
+
return new MachineTaskEnvironmentHandle(locator, deps, envMode);
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function waitForSsh(
|
|
952
|
+
handle: MachineTaskEnvironmentHandle,
|
|
953
|
+
budgetMs: number,
|
|
954
|
+
): Promise<void> {
|
|
955
|
+
const deadline = Date.now() + budgetMs;
|
|
956
|
+
let lastDetail = "ssh never answered";
|
|
957
|
+
while (Date.now() < deadline) {
|
|
958
|
+
try {
|
|
959
|
+
const probe = await handle.exec({
|
|
960
|
+
argv: ["/bin/true"],
|
|
961
|
+
projectEnvProfile: "maintenance",
|
|
962
|
+
timeoutMs: 10_000,
|
|
963
|
+
maxOutputBytes: 4 * 1024,
|
|
964
|
+
});
|
|
965
|
+
if (probe.exitCode === 0) return;
|
|
966
|
+
lastDetail = Buffer.from(probe.stderr).toString("utf8").trim() ||
|
|
967
|
+
`probe exited ${probe.exitCode ?? "killed"}`;
|
|
968
|
+
} catch (error) {
|
|
969
|
+
lastDetail = error instanceof Error ? error.message : String(error);
|
|
970
|
+
}
|
|
971
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 2_000));
|
|
972
|
+
}
|
|
973
|
+
throw new Error(`machine never became reachable over ssh: ${lastDetail}`);
|
|
974
|
+
}
|