feat(port): move five bash entry points to Python and drop bash from .mcp.json

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 21:02:03 +02:00
commit dcd3eae534
20 changed files with 1523 additions and 542 deletions

View file

@ -0,0 +1,307 @@
"""The plugin installs, packages and serves on a machine with no POSIX shell.
Two adopters are waiting and neither is guaranteed to be on macOS, so "runs on
Windows" is an acceptance criterion here rather than a nicety. The measurement
that started this was narrow: `.mcp.json` named `bash` as the command, stock
Windows has no bash, and the connector therefore could not start at all. Five
shell entry points behind it reached for `grep`, `sed`, `find`, `awk`, `zip`
and `unzip`, none of which are there either.
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 -- so the port is of the shell layer only.
What this file can and cannot measure, stated rather than implied:
* **Measurable here.** Every platform-dependent decision is a pure function
taking the platform as an argument -- `venv_python`, `path_candidates`,
`venv_location` -- so the Windows branch is exercised on this Mac by passing
`"nt"`. That is the difference between a Windows port and a Windows claim.
* **Not measurable here.** Whether Claude Code and Cowork actually spawn the
server on Windows, and whether Cowork on Windows bridges to a host-side stdio
MCP the way it does on this Mac. `docs/cowork-probe.md` measured macOS only.
Nothing in this file asserts either, in either direction.
Style note: this file follows tests/test_public_surface.py.
"""
import ast
import json
import os
import re
import subprocess
import sys
import pytest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(REPO, "scripts")
MCP = os.path.join(REPO, ".mcp.json")
README = os.path.join(REPO, "README.md")
#: The entry points an adopter has to be able to run. `cowork_probe_check` is
#: not among them: it reads this Mac's own Claude logs and is a measurement
#: tool, not part of installing, packaging or serving.
INNGANGSPUNKTER = ("bootstrap.py", "build_stamp.py", "package_plugin.py",
"jobbsok_tools_launch.py")
#: Programs that do not exist on a stock Windows install. A `command` naming
#: any of these is the bug this port closed.
POSIX_SKALL = ("bash", "sh", "zsh", "dash", "/bin/bash", "/bin/sh", "env")
def sporede_filer():
ut = subprocess.run(["git", "-C", REPO, "ls-files"], capture_output=True, text=True)
assert ut.returncode == 0, ut.stderr
return [p for p in ut.stdout.split("\n") if p]
def test_the_mcp_server_is_started_without_a_posix_shell():
with open(MCP, "r", encoding="utf-8") as handle:
konfig = json.load(handle)
servere = konfig["mcpServers"]
assert list(servere) == ["jobbsok-tools"], (
"the declared servers are %r; the name is pinned because the server "
"echoes it back and test_mcp_jobbsok_tools.py compares them" % (list(servere),)
)
server = servere["jobbsok-tools"]
assert server["command"] not in POSIX_SKALL, (
"command is %r, which stock Windows does not have -- this is the hard "
"blocker the port exists to remove" % (server["command"],)
)
for arg in server.get("args", []):
assert not arg.endswith(".sh"), (
"args still carry a shell script (%r); an interpreter that can run "
"it is the same dependency wearing a different name" % (arg,)
)
assert any("${CLAUDE_PLUGIN_ROOT}" in arg for arg in server.get("args", [])), (
"the server script must be addressed through ${CLAUDE_PLUGIN_ROOT}"
)
def test_the_mcp_command_carries_a_default_so_it_never_expands_to_nothing():
"""No single interpreter name exists on every platform, so the name is a
variable -- and a variable without a default is worse than a wrong name.
Measured against the CLI's expansion pass: `${VAR}` with VAR unset is
passed through UNEXPANDED and merely warned about, so the spawn would try
to run a program literally called `${VAR}`. `${VAR:-default}` is the only
form that always yields a name.
"""
with open(MCP, "r", encoding="utf-8") as handle:
server = json.load(handle)["mcpServers"]["jobbsok-tools"]
treff = re.match(r"^\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]+)\}$", server["command"])
assert treff, (
"command is %r. It has to be ${VAR:-default}: no literal interpreter "
"name exists on macOS and Windows both -- `python3` is absent on stock "
"Windows, `python` and `py` are absent on this Mac (measured)."
% (server["command"],)
)
variabel, standard = treff.group(1), treff.group(2)
assert variabel != "JOBBSOK_PYTHON", (
"the launch variable must not be JOBBSOK_PYTHON. That one names the "
"interpreter to SERVE on, and the launcher refuses outright rather "
"than falling through when it is set -- so a Windows adopter setting "
"it merely to spell `python` would silently bypass the bootstrapped "
"virtualenv and serve without the guard."
)
assert standard == "python3", (
"the default is %r; it must stay python3 so macOS and Linux keep "
"working with nothing set, exactly as they did before the port"
% (standard,)
)
def test_no_entry_point_is_a_shell_script():
skall = [p for p in sporede_filer() if p.startswith("scripts/") and p.endswith(".sh")]
assert skall == [], (
"scripts/ still tracks shell entry points: %r. Installing, packaging "
"and starting the server must not need a shell." % (skall,)
)
for navn in INNGANGSPUNKTER:
assert os.path.isfile(os.path.join(SCRIPTS, navn)), (
"entry point scripts/%s does not exist" % navn
)
def test_the_entry_points_parse_on_the_interpreter_they_exist_to_refuse():
"""A refusal that is a SyntaxError is not a refusal.
The launcher and the bootstrap are both started by an interpreter they do
not trust -- under an empty launchd PATH on this Mac that is 3.9.6 (risk
H2) -- and both exist to say no to it in words. Syntax newer than the floor
they enforce would turn the message into a traceback.
"""
for navn in ("jobbsok_tools_launch.py", "bootstrap.py"):
with open(os.path.join(SCRIPTS, navn), "r", encoding="utf-8") as handle:
kilde = handle.read()
try:
ast.parse(kilde, filename=navn, feature_version=(3, 7))
except SyntaxError as feil:
pytest.fail(
"scripts/%s does not parse on Python 3.7: %s (line %s). It has "
"to refuse an old interpreter in words, not in a traceback."
% (navn, feil.msg, feil.lineno)
)
def test_the_launcher_looks_for_a_virtualenv_where_each_platform_puts_one():
import jobbsok_tools_launch as launcher
assert launcher.venv_python("/x/venv", "posix") == os.path.join(
"/x/venv", "bin", "python"
)
# Windows venvs have Scripts\python.exe and no python3 at all, so a port
# that kept `bin/python3` would find nothing on the one platform it was
# written for.
windows = launcher.venv_python("C:\\x\\venv", "nt")
assert windows.endswith(os.path.join("Scripts", "python.exe")), windows
assert "bin" not in windows.split(os.sep)
def test_the_launcher_asks_for_the_interpreter_name_each_platform_uses():
import jobbsok_tools_launch as launcher
assert launcher.path_candidates("posix") == ("python3",), (
"POSIX must keep exactly what the shell version tried; anything more "
"is a behaviour change smuggled in under a port"
)
assert launcher.path_candidates("nt") == ("python", "py"), (
"a bare python3 on Windows is the Microsoft Store stub, which opens a "
"shop instead of running anything"
)
def test_the_launcher_still_refuses_the_path_interpreter_that_fails_the_gate():
"""The M9 property, as a unit rather than a subprocess.
A mutation that reinstated an ungated PATH fallback is the failure this
guards, and it has to hold on both platforms' interpreter names -- the
subprocess test next door can only ever exercise the host's own.
"""
import jobbsok_tools_launch as launcher
funnet = []
def which(navn):
funnet.append(navn)
return "/falsk/" + os.path.basename(navn)
tolk = launcher.resolve_interpreter(
{}, "/ingen/plugin/rot", which=which, gate=lambda _tolk: False
)
assert tolk is None, (
"the launcher returned %r from a candidate that failed the version "
"gate; every branch is gated or none of them are" % (tolk,)
)
assert funnet, "the launcher never looked at a candidate at all"
# And the other direction, so the test above cannot pass by never looking.
tolk = launcher.resolve_interpreter(
{}, "/ingen/plugin/rot", which=which, gate=lambda _tolk: True
)
assert tolk is not None
def test_an_interpreter_named_by_the_operator_is_refused_and_never_skipped():
import jobbsok_tools_launch as launcher
with pytest.raises(launcher.Avvist) as fanget:
launcher.resolve_interpreter(
{"JOBBSOK_PYTHON": "/finnes/ikke"},
"/ingen/plugin/rot",
which=lambda navn: None,
gate=lambda _tolk: True,
)
assert "not executable" in fanget.value.linjer[0]
with pytest.raises(launcher.Avvist) as fanget:
launcher.resolve_interpreter(
{"JOBBSOK_PYTHON": "/finnes/men/er/gammel"},
"/ingen/plugin/rot",
which=lambda navn: navn,
gate=lambda _tolk: False,
)
assert "3.10" in " ".join(fanget.value.linjer), fanget.value.linjer
def test_the_bootstrap_builds_the_environment_where_a_plugin_update_cannot_erase_it():
import bootstrap
katalog, art = bootstrap.venv_location({"CLAUDE_PLUGIN_DATA": "/data"}, "/repo")
assert katalog == os.path.join("/data", "venv")
assert "plugin-data" in art
katalog, art = bootstrap.venv_location({}, "/repo")
assert katalog == os.path.join("/repo", ".venv")
assert "repo root" in art
assert bootstrap.venv_python("/x/venv", "nt").endswith(
os.path.join("Scripts", "python.exe")
)
def test_the_bootstrap_refuses_an_interpreter_below_the_floor():
import bootstrap
with pytest.raises(bootstrap.Avvist) as fanget:
bootstrap.resolve_interpreter(
{"JOBBSOK_PYTHON": "/finnes/ikke"}, which=lambda navn: None
)
assert "no such interpreter" in fanget.value.linjer[0]
with pytest.raises(bootstrap.Avvist) as fanget:
bootstrap.resolve_interpreter(
{"JOBBSOK_PYTHON": "/gammel"},
which=lambda navn: navn,
gate=lambda _tolk: False,
)
assert "3.10" in " ".join(fanget.value.linjer), fanget.value.linjer
# And at the process boundary, where an adopter meets it: a named
# interpreter that is not there must exit non-zero rather than building an
# environment on something else.
kjort = subprocess.run(
[sys.executable, os.path.join(SCRIPTS, "bootstrap.py")],
env=dict(os.environ, JOBBSOK_PYTHON="/finnes/ikke/heller"),
capture_output=True,
text=True,
)
assert kjort.returncode != 0, kjort.stdout
assert "JOBBSOK_PYTHON" in kjort.stderr
def test_the_readme_install_block_carries_a_windows_route():
with open(README, "r", encoding="utf-8") as handle:
tekst = handle.read()
installer = tekst.split("## Install", 1)[1].split("\n## ", 1)[0]
assert "Windows" in installer, "the install block never mentions Windows"
for skall in ("WSL", "Git Bash"):
assert skall not in installer, (
"the install block still routes Windows through %r. A POSIX shell "
"as a prerequisite is precisely the gap this port closed." % skall
)
def test_the_probe_checker_still_recognises_a_leaked_archive_entry():
"""The fifth entry point, and the one piece of logic in it worth gating.
`cowork_probe_check` measures this Mac and only this Mac -- what it reads
lives under ~/Library -- so what the port had to preserve was not
portability but the predicate that decides whether the probe vehicle
shipped something local.
"""
import cowork_probe_check as probe
for lekk in (".git/config", "STATE.md", ".venv/bin/python", ".claude/x.md"):
assert probe.lekkasje(lekk), "%r would be shipped unnoticed" % lekk
for greit in ("skills/probe-versjon/SKILL.md", ".claude-plugin/plugin.json"):
assert not probe.lekkasje(greit), "%r is not a leak" % greit
assert probe.antall_svar("- Host-MCP: ja\n- python3: nei\n") == 2
assert probe.antall_svar("- Host-MCP: kanskje\n") == 0