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

@ -42,7 +42,7 @@ from helpers import mcp_stdio
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(REPO, "scripts")
GOLDEN = os.path.join(REPO, "tests", "golden", "jobbsok-tools.tools.json")
LAUNCHER = os.path.join(SCRIPTS, "jobbsok_tools_launch.sh")
LAUNCHER = os.path.join(SCRIPTS, "jobbsok_tools_launch.py")
PROFIL_FIXTURE = ("profiles", "01-gyldig.md")
ANNONSE_FIXTURE = ("listings", "01-alt-passer.md")
@ -239,20 +239,44 @@ def test_selvsjekk_reports_the_interpreter_and_the_pinned_guard(klient, arbeidso
assert "build_stamp" in rapport
def falsk_tolk(katalog, navn, versjon="3.9.6"):
"""Write a program that fails the version gate, in the host's own form.
The gate probes a candidate by running it, so a fake has to be a program
and not a Python file -- a .py would be run by the real interpreter and
would measure that one instead. On Windows that means a .cmd, which
shutil.which resolves through PATHEXT exactly as it resolves python.exe.
The two forms differ in one way, deliberately: cmd cannot inspect the
probe's arguments without re-quoting a string full of parentheses and
semicolons, so the Windows form answers unconditionally. Both fail the
gate, which is the only thing the gate reads.
The Windows branch is written but NOT measured -- this repository's suite
has only ever run on macOS.
"""
if os.name == "nt":
sti = katalog / (navn + ".cmd")
sti.write_text("@echo off\r\necho %s\r\nexit /b 1\r\n" % versjon)
else:
sti = katalog / navn
sti.write_text(
"#!/bin/sh\n"
"# Answers the version probe as %s and fails the version gate.\n"
'case "$*" in\n'
" *print*) echo '%s'; exit 0 ;;\n"
"esac\n"
"exit 1\n" % (versjon, versjon)
)
sti.chmod(0o755)
return sti
def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one(tmp_path):
falsk = tmp_path / "python3.9"
falsk.write_text(
"#!/bin/bash\n"
"# Answers the version probe as 3.9.6 and fails the version gate.\n"
'case "$*" in\n'
" *print*) echo '3.9.6'; exit 0 ;;\n"
"esac\n"
"exit 1\n"
)
falsk.chmod(0o755)
falsk = falsk_tolk(tmp_path, "python3.9")
avvist = subprocess.run(
["bash", LAUNCHER],
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(falsk)),
input="",
capture_output=True,
@ -272,8 +296,7 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
# PATH fallback survived the JOBBSOK_PYTHON case untouched.
falsk_bin = tmp_path / "bin"
falsk_bin.mkdir()
(falsk_bin / "python3").write_text(falsk.read_text())
(falsk_bin / "python3").chmod(0o755)
falsk_tolk(falsk_bin, "python3")
tom_rot = tmp_path / "tom-plugin-rot"
(tom_rot / "scripts").mkdir(parents=True)
@ -281,9 +304,9 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
miljo.pop("JOBBSOK_PYTHON", None)
miljo.pop("CLAUDE_PLUGIN_DATA", None)
miljo["CLAUDE_PLUGIN_ROOT"] = str(tom_rot)
miljo["PATH"] = "%s:/usr/bin:/bin" % falsk_bin
miljo["PATH"] = str(falsk_bin) + os.pathsep + os.path.dirname(sys.executable)
uten_kandidat = subprocess.run(
["/bin/bash", LAUNCHER],
[sys.executable, LAUNCHER],
env=miljo,
input="",
capture_output=True,
@ -314,7 +337,7 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
+ "\n"
)
servert = subprocess.run(
["bash", LAUNCHER],
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=sys.executable),
input=forespoersler,
capture_output=True,