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
7 changes: 5 additions & 2 deletions scripts/check-codex-cloud-setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
}
Expand Down
42 changes: 28 additions & 14 deletions scripts/install-codex-cloud-command-shims.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,33 @@ 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)"
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)"
Expand All @@ -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}."
Expand All @@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Working-directory commands disappear 🐞 Bug ≡ Correctness

The generated shim rebuilds clean_path with IFS=: read -r -a, which drops a trailing empty
PATH entry rather than preserving it. When the inherited PATH ends in :, the shim removes the
working directory from command lookup for child processes; the installer’s matching fallback_path
loop also loses it during fallback lookups.
Agent Prompt
## Issue description
The new PATH-splitting loops discard a trailing empty entry, changing command lookup semantics for a PATH ending in `:`.

## Fix Focus Areas
- scripts/install-codex-cloud-command-shims.sh[34-45]
- scripts/install-codex-cloud-command-shims.sh[81-92]

## Recommended Fix
Filter the shim directory using a literal comparison while preserving every other PATH component, including trailing empty components. Apply the same approach to the installer and generated wrapper, and test a PATH ending in `:`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

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
Expand Down
2 changes: 2 additions & 0 deletions scripts/setup-codex-cloud.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 15 additions & 6 deletions tests/codex-cloud-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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"');
Expand All @@ -769,16 +771,23 @@ 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");
const bashNodeBin = bashPathEntry(nodeBin);
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(
Expand Down
21 changes: 21 additions & 0 deletions tests/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});