Visitar URL original
fix(entrypoints): compare import.meta.url as a URL so mjs guards fire on Windows by mogul · Pull Request #556 · GSA-TTS/agentic-coding-patterns · GitHub
Skip to content

fix(entrypoints): compare import.meta.url as a URL so mjs guards fire on Windows - #556

Open
mogul wants to merge 3 commits into
mainfrom
fix/windows-mjs-entrypoints
Open

mogul wants to merge 3 commits into
mainfrom
fix/windows-mjs-entrypoints

Conversation

@mogul

@mogul mogul commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Four .mjs entrypoints never ran main() on Windows: their guard compared import.meta.url against a file:// template of process.argv[1], which does not produce the same string there. The scripts exited 0 and wrote nothing — a silent no-op. They now compare URL against URL.

The two fixture normalizers in integrations/isolation/schemas/fixtures/ also wrote CRLF on Windows, so their output stopped matching the committed LF-only snapshots byte for byte. They now write newline="\n".

Eligibility & Accountability

  • I am a federal employee or contractor (PRs merge with @GSA-TTS/agentic-coding-team code-owner approval; eligibility is attested here, not authenticated). Anyone may open issues without this.
  • I understand this change and can explain it in my own words — I am the author of record and accountable for it.
  • By opening this PR, I attest I have the right to release this work under the repository's CC0-1.0 dedication.

AI Assistance Disclosure

  • AI tools were used in this contribution. OpenCode (deepseek-v4.1-flash) reproduced both defects on Windows, drafted the fix, and wrote the regression tests; a human reviewed the diff and the evidence below.
  • I verified all AI-generated content: no fabricated/hallucinated APIs, dependencies, citations, or placeholder content; any AI-suggested dependency was confirmed to exist.
  • Any inherited/reused third-party material retains its original license and is identified (not represented as public domain).

Type of Change

  • Bug fix
  • Infrastructure/tooling change
  • Documentation update

Motivation

Defect 1 — the guard never fires on Windows

if (import.meta.url === `file://${process.argv[1]}`) { main(); }

On Windows process.argv[1] is C:\..., so the template yields file://C:\..., while import.meta.url is file:///C:/... (three slashes, forward slashes). The two strings are never equal, main() never runs, and the process exits 0 having done nothing and written no output. A crash would be noticed; a silent exit 0 is not, which is why this needs a behavioural test rather than an exit-code check.

Verified on origin/main (7dd2ddc) on Windows 11 with Node v22.22.2:

> node integrations/isolation/acq-kits/usai-provider/scripts/normalize-catalog.mjs \
    --source integrations/providers/usai/catalog.json --out <tmp>/out.json
exit=0
file exists: False

The portable idiom already exists in this repo (integrations/isolation/acq-kits/paseo/files/home/paseo-register-mounts.mjs:262) and is now used by all four entrypoints:

process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href

Fixed in:

  • integrations/isolation/acq-kits/usai-provider/scripts/normalize-catalog.mjs
  • integrations/isolation/acq-kits/opencode/files/home/opencode-config/render-provider-config.mjs
  • integrations/isolation/acq-kits/paseo/files/home/paseo-assert-daemon-config.mjs
  • integrations/providers/usai/scripts/build-catalog.mjs

Defect 2 — fixture catalogs written with CRLF on Windows

integrations/isolation/schemas/fixtures/{usai,acme-ai}/normalize.py wrote their output with Path.write_text(...), which translates \n to the platform separator. On Windows that produced 43 CR bytes (usai) and 37 CR bytes (acme-ai) against snapshots that contain none, so the output diverged byte for byte even though the JSON was identical. Both now pass newline="\n" at the write site; no byte-exact assertion was relaxed and no snapshot was touched.

Red/green evidence

Both states measured with the same command on the same host (Windows 11, Node v22.22.2, Python 3.12.10):

Red — origin/main plus only the new test file (source fixes stashed):

$ pytest scripts/tests/test_mjs_entrypoint_guards.py -v
11 failed

test_no_source_file_uses_the_non_portable_argv1_guard FAILED
  AssertionError: non-portable entrypoint guard(s) found:
    integrations/isolation/acq-kits/opencode/files/home/opencode-config/render-provider-config.mjs:831
    integrations/isolation/acq-kits/paseo/files/home/paseo-assert-daemon-config.mjs:61
    integrations/isolation/acq-kits/usai-provider/scripts/normalize-catalog.mjs:273
    integrations/providers/usai/scripts/build-catalog.mjs:1297
test_normalize_catalog_entrypoint_writes_its_output FAILED
  AssertionError: entrypoint exited 0 without writing .../catalog.json; stderr=''
test_build_catalog_entrypoint_runs_drift_check FAILED
  AssertionError: entrypoint exited silently
test_render_provider_config_entrypoint_writes_global_config FAILED
  AssertionError: entrypoint exited 0 without rendering config; stderr=''
test_paseo_assert_daemon_config_entrypoint_fails_closed FAILED
  AssertionError: unsafe config was accepted (exit 0, stdout=''); the guard did not fire, so main() never ran
test_fixture_normalizer_emits_lf_only_bytes[usai] FAILED
test_fixture_normalizer_emits_lf_only_bytes[acme-ai] FAILED
  AssertionError: usai normalizer emitted CR bytes (CRLF line endings)

Green — this branch:

$ pytest scripts/tests/test_mjs_entrypoint_guards.py -v
11 passed in 4.32s

$ node integrations/isolation/acq-kits/usai-provider/scripts/normalize-catalog.mjs \
    --source integrations/providers/usai/catalog.json --out <tmp>/out.json
Wrote 24 normalized models to <tmp>\out.json
exit=0 exists=True

$ node integrations/providers/usai/scripts/build-catalog.mjs --check \
    --out integrations/providers/usai/catalog.json
catalog.json is up to date (24 models)
exit=0

$ node integrations/isolation/acq-kits/paseo/files/home/paseo-assert-daemon-config.mjs \
    --paseo-home <valid> --listen 127.0.0.1:6767
exit=0
$ node integrations/isolation/acq-kits/paseo/files/home/paseo-assert-daemon-config.mjs \
    --paseo-home <unsafe> --listen 127.0.0.1:6767
error: config.json missing required daemon settings: daemon.listen="127.0.0.1:1", ...
exit=1

$ python integrations/isolation/schemas/fixtures/usai/normalize.py --source ... --out ...
$ python integrations/isolation/schemas/fixtures/acme-ai/normalize.py --source ... --out ...
usai     out CR=0 LF=43 | byte-identical to snapshot: True
acme-ai  out CR=0 LF=37 | byte-identical to snapshot: True

Changes

  • fix(entrypoints) — portable main() guards in the four .mjs entrypoints, each with a comment recording the failing strings, plus the new test file.
  • fix(fixtures) — newline="\n" in both fixture normalizers, plus the LF/byte-exact regression test.
  • docs(contributing) — a short "Working on Windows" note: symlinks need Developer Mode / SeCreateSymbolicLinkPrivilege; with core.symlinks=false the tracked skills symlink arrives as a plain 14-byte file (.agents/skills) so skills/ does not materialise and a partial checkout is expected; run Node scripts through the interpreter because shebangs are not honoured; bash on PATH is often WSL, not Git Bash.

New tests (scripts/tests/test_mjs_entrypoint_guards.py, 11 cases) run on every platform — they are not Windows-skipped, because the entrypoint tests are meaningful invariants everywhere and are the actual regression detector on Windows. Each entrypoint is invoked as a script through the interpreter (node <file>, not a shebang) and must demonstrably do its work:

  • normalize-catalog.mjs writes the neutral catalog (asserted on its contents);
  • build-catalog.mjs --check reports on the committed catalog offline (a non-zero exit is accepted only with a diagnostic — silence is the failure mode being tested);
  • render-provider-config.mjs renders opencode.jsonc from a fixture provider input root;
  • paseo-assert-daemon-config.mjs rejects an unsafe config with exit 1 and a message, and accepts a safe one with exit 0;
  • a static scan fails if file://${process.argv[1]} (or its string-concat form) reappears in any .mjs/.cjs/.js/.mts/.cts file.

scripts/tests/test_provider_input_contract.py is deliberately untouched: another open PR owns that file.

Testing

Actually run on Windows 11 (native, Python 3.12.10, Node v22.22.2):

  • pytest scripts/tests/test_mjs_entrypoint_guards.py -v — 11 passed (red before the fix: 11 failed).
  • ruff check scripts — All checks passed.
  • ruff format --check scripts — 42 files already formatted.
  • python scripts/validate_repo.py — fails, for a pre-existing reason unrelated to this PR: the Cross-reference & delegation-cycle validation validator reports 17 unknown pattern '…' errors because skills/ is checked out as a 14-byte file here (core.symlinks=false, no Developer Mode), so the skills patterns never materialise. It also needs PYTHONUTF8=1 on this host to print ✗. Both conditions exist on main without this PR.
  • python scripts/validate_provider_input_contract.py is not run as evidence: it already fails on Windows on main because of an exec-bit defect owned by fix(validator): make normalizer exec-bit check Windows-aware #547, which is out of scope here.

What could not be verified locally

  • Linux and macOS. The entrypoint guard fix is host-independent by construction (it compares URL to URL), and the fixture fix makes the bytes identical on every platform, but only Windows was executed here; CI covers the Linux path.
  • Any platform without node: the entrypoint tests skip with an explicit reason instead of silently passing. CI has Node, so the class of regression is still covered there.
  • render-provider-config.mjs exits 1 on Windows after successfully rendering, because replaceAtomically calls fsync on a directory handle and Windows returns EPERM; the rendered opencode.jsonc is already in place at that point. That is a separate defect from the guard, was not introduced here, and is not fixed here — the test asserts the render happened and that no failure is silent. Worth its own issue.

Safety Checklist

  • No secrets, API keys, tokens, or passwords included
  • No PII (names, emails, SSNs, addresses)
  • No CUI (Controlled Unclassified Information)
  • No internal URLs, hostnames, or system paths
  • No customer data or proprietary information
  • Pattern defines prohibited_content in output contract (n/a — no pattern content)

Quality Checklist

  • Frontmatter includes all required fields (n/a — no pattern files touched)
  • Plain language used (Grade 10 or below preferred)
  • Examples included
  • Prerequisites clearly stated
  • Output contract defined (n/a — no pattern content)
  • Status set to experimental for new patterns (n/a — no new patterns)

Validation Results

ruff check scripts          → All checks passed!
ruff format --check scripts → 42 files already formatted
pytest scripts/tests/test_mjs_entrypoint_guards.py -v → 11 passed

python scripts/validate_repo.py (PYTHONUTF8=1)
✗ 1 validator(s) failed:
  - Cross-reference & delegation-cycle validation   # pre-existing: skills/ not materialised (core.symlinks=false)

Related Issues

Relates to #547 — see the validation comment #547 (comment). That PR fixes a different Windows defect (exec bits in scripts/validate_provider_input_contract.py); this PR does not touch that file or that validator.

Additional Context

No Co-authored-by: trailer is used; AI attribution follows the repository convention in commit bodies and the disclosure above.

AI-assisted (OpenCode [deepseek-v4.1-flash]); human review required.

mogul added 3 commits October 29, 2026 05:54
…Windows

Four `.mjs` entrypoints guarded `main()` with:

    if (import.meta.url === `file://${process.argv[1]}`) { main(); }

On Windows `process.argv[1]` is `C:\...`, so the template produces
`file://C:\...` while `import.meta.url` is `file:///C:/...` (three slashes,
forward slashes). The comparison is never true, so the script exits 0 without
running `main()` and without writing its output -- a silent no-op that no
downstream check notices.

Fixed by comparing URL against URL, the idiom already used in
`integrations/isolation/acq-kits/paseo/files/home/paseo-register-mounts.mjs`:

    process.argv[1] &&
    import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href

Affected entrypoints:

- integrations/isolation/acq-kits/usai-provider/scripts/normalize-catalog.mjs
- integrations/isolation/acq-kits/opencode/files/home/opencode-config/render-provider-config.mjs
- integrations/isolation/acq-kits/paseo/files/home/paseo-assert-daemon-config.mjs
- integrations/providers/usai/scripts/build-catalog.mjs

New `scripts/tests/test_mjs_entrypoint_guards.py` runs each entrypoint as a
script through the interpreter (`node <file>`, not a shebang) and asserts it
did the work: the normalized catalog is written, the drift check reports on
the committed catalog, the OpenCode config is rendered, the daemon-config
assertion rejects an unsafe config. A static scan fails if the
`file://${process.argv[1]}` idiom reappears in any `.mjs`/`.js` file. The
tests run on every platform; on Windows they are the regression detectors.

AI-assisted (OpenCode [deepseek-v4.1-flash]); human review required.
…indows

`integrations/isolation/schemas/fixtures/*/normalize.py` wrote their output with
`Path.write_text(...)`, which applies the platform line separator. On Windows
that produces CRLF, so the output diverged from the committed LF-only snapshot
byte for byte (43 CR bytes for `usai`, 37 for `acme-ai`) even though the JSON
was identical. Pass `newline="\n"` at the write site so the fixture emits LF on
every platform.

Only the source of the bytes changed: no byte-exact assertion was relaxed, and
`integrations/isolation/schemas/fixtures/*/snapshot.json` is untouched. The
`acme-ai` normalizer carries the identical defect and is fixed in the same
commit.

`test_fixture_normalizer_emits_lf_only_bytes` runs both normalizers with the
current interpreter and asserts the output contains no CR bytes and is
byte-identical to its snapshot. It fails on Windows before this change and
passes after; on Linux and macOS it is a cheap invariant.

AI-assisted (OpenCode [deepseek-v4.1-flash]); human review required.
Add a short "Working on Windows" note under the SBX section:

- symlink creation needs Developer Mode (`SeCreateSymbolicLinkPrivilege`) or an
  elevated shell;
- with `core.symlinks=false` the tracked `skills` symlink arrives as a plain
  14-byte file holding its target (`.agents/skills`), so `skills/` does not
  materialise as a directory and a partial checkout on Windows is expected;
- run Node scripts through the interpreter because Windows does not honour
  `#!/usr/bin/env node` shebangs;
- `bash` on `PATH` is often WSL, not Git Bash.

Factual notes only; no claim is made about local test-suite status.

AI-assisted (OpenCode [deepseek-v4.1-flash]); human review required.
@mogul
mogul requested a review from a team as a code owner October 29, 2026 12:56

@wz-gsa wz-gsa left a comment

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.

Fixed-head adversarial review of 89ab3d5f57ba0cffc92def7ca8b9ca6e97e1c873 (full eight-file diff inspected; required checks pass). Major — integrations/isolation/acq-kits/paseo/files/home/paseo-assert-daemon-config.mjs:66-71: the new URL comparison still silently skips assertDaemonConfig when invoked through a symlink: Node resolves import.meta.url to the target while process.argv[1] names the link. I reproduced this on Node 22 in a network-disabled, read-only-source container: node /tmp/assert-daemon.mjs --paseo-home /tmp/no-daemon-config --listen 127.0.0.1:6767 exited 0 without checking the nonexistent configuration. The same entrypoint guard pattern is added to normalize-catalog.mjs, render-provider-config.mjs, and build-catalog.mjs. Canonicalize the invoked file path consistently (or fail closed when the invocation cannot be recognized), and add a symlink-invocation negative test for the daemon guard. Test quality — scripts/tests/test_mjs_entrypoint_guards.py:175-186: the renderer test permits any nonzero exit after writing a file as long as stderr is nonempty; require success on supported platforms, with a narrowly matched documented Windows post-rename EPERM case if needed. The Python LF fixture changes are sound on static inspection. No PR code executed on the host; the symlink probe ran in isolation. AI-assisted review (OpenCode). Rereview a corrected head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants