Skip to content

Commit 22cc1c5

Browse files
0xmariowuclaude
andauthored
fix(p0-1): npm wrapper fake success — ENOENT + PATH persistence + --no-init (#427)
* test(npm-wrapper): add failing scaffold for ENOENT detection (F003 S1) * fix(npm-wrapper): exit non-zero on spawn ENOENT with fix hint (F003 S2) * test(install-sh): add failing scaffold for PATH persistence (F003 S3) * fix(install-sh): persist PATH to shell profile only when missing (F003 S4) * fix(npm-wrapper): prepend ~/.local/bin to PATH before post-install spawn (F003 S5) * fix(npm-wrapper): pass --no-init to installer to avoid implicit init (F003 S6) * test(install-script): add e2e smoke for install → spawn happy path (F003 S7) * chore(gitleaks): allowlist install + npm-wrapper paths for .local XDG dirs Tailscale-domain rule's regex was matching POSIX XDG user paths ($HOME/.local/bin) introduced by F003 npm wrapper PATH-persistence work. These are filesystem paths, not mDNS/Tailscale hostnames. Allowlist scoped to scripts/install.sh, npm/bin/*.js, and the related smoke/unit tests; regexes pinned to recognized XDG path components. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install-sh): prefer sourced bash profile * docs(install-sh): clarify path persistence writes * test(npm-wrapper): skip POSIX wrapper shims on Windows * test(install-script): skip e2e bash stub on Windows * fix(npm-wrapper): handle spawn permission errors * fix(p0-6): redact scenario error + stderr in e2b reports (#432) * test(e2b-report): add failing scaffold for error redaction in summary + results.json (F008 S1) * fix(e2b-report): redact scenario error in summary.md (F008 S2) * fix(e2b-report): redact scenario error in results.json (F008 S3) * fix(e2b-comprehensive): redact scenario error + stderr in transcripts (F008 S4) * chore(gitleaks): widen install/wrapper allowlist to cover new permission test file * fix(install-sh): skip PATH persist for unknown shells (don't write bash syntax to .bashrc) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4509884 commit 22cc1c5

7 files changed

Lines changed: 520 additions & 16 deletions

File tree

.gitleaks.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ keywords = [".ts.net", ".local"]
3535
paths = ['''skills/.*/SKILL\.md$''', '''docs/.*\.md$''']
3636
regexes = ['''\.local/''']
3737

38+
[[rules.allowlists]]
39+
description = "Install scripts + npm wrapper + their tests reference $HOME/.local POSIX user dirs (XDG, not Tailscale .local hostname)"
40+
paths = ['''scripts/install\.sh$''', '''npm/bin/.*\.js$''', '''tests/(smoke|unit)/test_(npm_wrapper|install_script|install_sh_flags|npm_wrapper_permissions)\.py$''']
41+
regexes = ['''\.local/(bin|share|lib|var|state|cache|tmp)''', '''\$HOME/\.local''', '''process\.env\.HOME[^"]*\.local''']
42+
3843
# ── Public-repo-only rules ───────────────────────────────────────────────────
3944

4045
[[rules]]

npm/bin/autosearch-ai.js

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,72 @@ function hasAutosearch() {
4848
return getInstalledAutosearchVersion() !== null;
4949
}
5050

51+
function expectedAutosearchPath() {
52+
if (isWindows()) {
53+
return "%USERPROFILE%\\AppData\\Roaming\\Python\\Python312\\Scripts\\autosearch.exe";
54+
}
55+
return `${process.env.HOME || "~"}/.local/bin/autosearch`;
56+
}
57+
58+
function prependHomeLocalBinToPath() {
59+
if (!process.env.HOME) return;
60+
61+
const localBin = `${process.env.HOME}/.local/bin`;
62+
const separator = isWindows() ? ";" : ":";
63+
const currentPath = process.env.PATH || "";
64+
if (currentPath.split(separator).includes(localBin)) return;
65+
66+
process.env.PATH = currentPath
67+
? `${localBin}${separator}${currentPath}`
68+
: localBin;
69+
}
70+
71+
function printAutosearchNotFoundHint(error) {
72+
process.stderr.write(
73+
`\nautosearch not found after install.\n` +
74+
`Expected executable: ${expectedAutosearchPath()}\n` +
75+
`Your PATH may not include the install location yet.\n` +
76+
`Re-source your shell profile or install AutoSearch directly:\n` +
77+
` curl -fsSL ${INSTALL_SCRIPT} | bash\n` +
78+
` pipx install autosearch && autosearch init\n` +
79+
` pip install --user autosearch && autosearch init\n` +
80+
(error?.message ? `\nOriginal error: ${error.message}\n` : "\n"),
81+
);
82+
}
83+
84+
function printInstallerNotFoundHint(error) {
85+
process.stderr.write(
86+
`\nUnable to start the AutoSearch installer.\n` +
87+
`The underlying installer command was not found. Ensure one of these ` +
88+
`commands is available on PATH: curl, bash, pipx, py, python.\n` +
89+
`Then re-run: npx autosearch-ai --yes\n` +
90+
(error?.message ? `\nOriginal error: ${error.message}\n` : "\n"),
91+
);
92+
}
93+
94+
function isPermissionError(error) {
95+
return error?.code === "EACCES" || error?.code === "EPERM";
96+
}
97+
98+
function printAutosearchPermissionHint(error) {
99+
process.stderr.write(
100+
`\nautosearch failed to start: permission denied.\n` +
101+
`Expected executable: ${expectedAutosearchPath()}\n` +
102+
`Check that the autosearch executable has execute permission, ` +
103+
`or reinstall AutoSearch with pipx or pip.\n` +
104+
(error?.message ? `\nOriginal error: ${error.message}\n` : "\n"),
105+
);
106+
}
107+
108+
function printInstallerPermissionHint(error) {
109+
process.stderr.write(
110+
`\nUnable to start the AutoSearch installer: permission denied.\n` +
111+
`Check permissions for the installer command on PATH ` +
112+
`(bash, pipx, py, or python), then re-run: npx autosearch-ai --yes\n` +
113+
(error?.message ? `\nOriginal error: ${error.message}\n` : "\n"),
114+
);
115+
}
116+
51117
// Bug 6 (fix-plan v8 follow-up): compare the wrapper's expected Python CLI
52118
// version against what's actually installed. The npm package version
53119
// `YYYY.M.DD` derives from pyproject `YYYY.MM.DD.N` (N is the daily counter
@@ -150,7 +216,7 @@ function describeInstallStep() {
150216
if (hasOnPath("python")) return `python -m pip install --user autosearch`;
151217
return null;
152218
}
153-
return `curl -fsSL ${INSTALL_SCRIPT} | bash`;
219+
return `curl -fsSL ${INSTALL_SCRIPT} | bash -s -- --no-init`;
154220
}
155221

156222
function runInstall() {
@@ -182,7 +248,7 @@ function runInstall() {
182248
}
183249
return spawnSync(
184250
"bash",
185-
["-c", `curl -fsSL ${INSTALL_SCRIPT} | bash`],
251+
["-c", `curl -fsSL ${INSTALL_SCRIPT} | bash -s -- --no-init`],
186252
{ stdio: "inherit" },
187253
);
188254
}
@@ -226,15 +292,40 @@ async function main() {
226292
}
227293
}
228294
const result = runInstall();
295+
if (isPermissionError(result.error)) {
296+
printInstallerPermissionHint(result.error);
297+
process.exit(1);
298+
}
299+
if (result.error?.code === "ENOENT") {
300+
printInstallerNotFoundHint(result.error);
301+
process.exit(1);
302+
}
303+
if (result.error) {
304+
printInstallerNotFoundHint(result.error);
305+
process.exit(1);
306+
}
229307
if ((result.status ?? 0) !== 0) {
230308
process.exit(result.status ?? 1);
231309
}
310+
prependHomeLocalBinToPath();
232311
} else {
233312
checkVersionAlignment(installedVersion);
234313
}
235314

236315
const cmd = args.length > 0 ? args : ["init"];
237316
const result = spawnSync("autosearch", cmd, { stdio: "inherit" });
317+
if (isPermissionError(result.error)) {
318+
printAutosearchPermissionHint(result.error);
319+
process.exit(1);
320+
}
321+
if (result.error?.code === "ENOENT") {
322+
printAutosearchNotFoundHint(result.error);
323+
process.exit(1);
324+
}
325+
if (result.error) {
326+
printAutosearchNotFoundHint(result.error);
327+
process.exit(1);
328+
}
238329
process.exit(result.status ?? 0);
239330
}
240331

scripts/install.sh

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ GREEN="\033[32m"
77
YELLOW="\033[33m"
88
RED="\033[31m"
99
RESET="\033[0m"
10+
ORIGINAL_PATH="$PATH"
1011

1112
info() { echo -e "${BOLD}$*${RESET}"; }
1213
success() { echo -e "${GREEN}${RESET} $*"; }
@@ -16,6 +17,7 @@ die() { echo -e "${RED}✗${RESET} $*" >&2; exit 1; }
1617
# ── Flag parsing ─────────────────────────────────────────────────────────────
1718
DRY_RUN=false
1819
NO_INIT=false
20+
CHECK_PATH_PERSISTENCE=false
1921
VERSION=""
2022

2123
usage() {
@@ -33,6 +35,10 @@ Flags:
3335
config separately.
3436
--version VER Pin a specific version (e.g. 2026.04.24.4) instead of
3537
installing the latest. Applies to uv / pipx / pip.
38+
--check-path-persistence
39+
Check shell-profile PATH persistence logic, then exit.
40+
May append PATH export to the shell profile if missing;
41+
combine with --dry-run to preview without writing.
3642
-h, --help Show this help.
3743
EOF
3844
}
@@ -41,6 +47,7 @@ while [[ $# -gt 0 ]]; do
4147
case "$1" in
4248
--dry-run) DRY_RUN=true; shift ;;
4349
--no-init) NO_INIT=true; shift ;;
50+
--check-path-persistence) CHECK_PATH_PERSISTENCE=true; shift ;;
4451
--version) [[ -z "${2:-}" ]] && die "--version requires an argument"; VERSION="$2"; shift 2 ;;
4552
--version=*) VERSION="${1#--version=}"; shift ;;
4653
-h|--help) usage; exit 0 ;;
@@ -130,24 +137,58 @@ fix_path() {
130137
}
131138
132139
# ── 4. Persist PATH to shell profile ─────────────────────────────────────────
140+
shell_profile() {
141+
case "$(basename "${SHELL:-}")" in
142+
zsh) echo "$HOME/.zshrc" ;;
143+
bash)
144+
for profile in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do
145+
if [[ -f "$profile" ]]; then
146+
echo "$profile"
147+
return 0
148+
fi
149+
done
150+
echo "$HOME/.bashrc"
151+
;;
152+
*) return 1 ;;
153+
esac
154+
}
155+
133156
persist_path() {
134157
local line='export PATH="$HOME/.local/bin:$PATH"'
135-
for profile in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile"; do
136-
if [ -f "$profile" ] && ! grep -q '.local/bin' "$profile" 2>/dev/null; then
137-
if [[ "$DRY_RUN" == true ]]; then
138-
printf " [dry-run] would append PATH export to %s\n" "$profile"
139-
else
140-
echo "" >> "$profile"
141-
echo "# Added by AutoSearch installer" >> "$profile"
142-
echo "$line" >> "$profile"
143-
warn "Added ~/.local/bin to PATH in $profile"
144-
fi
145-
break
146-
fi
147-
done
158+
local profile
159+
160+
if [[ ":$ORIGINAL_PATH:" == *":$HOME/.local/bin:"* ]]; then
161+
return 0
162+
fi
163+
164+
if ! profile="$(shell_profile)"; then
165+
warn "Unknown shell ($(basename "${SHELL:-}")) — skipping PATH persistence."
166+
warn "Add this line to your shell profile manually: $line"
167+
return 0
168+
fi
169+
if [[ -f "$profile" ]] && grep -Fqx "$line" "$profile" 2>/dev/null; then
170+
return 0
171+
fi
172+
173+
if [[ "$DRY_RUN" == true ]]; then
174+
printf " [dry-run] would append PATH export to %s\n" "$profile"
175+
return 0
176+
fi
177+
178+
mkdir -p "$(dirname "$profile")"
179+
touch "$profile"
180+
echo "" >> "$profile"
181+
echo "# Added by AutoSearch installer" >> "$profile"
182+
echo "$line" >> "$profile"
183+
warn "Added ~/.local/bin to PATH in $profile"
148184
}
149185

150186
# ── Main ─────────────────────────────────────────────────────────────────────
187+
if [[ "$CHECK_PATH_PERSISTENCE" == true ]]; then
188+
persist_path
189+
exit 0
190+
fi
191+
151192
install_autosearch
152193
fix_path
153194

@@ -163,8 +204,9 @@ if [[ "$DRY_RUN" == true ]]; then
163204
exit 0
164205
fi
165206

207+
persist_path
208+
166209
if ! command -v autosearch &>/dev/null; then
167-
persist_path
168210
fix_path
169211
fi
170212

tests/smoke/test_install_script.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import os
2+
import shutil
13
import subprocess
24
from pathlib import Path
35

6+
import pytest
7+
48

59
ROOT = Path(__file__).resolve().parents[2]
10+
NPM_DIR = ROOT / "npm"
611
INSTALL_SCRIPT = ROOT / "scripts" / "install.sh"
712

813

@@ -42,3 +47,48 @@ def test_install_script_rejects_path_traversal_version() -> None:
4247

4348
assert result.returncode != 0
4449
assert "PEP 440-style version" in combined_output
50+
51+
52+
@pytest.mark.skipif(os.name == "nt", reason="POSIX-only fake bash installer shim")
53+
def test_install_then_run_e2e_smoke(tmp_path: Path) -> None:
54+
home = tmp_path / "home"
55+
home.mkdir()
56+
fake_bin = tmp_path / "bin"
57+
fake_bin.mkdir()
58+
fake_bash = fake_bin / "bash"
59+
fake_bash.write_text(
60+
"#!/bin/sh\n"
61+
'mkdir -p "$HOME/.local/bin"\n'
62+
"cat > \"$HOME/.local/bin/autosearch\" <<'EOF'\n"
63+
"#!/bin/sh\n"
64+
'echo "e2e autosearch $*"\n'
65+
"exit 0\n"
66+
"EOF\n"
67+
'chmod +x "$HOME/.local/bin/autosearch"\n'
68+
"exit 0\n",
69+
encoding="utf-8",
70+
)
71+
fake_bash.chmod(0o755)
72+
73+
env = os.environ.copy()
74+
env["HOME"] = str(home)
75+
env["PATH"] = os.pathsep.join([str(fake_bin), "/usr/bin", "/bin"])
76+
77+
result = subprocess.run(
78+
[
79+
shutil.which("node") or "node",
80+
str(NPM_DIR / "bin" / "autosearch-ai.js"),
81+
"--yes",
82+
"doctor",
83+
],
84+
cwd=ROOT,
85+
env=env,
86+
capture_output=True,
87+
text=True,
88+
timeout=30,
89+
check=False,
90+
)
91+
92+
combined_output = result.stdout + result.stderr
93+
assert result.returncode == 0, combined_output
94+
assert "e2e autosearch doctor" in result.stdout

0 commit comments

Comments
 (0)