fix(stage-1d): e2e skip mapping, register scan wording

This commit is contained in:
John Smith committed 2026-10-14 17:29:40 -04:00
1 parent 90a80cb115
commit 161e0a715e
12 files changed
+360 -17

No files matched your search

+13
View File
@@ -165,6 +165,19 @@ export function dispatcherPort(): number {
return port;
}
/**
* Interface the Dispatcher service binds. Defaults to loopback; the
* fleet compose file sets `0.0.0.0` so the gateway can reach it over the
* internal `fleet` network. Binding loopback inside the container makes
* the gateway's `dispatcher:8080` route refuse connections, so an empty
* value refuses to start rather than silently binding nowhere useful.
*/
export function dispatcherHostname(): string {
const raw = optionalEnv("DISPATCHER_HOSTNAME", "127.0.0.1");
if (raw.trim() === "") throw new Error("refusing to start: DISPATCHER_HOSTNAME is empty");
return raw;
}
/** Filesystem roots the Dispatcher service reads. All have fixed defaults. */
export function dispatcherPaths(): {
stateDir: string;
+38 -8
View File
@@ -2,6 +2,7 @@ import { readdir, stat } from "node:fs/promises";
import { loadAllowlist, SessionStore } from "./auth.js";
import {
dispatcherCredentials,
dispatcherHostname,
dispatcherPaths,
dispatcherPort,
loadFleet,
@@ -127,7 +128,7 @@ export async function boot(options: BootOptions = {}): Promise<BootedService> {
notify:
wakeClient !== undefined ? directorWakeHook(engine, { director: wakeClient }) : undefined,
port: options.port ?? dispatcherPort(),
hostname: options.hostname ?? "127.0.0.1",
hostname: options.hostname ?? dispatcherHostname(),
});
const pollMs = options.pollMs ?? 5000;
@@ -156,17 +157,46 @@ export async function boot(options: BootOptions = {}): Promise<BootedService> {
};
}
/**
* Signal shutdown (Stage 1d recovery fix): the audit lock is released by
* `close`, so the process must stay alive until `close` settles. Calling
* `process.exit` first leaves a stale `audit.lock` behind, and the
* replacement container then refuses to start with `LockedError`,
* crash-looping the whole fleet. A second signal while a close is in
* flight is ignored so it cannot cut the release short. Exported for
* tests.
*/
export function shutdownService(
service: Pick<BootedService, "close"> | undefined,
exit: (code: number) => void,
): void {
if (service === undefined) {
exit(0);
return;
}
service.close().then(
() => exit(0),
() => exit(1),
);
}
async function main(): Promise<void> {
let service: BootedService | undefined;
const shutdown = (): void => {
if (service !== undefined) {
service.close().catch(() => {});
service = undefined;
let closing = false;
const exit = (code: number): void => process.exit(code);
const onSignal = (): void => {
if (closing) return;
const current = service;
service = undefined;
if (current === undefined) {
exit(0);
return;
}
process.exit(0);
closing = true;
shutdownService(current, exit);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
process.on("SIGTERM", onSignal);
process.on("SIGINT", onSignal);
try {
service = await boot();
} catch (err) {
+13
View File
@@ -8,6 +8,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import {
dispatcherHostname,
expertConnection,
fleetModel,
loadFleet,
@@ -104,4 +105,16 @@ describe("stage-1c config", () => {
expect(verifierConnection()).toEqual({ baseUrl: "http://verifier:8080", token: "tok" });
});
});
test("dispatcherHostname defaults to loopback and refuses empty", () => {
withEnv("DISPATCHER_HOSTNAME", undefined, () => {
expect(dispatcherHostname()).toBe("127.0.0.1");
});
withEnv("DISPATCHER_HOSTNAME", "0.0.0.0", () => {
expect(dispatcherHostname()).toBe("0.0.0.0");
});
withEnv("DISPATCHER_HOSTNAME", "", () => {
expect(() => dispatcherHostname()).toThrow(/DISPATCHER_HOSTNAME/);
});
});
});
+59 -1
View File
@@ -3,7 +3,7 @@ import { cpSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, test } from "bun:test";
import { boot } from "../src/main.js";
import { boot, shutdownService } from "../src/main.js";
import { enabledExperts, FleetSchema } from "../src/fleet-schema.js";
import { parse as parseYaml } from "yaml";
import { startFakeOpenCode, type FakeOpenCode } from "./support/fake-opencode.js";
@@ -133,3 +133,61 @@ describe("dispatcher startup", () => {
}
}, 120_000);
});
describe("shutdownService", () => {
const settle = (): Promise<void> => new Promise((r) => setTimeout(r, 10));
test("exits 0 at once when there is no service", () => {
const exits: number[] = [];
shutdownService(undefined, (code) => {
exits.push(code);
});
expect(exits).toEqual([0]);
});
test("waits for close to release resources before exiting", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let closed = false;
const exits: number[] = [];
shutdownService(
{
close: async () => {
await gate;
closed = true;
},
},
(code) => {
exits.push(code);
},
);
// Defect: the old handler called process.exit(0) synchronously after
// kicking off close, so the audit lock was never released. The exit
// must not happen until close settles.
expect(closed).toBe(false);
expect(exits).toEqual([]);
release();
await gate;
await settle();
expect(closed).toBe(true);
expect(exits).toEqual([0]);
});
test("exits non-zero when close fails", async () => {
const exits: number[] = [];
shutdownService(
{
close: async () => {
throw new Error("close failed");
},
},
(code) => {
exits.push(code);
},
);
await settle();
expect(exits).toEqual([1]);
});
});
+4
View File
@@ -806,6 +806,7 @@ services:
AGENCY_MODEL: ${AGENCY_MODEL}
FLEET_MCP_TOKEN: ${FLEET_MCP_TOKEN}
VERIFIER_TOKEN: ${VERIFIER_TOKEN}
DISPATCHER_HOSTNAME: 0.0.0.0
DISPATCHER_STATE_DIR: /data
DISPATCHER_REGISTRY_DIR: /data/registry
DISPATCHER_WORKSPACE: /work
@@ -834,8 +835,11 @@ services:
restart: unless-stopped
networks:
- fleet
- outside
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
pids_limit: 256
@@ -0,0 +1,80 @@
accept stage 1d (2026-10-14T21:17:07Z), timeout 900s per check
===== 1d/01-make-check (exit 0) =====
CHECK 1d/01-make-check: PASS
===== 1d/02-up-healthy (exit 1) =====
CHECK 1d/02-up-healthy: FAIL make up failed: dependency failed to start: container agency-dispatcher is unhealthy
make[1]: *** [Makefile:60: up] Error 1
make[1]: Leaving directory '/home/john/Projects/The-Agency/worktrees/wave-1d-service'
===== 1d/03-only-gateway-published (exit 0) =====
gateway alone publishes 127.0.0.1:8081
CHECK 1d/03-only-gateway-published: PASS
===== 1d/04-credentials-required (exit 0) =====
CHECK 1d/04-credentials-required: PASS
===== 1d/05-auth-and-roles (exit 1) =====
CHECK 1d/05-auth-and-roles: FAIL test dispatcher never healthy
===== 1d/06-director-scout (exit 77) =====
CHECK 1d/06-director-scout: SKIP no provider key in .env.provider
===== 1d/07-scout-completes (exit 77) =====
CHECK 1d/07-scout-completes: SKIP no provider key in .env.provider
===== 1d/08-docs-lands (exit 77) =====
CHECK 1d/08-docs-lands: SKIP no provider key in .env.provider
===== 1d/09-linter-break-fails-verify (exit 77) =====
CHECK 1d/09-linter-break-fails-verify: SKIP no provider key in .env.provider
===== 1d/10-joint-breakage-resets (exit 77) =====
CHECK 1d/10-joint-breakage-resets: SKIP no provider key in .env.provider
===== 1d/11-scope-refusal (exit 1) =====
Traceback (most recent call last):
File "<string>", line 1, in <module>
import json,sys; print(json.load(sys.stdin)["token"])
~~~~~~~~~^^^^^^^^^^^
File "/home/linuxbrew/.linuxbrew/Cellar/[email protected]/3.15.0/lib/python3.15/json/__init__.py", line 306, in load
return loads(fp.read(),
cls=cls, object_hook=object_hook,
parse_float=parse_float, parse_int=parse_int,
parse_constant=parse_constant, object_pairs_hook=object_pairs_hook,
array_hook=array_hook, **kw)
File "/home/linuxbrew/.linuxbrew/Cellar/[email protected]/3.15.0/lib/python3.15/json/__init__.py", line 371, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/home/linuxbrew/.linuxbrew/Cellar/[email protected]/3.15.0/lib/python3.15/json/decoder.py", line 358, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/home/linuxbrew/.linuxbrew/Cellar/[email protected]/3.15.0/lib/python3.15/json/decoder.py", line 376, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
CHECK 1d/11-scope-refusal: FAIL approver login failed
===== 1d/12-restart-collects (exit 77) =====
CHECK 1d/12-restart-collects: SKIP no provider key in .env.provider
===== 1d/13-e2e (exit 1) =====
make[1]: Entering directory '/home/john/Projects/The-Agency/worktrees/wave-1d-service'
bash scripts/e2e.sh
e2e: SKIP no provider key in .env.provider
make[1]: *** [Makefile:78: e2e] Error 77
make[1]: Leaving directory '/home/john/Projects/The-Agency/worktrees/wave-1d-service'
CHECK 1d/13-e2e: FAIL make e2e failed: e2e: SKIP no provider key in .env.provider make[1]: *** [Makefile:78: e2e] Error 77 make[1]: Leaving directory '/home/john/Projects/The-Agency/worktrees/wave-1d-service'
===== 1d/14-director-chat-proxy (exit 77) =====
CHECK 1d/14-director-chat-proxy: SKIP no provider key in .env.provider
===== 1d/15-audit-chain (exit 0) =====
audit: 2 entries, tip b6c85f718b807a02fce85362f3cd1c4b251fdb3939bdcf321db4944425a788f9; 0 landed merge(s) anchored across 0 branch(es)
CHECK 1d/15-audit-chain: PASS
===== 1d/16-stage-1-register (exit 1) =====
CHECK 1d/16-stage-1-register: FAIL bubblewrap referenced: scripts/accept/0/06-no-donor-code.sh
scripts/accept/1d/16-stage-1-register.sh
CHECKS for stage 1d
1d/01-make-check PASS
1d/02-up-healthy FAIL make up failed: dependency failed to start: container agency-dispatcher is unhealthy
1d/03-only-gateway-published PASS
1d/04-credentials-required PASS
1d/05-auth-and-roles FAIL test dispatcher never healthy
1d/06-director-scout SKIP no provider key in .env.provider
1d/07-scout-completes SKIP no provider key in .env.provider
1d/08-docs-lands SKIP no provider key in .env.provider
1d/09-linter-break-fails-verify SKIP no provider key in .env.provider
1d/10-joint-breakage-resets SKIP no provider key in .env.provider
1d/11-scope-refusal FAIL approver login failed
1d/12-restart-collects SKIP no provider key in .env.provider
1d/13-e2e FAIL make e2e failed: e2e: SKIP no provider key in .env.provider make[1]: *** [Makefile:78: e2e] Error 77 make[1]: Leaving directory '/home/john/Projects/The-Agency/worktrees/wave-1d-service'
1d/14-director-chat-proxy SKIP no provider key in .env.provider
1d/15-audit-chain PASS
1d/16-stage-1-register FAIL bubblewrap referenced: scripts/accept/0/06-no-donor-code.sh
stage 1d: 4 pass, 7 skip, 5 fail (16 checks)
@@ -0,0 +1,78 @@
accept stage 1d (2026-10-14T21:13:50Z), timeout 900s per check
===== 1d/01-make-check (exit 0) =====
CHECK 1d/01-make-check: PASS
===== 1d/02-up-healthy (exit 1) =====
workspace-init: demo workspace ready at workspace/demo
curl: (7) Failed to connect to 127.0.0.1:8081 after 0 ms: Could not connect to server
CHECK 1d/02-up-healthy: FAIL gateway healthz unreachable
===== 1d/03-only-gateway-published (exit 0) =====
gateway alone publishes 127.0.0.1:8081
CHECK 1d/03-only-gateway-published: PASS
===== 1d/04-credentials-required (exit 0) =====
CHECK 1d/04-credentials-required: PASS
===== 1d/05-auth-and-roles (exit 1) =====
CHECK 1d/05-auth-and-roles: FAIL test dispatcher never healthy
===== 1d/06-director-scout (exit 77) =====
CHECK 1d/06-director-scout: SKIP no provider key in .env.provider
===== 1d/07-scout-completes (exit 77) =====
CHECK 1d/07-scout-completes: SKIP no provider key in .env.provider
===== 1d/08-docs-lands (exit 77) =====
CHECK 1d/08-docs-lands: SKIP no provider key in .env.provider
===== 1d/09-linter-break-fails-verify (exit 77) =====
CHECK 1d/09-linter-break-fails-verify: SKIP no provider key in .env.provider
===== 1d/10-joint-breakage-resets (exit 77) =====
CHECK 1d/10-joint-breakage-resets: SKIP no provider key in .env.provider
===== 1d/11-scope-refusal (exit 1) =====
Traceback (most recent call last):
File "<string>", line 1, in <module>
import json,sys; print(json.load(sys.stdin)["token"])
~~~~~~~~~^^^^^^^^^^^
File "/home/linuxbrew/.linuxbrew/Cellar/[email protected]/3.15.0/lib/python3.15/json/__init__.py", line 306, in load
return loads(fp.read(),
cls=cls, object_hook=object_hook,
parse_float=parse_float, parse_int=parse_int,
parse_constant=parse_constant, object_pairs_hook=object_pairs_hook,
array_hook=array_hook, **kw)
File "/home/linuxbrew/.linuxbrew/Cellar/[email protected]/3.15.0/lib/python3.15/json/__init__.py", line 371, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/home/linuxbrew/.linuxbrew/Cellar/[email protected]/3.15.0/lib/python3.15/json/decoder.py", line 358, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/home/linuxbrew/.linuxbrew/Cellar/[email protected]/3.15.0/lib/python3.15/json/decoder.py", line 376, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
CHECK 1d/11-scope-refusal: FAIL approver login failed
===== 1d/12-restart-collects (exit 77) =====
CHECK 1d/12-restart-collects: SKIP no provider key in .env.provider
===== 1d/13-e2e (exit 1) =====
bash scripts/e2e.sh
e2e: SKIP no provider key in .env.provider
make: *** [Makefile:78: e2e] Error 77
CHECK 1d/13-e2e: FAIL make e2e failed: bash scripts/e2e.sh e2e: SKIP no provider key in .env.provider make: *** [Makefile:78: e2e] Error 77
===== 1d/14-director-chat-proxy (exit 77) =====
CHECK 1d/14-director-chat-proxy: SKIP no provider key in .env.provider
===== 1d/15-audit-chain (exit 0) =====
audit: 3 entries, tip ed9d47fdf5250590bafd91396a8fff35e475b6c33f4e97ffc3ee44e7639fed25; 0 landed merge(s) anchored across 0 branch(es)
CHECK 1d/15-audit-chain: PASS
===== 1d/16-stage-1-register (exit 1) =====
CHECK 1d/16-stage-1-register: FAIL bubblewrap referenced: scripts/accept/0/06-no-donor-code.sh
scripts/accept/1d/16-stage-1-register.sh
CHECKS for stage 1d
1d/01-make-check PASS
1d/02-up-healthy FAIL gateway healthz unreachable
1d/03-only-gateway-published PASS
1d/04-credentials-required PASS
1d/05-auth-and-roles FAIL test dispatcher never healthy
1d/06-director-scout SKIP no provider key in .env.provider
1d/07-scout-completes SKIP no provider key in .env.provider
1d/08-docs-lands SKIP no provider key in .env.provider
1d/09-linter-break-fails-verify SKIP no provider key in .env.provider
1d/10-joint-breakage-resets SKIP no provider key in .env.provider
1d/11-scope-refusal FAIL approver login failed
1d/12-restart-collects SKIP no provider key in .env.provider
1d/13-e2e FAIL make e2e failed: bash scripts/e2e.sh e2e: SKIP no provider key in .env.provider make: *** [Makefile:78: e2e] Error 77
1d/14-director-chat-proxy SKIP no provider key in .env.provider
1d/15-audit-chain PASS
1d/16-stage-1-register FAIL bubblewrap referenced: scripts/accept/0/06-no-donor-code.sh
stage 1d: 4 pass, 7 skip, 5 fail (16 checks)
+21 -2
View File
@@ -58,7 +58,22 @@ artifacts_vol=$(docker inspect agency-verifier \
--format '{{ range .Mounts }}{{ if eq .Destination "/artifacts" }}{{ print .Name }}{{ end }}{{ end }}' 2>/dev/null) ||
fail "cannot find the artifacts volume (run check 02 first)"
[ -n "$artifacts_vol" ] || fail "verifier has no /artifacts mount (run check 02 first)"
docker run -d --name agency-dispatcher-test --network agency_fleet \
# The daemon silently drops published ports on an internal-network-only
# container (same root cause as the gateway defect: verified on Docker
# 29.9.0), so the test Dispatcher starts on the non-internal network the
# egress proxy already uses and joins the fleet network second for expert
# DNS. DISPATCHER_HOSTNAME=0.0.0.0 is required too: the proxy forwards to
# the container address, not loopback.
fleet_net=$(docker inspect agency-verifier \
--format '{{ range $k,$v := .NetworkSettings.Networks }}{{ print $k }}{{ end }}' 2>/dev/null |
head -1) || fail "cannot find the fleet network (run check 02 first)"
[ -n "$fleet_net" ] || fail "verifier has no network (run check 02 first)"
outside_net=$(docker inspect agency-egress \
--format '{{ range $k,$v := .NetworkSettings.Networks }}{{ print $k }}{{ end }}' 2>/dev/null |
grep -v -x "$fleet_net" | head -1) ||
fail "cannot find the non-internal network (run check 02 first)"
[ -n "$outside_net" ] || fail "egress has no non-internal network (run check 02 first)"
docker create --name agency-dispatcher-test --network "$outside_net" \
-p 127.0.0.1:18080:8080 \
-v "$root/workspace:/work:rw,z" \
-v "$artifacts_vol:/artifacts:z" \
@@ -71,10 +86,14 @@ docker run -d --name agency-dispatcher-test --network agency_fleet \
-e PW_DBA="$PW_DBA" -e PW_PLATFORM="$PW_PLATFORM" \
-e PW_SECURITY="$PW_SECURITY" -e PW_QA="$PW_QA" \
-e PW_REVIEWER="$PW_REVIEWER" -e PW_DOCS="$PW_DOCS" \
-e DISPATCHER_HOSTNAME=0.0.0.0 \
-e DISPATCHER_STATE_DIR=/data -e DISPATCHER_REGISTRY_DIR=/data/registry \
-e DISPATCHER_WORKSPACE=/work -e DISPATCHER_ARTIFACTS=/artifacts \
-e DISPATCHER_ALLOWLIST=/data/registry/allowlist.yaml \
agency-dispatcher:1.0.0 >/dev/null || fail "cannot start test dispatcher"
agency-dispatcher:1.0.0 >/dev/null || fail "cannot create test dispatcher"
docker network connect "$fleet_net" agency-dispatcher-test ||
fail "cannot join test dispatcher to $fleet_net"
docker start agency-dispatcher-test >/dev/null || fail "cannot start test dispatcher"
test_base="http://127.0.0.1:18080"
healthy=""
+3 -1
View File
@@ -10,7 +10,9 @@ require_cmd docker
out=$(cd "$root" && make e2e 2>&1)
code=$?
echo "$out" | tail -5
if [ "$code" -eq 77 ]; then
# GNU make reports the recipe's 77 as its own failure (exit 2); the SKIP
# line inside the output is the authority, not make's exit code.
if echo "$out" | grep -q "e2e: SKIP"; then
skip "make e2e skipped (no provider key)"
fi
[ "$code" -eq 0 ] || fail "make e2e failed: $(echo "$out" | tail -3 | tr '\n' ' ')"
+4 -3
View File
@@ -47,11 +47,11 @@ run_guard dispatcher/test/registry-schema.test.ts "checks.yaml rejects an empty
run_guard verifier/test/server.test.ts "an empty check list returns no-checks, never success"
run_guard dispatcher/test/runner.test.ts "a scripted permission request is rejected with the exact message and recorded"
# D-06: no sandbox lanes, no bubblewrap anywhere in first-party code.
# D-06: no sandbox lanes and no donor code. The stage-0 scan owns the
# self-aware source sweep (it exempts its own mention); re-run it
# rather than duplicating its grep.
[ ! -e dispatcher/src/lanes ] || fail "lanes directory exists"
[ ! -e dispatcher/src/scheduler/runner.ts ] || fail "old scheduler runner exists"
bwrap=$(grep -rln "bwrap" dispatcher/src verifier/src scripts envrunner lifecycle 2>/dev/null || true)
[ -z "$bwrap" ] || fail "bubblewrap referenced: $bwrap"
# Static stage guards that run in seconds, with their own result lines
# captured so they cannot end this check early.
@@ -65,6 +65,7 @@ static_check "$root/scripts/accept/1c/03-scheduler-pure.sh" "1c/03"
static_check "$root/scripts/accept/1c/04-single-opencode-module.sh" "1c/04"
static_check "$root/scripts/accept/1c/09-no-task-exec-in-dispatcher.sh" "1c/09"
static_check "$root/scripts/accept/0/07-no-skips.sh" "0/07"
static_check "$root/scripts/accept/0/06-no-donor-code.sh" "0/06"
# D-07: generated services carry limits, dropped capabilities, and :z.
bun -e '
+26 -1
View File
@@ -128,10 +128,35 @@ describe("render", () => {
expect(caddy.content).not.toContain("sleeper.agency.localhost");
const compose = files.find((f) => f.path.endsWith("docker-compose.yml"))!;
const doc = parseYaml(compose.content.replace(/^#.*\n/, "")) as {
services: Record<string, { image?: string; ports?: string[] }>;
services: Record<
string,
{
image?: string;
ports?: string[];
cap_add?: string[];
cap_drop?: string[];
environment?: Record<string, string>;
networks?: string[];
}
>;
};
expect(doc.services["gateway"]?.image).toContain("caddy@sha256:");
expect(doc.services["gateway"]?.ports).toEqual(["127.0.0.1:${AGENCY_HTTP_PORT}:8080"]);
// The Caddy binary carries cap_net_bind_service as a file capability;
// dropping ALL without re-adding it makes exec fail, so the renderer
// grants exactly that one capability back.
expect(doc.services["gateway"]?.cap_drop).toEqual(["ALL"]);
expect(doc.services["gateway"]?.cap_add).toEqual(["NET_BIND_SERVICE"]);
// Defect: the dispatcher bound loopback inside its container, so the
// gateway's dispatcher:8080 route was refused with 502. The renderer
// binds all interfaces so the gateway can reach it over the fleet
// network.
expect(doc.services["dispatcher"]?.environment?.["DISPATCHER_HOSTNAME"]).toBe("0.0.0.0");
// Defect: the daemon silently drops published ports on an
// internal-network-only container, so the gateway is dual-homed with
// the egress proxy while experts stay internal-only.
expect(doc.services["gateway"]?.networks).toEqual(["fleet", "outside"]);
expect(doc.services["writer"]?.networks).toEqual(["fleet"]);
expect(doc.services["dispatcher"]?.ports).toBeUndefined();
expect(doc.services["writer"]?.ports).toBeUndefined();
});
+21 -1
View File
@@ -474,6 +474,10 @@ function renderCompose(fleet: FleetConfig, inputs: RenderInputs): string {
AGENCY_MODEL: "${AGENCY_MODEL}",
FLEET_MCP_TOKEN: "${FLEET_MCP_TOKEN}",
VERIFIER_TOKEN: "${VERIFIER_TOKEN}",
// Reachable on the fleet network: the gateway proxies
// agency.localhost to dispatcher:8080, which fails when the service
// binds loopback inside its own container.
DISPATCHER_HOSTNAME: "0.0.0.0",
DISPATCHER_STATE_DIR: "/data",
DISPATCHER_REGISTRY_DIR: "/data/registry",
DISPATCHER_WORKSPACE: "/work",
@@ -524,8 +528,24 @@ function renderCompose(fleet: FleetConfig, inputs: RenderInputs): string {
container_name: "agency-gateway",
image: `caddy@${caddyDigest}`,
restart: "unless-stopped",
networks: ["fleet"],
// Dual-homed like egress: `fleet` reaches the Dispatcher and experts,
// `outside` lets the daemon program the loopback-only published port.
// A container on the internal `fleet` network alone gets its published
// ports silently dropped by the daemon (verified on Docker 29.9.0: an
// identical `docker run -p` on the internal network programs no
// mapping, while the same run on a bridge network does). The gateway
// still publishes only on 127.0.0.1, and its static Caddyfile only
// reverse-proxies to fleet upstreams, so it initiates no egress
// itself; experts stay on the internal network only.
networks: ["fleet", "outside"],
cap_drop: ["ALL"],
// The official Caddy binary carries the file capability
// cap_net_bind_service=ep. Dropping ALL empties the bounding set, so
// execve of the binary fails with EPERM ("operation not permitted")
// and the container restarts forever. Re-adding only NET_BIND_SERVICE
// keeps the bounding set compatible while granting nothing the gateway
// needs beyond executing its own binary (it serves port 8080).
cap_add: ["NET_BIND_SERVICE"],
security_opt: ["no-new-privileges:true"],
pids_limit: 256,
cpus: 1,