Skip to content
Merged
15 changes: 11 additions & 4 deletions scripts/agent-operation-worker.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function sessionCookie(user, jwtSecret) {
}

async function fetchJson(url, init) {
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(15 * 60 * 1000) });
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(30 * 60 * 1000) });
const text = await response.text();
let body = {};
try { body = text ? JSON.parse(text) : {}; } catch { body = { error: text.slice(0, 1000) }; }
Expand Down Expand Up @@ -84,10 +84,12 @@ async function executeRedeploy({ prisma, operation, baseUrl, jwtSecret }) {
if (!parseResources(current.grant.resources).includes(current.deploymentId)) {
return markFailed(prisma, current, "The grant no longer authorizes this deployment");
}
if (current.type !== "deployment.redeploy") {
if (!["deployment.redeploy", "deployment.source.deploy"].includes(current.type)) {
return markFailed(prisma, current, `Unsupported operation type: ${current.type}`);
}

const input = parseJson(current.inputJson) || {};
const sourceDeploy = current.type === "deployment.source.deploy";
const projectSlug = current.deployment.legacyProject?.slug || current.deployment.slug;
const latestRelease = current.deployment.legacyProjectId
? await prisma.deployment.findFirst({
Expand All @@ -111,7 +113,8 @@ async function executeRedeploy({ prisma, operation, baseUrl, jwtSecret }) {
projectPath: current.deployment.sourcePath || undefined,
composePath: current.deployment.composePath || undefined,
publicUrl,
action: "redeploy",
action: sourceDeploy ? "source-deploy" : "redeploy",
branch: sourceDeploy && typeof input.branch === "string" ? input.branch : undefined,
}),
});

Expand All @@ -125,7 +128,11 @@ async function executeRedeploy({ prisma, operation, baseUrl, jwtSecret }) {
data: {
status: "verifying",
resultJson: JSON.stringify(body).slice(0, 20000),
evidenceJson: JSON.stringify({ phase: "redeploy_started", projectSlug }).slice(0, 20000),
evidenceJson: JSON.stringify({
phase: sourceDeploy ? "source_deploy_started" : "redeploy_started",
projectSlug,
branch: sourceDeploy && typeof input.branch === "string" ? input.branch : undefined,
}).slice(0, 20000),
leaseUntil: new Date(Date.now() + LEASE_MS),
},
});
Expand Down
164 changes: 128 additions & 36 deletions src/app/api/projects/compose/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { resolveTemplateDeploymentTarget } from "@/lib/template-deployment-state
import { requireAuth } from "@/lib/auth";
import { handleApiError, HttpError } from "@/lib/errors";
import { validateSafePath } from "@/lib/host-safety";
import { syncGithubDeploymentSource } from "@/lib/github-source-deploy";

function normalizePath(value: unknown): string {
return String(value || "").trim().replace(/\/+$/, "");
Expand Down Expand Up @@ -284,6 +285,7 @@ export async function POST(req: NextRequest) {
verificationChecks: requestedVerificationChecks,
services,
action,
branch,
} = await req.json();
if (!isValidProjectSlug(projectSlug)) {
return NextResponse.json({ error: "A valid projectSlug is required" }, { status: 400 });
Expand All @@ -302,7 +304,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: requestedComposePathError }, { status: 400 });
}
const vps = await getActiveVps();
if (action === "redeploy") {
const isRedeploy = action === "redeploy" || action === "source-deploy";
const isSourceDeploy = action === "source-deploy";
if (isRedeploy) {
redeployLogFile = `/tmp/gc-redeploy-${projectSlug}.log`;
redeployVps = vps;
await recordRedeployEvidence(
Expand Down Expand Up @@ -337,7 +341,7 @@ export async function POST(req: NextRequest) {
404
);
}
if (action === "redeploy") {
if (isRedeploy) {
await recordRedeployEvidence(
redeployLogFile!,
`[target] Using ${target.projectPath} (${target.source})`,
Expand Down Expand Up @@ -370,7 +374,7 @@ export async function POST(req: NextRequest) {
].map(normalizePublicEndpointUrl).find((url): url is string => Boolean(url)) || null;
const verificationChecks = normalizeDeploymentVerificationChecks(publicUrl, requestedVerificationChecks);

if (action !== "redeploy") {
if (!isRedeploy) {
if (["start", "recreate", "restart"].includes(action || "start") && project) {
await applyEnvToDeployment(
{ ...project, path: target.projectPath },
Expand Down Expand Up @@ -411,6 +415,58 @@ export async function POST(req: NextRequest) {
}

const startedAt = Date.now();

let sourceSync: Awaited<ReturnType<typeof syncGithubDeploymentSource>> | null = null;
if (isSourceDeploy) {
if (!project) {
throw new HttpError("Source deployment requires a managed GroundControl project.", 409);
}
const enrolled = await prisma.enrolledDeployment.findFirst({
where: {
OR: [
{ legacyProjectId: project.id },
{ sourcePath: target.projectPath },
{ slug: projectSlug },
],
},
select: { id: true, managementMode: true },
});
if (!enrolled) {
throw new HttpError("Source deployment is not enrolled in GroundControl.", 409);
}
if (enrolled.managementMode !== "managed") {
throw new HttpError("Source deployment requires managementMode=managed.", 409);
}

redeployPhase = "source";
await recordRedeployEvidence(
redeployLogFile!,
"[source] Syncing linked GitHub repository on the deployment host",
vps
);
sourceSync = await syncGithubDeploymentSource({
deploymentId: enrolled.id,
projectPath: target.projectPath,
branch,
vps,
});
await recordRedeployEvidence(
redeployLogFile!,
`[source] Synced ${sourceSync.repository}@${sourceSync.commitSha.slice(0, 12)} branch=${sourceSync.branch}`,
vps
);

const refreshedCompose = await resolveComposeFile(
target.projectPath,
vps,
requestedComposePathForCandidate(requestedComposePath, target.projectPath)
);
if (!refreshedCompose) {
throw new HttpError("The synced source does not contain the expected Compose file.", 409);
}
composeFile = refreshedCompose;
}

redeployPhase = "configuration";
let environmentHash: string | undefined;
if (project) {
Expand Down Expand Up @@ -488,41 +544,66 @@ export async function POST(req: NextRequest) {
const selectedServices = Array.isArray(services) ? services.map((service) => String(service)) : undefined;
const expectedImages = expectedComposeImages(configCheck.stdout, selectedServices);

redeployPhase = "registry";
await recordRedeployEvidence(
redeployLogFile!,
"[registry] Authenticating configured container registry",
vps
);
await ensureGithubRegistryLogin(vps);
await recordRedeployEvidence(
redeployLogFile!,
"[registry] Registry authentication ready",
vps
);
redeployPhase = "pull";
await recordRedeployEvidence(
redeployLogFile!,
"[pull] Pulling images from the effective Compose configuration",
vps
);
const pullResult = await execOnTargetStrict(
`cd ${shQuote(target.projectPath)} && ${buildManagedComposeInvocation(composeCmd, `pull${serviceArgs ? ` ${serviceArgs}` : ""}`, composeFile)}`,
vps
);
if (serviceArgs && pullResult.code !== 0) {
throw new HttpError(
`Image pull failed: ${(pullResult.stderr || pullResult.stdout || "registry rejected the image").trim().slice(0, 500)}`,
400
let pullResult: { stdout: string; stderr: string; code: number } = { stdout: "", stderr: "", code: 0 };
if (isSourceDeploy) {
redeployPhase = "build";
await recordRedeployEvidence(
redeployLogFile!,
"[build] Building Compose services on the deployment host",
vps
);
const buildResult = await execOnTargetStrict(
`cd ${shQuote(target.projectPath)} && ${buildManagedComposeInvocation(composeCmd, `build${serviceArgs ? ` ${serviceArgs}` : ""}`, composeFile)}`,
vps
);
if (buildResult.code !== 0) {
throw new HttpError(
`Source build failed: ${(buildResult.stderr || buildResult.stdout || "docker compose build failed").trim().slice(-1500)}`,
400
);
}
await recordRedeployEvidence(
redeployLogFile!,
"[build] Host build completed",
vps
);
} else {
redeployPhase = "registry";
await recordRedeployEvidence(
redeployLogFile!,
"[registry] Authenticating configured container registry",
vps
);
await ensureGithubRegistryLogin(vps);
await recordRedeployEvidence(
redeployLogFile!,
"[registry] Registry authentication ready",
vps
);
redeployPhase = "pull";
await recordRedeployEvidence(
redeployLogFile!,
"[pull] Pulling images from the effective Compose configuration",
vps
);
pullResult = await execOnTargetStrict(
`cd ${shQuote(target.projectPath)} && ${buildManagedComposeInvocation(composeCmd, `pull${serviceArgs ? ` ${serviceArgs}` : ""}`, composeFile)}`,
vps
);
if (serviceArgs && pullResult.code !== 0) {
throw new HttpError(
`Image pull failed: ${(pullResult.stderr || pullResult.stdout || "registry rejected the image").trim().slice(0, 500)}`,
400
);
}
await recordRedeployEvidence(
redeployLogFile!,
pullResult.code === 0
? "[pull] Images resolved"
: "[pull] Image pull completed with build-only services skipped",
vps
);
}
await recordRedeployEvidence(
redeployLogFile!,
pullResult.code === 0
? "[pull] Images resolved"
: "[pull] Image pull completed with build-only services skipped",
vps
);

redeployPhase = "recreate";
const deployArgs = `up -d --remove-orphans --force-recreate${serviceArgs ? ` ${serviceArgs}` : ""}`;
Expand Down Expand Up @@ -624,6 +705,11 @@ export async function POST(req: NextRequest) {
imageDigest,
vps,
});
if (sourceSync) {
sourceFingerprint.repoUrl = `https://github.com/${sourceSync.repository}`;
sourceFingerprint.commitSha = sourceSync.commitSha;
sourceFingerprint.branch = sourceSync.branch;
}
if (project) {
if (sourceFingerprint.repoUrl && !project.repoUrl) {
await prisma.project.update({
Expand All @@ -648,6 +734,7 @@ export async function POST(req: NextRequest) {
`[validate] Effective Compose configuration OK (${composeFile})`,
environmentHash ? `[configuration] revision=${environmentHash}` : "",
sourceFingerprint.commitSha ? `[source] revision=${sourceFingerprint.commitSha.slice(0, 12)} repo=${sourceFingerprint.repoUrl || "unresolved"}` : "[source] revision unresolved",
sourceSync ? `[source] ${sourceSync.repository}@${sourceSync.commitSha} branch=${sourceSync.branch}` : "",
pullResult.stdout || pullResult.stderr ? `[pull]\n${pullResult.stdout || pullResult.stderr}` : "",
result.stdout,
].filter(Boolean).join("\n") || null,
Expand Down Expand Up @@ -691,6 +778,11 @@ export async function POST(req: NextRequest) {
detached: detached || undefined,
imageDigest: imageDigest || undefined,
sourceFingerprint,
sourceSync: sourceSync ? {
repository: sourceSync.repository,
branch: sourceSync.branch,
commitSha: sourceSync.commitSha,
} : undefined,
environmentHash,
changedFields: changedFields.length > 0 ? changedFields : undefined,
});
Expand Down
2 changes: 2 additions & 0 deletions src/app/mcp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ describe("GroundControl MCP transport", () => {
expect(data.result.capabilities.tools).toEqual({ listChanged: false });
expect(data.result._meta["io.modelcontextprotocol/serverInfo"]).toMatchObject({
name: "GroundControl",
version: "0.1.0",
});
expect(data.result._meta["io.groundcontrol/toolsetRevision"]).toBe("2026-09-20.1");
expect(response.headers.get("MCP-Protocol-Version")).toBe("2026-07-28");
expect(mock.authenticateAccessToken).not.toHaveBeenCalled();
});
Expand Down
4 changes: 3 additions & 1 deletion src/app/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { executeAgentTool, toolDefinitionsForScopes } from "@/lib/mcp-agent";

const LEGACY_PROTOCOL_VERSION = "2025-11-25";
const LEGACY_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"];
const TOOLSET_REVISION = "2026-09-20.1";
const SERVER_INFO = {
name: "GroundControl",
title: "GroundControl",
Expand Down Expand Up @@ -58,6 +59,7 @@ function addServerMeta(result: unknown, modern: boolean): unknown {
_meta: {
...existingMeta,
"io.modelcontextprotocol/serverInfo": SERVER_INFO,
"io.groundcontrol/toolsetRevision": TOOLSET_REVISION,
},
};
}
Expand Down Expand Up @@ -193,7 +195,7 @@ export async function POST(req: NextRequest) {
if (body.method === "tools/list") {
return rpc(body.id, {
tools: toolDefinitionsForScopes(),
...(modern ? { ttlMs: 30_000, cacheScope: "private" } : {}),
...(modern ? { ttlMs: 5_000, cacheScope: "private" } : {}),
}, modern);
}

Expand Down
26 changes: 26 additions & 0 deletions src/lib/github-source-deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildGithubSourceSyncCommand, normalizeSourceBranch } from "./github-source-deploy";

describe("GitHub source deploy", () => {
it("accepts normal branch names and rejects unsafe refs", () => {
expect(normalizeSourceBranch("feature/direct-deploy")).toBe("feature/direct-deploy");
expect(normalizeSourceBranch("", "main")).toBe("main");
expect(() => normalizeSourceBranch("../main")).toThrow();
expect(() => normalizeSourceBranch("feature@{1}")).toThrow();
expect(() => normalizeSourceBranch("-main")).toThrow();
});

it("uses a temporary askpass helper without embedding credentials in the command", () => {
const command = buildGithubSourceSyncCommand({
projectPath: "/srv/groundcontrol/deployments/rentaweekend",
repository: "teckedd-code2save/RentAWeekend",
branch: "main",
});
expect(command).toContain("GIT_ASKPASS");
expect(command).toContain("IFS= read -r GC_GITHUB_TOKEN");
expect(command).toContain("https://github.com/teckedd-code2save/RentAWeekend.git");
expect(command).not.toContain("github_pat_");
expect(command).not.toContain("git clean");
});
});
Loading
Loading