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
16 changes: 16 additions & 0 deletions .github/workflows/batch-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,22 @@ jobs:
run: |
& tests\selfapps_envname.ps1

- name: "Self-test: env-name sanitization (ampersand readability, CLAUDE.md Item 26)"
if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }}
env:
ENVNAME_SCENARIO: 'ampersand'
shell: pwsh
run: |
& tests\selfapps_envname.ps1

- name: "Self-test: env-name sanitization (64-char truncation bound, CodeRabbit PR #417)"
if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }}
env:
ENVNAME_SCENARIO: 'longname'
shell: pwsh
run: |
& tests\selfapps_envname.ps1

- name: "Self-test: bootstrapper size tripwire (REQ-017)"
# derived requirement (item 7 scoping pass): this step never executes run_setup.bat
# (a static byte-size check only), so !cancelled() carries zero duration-inflation risk --
Expand Down
20 changes: 0 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,26 +567,6 @@ start at 1 and has gaps.
discipline as Item 25's own deferral. Low urgency: this only affects the `cache`-lane,
non-gating `self.layered_e2e.chain` test; it does not block any lane that gates PR merges.

- **Item 26: `ENVNAME` sanitization collapses `&` (and every other non-word/non-hyphen character)
to a bare underscore in the built EXE's filename, losing readability -- owner-suggested
refinement, deliberately deferred as a far-term nice-to-have, not a defect.** `ENVNAME` (derived
from the project folder name near the top of `run_setup.bat`, right after
`:define_helper_payloads`) is already sanitized via a PowerShell regex (`-replace
'[^A-Za-z0-9_-]', '_'`) before it becomes both the conda env name and the actual built artifact
filename, `dist\%ENVNAME%.exe` -- exactly the kind of file a user might rename and email to
someone. No live bug: `&` is not in the allowed character set, so it already collapses to `_`
today, never reaching the filename raw. The owner's point (unprompted, general guidance for any
future bootstrapper output meant for user consumption, not a report of a broken case): many
tools mishandle a raw `&` in a filename (confuses it for URL query-string syntax), and Outlook
specifically renders `&`-containing filenames oddly in email -- but the CURRENT blanket
`[^A-Za-z0-9_-]` -> `_` substitution already avoids that failure mode categorically, just at the
cost of readability (a folder named `Sales & Marketing` becomes `Sales___Marketing.exe`, not
`Sales_and_Marketing.exe`). A refinement would special-case `&` -> `and` (or `_and_`) BEFORE the
general blanket substitution runs, preserving semantic meaning for that one common case while
leaving every other stripped character's behavior unchanged. Low priority, no reported real-world
friction yet -- filed here rather than implemented immediately since the underlying safety
property is already satisfied.

## Cold Storage (promising ideas, deliberately shelved -- revisit only if a named trigger fires)

Moved to `docs/agent-cold-storage.md` (2026-07-31, to reduce this file's per-session context
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ trend toward this altitude -- keep the requirement crisp and let mechanism detai
2. `pyproject.toml` `requires-python`
3. Otherwise let the **selected provider pick latest** (no hard-coded fallback); then **write back `runtime.txt`**. (When the conda provider is active, conda resolves the latest available Python from conda-forge; when the embedded-Python provider is active, it resolves to the newest entry in its pinned version table.)

- Environment naming: env name equals the **current folder name**, sanitized (characters outside `[A-Za-z0-9_-]` (e.g. spaces) become `_`, a **leading hyphen** is replaced with `_`, and a name that reduces to only separators falls back to `env`; internal hyphens like `my-app` are preserved). When the conda provider is active, this sanitized name is passed to `conda create -n`. The derived name is logged: `[INFO] Environment name: <name>`.
- Environment naming: env name equals the **current folder name**, sanitized (`&` is first special-cased to the bare word `and` for readability, e.g. `Sales & Marketing` -> `Sales_and_Marketing`; then characters outside `[A-Za-z0-9_-]` (e.g. spaces) become `_`; a **leading hyphen** is replaced with `_`; the result is **truncated to 64 characters** whenever it exceeds that length (most commonly caused by the `&`->`and` expansion, which can make the sanitized name longer than the original); and a name that reduces to only separators falls back to `env`; internal hyphens like `my-app` are preserved). When the conda provider is active, this sanitized name is passed to `conda create -n`. The derived name is logged: `[INFO] Environment name: <name>`.

- **Provider independence:** The bootstrapper cannot depend exclusively on a single provider. It must be able to function with only any one of the REQ-009 providers available (uv alone, conda alone, embedded Python alone, venv alone, or system Python alone). No bootstrap path may hard-require a specific provider to be present.
- UV is the preferred environment provider when available (cached or downloadable), as it is fast and avoids Miniconda download latency. When UV is unavailable or disabled, the bootstrapper falls back to Miniconda (conda provider); if conda also fails, it downloads a checksum-verified embeddable Python build directly from python.org (no admin rights, no pre-existing Python required); if that fails too, it falls back to a local venv built from whatever Python is already on the machine; and as a last resort runs the entry point under any available system Python. Every provider path preserves the Prime Directive -- at least one .py file runs with its imports satisfied.
Expand Down
55 changes: 55 additions & 0 deletions docs/agent-closed-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -1562,6 +1562,61 @@ this belongs to).
`--add-binary`, not yet observed for any real package in this repo's testing -- that made a live
CI trigger disproportionate effort to build for this specific gap).

### Item 26 (closed 2026-08-08)

- **`ENVNAME` sanitization collapsed `&` (and every other non-word/non-hyphen character) to a
bare underscore in the built EXE's filename, losing readability -- owner-suggested refinement,
not a defect.** `ENVNAME` (derived from the project folder name near the top of
`run_setup.bat`, right after `:define_helper_payloads`) is sanitized via a PowerShell regex
(`-replace '[^A-Za-z0-9_-]', '_'`) before it becomes both the conda env name and the actual
built artifact filename, `dist\%ENVNAME%.exe` -- exactly the kind of file a user might rename
and email to someone. No live bug: `&` was never in the allowed character set, so it already
collapsed to `_`, never reaching the filename raw -- many tools mishandle a raw `&` in a
filename (confuses it for URL query-string syntax), and Outlook specifically renders
`&`-containing filenames oddly in email, but the blanket substitution alone already avoided
that failure mode categorically, just at the cost of readability (a folder named
`Sales & Marketing` became `Sales___Marketing.exe`, not `Sales_and_Marketing.exe`).
**Fixed**: `&` is now special-cased to the bare word `and` immediately BEFORE the blanket
substitution runs (`run_setup.bat`, the `ENVNAME_SANITIZED` PowerShell one-liner) -- deliberately
a bare word with no surrounding underscores of its own, since the existing spaces on either
side of `&` are still converted to `_` by the blanket rule right after, so
`"Sales & Marketing"` -> `"Sales and Marketing"` -> `"Sales_and_Marketing"` without this
substitution needing to supply its own separators; matches the backlog's own illustrative
example exactly. Every other stripped character's behavior is unchanged.
**Test coverage**: `tests/selfapps_envname.ps1` gained a second scenario
(`ENVNAME_SCENARIO=ampersand`, `self.envname.ampersand`) alongside its existing leading-hyphen
case (`self.envname.hyphen`) -- both are real, live CI tests (not simulated), each creating a
real folder with the hazard character, running `run_setup.bat` with `HP_CI_SKIP_ENV=1` (no
conda needed, cheap), and asserting the logged `Environment name: ...` line matches the
expected sanitized form with no leading trace of the original hazard character. Wired as a
second CI step in `batch-check.yml` immediately after the existing hyphen step, same
`!cancelled()` gating (any lane, cheap, skip-env). `docs/agent-ndjson.md` updated with the new
row id.
**CodeRabbit review round on PR #417 caught 3 real follow-on gaps in this fix, all closed in the
same PR before merge:**
1. `&`->`and` is the sanitizer's first 1-to-3-char expansion (every other substitution is
1-to-1), so an ampersand-heavy folder name could make the sanitized result LONGER than the
original -- fixed with a 64-char post-substitution truncation
(`$san.Substring(0, 64).TrimEnd('_', '-')`), ample for a real project folder and well clear
of Windows/conda length limits for what this value later becomes (`ENV_PATH`,
`dist\<name>.exe`). Third scenario added (`ENVNAME_SCENARIO=longname`,
`self.envname.longname`) with an ampersand-heavy folder name that expands past 64 chars
pre-truncation, asserting both the exact truncated value AND (decoupled from the exact-match
assertion) the logged name's length is `<=64` -- proving the truncation branch actually ran,
not just that some 64-char value happened to match by chance.
2. The empty-after-truncation fallback check, `$san.Trim('_').Length -eq 0`, only trimmed
underscores -- a separator-only result like `_-` would pass the check (non-zero length after
trimming just underscores) and reach `conda create -n _-`, a name conda would likely also
reject. Fixed: `$san.Trim('_', '-').Length -eq 0`.
3. If the PowerShell sanitization command itself failed to run (missing `powershell.exe`,
execution-policy lockdown) or emitted no output, `ENVNAME_SANITIZED` stayed undefined and the
caller silently fell through to the RAW, unsanitized folder name -- defeating the whole guard
for exactly the failure mode most likely to coincide with a hostile/malformed name in the
first place. Fixed: fail closed -- `if defined ENVNAME_SANITIZED (...) else (call :log
"[WARN] ...falling back to 'env'..." & set "ENVNAME=env")`, mirroring the existing
empty-name-guard fallback used later in the file (`:env_state_check_done`'s
`if "%ENVNAME%"=="" (...) set "ENVNAME=env"`).

## Closed Backlog

- **Cascade-vs-postexec fix (Active Backlog item 9), 2026-07-25, owner-directed follow-up to a
Expand Down
71 changes: 71 additions & 0 deletions docs/agent-lessons-learned.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,77 @@ leaving stale guidance.**

---

## Quote a variable before piping it into `findstr`, or `&` in its value splits the command line

**Found via a CodeRabbit review finding on PR #417 (an "outside diff range" catch -- pre-existing
code, unrelated to that PR's own change, surfaced incidentally while reviewing nearby lines).**
The system-directory guard (near the top of `run_setup.bat`, right after the UNC-path check) did
`echo %HP_SCRIPT_ROOT%| findstr /I /C:"%WINDIR%\\" >nul` -- `HP_SCRIPT_ROOT` echoed UNQUOTED into
a pipe. cmd.exe's own command-line parser has no notion of "this `&` came from a variable, not
literal text" -- it decides whether `&` is a command separator purely from the CURRENT quote
state as it scans the line left to right, and that scan runs the SAME expansion pass that
substitutes `%HP_SCRIPT_ROOT%`. So a script dropped under a path like
`C:\Users\Sales & Marketing\run_setup.bat` would have this single line silently split into two
commands at the `&` -- `findstr` receives only the truncated prefix, and the guard can miss a
real match (or worse, run a bogus command named after whatever follows the `&`). This is a
DIFFERENT hazard from `:log`'s own "echoes UNQUOTED" entry below (that one is about `:log`'s
`echo %MSG%` misinterpreting `<`/`>`/`|`, here it's `&` splitting the command line the pipe itself
sits on) but the same family: an unquoted `%VAR%` reaching a live cmd.exe operator context.

**Fix: wrap the variable in quotes** -- `echo "%HP_SCRIPT_ROOT%"| findstr ...`. This works because
cmd.exe tracks quote state THROUGH the expansion, not around it: the literal `"` characters in the
source line toggle quote state before `%HP_SCRIPT_ROOT%` is substituted, so any `&` landing inside
the expanded value is scanned while the parser considers itself "inside quotes" and is never
treated as an operator. The one wrinkle: `echo` is one of the few cmd.exe builtins that does NOT
strip the quote characters from what it prints (unlike normal argument-parsing commands) -- so the
piped text becomes `"C:\Windows\Temp\MyApp\"` (literal quotes at both ends) instead of the bare
path. This is harmless here because `findstr /C:"..."` does a plain substring search, not an
exact-line match -- the real target text still appears in the middle of the quoted output
regardless of the extra leading/trailing `"` characters. Applied to all three system-directory
checks (`WINDIR`/`ProgramFiles`/`HP_PF86`); the existing `self.warn.sysdir` test (a plain,
non-`&` path) continues to prove the base guard still fires correctly post-fix, but does not by
itself exercise the `&`-specific scenario this fix targets -- a dedicated adversarial test
(a folder literally named with `&` under `%WINDIR%\Temp`) is a reasonable future addition, not
built here (small, defensive quoting fix outside this PR's own scope, same "fix now, note as a
candidate for future dedicated coverage" precedent already used elsewhere in this file for
review-caught correctness fixes that reuse an already-tested code shape).

---

## A multi-scenario PowerShell test's NDJSON `id` must stay a literal string at each `Write-NdjsonRow` call site, never a shared variable

**Found via a real CI failure while adding `tests/selfapps_envname.ps1`'s second scenario
(CLAUDE.md Item 26).** `tools/check_ndjson_registry.py`'s static scan of `tests/*.ps1` matches
one of four fixed textual patterns to discover which NDJSON `id`s a test file emits --
`CODE_HASHTABLE_ID_RE = re.compile(r"\bid\s*=\s*['\"]([A-Za-z0-9][A-Za-z0-9_.\-]*)['\"]")` is the
one this class of test uses. It is a plain regex over the file's TEXT, not a PowerShell parser --
it has no notion of variable assignment or control flow, so it can only ever match an `id` key
followed immediately by a quoted literal.

Refactoring a single-scenario test file into a multi-scenario one (env-var-selected, matching this
repo's own established `PYI_FAIL_SCENARIO`-style convention) naturally tempts consolidating the
per-scenario `id` into one shared variable (`$rowId = 'self.foo.bar'` in a `switch`, then
`Write-NdjsonRow ([ordered]@{ id=$rowId; ... })` at the single call site) -- this is correct,
idiomatic PowerShell and preserves the exact same runtime NDJSON output, but it silently breaks
the regex: `id=$rowId` never matches `\bid\s*=\s*['"]`, so the checker reports the id as
"registered in docs but no matching code emission site found" -- indistinguishable from a genuinely
stale/removed row. Confirmed doubly damaging in practice: not just the NEW scenario's id went
undetected, but the PRE-EXISTING scenario's id did too, even though its own emitted NDJSON content
was completely unchanged -- the regression was in the STATIC TEXT shape, not the runtime behavior.

**Fix: keep `id='literal.id.here'` as a literal at EVERY `Write-NdjsonRow` call site**, even if that
means branching on the scenario variable a second time right at the call site (`if ($scenario -eq
'x') { Write-NdjsonRow ([ordered]@{ id='self.foo.x'; ... }) } else { Write-NdjsonRow ([ordered]@{
id='self.foo.y'; ... }) }`) instead of consolidating into one call fed by a shared `$rowId`
variable. This is a small amount of duplication in exchange for staying legible to a scanner that
cannot execute the script. This check is advisory (`continue-on-error: true`, non-gating) so it
never blocks a merge on its own, but the finding is real and worth fixing on sight -- do not treat
it as noise. Verify any new multi-scenario test file against the same regex directly before
pushing: `python3 -c "import re; print(re.findall(r'\bid\s*=\s*[\'\"]([A-Za-z0-9][A-Za-z0-9_.-]*)[\'\"]', open('tests/the_file.ps1').read()))"`
and confirm every scenario's id appears.

---

## Never open a real source file in Python `'w'` mode as part of a "dry run" -- write to a NEW path and diff before overwriting

**Genuine near-miss (2026-07-25) while fixing the `HP_PREP_REQUIREMENTS` payload.** A verification
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-ndjson.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ self.entry.helper.invoke.absent, self.entry.results, self.entry.spaced-path, sel
self.entry.picker.overflow,
self.entry.req011.crossdir, self.entry.req011.sameDir, self.isolation.req010.pythonpath,
entry.single.direct, entry.expected, helper.invoke,
self.envname.hyphen, self.size.tripwire,
self.envname.hyphen, self.envname.ampersand, self.envname.longname, self.size.tripwire,
reqspec.translate.{gte,eq,compat,gt,neq,lte}, reqspec.conda.dryrun,
reqspec.conda.channelpin, reqspec.conda.dryrun.failcase,
reqspec.conda.channelpin.req006, reqspec.conda.dryrun.req006,
Expand Down
Loading
Loading