jobbsok could not start on stock Windows. `.mcp.json` named `bash` as the
command, and the five entry points behind it reached for grep, sed, find, awk,
zip and unzip. `bootstrap.sh` built the virtualenv, so a Windows adopter could
not even reach an interpreter. Two adopters are waiting and neither is
guaranteed to be on macOS, so this is the install, not a rough edge.
The Python layer was already clean -- no /tmp, no /usr, no os.uname, no home
directory assumption -- so only the shell layer moved. Behaviour is carried
over unchanged; the deliberate exceptions are listed in docs/.
THE ONE OPEN DECISION, AND WHY IT WAS FORCED
How does .mcp.json start an interpreter without a POSIX shell, when it is
called python3 on macOS and python or py on Windows? Measured against the
installed CLI, not assumed:
- The plugin mcpServers stdio schema has NO platform-conditional form. A
config carrying invented windows/darwin/platform keys was accepted and the
keys were silently discarded -- it fails quietly, not loudly.
- ${VAR:-default} IS expanded, in command, args and env.
- ${VAR} without a default is not safe: unset, it is passed through
unexpanded, so the spawn would try to run a program named ${VAR}.
- Windows spawns with shell:false, so a .py path as command is out.
- No single literal works. On this Mac, python and py are not on PATH.
So the default form is the only lever the schema offers:
"${JOBBSOK_LAUNCH_PYTHON:-python3}". macOS and Linux keep working with nothing
set; Windows sets one variable and needs no shell.
A SECOND VARIABLE, NOT A REUSE OF JOBBSOK_PYTHON
JOBBSOK_PYTHON names the interpreter to SERVE on: the launcher treats it as an
explicit operator choice, so it wins over the bootstrapped virtualenv. A
Windows adopter setting it merely to spell `python` would silently bypass that
virtualenv and serve WITHOUT the ingestion guard. JOBBSOK_LAUNCH_PYTHON only
says how to start the launcher. A test asserts the two never collapse into one.
O4 IS LEFT STANDING
The launcher still gates on the interpreter's version rather than on the guard
being importable. That is the shell version's semantics carried over on
purpose: harmless while nothing writes, a defect from M2, and an M2 decision.
VERIFY
- pytest tests/ -> 124 passed, exit 0 (was 112)
- grep -c '"command": "bash"' .mcp.json -> 0
- git ls-files 'scripts/*.sh' -> 0
- README install block names Windows, and neither WSL nor Git Bash
- server started end to end exactly as .mcp.json expands, and answered
initialize and tools/list
- the ported probe checker reproduces the shell version's output and exits 0
NOT MEASURED, AND NOT ASSUMED
Nothing here has ever run on Windows. Whether Cowork on Windows bridges to a
host-side stdio MCP as it does on this Mac is unmeasured -- docs/cowork-probe.md
covered macOS only. docs/cross-platform-port.md says what a Windows probe would
have to measure, and records two findings left deliberately untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
8.1 KiB
Markdown
139 lines
8.1 KiB
Markdown
# Cross-platform port: the shell layer, and the one decision it forced
|
|
|
|
Measured 2026-09-05, on the M1 codebase. This file records what was measured,
|
|
the single design decision the measurements forced, and what remains unmeasured
|
|
so that nobody has to re-derive any of it.
|
|
|
|
## The blocker
|
|
|
|
`.mcp.json` started the tool server with `"command": "bash"`. Stock Windows has
|
|
no bash, so the connector could not start at all. Five shell entry points behind
|
|
it — `bootstrap.sh`, `build_stamp.sh`, `package_plugin.sh`,
|
|
`jobbsok_tools_launch.sh`, `cowork_probe_check.sh` — reached for `grep`, `sed`,
|
|
`find`, `awk`, `zip` and `unzip`, none of which are on a stock Windows machine
|
|
either. `bootstrap.sh` built the virtualenv, so a Windows adopter could not
|
|
reach an interpreter at all.
|
|
|
|
The Python underneath was already clean: a scan of `scripts/*.py` and
|
|
`scripts/jobbsok_lib/` found no `/tmp`, no `/usr`, no `os.uname` and no home
|
|
directory assumption. The portability boundary was the shell layer alone, which
|
|
is why this was a packaging job rather than a rewrite.
|
|
|
|
All five are now Python and standard library only. The behaviour is carried over
|
|
unchanged; the deliberate exceptions are listed under *Port decisions* below.
|
|
|
|
## The decision: how `.mcp.json` names an interpreter
|
|
|
|
**Question.** How does `.mcp.json` start a Python interpreter without a POSIX
|
|
shell, when the interpreter is called `python3` on macOS and Linux and `python`
|
|
or `py` on Windows?
|
|
|
|
**What was measured, and how.**
|
|
|
|
1. **The plugin `mcpServers` schema has no platform-conditional form.** The
|
|
stdio entry is exactly `{type?, command, args, env?, timeout?, alwaysLoad?,
|
|
role?}`. This was read out of the installed CLI (v2.1.261) and then
|
|
confirmed behaviourally: a config carrying invented `windows`, `darwin` and
|
|
`platform` keys was accepted and the extra keys were **silently discarded** —
|
|
no error, no warning, no selection. An invented per-OS key therefore fails
|
|
quietly rather than loudly, which is the worse of the two failure modes.
|
|
2. **`${VAR:-default}` is expanded**, in `command`, in every `args` element and
|
|
in every `env` value. Measured: `${JOBBSOK_PY:-python3}` spawned the real
|
|
`python3`; `${JOBBSOK_UNSET:-fallback-used}` arrived as `fallback-used`.
|
|
3. **`${VAR}` without a default is NOT safe.** With the variable unset the value
|
|
is passed through unexpanded and merely warned about, so the spawn would try
|
|
to run a program literally named `${VAR}`.
|
|
4. **Windows spawns the server directly, with no shell** (`shell: false`). A
|
|
`.py` path as `command` is therefore not an option there. The known
|
|
`cmd /c` requirement (claude-code issue 58510) applies to `.cmd`/`.bat`
|
|
shims such as `npx`, not to a real executable like `python.exe`.
|
|
5. **No single literal name works.** Measured on the development Mac: `python`
|
|
and `py` are not on `PATH`; only `python3` is. On stock Windows the reverse
|
|
holds, and a bare `python3` there resolves to the Microsoft Store alias,
|
|
which opens a shop instead of running anything.
|
|
|
|
**Decision.** `"command": "${JOBBSOK_LAUNCH_PYTHON:-python3}"`. The default form
|
|
is the only lever the schema offers, so macOS and Linux keep working with
|
|
nothing set — no regression on the platform that works — and Windows needs one
|
|
documented environment variable, no POSIX shell of any kind.
|
|
|
|
**Why a second variable rather than reusing `JOBBSOK_PYTHON`.** They are
|
|
different questions and conflating them would have created a Windows-only
|
|
defect. `JOBBSOK_PYTHON` names the interpreter to **serve** on: the launcher
|
|
treats it as an explicit operator choice, so it wins over the virtualenv the
|
|
bootstrap built and a failure there is refused outright rather than skipped. A
|
|
Windows adopter who set it merely to spell `python` would silently bypass that
|
|
virtualenv and serve on a bare interpreter **without the ingestion guard**.
|
|
`JOBBSOK_LAUNCH_PYTHON` only says how to **start** the launcher; the launcher
|
|
then resolves the serving interpreter exactly as before. `README.md` states the
|
|
distinction, and `tests/test_cross_platform.py` asserts the two never collapse
|
|
into one.
|
|
|
|
## Port decisions, stated rather than smuggled
|
|
|
|
* **Both venv layouts.** A POSIX venv carries `bin/python`; a Windows venv
|
|
carries `Scripts\python.exe` and no `python3` at all. The shell version
|
|
reached for `bin/python` and `bin/python3` in different branches; the port
|
|
uses `python` uniformly, which is the same file on POSIX and the only correct
|
|
one on Windows.
|
|
* **PATH names.** The last-resort PATH lookup keeps exactly `python3` on POSIX —
|
|
no widening — and uses `python`, then `py`, on Windows.
|
|
* **The bootstrap's default interpreter** is now the one that started it rather
|
|
than `python3` on `PATH`, because Windows has no such name. The version gate
|
|
is unchanged, so an install attempted on 3.9.6 is still refused.
|
|
* **POSIX still `exec`s.** The launcher replaces its own process on POSIX, as
|
|
the shell version's `exec` did, so the client's child stays the process it
|
|
spawned. Windows has no such replacement — `os.execv` there starts a new
|
|
process and lets this one exit, leaving the client with a dead pid and a live
|
|
pipe — so on Windows the launcher stays as the parent.
|
|
* **`BUILD_STAMP` is written with an explicit `\n`.** Without that, Windows text
|
|
mode would write CRLF and the stamp would no longer be the byte string the
|
|
skills echo.
|
|
* **The archive carries no execute bits.** Nothing needs one now that every
|
|
entry point is started by naming an interpreter, and Windows has no such bit.
|
|
|
|
## What is measured here, and what is not
|
|
|
|
**Measured on this Mac.** Every platform-dependent decision is a pure function
|
|
that takes the platform as an argument — `venv_python`, `path_candidates`,
|
|
`venv_location` — so the Windows branch is exercised from macOS by passing
|
|
`"nt"`. The suite is 124 tests, offline. The server was also started end to end
|
|
exactly as `.mcp.json` expands it, and answered `initialize` and `tools/list`.
|
|
|
|
**Not measured, and not to be assumed in either direction:**
|
|
|
|
* That Claude Code on Windows spawns this server successfully. Nothing in this
|
|
repository has ever run on Windows.
|
|
* That `${CLAUDE_PLUGIN_ROOT}` expands to a native Windows path. The expansion
|
|
is a raw string substitution with no separator normalisation; the value it
|
|
substitutes on Windows was not observable from a macOS build.
|
|
* **That Cowork on Windows bridges to a host-side stdio MCP server the way it
|
|
does on this Mac.** `docs/cowork-probe.md` measured macOS only, and operator
|
|
decision 7 rests on that macOS measurement. Worse, the one piece of
|
|
documentation that describes a plugin's `.mcp.json` on a Cowork surface lists
|
|
`http` and `sse` only, with no `command`/`args` — which the macOS measurement
|
|
in this repository contradicts. Whether that documentation is simply scoped to
|
|
a different product or describes a real Windows restriction is **unknown**.
|
|
|
|
A Windows probe would have to measure, in this order: whether the connector
|
|
appears at all with `JOBBSOK_LAUNCH_PYTHON` set; whether it appears without it
|
|
(and if so, whether the Store alias was what answered); what
|
|
`${CLAUDE_PLUGIN_ROOT}` expanded to, read back through the `selvsjekk` tool; and
|
|
whether `python scripts\bootstrap.py` builds a virtualenv the launcher then
|
|
finds under `$CLAUDE_PLUGIN_DATA`.
|
|
|
|
## Findings left standing, deliberately
|
|
|
|
1. **The launcher still gates on the interpreter's version, not on the guard**
|
|
(O4 in `docs/cowork-probe.md`). Carried over unchanged on purpose: it is
|
|
harmless while nothing writes, and it becomes a defect in M2 when the
|
|
decision log and the case folder start writing. Whether the launcher should
|
|
refuse without the guard is an M2 decision, not a porting one.
|
|
2. **`skills/kandidatprofil/SKILL.md` tells the reader to run `python3`.** That
|
|
is the same class of bug as the one this port closed, one layer up: the skill
|
|
layer, not the entry points. It was outside this order's fence and is left
|
|
for a decision about how skills should spell an interpreter at all.
|
|
3. **`tests/plan-gates/*.sh` are still shell scripts.** They gate a planning
|
|
document, are never shipped in the archive and are not needed to install,
|
|
package or start anything, so they are not entry points in the sense this
|
|
port was about.
|