@moondeck/station 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set the task creator's git identity inside the container (ADR-029) so commits
|
|
3
|
+
* are authored as the user who created the task, not the image's generic
|
|
4
|
+
* identity. Best-effort, mirrors the SSH setup: a failure logs but never aborts
|
|
5
|
+
* task-up. Name falls back to the email when no display name is known.
|
|
6
|
+
*
|
|
7
|
+
* ensureTaskSshIdentity re-asserts the SSH signing identity and its dependent
|
|
8
|
+
* signing config the same way GitHub tokens are re-asserted at channel ensure.
|
|
9
|
+
* Git transport is separate: connected users push over HTTPS through gh; SSH
|
|
10
|
+
* is only the explicit no-connection fallback.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { eq } from "drizzle-orm";
|
|
18
|
+
|
|
19
|
+
import { getDb, schema } from "./db";
|
|
20
|
+
import { dockerCli } from "./docker-exec";
|
|
21
|
+
import {
|
|
22
|
+
removeTaskAppContainer,
|
|
23
|
+
taskAppContainerName,
|
|
24
|
+
taskAppContainerState,
|
|
25
|
+
taskContainerCli,
|
|
26
|
+
} from "./task-container-cli";
|
|
27
|
+
import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
28
|
+
import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
|
|
29
|
+
import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
|
|
30
|
+
import { assertSafeHostTaskId } from "./task-identity";
|
|
31
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
32
|
+
import {
|
|
33
|
+
deleteKey,
|
|
34
|
+
getPublicKey,
|
|
35
|
+
removeTaskIdentity,
|
|
36
|
+
writeTaskIdentity,
|
|
37
|
+
} from "./ssh";
|
|
38
|
+
|
|
39
|
+
const EXEC_TIMEOUT_MS = 10_000;
|
|
40
|
+
|
|
41
|
+
/** Injectable docker-exec seam (mocked in tests). Async so the docker call
|
|
42
|
+
* doesn't block the host event loop (this runs on every channel ensure). */
|
|
43
|
+
export type DockerExec = (
|
|
44
|
+
args: string[],
|
|
45
|
+
) => Promise<{ status: number | null; stdout?: string; stderr: string }>;
|
|
46
|
+
|
|
47
|
+
const defaultExec: DockerExec = async (args) => {
|
|
48
|
+
const res = await taskContainerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
|
|
49
|
+
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function managedTaskExecArgs(
|
|
53
|
+
user: "node" | "root",
|
|
54
|
+
container: string,
|
|
55
|
+
command: string[],
|
|
56
|
+
): string[] {
|
|
57
|
+
return [
|
|
58
|
+
"exec",
|
|
59
|
+
"-u",
|
|
60
|
+
user,
|
|
61
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
62
|
+
container,
|
|
63
|
+
...command,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A task environment we can exec in (machine or sidecar): the ssh steps run
|
|
68
|
+
* there as `node` under the runtime authority, key material over stdin. */
|
|
69
|
+
type EnvironmentExec = Pick<TaskEnvironmentHandle, "exec">;
|
|
70
|
+
|
|
71
|
+
async function environmentStep(
|
|
72
|
+
environment: EnvironmentExec,
|
|
73
|
+
argv: [string, ...string[]],
|
|
74
|
+
stdin?: Uint8Array,
|
|
75
|
+
): Promise<{ status: number | null; stdout: string; stderr: string }> {
|
|
76
|
+
const result = await environment.exec({
|
|
77
|
+
projectEnvProfile: "maintenance",
|
|
78
|
+
argv,
|
|
79
|
+
user: "node",
|
|
80
|
+
env: RUNTIME_AUTHORITY_ENV,
|
|
81
|
+
...(stdin ? { stdin } : {}),
|
|
82
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
83
|
+
maxOutputBytes: 64 * 1024,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
status: result.exitCode,
|
|
87
|
+
stdout: Buffer.from(result.stdout).toString("utf8"),
|
|
88
|
+
stderr: Buffer.from(result.stderr).toString("utf8"),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function setupTaskGitIdentity(
|
|
93
|
+
taskId: string,
|
|
94
|
+
name: string | null,
|
|
95
|
+
email: string | null,
|
|
96
|
+
exec: DockerExec = defaultExec,
|
|
97
|
+
environment?: Pick<TaskEnvironmentHandle, "exec">,
|
|
98
|
+
): Promise<boolean> {
|
|
99
|
+
if (!email) {
|
|
100
|
+
console.log(`[git] task ${taskId}: no owner email — leaving git identity`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const displayName = name && name.trim() ? name.trim() : email;
|
|
104
|
+
const container = taskAppContainerName(taskId);
|
|
105
|
+
// Values are passed as argv (never interpolated into a shell string), so a
|
|
106
|
+
// name/email can't shell-inject.
|
|
107
|
+
const entries: ReadonlyArray<readonly [string, string]> = [
|
|
108
|
+
["user.name", displayName],
|
|
109
|
+
["user.email", email],
|
|
110
|
+
];
|
|
111
|
+
for (const [key, value] of entries) {
|
|
112
|
+
const command = ["/usr/bin/git", "config", "--global", key, value] as [
|
|
113
|
+
string,
|
|
114
|
+
...string[],
|
|
115
|
+
];
|
|
116
|
+
const res = environment
|
|
117
|
+
? await environment.exec({
|
|
118
|
+
projectEnvProfile: "maintenance",
|
|
119
|
+
argv: command,
|
|
120
|
+
user: "node",
|
|
121
|
+
env: RUNTIME_AUTHORITY_ENV,
|
|
122
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
123
|
+
maxOutputBytes: 64 * 1024,
|
|
124
|
+
}).then((result) => ({
|
|
125
|
+
status: result.exitCode,
|
|
126
|
+
stderr: Buffer.from(result.stderr).toString("utf8"),
|
|
127
|
+
}))
|
|
128
|
+
: await exec(managedTaskExecArgs("node", container, command));
|
|
129
|
+
if (res.status !== 0) {
|
|
130
|
+
console.warn(
|
|
131
|
+
`[git] task ${taskId}: git config ${key} failed: ${res.stderr.trim()}`,
|
|
132
|
+
);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
console.log(`[git] task ${taskId}: git identity → ${displayName} <${email}>`);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The key's dependent signing config, applied as one unit with the key itself.
|
|
141
|
+
// Deliberately no URL rewrite: doing that here would override a connected
|
|
142
|
+
// user's gh HTTPS credential and make SSH mandatory again.
|
|
143
|
+
//
|
|
144
|
+
// allowed_signers rides along so verification works, not just signing:
|
|
145
|
+
// without gpg.ssh.allowedSignersFile, `git log --format=%G?` prints N ("no
|
|
146
|
+
// signature") for commits that ARE signed and GitHub-verified — git reports
|
|
147
|
+
// the absence of a verification RESULT as the absence of a signature, and a
|
|
148
|
+
// crew read "all 25 commits unsigned" off perfectly signed work (live
|
|
149
|
+
// 2026-08-26). The wildcard principal is deliberate: the task key is the
|
|
150
|
+
// container's only signer, and the identity email may not be configured yet
|
|
151
|
+
// when the key lands.
|
|
152
|
+
export const SSH_SIGNING_CONFIG =
|
|
153
|
+
"/usr/bin/git config --global gpg.format ssh" +
|
|
154
|
+
" && /usr/bin/git config --global user.signingkey /home/node/.ssh/id_ed25519.pub" +
|
|
155
|
+
" && /usr/bin/git config --global commit.gpgsign true" +
|
|
156
|
+
" && /usr/bin/git config --global tag.gpgsign true" +
|
|
157
|
+
' && /usr/bin/printf \'* %s\\n\' "$(/bin/cat /home/node/.ssh/id_ed25519.pub)" > /home/node/.ssh/allowed_signers' +
|
|
158
|
+
" && /usr/bin/git config --global gpg.ssh.allowedSignersFile /home/node/.ssh/allowed_signers";
|
|
159
|
+
|
|
160
|
+
export const SSH_TRANSPORT_CONFIG =
|
|
161
|
+
"/usr/bin/git config --global --unset-all 'url.https://github.com/.insteadOf' >/dev/null 2>&1 || true; " +
|
|
162
|
+
"/usr/bin/git config --global --replace-all 'url.git@github.com:.insteadOf' 'https://github.com/'; " +
|
|
163
|
+
'/usr/bin/git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"';
|
|
164
|
+
|
|
165
|
+
/** Select SSH transport inside a disconnected task container. */
|
|
166
|
+
export async function configureTaskSshTransport(
|
|
167
|
+
taskId: string,
|
|
168
|
+
exec: DockerExec = defaultExec,
|
|
169
|
+
): Promise<boolean> {
|
|
170
|
+
const res = await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), [
|
|
171
|
+
"/bin/sh",
|
|
172
|
+
"-c",
|
|
173
|
+
SSH_TRANSPORT_CONFIG,
|
|
174
|
+
]));
|
|
175
|
+
if (res.status !== 0) {
|
|
176
|
+
console.warn(
|
|
177
|
+
`[ssh] task ${taskId}: SSH transport fallback setup failed: ${res.stderr.trim()}`,
|
|
178
|
+
);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
console.log(`[ssh] task ${taskId}: SSH Git transport fallback selected`);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Copy the SSH identity at `keyPath` into the task container and apply its
|
|
187
|
+
* dependent git config. Every failure is LOGGED with its step — the silent
|
|
188
|
+
* `|| true` chain this replaces let a missing key masquerade as a GitHub
|
|
189
|
+
* permissions problem for an entire afternoon.
|
|
190
|
+
*/
|
|
191
|
+
export async function injectSshIdentity(
|
|
192
|
+
taskId: string,
|
|
193
|
+
keyPath: string,
|
|
194
|
+
exec: DockerExec = defaultExec,
|
|
195
|
+
environment?: EnvironmentExec,
|
|
196
|
+
): Promise<boolean> {
|
|
197
|
+
if (environment) {
|
|
198
|
+
// ADR-121/133: no container and no `docker cp` on a machine — the key
|
|
199
|
+
// pair rides stdin into files node owns from the start, so there is no
|
|
200
|
+
// chown race and nothing for the machine's other users to read. Before
|
|
201
|
+
// this branch every task machine logged `mkdir ~/.ssh failed: spawn
|
|
202
|
+
// docker ENOENT` on session start and signed nothing (2026-09-10).
|
|
203
|
+
const privateKey = readFileSync(keyPath);
|
|
204
|
+
const publicKey = readFileSync(`${keyPath}.pub`);
|
|
205
|
+
const steps: ReadonlyArray<readonly [string, [string, ...string[]], Uint8Array?]> = [
|
|
206
|
+
["mkdir ~/.ssh", ["/bin/sh", "-c", "/bin/mkdir -p /home/node/.ssh && /bin/chmod 700 /home/node/.ssh"]],
|
|
207
|
+
["copy key", ["/bin/sh", "-c", "umask 077 && /bin/cat > /home/node/.ssh/id_ed25519"], privateKey],
|
|
208
|
+
["copy pubkey", ["/bin/sh", "-c", "umask 022 && /bin/cat > /home/node/.ssh/id_ed25519.pub"], publicKey],
|
|
209
|
+
["own + chmod", ["/bin/sh", "-c", "/bin/chmod 700 /home/node/.ssh && /bin/chmod 600 /home/node/.ssh/id_ed25519"]],
|
|
210
|
+
["SSH signing config", ["/bin/sh", "-c", SSH_SIGNING_CONFIG]],
|
|
211
|
+
];
|
|
212
|
+
for (const [label, argv, stdin] of steps) {
|
|
213
|
+
const res = await environmentStep(environment, argv, stdin);
|
|
214
|
+
if (res.status !== 0) {
|
|
215
|
+
console.warn(
|
|
216
|
+
`[ssh] task ${taskId}: ${label} failed: ${res.stderr.trim() || `exit ${res.status}`}`,
|
|
217
|
+
);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
console.log(`[ssh] task ${taskId}: signing identity + git signing config asserted`);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
const container = taskAppContainerName(taskId);
|
|
225
|
+
const steps: ReadonlyArray<readonly [string, string[]]> = [
|
|
226
|
+
[
|
|
227
|
+
"mkdir ~/.ssh",
|
|
228
|
+
managedTaskExecArgs("root", container, [
|
|
229
|
+
"/bin/mkdir",
|
|
230
|
+
"-p",
|
|
231
|
+
"/home/node/.ssh",
|
|
232
|
+
]),
|
|
233
|
+
],
|
|
234
|
+
["copy key", ["cp", keyPath, `${container}:/home/node/.ssh/id_ed25519`]],
|
|
235
|
+
["copy pubkey", ["cp", `${keyPath}.pub`, `${container}:/home/node/.ssh/id_ed25519.pub`]],
|
|
236
|
+
[
|
|
237
|
+
"own + chmod",
|
|
238
|
+
managedTaskExecArgs("root", container, [
|
|
239
|
+
"/bin/sh", "-c",
|
|
240
|
+
"/bin/chown -R node:node /home/node/.ssh && /bin/chmod 700 /home/node/.ssh && /bin/chmod 600 /home/node/.ssh/id_ed25519",
|
|
241
|
+
]),
|
|
242
|
+
],
|
|
243
|
+
[
|
|
244
|
+
"SSH signing config",
|
|
245
|
+
managedTaskExecArgs("node", container, [
|
|
246
|
+
"/bin/sh",
|
|
247
|
+
"-c",
|
|
248
|
+
SSH_SIGNING_CONFIG,
|
|
249
|
+
]),
|
|
250
|
+
],
|
|
251
|
+
];
|
|
252
|
+
for (const [label, args] of steps) {
|
|
253
|
+
const res = await exec(args);
|
|
254
|
+
if (res.status !== 0) {
|
|
255
|
+
console.warn(
|
|
256
|
+
`[ssh] task ${taskId}: ${label} failed: ${res.stderr.trim() || `exit ${res.status}`}`,
|
|
257
|
+
);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
console.log(`[ssh] task ${taskId}: signing identity + git signing config asserted`);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// One in-flight ensure per task. Channel-ensure fires on every reconnect/
|
|
266
|
+
// nudge (seconds apart under load), and `docker cp` is unlink+create — two
|
|
267
|
+
// concurrent injects interleave so one's `cp id_ed25519` lands AFTER the
|
|
268
|
+
// other's chown, leaving the private key owned by the macOS uid and
|
|
269
|
+
// UNREADABLE by node inside the container ("Load key: Permission denied",
|
|
270
|
+
// live 2026-07-21). Serialize, and skip entirely when the key is already
|
|
271
|
+
// good, so the steady state does zero docker work.
|
|
272
|
+
interface SshEnsureInFlight {
|
|
273
|
+
fingerprint: string;
|
|
274
|
+
promise: Promise<boolean>;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const sshEnsureInFlight = new Map<string, SshEnsureInFlight>();
|
|
278
|
+
const sshDeleteInFlight = new Map<string, Promise<void>>();
|
|
279
|
+
/** Temporary assignment fence: cleanup seals a task, joins the older ensure,
|
|
280
|
+
* and deletes the copied key. A later safe assignment may explicitly reopen
|
|
281
|
+
* the task id. */
|
|
282
|
+
const sealedTaskSshEnsures = new Set<string>();
|
|
283
|
+
/** Durable for this host process: an orphan id is immutable/non-reused, and
|
|
284
|
+
* no later channel/recovery continuation may materialize its private key. */
|
|
285
|
+
const retiredTaskSshEnsures = new Set<string>();
|
|
286
|
+
|
|
287
|
+
export async function sealTaskSshIdentityEnsures(
|
|
288
|
+
taskId: string,
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
assertSafeHostTaskId(taskId);
|
|
291
|
+
sealedTaskSshEnsures.add(taskId);
|
|
292
|
+
for (;;) {
|
|
293
|
+
const current = sshEnsureInFlight.get(taskId);
|
|
294
|
+
if (!current) return;
|
|
295
|
+
await current.promise.catch(() => false);
|
|
296
|
+
if (sshEnsureInFlight.get(taskId) === current) {
|
|
297
|
+
sshEnsureInFlight.delete(taskId);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function openTaskSshIdentityEnsures(taskId: string): void {
|
|
303
|
+
assertSafeHostTaskId(taskId);
|
|
304
|
+
if (retiredTaskSshEnsures.has(taskId)) {
|
|
305
|
+
throw new Error(`task ${taskId} has been permanently retired`);
|
|
306
|
+
}
|
|
307
|
+
if (sshEnsureInFlight.has(taskId)) {
|
|
308
|
+
throw new Error(`task ${taskId} still has an SSH identity writer`);
|
|
309
|
+
}
|
|
310
|
+
sealedTaskSshEnsures.delete(taskId);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Fence future task SSH ensures and join the exact older materialization, if
|
|
314
|
+
* any. This is stronger than deleting the directory once: channel startup and
|
|
315
|
+
* runtime recovery intentionally launch SSH reconciliation in the background,
|
|
316
|
+
* and an older health probe can otherwise resume by writing the key after GC
|
|
317
|
+
* has already checked absence. Never rejects; strict removal is the final
|
|
318
|
+
* filesystem authority and will surface any cleanup failure to the cloud. */
|
|
319
|
+
export async function retireTaskSshIdentityEnsures(
|
|
320
|
+
taskId: string,
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
assertSafeHostTaskId(taskId);
|
|
323
|
+
retiredTaskSshEnsures.add(taskId);
|
|
324
|
+
await sealTaskSshIdentityEnsures(taskId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function sshPublicKeyFingerprint(publicKey: string): string | null {
|
|
328
|
+
const [, encoded] = publicKey.trim().split(/\s+/, 3);
|
|
329
|
+
if (!encoded) return null;
|
|
330
|
+
try {
|
|
331
|
+
const material = Buffer.from(encoded, "base64");
|
|
332
|
+
if (material.length === 0) return null;
|
|
333
|
+
return createHash("sha256")
|
|
334
|
+
.update(material)
|
|
335
|
+
.digest("base64")
|
|
336
|
+
.replace(/=+$/, "");
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** True only when the readable container key is the current DB-authoritative
|
|
343
|
+
* key for this owner. A copied key is not authority after ssh.key.delete. */
|
|
344
|
+
async function sshKeyHealthy(
|
|
345
|
+
taskId: string,
|
|
346
|
+
expectedPublicKey: string,
|
|
347
|
+
exec: DockerExec,
|
|
348
|
+
environment?: EnvironmentExec,
|
|
349
|
+
): Promise<boolean> {
|
|
350
|
+
const probe: [string, ...string[]] = [
|
|
351
|
+
"/bin/sh",
|
|
352
|
+
"-c",
|
|
353
|
+
"test -r /home/node/.ssh/id_ed25519 && /bin/cat /home/node/.ssh/id_ed25519.pub",
|
|
354
|
+
];
|
|
355
|
+
const res = environment
|
|
356
|
+
? await environmentStep(environment, probe)
|
|
357
|
+
: await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), probe));
|
|
358
|
+
const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
|
|
359
|
+
const actualFingerprint = sshPublicKeyFingerprint(res.stdout ?? "");
|
|
360
|
+
return (
|
|
361
|
+
res.status === 0 &&
|
|
362
|
+
expectedFingerprint !== null &&
|
|
363
|
+
actualFingerprint === expectedFingerprint
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
interface EnsureTaskSshIdentityDeps {
|
|
368
|
+
publicKeyForUser?: (userId: string) => string | null;
|
|
369
|
+
writeIdentity?: typeof writeTaskIdentity;
|
|
370
|
+
/** The task's environment when it is a machine or sidecar (no container to
|
|
371
|
+
* exec into or `docker cp` to). Null/undefined = the container path. */
|
|
372
|
+
environment?: () => EnvironmentExec | null | undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Ensure the task container has a node-readable SSH signing identity + git
|
|
377
|
+
* signing config. Idempotent and race-free: skips the copy when the key is already
|
|
378
|
+
* healthy (the common case), and never runs two injects for one task at
|
|
379
|
+
* once. Only (re)materializes + copies the key when it is missing or
|
|
380
|
+
* unreadable. Safe to call on every channel ensure.
|
|
381
|
+
*/
|
|
382
|
+
export async function ensureTaskSshIdentity(
|
|
383
|
+
taskId: string,
|
|
384
|
+
ownerUserId: string | null | undefined,
|
|
385
|
+
exec: DockerExec = defaultExec,
|
|
386
|
+
deps: EnsureTaskSshIdentityDeps = {},
|
|
387
|
+
): Promise<boolean> {
|
|
388
|
+
// ADR-154: the runner's own SSH key reaches a task only while the runner
|
|
389
|
+
// is on its own credentials; leaving them waits for a copy under way.
|
|
390
|
+
return (
|
|
391
|
+
(await whileOnOwnCredentials(() => ensureOwnTaskSshIdentity(taskId, ownerUserId, exec, deps))) ?? false
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function ensureOwnTaskSshIdentity(
|
|
396
|
+
taskId: string,
|
|
397
|
+
ownerUserId: string | null | undefined,
|
|
398
|
+
exec: DockerExec,
|
|
399
|
+
deps: EnsureTaskSshIdentityDeps,
|
|
400
|
+
): Promise<boolean> {
|
|
401
|
+
if (
|
|
402
|
+
retiredTaskSshEnsures.has(taskId) ||
|
|
403
|
+
sealedTaskSshEnsures.has(taskId)
|
|
404
|
+
) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
if (ownerUserId) {
|
|
408
|
+
const deleting = sshDeleteInFlight.get(ownerUserId);
|
|
409
|
+
if (deleting) {
|
|
410
|
+
await deleting.catch(() => {});
|
|
411
|
+
return ensureTaskSshIdentity(taskId, ownerUserId, exec, deps);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const publicKeyForUser = deps.publicKeyForUser ?? getPublicKey;
|
|
415
|
+
const expectedPublicKey = ownerUserId
|
|
416
|
+
? publicKeyForUser(ownerUserId)
|
|
417
|
+
: null;
|
|
418
|
+
// Consult authority before joining an older in-flight ensure. A deleted key
|
|
419
|
+
// must turn fallback off immediately even if its prior copy is still readable.
|
|
420
|
+
if (!ownerUserId || !expectedPublicKey) return false;
|
|
421
|
+
const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
|
|
422
|
+
if (!expectedFingerprint) return false;
|
|
423
|
+
const existing = sshEnsureInFlight.get(taskId);
|
|
424
|
+
if (existing) {
|
|
425
|
+
const result = await existing.promise;
|
|
426
|
+
if (existing.fingerprint !== expectedFingerprint) {
|
|
427
|
+
// An older key finished copying while authority rotated. Replace its map
|
|
428
|
+
// entry and run once more for the current key; never bless A as B.
|
|
429
|
+
if (sshEnsureInFlight.get(taskId) === existing) {
|
|
430
|
+
sshEnsureInFlight.delete(taskId);
|
|
431
|
+
}
|
|
432
|
+
return ensureTaskSshIdentity(taskId, ownerUserId, exec, deps);
|
|
433
|
+
}
|
|
434
|
+
return (
|
|
435
|
+
result &&
|
|
436
|
+
sshPublicKeyFingerprint(publicKeyForUser(ownerUserId) ?? "") ===
|
|
437
|
+
sshPublicKeyFingerprint(expectedPublicKey)
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const pending = ensureTaskSshIdentityOnce(
|
|
441
|
+
taskId,
|
|
442
|
+
ownerUserId,
|
|
443
|
+
expectedPublicKey,
|
|
444
|
+
exec,
|
|
445
|
+
deps.writeIdentity ?? writeTaskIdentity,
|
|
446
|
+
deps.environment?.() ?? undefined,
|
|
447
|
+
);
|
|
448
|
+
const entry = { fingerprint: expectedFingerprint, promise: pending };
|
|
449
|
+
sshEnsureInFlight.set(taskId, entry);
|
|
450
|
+
try {
|
|
451
|
+
const result = await pending;
|
|
452
|
+
return (
|
|
453
|
+
result &&
|
|
454
|
+
sshPublicKeyFingerprint(publicKeyForUser(ownerUserId) ?? "") ===
|
|
455
|
+
sshPublicKeyFingerprint(expectedPublicKey)
|
|
456
|
+
);
|
|
457
|
+
} finally {
|
|
458
|
+
if (sshEnsureInFlight.get(taskId) === entry) {
|
|
459
|
+
sshEnsureInFlight.delete(taskId);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function ensureTaskSshIdentityOnce(
|
|
465
|
+
taskId: string,
|
|
466
|
+
ownerUserId: string,
|
|
467
|
+
expectedPublicKey: string,
|
|
468
|
+
exec: DockerExec,
|
|
469
|
+
writeIdentity: typeof writeTaskIdentity,
|
|
470
|
+
environment?: EnvironmentExec,
|
|
471
|
+
): Promise<boolean> {
|
|
472
|
+
// Fast path: the DB-authoritative key is already good → re-assert only the
|
|
473
|
+
// cheap signing config and skip docker cp churn.
|
|
474
|
+
if (await sshKeyHealthy(taskId, expectedPublicKey, exec, environment)) {
|
|
475
|
+
// ADR-154: after the probe too, nothing is written once the runner has
|
|
476
|
+
// begun leaving its own credentials.
|
|
477
|
+
if (!onOwnCredentials()) return false;
|
|
478
|
+
const config: [string, ...string[]] = ["/bin/sh", "-c", SSH_SIGNING_CONFIG];
|
|
479
|
+
const configured = environment
|
|
480
|
+
? await environmentStep(environment, config)
|
|
481
|
+
: await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), config));
|
|
482
|
+
return configured.status === 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ADR-154: the last hop, fenced: no key is materialized or copied once the
|
|
486
|
+
// runner has begun leaving its own credentials.
|
|
487
|
+
if (!onOwnCredentials()) return false;
|
|
488
|
+
const dir = writeIdentity(taskId, ownerUserId);
|
|
489
|
+
if (!dir) {
|
|
490
|
+
console.log(`[ssh] task ${taskId}: no SSH signing identity for its owner`);
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
const keyPath = resolve(dir, "id_ed25519");
|
|
494
|
+
try {
|
|
495
|
+
return await injectSshIdentity(taskId, keyPath, exec, environment);
|
|
496
|
+
} finally {
|
|
497
|
+
// Host hygiene (same as task-up): the materialized private key never
|
|
498
|
+
// outlives the injection.
|
|
499
|
+
removeTaskIdentity(taskId);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const REMOVE_MANAGED_SSH_IDENTITY = [
|
|
504
|
+
"set -eu",
|
|
505
|
+
"/usr/bin/rm -f -- /home/node/.ssh/id_ed25519 /home/node/.ssh/id_ed25519.pub /home/node/.ssh/allowed_signers",
|
|
506
|
+
"/usr/bin/git config --global --unset-all gpg.ssh.allowedSignersFile >/dev/null 2>&1 || true",
|
|
507
|
+
"/usr/bin/git config --global --unset-all user.signingkey >/dev/null 2>&1 || true",
|
|
508
|
+
"/usr/bin/git config --global --unset-all commit.gpgsign >/dev/null 2>&1 || true",
|
|
509
|
+
"/usr/bin/git config --global --unset-all tag.gpgsign >/dev/null 2>&1 || true",
|
|
510
|
+
"/usr/bin/git config --global --unset-all gpg.format >/dev/null 2>&1 || true",
|
|
511
|
+
"/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true",
|
|
512
|
+
"/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true",
|
|
513
|
+
"/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'",
|
|
514
|
+
"/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
|
|
515
|
+
"test ! -e /home/node/.ssh/id_ed25519",
|
|
516
|
+
"test ! -e /home/node/.ssh/id_ed25519.pub",
|
|
517
|
+
"test -z \"$(/usr/bin/git config --global --get-all user.signingkey 2>/dev/null || true)\"",
|
|
518
|
+
].join("; ");
|
|
519
|
+
|
|
520
|
+
const SANITIZED_SSH_EXEC_ENV = [
|
|
521
|
+
"-e",
|
|
522
|
+
"HOME=/home/node",
|
|
523
|
+
"-e",
|
|
524
|
+
"PATH=/usr/bin:/bin",
|
|
525
|
+
"-e",
|
|
526
|
+
"XDG_CONFIG_HOME=",
|
|
527
|
+
"-e",
|
|
528
|
+
"LD_PRELOAD=",
|
|
529
|
+
"-e",
|
|
530
|
+
"LD_LIBRARY_PATH=",
|
|
531
|
+
"-e",
|
|
532
|
+
"GIT_CONFIG=",
|
|
533
|
+
"-e",
|
|
534
|
+
"GIT_CONFIG_GLOBAL=",
|
|
535
|
+
"-e",
|
|
536
|
+
"GIT_CONFIG_SYSTEM=",
|
|
537
|
+
"-e",
|
|
538
|
+
"GIT_CONFIG_NOSYSTEM=",
|
|
539
|
+
"-e",
|
|
540
|
+
"GIT_CONFIG_COUNT=0",
|
|
541
|
+
"-e",
|
|
542
|
+
"GIT_EXEC_PATH=",
|
|
543
|
+
"-e",
|
|
544
|
+
"GIT_SSH=",
|
|
545
|
+
"-e",
|
|
546
|
+
"GIT_SSH_COMMAND=",
|
|
547
|
+
] as const;
|
|
548
|
+
|
|
549
|
+
async function checkedSshDocker(
|
|
550
|
+
exec: DockerExec,
|
|
551
|
+
args: string[],
|
|
552
|
+
label: string,
|
|
553
|
+
): Promise<{ status: number | null; stdout?: string; stderr: string }> {
|
|
554
|
+
const result = await exec(args);
|
|
555
|
+
if (result.status !== 0) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
`${label} failed: ${result.stderr.trim() || `exit ${result.status}`}`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Remove a copied SSH key and every config entry that depends on it. A
|
|
564
|
+
* non-running container is disposable and is removed without executing its
|
|
565
|
+
* credential-bearing filesystem; Compose recreates it on Resume. */
|
|
566
|
+
export async function removeTaskSshIdentityFromContainer(
|
|
567
|
+
taskId: string,
|
|
568
|
+
exec: DockerExec = defaultExec,
|
|
569
|
+
): Promise<void> {
|
|
570
|
+
const container = taskAppContainerName(taskId);
|
|
571
|
+
if (exec === defaultExec) {
|
|
572
|
+
// Backend-aware state/removal: the Apple CLI has no `ps`/`rm` verbs
|
|
573
|
+
// (review 2026-08-18 round 3, verified live: "Plugin 'container-ps' not
|
|
574
|
+
// found"), so the shared helpers own the per-backend translation and
|
|
575
|
+
// the whole-line absence proofs. The injected-exec seam below keeps
|
|
576
|
+
// scripting docker-shaped answers for tests.
|
|
577
|
+
const state = await taskAppContainerState(container);
|
|
578
|
+
if (state.kind === "absent") return;
|
|
579
|
+
if (state.kind === "unknown") {
|
|
580
|
+
throw new Error(`inspect ${container} failed: ${state.detail}`);
|
|
581
|
+
}
|
|
582
|
+
if (state.kind === "stopped") {
|
|
583
|
+
// Non-running credential-bearing containers are removed without
|
|
584
|
+
// executing their filesystem; removal is absence-verified inside.
|
|
585
|
+
await removeTaskAppContainer(container);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
const inspectArgs = [
|
|
590
|
+
"ps",
|
|
591
|
+
"--all",
|
|
592
|
+
"--filter",
|
|
593
|
+
`name=^/${container}$`,
|
|
594
|
+
"--format",
|
|
595
|
+
"{{.State}}",
|
|
596
|
+
];
|
|
597
|
+
const listed = await checkedSshDocker(
|
|
598
|
+
exec,
|
|
599
|
+
inspectArgs,
|
|
600
|
+
`inspect ${container}`,
|
|
601
|
+
);
|
|
602
|
+
const states = (listed.stdout ?? "")
|
|
603
|
+
.split(/\r?\n/)
|
|
604
|
+
.map((state) => state.trim())
|
|
605
|
+
.filter(Boolean);
|
|
606
|
+
if (states.length === 0) return;
|
|
607
|
+
if (states.length !== 1) {
|
|
608
|
+
throw new Error(
|
|
609
|
+
`inspect ${container} returned ${states.length} containers`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (states[0] !== "running") {
|
|
613
|
+
await checkedSshDocker(
|
|
614
|
+
exec,
|
|
615
|
+
["rm", "--force", container],
|
|
616
|
+
`remove SSH credential-bearing ${container}`,
|
|
617
|
+
);
|
|
618
|
+
const remaining = await checkedSshDocker(
|
|
619
|
+
exec,
|
|
620
|
+
inspectArgs,
|
|
621
|
+
`verify removal of ${container}`,
|
|
622
|
+
);
|
|
623
|
+
if ((remaining.stdout ?? "").trim()) {
|
|
624
|
+
throw new Error(`remove ${container} was not verified`);
|
|
625
|
+
}
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
await checkedSshDocker(
|
|
631
|
+
exec,
|
|
632
|
+
[
|
|
633
|
+
"exec",
|
|
634
|
+
"-u",
|
|
635
|
+
"node",
|
|
636
|
+
...SANITIZED_SSH_EXEC_ENV,
|
|
637
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
638
|
+
container,
|
|
639
|
+
"/usr/bin/env",
|
|
640
|
+
"-i",
|
|
641
|
+
"HOME=/home/node",
|
|
642
|
+
"PATH=/usr/bin:/bin",
|
|
643
|
+
"/bin/sh",
|
|
644
|
+
"-c",
|
|
645
|
+
REMOVE_MANAGED_SSH_IDENTITY,
|
|
646
|
+
],
|
|
647
|
+
`remove managed SSH identity from ${container}`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function sshCredentialTaskIdsForUser(userId: string): string[] {
|
|
652
|
+
return getDb()
|
|
653
|
+
.select({
|
|
654
|
+
taskId: schema.hostTasks.taskId,
|
|
655
|
+
endedAt: schema.hostTasks.endedAt,
|
|
656
|
+
composeProject: schema.hostTasks.composeProject,
|
|
657
|
+
statusMirror: schema.hostTasks.statusMirror,
|
|
658
|
+
})
|
|
659
|
+
.from(schema.hostTasks)
|
|
660
|
+
.where(eq(schema.hostTasks.ownerUserId, userId))
|
|
661
|
+
.all()
|
|
662
|
+
.filter(
|
|
663
|
+
(task) =>
|
|
664
|
+
task.endedAt == null ||
|
|
665
|
+
task.composeProject != null ||
|
|
666
|
+
task.statusMirror === "error",
|
|
667
|
+
)
|
|
668
|
+
.map((task) => task.taskId);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export interface DeleteUserSshIdentityDeps {
|
|
672
|
+
/** Synchronous fail-closed check that must pass before authority changes. */
|
|
673
|
+
preflight?: () => void;
|
|
674
|
+
deleteStored?: (userId: string) => void;
|
|
675
|
+
taskIds?: (userId: string) => string[];
|
|
676
|
+
removeFromContainer?: (taskId: string) => Promise<void>;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Serialize deletion against new ensures, drain every older ensure, scrub all
|
|
680
|
+
* copied material, and only then delete authority. A failed scrub leaves the
|
|
681
|
+
* key authoritative and retryable instead of creating an unrecoverable split
|
|
682
|
+
* where host authority is gone but a valid private key remains in a container. */
|
|
683
|
+
export async function deleteUserSshIdentity(
|
|
684
|
+
userId: string,
|
|
685
|
+
deps: DeleteUserSshIdentityDeps = {},
|
|
686
|
+
): Promise<void> {
|
|
687
|
+
const existing = sshDeleteInFlight.get(userId);
|
|
688
|
+
if (existing) return existing;
|
|
689
|
+
const run = async (): Promise<void> => {
|
|
690
|
+
deps.preflight?.();
|
|
691
|
+
const taskIds = (deps.taskIds ?? sshCredentialTaskIdsForUser)(userId);
|
|
692
|
+
const removeFromContainer =
|
|
693
|
+
deps.removeFromContainer ?? removeTaskSshIdentityFromContainer;
|
|
694
|
+
const settled = await Promise.allSettled(
|
|
695
|
+
taskIds.map(async (taskId) => {
|
|
696
|
+
await sshEnsureInFlight.get(taskId)?.promise.catch(() => {});
|
|
697
|
+
removeTaskIdentity(taskId);
|
|
698
|
+
await removeFromContainer(taskId);
|
|
699
|
+
}),
|
|
700
|
+
);
|
|
701
|
+
const failed = settled.find(
|
|
702
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
703
|
+
);
|
|
704
|
+
if (failed) throw failed.reason;
|
|
705
|
+
(deps.deleteStored ?? deleteKey)(userId);
|
|
706
|
+
};
|
|
707
|
+
let tracked!: Promise<void>;
|
|
708
|
+
tracked = run().finally(() => {
|
|
709
|
+
if (sshDeleteInFlight.get(userId) === tracked) {
|
|
710
|
+
sshDeleteInFlight.delete(userId);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
sshDeleteInFlight.set(userId, tracked);
|
|
714
|
+
return tracked;
|
|
715
|
+
}
|