Owliver could offer neither create. The Create Position flow worked and no chip anywhere suggested it, because the chip row is entirely the backend's static catalogue and no intent in it wrote anything. The gap was never in the frontend's trigger matching — every phrasing already routed. `employee_roles` is the supply side of `job_postings`. A posting is what the ORGANIZATION needs filled; this is what a WORKER says they do. They share a vocabulary and almost nothing else: "3 years" on a posting is a minimum an applicant must clear, and the same words here are what the person has. There is deliberately no foreign key between them — supply and demand already meet through `job_applications`, which carries the funnel, the interview and the outcome, and a second weaker link would disagree with it the first time somebody withdrew. NO NEW COMPANY ENTITY, AND THAT IS THE LOAD-BEARING DECISION. "Create a company position" reads like it needs a client record. `organizations` is the TENANT — absent from the resource table, absent from the policy map, written only by the seeder — so creating a row there from a chat flow would provision a new tenant, and the position would carry an org_id the operator's session cannot see. The operator could never view the record they just created. That breaks I5 and I1 to add a feature nobody asked for. The client stays free text on the posting, per blueprint decision D2, and the flow simply offers the clients this organization already staffs for as chips. No schema change, no endpoint change. Create is operators-only, and that is an I1 decision rather than a deferral. The worker is named explicitly on the row and is deliberately NOT derived from the session, because an operator recording a role on somebody's behalf is the whole point of the flow. Granting talent the same Create would let a talent caller write a role under any worker_email in the tenant — the attribution hole Phase 3D closed elsewhere. Talent reads its own via a ScopeEmail predicate, which is in place now so the grant is one line when a talent console exists. `created_by` is in gen_resources.py's SERVER_OWNED as well as the policy's Derived list. Both are required and the pairing is easy to miss: Derived fills the column from the session, SERVER_OWNED is what makes the descriptor ReadOnly so a request body cannot set it in the first place. Without it, TestDerivedColumnsAreReadOnlyOrTalentScoped fails — verified by mutation, not by reading. The two catalogue intents carry PHRASE terms only. A bare "position" or "role" term scores 10, the same as every reading on that page, and wins the tie on declaration order — so a create chip would have arrived by evicting `positions-attention` from the exact ordered result TestPositionsSuggestions asserts. An offer to create something must not displace the reading a person actually asked for. Neither declares a Subject, on the precedent of `position-spec-steps`: a Subject would let the bare query "summarize" match through matchShape and survive filterOnTopic. Neither declares a Signal, so an empty composer still reports what the organization needs rather than proposing paperwork. Chip text is the coupling with nothing else holding it together: no page context declares `capabilities`, so every server suggestion dispatches as its own TEXT and is answered by whichever skill's trigger that text matches. A renamed chip would open nothing, silently. Asserted on the frontend side. The down migration drops `employee_role_status` and keeps `english_level`, which is shared with job_postings.english_required and job_applications.english_level. Rolled back and re-applied against the database to prove it, not asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
305 lines
15 KiB
Python
Executable File
305 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Verify a deployed Krow API, endpoint by endpoint.
|
|
|
|
KROW_EMAIL=you@example.com KROW_PASSWORD=... \
|
|
python3 scripts/verify-deploy.py https://mcp.krowforce.com
|
|
|
|
make verify-deploy BASE=https://mcp.krowforce.com
|
|
|
|
Credentials come from the environment, never from an argument, so they do not
|
|
land in shell history or in a process list.
|
|
|
|
READ-ONLY by default. The two write paths (hire, assignment) are exercised only
|
|
with --write, because they change tenant data and a smoke test that mutates the
|
|
thing it is checking is not a smoke test.
|
|
|
|
Why this exists: this API runs its auth middleware BEFORE routing, so an
|
|
unauthenticated probe answers 401 for every path — including paths that do not
|
|
exist. `curl` against a deployed host therefore cannot tell a missing endpoint
|
|
from a guarded one, and the only honest check is an authenticated one.
|
|
|
|
Exit code is non-zero if any check fails.
|
|
"""
|
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error, http.cookiejar
|
|
|
|
BASE = (sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-")
|
|
else os.environ.get("KROW_BASE_URL", "http://127.0.0.1:8080")).rstrip("/")
|
|
WRITE = "--write" in sys.argv
|
|
|
|
class BrowserLikePolicy(http.cookiejar.DefaultCookiePolicy):
|
|
"""Accept Secure cookies over http://localhost, as every browser does.
|
|
|
|
Browsers treat localhost as a potentially-trustworthy origin, so a Secure
|
|
cookie set through a dev-server proxy is stored and sent. Python's default
|
|
policy refuses it, which makes a perfectly working frontend look like a
|
|
broken session: login returns 200 and the very next request is 401.
|
|
|
|
Only localhost. Anywhere else, a Secure cookie over plaintext is refused as
|
|
it should be.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _trustworthy(request):
|
|
host = urllib.parse.urlparse(request.get_full_url()).hostname or ""
|
|
return host in ("localhost", "127.0.0.1", "::1", "[::1]")
|
|
|
|
def set_ok_secure(self, cookie, request):
|
|
return self._trustworthy(request) or super().set_ok_secure(cookie, request)
|
|
|
|
def return_ok_secure(self, cookie, request):
|
|
return self._trustworthy(request) or super().return_ok_secure(cookie, request)
|
|
|
|
|
|
jar = http.cookiejar.CookieJar(policy=BrowserLikePolicy())
|
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
results = []
|
|
|
|
def call(method, path, body=None, accept="application/json", timeout=45):
|
|
"""Returns (status, text, headers). Never raises for an HTTP status."""
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(BASE + path, data=data, method=method)
|
|
req.add_header("Content-Type", "application/json")
|
|
req.add_header("Accept", accept)
|
|
try:
|
|
with opener.open(req, timeout=timeout) as r:
|
|
return r.status, r.read().decode("utf-8", "replace"), dict(r.headers)
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.read().decode("utf-8", "replace"), dict(e.headers)
|
|
except Exception as e:
|
|
return 0, f"{type(e).__name__}: {e}", {}
|
|
|
|
def check(name, ok, detail=""):
|
|
results.append((name, bool(ok), detail))
|
|
print(f"[{' ok ' if ok else ' FAIL '}] {name}" + (f" — {detail}" if detail else ""))
|
|
return ok
|
|
|
|
def group(title):
|
|
print(f"\n── {title} " + "─" * max(0, 66 - len(title)))
|
|
|
|
def as_json(text):
|
|
try:
|
|
return json.loads(text)
|
|
except Exception:
|
|
return None
|
|
|
|
# resource -> the operations it declares (internal/domain/resources_gen.go).
|
|
# Anything not declared is deliberately unregistered: "the database having a
|
|
# table is never a reason for an endpoint to exist" (api.go). `badges` declares
|
|
# nothing at all, so every badges path is correctly a 404.
|
|
RESOURCE_OPS = {
|
|
"job-postings": ["List", "Get", "Create", "Update"],
|
|
"job-applications": ["List", "Create", "Update", "Delete"],
|
|
"ai-interviews": ["List", "Create"],
|
|
"staff": ["List", "Create", "Update"],
|
|
"worker-profiles": ["List", "Create", "Update"],
|
|
"employee-roles": ["List", "Get", "Create", "Update"],
|
|
"courses": ["List", "Get", "Create", "Update"],
|
|
"learning-paths": ["List"],
|
|
"role-categories": ["List", "Create"],
|
|
"certifications": ["List", "Create", "Delete"],
|
|
"user-activity": ["List", "Create"],
|
|
"evidence": ["List", "Create", "Update"],
|
|
"assignments": ["List", "Create"],
|
|
"shift-records": ["List"],
|
|
"badges": [],
|
|
}
|
|
RESOURCES = [r for r, ops in RESOURCE_OPS.items() if "List" in ops]
|
|
|
|
print(f"Verifying {BASE} ({'read/write' if WRITE else 'read-only'})")
|
|
|
|
# ── 1. Reachable, and guarded ────────────────────────────────────────────────
|
|
group("Reachable and guarded")
|
|
s, t, _ = call("GET", "/health")
|
|
health = as_json(t) or {}
|
|
check("/health answers 200", s == 200, f"{s} {health.get('status', t[:40])}")
|
|
check("/health reports a healthy database", health.get("status") == "ok",
|
|
f"status={health.get('status')} (degraded = schema unmigrated or dirty)")
|
|
s, _, _ = call("GET", "/api/v1/job-postings")
|
|
check("a protected endpoint refuses an anonymous caller", s in (401, 403), f"{s}")
|
|
|
|
# ── 2. Sign in ───────────────────────────────────────────────────────────────
|
|
group("Authentication")
|
|
email, password = os.environ.get("KROW_EMAIL"), os.environ.get("KROW_PASSWORD")
|
|
if not (email and password):
|
|
print("\nKROW_EMAIL / KROW_PASSWORD are not set — cannot check anything behind auth.")
|
|
print("Everything below needs a session. Set them and re-run.")
|
|
sys.exit(2)
|
|
|
|
s, t, _ = call("POST", "/api/v1/auth/login", {"email": email, "password": password})
|
|
if not check("sign-in succeeds", s == 200, str(s)):
|
|
print("\nNo session — stopping. Every remaining check needs one.")
|
|
sys.exit(1)
|
|
check("a session cookie was set", len(jar) > 0, f"{len(jar)} cookie(s)")
|
|
|
|
s, t, _ = call("GET", "/api/v1/me")
|
|
me = (as_json(t) or {}).get("data") or {}
|
|
check("GET /api/v1/me returns the signed-in user", s == 200 and bool(me), f"{s}")
|
|
check("...and it is the account that signed in",
|
|
str(me.get("email", "")).lower() == email.lower(), me.get("email", "?"))
|
|
role = me.get("role", "?")
|
|
print(f" signed in as {me.get('email','?')} (role: {role})")
|
|
|
|
s, _, _ = call("GET", "/api/v1/me/preferences")
|
|
check("GET /api/v1/me/preferences", s == 200, f"{s}")
|
|
|
|
# Which build is actually serving. Without this, "did my deploy land?" has no
|
|
# answer and a redeploy that silently rolled back looks identical to one that
|
|
# worked. Set KROW_EXPECT_VERSION to make a stale deployment a failure.
|
|
s, t, _ = call("GET", "/api/v1/version")
|
|
build = (as_json(t) or {}).get("data") or {}
|
|
running = build.get("version", "")
|
|
check("GET /api/v1/version reports the running build", s == 200 and bool(running),
|
|
f"{s}, version={running or 'none'}, env={build.get('env')}, "
|
|
f"endpoints={build.get('endpoints')}")
|
|
if running == "unknown":
|
|
check("...and the build was actually stamped", False,
|
|
"reports \"unknown\" — built without -X main.version, so it cannot be traced")
|
|
expected = os.environ.get("KROW_EXPECT_VERSION")
|
|
if expected:
|
|
check("...and it is the build you expected", running == expected,
|
|
f"running {running!r}, expected {expected!r}")
|
|
|
|
# ── 3. Every resource collection ─────────────────────────────────────────────
|
|
group(f"Resource endpoints ({len(RESOURCES)} collections)")
|
|
first_ids = {}
|
|
for r in RESOURCES:
|
|
s, t, _ = call("GET", f"/api/v1/{r}")
|
|
body = as_json(t) or {}
|
|
recs = body.get("data")
|
|
ok = s == 200 and isinstance(recs, list) and isinstance(body.get("meta"), dict)
|
|
total = (body.get("meta") or {}).get("total")
|
|
check(f"GET /api/v1/{r}", ok, f"{s}" + (f", {len(recs)} records, meta.total={total}" if ok else f" {t[:90]}"))
|
|
if ok and recs:
|
|
first_ids[r] = recs[0].get("id")
|
|
|
|
group("Reading one record by id (only where the resource declares Get)")
|
|
for r, rid in first_ids.items():
|
|
s, t, _ = call("GET", f"/api/v1/{r}/{rid}")
|
|
if "Get" in RESOURCE_OPS[r]:
|
|
check(f"GET /api/v1/{r}/{{id}}", s == 200, f"{s}")
|
|
else:
|
|
check(f"GET /api/v1/{r}/{{id}} is refused — it declares no Get",
|
|
s in (404, 405), f"{s}")
|
|
|
|
group("A resource that declares nothing exposes nothing")
|
|
for r, ops in RESOURCE_OPS.items():
|
|
if ops:
|
|
continue
|
|
s, _, _ = call("GET", f"/api/v1/{r}")
|
|
check(f"GET /api/v1/{r} → 404", s == 404, f"{s}")
|
|
|
|
group("An id that does not exist is 404, not 500")
|
|
s, _, _ = call("GET", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000")
|
|
check("unknown id → 404", s == 404, f"{s}")
|
|
s, _, _ = call("GET", "/api/v1/job-postings/not-a-uuid")
|
|
check("malformed id → 4xx, never 5xx", 400 <= s < 500, f"{s}")
|
|
|
|
# ── 4. The agent layer ───────────────────────────────────────────────────────
|
|
group("Agent and skill registry")
|
|
s, t, _ = call("GET", "/api/v1/agent-definitions")
|
|
body = as_json(t) or {}
|
|
agents = body.get("data") if isinstance(body, dict) else body
|
|
agents = agents if isinstance(agents, list) else []
|
|
check("GET /api/v1/agent-definitions", s == 200 and isinstance(agents, list), f"{s}, {len(agents)} agents")
|
|
if agents:
|
|
print(" " + ", ".join(sorted(str(a.get("definition_id") or a.get("id")) for a in agents)))
|
|
# Two different keys, deliberately. The registry endpoint is a CRUD resource
|
|
# keyed by uuid (repo.GetAgent: WHERE id = $1::uuid); the run endpoint is
|
|
# addressed by the stable definition_id a spec author writes. Passing the
|
|
# definition_id to the registry endpoint is a 404, which is correct.
|
|
row_uuid = agents[0].get("id")
|
|
s, _, _ = call("GET", f"/api/v1/agent-definitions/{row_uuid}")
|
|
check("GET /api/v1/agent-definitions/{uuid}", s == 200, f"{s}")
|
|
s, _, _ = call("GET", f"/api/v1/agent-definitions/{agents[0].get('definition_id')}")
|
|
check("...and the definition_id is not a uuid, so it is refused there", s == 404, f"{s}")
|
|
|
|
s, t, _ = call("GET", "/api/v1/skill-definitions")
|
|
body = as_json(t) or {}
|
|
skills = body.get("data") if isinstance(body, dict) else body
|
|
skills = skills if isinstance(skills, list) else []
|
|
check("GET /api/v1/skill-definitions", s == 200, f"{s}, {len(skills)} skills")
|
|
|
|
s, t, _ = call("GET", "/api/v1/owliver/suggestions?page=control-center")
|
|
check("GET /api/v1/owliver/suggestions?page=...", s == 200, f"{s}")
|
|
s, _, _ = call("GET", "/api/v1/owliver/suggestions")
|
|
check("...and it requires a page rather than guessing one", s == 400, f"{s}")
|
|
s, _, _ = call("GET", "/api/v1/owliver/suggestions?page=not-a-real-page")
|
|
check("...and rejects a page that does not exist", s == 400, f"{s}")
|
|
|
|
# ── 5. An actual agent run ───────────────────────────────────────────────────
|
|
group("Running an agent (this calls the model — it costs tokens)")
|
|
run_id = None
|
|
if not agents:
|
|
check("an agent run completes", False, "no agents are published on this deployment")
|
|
else:
|
|
aid = agents[0].get("definition_id") or agents[0].get("id")
|
|
t0 = time.time()
|
|
s, t, _ = call("POST", f"/api/v1/agents/{aid}/runs",
|
|
{"input": "What can you help me with? Answer in one sentence."})
|
|
body = as_json(t) or {}
|
|
took = time.time() - t0
|
|
ok = s == 200 and body.get("termination") is not None
|
|
check(f"POST /api/v1/agents/{aid}/runs", ok,
|
|
f"{s}, termination={body.get('termination')}, {took:.1f}s" if ok else f"{s} {t[:160]}")
|
|
if ok:
|
|
run_id = body.get("runId") or body.get("run_id")
|
|
check("...the run terminated cleanly",
|
|
body.get("termination") in ("Completed", "ConfirmationPending"),
|
|
str(body.get("termination")))
|
|
check("...and it produced an answer",
|
|
bool(body.get("output") or body.get("message") or body.get("confirmations")),
|
|
(body.get("output") or body.get("message") or "")[:70] or "confirmation proposed")
|
|
usage = body.get("usage") or {}
|
|
check("...with token accounting attached",
|
|
(usage.get("inputTokens", 0) or 0) > 0,
|
|
f"in={usage.get('inputTokens')} out={usage.get('outputTokens')}")
|
|
|
|
if run_id:
|
|
s, t, _ = call("GET", f"/api/v1/runs/{run_id}")
|
|
rb = as_json(t) or {}
|
|
check("GET /api/v1/runs/{id} returns the trajectory", s == 200, f"{s}")
|
|
entries = rb.get("entries")
|
|
check("...with the trajectory persisted",
|
|
isinstance(entries, list) and len(entries) > 0,
|
|
f"{len(entries) if isinstance(entries, list) else 0} entries, "
|
|
f"termination={rb.get('termination')}, model={rb.get('model') or '?'}")
|
|
check("...and the run pins the agent version it started with",
|
|
isinstance(rb.get("agentVersion"), int) and rb["agentVersion"] > 0,
|
|
f"v{rb.get('agentVersion')}")
|
|
|
|
# Streaming is the path the chat panel actually uses.
|
|
s, t, h = call("POST", f"/api/v1/agents/{aid}/runs",
|
|
{"input": "Say hello in five words."}, accept="text/event-stream")
|
|
ctype = (h.get("Content-Type") or h.get("content-type") or "")
|
|
check("the same endpoint streams on Accept: text/event-stream",
|
|
s == 200 and "event-stream" in ctype, f"{s}, content-type={ctype or 'none'}")
|
|
check("...and the stream carries more than one event",
|
|
t.count("data:") > 1, f"{t.count('data:')} data frames")
|
|
|
|
# ── 6. Writes (only with --write) ────────────────────────────────────────────
|
|
group("Write paths")
|
|
if not WRITE:
|
|
print(" skipped — re-run with --write to exercise hire and assignment")
|
|
else:
|
|
s, t, _ = call("POST", "/api/v1/job-postings/x/assignments", {})
|
|
check("POST assignments rejects a bad request rather than 500", 400 <= s < 500, f"{s}")
|
|
s, t, _ = call("POST", "/api/v1/job-applications/x/hire", {})
|
|
check("POST hire rejects a bad request rather than 500", 400 <= s < 500, f"{s}")
|
|
|
|
# ── 7. Sign out ──────────────────────────────────────────────────────────────
|
|
group("Sign out")
|
|
s, _, _ = call("POST", "/api/v1/auth/logout")
|
|
check("POST /api/v1/auth/logout", s in (200, 204), f"{s}")
|
|
s, _, _ = call("GET", "/api/v1/me")
|
|
check("the session is dead afterwards", s in (401, 403), f"{s}")
|
|
|
|
# ── Summary ──────────────────────────────────────────────────────────────────
|
|
failed = [r for r in results if not r[1]]
|
|
print(f"\n{len(results) - len(failed)}/{len(results)} checks passed")
|
|
if failed:
|
|
print("\nFailed:")
|
|
for name, _, detail in failed:
|
|
print(f" - {name}{f' ({detail})' if detail else ''}")
|
|
sys.exit(1)
|