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>
265 lines
11 KiB
Python
265 lines
11 KiB
Python
"""The plugin manifest, the build stamp and what the archive may contain
|
|
(plan Step 13).
|
|
|
|
Two failures this file exists to prevent, and they are different in kind.
|
|
|
|
**The false green.** Cowork caches an uploaded plugin, and a cached build
|
|
answers every question correctly except the one being asked. So the plugin
|
|
carries a `BUILD_STAMP` -- the short hash of the commit it was built from --
|
|
and the `Verifiser i Cowork` steps compare it against `git rev-parse --short
|
|
HEAD`. `plugin.json`'s version cannot do that job: it does not change between
|
|
milestones, so a stale build would echo the right number (risk H11).
|
|
|
|
**The over-broad archive.** The packaging command is printed in a public
|
|
README and run before every Cowork upload. An archiver that walked the
|
|
repository root would ship `.git`, the virtualenv, the local-only `STATE.md`
|
|
and everything under `.claude/`, which holds the operator's decisions and
|
|
absolute home paths. `zip` does not consult `.gitignore`, so the include list
|
|
is the only thing standing between those files and an uploaded archive -- and
|
|
the test reads the built archive's own table of contents rather than trusting
|
|
the script's include list to be what it says it is.
|
|
|
|
The stamp being gitignored is asserted twice, deliberately: once against the
|
|
text of `.gitignore` and once against `git check-ignore`, which is the only
|
|
one of the two that knows about precedence, negations and later rules. An
|
|
untested claim of "gitignored" is exactly how a stamp ends up tracked and the
|
|
suite ends up permanently red from this step onward.
|
|
|
|
One divergence from the plan, stated rather than smuggled. The plan asks that
|
|
`plugin.json`'s version equal "the top heading in CHANGELOG.md". The top
|
|
heading today is `## [Unreleased]`, because nothing has been released --
|
|
README says so on its first screen. Writing a `## [0.1.0]` heading to satisfy
|
|
a test would assert a release that has not happened. So the check is the one
|
|
the rule is actually for: **if** the changelog carries a version heading, the
|
|
topmost one must equal `plugin.json`'s version. The day a release lands, the
|
|
check tightens by itself.
|
|
|
|
Style note: this file follows tests/test_skills_contract.py.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
MANIFEST = os.path.join(REPO, ".claude-plugin", "plugin.json")
|
|
CHANGELOG = os.path.join(REPO, "CHANGELOG.md")
|
|
GITIGNORE = os.path.join(REPO, ".gitignore")
|
|
SKILLS = os.path.join(REPO, "skills")
|
|
BUILD_STAMP = os.path.join(REPO, "scripts", "build_stamp.py")
|
|
PACKAGE = os.path.join(REPO, "scripts", "package_plugin.py")
|
|
|
|
#: 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
|
|
#: no directory outside the list ever appears.
|
|
SKILL_NAVN = (
|
|
"kandidatprofil",
|
|
"annonse-uttrekk",
|
|
"kandidatvurdering",
|
|
"beslutning",
|
|
"sak",
|
|
"korrespondanse",
|
|
"soknad",
|
|
"intervju",
|
|
"referanser",
|
|
"forhandling",
|
|
"dagens",
|
|
"utfall",
|
|
"laering",
|
|
"datahygiene",
|
|
)
|
|
|
|
#: A path component that must never appear in the uploaded archive. Each of
|
|
#: these carries something that is local-only, operator-private, or both.
|
|
FORBUDT_I_ARKIV = (".git", ".venv", "STATE.md", ".claude/", "__pycache__", ".pytest_cache")
|
|
|
|
VERSJONSOVERSKRIFT = re.compile(r"^##\s*\[?(\d+\.\d+\.\d+)\]?", re.MULTILINE)
|
|
|
|
|
|
def git(*args):
|
|
ut = subprocess.run(
|
|
["git", "-C", REPO] + list(args), capture_output=True, text=True
|
|
)
|
|
assert ut.returncode == 0, "git %s failed: %s" % (" ".join(args), ut.stderr)
|
|
return ut.stdout.strip()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def manifest():
|
|
with open(MANIFEST, "r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
@pytest.fixture
|
|
def arkiv(tmp_path):
|
|
"""Build the plugin archive into a temporary file and return its path.
|
|
|
|
A deliberately stale stamp is planted first. Without it the archive would
|
|
carry a correct hash whether or not the packaging script regenerated one,
|
|
and a mutation that dropped the regeneration survived every assertion in
|
|
this file -- which is the H11 false green wearing the test's own colours.
|
|
"""
|
|
stempel = os.path.join(REPO, "BUILD_STAMP")
|
|
fantes = os.path.exists(stempel)
|
|
forrige = None
|
|
if fantes:
|
|
with open(stempel, "r", encoding="utf-8") as handle:
|
|
forrige = handle.read()
|
|
with open(stempel, "w", encoding="utf-8") as handle:
|
|
handle.write("foreldet\n")
|
|
|
|
try:
|
|
ut = tmp_path / "jobbsok.plugin"
|
|
kjort = subprocess.run(
|
|
[sys.executable, PACKAGE, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
|
|
)
|
|
assert kjort.returncode == 0, "package_plugin.py failed:\n%s" % kjort.stderr
|
|
assert ut.exists(), "package_plugin.py reported success but wrote no archive"
|
|
yield str(ut)
|
|
finally:
|
|
if forrige is None:
|
|
os.remove(stempel)
|
|
else:
|
|
with open(stempel, "w", encoding="utf-8") as handle:
|
|
handle.write(forrige)
|
|
|
|
|
|
def test_the_manifest_carries_the_five_fields_a_plugin_needs(manifest):
|
|
for felt in ("name", "version", "description", "author", "license"):
|
|
assert felt in manifest, "plugin.json is missing %r" % felt
|
|
assert manifest[felt], "plugin.json has an empty %r" % felt
|
|
assert manifest["name"] == "jobbsok"
|
|
assert re.match(r"^\d+\.\d+\.\d+$", manifest["version"]), (
|
|
"version %r is not semantic" % manifest["version"]
|
|
)
|
|
assert isinstance(manifest["author"], dict) and manifest["author"].get("name")
|
|
|
|
|
|
def test_the_version_does_not_contradict_the_changelog(manifest):
|
|
with open(CHANGELOG, "r", encoding="utf-8") as handle:
|
|
tekst = handle.read()
|
|
versjoner = VERSJONSOVERSKRIFT.findall(tekst)
|
|
if not versjoner:
|
|
# Pre-release: the only heading is [Unreleased], and that is honest.
|
|
assert "[Unreleased]" in tekst, (
|
|
"the changelog carries neither a version heading nor an "
|
|
"Unreleased section; one of the two has to be true"
|
|
)
|
|
return
|
|
assert versjoner[0] == manifest["version"], (
|
|
"plugin.json says %r but the newest changelog heading says %r"
|
|
% (manifest["version"], versjoner[0])
|
|
)
|
|
|
|
|
|
def test_this_plugin_is_skills_only_and_never_grows_a_commands_directory():
|
|
assert not os.path.exists(os.path.join(REPO, "commands")), (
|
|
"this plugin is skills-only (build-brief section 4); a commands/ "
|
|
"directory would be a second, undocumented surface"
|
|
)
|
|
sporet = [p for p in git("ls-files").split("\n") if p.startswith("commands/")]
|
|
assert sporet == [], "commands/ is tracked: %r" % sporet
|
|
|
|
|
|
def test_every_skill_directory_is_one_the_brief_names():
|
|
if not os.path.isdir(SKILLS):
|
|
pytest.fail("skills/ does not exist")
|
|
funnet = sorted(
|
|
navn for navn in os.listdir(SKILLS)
|
|
if os.path.isdir(os.path.join(SKILLS, navn))
|
|
)
|
|
ukjente = [navn for navn in funnet if navn not in SKILL_NAVN]
|
|
assert ukjente == [], (
|
|
"skills/ holds %r, which build-brief section 4 does not name. The "
|
|
"fourteen are %r" % (ukjente, list(SKILL_NAVN))
|
|
)
|
|
assert funnet, "skills/ is empty; M1 ships two of the fourteen"
|
|
|
|
|
|
def test_the_stamp_script_writes_the_hash_of_the_commit_it_ran_on(tmp_path):
|
|
ut = tmp_path / "BUILD_STAMP"
|
|
kjort = subprocess.run(
|
|
[sys.executable, BUILD_STAMP, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
|
|
)
|
|
assert kjort.returncode == 0, kjort.stderr
|
|
with open(ut, "r", encoding="utf-8") as handle:
|
|
stempel = handle.read().strip()
|
|
assert stempel == git("rev-parse", "--short", "HEAD"), (
|
|
"the stamp says %r but HEAD is %r" % (stempel, git("rev-parse", "--short", "HEAD"))
|
|
)
|
|
# Trailing newline and nothing else: the skills read this file and echo it.
|
|
with open(ut, "r", encoding="utf-8") as handle:
|
|
assert handle.read() == stempel + "\n"
|
|
|
|
|
|
def test_the_build_artefacts_are_ignored_and_git_agrees():
|
|
with open(GITIGNORE, "r", encoding="utf-8") as handle:
|
|
linjer = [line.strip() for line in handle]
|
|
assert "BUILD_STAMP" in linjer, (
|
|
".gitignore has no BUILD_STAMP line. The stamp is regenerated on every "
|
|
"package run, so a tracked stamp is red from the next commit onward."
|
|
)
|
|
assert "jobbsok.plugin" in linjer, (
|
|
".gitignore has no jobbsok.plugin line; the README tells the operator "
|
|
"to build that archive in the repository root"
|
|
)
|
|
|
|
# The text is one thing; what git actually does is the thing. check-ignore
|
|
# knows about precedence and negation, and a later rule could undo the line
|
|
# above without the assertion over the text noticing.
|
|
for navn in ("BUILD_STAMP", "jobbsok.plugin"):
|
|
sti = os.path.join(REPO, navn)
|
|
fantes = os.path.exists(sti)
|
|
if not fantes:
|
|
with open(sti, "w", encoding="utf-8") as handle:
|
|
handle.write("probe\n")
|
|
try:
|
|
kjort = subprocess.run(
|
|
["git", "-C", REPO, "check-ignore", "-q", navn],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert kjort.returncode == 0, (
|
|
"git does not ignore %s despite the .gitignore line" % navn
|
|
)
|
|
finally:
|
|
if not fantes:
|
|
os.remove(sti)
|
|
|
|
|
|
def test_the_archive_ships_no_local_only_or_operator_private_path(arkiv):
|
|
with zipfile.ZipFile(arkiv) as pakke:
|
|
oppforinger = pakke.namelist()
|
|
|
|
assert oppforinger, "the archive is empty"
|
|
for oppforing in oppforinger:
|
|
for forbudt in FORBUDT_I_ARKIV:
|
|
assert forbudt not in oppforing, (
|
|
"the archive ships %r, which contains %r. The include list is "
|
|
"the only thing keeping it out -- zip does not read .gitignore."
|
|
% (oppforing, forbudt)
|
|
)
|
|
|
|
# And the things it must carry, so an over-eager exclusion is caught too.
|
|
for pakket in (".claude-plugin/plugin.json", ".mcp.json", "BUILD_STAMP", "LICENSE"):
|
|
assert pakket in oppforinger, (
|
|
"the archive is missing %r; it holds %r" % (pakket, sorted(oppforinger))
|
|
)
|
|
assert any(o.startswith("skills/") for o in oppforinger)
|
|
assert any(o.startswith("scripts/") for o in oppforinger)
|
|
|
|
# And the stamp it ships is the one this commit deserves. The fixture
|
|
# planted a stale one on purpose, so a packaging script that stopped
|
|
# regenerating would archive "foreldet" and be caught here rather than in
|
|
# Cowork, six uploads later.
|
|
with zipfile.ZipFile(arkiv) as pakke:
|
|
pakket_stempel = pakke.read("BUILD_STAMP").decode("utf-8").strip()
|
|
assert pakket_stempel == git("rev-parse", "--short", "HEAD"), (
|
|
"the archive ships stamp %r while HEAD is %r; the packaging script did "
|
|
"not regenerate it" % (pakket_stempel, git("rev-parse", "--short", "HEAD"))
|
|
)
|