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

@ -1,8 +1,8 @@
{ {
"mcpServers": { "mcpServers": {
"jobbsok-tools": { "jobbsok-tools": {
"command": "bash", "command": "${JOBBSOK_LAUNCH_PYTHON:-python3}",
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/jobbsok_tools_launch.sh"] "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/jobbsok_tools_launch.py"]
} }
} }
} }

View file

@ -4,4 +4,8 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- Cross-platform port: every entry point is Python and the standard
library, and `.mcp.json` no longer starts the tool server through a
shell, so the plugin installs and runs on Windows. See
`docs/cross-platform-port.md`.
- Repository created; build brief in `docs/build-brief.md`; manifest only. - Repository created; build brief in `docs/build-brief.md`; manifest only.

View file

@ -32,6 +32,13 @@ Code, identifiers, file names and docs are English.
- **No autonomous scheduling.** Nothing polls, nothing runs in background. - **No autonomous scheduling.** Nothing polls, nothing runs in background.
- **Build in milestone order (brief §11, M1–M6).** Do not skip ahead. - **Build in milestone order (brief §11, M1–M6).** Do not skip ahead.
- **`${CLAUDE_PLUGIN_ROOT}` for every intra-plugin path.** Never hardcode. - **`${CLAUDE_PLUGIN_ROOT}` for every intra-plugin path.** Never hardcode.
- **No shell entry point, ever.** Every entry point is Python and the
standard library, and `.mcp.json` starts the server by naming an
interpreter, never a shell. The plugin runs on macOS, Linux and
Windows; a new `scripts/*.sh` breaks Windows and the suite says so.
The interpreter name is `${JOBBSOK_LAUNCH_PYTHON:-python3}` because
the plugin schema has no platform-conditional command, and that
variable is NOT `JOBBSOK_PYTHON` — see `docs/cross-platform-port.md`.
## Commands ## Commands

View file

@ -39,12 +39,12 @@ will not, and that is not a cosmetic loss: the guard is the boundary every
untrusted listing and email body passes at the write, so an install without it untrusted listing and email body passes at the write, so an install without it
is a reader rather than a workspace. is a reader rather than a workspace.
**Platform support.** macOS and Linux work today. **Windows does not yet**, and **Platform support.** macOS, Linux and Windows. Every entry point is Python
the reason is specific rather than general: this plugin's entry points are shell and the standard library; nothing here needs a POSIX shell, `zip` or the
scripts and `.mcp.json` starts the tool server through `bash`, which stock coreutils. On Windows the interpreter is normally called `python` or `py` --
Windows does not have. The Python underneath is already platform-clean, so this `python3` is not a stock Windows name -- so the commands below are given in
is a packaging gap being closed rather than a rewrite. Until it is closed, both spellings, and the one setting that needs saying is in *Windows: name the
Windows needs a POSIX shell (WSL or Git Bash) and is not a supported target. interpreter* further down.
The plugin runs on two surfaces, and they install differently. The plugin runs on two surfaces, and they install differently.
@ -53,7 +53,8 @@ build the archive from this repository's explicit include list, then upload it
through *Customize -> Plugins*: through *Customize -> Plugins*:
```bash ```bash
bash scripts/package_plugin.sh # writes jobbsok.plugin python3 scripts/package_plugin.py # writes jobbsok.plugin
python scripts\package_plugin.py # Windows
``` ```
Never build that archive with a recursive zip of the repository root: it would Never build that archive with a recursive zip of the repository root: it would
@ -72,20 +73,41 @@ and the host tool server run on. This is also the step that installs the
ingestion guard, and it prints the version it resolved: ingestion guard, and it prints the version it resolved:
```bash ```bash
bash scripts/bootstrap.sh # add --med-xlsx for the spreadsheet export python3 scripts/bootstrap.py # add --med-xlsx for the spreadsheet export
python scripts\bootstrap.py # Windows
``` ```
In an installed copy this builds the environment under `$CLAUDE_PLUGIN_DATA`, In an installed copy this builds the environment under `$CLAUDE_PLUGIN_DATA`,
which survives a plugin update; the packaged archive deliberately excludes the which survives a plugin update; the packaged archive deliberately excludes the
virtualenv, so the bootstrap is the supported route there. Without it the virtualenv, so the bootstrap is the supported route there. Without it the
`jobbsok-tools` server has no interpreter to run on and refuses to start `jobbsok-tools` server has no interpreter to run on and refuses to start
rather than serving on whatever `python3` the PATH offers. `JOBBSOK_PYTHON` rather than serving on whatever interpreter the PATH offers.
overrides the interpreter choice.
**Windows: name the interpreter.** `.mcp.json` has to spell the interpreter
that starts the tool server, and the plugin format offers no way to spell it
differently per platform -- so it reads `${JOBBSOK_LAUNCH_PYTHON:-python3}`.
macOS and Linux need nothing. On Windows, set it once:
```
setx JOBBSOK_LAUNCH_PYTHON python
```
Then restart the app, because a process reads its environment at startup.
Without this the connector will not appear: `python3` is not a Windows name,
and where the Microsoft Store alias is enabled it is worse than absent -- it
opens the Store rather than running anything.
`JOBBSOK_LAUNCH_PYTHON` and `JOBBSOK_PYTHON` are different settings and are not
interchangeable. The first only says how to *start* the launcher. The second
overrides which interpreter the server *runs on*, and it wins over the
environment the bootstrap built -- so setting it to work around a spelling
problem would quietly leave the server without the ingestion guard.
**Finally, the workspace** -- the plugin never guesses at a location: **Finally, the workspace** -- the plugin never guesses at a location:
```bash ```bash
export JOBBSOK_WORKSPACE=~/jobbsok-workspace # or pass --workspace export JOBBSOK_WORKSPACE=~/jobbsok-workspace # or pass --workspace
setx JOBBSOK_WORKSPACE %USERPROFILE%\jobbsok-workspace # Windows
``` ```
The `kandidatprofil` skill scaffolds the tree on first run. The workspace is The `kandidatprofil` skill scaffolds the tree on first run. The workspace is

View file

@ -43,7 +43,7 @@ become a file. (*The guard pipeline lands at M3.*)
| --- | --- | --- | | --- | --- | --- |
| Workspace containment | `jobbsok_lib.paths.safe_join`, which resolves symlinks before comparing | M1 | | Workspace containment | `jobbsok_lib.paths.safe_join`, which resolves symlinks before comparing | M1 |
| Explicit workspace, never a guessed one | `jobbsok_lib.paths.workspace_root`; every MCP tool requires it | M1 | | Explicit workspace, never a guessed one | `jobbsok_lib.paths.workspace_root`; every MCP tool requires it | M1 |
| Interpreter floor | `scripts/jobbsok_tools_launch.sh` refuses anything below Python 3.10 | M1 | | Interpreter floor | `scripts/jobbsok_tools_launch.py` refuses anything below Python 3.10 | M1 |
| Build identity | `BUILD_STAMP`, so a cached upload cannot pass for a fresh one | M1 | | Build identity | `BUILD_STAMP`, so a cached upload cannot pass for a fresh one | M1 |
| Ingestion | `scripts/guard_ingest.py` at the point of the write | M3 | | Ingestion | `scripts/guard_ingest.py` at the point of the write | M3 |
| Browser read-only | deny list plus a `PreToolUse` hook | M3 | | Browser read-only | deny list plus a `PreToolUse` hook | M3 |

139
docs/cross-platform-port.md Normal file
View file

@ -0,0 +1,139 @@
# 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.

210
scripts/bootstrap.py Normal file
View file

@ -0,0 +1,210 @@
"""Build the jobbsok Python environment and pin the ingestion guard.
Python rather than bash, and standard library only. The shell version made the
first step of the install impossible on stock Windows, which is the one step
nobody can skip -- without it there is no interpreter for the host tool server
and no guard for M3 ingestion. Like the launcher, this file is written to parse
on Python 3.7: it may be started by exactly the old interpreter it refuses, and
a refusal that is a SyntaxError is not a refusal.
Where the environment lives is load-bearing, not incidental. When
CLAUDE_PLUGIN_DATA is set the environment is built there, because that
directory survives a plugin update -- the plugin's own cache does not. A
repo-root-only .venv would leave every non-development install without the
guard, which is risk H2. The repo-root .venv is the development case.
One port decision, stated rather than smuggled: the shell version defaulted to
`python3` on PATH, and Windows has no such name. The default here is the
interpreter that started this file, which is the same intent and is more
predictable than a PATH lookup -- and it is gated on the version exactly as
before, so an install started on 3.9.6 is still refused rather than built.
Usage:
python3 scripts/bootstrap.py # dev dependencies
python3 scripts/bootstrap.py --med-xlsx # also the openpyxl extra
On Windows the interpreter is normally called `python`, not `python3`.
"""
import os
import shutil
import subprocess
import sys
MIN_MAJOR = 3
MIN_MINOR = 10
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
class Avvist(Exception):
"""An interpreter that cannot be built on, and the words to say so."""
def __init__(self, linjer):
Exception.__init__(self, linjer[0])
self.linjer = linjer
def parse_args(argv):
"""Return True when the xlsx extra was asked for; exit as the shell did."""
med_xlsx = False
for arg in argv:
if arg == "--med-xlsx":
med_xlsx = True
elif arg in ("-h", "--help"):
sys.stdout.write(__doc__)
raise SystemExit(0)
else:
sys.stderr.write("bootstrap: unknown argument: %s\n" % arg)
sys.stderr.write(
"bootstrap: usage: python3 scripts/bootstrap.py [--med-xlsx]\n"
)
raise SystemExit(2)
return med_xlsx
def venv_location(env, repo_root):
"""Return (directory, what kind of directory it is) for the environment."""
plugin_data = env.get("CLAUDE_PLUGIN_DATA")
if plugin_data:
return (
os.path.join(plugin_data, "venv"),
"plugin-data (survives a plugin update)",
)
return os.path.join(repo_root, ".venv"), "repo root (development)"
def venv_python(venv_dir, os_name=None):
"""The interpreter inside a virtualenv, in the layout this platform uses."""
if os_name is None:
os_name = os.name
if os_name == "nt":
return os.path.join(venv_dir, "Scripts", "python.exe")
return os.path.join(venv_dir, "bin", "python")
def version_ok(tolk):
"""True when ``tolk`` reports at least the floor."""
return 0 == subprocess.call(
[
tolk,
"-c",
"import sys; sys.exit(0 if sys.version_info >= (%d, %d) else 1)"
% (MIN_MAJOR, MIN_MINOR),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def version_of(tolk):
"""The version ``tolk`` reports, or "ukjent" when it will not say."""
try:
ut = subprocess.run(
[tolk, "-c", "import sys; print('%d.%d.%d' % sys.version_info[:3])"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
except OSError:
return "ukjent"
if ut.returncode != 0 or not ut.stdout.strip():
return "ukjent"
return ut.stdout.strip()
def resolve_interpreter(env, which=None, gate=None):
"""The interpreter to build the environment on.
Refuses rather than building an environment that parses everything and
fails at runtime: under a GUI-spawned process with an empty PATH, the
interpreter here can be 3.9.6.
"""
if which is None:
which = shutil.which
if gate is None:
gate = version_ok
navngitt = env.get("JOBBSOK_PYTHON")
tolk = navngitt or sys.executable
funnet = which(tolk)
if not funnet:
raise Avvist(
[
"bootstrap: no such interpreter: %s" % tolk,
"bootstrap: set JOBBSOK_PYTHON to a Python %d.%d+ executable."
% (MIN_MAJOR, MIN_MINOR),
]
)
if not gate(funnet):
raise Avvist(
[
"bootstrap: %s is Python %s; %d.%d+ is required."
% (tolk, version_of(funnet), MIN_MAJOR, MIN_MINOR),
"bootstrap: refusing to build an environment on it. Set "
"JOBBSOK_PYTHON instead.",
]
)
return funnet
def main(argv, env=None):
if env is None:
env = os.environ
med_xlsx = parse_args(argv)
try:
tolk = resolve_interpreter(env)
except Avvist as avvist:
for linje in avvist.linjer:
sys.stderr.write(linje + "\n")
return 1
venv_dir, art = venv_location(env, REPO_ROOT)
sys.stdout.write("bootstrap: interpreter %s\n" % tolk)
sys.stdout.write("bootstrap: environment %s [%s]\n" % (venv_dir, art))
venv_py = venv_python(venv_dir)
if not os.path.isfile(venv_py):
forelder = os.path.dirname(venv_dir)
if forelder and not os.path.isdir(forelder):
os.makedirs(forelder)
kode = subprocess.call([tolk, "-m", "venv", venv_dir])
if kode != 0:
return kode
pyproject = os.path.join(REPO_ROOT, "pyproject.toml")
# PEP 735 dependency groups need pip 25.1+; the upgrade is what makes the
# --group line below safe, and a pip too old to understand it fails loudly.
trinn = [
[venv_py, "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
[venv_py, "-m", "pip", "install", "--quiet", "--group", pyproject + ":dev"],
]
if med_xlsx:
trinn.append(
[venv_py, "-m", "pip", "install", "--quiet", "--group", pyproject + ":xlsx"]
)
for kommando in trinn:
kode = subprocess.call(kommando)
if kode != 0:
return kode
rapport = (
"import sys\n"
"import llm_ingestion_guard\n"
"print('bootstrap: sys.executable %s' % sys.executable)\n"
"print('bootstrap: python %s' % sys.version.split()[0])\n"
"print('bootstrap: guard %s' % llm_ingestion_guard.__version__)\n"
)
kode = subprocess.call([venv_py, "-c", rapport])
if kode != 0:
return kode
sys.stdout.write("bootstrap: ok\n")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,91 +0,0 @@
#!/bin/bash
# Build the jobbsok Python environment and pin the ingestion guard.
#
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2,
# and a multibyte character has crashed a `set -u` script here before.
#
# Where the environment lives is load-bearing, not incidental. When
# CLAUDE_PLUGIN_DATA is set the environment is built there, because that
# directory survives a plugin update -- the plugin's own cache does not. A
# repo-root-only .venv would leave every non-development install without the
# guard, which is risk H2. The repo-root .venv is the development case.
#
# Usage:
# bash scripts/bootstrap.sh # dev dependencies
# bash scripts/bootstrap.sh --med-xlsx # also the openpyxl extra
set -eu
MIN_MAJOR=3
MIN_MINOR=10
WITH_XLSX=0
for arg in "$@"; do
case "$arg" in
--med-xlsx) WITH_XLSX=1 ;;
-h|--help)
sed -n '2,20p' "$0"
exit 0 ;;
*)
echo "bootstrap: unknown argument: $arg" >&2
echo "bootstrap: usage: bash scripts/bootstrap.sh [--med-xlsx]" >&2
exit 2 ;;
esac
done
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
if [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
VENV_DIR="${CLAUDE_PLUGIN_DATA}/venv"
VENV_KIND="plugin-data (survives a plugin update)"
else
VENV_DIR="${REPO_ROOT}/.venv"
VENV_KIND="repo root (development)"
fi
# Refuse an interpreter that is too old rather than building an environment
# that parses everything and fails at runtime. Under a GUI-spawned process with
# an empty PATH, python3 here can resolve to 3.9.6.
PYTHON_BIN="${JOBBSOK_PYTHON:-python3}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "bootstrap: no such interpreter: $PYTHON_BIN" >&2
echo "bootstrap: set JOBBSOK_PYTHON to a Python ${MIN_MAJOR}.${MIN_MINOR}+ executable." >&2
exit 1
fi
if ! "$PYTHON_BIN" -c "import sys; sys.exit(0 if sys.version_info >= ($MIN_MAJOR, $MIN_MINOR) else 1)"; then
FOUND=$("$PYTHON_BIN" -c "import sys; print('%d.%d.%d' % sys.version_info[:3])")
echo "bootstrap: $PYTHON_BIN is Python $FOUND; ${MIN_MAJOR}.${MIN_MINOR}+ is required." >&2
echo "bootstrap: refusing to build an environment on it. Set JOBBSOK_PYTHON instead." >&2
exit 1
fi
echo "bootstrap: interpreter $("$PYTHON_BIN" -c 'import sys; print(sys.executable)')"
echo "bootstrap: environment $VENV_DIR [$VENV_KIND]"
if [ ! -x "${VENV_DIR}/bin/python" ]; then
mkdir -p "$(dirname "$VENV_DIR")"
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
VENV_PY="${VENV_DIR}/bin/python"
"$VENV_PY" -m pip install --quiet --upgrade pip
# PEP 735 dependency groups need pip 25.1+; the upgrade above is what makes
# this line safe, and a pip too old to understand --group fails loudly here.
"$VENV_PY" -m pip install --quiet --group "${REPO_ROOT}/pyproject.toml:dev"
if [ "$WITH_XLSX" -eq 1 ]; then
"$VENV_PY" -m pip install --quiet --group "${REPO_ROOT}/pyproject.toml:xlsx"
fi
"$VENV_PY" - <<'PYEOF'
import sys
import llm_ingestion_guard
print("bootstrap: sys.executable %s" % sys.executable)
print("bootstrap: python %s" % sys.version.split()[0])
print("bootstrap: guard %s" % llm_ingestion_guard.__version__)
PYEOF
echo "bootstrap: ok"

94
scripts/build_stamp.py Normal file
View file

@ -0,0 +1,94 @@
"""Write the short hash of the commit this build came from to BUILD_STAMP.
Python rather than bash, and standard library only. Stock Windows has no bash
and none of the coreutils the shell version reached for, so a shell entry point
made the plugin uninstallable there -- for adopters who are not on this Mac,
that is the whole install, not a rough edge.
Why a stamp and not the manifest version: Cowork caches an uploaded plugin, and
plugin.json's version does not change between milestones, so a stale build
would echo the right number and every answer after it would be about some other
build (risk H11). The short hash moves on every commit, which is exactly the
property the check needs.
The stamp is a build artefact and is gitignored. It is regenerated by
scripts/package_plugin.py before every archive; a committed stamp would be
false the instant the next commit moved HEAD.
Usage:
python3 scripts/build_stamp.py # write <plugin root>/BUILD_STAMP
python3 scripts/build_stamp.py --ut <sti> # write somewhere else
On Windows the interpreter is normally called `python`, not `python3`.
"""
import os
import subprocess
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
def parse_args(argv):
"""Return the output path, or exit the way the shell version exited.
Kept as a function so the argument handling is reachable from a test
without starting a subprocess for every case.
"""
ut = os.path.join(REPO_ROOT, "BUILD_STAMP")
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--ut":
if i + 1 >= len(argv):
sys.stderr.write("build_stamp: --ut needs a path\n")
raise SystemExit(2)
ut = argv[i + 1]
i += 2
elif arg in ("-h", "--help"):
sys.stdout.write(__doc__)
raise SystemExit(0)
else:
sys.stderr.write("build_stamp: unknown argument: %s\n" % arg)
raise SystemExit(2)
return ut
def git(repo, *args):
"""Run git in ``repo`` and return (returncode, stdout)."""
kjort = subprocess.run(
["git", "-C", repo] + list(args),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
return kjort.returncode, kjort.stdout.strip()
def main(argv):
ut = parse_args(argv)
kode, _ = git(REPO_ROOT, "rev-parse", "--git-dir")
if kode != 0:
sys.stderr.write("build_stamp: %s is not a git working tree.\n" % REPO_ROOT)
sys.stderr.write(
"build_stamp: the stamp is the commit this build came from; "
"there is nothing to stamp.\n"
)
return 1
kode, stempel = git(REPO_ROOT, "rev-parse", "--short", "HEAD")
if kode != 0:
sys.stderr.write("build_stamp: git rev-parse --short HEAD failed.\n")
return 1
# Newline and nothing else: the skills read this file and echo it.
with open(ut, "w", encoding="utf-8", newline="\n") as handle:
handle.write(stempel + "\n")
sys.stdout.write("build_stamp: %s -> %s\n" % (stempel, ut))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,52 +0,0 @@
#!/bin/bash
# Write the short hash of the commit this build came from to BUILD_STAMP.
#
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2.
#
# Why a stamp and not the manifest version: Cowork caches an uploaded plugin,
# and plugin.json's version does not change between milestones, so a stale
# build would echo the right number and every answer after it would be about
# some other build (risk H11). The short hash moves on every commit, which is
# exactly the property the check needs.
#
# The stamp is a build artefact and is gitignored. It is regenerated by
# scripts/package_plugin.sh before every archive; a committed stamp would be
# false the instant the next commit moved HEAD.
#
# Usage:
# bash scripts/build_stamp.sh # write <plugin root>/BUILD_STAMP
# bash scripts/build_stamp.sh --ut <sti> # write somewhere else
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
OUT="${REPO_ROOT}/BUILD_STAMP"
while [ $# -gt 0 ]; do
case "$1" in
--ut)
if [ $# -lt 2 ]; then
echo "build_stamp: --ut needs a path" >&2
exit 2
fi
OUT="$2"
shift 2 ;;
-h|--help)
sed -n '2,20p' "$0"
exit 0 ;;
*)
echo "build_stamp: unknown argument: $1" >&2
exit 2 ;;
esac
done
if ! git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
echo "build_stamp: $REPO_ROOT is not a git working tree." >&2
echo "build_stamp: the stamp is the commit this build came from; there is nothing to stamp." >&2
exit 1
fi
STAMP=$(git -C "$REPO_ROOT" rev-parse --short HEAD)
printf '%s\n' "$STAMP" > "$OUT"
echo "build_stamp: $STAMP -> $OUT"

View file

@ -0,0 +1,260 @@
"""Report, from the host, how far the Cowork probe has actually got.
The probe asks four questions that only the operator can answer in a Cowork
session. But the host-MCP one leaves hard traces on this Mac, and a trace is a
measurement where "what did you see in the UI" is a recollection. This script
reads those traces so each step is verified before the next begins.
Python rather than bash: the shell version reached for find, grep, sed, awk and
unzip, and this repository no longer has a shell entry point anywhere. What it
INSPECTS is still macOS-only -- Claude's own log and session directories live
under ~/Library -- so this remains a tool for measuring this Mac. That is a
property of the question, not of the language it is written in.
Two earlier queries in this script were WRONG and the measurement caught both;
the corrections are written down here so they are not re-derived:
1. It searched for a directory named after the plugin. Cowork installs into
rpm/plugin_<opaque id>/, so the name never appears in a path. The manifest
at rpm/manifest.json is the index; read that instead.
2. It expected ~/Library/Logs/Claude/mcp-server-<name>.log. That naming is
for Claude Desktop's own connectors. A plugin's MCP server is logged by
LocalMcpServerManager into main.log instead. No file at the guessed path
meant the guess was wrong, not that nothing had happened.
It reports; it does not gate. Exit 0 when every check has landed, 1 while any
is pending. Read-only.
"""
import json
import os
import re
import subprocess
import sys
import zipfile
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
PROBE_DOC = os.path.join(REPO_ROOT, "docs", "cowork-probe.md")
FIXTURE = os.path.join(REPO_ROOT, "tests", "fixtures", "cowork-probe")
MAIN_LOG = os.path.expanduser("~/Library/Logs/Claude/main.log")
SESSIONS = os.path.expanduser(
"~/Library/Application Support/Claude/local-agent-mode-sessions"
)
PLUGIN_NAME = "jobbsok-probe"
SERVER_KEY = "plugin:jobbsok-probe:probe-tools"
#: An archive entry that would mean the probe vehicle leaked something local.
LEKKASJE = re.compile(r"^\.git|STATE\.md|\.venv|^\.claude/")
#: One of the four answers, written down in the form the probe doc asks for.
SVAR = re.compile(
r"^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT): +(lokal VM|sky|ja|nei)\b",
re.MULTILINE,
)
def lekkasje(navn):
"""True when an archive entry name is one the probe vehicle must not ship."""
return LEKKASJE.search(navn) is not None
def antall_svar(tekst):
"""How many of the four probe questions are answered in ``tekst``."""
return len(SVAR.findall(tekst))
def antall_filer(katalog):
n = 0
for _dirpath, _dirnames, filenames in os.walk(katalog):
n += len(filenames)
return n
def les_tekst(sti):
"""The file's text, or None when it is not there. Never raises on encoding."""
try:
with open(sti, "r", encoding="utf-8", errors="replace") as handle:
return handle.read()
except IOError:
return None
def installert_kopi(sessions, navn):
"""Find ``navn`` in any rpm/manifest.json under ``sessions``.
Returns (directory, id, marketplace, updatedAt) or None. The manifest is
the index -- the install directory is named after an opaque id, so the
plugin's own name never appears in a path.
"""
for dirpath, _dirnames, filenames in os.walk(sessions):
if os.path.basename(dirpath) != "rpm" or "manifest.json" not in filenames:
continue
try:
with open(os.path.join(dirpath, "manifest.json"), "r", encoding="utf-8") as h:
data = json.load(h)
except (IOError, ValueError):
continue
for p in data.get("plugins", []):
if p.get("name") == navn:
return (
os.path.join(dirpath, p["id"]),
p["id"],
p.get("marketplaceName", "?"),
p.get("updatedAt", "?"),
)
return None
class Rapport(object):
def __init__(self):
self.pending = 0
def done(self, tekst):
sys.stdout.write(" [DONE] %s\n" % tekst)
def pend(self, tekst):
sys.stdout.write(" [PENDING] %s\n" % tekst)
self.pending += 1
def info(self, tekst):
sys.stdout.write(" %s\n" % tekst)
def main(argv):
arkiv = os.environ.get("JOBBSOK_PROBE_ARCHIVE", "/tmp/jobbsok-probe.plugin")
r = Rapport()
sys.stdout.write(
"\n=== 1. Probe vehicle and archive ============================================\n"
)
n = antall_filer(FIXTURE)
if n == 4:
r.done("probe vehicle: 4 files under tests/fixtures/cowork-probe")
else:
r.pend("probe vehicle: expected 4 files, found %d" % n)
if os.path.isfile(arkiv):
try:
with zipfile.ZipFile(arkiv) as pakke:
oppforinger = pakke.namelist()
except zipfile.BadZipFile:
oppforinger = None
if oppforinger is None:
r.pend("archive: %s is not a readable zip - rebuild it" % arkiv)
else:
lekk = [o for o in oppforinger if lekkasje(o)]
if len(oppforinger) == 4 and not lekk:
r.done("archive: %s (4 files, no leaked entries)" % arkiv)
else:
r.pend(
"archive: %s has %d entries and %d leaked ones - rebuild it"
% (arkiv, len(oppforinger), len(lekk))
)
else:
r.pend("archive not built: %s" % arkiv)
sys.stdout.write(
"\n=== 2. Is jobbsok-probe installed in Cowork? =================================\n"
)
funnet = installert_kopi(SESSIONS, PLUGIN_NAME)
if funnet:
katalog, plugin_id, marked, oppdatert = funnet
r.done(
"listed in rpm/manifest.json as %s (marketplace: %s, updated %s)"
% (plugin_id, marked, oppdatert)
)
f = antall_filer(katalog)
if f == 4:
r.done("installed copy holds 4 files, matching the archive")
else:
r.pend("installed copy holds %d files, expected 4" % f)
manifest = os.path.join(katalog, ".claude-plugin", "plugin.json")
v = None
tekst = les_tekst(manifest)
if tekst:
try:
v = json.loads(tekst)["version"]
except (ValueError, KeyError):
v = None
if v == "0.0.1":
r.done("installed manifest version is 0.0.1 - not a stale cached build")
else:
r.pend(
"installed manifest version is %r, expected 0.0.1 - Cowork served "
"a cached build" % (v,)
)
else:
r.pend("no plugin named %s in any rpm/manifest.json" % PLUGIN_NAME)
sys.stdout.write(
"\n=== 3. Did Cowork spawn probe-tools on THIS Mac? =============================\n"
)
sys.stdout.write(" (the -Host-MCP: question, measured rather than recalled)\n")
logg = les_tekst(MAIN_LOG)
if logg is not None:
linjer = logg.splitlines()
conn = [l for l in linjer if ("Connected to " + SERVER_KEY) in l]
neg = [l for l in linjer if (SERVER_KEY + " negotiated protocol version") in l]
if conn:
r.done("connected: %s" % conn[-1].split("[LocalMcpServerManager] ")[-1])
else:
r.pend("main.log has no 'Connected to %s' line" % SERVER_KEY)
if neg:
r.done("handshake: negotiated%s" % neg[-1].split("negotiated", 1)[-1])
else:
r.pend("no protocol negotiation recorded")
if [l for l in linjer if "probe_ping" in l]:
r.done("probe_ping seen in the log")
else:
r.info("no probe_ping line in main.log yet - the connection lifecycle is")
r.info("logged there but an individual tool call may not be. Confirm the")
r.info("call by its answer in the Cowork chat, not by this line's absence.")
else:
r.pend("no main.log at %s" % MAIN_LOG)
try:
proc = subprocess.run(
["pgrep", "-fl", "probe_tools.py"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
treff = proc.stdout.splitlines()
except OSError:
treff = []
r.info("no pgrep on this platform - the log above is the record.")
if treff:
r.done("process alive: pid %s" % treff[0].split()[0])
tolk = re.search(r"/[^ ]*python3?", treff[0])
if tolk:
r.info("interpreter: %s (host, not sandbox)" % tolk.group(0))
else:
r.info("no probe_tools.py process at this instant - servers may be started on")
r.info("demand, so absence here is not a nei. The log above is the record.")
sys.stdout.write(
"\n=== 4. Are the four answers written down? ===================================\n"
)
doc = les_tekst(PROBE_DOC) or ""
c = antall_svar(doc)
if c == 4:
r.done("all four answered - plan Step 1 Verify passes")
else:
r.pend("%d of 4 answered in docs/cowork-probe.md" % c)
for linje in doc.splitlines():
if re.match(r"^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT):", linje):
sys.stdout.write(" %s\n" % linje)
sys.stdout.write(
"\n============================================================================\n"
)
if r.pending == 0:
sys.stdout.write("ALL CHECKS LANDED.\n")
return 0
sys.stdout.write("%d check(s) still pending.\n" % r.pending)
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,155 +0,0 @@
#!/bin/bash
# Report, from the host, how far the Cowork probe has actually got.
#
# The probe asks four questions that only the operator can answer in a Cowork
# session. But the host-MCP one leaves hard traces on this Mac, and a trace is
# a measurement where "what did you see in the UI" is a recollection. This
# script reads those traces so each step is verified before the next begins.
#
# Two earlier queries in this script were WRONG and the measurement caught
# both; the corrections are written down here so they are not re-derived:
#
# 1. It searched for a directory named after the plugin. Cowork installs into
# rpm/plugin_<opaque id>/, so the name never appears in a path. The
# manifest at rpm/manifest.json is the index; read that instead.
# 2. It expected ~/Library/Logs/Claude/mcp-server-<name>.log. That naming is
# for Claude Desktop's own connectors. A plugin's MCP server is logged by
# LocalMcpServerManager into main.log instead. No file at the guessed path
# meant the guess was wrong, not that nothing had happened.
#
# It reports; it does not gate. Exit 0 when every check has landed, 1 while any
# is pending. bash 3.2-clean, ASCII-only, read-only.
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
PROBE_DOC="$REPO_ROOT/docs/cowork-probe.md"
FIXTURE="$REPO_ROOT/tests/fixtures/cowork-probe"
ARCHIVE="${JOBBSOK_PROBE_ARCHIVE:-/tmp/jobbsok-probe.plugin}"
MAIN_LOG="$HOME/Library/Logs/Claude/main.log"
SESSIONS="$HOME/Library/Application Support/Claude/local-agent-mode-sessions"
PLUGIN_NAME="jobbsok-probe"
SERVER_KEY="plugin:jobbsok-probe:probe-tools"
pending=0
done_() { printf ' [DONE] %s\n' "$1"; }
pend() { printf ' [PENDING] %s\n' "$1"; pending=$((pending+1)); }
info() { printf ' %s\n' "$1"; }
printf '\n=== 1. Probe vehicle and archive ============================================\n'
n=$(find "$FIXTURE" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$n" = "4" ]; then
done_ "probe vehicle: 4 files under tests/fixtures/cowork-probe"
else
pend "probe vehicle: expected 4 files, found $n"
fi
if [ -f "$ARCHIVE" ]; then
a=$(unzip -Z1 "$ARCHIVE" 2>/dev/null | wc -l | tr -d ' ')
leak=$(unzip -Z1 "$ARCHIVE" 2>/dev/null | grep -cE '^\.git|STATE\.md|\.venv|^\.claude/')
if [ "$a" = "4" ] && [ "$leak" = "0" ]; then
done_ "archive: $ARCHIVE ($a files, no leaked entries)"
else
pend "archive: $ARCHIVE has $a entries and $leak leaked ones - rebuild it"
fi
else
pend "archive not built: $ARCHIVE"
fi
printf '\n=== 2. Is jobbsok-probe installed in Cowork? =================================\n'
# python3 does the walking: the path contains "Application Support", and an
# unquoted $(find ...) in a for-loop splits on that space. That exact bug is
# what made this check report "not installed" while the plugin was running.
INSTALL_DIR=$(python3 - "$SESSIONS" "$PLUGIN_NAME" <<'PYEOF'
import json, os, sys
sessions, name = sys.argv[1], sys.argv[2]
for dirpath, dirnames, filenames in os.walk(sessions):
if os.path.basename(dirpath) != "rpm" or "manifest.json" not in filenames:
continue
try:
data = json.load(open(os.path.join(dirpath, "manifest.json")))
except Exception:
continue
for p in data.get("plugins", []):
if p.get("name") == name:
print("%s\t%s\t%s\t%s" % (
os.path.join(dirpath, p["id"]), p["id"],
p.get("marketplaceName", "?"), p.get("updatedAt", "?")))
sys.exit(0)
PYEOF
)
if [ -n "$INSTALL_DIR" ]; then
D=$(printf '%s' "$INSTALL_DIR" | cut -f1)
ID=$(printf '%s' "$INSTALL_DIR" | cut -f2)
MP=$(printf '%s' "$INSTALL_DIR" | cut -f3)
AT=$(printf '%s' "$INSTALL_DIR" | cut -f4)
done_ "listed in rpm/manifest.json as $ID (marketplace: $MP, updated $AT)"
f=$(find "$D" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$f" = "4" ]; then
done_ "installed copy holds 4 files, matching the archive"
else
pend "installed copy holds $f files, expected 4"
fi
v=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" "$D/.claude-plugin/plugin.json" 2>/dev/null)
if [ "$v" = "0.0.1" ]; then
done_ "installed manifest version is 0.0.1 - not a stale cached build"
else
pend "installed manifest version is '$v', expected 0.0.1 - Cowork served a cached build"
fi
else
pend "no plugin named $PLUGIN_NAME in any rpm/manifest.json"
fi
printf '\n=== 3. Did Cowork spawn probe-tools on THIS Mac? =============================\n'
printf ' (the -Host-MCP: question, measured rather than recalled)\n'
if [ -f "$MAIN_LOG" ]; then
conn=$(grep -a "Connected to $SERVER_KEY" "$MAIN_LOG" 2>/dev/null | tail -1)
neg=$(grep -a "$SERVER_KEY negotiated protocol version" "$MAIN_LOG" 2>/dev/null | tail -1)
if [ -n "$conn" ]; then
done_ "connected: $(printf '%s' "$conn" | sed 's/.*\[LocalMcpServerManager\] //')"
else
pend "main.log has no 'Connected to $SERVER_KEY' line"
fi
if [ -n "$neg" ]; then
done_ "handshake: $(printf '%s' "$neg" | sed 's/.*negotiated/negotiated/')"
else
pend "no protocol negotiation recorded"
fi
call=$(grep -a 'probe_ping' "$MAIN_LOG" 2>/dev/null | tail -1)
if [ -n "$call" ]; then
done_ "probe_ping seen in the log"
else
info "no probe_ping line in main.log yet - the connection lifecycle is"
info "logged there but an individual tool call may not be. Confirm the"
info "call by its answer in the Cowork chat, not by this line's absence."
fi
else
pend "no main.log at $MAIN_LOG"
fi
proc=$(pgrep -fl probe_tools.py 2>/dev/null | head -1)
if [ -n "$proc" ]; then
done_ "process alive: pid $(printf '%s' "$proc" | awk '{print $1}')"
interp=$(printf '%s' "$proc" | grep -oE '/[^ ]*python3?' | head -1)
[ -n "$interp" ] && info "interpreter: $interp (host, not sandbox)"
else
info "no probe_tools.py process at this instant - servers may be started on"
info "demand, so absence here is not a nei. The log above is the record."
fi
printf '\n=== 4. Are the four answers written down? ===================================\n'
RE='^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT): +(lokal VM|sky|ja|nei)\b'
c=$(grep -cE "$RE" "$PROBE_DOC" 2>/dev/null)
if [ "$c" = "4" ]; then
done_ "all four answered - plan Step 1 Verify passes"
else
pend "$c of 4 answered in docs/cowork-probe.md"
fi
grep -E '^- (Sesjonsmodus|Host-MCP|python3|CLAUDE_PLUGIN_ROOT):' "$PROBE_DOC" 2>/dev/null | sed 's|^| |'
printf '\n============================================================================\n'
if [ "$pending" -eq 0 ]; then
printf 'ALL CHECKS LANDED.\n'
exit 0
fi
printf '%s check(s) still pending.\n' "$pending"
exit 1

View file

@ -0,0 +1,257 @@
"""Start the jobbsok-tools stdio MCP server on an interpreter that is new enough.
Python rather than bash, and standard library only. Stock Windows has no bash,
so a shell launcher meant `.mcp.json` could not start the server there at all.
This file is deliberately written to parse on an OLD interpreter -- no syntax
newer than 3.7 -- because the interpreter that starts it is exactly the one it
exists to distrust: refusing 3.9.6 is worth nothing if the refusal is a
SyntaxError.
Why this file exists at all, rather than .mcp.json naming an interpreter
directly: a GUI-spawned process on this Mac can resolve python3 to 3.9.6 under
an empty launchd PATH (risk H2). A server running on 3.9.6 is worse than no
server, because the degradation branch already covers an absent server and
nothing covers a silently wrong one. So every candidate below is gated on the
version, and when none passes the launcher refuses instead of falling through
to whatever interpreter the PATH happens to offer.
Resolution order, first candidate that passes the gate wins:
1. $JOBBSOK_PYTHON -- the manual override. Named explicitly by
the operator, so a version failure here
is refused outright rather than skipped:
falling back from an interpreter someone
asked for would hide their mistake.
2. $CLAUDE_PLUGIN_DATA/venv -- what scripts/bootstrap.py builds against
an installed plugin. This is the
supported route in an installed copy.
3. $CLAUDE_PLUGIN_ROOT/.venv -- the development environment. The packaged
archive excludes the virtualenv, so this
branch never fires in an installed copy.
4. python on PATH -- last resort, and only at 3.10 or newer.
Two environment variables, and they are NOT the same one:
JOBBSOK_LAUNCH_PYTHON names the interpreter that STARTS this file, and is
read by .mcp.json, never here. It exists because the
plugin MCP schema has no platform-conditional command
(measured 2026-09-05: an invented per-OS key is
silently discarded) and no literal name exists on both
macOS and Windows. It defaults to python3, so nothing
changes on macOS or Linux.
JOBBSOK_PYTHON names the interpreter to SERVE on, and is candidate 1
below. Setting it wins over the bootstrapped
virtualenv, so it must not be borrowed for the
spelling problem above: a Windows adopter setting it
merely to say `python` would serve without the guard.
The gate is on the VERSION, not on the guard being importable. That is the
shell version's semantics carried over unchanged, and it is a known weakness
(O4 in docs/cowork-probe.md): `selvsjekk` can report `guard_versjon: null`
while the server runs happily. It is harmless while nothing writes and becomes
a defect in M2. Fixing it is an M2 decision, not a porting one.
"""
import os
import shutil
import subprocess
import sys
MIN_MAJOR = 3
MIN_MINOR = 10
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
class Avvist(Exception):
"""An interpreter the operator named explicitly and that cannot be used.
Separate from "no candidate found" on purpose: skipping past a broken
interpreter someone asked for by name would hide their mistake.
"""
def __init__(self, linjer):
Exception.__init__(self, linjer[0])
self.linjer = linjer
def path_candidates(os_name=None):
"""What the interpreter is called on PATH, on ``os_name``.
POSIX keeps exactly what the shell version tried, so nothing changes
there. Windows has no `python3` unless Python came from the Microsoft
Store -- the python.org installer ships `python` and `py`, and a bare
`python3` there resolves to the Store stub, which opens a shop rather than
running anything.
"""
if os_name is None:
os_name = os.name
if os_name == "nt":
return ("python", "py")
return ("python3",)
def venv_python(venv_dir, os_name=None):
"""The interpreter inside a virtualenv, in the layout this platform uses.
POSIX venvs carry both `bin/python` and `bin/python3`; Windows venvs carry
`Scripts/python.exe` and no `python3` at all, so `python` is the one name
that is right everywhere.
"""
if os_name is None:
os_name = os.name
if os_name == "nt":
return os.path.join(venv_dir, "Scripts", "python.exe")
return os.path.join(venv_dir, "bin", "python")
def version_ok(tolk):
"""True when ``tolk`` reports at least the floor. Runs it; does not parse a name."""
return 0 == subprocess.call(
[
tolk,
"-c",
"import sys; sys.exit(0 if sys.version_info >= (%d, %d) else 1)"
% (MIN_MAJOR, MIN_MINOR),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def version_of(tolk):
"""The version ``tolk`` reports, or "ukjent" when it will not say."""
try:
ut = subprocess.run(
[tolk, "-c", "import sys; print('%d.%d.%d' % sys.version_info[:3])"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
except OSError:
return "ukjent"
if ut.returncode != 0 or not ut.stdout.strip():
return "ukjent"
return ut.stdout.strip()
def usable(kandidat, which=None, gate=None):
"""Return ``kandidat`` resolved as a program if it passes the gate, else None.
``which`` stands in for the shell version's `command -v`: given a path it
checks that path, given a bare name it searches PATH, and on Windows it
applies PATHEXT, which is how `python.exe` is found from `python`.
"""
if which is None:
which = shutil.which
if gate is None:
gate = version_ok
if not kandidat:
return None
funnet = which(kandidat)
if not funnet:
return None
if not gate(funnet):
return None
return funnet
def resolve_interpreter(env, plugin_root, which=None, gate=None):
"""Return the first candidate that passes the gate, or None.
Raises ``Avvist`` for the one case that must not fall through: an
interpreter the operator named in JOBBSOK_PYTHON that cannot be used.
"""
if which is None:
which = shutil.which
if gate is None:
gate = version_ok
navngitt = env.get("JOBBSOK_PYTHON")
if navngitt:
funnet = which(navngitt)
if not funnet:
raise Avvist(
[
"jobbsok-tools: JOBBSOK_PYTHON=%s is not executable." % navngitt,
"jobbsok-tools: refusing to start. Point it at a Python "
"%d.%d+ interpreter." % (MIN_MAJOR, MIN_MINOR),
]
)
if not gate(funnet):
raise Avvist(
[
"jobbsok-tools: JOBBSOK_PYTHON=%s is Python %s."
% (navngitt, version_of(funnet)),
"jobbsok-tools: %d.%d+ is required; refusing to start on it."
% (MIN_MAJOR, MIN_MINOR),
]
)
return funnet
plugin_data = env.get("CLAUDE_PLUGIN_DATA")
if plugin_data:
funnet = usable(venv_python(os.path.join(plugin_data, "venv")), which, gate)
if funnet:
return funnet
funnet = usable(venv_python(os.path.join(plugin_root, ".venv")), which, gate)
if funnet:
return funnet
for navn in path_candidates():
funnet = usable(navn, which, gate)
if funnet:
return funnet
return None
def main(argv, env=None):
if env is None:
env = os.environ
plugin_root = env.get("CLAUDE_PLUGIN_ROOT") or os.path.dirname(SCRIPT_DIR)
server = os.path.join(plugin_root, "scripts", "jobbsok_tools.py")
try:
tolk = resolve_interpreter(env, plugin_root)
except Avvist as avvist:
for linje in avvist.linjer:
sys.stderr.write(linje + "\n")
return 1
if not tolk:
sys.stderr.write(
"jobbsok-tools: found no Python %d.%d+ interpreter.\n"
% (MIN_MAJOR, MIN_MINOR)
)
sys.stderr.write(
"jobbsok-tools: tried JOBBSOK_PYTHON, $CLAUDE_PLUGIN_DATA/venv, "
"$CLAUDE_PLUGIN_ROOT/.venv and %s on PATH.\n"
% " or ".join(path_candidates())
)
sys.stderr.write(
"jobbsok-tools: run 'python3 scripts/bootstrap.py' against the "
"installed plugin, or set JOBBSOK_PYTHON.\n"
)
sys.stderr.write(
"jobbsok-tools: refusing to start rather than serving on an "
"interpreter below %d.%d.\n" % (MIN_MAJOR, MIN_MINOR)
)
return 1
if not os.path.isfile(server):
sys.stderr.write("jobbsok-tools: server not found at %s\n" % server)
return 1
# POSIX replaces this process, as the shell version's `exec` did: the MCP
# client's child stays the process it spawned, and killing it kills the
# server. Windows has no such replacement -- os.execv there starts a new
# process and lets this one exit, which would leave the client holding a
# dead pid and a live pipe -- so there the launcher stays as the parent.
if os.name != "nt":
os.execv(tolk, [tolk, server] + list(argv))
return subprocess.call([tolk, server] + list(argv))
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,107 +0,0 @@
#!/bin/bash
# Start the jobbsok-tools stdio MCP server on an interpreter that is new enough.
#
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2,
# and a multibyte character has crashed a `set -u` script here before.
#
# Why this file exists at all, rather than .mcp.json naming python3 directly:
# a GUI-spawned process on this Mac can resolve python3 to 3.9.6 under an
# empty launchd PATH (risk H2). A server running on 3.9.6 is worse than no
# server, because the degradation branch already covers an absent server and
# nothing covers a silently wrong one. So every candidate below is gated on
# the version, and when none passes the launcher refuses instead of falling
# through to whatever python3 the PATH happens to offer.
#
# Resolution order, first candidate that passes the gate wins:
# 1. $JOBBSOK_PYTHON -- the manual override. Named explicitly by
# the operator, so a version failure here
# is refused outright rather than skipped:
# falling back from an interpreter someone
# asked for would hide their mistake.
# 2. $CLAUDE_PLUGIN_DATA/venv -- what scripts/bootstrap.sh builds against
# an installed plugin. This is the
# supported route in an installed copy.
# 3. $CLAUDE_PLUGIN_ROOT/.venv -- the development environment. The packaged
# archive excludes the virtualenv, so this
# branch never fires in an installed copy.
# 4. python3 on PATH -- last resort, and only at 3.10 or newer.
set -eu
MIN_MAJOR=3
MIN_MINOR=10
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
PLUGIN_ROOT="$CLAUDE_PLUGIN_ROOT"
else
PLUGIN_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
fi
SERVER="${PLUGIN_ROOT}/scripts/jobbsok_tools.py"
version_ok() {
"$1" -c "import sys; sys.exit(0 if sys.version_info >= ($MIN_MAJOR, $MIN_MINOR) else 1)" \
>/dev/null 2>&1
}
version_of() {
"$1" -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>/dev/null || echo "ukjent"
}
usable() {
[ -n "$1" ] || return 1
command -v "$1" >/dev/null 2>&1 || return 1
version_ok "$1"
}
PYTHON_BIN=""
if [ -n "${JOBBSOK_PYTHON:-}" ]; then
if ! command -v "$JOBBSOK_PYTHON" >/dev/null 2>&1; then
echo "jobbsok-tools: JOBBSOK_PYTHON=$JOBBSOK_PYTHON is not executable." >&2
echo "jobbsok-tools: refusing to start. Point it at a Python ${MIN_MAJOR}.${MIN_MINOR}+ interpreter." >&2
exit 1
fi
if ! version_ok "$JOBBSOK_PYTHON"; then
echo "jobbsok-tools: JOBBSOK_PYTHON=$JOBBSOK_PYTHON is Python $(version_of "$JOBBSOK_PYTHON")." >&2
echo "jobbsok-tools: ${MIN_MAJOR}.${MIN_MINOR}+ is required; refusing to start on it." >&2
exit 1
fi
PYTHON_BIN="$JOBBSOK_PYTHON"
fi
if [ -z "$PYTHON_BIN" ] && [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
CANDIDATE="${CLAUDE_PLUGIN_DATA}/venv/bin/python"
if usable "$CANDIDATE"; then
PYTHON_BIN="$CANDIDATE"
fi
fi
if [ -z "$PYTHON_BIN" ]; then
CANDIDATE="${PLUGIN_ROOT}/.venv/bin/python3"
if usable "$CANDIDATE"; then
PYTHON_BIN="$CANDIDATE"
fi
fi
if [ -z "$PYTHON_BIN" ]; then
CANDIDATE=$(command -v python3 2>/dev/null || true)
if usable "$CANDIDATE"; then
PYTHON_BIN="$CANDIDATE"
fi
fi
if [ -z "$PYTHON_BIN" ]; then
echo "jobbsok-tools: found no Python ${MIN_MAJOR}.${MIN_MINOR}+ interpreter." >&2
echo "jobbsok-tools: tried JOBBSOK_PYTHON, \$CLAUDE_PLUGIN_DATA/venv, \$CLAUDE_PLUGIN_ROOT/.venv and python3 on PATH." >&2
echo "jobbsok-tools: run 'bash scripts/bootstrap.sh' against the installed plugin, or set JOBBSOK_PYTHON." >&2
echo "jobbsok-tools: refusing to start rather than serving on an interpreter below ${MIN_MAJOR}.${MIN_MINOR}." >&2
exit 1
fi
if [ ! -f "$SERVER" ]; then
echo "jobbsok-tools: server not found at $SERVER" >&2
exit 1
fi
exec "$PYTHON_BIN" "$SERVER" "$@"

160
scripts/package_plugin.py Normal file
View file

@ -0,0 +1,160 @@
"""Build jobbsok.plugin from an explicit include list.
Python rather than bash, and standard library only: `zip` is not on stock
Windows any more than `bash` is, and `zipfile` does the same job everywhere.
The include list is the whole point, and it is not a convenience. An archiver
does not consult .gitignore, so archiving the repository root would ship .git,
the virtualenv from Step 2, the local-only STATE.md and everything under
.claude/ -- which holds the operator's decisions and absolute home paths. This
archive is uploaded to Cowork once per milestone and its command is printed in
a public README. An include list is the only form that is safe to publish.
The virtualenv is excluded for a second reason on top of that one: a venv's
absolute paths do not survive relocation, so an archived one would be broken
wherever it landed. The supported route in an installed copy is to run
scripts/bootstrap.py once against it, which builds the environment under
$CLAUDE_PLUGIN_DATA. README.md says so.
Entry names are written with forward slashes and no Unix permission bits,
which is what the shell version's `-X` was after: two builds of the same commit
differ only in timestamps, and nothing in the archive needs an execute bit now
that every entry point is started by naming an interpreter.
Usage:
python3 scripts/package_plugin.py # -> <repo root>/jobbsok.plugin
python3 scripts/package_plugin.py --ut <sti> # -> somewhere else
On Windows the interpreter is normally called `python`, not `python3`.
"""
import fnmatch
import os
import subprocess
import sys
import zipfile
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
#: Directories are archived whole, minus the exclusions below. Entries that do
#: not exist yet are reported and skipped -- hooks/ and templates/ land in
#: later steps -- but the two the archive is worthless without are required.
INCLUDE = (
".claude-plugin",
"skills",
"scripts",
"hooks",
"templates",
".mcp.json",
"README.md",
"SECURITY.md",
"LICENSE",
"BUILD_STAMP",
)
REQUIRED = (".claude-plugin/plugin.json", "BUILD_STAMP")
#: Belt to the include list's braces: nothing under scripts/ or skills/ that is
#: a build artefact of a local test run may ride along. The patterns are the
#: ones the shell version passed to `zip -x`, matched against the archive entry
#: name, where `*` crosses directory separators exactly as it did there.
UTELAT = (
"*/__pycache__/*",
"*.pyc",
"*/.pytest_cache/*",
"*/.venv/*",
"*.local.md",
"*/.DS_Store",
)
def parse_args(argv):
"""Return the archive path, or exit the way the shell version exited."""
ut = os.path.join(REPO_ROOT, "jobbsok.plugin")
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--ut":
if i + 1 >= len(argv):
sys.stderr.write("package_plugin: --ut needs a path\n")
raise SystemExit(2)
ut = argv[i + 1]
i += 2
elif arg in ("-h", "--help"):
sys.stdout.write(__doc__)
raise SystemExit(0)
else:
sys.stderr.write("package_plugin: unknown argument: %s\n" % arg)
raise SystemExit(2)
return os.path.abspath(ut)
def skal_utelates(navn):
"""True when ``navn`` -- an archive entry name -- matches an exclusion."""
for monster in UTELAT:
if fnmatch.fnmatch(navn, monster):
return True
return False
def oppforinger(rot, include):
"""Return [(absolute path, archive entry name)] for ``include`` under ``rot``.
Sorted, so two builds of the same tree lay the archive out identically.
"""
funnet = []
for sti in include:
full = os.path.join(rot, sti)
if os.path.isfile(full):
funnet.append((full, sti.replace(os.sep, "/")))
continue
for dirpath, dirnames, filenames in os.walk(full):
dirnames.sort()
for filnavn in sorted(filenames):
filsti = os.path.join(dirpath, filnavn)
navn = os.path.relpath(filsti, rot).replace(os.sep, "/")
funnet.append((filsti, navn))
return [(f, n) for f, n in funnet if not skal_utelates(n)]
def main(argv):
ut = parse_args(argv)
# Regenerate first: an archive carrying a stale stamp is the exact failure
# the stamp exists to catch, and building one would be worse than not
# stamping. Started with this interpreter, so the packaging run and the
# stamp it ships cannot end up on two different Pythons.
stempling = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "build_stamp.py")]
)
if stempling.returncode != 0:
return stempling.returncode
for sti in REQUIRED:
if not os.path.exists(os.path.join(REPO_ROOT, sti.replace("/", os.sep))):
sys.stderr.write("package_plugin: required entry missing: %s\n" % sti)
return 1
present = [p for p in INCLUDE if os.path.exists(os.path.join(REPO_ROOT, p))]
skipped = [p for p in INCLUDE if not os.path.exists(os.path.join(REPO_ROOT, p))]
if skipped:
sys.stdout.write("package_plugin: not present yet, skipped: %s\n" % " ".join(skipped))
if os.path.exists(ut):
os.remove(ut)
with zipfile.ZipFile(ut, "w", zipfile.ZIP_DEFLATED) as pakke:
for filsti, navn in oppforinger(REPO_ROOT, present):
pakke.write(filsti, navn)
with open(os.path.join(REPO_ROOT, "BUILD_STAMP"), "r", encoding="utf-8") as handle:
stempel = handle.read().strip()
sys.stdout.write(
"package_plugin: %s built from %s\n" % (os.path.basename(ut), stempel)
)
sys.stdout.write("package_plugin: %s\n" % ut)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,98 +0,0 @@
#!/bin/bash
# Build jobbsok.plugin from an explicit include list.
#
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2.
#
# The include list is the whole point, and it is not a convenience. zip does
# not consult .gitignore, so archiving the repository root would ship .git,
# the virtualenv from Step 2, the local-only STATE.md and everything under
# .claude/ -- which holds the operator's decisions and absolute home paths.
# This archive is uploaded to Cowork once per milestone and its command is
# printed in a public README. An include list is the only form that is safe to
# publish.
#
# The virtualenv is excluded for a second reason on top of that one: a venv's
# absolute paths do not survive relocation, so an archived one would be broken
# wherever it landed. The supported route in an installed copy is to run
# scripts/bootstrap.sh once against it, which builds the environment under
# $CLAUDE_PLUGIN_DATA. README.md says so.
#
# Usage:
# bash scripts/package_plugin.sh # -> <repo root>/jobbsok.plugin
# bash scripts/package_plugin.sh --ut <sti> # -> somewhere else
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
OUT="${REPO_ROOT}/jobbsok.plugin"
while [ $# -gt 0 ]; do
case "$1" in
--ut)
if [ $# -lt 2 ]; then
echo "package_plugin: --ut needs a path" >&2
exit 2
fi
OUT="$2"
shift 2 ;;
-h|--help)
sed -n '2,23p' "$0"
exit 0 ;;
*)
echo "package_plugin: unknown argument: $1" >&2
exit 2 ;;
esac
done
case "$OUT" in
/*) ;;
*) OUT="$(pwd)/$OUT" ;;
esac
# Regenerate first: an archive carrying a stale stamp is the exact failure the
# stamp exists to catch, and building one would be worse than not stamping.
bash "${SCRIPT_DIR}/build_stamp.sh"
# The include list. Directories are archived whole, minus the exclusions below.
# Entries that do not exist yet are reported and skipped -- hooks/, templates/
# and SECURITY.md land in later steps -- but the two the archive is worthless
# without are required outright.
INCLUDE=".claude-plugin skills scripts hooks templates .mcp.json README.md SECURITY.md LICENSE BUILD_STAMP"
REQUIRED=".claude-plugin/plugin.json BUILD_STAMP"
for path in $REQUIRED; do
if [ ! -e "${REPO_ROOT}/${path}" ]; then
echo "package_plugin: required entry missing: $path" >&2
exit 1
fi
done
PRESENT=""
SKIPPED=""
for path in $INCLUDE; do
if [ -e "${REPO_ROOT}/${path}" ]; then
PRESENT="$PRESENT $path"
else
SKIPPED="$SKIPPED $path"
fi
done
if [ -n "$SKIPPED" ]; then
echo "package_plugin: not present yet, skipped:$SKIPPED"
fi
rm -f "$OUT"
# -X drops the extra file attributes, so the archive is reproducible enough
# that two builds of the same commit differ only in timestamps. The exclusions
# are belt to the include list's braces: nothing under scripts/ or skills/ that
# is a build artefact of a local test run may ride along.
cd "$REPO_ROOT"
# shellcheck disable=SC2086 -- PRESENT is a deliberate word-split list
zip -q -r -X "$OUT" $PRESENT \
-x '*/__pycache__/*' '*.pyc' '*/.pytest_cache/*' '*/.venv/*' '*.local.md' \
'*/.DS_Store'
echo "package_plugin: $(basename "$OUT") built from $(cat "${REPO_ROOT}/BUILD_STAMP")"
echo "package_plugin: $OUT"

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

View file

@ -42,7 +42,7 @@ from helpers import mcp_stdio
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(REPO, "scripts") SCRIPTS = os.path.join(REPO, "scripts")
GOLDEN = os.path.join(REPO, "tests", "golden", "jobbsok-tools.tools.json") 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") PROFIL_FIXTURE = ("profiles", "01-gyldig.md")
ANNONSE_FIXTURE = ("listings", "01-alt-passer.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 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): def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one(tmp_path):
falsk = tmp_path / "python3.9" falsk = falsk_tolk(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)
avvist = subprocess.run( avvist = subprocess.run(
["bash", LAUNCHER], [sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(falsk)), env=dict(os.environ, JOBBSOK_PYTHON=str(falsk)),
input="", input="",
capture_output=True, 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. # PATH fallback survived the JOBBSOK_PYTHON case untouched.
falsk_bin = tmp_path / "bin" falsk_bin = tmp_path / "bin"
falsk_bin.mkdir() falsk_bin.mkdir()
(falsk_bin / "python3").write_text(falsk.read_text()) falsk_tolk(falsk_bin, "python3")
(falsk_bin / "python3").chmod(0o755)
tom_rot = tmp_path / "tom-plugin-rot" tom_rot = tmp_path / "tom-plugin-rot"
(tom_rot / "scripts").mkdir(parents=True) (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("JOBBSOK_PYTHON", None)
miljo.pop("CLAUDE_PLUGIN_DATA", None) miljo.pop("CLAUDE_PLUGIN_DATA", None)
miljo["CLAUDE_PLUGIN_ROOT"] = str(tom_rot) 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( uten_kandidat = subprocess.run(
["/bin/bash", LAUNCHER], [sys.executable, LAUNCHER],
env=miljo, env=miljo,
input="", input="",
capture_output=True, capture_output=True,
@ -314,7 +337,7 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
+ "\n" + "\n"
) )
servert = subprocess.run( servert = subprocess.run(
["bash", LAUNCHER], [sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=sys.executable), env=dict(os.environ, JOBBSOK_PYTHON=sys.executable),
input=forespoersler, input=forespoersler,
capture_output=True, capture_output=True,

View file

@ -41,6 +41,7 @@ import json
import os import os
import re import re
import subprocess import subprocess
import sys
import zipfile import zipfile
import pytest import pytest
@ -50,8 +51,8 @@ MANIFEST = os.path.join(REPO, ".claude-plugin", "plugin.json")
CHANGELOG = os.path.join(REPO, "CHANGELOG.md") CHANGELOG = os.path.join(REPO, "CHANGELOG.md")
GITIGNORE = os.path.join(REPO, ".gitignore") GITIGNORE = os.path.join(REPO, ".gitignore")
SKILLS = os.path.join(REPO, "skills") SKILLS = os.path.join(REPO, "skills")
BUILD_STAMP = os.path.join(REPO, "scripts", "build_stamp.sh") BUILD_STAMP = os.path.join(REPO, "scripts", "build_stamp.py")
PACKAGE = os.path.join(REPO, "scripts", "package_plugin.sh") PACKAGE = os.path.join(REPO, "scripts", "package_plugin.py")
#: Build-brief section 4. The count assertion -- that all fourteen exist -- #: Build-brief section 4. The count assertion -- that all fourteen exist --
#: belongs to M6, when the last of them lands; what is asserted here is that #: belongs to M6, when the last of them lands; what is asserted here is that
@ -115,10 +116,10 @@ def arkiv(tmp_path):
try: try:
ut = tmp_path / "jobbsok.plugin" ut = tmp_path / "jobbsok.plugin"
kjort = subprocess.run( kjort = subprocess.run(
["bash", PACKAGE, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True [sys.executable, PACKAGE, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
) )
assert kjort.returncode == 0, "package_plugin.sh failed:\n%s" % kjort.stderr assert kjort.returncode == 0, "package_plugin.py failed:\n%s" % kjort.stderr
assert ut.exists(), "package_plugin.sh reported success but wrote no archive" assert ut.exists(), "package_plugin.py reported success but wrote no archive"
yield str(ut) yield str(ut)
finally: finally:
if forrige is None: if forrige is None:
@ -183,7 +184,7 @@ def test_every_skill_directory_is_one_the_brief_names():
def test_the_stamp_script_writes_the_hash_of_the_commit_it_ran_on(tmp_path): def test_the_stamp_script_writes_the_hash_of_the_commit_it_ran_on(tmp_path):
ut = tmp_path / "BUILD_STAMP" ut = tmp_path / "BUILD_STAMP"
kjort = subprocess.run( kjort = subprocess.run(
["bash", BUILD_STAMP, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True [sys.executable, BUILD_STAMP, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
) )
assert kjort.returncode == 0, kjort.stderr assert kjort.returncode == 0, kjort.stderr
with open(ut, "r", encoding="utf-8") as handle: with open(ut, "r", encoding="utf-8") as handle:

View file

@ -21,7 +21,7 @@ cannot be stated is a rule that will be quietly widened later:
1. **An absolute home path means a literal one.** `/Users/<name>/` and 1. **An absolute home path means a literal one.** `/Users/<name>/` and
`/home/<name>/` are refused; `$HOME/Library/...` is not, because that is the `/home/<name>/` are refused; `$HOME/Library/...` is not, because that is the
correct way to write the same path and `scripts/cowork_probe_check.sh` has correct way to write the same path and `scripts/cowork_probe_check.py` has
to name a macOS directory to read a log out of it. to name a macOS directory to read a log out of it.
2. **Infrastructure hosts are allowed by name.** The Forgejo host this 2. **Infrastructure hosts are allowed by name.** The Forgejo host this
repository lives on and the badge service in the README are addresses the repository lives on and the badge service in the README are addresses the
@ -133,11 +133,11 @@ def test_the_readme_install_block_covers_both_surfaces():
"archive and not by a marketplace command -- a reader following the " "archive and not by a marketplace command -- a reader following the "
"Claude Code lines there gets nowhere" "Claude Code lines there gets nowhere"
) )
assert "scripts/package_plugin.sh" in installer, ( assert "scripts/package_plugin.py" in installer, (
"the Cowork route needs the packaging command; never a recursive zip " "the Cowork route needs the packaging command; never a recursive zip "
"of the repository root" "of the repository root"
) )
assert "scripts/bootstrap.sh" in installer, ( assert "scripts/bootstrap.py" in installer, (
"without the bootstrap there is no environment for the host MCP server " "without the bootstrap there is no environment for the host MCP server "
"to run on in an installed copy" "to run on in an installed copy"
) )