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>
260 lines
9.5 KiB
Python
260 lines
9.5 KiB
Python
"""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:]))
|