From 6b5cd22e9f696b3956e026e14b3de28110cd58c5 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:56:42 -0700 Subject: [PATCH] Keep the deploy wrapper on tools the systemd PATH actually provides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first v2.1.4 production deploy failed closed at restart: validate_v2_server_config ran a node heredoc, but the service runs under systemd where node is not on PATH, so the unit exited 127 and production rolled back. The runtime-key validation (shape, kid pattern, canonical base64 of >=32 bytes, pairwise kid/secret distinctness) is now expressed in jq and coreutils — the same dependencies the wrapper already requires — with identical fail-closed semantics. Constraint: systemd unit PATH provides no node; the wrapper must not gain runtime dependencies beyond bash, jq, coreutils, aws, git, and docker. Rejected: resolving node by absolute path or via docker run | node is not guaranteed installed on the host at all, and pulling an image at unit start adds a network dependency to a boot-time validation. Confidence: high — every existing HMAC/key rejection test exercises the rewritten validator and still fails closed. Scope-risk: narrow — one function in the wrapper plus its test fixture. Directive: compose-up.sh may only assume bash, jq, and coreutils beyond the stubbed service commands; the new regression test runs the whole wrapper under a stripped systemd-like PATH with a failing node stub. Tested: node --test infra/test/deploy-assets.test.mjs (29/29 incl. new systemd-PATH test); root npm test (625 workspace + 36 infra); git diff --check clean. Not-tested: the real unit restart on the box — that is the next deploy. --- infra/clockchain-mcp/compose-up.sh | 82 +++++++++++++++--------------- infra/test/deploy-assets.test.mjs | 34 +++++++++++-- 2 files changed, 71 insertions(+), 45 deletions(-) diff --git a/infra/clockchain-mcp/compose-up.sh b/infra/clockchain-mcp/compose-up.sh index 0f62467..fffc82c 100755 --- a/infra/clockchain-mcp/compose-up.sh +++ b/infra/clockchain-mcp/compose-up.sh @@ -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() { diff --git a/infra/test/deploy-assets.test.mjs b/infra/test/deploy-assets.test.mjs index b7428d4..aa2c48c 100644 --- a/infra/test/deploy-assets.test.mjs +++ b/infra/test/deploy-assets.test.mjs @@ -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); @@ -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 }, @@ -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, @@ -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" },