feat(m2): expose status and daily tools through jobbsok-tools
This commit is contained in:
parent
bdb6291042
commit
c7db9f16a0
4 changed files with 578 additions and 19 deletions
|
|
@ -13,10 +13,19 @@ SDK would add a PyPI dependency the Cowork sandbox cannot install, and would
|
|||
cost import time at every cold start against an MCP start-up timeout. Three
|
||||
JSON-RPC methods is less code than the dependency it would replace.
|
||||
|
||||
**Nothing heavy is imported at module load.** `kandidat_schema` and
|
||||
`vurdering` are imported inside the handlers, not at the top. The server has
|
||||
to answer `initialize` fast enough that Cowork does not time it out, and it
|
||||
answers that one without touching either module.
|
||||
**Nothing heavy is imported at module load.** `kandidat_schema`,
|
||||
`vurdering`, `sak_status`, `dagens` and `beslutninger` are imported inside the
|
||||
handlers, not at the top. The server has to answer `initialize` fast enough
|
||||
that Cowork does not time it out, and it answers that one without touching any
|
||||
of them.
|
||||
|
||||
**The M2 tools call the command line's own `main()`, with the streams
|
||||
redirected** (Step 24). Parity between the two paths is not a nicety here: every
|
||||
skill degrades to reading what the CLI produced when the server is absent, and
|
||||
that instruction is only true if the content is the same. A second renderer on
|
||||
this side would be a copy free to drift from the first, so what runs is the
|
||||
same entry point -- same argument parsing, same output, same refusals -- and
|
||||
the only thing this layer adds is turning an exit code into an MCP answer.
|
||||
|
||||
**Every tool takes an explicit `workspace`.** `jobbsok_lib.paths` refuses to
|
||||
guess at a home directory, and this process runs unsandboxed on the operator's
|
||||
|
|
@ -61,6 +70,23 @@ WORKSPACE_ARG = {
|
|||
),
|
||||
}
|
||||
|
||||
#: The injected clock. Every M2 tool takes it, and none of them reads the wall
|
||||
#: clock when it is absent only because a caller that cannot pin the date is
|
||||
#: still better served by today than by a refusal -- but a `Verifiser`-step
|
||||
#: that wants a reproducible answer passes it.
|
||||
TODAY_ARG = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens "
|
||||
"dato. Oppgi den i alt som skal kunne reproduseres."
|
||||
),
|
||||
}
|
||||
|
||||
SAK_ARG = {
|
||||
"type": "string",
|
||||
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet.",
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "kandidat_valider",
|
||||
|
|
@ -145,6 +171,95 @@ TOOLS = [
|
|||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "sak_status",
|
||||
"description": (
|
||||
"Utled status, ventende part, siste aktivitet, neste frist og "
|
||||
"stillhets-flagg for hver sak, fra logg.jsonl. sak.md er en "
|
||||
"hurtigbuffer og loggen vinner. Skriver ingenting."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": WORKSPACE_ARG,
|
||||
"today": TODAY_ARG,
|
||||
"sak": SAK_ARG,
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["tekst", "json"],
|
||||
"description": "Utdataform. Standard: tekst.",
|
||||
},
|
||||
},
|
||||
"required": ["workspace"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "sak_status_check",
|
||||
"description": (
|
||||
"Samme utledning, men rapporterer om en hurtigbuffer i sak.md er "
|
||||
"uenig med loggen. Avviket staar i teksten, med saken navngitt; "
|
||||
"kommandolinja svarer i tillegg med exit-kode 1, som MCP ikke har. "
|
||||
"Retter ingenting og skriver ingenting."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": WORKSPACE_ARG,
|
||||
"today": TODAY_ARG,
|
||||
"sak": SAK_ARG,
|
||||
},
|
||||
"required": ["workspace"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "dagens",
|
||||
"description": (
|
||||
"Dagens arbeidsbilde som ren tekst: hva som krever handling, hva "
|
||||
"som har gaatt stille, kommende frister og saker per tilstand. "
|
||||
"Byte-stabil for samme arbeidsomraade og samme dato. Skriver "
|
||||
"ingenting."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"workspace": WORKSPACE_ARG, "today": TODAY_ARG},
|
||||
"required": ["workspace"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "beslutning_append",
|
||||
"description": (
|
||||
"Legg til én beslutning i den append-only beslutningsloggen "
|
||||
"(build-brief 5.4), eller en korrigering som navngir en tidligere "
|
||||
"beslutning. En retting er alltid en ny linje, aldri en "
|
||||
"redigering. Returnerer posten som ble skrevet."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": WORKSPACE_ARG,
|
||||
"post": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Posten. Feltene er build-brief 5.4: id, dato, kilde, "
|
||||
"url, tittel, arbeidsgiver, beslutning, arsak, notat, "
|
||||
"score_da, delscore, vekter, vekt_hash, korrigerer."
|
||||
),
|
||||
},
|
||||
"korriger": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Posten er en korrigering av en tidligere beslutning. "
|
||||
"Standard: false."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["workspace", "post"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -242,6 +357,95 @@ def tool_annonse_vurder(arguments):
|
|||
return til_json(resultat)
|
||||
|
||||
|
||||
def _streng(arguments, navn):
|
||||
"""An optional string argument, refused rather than coerced.
|
||||
|
||||
Types are checked before anything reaches `argparse`: a non-string would
|
||||
become a malformed argv, and argparse answers a malformed argv with
|
||||
SystemExit -- which is a BaseException and would take the server down
|
||||
rather than returning a tool error.
|
||||
"""
|
||||
verdi = arguments.get(navn)
|
||||
if verdi is None:
|
||||
return None
|
||||
if not isinstance(verdi, str) or not verdi.strip():
|
||||
raise ToolError("%s må være en ikke-tom tekst, fikk %r" % (navn, verdi))
|
||||
return verdi
|
||||
|
||||
|
||||
def _kjor_cli(modul, argv):
|
||||
"""Run a command-line entry point in-process and return its stdout.
|
||||
|
||||
The exit code is translated, not swallowed: a hard failure becomes a tool
|
||||
error carrying what the CLI wrote to stderr. `sak_status`'s exit code 1 --
|
||||
"a cache diverged" -- is deliberately not a failure; it is an answer, and
|
||||
it is in the text.
|
||||
"""
|
||||
import io as _io
|
||||
|
||||
ut, feil = _io.StringIO(), _io.StringIO()
|
||||
kode = modul.main(argv, stdout=ut, stderr=feil)
|
||||
if kode == modul.EXIT_FEIL:
|
||||
raise ToolError(feil.getvalue().strip() or "kommandoen feilet uten melding")
|
||||
return ut.getvalue()
|
||||
|
||||
|
||||
def _sak_status_argv(arguments):
|
||||
argv = ["--workspace", _workspace(arguments)]
|
||||
today = _streng(arguments, "today")
|
||||
if today:
|
||||
argv.extend(["--today", today])
|
||||
sak = _streng(arguments, "sak")
|
||||
if sak:
|
||||
argv.extend(["--sak", sak])
|
||||
return argv
|
||||
|
||||
|
||||
def tool_sak_status(arguments):
|
||||
import sak_status
|
||||
|
||||
argv = _sak_status_argv(arguments)
|
||||
form = _streng(arguments, "format") or "tekst"
|
||||
if form not in ("tekst", "json"):
|
||||
raise ToolError("format må være tekst eller json, fikk %r" % (form,))
|
||||
return _kjor_cli(sak_status, argv + ["--format", form])
|
||||
|
||||
|
||||
def tool_sak_status_check(arguments):
|
||||
import sak_status
|
||||
|
||||
# Text, not JSON: the CLI appends the divergence line to stdout after the
|
||||
# report, which is natural in the text form and would make the JSON form
|
||||
# unparseable. The answer is in that line, because MCP has no exit code.
|
||||
return _kjor_cli(sak_status, _sak_status_argv(arguments) + ["--check"])
|
||||
|
||||
|
||||
def tool_dagens(arguments):
|
||||
import dagens
|
||||
|
||||
argv = ["--workspace", _workspace(arguments)]
|
||||
today = _streng(arguments, "today")
|
||||
if today:
|
||||
argv.extend(["--today", today])
|
||||
return _kjor_cli(dagens, argv)
|
||||
|
||||
|
||||
def tool_beslutning_append(arguments):
|
||||
import beslutninger
|
||||
|
||||
root = _workspace(arguments)
|
||||
post = arguments.get("post")
|
||||
if not isinstance(post, dict):
|
||||
raise ToolError(
|
||||
"post mangler; forventet beslutningen som et kart med feltene fra "
|
||||
"build-brief 5.4"
|
||||
)
|
||||
argv = ["--workspace", root, "--json", json.dumps(post, ensure_ascii=False)]
|
||||
if arguments.get("korriger"):
|
||||
argv.append("--korriger")
|
||||
return _kjor_cli(beslutninger, argv)
|
||||
|
||||
|
||||
def tool_selvsjekk(arguments):
|
||||
root = _workspace(arguments)
|
||||
|
||||
|
|
@ -277,10 +481,16 @@ def tool_selvsjekk(arguments):
|
|||
)
|
||||
|
||||
|
||||
#: Insertion order is the order `tools/list` publishes, and the golden file
|
||||
#: pins it. Keep it the same as TOOLS above.
|
||||
HANDLERS = {
|
||||
"kandidat_valider": tool_kandidat_valider,
|
||||
"annonse_vurder": tool_annonse_vurder,
|
||||
"selvsjekk": tool_selvsjekk,
|
||||
"sak_status": tool_sak_status,
|
||||
"sak_status_check": tool_sak_status_check,
|
||||
"dagens": tool_dagens,
|
||||
"beslutning_append": tool_beslutning_append,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,11 +44,31 @@ Two environment variables, and they are NOT the same one:
|
|||
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.
|
||||
Two gates, in this order (O4, decided in docs/cowork-probe.md and implemented
|
||||
at plan Step 24):
|
||||
|
||||
1. VERSION. The interpreter reports at least 3.10, or it is not used.
|
||||
2. THE GUARD. The interpreter can import `llm_ingestion_guard`, or the
|
||||
launcher refuses to serve at all and says which command installs it.
|
||||
|
||||
Until M2 there was only the first, and the measured consequence is the reason
|
||||
for the second: the M1 probe found the server running happily on the host's
|
||||
3.14.0 with `selvsjekk` reporting `guard_versjon: null`, because nothing had
|
||||
ever asked. That cost nothing while no tool wrote anything. From M2 the case
|
||||
folder and the decision log persist employer names, titles, URLs and notes
|
||||
that came out of a listing, and the guard is the boundary those writes are
|
||||
supposed to cross.
|
||||
|
||||
Refusing is cheap here, and that is the deciding argument rather than a
|
||||
consolation: every skill in this plugin degrades to manual paste with
|
||||
`jobbsok-tools` absent. A launcher that refuses removes a connector; it does
|
||||
not strand the operator. Failing open would persist untrusted text through the
|
||||
one boundary this plugin exists to hold.
|
||||
|
||||
The guard gate costs one extra interpreter start-up at launch, which is paid
|
||||
once per session against Cowork's MCP start-up timeout. It is a real cost and
|
||||
it is worth naming; it is not a reason to check something cheaper that answers
|
||||
a different question.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -118,6 +138,25 @@ def version_ok(tolk):
|
|||
)
|
||||
|
||||
|
||||
#: The ingestion guard, by the name the interpreter has to be able to import.
|
||||
#: build-brief calls this the trust boundary, and the boundary is the write.
|
||||
GUARD_MODUL = "llm_ingestion_guard"
|
||||
|
||||
#: What fixes a failed guard gate. Named in the refusal, because a refusal
|
||||
#: that does not say what to run is a dead end.
|
||||
BOOTSTRAP = "python scripts/bootstrap.py"
|
||||
|
||||
|
||||
def guard_ok(tolk):
|
||||
"""True when ``tolk`` can import the ingestion guard. Runs it; does not
|
||||
look for a file, because what matters is that interpreter's own path."""
|
||||
return 0 == subprocess.call(
|
||||
[tolk, "-c", "import " + GUARD_MODUL],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def version_of(tolk):
|
||||
"""The version ``tolk`` reports, or "ukjent" when it will not say."""
|
||||
try:
|
||||
|
|
@ -206,9 +245,11 @@ def resolve_interpreter(env, plugin_root, which=None, gate=None):
|
|||
return None
|
||||
|
||||
|
||||
def main(argv, env=None):
|
||||
def main(argv, env=None, guard=None):
|
||||
if env is None:
|
||||
env = os.environ
|
||||
if guard is None:
|
||||
guard = guard_ok
|
||||
plugin_root = env.get("CLAUDE_PLUGIN_ROOT") or os.path.dirname(SCRIPT_DIR)
|
||||
server = os.path.join(plugin_root, "scripts", "jobbsok_tools.py")
|
||||
|
||||
|
|
@ -243,6 +284,23 @@ def main(argv, env=None):
|
|||
sys.stderr.write("jobbsok-tools: server not found at %s\n" % server)
|
||||
return 1
|
||||
|
||||
# Gate two. Last, because it is the expensive one and there is no point
|
||||
# probing an interpreter that already failed the floor.
|
||||
if not guard(tolk):
|
||||
sys.stderr.write(
|
||||
"jobbsok-tools: %s cannot import %s.\n" % (tolk, GUARD_MODUL)
|
||||
)
|
||||
sys.stderr.write(
|
||||
"jobbsok-tools: run '%s' against the installed plugin; the guard "
|
||||
"is installed with it.\n" % BOOTSTRAP
|
||||
)
|
||||
sys.stderr.write(
|
||||
"jobbsok-tools: refusing to start rather than serving tools that "
|
||||
"write without the ingestion guard. Every skill still works by "
|
||||
"manual paste.\n"
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -80,6 +80,111 @@
|
|||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sak_status",
|
||||
"description": "Utled status, ventende part, siste aktivitet, neste frist og stillhets-flagg for hver sak, fra logg.jsonl. sak.md er en hurtigbuffer og loggen vinner. Skriver ingenting.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
|
||||
},
|
||||
"today": {
|
||||
"type": "string",
|
||||
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
|
||||
},
|
||||
"sak": {
|
||||
"type": "string",
|
||||
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tekst",
|
||||
"json"
|
||||
],
|
||||
"description": "Utdataform. Standard: tekst."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sak_status_check",
|
||||
"description": "Samme utledning, men rapporterer om en hurtigbuffer i sak.md er uenig med loggen. Avviket staar i teksten, med saken navngitt; kommandolinja svarer i tillegg med exit-kode 1, som MCP ikke har. Retter ingenting og skriver ingenting.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
|
||||
},
|
||||
"today": {
|
||||
"type": "string",
|
||||
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
|
||||
},
|
||||
"sak": {
|
||||
"type": "string",
|
||||
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dagens",
|
||||
"description": "Dagens arbeidsbilde som ren tekst: hva som krever handling, hva som har gaatt stille, kommende frister og saker per tilstand. Byte-stabil for samme arbeidsomraade og samme dato. Skriver ingenting.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
|
||||
},
|
||||
"today": {
|
||||
"type": "string",
|
||||
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "beslutning_append",
|
||||
"description": "Legg til én beslutning i den append-only beslutningsloggen (build-brief 5.4), eller en korrigering som navngir en tidligere beslutning. En retting er alltid en ny linje, aldri en redigering. Returnerer posten som ble skrevet.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
|
||||
},
|
||||
"post": {
|
||||
"type": "object",
|
||||
"description": "Posten. Feltene er build-brief 5.4: id, dato, kilde, url, tittel, arbeidsgiver, beslutning, arsak, notat, score_da, delscore, vekter, vekt_hash, korrigerer."
|
||||
},
|
||||
"korriger": {
|
||||
"type": "boolean",
|
||||
"description": "Posten er en korrigering av en tidligere beslutning. Standard: false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace",
|
||||
"post"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,21 @@ 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.py")
|
||||
|
||||
KORPUS = os.path.join(REPO, "tests", "fixtures", "workspace")
|
||||
TODAY = "2026-09-15"
|
||||
|
||||
#: The tool surface, in the order the server declares it. A tool list is an
|
||||
#: API: M1 shipped the first three, Step 24 added the four M2 tools.
|
||||
VERKTOY = [
|
||||
"kandidat_valider",
|
||||
"annonse_vurder",
|
||||
"selvsjekk",
|
||||
"sak_status",
|
||||
"sak_status_check",
|
||||
"dagens",
|
||||
"beslutning_append",
|
||||
]
|
||||
|
||||
PROFIL_FIXTURE = ("profiles", "01-gyldig.md")
|
||||
ANNONSE_FIXTURE = ("listings", "01-alt-passer.md")
|
||||
|
||||
|
|
@ -96,9 +111,10 @@ def test_initialize_and_tools_list_match_the_golden_surface(klient, golden):
|
|||
verktoy = klient.tools()
|
||||
golden(GOLDEN, json.dumps({"tools": verktoy}, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
# The golden pins the shape; this pins the fact that M1 ships exactly three
|
||||
# tools, so Step 24 adding four more is a visible change and not a drift.
|
||||
assert [t["name"] for t in verktoy] == ["kandidat_valider", "annonse_vurder", "selvsjekk"]
|
||||
# The golden pins the shape; this pins the surface, so a tool appearing or
|
||||
# disappearing is a visible change and not a drift. M1 shipped the first
|
||||
# three; Step 24 added the four M2 tools.
|
||||
assert [t["name"] for t in verktoy] == VERKTOY
|
||||
|
||||
|
||||
def test_every_tool_requires_an_explicit_workspace(klient, arbeidsomrade):
|
||||
|
|
@ -206,7 +222,8 @@ def test_importing_the_server_does_not_import_the_capability_modules():
|
|||
"-c",
|
||||
"import sys; import jobbsok_tools; "
|
||||
"print(sorted(m for m in ('kandidat_schema', 'vurdering', "
|
||||
"'llm_ingestion_guard') if m in sys.modules))",
|
||||
"'sak_status', 'dagens', 'beslutninger', 'llm_ingestion_guard') "
|
||||
"if m in sys.modules))",
|
||||
],
|
||||
cwd=REPO,
|
||||
env=dict(os.environ, PYTHONPATH=SCRIPTS),
|
||||
|
|
@ -350,8 +367,177 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
|
|||
"expected exactly two replies, one per request: %r" % (servert.stdout,)
|
||||
)
|
||||
assert svar[0]["result"]["serverInfo"]["name"] == "jobbsok-tools"
|
||||
assert [t["name"] for t in svar[1]["result"]["tools"]] == [
|
||||
"kandidat_valider",
|
||||
"annonse_vurder",
|
||||
"selvsjekk",
|
||||
]
|
||||
assert [t["name"] for t in svar[1]["result"]["tools"]] == VERKTOY
|
||||
|
||||
|
||||
def cli(script, *argv):
|
||||
"""Run one of the command-line entry points and return its stdout."""
|
||||
env = dict(os.environ)
|
||||
env.pop("JOBBSOK_WORKSPACE", None)
|
||||
env.pop("CLAUDE_PLUGIN_DATA", None)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS, script)] + list(argv),
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def test_the_m2_tools_answer_with_the_bytes_the_command_line_produces(
|
||||
klient, empty_workspace, tmp_path
|
||||
):
|
||||
"""Parity is what makes the degradation branch honest.
|
||||
|
||||
Every skill degrades to reading what the CLI produced when the server is
|
||||
absent. That instruction is only true if the two paths produce the same
|
||||
content -- otherwise the degraded operator is reading a different report
|
||||
and does not know it.
|
||||
"""
|
||||
for verktoy, argumenter, script, argv in (
|
||||
("sak_status",
|
||||
{"workspace": KORPUS, "today": TODAY, "format": "json"},
|
||||
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--format", "json"]),
|
||||
("sak_status_check",
|
||||
{"workspace": KORPUS, "today": TODAY},
|
||||
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--check"]),
|
||||
("dagens",
|
||||
{"workspace": KORPUS, "today": TODAY},
|
||||
"dagens.py", ["--workspace", KORPUS, "--today", TODAY]),
|
||||
):
|
||||
fra_server = klient.call_text(verktoy, argumenter)
|
||||
fra_kommandolinja = cli(script, *argv)
|
||||
assert fra_server == fra_kommandolinja, (
|
||||
"%s and the command line disagree; the degradation branch tells "
|
||||
"the operator these are the same content" % verktoy
|
||||
)
|
||||
|
||||
# `sak_status_check` must carry the verdict in the payload: over MCP there
|
||||
# is no exit code, and a check whose answer was only an exit status would
|
||||
# arrive at Cowork as an unqualified report.
|
||||
svar = klient.call_text("sak_status_check", {"workspace": KORPUS, "today": TODAY})
|
||||
assert "utdatert hurtigbuffer" in svar, (
|
||||
"the corpus holds a deliberately divergent case and the check never "
|
||||
"says so: %r" % (svar[-200:],)
|
||||
)
|
||||
|
||||
# The writing tool, appended through each path into its own fresh
|
||||
# workspace, because the log refuses a reused id by design.
|
||||
from jobbsok_lib import paths as _paths
|
||||
|
||||
andre = str(tmp_path / "andre-workspace")
|
||||
_paths.scaffold(andre)
|
||||
post = {
|
||||
"id": "b-0001", "dato": "2026-09-01T09:00:00+02:00", "kilde": "manuell",
|
||||
"url": "https://stillinger.example/sak-1", "tittel": "Dataingeniør",
|
||||
"arbeidsgiver": "Værøy Sjømat AS", "beslutning": "ja",
|
||||
"arsak": ["fagomrade"], "notat": "treffer kjernen", "score_da": 78,
|
||||
"delscore": {"fagomrade": 78, "oppgavetype": 73, "teknologi": 68,
|
||||
"selskapstype": 63},
|
||||
"vekter": {"fagomrade": 40, "oppgavetype": 30, "teknologi": 20,
|
||||
"selskapstype": 10},
|
||||
"vekt_hash": "sha256:" + "e4" * 32,
|
||||
}
|
||||
fra_server = klient.call_text(
|
||||
"beslutning_append", {"workspace": empty_workspace, "post": post}
|
||||
)
|
||||
fra_kommandolinja = cli(
|
||||
"beslutninger.py", "--workspace", andre, "--json", json.dumps(post)
|
||||
)
|
||||
assert fra_server == fra_kommandolinja
|
||||
|
||||
# And it actually wrote, in both places: a parity test over two no-ops
|
||||
# would pass just as quietly.
|
||||
for rot in (empty_workspace, andre):
|
||||
with open(os.path.join(rot, "beslutninger.jsonl"), "r", encoding="utf-8") as handle:
|
||||
assert len(handle.read().splitlines()) == 1
|
||||
|
||||
|
||||
def tolk_uten_vakt(katalog, navn, versjon="3.14.0", har_vakt=False):
|
||||
"""A program that clears the version gate and answers the guard probe.
|
||||
|
||||
Two probes, and the fake has to tell them apart: the version gate runs
|
||||
`sys.version_info >= ...` and the guard gate runs `import
|
||||
llm_ingestion_guard`. With ``har_vakt`` false the second one fails, which
|
||||
is exactly the measured M1 state -- an interpreter at 3.14.0 that clears
|
||||
the floor and has no guard (O4 in docs/cowork-probe.md).
|
||||
|
||||
The Windows form is written but NOT measured; this suite has only ever run
|
||||
on macOS.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
sti = katalog / (navn + ".cmd")
|
||||
linjer = ["@echo off"]
|
||||
if not har_vakt:
|
||||
linjer.append('echo %* | findstr /C:"llm_ingestion_guard" >nul && exit /b 1')
|
||||
linjer.append('echo %%* | findstr /C:"print" >nul && (echo %s & exit /b 0)' % versjon)
|
||||
linjer.append("exit /b 0")
|
||||
sti.write_text("\r\n".join(linjer) + "\r\n")
|
||||
return sti
|
||||
|
||||
avvis = "" if har_vakt else " *llm_ingestion_guard*) exit 1 ;;\n"
|
||||
sti = katalog / navn
|
||||
sti.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# Clears the version gate at %s; the guard import is the variable.\n"
|
||||
'case "$*" in\n'
|
||||
"%s"
|
||||
" *print*) echo '%s'; exit 0 ;;\n"
|
||||
"esac\n"
|
||||
"exit 0\n" % (versjon, avvis, versjon)
|
||||
)
|
||||
sti.chmod(0o755)
|
||||
return sti
|
||||
|
||||
|
||||
def test_the_launcher_refuses_to_serve_without_the_ingestion_guard(tmp_path):
|
||||
"""O4, decided in docs/cowork-probe.md and implemented here.
|
||||
|
||||
The measured M1 incident: the server served on the host's 3.14.0, cleared
|
||||
the version floor, and reported `guard_versjon: null` because nothing had
|
||||
ever asked. From M2 the case folder and the decision log persist employer
|
||||
names, titles, URLs and notes that came out of a listing, so serving
|
||||
without the guard is a defect rather than an observation.
|
||||
|
||||
Failing closed is cheap here and that is the deciding argument: every
|
||||
skill degrades to manual paste with the server absent, so a launcher that
|
||||
refuses removes a connector -- it does not strand anyone.
|
||||
"""
|
||||
(tmp_path / "uten").mkdir()
|
||||
uten = tolk_uten_vakt(tmp_path / "uten", "python3.14", har_vakt=False)
|
||||
|
||||
avvist = subprocess.run(
|
||||
[sys.executable, LAUNCHER],
|
||||
env=dict(os.environ, JOBBSOK_PYTHON=str(uten)),
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert avvist.returncode != 0, (
|
||||
"the launcher served on an interpreter that cannot import the guard"
|
||||
)
|
||||
assert "llm_ingestion_guard" in avvist.stderr, (
|
||||
"the refusal must name what was missing: %r" % (avvist.stderr,)
|
||||
)
|
||||
assert "scripts/bootstrap.py" in avvist.stderr, (
|
||||
"the refusal must name the command that fixes it, or it is a dead end: "
|
||||
"%r" % (avvist.stderr,)
|
||||
)
|
||||
|
||||
# The positive control, without which the assertion above could be passing
|
||||
# on the version gate, on a missing file, or on nothing at all: the same
|
||||
# fake with the guard import answering must NOT be refused.
|
||||
(tmp_path / "med").mkdir()
|
||||
med = tolk_uten_vakt(tmp_path / "med", "python3.14", har_vakt=True)
|
||||
sluppet_gjennom = subprocess.run(
|
||||
[sys.executable, LAUNCHER],
|
||||
env=dict(os.environ, JOBBSOK_PYTHON=str(med)),
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert sluppet_gjennom.returncode == 0, (
|
||||
"an interpreter that clears both gates was still refused: %r"
|
||||
% (sluppet_gjennom.stderr,)
|
||||
)
|
||||
assert "refusing" not in sluppet_gjennom.stderr
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue