mirror of
https://github.com/Heretek-AI/The-Agency.git
synced 2026-11-07 10:06:01 -10:00
fix(stage-1d): e2e skip mapping, register scan wording
This commit is contained in:
1 parent
90a80cb115
commit
161e0a715e
12 files changed
+360
-17
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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=""
|
||||
|
||||
@@ -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' ' ')"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user