diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index 8c3996827f..78dc144b46 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -1081,13 +1081,16 @@ function validateMaintenanceAndCommandShims(sources, errors) { } for (const [pattern, message] of [ [/nvm version/, "Cloud command shims must resolve the selected Node version without following their own wrappers."], - [/clean_path=.*\.local.*bin/, "Cloud command shims must remove their directory before running child npm scripts."], + [ + /\[\[ "\\\$path_entry" == "\\\$HOME\/\.local\/bin" \]\] && continue[\s\S]*clean_path\+="\\\$path_entry"/, + "Cloud command shims must remove their directory before running child npm scripts.", + ], [/mkdir -p "\$HOME\/\.local\/bin"/, "Cloud command shims must create their destination directory."], [/\.clinical-kb-codex-cloud\.sh/, "Cloud command shims must load the generated profile."], ]) { requireMatch(errors, commandShims, pattern, message); } - if (!commandShims.includes('exec "$node_bin/$command_name" "\\$@"')) { + if (!commandShims.includes('exec "$command_path" "\\$@"')) { errors.push("Cloud command shims must execute absolute Node commands."); } } diff --git a/scripts/install-codex-cloud-command-shims.sh b/scripts/install-codex-cloud-command-shims.sh index 461e891f21..1afa584ffc 100644 --- a/scripts/install-codex-cloud-command-shims.sh +++ b/scripts/install-codex-cloud-command-shims.sh @@ -24,12 +24,26 @@ node_version_supported() { local actual_major actual_minor actual_patch [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 IFS=. read -r actual_major actual_minor actual_patch <<< "$version" - (( actual_major < 25 )) || return 1 - (( actual_major > 24 )) && return 0 + (( actual_major == 24 )) || return 1 (( actual_minor > 15 )) && return 0 (( actual_minor == 15 && actual_patch >= 0 )) } +# Existing shims must never be picked as their own targets, so every PATH lookup below skips +# $HOME/.local/bin. +fallback_path="" +path_entry_seen=false +IFS=: read -r -a path_entries <<< "$PATH" +for path_entry in "${path_entries[@]}"; do + [[ "$path_entry" == "$HOME/.local/bin" ]] && continue + if $path_entry_seen; then + fallback_path+=: + fi + fallback_path+="$path_entry" + path_entry_seen=true +done +unset path_entries path_entry path_entry_seen + resolved_node_version="" if command -v nvm >/dev/null 2>&1; then resolved_node_version="$(nvm version "$expected_node_major" 2>/dev/null || true)" @@ -37,12 +51,6 @@ fi if [[ -n "$resolved_node_version" && "$resolved_node_version" != "N/A" ]]; then node_bin="$NVM_DIR/versions/node/$resolved_node_version/bin" else - fallback_path=":$PATH:" - while [[ "$fallback_path" == *":$HOME/.local/bin:"* ]]; do - fallback_path="${fallback_path//:$HOME\/.local\/bin:/:}" - done - fallback_path="${fallback_path#:}" - fallback_path="${fallback_path%:}" fallback_node="$(PATH="$fallback_path" command -v node 2>/dev/null || true)" [[ -n "$fallback_node" ]] || fail "Node ${expected_node_major} is unavailable through nvm or PATH." fallback_node_version="$($fallback_node -p 'process.versions.node' 2>/dev/null || true)" @@ -60,7 +68,7 @@ for command_name in node npm npx; do if [[ -x "$node_bin/$command_name" ]]; then command_path="$node_bin/$command_name" else - command_path="$(PATH="${fallback_path:-$PATH}" command -v "$command_name" 2>/dev/null || true)" + command_path="$(PATH="$fallback_path" command -v "$command_name" 2>/dev/null || true)" fi fi [[ -x "$command_path" ]] || fail "${command_name} is unavailable in Node ${expected_node_major}." @@ -70,12 +78,18 @@ set -Eeuo pipefail # Generated by scripts/install-codex-cloud-command-shims.sh. Re-running setup # or maintenance replaces this wrapper through the same idempotent command. . "$runtime_profile" -clean_path=":\$PATH:" -while [[ "\$clean_path" == *":\$HOME/.local/bin:"* ]]; do - clean_path="\${clean_path//:\$HOME\/.local\/bin:/:}" +clean_path="" +path_entry_seen=false +IFS=: read -r -a path_entries <<< "\$PATH" +for path_entry in "\${path_entries[@]}"; do + [[ "\$path_entry" == "\$HOME/.local/bin" ]] && continue + if \$path_entry_seen; then + clean_path+=: + fi + clean_path+="\$path_entry" + path_entry_seen=true done -clean_path="\${clean_path#:}" -clean_path="\${clean_path%:}" +unset path_entries path_entry path_entry_seen if [[ -n "\$clean_path" ]]; then export PATH="$node_bin:\$clean_path" else diff --git a/scripts/setup-codex-cloud.sh b/scripts/setup-codex-cloud.sh index ec0e108534..21f291a57b 100755 --- a/scripts/setup-codex-cloud.sh +++ b/scripts/setup-codex-cloud.sh @@ -274,6 +274,8 @@ for shell_profile in "$HOME/.bashrc" "$HOME/.profile" "$HOME/.bash_profile"; do profile_existing="$(<"$shell_profile")" profile_existing="$(printf '%s\n' "$profile_existing" | grep -Fvx '# PsychSift Codex Cloud runtime' || true)" profile_existing="$(printf '%s\n' "$profile_existing" | grep -Fvx "$profile_source" || true)" + # Drop the separator a previous run wrote after the block so reruns stay idempotent. + profile_existing="$(printf '%s\n' "$profile_existing" | sed '/./,$!d')" profile_candidate="$(mktemp "${shell_profile}.XXXXXX")" if [[ -n "$profile_existing" ]]; then printf '# PsychSift Codex Cloud runtime\n%s\n\n%s\n' "$profile_source" "$profile_existing" > "$profile_candidate" diff --git a/src/lib/env.ts b/src/lib/env.ts index da38cc4875..55a3e57816 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -71,7 +71,9 @@ const envSchema = z .optional(), ), SUPABASE_DB_URL: z.string().url().optional(), - HEALTH_DEEP_PROBE_SECRET: z.string().min(16).optional(), + // Blank means unset (offline wrappers blank inherited secrets); an unset secret keeps the + // deep probe closed, and a non-blank value must still be at least 16 characters. + HEALTH_DEEP_PROBE_SECRET: z.preprocess(coerceBlankEnv, z.string().min(16).optional()), // Inbound webhook receivers. Each shared secret gates a machine-to-machine // endpoint under /api/webhooks/* and fails closed when unset (the route 503s // rather than trusting an unauthenticated caller). See docs/webhooks.md. diff --git a/tests/codex-cloud-setup.test.ts b/tests/codex-cloud-setup.test.ts index 942b264a44..d67f9836c1 100644 --- a/tests/codex-cloud-setup.test.ts +++ b/tests/codex-cloud-setup.test.ts @@ -690,7 +690,9 @@ describe("Codex Cloud environment contract", () => { new URL("../scripts/delete-codex-cloud-branch-with-pat.sh", import.meta.url), "utf8", ); - expect(setup).toContain("if ! grep -Fq '.clinical-kb-codex-cloud.sh'"); + expect(setup).toContain('profile_source=\'[ -f "$HOME/.clinical-kb-codex-cloud.sh" ]'); + expect(setup).toContain('grep -Fvx "$profile_source"'); + expect(setup).toContain("sed '/./,$!d'"); expect(setup).toContain('if [[ "$actual_version" != "$expected_version" ]]'); expect(setup).toContain('expected_node_range="$(sed'); expect(setup).toContain('if ! node_version_supported "$actual_node_version"; then'); @@ -753,8 +755,8 @@ describe("Codex Cloud environment contract", () => { } expect(commandShims).toContain('nvm version "$expected_node_major"'); expect(commandShims).toContain('node_bin="$NVM_DIR/versions/node/$resolved_node_version/bin"'); - expect(commandShims).toContain('while [[ "\\$clean_path" == *":\\$HOME/.local/bin:"* ]]; do'); - expect(commandShims).toContain('clean_path="\\${clean_path//:\\$HOME\\/.local\\/bin:/:}"'); + expect(commandShims).toContain('IFS=: read -r -a path_entries <<< "\\$PATH"'); + expect(commandShims).toContain('[[ "\\$path_entry" == "\\$HOME/.local/bin" ]] && continue'); expect(commandShims).toContain('if [[ -n "\\$clean_path" ]]; then'); expect(commandShims).toContain('export PATH="$node_bin"'); expect(commandShims).toContain('. "$runtime_profile"'); @@ -769,8 +771,10 @@ describe("Codex Cloud environment contract", () => { expect(patDelete).toContain("core.hooksPath=/dev/null"); }); - it("removes every adjacent command-shim PATH entry", () => { - const home = temporaryDirectory("codex-cloud-shims-"); + it("removes every adjacent command-shim PATH entry, including bracketed home paths", () => { + const homeRoot = temporaryDirectory("codex-cloud-shims-"); + const home = path.join(homeRoot, "home[1]"); + mkdirSync(home, { recursive: true }); const bashHome = bashPathEntry(home); const nodeVersion = "v24.19.0"; const nodeBin = path.join(home, ".nvm", "versions", "node", nodeVersion, "bin"); @@ -778,7 +782,12 @@ describe("Codex Cloud environment contract", () => { mkdirSync(nodeBin, { recursive: true }); for (const command of ["node", "npm", "npx"]) { const executable = path.join(nodeBin, command); - writeFileSync(executable, '#!/usr/bin/env bash\nprintf "%s\\n" "$PATH"\n'); + writeFileSync( + executable, + command === "node" + ? `#!/usr/bin/env bash\nif [[ "\${1:-}" = "-p" ]]; then printf "%s\\n" "${nodeVersion.slice(1)}"; else printf "%s\\n" "$PATH"; fi\n` + : '#!/usr/bin/env bash\nprintf "%s\\n" "$PATH"\n', + ); chmodSync(executable, 0o755); } writeFileSync( diff --git a/tests/env.test.ts b/tests/env.test.ts index 2b5fca1588..0e08d9c06c 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -20,3 +20,24 @@ describe("developer-area access-key environment parsing", () => { } }); }); + +describe("deep-probe secret environment parsing", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("treats a blank secret as unset, as the offline test wrapper leaves it", async () => { + vi.stubEnv("HEALTH_DEEP_PROBE_SECRET", ""); + + await expect(import("@/lib/env")).resolves.toMatchObject({ + env: { HEALTH_DEEP_PROBE_SECRET: undefined }, + }); + }); + + it("still rejects a non-blank secret shorter than 16 characters", async () => { + vi.stubEnv("HEALTH_DEEP_PROBE_SECRET", "too-short"); + + await expect(import("@/lib/env")).rejects.toThrow(); + }); +});