@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,467 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
|
|
5
|
+
import { RUNTIME_RECHECK_COMMAND } from "../container-runtime";
|
|
6
|
+
import { dockerCli, type DockerResult } from "../docker-exec";
|
|
7
|
+
import { getDb, schema, type HostTask } from "../db";
|
|
8
|
+
import { parsePreviewPortRuntimes } from "../preview-ports";
|
|
9
|
+
import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
|
|
10
|
+
import { assertSafeHostTaskId } from "../task-identity";
|
|
11
|
+
import {
|
|
12
|
+
APPLE_TASK_ENVIRONMENT_PROVIDER,
|
|
13
|
+
parseAppleTaskEnvironmentLocator,
|
|
14
|
+
} from "./apple-container";
|
|
15
|
+
import {
|
|
16
|
+
dockerTaskEnvironmentLocator,
|
|
17
|
+
parseDockerTaskEnvironmentLocator,
|
|
18
|
+
type DockerMachineIdentity,
|
|
19
|
+
} from "./docker";
|
|
20
|
+
import type { HostMachineIdentity } from "./index";
|
|
21
|
+
import {
|
|
22
|
+
parseTaskEnvironmentLocator,
|
|
23
|
+
serializeTaskEnvironmentLocator,
|
|
24
|
+
type TaskEnvironmentLocator,
|
|
25
|
+
isEnvironmentTransportProvider,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
const INSPECT_OUTPUT_LIMIT = 128 * 1024;
|
|
29
|
+
const INVENTORY_OUTPUT_LIMIT = 128 * 1024;
|
|
30
|
+
const LEGACY_INSPECT_FORMAT =
|
|
31
|
+
'{{json .Id}}\t{{json .Name}}\t{{json (index .Config.Labels "com.docker.compose.project")}}\t{{json (index .Config.Labels "com.docker.compose.service")}}\t{{json .State.Status}}\t{{json .Mounts}}';
|
|
32
|
+
// The third column is the preview-sidecar marker: a sidecar can wear the
|
|
33
|
+
// task's compose project/service labels (teardown finds it by project), so
|
|
34
|
+
// the app-container census must see and skip it — counting one as an app
|
|
35
|
+
// container failed the WHOLE runtime adoption closed (live 2026-08-18).
|
|
36
|
+
const LEGACY_INVENTORY_FORMAT = `{{.ID}}\t{{.Names}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
|
|
37
|
+
|
|
38
|
+
type LegacyTaskRow = Pick<
|
|
39
|
+
HostTask,
|
|
40
|
+
| "taskId"
|
|
41
|
+
| "composeProject"
|
|
42
|
+
| "worktreePath"
|
|
43
|
+
| "environmentProvider"
|
|
44
|
+
| "environmentLocator"
|
|
45
|
+
| "codeServerPort"
|
|
46
|
+
| "previewPorts"
|
|
47
|
+
>;
|
|
48
|
+
|
|
49
|
+
interface LegacyCandidate {
|
|
50
|
+
row: LegacyTaskRow;
|
|
51
|
+
composeProject: string;
|
|
52
|
+
containerName: string;
|
|
53
|
+
workspaceSource: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface LegacyProof {
|
|
57
|
+
containerId: string;
|
|
58
|
+
containerName: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface LegacyAdoption {
|
|
62
|
+
snapshot: LegacyTaskRow;
|
|
63
|
+
locator: TaskEnvironmentLocator;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LegacyTaskEnvironmentAdoptionVerdict {
|
|
67
|
+
ok: boolean;
|
|
68
|
+
detail?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface LegacyTaskEnvironmentAdoptionDeps {
|
|
72
|
+
docker?: typeof dockerCli;
|
|
73
|
+
loadTasks?: () => ReadonlyArray<LegacyTaskRow>;
|
|
74
|
+
/** Must atomically re-probe and return the still-selected daemon identity. */
|
|
75
|
+
reprobeMachine: () => Promise<HostMachineIdentity | null>;
|
|
76
|
+
/** Test seam. Production uses one synchronous SQLite transaction. */
|
|
77
|
+
persist?: (adoptions: ReadonlyArray<LegacyAdoption>) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Adopt pre-provider rows without guessing which daemon owns them.
|
|
82
|
+
*
|
|
83
|
+
* Every Docker operation here is read-only. No locator is written until all
|
|
84
|
+
* legacy resources have an exact container proof and a final atomic daemon-ID
|
|
85
|
+
* re-probe confirms that the inspected engine is still the selected engine.
|
|
86
|
+
*/
|
|
87
|
+
export async function adoptLegacyTaskEnvironments(
|
|
88
|
+
machine: HostMachineIdentity,
|
|
89
|
+
deps: LegacyTaskEnvironmentAdoptionDeps,
|
|
90
|
+
): Promise<LegacyTaskEnvironmentAdoptionVerdict> {
|
|
91
|
+
const rows = [...(deps.loadTasks?.() ?? loadHostTasks())];
|
|
92
|
+
const candidates: LegacyCandidate[] = [];
|
|
93
|
+
try {
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
const candidate = legacyCandidate(row, machine);
|
|
96
|
+
if (candidate) candidates.push(candidate);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return failedAdoption(error);
|
|
100
|
+
}
|
|
101
|
+
if (candidates.length === 0) return { ok: true };
|
|
102
|
+
if (machine.backend !== "docker") {
|
|
103
|
+
// legacyCandidate already failed such rows; this narrows the type and
|
|
104
|
+
// stays fail-closed if that invariant ever changes.
|
|
105
|
+
return failedAdoption(
|
|
106
|
+
new Error(
|
|
107
|
+
`legacy ambient-Docker resources cannot move to ${machine.backend}`,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const docker = deps.docker ?? dockerCli;
|
|
113
|
+
const adoptions: LegacyAdoption[] = [];
|
|
114
|
+
try {
|
|
115
|
+
for (const candidate of candidates) {
|
|
116
|
+
const proof = await inspectLegacyCandidate(docker, candidate);
|
|
117
|
+
await proveNoConflictingAppContainer(docker, candidate, proof);
|
|
118
|
+
const row = candidate.row;
|
|
119
|
+
const locator = dockerTaskEnvironmentLocator(
|
|
120
|
+
row.taskId,
|
|
121
|
+
{
|
|
122
|
+
composeProject: candidate.composeProject,
|
|
123
|
+
worktreePath: row.worktreePath!,
|
|
124
|
+
...(row.codeServerPort === null
|
|
125
|
+
? {}
|
|
126
|
+
: { codeServerPort: row.codeServerPort }),
|
|
127
|
+
previewPorts: parsePreviewPortRuntimes(row.previewPorts),
|
|
128
|
+
},
|
|
129
|
+
machine,
|
|
130
|
+
);
|
|
131
|
+
adoptions.push({ snapshot: row, locator });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const reproved = await deps.reprobeMachine();
|
|
135
|
+
if (!sameMachine(machine, reproved)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"the Docker daemon identity changed during legacy resource inspection",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
(deps.persist ?? persistLegacyAdoptions)(adoptions);
|
|
141
|
+
return { ok: true };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return failedAdoption(error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function loadHostTasks(): LegacyTaskRow[] {
|
|
148
|
+
return getDb().select().from(schema.hostTasks).all();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function legacyCandidate(
|
|
152
|
+
row: LegacyTaskRow,
|
|
153
|
+
machine: HostMachineIdentity,
|
|
154
|
+
): LegacyCandidate | null {
|
|
155
|
+
if ((row.environmentProvider === null) !== (row.environmentLocator === null)) {
|
|
156
|
+
throw new Error(`task ${row.taskId} has incomplete provider metadata`);
|
|
157
|
+
}
|
|
158
|
+
if (row.environmentProvider !== null) {
|
|
159
|
+
const locator = parseTaskEnvironmentLocator(row.environmentLocator!);
|
|
160
|
+
if (locator.provider !== row.environmentProvider) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`task ${row.taskId} provider does not match its persisted locator`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
// A row with provider metadata is already adopted, whatever its backend:
|
|
166
|
+
// it only needs to belong to the currently-selected machine. Hard-coding
|
|
167
|
+
// docker-compose here wedged the first fresh Apple host (live
|
|
168
|
+
// 2026-08-15): a crash-preserved prepared apple locator made adoption —
|
|
169
|
+
// and with it runtime selection — fail closed, so the task could not
|
|
170
|
+
// even be settled.
|
|
171
|
+
// ADR-121: machine-backed rows are not container-runtime residents at
|
|
172
|
+
// all — their locator names an ephemeral VM, not a docker/apple daemon.
|
|
173
|
+
// They must never wedge container-runtime selection (live 2026-08-27:
|
|
174
|
+
// the first machine task quarantined the whole VM runtime on reconnect,
|
|
175
|
+
// collapsing every host command into a retry storm).
|
|
176
|
+
if (isEnvironmentTransportProvider(locator.provider)) return null;
|
|
177
|
+
let persistedMachine: HostMachineIdentity;
|
|
178
|
+
if (locator.provider === "docker-compose") {
|
|
179
|
+
persistedMachine = parseDockerTaskEnvironmentLocator(locator).machine;
|
|
180
|
+
} else if (locator.provider === APPLE_TASK_ENVIRONMENT_PROVIDER) {
|
|
181
|
+
persistedMachine = parseAppleTaskEnvironmentLocator(locator).machine;
|
|
182
|
+
} else {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`task ${row.taskId} belongs to unavailable environment provider ${locator.provider}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (!sameMachine(machine, persistedMachine)) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`task ${row.taskId} belongs to a different container backend identity`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (row.composeProject === null && row.worktreePath === null) return null;
|
|
195
|
+
if (row.composeProject === null || row.worktreePath === null) {
|
|
196
|
+
throw new Error(`task ${row.taskId} has incomplete legacy runtime identity`);
|
|
197
|
+
}
|
|
198
|
+
if (machine.backend !== "docker") {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`task ${row.taskId} has legacy ambient-Docker resources and cannot move to ${machine.backend}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
assertSafeHostTaskId(row.taskId);
|
|
204
|
+
const composeProject = `task-${row.taskId}`;
|
|
205
|
+
if (row.composeProject !== composeProject) {
|
|
206
|
+
throw new Error(`task ${row.taskId} has an unexpected legacy Compose project`);
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
row.worktreePath.length === 0 ||
|
|
210
|
+
row.worktreePath.length > 4_096 ||
|
|
211
|
+
!path.isAbsolute(row.worktreePath) ||
|
|
212
|
+
path.resolve(row.worktreePath) !== row.worktreePath ||
|
|
213
|
+
/[\0\r\n]/.test(row.worktreePath)
|
|
214
|
+
) {
|
|
215
|
+
throw new Error(`task ${row.taskId} has an unsafe legacy worktree path`);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
row,
|
|
219
|
+
composeProject,
|
|
220
|
+
containerName: `${composeProject}-app-1`,
|
|
221
|
+
workspaceSource: path.join(row.worktreePath, "workspace"),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function inspectLegacyCandidate(
|
|
226
|
+
docker: typeof dockerCli,
|
|
227
|
+
candidate: LegacyCandidate,
|
|
228
|
+
): Promise<LegacyProof | null> {
|
|
229
|
+
const result = await docker(
|
|
230
|
+
[
|
|
231
|
+
"inspect",
|
|
232
|
+
"--type",
|
|
233
|
+
"container",
|
|
234
|
+
"--format",
|
|
235
|
+
LEGACY_INSPECT_FORMAT,
|
|
236
|
+
candidate.containerName,
|
|
237
|
+
],
|
|
238
|
+
{ timeoutMs: 30_000, maxOutputBytes: INSPECT_OUTPUT_LIMIT },
|
|
239
|
+
);
|
|
240
|
+
// Exact absence is a settleable answer, not a failure: a pruned container
|
|
241
|
+
// must not strand the host in a degraded runtime with teardown fenced off.
|
|
242
|
+
// Only Docker's literal not-found error qualifies — a daemon that cannot
|
|
243
|
+
// answer still fails adoption closed.
|
|
244
|
+
if (
|
|
245
|
+
result.status !== 0 &&
|
|
246
|
+
/No such (?:container|object)/i.test(result.stderr)
|
|
247
|
+
) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const fields = exactTabFields(result, 6, "legacy app inspect");
|
|
251
|
+
const [containerId, containerName, composeProject, service, status, mounts] =
|
|
252
|
+
fields.map(parseJsonField);
|
|
253
|
+
if (!isContainerId(containerId)) {
|
|
254
|
+
throw new Error(`task ${candidate.row.taskId} returned an invalid container ID`);
|
|
255
|
+
}
|
|
256
|
+
if (containerName !== `/${candidate.containerName}`) {
|
|
257
|
+
throw new Error(`task ${candidate.row.taskId} app container name does not match`);
|
|
258
|
+
}
|
|
259
|
+
if (composeProject !== candidate.composeProject || service !== "app") {
|
|
260
|
+
throw new Error(`task ${candidate.row.taskId} Compose labels do not match`);
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
typeof status !== "string" ||
|
|
264
|
+
![
|
|
265
|
+
"created",
|
|
266
|
+
"running",
|
|
267
|
+
"paused",
|
|
268
|
+
"restarting",
|
|
269
|
+
"removing",
|
|
270
|
+
"exited",
|
|
271
|
+
"dead",
|
|
272
|
+
].includes(status)
|
|
273
|
+
) {
|
|
274
|
+
throw new Error(`task ${candidate.row.taskId} container state is invalid`);
|
|
275
|
+
}
|
|
276
|
+
validateWorkspaceMount(candidate, mounts);
|
|
277
|
+
return { containerId, containerName: candidate.containerName };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function validateWorkspaceMount(candidate: LegacyCandidate, value: unknown): void {
|
|
281
|
+
if (!Array.isArray(value) || value.length > 256) {
|
|
282
|
+
throw new Error(`task ${candidate.row.taskId} container mounts are invalid`);
|
|
283
|
+
}
|
|
284
|
+
const workspaceMounts = value.filter((entry) => {
|
|
285
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
286
|
+
const destination = (entry as Record<string, unknown>).Destination;
|
|
287
|
+
return (
|
|
288
|
+
typeof destination === "string" &&
|
|
289
|
+
path.posix.normalize(destination) === "/workspace"
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
if (workspaceMounts.length !== 1) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`task ${candidate.row.taskId} must have exactly one /workspace mount`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const mount = workspaceMounts[0] as Record<string, unknown>;
|
|
298
|
+
if (
|
|
299
|
+
mount.Type !== "bind" ||
|
|
300
|
+
mount.Destination !== "/workspace" ||
|
|
301
|
+
mount.Source !== candidate.workspaceSource
|
|
302
|
+
) {
|
|
303
|
+
throw new Error(`task ${candidate.row.taskId} workspace bind does not match`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function proveNoConflictingAppContainer(
|
|
308
|
+
docker: typeof dockerCli,
|
|
309
|
+
candidate: LegacyCandidate,
|
|
310
|
+
expected: LegacyProof | null,
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
const result = await docker(
|
|
313
|
+
[
|
|
314
|
+
"ps",
|
|
315
|
+
"--all",
|
|
316
|
+
"--no-trunc",
|
|
317
|
+
"--filter",
|
|
318
|
+
`label=com.docker.compose.project=${candidate.composeProject}`,
|
|
319
|
+
"--filter",
|
|
320
|
+
"label=com.docker.compose.service=app",
|
|
321
|
+
"--format",
|
|
322
|
+
LEGACY_INVENTORY_FORMAT,
|
|
323
|
+
],
|
|
324
|
+
{ timeoutMs: 30_000, maxOutputBytes: INVENTORY_OUTPUT_LIMIT },
|
|
325
|
+
);
|
|
326
|
+
if (
|
|
327
|
+
result.status !== 0 ||
|
|
328
|
+
result.outputTruncated === true ||
|
|
329
|
+
Buffer.byteLength(result.stdout) > INVENTORY_OUTPUT_LIMIT
|
|
330
|
+
) {
|
|
331
|
+
throw new Error(`task ${candidate.row.taskId} app inventory failed`);
|
|
332
|
+
}
|
|
333
|
+
if (result.stdout !== "" && !result.stdout.endsWith("\n")) {
|
|
334
|
+
throw new Error(`task ${candidate.row.taskId} app inventory failed`);
|
|
335
|
+
}
|
|
336
|
+
const appRows: string[][] = [];
|
|
337
|
+
for (const line of result.stdout === ""
|
|
338
|
+
? []
|
|
339
|
+
: result.stdout.slice(0, -1).split("\n")) {
|
|
340
|
+
const fields = line.split("\t");
|
|
341
|
+
if (fields.length !== 3) {
|
|
342
|
+
throw new Error(`task ${candidate.row.taskId} app inventory failed`);
|
|
343
|
+
}
|
|
344
|
+
// A preview sidecar wearing the task's compose labels is not an app
|
|
345
|
+
// container; counting one here failed the whole runtime adoption closed
|
|
346
|
+
// (live 2026-08-18, six labeled browser sidecars on the Studio).
|
|
347
|
+
if (fields[2] !== "") continue;
|
|
348
|
+
appRows.push(fields);
|
|
349
|
+
}
|
|
350
|
+
if (expected === null) {
|
|
351
|
+
// The exact container is proven absent; adoption is sound only if no
|
|
352
|
+
// same-project replacement exists either.
|
|
353
|
+
if (appRows.length !== 0) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`task ${candidate.row.taskId} has conflicting app containers`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (appRows.length !== 1) {
|
|
361
|
+
throw new Error(`task ${candidate.row.taskId} has conflicting app containers`);
|
|
362
|
+
}
|
|
363
|
+
const fields = appRows[0]!;
|
|
364
|
+
if (
|
|
365
|
+
fields[0] !== expected.containerId ||
|
|
366
|
+
fields[1] !== expected.containerName
|
|
367
|
+
) {
|
|
368
|
+
throw new Error(`task ${candidate.row.taskId} app inventory does not match`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function exactTabFields(
|
|
373
|
+
result: DockerResult,
|
|
374
|
+
count: number,
|
|
375
|
+
operation: string,
|
|
376
|
+
): string[] {
|
|
377
|
+
if (
|
|
378
|
+
result.status !== 0 ||
|
|
379
|
+
result.outputTruncated === true ||
|
|
380
|
+
Buffer.byteLength(result.stdout) > INSPECT_OUTPUT_LIMIT ||
|
|
381
|
+
!result.stdout.endsWith("\n") ||
|
|
382
|
+
result.stdout.slice(0, -1).includes("\n")
|
|
383
|
+
) {
|
|
384
|
+
throw new Error(`${operation} failed`);
|
|
385
|
+
}
|
|
386
|
+
const fields = result.stdout.slice(0, -1).split("\t");
|
|
387
|
+
if (fields.length !== count || fields.some((field) => field.length === 0)) {
|
|
388
|
+
throw new Error(`${operation} output is malformed`);
|
|
389
|
+
}
|
|
390
|
+
return fields;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function parseJsonField(value: string): unknown {
|
|
394
|
+
try {
|
|
395
|
+
return JSON.parse(value);
|
|
396
|
+
} catch {
|
|
397
|
+
throw new Error("legacy app inspect output is malformed");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function isContainerId(value: unknown): value is string {
|
|
402
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function sameMachine(
|
|
406
|
+
expected: HostMachineIdentity,
|
|
407
|
+
actual: HostMachineIdentity | null,
|
|
408
|
+
): boolean {
|
|
409
|
+
return (
|
|
410
|
+
actual !== null &&
|
|
411
|
+
actual.backend === expected.backend &&
|
|
412
|
+
actual.endpoint === expected.endpoint &&
|
|
413
|
+
actual.engineId === expected.engineId
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function persistLegacyAdoptions(
|
|
418
|
+
adoptions: ReadonlyArray<LegacyAdoption>,
|
|
419
|
+
): void {
|
|
420
|
+
getDb().transaction((tx) => {
|
|
421
|
+
for (const adoption of adoptions) {
|
|
422
|
+
const current = tx
|
|
423
|
+
.select()
|
|
424
|
+
.from(schema.hostTasks)
|
|
425
|
+
.where(eq(schema.hostTasks.taskId, adoption.snapshot.taskId))
|
|
426
|
+
.get();
|
|
427
|
+
if (!current || !sameLegacySnapshot(current, adoption.snapshot)) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`task ${adoption.snapshot.taskId} changed during legacy adoption`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
tx.update(schema.hostTasks)
|
|
433
|
+
.set({
|
|
434
|
+
environmentProvider: adoption.locator.provider,
|
|
435
|
+
environmentLocator: serializeTaskEnvironmentLocator(adoption.locator),
|
|
436
|
+
updatedAt: Date.now(),
|
|
437
|
+
})
|
|
438
|
+
.where(eq(schema.hostTasks.taskId, adoption.snapshot.taskId))
|
|
439
|
+
.run();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function sameLegacySnapshot(current: LegacyTaskRow, expected: LegacyTaskRow): boolean {
|
|
445
|
+
return (
|
|
446
|
+
current.taskId === expected.taskId &&
|
|
447
|
+
current.composeProject === expected.composeProject &&
|
|
448
|
+
current.worktreePath === expected.worktreePath &&
|
|
449
|
+
current.environmentProvider === null &&
|
|
450
|
+
current.environmentLocator === null &&
|
|
451
|
+
current.codeServerPort === expected.codeServerPort &&
|
|
452
|
+
current.previewPorts === expected.previewPorts
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function failedAdoption(error: unknown): LegacyTaskEnvironmentAdoptionVerdict {
|
|
457
|
+
const detail = (error instanceof Error ? error.message : String(error)).slice(
|
|
458
|
+
0,
|
|
459
|
+
512,
|
|
460
|
+
);
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
detail:
|
|
464
|
+
`${detail}. Start the ambient Docker daemon that owns this task and run ` +
|
|
465
|
+
`\`${RUNTIME_RECHECK_COMMAND}\`, or settle/remove the task before switching runtimes.`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task machine's machine-wide environment, /etc/environment, which sshd's
|
|
3
|
+
* pam_env applies to every session on it: what a self or machine task's
|
|
4
|
+
* processes run beneath their own `env`.
|
|
5
|
+
*
|
|
6
|
+
* ADR-154 §6: it never supplies an engine's credential. A machine-wide value
|
|
7
|
+
* of one (an owner's sign-in left in an older image) never reaches a task's
|
|
8
|
+
* process, leased or not: a leased engine's comes from its admission, and an
|
|
9
|
+
* unleased one's from its own request.
|
|
10
|
+
*/
|
|
11
|
+
import { isEngineCredentialEnvName, isLaunchSecretEnvName } from "../engine-credential-env";
|
|
12
|
+
|
|
13
|
+
export const ETC_ENVIRONMENT_PATH = "/etc/environment";
|
|
14
|
+
|
|
15
|
+
/** What an anchored launch's environment starts from where nothing else
|
|
16
|
+
* gives a PATH: the usual system one. */
|
|
17
|
+
export const BASELINE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* /etc/environment, as pam_env reads it: `KEY=value` lines, optional
|
|
21
|
+
* `export`, optional single or double quotes, `#` comments.
|
|
22
|
+
*/
|
|
23
|
+
export function parseEtcEnvironment(text: string): Record<string, string> {
|
|
24
|
+
const out: Record<string, string> = {};
|
|
25
|
+
for (const raw of text.split("\n")) {
|
|
26
|
+
const line = raw.trim();
|
|
27
|
+
if (!line || line.startsWith("#")) continue;
|
|
28
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
29
|
+
if (!match) continue;
|
|
30
|
+
let value = match[2]!.trim();
|
|
31
|
+
if (
|
|
32
|
+
value.length >= 2 &&
|
|
33
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
34
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
35
|
+
) {
|
|
36
|
+
value = value.slice(1, -1);
|
|
37
|
+
}
|
|
38
|
+
out[match[1]!] = value;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `environment` without any engine's credential. */
|
|
44
|
+
export function withoutEngineCredentials(
|
|
45
|
+
environment: Readonly<Record<string, string>>,
|
|
46
|
+
): Record<string, string> {
|
|
47
|
+
return Object.fromEntries(Object.entries(environment).filter(([name]) => !isEngineCredentialEnvName(name)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `environment` without any launch secret: an engine's credential, or an
|
|
52
|
+
* agent's task token under any of its names. What an anchored launch's
|
|
53
|
+
* baseline takes from the machine-wide environment, which rides its command
|
|
54
|
+
* line: its secrets come from its admission. An unanchored launch keeps the
|
|
55
|
+
* machine-wide values it always had, but for an engine's credential.
|
|
56
|
+
*/
|
|
57
|
+
export function withoutLaunchSecrets(
|
|
58
|
+
environment: Readonly<Record<string, string>>,
|
|
59
|
+
): Record<string, string> {
|
|
60
|
+
return Object.fromEntries(Object.entries(environment).filter(([name]) => !isLaunchSecretEnvName(name)));
|
|
61
|
+
}
|