@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,1344 @@
|
|
|
1
|
+
import {
|
|
2
|
+
spawn as nodeSpawn,
|
|
3
|
+
type ChildProcessWithoutNullStreams,
|
|
4
|
+
} from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
AgentError,
|
|
10
|
+
agent,
|
|
11
|
+
type TaskDownResult,
|
|
12
|
+
type TaskStatusResult,
|
|
13
|
+
type TaskUpCredentials,
|
|
14
|
+
type TaskLaunchInput,
|
|
15
|
+
type TaskUpResult,
|
|
16
|
+
} from "../agent";
|
|
17
|
+
import { dockerCli, type DockerResult } from "../docker-exec";
|
|
18
|
+
import { isLaunchSecretEnvName } from "../engine-credential-env";
|
|
19
|
+
import {
|
|
20
|
+
hostWorkspaceRead,
|
|
21
|
+
hostWorkspaceWrite,
|
|
22
|
+
} from "./workspace-files";
|
|
23
|
+
import { taskDir } from "../env";
|
|
24
|
+
import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
|
|
25
|
+
import { assertSafeHostTaskId } from "../task-identity";
|
|
26
|
+
import { applyProjectEnvProfile } from "../runtime-authority";
|
|
27
|
+
import { rootAnchoredRequest } from "./anchor";
|
|
28
|
+
import {
|
|
29
|
+
assertTaskEnvironmentExecRequest,
|
|
30
|
+
assertTaskEnvironmentProcessRequest,
|
|
31
|
+
assertTaskEnvironmentSessionRequest,
|
|
32
|
+
type TaskEnvironmentCallOptions,
|
|
33
|
+
type TaskEnvironmentReadOptions,
|
|
34
|
+
type TaskEnvironmentCopyRequest,
|
|
35
|
+
type TaskEnvironmentDescriptor,
|
|
36
|
+
type TaskEnvironmentDetachedSessionRequest,
|
|
37
|
+
type TaskEnvironmentExecRequest,
|
|
38
|
+
type TaskEnvironmentExecResult,
|
|
39
|
+
type TaskEnvironmentHandle,
|
|
40
|
+
type TaskEnvironmentLocator,
|
|
41
|
+
type TaskEnvironmentPort,
|
|
42
|
+
type TaskEnvironmentProcess,
|
|
43
|
+
type TaskEnvironmentProcessExit,
|
|
44
|
+
type ProjectEnvMode,
|
|
45
|
+
type TaskEnvironmentProvider,
|
|
46
|
+
type TaskEnvironmentRecoveryContext,
|
|
47
|
+
type TaskEnvironmentRecoveryResult,
|
|
48
|
+
type TaskEnvironmentSessionRequest,
|
|
49
|
+
type TaskEnvironmentSpawnRequest,
|
|
50
|
+
type TaskEnvironmentStatus,
|
|
51
|
+
} from "./types";
|
|
52
|
+
import type { TaskWriteEnvironment } from "../credential-consumers/task-files";
|
|
53
|
+
|
|
54
|
+
export const DOCKER_TASK_ENVIRONMENT_PROVIDER = "docker-compose";
|
|
55
|
+
const DOCKER_LOCATOR_SCHEMA_VERSION = 2;
|
|
56
|
+
const MAX_LOCATOR_COMPONENT = 512;
|
|
57
|
+
|
|
58
|
+
export type DockerMachineBackend = "docker";
|
|
59
|
+
|
|
60
|
+
export interface DockerMachineIdentity {
|
|
61
|
+
backend: DockerMachineBackend;
|
|
62
|
+
/** Stable process/durable endpoint identity, never a credential-bearing URL. */
|
|
63
|
+
endpoint: string;
|
|
64
|
+
/** Stable Docker daemon `.ID`, proved atomically with server liveness. */
|
|
65
|
+
engineId: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface DockerTaskEnvironmentLocatorValue {
|
|
69
|
+
schemaVersion: typeof DOCKER_LOCATOR_SCHEMA_VERSION;
|
|
70
|
+
taskId: string;
|
|
71
|
+
composeProject: string;
|
|
72
|
+
containerName: string;
|
|
73
|
+
hostWorktreePath: string;
|
|
74
|
+
machine: DockerMachineIdentity;
|
|
75
|
+
codeServerPort?: number;
|
|
76
|
+
previewPorts?: Array<{ name: string; hostPort: number }>;
|
|
77
|
+
initWarning?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface DockerTaskEnvironmentDeps {
|
|
81
|
+
taskUp?: (
|
|
82
|
+
input: TaskLaunchInput,
|
|
83
|
+
credentials?: TaskUpCredentials,
|
|
84
|
+
envMode?: ProjectEnvMode,
|
|
85
|
+
runtimeEnvironment?: () => TaskWriteEnvironment,
|
|
86
|
+
) => Promise<TaskUpResult>;
|
|
87
|
+
taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
|
|
88
|
+
taskStatus?: (taskId: string) => Promise<TaskStatusResult>;
|
|
89
|
+
docker?: typeof dockerCli;
|
|
90
|
+
spawn?: typeof nodeSpawn;
|
|
91
|
+
machineIdentity: () => DockerMachineIdentity;
|
|
92
|
+
/** Full L4/L5-aware recovery hook. Absence intentionally does not start. */
|
|
93
|
+
recover?: DockerTaskEnvironmentRecoveryDriver;
|
|
94
|
+
/** Late-bound production seam avoids an orchestrator/provider import cycle
|
|
95
|
+
* while letting tests supply an instance-local recovery implementation. */
|
|
96
|
+
recoveryDriver?: () => DockerTaskEnvironmentRecoveryDriver | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type DockerTaskEnvironmentRecoveryDriver = (
|
|
100
|
+
descriptor: TaskEnvironmentDescriptor,
|
|
101
|
+
context: TaskEnvironmentRecoveryContext,
|
|
102
|
+
) => Promise<TaskEnvironmentRecoveryResult>;
|
|
103
|
+
|
|
104
|
+
export function createDockerTaskEnvironmentProvider(
|
|
105
|
+
deps: DockerTaskEnvironmentDeps,
|
|
106
|
+
): TaskEnvironmentProvider<
|
|
107
|
+
TaskLaunchInput,
|
|
108
|
+
TaskUpCredentials,
|
|
109
|
+
TaskUpResult,
|
|
110
|
+
TaskDownResult
|
|
111
|
+
> {
|
|
112
|
+
const resolved = {
|
|
113
|
+
taskUp: deps.taskUp ?? agent.taskUp,
|
|
114
|
+
taskEnvironmentDown:
|
|
115
|
+
deps.taskEnvironmentDown ?? agent.taskEnvironmentDown,
|
|
116
|
+
taskStatus: deps.taskStatus,
|
|
117
|
+
docker: deps.docker ?? dockerCli,
|
|
118
|
+
spawn: deps.spawn ?? nodeSpawn,
|
|
119
|
+
machineIdentity: deps.machineIdentity,
|
|
120
|
+
recover: deps.recover,
|
|
121
|
+
recoveryDriver: deps.recoveryDriver,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
kind: DOCKER_TASK_ENVIRONMENT_PROVIDER,
|
|
126
|
+
async provision(request) {
|
|
127
|
+
if (request.taskId !== request.input.task.id) {
|
|
128
|
+
throw new Error("task environment provision identity mismatch");
|
|
129
|
+
}
|
|
130
|
+
const machine = validateMachineIdentity(resolved.machineIdentity());
|
|
131
|
+
const preparedLocator = dockerTaskEnvironmentPreparedLocator(
|
|
132
|
+
request.taskId,
|
|
133
|
+
machine,
|
|
134
|
+
);
|
|
135
|
+
await request.onPrepared?.(preparedLocator);
|
|
136
|
+
const result = await resolved.taskUp(
|
|
137
|
+
request.input,
|
|
138
|
+
request.credentials,
|
|
139
|
+
request.envMode ?? "loader",
|
|
140
|
+
// ADR-154 §12: the container as task-up has created it, for the
|
|
141
|
+
// runtime-ready writes before uai-init.
|
|
142
|
+
() => new DockerTaskEnvironmentHandle(preparedLocator, resolved, request.envMode ?? "loader"),
|
|
143
|
+
);
|
|
144
|
+
const prepared = parseDockerTaskEnvironmentLocator(preparedLocator);
|
|
145
|
+
// task-up composes its worktree path in shell from the operator's
|
|
146
|
+
// UAI_WORKSPACE_ROOT verbatim, so a benign trailing slash would fail a
|
|
147
|
+
// strict string compare AFTER the stack is already up. Resolve before
|
|
148
|
+
// comparing; a path that differs after resolution (wrong directory,
|
|
149
|
+
// unexpanded `~`) still fails closed.
|
|
150
|
+
if (
|
|
151
|
+
result.composeProject !== prepared.composeProject ||
|
|
152
|
+
path.resolve(result.worktreePath) !== prepared.hostWorktreePath
|
|
153
|
+
) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"task environment returned an unexpected Compose project or worktree path",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const locator = dockerTaskEnvironmentLocator(
|
|
159
|
+
request.taskId,
|
|
160
|
+
// Persist the canonical resolved path, not the shell-composed spelling.
|
|
161
|
+
{ ...result, worktreePath: prepared.hostWorktreePath },
|
|
162
|
+
machine,
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
handle: new DockerTaskEnvironmentHandle(
|
|
166
|
+
locator,
|
|
167
|
+
resolved,
|
|
168
|
+
request.envMode ?? "loader",
|
|
169
|
+
),
|
|
170
|
+
result,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
async reconstruct(locator, envMode = "baked") {
|
|
174
|
+
const persisted = parseDockerTaskEnvironmentLocator(locator);
|
|
175
|
+
const current = validateMachineIdentity(resolved.machineIdentity());
|
|
176
|
+
if (
|
|
177
|
+
persisted.machine.backend !== current.backend ||
|
|
178
|
+
persisted.machine.endpoint !== current.endpoint ||
|
|
179
|
+
persisted.machine.engineId !== current.engineId
|
|
180
|
+
) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"task environment belongs to a different container backend",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return new DockerTaskEnvironmentHandle(locator, resolved, envMode);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function dockerTaskEnvironmentLocator(
|
|
191
|
+
taskId: string,
|
|
192
|
+
result: TaskUpResult,
|
|
193
|
+
machine: DockerMachineIdentity,
|
|
194
|
+
): TaskEnvironmentLocator {
|
|
195
|
+
assertSafeHostTaskId(taskId);
|
|
196
|
+
const composeProject = `task-${taskId}`;
|
|
197
|
+
if (result.composeProject !== composeProject) {
|
|
198
|
+
throw new Error("task environment returned an unexpected Compose project");
|
|
199
|
+
}
|
|
200
|
+
const value: DockerTaskEnvironmentLocatorValue = {
|
|
201
|
+
schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
|
|
202
|
+
taskId,
|
|
203
|
+
composeProject,
|
|
204
|
+
containerName: `${composeProject}-app-1`,
|
|
205
|
+
hostWorktreePath: result.worktreePath,
|
|
206
|
+
machine: validateMachineIdentity(machine),
|
|
207
|
+
...(result.codeServerPort === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { codeServerPort: result.codeServerPort }),
|
|
210
|
+
...(result.previewPorts === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { previewPorts: result.previewPorts }),
|
|
213
|
+
...(result.initWarning === undefined
|
|
214
|
+
? {}
|
|
215
|
+
: { initWarning: result.initWarning }),
|
|
216
|
+
};
|
|
217
|
+
validateDockerLocatorValue(value);
|
|
218
|
+
return {
|
|
219
|
+
schemaVersion: 1,
|
|
220
|
+
provider: DOCKER_TASK_ENVIRONMENT_PROVIDER,
|
|
221
|
+
value,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Deterministic crash-recovery identity, derived solely from admitted host
|
|
226
|
+
* configuration and task id. Never accept a worktree path from the caller. */
|
|
227
|
+
export function dockerTaskEnvironmentPreparedLocator(
|
|
228
|
+
taskId: string,
|
|
229
|
+
machine: DockerMachineIdentity,
|
|
230
|
+
): TaskEnvironmentLocator {
|
|
231
|
+
assertSafeHostTaskId(taskId);
|
|
232
|
+
const composeProject = `task-${taskId}`;
|
|
233
|
+
const value: DockerTaskEnvironmentLocatorValue = {
|
|
234
|
+
schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
|
|
235
|
+
taskId,
|
|
236
|
+
composeProject,
|
|
237
|
+
containerName: `${composeProject}-app-1`,
|
|
238
|
+
hostWorktreePath: taskDir(taskId),
|
|
239
|
+
machine: validateMachineIdentity(machine),
|
|
240
|
+
};
|
|
241
|
+
validateDockerLocatorValue(value);
|
|
242
|
+
return {
|
|
243
|
+
schemaVersion: 1,
|
|
244
|
+
provider: DOCKER_TASK_ENVIRONMENT_PROVIDER,
|
|
245
|
+
value,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function parseDockerTaskEnvironmentLocator(
|
|
250
|
+
locator: TaskEnvironmentLocator,
|
|
251
|
+
): DockerTaskEnvironmentLocatorValue {
|
|
252
|
+
if (
|
|
253
|
+
locator.schemaVersion !== 1 ||
|
|
254
|
+
locator.provider !== DOCKER_TASK_ENVIRONMENT_PROVIDER
|
|
255
|
+
) {
|
|
256
|
+
throw new Error("task environment locator does not belong to Docker Compose");
|
|
257
|
+
}
|
|
258
|
+
return validateDockerLocatorValue(locator.value);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
class DockerTaskEnvironmentHandle
|
|
262
|
+
implements TaskEnvironmentHandle<TaskDownResult>
|
|
263
|
+
{
|
|
264
|
+
readonly descriptor: TaskEnvironmentDescriptor;
|
|
265
|
+
readonly durableIdentity: string;
|
|
266
|
+
/** ADR-154 §6: anchored requests start under the engine supervisor. */
|
|
267
|
+
readonly anchors = true;
|
|
268
|
+
readonly #value: DockerTaskEnvironmentLocatorValue;
|
|
269
|
+
|
|
270
|
+
constructor(
|
|
271
|
+
locator: TaskEnvironmentLocator,
|
|
272
|
+
private readonly deps: Required<
|
|
273
|
+
Omit<
|
|
274
|
+
DockerTaskEnvironmentDeps,
|
|
275
|
+
"recover" | "recoveryDriver" | "taskStatus"
|
|
276
|
+
>
|
|
277
|
+
> &
|
|
278
|
+
Pick<
|
|
279
|
+
DockerTaskEnvironmentDeps,
|
|
280
|
+
"recover" | "recoveryDriver" | "taskStatus"
|
|
281
|
+
>,
|
|
282
|
+
envMode: ProjectEnvMode,
|
|
283
|
+
) {
|
|
284
|
+
this.#value = parseDockerTaskEnvironmentLocator(locator);
|
|
285
|
+
this.durableIdentity = this.#value.containerName;
|
|
286
|
+
this.descriptor = {
|
|
287
|
+
taskId: this.#value.taskId,
|
|
288
|
+
locator,
|
|
289
|
+
envMode,
|
|
290
|
+
workspacePath: "/workspace",
|
|
291
|
+
...(this.#value.codeServerPort === undefined
|
|
292
|
+
? {}
|
|
293
|
+
: { codeServerPort: this.#value.codeServerPort }),
|
|
294
|
+
...(this.#value.previewPorts === undefined
|
|
295
|
+
? {}
|
|
296
|
+
: { previewPorts: this.#value.previewPorts }),
|
|
297
|
+
...(this.#value.initWarning === undefined
|
|
298
|
+
? {}
|
|
299
|
+
: { initWarning: this.#value.initWarning }),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async readWorkspaceFile(filePath: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
|
|
304
|
+
options?.admit?.();
|
|
305
|
+
return hostWorkspaceRead(
|
|
306
|
+
this.descriptor,
|
|
307
|
+
path.join(this.#value.hostWorktreePath, "workspace"),
|
|
308
|
+
filePath,
|
|
309
|
+
options,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async writeWorkspaceFile(filePath: string, bytes: Uint8Array, options?: TaskEnvironmentCallOptions): Promise<void> {
|
|
314
|
+
options?.admit?.();
|
|
315
|
+
await hostWorkspaceWrite(
|
|
316
|
+
this.descriptor,
|
|
317
|
+
path.join(this.#value.hostWorktreePath, "workspace"),
|
|
318
|
+
filePath,
|
|
319
|
+
bytes,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
|
|
324
|
+
assertTaskEnvironmentExecRequest(request);
|
|
325
|
+
const { privileged, ...unprivileged } = request;
|
|
326
|
+
const profiled = applyProjectEnvProfile(
|
|
327
|
+
rootAnchoredRequest(unprivileged),
|
|
328
|
+
this.descriptor.envMode ?? "baked",
|
|
329
|
+
"/usr/bin/zsh",
|
|
330
|
+
);
|
|
331
|
+
request.admit?.();
|
|
332
|
+
const invocation = dockerExecArgs(
|
|
333
|
+
this.#value.containerName,
|
|
334
|
+
profiled,
|
|
335
|
+
"interactive",
|
|
336
|
+
privileged === true,
|
|
337
|
+
);
|
|
338
|
+
return capturedCliExec(
|
|
339
|
+
this.deps.spawn,
|
|
340
|
+
"docker",
|
|
341
|
+
invocation.args,
|
|
342
|
+
profiled,
|
|
343
|
+
invocation.env,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async spawn(
|
|
348
|
+
request: TaskEnvironmentSpawnRequest,
|
|
349
|
+
): Promise<TaskEnvironmentProcess> {
|
|
350
|
+
assertTaskEnvironmentProcessRequest(request);
|
|
351
|
+
const profiled = applyProjectEnvProfile(
|
|
352
|
+
rootAnchoredRequest(request),
|
|
353
|
+
this.descriptor.envMode ?? "baked",
|
|
354
|
+
"/usr/bin/zsh",
|
|
355
|
+
);
|
|
356
|
+
const invocation = dockerExecArgs(this.#value.containerName, profiled);
|
|
357
|
+
request.admit?.();
|
|
358
|
+
return streamingCliExec(
|
|
359
|
+
this.deps.spawn,
|
|
360
|
+
"docker",
|
|
361
|
+
invocation.args,
|
|
362
|
+
profiled,
|
|
363
|
+
invocation.env,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async spawnSession(
|
|
368
|
+
request: TaskEnvironmentSessionRequest,
|
|
369
|
+
): Promise<TaskEnvironmentProcess> {
|
|
370
|
+
assertTaskEnvironmentSessionRequest(request);
|
|
371
|
+
const profiled = applyProjectEnvProfile(
|
|
372
|
+
rootAnchoredRequest(request),
|
|
373
|
+
this.descriptor.envMode ?? "baked",
|
|
374
|
+
"/usr/bin/zsh",
|
|
375
|
+
);
|
|
376
|
+
const invocation = dockerExecArgs(this.#value.containerName, profiled);
|
|
377
|
+
request.admit?.();
|
|
378
|
+
return streamingCliExec(
|
|
379
|
+
this.deps.spawn,
|
|
380
|
+
"docker",
|
|
381
|
+
invocation.args,
|
|
382
|
+
profiled,
|
|
383
|
+
invocation.env,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async launchDetachedSession(
|
|
388
|
+
request: TaskEnvironmentDetachedSessionRequest,
|
|
389
|
+
): Promise<TaskEnvironmentExecResult> {
|
|
390
|
+
assertTaskEnvironmentSessionRequest(request);
|
|
391
|
+
const profiled = applyProjectEnvProfile(
|
|
392
|
+
rootAnchoredRequest(request),
|
|
393
|
+
this.descriptor.envMode ?? "baked",
|
|
394
|
+
"/usr/bin/zsh",
|
|
395
|
+
);
|
|
396
|
+
const invocation = dockerExecArgs(
|
|
397
|
+
this.#value.containerName,
|
|
398
|
+
profiled,
|
|
399
|
+
"detached",
|
|
400
|
+
);
|
|
401
|
+
request.admit?.();
|
|
402
|
+
const result = await this.deps.docker(invocation.args, {
|
|
403
|
+
timeoutMs: request.launchTimeoutMs,
|
|
404
|
+
maxOutputBytes: request.maxOutputBytes,
|
|
405
|
+
...(invocation.env === undefined ? {} : { env: invocation.env }),
|
|
406
|
+
});
|
|
407
|
+
return {
|
|
408
|
+
exitCode: result.status,
|
|
409
|
+
signal: null,
|
|
410
|
+
stdout: Buffer.from(result.stdout),
|
|
411
|
+
stderr: Buffer.from(result.stderr),
|
|
412
|
+
stdoutTruncated: result.outputTruncated === true,
|
|
413
|
+
stderrTruncated: result.outputTruncated === true,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
|
|
418
|
+
validateCopyRequest(request);
|
|
419
|
+
const args =
|
|
420
|
+
request.direction === "into"
|
|
421
|
+
? [
|
|
422
|
+
"cp",
|
|
423
|
+
request.source,
|
|
424
|
+
`${this.#value.containerName}:${request.destination}`,
|
|
425
|
+
]
|
|
426
|
+
: [
|
|
427
|
+
"cp",
|
|
428
|
+
`${this.#value.containerName}:${request.source}`,
|
|
429
|
+
request.destination,
|
|
430
|
+
];
|
|
431
|
+
request.admit?.();
|
|
432
|
+
const result = await this.deps.docker(args, {
|
|
433
|
+
timeoutMs: 120_000,
|
|
434
|
+
maxOutputBytes: 1024 * 1024,
|
|
435
|
+
});
|
|
436
|
+
requireDockerSuccess(result, "copy task environment data");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async ports(): Promise<TaskEnvironmentPort[]> {
|
|
440
|
+
const result = await this.deps.docker(
|
|
441
|
+
["port", this.#value.containerName],
|
|
442
|
+
{ timeoutMs: 10_000, maxOutputBytes: 256 * 1024 },
|
|
443
|
+
);
|
|
444
|
+
requireDockerSuccess(result, "inspect task environment ports");
|
|
445
|
+
return parseDockerPorts(result.stdout);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async status(): Promise<TaskEnvironmentStatus> {
|
|
449
|
+
if (this.deps.taskStatus) {
|
|
450
|
+
return taskStatusToEnvironmentStatus(
|
|
451
|
+
await this.deps.taskStatus(this.#value.taskId),
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return inspectDockerTaskEnvironmentStatus(this.deps.docker, this.#value);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async recover(
|
|
458
|
+
context: TaskEnvironmentRecoveryContext,
|
|
459
|
+
): Promise<TaskEnvironmentRecoveryResult> {
|
|
460
|
+
const recover = this.deps.recover ?? this.deps.recoveryDriver?.();
|
|
461
|
+
if (recover) {
|
|
462
|
+
return recover(this.descriptor, context);
|
|
463
|
+
}
|
|
464
|
+
const status = await this.status();
|
|
465
|
+
return status.state === "unknown"
|
|
466
|
+
? { outcome: "deferred", detail: status.detail }
|
|
467
|
+
: { outcome: "recovered", status };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async stop(): Promise<void> {
|
|
471
|
+
// Stop the whole Compose project (app + sidecars) while keeping every
|
|
472
|
+
// container, volume, and the worktree so the task can be resumed. The
|
|
473
|
+
// label filter is scoped by the locator's project, which provision
|
|
474
|
+
// derived from the admitted task id.
|
|
475
|
+
const listed = await this.deps.docker(
|
|
476
|
+
[
|
|
477
|
+
"ps",
|
|
478
|
+
"-q",
|
|
479
|
+
"--filter",
|
|
480
|
+
`label=com.docker.compose.project=${this.#value.composeProject}`,
|
|
481
|
+
],
|
|
482
|
+
{ timeoutMs: 30_000, maxOutputBytes: 256 * 1024 },
|
|
483
|
+
);
|
|
484
|
+
requireDockerSuccess(listed, "list task environment containers");
|
|
485
|
+
const ids = listed.stdout
|
|
486
|
+
.split("\n")
|
|
487
|
+
.map((id) => id.trim())
|
|
488
|
+
.filter(Boolean);
|
|
489
|
+
if (ids.length === 0) return;
|
|
490
|
+
const stopped = await this.deps.docker(["stop", ...ids], {
|
|
491
|
+
timeoutMs: 60_000,
|
|
492
|
+
maxOutputBytes: 256 * 1024,
|
|
493
|
+
});
|
|
494
|
+
requireDockerSuccess(stopped, "stop task environment containers");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The boot sweep's way into a stopped app container (types.ts). After a
|
|
499
|
+
* reboot every task container is Exited, and `docker exec` into one fails,
|
|
500
|
+
* so its credential files, in the container's writable layer, could never
|
|
501
|
+
* be proven gone and the runner stayed in `checking` (2026-10-30). Only
|
|
502
|
+
* the exact app container starts, by id, never the compose project. On the
|
|
503
|
+
* standard image its command is `sleep infinity` under Docker's own init,
|
|
504
|
+
* and everything else in a task (uai-init, the editor, engines, the
|
|
505
|
+
* forwarder) is a later `docker exec` of the runner's. A project's `extra`
|
|
506
|
+
* Dockerfile lines can still make a derived image run its own code on a
|
|
507
|
+
* plain start, so the start is refused on any sign of it (`#startRefusal`).
|
|
508
|
+
*/
|
|
509
|
+
async runStoppedForCleanup<T>(
|
|
510
|
+
work: () => Promise<T>,
|
|
511
|
+
oweStop: (containerId: string) => void | Promise<void>,
|
|
512
|
+
): Promise<T> {
|
|
513
|
+
const before = await this.#inspectApp(this.#value.containerName);
|
|
514
|
+
if (before.kind !== "found") {
|
|
515
|
+
throw new Error(`the stopped task environment cannot be started for cleanup: ${before.detail}`);
|
|
516
|
+
}
|
|
517
|
+
const refusal = await this.#startRefusal(before.container);
|
|
518
|
+
if (refusal !== null) {
|
|
519
|
+
throw new Error(`the task environment is not started for cleanup: ${refusal}`);
|
|
520
|
+
}
|
|
521
|
+
const { id } = before.container;
|
|
522
|
+
// Durable before the start: a crash, a start that timed out yet started
|
|
523
|
+
// it, or a stop that fails below all leave it owed.
|
|
524
|
+
await oweStop(id);
|
|
525
|
+
let value: T | undefined;
|
|
526
|
+
let failed = false;
|
|
527
|
+
let failure: unknown;
|
|
528
|
+
try {
|
|
529
|
+
const started = await this.deps.docker(["start", id], {
|
|
530
|
+
timeoutMs: 60_000,
|
|
531
|
+
maxOutputBytes: 64 * 1024,
|
|
532
|
+
});
|
|
533
|
+
requireDockerSuccess(started, "start the stopped task environment for cleanup");
|
|
534
|
+
const running = await this.#inspectApp(id);
|
|
535
|
+
if (
|
|
536
|
+
running.kind !== "found" ||
|
|
537
|
+
running.container.id !== id ||
|
|
538
|
+
running.container.state !== "running"
|
|
539
|
+
) {
|
|
540
|
+
throw new Error("the task environment did not start for cleanup");
|
|
541
|
+
}
|
|
542
|
+
value = await work();
|
|
543
|
+
} catch (error) {
|
|
544
|
+
failed = true;
|
|
545
|
+
failure = error;
|
|
546
|
+
}
|
|
547
|
+
// Whatever happened above (a start that reported failure may still have
|
|
548
|
+
// started it), it goes back to stopped.
|
|
549
|
+
if (!(await this.settleCleanupStart(id))) {
|
|
550
|
+
throw new Error(
|
|
551
|
+
"the task environment could not be proven stopped again after cleanup" +
|
|
552
|
+
(failed ? `; the cleanup itself failed: ${boundedErrorMessage(failure)}` : ""),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
if (failed) throw failure;
|
|
556
|
+
return value as T;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async settleCleanupStart(containerId: string): Promise<boolean> {
|
|
560
|
+
if (!/^[0-9a-f]{64}$/.test(containerId)) return false;
|
|
561
|
+
const state = await this.#containerStateById(containerId);
|
|
562
|
+
if (state === null) return false;
|
|
563
|
+
if (state === "absent" || state === "exited" || state === "created" || state === "dead") {
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
if (state !== "running" && state !== "paused" && state !== "restarting") return false;
|
|
567
|
+
const stopped = await this.deps.docker(["stop", "--time", "10", containerId], {
|
|
568
|
+
timeoutMs: 60_000,
|
|
569
|
+
maxOutputBytes: 64 * 1024,
|
|
570
|
+
});
|
|
571
|
+
if (stopped.status !== 0) return false;
|
|
572
|
+
const after = await this.#containerStateById(containerId);
|
|
573
|
+
return after === "absent" || after === "exited";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Exactly this container's state by its full id; `absent` only when the
|
|
577
|
+
* daemon answered and listed none; null when it did not answer. */
|
|
578
|
+
async #containerStateById(containerId: string): Promise<string | null> {
|
|
579
|
+
const result = await this.deps.docker(
|
|
580
|
+
["ps", "-a", "--no-trunc", "--filter", `id=${containerId}`, "--format", "{{.ID}}\t{{.State}}"],
|
|
581
|
+
{ timeoutMs: 30_000, maxOutputBytes: 64 * 1024 },
|
|
582
|
+
);
|
|
583
|
+
if (result.status !== 0) return null;
|
|
584
|
+
const rows = result.stdout
|
|
585
|
+
.split("\n")
|
|
586
|
+
.map((line) => line.trim())
|
|
587
|
+
.filter(Boolean)
|
|
588
|
+
.map((line) => line.split("\t"));
|
|
589
|
+
if (rows.length === 0) return "absent";
|
|
590
|
+
if (rows.length !== 1 || rows[0]![0] !== containerId || typeof rows[0]![1] !== "string") return null;
|
|
591
|
+
return rows[0]![1].toLowerCase();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Why this container may not be started for cleanup, or null. Each
|
|
596
|
+
* refusal keeps the cleanup failed and the runner pending, as before.
|
|
597
|
+
* - not `exited`/`created`: paused, restarting or running is not "stopped".
|
|
598
|
+
* - its own command not `sleep infinity`, or an entrypoint: it would run
|
|
599
|
+
* something of the image's.
|
|
600
|
+
* - an enabled healthcheck, its own or its image's: Docker runs it, project
|
|
601
|
+
* code, on its interval as soon as the container starts (a derived
|
|
602
|
+
* image's HEALTHCHECK, or one inherited from its base).
|
|
603
|
+
* - a restart policy other than `no`: Docker could start it again after
|
|
604
|
+
* the cleanup stopped it (on a daemon restart, or `unless-stopped`).
|
|
605
|
+
* - init other than the runner's `init: true` (task-up.sh, since
|
|
606
|
+
* 2026-05-29): every app container the runner made has Docker's own
|
|
607
|
+
* init as PID 1; one without it was not made by the runner's compose.
|
|
608
|
+
* - auto-remove: `docker stop` would delete the container and its
|
|
609
|
+
* writable layer, a destroy outside teardown.
|
|
610
|
+
*/
|
|
611
|
+
async #startRefusal(container: InspectedApp): Promise<string | null> {
|
|
612
|
+
if (container.state !== "exited" && container.state !== "created") {
|
|
613
|
+
return `it is ${container.state}, not stopped`;
|
|
614
|
+
}
|
|
615
|
+
if (!container.idle) return "its own command is not the idle one";
|
|
616
|
+
if (container.restartPolicy !== "no" && container.restartPolicy !== "") {
|
|
617
|
+
return `its restart policy is ${container.restartPolicy || "unknown"}, not no`;
|
|
618
|
+
}
|
|
619
|
+
if (container.init !== true) return "it does not run the station's init";
|
|
620
|
+
if (container.autoRemove !== false) return "it is removed when it stops";
|
|
621
|
+
if (container.healthcheck === "enabled") return "it has a healthcheck, which would run the project's code";
|
|
622
|
+
if (container.healthcheck === "unknown") return "its healthcheck could not be read";
|
|
623
|
+
if (container.healthcheck === "inherit") {
|
|
624
|
+
const image = await this.#imageHealthcheck(container.image);
|
|
625
|
+
if (image === "enabled") return "its image has a healthcheck, which would run the project's code";
|
|
626
|
+
if (image !== "disabled") return "its image's healthcheck could not be read";
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async #imageHealthcheck(image: string | null): Promise<HealthcheckState> {
|
|
632
|
+
if (image === null) return "unknown";
|
|
633
|
+
const result = await this.deps.docker(["image", "inspect", image], {
|
|
634
|
+
timeoutMs: 30_000,
|
|
635
|
+
maxOutputBytes: 1024 * 1024,
|
|
636
|
+
});
|
|
637
|
+
if (result.status !== 0) return "unknown";
|
|
638
|
+
let parsed: unknown;
|
|
639
|
+
try {
|
|
640
|
+
parsed = JSON.parse(result.stdout);
|
|
641
|
+
} catch {
|
|
642
|
+
return "unknown";
|
|
643
|
+
}
|
|
644
|
+
const record = Array.isArray(parsed) && parsed.length === 1 ? (parsed[0] as Record<string, unknown>) : null;
|
|
645
|
+
if (record === null) return "unknown";
|
|
646
|
+
const config = record.Config as Record<string, unknown> | null | undefined;
|
|
647
|
+
if (config === undefined) return "unknown";
|
|
648
|
+
// An image's own empty or absent test inherits nothing further here:
|
|
649
|
+
// its build already merged its base's.
|
|
650
|
+
const state = healthcheckState(config?.Healthcheck);
|
|
651
|
+
return state === "inherit" ? "disabled" : state;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The exact app container, by name or id, bound to this locator. */
|
|
655
|
+
async #inspectApp(ref: string): Promise<
|
|
656
|
+
| { kind: "found"; container: InspectedApp }
|
|
657
|
+
| { kind: "refused"; detail: string }
|
|
658
|
+
> {
|
|
659
|
+
const result = await this.deps.docker(["container", "inspect", ref], {
|
|
660
|
+
timeoutMs: 30_000,
|
|
661
|
+
maxOutputBytes: 1024 * 1024,
|
|
662
|
+
});
|
|
663
|
+
if (result.status !== 0) {
|
|
664
|
+
return { kind: "refused", detail: boundedDockerFailure(result, "inspect the task environment") };
|
|
665
|
+
}
|
|
666
|
+
let parsed: unknown;
|
|
667
|
+
try {
|
|
668
|
+
parsed = JSON.parse(result.stdout);
|
|
669
|
+
} catch {
|
|
670
|
+
return { kind: "refused", detail: "task environment inspection was not JSON" };
|
|
671
|
+
}
|
|
672
|
+
const container = Array.isArray(parsed) && parsed.length === 1 ? (parsed[0] as Record<string, unknown>) : null;
|
|
673
|
+
const config = container?.Config as Record<string, unknown> | undefined;
|
|
674
|
+
const labels = config?.Labels as Record<string, unknown> | undefined;
|
|
675
|
+
const stateRecord = container?.State as Record<string, unknown> | undefined;
|
|
676
|
+
const hostConfig = container?.HostConfig as Record<string, unknown> | undefined;
|
|
677
|
+
const restartPolicy = hostConfig?.RestartPolicy as Record<string, unknown> | undefined;
|
|
678
|
+
const id = container?.Id;
|
|
679
|
+
const state = stateRecord?.Status;
|
|
680
|
+
if (
|
|
681
|
+
typeof id !== "string" ||
|
|
682
|
+
!/^[0-9a-f]{64}$/.test(id) ||
|
|
683
|
+
container?.Name !== `/${this.#value.containerName}` ||
|
|
684
|
+
labels?.["com.docker.compose.project"] !== this.#value.composeProject ||
|
|
685
|
+
labels?.["com.docker.compose.service"] !== "app" ||
|
|
686
|
+
(labels?.[PREVIEW_SIDECAR_TASK_LABEL] ?? "") !== "" ||
|
|
687
|
+
typeof state !== "string"
|
|
688
|
+
) {
|
|
689
|
+
return { kind: "refused", detail: "task environment inspection did not name this task's app container" };
|
|
690
|
+
}
|
|
691
|
+
const entrypoint = config?.Entrypoint;
|
|
692
|
+
const cmd = config?.Cmd;
|
|
693
|
+
const idle =
|
|
694
|
+
(entrypoint === null || entrypoint === undefined ||
|
|
695
|
+
(Array.isArray(entrypoint) && entrypoint.length === 0)) &&
|
|
696
|
+
Array.isArray(cmd) &&
|
|
697
|
+
cmd.length === 2 &&
|
|
698
|
+
cmd[0] === "sleep" &&
|
|
699
|
+
cmd[1] === "infinity";
|
|
700
|
+
const image = container?.Image;
|
|
701
|
+
return {
|
|
702
|
+
kind: "found",
|
|
703
|
+
container: {
|
|
704
|
+
id,
|
|
705
|
+
state,
|
|
706
|
+
idle,
|
|
707
|
+
healthcheck: healthcheckState(config?.Healthcheck),
|
|
708
|
+
image: typeof image === "string" && image.length > 0 ? image : null,
|
|
709
|
+
restartPolicy: typeof restartPolicy?.Name === "string" ? restartPolicy.Name : "unknown",
|
|
710
|
+
init: typeof hostConfig?.Init === "boolean" ? hostConfig.Init : null,
|
|
711
|
+
autoRemove: typeof hostConfig?.AutoRemove === "boolean" ? hostConfig.AutoRemove : null,
|
|
712
|
+
},
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async teardown(): Promise<TaskDownResult> {
|
|
717
|
+
const result = await this.deps.taskEnvironmentDown(this.#value.taskId);
|
|
718
|
+
let status: TaskEnvironmentStatus;
|
|
719
|
+
try {
|
|
720
|
+
status = await this.status();
|
|
721
|
+
} catch (error) {
|
|
722
|
+
// Preserve structured agent failures so the host command boundary can
|
|
723
|
+
// still poison a vanished daemon. Other provider errors remain an
|
|
724
|
+
// explicit lack of absence proof.
|
|
725
|
+
if (AgentError.is(error)) throw error;
|
|
726
|
+
throw new Error(
|
|
727
|
+
`task environment teardown is unconfirmed: ${boundedErrorMessage(error)}`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (status.state !== "absent") {
|
|
731
|
+
throw new Error(
|
|
732
|
+
status.state === "unknown"
|
|
733
|
+
? `task environment teardown is unconfirmed: ${status.detail}`
|
|
734
|
+
: `task environment teardown left the task ${status.state}`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
return result;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function validateDockerLocatorValue(
|
|
742
|
+
value: unknown,
|
|
743
|
+
): DockerTaskEnvironmentLocatorValue {
|
|
744
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
745
|
+
throw new Error("Docker task environment locator must be an object");
|
|
746
|
+
}
|
|
747
|
+
const record = value as Record<string, unknown>;
|
|
748
|
+
const allowed = new Set([
|
|
749
|
+
"schemaVersion",
|
|
750
|
+
"taskId",
|
|
751
|
+
"composeProject",
|
|
752
|
+
"containerName",
|
|
753
|
+
"hostWorktreePath",
|
|
754
|
+
"machine",
|
|
755
|
+
"codeServerPort",
|
|
756
|
+
"previewPorts",
|
|
757
|
+
"initWarning",
|
|
758
|
+
]);
|
|
759
|
+
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
|
760
|
+
throw new Error("Docker task environment locator has unexpected fields");
|
|
761
|
+
}
|
|
762
|
+
if (record.schemaVersion !== DOCKER_LOCATOR_SCHEMA_VERSION) {
|
|
763
|
+
throw new Error("unsupported Docker task environment locator schema");
|
|
764
|
+
}
|
|
765
|
+
const taskId = exactComponent(record.taskId, "task id");
|
|
766
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,127}$/.test(taskId)) {
|
|
767
|
+
throw new Error("Docker task environment task id is invalid");
|
|
768
|
+
}
|
|
769
|
+
const composeProject = exactComponent(record.composeProject, "Compose project");
|
|
770
|
+
const containerName = exactComponent(record.containerName, "container name");
|
|
771
|
+
if (
|
|
772
|
+
composeProject !== `task-${taskId}` ||
|
|
773
|
+
containerName !== `${composeProject}-app-1`
|
|
774
|
+
) {
|
|
775
|
+
throw new Error("Docker task environment identity is inconsistent");
|
|
776
|
+
}
|
|
777
|
+
const hostWorktreePath = exactComponent(
|
|
778
|
+
record.hostWorktreePath,
|
|
779
|
+
"host worktree path",
|
|
780
|
+
);
|
|
781
|
+
if (
|
|
782
|
+
!path.isAbsolute(hostWorktreePath) ||
|
|
783
|
+
path.resolve(hostWorktreePath) !== hostWorktreePath ||
|
|
784
|
+
/[\r\n]/.test(hostWorktreePath)
|
|
785
|
+
) {
|
|
786
|
+
throw new Error("Docker task environment worktree path must be absolute");
|
|
787
|
+
}
|
|
788
|
+
const machine = validateMachineIdentity(record.machine);
|
|
789
|
+
const codeServerPort = optionalPort(record.codeServerPort, "code server port");
|
|
790
|
+
const previewPorts = validatePreviewPorts(record.previewPorts);
|
|
791
|
+
const initWarning =
|
|
792
|
+
record.initWarning === undefined
|
|
793
|
+
? undefined
|
|
794
|
+
: exactComponent(record.initWarning, "init warning", 4096);
|
|
795
|
+
return {
|
|
796
|
+
schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
|
|
797
|
+
taskId,
|
|
798
|
+
composeProject,
|
|
799
|
+
containerName,
|
|
800
|
+
hostWorktreePath,
|
|
801
|
+
machine,
|
|
802
|
+
...(codeServerPort === undefined ? {} : { codeServerPort }),
|
|
803
|
+
...(previewPorts === undefined ? {} : { previewPorts }),
|
|
804
|
+
...(initWarning === undefined ? {} : { initWarning }),
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function validateMachineIdentity(value: unknown): DockerMachineIdentity {
|
|
809
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
810
|
+
throw new Error("Docker machine identity must be an object");
|
|
811
|
+
}
|
|
812
|
+
const record = value as Record<string, unknown>;
|
|
813
|
+
if (
|
|
814
|
+
Object.keys(record).sort().join("\0") !==
|
|
815
|
+
"backend\0endpoint\0engineId"
|
|
816
|
+
) {
|
|
817
|
+
throw new Error("Docker machine identity has unexpected fields");
|
|
818
|
+
}
|
|
819
|
+
if (record.backend !== "docker") {
|
|
820
|
+
throw new Error("Docker machine backend is invalid");
|
|
821
|
+
}
|
|
822
|
+
const endpoint = exactComponent(record.endpoint, "Docker endpoint", 2048);
|
|
823
|
+
const socketPath = endpoint.slice("unix://".length);
|
|
824
|
+
if (
|
|
825
|
+
!endpoint.startsWith("unix:///") ||
|
|
826
|
+
/[@?#\r\n]/.test(endpoint) ||
|
|
827
|
+
!path.isAbsolute(socketPath) ||
|
|
828
|
+
path.resolve(socketPath) !== socketPath
|
|
829
|
+
) {
|
|
830
|
+
throw new Error("Docker machine endpoint must be a local Unix socket");
|
|
831
|
+
}
|
|
832
|
+
const engineId = exactComponent(record.engineId, "Docker engine id", 512);
|
|
833
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(engineId)) {
|
|
834
|
+
throw new Error("Docker engine id is invalid");
|
|
835
|
+
}
|
|
836
|
+
return { backend: record.backend, endpoint, engineId };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function validatePreviewPorts(
|
|
840
|
+
value: unknown,
|
|
841
|
+
): Array<{ name: string; hostPort: number }> | undefined {
|
|
842
|
+
if (value === undefined) return undefined;
|
|
843
|
+
if (!Array.isArray(value) || value.length > 64) {
|
|
844
|
+
throw new Error("Docker task environment preview ports are invalid");
|
|
845
|
+
}
|
|
846
|
+
return value.map((entry) => {
|
|
847
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
848
|
+
throw new Error("Docker task environment preview port is invalid");
|
|
849
|
+
}
|
|
850
|
+
const record = entry as Record<string, unknown>;
|
|
851
|
+
if (Object.keys(record).sort().join("\0") !== "hostPort\0name") {
|
|
852
|
+
throw new Error("Docker task environment preview port has unexpected fields");
|
|
853
|
+
}
|
|
854
|
+
return {
|
|
855
|
+
name: exactComponent(record.name, "preview port name"),
|
|
856
|
+
hostPort: requiredPort(record.hostPort, "preview host port"),
|
|
857
|
+
};
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function exactComponent(
|
|
862
|
+
value: unknown,
|
|
863
|
+
name: string,
|
|
864
|
+
max = MAX_LOCATOR_COMPONENT,
|
|
865
|
+
): string {
|
|
866
|
+
if (
|
|
867
|
+
typeof value !== "string" ||
|
|
868
|
+
value.length === 0 ||
|
|
869
|
+
value.length > max ||
|
|
870
|
+
value.includes("\0")
|
|
871
|
+
) {
|
|
872
|
+
throw new Error(`${name} is invalid`);
|
|
873
|
+
}
|
|
874
|
+
return value;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function optionalPort(value: unknown, name: string): number | undefined {
|
|
878
|
+
return value === undefined ? undefined : requiredPort(value, name);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function requiredPort(value: unknown, name: string): number {
|
|
882
|
+
if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65_535) {
|
|
883
|
+
throw new Error(`${name} is invalid`);
|
|
884
|
+
}
|
|
885
|
+
return Number(value);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** The argv of one `docker exec`, and the environment the docker CLI must run
|
|
889
|
+
* with to resolve the credentials it names (cliExecEnv). */
|
|
890
|
+
function dockerExecArgs(
|
|
891
|
+
containerName: string,
|
|
892
|
+
request:
|
|
893
|
+
| TaskEnvironmentExecRequest
|
|
894
|
+
| TaskEnvironmentSpawnRequest
|
|
895
|
+
| TaskEnvironmentSessionRequest
|
|
896
|
+
| TaskEnvironmentDetachedSessionRequest,
|
|
897
|
+
mode: "interactive" | "detached" = "interactive",
|
|
898
|
+
/** Passed only by exec(), after assertTaskEnvironmentExecRequest. */
|
|
899
|
+
privileged = false,
|
|
900
|
+
): { args: string[]; env: NodeJS.ProcessEnv | undefined } {
|
|
901
|
+
const args = ["exec", mode === "detached" ? "-d" : "-i"];
|
|
902
|
+
if (privileged) args.push("--privileged");
|
|
903
|
+
if (request.cwd) args.push("--workdir", request.cwd);
|
|
904
|
+
if (request.user) args.push("--user", request.user);
|
|
905
|
+
const execEnv = cliExecEnv(request);
|
|
906
|
+
args.push(...execEnv.args, containerName, ...request.argv);
|
|
907
|
+
return { args, env: execEnv.env };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* The `--env` options of one exec, for a CLI that resolves a bare
|
|
912
|
+
* `--env NAME` from its own environment (docker, Apple `container`), and the
|
|
913
|
+
* environment the CLI child must then run with.
|
|
914
|
+
*
|
|
915
|
+
* An engine credential, or an agent's task token under any of its names, is
|
|
916
|
+
* named bare and its value set in that child's environment only, so it never
|
|
917
|
+
* lands on the runner's argv, which every local user can read with `ps`
|
|
918
|
+
* (ADR-154 §6). The request's value wins over the
|
|
919
|
+
* runner's own variable of the same name (an owned runner may still carry a
|
|
920
|
+
* legacy CLAUDE_CODE_OAUTH_TOKEN), and a credential the request does not
|
|
921
|
+
* carry is never named here; a name in `inheritEnv` still forwards the
|
|
922
|
+
* runner's own value, as that field promises. Every other entry keeps its
|
|
923
|
+
* `NAME=value` form: in the child's environment, a task's HOME or PATH would
|
|
924
|
+
* steer the CLI's own config and credential-helper lookup. With no
|
|
925
|
+
* credential, `env` is undefined and the child inherits the runner's
|
|
926
|
+
* environment as before.
|
|
927
|
+
*/
|
|
928
|
+
export function cliExecEnv(request: {
|
|
929
|
+
inheritEnv?: readonly string[];
|
|
930
|
+
env?: Readonly<Record<string, string>>;
|
|
931
|
+
}): { args: string[]; env: NodeJS.ProcessEnv | undefined } {
|
|
932
|
+
const args: string[] = [];
|
|
933
|
+
for (const name of request.inheritEnv ?? []) {
|
|
934
|
+
if (process.env[name] !== undefined) args.push("--env", name);
|
|
935
|
+
}
|
|
936
|
+
const credentials: Record<string, string> = {};
|
|
937
|
+
for (const [name, value] of Object.entries(request.env ?? {}).sort(([a], [b]) =>
|
|
938
|
+
a.localeCompare(b),
|
|
939
|
+
)) {
|
|
940
|
+
if (isLaunchSecretEnvName(name)) {
|
|
941
|
+
args.push("--env", name);
|
|
942
|
+
credentials[name] = value;
|
|
943
|
+
} else {
|
|
944
|
+
args.push("--env", `${name}=${value}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
args,
|
|
949
|
+
env:
|
|
950
|
+
Object.keys(credentials).length === 0
|
|
951
|
+
? undefined
|
|
952
|
+
: { ...process.env, ...credentials },
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Bounded one-shot exec, generalized over the CLI so the Apple container
|
|
957
|
+
* provider reuses the exact truncation/timeout/exit-mapping semantics. */
|
|
958
|
+
export function capturedCliExec(
|
|
959
|
+
spawn: typeof nodeSpawn,
|
|
960
|
+
command: string,
|
|
961
|
+
args: readonly string[],
|
|
962
|
+
request: TaskEnvironmentExecRequest,
|
|
963
|
+
/** The child's own environment, when it must differ from the runner's
|
|
964
|
+
* (cliExecEnv). */
|
|
965
|
+
env?: NodeJS.ProcessEnv,
|
|
966
|
+
): Promise<TaskEnvironmentExecResult> {
|
|
967
|
+
return new Promise((resolve) => {
|
|
968
|
+
const child = spawn(command, [...args], {
|
|
969
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
970
|
+
...(env === undefined ? {} : { env }),
|
|
971
|
+
}) as ChildProcessWithoutNullStreams;
|
|
972
|
+
let stdout: Buffer[] = [];
|
|
973
|
+
let stderr: Buffer[] = [];
|
|
974
|
+
let retained = 0;
|
|
975
|
+
let stdoutTruncated = false;
|
|
976
|
+
let stderrTruncated = false;
|
|
977
|
+
let settled = false;
|
|
978
|
+
const append = (kind: "stdout" | "stderr", chunk: Buffer): void => {
|
|
979
|
+
if (settled) return;
|
|
980
|
+
const remaining = Math.max(0, request.maxOutputBytes - retained);
|
|
981
|
+
const kept = remaining === 0 ? Buffer.alloc(0) : chunk.subarray(0, remaining);
|
|
982
|
+
if (kept.length > 0) {
|
|
983
|
+
if (kind === "stdout") stdout.push(kept);
|
|
984
|
+
else stderr.push(kept);
|
|
985
|
+
retained += kept.length;
|
|
986
|
+
}
|
|
987
|
+
if (kept.length !== chunk.length) {
|
|
988
|
+
if (kind === "stdout") stdoutTruncated = true;
|
|
989
|
+
else stderrTruncated = true;
|
|
990
|
+
child.kill("SIGKILL");
|
|
991
|
+
// Same bounded settlement as the deadline kill: never wait forever
|
|
992
|
+
// on close after an overflow kill.
|
|
993
|
+
const settle = setTimeout(() => finish(null, "SIGKILL"), 5_000);
|
|
994
|
+
settle.unref?.();
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
child.stdout.on("data", (chunk: Buffer) => append("stdout", Buffer.from(chunk)));
|
|
998
|
+
child.stderr.on("data", (chunk: Buffer) => append("stderr", Buffer.from(chunk)));
|
|
999
|
+
const timer = setTimeout(() => {
|
|
1000
|
+
child.kill("SIGKILL");
|
|
1001
|
+
// Settle even if the killed child never emits close (unreapable state);
|
|
1002
|
+
// the caller's own deadline already expired.
|
|
1003
|
+
const settle = setTimeout(() => finish(null, "SIGKILL"), 5_000);
|
|
1004
|
+
settle.unref?.();
|
|
1005
|
+
}, request.timeoutMs);
|
|
1006
|
+
timer.unref?.();
|
|
1007
|
+
const finish = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
|
1008
|
+
if (settled) return;
|
|
1009
|
+
settled = true;
|
|
1010
|
+
clearTimeout(timer);
|
|
1011
|
+
resolve({
|
|
1012
|
+
exitCode,
|
|
1013
|
+
signal,
|
|
1014
|
+
stdout: Buffer.concat(stdout),
|
|
1015
|
+
stderr: Buffer.concat(stderr),
|
|
1016
|
+
stdoutTruncated,
|
|
1017
|
+
stderrTruncated,
|
|
1018
|
+
});
|
|
1019
|
+
stdout = [];
|
|
1020
|
+
stderr = [];
|
|
1021
|
+
};
|
|
1022
|
+
child.once("error", (error) => {
|
|
1023
|
+
stderr.push(Buffer.from(error.message));
|
|
1024
|
+
finish(null, null);
|
|
1025
|
+
});
|
|
1026
|
+
child.once("close", finish);
|
|
1027
|
+
child.stdin.on("error", () => {});
|
|
1028
|
+
child.stdin.end(request.stdin);
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** Streaming exec, generalized over the CLI for the same reason. */
|
|
1033
|
+
export function streamingCliExec(
|
|
1034
|
+
spawn: typeof nodeSpawn,
|
|
1035
|
+
command: string,
|
|
1036
|
+
args: readonly string[],
|
|
1037
|
+
request: TaskEnvironmentSpawnRequest | TaskEnvironmentSessionRequest,
|
|
1038
|
+
/** As for capturedCliExec. */
|
|
1039
|
+
env?: NodeJS.ProcessEnv,
|
|
1040
|
+
): TaskEnvironmentProcess {
|
|
1041
|
+
const child = spawn(command, [...args], {
|
|
1042
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1043
|
+
...(env === undefined ? {} : { env }),
|
|
1044
|
+
}) as ChildProcessWithoutNullStreams;
|
|
1045
|
+
// Every kill forces settlement within a bounded grace, like the captured
|
|
1046
|
+
// runner: a wedged client must not leave an agent session's completion
|
|
1047
|
+
// pending indefinitely.
|
|
1048
|
+
let forceSettle: (() => void) | null = null;
|
|
1049
|
+
const killAndSettle = (): void => {
|
|
1050
|
+
child.kill("SIGKILL");
|
|
1051
|
+
const settle = setTimeout(() => forceSettle?.(), 5_000);
|
|
1052
|
+
settle.unref?.();
|
|
1053
|
+
};
|
|
1054
|
+
const budget = new SharedOutputBudget(request.maxOutputBytes, killAndSettle);
|
|
1055
|
+
const stdout = new BoundedByteQueue(budget);
|
|
1056
|
+
const stderr = new BoundedByteQueue(budget);
|
|
1057
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(Buffer.from(chunk)));
|
|
1058
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(Buffer.from(chunk)));
|
|
1059
|
+
const timer =
|
|
1060
|
+
"timeoutMs" in request
|
|
1061
|
+
? setTimeout(killAndSettle, request.timeoutMs)
|
|
1062
|
+
: null;
|
|
1063
|
+
timer?.unref?.();
|
|
1064
|
+
const completion = new Promise<TaskEnvironmentProcessExit>((resolve) => {
|
|
1065
|
+
let settled = false;
|
|
1066
|
+
const finish = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
|
1067
|
+
if (settled) return;
|
|
1068
|
+
settled = true;
|
|
1069
|
+
if (timer) clearTimeout(timer);
|
|
1070
|
+
stdout.end();
|
|
1071
|
+
stderr.end();
|
|
1072
|
+
resolve({ exitCode, signal });
|
|
1073
|
+
};
|
|
1074
|
+
forceSettle = () => finish(null, "SIGKILL");
|
|
1075
|
+
child.once("error", () => finish(null, null));
|
|
1076
|
+
child.once("close", finish);
|
|
1077
|
+
});
|
|
1078
|
+
child.stdin.on("error", () => {});
|
|
1079
|
+
return {
|
|
1080
|
+
stdout,
|
|
1081
|
+
stderr,
|
|
1082
|
+
completion,
|
|
1083
|
+
write: (chunk) => writeChildInput(child, chunk),
|
|
1084
|
+
closeInput: async () => {
|
|
1085
|
+
child.stdin.end();
|
|
1086
|
+
},
|
|
1087
|
+
terminate: async (signal = "SIGTERM") => {
|
|
1088
|
+
child.kill(signal);
|
|
1089
|
+
},
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function writeChildInput(
|
|
1094
|
+
child: ChildProcessWithoutNullStreams,
|
|
1095
|
+
chunk: Uint8Array,
|
|
1096
|
+
): Promise<void> {
|
|
1097
|
+
if (child.stdin.destroyed || !child.stdin.writable) {
|
|
1098
|
+
return Promise.reject(new Error("task environment process input is closed"));
|
|
1099
|
+
}
|
|
1100
|
+
return new Promise((resolve, reject) => {
|
|
1101
|
+
const onError = (error: Error): void => {
|
|
1102
|
+
child.stdin.off("drain", onDrain);
|
|
1103
|
+
reject(error);
|
|
1104
|
+
};
|
|
1105
|
+
const onDrain = (): void => {
|
|
1106
|
+
child.stdin.off("error", onError);
|
|
1107
|
+
resolve();
|
|
1108
|
+
};
|
|
1109
|
+
child.stdin.once("error", onError);
|
|
1110
|
+
if (child.stdin.write(chunk)) {
|
|
1111
|
+
child.stdin.off("error", onError);
|
|
1112
|
+
resolve();
|
|
1113
|
+
} else {
|
|
1114
|
+
child.stdin.once("drain", onDrain);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
class SharedOutputBudget {
|
|
1120
|
+
#bytes = 0;
|
|
1121
|
+
#overflowed = false;
|
|
1122
|
+
|
|
1123
|
+
constructor(
|
|
1124
|
+
private readonly maxBytes: number,
|
|
1125
|
+
private readonly onOverflow: () => void,
|
|
1126
|
+
) {}
|
|
1127
|
+
|
|
1128
|
+
retain(bytes: number): boolean {
|
|
1129
|
+
if (this.#overflowed || this.#bytes + bytes > this.maxBytes) {
|
|
1130
|
+
if (!this.#overflowed) {
|
|
1131
|
+
this.#overflowed = true;
|
|
1132
|
+
this.onOverflow();
|
|
1133
|
+
}
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
this.#bytes += bytes;
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
release(bytes: number): void {
|
|
1141
|
+
this.#bytes = Math.max(0, this.#bytes - bytes);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
class BoundedByteQueue implements AsyncIterable<Uint8Array> {
|
|
1146
|
+
readonly #chunks: Buffer[] = [];
|
|
1147
|
+
readonly #waiters: Array<
|
|
1148
|
+
(result: IteratorResult<Uint8Array>) => void
|
|
1149
|
+
> = [];
|
|
1150
|
+
#ended = false;
|
|
1151
|
+
|
|
1152
|
+
constructor(private readonly budget: SharedOutputBudget) {}
|
|
1153
|
+
|
|
1154
|
+
push(chunk: Buffer): void {
|
|
1155
|
+
if (this.#ended || chunk.length === 0 || !this.budget.retain(chunk.length)) return;
|
|
1156
|
+
const waiter = this.#waiters.shift();
|
|
1157
|
+
if (waiter) {
|
|
1158
|
+
this.budget.release(chunk.length);
|
|
1159
|
+
waiter({ done: false, value: chunk });
|
|
1160
|
+
} else {
|
|
1161
|
+
this.#chunks.push(chunk);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
end(): void {
|
|
1166
|
+
if (this.#ended) return;
|
|
1167
|
+
this.#ended = true;
|
|
1168
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
1169
|
+
waiter({ done: true, value: undefined });
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
[Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
|
|
1174
|
+
return {
|
|
1175
|
+
next: () => {
|
|
1176
|
+
const chunk = this.#chunks.shift();
|
|
1177
|
+
if (chunk) {
|
|
1178
|
+
this.budget.release(chunk.length);
|
|
1179
|
+
return Promise.resolve({ done: false as const, value: chunk });
|
|
1180
|
+
}
|
|
1181
|
+
if (this.#ended) {
|
|
1182
|
+
return Promise.resolve({ done: true as const, value: undefined });
|
|
1183
|
+
}
|
|
1184
|
+
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
1185
|
+
},
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function validateCopyRequest(request: TaskEnvironmentCopyRequest): void {
|
|
1191
|
+
for (const path of [request.source, request.destination]) {
|
|
1192
|
+
if (!path.startsWith("/") || path.includes("\0") || path.includes(":")) {
|
|
1193
|
+
throw new Error("task environment copy paths must be absolute and unambiguous");
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function parseDockerPorts(stdout: string): TaskEnvironmentPort[] {
|
|
1199
|
+
const ports: TaskEnvironmentPort[] = [];
|
|
1200
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
1201
|
+
if (!line.trim()) continue;
|
|
1202
|
+
const match = /^(\d+)(?:\/(?:tcp|udp))? -> 127\.0\.0\.1:(\d+)$/.exec(
|
|
1203
|
+
line.trim(),
|
|
1204
|
+
);
|
|
1205
|
+
if (!match) {
|
|
1206
|
+
throw new Error("task environment published a non-loopback or malformed port");
|
|
1207
|
+
}
|
|
1208
|
+
ports.push({
|
|
1209
|
+
containerPort: requiredPort(Number(match[1]), "container port"),
|
|
1210
|
+
host: "127.0.0.1",
|
|
1211
|
+
hostPort: requiredPort(Number(match[2]), "host port"),
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
return ports;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function taskStatusToEnvironmentStatus(
|
|
1218
|
+
status: TaskStatusResult,
|
|
1219
|
+
): TaskEnvironmentStatus {
|
|
1220
|
+
return {
|
|
1221
|
+
state: status.composeRunning
|
|
1222
|
+
? "running"
|
|
1223
|
+
: status.containers.length > 0 || status.worktreePresent
|
|
1224
|
+
? "stopped"
|
|
1225
|
+
: "absent",
|
|
1226
|
+
instances: [...status.containers],
|
|
1227
|
+
workspacePresent: status.worktreePresent,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// The fourth column is the preview-sidecar marker: a sidecar wearing the
|
|
1232
|
+
// task's compose labels must read as "not the app", not as malformed
|
|
1233
|
+
// identity data — the latter turned a healthy running task's status into
|
|
1234
|
+
// "unknown" (live 2026-08-18).
|
|
1235
|
+
const DOCKER_STATUS_FORMAT =
|
|
1236
|
+
`{{.Names}}\t{{.Label "com.docker.compose.project"}}\t{{.State}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
|
|
1237
|
+
|
|
1238
|
+
async function inspectDockerTaskEnvironmentStatus(
|
|
1239
|
+
docker: typeof dockerCli,
|
|
1240
|
+
environment: DockerTaskEnvironmentLocatorValue,
|
|
1241
|
+
): Promise<TaskEnvironmentStatus> {
|
|
1242
|
+
const result = await docker(
|
|
1243
|
+
[
|
|
1244
|
+
"ps",
|
|
1245
|
+
"--all",
|
|
1246
|
+
"--filter",
|
|
1247
|
+
`label=com.docker.compose.project=${environment.composeProject}`,
|
|
1248
|
+
"--filter",
|
|
1249
|
+
"label=com.docker.compose.service=app",
|
|
1250
|
+
"--format",
|
|
1251
|
+
DOCKER_STATUS_FORMAT,
|
|
1252
|
+
],
|
|
1253
|
+
{ timeoutMs: 30_000, maxOutputBytes: 256 * 1024 },
|
|
1254
|
+
);
|
|
1255
|
+
if (result.status !== 0) {
|
|
1256
|
+
return {
|
|
1257
|
+
state: "unknown",
|
|
1258
|
+
detail: boundedDockerFailure(result, "inspect task environment"),
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
let state: string | undefined;
|
|
1263
|
+
for (const raw of result.stdout.split(/\r?\n/)) {
|
|
1264
|
+
// No trim before splitting: the app row's LAST column (the sidecar
|
|
1265
|
+
// marker) is empty, and trimming would eat its tab and shift the shape.
|
|
1266
|
+
if (!raw.trim()) continue;
|
|
1267
|
+
const [name, project, candidateState, sidecar, ...extra] = raw.split("\t");
|
|
1268
|
+
// A preview sidecar wearing the task's compose labels is not the app
|
|
1269
|
+
// container; skipping it keeps a healthy task from reading as unknown.
|
|
1270
|
+
if (sidecar !== undefined && sidecar !== "") continue;
|
|
1271
|
+
if (
|
|
1272
|
+
state !== undefined ||
|
|
1273
|
+
name !== environment.containerName ||
|
|
1274
|
+
project !== environment.composeProject ||
|
|
1275
|
+
!candidateState ||
|
|
1276
|
+
!candidateState.trim() ||
|
|
1277
|
+
sidecar === undefined ||
|
|
1278
|
+
extra.length > 0
|
|
1279
|
+
) {
|
|
1280
|
+
return {
|
|
1281
|
+
state: "unknown",
|
|
1282
|
+
detail: "task environment inventory returned malformed identity data",
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
state = candidateState.trim();
|
|
1286
|
+
}
|
|
1287
|
+
const workspacePresent = existsSync(environment.hostWorktreePath);
|
|
1288
|
+
if (state === undefined) {
|
|
1289
|
+
return {
|
|
1290
|
+
state: workspacePresent ? "stopped" : "absent",
|
|
1291
|
+
instances: [],
|
|
1292
|
+
workspacePresent,
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
return {
|
|
1296
|
+
state: state === "running" ? "running" : "stopped",
|
|
1297
|
+
instances: [environment.containerName],
|
|
1298
|
+
workspacePresent,
|
|
1299
|
+
...(state === "paused" || state === "restarting" ? { frozen: true as const } : {}),
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function requireDockerSuccess(result: DockerResult, operation: string): void {
|
|
1304
|
+
if (result.status === 0) return;
|
|
1305
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 512);
|
|
1306
|
+
throw new Error(`${operation} failed${detail ? `: ${detail}` : ""}`);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** What `docker container inspect` says of an app container that the boot
|
|
1310
|
+
* sweep may start for cleanup (`#startRefusal`). */
|
|
1311
|
+
interface InspectedApp {
|
|
1312
|
+
id: string;
|
|
1313
|
+
state: string;
|
|
1314
|
+
idle: boolean;
|
|
1315
|
+
healthcheck: HealthcheckState;
|
|
1316
|
+
image: string | null;
|
|
1317
|
+
restartPolicy: string;
|
|
1318
|
+
init: boolean | null;
|
|
1319
|
+
autoRemove: boolean | null;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** `disabled`: no test, or `["NONE"]`. `inherit`: a container's absent or
|
|
1323
|
+
* empty test, which Docker reads as "the image's". */
|
|
1324
|
+
type HealthcheckState = "enabled" | "disabled" | "inherit" | "unknown";
|
|
1325
|
+
|
|
1326
|
+
function healthcheckState(healthcheck: unknown): HealthcheckState {
|
|
1327
|
+
if (healthcheck === undefined || healthcheck === null) return "inherit";
|
|
1328
|
+
if (typeof healthcheck !== "object" || Array.isArray(healthcheck)) return "unknown";
|
|
1329
|
+
const test = (healthcheck as Record<string, unknown>).Test;
|
|
1330
|
+
if (test === undefined || test === null) return "inherit";
|
|
1331
|
+
if (!Array.isArray(test)) return "unknown";
|
|
1332
|
+
if (test.length === 0) return "inherit";
|
|
1333
|
+
if (test.length === 1 && test[0] === "NONE") return "disabled";
|
|
1334
|
+
return "enabled";
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function boundedDockerFailure(result: DockerResult, operation: string): string {
|
|
1338
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 512);
|
|
1339
|
+
return `${operation} failed${detail ? `: ${detail}` : ""}`;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function boundedErrorMessage(error: unknown): string {
|
|
1343
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 512);
|
|
1344
|
+
}
|