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:
parent
f8c74fa0ac
commit
dcd3eae534
20 changed files with 1523 additions and 542 deletions
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"jobbsok-tools": {
|
||||
"command": "bash",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/jobbsok_tools_launch.sh"]
|
||||
"command": "${JOBBSOK_LAUNCH_PYTHON:-python3}",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/jobbsok_tools_launch.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,8 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [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.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ Code, identifiers, file names and docs are English.
|
|||
- **No autonomous scheduling.** Nothing polls, nothing runs in background.
|
||||
- **Build in milestone order (brief §11, M1–M6).** Do not skip ahead.
|
||||
- **`${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
|
||||
|
||||
|
|
|
|||
42
README.md
42
README.md
|
|
@ -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
|
||||
is a reader rather than a workspace.
|
||||
|
||||
**Platform support.** macOS and Linux work today. **Windows does not yet**, and
|
||||
the reason is specific rather than general: this plugin's entry points are shell
|
||||
scripts and `.mcp.json` starts the tool server through `bash`, which stock
|
||||
Windows does not have. The Python underneath is already platform-clean, so this
|
||||
is a packaging gap being closed rather than a rewrite. Until it is closed,
|
||||
Windows needs a POSIX shell (WSL or Git Bash) and is not a supported target.
|
||||
**Platform support.** macOS, Linux and Windows. Every entry point is Python
|
||||
and the standard library; nothing here needs a POSIX shell, `zip` or the
|
||||
coreutils. On Windows the interpreter is normally called `python` or `py` --
|
||||
`python3` is not a stock Windows name -- so the commands below are given in
|
||||
both spellings, and the one setting that needs saying is in *Windows: name the
|
||||
interpreter* further down.
|
||||
|
||||
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*:
|
||||
|
||||
```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
|
||||
|
|
@ -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:
|
||||
|
||||
```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`,
|
||||
which survives a plugin update; the packaged archive deliberately excludes 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
|
||||
rather than serving on whatever `python3` the PATH offers. `JOBBSOK_PYTHON`
|
||||
overrides the interpreter choice.
|
||||
rather than serving on whatever interpreter the PATH offers.
|
||||
|
||||
**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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| 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 |
|
||||
| Ingestion | `scripts/guard_ingest.py` at the point of the write | M3 |
|
||||
| Browser read-only | deny list plus a `PreToolUse` hook | M3 |
|
||||
|
|
|
|||
139
docs/cross-platform-port.md
Normal file
139
docs/cross-platform-port.md
Normal 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
210
scripts/bootstrap.py
Normal 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:]))
|
||||
|
|
@ -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
94
scripts/build_stamp.py
Normal 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:]))
|
||||
|
|
@ -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"
|
||||
260
scripts/cowork_probe_check.py
Normal file
260
scripts/cowork_probe_check.py
Normal 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:]))
|
||||
|
|
@ -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
|
||||
257
scripts/jobbsok_tools_launch.py
Normal file
257
scripts/jobbsok_tools_launch.py
Normal 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:]))
|
||||
|
|
@ -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
160
scripts/package_plugin.py
Normal 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:]))
|
||||
|
|
@ -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"
|
||||
307
tests/test_cross_platform.py
Normal file
307
tests/test_cross_platform.py
Normal 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
|
||||
|
|
@ -42,7 +42,7 @@ from helpers import mcp_stdio
|
|||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCRIPTS = os.path.join(REPO, "scripts")
|
||||
GOLDEN = os.path.join(REPO, "tests", "golden", "jobbsok-tools.tools.json")
|
||||
LAUNCHER = os.path.join(SCRIPTS, "jobbsok_tools_launch.sh")
|
||||
LAUNCHER = os.path.join(SCRIPTS, "jobbsok_tools_launch.py")
|
||||
|
||||
PROFIL_FIXTURE = ("profiles", "01-gyldig.md")
|
||||
ANNONSE_FIXTURE = ("listings", "01-alt-passer.md")
|
||||
|
|
@ -239,20 +239,44 @@ def test_selvsjekk_reports_the_interpreter_and_the_pinned_guard(klient, arbeidso
|
|||
assert "build_stamp" in rapport
|
||||
|
||||
|
||||
def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one(tmp_path):
|
||||
falsk = tmp_path / "python3.9"
|
||||
falsk.write_text(
|
||||
"#!/bin/bash\n"
|
||||
"# Answers the version probe as 3.9.6 and fails the version gate.\n"
|
||||
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 '3.9.6'; exit 0 ;;\n"
|
||||
" *print*) echo '%s'; exit 0 ;;\n"
|
||||
"esac\n"
|
||||
"exit 1\n"
|
||||
"exit 1\n" % (versjon, versjon)
|
||||
)
|
||||
falsk.chmod(0o755)
|
||||
sti.chmod(0o755)
|
||||
return sti
|
||||
|
||||
|
||||
def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one(tmp_path):
|
||||
falsk = falsk_tolk(tmp_path, "python3.9")
|
||||
|
||||
avvist = subprocess.run(
|
||||
["bash", LAUNCHER],
|
||||
[sys.executable, LAUNCHER],
|
||||
env=dict(os.environ, JOBBSOK_PYTHON=str(falsk)),
|
||||
input="",
|
||||
capture_output=True,
|
||||
|
|
@ -272,8 +296,7 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
|
|||
# PATH fallback survived the JOBBSOK_PYTHON case untouched.
|
||||
falsk_bin = tmp_path / "bin"
|
||||
falsk_bin.mkdir()
|
||||
(falsk_bin / "python3").write_text(falsk.read_text())
|
||||
(falsk_bin / "python3").chmod(0o755)
|
||||
falsk_tolk(falsk_bin, "python3")
|
||||
tom_rot = tmp_path / "tom-plugin-rot"
|
||||
(tom_rot / "scripts").mkdir(parents=True)
|
||||
|
||||
|
|
@ -281,9 +304,9 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
|
|||
miljo.pop("JOBBSOK_PYTHON", None)
|
||||
miljo.pop("CLAUDE_PLUGIN_DATA", None)
|
||||
miljo["CLAUDE_PLUGIN_ROOT"] = str(tom_rot)
|
||||
miljo["PATH"] = "%s:/usr/bin:/bin" % falsk_bin
|
||||
miljo["PATH"] = str(falsk_bin) + os.pathsep + os.path.dirname(sys.executable)
|
||||
uten_kandidat = subprocess.run(
|
||||
["/bin/bash", LAUNCHER],
|
||||
[sys.executable, LAUNCHER],
|
||||
env=miljo,
|
||||
input="",
|
||||
capture_output=True,
|
||||
|
|
@ -314,7 +337,7 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
|
|||
+ "\n"
|
||||
)
|
||||
servert = subprocess.run(
|
||||
["bash", LAUNCHER],
|
||||
[sys.executable, LAUNCHER],
|
||||
env=dict(os.environ, JOBBSOK_PYTHON=sys.executable),
|
||||
input=forespoersler,
|
||||
capture_output=True,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
|
@ -50,8 +51,8 @@ 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.sh")
|
||||
PACKAGE = os.path.join(REPO, "scripts", "package_plugin.sh")
|
||||
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
|
||||
|
|
@ -115,10 +116,10 @@ def arkiv(tmp_path):
|
|||
try:
|
||||
ut = tmp_path / "jobbsok.plugin"
|
||||
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 ut.exists(), "package_plugin.sh reported success but wrote no archive"
|
||||
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:
|
||||
|
|
@ -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):
|
||||
ut = tmp_path / "BUILD_STAMP"
|
||||
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
|
||||
with open(ut, "r", encoding="utf-8") as handle:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`/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.
|
||||
2. **Infrastructure hosts are allowed by name.** The Forgejo host this
|
||||
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 "
|
||||
"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 "
|
||||
"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 "
|
||||
"to run on in an installed copy"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue