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
82 changes: 40 additions & 42 deletions infra/clockchain-mcp/compose-up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -193,48 +193,46 @@ validate_v2_server_config() {
printf 'invalid AGENT_HANDSHAKE_RELEASE_PIN configuration\n' >&2
return 1
fi
node <<'NODE'
const specs = [
["AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE", true],
["AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS", true],
["AGENT_HANDSHAKE_ACCEPTANCE_HMAC_ACTIVE", true],
["AGENT_HANDSHAKE_ACCEPTANCE_HMAC_PREVIOUS", false],
];
const kidPattern = /^[a-z0-9][a-z0-9-]{0,63}$/;
const seenKids = new Set();
const seenSecrets = new Set();
function fail(message) {
console.error(message);
process.exit(1);
}
function readKey(name, required) {
const raw = process.env[name] ?? "";
if (raw === "" && !required) return;
if (raw === "") fail(`missing ${name} configuration`);
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
fail(`invalid ${name} configuration`);
}
if (
parsed === null || typeof parsed !== "object" || Array.isArray(parsed) ||
Object.keys(parsed).sort().join(",") !== "kid,secretBase64" ||
typeof parsed.kid !== "string" || !kidPattern.test(parsed.kid) ||
typeof parsed.secretBase64 !== "string"
) fail(`invalid ${name} configuration`);
const secret = Buffer.from(parsed.secretBase64, "base64");
if (secret.length < 32 || secret.toString("base64") !== parsed.secretBase64) {
fail(`invalid ${name} configuration`);
}
const secretId = secret.toString("base64");
if (seenKids.has(parsed.kid)) fail("agent-handshake runtime key ids must be distinct");
if (seenSecrets.has(secretId)) fail("agent-handshake runtime key secrets must be distinct");
seenKids.add(parsed.kid);
seenSecrets.add(secretId);
}
for (const [name, required] of specs) readKey(name, required);
NODE
# Runtime keys are {"kid","secretBase64"} JSON. Validate with jq + coreutils
# only: the service runs under systemd, whose PATH does not provide node.
local key_filter name raw kid secret
local seen_kids="" seen_secrets=""
key_filter='type == "object" and (keys | sort) == ["kid","secretBase64"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.secretBase64 | type == "string")'
for name in \
AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE \
AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS \
AGENT_HANDSHAKE_ACCEPTANCE_HMAC_ACTIVE \
AGENT_HANDSHAKE_ACCEPTANCE_HMAC_PREVIOUS
do
raw="${!name:-}"
if [[ -z "$raw" ]]; then
# Only the previous acceptance HMAC may be absent.
[[ "$name" == "AGENT_HANDSHAKE_ACCEPTANCE_HMAC_PREVIOUS" ]] && continue
printf 'missing %s configuration\n' "$name" >&2
return 1
fi
if ! jq -e "$key_filter" >/dev/null 2>&1 <<<"$raw"; then
printf 'invalid %s configuration\n' "$name" >&2
return 1
fi
kid="$(jq -r '.kid' <<<"$raw")"
secret="$(jq -r '.secretBase64' <<<"$raw")"
# Canonical base64 of >=32 decoded bytes: a non-decodable or non-canonical
# value never round-trips to itself.
if [[ "$(printf '%s' "$secret" | base64 -d 2>/dev/null | base64 | tr -d '\n')" != "$secret" ]] ||
(( $(printf '%s' "$secret" | base64 -d | wc -c) < 32 )); then
printf 'invalid %s configuration\n' "$name" >&2
return 1
fi
case "|$seen_kids|" in
*"|$kid|"*) printf 'agent-handshake runtime key ids must be distinct\n' >&2; return 1 ;;
esac
case "|$seen_secrets|" in
*"|$secret|"*) printf 'agent-handshake runtime key secrets must be distinct\n' >&2; return 1 ;;
esac
seen_kids="$seen_kids|$kid"
seen_secrets="$seen_secrets|$secret"
done
}

materialize_host_secrets() {
Expand Down
34 changes: 31 additions & 3 deletions infra/test/deploy-assets.test.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import assert from "node:assert/strict";
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import test from "node:test";

const repoRoot = path.resolve(new URL("../..", import.meta.url).pathname);
Expand Down Expand Up @@ -284,7 +284,7 @@ done
if [[ "\${DOCKER_FAIL_HEALTH:-0}" == "1" ]]; then
exit 78
fi
node "$ENV_CHECK_FILE"
"$TEST_NODE_BIN" "$ENV_CHECK_FILE"
printf 'docker compose invoked\\n'
`,
{ mode: 0o755 },
Expand All @@ -301,6 +301,9 @@ printf 'docker compose invoked\\n'
DOCKER_INVOKED_FILE: dockerInvokedFile,
DOCKER_OK_FILE: dockerOkFile,
ENV_CHECK_FILE: path.join(temp, "env-check.mjs"),
// Absolute node path so the fixture's own checks still work when a test
// strips node from PATH to simulate the systemd unit environment.
TEST_NODE_BIN: process.execPath,
EXPECTED_ENV_FILE: path.join(temp, "expected-env.json"),
EXPECTED_HOST_SECRETS_FILE: path.join(temp, "expected-host-secrets.json"),
EXPECTED_HOST_SECRET_DIR: hostSecretDir,
Expand Down Expand Up @@ -673,6 +676,31 @@ test("deploy wrapper and v2 runtime declare one helper release line and derive t
);
});

test("compose wrapper runs under a systemd PATH with no node on it", async () => {
// Production runs the wrapper as a systemd unit; that PATH has no node.
// Reproduce it here: PATH is only the fixture bin dir plus the system dirs,
// with a `node` stub that exits 127 in case node lives in a system dir on
// the test host. Any bare `node` invocation in the wrapper fails the run.
const { temp, dockerOkFile, env } = await createWrapperFixture();
try {
const jqPath = spawnSync("which", ["jq"], { encoding: "utf8" }).stdout.trim();
assert.ok(jqPath, "jq must be installed to run this test");
const binDir = path.join(temp, "bin");
await symlink(jqPath, path.join(binDir, "jq"));
await writeFile(path.join(binDir, "node"), "#!/usr/bin/env bash\nexit 127\n", { mode: 0o755 });

const result = await run(wrapper, [], {
cwd: temp,
env: { ...env, PATH: `${binDir}:/usr/bin:/bin` },
});

assert.equal(result.code, 0, result.stderr);
assert.equal(await readFile(dockerOkFile, "utf8"), "ok\n");
} finally {
await rm(temp, { recursive: true, force: true });
}
});

test("compose wrapper rejects invalid degraded handshake mode before docker", async () => {
const { temp, dockerOkFile, env } = await createWrapperFixture({
env: { HANDSHAKE_ALLOW_DEGRADED: "yes" },
Expand Down
Loading