Skip to content

ci: run the test suite on Windows as well as Linux - #390

Merged
Jason Robert (jrob5756) merged 14 commits into
microsoft:mainfrom
franklixuefei:ci/385-windows-test-matrix
Aug 12, 2026
Merged

ci: run the test suite on Windows as well as Linux#390
Jason Robert (jrob5756) merged 14 commits into
microsoft:mainfrom
franklixuefei:ci/385-windows-test-matrix

Conversation

@franklixuefei

Copy link
Copy Markdown
Member

Fixes#385.

The gap

CI ran ubuntu-latest only for Lint, Type Check and Test. Conductor has a substantial amount of Windows-specific code that nothing executed:

  • cli/pid.py — an entire OpenProcess / GetExitCodeProcess ctypes implementation
  • cli/bg_runner.pyCREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS, job-object breakaway and its access-denied fallback
  • sys.platform == "win32" branches in cli/app.py and cli/update.py

Three bugs found in one week of Windows use all reached a release: #342 (JSON truncation via a if WINDOWS chunked-write path), #344 (conductor stop orphaning runs because CTRL_BREAK_EVENT needs a shared console), #382 (MarkupError through the console path).

The gap is bidirectional, which is the part I would stress

It is tempting to read this as "Linux CI can't catch Windows bugs". It is worse than that — Linux-only CI also lets Windows-passing tests hide Linux breakage, because the existing tests fake the platform.

Both directions bit me while fixing #344:

  • A test I wrote patched conductor.cli.app.os.kill. os is a single shared module object, so on POSIX that also patched cli/pid.py's liveness probe and turned a DEAD result into UNKNOWN. It passed on Windows and would have failed on Ubuntu CI. Caught by review, not by a test run.
  • Conversely, _liveness_windows is covered everywhere by monkey-patching a _kernel32 mock. That is deliberate and good — the logic is exercised on every platform — but it means nothing validates the real ctypes argtypes/restype. An ABI mistake would be CI-green and fail only on a user's machine.

So the current setup cannot catch either direction, and the mocked-platform tests give a false sense of coverage for the one platform where the code actually differs.

The change

Windows added to the test matrix on one Python version rather than a full 2×2:

matrix:
os: [ubuntu-latest]python-version: ["3.12", "3.13"]include:
- os: windows-latestpython-version: "3.13"

The platform is the variable here, not the interpreter — the Windows-specific code is not version-sensitive. And the Install Scripts job already provisions a Windows runner on every PR (its own comment says it runs there because "the failure modes actually live" there), so this is incremental cost on infrastructure that is already paid for, not a new platform commitment.

Two things this required beyond the matrix:

  • The "Remove bundled Copilot CLI binary" step used find, which is not a native Windows command — the step would have failed outright. It now runs under shell: bash (Git Bash is preinstalled on the runner) and also matches the Windows venv layout, where scripts live under Scripts/ and the binary carries a .exe suffix.
  • Coverage upload is now gated on ubuntu-latest as well as 3.12, so a second runner cannot upload a partial report for the same commit and skew the totals.

I expect the first Windows run to be red

I would rather say that up front than have it look like a surprise. Likely causes, in order:

  1. Tests that fake a platform.patch("conductor.cli.pid.sys.platform", "win32") and the _kernel32 mocks are written to run on Linux; some will double up confusingly on real Windows. There is already a correct pattern for this in the repo — TestIsProcessAliveWindowsReal guards with skipif.
  2. Path and encoding assumptions. Tests asserting on /tmp/... string literals, and anything comparing rendered console output.

I am happy to triage that first red run and push the fixes onto this branch — I have a Windows environment and have already been through these modules for #381, #383 and #387. Equally happy for you to prefer a narrower first step (e.g. Windows restricted to tests/test_cli/test_pid.py, test_stop.py, test_bg_runner.py, where essentially all the platform-specific code lives) — say the word and I will rework it.

Related

#342 / #381, #344 / #383, #382 / #387 — every one of them is a bug this job would plausibly have caught before release.

CI ran ubuntu-latest only. Conductor has a substantial amount of Windows-
specific code that nothing executed: cli/pid.py is an entire
OpenProcess/GetExitCodeProcess ctypes implementation, cli/bg_runner.py handles
CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS and job-object breakaway, and there
are sys.platform branches in cli/app.py and cli/update.py.
Three bugs found in one week of Windows use all reached a release: microsoft#342 (JSON
truncation via a WINDOWS-only chunked-write path), microsoft#344 (conductor stop
orphaning runs because CTRL_BREAK_EVENT needs a shared console) and microsoft#382
(MarkupError through the console path).
The gap is bidirectional, which is the part worth stressing. While fixing microsoft#344 I
wrote a test that patched conductor.cli.app.os.kill; because os is one shared
module object, on POSIX that also patched pid.py's liveness probe and turned
DEAD into UNKNOWN. It passed on Windows and would have failed on Ubuntu. In the
other direction, the Windows liveness logic is covered everywhere by patching a
_kernel32 mock -- good design, but it means no job anywhere validates the real
ctypes argtypes/restype, so an ABI mistake would be CI-green and fail only for
users.
Deliberately ONE Python version on Windows rather than a full 2x2: the platform
is the variable, not the interpreter, and the install-scripts job already
provisions a Windows runner on every PR, so this is incremental cost rather than
a new platform commitment.
Two things this required beyond the matrix:
- The 'Remove bundled Copilot CLI binary' step used ind, which is not a
native Windows command. It now runs under shell: bash (Git Bash is
preinstalled on the runner) and also matches the Windows venv layout, where
scripts live under Scripts/ and the binary carries a .exe suffix.
- Coverage upload is now gated on ubuntu-latest as well as 3.12, so a second
runner cannot upload a partial report for the same commit and skew the totals.
I expect the first Windows run to surface failures rather than be green --
tests that fake a platform via sys.platform, and any assertions on POSIX-shaped
paths or rendered console output, are the likely candidates. Better to see them
than to keep not knowing.
Fixesmicrosoft#385
@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.00000% with 6 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@0ad105c). Learn more about missing BASE report.

Files with missing linesPatch %Lines
src/conductor/registry/config.py78.57%6 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #390 +/- ##
=======================================
Coverage ? 91.78% =======================================
Files ? 112 Lines ? 18278 Branches ? 0 =======================================
Hits ? 16777 Misses ? 1501 Partials ? 0 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@franklixuefei

Copy link
Copy Markdown
MemberAuthor

First Windows run: 30 failed, 5,076 passed (99.4%)

Better than I expected, and more useful than a green run would have been — every failure is a real portability bug that was invisible before.

All 30 are test-level assumptions, not conductor defects. I checked each class against the product code; nothing here is a bug a user would hit.

filecountclass
test_registry/test_integration.py10path separators
test_mcp/test_manager_truncation.py5POSIX file modes
test_skills/test_path_entries.py3path separators
test_providers/test_claude_agent_sdk.py2path separators
test_providers/test_pydantic_ai_mcp.py2path separators
test_skills/test_registry.py2path separators
6 others1 eachmixed

Four distinct causes, all mechanical:

1. Hardcoded POSIX separators (~20 of 30)

assert parsed["data"]["path"] == "/some/path"
E AssertionError: assert '\\some\\path' == '/some/path'

The product normalises correctly; the test compares against a literal.

2. POSIX file modes (5, all in test_manager_truncation.py)

assert stat.S_IMODE(spill_file.stat().st_mode) == 0o600
E AssertionError: assert 438 == 384

0o600 is not expressible on Windows — chmod there sets only the read-only bit, so the mode reads back 0o666. The spill file is created correctly; the permission assertion simply cannot hold. Needs a skipif, or an assertion on POSIX only.

3. Line endings (1)

assert output.stdout == "from-args\nfrom-stdin"
E AssertionError: assert 'from-args\r\nfrom-stdin' == ...

4. POSIX-shaped fixture paths (1)

ExecutionError: Agent 'worker': working_dir 'C:\Users\runneradmin\proj' does not exist

The test builds a path the product then correctly rejects.

How would you like this split?

I do not think 30 test fixes belong in the same PR as "add a CI job" — it makes the review much harder and mixes two concerns. Three options, and I am happy with any:

  1. Land the job as continue-on-error: true now, fix the 30 in a follow-up, then flip it to blocking. Gets the signal immediately without gating anyone, at the risk that an advisory job gets ignored.
  2. Hold this PR, I push the 30 fixes onto this branch, and it lands green and blocking in one go.
  3. Narrow the first step to the platform-sensitive modules (test_cli/test_pid.py, test_stop.py, test_bg_runner.py) which are already green here, and widen once the rest are fixed.

My preference is 2 — the fixes are mechanical and a blocking job from day one is worth more than an advisory one — but it is your call on review load, and 1 is the lower-risk way to start.

One thing worth noting either way: the platform-specific modules this job exists for — test_pid.py, test_stop.py, test_bg_runner.py, test_stop_ladder.pyall passed on Windows. So the ctypes layer is genuinely sound; the failures are entirely in tests that were written POSIX-first.

The Windows CI job added in the previous commit found a real product defect on its
first run, alongside 29 test-level POSIX assumptions.
Product fix — `_format_toml` built TOML by interpolating values straight into *basic*
strings, which process backslash escapes. A `path` registry whose source is a Windows
path (`C:\Users\alice\...`) therefore serialised `\U`, which TOML reads as a Unicode
escape and rejects with "Invalid hex value" — so `conductor registry add` wrote a
`registries.toml` that could never be read back. Not Windows-only: any source
containing `\` or `"` corrupted the file on every platform. Values are now escaped per
the TOML v1.0 spec. This single fix cleared all 10 `test_registry/test_integration.py`
failures.
Regression coverage: the existing round-trip test used `/dev/workflows`, a POSIX path
with nothing to escape, which is why this was never caught. Added a round-trip over
backslashes plus a parametrised case for each TOML metacharacter — `\U` (invalid hex),
`\t`/`\n` (silent corruption rather than an error), `\x`, an embedded quote, and a
trailing backslash. Verified they fail without the fix: reverting `_toml_escape`
turns all 6 red and nothing else.
Test-level fixes, by cause:
- POSIX permission bits (`test_manager_truncation`) — `stat.S_IMODE` reports 0o666/0o777
on Windows whatever mode was requested. The mode assertions are now POSIX-guarded;
the surrounding behaviour still runs everywhere.
- `test_spill_os_error_falls_back_to_marker_without_path` simulated failure with
`mkdir(mode=0o500)`, a no-op on Windows, so the spill silently succeeded and produced
the wrong marker. It now patches `os.open` to raise, exercising the same
`except (OSError, ValueError)` branch deterministically on every platform — better
coverage than the permission side-effect it replaces.
- Path separators — `.copilot/skills`, `.hermes/profiles/chloe` and the spill marker
were compared against literal forward slashes. The discovery warning embeds `repr()`
of the search list, which doubles backslashes on Windows, so that assertion accounts
for both forms rather than being weakened.
- `Path("/plug")` and `Path("/skills/acme")` are absolute on POSIX but not on Windows,
which needs a drive. Anchored to the current drive root via a helper.
- `~` expansion — Windows resolves `~` from `USERPROFILE`, not `HOME`, so setting `HOME`
alone left `expanduser()` pointing at the real profile.
- Line endings — the child's text-mode stdout emits `\r\n`; asserted via `splitlines()`.
- `chmod(0o000)` blocks neither a Windows owner nor root; those tests now skip on
Windows using the same idiom already present in `test_discovery.py`.
- `Skill.md` vs `SKILL.md` cannot express a mis-cased filename on a case-insensitive
filesystem, so that scenario skips on Windows.
Verified on Windows: 4,717 passed. The 3 remaining failures (`test_aca_runner`,
`test_agent_iteration_limits`) reproduce identically with this commit's production
change reverted, so they are pre-existing and environmental, not introduced here.
`ruff check` and `ruff format --check` are clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ba2dfe2e-2c85-4d70-88f9-a604a700a0c4
@franklixuefei

Copy link
Copy Markdown
MemberAuthor

Correction, and a result: the Windows job found a product bug on its first run

When I opened this I reported the first Windows run as "30 failures, all test-level POSIX
assumptions, zero product defects."
That was wrong, and I'd rather correct it here than let it
stand.

Ten of the thirty were one real defect in conductor/registry/config.py.

The bug

_format_toml built TOML by interpolating values directly into basic strings:

lines.append(f'source = "{entry.source}"')

TOML basic strings process backslash escapes. A path registry pointing at a Windows directory
therefore serialises as:

source = "C:\Users\alice\AppData\Local\workflows"

\U is a Unicode escape, so reading it back fails:

RegistryError: Failed to parse ...registries.toml: Invalid hex value (at line 5, column 15)

conductor registry add writes a config file that can never be read back — every subsequent
registry command on that machine fails.

This is not Windows-only. It is only reliably reachable on Windows because paths there start
with a drive and contain backslashes. On Linux, any source containing \ or " corrupts the file
the same way, and \t/\n corrupt it silently rather than erroring, which is worse.

Fixed by escaping per the TOML v1.0 spec.

Why the existing tests missed it

The round-trip test used a POSIX path with nothing to escape:

"dev": RegistryEntry(type=RegistryType.path, source="/dev/workflows"),

Added a backslash round-trip plus a parametrised case per metacharacter: \U (invalid hex),
\t/\n (silent corruption), \x, an embedded quote, and a trailing backslash. I verified these
actually bite — reverting _toml_escape turns exactly those 6 red and nothing else.

The other 29

Genuinely test-level, grouped by cause in the commit message: POSIX mode bits (S_IMODE reports
0o666 on Windows regardless), path separators, ~ resolving from USERPROFILE rather than HOME,
\r\n, Path("/x") not being absolute without a drive, chmod(0o000) blocking neither a Windows
owner nor root, and a case-insensitive filesystem making a mis-cased Skill.md unreachable.

One of these is now a better test on both platforms:
test_spill_os_error_falls_back_to_marker_without_path simulated failure with mkdir(mode=0o500),
which is a no-op on Windows — the spill quietly succeeded and produced the wrong marker. It now
patches os.open to raise, exercising the same except (OSError, ValueError) branch
deterministically everywhere, instead of depending on a permission side-effect.

On the earlier question about how to split this

I asked which of three split options you'd prefer, including landing the job continue-on-error.
Withdrawing that: a non-blocking CI job is one nobody looks at, which defeats the point of #385. The
job stays blocking and the suite is now green under it.

Verification

Run locally on Windows (Python 3.14): 4,717 passed. The 3 remaining failures
(test_aca_runner ×2, test_agent_iteration_limits ×1) reproduce identically with this commit's
production change reverted
, so they are pre-existing and environmental rather than introduced
here. ruff check and ruff format --check clean.

Happy to split the product fix into its own PR ahead of the CI change if you'd prefer them
separate — though they are coupled in the sense that the job cannot go green without the fix.

xuefl-msftand others added 2 commits August 7, 2026 23:09
…obing POSIX paths there
`validate_connection` carries a comment saying to keep it in sync with the SDK's
`_find_cli`. It had drifted, in two ways — both surfaced by the new Windows CI job.
1. The bundled-CLI probe only looked for `_bundled/claude`. The SDK names that binary
per-platform (`claude.exe` on Windows, `claude` elsewhere — see `_find_bundled_cli`),
so on Windows conductor reported "Claude CLI not found" even with the bundled binary
present and usable. Also probes `claude.exe` via `shutil.which`, since PATH may hold
npm's `claude.cmd` shim while the native executable lives elsewhere.
2. The hardcoded fallback list probed POSIX paths on every platform. `Path("/usr/local/bin/claude")`
is rooted but driveless, so on Windows it resolves against the current drive —
`C:\usr\local\bin\claude` — a location any unprivileged local user can create. A planted
file there would make conductor report the CLI as available. The SDK refuses these on
Windows for exactly this reason, describing it as a binary-planting probe; conductor now
matches, probing only `~/.local/bin/claude.exe` there.
The remaining test fixes follow the same patterns as the previous commit: POSIX mode
assertions guarded (`S_IMODE` reports 0o666 on Windows whatever mode was requested), and
the explicit-spill-dir assertion compares against the *resolved* directory, since the
manager resolves before writing and `resolve()` also expands 8.3 short components on
Windows.
Windows CI went 30 failures -> 4 with the previous commit; these are those 4.
Verified locally on Windows: 4,716 passed. The 3 pre-existing failures (`test_aca_runner`
x2, `test_agent_iteration_limits`) reproduce with the production change reverted;
`test_performance` is `pytestmark = pytest.mark.performance` and is deselected in CI.
`ruff check` and `ruff format --check` clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ba2dfe2e-2c85-4d70-88f9-a604a700a0c4
result.output is a JSON document, so a Windows path inside it carries JSON-escaped
backslashes (custom-spill\\\\mcp-...). Comparing the raw path could therefore never
match on Windows. Accept either encoding rather than weakening the assertion to a
loose substring.
Last of the 30 Windows failures: the job now runs 5,106 tests green.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ba2dfe2e-2c85-4d70-88f9-a604a700a0c4
@franklixuefei

Copy link
Copy Markdown
MemberAuthor

✅ Green — 10/10, Windows included

Test (Python 3.13, windows-latest): 5,106 passed, 45 skipped, 0 failed. The job is
blocking, not continue-on-error.

The 30 → 0 walk, and what it cost to get there:

2 product defectsTOML escaping (10 failures), bundled-CLI detection on Windows (1)
19 test-levelPOSIX mode bits, separators, \r\n, USERPROFILE vs HOME, driveless roots, chmod(0o000), case-insensitive FS, JSON-escaped paths
1 test made strongerpermission-based failure simulation → deterministic os.open patch

The second product defect

validate_connection carries a comment saying to keep it in sync with the SDK's _find_cli. It had
drifted twice:

  • The bundled probe looked only for _bundled/claude. The SDK names it per-platform —
    cli_name = "claude.exe" if platform.system() == "Windows" else "claude" — so conductor reported
    "Claude CLI not found" on Windows with the bundled binary sitting right there.
  • The fallback list probed POSIX paths on every platform. Path("/usr/local/bin/claude") is rooted
    but driveless, so on Windows it resolves against the current drive — C:\usr\local\bin\claude
    — a path any unprivileged local user can create. A planted file there would make conductor report
    the CLI as available. The SDK refuses these on Windows and names the reason in its own source: a
    binary-planting probe
    . Conductor now matches, probing only ~/.local/bin/claude.exe.

Scope note: conductor uses this for a boolean availability check, not to select what it spawns — so
this is a false-positive signal rather than direct execution. Worth fixing regardless, and worth not
diverging from the SDK on a security-shaped decision it made deliberately.

On being wrong

I opened this PR asserting "all 30 are test-level POSIX assumptions, zero product defects." Two of
them were product defects, one security-shaped. Thirty failures was too many to have actually checked
before saying that, and the confident phrasing is what made it worth correcting rather than quietly
amending.

That is also the argument for the job existing: it found two real bugs on its first two runs, in code
paths that ship to Windows users today and were invisible to a Linux-only matrix.

Verification

Local Windows run (Python 3.14): 4,716 passed. Three failures (test_aca_runner ×2,
test_agent_iteration_limits) reproduce identically with the production changes reverted — I
checked rather than inferred it from "those files look unrelated". test_performance is
pytestmark = pytest.mark.performance and is deselected in CI. ruff check / ruff format --check
clean. Regression tests for the TOML fix were verified to fail on revert — all 6, and nothing else.

Ready for review. Happy to split the two product fixes into their own PR ahead of the CI change if
you'd rather review them separately — though the job cannot go green without them, so they'd need to
land first either way.

@jrob5756Jason Robert (jrob5756) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for doing the triage and digging in! The Windows leg earns its place: it found a real cross-platform bug in _format_toml on its first run, and it makes TestIsProcessAliveWindowsReal execute for the first time since it was written.

Most of my comments are about the two production fixes that came along for the ride, not the CI change itself

The one I can't leave inline: neither production fix has a CHANGELOG entry. The registry TOML bug hit every platform, not just Windows, so it belongs under Unreleased > Fixed alongside the Claude CLI detection change.

I pulled both claude-agent-sdk wheels to check the SDK claims. Details in the comment on claude_agent_sdk.py.

# driveless path such as "/usr/local/bin/claude" resolves against the current
# drive (C:\usr\local\bin\claude) — a location any unprivileged local user can
# create — so probing it would let a planted file report the CLI as available.
# The SDK refuses these on Windows for exactly this reason.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I pulled both wheels to check this. claude-agent-sdk 0.2.87, which is what uv.lock pins and what CI installs, has no platform branch in _find_cli at all. It probes all six POSIX paths on every OS, Windows included. The branch you're mirroring shows up much later: 0.2.134 has it, with almost this exact binary-planting wording.

Two consequences. The comment tells a future maintainer the hole is closed upstream, and at the pinned version it isn't, since the SDK still probes and still spawns a planted C:\usr\local\bin\claude. And on Windows, a user whose CLI sits at ~/.claude/local/claude (where Claude Code's local installer puts it) now gets validate_connection() == False even though the SDK would find and run it. What they see is "Failed to connect... Check your credentials and network connection", plus a suggestion to npm-install a CLI they already have.

Worth noting only /usr/local/bin/claude is driveless. The other five are Path.home()-anchored, so the planting argument doesn't cover them. Dropping just that one entry on Windows would give you the hardening without the false negative.

If you keep the narrowing, name the version you audited. This file already does that well at line 1274 ("audited against claude-agent-sdk 0.2.87").

Suggested change
# The SDK refuses these on Windows for exactly this reason.
# Note this narrows Conductor's report only: claude-agent-sdk 0.2.87 (the
# locked version) still probes these unconditionally in _find_cli, so a
# planted binary can be spawned even when this returns False. Later SDKs
# (>= 0.2.13x) refuse them; this matches that behaviour ahead of the pin.

return True
# On Windows, PATH may hold npm's claude.cmd shim while the native claude.exe
# lives elsewhere; the SDK probes the .exe name explicitly for the same reason.
if is_windows and shutil.which("claude.exe"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Line 805 returns first here. On Windows shutil.which("claude") already applies PATHEXT, so a claude.exe on PATH gets found there and this never runs.

0.2.134 makes the equivalent probe reachable by vetting the hit with _is_windows_native_exe first, so an npm claude.cmd shim doesn't short-circuit it. Without that vetting the branch is dead. Either port the vetting or drop the branch and its comment.

from pathlib import Path

# Bundled CLI takes precedence (matches the SDK's own resolution).
is_windows = platform.system() == "Windows"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every other Windows check in src/ uses sys.platform == "win32". There are 11 of them, and cli/pid.py:44 calls it out as the house idiom.

Beyond consistency it costs testability: the repo fakes Windows by patching sys.platform (test_script.py:472 does exactly this), and nothing patches platform.system. Switching would let you exercise the new branch from Linux.

Needs import sys at module scope, which this file doesn't currently have.

Suggested change
is_windows=platform.system()=="Windows"
is_windows=sys.platform=="win32"

Comment threadsrc/conductor/registry/config.py Outdated

for name, entry in config.registries.items():
lines.append("")
lines.append(f"[registries.{name}]")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The values are escaped now, but the table key still isn't, and _validate_registry_name only rejects / and \. I ran it: my.team, has"quote, with space, a=b and a#b all write successfully and then never load.

That's a worse version of the bug you're fixing, because add_registry, remove_registry and get_registry all call load_config() first. Once the file is written the user can't remove the entry that broke it, and unrelated registries go down with it. conductor registry add my.team reports success.

Names also become cache directory names (registry/cache.py:93), and ", :, *, ? are illegal in Windows filenames, so a charset check at write time covers both problems. Something like ^[A-Za-z0-9._-]+$ in _validate_registry_name would subsume the existing / and \ cases.

If you'd rather escape than reject:

Suggested change
lines.append(f"[registries.{name}]")
lines.append(f'[registries."{_toml_escape(name)}"]')

@patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock)
async def test_validate_connection_returns_true_when_bundled_cli_present(self) -> None:
"""The SDK ships a bundled CLI under _bundled/claude — detection should succeed."""
"""The SDK ships a bundled CLI under ``_bundled/`` — detection should succeed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The new Windows branch in validate_connection has no test behind it. On Linux is_windows is always False, so it's dead code. On the Windows runner the bundled claude.exe ships in the wheel, so the probe returns True at line 801 and the fallback block never runs there either. You could delete the whole branch and all three CI legs stay green.

It's testable from Linux, since platform is imported inside the function:

withpatch("platform.system", return_value="Windows"):
...

Asserting the probe set per platform would have caught the SDK version mismatch while you were writing it, because you'd have had to put ~/.claude/local/claude -> False in the test as a literal expectation.

assert spill_file.exists()
assert spill_file.read_text() == full_text
assert stat.S_IMODE(spill_file.stat().st_mode) == 0o600
if _POSIX_PERMS:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This reports PASSED on Windows having asserted nothing about the mode of a file that holds raw tool output. Same shape at line 263, at test_pydantic_ai_mcp.py:349 and :411, and at test_claude_agent_sdk.py:1826, where the file holds resolved env values and Authorization headers.

You already have the honest mechanism a few lines up. Splitting the mode assertion into its own @requires_posix_perms test makes the report say SKIPPED rather than PASSED, which is the difference between a known gap and an invisible one.

used to turn that same mistake into silence — one fewer skill, no message.
"""

@pytest.mark.skipif(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The reason is accurate, but it describes the fixture rather than the behaviour. expand_skills_root reports any immediate subdirectory without a SKILL.md; the mis-cased filename is only one way to trigger it. A file that isn't a skill manifest keeps the assertion running everywhere:

(root/"oops"/"notes.md").write_text("a file that is not SKILL.md")

This one matters more than most, because mis-casing SKILL.md is a Windows-authoring mistake specifically: it works locally and then vanishes for Linux teammates. Windows is where you'd most want the diagnostic covered. Keeping the mis-cased variant as a separate POSIX-only test would preserve both.

Comment thread.github/workflows/ci.yml Outdated
Comment on lines +137 to +138
find .venv -path '*/copilot/bin/copilot*' -delete 2>/dev/null || true
find .venv -iname 'copilot.exe' -delete 2>/dev/null || true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The binary ships inside the package on both platforms (copilot/bin/copilot on Linux, copilot/bin/copilot.exe in the win_amd64 wheel), and there's no console-script entry point, so it never lands in Scripts/. The pattern above already matches the Windows layout: the leading */ absorbs Lib/site-packages/ and the trailing * absorbs .exe. I checked against both wheels.

So line 138 is an unanchored -iname sweep of the whole venv that deletes nothing the line above missed.

Separately, find exits 0 when it matches nothing, and 2>/dev/null || true hides -delete failures, which are likelier on Windows against a read-only file. Either way the binary survives, tests taking the real CLI path hang, and the job dies at the 10-minute timeout with an error naming neither the CLI nor this step.

Suggested change
find .venv -path '*/copilot/bin/copilot*' -delete 2>/dev/null || true
find .venv -iname 'copilot.exe' -delete 2>/dev/null || true
found=$(find .venv -path '*/copilot/bin/copilot*' -print -delete)
if [ -z "$found" ]; then
echo "::error::No bundled Copilot CLI matched. The SDK layout changed; tests taking the real CLI path will hang until the job timeout."
exit 1
fi
echo "Removed: $found"

Comment thread.github/workflows/ci.yml Outdated
Comment on lines +128 to +129
# is preinstalled). Without it the step fails on Windows, since `find`
# is not a native command there.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

find.exe ships in System32, so a bare find does resolve to a real binary under cmd or pwsh. It's the legacy DOS search tool with unrelated semantics. shell: bash is still the right fix, but "not a native command" would send someone hunting for a missing tool rather than a colliding one.

While you're in here, the header block at line 9 still describes the test job as "Python 3.12 and 3.13" with no platform, and it does name platforms for install-scripts further down.

Suggested change
# is preinstalled). Without it the step fails on Windows, since `find`
#is not a native command there.
# is preinstalled). Without it the step fails on Windows, where a bare
#`find` resolves to the unrelated DOS search tool in System32.

Addresses the review on microsoft#390.
The SDK claim was wrong, and I verified it against the wheels rather than
the changelog. claude-agent-sdk 0.2.87 -- the version uv.lock pins and CI
installs -- has no platform branch in _find_cli at all: it probes all six
POSIX paths on every OS, Windows included. The comment told a future
maintainer the hole was closed upstream when at the pinned version it is not.
Worse, the narrowing caused a false negative. Only "/usr/local/bin/claude"
is driveless; the other five are Path.home()-anchored, so dropping them made
validate_connection() return False on Windows for a CLI at
~/.claude/local/claude -- where Claude Code's own local installer puts it,
and which the SDK would find and run. The user saw "Failed to connect ...
check your credentials" plus advice to npm-install a CLI they already had.
Now only the driveless entry is skipped, the comment names the audited
version, and it says plainly that this narrows conductor's report only.
Dropped the claude.exe branch. shutil.which applies PATHEXT on Windows, so a
claude.exe on PATH is already found by the line above and the branch was
dead. 0.2.134 makes the equivalent probe reachable by vetting the hit first;
there is nothing to port from 0.2.87.
platform.system() -> sys.platform == "win32", the house idiom (11 other
sites; cli/pid.py calls it out). That is what makes the branch testable from
Linux, since the repo fakes Windows by patching sys.platform and nothing
patches platform.system.
Registry names could still corrupt the config. Escaping the values left the
table key unescaped and the charset unchecked, so 'has"quote', 'with space',
'a=b' and 'a#b' all wrote successfully and then failed to parse -- and
because add_registry, remove_registry and get_registry all load first, the
user could not remove the entry that broke it and unrelated registries went
down with it. Names are now restricted to [A-Za-z0-9._-], which also keeps
them legal as cache directory names on Windows.
The charset check alone was not enough: the key is written unquoted, so
"my.team" -- which the suggested regex allows -- became [registries.my.team],
a nested table, and the entry silently vanished on load rather than failing
loudly. The key is quoted now as well, and a round-trip test pins it.
Tests. The Windows branch had none: on Linux is_windows is always False, and
on the Windows runner the bundled claude.exe short-circuits before the
fallbacks, so the block ran on no CI leg and could have been deleted with all
three staying green. It now asserts the probe *set* per platform, which is
what forces ~/.claude/local/claude to be written down as a literal
expectation. Registry names get charset and round-trip coverage.
CHANGELOG entries added for both production fixes; the PR had none.
2,152 passed across test_registry, test_providers, test_cli and
test_executor. ruff check and `ty check src` both unchanged from this
branch's baseline (2 and 68).
@franklixuefei

Copy link
Copy Markdown
MemberAuthor

Thanks — the SDK finding was the important one and it was right. Verified against the wheels rather than release notes, then fixed in 2b8c26f.

The SDK claim, confirmed

Pulled 0.2.87 and read _internal/transport/subprocess_cli.py. No platform branch in _find_cli at all — it probes all six POSIX paths on every OS:

ifcli:=shutil.which("claude"):
returnclilocations= [
Path.home() /".npm-global/bin/claude",
Path("/usr/local/bin/claude"),
Path.home() /".local/bin/claude",
Path.home() /"node_modules/.bin/claude",
Path.home() /".yarn/bin/claude",
Path.home() /".claude/local/claude",
]

So the comment told a future maintainer the hole was closed upstream when at the pinned version it is not. And your second consequence is the one that would have hurt: only /usr/local/bin/claude is driveless, so dropping the other five made validate_connection() return False on Windows for a CLI at ~/.claude/local/claude — where Claude Code's own installer puts it, and which the SDK would find and run.

Now only the driveless entry is skipped on Windows, the comment names the audited version as you suggested, and it says plainly that this narrows conductor's report only.

(0.2.134 is not on our package proxy — latest available is 0.2.132 — so I confirmed the 0.2.87 half directly and took your reading of the later one.)

The other two on that file

  • claude.exe branch dropped. You are right that shutil.which applies PATHEXT, so the line above already finds it and the branch was dead. There is nothing to port from 0.2.87, which has no vetting.
  • platform.system()sys.platform == "win32". House idiom, and it is what makes the branch testable from Linux — the repo fakes Windows by patching sys.platform and nothing patches platform.system.

Registry names — worse than described, and the suggested fix alone was not enough

Reproduced all five. But I found the charset check by itself would not have closed it: the key is written unquoted at config.py:178, so my.team — which ^[A-Za-z0-9._-]+$ allows — becomes [registries.my.team], a nested table. The entry then vanishes silently on load rather than failing loudly, which is arguably worse than the parse error.

So I did both of your suggestions: quote the key and restrict the charset. A round-trip test pins that a dotted name stays one registry key.

Tests

You were right that the Windows branch had no test behind it, and about why: on Linux is_windows is always False, and on the Windows runner the bundled claude.exe short-circuits before the fallbacks — so it ran on no CI leg and could have been deleted with all three staying green.

It now asserts the probe set per platform, which is exactly the thing that forces ~/.claude/local/claude to be written down as a literal expectation.

One honest caveat on discrimination: those two tests fail against the pre-fix code, but partly for a mechanical reason — the old code used platform.system(), so patching sys.platform finds no attribute. They pin the corrected behaviour going forward rather than cleanly isolating the old bug.

CHANGELOG

Added for both production fixes, as you asked — the registry TOML escaping and the Claude CLI detection change, plus the name-validation fix.

Not done here

The skipped-on-Windows file-mode tests (test_manager_truncation.py:83 and :263, test_pydantic_ai_mcp.py:349/:411, test_claude_agent_sdk.py:1826) report PASSED having asserted nothing about files that hold tool output and Authorization headers. That is a real gap and I agree it should not stay, but it is five call sites across three files and unrelated to the CI matrix this PR adds — happy to take it as a follow-up if you would rather keep this PR to its subject.

Gates

2,152 passed across test_registry, test_providers, test_cli and test_executor. ruff check and ty check src both unchanged from this branch's baseline (2 and 68).

@franklixuefei

Copy link
Copy Markdown
MemberAuthor

CI update: the Windows leg is now failing, and it is doing its job.

Merged main (631dda9). Test (Python 3.13, windows-latest) fails on 5 tests in tests/test_plugins/test_fetch.py — code that arrived from main with the plugin work (#378/#394), not from this PR.

Confirmed pre-existing rather than assumed. I checked out plain origin/main with no PR changes on a Windows box and ran the same file:

FAILED tests/test_plugins/test_fetch.py::TestPinning::test_pinned_source_resolves_without_the_remote
FAILED tests/test_plugins/test_fetch.py::TestOfflineFallback::test_warns_and_reuses_the_cached_checkout
FAILED tests/test_plugins/test_fetch.py::TestOfflineFallback::test_the_ref_pointer_is_what_makes_the_fallback_possible
FAILED tests/test_plugins/test_fetch.py::TestReadiness::test_a_checkout_without_its_sentinel_is_not_cached
FAILED tests/test_plugins/test_fetch.py::TestMultipleSourceFailures::test_a_duplicated_source_is_fetched_once
5 failed, 35 passed

Identical set, identical on this branch. Nothing in this PR touches src/conductor/plugins/.

There appear to be two distinct problems:

  1. PermissionError: [WinError 5] during teardownshutil.rmtree on source-repo\.git\objects\3a\.... Git marks object files read-only, and on Windows rmtree cannot unlink a read-only file. The two rmtree calls inside plugins/fetch.py pass ignore_errors=True, so this is the test fixture's own cleanup, not production code.
  2. PluginSourceError from fetch.py:671 — a fetch failure that surfaces before teardown, so at least some of these are not merely a cleanup artifact.

That is the third real cross-platform bug this leg has found, after _format_toml and the Claude CLI probe — which is a decent argument for the leg itself.

How would you like to sequence it? Three options as I see them:

  • Fix it here, and accept that this PR grows a src/conductor/plugins change it did not set out to make.
  • Fix it in a separate PR that lands first, and rebase this one onto it — my preference, since the fix belongs with the plugin code and its author has the context.
  • Land this with the Windows leg continue-on-error: true and a follow-up issue to remove that, so the matrix is in place while the plugin bug is fixed. Cheapest, but a soft gate has a habit of staying soft.

Happy to take the first or second if you would rather not context-switch; say which and I will do it. I have not guessed, because all three are defensible and it is your PR's scope to spend.

Everything else on this PR is green: Lint, Type Check, both Ubuntu legs, Frontend, both Install Scripts legs, and Validate Example Workflows.

The Windows CI leg this PR adds went red on 7 tests. All of them are real
platform bugs in \conductor\, not test artefacts, so they are fixed here
rather than skipped -- a CI leg that has to be read past is worse than none.
Three defects, all reachable only from a \ile://\ source:
1. The cache key is built from the URL path split on \/\, so a native
Windows path arrived as one segment with its backslashes intact. The key
kept them and the checkout landed at a drive-absolute location outside
the cache root -- the escape the \..\ check exists to prevent, through a
door it does not cover.
2. A drive colon survived into a segment, so an owner of \C:_src\ read as a
drive, or mid-name as an NTFS alternate data stream.
3. Flattening a deep path into a single owner segment produced a name long
enough that \git\ could not create \.git\ inside it (260-char cap).
Fixed by folding separators for \ile://\, substituting the characters that
change a path's meaning on Windows, and replacing an over-long segment with
a digest of itself. Applied on every platform: one workflow file must
resolve to the same cache layout wherever it runs, and a Windows-gated
branch would never execute on the Linux legs.
Separately, \_publish\ tolerated losing a race to a concurrent fetch only
for the POSIX errnos. Windows reports a non-empty destination directory as
ERROR_ACCESS_DENIED, so the tolerance never applied and a second source
resolving to the same SHA failed outright. Safe to accept because the
readiness sentinel is written after publishing, so a winner that died
mid-clone leaves no sentinel and the tree is re-fetched.
And \_is_local_path\ asked \pathlib.Path\, the running platform's flavour,
so \/srv/plugins\ was not a local path on Windows. Both conventions now.
Tests: \shutil.rmtree\ of a git repo fails on Windows because git marks
objects read-only, so the four sites that delete a checkout share a helper
that clears the bit. New assertions cover containment directly rather than
leaving it to whichever test happened to notice -- reverting any of the
three fixes fails them.
@franklixuefei

Copy link
Copy Markdown
MemberAuthor

Update: I stopped asking how to sequence around the Windows failures and fixed them.92ebd97.

I had offered three options for working around the 7 failures on the new Windows leg. That was the wrong question -- a CI leg you have to read past is worse than no CI leg, and this PR exists to make Windows visible. All 7 turned out to be real platform bugs in conductor, not test artefacts, and all 7 are fixed.

What was actually broken

Three distinct defects, all reachable only from a file:// source, all of which put a checkout somewhere other than where the cache key said:

  1. Backslashes survived as separators. The key is built from the URL path split on /, so a native Windows path arrived as a single segment with its separators intact. The key kept them and the checkout landed at a drive-absolute location outside the cache root entirely. That is the same escape the .. check exists to prevent, through a door it does not cover.
  2. The drive colon survived. An owner of C:_src reads as a drive on Windows, or mid-name as an NTFS alternate data stream.
  3. The flattened path was too long. Joining every segment but the last into one owner produced a name long enough that git refused to create .git inside it, at the 260-character cap.

This is what test_a_checkout_without_its_sentinel_is_not_cached was reporting as StopIteration: is_cached() was True while rglob("*.ready") found nothing, because the sentinel was written outside the tree being searched.

Separately, _publish tolerates losing a race to a concurrent fetch, but recognised only ENOTEMPTY/EEXIST. Windows reports a non-empty destination directory as ERROR_ACCESS_DENIED. I checked rather than inferred:

os.replace(dir_a, dir_b) -> errno 13 EACCES, winerror 5

So the tolerance never applied there and a second source resolving to the same SHA failed the whole fetch. Accepting it is safe because the readiness sentinel is written after publishing: a winner that died mid-clone leaves no sentinel, is_cached reports a miss, and the tree is re-fetched rather than read half-written.

And _is_local_path asked pathlib.Path -- the running platform's flavour -- so /srv/plugins was not a local path on Windows. Both conventions are consulted now.

Choices worth challenging

  • Substituted, not refused. Unlike .., a Windows path is a legitimate source, so refusing would make file:// unusable there.
  • Applied on every platform, not gated on sys.platform. One workflow file must resolve to the same cache layout wherever it runs, and a Windows-gated branch would never execute on the Linux legs -- the same argument the cp1252 tests in fix(cli): emit ASCII-safe JSON so results survive a legacy stdout codec #381 make.
  • Digest for an over-long segment. The key's leaf already carries a digest of the full location, so the owner disambiguates nothing on its own; there is a test that two sources differing only mid-path still get distinct keys.
  • Test-side:shutil.rmtree of a git repo fails on Windows because git marks objects read-only, so the four sites that delete a checkout now share a helper that clears the bit. That one genuinely is a test bug.

Verification

tests/test_plugins: 365 passed, 2 skipped -- was 7 failed. Full suite locally: 5690 passed. CI's Windows leg was 7 failed, 5632 passed, so this should take it to zero.

New assertions cover containment directly rather than leaving it to whichever test happened to notice. Reverting each fix independently:

revertedresult
backslash foldingcaught
colon substitutioncaught
length boundcaught

Five tests still fail on my Windows box and do not fail on CI's -- two test_aca_runner server tests, two test_engine/test_limits clock-resolution ones, and a test_skills case that shells out to uv build. I confirmed all five fail identically with my changes stashed, so they are environmental here rather than yours or mine.

The containment fix traded one Windows failure for another, and CI caught
what my box did not: my home directory is shorter than the runner's, so the
same path came to 264 characters there and 240 here.
Worth stating plainly, because it is the reason this was not visible until
now: the escaped path was *short*. Landing the checkout at a drive-absolute
location outside the cache had been masking a length problem the whole time.
Containing it exposed the second bug rather than causing it.
Two changes:
- Bound a key segment at 24 characters rather than 48, keeping the tail
instead of the head -- what survives is the directory nearest the
repository, which is the part someone browsing the cache can recognise,
where the head is a drive letter and \Users\. Sized against CI's actual
worst case: 264 at 48, 240 at 24, against a 260 limit.
- Pass \core.longpaths=true\ to every git invocation. That is git-for-
Windows' opt-in to the extended-length API, and without it git refuses any
path over 260 regardless of what Conductor generates. Set per-invocation
because the depth is a property of the paths Conductor builds, not a
preference of the user's. A no-op on POSIX.
Neither alone is enough. The bound keeps Conductor's own paths reasonable;
\core.longpaths\ covers a user whose cache legitimately sits somewhere deep.
@franklixuefei

Copy link
Copy Markdown
MemberAuthor

Correction to my last comment, and CI is now green: 5700 passed, 0 failed on the Windows leg (was 7 failed, 5632 passed).

The first attempt regressed it to 23 failures, and the reason is worth recording because it is the same trap twice.

I claimed the containment fix was verified because tests/test_plugins went 7 failed to 365 passed on my machine. It did. But my home directory is C:\Users\xuefl and the runner's is C:\Users\runneradmin, and that difference is enough: the same path came to 264 characters there against a 260 limit, and 240 here. I verified on the only box where the bug could not reproduce.

What that exposed is the more interesting part. The escaped path was short. Landing the checkout at a drive-absolute location outside the cache had been masking a length problem the whole time — so containing it did not cause the second bug, it revealed one that had been there since file:// sources were supported. The Filename too long errors are what the escape was hiding.

Two changes in 6f4178b:

  • Segment bound 48 to 24 characters, keeping the tail rather than the head. Sized against CI's actual worst case rather than a guess: 264 at 48, 240 at 24. The tail is also the more useful half — _local/is_honoure0-b60d29bde844/source-repo-ffba0f1a names the directory nearest the repository, where the head is a drive letter and Users.
  • core.longpaths=true on every git invocation. git-for-Windows refuses any path over 260 regardless of what Conductor generates, and this is its documented opt-in. Set per-invocation because the depth is a property of the paths Conductor builds, not a preference of the user's. A no-op on POSIX.

Neither alone is enough. The bound keeps Conductor's own paths reasonable; core.longpaths covers a user whose cache legitimately sits somewhere deep, which no bound of mine can fix.

I also A/B'd core.longpaths at a forced breach rather than assuming it would help, and confirmed it is not sufficient on its own — which is why both changes are here.

Final state, all against the runner rather than my box:

legbeforeafter
Test (3.13, windows-latest)7 failed, 5632 passed5700 passed
Test (3.12 / 3.13, ubuntu-latest)passpass
Lint / Type Check / Install Scripts / Frontendpasspass

11 of 11 checks green.

Jason Robertand others added 2 commits August 12, 2026 08:07
Review follow-ups on the Windows test matrix, all on the CI change itself.
The removal step could not fail and never checked that it worked. `find`
exits 0 when it matches nothing, so `2>/dev/null || true` masked genuine
errors rather than the no-match case. If the SDK layout ever changes the
binary survives, tests taking the real CLI path hang on auth, and the job
dies at the 10-minute timeout with an error naming neither the CLI nor
this step. It now fails immediately with an actionable annotation.
The second `find` deleted nothing the first missed, and the comment
justifying it was wrong. Verified against both wheels: the binary ships
inside the package (`copilot/bin/copilot`, `copilot/bin/copilot.exe`)
with no console-script entry point, so it never lands in `Scripts/`. One
pattern covers both layouts -- the leading `*/` absorbs `lib/pythonX.Y/`
or `Lib/`, the trailing `*` absorbs `.exe`.
`find` is also not absent on Windows: `find.exe` ships in System32. It
collides with the DOS search tool, which is a different debugging trail
than a missing binary, so the comment now says so. The header block
listed the test job as Ubuntu-only, while naming platforms for
install-scripts a few lines down.
Both paths exercised locally against a real venv: present -> removed,
exit 0; absent -> annotation, exit 1.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`test_subdirectory_without_skill_md_is_reported` was skipped entirely on
Windows, but the reason described the fixture rather than the behaviour.
`expand_skills_root` reports any immediate subdirectory without a
`SKILL.md`; a mis-cased `Skill.md` is only one way to reach it, and it is
the one way that cannot exist on a case-insensitive filesystem.
Using a plain non-manifest filename keeps the general assertion live on
every platform. The mis-cased case stays as its own test with the
existing skip, so nothing is lost.
Worth having on Windows specifically: mis-casing `SKILL.md` is a Windows
authoring hazard -- it resolves locally and then vanishes for everyone
else -- so Windows is where the diagnostic most wants covering.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jrob5756

Copy link
Copy Markdown
Collaborator

Pushed the four leftovers onto your branch rather than sending you round again — they were all small and all on the CI change itself. Two commits: 45080b8 and 5036fd4.

The removal step now verifies itself. It could not fail and never checked that it worked: find exits 0 when it matches nothing, so 2>/dev/null || true was masking real errors rather than the no-match case. A layout change would leave the binary in place, hang the tests on auth, and surface ten minutes later as a bare timeout naming neither the CLI nor the step. It now fails immediately with an annotation. I exercised both paths against a real venv — present removes and exits 0, absent annotates and exits 1.

Dropped the second find. Checked both wheels: the binary ships inside the package (copilot/bin/copilot, copilot/bin/copilot.exe) with no console-script entry point, so it never reaches Scripts/. One pattern covers both layouts. Incidentally confirmed when my local venv resynced as 3.13 instead of 3.12 and the pattern still matched.

Two comment corrections.find.exe ships in System32, so it collides with the DOS search tool rather than being absent — a different debugging trail. And the header block listed the test job as Ubuntu-only while naming platforms for install-scripts a few lines down.

Split the skills-root test so the general case runs on Windows. The skip reason was accurate but described the fixture, not the behaviour: expand_skills_root reports any subdirectory without a SKILL.md, and mis-casing is just the one trigger that cannot exist on a case-insensitive filesystem. A plain non-manifest filename keeps the assertion live everywhere; the mis-cased case kept its own test and its skip. Worth having on Windows given mis-casing SKILL.md is a Windows authoring hazard specifically.

Gates from a clean lockfile sync: 5,734 passed, 14 skipped. ruff check and ruff format --check clean.


On the file-mode gap — yes, take it as a follow-up. Your scoping argument holds, and there is a second reason to keep it out of here: mcp/manager.py does not re-verify the mode after chmod, so on Windows the 0o700 hardening silently no-ops in production, not just in the tests. Fixing the assertions without first deciding what confidentiality should mean there would just relocate the gap. That is its own change.


Three things I want to call out from re-reviewing, because they are the reason this took one round instead of three.

You were too hard on your own tests. You flagged that the two probe-set tests fail against the pre-fix code "partly for a mechanical reason" since the old code used platform.system(). I mutation-tested that: reverting only the fallbacks tuple while keeping sys.platform still fails test_windows_skips_only_the_driveless_path cleanly, with the POSIX one passing. They isolate the bug properly.

Pushing back on my registry suggestion was correct. The charset check alone would have let my.team through, and a nested table makes the entry vanish silently on load — worse than the parse error, and I had not spotted it.

Not taking any of the three workarounds was the right call. "A CI leg you have to read past is worse than no CI leg" is exactly right, and the cache-escape bug underneath was worth finding — a file:// checkout landing outside the cache root is the same escape the .. check exists to stop, through a door it did not cover. I verified the fix independently: hostile inputs still refused including the backslash form, so the fold-then-check ordering holds, and owner-segment collisions stay distinct via the leaf digest.

Approving once CI comes back green.

Jason Robertand others added 3 commits August 12, 2026 08:19
`test_plugin_path_survives_the_report` interpolated `tmp_path` straight
into a YAML *double-quoted* scalar. Those process backslash escapes, so
on Windows the runner's `C:\Users\runneradmin\...` reached the parser as
`\U` -- a Unicode escape -- and the fixture failed with "expected escape
sequence of 8 hexdecimal numbers, but found 's'" before the assertion
under test ever ran.
Same class as the `_format_toml` bug this branch already fixed, one
layer out: a value with meaning to the serialiser, interpolated as if it
were inert.
YAML is a superset of JSON, so `json.dumps` produces a scalar that is
valid on every platform and round-trips the path exactly. Verified
against the literal path from the failing CI run: the old form raises
ScannerError, the new form round-trips.
Arrived with microsoft#408, so it is not a regression from this branch -- but it
is only visible because of the Windows leg this branch adds, and the
suite has to be green for that leg to mean anything.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`test_plugin_path_survives_the_report` puts a rich markup marker inside a
directory name. `/` is a path separator on Windows, so `plug[/etc/x]`
becomes three nested components -- `plug[`, `etc`, `x]` -- and the literal
string the assertion looks for cannot appear in the rendered path.
Rich's crashing form is a *closing* tag, so it needs the slash. There is
no Windows-expressible variant to substitute, which makes this a scenario
the platform cannot host rather than an assertion to loosen.
Scoped to the one parametrized case rather than the test: `[dim]` is a
legal Windows directory name, so the silent-deletion half still runs
there. The crashing form keeps its coverage too -- ten other cases in this
module put it through error text, agent names, descriptions and manifest
messages, none of which touch the filesystem.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jrob5756

Copy link
Copy Markdown
Collaborator

Resolved the conflict and got the Windows leg green. MERGEABLE / CLEAN, all 11 checks passing.

The conflict was CHANGELOG.md only — both sides had inserted at the top of Unreleased > Fixed. Everything else auto-merged, including validate_connection, which I checked line by line afterwards. I kept both blocks in place and verified the result is the exact union of the two sides: 99 entries, none dropped from either.

Two test failures on the Windows leg after the merge, both from test_markup_injection.py, which arrived with #408.

The first is the same bug class this branch already fixed, one layer out. The fixture interpolated tmp_path into a YAML double-quoted scalar, which processes backslash escapes — so the runner's C:\Users\runneradmin\... reached the parser as \U and it died with "expected escape sequence of 8 hexdecimal numbers, but found 's'" before the assertion under test ever ran. Same shape as _format_toml: a value with meaning to the serialiser, interpolated as though it were inert. json.dumps fixes it, since YAML is a superset of JSON. Verified against the literal path from the failing run — old form raises ScannerError, new form round-trips exactly.

The second one I want to flag, because the honest answer was a skip and I would rather say why than have it look like giving up. The test puts a rich markup marker inside a directory name, and the CRASHING marker is [/etc/x]. On Windows / is a path separator, so that becomes three nested components — plug[, etc, x] — and the literal string the assertion looks for cannot appear in the path at all. Rich's crashing form is a closing tag, so it needs the slash; there is no Windows-expressible variant to substitute. That makes it a scenario the platform cannot host rather than an assertion to loosen.

I scoped the skip to the one parametrized case rather than the test, so [dim] still runs there — and the crashing form keeps its coverage anyway, since ten other cases in that module put it through error text, agent names, descriptions and manifest messages, none of which touch the filesystem.

That is the fourth and fifth real cross-platform bug this leg has surfaced, after _format_toml, the Claude CLI probe, and the plugin cache key.

Windows: 6,258 passed, 58 skipped. Linux: 6,302 passed, 14 skipped. Lint, format and typecheck clean.

I also ran the removal step against a genuinely fresh uv sync rather than trusting my local venv, since I had already deleted the binary while testing the failure path: present removes and exits 0. Incidentally it resynced as 3.13 rather than 3.12 and the single pattern still matched, which is a nicer proof that it is interpreter-version agnostic than the one I wrote in the commit message.

Nothing outstanding from my side. Approving now — the file-mode gap is yours to take as the follow-up we agreed.

@jrob5756Jason Robert (jrob5756) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Approved!

@jrob5756
Jason Robert (jrob5756) merged commit b3662fe into microsoft:mainAug 12, 2026
11 checks passed
Jason Robert (jrob5756) pushed a commit that referenced this pull request Aug 14, 2026
The Windows CI job added in #390 exercised this code for the first time
and found five failures. One is a real defect.
`write_run_record`'s "atomic write" relied on `os.replace`, which is
atomic and reader-proof on POSIX but raises `PermissionError` on Windows
while another process holds the destination open. This record is read
constantly -- `conductor status`, `fleet list`, the TUI's ~2s poll, the
`--web-bg` launch gate -- so the write failed, `cli/run.py` swallowed it,
and the run silently became undiscoverable and unstoppable: exactly the
defect the run record exists to prevent, reachable only on Windows.
`_replace_with_retry` retries briefly there; a persistent permission
error still surfaces rather than stalling. Predicted as R13 in review and
deferred as unlikely -- CI reproduced it deterministically.
The remaining four were tests asserting POSIX-only behaviour:
- `TestStopProcessUnexpectedOSError` and
`test_kill_already_dead_pid_does_not_raise` simulate an errno from the
kill primitive by patching `os.kill`. `terminate_process` dispatches to
`_terminate_process_windows` (ctypes `TerminateProcess`) there, so the
patch is never reached and the assertions describe a path Windows
cannot take. Skipped with that reason. Note the sibling
`..._removed_when_process_confirmed_dead_after_oserror` was passing on
Windows for the same wrong reason, and is covered by the class-level
skip.
- `test_concurrent_writers_never_yield_torn_read`'s reader now tolerates
`PermissionError` alongside `FileNotFoundError`: both are races with
`os.replace` itself, not the *torn* read the test exists to rule out.
- `_rendered_footer` assumed the footer is the compositor's last strip.
It is on Linux; on Windows the trailing strip is blank, so every footer
assertion read an empty line. It now finds the row carrying a binding.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Jason Robert (jrob5756) added a commit that referenced this pull request Aug 14, 2026
…an interactive TUI (#431)
* feat(fleet): add the Fleet Manager (run records, conductor fleet, TUI)
Every run path now writes a run_id-keyed JSON run record describing its
mode/PID/workflow/port, so foreground, --web and --web-bg runs are all
discoverable the same way. This fixes the bug where a plain `conductor
run` was invisible to `conductor stop` and to discovery, which only ever
saw port-keyed .pid files written by --web-bg.
- fleet/records.py: RunRecord + atomic write/read/prune primitives,
tolerant of corrupt JSON and legacy port-keyed .pid files.
- fleet/summary.py: RunSummary derived from a bounded event-log tail
(status, current step, elapsed, tokens, cost, open gate). Status comes
from explicit event markers, never inferred from timing.
- fleet/history.py, fleet/retention.py, fleet/launch.py: History
enumeration, $TMPDIR/conductor event-log retention, and New Run launch
reusing cli/bg_runner.launch_background.
- fleet/tui/: Textual app with Runs, Run detail, Providers, Registries,
New Run and History screens, plus shared stop/kill/gate actions and
debounced terminal notifications. Behind the optional `tui` extra.
- cli/fleet.py: `conductor fleet` (bare launches the TUI), `fleet list`,
`fleet prune`; `conductor stop` now operates over run records.
- settings.py: read-only ~/.conductor/config.toml ([fleet.retention]).
- cli/pid.py is retained only so pre-upgrade background runs stay
discoverable; parent-side PID writing is gone.
Squashed from the E1-E15 epic series; that granular history is preserved
at tag backup/fleet-manager-pre-rebase.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(fleet): make the New-run form readable and launchable from a registry
The New-run screen had no CSS at all, so Textual laid every widget out
`width: auto`: a field's label carried the workflow's entire description
on one unwrappable line and ran off the right edge, an unlabelled
Checkbox collapsed to a truncated "X…" with nothing to say which field it
belonged to, and the Launch button sat below a full-height scroller
rather than where it could be seen.
- Split each field into a heading (name, type, required), a wrapping
muted description line, and the widget itself.
- Carry the field name as the Checkbox's own label.
- Add DEFAULT_CSS: `width: 100%` is what makes a description wrap, and a
`1fr` field list keeps the launch bar on screen.
- Focus the first field once a workflow resolves.
Also bind `n` on both Registries drill-down screens (the workflows list
acts on the highlighted row, matching `k`/`g` on Runs) to open New Run
with `<workflow>@<registry>` pre-filled and resolved -- launching the
workflow you are looking at no longer means escaping back out and
retyping a reference you just navigated through.
That makes New Run reachable three levels deep, where popping a single
screen on a successful launch would land back on a workflow's inputs with
the just-started run nowhere in sight, so `FleetApp.return_to_runs()`
unwinds to Runs instead -- where the run appears on the next poll tick.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(fleet): give the TUI a design system and a run preview
Five of the six screens carried no CSS at all, so Textual laid them out
with raw defaults: tables flush against column 0, no spacing, and every
cell the same visual weight as its neighbours. The home screen showed
three rows of runs above seventeen blank lines, and the footer silently
truncated mid-word, dropping the History binding entirely.
Design system:
- tui/theme.py is now the single status vocabulary. runs.py, history.py
and run_detail.py each defined their own overlapping glyph map, none of
which carried colour, so a failure and an unknown outcome landed with
identical weight. Colours are Rich style names, not Textual CSS
variables -- these render inside Text objects, where $success would be
printed literally.
- App-level CSS plus a default theme, applied in on_mount rather than as
a `theme = ...` class attribute: App.theme is a reactive, and assigning
a plain string in the class body replaces the descriptor, which leaves
the app looking themed while silently breaking theme switching.
- Screens inherit $background, not $surface. Surface is the *elevated*
colour (nord #3B4252 vs #2E3440), so painting screens with it produced
a flat haze with dim text on top.
- `unknown` is left unstyled rather than dim grey: it is History's most
common row, and dark-grey-on-dark was near invisible.
Run preview:
- A summary bar (counts by status, token and cost totals) and a preview
pane showing what the one-line row has no column for: PID, port, an
open gate's prompt and options, and the workflow's step chain with the
current step marked.
- The table is height:auto capped at 60% rather than 1fr, which is what
removed the dead band between it and the pane.
- The trailing column is Mode, not Port: a blank port was the only signal
that a run was foreground, and blank reads as "no data yet".
New-run form: the Resolve and Launch buttons are gone. Enter already
resolved the reference, and a form whose submit control sits below its
own scroll region is worse than one submitted from anywhere; both are
keystrokes now (ctrl+r / ctrl+s), with a hint line that says why a launch
is not yet possible instead of grey ing out a control.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(fleet): open the dashboard on WSL and recover a legacy run's event log
Two failures seen against a real background run.
Dashboard would not open. On WSL there is no X display and usually no
Linux browser, so `webbrowser.open` falls through to `gio`, which answers
"Operation not supported" and opens nothing. WSL is now detected and the
URL handed to the Windows side -- `wslview` when the `wslu` package is
installed, otherwise PowerShell's Start-Process. `explorer.exe` also
works but exits non-zero on success, so it cannot report whether it
opened anything, which is exactly what the caller needs.
The caller made that invisible: it discarded `webbrowser.open`'s return
value and notified "Opened dashboard" unconditionally, so a failed open
still reported success. It now surfaces the failure and prints the URL,
which is the part the user actually needs when no browser will open.
No run details. A pre-Fleet-Manager `.pid` file records no event-log
path, so current step, tokens, cost and topology all came out blank --
while the log sat on disk beside it. The record does carry the run id and
the log's filename ends in it, so the pairing is recoverable. Only an
unambiguous match is adopted: run ids are 8 hex chars and a resumed run
reuses its predecessor's, so one id can legitimately name several logs
(a test suite pinning one produces dozens), and showing a run's details
against another run's log is worse than showing none.
The lookup does not go through `retention.event_log_root()` -- that
creates the directory because it is about to delete inside it, and this
is a read-only path that may not exist.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(fleet): give the TUI a design system, a DAG preview and step drill-down
Builds the Fleet Manager TUI out from a bare table into something you can
actually read a running fleet from.
Presentation is factored into leaf modules rather than inlined in the
screens that use them: `anim.py` drives every moving glyph from one
screen-level frame counter (so they stay in phase and there is one timer,
not one per widget), `art.py` holds the whitespace-significant banner art
that cannot be edited safely inline, and `dag.py` renders where a run is in
its workflow -- shared by the Runs preview and the run-detail header, which
both had to answer that question.
Two new screens: a splash that covers the run-record scan and per-run
bounded log read the first Runs render already blocked on, and a step
drill-down (`enter` on a run-detail row) answering what a single step
actually did -- its input, output and activity stream -- which neither table
above it could show. `summary.py` grows `derive_step_detail` and a bounded
head read to feed it, and `records.py` grows `find_event_log_for_run` so a
run's log can be located from its id and start time.
Runs-screen keys are split into two blocks -- row-scoped (`enter`, `w`, `k`,
`g`) then fleet-scoped (`n`, `p`, `r`, `h`, `K`, `q`) -- with `BlockFooter`
drawing the rule between them, since ordering alone renders as one
undifferentiated run of keys. `enter` needs `priority` to appear at all:
DataTable binds it first, hidden, and silently shadowed it. Row-scoped keys
retire while the fleet is empty. `K` (kill *all*) moves into the fleet block,
away from the single-run `k` it sat one stray Shift from.
Also fixes a real engine bug the fleet view exposed: a `questions` node
emitted `gate_presented` and never the matching `gate_resolved`, so every
consumer tracking "is this run waiting on a human" -- the fleet summary and
the dashboard both -- showed the run parked at a question it had already
answered, for the rest of the run. `workflow_started` now also records the
inputs a run was launched with, so two runs of one workflow are
distinguishable in the log by something other than their ids.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(cli): declare --input-json so background launches with inputs work
`bg_runner` forwards workflow inputs to the child via `--input-json`, but
`conductor run` never declared that option -- so every `--web-bg` launch
carrying an input died immediately with exit code 2 ("No such option:
--input-json"). That took the Fleet Manager's New Run screen with it: its
form is generated from the workflow's declared `input:` block, so it
essentially always passes some.
The flag is load-bearing rather than incidental. The New Run form coerces
each field to its declared type (`fleet/launch.py::_coerce_input` returns a
`string`-typed value verbatim) and nothing downstream restores that typing
-- the engine's `_apply_input_defaults` only fills in *missing* inputs, so
whatever the child's CLI parse produces is final. Routing these through the
public `--input` flag would hand them to `coerce_value`, whose command-line
heuristic re-guesses a bare `true`/`42`/`[1,2]` into a bool/int/list and
silently discards the declared type.
`_serialize_input_value`'s docstring claimed `coerce_value` tries
`json.loads` first, so a JSON-quoted string would decode back to a string.
It does not -- it only tries JSON for values starting with `[` or `{`, so
every string arrives with its quotes baked in. That false comment reads as
though `--input` already handled this, which is the likeliest reason the
option was never wired up; it now describes the real mechanism.
The existing tests missed all of this because they patch out
`subprocess.Popen` and assert on the argv the launcher *builds*, which only
proves it built what the test expected. `TestLaunchedArgvIsAcceptedByTheChildCLI`
checks every flag the launcher emits against the child command's real,
declared Click options, catching argv/CLI divergence without paying for a
subprocess.
Also promotes the Fleet Manager to a headline README section alongside the
web dashboard -- screenshot, key features, and the breadth-vs-depth split
between the two surfaces -- and trims the CLI-reference entry to a pointer
rather than a second copy.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(fleet): close code-review blockers in the Fleet Manager
Nine blocking defects from the review of this PR, plus the highest-value
recommendations.
Data loss and correctness:
- An unrecognised run-record `mode` no longer raises. A raise reached
`_read_and_prune` as `corrupt`, which deletes **without consulting
liveness** -- so a record written by a newer Conductor made an older one
delete a live run's record and orphan that process from `stop`, `status`
and the fleet. It now normalises prefix-wise (`fg*` to `fg`, else `bg`),
which also keeps D1's stop confirmation armed rather than silently
dropping it. `mode` is a `RunMode` Literal so the single write site is
checked by `ty`.
- `stop` no longer clears a legacy PID file by port alone. The port-keyed
fallback fires on the *normal* path (a cooperating child removes its own
record), so a freed-and-rebound port meant deleting a different, live
run's file -- the hazard issue #344 exists to prevent, under a comment
claiming the removal was identity-checked. Adds
`remove_pid_file_for_pid`, which matches on port *and* pid and delegates
to `remove_pid_file_at`.
Reporting a success that did not happen:
- `conductor fleet prune` reported a wholesale sweep failure -- including
the symlink-tamper refusal -- as "Nothing to prune." with exit 0.
`PruneResult.error` carries it; `prune_event_logs` still never raises,
since the opportunistic startup sweep depends on that.
- Per-file deletion failures had nowhere to go, so a read-only log
directory reported a working sweep. `_safe_unlink` now distinguishes
"already absent" from a refusal, and `PruneResult.failed` is reported.
- The TUI kill action announced "Killed 0 run(s)." at *informational*
severity while discarding every reason -- including the identity-mismatch
refusal, a safety stop -- into a dropped buffer. `StopOutcome.failed`
carries them to the user.
Rich markup (AGENTS.md, Console Output):
- The kill-confirmation dialog markup-parsed the workflow names it was
listing for deletion; two gate notifications and two more sites did the
same; `fleet prune --help` dropped the `[fleet.retention]` section name
it existed to communicate.
- `test_markup_guards.py` gains rules I and J for the surfaces that let
those through: Textual content sinks (`Static`/`Label`/`notify`, and
every `str` cell of a `DataTable`) and Typer command docstrings. Both
were verified against the pre-fix tree. Rule I found a live instance of
the same defect in the run-detail table, fixed here.
Test isolation:
- `read_run_records()` is a pruning reader, and `cli.pid.pid_dir()` ignores
`CONDUCTOR_HOME`, so the suite could delete a developer's live run
records. An autouse fixture redirects both directories, mirroring the
existing `_isolate_event_log_root`.
Also: `stop --json` takes D1's refusal branch instead of skipping the gate;
foreground self-exclusion works off Linux via a dedicated
`CONDUCTOR_SELF_RUN_ID` (a separate name because `event_log.py` reads
`CONDUCTOR_RUN_ID` as "adopt this run id"); an inherited `SIG_IGN` is
honoured; the event-log head read is lazy; `textual>=8.0` to match the
tested floor; dead code and two inert parameters removed; docs, CHANGELOG
and AGENTS.md corrected where they described code that no longer exists.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(fleet): make run-record writes and the stop tests Windows-safe
The Windows CI job added in #390 exercised this code for the first time
and found five failures. One is a real defect.
`write_run_record`'s "atomic write" relied on `os.replace`, which is
atomic and reader-proof on POSIX but raises `PermissionError` on Windows
while another process holds the destination open. This record is read
constantly -- `conductor status`, `fleet list`, the TUI's ~2s poll, the
`--web-bg` launch gate -- so the write failed, `cli/run.py` swallowed it,
and the run silently became undiscoverable and unstoppable: exactly the
defect the run record exists to prevent, reachable only on Windows.
`_replace_with_retry` retries briefly there; a persistent permission
error still surfaces rather than stalling. Predicted as R13 in review and
deferred as unlikely -- CI reproduced it deterministically.
The remaining four were tests asserting POSIX-only behaviour:
- `TestStopProcessUnexpectedOSError` and
`test_kill_already_dead_pid_does_not_raise` simulate an errno from the
kill primitive by patching `os.kill`. `terminate_process` dispatches to
`_terminate_process_windows` (ctypes `TerminateProcess`) there, so the
patch is never reached and the assertions describe a path Windows
cannot take. Skipped with that reason. Note the sibling
`..._removed_when_process_confirmed_dead_after_oserror` was passing on
Windows for the same wrong reason, and is covered by the class-level
skip.
- `test_concurrent_writers_never_yield_torn_read`'s reader now tolerates
`PermissionError` alongside `FileNotFoundError`: both are races with
`os.replace` itself, not the *torn* read the test exists to rule out.
- `_rendered_footer` assumed the footer is the compositor's last strip.
It is on Linux; on Windows the trailing strip is blank, so every footer
assertion read an empty line. It now finds the row carrying a binding.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test(fleet): wait for the footer to paint before asserting on it
`_rendered_footer` read the compositor after a single `pause()`. That is
enough on Linux; on Windows the first frame can land before the footer is
composited, so the assertion read a blank line -- the last remaining
Windows CI failure, and unrelated to the binding grouping these tests
cover. It now polls until the footer row appears.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Jason Robert <jasonrobert@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

Windows runs the install scripts but never the test suite, so the Windows-specific code paths are untested

4 participants

@franklixuefei@codecov-commenter@jrob5756@xuefl-msft