From 538b99c054d188b47d6d00d7cb485b055c7569b9 Mon Sep 17 00:00:00 2001 From: chbndrhnns <7534547+chbndrhnns@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:51:49 +0000 Subject: [PATCH] fix(deployments): resolve local clone credentials for Docker builds on local host --- apps/api/src/lib/ssh-manager.ts | 8 +++--- .../src/modules/deployments/build-pipeline.ts | 1 + .../modules/deployments/clone-plan.test.ts | 9 +++++++ .../api/src/modules/deployments/clone-plan.ts | 21 +++++++++++---- apps/api/src/modules/deployments/preflight.ts | 9 ++++++- .../modules/deployments/clone-plan.test.ts | 27 +++++++++++++++++++ 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/api/src/lib/ssh-manager.ts b/apps/api/src/lib/ssh-manager.ts index fef6d0563..34033a7da 100644 --- a/apps/api/src/lib/ssh-manager.ts +++ b/apps/api/src/lib/ssh-manager.ts @@ -43,10 +43,10 @@ import { type HostChannelHealth, type SshConfig, } from "@repo/adapters"; -import { formatDuration, systemDebug } from "@/lib/system-debug"; -import { decryptSecretField } from "@/lib/credential-encryption"; -import { operatorSshKeyRoots, resolveSafeSshKeyPath } from "@/lib/ssh-key-path"; -import { isLocalHostRow } from "@/lib/box-org"; +import { formatDuration, systemDebug } from "./system-debug"; +import { decryptSecretField } from "./credential-encryption"; +import { operatorSshKeyRoots, resolveSafeSshKeyPath } from "./ssh-key-path"; +import { isLocalHostRow } from "./box-org"; import { safeErrorMessage } from "@repo/core"; const execFileAsync = promisify(execFile); diff --git a/apps/api/src/modules/deployments/build-pipeline.ts b/apps/api/src/modules/deployments/build-pipeline.ts index 58bdab079..c657afe09 100644 --- a/apps/api/src/modules/deployments/build-pipeline.ts +++ b/apps/api/src/modules/deployments/build-pipeline.ts @@ -718,6 +718,7 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes isDesktop: plat.target === "desktop", forwardGitCredentials: snapshot.forwardGitCredentials, repoIsGithub: !!project.gitOwner, + isLocalHost: resolved.platform.localHost, }); const cloneOnServer = clonePlan.runsOnServer; // The relay needs a real SSH reverse tunnel — `reverseForward` exists on every diff --git a/apps/api/src/modules/deployments/clone-plan.test.ts b/apps/api/src/modules/deployments/clone-plan.test.ts index 9cc6c2f29..6741a54e6 100644 --- a/apps/api/src/modules/deployments/clone-plan.test.ts +++ b/apps/api/src/modules/deployments/clone-plan.test.ts @@ -38,4 +38,13 @@ describe("resolveClonePlan — relayEligible (forward is the default on desktop) }).relayEligible, ).toBe(false); }); + it("is NOT eligible on a local host server (no remote host)", () => { + expect( + resolveClonePlan({ + ...base, + isLocalHost: true, + forwardGitCredentials: true, + }).relayEligible, + ).toBe(false); + }); }); diff --git a/apps/api/src/modules/deployments/clone-plan.ts b/apps/api/src/modules/deployments/clone-plan.ts index 637101b88..2ba01c7ce 100644 --- a/apps/api/src/modules/deployments/clone-plan.ts +++ b/apps/api/src/modules/deployments/clone-plan.ts @@ -49,6 +49,10 @@ export interface ClonePlanInput { * adapter re-validates the URL (github + https) before downloading and falls * back to clone. Local/imported projects → false → unchanged. */ repoIsGithub?: boolean; + /** Target server is the local host ("This Server" row, socket DooD). + * Docker builds on the local host build over the local Docker socket and + * prepare context on the API host — they never clone on a remote server. */ + isLocalHost?: boolean; } export interface ClonePlan { @@ -89,10 +93,18 @@ export function relayConfigEligible(input: { export function resolveClonePlan(input: ClonePlanInput): ClonePlan { const onServer = input.effectiveTarget === "server"; + const isLocalHost = input.isLocalHost === true || input.effectiveTarget === "local"; + + // Docker on the local host (socket DooD) or a local build (buildStrategy === "local" + // without cloneStrategy === "server") builds via createDockerBuildContext on the API host + // and NEVER clones on a remote host via SSH. + const isLocalDockerBuild = + !input.runtimeIsBare && + (isLocalHost || (input.buildStrategy === "local" && input.cloneStrategy !== "server")); // Docker acquires source ON THE SERVER when the deploy opted in - // (cloneStrategy="server") OR the repo is a GitHub HTTPS remote — the server - // downloads the tarball directly, skipping the orchestrator clone + context + // (cloneStrategy="server") OR the repo is a GitHub HTTPS remote on a remote server build — + // the server downloads the tarball directly, skipping the orchestrator clone + context // transfer. Bare has its own always-on-server path (below), so it's excluded // here. Whether it truly runs on the server still hinges on a shippable // credential; effectiveCloneOnServer degrades to an api-host clone otherwise @@ -100,17 +112,16 @@ export function resolveClonePlan(input: ClonePlanInput): ClonePlan { const dockerServerSide = onServer && !input.runtimeIsBare && + !isLocalDockerBuild && (input.cloneStrategy === "server" || input.repoIsGithub === true); // Pipeline: the clone runs on the server (bare always; docker per above). const runsOnServer = - onServer && !!input.serverId && (input.runtimeIsBare || dockerServerSide); + onServer && !isLocalDockerBuild && !!input.serverId && (input.runtimeIsBare || dockerServerSide); // Preflight warn-case + api-host-fallback gate: DOCKER (non-bare) acquiring on // the server. Bare is handled by the separate hard-fail remote-build checks. const dockerClonesOnServer = dockerServerSide; - - // The clone's credential purpose follows WHERE THE CLONE RUNS, not where the // build runs: a local build clones on this machine, and a server deploy that // isn't cloning on the server clones on the api-host (both local → gh OK). // Everything else (on-server clone, cloud workspace clone) is off-host → remote. diff --git a/apps/api/src/modules/deployments/preflight.ts b/apps/api/src/modules/deployments/preflight.ts index 7b5a62054..fcf8e5c1f 100644 --- a/apps/api/src/modules/deployments/preflight.ts +++ b/apps/api/src/modules/deployments/preflight.ts @@ -33,6 +33,7 @@ import { isStaticService, type DeployableService } from "../../lib/deployable-se import { isFullyPinned, snapshotNeedsGitSource } from "./pinned-artifacts"; import { snapshotToClass } from "./deployment-class"; import { relayConfigEligible, resolveClonePlan } from "./clone-plan"; +import { isLocalHostRow } from "../../lib/box-org"; import { hasLocalGitIdentity } from "../github/github.local-auth"; import { isPublicRepo } from "../github/github.http"; import { getRoutingBaseDomain } from "../../lib/routing-domains"; @@ -1438,12 +1439,17 @@ export async function runPreflightChecks( // Does this machine meet what the app says it needs? Cloud is sized from the // tier table, not from host hardware, so there is nothing to match there (and // nothing to probe — a multi-tenant control plane must not dial a tenant's box). + let isLocalTarget = effectiveTarget === "local"; + if (snapshot.serverId) { + const server = await repos.server?.get?.(snapshot.serverId).catch(() => null); + if (server) isLocalTarget = await isLocalHostRow(server); + } if (opts?.appTemplateId && snapshot.organizationId && effectiveTarget !== "cloud") { const hostCapacity = await checkHostCapacity( snapshot.organizationId, opts.appTemplateId, snapshot.serverId, - effectiveTarget === "local", + isLocalTarget, opts.firstDeploy ?? false, ); if (hostCapacity) checks.push(hostCapacity); @@ -1564,6 +1570,7 @@ export async function runPreflightChecks( // tarball on the server for them. Same structured signal the pipeline uses // (`!!project.gitOwner`) so the two decisions can't drift. repoIsGithub: !!opts?.gitOwner, + isLocalHost: isLocalTarget, }).dockerClonesOnServer; if (dockerClonesOnServer) { checks.push( diff --git a/apps/api/test/modules/deployments/clone-plan.test.ts b/apps/api/test/modules/deployments/clone-plan.test.ts index 53930962f..82eac5e7d 100644 --- a/apps/api/test/modules/deployments/clone-plan.test.ts +++ b/apps/api/test/modules/deployments/clone-plan.test.ts @@ -19,6 +19,33 @@ describe("resolveClonePlan", () => { expect(plan.runsLocally).toBe(true); expect(plan.cloneBuildStrategy).toBe("local"); }); + it("docker + server + repoIsGithub + buildStrategy=local → clone runs locally with local credential", () => { + const plan = resolveClonePlan({ + ...base, + effectiveTarget: "server", + buildStrategy: "local", + repoIsGithub: true, + }); + expect(plan.runsOnServer).toBe(false); + expect(plan.dockerClonesOnServer).toBe(false); + expect(plan.runsLocally).toBe(true); + expect(plan.cloneBuildStrategy).toBe("local"); + }); + + it("docker + server (localHost / This Server) + repoIsGithub → clones locally with local credential, not on server", () => { + const plan = resolveClonePlan({ + ...base, + effectiveTarget: "server", + isLocalHost: true, + repoIsGithub: true, + cloneStrategy: "server", + }); + expect(plan.runsOnServer).toBe(false); + expect(plan.dockerClonesOnServer).toBe(false); + expect(plan.runsLocally).toBe(true); + expect(plan.cloneBuildStrategy).toBe("local"); + }); + it("docker + server + api-host clone → api-host clone (local credential), not on server", () => { const plan = resolveClonePlan({ ...base, cloneStrategy: "api-host" });