Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion .sandcastle/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { createHmac } from "node:crypto";
import * as https from "node:https";
import * as http from "node:http";
import { execFile, spawn } from "node:child_process";
import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { join, dirname } from "node:path";
Expand Down Expand Up @@ -58,6 +58,36 @@ export function parseOrchEnv(content: string): Record<string, string> {
return result;
}

/**
* Decide the DOCKER_HOST the orchestrator's docker CLI should use, to dodge the
* docker-outside-of-docker socat proxy.
*
* The DooD feature fixes socket permissions by fronting the host socket with
* `socat UNIX-LISTEN:/var/run/docker.sock ... UNIX-CONNECT:/var/run/docker-host.sock`.
* But socat tears down `docker exec`'s *hijacked* bidirectional stream after the
* first data burst — and sandcastle streams the agent's stream-json over
* `docker exec`. So the agent's init line arrives, then the rest of the turn is
* dropped: every iteration is an empty "started → stopped" turn with zero commits
* (the close handler's `code ?? 0` even masks it as a clean exit). The feature
* also exposes the *real* host socket at docker-host.sock and adds the user to the
* docker group, so pointing the CLI straight at it restores streaming.
*
* Guarded so it is a no-op outside the socat setup — returns undefined (leave
* DOCKER_HOST as-is) when the caller already set one, or when the direct socket is
* absent. A bare `docker compose up` (no DooD feature, no socat) has no
* docker-host.sock and its raw /var/run/docker.sock works natively, so it must be
* left untouched. Pure: the decision is returned, not applied, so it's testable
* without env/fs side effects.
*/
export function resolveDockerHost(
currentDockerHost: string | undefined,
directSocketExists: boolean,
): string | undefined {
if (currentDockerHost) return undefined; // an explicit choice always wins
if (!directSocketExists) return undefined; // no socat proxy in the way
return "unix:///var/run/docker-host.sock";
}

export interface ResolvedCredentials {
readonly GH_TOKEN: string | undefined;
readonly GITHUB_TOKEN: string | undefined;
Expand Down Expand Up @@ -543,6 +573,17 @@ async function main(): Promise<void> {
return;
}

// Dodge the docker-outside-of-docker socat proxy (see resolveDockerHost): socat
// breaks docker exec's streamed output, which sandcastle relies on for the agent
// turn — without this every iteration is an empty turn. No-op when socat isn't in
// play (bare `docker compose up`, or an explicit DOCKER_HOST). Must run before any
// docker call below.
const dockerHost = resolveDockerHost(
process.env.DOCKER_HOST,
existsSync("/var/run/docker-host.sock"),
);
if (dockerHost) process.env.DOCKER_HOST = dockerHost;

const repo = process.env.AGENTIC_REPO;
const base = process.env.AGENTIC_BASE_BRANCH ?? "main";
const repoRoot = process.cwd();
Expand Down
20 changes: 19 additions & 1 deletion .sandcastle/reduce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { reduce, READY_LABEL, type State, type CiStatus, type Pr } from "./reduce.ts";
import { parseBlockedBy } from "./issue-source.ts";
import { sweepOrphanedSandboxes, ensureSandboxNetwork, parseConcurrency, withRetry, resetAgentBranch, refreshBase, validateSignature, classifyDelivery, parseSmeeEvent, parseOrchEnv, resolveCredentials, resolveRunMode } from "./main.ts";
import { sweepOrphanedSandboxes, ensureSandboxNetwork, parseConcurrency, withRetry, resetAgentBranch, refreshBase, validateSignature, classifyDelivery, parseSmeeEvent, parseOrchEnv, resolveCredentials, resolveRunMode, resolveDockerHost } from "./main.ts";
import { createHmac } from "node:crypto";
import { SANDBOX_LABEL, PROJECT_LABEL_KEY, deriveProject } from "./sandbox-runner.ts";

Expand Down Expand Up @@ -915,6 +915,24 @@ test("resolveCredentials: resolves all four credential keys independently", () =
assert.equal(creds.CLAUDE_CODE_OAUTH_TOKEN, "cco-orch");
});

// ─── resolveDockerHost (socat-proxy bypass) ────────────────────────────────────

test("resolveDockerHost: socat present (direct socket exists) → redirect to docker-host.sock", () => {
assert.equal(resolveDockerHost(undefined, true), "unix:///var/run/docker-host.sock");
});

test("resolveDockerHost: no direct socket (bare compose, no socat) → leave DOCKER_HOST untouched", () => {
assert.equal(resolveDockerHost(undefined, false), undefined);
});

test("resolveDockerHost: an explicit DOCKER_HOST always wins, even when the direct socket exists", () => {
assert.equal(resolveDockerHost("unix:///custom.sock", true), undefined);
});

test("resolveDockerHost: explicit DOCKER_HOST with no direct socket is still left untouched", () => {
assert.equal(resolveDockerHost("tcp://1.2.3.4:2375", false), undefined);
});

// ─── resolveRunMode ───────────────────────────────────────────────────────────

test("resolveRunMode: AGENTIC_IN_CONTAINER set → detached", () => {
Expand Down
Loading