diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml new file mode 100644 index 0000000..9aee820 --- /dev/null +++ b/.github/workflows/release-checks.yml @@ -0,0 +1,29 @@ +name: Release packaging checks (no publishing) +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: release-checks-${{ github.ref }} + cancel-in-progress: true +jobs: + wheel: + runs-on: ubuntu-latest + env: + UV_CONCURRENT_BUILDS: '1' + UV_CONCURRENT_INSTALLS: '1' + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + version: '0.12.23' + - run: uv python install 3.11 + - run: uv lock --check + - name: Check deployment export drift + run: | + uv export --frozen --extra admin --extra vault --no-dev --no-emit-project --no-hashes -o admin/requirements-lock.txt + git diff --exit-code -- admin/requirements-lock.txt + - run: uv run --no-project --python 3.11 python tests/release/check_release.py + - run: uv run --no-project --python 3.11 python tests/release/test_deployment.py + - run: bash -n deploy/install.sh deploy/redeploy-toolstack diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..f3f4861 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# Source-integrity SHA-256, not a credential: verification.json line 16 records +# macapp/Sources/ToolstackKit/ApiClient.swift at baseline 06df1b15c0c8ac996ec8ce4851e674199b124393. +# Independently matched to SHA-256 of git show BASELINE:path. Exempt only this +# archived finding fingerprint; no path, rule or digest-wide exemption. +9f36f44775e4b187ac1cc6ee1155beddf5184237:docs/ui-directions/iterations/01-superseded.zip!evidence/verification.json:generic-api-key:16 diff --git a/README.md b/README.md index cf32fd8..79975ab 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,41 @@ -

- Toolstack logo -

- # Toolstack -**Trust agents with action, not access.** An agent can *ask*; the broker *decides*; tools -*execute*; secrets stay with the tools. The agent can reach the broker, and nothing else. - -The security comes from physical boundaries (a single ingress, loopback binding, and -secrets resolved at the workload), not from rules on paper. The agent carries only a -low-power token to the broker; everything sensitive lives behind it. - -## Components - -- **Broker**: the authority boundary and the only address the agent has: auth, - policy, request lifecycle, approval orchestration, and audit. One process, one - SQLite file. (Internally split into module seams; see the architecture doc.) -- **Toolyard**: container lifecycle + per-tool secret resolution at container start. -- **Tool template**: what a new tool needs (drop a `toolyard.toml`, run `toolyard up`). -- **Approval adapter**: a broker module that talks to nod; pluggable via - [docs/approval-surface-adapter.md](docs/approval-surface-adapter.md). -- **Agent client**: the generic `toolstack` CLI + skill (and an MCP adapter) an - agent uses to discover and call tools through the broker; see - [client/SKILL.md](client/SKILL.md). -- **Admin web app**: the operator's control panel: run the broker, manage - callers/tokens/policies, author/edit tools (form → `toolyard.toml`), and watch - requests + audit. Local/homelab, loopback-only; see [admin/README.md](admin/README.md). -- **Operator apps**: a cross-platform desktop shell ([desktop/](desktop/), an OS-WebKit window - around the admin) and a native macOS app ([macapp/](macapp/), SwiftUI over the JSON API). - -## You also run - -- **nod**: self-hosted approval surface for human-in-the-loop decisions. -- **Secret backend**: Infisical or SOPS. -- **Tailnet**: Tailscale Serve (or any VPN); the ingress boundary. - -## Run it - -The fastest way to bring up the whole stack (broker + admin panel) is the one-box Docker setup: - -```bash -cd deploy/docker -cp .env.example .env # set an admin password + vault passphrase -docker compose up -``` - -Open the admin panel at http://127.0.0.1:8780 and drive everything from there. For a native -single-host install under systemd, run `sudo deploy/install.sh` (it creates the service user, -the virtualenv, the admin password, and the unit) or follow [deploy/README.md](deploy/README.md). - -## The CLIs - -The broker, toolyard, and client are stdlib-only Python (3.11+, for `tomllib`). `pip install -e .` -puts the commands on your PATH: - -```bash -pip install -e . -toolstack --help -``` - -That installs `toolstack` (agent client), `brokerctl` (operator), `toolyard` (tool runner), and -`toolstack-mcp` (the client as an MCP stdio server); each also runs uninstalled as -`python3 -m client.toolstack`, and so on. **Installing the CLIs does not start anything**: they -talk to a broker you run via one of the paths above. Only the admin app carries runtime deps -(FastAPI/uvicorn), in its own venv; see [admin/README.md](admin/README.md). - -## Maturity - -The full path runs end to end (agent → broker → human approval → tool execution, with the -broker never holding a secret) and it's hardened for a single-host deployment: a systemd -state directory and sandbox, login throttling, bind and SSRF guards, runtime timeouts with -partial-failure cleanup, pinned dependencies, and database backups. It binds loopback by -default; reach it over a tunnel that terminates TLS. A few things are deliberately deferred: -tmpfs secret injection, just-in-time grants, and a background approval-expiry sweeper. - -## Docs - -- [docs/walkthrough.md](docs/walkthrough.md), the system end to end: how it runs, the security properties, and the evidence. -- [docs/component-decomposition.md](docs/component-decomposition.md): architecture, broker internals, and trust boundaries. -- [admin/README.md](admin/README.md) · [broker/README.md](broker/README.md) · [toolyard/README.md](toolyard/README.md): per-component detail. +Brokered actions for agents: callers receive scoped action permissions, while +authorized tools retrieve workload credentials through SPS. Web admin controls +callers, policies and trusted tools. The broker and SPS make separate decisions. + +**Supported release boundary: a dedicated Toolstack VM containing only Toolstack +and trusted tools under its OS account. Agents stay outside that host and reach +only the broker.** No VM setup is performed by these source changes. Same-machine +or same-account agents (including on a Mac) are local/development use only and +do not isolate secrets. Process mode is not hostile-code isolation. Secrets +exist as plaintext in endpoint memory and can be returned by tool responses. +Private files and TLS do not isolate processes sharing the same unrestricted uid. + +- [Runtime support matrix](docs/runtime-matrix.md) +- [Integrated security contracts and remaining release gates](docs/security-contracts.md) +- [Deployment and offline SPS provisioning](deploy/README.md) +- [Fake-config walkthrough](docs/walkthrough.md) +- [SPS architecture](docs/sps.md) +- [Local Mac signing gate](docs/mac-local-signing.md) +- [Release notes and pending validation](docs/release-notes.md) + +The Linux installer and systemd deployment are untested. +The isolated retained-wheel full-stack check on this Mac **passed**, including admin +restart/persisted recovery and independently verified owned cleanup. This closes +the bounded clean-install check, using fake state and fake Nod, not real Nod, +signature or human approval proof. No GUI checks ran. See the release notes for +its scope. Manual hands checks have not run and remain OFF pending fresh +principal go through chief; no release acceptance is implied. + +Python 3.11+ is required. Build with `uv build`; the wheel includes admin, broker, +client, toolyard, SPS/plugins and forwarder. Admin dependencies and localfile crypto +are optional extras (`.[admin,vault]`). Full deployment dependencies are resolved in +`uv.lock` and exported to `admin/requirements-lock.txt`; deployment scripts consume +that export consistently as requirements and constraints. + +Run `uv run --no-project --python 3.11 python tests/release/check_release.py` for +isolated packaging verification. Release workflow checks/builds only: no tags, +publishing, GHCR, signing or release creation. Full fake-secret E2E is a separate +integration gate. Docker/Seatbelt with SPS and mutable Linux privileged helpers +are unsupported. Source build/sign scripts do not establish distribution readiness. diff --git a/admin/README.md b/admin/README.md index 7876a40..974d63f 100644 --- a/admin/README.md +++ b/admin/README.md @@ -39,8 +39,9 @@ a relative tools root. Then sign in and: command/port or image, operations, and secret *declarations*) into a directory you name on the server. REST tools can be authored directly or bootstrapped from a pasted OpenAPI/Swagger spec. **Edit** loads an existing tool back into that form; **Remove** - unregisters a panel-added tool (stops it and drops it from the broker's search path; - its files stay on disk). Start/stop tools, then restart the broker to register a new + unregisters an external tool directory (stops it and removes that configured source; + its files stay on disk), or deletes a known managed folder. Unknown ownership refuses + removal; another source may still discover the same tool. Start/stop tools, then restart the broker to register a new or changed one (the broker reads its registry at startup). You bring the tool's code (a process `command`/`app.py`, or a Docker image); the panel writes only the manifest. @@ -59,8 +60,8 @@ by binding a public interface. failed attempts are audited (`admin.login_failed` with the IP + attempted username, never the password). - Secrets stay off the browser: the nod token is **write-only / masked**, and tool - secret *values* are never entered here (the toolyard resolves them from the on-disk - secrets file); the tool editor authors only secret *declarations* (name + field). + secret *values* are never entered here (tools procure them through SPS); + the tool editor authors only secret *declarations* (name + field). - The tool editor writes to a directory you name (operator-trusted, loopback-only); it only ever writes a file called `toolyard.toml`, and registers the directory in the run-config's `tool_dirs` so the broker discovers it. @@ -96,3 +97,89 @@ round-trip, a **real broker** start/stop via the supervisor, the toolyard listin tool authoring (build/validate a `toolyard.toml` and confirm the broker registry can read it), and a full login → create-caller → set-policy flow through FastAPI's `TestClient`. +## Current operator contracts + +The implemented Clear desk web UI uses 38px controls, real HTML selects and +accessible policy labels. Failed loads disable saving; failed saves retain drafts. +Unknown outcomes require fresh read/comparison before retry, never automatic effect +replay. Policy adapters preserve advanced/unavailable grants; the broker's underlying +full-replacement policy semantics are unchanged. + +### Catalog, removal and authoring + +Public `GET /v1/health` returns `status: ok` and optionally a random opaque +`catalog_observation_id` only: no fingerprint, tool names, paths or secret blocks. +Authenticated `GET /api/catalog` returns `{status: verified, observation_id: , +tool_ids: [...]}` from the same fingerprint-verified Registry snapshot, including +zero-operation tools, or `{status: unknown}`. An empty list is a valid global +observation but verifies no target. Missing, legacy or malformed membership means +target unknown. Each row/target needs membership in that same receipt; +`/api/tools` discovery is independent and is not registration proof. + +Files added, tool Start, and broker registration are separate explicit stages. +Adding never starts a tool or grants authority automatically. Registration proves +neither process liveness nor health. `DELETE /api/tools/{id}` returns +`{removed: id, deleted: false}` for external unregister/files retained and +`deleted: true` for managed-folder deletion. Missing deletion metadata leaves the +filesystem outcome unknown; other duplicate sources may still discover the tool. + +OpenAPI `auth_headers` are caller-envelope hints, not stored automatic injection. +Preserve `allowed_headers` and secret declarations; callers supply +`{{secret:NAME}}` placeholders in request headers. Never enter actual secret values +in the operator UI. + +At source 855da6e (broker a9319b9 / release059dd13), see [integrated security contracts](../docs/security-contracts.md) +for disclosure and remaining gates. Optional operation approval_fields is preserved +absent versus []; both visibly omit ALL arguments. Pointers explicitly disclose +selected subtrees, not universally safe free text. No UI disclosure controls were +added. The shared validator now rejects '*' anywhere; '/' is a valid empty key, +empty root pointer invalid. Refresh needs ordinary grants; token revocation needs 12–64 lowercase hex. +Policy changes invalidate pending approvals; manifest generation is rechecked at +both immediate/deferred claims (the executing claim is the cancellation commit point). +Schema3 receipts distinguish received/pending_approval/executing/unknown/result_expired, +with idempotency_key/reused/payloads_pruned and wait-timeout broker_status fields; +consumers must adopt them and conflict409 rather than label everything pending. +Matching keys return old receipts even after generation changes, never new effects. +Historical output requires a live current caller/exact presented token and current +allow OR review grant; review is historical read authority, not a new approval. +Deny/removal/revoked token withholds output, not bytes already returned. + +Manual prune-payloads/backup/reconcile-offline/restore-new commands are documented +in [security contracts](../docs/security-contracts.md#manual-retention-and-offline-recovery). +Stop ALL broker/admin/CLI writers before confirming offline reconcile/restore; +ordinary Store opening never relabels active work. NEW-destination restore revokes +ALL tokens, clears ALL policies and rotates UUID, never overwrites source or replays. +Keep restored authority revoked until external/post-snapshot history is reconciled; +never blindly replay queues. Seven-day pruning preserves tombstones, not secure +erasure. Native canonical fixture/status adoption is implemented, not full web parity proof. + +Password reset rotates the session generation atomically. Running admin instances +reject old browser cookies and bearer tokens on their next request, without a +restart. An already-authorized in-flight action is not cancelled by reset. + +Configuration audit records changed schema field names, never values, tokens, +paths or destinations. An intent audit must succeed before saving. If completion +auditing fails after saving, the operation fails with an explicit already-saved +error; verify before retrying. Authenticated host administrators can delete the +local audit (HTML also requires session-bound CSRF). Deletion leaves a new +`audit_cleared` marker where storage succeeds, but is not secure erasure or +tamper-proof evidence. A marker-write failure cannot undo the deletion. + +Blocking tool import/update, tool lifecycle and broker process control use one +off-loop worker with immediate busy rejection, not an unbounded job queue. +Cancelling a request does not free admission until its actual work ends. +No automatic replay or cancellation of already-running external actions is implied. + +Native/API contract fixtures live in `admin/tests/fixtures/native_api.json`. +Backend identity uses `plugin`, not `name`; unknown secret inspection is +`status: UNKNOWN` with `provisioned: null`. Liveness is distinct from recorded +running intent and from health; no health probe means health is unknown, not +healthy. Failed liveness inspection is nullable/unknown. Native strict decoders +adopt these contracts. Old API secret-value provisioning returns 410 and +does not parse or store values; provisioning is host-side only. + +Lifecycle mutations require the integrated runner `state_lock` across the +complete load/mutate/save operation. Missing lock support fails closed. A newly +started runtime is stopped if its record cannot be published; failed cleanup +returns explicit failure and retains a runtime reference for recovery. State +and channel-view publication are not a single filesystem transaction. diff --git a/admin/__main__.py b/admin/__main__.py index fe6a379..9eca3d1 100644 --- a/admin/__main__.py +++ b/admin/__main__.py @@ -29,7 +29,7 @@ def _set_password(args) -> None: settings.write_password_hash(auth.hash_password(pw)) settings.rotate_session_secret() # a password change logs out existing sessions print(f"admin password set ({settings.password_hash_file()}); existing sessions invalidated " - "(restart the admin to apply).") + "on their next request.") def _serve(args) -> None: diff --git a/admin/api.py b/admin/api.py index 24bd25a..3d05268 100644 --- a/admin/api.py +++ b/admin/api.py @@ -9,7 +9,7 @@ Split out of ``server.create_app`` so the JSON surface stays separate from the HTML server. Covers the full operator surface: auth, broker status/control, callers/policies/tokens, tools -(add/author/update + secret declarations), secret values, and the request + audit log. +(add/author/update + secret declarations), unknown-safe secret status, and the request + audit log. """ from __future__ import annotations @@ -26,6 +26,8 @@ from . import (auth, broker_config, secret_values, settings, supervisor, tool_authoring, tool_sources, toolyard_ops) from .store_access import open_store +from . import operator_audit +from .blocking import work # broker lifecycle action -> the admin.* audit event it records (shared with the HTML handler) _BROKER_EVENTS = {"start": "broker_started", "stop": "broker_stopped", "restart": "broker_restarted"} @@ -77,14 +79,14 @@ async def _json_object(request: Request) -> dict: def add_api_routes(app: FastAPI, secret: str, guard) -> None: - """Register the ``/api/*`` JSON routes on the admin app. ``secret`` is the session-signing - secret (same one the cookie uses), so a token minted here is interchangeable with the panel's. + """Register JSON routes. The startup secret argument is retained for compatibility; + requests use the generation snapshot set by the shared middleware instead. ``guard`` is the shared :class:`loginguard.LoginGuard` so /api/login and /login throttle as one.""" def require_user(request: Request) -> str: authz = request.headers.get("authorization", "") token = authz[7:].strip() if authz[:7].lower() == "bearer " else "" - user = auth.verify_session(token, secret) + user = auth.verify_session(token, request.state.session_secret) if not user: raise HTTPException(status_code=401, detail="unauthorized") return user @@ -106,7 +108,7 @@ async def api_login(request: Request): # same fail-closed message for bad user OR bad password; don't reveal which raise HTTPException(status_code=401, detail="invalid credentials") guard.record_success(ip) - token = auth.sign_session(username, secret, settings.session_ttl_seconds()) + token = auth.sign_session(username, request.state.session_secret, settings.session_ttl_seconds()) return {"token": token, "username": username} @app.get("/api/broker") @@ -119,7 +121,9 @@ async def api_broker_action(action: str, user: str = Depends(require_user)): raise HTTPException(status_code=400, detail=f"unknown broker action: {action}") config = broker_config.load() try: - result = supervisor.stop() if action == "stop" else getattr(supervisor, action)(config) + result = await work.run(supervisor.stop) if action == "stop" else await work.run(getattr(supervisor, action), config) + except HTTPException: + raise except Exception as exc: # a failed spawn is a 502, not a 500 raise HTTPException(status_code=502, detail=f"broker {action} failed: {exc}") with open_store(config) as store: @@ -224,8 +228,7 @@ async def api_audit(user: str = Depends(require_user), limit: int = 50): @app.delete("/api/audit") async def api_clear_audit(user: str = Depends(require_user)): - with open_store(broker_config.load()) as store: - store.clear_audit() + operator_audit.clear_audit(broker_config.load(), user) return {"ok": True} @app.get("/api/tools") @@ -263,6 +266,11 @@ async def api_tools(user: str = Depends(require_user)): tool["source"] = tool_sources.read_source(tool["path"]) # sidecar (path/github) or null return {"tools": tools} + @app.get("/api/catalog") + async def api_catalog(user: str = Depends(require_user)): + from .catalog_observation import observe_catalog + return await work.run(observe_catalog, broker_config.load()) + @app.post("/api/tools") async def api_add_tool(request: Request, user: str = Depends(require_user)): """Add a tool by COPYING it into the broker's tools dir (where it's auto-discovered). The @@ -285,14 +293,14 @@ async def api_add_tool(request: Request, user: str = Depends(require_user)): existing = [] # can't confirm uniqueness while some tool fails to load; _ingest still guards the dir try: if repo: - tool = tool_sources.add_from_github( + tool = await work.run(tool_sources.add_from_github, repo, config.tools_root, existing, subdir=data.get("subdir", ""), ref=data.get("ref", "")) elif manifest is not None: # author-from-code-only: copy the folder + write the supplied manifest (no toolyard.toml needed) - tool = tool_sources.add_with_manifest(source, config.tools_root, existing, manifest) + tool = await work.run(tool_sources.add_with_manifest, source, config.tools_root, existing, manifest) else: - tool = tool_sources.add_from_path(source, config.tools_root, existing) + tool = await work.run(tool_sources.add_from_path, source, config.tools_root, existing) except tool_sources.NoManifest: raise HTTPException(status_code=422, detail="no toolyard.toml at that location; author one to add it") except ValueError as exc: @@ -371,7 +379,7 @@ async def api_update_tool_source(tool_id: str, user: str = Depends(require_user) if tool_id not in by_id: raise HTTPException(status_code=404, detail=f"no such tool: {tool_id}") try: - tool = tool_sources.update(by_id[tool_id]["path"]) + tool = await work.run(tool_sources.update, by_id[tool_id]["path"]) except tool_sources.NoManifest: raise HTTPException(status_code=422, detail="the tool's source no longer has a toolyard.toml") except ValueError as exc: @@ -382,19 +390,16 @@ async def api_update_tool_source(tool_id: str, user: str = Depends(require_user) @app.delete("/api/tools/{tool_id}") async def api_remove_tool(tool_id: str, user: str = Depends(require_user)): - """Remove a TSR-managed tool: stop it if running and delete its folder under the tools - root. A tool referenced from an external tool_dir is not deleted (a 400 explains how to - unregister it).""" + """Delete a managed folder, or unregister an external source while retaining files.""" config = broker_config.load() + from .tool_removal import remove_tool try: - toolyard_ops.remove(tool_id, config.tools_root, config.tool_dirs) + result = await work.run(remove_tool, config, tool_id, user) except LookupError as exc: raise HTTPException(status_code=404, detail=str(exc)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) - with open_store(config) as store: - operations.record_admin_event(store, user, "tool_removed", {"tool": tool_id}) - return {"removed": tool_id} + return result def _declared_fields(config, tool_id: str) -> list[str]: """The secret FIELDS a tool declares (or None if there's no such tool).""" @@ -408,9 +413,10 @@ def _declared_fields(config, tool_id: str) -> list[str]: @app.get("/api/tools/{tool_id}/secrets") async def api_tool_secret_status(tool_id: str, user: str = Depends(require_user)): - """Which of a tool's declared secret fields currently have a value, and whether the operator - can set them here. Provisioning is supported only for the local 'vault' backend; values are - NEVER returned, only set/unset status.""" + """Declared fields and inspection status; values are never fetched. + Unknown inspection is null, not evidence that fields are absent. + Provisioning remains host-side only. + """ config = broker_config.load() declared = _declared_fields(config, tool_id) if declared is None: @@ -422,7 +428,14 @@ async def api_tool_secret_status(tool_id: str, user: str = Depends(require_user) return {"backend": settings.sps_info()["plugin"], "settable": secret_values.is_settable(), "fields": declared, - "provisioned": provisioned} + "provisioned": provisioned, + "status": "UNKNOWN" if provisioned is None else "KNOWN"} + + @app.post("/api/tools/{tool_id}/secrets") + async def api_obsolete_provisioning(tool_id: str, user: str = Depends(require_user)): + # Explicit tombstone for old native clients. Do not parse or persist + # their value body, and never manufacture a successful provisioning. + raise HTTPException(status_code=410, detail="Secret provisioning is host-side only; this API has been removed") # Per-tool lifecycle. Declared AFTER the literal /update route so that matches # first; this catches start | stop | restart. Mirrors the HTML panel's /toolyard/tools/{id}/{action}. @@ -442,11 +455,13 @@ async def api_tool_action(tool_id: str, action: str, user: str = Depends(require raise HTTPException(status_code=404, detail=f"no such tool: {tool_id}") try: if action == "stop": - toolyard_ops.stop(tool_id) + await work.run(toolyard_ops.stop, tool_id) else: - getattr(toolyard_ops, action)( + await work.run(getattr(toolyard_ops, action), tool_id, config.tools_root, config.tool_dirs, settings.tool_runner_backend()) + except HTTPException: + raise except Exception as exc: # a failed spawn/build is a 502, not a 500 raise HTTPException(status_code=502, detail=f"tool {action} failed: {exc}") with open_store(config) as store: @@ -464,41 +479,11 @@ async def api_set_config(request: Request, user: str = Depends(require_user)): data = await _json_object(request) current = broker_config.load() - def _int(key: str, default: int) -> int: - if key not in data: - return default - try: - return int(data[key]) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail=f"{key} must be an integer") - - port = _int("port", current.port) - approval_ttl = _int("approval_ttl", current.approval_ttl) - rate_limit = _int("rate_limit", current.rate_limit) - if not 1 <= port <= 65535: - raise HTTPException(status_code=400, detail="port must be 1..65535") - if approval_ttl < 1: - raise HTTPException(status_code=400, detail="approval_ttl must be >= 1") - if rate_limit < 0: - raise HTTPException(status_code=400, detail="rate_limit must be >= 0") - # nod token is write-only: keep the stored value unless a new non-empty one is given. - nod_token = (data.get("nod_token") or "").strip() or current.nod_token - updated = broker_config.BrokerRunConfig( - port=port, - db_path=current.db_path, # not editable here: changing it would orphan the DB - tools_root=(data.get("tools_root") or current.tools_root).strip() or current.tools_root, - nod_url=data.get("nod_url", current.nod_url).strip(), - nod_token=nod_token, - nod_channel=data.get("nod_channel", current.nod_channel).strip(), - approval_ttl=approval_ttl, - rate_limit=rate_limit, - tool_dirs=current.tool_dirs, - ) try: - broker_config.validate_nod_url(updated.nod_url) # SSRF guard (carries the nod token) + updated = broker_config.merge_update(current, data) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) - broker_config.save(updated) # the operator restarts the broker to apply (like the HTML panel) + operator_audit.save_config(current, updated, user) return updated.masked() @app.get("/api/secret-backend") diff --git a/admin/auth.py b/admin/auth.py index 1068c7e..41af716 100644 --- a/admin/auth.py +++ b/admin/auth.py @@ -1,7 +1,7 @@ """Password hashing, signed browser sessions, and CSRF tokens. -The password and session helpers are ported verbatim from the previous -broker-panel (stdlib ``scrypt`` + HMAC, no external dependency). Passwords use +The password and session helpers derive from the previous broker-panel +(stdlib ``scrypt`` + HMAC, no external dependency). Passwords use ``scrypt`` because they are human-chosen and low-entropy; this is deliberately different from broker *tokens*, which are high-entropy random and only SHA-256 hashed (see :mod:`broker.identity`). @@ -9,6 +9,7 @@ CSRF protection uses a synchronizer token derived from the session value. A cross-site attacker can drive the browser to POST but cannot read the session cookie, so cannot compute a matching token; this closes a gap the old panel had. +Session and CSRF HMACs have distinct purpose prefixes; legacy tokens are rejected. """ from __future__ import annotations @@ -58,13 +59,13 @@ def verify_password(password: str, encoded: str) -> bool: def sign_session(username: str, secret: str, ttl_seconds: int) -> str: expires_at = int(time.time()) + ttl_seconds message = f"{username}|{expires_at}" - signature = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() + signature = hmac.new(secret.encode("utf-8"), ("session|" + message).encode("utf-8"), hashlib.sha256).hexdigest() raw = f"{message}|{signature}".encode("utf-8") return base64.urlsafe_b64encode(raw).decode("ascii") def verify_session(cookie: str | None, secret: str) -> str | None: - if not cookie: + if not isinstance(cookie, str) or not cookie: return None try: decoded = base64.urlsafe_b64decode(cookie.encode("ascii")).decode("utf-8") @@ -75,8 +76,8 @@ def verify_session(cookie: str | None, secret: str) -> str | None: if expires_at < int(time.time()): return None message = f"{username}|{expires_at}" - expected = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() - if not hmac.compare_digest(signature, expected): + expected = hmac.new(secret.encode("utf-8"), ("session|" + message).encode("utf-8"), hashlib.sha256).hexdigest() + if not signature.isascii() or not hmac.compare_digest(signature, expected): return None return username @@ -84,11 +85,16 @@ def verify_session(cookie: str | None, secret: str) -> str | None: def csrf_token(session_value: str, secret: str) -> str: """A CSRF token bound to one session, derived via HMAC over the session cookie.""" return hmac.new( - secret.encode("utf-8"), ("csrf|" + session_value).encode("utf-8"), hashlib.sha256 + secret.encode("utf-8"), ("csrf-v1|" + session_value).encode("utf-8"), hashlib.sha256 ).hexdigest() def verify_csrf(token: str | None, session_value: str | None, secret: str) -> bool: - if not token or not session_value: + if (not isinstance(token, str) or not token or not token.isascii() + or not isinstance(session_value, str) or not session_value): return False - return hmac.compare_digest(token, csrf_token(session_value, secret)) + try: + expected = csrf_token(session_value, secret) + except UnicodeEncodeError: + return False + return hmac.compare_digest(token, expected) diff --git a/admin/blocking.py b/admin/blocking.py new file mode 100644 index 0000000..36f4f8e --- /dev/null +++ b/admin/blocking.py @@ -0,0 +1,28 @@ +"""Bounded off-loop work; no service or unbounded executor queue.""" +import asyncio +import functools +import threading +from concurrent.futures import ThreadPoolExecutor + +from fastapi import HTTPException + + +class BlockingWork: + def __init__(self, limit=1): + self._slots = threading.BoundedSemaphore(limit) + self._executor = ThreadPoolExecutor(max_workers=limit, thread_name_prefix="admin-work") + + async def run(self, fn, *args, **kwargs): + if not self._slots.acquire(blocking=False): + raise HTTPException(status_code=503, detail="Admin work capacity busy; retry later") + try: + future = self._executor.submit(functools.partial(fn, *args, **kwargs)) + except BaseException: + self._slots.release() + raise + # Release only when actual work finishes, even if the caller disconnects. + future.add_done_callback(lambda _: self._slots.release()) + return await asyncio.shield(asyncio.wrap_future(future)) + + +work = BlockingWork() diff --git a/admin/broker_config.py b/admin/broker_config.py index e5a797c..49ebc4a 100644 --- a/admin/broker_config.py +++ b/admin/broker_config.py @@ -117,6 +117,37 @@ def masked(self) -> dict: return data +def merge_update(current: BrokerRunConfig, data: dict) -> BrokerRunConfig: + """Shared operator save contract. Unexposed fields stay unchanged.""" + values = asdict(current) + for key in ("port", "approval_ttl", "rate_limit"): + raw = data.get(key, values[key]) + if raw == "": + continue + if isinstance(raw, bool) or not isinstance(raw, (str, int)): + raise ValueError(f"{key} must be an integer") + try: + values[key] = int(raw) + except ValueError: + raise ValueError(f"{key} must be an integer") from None + if not 1 <= values["port"] <= 65535: + raise ValueError("port must be 1..65535") + if values["approval_ttl"] < 1: + raise ValueError("approval_ttl must be >= 1") + if values["rate_limit"] < 0: + raise ValueError("rate_limit must be >= 0") + for key in ("tools_root", "nod_url", "nod_token", "nod_channel"): + if key not in data: + continue + if not isinstance(data[key], str): + raise ValueError(f"{key} must be text") + value = data[key].strip() + if value or key not in ("tools_root", "nod_token"): + values[key] = value + validate_nod_url(values["nod_url"]) + return BrokerRunConfig(**values) + + def config_file() -> Path: return settings.config_dir() / "broker.toml" diff --git a/admin/catalog_observation.py b/admin/catalog_observation.py new file mode 100644 index 0000000..7b05e14 --- /dev/null +++ b/admin/catalog_observation.py @@ -0,0 +1,43 @@ +"""Authenticated control-plane verification of a private loaded catalog receipt.""" +import json +import urllib.request + +from broker.registry import Registry +from .store_access import open_store + + +def observe_catalog(config): + unknown = {"status": "unknown"} + try: + # The public endpoint carries only a random observation id, never a digest. + with urllib.request.urlopen(f"http://127.0.0.1:{config.port}/v1/health", timeout=1) as response: + if response.status != 200: + return unknown + health = json.loads(response.read(8192)) + observation = health.get("catalog_observation_id") + if health.get("status") != "ok" or not isinstance(observation, str) or not observation: + return unknown + with open_store(config) as store: + receipt = store.catalog_receipt() + current = Registry.from_sources(config.tools_root, config.tool_dirs) + if not isinstance(receipt, dict) or receipt.get("observation_id") != observation: + return unknown + if receipt.get("fingerprint") != current.observation_fingerprint(config.tools_root, config.tool_dirs): + return unknown + # A restart/reload racing the private read cannot certify the previous receipt. + with urllib.request.urlopen(f"http://127.0.0.1:{config.port}/v1/health", timeout=1) as response: + latest = json.loads(response.read(8192)) + if response.status != 200 or latest.get('status') != 'ok' or latest.get('catalog_observation_id') != observation: + return unknown + return {"status": "verified", "observation_id": observation, "tool_ids": current.tool_ids()} + except Exception: + return unknown + + +def target_verified(catalog, target): + """Legacy/global verdicts cannot establish membership of a separately cached row.""" + if not isinstance(catalog, dict) or catalog.get('status') != 'verified': + return False + ids = catalog.get('tool_ids') + return (isinstance(ids, list) and all(isinstance(item, str) and item for item in ids) + and target in ids) diff --git a/admin/manifest_files.py b/admin/manifest_files.py new file mode 100644 index 0000000..8b7d8bc --- /dev/null +++ b/admin/manifest_files.py @@ -0,0 +1,68 @@ +"""Non-following manifest access anchored to an opened directory.""" +import os +import stat +import uuid +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def directory(path): + fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + try: + for part in Path(os.path.abspath(path)).parts[1:]: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = child + except OSError as exc: + raise ValueError("manifest directory must contain no links") from exc + yield fd + finally: + os.close(fd) + + +def check(fd): + try: + info = os.stat("toolyard.toml", dir_fd=fd, follow_symlinks=False) + except FileNotFoundError: + return + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise ValueError("manifest must be a regular, unlinked file") + + +def validate(path): + with directory(path) as fd: + check(fd) + + +def read(path): + with directory(path) as fd: + check(fd) + file_fd = os.open("toolyard.toml", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fd) + with os.fdopen(file_fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise ValueError("manifest must be a regular, unlinked file") + return stream.read() + + +def write(path, text): + with directory(path) as fd: + check(fd) + name = ".manifest-" + uuid.uuid4().hex + temp = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=fd) + try: + with os.fdopen(temp, "w", encoding="utf-8") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + check(fd) + os.replace(name, "toolyard.toml", src_dir_fd=fd, dst_dir_fd=fd) + os.fsync(fd) + finally: + try: + os.unlink(name, dir_fd=fd) + except FileNotFoundError: + pass diff --git a/admin/operator_audit.py b/admin/operator_audit.py new file mode 100644 index 0000000..8c156b2 --- /dev/null +++ b/admin/operator_audit.py @@ -0,0 +1,34 @@ +"""Bounded metadata only. The host operator can delete this local audit.""" +from dataclasses import asdict + +from broker import operations +from . import broker_config +from .store_access import open_store + + +class AppliedAuditError(RuntimeError): + """The requested change happened but its completion audit did not.""" + + +def save_config(current, updated, user): + # Field names are a fixed schema: never record tokens, paths or URL values. + before, after = asdict(current), asdict(updated) + fields = [key for key in before if before[key] != after[key]] + with open_store(current) as store: + operations.record_admin_event(store, user, "config_change_requested", {"fields": fields}) + broker_config.save(updated) + try: + operations.record_admin_event(store, user, "config_changed", {"fields": fields}) + except Exception: + raise AppliedAuditError("Configuration saved but completion audit failed; verify before retrying") from None + + +def clear_audit(config, user): + # Both authenticated adapters require administrator authority. Deletion is + # intentional, not a security erasure or independent evidence guarantee. + with open_store(config) as store: + store.clear_audit() + try: + operations.record_admin_event(store, user, "audit_cleared", {"authority": "host_admin"}) + except Exception: + raise AppliedAuditError("Audit cleared but deletion marker failed; verify before retrying") from None diff --git a/admin/policy_edits.py b/admin/policy_edits.py new file mode 100644 index 0000000..77f0f8f --- /dev/null +++ b/admin/policy_edits.py @@ -0,0 +1,57 @@ +"""Merge the web editor's explicit edits without replacing unseen authority.""" + + +def operation_fields(current, catalog): + """Exact web field identities, restricted to currently enabled operations.""" + from broker.operations import enabled_tools + + result = {} + for tool in enabled_tools(current): + for operation in catalog.get(tool, []): + op = operation['op'] + key = f'op__{tool}__{op}' + target = (tool, op) + if key in result and result[key] != target: + result[key] = None # Ambiguous encoding must never pick a target. + else: + result[key] = target + return result + + +def retained_draft(current, fields, field_map): + """Keep only valid, unambiguous posted values on a safe current scope.""" + draft = dict(current, tools={t: dict(ops) for t, ops in current.get('tools', {}).items()}) + for key, effect in fields.items(): + target = field_map.get(key) + if target and effect in ('allow', 'review', 'deny'): + tool, op = target + draft['tools'].setdefault(tool, {})[op] = effect + return draft + + +def merge_grants(current, fields, field_map=None): + grants = {f"{tool}.{op}": effect + for tool, ops in current.get("tools", {}).items() + for op, effect in ops.items()} + for key, effect in fields.items(): + if not key.startswith("op__"): + continue + parts = key.split("__", 2) + if len(parts) != 3 or effect not in ("allow", "review", "deny"): + raise ValueError("Invalid operation policy") + if field_map is not None: + target = field_map.get(key) + if not target: + raise ValueError("Operation set changed. Load the current policy page before saving.") + tool, op = target + else: + tool, op = parts[1:] + grants[f"{tool}.{op}"] = effect + return tuple([key for key, value in grants.items() if value == effect] + for effect in ("allow", "review", "deny")) + + +def merge_enabled(current, available, selected): + # Tools absent from the catalog cannot be unchecked in the editor. + prior = list(dict.fromkeys(current.get("enabled", []) + list(current.get("tools", {})))) + return list(dict.fromkeys([t for t in prior if t not in available] + selected)) diff --git a/admin/reconciler.py b/admin/reconciler.py index e42ce59..1078211 100644 --- a/admin/reconciler.py +++ b/admin/reconciler.py @@ -39,7 +39,8 @@ def reconcile_running_tools(config, link, *, wait_timeout: float = 60.0) -> int: log.error("reconcile: SPS not up after %.0fs; not starting tools", wait_timeout) return 0 - state = _load_state() + with toolyard_ops.state_lock(): + state = _load_state() started = 0 for tool_id, rec in state.items(): if not isinstance(rec, dict): @@ -68,9 +69,10 @@ def reconcile_running_tools(config, link, *, wait_timeout: float = 60.0) -> int: # Re-read + merge rather than writing our stale snapshot, which would # revert tools that were successfully restarted earlier in this pass. log.exception("reconcile: failed to restart %s; restoring intent", tool_id) - current = _load_state() - current.setdefault(tool_id, rec) # keep any newer record; restore intent if gone - _save_state(current) + with toolyard_ops.state_lock(): + current = _load_state() + current.setdefault(tool_id, rec) # keep newer record + _save_state(current) return started except Exception: log.exception("reconcile pass failed") diff --git a/admin/requirements-lock.txt b/admin/requirements-lock.txt new file mode 100644 index 0000000..4390023 --- /dev/null +++ b/admin/requirements-lock.txt @@ -0,0 +1,44 @@ +# This file was autogenerated by uv via the following command: +# uv export --frozen --extra admin --extra vault --no-dev --no-emit-project --no-hashes -o admin/requirements-lock.txt +annotated-doc==0.0.5 + # via fastapi +annotated-types==0.8.0 + # via pydantic +anyio==4.15.1 + # via starlette +cffi==2.0.0 ; platform_python_implementation != 'PyPy' + # via cryptography +click==8.5.0 + # via uvicorn +cryptography==50.0.2 + # via toolstack +fastapi==0.137.1 + # via toolstack +h11==0.16.0 + # via uvicorn +idna==3.20 + # via anyio +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' + # via cffi +pydantic==2.14.1 + # via fastapi +pydantic-core==2.50.1 + # via pydantic +pyyaml==6.0.3 + # via toolstack +starlette==1.9.0 + # via fastapi +typing-extensions==4.16.0 + # via + # anyio + # fastapi + # pydantic + # pydantic-core + # starlette + # typing-inspection +typing-inspection==0.4.4 + # via + # fastapi + # pydantic +uvicorn==0.49.0 + # via toolstack diff --git a/admin/requirements.txt b/admin/requirements.txt index 6d1745f..bd4e782 100644 --- a/admin/requirements.txt +++ b/admin/requirements.txt @@ -1,14 +1,3 @@ -# The admin app is the one Toolstack component allowed runtime dependencies. -# Install into a dedicated virtualenv (the broker/toolyard/client stay stdlib): -# python3 -m venv admin/.venv && admin/.venv/bin/pip install -r admin/requirements.txt -# -# Pinned (==) so a fresh, wider deploy is reproducible; these are the versions the -# test suite runs against. Bump deliberately (change here, re-run the admin tests). -# Transitive deps (starlette, pydantic, anyio, ...) float; for a fully frozen install -# run `admin/.venv/bin/pip freeze > admin/requirements.lock` and install from that. -fastapi==0.137.1 -uvicorn==0.49.0 -PyYAML==6.0.3 - -# Needed only to run the test suite (FastAPI's TestClient is built on httpx): -httpx==0.28.1 +# Generated from uv.lock; regenerate BOTH files after dependency changes: +# uv lock && uv export --frozen --extra admin --extra vault --no-dev --no-emit-project --no-hashes -o admin/requirements-lock.txt +-r requirements-lock.txt diff --git a/admin/secret_values.py b/admin/secret_values.py index 2d85107..1f0f6bf 100644 --- a/admin/secret_values.py +++ b/admin/secret_values.py @@ -3,11 +3,8 @@ The control plane never writes secret values. Operator provisioning lives on the host via ``python3 -m sps.cli vault-set`` (which authenticates against the runner's SP_SECRET directly). This module is intentionally read-only and -just provides the response shape the panel expects (``is_settable``, -``provisioned_fields``). Phase 5 doesn't yet have a "list set secrets" SPS -op, so ``provisioned_fields`` returns ``[]`` -- the panel renders -"set/unset" as "unset" across the board for now (operators run -``python3 -m sps.cli vault-get`` to verify). +provides status without fetching values. SPS has no inspection operation, +so provisioned_fields returns None (UNKNOWN), never an empty known list. """ from __future__ import annotations @@ -19,8 +16,8 @@ def is_settable() -> bool: return False -def provisioned_fields(tool_id: str, declared: list[str]) -> list[str]: +def provisioned_fields(tool_id: str, declared: list[str]) -> list[str] | None: """Which of the tool's declared secret fields currently have a value in - SPS. Phase 5 returns ``[]`` (the SPS wire format has no + SPS. Returns None for unknown (the SPS wire format has no "list provisioned" op; a future enhancement can add one).""" - return [] + return None diff --git a/admin/server.py b/admin/server.py index c541cfb..4287e30 100644 --- a/admin/server.py +++ b/admin/server.py @@ -14,8 +14,9 @@ import json import os import sqlite3 +from dataclasses import replace from pathlib import Path -from urllib.parse import parse_qs +from urllib.parse import parse_qs, quote from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse @@ -29,6 +30,9 @@ from .reconciler import RECONCILE_LOCK, start_reconciler_thread from .sps_link import SPSLink from .store_access import open_store +from . import operator_audit +from .blocking import work +from .policy_edits import merge_grants, merge_enabled, operation_fields, retained_draft log = logging.getLogger(__name__) @@ -43,13 +47,25 @@ def create_app() -> FastAPI: guard = loginguard.LoginGuard() # shared by /login and /api/login app = FastAPI(title="Toolstack Admin", docs_url=None, redoc_url=None, openapi_url=None) + @app.middleware("http") + async def session_generation(request: Request, call_next): + # Snapshot once per request; never use the startup key after reset. + # Missing/unreadable key fails closed, rather than recreating authority. + try: + key = settings.session_secret_file().read_text(encoding="utf-8").strip() + if not key: + raise ValueError("empty session generation") + except (OSError, ValueError): + return JSONResponse({"detail": "Session authority unavailable"}, status_code=503) + request.state.session_secret = key + return await call_next(request) + # --- SPS restart watchdog + startup reconciliation ------------------------- # SPS is in-memory only (see sps/store.py). If SPS bounces (crash, library # upgrade, manual restart) every running tool loses its registration. The # watchdog detects that from a live boot_id ping and re-registers the tools # that are still alive. - from .sps_watchdog import SPSWatchdog - from toolyard.runner import reregister_all_with_sps + from .sps_watchdog import SPSWatchdog, recover_with_runner _initial_cfg = broker_config.load() _sps_env_path = settings.sps_env_path() or "/etc/toolstack/sps.env" @@ -66,7 +82,7 @@ def create_app() -> FastAPI: _watchdog = SPSWatchdog( config=_initial_cfg, probe=_sps_link.boot_id, - re_register_fn=reregister_all_with_sps, + re_register_fn=recover_with_runner, sps_env_path=_sps_env_path, lock=RECONCILE_LOCK, ) @@ -87,6 +103,13 @@ def _stop_watchdog() -> None: else: app.state.reconciler = start_reconciler_thread(_initial_cfg, _sps_link) + @app.exception_handler(operator_audit.AppliedAuditError) + async def _applied_audit_error(request: Request, exc): + if request.url.path.startswith("/api/"): + return JSONResponse({"detail": str(exc), "action_applied": True, + "audit_complete": False}, status_code=503) + return HTMLResponse(f"

{views.esc(str(exc))}

", status_code=503) + @app.exception_handler(sqlite3.OperationalError) async def _operational_error(request: Request, exc: sqlite3.OperationalError): # The admin opens short-lived connections; under sustained write contention with the @@ -109,11 +132,11 @@ def session_value(request: Request) -> str | None: return request.cookies.get(SESSION_COOKIE) def current_user(request: Request) -> str | None: - return auth.verify_session(session_value(request), secret) + return auth.verify_session(session_value(request), request.state.session_secret) def csrf_for(request: Request) -> str: sv = session_value(request) - return auth.csrf_token(sv, secret) if sv else "" + return auth.csrf_token(sv, request.state.session_secret) if sv else "" async def read_form(request: Request) -> dict[str, str]: # Parse urlencoded bodies ourselves (no python-multipart dependency), as @@ -123,7 +146,7 @@ async def read_form(request: Request) -> dict[str, str]: return {k: v[-1] if v else "" for k, v in parsed.items()} def csrf_ok(request: Request, data: dict) -> bool: - return auth.verify_csrf(data.get("_csrf"), session_value(request), secret) + return auth.verify_csrf(data.get("_csrf"), session_value(request), request.state.session_secret) def redirect(path: str = "/") -> RedirectResponse: return RedirectResponse(path, status_code=303) @@ -192,7 +215,7 @@ async def login(request: Request): resp = redirect("/") resp.set_cookie( SESSION_COOKIE, - auth.sign_session(username, secret, settings.session_ttl_seconds()), + auth.sign_session(username, request.state.session_secret, settings.session_ttl_seconds()), # Secure only when reached over TLS (TOOLSTACK_ADMIN_SECURE_COOKIE=1); loopback http # and the container's http-behind-publish would otherwise drop the cookie. httponly=True, samesite="strict", secure=settings.cookie_secure(), @@ -227,16 +250,37 @@ async def broker_action(request: Request, action: str): return render_dashboard(request, user, error="Invalid CSRF token.") config = broker_config.load() events = {"start": "broker_started", "stop": "broker_stopped", "restart": "broker_restarted"} + target = data.get('catalog_target') + + async def registration_result(error=None): + from .catalog_observation import observe_catalog + catalog = await work.run(observe_catalog, config) + tools, list_error = safe_list_tools(config) + from .catalog_observation import target_verified + verified = target_verified(catalog, target) + message = f"{views.esc(target)} · " + ('Catalog registration verified.' if verified else 'Registration unknown. Refresh state before deciding whether to restart again.') + return HTMLResponse(views.tools_view(user=user, csrf=csrf_for(request), tools=tools, + tools_root=config.tools_root, catalog=catalog, broker_port=config.port, + banner=message if verified else None, error=error or list_error or (None if verified else message), + mutation_outcome='unknown' if error or not verified else None)) if action not in events: return render_dashboard(request, user, error="Unknown broker action.") try: - result = supervisor.stop() if action == "stop" else getattr(supervisor, action)(config) + result = await work.run(supervisor.stop) if action == "stop" else await work.run(getattr(supervisor, action), config) except Exception as exc: # surface a failed spawn rather than 500 - return render_dashboard(request, user, error=f"Broker {action} failed: {exc}") + if target: + return await registration_result(f"Broker restart outcome unknown for {target}. Refresh state; no retry was sent.") + response = render_dashboard(request, user, error=f"Broker {action} outcome unknown. Refresh state before another action.") + response.headers['X-Toolstack-Mutation-Outcome'] = 'unknown' + return response with open_store(config) as store: operations.record_admin_event(store, user, events[action], {}) if isinstance(result, dict) and result.get("error"): # started but never went healthy + if target: + return await registration_result(f"Broker restart did not establish readiness for {target}. Refresh state.") return render_dashboard(request, user, error=f"Broker {action}: {result['error']}") + if target: + return await registration_result() return redirect("/") # --- run config ----------------------------------------------------------- @@ -258,13 +302,14 @@ async def save_config(request: Request): user=user, csrf=csrf_for(request), config=broker_config.load(), error="Invalid CSRF token.")) try: - updated = _config_from_form(data, broker_config.load()) + current = broker_config.load() + updated = _config_from_form(data, current) broker_config.validate_nod_url(updated.nod_url) # SSRF guard (carries the nod token) except ValueError as exc: return HTMLResponse(views.config_view( user=user, csrf=csrf_for(request), config=broker_config.load(), error=f"Invalid config: {exc}")) - broker_config.save(updated) + operator_audit.save_config(current, updated, user) return render_dashboard(request, user, banner="Saved broker config. Restart the broker to apply changes.") @@ -350,11 +395,30 @@ async def clear_audit(request: Request): data = await read_form(request) if not csrf_ok(request, data): return render_dashboard(request, user, error="Invalid CSRF token.") - with open_store(broker_config.load()) as store: - store.clear_audit() + operator_audit.clear_audit(broker_config.load(), user) return redirect("/#audit") # --- policy --------------------------------------------------------------- + def policy_recovery(request, user, name, data, message, status): + try: + config = broker_config.load() + with open_store(config) as store: + current = store.policy_for(operations.require_caller(store, name)["id"]) + catalog = ops_by_tool(config) + field_map = operation_fields(current, catalog) + scope_changed = any(k.startswith('op__') and not field_map.get(k) for k in data) + shown = {t: ops for t, ops in catalog.items() if t in operations.enabled_tools(current)} + if scope_changed: + message += " Operation set changed. Load the current policy page; only still-editable draft values are shown." + return HTMLResponse(views.policy_view( + user=user, csrf=csrf_for(request), caller=name, ops_by_tool=shown, + current=retained_draft(current, data, field_map), loaded=not scope_changed and status != 503, + error=message), status_code=status) + except (LookupError, OSError, ValueError, sqlite3.Error): + return HTMLResponse(views.policy_view( + user=user, csrf=csrf_for(request), caller=name, ops_by_tool={}, current={}, + loaded=False, error=message + " Draft could not be displayed. Return to the previous page to keep your inputs."), status_code=status) + @app.get("/callers/{name}/policy") async def edit_policy(request: Request, name: str): user = current_user(request) @@ -365,9 +429,16 @@ async def edit_policy(request: Request, name: str): with open_store(config) as store: caller = operations.require_caller(store, name) current = store.policy_for(caller["id"]) - except LookupError as exc: - return render_dashboard(request, user, error=str(exc)) - all_ops = ops_by_tool(config) + except (LookupError, OSError, sqlite3.Error): + return HTMLResponse(views.policy_view( + user=user, csrf=csrf_for(request), caller=name, ops_by_tool={}, + current={}, loaded=False, error="Policy could not be loaded. Retry load."), status_code=503) + try: + all_ops = ops_by_tool(config) + except (OSError, ValueError): + return HTMLResponse(views.policy_view( + user=user, csrf=csrf_for(request), caller=name, ops_by_tool={}, + current={}, loaded=False, error="Tool catalog could not be loaded. Retry load."), status_code=503) enabled = operations.enabled_tools(current) shown = {t: ops for t, ops in all_ops.items() if t in enabled} return HTMLResponse(views.policy_view( @@ -381,32 +452,32 @@ async def save_policy(request: Request, name: str): return redirect("/login") data = await read_form(request) config = broker_config.load() + wants_receipt = request.headers.get("accept") == "application/json" if not csrf_ok(request, data): - try: - with open_store(config) as store: - current = store.policy_for(operations.require_caller(store, name)["id"]) - except LookupError: - current = {} - all_ops = ops_by_tool(config) - shown = {t: ops for t, ops in all_ops.items() if t in operations.enabled_tools(current)} - return HTMLResponse(views.policy_view( - user=user, csrf=csrf_for(request), caller=name, - ops_by_tool=shown, current=current, has_tools=bool(all_ops), - error="Invalid CSRF token.")) - allow, review, deny = [], [], [] - for key, value in data.items(): - if key.startswith("op__"): # api/mcp: one effect per op; deny == omitted - _, tool, op = key.split("__", 2) - spec = f"{tool}.{op}" - if value == "allow": - allow.append(spec) - elif value == "review": - review.append(spec) + if wants_receipt: + return JSONResponse({"outcome": "rejected", "error": "Session check failed. Reload policy before saving."}, status_code=403) + return policy_recovery(request, user, name, data, + "Save rejected. Invalid CSRF token. Valid editable draft values retained; review before saving.", 403) + effect_started = False try: with open_store(config) as store: + current = store.policy_for(operations.require_caller(store, name)["id"]) + field_map = operation_fields(current, ops_by_tool(config)) + allow, review, deny = merge_grants(current, data, field_map) + effect_started = True operations.set_policy(store, name, allow, review, user, deny=deny) - except LookupError as exc: - return render_dashboard(request, user, error=str(exc)) + except Exception as exc: + rejected = not effect_started and isinstance(exc, (LookupError, ValueError)) + if wants_receipt: + return JSONResponse({"outcome": "rejected" if rejected else "unknown", + "error": "Policy not saved. Operation set or effects may have changed. Reload to compare before saving." if rejected else + "Save outcome unknown. Reload to compare before retrying."}, status_code=400 if rejected else 503) + return policy_recovery(request, user, name, data, + "Save rejected. Valid editable draft values retained; review the current operation set and effects." if rejected else + "Save outcome unknown. Draft retained where still editable. Reload policy to compare before retrying.", + 400 if rejected else 503) + if wants_receipt: + return JSONResponse({"outcome": "saved", "target": name}) return redirect(f"/callers/{name}/policy") @app.get("/callers/{name}/tools") @@ -439,6 +510,8 @@ async def save_caller_tools(request: Request, name: str): if k.startswith("tool__") and v == "on"] try: with open_store(config) as store: + current = store.policy_for(operations.require_caller(store, name)["id"]) + enabled = merge_enabled(current, ops_by_tool(config), enabled) operations.set_enabled_tools(store, name, enabled, user) except LookupError as exc: return render_dashboard(request, user, error=str(exc)) @@ -454,9 +527,14 @@ async def tools_page(request: Request): tools, error = safe_list_tools(config) for t in tools: # attach the source sidecar so updatable tools get an Update button t["source"] = tool_sources.read_source(t["path"]) + from .catalog_observation import observe_catalog + catalog = await work.run(observe_catalog, config) + selected = request.query_params.get('tool') + if selected: + tools = [t for t in tools if t['id'] == selected] return HTMLResponse(views.tools_view( user=user, csrf=csrf_for(request), tools=tools, - tools_root=config.tools_root, error=error)) + tools_root=config.tools_root, error=error, catalog=catalog, broker_port=config.port, focused=bool(selected))) @app.post("/toolyard/tools/{tool_id}/{action}") async def toolyard_action(request: Request, tool_id: str, action: str): @@ -467,11 +545,12 @@ async def toolyard_action(request: Request, tool_id: str, action: str): config = broker_config.load() events = {"start": "tool_started", "stop": "tool_stopped", "restart": "tool_restarted"} - def tools_error(msg: str) -> HTMLResponse: + def tools_error(msg: str, unknown=False) -> HTMLResponse: tools, _ = safe_list_tools(config) return HTMLResponse(views.tools_view( - user=user, csrf=csrf_for(request), tools=tools, - tools_root=config.tools_root, error=msg)) + user=user, csrf=csrf_for(request), tools=[t for t in tools if t['id'] == tool_id], + tools_root=config.tools_root, error=msg, focused=True, broker_port=config.port, + mutation_outcome='unknown' if unknown else None)) if not csrf_ok(request, data): return tools_error("Invalid CSRF token.") @@ -479,16 +558,16 @@ def tools_error(msg: str) -> HTMLResponse: return redirect("/tools") try: if action == "stop": - toolyard_ops.stop(tool_id) + await work.run(toolyard_ops.stop, tool_id) else: - getattr(toolyard_ops, action)( + await work.run(getattr(toolyard_ops, action), tool_id, config.tools_root, config.tool_dirs, settings.tool_runner_backend()) with open_store(config) as store: operations.record_admin_event(store, user, events[action], {"tool": tool_id}) - except Exception as exc: - return tools_error(f"Tool {action} failed: {exc}") - return redirect("/tools") + except Exception: + return tools_error(f"{tool_id} · {action} outcome unknown. Refresh state before another action; nothing was replayed.", unknown=True) + return redirect(f"/tools?tool={quote(tool_id, safe='')}") # --- tool authoring (write/edit toolyard.toml) ---------------------------- @app.get("/tools/new") @@ -546,8 +625,9 @@ async def create_tool(request: Request): tool_authoring.write(dir_path, tool) norm_dir = str(Path(dir_path)) # normalize so the later removable check matches if norm_dir not in config.tool_dirs: + previous = replace(config) config.tool_dirs = [*config.tool_dirs, norm_dir] - broker_config.save(config) + operator_audit.save_config(previous, config, user) with open_store(config) as store: operations.record_admin_event(store, user, "tool_created", {"tool": tool["id"], "dir": norm_dir}) return render_dashboard(request, user, banner=( @@ -616,32 +696,14 @@ async def remove_tool(request: Request, tool_id: str): if not csrf_ok(request, data): return render_dashboard(request, user, error="Invalid CSRF token.") config = broker_config.load() - tools_list, list_err = safe_list_tools(config) - if list_err: - return render_dashboard(request, user, error=list_err) - tools = {t["id"]: t for t in tools_list} - if tool_id not in tools: - return render_dashboard(request, user, error=f"no such tool: {tool_id}") - path = str(Path(tools[tool_id]["path"])) - is_external = path in {str(Path(p)) for p in config.tool_dirs} + from .tool_removal import remove_tool as remove_owned_tool try: - if is_external: - # An operator-owned folder registered via tool_dirs: unregister it, leave the files. - toolyard_ops.stop(tool_id) # stop if running (no-op otherwise) - config.tool_dirs = [p for p in config.tool_dirs if str(Path(p)) != path] - broker_config.save(config) - banner = (f"Unregistered tool {views.esc(tool_id)} (its files were " - "left on disk). Restart the broker to apply.") - else: - # A TSR-managed tool under the tools root: stop it and delete its folder. - toolyard_ops.remove(tool_id, config.tools_root, config.tool_dirs) - banner = (f"Removed tool {views.esc(tool_id)} (deleted its folder). " - "Restart the broker to apply.") + result = await work.run(remove_owned_tool, config, tool_id, user) + effect = "deleted its managed folder" if result['deleted'] else "files retained on disk" + label = "Removed tool" if result['deleted'] else "Unregistered tool" + banner = f"{label} {views.esc(tool_id)} · {effect}. Restart the broker to apply." except (LookupError, ValueError) as exc: return render_dashboard(request, user, error=str(exc)) - with open_store(config) as store: - operations.record_admin_event(store, user, "tool_removed", - {"tool": tool_id, "dir": path, "deleted": not is_external}) return render_dashboard(request, user, banner=banner) # --- add / update a tool from its source (folder or git) ------------------ @@ -671,20 +733,22 @@ def again(error: str) -> HTMLResponse: existing_ids = [] if list_err else [t["id"] for t in existing] try: if data.get("kind") == "github": - tool = tool_sources.add_from_github( + tool = await work.run(tool_sources.add_from_github, repo_val, config.tools_root, existing_ids, subdir=(data.get("subdir") or "").strip(), ref=(data.get("ref") or "").strip()) else: - tool = tool_sources.add_from_path(src_val, config.tools_root, existing_ids) + tool = await work.run(tool_sources.add_from_path, src_val, config.tools_root, existing_ids) except tool_sources.NoManifest: return again("No toolyard.toml at that location; use “Author a tool” to create one.") except ValueError as exc: return again(str(exc)) with open_store(config) as store: operations.record_admin_event(store, user, "tool_created", {"tool": tool["id"], "dir": tool["path"]}) - return render_dashboard(request, user, banner=( - f"Added tool {views.esc(tool['id'])} from its source. Restart the broker to register it, " - "then grant a caller access in its policy.")) + tools, error = safe_list_tools(config) + return HTMLResponse(views.tools_view(user=user, csrf=csrf_for(request), + tools=[t for t in tools if t['id'] == tool['id']], tools_root=config.tools_root, + broker_port=config.port, error=error, focused=True, + banner=f"Added {views.esc(tool['id'])}. Start executes this trusted local tool. Catalog registration and caller grants remain separate decisions.")) @app.post("/tools/{tool_id}/update-source") async def update_tool_source(request: Request, tool_id: str): @@ -702,7 +766,7 @@ async def update_tool_source(request: Request, tool_id: str): if tool_id not in tools: return render_dashboard(request, user, error=f"no such tool: {tool_id}") try: - tool = tool_sources.update(tools[tool_id]["path"]) + tool = await work.run(tool_sources.update, tools[tool_id]["path"]) except tool_sources.NoManifest: return render_dashboard(request, user, error="the tool's source no longer has a toolyard.toml") except ValueError as exc: @@ -749,22 +813,4 @@ def _parse_openapi_text(raw: str) -> dict: def _config_from_form(data: dict, current) -> "broker_config.BrokerRunConfig": - def _int(key: str, default: int) -> int: - raw = data.get(key, "").strip() - try: - return int(raw) if raw else default - except ValueError: - return default - - # nod token is write-only: keep the stored value unless a new one is provided. - nod_token = data.get("nod_token", "") or current.nod_token - return broker_config.BrokerRunConfig( - port=_int("port", current.port), - db_path=data.get("db_path", "").strip() or current.db_path, - tools_root=data.get("tools_root", "").strip() or current.tools_root, - nod_url=data.get("nod_url", current.nod_url).strip(), - nod_token=nod_token, - nod_channel=data.get("nod_channel", current.nod_channel).strip(), - approval_ttl=_int("approval_ttl", current.approval_ttl), - rate_limit=_int("rate_limit", current.rate_limit), - ) + return broker_config.merge_update(current, data) diff --git a/admin/settings.py b/admin/settings.py index 944d7df..7ab6e48 100644 --- a/admin/settings.py +++ b/admin/settings.py @@ -12,6 +12,7 @@ import ipaddress import os import secrets +import tempfile from pathlib import Path @@ -109,8 +110,16 @@ def sps_info() -> dict: def _write_private(path: Path, data: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(data, encoding="utf-8") - path.chmod(0o600) + fd, name = tempfile.mkstemp(prefix=".admin-", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + finally: + if os.path.exists(name): + os.unlink(name) def read_password_hash() -> str | None: @@ -134,7 +143,7 @@ def load_or_create_session_secret() -> str: def rotate_session_secret() -> str: """Replace the session-signing secret, invalidating every existing signed session. Called when the admin password changes so a reset also logs out any other live sessions (it takes - effect on the next app start, which loads the secret once).""" + effect on the next request, including in already-running admin instances).""" secret = secrets.token_urlsafe(32) _write_private(session_secret_file(), secret) return secret diff --git a/admin/sps_link.py b/admin/sps_link.py index 1223172..297ddc6 100644 --- a/admin/sps_link.py +++ b/admin/sps_link.py @@ -20,13 +20,18 @@ import time from sps.client import SPSError, SPSClient -from sps.config import load_config +from sps.config import load_config, parse_bool log = logging.getLogger(__name__) _DEFAULT_SPS_ENV = "/etc/toolstack/sps.env" +def parse_verify(value: str) -> bool: + """Use the integrated shared TLS verification contract.""" + return parse_bool(value, name="TOOLSTACK_SPS_VERIFY") + + class SPSLink: def __init__(self, host: str, port: int, ca_file: str | None, verify: bool = True) -> None: @@ -50,7 +55,7 @@ def from_env(cls, sps_env_path: str | None = None) -> "SPSLink | None": cfg = load_config(path) # Resolve env overrides inside the guard: a bad value (e.g. # TOOLSTACK_SPS_PORT=abc) must not raise into startup. - verify = os.environ.get("TOOLSTACK_SPS_VERIFY", "1") == "1" + verify = parse_verify(os.environ.get("TOOLSTACK_SPS_VERIFY", "1")) return cls( host=os.environ.get("TOOLSTACK_SPS_HOST", cfg.sp_host), port=int(os.environ.get("TOOLSTACK_SPS_PORT", str(cfg.sp_port))), diff --git a/admin/sps_watchdog.py b/admin/sps_watchdog.py index d44ce72..73fcd97 100644 --- a/admin/sps_watchdog.py +++ b/admin/sps_watchdog.py @@ -10,8 +10,8 @@ random ``boot_id``. This thread polls an injected ``probe`` callable (the live ``SPSLink.boot_id``); whenever the id changes, it walks every running tool in the toolyard state and re-registers it via the existing -``toolyard.runner.reregister_all_with_sps`` (idempotent: overwrites the -existing registration). +``toolyard.runner.reregister_all_with_sps_detailed`` (idempotent). +A boot is acknowledged only when the detailed result reports complete. The watchdog lives in admin because admin is the long-lived supervisor. The broker process is short-lived (supervised by ``admin.supervisor``) and @@ -35,6 +35,16 @@ DEFAULT_POLL_INTERVAL = 30.0 # seconds +def recover_with_runner(**kwargs): + """Require the runner's detailed contract; a missing API is retryable. + + Import at call time so an unintegrated runner does not prevent admin + startup. Never fall back to the legacy failure-swallowing integer API. + """ + from toolyard.runner import reregister_all_with_sps_detailed + return reregister_all_with_sps_detailed(**kwargs) + + class SPSWatchdog(threading.Thread): """Poll SPS's live boot_id; on change, re-register every running tool.""" @@ -89,11 +99,11 @@ def _check_once(self) -> None: "SPS boot_id changed: %r -> %r (re-registering tools)", self._last_seen, cur, ) - self._last_seen = cur - self._re_register_all() + if self._re_register_all(): + self._last_seen = cur - def _re_register_all(self) -> None: - def _pass() -> int: + def _re_register_all(self) -> bool: + def _pass(): return self._re_register( tools_root=self._config.tools_root, tool_dirs=self._config.tool_dirs, @@ -108,6 +118,12 @@ def _pass() -> int: count = _pass() else: count = _pass() - log.info("SPS watchdog re-registered %d tool(s)", count) + # Legacy integer counts swallow per-tool failures: never treat them + # as proof of completion. Runner must supply an explicit flag. + complete = getattr(count, "complete", False) is True + if not complete: + log.warning("SPS recovery incomplete or unconfirmed; retrying this boot") + return complete except Exception: log.exception("SPS re-registration pass failed") + return False diff --git a/admin/tests/archive_webkit_dom.py b/admin/tests/archive_webkit_dom.py new file mode 100644 index 0000000..b76ce81 --- /dev/null +++ b/admin/tests/archive_webkit_dom.py @@ -0,0 +1,52 @@ +"""Losslessly archive generated WK DOM exports; verify bytes before removing copies. + +Run with explicit evidence case directories. Archives remain in scope for secret scans. +""" +import hashlib +import json +from pathlib import Path +import sys +import zipfile + + +def archive(directory): + directory = Path(directory).resolve() + evidence = Path(__file__).resolve().parent / 'evidence' + if not directory.is_relative_to(evidence) or not directory.parent.name.startswith('webkit-'): + raise ValueError('Expected an explicit owned WebKit evidence case directory') + files = sorted(directory.glob('*.html')) + if not files or any(path.is_symlink() for path in files): + raise ValueError('Expected regular generated HTML exports') + output = directory / 'raw-dom.zip' + manifest = directory / 'raw-dom-manifest.json' + if output.exists() or manifest.exists(): + raise FileExistsError('Refusing to replace an existing archive or manifest') + originals = {path.name: path.read_bytes() for path in files} + with zipfile.ZipFile(output, 'x', compression=zipfile.ZIP_DEFLATED) as packed: + for name, data in originals.items(): + packed.writestr(name, data) + members = [] + with zipfile.ZipFile(output) as packed: + assert packed.namelist() == list(originals) + assert packed.testzip() is None + for name, original in originals.items(): + restored = packed.read(name) + digest = hashlib.sha256(original).hexdigest() + assert restored == original == (directory / name).read_bytes() + assert hashlib.sha256(restored).hexdigest() == digest + members.append({'name': name, 'bytes': len(original), 'sha256': digest}) + manifest.write_text(json.dumps({ + 'archive': output.name, 'format': 'ZIP/DEFLATE', + 'archive_sha256': hashlib.sha256(output.read_bytes()).hexdigest(), + 'verification': 'Every member byte-compared and SHA-256 checked against original before plaintext removal', + 'secret_scan': 'Archive members MUST be included in the parent final secret scan; no exemption', + 'members': members, + }, indent=2) + '\n') + for path in files: + path.unlink() + print(f'{output}: {len(members)} members verified and archived') + + +if __name__ == '__main__': + for argument in sys.argv[1:]: + archive(argument) diff --git a/admin/tests/clear_desk_browser.py b/admin/tests/clear_desk_browser.py new file mode 100644 index 0000000..2578186 --- /dev/null +++ b/admin/tests/clear_desk_browser.py @@ -0,0 +1,121 @@ +"""Bounded real Chrome DOM checks, with fake fetch receipts and no live backend. + +Run as a module. Only installed Chrome, isolated profile, owned temporary files. +Evidence distinguishes rendered DOM checks from live service / native testing. +""" +import json +import subprocess +import tempfile +from pathlib import Path + +from admin.views.callers import policy_view +from admin.views.tools import tool_editor_view + +CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +TEMP = '/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode' +OUT = Path(__file__).parent / 'evidence' / 'clear-desk' + +POLICY = r""" +const form=document.querySelector('#policy-form'), select=form.querySelector('select'); +const save=form.querySelector('[type=submit]'); +const original=document.documentElement.outerHTML; +select.value='allow';select.dispatchEvent(new Event('change',{bubbles:true})); +window.fetch=async()=>({ok:false,status:400,json:async()=>({outcome:'rejected'})}); +form.requestSubmit();await new Promise(r=>setTimeout(r,20)); +check(select.value==='allow'&&save.disabled,'rejected save retains draft and gates save until comparison'); +check(form.textContent.includes('Save rejected'),'rejection visible'); +window.fetch=async()=>({ok:true,text:async()=>original}); +Array.from(form.querySelectorAll('button')).find(b=>b.textContent==='Reload to compare').click(); +await new Promise(r=>setTimeout(r,20)); +window.fetch=async()=>{throw Error('lost response');}; +form.requestSubmit();await new Promise(r=>setTimeout(r,20)); +check(save.disabled&&select.value==='allow','unknown save blocks retry and retains draft'); +window.fetch=async()=>({ok:true,text:async()=>original}); +Array.from(form.querySelectorAll('button')).find(b=>b.textContent==='Reload to compare').click(); +await new Promise(r=>setTimeout(r,20)); +check(!save.disabled&&select.value==='allow','comparison retains differing draft'); +check(form.textContent.includes('Saved: deny'),'saved effect shown alongside draft'); +form.querySelector('a').click(); +check(document.querySelector('dialog[open]')!==null,'dirty navigation opens dialog'); +check(document.activeElement.textContent==='Stay','safe Stay receives focus'); +document.activeElement.click(); +check(select.value==='allow','Stay retains draft'); +select.focus();check(document.activeElement===select,'policy control focusable'); +window.fetch=async()=>({ok:true,json:async()=>({outcome:'saved',target:'writer-demo'})}); +form.requestSubmit();await new Promise(r=>setTimeout(r,20)); +check(form.textContent.includes('Policy saved'),'acknowledged save receipt'); +select.value='review';select.dispatchEvent(new Event('change',{bubbles:true})); +let writes=0; +window.fetch=async()=>{writes++;return {ok:false,status:500,json:async()=>({outcome:'rejected'})};}; +form.requestSubmit();form.requestSubmit();await new Promise(r=>setTimeout(r,20)); +check(writes===1&&save.disabled,'500 after effect is unknown; duplicate submit sends one write'); +const applied=document.documentElement.outerHTML; +window.fetch=async()=>({ok:true,text:async()=>applied}); +Array.from(form.querySelectorAll('button')).find(b=>b.textContent==='Reload to compare').click(); +await new Promise(r=>setTimeout(r,20)); +check(writes===1,'comparison never replays mutation'); +""" + +EDITOR = r""" +const form=document.querySelector('#tool-form'); +form.addEventListener('submit',e=>e.preventDefault()); +form.dispatchEvent(new Event('submit',{cancelable:true})); +const data=JSON.parse(document.querySelector('#tool_json').value); +check(!Object.hasOwn(data.operations[0],'approval_fields'),'absent approval_fields remains absent through serialization'); +check(Array.isArray(data.operations[1].approval_fields)&&data.operations[1].approval_fields.length===0,'empty approval_fields remains empty through serialization'); +check(data.operations[2].approval_fields[0]==='message','explicit approval_fields retained'); +document.querySelectorAll('.dup-op')[1].click(); +form.dispatchEvent(new Event('submit',{cancelable:true})); +const copied=JSON.parse(document.querySelector('#tool_json').value); +check(copied.operations[2].approval_fields.length===0,'duplicate preserves explicit empty approval_fields'); +""" + + +def instrument(source, checks): + script = """""".replace('CHECKS', checks) + return source.replace('', script + '') + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + policy = policy_view(user='fake-admin', csrf='fake', caller='writer-demo', + ops_by_tool={'notes-demo': [{'op': 'read_note', 'risk': 'read'}]}, + current={'tools': {'notes-demo': {'read_note': 'deny'}}}) + editor = tool_editor_view(user='fake-admin', csrf='fake', mode='edit', + dir_value='/fake/notes', tool={'id': 'notes-demo', 'type': 'api', 'port': 4700, + 'operations': [{'name': 'absent'}, {'name': 'empty', 'approval_fields': []}, + {'name': 'explicit', 'approval_fields': ['message']}]}) + results = [] + with tempfile.TemporaryDirectory(prefix='clear-desk-browser-', dir=TEMP) as temp: + for name, source, checks in [('policy', policy, POLICY), ('editor', editor, EDITOR)]: + path = Path(temp) / (name + '.html') + path.write_text(instrument(source, checks)) + for width in (1440, 1180): + key = f'{name}-{width}' + command = [CHROME, '--headless', '--disable-gpu', '--no-first-run', '--use-mock-keychain', '--password-store=basic', + '--no-default-browser-check', '--disable-background-networking', + '--disable-component-update', '--disable-sync', '--disable-extensions', + f'--user-data-dir={temp}/profile', f'--window-size={width},900', + '--virtual-time-budget=2000', '--dump-dom', + f'--screenshot={OUT / (key + ".png")}', path.as_uri()] + run = subprocess.run(command, capture_output=True, text=True, timeout=30) + (OUT / (key + '.html')).write_text(run.stdout) + (OUT / (key + '.log')).write_text(run.stderr) + passed = run.returncode == 0 and 'PROOF PASS' in run.stdout + results.append({'case': key, 'passed': passed, 'returncode': run.returncode}) + (OUT / 'results.json').write_text(json.dumps(results, indent=2)) + print(json.dumps(results, indent=2)) + if not all(r['passed'] for r in results): + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/admin/tests/evidence/clear-desk/admin-corrections.log b/admin/tests/evidence/clear-desk/admin-corrections.log new file mode 100644 index 0000000..a47aa11 --- /dev/null +++ b/admin/tests/evidence/clear-desk/admin-corrections.log @@ -0,0 +1,23 @@ +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa +test_bad_tool_degrades_to_banner_not_500 (admin.tests.test_app.AdminApp.test_bad_tool_degrades_to_banner_not_500) ... ok +test_remove_root_tool_deletes_its_folder (admin.tests.test_app.AdminApp.test_remove_root_tool_deletes_its_folder) ... ok +test_remove_tool_unregisters_but_keeps_files (admin.tests.test_app.AdminApp.test_remove_tool_unregisters_but_keeps_files) ... ok +test_toolyard_action_requires_csrf (admin.tests.test_app.AdminApp.test_toolyard_action_requires_csrf) ... ok +test_dead_record_leaves_start_button_enabled (admin.tests.test_toolyard_ops.ToolyardOps.test_dead_record_leaves_start_button_enabled) ... ok +test_list_tools_includes_explicit_tool_dirs (admin.tests.test_toolyard_ops.ToolyardOps.test_list_tools_includes_explicit_tool_dirs) ... ok +test_list_tools_reports_defined_not_running (admin.tests.test_toolyard_ops.ToolyardOps.test_list_tools_reports_defined_not_running) ... ok +test_removable_flag_marks_only_tool_dirs (admin.tests.test_toolyard_ops.ToolyardOps.test_removable_flag_marks_only_tool_dirs) ... ok +test_remove_deletes_a_managed_tool_dir (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_deletes_a_managed_tool_dir) ... ok +test_remove_refuses_external_tool_dir (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_refuses_external_tool_dir) ... ok +test_remove_unknown_tool_raises (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_unknown_tool_raises) ... ok +test_start_keeps_live_record (admin.tests.test_toolyard_ops.ToolyardOps.test_start_keeps_live_record) ... ok +test_start_replaces_dead_record (admin.tests.test_toolyard_ops.ToolyardOps.test_start_replaces_dead_record) ... ok +test_start_unknown_tool_raises (admin.tests.test_toolyard_ops.ToolyardOps.test_start_unknown_tool_raises) ... ok +test_stop_unknown_tool_is_noop (admin.tests.test_toolyard_ops.ToolyardOps.test_stop_unknown_tool_is_noop) ... ok +test_external_delete_unregisters_only_matching_source (admin.tests.test_api.JsonApi.test_external_delete_unregisters_only_matching_source) ... ok + +---------------------------------------------------------------------- +Ran 16 tests in 0.298s + +OK diff --git a/admin/tests/evidence/clear-desk/admin-milestone.log b/admin/tests/evidence/clear-desk/admin-milestone.log new file mode 100644 index 0000000..7c0ce62 --- /dev/null +++ b/admin/tests/evidence/clear-desk/admin-milestone.log @@ -0,0 +1,546 @@ +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa +test_completion_failure_explains_that_save_already_happened (admin.tests.test_admin_controls.AuditFailure.test_completion_failure_explains_that_save_already_happened) ... ok +test_failed_intent_audit_prevents_config_write (admin.tests.test_admin_controls.AuditFailure.test_failed_intent_audit_prevents_config_write) ... ok +test_failed_persistence_rolls_back_new_start (admin.tests.test_admin_controls.Lifecycle.test_failed_persistence_rolls_back_new_start) ... ok +test_failed_rollback_retains_explicit_runtime_reference (admin.tests.test_admin_controls.Lifecycle.test_failed_rollback_retains_explicit_runtime_reference) ... ok +test_missing_runner_lock_fails_closed (admin.tests.test_admin_controls.Lifecycle.test_missing_runner_lock_fails_closed) ... ok +test_restart_uses_one_complete_nonreentrant_lock (admin.tests.test_admin_controls.Lifecycle.test_restart_uses_one_complete_nonreentrant_lock) ... ok +test_runner_cleanup_failure_record_is_preserved (admin.tests.test_admin_controls.Lifecycle.test_runner_cleanup_failure_record_is_preserved) ... ok +test_recorded_state_is_not_health_evidence (admin.tests.test_admin_controls.NativeLiveness.test_recorded_state_is_not_health_evidence) ... ok +test_invalid_verify_disables_readiness_not_verification (admin.tests.test_admin_controls.Readiness.test_invalid_verify_disables_readiness_not_verification) ... SPS link: cannot load /fake/unused: TOOLSTACK_SPS_VERIFY: expected a strict boolean +ok +test_strict_boolean (admin.tests.test_admin_controls.Readiness.test_strict_boolean) ... ok +test_cancelled_caller_keeps_slot_until_work_finishes (admin.tests.test_admin_controls.WorkerAdmission.test_cancelled_caller_keeps_slot_until_work_finishes) ... ok +test_work_is_off_loop_and_result_propagates (admin.tests.test_admin_controls.WorkerAdmission.test_work_is_off_loop_and_result_propagates) ... ok +test_add_tool_bad_repo_url_400 (admin.tests.test_api.JsonApi.test_add_tool_bad_repo_url_400) ... ok +test_add_tool_duplicate_id_400 (admin.tests.test_api.JsonApi.test_add_tool_duplicate_id_400) ... ok +test_add_tool_from_folder (admin.tests.test_api.JsonApi.test_add_tool_from_folder) ... ok +test_add_tool_from_github (admin.tests.test_api.JsonApi.test_add_tool_from_github) ... ok +test_add_tool_manifest_must_be_object_400 (admin.tests.test_api.JsonApi.test_add_tool_manifest_must_be_object_400) ... ok +test_add_tool_needs_auth (admin.tests.test_api.JsonApi.test_add_tool_needs_auth) ... ok +test_add_tool_no_manifest_422 (admin.tests.test_api.JsonApi.test_add_tool_no_manifest_422) ... ok +test_add_tool_requires_source_or_repo (admin.tests.test_api.JsonApi.test_add_tool_requires_source_or_repo) ... ok +test_add_tool_with_manifest_authors_code_only_folder (admin.tests.test_api.JsonApi.test_add_tool_with_manifest_authors_code_only_folder) ... ok +test_audit_returns_events (admin.tests.test_api.JsonApi.test_audit_returns_events) ... ok +test_broker_start_calls_supervisor_and_audits (admin.tests.test_api.JsonApi.test_broker_start_calls_supervisor_and_audits) ... ok +test_broker_start_failure_is_502 (admin.tests.test_api.JsonApi.test_broker_start_failure_is_502) ... ok +test_broker_status_when_stopped (admin.tests.test_api.JsonApi.test_broker_status_when_stopped) ... ok +test_catalog_requires_admin_and_legacy_observation_is_unknown (admin.tests.test_api.JsonApi.test_catalog_requires_admin_and_legacy_observation_is_unknown) ... ok +test_clear_audit_removes_events (admin.tests.test_api.JsonApi.test_clear_audit_removes_events) ... ok +test_config_audit_contains_field_names_not_values (admin.tests.test_api.JsonApi.test_config_audit_contains_field_names_not_values) ... ok +test_config_round_trip_write_only_token_and_validation (admin.tests.test_api.JsonApi.test_config_round_trip_write_only_token_and_validation) ... ok +test_create_and_list_callers (admin.tests.test_api.JsonApi.test_create_and_list_callers) ... ok +test_create_duplicate_is_409 (admin.tests.test_api.JsonApi.test_create_duplicate_is_409) ... ok +test_create_rejects_bad_allow_type (admin.tests.test_api.JsonApi.test_create_rejects_bad_allow_type) ... ok +test_create_requires_name (admin.tests.test_api.JsonApi.test_create_requires_name) ... ok +test_edit_tool_description_only_keeps_secrets (admin.tests.test_api.JsonApi.test_edit_tool_description_only_keeps_secrets) ... ok +test_edit_tool_ignores_entrypoint_and_ops_in_body (admin.tests.test_api.JsonApi.test_edit_tool_ignores_entrypoint_and_ops_in_body) ... ok +test_edit_tool_invalid_secret_400 (admin.tests.test_api.JsonApi.test_edit_tool_invalid_secret_400) ... ok +test_edit_tool_needs_auth (admin.tests.test_api.JsonApi.test_edit_tool_needs_auth) ... ok +test_edit_tool_unknown_404 (admin.tests.test_api.JsonApi.test_edit_tool_unknown_404) ... ok +test_edit_tool_updates_description_and_secrets (admin.tests.test_api.JsonApi.test_edit_tool_updates_description_and_secrets) ... ok +test_external_delete_unregisters_only_matching_source (admin.tests.test_api.JsonApi.test_external_delete_unregisters_only_matching_source) ... ok +test_login_bad_password_401 (admin.tests.test_api.JsonApi.test_login_bad_password_401) ... ok +test_login_issues_a_token (admin.tests.test_api.JsonApi.test_login_issues_a_token) ... ok +test_login_requires_json_object (admin.tests.test_api.JsonApi.test_login_requires_json_object) ... ok +test_missing_session_generation_fails_closed (admin.tests.test_api.JsonApi.test_missing_session_generation_fails_closed) ... ok +test_native_backend_and_unknown_contract_fixture (admin.tests.test_api.JsonApi.test_native_backend_and_unknown_contract_fixture) ... ok +test_obsolete_native_provisioning_has_no_success_path (admin.tests.test_api.JsonApi.test_obsolete_native_provisioning_has_no_success_path) ... ok +test_operator_routes_need_auth (admin.tests.test_api.JsonApi.test_operator_routes_need_auth) ... ok +test_parse_openapi_accepts_object_spec (admin.tests.test_api.JsonApi.test_parse_openapi_accepts_object_spec) ... ok +test_parse_openapi_rejects_bad_spec (admin.tests.test_api.JsonApi.test_parse_openapi_rejects_bad_spec) ... ok +test_policy_missing_caller_404 (admin.tests.test_api.JsonApi.test_policy_missing_caller_404) ... ok +test_policy_round_trip (admin.tests.test_api.JsonApi.test_policy_round_trip) ... ok +test_protected_route_needs_a_token (admin.tests.test_api.JsonApi.test_protected_route_needs_a_token) ... ok +test_reset_invalidates_live_bearer_without_restart (admin.tests.test_api.JsonApi.test_reset_invalidates_live_bearer_without_restart) ... ok +test_revoke_caller_and_missing_404 (admin.tests.test_api.JsonApi.test_revoke_caller_and_missing_404) ... ok +test_revoke_token_empty_prefix_400 (admin.tests.test_api.JsonApi.test_revoke_token_empty_prefix_400) ... ok +test_rotate_token_returns_new (admin.tests.test_api.JsonApi.test_rotate_token_returns_new) ... ok +test_secret_endpoints_need_auth (admin.tests.test_api.JsonApi.test_secret_endpoints_need_auth) ... ok +test_set_enabled_tools (admin.tests.test_api.JsonApi.test_set_enabled_tools) ... ok +test_tool_action_needs_auth (admin.tests.test_api.JsonApi.test_tool_action_needs_auth) ... ok +test_tool_action_unknown_400 (admin.tests.test_api.JsonApi.test_tool_action_unknown_400) ... ok +test_tool_action_unknown_tool_404 (admin.tests.test_api.JsonApi.test_tool_action_unknown_tool_404) ... ok +test_tool_source_in_listing_and_update (admin.tests.test_api.JsonApi.test_tool_source_in_listing_and_update) ... ok +test_tool_start_stop_restart (admin.tests.test_api.JsonApi.test_tool_start_stop_restart) ... ok +test_unknown_broker_action_400 (admin.tests.test_api.JsonApi.test_unknown_broker_action_400) ... ok +test_update_needs_auth (admin.tests.test_api.JsonApi.test_update_needs_auth) ... ok +test_update_non_tsr_tool_400 (admin.tests.test_api.JsonApi.test_update_non_tsr_tool_400) ... ok +test_update_unknown_tool_404 (admin.tests.test_api.JsonApi.test_update_unknown_tool_404) ... ok +test_activity_views_are_filterable (admin.tests.test_app.AdminApp.test_activity_views_are_filterable) ... ok +test_add_from_folder_without_manifest_guides_to_author (admin.tests.test_app.AdminApp.test_add_from_folder_without_manifest_guides_to_author) ... ok +test_add_from_source_page_renders (admin.tests.test_app.AdminApp.test_add_from_source_page_renders) ... ok +test_add_tool_from_folder_then_update (admin.tests.test_app.AdminApp.test_add_tool_from_folder_then_update) ... ok +test_add_tool_rejects_invalid_definition (admin.tests.test_app.AdminApp.test_add_tool_rejects_invalid_definition) ... ok +test_add_tool_rejects_missing_directory (admin.tests.test_app.AdminApp.test_add_tool_rejects_missing_directory) ... ok +test_add_tool_writes_toml_and_registers (admin.tests.test_app.AdminApp.test_add_tool_writes_toml_and_registers) ... ok +test_bad_password_rejected (admin.tests.test_app.AdminApp.test_bad_password_rejected) ... ok +test_bad_tool_degrades_to_banner_not_500 (admin.tests.test_app.AdminApp.test_bad_tool_degrades_to_banner_not_500) ... FAIL +test_broker_action_requires_csrf (admin.tests.test_app.AdminApp.test_broker_action_requires_csrf) ... ok +test_clear_audit_route_removes_events (admin.tests.test_app.AdminApp.test_clear_audit_route_removes_events) ... ok +test_create_app_skips_reconcile_when_sps_configured_but_unavailable (admin.tests.test_app.AdminApp.test_create_app_skips_reconcile_when_sps_configured_but_unavailable) ... SPS is configured at /etc/toolstack/sps.env but no usable link was built; skipping tool reconciliation +ok +test_create_app_starts_reconciler_when_sps_unconfigured (admin.tests.test_app.AdminApp.test_create_app_starts_reconciler_when_sps_unconfigured) ... ok +test_create_app_starts_watchdog_when_sps_linked (admin.tests.test_app.AdminApp.test_create_app_starts_watchdog_when_sps_linked) ... /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/server.py:92: DeprecationWarning: + on_event is deprecated, use lifespan event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/). + + @app.on_event("shutdown") +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/applications.py:4601: DeprecationWarning: + on_event is deprecated, use lifespan event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/). + + return self.router.on_event(event_type) # ty: ignore[deprecated] +ok +test_create_caller_and_set_policy (admin.tests.test_app.AdminApp.test_create_caller_and_set_policy) ... ok +test_csrf_required_for_mutations (admin.tests.test_app.AdminApp.test_csrf_required_for_mutations) ... ok +test_edit_tool_rewrites_toml (admin.tests.test_app.AdminApp.test_edit_tool_rewrites_toml) ... ok +test_login_throttled_and_failures_audited (admin.tests.test_app.AdminApp.test_login_throttled_and_failures_audited) ... ok +test_parse_openapi_accepts_yaml (admin.tests.test_app.AdminApp.test_parse_openapi_accepts_yaml) ... ok +test_parse_openapi_rejects_bad_json (admin.tests.test_app.AdminApp.test_parse_openapi_rejects_bad_json) ... ok +test_parse_openapi_returns_forwarder_ops (admin.tests.test_app.AdminApp.test_parse_openapi_returns_forwarder_ops) ... ok +test_password_reset_invalidates_cookie_and_csrf_without_restart (admin.tests.test_app.AdminApp.test_password_reset_invalidates_cookie_and_csrf_without_restart) ... ok +test_policy_after_effect_failure_is_unknown_and_read_does_not_replay (admin.tests.test_app.AdminApp.test_policy_after_effect_failure_is_unknown_and_read_does_not_replay) ... ok +test_policy_failed_load_disables_save (admin.tests.test_app.AdminApp.test_policy_failed_load_disables_save) ... ok +test_remove_root_tool_deletes_its_folder (admin.tests.test_app.AdminApp.test_remove_root_tool_deletes_its_folder) ... FAIL +test_remove_tool_unregisters_but_keeps_files (admin.tests.test_app.AdminApp.test_remove_tool_unregisters_but_keeps_files) ... ok +test_requires_login (admin.tests.test_app.AdminApp.test_requires_login) ... ok +test_revoke_token_via_panel (admin.tests.test_app.AdminApp.test_revoke_token_via_panel) ... ok +test_tool_editor_requires_login (admin.tests.test_app.AdminApp.test_tool_editor_requires_login) ... ok +test_tools_page_lists_defined_tools (admin.tests.test_app.AdminApp.test_tools_page_lists_defined_tools) ... ok +test_toolyard_action_requires_csrf (admin.tests.test_app.AdminApp.test_toolyard_action_requires_csrf) ... ok +test_web_edits_preserve_unrendered_authority (admin.tests.test_app.AdminApp.test_web_edits_preserve_unrendered_authority) ... ok +test_existing_editor_preserves_presence_nonvisually (admin.tests.test_approval_fields.ApprovalFields.test_existing_editor_preserves_presence_nonvisually) ... ok +test_invalid_raw_values_refused_without_publication (admin.tests.test_approval_fields.ApprovalFields.test_invalid_raw_values_refused_without_publication) ... ok +test_roundtrip_registry_all_transports (admin.tests.test_approval_fields.ApprovalFields.test_roundtrip_registry_all_transports) ... ok +test_shared_validator_is_imported (admin.tests.test_approval_fields.ApprovalFields.test_shared_validator_is_imported) ... ok +test_missing_inputs_rejected (admin.tests.test_auth.Csrf.test_missing_inputs_rejected) ... ok +test_token_bound_to_session (admin.tests.test_auth.Csrf.test_token_bound_to_session) ... ok +test_token_verifies (admin.tests.test_auth.Csrf.test_token_verifies) ... ok +test_hash_is_salted (admin.tests.test_auth.Password.test_hash_is_salted) ... ok +test_hash_verify_roundtrip (admin.tests.test_auth.Password.test_hash_verify_roundtrip) ... ok +test_verify_rejects_garbage (admin.tests.test_auth.Password.test_verify_rejects_garbage) ... ok +test_expired_rejected (admin.tests.test_auth.Session.test_expired_rejected) ... ok +test_none_rejected (admin.tests.test_auth.Session.test_none_rejected) ... ok +test_roundtrip (admin.tests.test_auth.Session.test_roundtrip) ... ok +test_tampered_rejected (admin.tests.test_auth.Session.test_tampered_rejected) ... ok +test_wrong_secret_rejected (admin.tests.test_auth.Session.test_wrong_secret_rejected) ... ok +test_html_and_json_share_preserving_merge (admin.tests.test_bounded_repairs.ConfigMerge.test_html_and_json_share_preserving_merge) ... ok +test_invalid_input_is_not_silently_saved (admin.tests.test_bounded_repairs.ConfigMerge.test_invalid_input_is_not_silently_saved) ... ok +test_secret_inspection_is_unknown (admin.tests.test_bounded_repairs.ConfigMerge.test_secret_inspection_is_unknown) ... ok +test_atomic_write_private_file (admin.tests.test_bounded_repairs.ManifestSafety.test_atomic_write_private_file) ... ok +test_failed_atomic_replace_preserves_original (admin.tests.test_bounded_repairs.ManifestSafety.test_failed_atomic_replace_preserves_original) ... ok +test_hardlinked_manifest_rejected (admin.tests.test_bounded_repairs.ManifestSafety.test_hardlinked_manifest_rejected) ... ok +test_linked_directory_rejected (admin.tests.test_bounded_repairs.ManifestSafety.test_linked_directory_rejected) ... ok +test_linked_manifest_rejected_on_ingestion_and_write (admin.tests.test_bounded_repairs.ManifestSafety.test_linked_manifest_rejected_on_ingestion_and_write) ... ok +test_adapter_uses_detailed_runner_contract (admin.tests.test_bounded_repairs.Recovery.test_adapter_uses_detailed_runner_contract) ... ok +test_same_boot_retries_until_explicit_completion (admin.tests.test_bounded_repairs.Recovery.test_same_boot_retries_until_explicit_completion) ... SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1124, in __call__ + return self._mock_call(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1128, in _mock_call + return self._execute_mock_call(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1187, in _execute_mock_call + raise result +OSError: fake failure +SPS recovery incomplete or unconfirmed; retrying this boot +SPS recovery incomplete or unconfirmed; retrying this boot +ok +test_defaults_to_loopback (admin.tests.test_broker_config.AdminHost.test_defaults_to_loopback) ... ok +test_nonloopback_allowed_with_optin (admin.tests.test_broker_config.AdminHost.test_nonloopback_allowed_with_optin) ... ok +test_nonloopback_fails_closed (admin.tests.test_broker_config.AdminHost.test_nonloopback_fails_closed) ... ok +test_config_file_is_private (admin.tests.test_broker_config.BrokerConfig.test_config_file_is_private) ... ok +test_defaults_when_no_file (admin.tests.test_broker_config.BrokerConfig.test_defaults_when_no_file) ... ok +test_empty_tool_dirs_omitted_from_env (admin.tests.test_broker_config.BrokerConfig.test_empty_tool_dirs_omitted_from_env) ... ok +test_masked_hides_token (admin.tests.test_broker_config.BrokerConfig.test_masked_hides_token) ... ok +test_save_escapes_quotes_and_backslashes (admin.tests.test_broker_config.BrokerConfig.test_save_escapes_quotes_and_backslashes) ... ok +test_save_load_roundtrip (admin.tests.test_broker_config.BrokerConfig.test_save_load_roundtrip) ... broker config: nod_url is invalid (nod_url must use https (plain http is allowed only for loopback)) +ok +test_to_env_includes_nod_when_set (admin.tests.test_broker_config.BrokerConfig.test_to_env_includes_nod_when_set) ... ok +test_to_env_omits_empty_optionals (admin.tests.test_broker_config.BrokerConfig.test_to_env_omits_empty_optionals) ... ok +test_tool_dirs_roundtrip_and_env (admin.tests.test_broker_config.BrokerConfig.test_tool_dirs_roundtrip_and_env) ... ok +test_allows_https_loopback_http_and_empty (admin.tests.test_broker_config.ValidateNodUrl.test_allows_https_loopback_http_and_empty) ... ok +test_rejects_metadata_and_link_local (admin.tests.test_broker_config.ValidateNodUrl.test_rejects_metadata_and_link_local) ... ok +test_rejects_non_http_scheme (admin.tests.test_broker_config.ValidateNodUrl.test_rejects_non_http_scheme) ... ok +test_rejects_plain_http_to_remote (admin.tests.test_broker_config.ValidateNodUrl.test_rejects_plain_http_to_remote) ... ok +test_actual_load_edit_reload_and_failed_reload (admin.tests.test_catalog_observation.CatalogObservation.test_actual_load_edit_reload_and_failed_reload) ... {"at": 1792851724.720236, "component": "registry", "correlation_id": "169bd4aec77b4c309efb75c6b5855ddd", "details": {"ops_after": 1, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_health_only_exposes_random_identity (admin.tests.test_catalog_observation.CatalogObservation.test_health_only_exposes_random_identity) ... ok +test_missing_mismatched_receipt_and_sources (admin.tests.test_catalog_observation.CatalogObservation.test_missing_mismatched_receipt_and_sources) ... ok +test_previous_process_receipt_cannot_certify_new_load (admin.tests.test_catalog_observation.CatalogObservation.test_previous_process_receipt_cannot_certify_new_load) ... {"at": 1792851726.266926, "component": "registry", "correlation_id": "e236681107144f99b3a811590b57c222", "details": {"ops_after": 1, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_secret_block_content_does_not_enter_private_fingerprint (admin.tests.test_catalog_observation.CatalogObservation.test_secret_block_content_does_not_enter_private_fingerprint) ... ok +test_global_cap_throttles_a_fresh_ip (admin.tests.test_loginguard.LoginGuardTests.test_global_cap_throttles_a_fresh_ip) ... ok +test_lock_expires_after_the_window (admin.tests.test_loginguard.LoginGuardTests.test_lock_expires_after_the_window) ... ok +test_locks_after_max_per_ip (admin.tests.test_loginguard.LoginGuardTests.test_locks_after_max_per_ip) ... ok +test_other_ip_unaffected_by_a_peers_failures (admin.tests.test_loginguard.LoginGuardTests.test_other_ip_unaffected_by_a_peers_failures) ... ok +test_success_clears_the_lock (admin.tests.test_loginguard.LoginGuardTests.test_success_clears_the_lock) ... ok +test_explicit_denial_preserves_unrendered_grants (admin.tests.test_policy_edits.PolicyEdits.test_explicit_denial_preserves_unrendered_grants) ... ok +test_failed_read_is_not_an_empty_policy (admin.tests.test_policy_edits.PolicyEdits.test_failed_read_is_not_an_empty_policy) ... ok +test_invalid_effect_does_not_silently_remove_grants (admin.tests.test_policy_edits.PolicyEdits.test_invalid_effect_does_not_silently_remove_grants) ... ok +test_unavailable_enabled_tools_survive_explicit_visible_disable (admin.tests.test_policy_edits.PolicyEdits.test_unavailable_enabled_tools_survive_explicit_visible_disable) ... ok +test_gate_exception_is_contained (admin.tests.test_reconciler.ReconcileTests.test_gate_exception_is_contained) ... reconcile pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/reconciler.py", line 38, in reconcile_running_tools + if link is not None and not link.wait_until_up(wait_timeout): + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1124, in __call__ + return self._mock_call(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1128, in _mock_call + return self._execute_mock_call(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1183, in _execute_mock_call + raise effect +RuntimeError: boom +ok +test_no_start_when_sps_not_up (admin.tests.test_reconciler.ReconcileTests.test_no_start_when_sps_not_up) ... reconcile: SPS not up after 1s; not starting tools +ok +test_none_link_starts_dead_tools (admin.tests.test_reconciler.ReconcileTests.test_none_link_starts_dead_tools) ... ok +test_restart_failure_preserves_record (admin.tests.test_reconciler.ReconcileTests.test_restart_failure_preserves_record) ... reconcile: failed to restart echo; restoring intent +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/reconciler.py", line 60, in reconcile_running_tools + toolyard_ops.start(tool_id, config.tools_root, config.tool_dirs, + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1124, in __call__ + return self._mock_call(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1128, in _mock_call + return self._execute_mock_call(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/lib/python3.11/unittest/mock.py", line 1189, in _execute_mock_call + result = effect(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_reconciler.py", line 107, in fake_start + raise RuntimeError("boom") +RuntimeError: boom +ok +test_skips_alive_tools (admin.tests.test_reconciler.ReconcileTests.test_skips_alive_tools) ... ok +test_unknown_tool_is_logged_not_raised (admin.tests.test_reconciler.ReconcileTests.test_unknown_tool_is_logged_not_raised) ... reconcile: ghost in state but not discoverable; leaving record +ok +test_waits_for_sps_then_starts_dead_tools (admin.tests.test_reconciler.ReconcileTests.test_waits_for_sps_then_starts_dead_tools) ... ok +test_boot_id_raises_when_ping_lacks_boot_id (admin.tests.test_sps_link.SPSLinkTests.test_boot_id_raises_when_ping_lacks_boot_id) ... ok +test_boot_id_returns_ping_value (admin.tests.test_sps_link.SPSLinkTests.test_boot_id_returns_ping_value) ... ok +test_constructor_accepts_positional_args (admin.tests.test_sps_link.SPSLinkTests.test_constructor_accepts_positional_args) ... ok +test_from_env_bad_port_returns_none (admin.tests.test_sps_link.SPSLinkTests.test_from_env_bad_port_returns_none) ... SPS link: cannot load /private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-web-tmp/spslink-gj1uo1q9/sps.env: invalid literal for int() with base 10: 'not-a-port' +ok +test_from_env_missing_plugin_block_key_returns_none (admin.tests.test_sps_link.SPSLinkTests.test_from_env_missing_plugin_block_key_returns_none) ... SPS link: cannot load /private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-web-tmp/spslink-kknyymrc/sps.env: 'VAULT_FILE' +ok +test_from_env_parses_config (admin.tests.test_sps_link.SPSLinkTests.test_from_env_parses_config) ... ok +test_from_env_returns_none_when_file_missing (admin.tests.test_sps_link.SPSLinkTests.test_from_env_returns_none_when_file_missing) ... ok +test_from_env_returns_none_when_skip_set (admin.tests.test_sps_link.SPSLinkTests.test_from_env_returns_none_when_skip_set) ... ok +test_wait_until_up_propagates_unexpected_error (admin.tests.test_sps_link.SPSLinkTests.test_wait_until_up_propagates_unexpected_error) ... ok +test_wait_until_up_retries_then_succeeds (admin.tests.test_sps_link.SPSLinkTests.test_wait_until_up_retries_then_succeeds) ... ok +test_wait_until_up_times_out (admin.tests.test_sps_link.SPSLinkTests.test_wait_until_up_times_out) ... SPS not reachable after 0s: refused +ok +test_empty_probe_value_is_treated_as_not_up (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_empty_probe_value_is_treated_as_not_up) +An empty-string boot_id (SPS up but not yet reporting an id) must be ... ok +test_first_seen_boot_id_triggers_re_register (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_first_seen_boot_id_triggers_re_register) ... ok +test_new_boot_id_triggers_another_re_register (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_new_boot_id_triggers_another_re_register) ... ok +test_probe_error_is_swallowed (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_probe_error_is_swallowed) ... ok +test_probe_returns_none_is_noop (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_probe_returns_none_is_noop) +When SPS hasn't reported a boot_id yet (or is down), the watchdog ... ok +test_re_register_failure_does_not_kill_loop (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_re_register_failure_does_not_kill_loop) +A re_register exception must be caught and logged; the loop keeps ... SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +SPS re-registration pass failed +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 120, in _re_register_all + count = _pass() + ^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/sps_watchdog.py", line 107, in _pass + return self._re_register( + ^^^^^^^^^^^^^^^^^^ + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_sps_watchdog.py", line 125, in failing + raise RuntimeError("sps unreachable") +RuntimeError: sps unreachable +ok +test_re_register_runs_under_the_lock (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_re_register_runs_under_the_lock) +When a lock is provided, the re-register pass must run while it is ... ok +test_same_boot_id_is_noop (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_same_boot_id_is_noop) ... ok +test_thread_loop_detects_changes (admin.tests.test_sps_watchdog.SPSWatchdogTests.test_thread_loop_detects_changes) +End-to-end: spin the watchdog's loop, change the probed boot_id, ... ok +test_non_broker_pid_is_rejected (admin.tests.test_supervisor.IsBrokerIdentity.test_non_broker_pid_is_rejected) ... ok +test_nonexistent_pid_is_rejected (admin.tests.test_supervisor.IsBrokerIdentity.test_nonexistent_pid_is_rejected) ... ok +test_restart_replaces_process (admin.tests.test_supervisor.Supervisor.test_restart_replaces_process) ... ok +test_start_becomes_healthy_then_stop (admin.tests.test_supervisor.Supervisor.test_start_becomes_healthy_then_stop) ... ok +test_status_is_stopped_before_start (admin.tests.test_supervisor.Supervisor.test_status_is_stopped_before_start) ... ok +test_every_bundled_tool_is_docker_startable (admin.tests.test_tool_authoring.BundledSampleTools.test_every_bundled_tool_is_docker_startable) ... ok +test_docker_build_tool_requires_a_dockerfile (admin.tests.test_tool_authoring.ReadWrite.test_docker_build_tool_requires_a_dockerfile) ... ok +test_docker_runner_rejects_command_only_tool (admin.tests.test_tool_authoring.ReadWrite.test_docker_runner_rejects_command_only_tool) ... ok +test_read_write_destructive_risks_are_valid (admin.tests.test_tool_authoring.ReadWrite.test_read_write_destructive_risks_are_valid) ... ok +test_write_invalid_raises (admin.tests.test_tool_authoring.ReadWrite.test_write_invalid_raises) ... ok +test_write_missing_dir_raises (admin.tests.test_tool_authoring.ReadWrite.test_write_missing_dir_raises) ... ok +test_write_then_read_roundtrip (admin.tests.test_tool_authoring.ReadWrite.test_write_then_read_roundtrip) ... ok +test_written_file_is_consumable_by_broker_and_toolyard (admin.tests.test_tool_authoring.ReadWrite.test_written_file_is_consumable_by_broker_and_toolyard) ... ok +test_allows_query_text_in_rest_path (admin.tests.test_tool_authoring.RestAuthoring.test_allows_query_text_in_rest_path) ... ok +test_normalize_defaults_forwarder_without_broker_channel (admin.tests.test_tool_authoring.RestAuthoring.test_normalize_defaults_forwarder_without_broker_channel) ... ok +test_redaction_flags_round_trip_with_explicit_writeback_choices (admin.tests.test_tool_authoring.RestAuthoring.test_redaction_flags_round_trip_with_explicit_writeback_choices) ... ok +test_rejects_bad_rest_path_and_header (admin.tests.test_tool_authoring.RestAuthoring.test_rejects_bad_rest_path_and_header) ... ok +test_rejects_rest_without_base_url (admin.tests.test_tool_authoring.RestAuthoring.test_rejects_rest_without_base_url) ... ok +test_rejects_secret_update_to_non_writable_secret (admin.tests.test_tool_authoring.RestAuthoring.test_rejects_secret_update_to_non_writable_secret) ... ok +test_rest_docker_runner_needs_no_tool_dockerfile (admin.tests.test_tool_authoring.RestAuthoring.test_rest_docker_runner_needs_no_tool_dockerfile) ... ok +test_to_toml_writes_forwarder_contract_shape (admin.tests.test_tool_authoring.RestAuthoring.test_to_toml_writes_forwarder_contract_shape) ... ok +test_writeback_requires_explicit_visibility_choices (admin.tests.test_tool_authoring.RestAuthoring.test_writeback_requires_explicit_visibility_choices) ... ok +test_written_rest_tool_is_consumable_by_stack (admin.tests.test_tool_authoring.RestAuthoring.test_written_rest_tool_is_consumable_by_stack) ... ok +test_blank_item_is_omitted (admin.tests.test_tool_authoring.Serialize.test_blank_item_is_omitted) ... ok +test_blank_tool_description_omitted (admin.tests.test_tool_authoring.Serialize.test_blank_tool_description_omitted) ... ok +test_docker_image_entrypoint (admin.tests.test_tool_authoring.Serialize.test_docker_image_entrypoint) ... ok +test_escapes_quotes_in_description (admin.tests.test_tool_authoring.Serialize.test_escapes_quotes_in_description) ... ok +test_infisical_item_round_trip (admin.tests.test_tool_authoring.Serialize.test_infisical_item_round_trip) ... ok +test_multiline_description_escaped_not_broken (admin.tests.test_tool_authoring.Serialize.test_multiline_description_escaped_not_broken) ... ok +test_to_toml_parses_back (admin.tests.test_tool_authoring.Serialize.test_to_toml_parses_back) ... ok +test_tool_description_round_trips (admin.tests.test_tool_authoring.Serialize.test_tool_description_round_trips) ... ok +test_vault_is_not_serialized_from_editor_input (admin.tests.test_tool_authoring.Serialize.test_vault_is_not_serialized_from_editor_input) ... ok +test_bad_id_with_dot (admin.tests.test_tool_authoring.Validate.test_bad_id_with_dot) ... ok +test_bad_port (admin.tests.test_tool_authoring.Validate.test_bad_port) ... ok +test_bad_risk (admin.tests.test_tool_authoring.Validate.test_bad_risk) ... ok +test_duplicate_operation (admin.tests.test_tool_authoring.Validate.test_duplicate_operation) ... ok +test_empty_command_and_image_is_valid_for_docker_build (admin.tests.test_tool_authoring.Validate.test_empty_command_and_image_is_valid_for_docker_build) ... ok +test_mcp_type_is_valid (admin.tests.test_tool_authoring.Validate.test_mcp_type_is_valid) ... ok +test_no_operations (admin.tests.test_tool_authoring.Validate.test_no_operations) ... ok +test_overlong_id_rejected (admin.tests.test_tool_authoring.Validate.test_overlong_id_rejected) ... ok +test_rest_type_is_valid_with_rest_fields (admin.tests.test_tool_authoring.Validate.test_rest_type_is_valid_with_rest_fields) ... ok +test_unknown_type_rejected (admin.tests.test_tool_authoring.Validate.test_unknown_type_rejected) ... ok +test_valid (admin.tests.test_tool_authoring.Validate.test_valid) ... ok +test_external_unregister_retains_files_and_other_sources (admin.tests.test_tool_removal.ToolRemoval.test_external_unregister_retains_files_and_other_sources) ... ok +test_unknown_ownership_refuses_before_effect (admin.tests.test_tool_removal.ToolRemoval.test_unknown_ownership_refuses_before_effect) ... ok +test_clone_argv_is_injection_safe (admin.tests.test_tool_sources.AddFromGithub.test_clone_argv_is_injection_safe) ... ok +test_clone_failure_is_clean_valueerror (admin.tests.test_tool_sources.AddFromGithub.test_clone_failure_is_clean_valueerror) ... ok +test_clones_root_and_records_github_source (admin.tests.test_tool_sources.AddFromGithub.test_clones_root_and_records_github_source) ... ok +test_clones_subdir (admin.tests.test_tool_sources.AddFromGithub.test_clones_subdir) ... ok +test_missing_git_errors (admin.tests.test_tool_sources.AddFromGithub.test_missing_git_errors) ... ok +test_private_repo_gets_friendly_message (admin.tests.test_tool_sources.AddFromGithub.test_private_repo_gets_friendly_message) ... ok +test_rejects_dangerous_or_nongit_urls (admin.tests.test_tool_sources.AddFromGithub.test_rejects_dangerous_or_nongit_urls) ... ok +test_rejects_subdir_traversal (admin.tests.test_tool_sources.AddFromGithub.test_rejects_subdir_traversal) ... ok +test_bad_source_raises (admin.tests.test_tool_sources.AddFromPath.test_bad_source_raises) ... ok +test_copies_into_tools_root_with_code_and_records_source (admin.tests.test_tool_sources.AddFromPath.test_copies_into_tools_root_with_code_and_records_source) ... ok +test_duplicate_id_rejected (admin.tests.test_tool_sources.AddFromPath.test_duplicate_id_rejected) ... ok +test_existing_destination_not_clobbered (admin.tests.test_tool_sources.AddFromPath.test_existing_destination_not_clobbered) ... ok +test_invalid_manifest_rejected_at_add_time (admin.tests.test_tool_sources.AddFromPath.test_invalid_manifest_rejected_at_add_time) ... ok +test_no_manifest_raises_NoManifest (admin.tests.test_tool_sources.AddFromPath.test_no_manifest_raises_NoManifest) ... ok +test_read_source_none_when_absent (admin.tests.test_tool_sources.AddFromPath.test_read_source_none_when_absent) ... ok +test_rejects_id_path_traversal (admin.tests.test_tool_sources.AddFromPath.test_rejects_id_path_traversal) ... ok +test_rejects_overlong_id (admin.tests.test_tool_sources.AddFromPath.test_rejects_overlong_id) ... ok +test_rejects_source_inside_tools_root (admin.tests.test_tool_sources.AddFromPath.test_rejects_source_inside_tools_root) ... ok +test_symlink_copied_verbatim_not_dereferenced (admin.tests.test_tool_sources.AddFromPath.test_symlink_copied_verbatim_not_dereferenced) ... ok +test_vcs_and_build_cruft_not_copied (admin.tests.test_tool_sources.AddFromPath.test_vcs_and_build_cruft_not_copied) ... ok +test_bad_source_rejected (admin.tests.test_tool_sources.AddWithManifest.test_bad_source_rejected) ... ok +test_copies_code_and_writes_manifest_no_sidecar (admin.tests.test_tool_sources.AddWithManifest.test_copies_code_and_writes_manifest_no_sidecar) ... ok +test_id_clash_rejected (admin.tests.test_tool_sources.AddWithManifest.test_id_clash_rejected) ... ok +test_invalid_manifest_rejected (admin.tests.test_tool_sources.AddWithManifest.test_invalid_manifest_rejected) ... ok +test_traversal_id_rejected_nothing_written (admin.tests.test_tool_sources.AddWithManifest.test_traversal_id_rejected_nothing_written) ... ok +test_github_update_reclones (admin.tests.test_tool_sources.Update.test_github_update_reclones) ... ok +test_path_update_pulls_new_code_but_keeps_operator_edits (admin.tests.test_tool_sources.Update.test_path_update_pulls_new_code_but_keeps_operator_edits) ... ok +test_successful_update_leaves_tools_root_clean (admin.tests.test_tool_sources.Update.test_successful_update_leaves_tools_root_clean) ... ok +test_swap_failure_restores_original_no_orphan (admin.tests.test_tool_sources.Update.test_swap_failure_restores_original_no_orphan) ... ok +test_update_id_change_rejected_original_intact (admin.tests.test_tool_sources.Update.test_update_id_change_rejected_original_intact) ... ok +test_update_source_gone_leaves_original_intact (admin.tests.test_tool_sources.Update.test_update_source_gone_leaves_original_intact) ... ok +test_update_without_sidecar_is_rejected (admin.tests.test_tool_sources.Update.test_update_without_sidecar_is_rejected) ... ok +test_dead_record_leaves_start_button_enabled (admin.tests.test_toolyard_ops.ToolyardOps.test_dead_record_leaves_start_button_enabled) ... ok +test_list_tools_includes_explicit_tool_dirs (admin.tests.test_toolyard_ops.ToolyardOps.test_list_tools_includes_explicit_tool_dirs) ... ok +test_list_tools_reports_defined_not_running (admin.tests.test_toolyard_ops.ToolyardOps.test_list_tools_reports_defined_not_running) ... ok +test_removable_flag_marks_only_tool_dirs (admin.tests.test_toolyard_ops.ToolyardOps.test_removable_flag_marks_only_tool_dirs) ... ok +test_remove_deletes_a_managed_tool_dir (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_deletes_a_managed_tool_dir) ... ok +test_remove_refuses_external_tool_dir (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_refuses_external_tool_dir) ... ok +test_remove_unknown_tool_raises (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_unknown_tool_raises) ... ok +test_start_keeps_live_record (admin.tests.test_toolyard_ops.ToolyardOps.test_start_keeps_live_record) ... ok +test_start_replaces_dead_record (admin.tests.test_toolyard_ops.ToolyardOps.test_start_replaces_dead_record) ... ok +test_start_unknown_tool_raises (admin.tests.test_toolyard_ops.ToolyardOps.test_start_unknown_tool_raises) ... ok +test_stop_unknown_tool_is_noop (admin.tests.test_toolyard_ops.ToolyardOps.test_stop_unknown_tool_is_noop) ... ok + +====================================================================== +FAIL: test_bad_tool_degrades_to_banner_not_500 (admin.tests.test_app.AdminApp.test_bad_tool_degrades_to_banner_not_500) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_app.py", line 345, in test_bad_tool_degrades_to_banner_not_500 + self.assertIn("Could not read tools", r.text) +AssertionError: 'Could not read tools' not found in 'Dashboard · Toolstack Admin

Toolstack Admin

admin
/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-web-tmp/admin-app-gfbn7nye/tools/broken/toolyard.toml: tool 'broken' needs an [entrypoint] port (integer 1-65535) for a 'api' tool; got None

Broker

stopped

Broker log
No broker log yet.

Run config

port8765
db_path/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-web-tmp/admin-app-gfbn7nye/broker.sqlite3
tools_root/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-web-tmp/admin-app-gfbn7nye/tools
nod_url
nod_tokennot set
nod_channel
approval_ttl3600
rate_limit120
tool_dirs[]

Edit configManage tools

Callers

NameStatusActions
No callers yet.

Active tokens

Only active tokens are listed. Revoked and rotated tokens appear in the Audit log.

Hash prefixCaller
No active tokens.

Recent requests

#CallerOperationStatus
No requests yet.

Audit

TimeEventOutcomeReqDetails
No audit events yet.
' + +====================================================================== +FAIL: test_remove_root_tool_deletes_its_folder (admin.tests.test_app.AdminApp.test_remove_root_tool_deletes_its_folder) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_app.py", line 476, in test_remove_root_tool_deletes_its_folder + self.assertIn("Removed tool", r.text) +AssertionError: 'Removed tool' not found in 'Dashboard · Toolstack Admin

Toolstack Admin

admin
Removed registration for echo · deleted its managed folder. Restart the broker to apply.

Broker

stopped

Broker log
No broker log yet.

Run config

port8765
db_path/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-web-tmp/admin-app-wbsdoa26/broker.sqlite3
tools_root/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-web-tmp/admin-app-wbsdoa26/tools
nod_url
nod_tokennot set
nod_channel
approval_ttl3600
rate_limit120
tool_dirs[]

Edit configManage tools

Callers

NameStatusActions
No callers yet.

Active tokens

Only active tokens are listed. Revoked and rotated tokens appear in the Audit log.

Hash prefixCaller
No active tokens.

Recent requests

#CallerOperationStatus
No requests yet.

Audit

TimeEventOutcomeReqDetails
2026-10-24 10:22:04admin.tool_removedok{"operator": "admin", "tool": "echo", "deleted": true}
' + +---------------------------------------------------------------------- +Ran 269 tests in 8.724s + +FAILED (failures=2) diff --git a/admin/tests/evidence/clear-desk/editor-1180.html b/admin/tests/evidence/clear-desk/editor-1180.html new file mode 100644 index 0000000..df023be --- /dev/null +++ b/admin/tests/evidence/clear-desk/editor-1180.html @@ -0,0 +1,505 @@ + +PROOF PASS

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

absent
empty
empty_copy
explicit

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

diff --git a/admin/tests/evidence/clear-desk/editor-1180.log b/admin/tests/evidence/clear-desk/editor-1180.log new file mode 100644 index 0000000..3dbd7ee --- /dev/null +++ b/admin/tests/evidence/clear-desk/editor-1180.log @@ -0,0 +1,13 @@ +[19476:43027740:1024/095547.501136:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19476:43027740:1024/095547.501438:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19476:43027740:1024/095547.501481:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19476:43027740:1024/095547.501543:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19476:43027740:1024/095547.501608:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19476:43027740:1024/095547.501666:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +Trying to load the allocator multiple times. This is *not* supported. +67295 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/clear-desk/editor-1180.png +[19470:43027691:1024/095547.628608:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4) +[19470:43027691:1024/095547.628619:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4) +Teardown watchdog expired; recording dump and terminating. +[1024/095557.648760:WARNING:third_party/crashpad/crashpad/util/process/process_memory_mac.cc:94] mach_vm_read(0x16bbc4000, 0x8000): (os/kern) invalid address (1) +[1024/095557.846678:WARNING:third_party/crashpad/crashpad/util/numeric/in_range_cast.h:38] value -143893734 out of range diff --git a/admin/tests/evidence/clear-desk/editor-1180.png b/admin/tests/evidence/clear-desk/editor-1180.png new file mode 100644 index 0000000..d6dc8e3 Binary files /dev/null and b/admin/tests/evidence/clear-desk/editor-1180.png differ diff --git a/admin/tests/evidence/clear-desk/editor-1440.html b/admin/tests/evidence/clear-desk/editor-1440.html new file mode 100644 index 0000000..df023be --- /dev/null +++ b/admin/tests/evidence/clear-desk/editor-1440.html @@ -0,0 +1,505 @@ + +PROOF PASS

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

absent
empty
empty_copy
explicit

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

diff --git a/admin/tests/evidence/clear-desk/editor-1440.log b/admin/tests/evidence/clear-desk/editor-1440.log new file mode 100644 index 0000000..e2f67df --- /dev/null +++ b/admin/tests/evidence/clear-desk/editor-1440.log @@ -0,0 +1,13 @@ +[19424:43027111:1024/095536.894173:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19424:43027111:1024/095536.894464:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19424:43027111:1024/095536.894509:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19424:43027111:1024/095536.894571:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19424:43027111:1024/095536.894614:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19424:43027111:1024/095536.894651:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +Trying to load the allocator multiple times. This is *not* supported. +69173 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/clear-desk/editor-1440.png +[19419:43027062:1024/095537.009200:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4) +[19419:43027062:1024/095537.009211:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4) +Teardown watchdog expired; recording dump and terminating. +[1024/095547.029553:WARNING:third_party/crashpad/crashpad/util/process/process_memory_mac.cc:94] mach_vm_read(0x16f390000, 0x8000): (os/kern) invalid address (1) +[1024/095547.232724:WARNING:third_party/crashpad/crashpad/util/numeric/in_range_cast.h:38] value -143893734 out of range diff --git a/admin/tests/evidence/clear-desk/editor-1440.png b/admin/tests/evidence/clear-desk/editor-1440.png new file mode 100644 index 0000000..92ef493 Binary files /dev/null and b/admin/tests/evidence/clear-desk/editor-1440.png differ diff --git a/admin/tests/evidence/clear-desk/flows-corrected.log b/admin/tests/evidence/clear-desk/flows-corrected.log new file mode 100644 index 0000000..fd5ca50 --- /dev/null +++ b/admin/tests/evidence/clear-desk/flows-corrected.log @@ -0,0 +1,9 @@ +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa +test_add_start_register_are_separate_named_decisions (admin.tests.test_app.AdminApp.test_add_start_register_are_separate_named_decisions) ... ok +test_failed_recovery_retains_named_target_without_replay (admin.tests.test_app.AdminApp.test_failed_recovery_retains_named_target_without_replay) ... ok + +---------------------------------------------------------------------- +Ran 2 tests in 0.126s + +OK diff --git a/admin/tests/evidence/clear-desk/flows-final.log b/admin/tests/evidence/clear-desk/flows-final.log new file mode 100644 index 0000000..cd260a6 --- /dev/null +++ b/admin/tests/evidence/clear-desk/flows-final.log @@ -0,0 +1,28 @@ +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa +test_add_start_register_are_separate_named_decisions (admin.tests.test_app.AdminApp.test_add_start_register_are_separate_named_decisions) ... FAIL +test_failed_recovery_retains_named_target_without_replay (admin.tests.test_app.AdminApp.test_failed_recovery_retains_named_target_without_replay) ... ok +test_dead_record_leaves_start_button_enabled (admin.tests.test_toolyard_ops.ToolyardOps.test_dead_record_leaves_start_button_enabled) ... ok +test_list_tools_includes_explicit_tool_dirs (admin.tests.test_toolyard_ops.ToolyardOps.test_list_tools_includes_explicit_tool_dirs) ... ok +test_list_tools_reports_defined_not_running (admin.tests.test_toolyard_ops.ToolyardOps.test_list_tools_reports_defined_not_running) ... ok +test_removable_flag_marks_only_tool_dirs (admin.tests.test_toolyard_ops.ToolyardOps.test_removable_flag_marks_only_tool_dirs) ... ok +test_remove_deletes_a_managed_tool_dir (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_deletes_a_managed_tool_dir) ... ok +test_remove_refuses_external_tool_dir (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_refuses_external_tool_dir) ... ok +test_remove_unknown_tool_raises (admin.tests.test_toolyard_ops.ToolyardOps.test_remove_unknown_tool_raises) ... ok +test_start_keeps_live_record (admin.tests.test_toolyard_ops.ToolyardOps.test_start_keeps_live_record) ... ok +test_start_replaces_dead_record (admin.tests.test_toolyard_ops.ToolyardOps.test_start_replaces_dead_record) ... ok +test_start_unknown_tool_raises (admin.tests.test_toolyard_ops.ToolyardOps.test_start_unknown_tool_raises) ... ok +test_stop_unknown_tool_is_noop (admin.tests.test_toolyard_ops.ToolyardOps.test_stop_unknown_tool_is_noop) ... ok + +====================================================================== +FAIL: test_add_start_register_are_separate_named_decisions (admin.tests.test_app.AdminApp.test_add_start_register_are_separate_named_decisions) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/test_app.py", line 241, in test_add_start_register_are_separate_named_decisions + self.assertIn('Added localfake', added.text) +AssertionError: 'Added localfake' not found in 'Add tool · Toolstack Admin

Toolstack Admin

admin
add at least one operation; the process runner needs an entrypoint command
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

' + +---------------------------------------------------------------------- +Ran 13 tests in 0.133s + +FAILED (failures=1) diff --git a/admin/tests/evidence/clear-desk/focused-contracts.log b/admin/tests/evidence/clear-desk/focused-contracts.log new file mode 100644 index 0000000..fbf4977 --- /dev/null +++ b/admin/tests/evidence/clear-desk/focused-contracts.log @@ -0,0 +1,29 @@ +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa +test_actual_load_edit_reload_and_failed_reload (admin.tests.test_catalog_observation.CatalogObservation.test_actual_load_edit_reload_and_failed_reload) ... {"at": 1792851642.811251, "component": "registry", "correlation_id": "18c81d0e97e3491a9ea8cd0bb9d5aad5", "details": {"ops_after": 1, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_health_only_exposes_random_identity (admin.tests.test_catalog_observation.CatalogObservation.test_health_only_exposes_random_identity) ... ok +test_missing_mismatched_receipt_and_sources (admin.tests.test_catalog_observation.CatalogObservation.test_missing_mismatched_receipt_and_sources) ... ok +test_previous_process_receipt_cannot_certify_new_load (admin.tests.test_catalog_observation.CatalogObservation.test_previous_process_receipt_cannot_certify_new_load) ... {"at": 1792851644.358251, "component": "registry", "correlation_id": "4169f5df72c34e5da4c2c68ca9637b6a", "details": {"ops_after": 1, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_secret_block_content_does_not_enter_private_fingerprint (admin.tests.test_catalog_observation.CatalogObservation.test_secret_block_content_does_not_enter_private_fingerprint) ... ok +test_external_unregister_retains_files_and_other_sources (admin.tests.test_tool_removal.ToolRemoval.test_external_unregister_retains_files_and_other_sources) ... ok +test_unknown_ownership_refuses_before_effect (admin.tests.test_tool_removal.ToolRemoval.test_unknown_ownership_refuses_before_effect) ... ok +test_policy_after_effect_failure_is_unknown_and_read_does_not_replay (admin.tests.test_app.AdminApp.test_policy_after_effect_failure_is_unknown_and_read_does_not_replay) ... ok +test_defaults_to_loopback_8780 (desktop.tests.test_app.AdminUrl.test_defaults_to_loopback_8780) ... ok +test_env_override (desktop.tests.test_app.AdminUrl.test_env_override) ... ok +test_false_when_nothing_listening (desktop.tests.test_app.Health.test_false_when_nothing_listening) ... ok +test_true_when_serving (desktop.tests.test_app.Health.test_true_when_serving) ... ok +test_already_up_does_not_spawn (desktop.tests.test_app.Lifecycle.test_already_up_does_not_spawn) ... ok +test_early_exit_raises_with_guidance (desktop.tests.test_app.Lifecycle.test_early_exit_raises_with_guidance) ... ok +test_spawns_waits_for_health_then_stops (desktop.tests.test_app.Lifecycle.test_spawns_waits_for_health_then_stops) ... ok +test_stop_is_idempotent_and_safe_when_never_started (desktop.tests.test_app.Lifecycle.test_stop_is_idempotent_and_safe_when_never_started) ... ok +test_extracts_or_defaults (desktop.tests.test_app.PortOf.test_extracts_or_defaults) ... ok +test_double_click_failure_renders_before_main_and_cleans_up (desktop.tests.test_app.StartupSurface.test_double_click_failure_renders_before_main_and_cleans_up) ... ok +test_error_is_escaped (desktop.tests.test_app.StartupSurface.test_error_is_escaped) ... ok +test_retry_failure_stays_local_then_success_opens_admin (desktop.tests.test_app.StartupSurface.test_retry_failure_stays_local_then_success_opens_admin) ... ok + +---------------------------------------------------------------------- +Ran 20 tests in 4.210s + +OK diff --git a/admin/tests/evidence/clear-desk/focused-final.log b/admin/tests/evidence/clear-desk/focused-final.log new file mode 100644 index 0000000..f293c97 --- /dev/null +++ b/admin/tests/evidence/clear-desk/focused-final.log @@ -0,0 +1,92 @@ +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa +test_bad_tool_degrades_to_banner_not_500 (admin.tests.test_app.AdminApp.test_bad_tool_degrades_to_banner_not_500) ... ok +test_remove_root_tool_deletes_its_folder (admin.tests.test_app.AdminApp.test_remove_root_tool_deletes_its_folder) ... ok +test_remove_tool_unregisters_but_keeps_files (admin.tests.test_app.AdminApp.test_remove_tool_unregisters_but_keeps_files) ... ok +test_external_delete_unregisters_only_matching_source (admin.tests.test_api.JsonApi.test_external_delete_unregisters_only_matching_source) ... ok +test_combines_root_and_tool_dirs (broker.tests.test_registry.FromSources.test_combines_root_and_tool_dirs) ... ok +test_missing_tool_dir_is_skipped (broker.tests.test_registry.FromSources.test_missing_tool_dir_is_skipped) ... ok +test_tool_dir_only (broker.tests.test_registry.FromSources.test_tool_dir_only) ... ok +test_list_ops_carries_tool_type (broker.tests.test_registry.FromToolsRoot.test_list_ops_carries_tool_type) ... ok +test_lookup_resolves_op_metadata_and_port (broker.tests.test_registry.FromToolsRoot.test_lookup_resolves_op_metadata_and_port) ... ok +test_registry_is_secret_unaware (broker.tests.test_registry.FromToolsRoot.test_registry_is_secret_unaware) ... ok +test_unknown_op_is_none (broker.tests.test_registry.FromToolsRoot.test_unknown_op_is_none) ... ok +test_unknown_tool_is_none (broker.tests.test_registry.FromToolsRoot.test_unknown_tool_is_none) ... ok +test_dotted_id_raises_naming_the_id (broker.tests.test_registry.IdValidation.test_dotted_id_raises_naming_the_id) ... ok +test_id_with_leading_dash_raises (broker.tests.test_registry.IdValidation.test_id_with_leading_dash_raises) ... ok +test_missing_id_raises (broker.tests.test_registry.IdValidation.test_missing_id_raises) ... ok +test_slash_in_id_raises (broker.tests.test_registry.IdValidation.test_slash_in_id_raises) ... ok +test_valid_id_with_dash_and_underscore_loads (broker.tests.test_registry.IdValidation.test_valid_id_with_dash_and_underscore_loads) ... ok +test_lookup_carries_mcp_type_and_port (broker.tests.test_registry.McpType.test_lookup_carries_mcp_type_and_port) ... ok +test_boolean_port_raises (broker.tests.test_registry.PortValidation.test_boolean_port_raises) ... ok +test_mcp_tool_also_needs_a_port (broker.tests.test_registry.PortValidation.test_mcp_tool_also_needs_a_port) ... ok +test_missing_port_raises_naming_the_tool (broker.tests.test_registry.PortValidation.test_missing_port_raises_naming_the_tool) ... ok +test_non_integer_port_raises (broker.tests.test_registry.PortValidation.test_non_integer_port_raises) ... ok +test_out_of_range_port_raises (broker.tests.test_registry.PortValidation.test_out_of_range_port_raises) ... ok +test_rest_type_loads_with_valid_port (broker.tests.test_registry.PortValidation.test_rest_type_loads_with_valid_port) ... ok +test_unknown_type_rejected_even_with_a_valid_port (broker.tests.test_registry.PortValidation.test_unknown_type_rejected_even_with_a_valid_port) ... ok +test_valid_port_loads (broker.tests.test_registry.PortValidation.test_valid_port_loads) ... ok +test_base_url_host_with_invalid_characters_is_rejected (broker.tests.test_registry.RestType.test_base_url_host_with_invalid_characters_is_rejected) ... ok +test_credentialed_base_url_is_rejected (broker.tests.test_registry.RestType.test_credentialed_base_url_is_rejected) ... ok +test_lookup_carries_rest_metadata_and_strips_base_url_to_host (broker.tests.test_registry.RestType.test_lookup_carries_rest_metadata_and_strips_base_url_to_host) ... ok +test_registry_is_secret_and_base_url_path_unaware (broker.tests.test_registry.RestType.test_registry_is_secret_and_base_url_path_unaware) ... FAIL +test_does_not_inject_when_no_secrets_block (broker.tests.test_registry_inject.RefreshOpInjection.test_does_not_inject_when_no_secrets_block) ... ok +test_does_not_inject_when_secrets_block_empty (broker.tests.test_registry_inject.RefreshOpInjection.test_does_not_inject_when_secrets_block_empty) ... ok +test_existing_ops_preserved (broker.tests.test_registry_inject.RefreshOpInjection.test_existing_ops_preserved) ... ok +test_injects_refresh_ops_when_secrets_declared (broker.tests.test_registry_inject.RefreshOpInjection.test_injects_refresh_ops_when_secrets_declared) ... ok +test_refresh_one_takes_a_name_arg (broker.tests.test_registry_inject.RefreshOpInjection.test_refresh_one_takes_a_name_arg) ... ok +test_refresh_ops_are_read_risk (broker.tests.test_registry_inject.RefreshOpInjection.test_refresh_ops_are_read_risk) ... ok +test_allow_returns_200_with_result (broker.tests.test_gateway.Actions.test_allow_returns_200_with_result) ... ok +test_denied_returns_403 (broker.tests.test_gateway.Actions.test_denied_returns_403) ... ok +test_malformed_action_path_returns_400 (broker.tests.test_gateway.Actions.test_malformed_action_path_returns_400) ... ok +test_malformed_body_returns_400 (broker.tests.test_gateway.Actions.test_malformed_body_returns_400) ... ok +test_review_returns_202 (broker.tests.test_gateway.Actions.test_review_returns_202) ... ok +test_token_value_never_logged (broker.tests.test_gateway.Actions.test_token_value_never_logged) ... ok +test_unknown_tool_returns_404 (broker.tests.test_gateway.Actions.test_unknown_tool_returns_404) ... ok +test_authenticated_unknown_route_404 (broker.tests.test_gateway.Auth.test_authenticated_unknown_route_404) ... ok +test_bad_token_401 (broker.tests.test_gateway.Auth.test_bad_token_401) ... ok +test_revoked_token_401 (broker.tests.test_gateway.Auth.test_revoked_token_401) ... ok +test_unauthenticated_action_401 (broker.tests.test_gateway.Auth.test_unauthenticated_action_401) ... ok +test_minted_when_absent (broker.tests.test_gateway.CorrelationId.test_minted_when_absent) ... ok +test_supplied_is_propagated (broker.tests.test_gateway.CorrelationId.test_supplied_is_propagated) ... ok +test_describe_allowed_op (broker.tests.test_gateway.Discovery.test_describe_allowed_op) ... ok +test_describe_denied_op_is_404 (broker.tests.test_gateway.Discovery.test_describe_denied_op_is_404) ... ok +test_tools_lists_only_allowed_with_effects (broker.tests.test_gateway.Discovery.test_tools_lists_only_allowed_with_effects) ... ok +test_rate_limit_returns_429 (broker.tests.test_gateway.Hardening.test_rate_limit_returns_429) ... ok +test_reason_is_omitted_in_audit (broker.tests.test_gateway.Hardening.test_reason_is_omitted_in_audit) ... ok +test_health_is_not_audited (broker.tests.test_gateway.Health.test_health_is_not_audited) ... ok +test_open_without_auth (broker.tests.test_gateway.Health.test_open_without_auth) ... ok +test_cannot_read_another_callers_request (broker.tests.test_gateway.RequestStatus.test_cannot_read_another_callers_request) ... ok +test_poll_pending_then_approved_runs_it (broker.tests.test_gateway.RequestStatus.test_poll_pending_then_approved_runs_it) ... ok +test_unknown_request_404 (broker.tests.test_gateway.RequestStatus.test_unknown_request_404) ... ok +test_binary_body_must_be_base64 (broker.tests.test_gateway.RestEnvelopeValidation.test_binary_body_must_be_base64) ... ok +test_none_body_rejects_present_body (broker.tests.test_gateway.RestEnvelopeValidation.test_none_body_rejects_present_body) ... ok +test_text_body_required_and_must_be_string (broker.tests.test_gateway.RestEnvelopeValidation.test_text_body_required_and_must_be_string) ... ok +test_valid_text_envelope_submits (broker.tests.test_gateway.RestEnvelopeValidation.test_valid_text_envelope_submits) ... ok +test_variables_and_headers_must_be_string_maps (broker.tests.test_gateway.RestEnvelopeValidation.test_variables_and_headers_must_be_string_maps) ... ok +test_defaults_to_loopback_8780 (desktop.tests.test_app.AdminUrl.test_defaults_to_loopback_8780) ... ok +test_env_override (desktop.tests.test_app.AdminUrl.test_env_override) ... ok +test_false_when_nothing_listening (desktop.tests.test_app.Health.test_false_when_nothing_listening) ... ok +test_true_when_serving (desktop.tests.test_app.Health.test_true_when_serving) ... ok +test_already_up_does_not_spawn (desktop.tests.test_app.Lifecycle.test_already_up_does_not_spawn) ... ok +test_early_exit_raises_with_guidance (desktop.tests.test_app.Lifecycle.test_early_exit_raises_with_guidance) ... ok +test_spawns_waits_for_health_then_stops (desktop.tests.test_app.Lifecycle.test_spawns_waits_for_health_then_stops) ... ok +test_stop_is_idempotent_and_safe_when_never_started (desktop.tests.test_app.Lifecycle.test_stop_is_idempotent_and_safe_when_never_started) ... ok +test_extracts_or_defaults (desktop.tests.test_app.PortOf.test_extracts_or_defaults) ... ok +test_double_click_failure_renders_before_main_and_cleans_up (desktop.tests.test_app.StartupSurface.test_double_click_failure_renders_before_main_and_cleans_up) ... ok +test_error_is_escaped (desktop.tests.test_app.StartupSurface.test_error_is_escaped) ... ok +test_retry_failure_stays_local_then_success_opens_admin (desktop.tests.test_app.StartupSurface.test_retry_failure_stays_local_then_success_opens_admin) ... ok +test_retry_latch_and_close_during_startup (desktop.tests.test_app.StartupSurface.test_retry_latch_and_close_during_startup) ... ok + +====================================================================== +FAIL: test_registry_is_secret_and_base_url_path_unaware (broker.tests.test_registry.RestType.test_registry_is_secret_and_base_url_path_unaware) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/broker/tests/test_registry.py", line 167, in test_registry_is_secret_and_base_url_path_unaware + self.assertNotIn(forbidden, blob) +AssertionError: 'tenant' unexpectedly found in '{\'kv\': {\'port\': 4621, \'type\': \'rest\', \'ops\': {\'get_item\': {\'risk\': \'read\', \'description\': \'\', \'args\': [], \'approval_fields\': [], \'verb\': \'GET\', \'path_template\': \'/items/{item_id}\', \'base_url_host\': \'api.example.test\', \'body_kind\': \'none\'}, \'refresh\': {\'risk\': \'read\', \'description\': "Re-pull every declared secret from the backend into the tool\'s in-memory cache.", \'args\': []}, \'refresh_one\': {\'risk\': \'read\', \'description\': \'Re-pull a single secret by name (arg: name).\', \'args\': [{\'name\': \'name\', \'type\': \'string\', \'required\': True, \'description\': \'Secret name to refresh.\'}]}}, \'observation_manifest\': {\'id\': \'kv\', \'type\': \'rest\', \'entrypoint\': {\'port\': 4621}, \'operations\': [{\'name\': \'get_item\', \'risk\': \'read\', \'verb\': \'GET\', \'path\': \'/items/{item_id}\', \'body_kind\': \'none\'}], \'base_url\': \'https://api.example.test/tenant/secret-path?debug=1\'}, \'manifest_path\': \'/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-web-tmp/tmpkv4s6e1n/kv/toolyard.toml\', \'manifest_digest\': \'a1d7eb76cc0605deb7394c9f04fa8f5cf37a2c88bc4ed444c71000a0b5b2e97c\'}}' + +---------------------------------------------------------------------- +Ran 77 tests in 1.814s + +FAILED (failures=1) diff --git a/admin/tests/evidence/clear-desk/js-syntax.json b/admin/tests/evidence/clear-desk/js-syntax.json new file mode 100644 index 0000000..726573d --- /dev/null +++ b/admin/tests/evidence/clear-desk/js-syntax.json @@ -0,0 +1,12 @@ +[ + { + "script": "panel", + "returncode": 0, + "stderr": "" + }, + { + "script": "editor", + "returncode": 0, + "stderr": "" + } +] \ No newline at end of file diff --git a/admin/tests/evidence/clear-desk/norman-admin.json b/admin/tests/evidence/clear-desk/norman-admin.json new file mode 100644 index 0000000..3d1a025 --- /dev/null +++ b/admin/tests/evidence/clear-desk/norman-admin.json @@ -0,0 +1,44 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [ + { + "file": "tests/evidence/clear-desk/editor-1180.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/clear-desk/editor-1440.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/clear-desk/policy-1180.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/clear-desk/policy-1440.html", + "inputs": 3, + "required": 0, + "labels": 0 + } + ] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 1, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [] + } +} diff --git a/admin/tests/evidence/clear-desk/norman-desktop.json b/admin/tests/evidence/clear-desk/norman-desktop.json new file mode 100644 index 0000000..a114e76 --- /dev/null +++ b/admin/tests/evidence/clear-desk/norman-desktop.json @@ -0,0 +1,19 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 0, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [] + } +} diff --git a/admin/tests/evidence/clear-desk/observation-concurrency.log b/admin/tests/evidence/clear-desk/observation-concurrency.log new file mode 100644 index 0000000..aab6201 --- /dev/null +++ b/admin/tests/evidence/clear-desk/observation-concurrency.log @@ -0,0 +1,9 @@ +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa +test_old_live_broker_and_concurrent_admin_reads (admin.tests.test_catalog_observation.CatalogObservation.test_old_live_broker_and_concurrent_admin_reads) ... ok +test_policy_failed_load_disables_save (admin.tests.test_app.AdminApp.test_policy_failed_load_disables_save) ... ok + +---------------------------------------------------------------------- +Ran 2 tests in 1.104s + +OK diff --git a/admin/tests/evidence/clear-desk/policy-1180.html b/admin/tests/evidence/clear-desk/policy-1180.html new file mode 100644 index 0000000..d293f45 --- /dev/null +++ b/admin/tests/evidence/clear-desk/policy-1180.html @@ -0,0 +1,226 @@ + +PROOF PASS

Toolstack Admin

fake-admin
BackPolicy for writer-demo

Policy saved · writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread

Leave policy draft · writer-demo?

diff --git a/admin/tests/evidence/clear-desk/policy-1180.log b/admin/tests/evidence/clear-desk/policy-1180.log new file mode 100644 index 0000000..7ab8760 --- /dev/null +++ b/admin/tests/evidence/clear-desk/policy-1180.log @@ -0,0 +1,13 @@ +[19387:43026617:1024/095526.305386:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19387:43026617:1024/095526.305681:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19387:43026617:1024/095526.305732:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19387:43026617:1024/095526.305777:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19387:43026617:1024/095526.305836:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19387:43026617:1024/095526.305879:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +Trying to load the allocator multiple times. This is *not* supported. +34031 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/clear-desk/policy-1180.png +[19383:43026557:1024/095526.412615:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4) +[19383:43026557:1024/095526.412624:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4) +Teardown watchdog expired; recording dump and terminating. +[1024/095536.425082:WARNING:third_party/crashpad/crashpad/util/process/process_memory_mac.cc:94] mach_vm_read(0x16f1b8000, 0x8000): (os/kern) invalid address (1) +[1024/095536.616458:WARNING:third_party/crashpad/crashpad/util/numeric/in_range_cast.h:38] value -143893734 out of range diff --git a/admin/tests/evidence/clear-desk/policy-1180.png b/admin/tests/evidence/clear-desk/policy-1180.png new file mode 100644 index 0000000..8451d97 Binary files /dev/null and b/admin/tests/evidence/clear-desk/policy-1180.png differ diff --git a/admin/tests/evidence/clear-desk/policy-1440.html b/admin/tests/evidence/clear-desk/policy-1440.html new file mode 100644 index 0000000..d293f45 --- /dev/null +++ b/admin/tests/evidence/clear-desk/policy-1440.html @@ -0,0 +1,226 @@ + +PROOF PASS

Toolstack Admin

fake-admin
BackPolicy for writer-demo

Policy saved · writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread

Leave policy draft · writer-demo?

diff --git a/admin/tests/evidence/clear-desk/policy-1440.log b/admin/tests/evidence/clear-desk/policy-1440.log new file mode 100644 index 0000000..2034c90 --- /dev/null +++ b/admin/tests/evidence/clear-desk/policy-1440.log @@ -0,0 +1,14 @@ +Trying to load the allocator multiple times. This is *not* supported. +[19367:43026062:1024/095515.723865:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19367:43026062:1024/095515.724226:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19367:43026062:1024/095515.724279:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19367:43026062:1024/095515.724339:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19367:43026062:1024/095515.724398:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19367:43026062:1024/095515.724446:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[19367:43026062:1024/095515.724505:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +34980 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/clear-desk/policy-1440.png +[19361:43026008:1024/095515.836425:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4) +[19361:43026008:1024/095515.836435:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4) +Teardown watchdog expired; recording dump and terminating. +[1024/095525.853516:WARNING:third_party/crashpad/crashpad/util/process/process_memory_mac.cc:94] mach_vm_read(0x16f870000, 0x8000): (os/kern) invalid address (1) +[1024/095526.042666:WARNING:third_party/crashpad/crashpad/util/numeric/in_range_cast.h:38] value -143893734 out of range diff --git a/admin/tests/evidence/clear-desk/policy-1440.png b/admin/tests/evidence/clear-desk/policy-1440.png new file mode 100644 index 0000000..32d670c Binary files /dev/null and b/admin/tests/evidence/clear-desk/policy-1440.png differ diff --git a/admin/tests/evidence/clear-desk/registry-final.log b/admin/tests/evidence/clear-desk/registry-final.log new file mode 100644 index 0000000..7b2f10a --- /dev/null +++ b/admin/tests/evidence/clear-desk/registry-final.log @@ -0,0 +1,38 @@ +test_combines_root_and_tool_dirs (broker.tests.test_registry.FromSources.test_combines_root_and_tool_dirs) ... ok +test_missing_tool_dir_is_skipped (broker.tests.test_registry.FromSources.test_missing_tool_dir_is_skipped) ... ok +test_tool_dir_only (broker.tests.test_registry.FromSources.test_tool_dir_only) ... ok +test_list_ops_carries_tool_type (broker.tests.test_registry.FromToolsRoot.test_list_ops_carries_tool_type) ... ok +test_lookup_resolves_op_metadata_and_port (broker.tests.test_registry.FromToolsRoot.test_lookup_resolves_op_metadata_and_port) ... ok +test_registry_is_secret_unaware (broker.tests.test_registry.FromToolsRoot.test_registry_is_secret_unaware) ... ok +test_unknown_op_is_none (broker.tests.test_registry.FromToolsRoot.test_unknown_op_is_none) ... ok +test_unknown_tool_is_none (broker.tests.test_registry.FromToolsRoot.test_unknown_tool_is_none) ... ok +test_dotted_id_raises_naming_the_id (broker.tests.test_registry.IdValidation.test_dotted_id_raises_naming_the_id) ... ok +test_id_with_leading_dash_raises (broker.tests.test_registry.IdValidation.test_id_with_leading_dash_raises) ... ok +test_missing_id_raises (broker.tests.test_registry.IdValidation.test_missing_id_raises) ... ok +test_slash_in_id_raises (broker.tests.test_registry.IdValidation.test_slash_in_id_raises) ... ok +test_valid_id_with_dash_and_underscore_loads (broker.tests.test_registry.IdValidation.test_valid_id_with_dash_and_underscore_loads) ... ok +test_lookup_carries_mcp_type_and_port (broker.tests.test_registry.McpType.test_lookup_carries_mcp_type_and_port) ... ok +test_boolean_port_raises (broker.tests.test_registry.PortValidation.test_boolean_port_raises) ... ok +test_mcp_tool_also_needs_a_port (broker.tests.test_registry.PortValidation.test_mcp_tool_also_needs_a_port) ... ok +test_missing_port_raises_naming_the_tool (broker.tests.test_registry.PortValidation.test_missing_port_raises_naming_the_tool) ... ok +test_non_integer_port_raises (broker.tests.test_registry.PortValidation.test_non_integer_port_raises) ... ok +test_out_of_range_port_raises (broker.tests.test_registry.PortValidation.test_out_of_range_port_raises) ... ok +test_rest_type_loads_with_valid_port (broker.tests.test_registry.PortValidation.test_rest_type_loads_with_valid_port) ... ok +test_unknown_type_rejected_even_with_a_valid_port (broker.tests.test_registry.PortValidation.test_unknown_type_rejected_even_with_a_valid_port) ... ok +test_valid_port_loads (broker.tests.test_registry.PortValidation.test_valid_port_loads) ... ok +test_base_url_host_with_invalid_characters_is_rejected (broker.tests.test_registry.RestType.test_base_url_host_with_invalid_characters_is_rejected) ... ok +test_credentialed_base_url_is_rejected (broker.tests.test_registry.RestType.test_credentialed_base_url_is_rejected) ... ok +test_lookup_carries_rest_metadata_and_strips_base_url_to_host (broker.tests.test_registry.RestType.test_lookup_carries_rest_metadata_and_strips_base_url_to_host) ... ok +test_registry_is_secret_and_base_url_path_unaware (broker.tests.test_registry.RestType.test_registry_is_secret_and_base_url_path_unaware) ... ok +test_actual_load_edit_reload_and_failed_reload (admin.tests.test_catalog_observation.CatalogObservation.test_actual_load_edit_reload_and_failed_reload) ... {"at": 1792851854.0342212, "component": "registry", "correlation_id": "1993154e5fab4a6b8c44e0f579dc070a", "details": {"ops_after": 1, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_health_only_exposes_random_identity (admin.tests.test_catalog_observation.CatalogObservation.test_health_only_exposes_random_identity) ... ok +test_missing_mismatched_receipt_and_sources (admin.tests.test_catalog_observation.CatalogObservation.test_missing_mismatched_receipt_and_sources) ... ok +test_previous_process_receipt_cannot_certify_new_load (admin.tests.test_catalog_observation.CatalogObservation.test_previous_process_receipt_cannot_certify_new_load) ... {"at": 1792851855.5787039, "component": "registry", "correlation_id": "d491069d896f4788a34a9d591396bd51", "details": {"ops_after": 1, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_secret_block_content_does_not_enter_private_fingerprint (admin.tests.test_catalog_observation.CatalogObservation.test_secret_block_content_does_not_enter_private_fingerprint) ... ok + +---------------------------------------------------------------------- +Ran 31 tests in 2.580s + +OK diff --git a/admin/tests/evidence/clear-desk/renderer-process-check.json b/admin/tests/evidence/clear-desk/renderer-process-check.json new file mode 100644 index 0000000..6f063dd --- /dev/null +++ b/admin/tests/evidence/clear-desk/renderer-process-check.json @@ -0,0 +1,15 @@ +{ + "recorded_log_pids": [ + 19361, + 19367, + 19383, + 19387, + 19419, + 19424, + 19470, + 19476 + ], + "ps_returncode": 1, + "remaining_matches": "", + "limit": "Only PIDs recorded by retained renderer logs; unrecorded descendants cannot be established from existing evidence. No processes killed or browsers launched." +} \ No newline at end of file diff --git a/admin/tests/evidence/clear-desk/results.json b/admin/tests/evidence/clear-desk/results.json new file mode 100644 index 0000000..3eec72f --- /dev/null +++ b/admin/tests/evidence/clear-desk/results.json @@ -0,0 +1,22 @@ +[ + { + "case": "policy-1440", + "passed": false, + "returncode": 2 + }, + { + "case": "policy-1180", + "passed": false, + "returncode": 2 + }, + { + "case": "editor-1440", + "passed": false, + "returncode": 2 + }, + { + "case": "editor-1180", + "passed": false, + "returncode": 2 + } +] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-current/attempt-1/fixtures.json b/admin/tests/evidence/webkit-current/attempt-1/fixtures.json new file mode 100644 index 0000000..8c68dab --- /dev/null +++ b/admin/tests/evidence/webkit-current/attempt-1/fixtures.json @@ -0,0 +1 @@ +[{"name": "policy-1440", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
BackPolicy for writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread
", "width": 1440, "height": 900}, {"name": "policy-1180", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
BackPolicy for writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread
", "width": 1180, "height": 900}, {"name": "load-failure-1440", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Policy could not be loaded. Retry load.
BackPolicy for writer-demo
Policy unavailable \u00b7 writer-demo. Retry load
", "width": 1440, "height": 900}, {"name": "load-failure-1180", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Policy could not be loaded. Retry load.
BackPolicy for writer-demo
Policy unavailable \u00b7 writer-demo. Retry load
", "width": 1180, "height": 900}, {"name": "editor-1440", "html": "Edit tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

", "width": 1440, "height": 900}, {"name": "editor-1180", "html": "Edit tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

", "width": 1180, "height": 900}, {"name": "confirmation-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "confirmation-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "returned-unknown-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "returned-unknown-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "replacement-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "replacement-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "add-1440", "html": "Add tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

", "width": 1440, "height": 900}, {"name": "add-1180", "html": "Add tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

", "width": 1180, "height": 900}, {"name": "registered-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Catalog verified
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "registered-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Catalog verified
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "added-stopped-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700stopped
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "added-stopped-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700stopped
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "register-confirm-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "register-confirm-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "shell-startup-620", "html": "Startup unavailable\n \n

Admin unavailable

http://127.0.0.1:8780

\n

Admin exited with status 7 before /login responded.

\n

Diagnostics: run python3 -m admin serve to inspect startup output.

\n \n \n ", "width": 620, "height": 360}] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-current/attempt-1/process.json b/admin/tests/evidence/webkit-current/attempt-1/process.json new file mode 100644 index 0000000..d61cb7c --- /dev/null +++ b/admin/tests/evidence/webkit-current/attempt-1/process.json @@ -0,0 +1,10 @@ +{ + "pid": 27504, + "start_epoch": 1792853340.289731, + "start_identity": "27504 27493 Sat Oct 24 10:49:00 2026 /private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-wk-current/runner /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/webkit-current/attempt-1/fixtures.json /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/webkit-current/attempt-1\n", + "exit_code": 2, + "elapsed_seconds": 0.1499800682067871, + "watchdog": false, + "after_identity": "", + "child_limit": "Only direct children observable by parent identity; launchd-managed WebKit XPC services are not claimed owned or signalled." +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-current/attempt-1/renderer.stderr b/admin/tests/evidence/webkit-current/attempt-1/renderer.stderr new file mode 100644 index 0000000..4549f95 --- /dev/null +++ b/admin/tests/evidence/webkit-current/attempt-1/renderer.stderr @@ -0,0 +1 @@ +Cannot prohibit activation diff --git a/admin/tests/evidence/webkit-current/attempt-1/renderer.stdout b/admin/tests/evidence/webkit-current/attempt-1/renderer.stdout new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-current/attempt-1/summary.json b/admin/tests/evidence/webkit-current/attempt-1/summary.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/admin/tests/evidence/webkit-current/attempt-1/summary.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-current/build-and-gate.json b/admin/tests/evidence/webkit-current/build-and-gate.json new file mode 100644 index 0000000..e82ebc5 --- /dev/null +++ b/admin/tests/evidence/webkit-current/build-and-gate.json @@ -0,0 +1,12 @@ +{ + "source_sha256": "fd2a8833a4da748a47a50e88a6817a49072611c10a19b125751cdd7ecbde681c", + "binary_sha256": "ff723af98ed12b4ccade2f38dba9c4c192845a8e05ed69017ba4ce0bcf7a14eb", + "compile_jobs": 2, + "compile_failure": "One output-path typo caused a linker failure; corrected before renderer launch. Original log preserved.", + "renderer_launches": 1, + "renderer_exit": 2, + "rendered_cases": 0, + "assertions_executed": 0, + "snapshots": 0, + "activation_result": "setActivationPolicy(.prohibited) returned false; actual current policy was not queried. WKWebView creation was never reached. This does not establish that WebKit itself cannot render offscreen." +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-current/compile-final.log b/admin/tests/evidence/webkit-current/compile-final.log new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-current/compile-private.log b/admin/tests/evidence/webkit-current/compile-private.log new file mode 100644 index 0000000..2449060 --- /dev/null +++ b/admin/tests/evidence/webkit-current/compile-private.log @@ -0,0 +1,3 @@ +error: link command failed with exit code 1 (use -v to see invocation) +ld: open() failed, errno=2 (No such file or directory) for '/private/var/folders/pz/lrml3ths3mgmf4bb75rmcvc0000gn/T/opencode/clear-desk-wk-current/runner' +clang: error: linker command failed with exit code 1 (use -v to see invocation) diff --git a/admin/tests/evidence/webkit-current/compile.log b/admin/tests/evidence/webkit-current/compile.log new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-current/norman-source.json b/admin/tests/evidence/webkit-current/norman-source.json new file mode 100644 index 0000000..3d1a025 --- /dev/null +++ b/admin/tests/evidence/webkit-current/norman-source.json @@ -0,0 +1,44 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [ + { + "file": "tests/evidence/clear-desk/editor-1180.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/clear-desk/editor-1440.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/clear-desk/policy-1180.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/clear-desk/policy-1440.html", + "inputs": 3, + "required": 0, + "labels": 0 + } + ] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 1, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [] + } +} diff --git a/admin/tests/evidence/webkit-membership/attempt-1/add-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/add-1180.png new file mode 100644 index 0000000..92aec75 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/add-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/add-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/add-1440.png new file mode 100644 index 0000000..2885c9b Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/add-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/added-stopped-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/added-stopped-1180.png new file mode 100644 index 0000000..058e7de Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/added-stopped-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/added-stopped-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/added-stopped-1440.png new file mode 100644 index 0000000..8ff759b Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/added-stopped-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/child-observations.json b/admin/tests/evidence/webkit-membership/attempt-1/child-observations.json new file mode 100644 index 0000000..a2a3002 --- /dev/null +++ b/admin/tests/evidence/webkit-membership/attempt-1/child-observations.json @@ -0,0 +1,4 @@ +{ + "recorded": [], + "after": "No direct child PIDs were observed; no assertion about launchd-managed XPC helpers." +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/attempt-1/confirmation-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/confirmation-1180.png new file mode 100644 index 0000000..efdd3ff Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/confirmation-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/confirmation-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/confirmation-1440.png new file mode 100644 index 0000000..5cb7e25 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/confirmation-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/editor-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/editor-1180.png new file mode 100644 index 0000000..f28c5b8 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/editor-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/editor-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/editor-1440.png new file mode 100644 index 0000000..35941a9 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/editor-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/fixtures.json b/admin/tests/evidence/webkit-membership/attempt-1/fixtures.json new file mode 100644 index 0000000..8c68dab --- /dev/null +++ b/admin/tests/evidence/webkit-membership/attempt-1/fixtures.json @@ -0,0 +1 @@ +[{"name": "policy-1440", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
BackPolicy for writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread
", "width": 1440, "height": 900}, {"name": "policy-1180", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
BackPolicy for writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread
", "width": 1180, "height": 900}, {"name": "load-failure-1440", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Policy could not be loaded. Retry load.
BackPolicy for writer-demo
Policy unavailable \u00b7 writer-demo. Retry load
", "width": 1440, "height": 900}, {"name": "load-failure-1180", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Policy could not be loaded. Retry load.
BackPolicy for writer-demo
Policy unavailable \u00b7 writer-demo. Retry load
", "width": 1180, "height": 900}, {"name": "editor-1440", "html": "Edit tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

", "width": 1440, "height": 900}, {"name": "editor-1180", "html": "Edit tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

", "width": 1180, "height": 900}, {"name": "confirmation-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "confirmation-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "returned-unknown-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "returned-unknown-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "replacement-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "replacement-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "add-1440", "html": "Add tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

", "width": 1440, "height": 900}, {"name": "add-1180", "html": "Add tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

", "width": 1180, "height": 900}, {"name": "registered-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Catalog verified
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "registered-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Catalog verified
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "added-stopped-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700stopped
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "added-stopped-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700stopped
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "register-confirm-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "register-confirm-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "shell-startup-620", "html": "Startup unavailable\n \n

Admin unavailable

http://127.0.0.1:8780

\n

Admin exited with status 7 before /login responded.

\n

Diagnostics: run python3 -m admin serve to inspect startup output.

\n \n \n ", "width": 620, "height": 360}] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/attempt-1/load-failure-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/load-failure-1180.png new file mode 100644 index 0000000..83dbe11 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/load-failure-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/load-failure-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/load-failure-1440.png new file mode 100644 index 0000000..2b4618a Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/load-failure-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/policy-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/policy-1180.png new file mode 100644 index 0000000..df5a367 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/policy-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/policy-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/policy-1440.png new file mode 100644 index 0000000..5966a56 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/policy-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/process.json b/admin/tests/evidence/webkit-membership/attempt-1/process.json new file mode 100644 index 0000000..5a0e1ab --- /dev/null +++ b/admin/tests/evidence/webkit-membership/attempt-1/process.json @@ -0,0 +1,10 @@ +{ + "pid": 31125, + "start_epoch": 1792854105.9374669, + "start_identity": "31125 31093 Sat Oct 24 11:01:45 2026 /private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-wk-membership/runner /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/webkit-membership/attempt-1/fixtures.json /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/webkit-membership/attempt-1\n", + "exit_code": 0, + "elapsed_seconds": 9.111610174179077, + "watchdog": false, + "after_identity": "", + "child_limit": "Only direct children observable by parent identity; launchd-managed WebKit XPC services are not claimed owned or signalled." +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/attempt-1/raw-dom-manifest.json b/admin/tests/evidence/webkit-membership/attempt-1/raw-dom-manifest.json new file mode 100644 index 0000000..ec67193 --- /dev/null +++ b/admin/tests/evidence/webkit-membership/attempt-1/raw-dom-manifest.json @@ -0,0 +1,114 @@ +{ + "archive": "raw-dom.zip", + "format": "ZIP/DEFLATE", + "archive_sha256": "4a10ab940db40c1363ef299d9f839a25c380b01c6978b4cea89b4bc44e787690", + "verification": "Every member byte-compared and SHA-256 checked against original before plaintext removal", + "secret_scan": "Archive members MUST be included in the parent final secret scan; no exemption", + "members": [ + { + "name": "add-1180.html", + "bytes": 20446, + "sha256": "45abdafdf6d14d0b0bbdcd5ac249748c8f3add7a81f719c586a119b54bb39d63" + }, + { + "name": "add-1440.html", + "bytes": 20446, + "sha256": "45abdafdf6d14d0b0bbdcd5ac249748c8f3add7a81f719c586a119b54bb39d63" + }, + { + "name": "added-stopped-1180.html", + "bytes": 21263, + "sha256": "63e084f3b20919d231322547ff53c100ac1ddc48fd90647a9279df6260a70c38" + }, + { + "name": "added-stopped-1440.html", + "bytes": 21263, + "sha256": "63e084f3b20919d231322547ff53c100ac1ddc48fd90647a9279df6260a70c38" + }, + { + "name": "confirmation-1180.html", + "bytes": 22544, + "sha256": "903a8856d1060fa62e674c02271cbbea9dc1f0c0a47bcf8b961681224346d74b" + }, + { + "name": "confirmation-1440.html", + "bytes": 22544, + "sha256": "903a8856d1060fa62e674c02271cbbea9dc1f0c0a47bcf8b961681224346d74b" + }, + { + "name": "editor-1180.html", + "bytes": 49759, + "sha256": "99258e84bb5a2dc27eb593c90e869e440618589eb006ee12ea82715a23743074" + }, + { + "name": "editor-1440.html", + "bytes": 49759, + "sha256": "99258e84bb5a2dc27eb593c90e869e440618589eb006ee12ea82715a23743074" + }, + { + "name": "load-failure-1180.html", + "bytes": 19322, + "sha256": "6b9e8d81f6447aad3c3456c2faedb386c360c81b754e67c2ddbe3db472f41e37" + }, + { + "name": "load-failure-1440.html", + "bytes": 19322, + "sha256": "6b9e8d81f6447aad3c3456c2faedb386c360c81b754e67c2ddbe3db472f41e37" + }, + { + "name": "policy-1180.html", + "bytes": 23309, + "sha256": "6b4e58e8a4d569e03cee0c6e4645ab106e5d282faa2eb528438196939ea19dc7" + }, + { + "name": "policy-1440.html", + "bytes": 23309, + "sha256": "6b4e58e8a4d569e03cee0c6e4645ab106e5d282faa2eb528438196939ea19dc7" + }, + { + "name": "register-confirm-1180.html", + "bytes": 21573, + "sha256": "ad0ddeed13114168a525b64d412577b585eb4b090d9a6abfb05801adcfd416ab" + }, + { + "name": "register-confirm-1440.html", + "bytes": 21573, + "sha256": "ad0ddeed13114168a525b64d412577b585eb4b090d9a6abfb05801adcfd416ab" + }, + { + "name": "registered-1180.html", + "bytes": 20798, + "sha256": "090d3004b1433faa98e21921625a28e5b1893d2950c8f70952c6f764bb5533e6" + }, + { + "name": "registered-1440.html", + "bytes": 20798, + "sha256": "090d3004b1433faa98e21921625a28e5b1893d2950c8f70952c6f764bb5533e6" + }, + { + "name": "replacement-1180.html", + "bytes": 19005, + "sha256": "92d8c441a1c0c8bcb41910da80a2f25a35dc9ab3699c81ec846291ee4ccef438" + }, + { + "name": "replacement-1440.html", + "bytes": 19005, + "sha256": "92d8c441a1c0c8bcb41910da80a2f25a35dc9ab3699c81ec846291ee4ccef438" + }, + { + "name": "returned-unknown-1180.html", + "bytes": 19127, + "sha256": "d62b815a101101bb5c416782838045c543f84a1494c79df23838763725ef077d" + }, + { + "name": "returned-unknown-1440.html", + "bytes": 19127, + "sha256": "d62b815a101101bb5c416782838045c543f84a1494c79df23838763725ef077d" + }, + { + "name": "shell-startup-620.html", + "bytes": 3089, + "sha256": "390c5daeeb1707c48454ff6192b7026a1a69b4810075a270fb1696739db2e202" + } + ] +} diff --git a/admin/tests/evidence/webkit-membership/attempt-1/raw-dom.zip b/admin/tests/evidence/webkit-membership/attempt-1/raw-dom.zip new file mode 100644 index 0000000..ceac972 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/raw-dom.zip differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/register-confirm-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/register-confirm-1180.png new file mode 100644 index 0000000..85407b4 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/register-confirm-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/register-confirm-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/register-confirm-1440.png new file mode 100644 index 0000000..ebb4fdb Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/register-confirm-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/registered-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/registered-1180.png new file mode 100644 index 0000000..4c119d7 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/registered-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/registered-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/registered-1440.png new file mode 100644 index 0000000..ccf7315 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/registered-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/renderer.stderr b/admin/tests/evidence/webkit-membership/attempt-1/renderer.stderr new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-membership/attempt-1/renderer.stdout b/admin/tests/evidence/webkit-membership/attempt-1/renderer.stdout new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-membership/attempt-1/replacement-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/replacement-1180.png new file mode 100644 index 0000000..952f46c Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/replacement-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/replacement-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/replacement-1440.png new file mode 100644 index 0000000..049ec9b Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/replacement-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/returned-unknown-1180.png b/admin/tests/evidence/webkit-membership/attempt-1/returned-unknown-1180.png new file mode 100644 index 0000000..06c8047 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/returned-unknown-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/returned-unknown-1440.png b/admin/tests/evidence/webkit-membership/attempt-1/returned-unknown-1440.png new file mode 100644 index 0000000..64fc6e9 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/returned-unknown-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/shell-startup-620.png b/admin/tests/evidence/webkit-membership/attempt-1/shell-startup-620.png new file mode 100644 index 0000000..e6cf7da Binary files /dev/null and b/admin/tests/evidence/webkit-membership/attempt-1/shell-startup-620.png differ diff --git a/admin/tests/evidence/webkit-membership/attempt-1/summary.json b/admin/tests/evidence/webkit-membership/attempt-1/summary.json new file mode 100644 index 0000000..50660db --- /dev/null +++ b/admin/tests/evidence/webkit-membership/attempt-1/summary.json @@ -0,0 +1,159 @@ +[ + { + "case": "policy-1440", + "failures": [], + "assertions": 21, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "policy-1180", + "failures": [], + "assertions": 21, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1440", + "failures": [ + "no button-button overlap" + ], + "assertions": 7, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1180", + "failures": [ + "no button-button overlap" + ], + "assertions": 7, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1440", + "failures": [], + "assertions": 11, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1180", + "failures": [], + "assertions": 11, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1440", + "failures": [ + "200 unknown stage cannot immediately retry without observation", + "returned unknown stage sends no duplicate mutation" + ], + "assertions": 6, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1180", + "failures": [ + "200 unknown stage cannot immediately retry without observation", + "returned unknown stage sends no duplicate mutation" + ], + "assertions": 6, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1440", + "failures": [], + "assertions": 8, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1180", + "failures": [], + "assertions": 8, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "shell-startup-620", + "failures": [], + "assertions": 6, + "snapshot": "saved", + "window_visible": false + } +] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/attempt-1/webkit-results.json b/admin/tests/evidence/webkit-membership/attempt-1/webkit-results.json new file mode 100644 index 0000000..c914a2b --- /dev/null +++ b/admin/tests/evidence/webkit-membership/attempt-1/webkit-results.json @@ -0,0 +1,3510 @@ +{ + "activation_policy": { + "after": 2, + "before": 2, + "request_returned": false, + "required": 2 + }, + "error": null, + "persistent_store": false, + "pid": 31125, + "results": [ + { + "case": "policy-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 110.140625 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 435.328125, + "y": 110.140625 + }, + { + "height": 22, + "label": "AllowReviewDeny", + "tag": "SELECT", + "width": 79, + "x": 819.265625, + "y": 284.78125 + } + ], + "results": [ + { + "name": "rejected save retains editable draft", + "ok": true + }, + { + "name": "rejection visible", + "ok": true + }, + { + "name": "unknown save blocks retry and retains draft", + "ok": true + }, + { + "name": "comparison retains differing draft", + "ok": true + }, + { + "name": "saved effect shown alongside draft", + "ok": true + }, + { + "name": "dirty navigation opens dialog", + "ok": true + }, + { + "name": "safe Stay receives focus", + "ok": true + }, + { + "name": "Stay retains draft", + "ok": true + }, + { + "name": "policy control focusable", + "ok": true + }, + { + "name": "acknowledged save receipt", + "ok": true + }, + { + "name": "500 after effect is unknown; duplicate submit sends one write", + "ok": true + }, + { + "name": "comparison never replays mutation", + "ok": true + }, + { + "name": "failed comparison locks Save", + "ok": true + }, + { + "name": "mismatched comparison scope locks Save", + "ok": true + }, + { + "name": "fresh successful comparison unlocks explicit save", + "ok": true + }, + { + "name": "signout defaults to Stay", + "ok": true + }, + { + "name": "signout Stay retains draft without submitting", + "ok": true + }, + { + "name": "signout Discard submits exactly once without capture loop", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "policy-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 110.140625 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 305.328125, + "y": 110.140625 + }, + { + "height": 22, + "label": "AllowReviewDeny", + "tag": "SELECT", + "width": 79, + "x": 689.265625, + "y": 284.78125 + } + ], + "results": [ + { + "name": "rejected save retains editable draft", + "ok": true + }, + { + "name": "rejection visible", + "ok": true + }, + { + "name": "unknown save blocks retry and retains draft", + "ok": true + }, + { + "name": "comparison retains differing draft", + "ok": true + }, + { + "name": "saved effect shown alongside draft", + "ok": true + }, + { + "name": "dirty navigation opens dialog", + "ok": true + }, + { + "name": "safe Stay receives focus", + "ok": true + }, + { + "name": "Stay retains draft", + "ok": true + }, + { + "name": "policy control focusable", + "ok": true + }, + { + "name": "acknowledged save receipt", + "ok": true + }, + { + "name": "500 after effect is unknown; duplicate submit sends one write", + "ok": true + }, + { + "name": "comparison never replays mutation", + "ok": true + }, + { + "name": "failed comparison locks Save", + "ok": true + }, + { + "name": "mismatched comparison scope locks Save", + "ok": true + }, + { + "name": "fresh successful comparison unlocks explicit save", + "ok": true + }, + { + "name": "signout defaults to Stay", + "ok": true + }, + { + "name": "signout Stay retains draft without submitting", + "ok": true + }, + { + "name": "signout Discard submits exactly once without capture loop", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 435.328125, + "y": 167.296875 + }, + { + "height": 38, + "label": "Retry load", + "tag": "A", + "width": 85.609375, + "x": 421.953125, + "y": 255.296875 + } + ], + "results": [ + { + "name": "failed load locks Save", + "ok": true + }, + { + "name": "failed load offers Retry", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 305.328125, + "y": 167.296875 + }, + { + "height": 38, + "label": "Retry load", + "tag": "A", + "width": 85.609375, + "x": 291.953125, + "y": 255.296875 + } + ], + "results": [ + { + "name": "failed load locks Save", + "ok": true + }, + { + "name": "failed load offers Retry", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1440", + "direct_children": [], + "dom": { + "below": [ + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 881.328125 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 889.328125 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 881.328125 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 881.328125 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 881.328125 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 919.328125 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 941.625 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 949.625 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 941.625 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 941.625 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 941.625 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 979.625 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 1001.921875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 1009.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 1001.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 1001.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 1001.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 1039.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 1138.21875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 1146.21875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 1138.21875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 1138.21875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 1138.21875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 1176.21875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 200.5, + "y": 1161.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 200.5, + "y": 1266.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 200.5, + "y": 1304.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 289.84375, + "y": 1304.90625 + } + ], + "collisions": [ + [ + "add argument", + "Add operation" + ] + ], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1327.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 200.5, + "y": 105 + }, + { + "height": 38, + "label": "f_id", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 218.03125 + }, + { + "height": 22, + "label": "apimcprest", + "tag": "SELECT", + "width": 520, + "x": 200.5, + "y": 340.921875 + }, + { + "height": 38, + "label": "f_command", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 524.109375 + }, + { + "height": 38, + "label": "f_image", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 626.703125 + }, + { + "height": 38, + "label": "f_port", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 739.734375 + }, + { + "height": 38, + "label": "Expand all", + "tag": "BUTTON", + "width": 69.828125, + "x": 286.875, + "y": 792.875 + }, + { + "height": 38, + "label": "Collapse all", + "tag": "BUTTON", + "width": 76.90625, + "x": 366.703125, + "y": 792.875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 881.328125 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 889.328125 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 881.328125 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 881.328125 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 881.328125 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 919.328125 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 941.625 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 949.625 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 941.625 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 941.625 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 941.625 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 979.625 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 1001.921875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 1009.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 1001.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 1001.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 1001.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 1039.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 1138.21875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 1146.21875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 1138.21875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 1138.21875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 1138.21875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 1176.21875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 200.5, + "y": 1161.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 200.5, + "y": 1266.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 200.5, + "y": 1304.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 289.84375, + "y": 1304.90625 + } + ], + "results": [ + { + "name": "absent approval_fields remains absent through serialization", + "ok": true + }, + { + "name": "empty approval_fields remains empty through serialization", + "ok": true + }, + { + "name": "explicit approval_fields retained", + "ok": true + }, + { + "name": "duplicate preserves explicit empty approval_fields", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": false + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1180", + "direct_children": [], + "dom": { + "below": [ + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 881.328125 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 889.328125 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 881.328125 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 881.328125 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 881.328125 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 919.328125 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 941.625 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 949.625 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 941.625 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 941.625 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 941.625 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 979.625 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 1001.921875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 1009.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 1001.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 1001.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 1001.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 1039.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 1138.21875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 1146.21875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 1138.21875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 1138.21875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 1138.21875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 1176.21875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 70.5, + "y": 1161.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 70.5, + "y": 1266.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 70.5, + "y": 1304.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 159.84375, + "y": 1304.90625 + } + ], + "collisions": [ + [ + "add argument", + "Add operation" + ] + ], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1067.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 70.5, + "y": 105 + }, + { + "height": 38, + "label": "f_id", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 218.03125 + }, + { + "height": 22, + "label": "apimcprest", + "tag": "SELECT", + "width": 520, + "x": 70.5, + "y": 340.921875 + }, + { + "height": 38, + "label": "f_command", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 524.109375 + }, + { + "height": 38, + "label": "f_image", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 626.703125 + }, + { + "height": 38, + "label": "f_port", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 739.734375 + }, + { + "height": 38, + "label": "Expand all", + "tag": "BUTTON", + "width": 69.828125, + "x": 156.875, + "y": 792.875 + }, + { + "height": 38, + "label": "Collapse all", + "tag": "BUTTON", + "width": 76.90625, + "x": 236.703125, + "y": 792.875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 881.328125 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 889.328125 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 881.328125 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 881.328125 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 881.328125 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 919.328125 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 941.625 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 949.625 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 941.625 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 941.625 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 941.625 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 979.625 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 1001.921875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 1009.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 1001.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 1001.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 1001.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 1039.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 1138.21875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 1146.21875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 1138.21875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 1138.21875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 1138.21875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 1176.21875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 70.5, + "y": 1161.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 70.5, + "y": 1266.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 70.5, + "y": 1304.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 159.84375, + "y": 1304.90625 + } + ], + "results": [ + { + "name": "absent approval_fields remains absent through serialization", + "ok": true + }, + { + "name": "empty approval_fields remains empty through serialization", + "ok": true + }, + { + "name": "explicit approval_fields retained", + "ok": true + }, + { + "name": "duplicate preserves explicit empty approval_fields", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": false + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 547.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 602.453125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 664, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 547.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 547.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 547.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 717.375, + "y": 411.890625 + } + ], + "results": [ + { + "name": "confirmation defaults to Cancel", + "ok": true + }, + { + "name": "confirmation names target", + "ok": true + }, + { + "name": "Cancel has no effect", + "ok": true + }, + { + "name": "synthetic Escape-equivalent close has no effect", + "ok": true + }, + { + "name": "accepted confirmation plus duplicate submits sends one POST", + "ok": true + }, + { + "name": "unknown outcome cannot replay POST", + "ok": true + }, + { + "name": "unknown mutation visible", + "ok": true + }, + { + "name": "unknown refresh retains target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 417.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 472.453125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 534, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 417.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 417.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 417.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 587.375, + "y": 411.890625 + } + ], + "results": [ + { + "name": "confirmation defaults to Cancel", + "ok": true + }, + { + "name": "confirmation names target", + "ok": true + }, + { + "name": "Cancel has no effect", + "ok": true + }, + { + "name": "synthetic Escape-equivalent close has no effect", + "ok": true + }, + { + "name": "accepted confirmation plus duplicate submits sends one POST", + "ok": true + }, + { + "name": "unknown outcome cannot replay POST", + "ok": true + }, + { + "name": "unknown mutation visible", + "ok": true + }, + { + "name": "unknown refresh retains target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 743.59375, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 798.484375, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 860.03125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 920.15625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 996.671875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 743.59375, + "y": 299.59375 + } + ], + "results": [ + { + "name": "returned unknown stage remains visible", + "ok": true + }, + { + "name": "200 unknown stage cannot immediately retry without observation", + "ok": false + }, + { + "name": "returned unknown stage sends no duplicate mutation", + "ok": false + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 613.59375, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 668.484375, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 730.03125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 790.15625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 866.671875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 613.59375, + "y": 299.59375 + } + ], + "results": [ + { + "name": "returned unknown stage remains visible", + "ok": true + }, + { + "name": "200 unknown stage cannot immediately retry without observation", + "ok": false + }, + { + "name": "returned unknown stage sends no duplicate mutation", + "ok": false + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 743.59375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 798.484375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 860.03125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 920.15625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 996.671875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 743.59375, + "y": 237.296875 + } + ], + "results": [ + { + "name": "replacement keeps focused recovery form", + "ok": true + }, + { + "name": "document.write replacement initializes production scripts", + "ok": true + }, + { + "name": "replacement dialog has safe focus", + "ok": true + }, + { + "name": "opening replacement confirmation does not send effect", + "ok": true + }, + { + "name": "replacement preserves navigation target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 613.59375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 668.484375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 730.03125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 790.15625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 866.671875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 613.59375, + "y": 237.296875 + } + ], + "results": [ + { + "name": "replacement keeps focused recovery form", + "ok": true + }, + { + "name": "document.write replacement initializes production scripts", + "ok": true + }, + { + "name": "replacement dialog has safe focus", + "ok": true + }, + { + "name": "opening replacement confirmation does not send effect", + "ok": true + }, + { + "name": "replacement preserves navigation target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "source", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 294.921875 + }, + { + "height": 38, + "label": "Add from folder", + "tag": "BUTTON", + "width": 121, + "x": 209, + "y": 344.921875 + }, + { + "height": 38, + "label": "repo", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 453.515625 + }, + { + "height": 38, + "label": "subdir", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 556.109375 + }, + { + "height": 38, + "label": "ref", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 658.703125 + }, + { + "height": 38, + "label": "Clone and add", + "tag": "BUTTON", + "width": 113.34375, + "x": 209, + "y": 708.703125 + } + ], + "results": [ + { + "name": "local host source remains editable", + "ok": true + }, + { + "name": "source action remains functional", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "source", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 294.921875 + }, + { + "height": 38, + "label": "Add from folder", + "tag": "BUTTON", + "width": 121, + "x": 79, + "y": 344.921875 + }, + { + "height": 38, + "label": "repo", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 453.515625 + }, + { + "height": 38, + "label": "subdir", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 556.109375 + }, + { + "height": 38, + "label": "ref", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 658.703125 + }, + { + "height": 38, + "label": "Clone and add", + "tag": "BUTTON", + "width": 113.34375, + "x": 79, + "y": 708.703125 + } + ], + "results": [ + { + "name": "local host source remains editable", + "ok": true + }, + { + "name": "source action remains functional", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 760.125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 815.015625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 876.5625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 936.6875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1013.203125, + "y": 251.59375 + } + ], + "results": [ + { + "name": "registration receipt names success", + "ok": true + }, + { + "name": "verified catalog does not offer redundant registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 630.125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 685.015625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 746.5625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 806.6875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 883.203125, + "y": 251.59375 + } + ], + "results": [ + { + "name": "registration receipt names success", + "ok": true + }, + { + "name": "verified catalog does not offer redundant registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 680.75, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 735.640625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 797.1875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 857.3125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 933.828125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1103.640625, + "y": 251.59375 + } + ], + "results": [ + { + "name": "added tool can be explicitly started", + "ok": true + }, + { + "name": "copy does not imply catalog registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 550.75, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 605.640625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 667.1875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 727.3125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 803.828125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 973.640625, + "y": 251.59375 + } + ], + "results": [ + { + "name": "added tool can be explicitly started", + "ok": true + }, + { + "name": "copy does not imply catalog registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 680.75, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 735.640625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 797.1875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 857.3125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 933.828125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1103.640625, + "y": 189.296875 + }, + { + "height": 156.59375, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo? Reloads catalog; interrupts broker availability ", + "tag": "DIALOG", + "width": 520, + "x": 460, + "y": 371.703125 + }, + { + "height": 38, + "label": "Cancel", + "tag": "BUTTON", + "width": 64.390625, + "x": 485, + "y": 465.296875 + }, + { + "height": 38, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo", + "tag": "BUTTON", + "width": 345.84375, + "x": 549.390625, + "y": 465.296875 + } + ], + "results": [ + { + "name": "registration confirmation names broker and target", + "ok": true + }, + { + "name": "registration warns of interruption", + "ok": true + }, + { + "name": "registration defaults to Cancel", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 550.75, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 605.640625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 667.1875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 727.3125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 803.828125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 973.640625, + "y": 189.296875 + }, + { + "height": 156.59375, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo? Reloads catalog; interrupts broker availability ", + "tag": "DIALOG", + "width": 520, + "x": 330, + "y": 371.703125 + }, + { + "height": 38, + "label": "Cancel", + "tag": "BUTTON", + "width": 64.390625, + "x": 355, + "y": 465.296875 + }, + { + "height": 38, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo", + "tag": "BUTTON", + "width": 345.84375, + "x": 419.390625, + "y": 465.296875 + } + ], + "results": [ + { + "name": "registration confirmation names broker and target", + "ok": true + }, + { + "name": "registration warns of interruption", + "ok": true + }, + { + "name": "registration defaults to Cancel", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "shell-startup-620", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Close", + "tag": "BUTTON", + "width": 67.53125, + "x": 28, + "y": 191.734375 + }, + { + "height": 38, + "label": "Retrying\u2026", + "tag": "BUTTON", + "width": 90.796875, + "x": 99.3125, + "y": 191.734375 + } + ], + "results": [ + { + "name": "shell observed cause", + "ok": true + }, + { + "name": "shell Retry available", + "ok": true + }, + { + "name": "shell Retry button latches synthetic duplicate clicks", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 360, + "width": 620 + } + }, + "snapshot": "saved", + "window_visible": false + } + ], + "started": "2026-10-24T15:01:46Z" +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/build-and-results.json b/admin/tests/evidence/webkit-membership/build-and-results.json new file mode 100644 index 0000000..aa7e3a5 --- /dev/null +++ b/admin/tests/evidence/webkit-membership/build-and-results.json @@ -0,0 +1,14 @@ +{ + "source_sha256": "3c6e2dc9dcf3eea962d0201db9d250fc3112d1aca08479dc830a186f4bc27c3c", + "binary_sha256": "33dd44d9a5eda9970b1595d6494cce66f8c6c3ac00d4e2ae9c529b575736c23f", + "compile_jobs": 2, + "final_cases": 21, + "final_assertions": 164, + "snapshots": 21, + "actual_policy": { + "before": 2, + "after": 2, + "required": 2, + "request_returned": false + } +} diff --git a/admin/tests/evidence/webkit-membership/compile.log b/admin/tests/evidence/webkit-membership/compile.log new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-membership/final/add-1180.png b/admin/tests/evidence/webkit-membership/final/add-1180.png new file mode 100644 index 0000000..92aec75 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/add-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/add-1440.png b/admin/tests/evidence/webkit-membership/final/add-1440.png new file mode 100644 index 0000000..2885c9b Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/add-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/added-stopped-1180.png b/admin/tests/evidence/webkit-membership/final/added-stopped-1180.png new file mode 100644 index 0000000..058e7de Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/added-stopped-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/added-stopped-1440.png b/admin/tests/evidence/webkit-membership/final/added-stopped-1440.png new file mode 100644 index 0000000..8ff759b Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/added-stopped-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/child-observations.json b/admin/tests/evidence/webkit-membership/final/child-observations.json new file mode 100644 index 0000000..a2a3002 --- /dev/null +++ b/admin/tests/evidence/webkit-membership/final/child-observations.json @@ -0,0 +1,4 @@ +{ + "recorded": [], + "after": "No direct child PIDs were observed; no assertion about launchd-managed XPC helpers." +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/final/confirmation-1180.png b/admin/tests/evidence/webkit-membership/final/confirmation-1180.png new file mode 100644 index 0000000..118dcf0 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/confirmation-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/confirmation-1440.png b/admin/tests/evidence/webkit-membership/final/confirmation-1440.png new file mode 100644 index 0000000..0456a00 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/confirmation-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/editor-1180.png b/admin/tests/evidence/webkit-membership/final/editor-1180.png new file mode 100644 index 0000000..f28c5b8 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/editor-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/editor-1440.png b/admin/tests/evidence/webkit-membership/final/editor-1440.png new file mode 100644 index 0000000..35941a9 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/editor-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/fixtures.json b/admin/tests/evidence/webkit-membership/final/fixtures.json new file mode 100644 index 0000000..6560249 --- /dev/null +++ b/admin/tests/evidence/webkit-membership/final/fixtures.json @@ -0,0 +1 @@ +[{"name": "policy-1440", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
BackPolicy for writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread
", "width": 1440, "height": 900}, {"name": "policy-1180", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
BackPolicy for writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread
", "width": 1180, "height": 900}, {"name": "load-failure-1440", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Policy could not be loaded. Retry load.
BackPolicy for writer-demo
Policy unavailable \u00b7 writer-demo. Retry load
", "width": 1440, "height": 900}, {"name": "load-failure-1180", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Policy could not be loaded. Retry load.
BackPolicy for writer-demo
Policy unavailable \u00b7 writer-demo. Retry load
", "width": 1180, "height": 900}, {"name": "editor-1440", "html": "Edit tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

", "width": 1440, "height": 900}, {"name": "editor-1180", "html": "Edit tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

", "width": 1180, "height": 900}, {"name": "confirmation-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "confirmation-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "returned-unknown-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "returned-unknown-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "replacement-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "replacement-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "add-1440", "html": "Add tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

", "width": 1440, "height": 900}, {"name": "add-1180", "html": "Add tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

", "width": 1180, "height": 900}, {"name": "registered-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Catalog verified
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "registered-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Catalog verified
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "added-stopped-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700stopped
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "added-stopped-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700stopped
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "register-confirm-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "register-confirm-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "shell-startup-620", "html": "Startup unavailable\n \n

Admin unavailable

http://127.0.0.1:8780

\n

Admin exited with status 7 before /login responded.

\n

Diagnostics: run python3 -m admin serve to inspect startup output.

\n \n \n ", "width": 620, "height": 360}] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/final/load-failure-1180.png b/admin/tests/evidence/webkit-membership/final/load-failure-1180.png new file mode 100644 index 0000000..83dbe11 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/load-failure-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/load-failure-1440.png b/admin/tests/evidence/webkit-membership/final/load-failure-1440.png new file mode 100644 index 0000000..2b4618a Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/load-failure-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/policy-1180.png b/admin/tests/evidence/webkit-membership/final/policy-1180.png new file mode 100644 index 0000000..df5a367 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/policy-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/policy-1440.png b/admin/tests/evidence/webkit-membership/final/policy-1440.png new file mode 100644 index 0000000..5966a56 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/policy-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/process.json b/admin/tests/evidence/webkit-membership/final/process.json new file mode 100644 index 0000000..63a112c --- /dev/null +++ b/admin/tests/evidence/webkit-membership/final/process.json @@ -0,0 +1,10 @@ +{ + "pid": 31422, + "start_epoch": 1792854291.806751, + "start_identity": "31422 31421 Sat Oct 24 11:04:51 2026 /private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-wk-membership/runner /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/webkit-membership/final/fixtures.json /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/webkit-membership/final\n", + "exit_code": 0, + "elapsed_seconds": 9.910784006118774, + "watchdog": false, + "after_identity": "", + "child_limit": "Only direct children observable by parent identity; launchd-managed WebKit XPC services are not claimed owned or signalled." +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/final/raw-dom-manifest.json b/admin/tests/evidence/webkit-membership/final/raw-dom-manifest.json new file mode 100644 index 0000000..efae38a --- /dev/null +++ b/admin/tests/evidence/webkit-membership/final/raw-dom-manifest.json @@ -0,0 +1,114 @@ +{ + "archive": "raw-dom.zip", + "format": "ZIP/DEFLATE", + "archive_sha256": "89eebff209cb64c1e4815746f487dcd3204eab491f8f6d3783d347a308c05e6e", + "verification": "Every member byte-compared and SHA-256 checked against original before plaintext removal", + "secret_scan": "Archive members MUST be included in the parent final secret scan; no exemption", + "members": [ + { + "name": "add-1180.html", + "bytes": 21348, + "sha256": "7cfbd01d1b0ad6914ab0f62c8b6ab0523a7e94116bad2badc5e5c678b9b66340" + }, + { + "name": "add-1440.html", + "bytes": 21348, + "sha256": "7cfbd01d1b0ad6914ab0f62c8b6ab0523a7e94116bad2badc5e5c678b9b66340" + }, + { + "name": "added-stopped-1180.html", + "bytes": 22165, + "sha256": "970958b8b05abe54f0ce3899cc1301cdba9225c2caf7bfc5f932e5c44acfe128" + }, + { + "name": "added-stopped-1440.html", + "bytes": 22165, + "sha256": "970958b8b05abe54f0ce3899cc1301cdba9225c2caf7bfc5f932e5c44acfe128" + }, + { + "name": "confirmation-1180.html", + "bytes": 23523, + "sha256": "e8936850e19432f2e4827623b77ad63e0016e6606a7354daa10754fd530eb0c3" + }, + { + "name": "confirmation-1440.html", + "bytes": 23523, + "sha256": "e8936850e19432f2e4827623b77ad63e0016e6606a7354daa10754fd530eb0c3" + }, + { + "name": "editor-1180.html", + "bytes": 50661, + "sha256": "eb2d8a44bd26d29070050406e835574aa48438757938b33aafdbc5b60262963b" + }, + { + "name": "editor-1440.html", + "bytes": 50661, + "sha256": "eb2d8a44bd26d29070050406e835574aa48438757938b33aafdbc5b60262963b" + }, + { + "name": "load-failure-1180.html", + "bytes": 20224, + "sha256": "9f69a85375e1699271f2f5c5b3b17fa3928aa12036544b5e11c8720eeb8ffd42" + }, + { + "name": "load-failure-1440.html", + "bytes": 20224, + "sha256": "9f69a85375e1699271f2f5c5b3b17fa3928aa12036544b5e11c8720eeb8ffd42" + }, + { + "name": "policy-1180.html", + "bytes": 24211, + "sha256": "095e7f0f827f840b0640c7e2353c92108dabd6ae1719dde21ef085607e05b663" + }, + { + "name": "policy-1440.html", + "bytes": 24211, + "sha256": "095e7f0f827f840b0640c7e2353c92108dabd6ae1719dde21ef085607e05b663" + }, + { + "name": "register-confirm-1180.html", + "bytes": 22475, + "sha256": "7cdd59b376685ad316fc3c11d4146aa9b59a56e47a9db29925cf8abce5ec116c" + }, + { + "name": "register-confirm-1440.html", + "bytes": 22475, + "sha256": "7cdd59b376685ad316fc3c11d4146aa9b59a56e47a9db29925cf8abce5ec116c" + }, + { + "name": "registered-1180.html", + "bytes": 21700, + "sha256": "ff5435c3b0e67fbabbb247523d1384b69b9cba7f6b6b26fa1ae9771a7fa58e45" + }, + { + "name": "registered-1440.html", + "bytes": 21700, + "sha256": "ff5435c3b0e67fbabbb247523d1384b69b9cba7f6b6b26fa1ae9771a7fa58e45" + }, + { + "name": "replacement-1180.html", + "bytes": 19697, + "sha256": "df730a730e2395613b849b72cbdd541bc8793be6b438f1333fdf322928f7c15b" + }, + { + "name": "replacement-1440.html", + "bytes": 19697, + "sha256": "df730a730e2395613b849b72cbdd541bc8793be6b438f1333fdf322928f7c15b" + }, + { + "name": "returned-unknown-1180.html", + "bytes": 43407, + "sha256": "98918b117561585bfba41195a90a16f76a223fd26547c679eadab4b3104a92e1" + }, + { + "name": "returned-unknown-1440.html", + "bytes": 43407, + "sha256": "98918b117561585bfba41195a90a16f76a223fd26547c679eadab4b3104a92e1" + }, + { + "name": "shell-startup-620.html", + "bytes": 3299, + "sha256": "7bfe75b4532f2bd443597daca76c9c08420a53fe16b04a673a180d888d3e7302" + } + ] +} diff --git a/admin/tests/evidence/webkit-membership/final/raw-dom.zip b/admin/tests/evidence/webkit-membership/final/raw-dom.zip new file mode 100644 index 0000000..a7c0c43 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/raw-dom.zip differ diff --git a/admin/tests/evidence/webkit-membership/final/register-confirm-1180.png b/admin/tests/evidence/webkit-membership/final/register-confirm-1180.png new file mode 100644 index 0000000..85407b4 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/register-confirm-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/register-confirm-1440.png b/admin/tests/evidence/webkit-membership/final/register-confirm-1440.png new file mode 100644 index 0000000..ebb4fdb Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/register-confirm-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/registered-1180.png b/admin/tests/evidence/webkit-membership/final/registered-1180.png new file mode 100644 index 0000000..4c119d7 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/registered-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/registered-1440.png b/admin/tests/evidence/webkit-membership/final/registered-1440.png new file mode 100644 index 0000000..ccf7315 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/registered-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/renderer.stderr b/admin/tests/evidence/webkit-membership/final/renderer.stderr new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-membership/final/renderer.stdout b/admin/tests/evidence/webkit-membership/final/renderer.stdout new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-membership/final/replacement-1180.png b/admin/tests/evidence/webkit-membership/final/replacement-1180.png new file mode 100644 index 0000000..952f46c Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/replacement-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/replacement-1440.png b/admin/tests/evidence/webkit-membership/final/replacement-1440.png new file mode 100644 index 0000000..049ec9b Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/replacement-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/returned-unknown-1180.png b/admin/tests/evidence/webkit-membership/final/returned-unknown-1180.png new file mode 100644 index 0000000..0c1efe9 Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/returned-unknown-1180.png differ diff --git a/admin/tests/evidence/webkit-membership/final/returned-unknown-1440.png b/admin/tests/evidence/webkit-membership/final/returned-unknown-1440.png new file mode 100644 index 0000000..9b0775a Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/returned-unknown-1440.png differ diff --git a/admin/tests/evidence/webkit-membership/final/shell-startup-620.png b/admin/tests/evidence/webkit-membership/final/shell-startup-620.png new file mode 100644 index 0000000..e6cf7da Binary files /dev/null and b/admin/tests/evidence/webkit-membership/final/shell-startup-620.png differ diff --git a/admin/tests/evidence/webkit-membership/final/summary.json b/admin/tests/evidence/webkit-membership/final/summary.json new file mode 100644 index 0000000..baffd36 --- /dev/null +++ b/admin/tests/evidence/webkit-membership/final/summary.json @@ -0,0 +1,149 @@ +[ + { + "case": "policy-1440", + "failures": [], + "assertions": 21, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "policy-1180", + "failures": [], + "assertions": 21, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1440", + "failures": [], + "assertions": 7, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1180", + "failures": [], + "assertions": 7, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1440", + "failures": [], + "assertions": 11, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1180", + "failures": [], + "assertions": 11, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1440", + "failures": [], + "assertions": 7, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1180", + "failures": [], + "assertions": 7, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1440", + "failures": [], + "assertions": 8, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1180", + "failures": [], + "assertions": 8, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1440", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1180", + "failures": [], + "assertions": 5, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "shell-startup-620", + "failures": [], + "assertions": 6, + "snapshot": "saved", + "window_visible": false + } +] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/final/webkit-results.json b/admin/tests/evidence/webkit-membership/final/webkit-results.json new file mode 100644 index 0000000..1b4e8df --- /dev/null +++ b/admin/tests/evidence/webkit-membership/final/webkit-results.json @@ -0,0 +1,2948 @@ +{ + "activation_policy": { + "after": 2, + "before": 2, + "request_returned": false, + "required": 2 + }, + "error": null, + "persistent_store": false, + "pid": 31422, + "results": [ + { + "case": "policy-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 110.140625 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 435.328125, + "y": 110.140625 + }, + { + "height": 22, + "label": "AllowReviewDeny", + "tag": "SELECT", + "width": 79, + "x": 819.265625, + "y": 284.78125 + } + ], + "results": [ + { + "name": "rejected save retains editable draft", + "ok": true + }, + { + "name": "rejection visible", + "ok": true + }, + { + "name": "unknown save blocks retry and retains draft", + "ok": true + }, + { + "name": "comparison retains differing draft", + "ok": true + }, + { + "name": "saved effect shown alongside draft", + "ok": true + }, + { + "name": "dirty navigation opens dialog", + "ok": true + }, + { + "name": "safe Stay receives focus", + "ok": true + }, + { + "name": "Stay retains draft", + "ok": true + }, + { + "name": "policy control focusable", + "ok": true + }, + { + "name": "acknowledged save receipt", + "ok": true + }, + { + "name": "500 after effect is unknown; duplicate submit sends one write", + "ok": true + }, + { + "name": "comparison never replays mutation", + "ok": true + }, + { + "name": "failed comparison locks Save", + "ok": true + }, + { + "name": "mismatched comparison scope locks Save", + "ok": true + }, + { + "name": "fresh successful comparison unlocks explicit save", + "ok": true + }, + { + "name": "signout defaults to Stay", + "ok": true + }, + { + "name": "signout Stay retains draft without submitting", + "ok": true + }, + { + "name": "signout Discard submits exactly once without capture loop", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "policy-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 110.140625 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 305.328125, + "y": 110.140625 + }, + { + "height": 22, + "label": "AllowReviewDeny", + "tag": "SELECT", + "width": 79, + "x": 689.265625, + "y": 284.78125 + } + ], + "results": [ + { + "name": "rejected save retains editable draft", + "ok": true + }, + { + "name": "rejection visible", + "ok": true + }, + { + "name": "unknown save blocks retry and retains draft", + "ok": true + }, + { + "name": "comparison retains differing draft", + "ok": true + }, + { + "name": "saved effect shown alongside draft", + "ok": true + }, + { + "name": "dirty navigation opens dialog", + "ok": true + }, + { + "name": "safe Stay receives focus", + "ok": true + }, + { + "name": "Stay retains draft", + "ok": true + }, + { + "name": "policy control focusable", + "ok": true + }, + { + "name": "acknowledged save receipt", + "ok": true + }, + { + "name": "500 after effect is unknown; duplicate submit sends one write", + "ok": true + }, + { + "name": "comparison never replays mutation", + "ok": true + }, + { + "name": "failed comparison locks Save", + "ok": true + }, + { + "name": "mismatched comparison scope locks Save", + "ok": true + }, + { + "name": "fresh successful comparison unlocks explicit save", + "ok": true + }, + { + "name": "signout defaults to Stay", + "ok": true + }, + { + "name": "signout Stay retains draft without submitting", + "ok": true + }, + { + "name": "signout Discard submits exactly once without capture loop", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 435.328125, + "y": 167.296875 + }, + { + "height": 38, + "label": "Retry load", + "tag": "A", + "width": 85.609375, + "x": 421.953125, + "y": 255.296875 + } + ], + "results": [ + { + "name": "failed load locks Save", + "ok": true + }, + { + "name": "failed load offers Retry", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 305.328125, + "y": 167.296875 + }, + { + "height": 38, + "label": "Retry load", + "tag": "A", + "width": 85.609375, + "x": 291.953125, + "y": 255.296875 + } + ], + "results": [ + { + "name": "failed load locks Save", + "ok": true + }, + { + "name": "failed load offers Retry", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1440", + "direct_children": [], + "dom": { + "below": [ + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 1001.921875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 1009.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 1001.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 1001.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 1001.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 1039.921875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 200.5, + "y": 1161.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 200.5, + "y": 1266.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 200.5, + "y": 1304.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 289.84375, + "y": 1304.90625 + } + ], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1327.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 200.5, + "y": 105 + }, + { + "height": 38, + "label": "f_id", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 218.03125 + }, + { + "height": 22, + "label": "apimcprest", + "tag": "SELECT", + "width": 520, + "x": 200.5, + "y": 340.921875 + }, + { + "height": 38, + "label": "f_command", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 524.109375 + }, + { + "height": 38, + "label": "f_image", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 626.703125 + }, + { + "height": 38, + "label": "f_port", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 739.734375 + }, + { + "height": 38, + "label": "Expand all", + "tag": "BUTTON", + "width": 69.828125, + "x": 286.875, + "y": 792.875 + }, + { + "height": 38, + "label": "Collapse all", + "tag": "BUTTON", + "width": 76.90625, + "x": 366.703125, + "y": 792.875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 1001.921875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 418.859375, + "y": 1009.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 534.859375, + "y": 1001.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 740.21875, + "y": 1001.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 850.03125, + "y": 1001.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 1039.921875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 200.5, + "y": 1161.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 200.5, + "y": 1266.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 200.5, + "y": 1304.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 289.84375, + "y": 1304.90625 + } + ], + "results": [ + { + "name": "absent approval_fields remains absent through serialization", + "ok": true + }, + { + "name": "empty approval_fields remains empty through serialization", + "ok": true + }, + { + "name": "explicit approval_fields retained", + "ok": true + }, + { + "name": "duplicate preserves explicit empty approval_fields", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1180", + "direct_children": [], + "dom": { + "below": [ + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 1001.921875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 1009.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 1001.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 1001.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 1001.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 1039.921875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 70.5, + "y": 1161.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 70.5, + "y": 1266.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 70.5, + "y": 1304.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 159.84375, + "y": 1304.90625 + } + ], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1067.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 70.5, + "y": 105 + }, + { + "height": 38, + "label": "f_id", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 218.03125 + }, + { + "height": 22, + "label": "apimcprest", + "tag": "SELECT", + "width": 520, + "x": 70.5, + "y": 340.921875 + }, + { + "height": 38, + "label": "f_command", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 524.109375 + }, + { + "height": 38, + "label": "f_image", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 626.703125 + }, + { + "height": 38, + "label": "f_port", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 739.734375 + }, + { + "height": 38, + "label": "Expand all", + "tag": "BUTTON", + "width": 69.828125, + "x": 156.875, + "y": 792.875 + }, + { + "height": 38, + "label": "Collapse all", + "tag": "BUTTON", + "width": 76.90625, + "x": 236.703125, + "y": 792.875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 1001.921875 + }, + { + "height": 22, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 106, + "x": 288.859375, + "y": 1009.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 404.859375, + "y": 1001.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 610.21875, + "y": 1001.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 720.03125, + "y": 1001.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 1039.921875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 70.5, + "y": 1161.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 70.5, + "y": 1266.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 70.5, + "y": 1304.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 159.84375, + "y": 1304.90625 + } + ], + "results": [ + { + "name": "absent approval_fields remains absent through serialization", + "ok": true + }, + { + "name": "empty approval_fields remains empty through serialization", + "ok": true + }, + { + "name": "explicit approval_fields retained", + "ok": true + }, + { + "name": "duplicate preserves explicit empty approval_fields", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 547.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 602.453125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 664, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 547.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 547.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 547.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 717.375, + "y": 411.890625 + } + ], + "results": [ + { + "name": "confirmation defaults to Cancel", + "ok": true + }, + { + "name": "confirmation names target", + "ok": true + }, + { + "name": "Cancel has no effect", + "ok": true + }, + { + "name": "synthetic Escape-equivalent close has no effect", + "ok": true + }, + { + "name": "accepted confirmation plus duplicate submits sends one POST", + "ok": true + }, + { + "name": "unknown outcome cannot replay POST", + "ok": true + }, + { + "name": "unknown mutation visible", + "ok": true + }, + { + "name": "unknown refresh retains target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 417.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 472.453125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 534, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 417.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 417.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 417.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 587.375, + "y": 411.890625 + } + ], + "results": [ + { + "name": "confirmation defaults to Cancel", + "ok": true + }, + { + "name": "confirmation names target", + "ok": true + }, + { + "name": "Cancel has no effect", + "ok": true + }, + { + "name": "synthetic Escape-equivalent close has no effect", + "ok": true + }, + { + "name": "accepted confirmation plus duplicate submits sends one POST", + "ok": true + }, + { + "name": "unknown outcome cannot replay POST", + "ok": true + }, + { + "name": "unknown mutation visible", + "ok": true + }, + { + "name": "unknown refresh retains target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 547.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 547.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 547.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 547.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 607.6875, + "y": 411.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 684.203125, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 854.015625, + "y": 411.890625 + } + ], + "results": [ + { + "name": "returned unknown stage remains visible", + "ok": true + }, + { + "name": "200 unknown stage cannot immediately retry without observation", + "ok": true + }, + { + "name": "returned unknown stage sends no duplicate mutation", + "ok": true + }, + { + "name": "unknown target cannot bypass gate via another lifecycle form", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 417.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 417.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 417.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 417.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 477.6875, + "y": 411.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 554.203125, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 724.015625, + "y": 411.890625 + } + ], + "results": [ + { + "name": "returned unknown stage remains visible", + "ok": true + }, + { + "name": "200 unknown stage cannot immediately retry without observation", + "ok": true + }, + { + "name": "returned unknown stage sends no duplicate mutation", + "ok": true + }, + { + "name": "unknown target cannot bypass gate via another lifecycle form", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 743.59375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 798.484375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 860.03125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 920.15625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 996.671875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 743.59375, + "y": 237.296875 + } + ], + "results": [ + { + "name": "replacement keeps focused recovery form", + "ok": true + }, + { + "name": "document.write replacement initializes production scripts", + "ok": true + }, + { + "name": "replacement dialog has safe focus", + "ok": true + }, + { + "name": "opening replacement confirmation does not send effect", + "ok": true + }, + { + "name": "replacement preserves navigation target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 613.59375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 668.484375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 730.03125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 790.15625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 866.671875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 613.59375, + "y": 237.296875 + } + ], + "results": [ + { + "name": "replacement keeps focused recovery form", + "ok": true + }, + { + "name": "document.write replacement initializes production scripts", + "ok": true + }, + { + "name": "replacement dialog has safe focus", + "ok": true + }, + { + "name": "opening replacement confirmation does not send effect", + "ok": true + }, + { + "name": "replacement preserves navigation target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "source", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 294.921875 + }, + { + "height": 38, + "label": "Add from folder", + "tag": "BUTTON", + "width": 121, + "x": 209, + "y": 344.921875 + }, + { + "height": 38, + "label": "repo", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 453.515625 + }, + { + "height": 38, + "label": "subdir", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 556.109375 + }, + { + "height": 38, + "label": "ref", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 658.703125 + }, + { + "height": 38, + "label": "Clone and add", + "tag": "BUTTON", + "width": 113.34375, + "x": 209, + "y": 708.703125 + } + ], + "results": [ + { + "name": "local host source remains editable", + "ok": true + }, + { + "name": "source action remains functional", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "source", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 294.921875 + }, + { + "height": 38, + "label": "Add from folder", + "tag": "BUTTON", + "width": 121, + "x": 79, + "y": 344.921875 + }, + { + "height": 38, + "label": "repo", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 453.515625 + }, + { + "height": 38, + "label": "subdir", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 556.109375 + }, + { + "height": 38, + "label": "ref", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 658.703125 + }, + { + "height": 38, + "label": "Clone and add", + "tag": "BUTTON", + "width": 113.34375, + "x": 79, + "y": 708.703125 + } + ], + "results": [ + { + "name": "local host source remains editable", + "ok": true + }, + { + "name": "source action remains functional", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 760.125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 815.015625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 876.5625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 936.6875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1013.203125, + "y": 251.59375 + } + ], + "results": [ + { + "name": "registration receipt names success", + "ok": true + }, + { + "name": "verified catalog does not offer redundant registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 630.125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 685.015625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 746.5625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 806.6875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 883.203125, + "y": 251.59375 + } + ], + "results": [ + { + "name": "registration receipt names success", + "ok": true + }, + { + "name": "verified catalog does not offer redundant registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 680.75, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 735.640625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 797.1875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 857.3125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 933.828125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1103.640625, + "y": 251.59375 + } + ], + "results": [ + { + "name": "added tool can be explicitly started", + "ok": true + }, + { + "name": "copy does not imply catalog registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 550.75, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 605.640625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 667.1875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 727.3125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 803.828125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 973.640625, + "y": 251.59375 + } + ], + "results": [ + { + "name": "added tool can be explicitly started", + "ok": true + }, + { + "name": "copy does not imply catalog registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 680.75, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 735.640625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 797.1875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 857.3125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 933.828125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1103.640625, + "y": 189.296875 + }, + { + "height": 156.59375, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo? Reloads catalog; interrupts broker availability ", + "tag": "DIALOG", + "width": 520, + "x": 460, + "y": 371.703125 + }, + { + "height": 38, + "label": "Cancel", + "tag": "BUTTON", + "width": 64.390625, + "x": 485, + "y": 465.296875 + }, + { + "height": 38, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo", + "tag": "BUTTON", + "width": 345.84375, + "x": 549.390625, + "y": 465.296875 + } + ], + "results": [ + { + "name": "registration confirmation names broker and target", + "ok": true + }, + { + "name": "registration warns of interruption", + "ok": true + }, + { + "name": "registration defaults to Cancel", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 550.75, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 605.640625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 667.1875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 727.3125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 803.828125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 973.640625, + "y": 189.296875 + }, + { + "height": 156.59375, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo? Reloads catalog; interrupts broker availability ", + "tag": "DIALOG", + "width": 520, + "x": 330, + "y": 371.703125 + }, + { + "height": 38, + "label": "Cancel", + "tag": "BUTTON", + "width": 64.390625, + "x": 355, + "y": 465.296875 + }, + { + "height": 38, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo", + "tag": "BUTTON", + "width": 345.84375, + "x": 419.390625, + "y": 465.296875 + } + ], + "results": [ + { + "name": "registration confirmation names broker and target", + "ok": true + }, + { + "name": "registration warns of interruption", + "ok": true + }, + { + "name": "registration defaults to Cancel", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "shell-startup-620", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Close", + "tag": "BUTTON", + "width": 67.53125, + "x": 28, + "y": 191.734375 + }, + { + "height": 38, + "label": "Retrying\u2026", + "tag": "BUTTON", + "width": 90.796875, + "x": 99.3125, + "y": 191.734375 + } + ], + "results": [ + { + "name": "shell observed cause", + "ok": true + }, + { + "name": "shell Retry available", + "ok": true + }, + { + "name": "shell Retry button latches synthetic duplicate clicks", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 360, + "width": 620 + } + }, + "snapshot": "saved", + "window_visible": false + } + ], + "started": "2026-10-24T15:04:51Z" +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-membership/focused-backend.log b/admin/tests/evidence/webkit-membership/focused-backend.log new file mode 100644 index 0000000..759542b --- /dev/null +++ b/admin/tests/evidence/webkit-membership/focused-backend.log @@ -0,0 +1,26 @@ +/Users/batteryshark/.cache/uv/archive-v0/glcQ6zq55NJq8otq/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa +test_actual_load_edit_reload_and_failed_reload (admin.tests.test_catalog_observation.CatalogObservation.test_actual_load_edit_reload_and_failed_reload) ... {"at": 1792854280.347747, "component": "registry", "correlation_id": "d78498e0d54941c8949d834dda06be3d", "details": {"ops_after": 1, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_cached_listing_does_not_prove_membership_after_reload (admin.tests.test_catalog_observation.CatalogObservation.test_cached_listing_does_not_prove_membership_after_reload) ... {"at": 1792854280.867477, "component": "registry", "correlation_id": "da8e05c43eb643ce9e944f8261ee5551", "details": {"ops_after": 0, "ops_before": 1, "tools_after": 0, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +{"at": 1792854280.870343, "component": "registry", "correlation_id": "86b4963f25b34df1b0f84555e1dd5ce9", "details": {"ops_after": 1, "ops_before": 0, "tools_after": 1, "tools_before": 0}, "event_id": 2, "event_type": "reloaded", "outcome": "ok", "request_id": null} +{"at": 1792854280.8727171, "component": "registry", "correlation_id": "face70b2dce9461593bf7e348a694d29", "details": {"ops_after": 0, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 3, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_cached_row_rejects_legacy_and_malformed_membership (admin.tests.test_catalog_observation.CatalogObservation.test_cached_row_rejects_legacy_and_malformed_membership) ... ok +test_health_only_exposes_random_identity (admin.tests.test_catalog_observation.CatalogObservation.test_health_only_exposes_random_identity) ... ok +test_missing_mismatched_receipt_and_sources (admin.tests.test_catalog_observation.CatalogObservation.test_missing_mismatched_receipt_and_sources) ... ok +test_old_live_broker_and_concurrent_admin_reads (admin.tests.test_catalog_observation.CatalogObservation.test_old_live_broker_and_concurrent_admin_reads) ... ok +test_previous_process_receipt_cannot_certify_new_load (admin.tests.test_catalog_observation.CatalogObservation.test_previous_process_receipt_cannot_certify_new_load) ... {"at": 1792854283.954138, "component": "registry", "correlation_id": "366aa936824942cb9c008ccd60343612", "details": {"ops_after": 1, "ops_before": 1, "tools_after": 1, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_secret_block_content_does_not_enter_private_fingerprint (admin.tests.test_catalog_observation.CatalogObservation.test_secret_block_content_does_not_enter_private_fingerprint) ... ok +test_catalog_api_membership_is_same_verified_snapshot_after_delete (admin.tests.test_api.JsonApi.test_catalog_api_membership_is_same_verified_snapshot_after_delete) ... {"at": 1792854285.063136, "component": "registry", "correlation_id": "9d33ace8544d44ecb9514accf4579b26", "details": {"ops_after": 0, "ops_before": 3, "tools_after": 0, "tools_before": 1}, "event_id": 1, "event_type": "reloaded", "outcome": "ok", "request_id": null} +ok +test_catalog_requires_admin_and_legacy_observation_is_unknown (admin.tests.test_api.JsonApi.test_catalog_requires_admin_and_legacy_observation_is_unknown) ... ok +test_registration_result_requires_membership_not_cached_listing (admin.tests.test_app.AdminApp.test_registration_result_requires_membership_not_cached_listing) ... ok +test_failed_recovery_retains_named_target_without_replay (admin.tests.test_app.AdminApp.test_failed_recovery_retains_named_target_without_replay) ... ok +test_add_start_register_are_separate_named_decisions (admin.tests.test_app.AdminApp.test_add_start_register_are_separate_named_decisions) ... ok + +---------------------------------------------------------------------- +Ran 13 tests in 5.474s + +OK diff --git a/admin/tests/evidence/webkit-membership/norman-source.json b/admin/tests/evidence/webkit-membership/norman-source.json new file mode 100644 index 0000000..3246983 --- /dev/null +++ b/admin/tests/evidence/webkit-membership/norman-source.json @@ -0,0 +1,284 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [ + { + "file": "tests/evidence/clear-desk/editor-1180.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/clear-desk/editor-1440.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/clear-desk/policy-1180.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/clear-desk/policy-1440.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/add-1180.html", + "inputs": 9, + "required": 2, + "labels": 4 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/add-1440.html", + "inputs": 9, + "required": 2, + "labels": 4 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/added-stopped-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/added-stopped-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/confirmation-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/confirmation-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/editor-1180.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/editor-1440.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/load-failure-1180.html", + "inputs": 2, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/load-failure-1440.html", + "inputs": 2, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/policy-1180.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/policy-1440.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/register-confirm-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/register-confirm-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/registered-1180.html", + "inputs": 5, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/registered-1440.html", + "inputs": 5, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/replacement-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/replacement-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/returned-unknown-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/attempt-1/returned-unknown-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/add-1180.html", + "inputs": 9, + "required": 2, + "labels": 4 + }, + { + "file": "tests/evidence/webkit-membership/final/add-1440.html", + "inputs": 9, + "required": 2, + "labels": 4 + }, + { + "file": "tests/evidence/webkit-membership/final/added-stopped-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/added-stopped-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/confirmation-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/confirmation-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/editor-1180.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/webkit-membership/final/editor-1440.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/webkit-membership/final/load-failure-1180.html", + "inputs": 2, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/load-failure-1440.html", + "inputs": 2, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/policy-1180.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/policy-1440.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/register-confirm-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/register-confirm-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/registered-1180.html", + "inputs": 5, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/registered-1440.html", + "inputs": 5, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/replacement-1180.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/replacement-1440.html", + "inputs": 7, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/returned-unknown-1180.html", + "inputs": 14, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/webkit-membership/final/returned-unknown-1440.html", + "inputs": 14, + "required": 0, + "labels": 0 + } + ] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 1, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [] + } +} diff --git a/admin/tests/evidence/webkit-sizing/build-and-bounds.json b/admin/tests/evidence/webkit-sizing/build-and-bounds.json new file mode 100644 index 0000000..f499985 --- /dev/null +++ b/admin/tests/evidence/webkit-sizing/build-and-bounds.json @@ -0,0 +1,88 @@ +{ + "source_sha256": "3c6e2dc9dcf3eea962d0201db9d250fc3112d1aca08479dc830a186f4bc27c3c", + "binary_sha256": "33dd44d9a5eda9970b1595d6494cce66f8c6c3ac00d4e2ae9c529b575736c23f", + "compile_jobs": 2, + "assertions": 356, + "bounds": { + "policy-1440": { + "selects": [ + { + "height": 38, + "label": "AllowReviewDeny", + "tag": "SELECT", + "width": 87, + "x": 807.5625, + "y": 284.78125 + } + ], + "button_heights": [ + 38 + ], + "below_viewport_count": 0 + }, + "policy-1180": { + "selects": [ + { + "height": 38, + "label": "AllowReviewDeny", + "tag": "SELECT", + "width": 87, + "x": 677.5625, + "y": 284.78125 + } + ], + "button_heights": [ + 38 + ], + "below_viewport_count": 0 + }, + "editor-1440": { + "selects": [ + { + "height": 38, + "label": "apimcprest", + "tag": "SELECT", + "width": 520, + "x": 200.5, + "y": 340.921875 + }, + { + "height": 38, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 114, + "x": 418.859375, + "y": 1017.921875 + } + ], + "button_heights": [ + 38 + ], + "below_viewport_count": 10 + }, + "editor-1180": { + "selects": [ + { + "height": 38, + "label": "apimcprest", + "tag": "SELECT", + "width": 520, + "x": 70.5, + "y": 340.921875 + }, + { + "height": 38, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 114, + "x": 288.859375, + "y": 1017.921875 + } + ], + "button_heights": [ + 38 + ], + "below_viewport_count": 10 + } + } +} diff --git a/admin/tests/evidence/webkit-sizing/compile.log b/admin/tests/evidence/webkit-sizing/compile.log new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-sizing/final/add-1180.png b/admin/tests/evidence/webkit-sizing/final/add-1180.png new file mode 100644 index 0000000..92aec75 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/add-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/add-1440.png b/admin/tests/evidence/webkit-sizing/final/add-1440.png new file mode 100644 index 0000000..2885c9b Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/add-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/added-stopped-1180.png b/admin/tests/evidence/webkit-sizing/final/added-stopped-1180.png new file mode 100644 index 0000000..058e7de Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/added-stopped-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/added-stopped-1440.png b/admin/tests/evidence/webkit-sizing/final/added-stopped-1440.png new file mode 100644 index 0000000..8ff759b Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/added-stopped-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/child-observations.json b/admin/tests/evidence/webkit-sizing/final/child-observations.json new file mode 100644 index 0000000..a2a3002 --- /dev/null +++ b/admin/tests/evidence/webkit-sizing/final/child-observations.json @@ -0,0 +1,4 @@ +{ + "recorded": [], + "after": "No direct child PIDs were observed; no assertion about launchd-managed XPC helpers." +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-sizing/final/confirmation-1180.png b/admin/tests/evidence/webkit-sizing/final/confirmation-1180.png new file mode 100644 index 0000000..118dcf0 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/confirmation-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/confirmation-1440.png b/admin/tests/evidence/webkit-sizing/final/confirmation-1440.png new file mode 100644 index 0000000..0456a00 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/confirmation-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/editor-1180.png b/admin/tests/evidence/webkit-sizing/final/editor-1180.png new file mode 100644 index 0000000..b3858a0 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/editor-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/editor-1440.png b/admin/tests/evidence/webkit-sizing/final/editor-1440.png new file mode 100644 index 0000000..07bc95a Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/editor-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/fixtures.json b/admin/tests/evidence/webkit-sizing/final/fixtures.json new file mode 100644 index 0000000..897380c --- /dev/null +++ b/admin/tests/evidence/webkit-sizing/final/fixtures.json @@ -0,0 +1 @@ +[{"name": "policy-1440", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
BackPolicy for writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread
", "width": 1440, "height": 900}, {"name": "policy-1180", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
BackPolicy for writer-demo

notes-demo

OperationRiskDescriptionEffect
read_noteread
", "width": 1180, "height": 900}, {"name": "load-failure-1440", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Policy could not be loaded. Retry load.
BackPolicy for writer-demo
Policy unavailable \u00b7 writer-demo. Retry load
", "width": 1440, "height": 900}, {"name": "load-failure-1180", "html": "Policy \u00b7 writer-demo \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Policy could not be loaded. Retry load.
BackPolicy for writer-demo
Policy unavailable \u00b7 writer-demo. Retry load
", "width": 1180, "height": 900}, {"name": "editor-1440", "html": "Edit tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

", "width": 1440, "height": 900}, {"name": "editor-1180", "html": "Edit tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Edit tool: notes-demo

Directory: /fake/notes

Leave both blank for a docker tool that builds the Dockerfile in its own directory.

Operations

Secrets (declarations only; values stay in the secret backend)

Name (file the tool reads)Field (backend key)Path (Infisical)Writable
Cancel

Saving writes toolyard.toml to the directory. Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

", "width": 1180, "height": 900}, {"name": "confirmation-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "confirmation-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "returned-unknown-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "returned-unknown-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "replacement-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "replacement-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Failed \u00b7 process absent (running record)
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "add-1440", "html": "Add tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

", "width": 1440, "height": 900}, {"name": "add-1180", "html": "Add tool \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
Back

Add a tool from a source

Copy a tool that already has a toolyard.toml into the tools root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update (re-pull) it later. To hand-author a manifest instead, use Author a tool.

From a local folder

From a Git repository

Cloned code is copied in, never executed by the panel. Restart the broker to register the new tool.

", "width": 1180, "height": 900}, {"name": "registered-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Catalog verified
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "registered-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Catalog verified
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "added-stopped-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700stopped
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "added-stopped-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700stopped
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "register-confirm-1440", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1440, "height": 900}, {"name": "register-confirm-1180", "html": "Tools \u00b7 Toolstack Admin

Toolstack Admin

fake-admin
ToolPortStateDirectoryActions
notes-demo4700Alive
Registration unknown
/fake/notesEdit

Starting a tool resolves its secrets and runs it. A newly added or edited tool is only registered after the broker is restarted (its registry is read at startup).

", "width": 1180, "height": 900}, {"name": "shell-startup-620", "html": "Startup unavailable\n \n

Admin unavailable

http://127.0.0.1:8780

\n

Admin exited with status 7 before /login responded.

\n

Diagnostics: run python3 -m admin serve to inspect startup output.

\n \n \n ", "width": 620, "height": 360}] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-sizing/final/load-failure-1180.png b/admin/tests/evidence/webkit-sizing/final/load-failure-1180.png new file mode 100644 index 0000000..83dbe11 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/load-failure-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/load-failure-1440.png b/admin/tests/evidence/webkit-sizing/final/load-failure-1440.png new file mode 100644 index 0000000..2b4618a Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/load-failure-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/policy-1180.png b/admin/tests/evidence/webkit-sizing/final/policy-1180.png new file mode 100644 index 0000000..3bd4d3b Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/policy-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/policy-1440.png b/admin/tests/evidence/webkit-sizing/final/policy-1440.png new file mode 100644 index 0000000..d596749 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/policy-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/process.json b/admin/tests/evidence/webkit-sizing/final/process.json new file mode 100644 index 0000000..e00aab8 --- /dev/null +++ b/admin/tests/evidence/webkit-sizing/final/process.json @@ -0,0 +1,10 @@ +{ + "pid": 36756, + "start_epoch": 1792855021.043875, + "start_identity": "36756 36647 Sat Oct 24 11:17:01 2026 /private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/clear-desk-wk-sizing/runner /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/webkit-sizing/final/fixtures.json /Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-web/admin/tests/evidence/webkit-sizing/final\n", + "exit_code": 0, + "elapsed_seconds": 8.805680990219116, + "watchdog": false, + "after_identity": "", + "child_limit": "Only direct children observable by parent identity; launchd-managed WebKit XPC services are not claimed owned or signalled." +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-sizing/final/raw-dom-manifest.json b/admin/tests/evidence/webkit-sizing/final/raw-dom-manifest.json new file mode 100644 index 0000000..3ba8965 --- /dev/null +++ b/admin/tests/evidence/webkit-sizing/final/raw-dom-manifest.json @@ -0,0 +1,114 @@ +{ + "archive": "raw-dom.zip", + "format": "ZIP/DEFLATE", + "archive_sha256": "0ea70bb61ab7119a307fe414ebaf57cdf8237726ef8504932c106b148e39490c", + "verification": "Every member byte-compared and SHA-256 checked against original before plaintext removal", + "secret_scan": "Archive members MUST be included in the parent final secret scan; no exemption", + "members": [ + { + "name": "add-1180.html", + "bytes": 22317, + "sha256": "b74dfd0a84628bf7e721ca780b0e721a641f67952bb9399559cee8d69a915e21" + }, + { + "name": "add-1440.html", + "bytes": 22317, + "sha256": "b74dfd0a84628bf7e721ca780b0e721a641f67952bb9399559cee8d69a915e21" + }, + { + "name": "added-stopped-1180.html", + "bytes": 23134, + "sha256": "4ca2ee1d70b5da07b1c82f191b44e39ec7eb11ac1d32a9b80d2e221f747019c4" + }, + { + "name": "added-stopped-1440.html", + "bytes": 23134, + "sha256": "4ca2ee1d70b5da07b1c82f191b44e39ec7eb11ac1d32a9b80d2e221f747019c4" + }, + { + "name": "confirmation-1180.html", + "bytes": 24492, + "sha256": "34fae1d48c926d3aba47e4392e98effedbe596897cefbcb0158f2d88275e32dd" + }, + { + "name": "confirmation-1440.html", + "bytes": 24492, + "sha256": "34fae1d48c926d3aba47e4392e98effedbe596897cefbcb0158f2d88275e32dd" + }, + { + "name": "editor-1180.html", + "bytes": 51630, + "sha256": "5e626e7b20330002c3420fdaa456fa7669485cb88b8b91fa92f21623965c2c3f" + }, + { + "name": "editor-1440.html", + "bytes": 51630, + "sha256": "5e626e7b20330002c3420fdaa456fa7669485cb88b8b91fa92f21623965c2c3f" + }, + { + "name": "load-failure-1180.html", + "bytes": 21193, + "sha256": "d2d785283be91187bb945995752e10e1f8c23cfaedb6f6db98a1c44715090fdc" + }, + { + "name": "load-failure-1440.html", + "bytes": 21193, + "sha256": "d2d785283be91187bb945995752e10e1f8c23cfaedb6f6db98a1c44715090fdc" + }, + { + "name": "policy-1180.html", + "bytes": 25241, + "sha256": "3ed5a9802817e8fad6db3cdb50f7e5a094ace7aeab4e478b1353cac9ea2954d5" + }, + { + "name": "policy-1440.html", + "bytes": 25241, + "sha256": "3ed5a9802817e8fad6db3cdb50f7e5a094ace7aeab4e478b1353cac9ea2954d5" + }, + { + "name": "register-confirm-1180.html", + "bytes": 23444, + "sha256": "2a0117e2089cea4feef36fb1cf674a3666c888f0c747269f18465e34688695d1" + }, + { + "name": "register-confirm-1440.html", + "bytes": 23444, + "sha256": "2a0117e2089cea4feef36fb1cf674a3666c888f0c747269f18465e34688695d1" + }, + { + "name": "registered-1180.html", + "bytes": 22669, + "sha256": "1c2afa561e8ba9f53da02fea856b8187b2f40ade507b1d818c99c2589d41c3f1" + }, + { + "name": "registered-1440.html", + "bytes": 22669, + "sha256": "1c2afa561e8ba9f53da02fea856b8187b2f40ade507b1d818c99c2589d41c3f1" + }, + { + "name": "replacement-1180.html", + "bytes": 20075, + "sha256": "954d0339d540ab6fbcb5903a78567a71e7f8c221b5f742c74eba6d37cc552f19" + }, + { + "name": "replacement-1440.html", + "bytes": 20075, + "sha256": "954d0339d540ab6fbcb5903a78567a71e7f8c221b5f742c74eba6d37cc552f19" + }, + { + "name": "returned-unknown-1180.html", + "bytes": 44755, + "sha256": "62950f4c739dbd8a4e08072c0b2859324bcf3dfcbe4c248e6b18873036fb5977" + }, + { + "name": "returned-unknown-1440.html", + "bytes": 44755, + "sha256": "62950f4c739dbd8a4e08072c0b2859324bcf3dfcbe4c248e6b18873036fb5977" + }, + { + "name": "shell-startup-620.html", + "bytes": 3890, + "sha256": "faa6521402cbb53dd2264e943493d19ef822ba17bf6e9b15878d2479456dd5fe" + } + ] +} diff --git a/admin/tests/evidence/webkit-sizing/final/raw-dom.zip b/admin/tests/evidence/webkit-sizing/final/raw-dom.zip new file mode 100644 index 0000000..e9499ab Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/raw-dom.zip differ diff --git a/admin/tests/evidence/webkit-sizing/final/register-confirm-1180.png b/admin/tests/evidence/webkit-sizing/final/register-confirm-1180.png new file mode 100644 index 0000000..85407b4 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/register-confirm-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/register-confirm-1440.png b/admin/tests/evidence/webkit-sizing/final/register-confirm-1440.png new file mode 100644 index 0000000..ebb4fdb Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/register-confirm-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/registered-1180.png b/admin/tests/evidence/webkit-sizing/final/registered-1180.png new file mode 100644 index 0000000..4c119d7 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/registered-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/registered-1440.png b/admin/tests/evidence/webkit-sizing/final/registered-1440.png new file mode 100644 index 0000000..ccf7315 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/registered-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/renderer.stderr b/admin/tests/evidence/webkit-sizing/final/renderer.stderr new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-sizing/final/renderer.stdout b/admin/tests/evidence/webkit-sizing/final/renderer.stdout new file mode 100644 index 0000000..e69de29 diff --git a/admin/tests/evidence/webkit-sizing/final/replacement-1180.png b/admin/tests/evidence/webkit-sizing/final/replacement-1180.png new file mode 100644 index 0000000..952f46c Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/replacement-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/replacement-1440.png b/admin/tests/evidence/webkit-sizing/final/replacement-1440.png new file mode 100644 index 0000000..049ec9b Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/replacement-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/returned-unknown-1180.png b/admin/tests/evidence/webkit-sizing/final/returned-unknown-1180.png new file mode 100644 index 0000000..0c1efe9 Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/returned-unknown-1180.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/returned-unknown-1440.png b/admin/tests/evidence/webkit-sizing/final/returned-unknown-1440.png new file mode 100644 index 0000000..9b0775a Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/returned-unknown-1440.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/shell-startup-620.png b/admin/tests/evidence/webkit-sizing/final/shell-startup-620.png new file mode 100644 index 0000000..e6cf7da Binary files /dev/null and b/admin/tests/evidence/webkit-sizing/final/shell-startup-620.png differ diff --git a/admin/tests/evidence/webkit-sizing/final/summary.json b/admin/tests/evidence/webkit-sizing/final/summary.json new file mode 100644 index 0000000..06d7649 --- /dev/null +++ b/admin/tests/evidence/webkit-sizing/final/summary.json @@ -0,0 +1,149 @@ +[ + { + "case": "policy-1440", + "failures": [], + "assertions": 26, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "policy-1180", + "failures": [], + "assertions": 26, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1440", + "failures": [], + "assertions": 9, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1180", + "failures": [], + "assertions": 9, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1440", + "failures": [], + "assertions": 20, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1180", + "failures": [], + "assertions": 20, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1440", + "failures": [], + "assertions": 23, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1180", + "failures": [], + "assertions": 23, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1440", + "failures": [], + "assertions": 19, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1180", + "failures": [], + "assertions": 19, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1440", + "failures": [], + "assertions": 19, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1180", + "failures": [], + "assertions": 19, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1440", + "failures": [], + "assertions": 9, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1180", + "failures": [], + "assertions": 9, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1440", + "failures": [], + "assertions": 15, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1180", + "failures": [], + "assertions": 15, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1440", + "failures": [], + "assertions": 16, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1180", + "failures": [], + "assertions": 16, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1440", + "failures": [], + "assertions": 18, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1180", + "failures": [], + "assertions": 18, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "shell-startup-620", + "failures": [], + "assertions": 8, + "snapshot": "saved", + "window_visible": false + } +] \ No newline at end of file diff --git a/admin/tests/evidence/webkit-sizing/final/webkit-results.json b/admin/tests/evidence/webkit-sizing/final/webkit-results.json new file mode 100644 index 0000000..c40a70d --- /dev/null +++ b/admin/tests/evidence/webkit-sizing/final/webkit-results.json @@ -0,0 +1,3716 @@ +{ + "activation_policy": { + "after": 2, + "before": 2, + "request_returned": false, + "required": 2 + }, + "error": null, + "persistent_store": false, + "pid": 36756, + "results": [ + { + "case": "policy-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 110.140625 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 435.328125, + "y": 110.140625 + }, + { + "height": 38, + "label": "AllowReviewDeny", + "tag": "SELECT", + "width": 87, + "x": 807.5625, + "y": 284.78125 + } + ], + "results": [ + { + "name": "rejected save retains editable draft", + "ok": true + }, + { + "name": "rejection visible", + "ok": true + }, + { + "name": "unknown save blocks retry and retains draft", + "ok": true + }, + { + "name": "comparison retains differing draft", + "ok": true + }, + { + "name": "saved effect shown alongside draft", + "ok": true + }, + { + "name": "dirty navigation opens dialog", + "ok": true + }, + { + "name": "safe Stay receives focus", + "ok": true + }, + { + "name": "Stay retains draft", + "ok": true + }, + { + "name": "policy control focusable", + "ok": true + }, + { + "name": "acknowledged save receipt", + "ok": true + }, + { + "name": "500 after effect is unknown; duplicate submit sends one write", + "ok": true + }, + { + "name": "comparison never replays mutation", + "ok": true + }, + { + "name": "failed comparison locks Save", + "ok": true + }, + { + "name": "mismatched comparison scope locks Save", + "ok": true + }, + { + "name": "fresh successful comparison unlocks explicit save", + "ok": true + }, + { + "name": "signout defaults to Stay", + "ok": true + }, + { + "name": "signout Stay retains draft without submitting", + "ok": true + }, + { + "name": "signout Discard submits exactly once without capture loop", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "select target at least 38px: op__notes-demo__read_note", + "ok": true + }, + { + "name": "policy effect names caller/tool/operation", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Save policy", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "policy-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 110.140625 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 305.328125, + "y": 110.140625 + }, + { + "height": 38, + "label": "AllowReviewDeny", + "tag": "SELECT", + "width": 87, + "x": 677.5625, + "y": 284.78125 + } + ], + "results": [ + { + "name": "rejected save retains editable draft", + "ok": true + }, + { + "name": "rejection visible", + "ok": true + }, + { + "name": "unknown save blocks retry and retains draft", + "ok": true + }, + { + "name": "comparison retains differing draft", + "ok": true + }, + { + "name": "saved effect shown alongside draft", + "ok": true + }, + { + "name": "dirty navigation opens dialog", + "ok": true + }, + { + "name": "safe Stay receives focus", + "ok": true + }, + { + "name": "Stay retains draft", + "ok": true + }, + { + "name": "policy control focusable", + "ok": true + }, + { + "name": "acknowledged save receipt", + "ok": true + }, + { + "name": "500 after effect is unknown; duplicate submit sends one write", + "ok": true + }, + { + "name": "comparison never replays mutation", + "ok": true + }, + { + "name": "failed comparison locks Save", + "ok": true + }, + { + "name": "mismatched comparison scope locks Save", + "ok": true + }, + { + "name": "fresh successful comparison unlocks explicit save", + "ok": true + }, + { + "name": "signout defaults to Stay", + "ok": true + }, + { + "name": "signout Stay retains draft without submitting", + "ok": true + }, + { + "name": "signout Discard submits exactly once without capture loop", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "select target at least 38px: op__notes-demo__read_note", + "ok": true + }, + { + "name": "policy effect names caller/tool/operation", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Save policy", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 435.328125, + "y": 167.296875 + }, + { + "height": 38, + "label": "Retry load", + "tag": "A", + "width": 85.609375, + "x": 421.953125, + "y": 255.296875 + } + ], + "results": [ + { + "name": "failed load locks Save", + "ok": true + }, + { + "name": "failed load offers Retry", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Save policy", + "ok": true + }, + { + "name": "button target at least 38px: Retry load", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "load-failure-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Save policy", + "tag": "BUTTON", + "width": 93.234375, + "x": 305.328125, + "y": 167.296875 + }, + { + "height": 38, + "label": "Retry load", + "tag": "A", + "width": 85.609375, + "x": 291.953125, + "y": 255.296875 + } + ], + "results": [ + { + "name": "failed load locks Save", + "ok": true + }, + { + "name": "failed load offers Retry", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Save policy", + "ok": true + }, + { + "name": "button target at least 38px: Retry load", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1440", + "direct_children": [], + "dom": { + "below": [ + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 1017.921875 + }, + { + "height": 38, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 114, + "x": 418.859375, + "y": 1017.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 542.859375, + "y": 1017.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 748.21875, + "y": 1017.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 858.03125, + "y": 1017.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 1055.921875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 200.5, + "y": 1177.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 200.5, + "y": 1282.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 200.5, + "y": 1320.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 289.84375, + "y": 1320.90625 + } + ], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1327.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 200.5, + "y": 105 + }, + { + "height": 38, + "label": "f_id", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 218.03125 + }, + { + "height": 38, + "label": "apimcprest", + "tag": "SELECT", + "width": 520, + "x": 200.5, + "y": 340.921875 + }, + { + "height": 38, + "label": "f_command", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 540.109375 + }, + { + "height": 38, + "label": "f_image", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 642.703125 + }, + { + "height": 38, + "label": "f_port", + "tag": "INPUT", + "width": 520, + "x": 200.5, + "y": 755.734375 + }, + { + "height": 38, + "label": "Expand all", + "tag": "BUTTON", + "width": 69.828125, + "x": 286.875, + "y": 808.875 + }, + { + "height": 38, + "label": "Collapse all", + "tag": "BUTTON", + "width": 76.90625, + "x": 366.703125, + "y": 808.875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 213.5, + "y": 1017.921875 + }, + { + "height": 38, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 114, + "x": 418.859375, + "y": 1017.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 542.859375, + "y": 1017.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 748.21875, + "y": 1017.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 858.03125, + "y": 1017.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 213.5, + "y": 1055.921875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 200.5, + "y": 1177.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 200.5, + "y": 1282.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 200.5, + "y": 1320.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 289.84375, + "y": 1320.90625 + } + ], + "results": [ + { + "name": "absent approval_fields remains absent through serialization", + "ok": true + }, + { + "name": "empty approval_fields remains empty through serialization", + "ok": true + }, + { + "name": "explicit approval_fields retained", + "ok": true + }, + { + "name": "duplicate preserves explicit empty approval_fields", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "select target at least 38px: ", + "ok": true + }, + { + "name": "select target at least 38px: op-risk", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Expand all", + "ok": true + }, + { + "name": "button target at least 38px: Collapse all", + "ok": true + }, + { + "name": "button target at least 38px: duplicate op", + "ok": true + }, + { + "name": "button target at least 38px: remove op", + "ok": true + }, + { + "name": "button target at least 38px: add argument", + "ok": true + }, + { + "name": "button target at least 38px: Add operation", + "ok": true + }, + { + "name": "button target at least 38px: Add secret", + "ok": true + }, + { + "name": "button target at least 38px: Save tool", + "ok": true + }, + { + "name": "button target at least 38px: Cancel", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "editor-1180", + "direct_children": [], + "dom": { + "below": [ + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 1017.921875 + }, + { + "height": 38, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 114, + "x": 288.859375, + "y": 1017.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 412.859375, + "y": 1017.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 618.21875, + "y": 1017.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 728.03125, + "y": 1017.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 1055.921875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 70.5, + "y": 1177.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 70.5, + "y": 1282.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 70.5, + "y": 1320.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 159.84375, + "y": 1320.90625 + } + ], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1067.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 70.5, + "y": 105 + }, + { + "height": 38, + "label": "f_id", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 218.03125 + }, + { + "height": 38, + "label": "apimcprest", + "tag": "SELECT", + "width": 520, + "x": 70.5, + "y": 340.921875 + }, + { + "height": 38, + "label": "f_command", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 540.109375 + }, + { + "height": 38, + "label": "f_image", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 642.703125 + }, + { + "height": 38, + "label": "f_port", + "tag": "INPUT", + "width": 520, + "x": 70.5, + "y": 755.734375 + }, + { + "height": 38, + "label": "Expand all", + "tag": "BUTTON", + "width": 69.828125, + "x": 156.875, + "y": 808.875 + }, + { + "height": 38, + "label": "Collapse all", + "tag": "BUTTON", + "width": 76.90625, + "x": 236.703125, + "y": 808.875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 83.5, + "y": 1017.921875 + }, + { + "height": 38, + "label": "readwritedestructive", + "tag": "SELECT", + "width": 114, + "x": 288.859375, + "y": 1017.921875 + }, + { + "height": 38, + "label": "", + "tag": "INPUT", + "width": 195.359375, + "x": 412.859375, + "y": 1017.921875 + }, + { + "height": 38, + "label": "duplicate op", + "tag": "BUTTON", + "width": 99.8125, + "x": 618.21875, + "y": 1017.921875 + }, + { + "height": 38, + "label": "remove op", + "tag": "BUTTON", + "width": 88.03125, + "x": 728.03125, + "y": 1017.921875 + }, + { + "height": 38, + "label": "add argument", + "tag": "BUTTON", + "width": 110.046875, + "x": 83.5, + "y": 1055.921875 + }, + { + "height": 38, + "label": "Add operation", + "tag": "BUTTON", + "width": 111.0625, + "x": 70.5, + "y": 1177.21875 + }, + { + "height": 38, + "label": "Add secret", + "tag": "BUTTON", + "width": 90.03125, + "x": 70.5, + "y": 1282.90625 + }, + { + "height": 38, + "label": "Save tool", + "tag": "BUTTON", + "width": 79.34375, + "x": 70.5, + "y": 1320.90625 + }, + { + "height": 38, + "label": "Cancel", + "tag": "A", + "width": 64.390625, + "x": 159.84375, + "y": 1320.90625 + } + ], + "results": [ + { + "name": "absent approval_fields remains absent through serialization", + "ok": true + }, + { + "name": "empty approval_fields remains empty through serialization", + "ok": true + }, + { + "name": "explicit approval_fields retained", + "ok": true + }, + { + "name": "duplicate preserves explicit empty approval_fields", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "select target at least 38px: ", + "ok": true + }, + { + "name": "select target at least 38px: op-risk", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Expand all", + "ok": true + }, + { + "name": "button target at least 38px: Collapse all", + "ok": true + }, + { + "name": "button target at least 38px: duplicate op", + "ok": true + }, + { + "name": "button target at least 38px: remove op", + "ok": true + }, + { + "name": "button target at least 38px: add argument", + "ok": true + }, + { + "name": "button target at least 38px: Add operation", + "ok": true + }, + { + "name": "button target at least 38px: Add secret", + "ok": true + }, + { + "name": "button target at least 38px: Save tool", + "ok": true + }, + { + "name": "button target at least 38px: Cancel", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 547.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 602.453125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 664, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 547.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 547.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 547.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 717.375, + "y": 411.890625 + } + ], + "results": [ + { + "name": "confirmation defaults to Cancel", + "ok": true + }, + { + "name": "confirmation names target", + "ok": true + }, + { + "name": "Cancel has no effect", + "ok": true + }, + { + "name": "synthetic Escape-equivalent close has no effect", + "ok": true + }, + { + "name": "accepted confirmation plus duplicate submits sends one POST", + "ok": true + }, + { + "name": "unknown outcome cannot replay POST", + "ok": true + }, + { + "name": "unknown mutation visible", + "ok": true + }, + { + "name": "unknown refresh retains target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "confirmation-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 417.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 472.453125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 534, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 417.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 417.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 417.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 587.375, + "y": 411.890625 + } + ], + "results": [ + { + "name": "confirmation defaults to Cancel", + "ok": true + }, + { + "name": "confirmation names target", + "ok": true + }, + { + "name": "Cancel has no effect", + "ok": true + }, + { + "name": "synthetic Escape-equivalent close has no effect", + "ok": true + }, + { + "name": "accepted confirmation plus duplicate submits sends one POST", + "ok": true + }, + { + "name": "unknown outcome cannot replay POST", + "ok": true + }, + { + "name": "unknown mutation visible", + "ok": true + }, + { + "name": "unknown refresh retains target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 547.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 547.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 547.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 547.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 607.6875, + "y": 411.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 684.203125, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 854.015625, + "y": 411.890625 + } + ], + "results": [ + { + "name": "returned unknown stage remains visible", + "ok": true + }, + { + "name": "200 unknown stage cannot immediately retry without observation", + "ok": true + }, + { + "name": "returned unknown stage sends no duplicate mutation", + "ok": true + }, + { + "name": "unknown target cannot bypass gate via another lifecycle form", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "returned-unknown-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 417.5625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 417.5625, + "y": 237.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 417.5625, + "y": 363.890625 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 417.5625, + "y": 411.890625 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 477.6875, + "y": 411.890625 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 554.203125, + "y": 411.890625 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 724.015625, + "y": 411.890625 + } + ], + "results": [ + { + "name": "returned unknown stage remains visible", + "ok": true + }, + { + "name": "200 unknown stage cannot immediately retry without observation", + "ok": true + }, + { + "name": "returned unknown stage sends no duplicate mutation", + "ok": true + }, + { + "name": "unknown target cannot bypass gate via another lifecycle form", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 743.59375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 798.484375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 860.03125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 920.15625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 996.671875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 743.59375, + "y": 237.296875 + } + ], + "results": [ + { + "name": "replacement keeps focused recovery form", + "ok": true + }, + { + "name": "document.write replacement initializes production scripts", + "ok": true + }, + { + "name": "replacement dialog has safe focus", + "ok": true + }, + { + "name": "opening replacement confirmation does not send effect", + "ok": true + }, + { + "name": "replacement preserves navigation target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "replacement-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 613.59375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 668.484375, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 730.03125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 790.15625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 866.671875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 613.59375, + "y": 237.296875 + } + ], + "results": [ + { + "name": "replacement keeps focused recovery form", + "ok": true + }, + { + "name": "document.write replacement initializes production scripts", + "ok": true + }, + { + "name": "replacement dialog has safe focus", + "ok": true + }, + { + "name": "opening replacement confirmation does not send effect", + "ok": true + }, + { + "name": "replacement preserves navigation target", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "source", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 294.921875 + }, + { + "height": 38, + "label": "Add from folder", + "tag": "BUTTON", + "width": 121, + "x": 209, + "y": 344.921875 + }, + { + "height": 38, + "label": "repo", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 453.515625 + }, + { + "height": 38, + "label": "subdir", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 556.109375 + }, + { + "height": 38, + "label": "ref", + "tag": "INPUT", + "width": 520, + "x": 209, + "y": 658.703125 + }, + { + "height": 38, + "label": "Clone and add", + "tag": "BUTTON", + "width": 113.34375, + "x": 209, + "y": 708.703125 + } + ], + "results": [ + { + "name": "local host source remains editable", + "ok": true + }, + { + "name": "source action remains functional", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Add from folder", + "ok": true + }, + { + "name": "button target at least 38px: Clone and add", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "add-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "source", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 294.921875 + }, + { + "height": 38, + "label": "Add from folder", + "tag": "BUTTON", + "width": 121, + "x": 79, + "y": 344.921875 + }, + { + "height": 38, + "label": "repo", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 453.515625 + }, + { + "height": 38, + "label": "subdir", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 556.109375 + }, + { + "height": 38, + "label": "ref", + "tag": "INPUT", + "width": 520, + "x": 79, + "y": 658.703125 + }, + { + "height": 38, + "label": "Clone and add", + "tag": "BUTTON", + "width": 113.34375, + "x": 79, + "y": 708.703125 + } + ], + "results": [ + { + "name": "local host source remains editable", + "ok": true + }, + { + "name": "source action remains functional", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Add from folder", + "ok": true + }, + { + "name": "button target at least 38px: Clone and add", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 760.125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 815.015625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 876.5625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 936.6875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1013.203125, + "y": 251.59375 + } + ], + "results": [ + { + "name": "registration receipt names success", + "ok": true + }, + { + "name": "verified catalog does not offer redundant registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "registered-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 630.125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 685.015625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 746.5625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 806.6875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 883.203125, + "y": 251.59375 + } + ], + "results": [ + { + "name": "registration receipt names success", + "ok": true + }, + { + "name": "verified catalog does not offer redundant registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 680.75, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 735.640625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 797.1875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 857.3125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 933.828125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1103.640625, + "y": 251.59375 + } + ], + "results": [ + { + "name": "added tool can be explicitly started", + "ok": true + }, + { + "name": "copy does not imply catalog registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "added-stopped-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 167.296875 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 167.296875 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 167.296875 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 550.75, + "y": 251.59375 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 605.640625, + "y": 251.59375 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 667.1875, + "y": 251.59375 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 727.3125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 803.828125, + "y": 251.59375 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 973.640625, + "y": 251.59375 + } + ], + "results": [ + { + "name": "added tool can be explicitly started", + "ok": true + }, + { + "name": "copy does not imply catalog registration", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1440", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1344.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 209, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 417.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 530.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 668.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 680.75, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 735.640625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 797.1875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 857.3125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 933.828125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 1103.640625, + "y": 189.296875 + }, + { + "height": 156.59375, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo? Reloads catalog; interrupts broker availability ", + "tag": "DIALOG", + "width": 520, + "x": 460, + "y": 371.703125 + }, + { + "height": 38, + "label": "Cancel", + "tag": "BUTTON", + "width": 64.390625, + "x": 485, + "y": 465.296875 + }, + { + "height": 38, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo", + "tag": "BUTTON", + "width": 345.84375, + "x": 549.390625, + "y": 465.296875 + } + ], + "results": [ + { + "name": "registration confirmation names broker and target", + "ok": true + }, + { + "name": "registration warns of interruption", + "ok": true + }, + { + "name": "registration defaults to Cancel", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "button target at least 38px: Cancel", + "ok": true + }, + { + "name": "button target at least 38px: Restart local broker 127.0.0.1:8765 for notes-demo", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1440 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "register-confirm-1180", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Sign out", + "tag": "BUTTON", + "width": 73.34375, + "x": 1084.65625, + "y": 14 + }, + { + "height": 38, + "label": "Back", + "tag": "A", + "width": 51.765625, + "x": 79, + "y": 105 + }, + { + "height": 38, + "label": "Author a tool", + "tag": "A", + "width": 102.90625, + "x": 287.984375, + "y": 105 + }, + { + "height": 38, + "label": "Add from source", + "tag": "A", + "width": 127.21875, + "x": 400.890625, + "y": 105 + }, + { + "height": 38, + "label": "Refresh state", + "tag": "A", + "width": 105.734375, + "x": 538.109375, + "y": 105 + }, + { + "height": 38, + "label": "Edit", + "tag": "A", + "width": 44.890625, + "x": 550.75, + "y": 189.296875 + }, + { + "height": 38, + "label": "Start", + "tag": "BUTTON", + "width": 51.546875, + "x": 605.640625, + "y": 189.296875 + }, + { + "height": 38, + "label": "Stop", + "tag": "BUTTON", + "width": 50.125, + "x": 667.1875, + "y": 189.296875 + }, + { + "height": 38, + "label": "Restart", + "tag": "BUTTON", + "width": 66.515625, + "x": 727.3125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Register with broker\u2026", + "tag": "BUTTON", + "width": 159.8125, + "x": 803.828125, + "y": 189.296875 + }, + { + "height": 38, + "label": "Remove", + "tag": "BUTTON", + "width": 71.53125, + "x": 973.640625, + "y": 189.296875 + }, + { + "height": 156.59375, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo? Reloads catalog; interrupts broker availability ", + "tag": "DIALOG", + "width": 520, + "x": 330, + "y": 371.703125 + }, + { + "height": 38, + "label": "Cancel", + "tag": "BUTTON", + "width": 64.390625, + "x": 355, + "y": 465.296875 + }, + { + "height": 38, + "label": "Restart local broker 127.0.0.1:8765 for notes-demo", + "tag": "BUTTON", + "width": 345.84375, + "x": 419.390625, + "y": 465.296875 + } + ], + "results": [ + { + "name": "registration confirmation names broker and target", + "ok": true + }, + { + "name": "registration warns of interruption", + "ok": true + }, + { + "name": "registration defaults to Cancel", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Sign out", + "ok": true + }, + { + "name": "button target at least 38px: Back", + "ok": true + }, + { + "name": "button target at least 38px: Author a tool", + "ok": true + }, + { + "name": "button target at least 38px: Add from source", + "ok": true + }, + { + "name": "button target at least 38px: Refresh state", + "ok": true + }, + { + "name": "button target at least 38px: Edit", + "ok": true + }, + { + "name": "button target at least 38px: Start", + "ok": true + }, + { + "name": "button target at least 38px: Stop", + "ok": true + }, + { + "name": "button target at least 38px: Restart", + "ok": true + }, + { + "name": "button target at least 38px: Register with broker\u2026", + "ok": true + }, + { + "name": "button target at least 38px: Remove", + "ok": true + }, + { + "name": "button target at least 38px: Cancel", + "ok": true + }, + { + "name": "button target at least 38px: Restart local broker 127.0.0.1:8765 for notes-demo", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 900, + "width": 1180 + } + }, + "snapshot": "saved", + "window_visible": false + }, + { + "case": "shell-startup-620", + "direct_children": [], + "dom": { + "below": [], + "collisions": [], + "horizontal": [], + "rectangles": [ + { + "height": 38, + "label": "Close", + "tag": "BUTTON", + "width": 67.53125, + "x": 28, + "y": 191.734375 + }, + { + "height": 38, + "label": "Retrying\u2026", + "tag": "BUTTON", + "width": 90.796875, + "x": 99.3125, + "y": 191.734375 + } + ], + "results": [ + { + "name": "shell observed cause", + "ok": true + }, + { + "name": "shell Retry available", + "ok": true + }, + { + "name": "shell Retry button latches synthetic duplicate clicks", + "ok": true + }, + { + "name": "no horizontal document overflow", + "ok": true + }, + { + "name": "visible controls fit horizontal viewport", + "ok": true + }, + { + "name": "button target at least 38px: Close", + "ok": true + }, + { + "name": "button target at least 38px: Retrying\u2026", + "ok": true + }, + { + "name": "no button-button overlap", + "ok": true + } + ], + "synthetic": true, + "viewport": { + "height": 360, + "width": 620 + } + }, + "snapshot": "saved", + "window_visible": false + } + ], + "started": "2026-10-24T15:17:01Z" +} \ No newline at end of file diff --git a/admin/tests/evidence/webkit-sizing/norman-source.json b/admin/tests/evidence/webkit-sizing/norman-source.json new file mode 100644 index 0000000..3d1a025 --- /dev/null +++ b/admin/tests/evidence/webkit-sizing/norman-source.json @@ -0,0 +1,44 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [ + { + "file": "tests/evidence/clear-desk/editor-1180.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/clear-desk/editor-1440.html", + "inputs": 70, + "required": 2, + "labels": 19 + }, + { + "file": "tests/evidence/clear-desk/policy-1180.html", + "inputs": 3, + "required": 0, + "labels": 0 + }, + { + "file": "tests/evidence/clear-desk/policy-1440.html", + "inputs": 3, + "required": 0, + "labels": 0 + } + ] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 1, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [] + } +} diff --git a/admin/tests/fixtures/native_api.json b/admin/tests/fixtures/native_api.json new file mode 100644 index 0000000..78c83e9 --- /dev/null +++ b/admin/tests/fixtures/native_api.json @@ -0,0 +1,10 @@ +{ + "backend": {"plugin": "localfile", "env_path": "/fake/sps.env", "host": "127.0.0.1", "port": 8743}, + "secret_status": {"backend": "localfile", "settable": false, "fields": ["API_KEY"], "provisioned": null, "status": "UNKNOWN"}, + "tool_states": [ + {"running": true, "alive": true, "state": "running", "health": "unknown"}, + {"running": true, "alive": false, "state": "crashed", "health": "stopped"}, + {"running": false, "alive": false, "state": "stopped", "health": "stopped"}, + {"running": true, "alive": null, "state": "unknown", "health": "unknown"} + ] +} diff --git a/admin/tests/offscreen_webkit.swift b/admin/tests/offscreen_webkit.swift new file mode 100644 index 0000000..9d26134 --- /dev/null +++ b/admin/tests/offscreen_webkit.swift @@ -0,0 +1,165 @@ +// Finite, unshown WKWebView proof runner. No activation or persistent website data. +import AppKit +import WebKit + +struct Fixture: Decodable { let name: String; let html: String; let width: Int; let height: Int } + +final class Runner: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + let fixtures: [Fixture] + let output: URL + var index = 0 + var window: NSWindow? + var view: WKWebView? + var timer: Timer? + var completed = false + var initialNavigation = true + var results: [[String: Any]] = [] + let started = Date() + var policy: [String: Any] = [:] + + init(fixtures: [Fixture], output: URL) { self.fixtures = fixtures; self.output = output } + + func start() { + // The parent also imposes a 120-second process deadline. + Timer.scheduledTimer(withTimeInterval: 115, repeats: false) { _ in self.finish("total watchdog") } + next() + } + + func next() { + guard index < fixtures.count else { finish(nil); return } + completed = false + initialNavigation = true + let f = fixtures[index] + timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { _ in self.finish("case watchdog: \(f.name)") } + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + config.userContentController.add(self, name: "proof") + // Block accidental external traffic; fixtures use only embedded fake fetch. + let rules = """ + [{"trigger":{"url-filter":"^https?://"},"action":{"type":"block"}}] + """ + let ruleURL = output.appendingPathComponent(".rule-store", isDirectory: true) + do { try FileManager.default.createDirectory(at: ruleURL, withIntermediateDirectories: true) } + catch { finish("private rule directory: \(error)"); return } + WKContentRuleListStore(url: ruleURL).compileContentRuleList(forIdentifier: "clear-desk-offscreen", encodedContentRuleList: rules) { list, error in + guard let list = list else { self.finish("traffic blocker: \(String(describing: error))"); return } + config.userContentController.add(list) + let frame = NSRect(x: 0, y: 0, width: CGFloat(f.width), height: CGFloat(f.height)) + let web = WKWebView(frame: frame, configuration: config) + web.navigationDelegate = self + let host = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false) + host.isReleasedWhenClosed = false + host.contentView = web + // Deliberately never orderFront/makeKey/activate. Window remains unshown. + self.window = host; self.view = web + web.loadHTMLString(f.html, baseURL: URL(string: "https://fixture.invalid/")) + } + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard !completed else { return } + completed = true + let f = fixtures[index] + var result: [String: Any] = ["case": f.name, "dom": message.body, "direct_children": children(), + "window_visible": window?.isVisible ?? false] + let resultIndex = results.count + results.append(result) + persist(nil) // DOM evidence must survive a blocked snapshot callback. + var snapshotFinished = false + func completeSnapshot() { + guard !snapshotFinished else { return } + snapshotFinished = true + self.results[resultIndex] = result + self.persist(nil) + self.timer?.invalidate() + self.view?.configuration.userContentController.removeScriptMessageHandler(forName: "proof") + self.view?.stopLoading(); self.window?.close(); self.view = nil; self.window = nil + self.index += 1 + self.next() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + if !snapshotFinished { result["snapshot_error"] = "offscreen snapshot timeout"; completeSnapshot() } + } + let snapshot = WKSnapshotConfiguration() + snapshot.rect = NSRect(x: 0, y: 0, width: CGFloat(f.width), height: CGFloat(f.height)) + view?.takeSnapshot(with: snapshot) { image, error in + guard !snapshotFinished else { return } + if let tiff = image?.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) { + do { try png.write(to: self.output.appendingPathComponent(f.name + ".png")); result["snapshot"] = "saved" } + catch { result["snapshot_error"] = String(describing: error) } + } else { result["snapshot_error"] = String(describing: error) } + completeSnapshot() + } + } + + func children() -> [String] { + let process = Process(), pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-axo", "pid=,ppid=,lstart=,comm="] + process.standardOutput = pipe + do { + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let parent = String(ProcessInfo.processInfo.processIdentifier) + return String(decoding: data, as: UTF8.self).split(separator: "\n").compactMap { line in + let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) + // Exclude the short-lived observation command itself. + return fields.count > 2 && fields[1] == parent && fields[0] != String(process.processIdentifier) ? String(line) : nil + } + } catch { return ["child observation unavailable: \(error)"] } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + let scheme = navigationAction.request.url?.scheme + if initialNavigation { initialNavigation = false; decisionHandler(.allow) } + else if scheme == "about" || scheme == "data" || navigationAction.request.url == nil { decisionHandler(.allow) } + else { + // Record attempted destinations for interaction assertions; still never load them. + if let url = navigationAction.request.url?.absoluteString, + let data = try? JSONSerialization.data(withJSONObject: [url]), + let json = String(data: data, encoding: .utf8) { + webView.evaluateJavaScript("(window.fixtureNavigations ||= []).push(\(json)[0])", completionHandler: nil) + } + decisionHandler(.cancel) + } // no backend or real navigation allowed + } + + func finish(_ error: String?) { + timer?.invalidate() + view?.stopLoading(); window?.close() + persist(error) + exit(error == nil ? 0 : 2) + } + + func persist(_ error: String?) { + let report: [String: Any] = ["pid": ProcessInfo.processInfo.processIdentifier, + "started": ISO8601DateFormatter().string(from: started), + "activation_policy": policy, "persistent_store": false, + "error": error as Any? ?? NSNull(), "results": results] + if let data = try? JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) { + try? data.write(to: output.appendingPathComponent("webkit-results.json")) + } + } +} + +let app = NSApplication.shared +let before = app.activationPolicy() +let requested = app.setActivationPolicy(.prohibited) +let after = app.activationPolicy() +let policy: [String: Any] = ["before": before.rawValue, "request_returned": requested, + "after": after.rawValue, "required": NSApplication.ActivationPolicy.prohibited.rawValue] +do { + let manifest = URL(fileURLWithPath: CommandLine.arguments[1]) + let output = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) + let fixtures = try JSONDecoder().decode([Fixture].self, from: Data(contentsOf: manifest)) + let runner = Runner(fixtures: fixtures, output: output) + runner.policy = policy + guard after == .prohibited else { runner.finish("Actual activation policy is not prohibited"); exit(2) } + DispatchQueue.main.async { runner.start() } + app.run() + withExtendedLifetime(runner) {} +} catch { fputs("\(error)\n", stderr); exit(2) } diff --git a/admin/tests/run_offscreen_webkit.py b/admin/tests/run_offscreen_webkit.py new file mode 100644 index 0000000..e66462b --- /dev/null +++ b/admin/tests/run_offscreen_webkit.py @@ -0,0 +1,61 @@ +"""Launch exactly one already-built, owned offscreen renderer with a hard deadline.""" +import json +import subprocess +import sys +import time +from pathlib import Path + +from admin.tests.webkit_fixtures import fixtures + + +def main(): + binary, output = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve() + output.mkdir(parents=True, exist_ok=True) + manifest = output/'fixtures.json' + manifest.write_text(json.dumps(fixtures())) + start = time.time() + with (output/'renderer.stdout').open('w') as stdout, (output/'renderer.stderr').open('w') as stderr: + process = subprocess.Popen([str(binary), str(manifest), str(output)], stdout=stdout, stderr=stderr) + identity = subprocess.run(['/bin/ps','-p',str(process.pid),'-o','pid=,ppid=,lstart=,command='],capture_output=True,text=True).stdout + deadline = False + try: + process.wait(timeout=118) + except subprocess.TimeoutExpired: + deadline = True + # Popen still owns this unreaped child. No name/pattern signals. + process.kill() + process.wait(timeout=1) + after = subprocess.run(['/bin/ps','-p',str(process.pid),'-o','pid=,ppid=,lstart=,command='],capture_output=True,text=True).stdout + report = dict(pid=process.pid,start_epoch=start,start_identity=identity,exit_code=process.returncode, + elapsed_seconds=time.time()-start,watchdog=deadline,after_identity=after, + child_limit='Only direct children observable by parent identity; launchd-managed WebKit XPC services are not claimed owned or signalled.') + (output/'process.json').write_text(json.dumps(report,indent=2)) + summary = [] + result_path=output/'webkit-results.json' + if result_path.exists(): + results=json.loads(result_path.read_text()) + recorded_children=[] + for case in results['results']: + recorded_children.extend(case.get('direct_children',[])) + dom=case.get('dom',{}) + html=dom.pop('html',None) + if html is not None: + (output/(case['case']+'.html')).write_text(html) + summary.append({'case':case['case'],'failures':[r['name'] for r in dom.get('results',[]) if not r['ok']], + 'assertions':len(dom.get('results',[])),'snapshot':case.get('snapshot',case.get('snapshot_error')), + 'window_visible':case.get('window_visible')}) + result_path.write_text(json.dumps(results,indent=2)) + ids=sorted({line.split()[0] for line in recorded_children if line.split() and line.split()[0].isdigit()}) + if ids: + after_children=subprocess.run(['/bin/ps','-p',','.join(ids),'-o','pid=,ppid=,lstart=,command='],capture_output=True,text=True).stdout + else: + after_children='No direct child PIDs were observed; no assertion about launchd-managed XPC helpers.' + (output/'child-observations.json').write_text(json.dumps({'recorded':recorded_children,'after':after_children},indent=2)) + (output/'summary.json').write_text(json.dumps(summary,indent=2)) + print(json.dumps({'process':report,'cases':summary},indent=2)) + if process.returncode or len(summary)!=len(fixtures()) or any(c['failures'] for c in summary): + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/admin/tests/test_admin_controls.py b/admin/tests/test_admin_controls.py new file mode 100644 index 0000000..68cc190 --- /dev/null +++ b/admin/tests/test_admin_controls.py @@ -0,0 +1,171 @@ +"""Fake-only contracts for session, lifecycle and worker admission repairs.""" +import asyncio +import json +import os +import threading +import unittest +from contextlib import contextmanager +from dataclasses import asdict +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +from fastapi import HTTPException +from admin import toolyard_ops +from admin.blocking import BlockingWork +from admin.sps_link import SPSLink, parse_verify +from admin import operator_audit, broker_config +from toolyard.runner import RunningTool + +runner_state_lock = toolyard_ops.state_lock + + +class AuditFailure(unittest.TestCase): + def test_failed_intent_audit_prevents_config_write(self): + config = broker_config.BrokerRunConfig() + with mock.patch("admin.operator_audit.open_store") as store, \ + mock.patch("admin.operator_audit.operations.record_admin_event", side_effect=OSError("fake")), \ + mock.patch("admin.operator_audit.broker_config.save") as save: + with self.assertRaises(OSError): + operator_audit.save_config(config, config, "fake-admin") + save.assert_not_called() + + def test_completion_failure_explains_that_save_already_happened(self): + config = broker_config.BrokerRunConfig() + with mock.patch("admin.operator_audit.open_store"), \ + mock.patch("admin.operator_audit.operations.record_admin_event", side_effect=[None, OSError("fake")]), \ + mock.patch("admin.operator_audit.broker_config.save") as save: + with self.assertRaisesRegex(RuntimeError, "saved but completion audit failed"): + operator_audit.save_config(config, config, "fake-admin") + save.assert_called_once() + + +class Readiness(unittest.TestCase): + def test_strict_boolean(self): + for text in ("1", "true", "yes", "on", " TRUE "): + self.assertIs(parse_verify(text), True) + for text in ("0", "false", "no", "off"): + self.assertIs(parse_verify(text), False) + for text in ("", "typo", "2"): + with self.assertRaises(ValueError): + parse_verify(text) + + def test_invalid_verify_disables_readiness_not_verification(self): + cfg = SimpleNamespace(sp_host="fake", sp_port=8743, sp_tls_ca="fake") + with mock.patch.dict(os.environ, {"TOOLSTACK_SPS_VERIFY": "typo", "TOOLSTACK_SPS_SKIP": "0"}), \ + mock.patch("admin.sps_link.os.path.exists", return_value=True), \ + mock.patch("admin.sps_link.load_config", return_value=cfg): + self.assertIsNone(SPSLink.from_env("/fake/unused")) + + +class Lifecycle(unittest.TestCase): + def setUp(self): + self.held = False + @contextmanager + def lock(): + self.assertFalse(self.held, "must not require a reentrant runner lock") + self.held = True + try: + yield + finally: + self.held = False + self.running = RunningTool("fake", 9999, "process", "fake-pid") + self.runner = mock.Mock() + self.runner.start.return_value = self.running + def load(): + self.assertTrue(self.held) + return {} + def save(state): + self.assertTrue(self.held) + for target, kw in ( + ("state_lock", {"side_effect": lock}), + ("_all_defs", {"return_value": {"fake": object()}}), + ("_load_state", {"side_effect": load}), + ("_save_state", {"side_effect": save}), + ("get_runner", {"return_value": self.runner}), + ): + patcher = mock.patch.object(toolyard_ops, target, **kw) + patcher.start() + self.addCleanup(patcher.stop) + + def test_restart_uses_one_complete_nonreentrant_lock(self): + toolyard_ops.restart("fake", "unused", []) + self.runner.start.assert_called_once() + + def test_failed_persistence_rolls_back_new_start(self): + with mock.patch.object(toolyard_ops, "_save_state", side_effect=OSError("fake disk")): + with self.assertRaises(OSError): + toolyard_ops.start("fake", "unused", []) + self.runner.stop.assert_called_once_with(self.running) + + def test_failed_rollback_retains_explicit_runtime_reference(self): + self.runner.stop.side_effect = OSError("fake cleanup") + with mock.patch.object(toolyard_ops, "_save_state", side_effect=OSError("fake disk")): + with self.assertRaises(toolyard_ops.UnrecordedRuntimeError) as caught: + toolyard_ops.start("fake", "unused", []) + self.assertIs(caught.exception.running, self.running) + + def test_runner_cleanup_failure_record_is_preserved(self): + exc = RuntimeError("fake start cleanup failure") + exc.running = self.running + self.runner.start.side_effect = exc + with mock.patch.object(toolyard_ops, "_save_state") as save: + with self.assertRaises(RuntimeError): + toolyard_ops.start("fake", "unused", []) + save.assert_called_once_with({"fake": asdict(self.running)}) + + def test_missing_runner_lock_fails_closed(self): + # Test the actual adapter, not the injected fake used above. + from toolyard import cli + with mock.patch.object(cli, "state_lock", None, create=True): + with self.assertRaisesRegex(RuntimeError, "unavailable"): + runner_state_lock() + + +class NativeLiveness(unittest.TestCase): + def test_recorded_state_is_not_health_evidence(self): + fixture = json.loads((Path(__file__).parent / "fixtures/native_api.json").read_text()) + definition = SimpleNamespace(id="fake", type="api", port=9999, path=Path("/fake")) + running = RunningTool("fake", 9999, "process", "fake") + for expected in fixture["tool_states"]: + record = {"fake": asdict(running)} if expected["running"] else {} + runner = mock.Mock() + if expected["alive"] is None: + runner.is_alive.side_effect = OSError("unknown") + else: + runner.is_alive.return_value = expected["alive"] + with mock.patch.object(toolyard_ops, "_load_state", return_value=record), \ + mock.patch.object(toolyard_ops, "_all_defs", return_value={"fake": definition}), \ + mock.patch.object(toolyard_ops, "get_runner", return_value=runner): + tool = toolyard_ops.list_tools("unused")[0] + self.assertEqual({key: tool[key] for key in expected}, expected) + + +class WorkerAdmission(unittest.IsolatedAsyncioTestCase): + async def test_cancelled_caller_keeps_slot_until_work_finishes(self): + work = BlockingWork(limit=1) + entered, release = threading.Event(), threading.Event() + def blocking(): + entered.set() + release.wait(3) + task = asyncio.create_task(work.run(blocking)) + try: + while not entered.is_set(): + await asyncio.sleep(0) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + with self.assertRaises(HTTPException) as caught: + await work.run(lambda: None) + self.assertEqual(caught.exception.status_code, 503) + finally: + release.set() + await asyncio.to_thread(work._executor.shutdown, wait=True) + + async def test_work_is_off_loop_and_result_propagates(self): + work = BlockingWork(limit=1) + try: + thread = threading.get_ident() + self.assertNotEqual(await work.run(threading.get_ident), thread) + finally: + work._executor.shutdown() diff --git a/admin/tests/test_api.py b/admin/tests/test_api.py index 67c2d33..321a4db 100644 --- a/admin/tests/test_api.py +++ b/admin/tests/test_api.py @@ -56,6 +56,97 @@ def _restore_env(self): else: os.environ[key] = prev + def test_reset_invalidates_live_bearer_without_restart(self): + authz = self._auth() + self.assertEqual(self.client.get("/api/config", headers=authz).status_code, 200) + settings.write_password_hash(auth.hash_password("replacement-fake")) + settings.rotate_session_secret() + self.assertEqual(self.client.get("/api/config", headers=authz).status_code, 401) + self.assertEqual(self.client.post("/api/login", json={"password": PASSWORD}).status_code, 401) + token = self.client.post("/api/login", json={"password": "replacement-fake"}).json()["token"] + self.assertEqual(self.client.get("/api/config", headers={"Authorization": f"Bearer {token}"}).status_code, 200) + + def test_external_delete_unregisters_only_matching_source(self): + folder = Path(self.tmp, 'external') + folder.mkdir() + manifest = folder / 'toolyard.toml' + content = 'id="external"\ntype="api"\n[entrypoint]\nport=43211\n' + manifest.write_text(content) + config = broker_config.load() + config.tool_dirs = [str(folder), str(Path(self.tmp, 'other'))] + broker_config.save(config) + self.assertEqual(self.client.delete('/api/tools/external').status_code, 401) + with mock.patch('admin.tool_removal.toolyard_ops.stop') as stop: + response = self.client.delete('/api/tools/external', headers=self._auth()) + self.assertEqual(response.json(), {'removed': 'external', 'deleted': False}) + self.assertEqual(manifest.read_text(), content) + self.assertEqual(broker_config.load().tool_dirs, [str(Path(self.tmp, 'other'))]) + stop.assert_called_once_with('external') + + def test_catalog_requires_admin_and_legacy_observation_is_unknown(self): + self.assertEqual(self.client.get('/api/catalog').status_code, 401) + with mock.patch('admin.catalog_observation.urllib.request.urlopen', side_effect=OSError): + self.assertEqual(self.client.get('/api/catalog', headers=self._auth()).json(), {'status': 'unknown'}) + + def test_catalog_api_membership_is_same_verified_snapshot_after_delete(self): + import threading + import urllib.request + from broker.server import build_server + config = broker_config.load() + server = build_server(port=0, host='127.0.0.1', db_path=config.db_path, + tools_root=config.tools_root, surface=mock.Mock()) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + config.port = server.server_address[1] + broker_config.save(config) + self.assertEqual(self.client.get('/api/catalog').status_code, 401) + headers = self._auth() + cached = self.client.get('/api/tools', headers=headers).json()['tools'] + self.assertEqual([t['id'] for t in cached], ['echo']) + self.assertEqual(self.client.get('/api/catalog', headers=headers).json()['tool_ids'], ['echo']) + Path(config.tools_root, 'echo', 'toolyard.toml').unlink() + server.reload_registry() + receipt = self.client.get('/api/catalog', headers=headers).json() + self.assertEqual(receipt['status'], 'verified') + self.assertEqual(receipt['tool_ids'], []) + with urllib.request.urlopen(f'http://127.0.0.1:{config.port}/v1/health', timeout=2) as response: + public = json.load(response) + self.assertEqual(set(public), {'status', 'catalog_observation_id'}) + self.assertEqual(public['catalog_observation_id'], receipt['observation_id']) + finally: + server.shutdown() + thread.join(3) + server.server_close() + server.ctx.store.close() + + def test_native_backend_and_unknown_contract_fixture(self): + fixture = json.loads((Path(__file__).parent / "fixtures/native_api.json").read_text()) + authz = self._auth() + with mock.patch("admin.settings.sps_info", return_value=fixture["backend"]): + self.assertEqual(self.client.get("/api/secret-backend", headers=authz).json(), fixture["backend"]) + self.assertEqual(self.client.get("/api/tools/echo/secrets", headers=authz).json(), fixture["secret_status"]) + + def test_config_audit_contains_field_names_not_values(self): + authz = self._auth() + response = self.client.post("/api/config", headers=authz, json={"nod_token": "fake-sensitive-token", "port": 9999}) + self.assertEqual(response.status_code, 200) + events = self.client.get("/api/audit", headers=authz).json()["audit"] + changed = next(e for e in events if e["event_type"] == "config_changed") + self.assertEqual(set(changed["details"]["fields"]), {"nod_token", "port"}) + self.assertNotIn("fake-sensitive-token", json.dumps(events)) + + def test_missing_session_generation_fails_closed(self): + authz = self._auth() + settings.session_secret_file().unlink() + self.assertEqual(self.client.get("/api/config", headers=authz).status_code, 503) + + def test_obsolete_native_provisioning_has_no_success_path(self): + response = self.client.post("/api/tools/echo/secrets", headers=self._auth(), + json={"field": "API_KEY", "value": "fake-only"}) + self.assertEqual(response.status_code, 410) + self.assertNotIn("fake-only", response.text) + def _token(self) -> str: r = self.client.post("/api/login", json={"password": PASSWORD}) self.assertEqual(r.status_code, 200) @@ -191,7 +282,9 @@ def test_clear_audit_removes_events(self): self.assertTrue(self.client.get("/api/audit", headers=authz).json()["audit"]) r = self.client.delete("/api/audit", headers=authz) self.assertEqual(r.status_code, 200) - self.assertEqual(self.client.get("/api/audit", headers=authz).json()["audit"], []) + audit = self.client.get("/api/audit", headers=authz).json()["audit"] + self.assertEqual(len(audit), 1) # deletion itself remains attributable + self.assertEqual(audit[0]["event_type"], "audit_cleared") def test_edit_tool_updates_description_and_secrets(self): r = self.client.post("/api/tools/echo", headers=self._auth(), json={ @@ -205,7 +298,8 @@ def test_edit_tool_updates_description_and_secrets(self): if t["id"] == "echo") self.assertEqual(echo["description"], "now with feeling") self.assertEqual({s["field"] for s in echo["secrets"]}, {"NEW_KEY", "TOKEN"}) - self.assertEqual([op["op"] for op in echo["ops"]], ["say"]) # operations preserved + self.assertEqual([op["op"] for op in echo["ops"]], + ["say", "refresh", "refresh_one"]) # declared + generated operations def test_edit_tool_description_only_keeps_secrets(self): # Omitting "secrets" leaves the existing declarations in place. diff --git a/admin/tests/test_app.py b/admin/tests/test_app.py index c5e7b58..4ed286b 100644 --- a/admin/tests/test_app.py +++ b/admin/tests/test_app.py @@ -6,11 +6,13 @@ admin/.venv/bin/python -m unittest admin.tests.test_app """ +import base64 import json import os import re import shutil import tempfile +import time import tomllib import unittest from pathlib import Path @@ -88,6 +90,18 @@ def _login(self): self.assertEqual(r.status_code, 200) # followed the 303 to the dashboard self.assertIn("Toolstack Admin", r.text) + def test_password_reset_invalidates_cookie_and_csrf_without_restart(self): + self._login() + csrf = _csrf(self.client.get("/").text) + settings.write_password_hash(auth.hash_password("replacement-fake")) + settings.rotate_session_secret() + self.assertEqual(self.client.get("/", follow_redirects=False).headers["location"], "/login") + self.assertEqual(self.client.post("/config", data={"_csrf": csrf, "port": "9999"}, + follow_redirects=False).headers["location"], "/login") + self.assertNotEqual(broker_config.load().port, 9999) + response = self.client.post("/login", data={"username": "admin", "password": "replacement-fake"}) + self.assertIn("Toolstack Admin", response.text) + def test_create_app_starts_reconciler_when_sps_unconfigured(self): with mock.patch.dict(os.environ, {"TOOLSTACK_SPS_ENV": "/nonexistent/sps.env"}), \ mock.patch("admin.server.SPSLink") as link, \ @@ -131,6 +145,30 @@ def test_requires_login(self): r = self.client.get("/") self.assertIn("Sign in", r.text) + def test_malformed_and_wrong_purpose_cookie_redirect_to_login(self): + expiry = str(int(time.time()) + 60) + secret = settings.session_secret_file().read_text().strip() + for signature in ("é", auth.csrf_token(expiry, secret)): + cookie = base64.urlsafe_b64encode(f"csrf|{expiry}|{signature}".encode()).decode() + self.client.cookies.set("toolstack_admin_session", cookie) + response = self.client.get("/", follow_redirects=False) + self.assertEqual(response.status_code, 303) + self.assertEqual(response.headers["location"], "/login") + self.client.cookies.clear() + self._login() + self.assertIn('name="_csrf"', self.client.get("/").text) + + def test_non_ascii_csrf_rejected_then_current_token_works(self): + self._login() + csrf = _csrf(self.client.get("/").text) + response = self.client.post("/callers", data={"name": "fake-new", "_csrf": "é"}) + self.assertEqual(response.status_code, 200) + self.assertIn("Invalid CSRF token.", response.text) + self.assertIsNone(self._store().caller_by_name("fake-new")) + response = self.client.post("/callers", data={"name": "fake-new", "_csrf": csrf}) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(self._store().caller_by_name("fake-new")) + def test_bad_password_rejected(self): r = self.client.post("/login", data={"username": "admin", "password": "wrong"}) self.assertEqual(r.status_code, 401) @@ -188,6 +226,99 @@ def test_create_caller_and_set_policy(self): self.assertTrue(any(e["event_type"] == "policy_changed" for e in store.audit_events())) self.assertTrue(any(e["event_type"] == "tools_changed" for e in store.audit_events())) + def test_web_edits_preserve_unrendered_authority(self): + self._login() + csrf = _csrf(self.client.get('/').text) + self.client.post('/callers', data={'name': 'writer', '_csrf': csrf}) + store = self._store() + caller = store.caller_by_name('writer') + store.set_policy(caller['id'], {'enabled': ['echo', 'offline'], 'tools': { + 'echo': {'say': 'allow', 'hidden': 'review'}, 'offline': {'fetch': 'allow'}}}) + response = self.client.post('/callers/writer/policy', + headers={'Accept': 'application/json'}, + data={'_csrf': csrf, 'op__echo__say': 'deny'}) + self.assertEqual(response.json()['outcome'], 'saved') + policy = store.policy_for(caller['id']) + self.assertEqual(policy['tools']['offline'], {'fetch': 'allow'}) + self.assertEqual(policy['tools']['echo']['hidden'], 'review') + self.assertNotEqual(policy['tools']['echo'].get('say'), 'allow') + self.client.post('/callers/writer/tools', data={'_csrf': csrf}) + self.assertEqual(store.policy_for(caller['id']), { + 'enabled': ['offline'], 'tools': {'offline': {'fetch': 'allow'}}}) + + def test_policy_failed_load_disables_save(self): + self._login() + with mock.patch('admin.server.open_store', side_effect=OSError('private path')): + response = self.client.get('/callers/writer/policy') + self.assertEqual(response.status_code, 503) + self.assertIn("type='submit' disabled", response.text) + self.assertIn('Retry load', response.text) + self.assertNotIn('private path', response.text) + + def test_add_start_register_are_separate_named_decisions(self): + self._login() + csrf = _csrf(self.client.get('/').text) + source = Path(self.tmp, 'source') + source.mkdir() + (source/'toolyard.toml').write_text('id="localfake"\ntype="api"\n[entrypoint]\nport=43212\ncommand="python3 fake.py"\n[[operations]]\nname="read"\nrisk="read"\n') + with mock.patch('admin.server.toolyard_ops.start') as start, \ + mock.patch('admin.server.supervisor.restart', return_value={'running': True}) as restart: + added = self.client.post('/tools/add-source', data={'_csrf': csrf, 'kind': 'path', 'source': str(source)}) + self.assertIn('Added localfake', added.text) + self.assertIn('/toolyard/tools/localfake/start', added.text) + start.assert_not_called() + restart.assert_not_called() + started = self.client.post('/toolyard/tools/localfake/start', data={'_csrf': csrf}, follow_redirects=False) + self.assertEqual(started.headers['location'], '/tools?tool=localfake') + start.assert_called_once() + restart.assert_not_called() + with mock.patch('admin.catalog_observation.observe_catalog', return_value={'status': 'verified', 'observation_id': 'fake', 'tool_ids': ['localfake']}): + registered = self.client.post('/broker/restart', data={'_csrf': csrf, 'catalog_target': 'localfake'}) + restart.assert_called_once() + self.assertIn('localfake · Catalog registration verified', registered.text) + + def test_failed_recovery_retains_named_target_without_replay(self): + self._login() + csrf = _csrf(self.client.get('/').text) + with mock.patch('admin.server.toolyard_ops.restart', side_effect=RuntimeError('private diagnostic')) as restart: + response = self.client.post('/toolyard/tools/echo/restart', data={'_csrf': csrf}) + restart.assert_called_once() + self.assertIn('echo · restart outcome unknown', response.text) + self.assertIn("data-mutation-outcome='unknown'", response.text) + self.assertIn('/tools?tool=echo', response.text) + self.assertNotIn('private diagnostic', response.text) + + def test_registration_result_requires_membership_not_cached_listing(self): + self._login() + csrf = _csrf(self.client.get('/').text) + for receipt in ({'status':'verified'}, {'status':'verified','tool_ids':[]}, + {'status':'verified','tool_ids':['other']}, {'status':'verified','tool_ids':'echo'}): + with mock.patch('admin.server.supervisor.restart', return_value={'running':True}), \ + mock.patch('admin.catalog_observation.observe_catalog', return_value=receipt): + response = self.client.post('/broker/restart', data={'_csrf':csrf,'catalog_target':'echo'}) + self.assertIn('Registration unknown', response.text) + self.assertNotIn('Catalog registration verified', response.text) + self.assertNotIn('Catalog verified', response.text) + + def test_policy_after_effect_failure_is_unknown_and_read_does_not_replay(self): + from broker import operations + self._login() + csrf = _csrf(self.client.get('/').text) + self.client.post('/callers', data={'name': 'writer', '_csrf': csrf}) + self.client.post('/callers/writer/tools', data={'_csrf': csrf, 'tool__echo': 'on'}) + original = operations.set_policy + def after_effect(*args, **kwargs): + original(*args, **kwargs) + raise ValueError('lost completion audit') + with mock.patch('admin.server.operations.set_policy', side_effect=after_effect) as save: + response = self.client.post('/callers/writer/policy', headers={'Accept': 'application/json'}, + data={'_csrf': csrf, 'op__echo__say': 'review'}) + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()['outcome'], 'unknown') + comparison = self.client.get('/callers/writer/policy') + self.assertIn("value='review' selected", comparison.text) + save.assert_called_once() + def test_revoke_token_via_panel(self): self._login() csrf = _csrf(self.client.get("/").text) @@ -403,7 +534,9 @@ def test_clear_audit_route_removes_events(self): self.assertTrue(store.recent_audit()) r = self.client.post("/audit/clear", data={"_csrf": csrf}) self.assertEqual(r.status_code, 200) - self.assertEqual(store.recent_audit(), []) + events = store.recent_audit() + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["event_type"], "audit_cleared") def test_remove_root_tool_deletes_its_folder(self): self._login() diff --git a/admin/tests/test_approval_fields.py b/admin/tests/test_approval_fields.py new file mode 100644 index 0000000..bd46c8f --- /dev/null +++ b/admin/tests/test_approval_fields.py @@ -0,0 +1,93 @@ +"""Nonvisual approval metadata adapter and shared registry contract.""" +import copy +import json +import tempfile +import tomllib +import unittest +from pathlib import Path +from unittest.mock import patch + +from admin import manifest_files, tool_authoring as author +from admin.tests.test_tool_authoring import FULL, REST_FULL +from admin.views import assets +from broker.approval_summary import validate_fields +from broker.registry import Registry + + +class ApprovalFields(unittest.TestCase): + def data(self, kind, fields=None, present=True): + data = copy.deepcopy(REST_FULL if kind == "rest" else FULL) + data["type"] = kind + if present: + data["operations"][0]["approval_fields"] = fields + return data + + def test_roundtrip_registry_all_transports(self): + for kind in ("api", "mcp", "rest"): + for present, fields in ((False, None), (True, []), + (True, ["/", "/escaped~1key/~0", "/items/0/name", "/ spaced "]), + (True, ["/" + "x" * 255]), + (True, [f"/k{i}" for i in range(32)])): + with self.subTest(kind=kind, present=present, fields=fields), tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + data = author.from_json(json.dumps(self.data(kind, fields, present))) + self.assertEqual(author.validate(data), []) + author.write(directory, data, runner="process") + parsed = tomllib.loads(manifest_files.read(directory).decode()) + loaded = author.read(directory) + for op in (data["operations"][0], parsed["operations"][0], loaded["operations"][0]): + self.assertEqual("approval_fields" in op, present) + if present: + self.assertEqual(op["approval_fields"], fields) + registry = Registry.from_sources(tool_dirs=[directory]) + name = data["operations"][0]["name"] + expected = fields if present else [] + self.assertEqual(registry.lookup(data["id"], name).approval_fields, tuple(expected)) + self.assertEqual(registry.describe(data["id"], name)["approval_fields"], expected) + if kind == "rest": + self.assertIs(loaded["operations"][1]["redact_response_body"], False) + self.assertIs(loaded["operations"][1]["redact_response_headers"], False) + self.assertEqual(loaded["operations"][1]["secret_update_rules"], data["operations"][1]["secret_update_rules"]) + + def test_invalid_raw_values_refused_without_publication(self): + invalid = [None, "PRIVATE_SENTINEL", {}, 1, True, ("/a",), + [1], [None], [{}], ["/a", "/a"], [""], ["no/slash"], + [" /a"], ["/bad~"], ["/bad~2"], ["/" + "x" * 256], + [f"/k{i}" for i in range(33)]] + for kind in ("api", "mcp", "rest"): + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + author.write(directory, author.normalize(self.data(kind, [], True)), runner="process") + before = manifest_files.read(directory) + for fields in invalid: + with self.subTest(kind=kind, value_type=type(fields).__name__): + data = author.normalize(self.data(kind, fields)) + self.assertIs(data["operations"][0]["approval_fields"], fields) + with self.assertRaises(ValueError): + validate_fields(fields) + self.assertEqual(author.validate(data), [ + "approval_fields must be <=32 unique non-root RFC6901 pointers (<=256 characters)"]) + with patch.object(manifest_files, "write") as publish: + with self.assertRaises(ValueError) as error: + author.write(directory, data, runner="process") + publish.assert_not_called() + self.assertNotIn("PRIVATE_SENTINEL", str(error.exception)) + self.assertEqual(manifest_files.read(directory), before) + with self.assertRaises(ValueError): + author.to_toml(data) + + def test_shared_validator_is_imported(self): + self.assertIs(author.validate_fields, validate_fields) + + def test_existing_editor_preserves_presence_nonvisually(self): + # No existing JS execution route in admin/tests. These are source contract + # checks, NOT live-form/browser proof; no new browser/harness is introduced. + source = Path(assets.__file__).read_text() + card = source.split("function opCard(o, open){", 1)[1].split("function serializeOp(card){", 1)[0] + serializer = source.split("function serializeOp(card){", 1)[1].split("function secRow(s){", 1)[0] + self.assertIn("hasOwnProperty.call(o, 'approval_fields')", card) + self.assertIn("card._approval_fields = o.approval_fields", card) + self.assertIn("hasOwnProperty.call(card, '_approval_fields')", serializer) + self.assertIn("operation.approval_fields = card._approval_fields", serializer) + self.assertIn("return operation;", serializer) + self.assertNotIn("approval_fields", card.split("card.querySelector('.op-name').value", 1)[0]) diff --git a/admin/tests/test_auth.py b/admin/tests/test_auth.py index a628854..f5f54e6 100644 --- a/admin/tests/test_auth.py +++ b/admin/tests/test_auth.py @@ -1,6 +1,10 @@ """admin.auth: password hashing, signed sessions, and CSRF tokens, all stdlib. Round-trips succeed; wrong secrets, tampering, and expiry all fail closed.""" +import base64 +import hashlib +import hmac +import time import unittest from admin import auth @@ -41,6 +45,25 @@ def test_tampered_rejected(self): def test_none_rejected(self): self.assertIsNone(auth.verify_session(None, self.SECRET)) + def test_wrong_purpose_and_legacy_signatures_rejected(self): + expiry = str(int(time.time()) + 60) + # This demonstrates a domain collision, not a pre-auth token oracle. + for username, signature in ( + ("csrf", auth.csrf_token(expiry, self.SECRET)), + ("csrf-v1", auth.csrf_token(expiry, self.SECRET)), + ("csrf", hmac.new(self.SECRET.encode(), ("csrf|" + expiry).encode(), hashlib.sha256).hexdigest()), + ("admin", hmac.new(self.SECRET.encode(), ("admin|" + expiry).encode(), hashlib.sha256).hexdigest()), + ): + cookie = base64.urlsafe_b64encode(f"{username}|{expiry}|{signature}".encode()).decode() + with self.subTest(username=username, signature=signature): + self.assertIsNone(auth.verify_session(cookie, self.SECRET)) + + def test_malformed_inputs_fail_closed(self): + malformed = base64.urlsafe_b64encode(f"admin|{int(time.time()) + 60}|é".encode()).decode() + for cookie in (malformed, "é", "\ud800", 42, b"cookie", [], {}, None): + with self.subTest(cookie=cookie): + self.assertIsNone(auth.verify_session(cookie, self.SECRET)) + class Csrf(unittest.TestCase): SECRET = "session-secret" @@ -59,6 +82,19 @@ def test_missing_inputs_rejected(self): self.assertFalse(auth.verify_csrf(None, "sess", self.SECRET)) self.assertFalse(auth.verify_csrf("tok", None, self.SECRET)) + def test_malformed_inputs_fail_closed(self): + for value in ("é", "\ud800", 42, b"token", [], {}, None): + with self.subTest(value=value): + self.assertFalse(auth.verify_csrf(value, "sess", self.SECRET)) + self.assertFalse(auth.verify_csrf("token", value, self.SECRET)) + + def test_legacy_csrf_and_session_signature_rejected(self): + session = auth.sign_session("admin", self.SECRET, 60) + legacy = hmac.new(self.SECRET.encode(), ("csrf|" + session).encode(), hashlib.sha256).hexdigest() + signature = base64.urlsafe_b64decode(session).decode().rsplit("|", 1)[1] + self.assertFalse(auth.verify_csrf(legacy, session, self.SECRET)) + self.assertFalse(auth.verify_csrf(signature, session, self.SECRET)) + if __name__ == "__main__": unittest.main() diff --git a/admin/tests/test_bounded_repairs.py b/admin/tests/test_bounded_repairs.py new file mode 100644 index 0000000..a803077 --- /dev/null +++ b/admin/tests/test_bounded_repairs.py @@ -0,0 +1,129 @@ +"""Fake-only seam regressions for the bounded admin repair tranche.""" +import errno +import os +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +from admin import broker_config, manifest_files, secret_values, tool_sources +from admin.server import _config_from_form +from admin.sps_watchdog import SPSWatchdog +from admin.sps_watchdog import recover_with_runner + + +class ConfigMerge(unittest.TestCase): + def test_html_and_json_share_preserving_merge(self): + current = broker_config.BrokerRunConfig(tool_dirs=["/fake/external"], nod_token="fake") + for merge in (_config_from_form, broker_config.merge_update): + result = merge({"port": "9000", "nod_token": ""}, current) if merge is _config_from_form else merge(current, {"port": "9000", "nod_token": ""}) + self.assertEqual(result.tool_dirs, current.tool_dirs) + self.assertEqual(result.nod_token, "fake") + self.assertEqual(result.port, 9000) + + def test_invalid_input_is_not_silently_saved(self): + for data in ({"port": True}, {"port": 2.5}, {"port": "oops"}, + {"port": 0}, {"approval_ttl": 0}, {"rate_limit": -1}, + {"nod_url": None}, {"nod_url": "http://example.com"}): + with self.subTest(data=data), self.assertRaises(ValueError): + broker_config.merge_update(broker_config.BrokerRunConfig(), data) + + def test_secret_inspection_is_unknown(self): + self.assertIsNone(secret_values.provisioned_fields("fake", ["FAKE"])) + + +class Recovery(unittest.TestCase): + def test_adapter_uses_detailed_runner_contract(self): + result = SimpleNamespace(complete=False) + with mock.patch("toolyard.runner.reregister_all_with_sps_detailed", create=True, + return_value=result) as recover: + self.assertIs(recover_with_runner(tools_root="fake"), result) + recover.assert_called_once_with(tools_root="fake") + + def test_same_boot_retries_until_explicit_completion(self): + for first in (OSError("fake failure"), 1, SimpleNamespace(complete=False)): + recover = mock.Mock(side_effect=[first, SimpleNamespace(complete=True)]) + watchdog = SPSWatchdog(config=broker_config.BrokerRunConfig(), + probe=lambda: "same", re_register_fn=recover, + sps_env_path="/fake/unused") + watchdog._check_once() + self.assertIsNone(watchdog._last_seen) + watchdog._check_once() + watchdog._check_once() + self.assertEqual(recover.call_count, 2) + + +class ManifestSafety(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.tool = self.root / "tool" + self.tool.mkdir() + self.outside = self.root / "outside" + self.outside.write_text("unchanged") + + def test_linked_manifest_rejected_on_ingestion_and_write(self): + (self.tool / "toolyard.toml").symlink_to(self.outside) + with self.assertRaises(ValueError): + tool_sources.add_from_path(str(self.tool), str(self.root / "managed")) + with self.assertRaises(ValueError): + manifest_files.write(self.tool, "replacement") + self.assertEqual(self.outside.read_text(), "unchanged") + + def test_linked_directory_rejected(self): + alias = self.root / "alias" + alias.symlink_to(self.tool, target_is_directory=True) + with mock.patch("admin.manifest_files.os.close", wraps=os.close) as close: + with self.assertRaises(ValueError): + manifest_files.write(alias, "replacement") + self.assertTrue(close.called) + for call in close.call_args_list: + with self.assertRaises(OSError) as closed: + os.fstat(call.args[0]) + self.assertEqual(closed.exception.errno, errno.EBADF) + + def test_hardlinked_manifest_rejected(self): + os.link(self.outside, self.tool / "toolyard.toml") + with self.assertRaises(ValueError): + manifest_files.write(self.tool, "replacement") + self.assertEqual(self.outside.read_text(), "unchanged") + + def test_failed_atomic_replace_preserves_original(self): + target = self.tool / "toolyard.toml" + target.write_text("original") + for operation in ("replace", "fsync"): + for code in (errno.ENOSPC, errno.EACCES, errno.EIO): + failure = OSError(code, "fake") + with self.subTest(operation=operation, code=code), mock.patch( + "admin.manifest_files.os." + operation, side_effect=failure): + with self.assertRaises(OSError) as caught: + manifest_files.write(self.tool, "replacement") + self.assertIs(caught.exception, failure) + self.assertEqual(target.read_text(), "original") + self.assertEqual(list(self.tool.iterdir()), [target]) + + def test_directory_body_errors_propagate_and_close_fd(self): + for code in (errno.ENOSPC, errno.EACCES, errno.EIO): + failure = OSError(code, "fake caller body") + with self.subTest(code=code), self.assertRaises(OSError) as caught: + with manifest_files.directory(self.tool) as fd: + os.fstat(fd) + raise failure + self.assertIs(caught.exception, failure) + with self.assertRaises(OSError) as closed: + os.fstat(fd) + self.assertEqual(closed.exception.errno, errno.EBADF) + + def test_directory_success_closes_fd(self): + with manifest_files.directory(self.tool) as fd: + os.fstat(fd) + with self.assertRaises(OSError) as closed: + os.fstat(fd) + self.assertEqual(closed.exception.errno, errno.EBADF) + + def test_atomic_write_private_file(self): + manifest_files.write(self.tool, "new") + self.assertEqual(manifest_files.read(self.tool), b"new") + self.assertEqual((self.tool / "toolyard.toml").stat().st_mode & 0o777, 0o600) diff --git a/admin/tests/test_caller_row_activation.py b/admin/tests/test_caller_row_activation.py new file mode 100644 index 0000000..452b895 --- /dev/null +++ b/admin/tests/test_caller_row_activation.py @@ -0,0 +1,43 @@ +"""Pure generated-markup contract; WebKit fixtures exercise the actual handlers.""" +import unittest +from html.parser import HTMLParser + +from admin.views.dashboard import _callers_section +from admin.views.layout import page + + +class Tags(HTMLParser): + def __init__(self, html): + super().__init__() + self.tags = [] + self.feed(html) + + def handle_starttag(self, tag, attrs): + self.tags.append((tag, dict(attrs))) + + +class CallerRowActivationTests(unittest.TestCase): + def test_rows_keep_named_keyboard_path_and_existing_destination(self): + for revoked in (None, 1): + html = _callers_section([dict(name='writer-demo', revoked_at=revoked)], 'fake') + tags = Tags(html).tags + row = next(attrs for tag, attrs in tags if tag == 'tr' and 'data-caller-policy' in attrs) + self.assertEqual(row['tabindex'], '0') + self.assertEqual(row['aria-label'], 'Open policy settings for writer-demo') + link = next(attrs for tag, attrs in tags if 'data-policy-link' in attrs) + self.assertEqual(link['href'], '/callers/writer-demo/policy') + self.assertEqual(sum(tag == 'button' for tag, _ in tags), 3) + + def test_empty_and_escaped_names(self): + self.assertNotIn('data-caller-policy', _callers_section([], 'fake')) + html = _callers_section([dict(name="a'<>\"", revoked_at=None)], 'fake') + rows = [attrs for tag, attrs in Tags(html).tags if 'data-caller-policy' in attrs] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['aria-label'], "Open policy settings for a'<>\"") + + def test_generated_script_guards_nested_actions_and_repeat(self): + html = page('Callers', _callers_section([], 'fake'), user=None) + self.assertIn("row.addEventListener('dblclick'", html) + self.assertIn("event.target.closest('a,button,input,select,textarea,form,summary", html) + self.assertIn("event.key==='Enter' && event.target===row && !event.repeat", html) + self.assertIn("row.querySelector('[data-policy-link]').click()", html) diff --git a/admin/tests/test_catalog_observation.py b/admin/tests/test_catalog_observation.py new file mode 100644 index 0000000..fe1cf8e --- /dev/null +++ b/admin/tests/test_catalog_observation.py @@ -0,0 +1,147 @@ +import json +import tempfile +import threading +import unittest +import urllib.request +from pathlib import Path +from unittest import mock + +from admin.broker_config import BrokerRunConfig +from admin.catalog_observation import observe_catalog +from broker.server import build_server + + +class CatalogObservation(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + self.folder = root / 'tools' / 'fake' + self.folder.mkdir(parents=True) + self.manifest = self.folder / 'toolyard.toml' + self.text = 'id="fake"\ntype="api"\n[entrypoint]\nport=43210\n[[operations]]\nname="read"\nrisk="read"\n' + self.manifest.write_text(self.text) + self.server = build_server(port=0, host='127.0.0.1', db_path=str(root/'test.db'), + tools_root=str(self.folder.parent), surface=mock.Mock()) + self.thread = threading.Thread(target=self.server.serve_forever) + self.thread.start() + self.addCleanup(self.close) + self.config = BrokerRunConfig(db_path=str(root/'test.db'), tools_root=str(self.folder.parent), + port=self.server.server_address[1]) + + def close(self): + self.server.shutdown() + self.thread.join(3) + self.server.server_close() + self.server.ctx.store.close() + + def health(self): + with urllib.request.urlopen(f'http://127.0.0.1:{self.config.port}/v1/health', timeout=2) as r: + return json.load(r) + + def test_actual_load_edit_reload_and_failed_reload(self): + first = self.health()['catalog_observation_id'] + self.assertEqual(observe_catalog(self.config)['status'], 'verified') + self.manifest.write_text(self.text.replace('risk="read"', 'risk="write"')) + self.assertEqual(observe_catalog(self.config), {'status': 'unknown'}) + self.server.reload_registry() + self.assertNotEqual(self.health()['catalog_observation_id'], first) + self.assertEqual(observe_catalog(self.config)['status'], 'verified') + self.manifest.write_text('invalid = [') + with self.assertRaises(ValueError): + self.server.reload_registry() + self.assertNotIn('catalog_observation_id', self.health()) + self.manifest.write_text(self.text) + self.assertEqual(observe_catalog(self.config), {'status': 'unknown'}) + + def test_cached_listing_does_not_prove_membership_after_reload(self): + from admin.toolyard_ops import list_tools + from admin.views.tools import tools_view + cached = list_tools(self.config.tools_root) + self.assertEqual(observe_catalog(self.config)['tool_ids'], ['fake']) + self.manifest.unlink() + self.server.reload_registry() + empty = observe_catalog(self.config) + self.assertEqual(empty['status'], 'verified') + self.assertEqual(empty['tool_ids'], []) + html = tools_view(user='fake',csrf='fake',tools=cached,tools_root=self.config.tools_root,catalog=empty) + self.assertNotIn('Catalog verified', html) + self.assertIn('Registration unknown', html) + self.manifest.write_text(self.text.replace('id="fake"','id="other"')) + self.server.reload_registry() + self.assertEqual(observe_catalog(self.config)['tool_ids'], ['other']) + self.manifest.write_text('id="fake"\ntype="api"\n[entrypoint]\nport=43210\n') + self.server.reload_registry() + zero_ops = observe_catalog(self.config) + self.assertEqual(zero_ops['tool_ids'], ['fake']) + self.assertEqual(self.server.ctx.registry.list_ops(), []) + self.assertIn('Catalog verified', tools_view(user='fake',csrf='fake',tools=cached,tools_root=self.config.tools_root,catalog=zero_ops)) + + def test_cached_row_rejects_legacy_and_malformed_membership(self): + from admin.catalog_observation import target_verified + from admin.toolyard_ops import list_tools + from admin.views.tools import tools_view + cached = list_tools(self.config.tools_root) + for ids in (None, 'fake', {}, [None], ['fake', 3], [], ['other']): + self.assertFalse(target_verified({'status':'verified','tool_ids':ids}, 'fake')) + html = tools_view(user='fake',csrf='fake',tools=cached,tools_root=self.config.tools_root, + catalog={'status':'verified','tool_ids':ids}) + self.assertNotIn('Catalog verified', html) + self.assertFalse(target_verified({'status':'verified'}, 'fake')) + self.assertTrue(target_verified({'status':'verified','tool_ids':['fake']}, 'fake')) + + def test_missing_mismatched_receipt_and_sources(self): + receipt = self.server.ctx.store.catalog_receipt() + for value in (None, {**receipt, 'observation_id': 'old-broker'}): + self.server.ctx.store.set_catalog_receipt(value) + self.assertEqual(observe_catalog(self.config), {'status': 'unknown'}) + self.server.ctx.store.set_catalog_receipt(receipt) + self.config.tool_dirs = [str(self.folder)] + self.assertEqual(observe_catalog(self.config), {'status': 'unknown'}) + self.config.tool_dirs = [] + self.config.tools_root = str(self.folder) + self.assertEqual(observe_catalog(self.config), {'status': 'unknown'}) + + def test_health_only_exposes_random_identity(self): + health = self.health() + self.assertEqual(set(health), {'status', 'catalog_observation_id'}) + encoded = json.dumps(health) + self.assertNotIn('fake', encoded) + self.assertNotIn(self.temp.name, encoded) + self.assertNotIn(self.server.ctx.store.catalog_receipt()['fingerprint'], encoded) + with mock.patch('admin.catalog_observation.urllib.request.urlopen', side_effect=OSError): + self.assertEqual(observe_catalog(self.config), {'status': 'unknown'}) + + def test_previous_process_receipt_cannot_certify_new_load(self): + old = self.server.ctx.store.catalog_receipt() + self.server.reload_registry() + self.server.ctx.store.set_catalog_receipt(old) + self.assertEqual(observe_catalog(self.config), {'status': 'unknown'}) + + def test_old_live_broker_and_concurrent_admin_reads(self): + from concurrent.futures import ThreadPoolExecutor + from dataclasses import replace + second = build_server(port=0, host='127.0.0.1', db_path=self.config.db_path, + tools_root=self.config.tools_root, surface=mock.Mock()) + thread = threading.Thread(target=second.serve_forever) + thread.start() + try: + # The old listener is still healthy, but no longer owns the private receipt. + self.assertEqual(observe_catalog(self.config), {'status': 'unknown'}) + current = replace(self.config, port=second.server_address[1]) + with ThreadPoolExecutor(max_workers=3) as pool: + observations = list(pool.map(observe_catalog, [current]*3)) + self.assertTrue(all(o['status'] == 'verified' for o in observations)) + finally: + second.shutdown() + thread.join(3) + second.server_close() + second.ctx.store.close() + + def test_secret_block_content_does_not_enter_private_fingerprint(self): + from broker.registry import Registry + self.manifest.write_text(self.text + '\n[[secrets]]\nname="fake_key"\nfield="FAKE_ONE"\n') + one = Registry.from_sources(self.config.tools_root).observation_fingerprint(self.config.tools_root) + self.manifest.write_text(self.text + '\n[[secrets]]\nname="fake_key"\nfield="FAKE_TWO"\n') + two = Registry.from_sources(self.config.tools_root).observation_fingerprint(self.config.tools_root) + self.assertEqual(one, two) diff --git a/admin/tests/test_dialog_logout.py b/admin/tests/test_dialog_logout.py new file mode 100644 index 0000000..c93db3f --- /dev/null +++ b/admin/tests/test_dialog_logout.py @@ -0,0 +1,57 @@ +"""Shared web/desktop dialog and sign-out regressions; private fake app settings.""" +import re +import unittest +from unittest import mock + +from admin.server import SESSION_COOKIE +from admin.tests import test_app +from admin.views.assets import _CSS, _JS + + +class DialogAssets(unittest.TestCase): + def test_both_dialogs_group_actions_with_focus_outline_clearance(self): + self.assertIn("actions.append(cancel,proceed);dialog.append(actions)", _JS) + self.assertIn("actions.append(stay,discard);d.append(actions)", _JS) + self.assertEqual(_JS.count("actions.className='actions'"), 2) + self.assertIn('dialog .actions{justify-content:flex-end;gap:20px;}', _CSS) + self.assertIn('.actions,.row{display:flex;', _CSS) + self.assertIn('outline:3px solid #0d6efd;outline-offset:3px;', _CSS) + self.assertIn("cancel.autofocus=true", _JS) + self.assertIn("stay.autofocus=true", _JS) + + +class SignOut(unittest.TestCase): + _restore_env = test_app.AdminApp._restore_env + _login = test_app.AdminApp._login + + def setUp(self): + with mock.patch('admin.server.SPSLink.from_env', return_value=None), \ + mock.patch('admin.server.start_reconciler_thread'): + test_app.AdminApp.setUp(self) + self.addCleanup(self.client.close) + + def test_signout_form_clears_session_and_get_logout_is_not_an_action(self): + # desktop/app.py loads this same admin URL without a separate logout handler. + self._login() + cookie = self.client.cookies.get(SESSION_COOKIE) + self.assertTrue(cookie) + response = self.client.get('/logout', follow_redirects=False) + self.assertEqual(response.status_code, 405) + self.assertEqual(response.json(), {'detail': 'Method Not Allowed'}) + self.assertEqual(response.headers['allow'], 'POST') + self.assertEqual(self.client.cookies.get(SESSION_COOKIE), cookie) + dashboard = self.client.get('/', follow_redirects=False) + self.assertEqual(dashboard.status_code, 200) + form = re.search(r"
(.*?)
", dashboard.text).group(1) + self.assertIn('Sign out', form) + response = self.client.post('/logout', data={'_csrf': test_app._csrf(form)}, follow_redirects=False) + self.assertEqual(response.status_code, 303) + self.assertEqual(response.headers['location'], '/login') + self.assertIn('Max-Age=0', response.headers['set-cookie']) + self.assertIsNone(self.client.cookies.get(SESSION_COOKIE)) + response = self.client.get('/', follow_redirects=False) + self.assertEqual(response.status_code, 303) + self.assertEqual(response.headers['location'], '/login') + self.assertEqual(self.client.get('/login').status_code, 200) + self.assertEqual(self.client.get('/logout').status_code, 405) + self.assertEqual(self.client.get('/', follow_redirects=False).headers['location'], '/login') diff --git a/admin/tests/test_policy_edits.py b/admin/tests/test_policy_edits.py new file mode 100644 index 0000000..430f995 --- /dev/null +++ b/admin/tests/test_policy_edits.py @@ -0,0 +1,26 @@ +import unittest + +from admin.policy_edits import merge_enabled, merge_grants + + +class PolicyEdits(unittest.TestCase): + def test_explicit_denial_preserves_unrendered_grants(self): + current = {"tools": {"notes": {"read": "allow", "write": "review"}, + "offline": {"fetch": "allow"}}} + allow, review, deny = merge_grants(current, {"op__notes__read": "deny"}) + self.assertEqual(allow, ["offline.fetch"]) + self.assertEqual(review, ["notes.write"]) + self.assertEqual(deny, ["notes.read"]) + self.assertEqual(current["tools"]["notes"]["read"], "allow") + + def test_unavailable_enabled_tools_survive_explicit_visible_disable(self): + current = {"enabled": ["offline", "notes"], "tools": {"cli-granted": {"read": "allow"}}} + self.assertEqual(merge_enabled(current, {"notes": []}, []), ["offline", "cli-granted"]) + + def test_invalid_effect_does_not_silently_remove_grants(self): + with self.assertRaises(ValueError): + merge_grants({"tools": {}}, {"op__notes__read": "invalid"}) + + def test_failed_read_is_not_an_empty_policy(self): + with self.assertRaises(AttributeError): + merge_grants(None, {"op__notes__read": "allow"}) diff --git a/admin/tests/test_reconciler.py b/admin/tests/test_reconciler.py index 4463987..3082c7e 100644 --- a/admin/tests/test_reconciler.py +++ b/admin/tests/test_reconciler.py @@ -21,6 +21,10 @@ def _record(tool_id="echo", backend="process", handle="123"): class ReconcileTests(unittest.TestCase): def setUp(self): + from contextlib import nullcontext + patcher = mock.patch("admin.toolyard_ops.state_lock", side_effect=nullcontext) + patcher.start() + self.addCleanup(patcher.stop) self.tmp = tempfile.mkdtemp(prefix="reconcile-") self.addCleanup(__import__("shutil").rmtree, self.tmp, ignore_errors=True) self._prev = os.environ.get("XDG_STATE_HOME") diff --git a/admin/tests/test_sps_watchdog.py b/admin/tests/test_sps_watchdog.py index c2b4396..00c57b9 100644 --- a/admin/tests/test_sps_watchdog.py +++ b/admin/tests/test_sps_watchdog.py @@ -4,6 +4,7 @@ import threading import time import unittest +from types import SimpleNamespace from unittest import mock from admin.sps_watchdog import SPSWatchdog @@ -29,7 +30,7 @@ def re_register(**kwargs): with self.re_register_lock: self.re_registered.append(kwargs) self.cfg_ev.set() - return 2 # pretend we re-registered 2 tools + return SimpleNamespace(complete=True) self.re_register = re_register self.wd = SPSWatchdog( @@ -150,7 +151,7 @@ def test_re_register_runs_under_the_lock(self) -> None: def re_register(**kwargs): held.append(lock.locked()) - return 1 + return SimpleNamespace(complete=True) wd = SPSWatchdog( config=self.cfg, diff --git a/admin/tests/test_tool_authoring.py b/admin/tests/test_tool_authoring.py index 4ce840a..f5f4688 100644 --- a/admin/tests/test_tool_authoring.py +++ b/admin/tests/test_tool_authoring.py @@ -31,7 +31,8 @@ "allowed_headers": ["X-Trace"], "body_kind": "none", "args": [{"name": "user_id", "type": "string", "required": True}]}, {"name": "login", "verb": "POST", "path": "/login", - "body_kind": "text", "body_content_type": "application/json", + "body_kind": "text", "body_content_type": "application/json", + "redact_response_body": False, "redact_response_headers": False, "secret_update_rules": [ {"secret_name": "auth_token", "response_type": "json", "extract_path": "session.token", "match_status": "2xx"}, @@ -166,7 +167,16 @@ def test_to_toml_writes_forwarder_contract_shape(self): self.assertEqual(parsed["operations"][1]["body_content_type"], "application/json") self.assertEqual(parsed["operations"][1]["secret_update_rules"][0]["secret_name"], "auth_token") - def test_redaction_flags_round_trip_and_default_off(self): + def test_writeback_requires_explicit_visibility_choices(self): + operation = dict(REST_FULL["operations"][1]) + operation.pop("redact_response_body") + operation.pop("redact_response_headers") + data = tool_authoring.normalize({**REST_FULL, "operations": [operation]}) + errors = tool_authoring.validate(data) + self.assertTrue(any("explicit redact_response_body" in e for e in errors)) + self.assertTrue(any("explicit redact_response_headers" in e for e in errors)) + + def test_redaction_flags_round_trip_with_explicit_writeback_choices(self): data = tool_authoring.normalize({**REST_FULL, "operations": [ {**REST_FULL["operations"][0], "redact_response_body": True, "redact_response_headers": True}, REST_FULL["operations"][1], @@ -174,9 +184,9 @@ def test_redaction_flags_round_trip_and_default_off(self): parsed = tomllib.loads(tool_authoring.to_toml(data)) self.assertTrue(parsed["operations"][0]["redact_response_body"]) self.assertTrue(parsed["operations"][0]["redact_response_headers"]) - # Off by default: omitted from the TOML rather than written as false. - self.assertNotIn("redact_response_body", parsed["operations"][1]) - self.assertNotIn("redact_response_headers", parsed["operations"][1]) + # Writeback visibility is an explicit author choice, including false. + self.assertIs(parsed["operations"][1]["redact_response_body"], False) + self.assertIs(parsed["operations"][1]["redact_response_headers"], False) # And the forwarder reads them back as the operation's flags. from toolstack_forwarder.config import load_config as load_rest_config tmp = tempfile.mkdtemp(prefix="admin-rest-redact-") diff --git a/admin/tests/test_tool_removal.py b/admin/tests/test_tool_removal.py new file mode 100644 index 0000000..72ec7b0 --- /dev/null +++ b/admin/tests/test_tool_removal.py @@ -0,0 +1,37 @@ +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from admin.broker_config import BrokerRunConfig +from admin.tool_removal import remove_tool + + +class ToolRemoval(unittest.TestCase): + def test_external_unregister_retains_files_and_other_sources(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + external = root/'external' + external.mkdir() + manifest = external/'toolyard.toml' + content = 'id="external"\ntype="api"\n[entrypoint]\nport=43211\n' + manifest.write_text(content) + config = BrokerRunConfig(db_path=str(root/'fake.db'), tools_root=str(root/'managed'), + tool_dirs=[str(external), str(root/'other')]) + with mock.patch('admin.tool_removal.toolyard_ops.stop') as stop, \ + mock.patch('admin.tool_removal.operator_audit.save_config') as save: + result = remove_tool(config, 'external', 'fake-admin') + self.assertEqual(result, {'removed': 'external', 'deleted': False}) + self.assertEqual(manifest.read_text(), content) + self.assertEqual(save.call_args.args[1].tool_dirs, [str(root/'other')]) + self.assertEqual(config.tool_dirs, [str(external), str(root/'other')]) + stop.assert_called_once_with('external') + + def test_unknown_ownership_refuses_before_effect(self): + config = BrokerRunConfig(tools_root='/fake/managed', tool_dirs=[]) + with mock.patch('admin.tool_removal.toolyard_ops.list_tools', return_value=[ + {'id': 'unknown', 'path': '/fake/elsewhere'}]), \ + mock.patch('admin.tool_removal.toolyard_ops.remove') as remove: + with self.assertRaisesRegex(ValueError, 'Ownership unknown for unknown'): + remove_tool(config, 'unknown', 'fake-admin') + remove.assert_not_called() diff --git a/admin/tests/test_tool_sources.py b/admin/tests/test_tool_sources.py index 2ca51d5..e3e2fa9 100644 --- a/admin/tests/test_tool_sources.py +++ b/admin/tests/test_tool_sources.py @@ -340,7 +340,9 @@ def test_swap_failure_restores_original_no_orphan(self): '[[operations]]\nname = "changed"\nrisk = "read"\n') real_replace = tool_sources.os.replace calls = {"n": 0} - def flaky(a, b): + def flaky(a, b, **kwargs): + if kwargs: # manifest publication is separate from directory swapping + return real_replace(a, b, **kwargs) calls["n"] += 1 if calls["n"] == 2: # fail moving the NEW version into place raise OSError("boom") diff --git a/admin/tests/test_toolyard_ops.py b/admin/tests/test_toolyard_ops.py index d7b1a32..0b6de26 100644 --- a/admin/tests/test_toolyard_ops.py +++ b/admin/tests/test_toolyard_ops.py @@ -3,6 +3,7 @@ toolyard's own runner tests, so this stays light (no process is spawned).""" import os +from contextlib import nullcontext import shutil import tempfile import unittest @@ -18,6 +19,9 @@ class ToolyardOps(unittest.TestCase): def setUp(self): + patcher = mock.patch("admin.toolyard_ops.state_lock", side_effect=nullcontext) + patcher.start() + self.addCleanup(patcher.stop) self.tmp = tempfile.mkdtemp(prefix="admin-ty-") self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) self._prev = os.environ.get("XDG_STATE_HOME") diff --git a/admin/tests/test_web_review_recovery.py b/admin/tests/test_web_review_recovery.py new file mode 100644 index 0000000..b79a005 --- /dev/null +++ b/admin/tests/test_web_review_recovery.py @@ -0,0 +1,107 @@ +"""Fake-only policy boundary regressions, using the existing isolated app fixture.""" +import unittest +from pathlib import Path +from unittest import mock + +from admin.tests import test_app +from admin.views.callers import caller_tools_view + + +class WebRecovery(unittest.TestCase): + setUp = test_app.AdminApp.setUp + _restore_env = test_app.AdminApp._restore_env + _store = test_app.AdminApp._store + _login = test_app.AdminApp._login + + def prepare(self, policy): + self._login() + self.csrf = test_app._csrf(self.client.get('/').text) + self.client.post('/callers', data={'name': 'writer', '_csrf': self.csrf}) + self.store = self._store() + self.caller = self.store.caller_by_name('writer')['id'] + self.store.set_policy(self.caller, policy) + + def post(self, fields, json=True): + return self.client.post('/callers/writer/policy', + headers={'Accept': 'application/json'} if json else {}, + data={'_csrf': self.csrf, **fields}, follow_redirects=False) + + def test_disabled_since_load_rejected_without_effect(self): + self.prepare({'enabled': ['echo'], 'tools': {'echo': {'say': 'deny'}}}) + self.client.get('/callers/writer/policy') + self.store.set_policy(self.caller, {'enabled': [], 'tools': {}}) + with mock.patch('admin.server.operations.set_policy') as effect: + response = self.post({'op__echo__say': 'allow'}) + self.assertEqual(response.json()['outcome'], 'rejected') + effect.assert_not_called() + self.assertEqual(self.store.policy_for(self.caller)['tools'], {}) + + def test_exact_double_underscore_and_unseen_grants(self): + self.prepare({'enabled': ['fake__tool', 'offline'], 'tools': { + 'fake__tool': {'read': 'deny', 'hidden': 'review'}, 'offline': {'read': 'allow'}}}) + tool = Path(self.tmp, 'tools', 'fake__tool') + tool.mkdir() + (tool / 'toolyard.toml').write_text('id = "fake__tool"\ntype = "api"\n[entrypoint]\nport = 4602\n[[operations]]\nname = "read"\nrisk = "read"\n') + response = self.post({'op__fake__tool__read': 'allow'}) + self.assertEqual(response.json()['outcome'], 'saved') + policy = self.store.policy_for(self.caller)['tools'] + self.assertEqual(policy['fake__tool']['read'], 'allow') + self.assertNotIn('fake', policy) + self.assertEqual(policy['fake__tool']['hidden'], 'review') + self.assertEqual(policy['offline']['read'], 'allow') + + def test_collision_and_noncatalog_rejected(self): + self.prepare({'enabled': ['fake', 'fake__tool'], 'tools': {}}) + catalog = {'fake': [{'op': 'tool__read'}], 'fake__tool': [{'op': 'read'}]} + ops = [dict(op, tool=tool) for tool, entries in catalog.items() for op in entries] + with mock.patch('admin.server.Registry.from_sources') as registry, mock.patch('admin.server.operations.set_policy') as effect: + registry.return_value.list_ops.return_value = ops + for field in ('op__fake__tool__read', 'op__fake__missing'): + self.assertEqual(self.post({field: 'allow'}).json()['outcome'], 'rejected') + effect.assert_not_called() + + def test_csrf_html_retains_safe_posted_values(self): + self.prepare({'enabled': ['echo'], 'tools': {'echo': {'say': 'deny'}}}) + response = self.post({'_csrf': 'invalid-fake', 'op__echo__say': 'review'}, json=False) + self.assertEqual(response.status_code, 403) + self.assertIn('Save rejected', response.text) + self.assertIn("value='review' selected", response.text) + self.assertEqual(self.store.policy_for(self.caller)['tools']['echo']['say'], 'deny') + + def test_scope_html_retains_only_valid_edits_and_gates_save(self): + self.prepare({'enabled': ['echo'], 'tools': {'echo': {'say': 'deny'}}}) + response = self.post({'op__echo__say': 'review', 'op__gone__write': 'allow'}, json=False) + self.assertEqual(response.status_code, 400) + self.assertIn('Operation set changed', response.text) + self.assertIn("value='review' selected", response.text) + self.assertNotIn('op__gone__write', response.text) + self.assertIn("type='submit' disabled", response.text) + + def test_auth_redirect_precedes_effect(self): + self.prepare({'enabled': ['echo'], 'tools': {}}) + self.client.cookies.clear() + with mock.patch('admin.server.operations.set_policy') as effect: + response = self.post({'op__echo__say': 'allow'}) + self.assertEqual(response.headers['location'], '/login') + effect.assert_not_called() + + def test_html_invalid_effect_rejected_without_inventing_grants(self): + self.prepare({'enabled': ['echo'], 'tools': {'echo': {'say': 'deny'}}}) + response = self.post({'op__echo__say': 'bogus'}, json=False) + self.assertEqual(response.status_code, 400) + self.assertIn('Save rejected', response.text) + self.assertIn("value='deny' selected", response.text) + self.assertEqual(self.store.policy_for(self.caller)['tools']['echo']['say'], 'deny') + + def test_html_after_effect_failure_stays_unknown_and_gated(self): + self.prepare({'enabled': ['echo'], 'tools': {'echo': {'say': 'deny'}}}) + with mock.patch('admin.server.operations.set_policy', side_effect=RuntimeError('fake failure')): + response = self.post({'op__echo__say': 'review'}, json=False) + self.assertEqual(response.status_code, 503) + self.assertIn('Save outcome unknown', response.text) + self.assertIn("value='review' selected", response.text) + self.assertIn("type='submit' disabled", response.text) + + def test_checkbox_name_escaped(self): + html = caller_tools_view(user='fake', csrf='fake', caller="writer'", all_tools=[('fakeb.textContent==='Reload to compare'); +window.fetch=async()=>{throw Error('comparison failed');};compare.hidden=false;compare.click(); +await wait();check(save.disabled,'failed comparison locks Save'); +window.fetch=async()=>({ok:true,text:async()=>original.replaceAll('writer-demo','other-target')}); +compare.click();await wait();check(save.disabled,'mismatched comparison scope locks Save'); +window.fetch=async()=>({ok:true,text:async()=>original});compare.click();await wait(); +check(!save.disabled,'fresh successful comparison unlocks explicit save'); +select.value='allow';select.dispatchEvent(new Event('change',{bubbles:true})); +const signout=document.querySelector('form[action="/logout"]');let signouts=0; +signout.addEventListener('submit',e=>{if(!e.defaultPrevented)signouts++;e.preventDefault();}); +signout.requestSubmit();check(document.activeElement.textContent==='Stay','signout defaults to Stay'); +document.activeElement.click();await wait();check(signouts===0&&select.value==='allow','signout Stay retains draft without submitting'); +signout.requestSubmit();Array.from(document.querySelector('dialog[open]').querySelectorAll('button')).find(b=>b.textContent==='Discard draft').click(); +await wait();check(signouts===1,'signout Discard submits exactly once without capture loop'); +""" + +CALLER_ROWS = r""" +window.fixtureNavigations=[]; +const rows=document.querySelectorAll('[data-caller-policy]'), row=rows[0]; +const doubleClick=el=>el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true})); +doubleClick(row.cells[0]);await wait(150); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/policy','row double-click navigates to caller policy'); +window.fixtureNavigations=[]; +for(const el of row.querySelectorAll('button,a,input,form'))doubleClick(el); +await wait(150);check(window.fixtureNavigations.length===0,'nested row controls never trigger double-click navigation'); +row.focus();check(document.activeElement===row && !!row.getAttribute('aria-label'),'caller row focusable and named'); +row.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));await wait(150); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/policy','Enter on focused row navigates to caller policy'); +window.fixtureNavigations=[]; +row.querySelector('button').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true})); +await wait();check(window.fixtureNavigations.length===0,'nested keyboard action does not navigate'); +doubleClick(rows[1].cells[1]);await wait(150); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/revoked-demo/policy','revoked row retains existing policy destination'); +""" + +CONFIRM = r""" +const form=document.querySelector('form[data-confirm]');let posts=0, resolve; +window.fetch=()=>{posts++;return new Promise(r=>resolve=r);}; +form.requestSubmit();let d=document.querySelector('dialog[open]'); +check(!!d&&document.activeElement.textContent==='Cancel','confirmation defaults to Cancel'); +check(!!d.getAttribute('aria-label'),'confirmation dialog has accessible name'); +check(d.textContent.includes('notes-demo'),'confirmation names target'); +document.activeElement.click();await wait();check(posts===0&&!document.querySelector('dialog[open]'),'Cancel has no effect'); +form.requestSubmit();d=document.querySelector('dialog[open]'); +// Synthetic cancel event is not proof of trusted Escape dispatch. +d.dispatchEvent(new Event('cancel',{cancelable:true}));d.close();await wait(); +check(posts===0&&!document.querySelector('dialog[open]'),'synthetic Escape-equivalent close has no effect'); +form.requestSubmit();d=document.querySelector('dialog[open]');d.querySelectorAll('button')[1].click(); +form.requestSubmit();form.requestSubmit();check(posts===1,'accepted confirmation plus duplicate submits sends one POST'); +resolve({ok:false,status:500,url:form.action,text:async()=>''});await wait(); +form.requestSubmit();check(posts===1,'unknown outcome cannot replay POST'); +check(document.body.textContent.includes('Outcome unknown'),'unknown mutation visible'); +check(document.querySelector('form[data-confirm] a').getAttribute('href').includes('notes-demo'),'unknown refresh retains target'); +""" + +UNKNOWN_HTML = r""" +const form=document.querySelector('form[data-mutation]');let posts=0; +window.fetch=async()=>{posts++;return {ok:true,status:200,url:form.action,text:async()=>window.RETURNED_PAGE};}; +form.requestSubmit();await wait(150); +check(document.body.textContent.includes('outcome unknown')||document.body.textContent.includes('Outcome unknown'),'returned unknown stage remains visible'); +const retry=document.querySelector('form[data-mutation]'); +check(!!retry&&retry.querySelector('button').disabled,'200 unknown stage cannot immediately retry without observation'); +if(retry)retry.requestSubmit();await wait();check(posts===1,'returned unknown stage sends no duplicate mutation'); +const alternate=document.querySelector('form[action$="/restart"]'); +alternate.requestSubmit();const dialog=document.querySelector('dialog[open]'); +if(dialog)dialog.querySelectorAll('button')[1].click();await wait(); +check(posts===1,'unknown target cannot bypass gate via another lifecycle form'); +if(dialog)dialog.close(); +""" + +REPLACEMENT = r""" +let posts=0; +window.fetch=async()=>{posts++;return {ok:true,status:200,url:'https://fixture.invalid/tools?tool=notes-demo',text:async()=>window.RETURNED_PAGE};}; +document.querySelector('form[data-mutation]').requestSubmit();await wait(150); +const form=document.querySelector('form[data-confirm]'); +check(!!form,'replacement keeps focused recovery form'); +form.requestSubmit();check(!!document.querySelector('dialog[open]'),'document.write replacement initializes production scripts'); +check(document.activeElement.textContent==='Cancel','replacement dialog has safe focus'); +check(posts===1,'opening replacement confirmation does not send effect'); +check(document.querySelector('a[href="/tools?tool=notes-demo"]')!==null,'replacement preserves navigation target'); +document.querySelector('dialog[open]').close(); +""" + +def instrument(html, checks, returned=None): + setup = ('window.RETURNED_PAGE=' + json.dumps(returned).replace('document.addEventListener('DOMContentLoaded',async()=>{ + const results=[];const check=(ok,name)=>results.push({name,ok:!!ok}); + const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms)); + const checkDialogButtons=dialog=>{ + const buttons=Array.from(dialog.querySelectorAll('button')); + check(buttons.length===2,'open dialog has two actions'); + const bounds=buttons.map(button=>{ + const r=button.getBoundingClientRect(),s=getComputedStyle(button); + const outline=s.outlineStyle==='none'?0:parseFloat(s.outlineWidth)+parseFloat(s.outlineOffset); + return {left:r.left-outline,right:r.right+outline,top:r.top-outline,bottom:r.bottom+outline}; + }); + for(let i=0;i=8, + 'open dialog buttons separated by at least 8px including focus outline'); + } + }; + SETUP + try { CHECKS }catch(error){check(false,String(error));} + await wait(); + const visible=Array.from(document.querySelectorAll('button,input,select,a.button,dialog[open]')).filter(e=>{ + if(!e.getClientRects().length)return false; + for(let p=e.parentElement;p;p=p.parentElement){ + if(p.tagName==='DETAILS'&&!p.open&&!p.querySelector('summary').contains(e))return false; + } + return true; + }); + const rectangles=visible.map(e=>{const r=e.getBoundingClientRect();return {tag:e.tagName,label:(e.textContent||e.name||e.id).slice(0,100),x:r.x,y:r.y,width:r.width,height:r.height};}); + const horizontal=rectangles.filter(r=>r.x < -1 || r.x+r.width > innerWidth+1); + check(document.documentElement.scrollWidth<=innerWidth,'no horizontal document overflow'); + check(horizontal.length===0,'visible controls fit horizontal viewport'); + const selects=visible.filter(e=>e.tagName==='SELECT'); + for(const select of selects){ + check(select.getBoundingClientRect().height>=38,'select target at least 38px: '+(select.name||select.className)); + if(select.name.startsWith('op__'))check(select.getAttribute('aria-label')==='Effect for writer-demo / notes-demo / read_note','policy effect names caller/tool/operation'); + } + for(const button of visible.filter(e=>e.matches('button,a.button'))){ + check(button.getBoundingClientRect().height>=38,'button target at least 38px: '+button.textContent.trim()); + } + // Vertical scrolling is recorded rather than treated as blanket clipping failure. + const below=rectangles.filter(r=>r.y+r.height>innerHeight); + const collisions=[]; + const buttons=visible.filter(e=>e.tagName==='BUTTON'&&!e.closest('dialog:not([open])')); + for(let i=0;i1&&Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top)>1) + collisions.push([buttons[i].textContent,buttons[j].textContent]); + } + // Modal-over-background containment is intentional; only non-modal frames use this check. + if(!document.querySelector('dialog[open]'))check(collisions.length===0,'no button-button overlap'); + document.querySelectorAll('dialog[open]').forEach(checkDialogButtons); + window.webkit.messageHandlers.proof.postMessage({results,rectangles,horizontal,below,collisions, + viewport:{width:innerWidth,height:innerHeight},synthetic:true,html:document.documentElement.outerHTML}); + });""".replace('SETUP', setup).replace('CHECKS', checks) + return html.replace('', script+'') if '' in html else html.replace('', script+'') + + +RECOVERY = r""" +const form=document.querySelector('#policy-form'), select=form.querySelector('select'), save=form.querySelector('[type=submit]'); +const original=document.documentElement.outerHTML, compare=Array.from(form.querySelectorAll('button')).find(b=>b.textContent==='Reload to compare'); +let posts=0, parses=0; +check(!Array.from(form.querySelectorAll('a')).find(a=>a.textContent==='Load current policy page').getClientRects().length,'current-page recovery hidden until scope change'); +select.value='allow';select.dispatchEvent(new Event('change',{bubbles:true})); +window.fetch=async(url,options)=>{if(options.method==='POST')posts++;return {ok:true,url:'https://fixture.invalid/login',json:async()=>{parses++;throw Error('HTML');},text:async()=>{parses++;return '
login
';}};}; +form.requestSubmit();await wait(); +check(posts===1&&parses===0,'save login redirect detected before JSON parsing'); +check(save.disabled&&select.value==='allow'&&form.textContent.includes('Save rejected'),'expired save known rejected and draft retained'); +check(document.activeElement===compare,'recovery receives focus'); +compare.click();await wait(); +check(parses===0&&save.disabled&&form.textContent.includes('Sign in in another tab'),'compare expiry detected before HTML parsing with recovery guidance'); +check(!form.textContent.includes('Save outcome unknown'),'expiry alone does not invent unknown mutation'); +window.fetch=async()=>({ok:true,text:async()=>original.replaceAll('value="fake"','value="fresh-fake"')}); +compare.click();await wait(); +check(!save.disabled&&select.value==='allow'&&form.querySelector('[name=_csrf]').value==='fresh-fake','reauth compare refreshes CSRF and retains draft'); +check(posts===1,'reauth comparison never replays save'); +check(!Array.from(form.querySelectorAll('a')).find(a=>a.textContent==='Load current policy page').getClientRects().length,'current-page recovery hidden after successful comparison'); +window.fetch=async()=>{posts++;throw Error('lost receipt');};form.requestSubmit();await wait(); +window.fetch=async()=>({ok:true,url:'https://fixture.invalid/login'});compare.click();await wait(); +check(save.disabled&&form.textContent.includes('Save outcome unknown'),'compare expiry preserves prior genuine unknown'); +window.fetch=async()=>({ok:true,text:async()=>original.replaceAll('op__notes-demo__read_note','op__notes-demo__changed')});compare.click();await wait(); +const current=Array.from(form.querySelectorAll('a')).find(a=>a.textContent==='Load current policy page'); +check(save.disabled&&select.value==='allow'&&!current.hidden&&form.textContent.includes('Operation set changed'),'permanent scope mismatch offers current page without overwriting draft'); +check(document.activeElement===current&¤t.href===form.action,'current-policy recovery focused and correctly targeted'); +current.click();let d=document.querySelector('dialog[open]'); +check(!!d&&!!d.getAttribute('aria-label')&&document.activeElement.textContent==='Stay','current page departure uses named safe Stay dialog'); +document.activeElement.click();await wait();check(select.value==='allow'&&posts===2,'Stay preserves draft; no replay'); +""" + + +def recovery_fixtures(): + policy = policy_view(user='fake-admin', csrf='fake', caller='writer-demo', + ops_by_tool={'notes-demo': [{'op': 'read_note', 'risk': 'read'}]}, + current={'tools': {'notes-demo': {'read_note': 'deny'}}}) + recovery = tools_view(user='fake-admin', csrf='fake', tools=[dict(id='notes-demo', + port=4700, path='/fake/notes', running=True, alive=False, removable=False)], + tools_root='/fake', focused=True, broker_port=8765) + return [dict(name='policy-recovery-1180', html=instrument(policy, RECOVERY), width=1180, height=900), + dict(name='confirmation-recovery-1180', html=instrument(recovery, CONFIRM), width=1180, height=900)] + + +def fixtures(): + args = dict(user='fake-admin', csrf='fake') + policy = policy_view(**args, caller='writer-demo', ops_by_tool={'notes-demo':[{'op':'read_note','risk':'read'}]}, current={'tools':{'notes-demo':{'read_note':'deny'}}}) + failed = policy_view(**args, caller='writer-demo', ops_by_tool={}, current={}, loaded=False, error='Policy could not be loaded. Retry load.') + editor = tool_editor_view(**args, mode='edit', dir_value='/fake/notes',tool={'id':'notes-demo','type':'api','port':4700,'operations':[{'name':'absent'},{'name':'empty','approval_fields':[]},{'name':'explicit','approval_fields':['message']}]}) + tool = dict(id='notes-demo',port=4700,path='/fake/notes',running=True,alive=False,removable=False) + recovery = tools_view(**args, tools=[tool],tools_root='/fake',focused=True,broker_port=8765) + unknown = tools_view(**args, tools=[tool],tools_root='/fake',focused=True,broker_port=8765,error='notes-demo · restart outcome unknown. Refresh state before another action; nothing was replayed.',mutation_outcome='unknown') + registered = tools_view(**args, tools=[{**tool,'alive':True}],tools_root='/fake',focused=True,broker_port=8765,catalog={'status':'verified','tool_ids':['notes-demo']},banner='notes-demo · Catalog registration verified.') + added = tools_view(**args, tools=[{**tool,'running':False}],tools_root='/fake',focused=True,broker_port=8765,banner='Added notes-demo. Start executes this trusted local tool. Catalog registration and caller grants remain separate decisions.') + registration = tools_view(**args, tools=[{**tool,'alive':True}],tools_root='/fake',focused=True,broker_port=8765) + specs = [ + ('caller-rows',page('Callers',_callers_section([{'name':'writer-demo','revoked_at':None},{'name':'revoked-demo','revoked_at':1}],'fake'),**args),CALLER_ROWS,None), + ('policy',policy,POLICY+POLICY_MORE,None), + ('load-failure',failed,"check(document.querySelector('#policy-form [type=submit]').disabled,'failed load locks Save');check(document.body.textContent.includes('Retry load'),'failed load offers Retry');",None), + ('editor',editor,EDITOR,None), + ('confirmation',recovery,CONFIRM,None), + ('returned-unknown',recovery,UNKNOWN_HTML,unknown), + ('replacement',recovery,REPLACEMENT,recovery), + ('add',tool_add_view(**args),"check(document.querySelector('[name=source]')!==null,'local host source remains editable');check(document.querySelector('form').method==='post','source action remains functional');",None), + ('registered',registered,"check(document.body.textContent.includes('Catalog registration verified'),'registration receipt names success');check(!document.querySelector('[name=catalog_target]'),'verified catalog does not offer redundant registration');",None), + ('added-stopped',added,"check(!document.querySelector('form[action$=\"/start\"] button').disabled,'added tool can be explicitly started');check(document.body.textContent.includes('Registration unknown'),'copy does not imply catalog registration');",None), + ('register-confirm',registration,"document.querySelector('form[action=\"/broker/restart\"]').requestSubmit();check(document.querySelector('dialog[open]').textContent.includes('127.0.0.1:8765 for notes-demo'),'registration confirmation names broker and target');check(document.querySelector('dialog[open]').textContent.includes('interrupts'),'registration warns of interruption');check(document.activeElement.textContent==='Cancel','registration defaults to Cancel');",None), + ] + result=[] + confirmation = page('Confirmation', '
', user=None) + for width in (1180,360): + result.append(dict(name=f'confirmation-open-{width}',html=instrument(confirmation, + "document.querySelector('form[data-confirm]').requestSubmit();check(document.activeElement.textContent==='Cancel','confirmation defaults to Cancel');"),width=width,height=820)) + for width in (1180,620): + result.append(dict(name=f'draft-open-{width}',html=instrument(policy, + "const select=document.querySelector('#policy-form select');select.value='allow';document.querySelector('form[action=\"/logout\"]').requestSubmit();check(document.activeElement.textContent==='Stay','draft defaults to Stay');"),width=width,height=820)) + for name,html,checks,returned in specs: + for width in (1440,1180): + result.append(dict(name=f'{name}-{width}',html=instrument(html,checks,returned),width=width,height=900)) + result.append(dict(name='shell-startup-620',html=instrument(startup_error('http://127.0.0.1:8780','Admin exited with status 7 before /login responded.'),"check(document.body.textContent.includes('status 7'),'shell observed cause');check(document.querySelector('#retry')!==null,'shell Retry available');let retries=0;window.pywebview={api:{retry:()=>{retries++;}}};document.querySelector('#retry').click();document.querySelector('#retry').click();check(retries===1,'shell Retry button latches synthetic duplicate clicks');"),width=620,height=360)) + return result + + +if __name__ == '__main__': + Path(sys.argv[1]).write_text(json.dumps(fixtures())) diff --git a/admin/tool_authoring.py b/admin/tool_authoring.py index 32bf2f3..d2f2395 100644 --- a/admin/tool_authoring.py +++ b/admin/tool_authoring.py @@ -17,6 +17,8 @@ import tomllib from pathlib import Path from urllib.parse import urlsplit +from broker.approval_summary import validate_fields +from . import manifest_files # The risk taxonomy: read / write / destructive. (One vocabulary, no low/medium/high.) RISK_CHOICES = ("read", "write", "destructive") @@ -61,6 +63,8 @@ def s(x) -> str: tool_type = s(data.get("type")) or "api" operations = [] for o in data.get("operations") or []: + # Preserve presence as well as the raw value; validation must not coerce it. + approval = {"approval_fields": o["approval_fields"]} if "approval_fields" in o else {} name = s(o.get("name")) if not name: continue @@ -70,6 +74,7 @@ def s(x) -> str: allowed_headers = [s(h) for h in (o.get("allowed_headers") or []) if s(h)] path_template = s(o.get("path")) operations.append({ + **approval, "name": name, "risk": REST_VERB_RISK.get(verb, "write"), "description": s(o.get("description")), @@ -79,8 +84,8 @@ def s(x) -> str: "body_kind": body_kind, "body_content_type": s(o.get("body_content_type")), "body_substitution": bool(o.get("body_substitution", body_kind == "text")), - "redact_response_body": bool(o.get("redact_response_body")), - "redact_response_headers": bool(o.get("redact_response_headers")), + "redact_response_body": o.get("redact_response_body"), + "redact_response_headers": o.get("redact_response_headers"), "secret_update_rules": _norm_secret_update_rules(o.get("secret_update_rules")), "args": _rest_envelope_args(path_template, allowed_headers, body_kind), }) @@ -97,6 +102,7 @@ def s(x) -> str: "description": s(a.get("description")), }) operations.append({ + **approval, "name": name, "risk": s(o.get("risk")) or "read", "description": s(o.get("description")), @@ -230,6 +236,11 @@ def validate(data: dict) -> list[str]: errors.append("add at least one operation") seen = set() for o in data["operations"]: + if "approval_fields" in o: + try: + validate_fields(o["approval_fields"]) + except ValueError: + errors.append("approval_fields must be <=32 unique non-root RFC6901 pointers (<=256 characters)") if not _NAME_RE.match(o["name"]): errors.append(f"operation name '{o['name']}' may contain only letters, digits and _") if o["name"] in seen: @@ -267,6 +278,11 @@ def validate(data: dict) -> list[str]: errors.append(f"secret '{sec['name']}' path has invalid characters") if data["type"] == "rest": for o in data["operations"]: + for flag in ("redact_response_body", "redact_response_headers"): + if o.get("secret_update_rules") and not isinstance(o.get(flag), bool): + errors.append(f"rest operation '{o['name']}' writeback needs explicit {flag} decision") + elif o.get(flag) is not None and not isinstance(o[flag], bool): + errors.append(f"rest operation '{o['name']}' {flag} must be boolean") for rule in o.get("secret_update_rules", []): if rule.get("secret_name") not in writable: errors.append( @@ -357,6 +373,9 @@ def to_toml(data: dict) -> str: out.append(f"port = {data['port']}") for o in data["operations"]: out += ["", "[[operations]]", f"name = {_s(o['name'])}"] + if "approval_fields" in o: + fields = validate_fields(o["approval_fields"]) + out.append("approval_fields = [" + ", ".join(_s(p) for p in fields) + "]") if data["type"] == "rest": out.append(f"verb = {_s(o['verb'])}") out.append(f"path = {_s(o['path'])}") @@ -373,10 +392,9 @@ def to_toml(data: dict) -> str: out.append(f"body_content_type = {_s(o['body_content_type'])}") if bool(o.get("body_substitution", o.get("body_kind") == "text")) != (o.get("body_kind") == "text"): out.append(f"body_substitution = {str(bool(o.get('body_substitution'))).lower()}") - if o.get("redact_response_body"): - out.append("redact_response_body = true") - if o.get("redact_response_headers"): - out.append("redact_response_headers = true") + for flag in ("redact_response_body", "redact_response_headers"): + if isinstance(o.get(flag), bool): + out.append(f"{flag} = {str(o[flag]).lower()}") if o.get("secret_update_rules"): out.append("secret_update_rules = [ " + ", ".join( _rule_inline(r) for r in o["secret_update_rules"]) + " ]") @@ -391,8 +409,7 @@ def to_toml(data: dict) -> str: def read(dir_path: str | Path) -> dict: """Load an existing tool's toolyard.toml into the editor's dict shape.""" - with open(Path(dir_path) / "toolyard.toml", "rb") as f: - data = tomllib.load(f) + data = tomllib.loads(manifest_files.read(dir_path).decode("utf-8")) entry = data.get("entrypoint", {}) return normalize({ "id": data.get("id", ""), @@ -421,5 +438,5 @@ def write(dir_path: str | Path, data: dict, runner: str | None = None) -> Path: if ep_err: raise ValueError(ep_err) target = path / "toolyard.toml" - target.write_text(to_toml(data), encoding="utf-8") + manifest_files.write(path, to_toml(data)) return target diff --git a/admin/tool_removal.py b/admin/tool_removal.py new file mode 100644 index 0000000..6bb46af --- /dev/null +++ b/admin/tool_removal.py @@ -0,0 +1,31 @@ +"""One ownership-sensitive remove operation for HTML and JSON adapters.""" +from dataclasses import replace +from pathlib import Path + +from broker import operations +from . import toolyard_ops, operator_audit +from .store_access import open_store + + +def remove_tool(config, tool_id, user): + try: + tools = {t['id']: t for t in toolyard_ops.list_tools(config.tools_root, config.tool_dirs)} + except (OSError, ValueError): + raise ValueError(f'Could not read tools. Check tool manifests before removing {tool_id}.') from None + if tool_id not in tools: + raise LookupError(f'no such tool: {tool_id}') + path = Path(tools[tool_id]['path']).resolve() + matches = [p for p in config.tool_dirs if Path(p).resolve() == path] + if matches: + toolyard_ops.stop(tool_id) + updated = replace(config, tool_dirs=[p for p in config.tool_dirs if Path(p).resolve() != path]) + operator_audit.save_config(config, updated, user) + deleted = False + else: + if not config.tools_root or path.parent != Path(config.tools_root).resolve(): + raise ValueError(f'Ownership unknown for {tool_id}; folder was not removed') + toolyard_ops.remove(tool_id, config.tools_root, config.tool_dirs) + deleted = True + with open_store(config) as store: + operations.record_admin_event(store, user, 'tool_removed', {'tool': tool_id, 'deleted': deleted}) + return {'removed': tool_id, 'deleted': deleted} diff --git a/admin/tool_sources.py b/admin/tool_sources.py index 9ca9aaf..fdfce57 100644 --- a/admin/tool_sources.py +++ b/admin/tool_sources.py @@ -21,7 +21,7 @@ from toolyard.config import load as load_tool -from . import tool_authoring +from . import tool_authoring, manifest_files SIDECAR = ".tsr-source.json" # Don't drag a source's VCS/build cruft (or a stale sidecar) into the managed copy. @@ -90,7 +90,9 @@ def _clone_into(tmp: Path, url: str, branch: str, sub: str) -> Path: raise ValueError("git is not installed on the admin's machine") if sub.startswith("..") or "/.." in sub or sub.startswith("/"): raise ValueError("subdir must be a relative path inside the repo") - clone = tmp / "repo" + # Canonicalize our own private temp directory (macOS /var is an alias), + # not an operator-controlled tool directory or imported manifest. + clone = tmp.resolve() / "repo" cmd = ["git", "clone", "--depth", "1"] if branch: cmd += [f"--branch={branch}"] # '=' binds the value, so a ref can't be read as a flag @@ -137,6 +139,7 @@ def update(dir_path: str | Path) -> dict: (source.get("subdir") or "").strip().strip("/")) else: raise ValueError(f"unknown source type: {kind!r}") + manifest_files.validate(new_dir) if not (new_dir / "toolyard.toml").exists(): raise NoManifest(str(new_dir)) merged = tool_authoring.read(new_dir) # upstream operations + entrypoint @@ -164,13 +167,13 @@ def _swap_in_place(dest: Path, new_dir: Path, merged: dict, source: dict) -> Non """Replace ``dest``'s contents with ``new_dir``'s files + the merged manifest + the source sidecar, via a staged rename so a mid-operation failure leaves the original tool intact. - Staging/backup live in ``/.tsr-staging/``: one level deeper than the tools, so the + Staging/backup live in a unique ``/.tsr-staging-*/``: one level deeper than the tools, so the ``*/toolyard.toml`` discovery glob never sees them (a half-built or crash-orphaned copy can't be mistaken for, or shadow, a real tool), while staying on the same filesystem so ``os.replace`` is atomic (no cross-device rename).""" - work = dest.parent / ".tsr-staging" - shutil.rmtree(work, ignore_errors=True) # clear any orphan from a previous crashed update - work.mkdir(parents=True, exist_ok=True) + # Concurrent imports must not clear one another's staging/backup trees. + # Nested manifests remain invisible to one-level tool discovery. + work = Path(tempfile.mkdtemp(prefix=".tsr-staging-", dir=dest.parent)) staging = work / dest.name backup = work / (dest.name + ".old") try: @@ -208,6 +211,7 @@ def _clone_error(exc: subprocess.CalledProcessError) -> str: def _ingest(tool_dir: Path, root: Path, existing_ids, meta: dict) -> dict: """Validate the tool at ``tool_dir`` and copy it into ``root/`` with a source sidecar. Shared by add_from_path / add_from_github. Raises ``NoManifest`` / ``ValueError``.""" + manifest_files.validate(tool_dir) if not (tool_dir / "toolyard.toml").exists(): raise NoManifest(str(tool_dir)) tool = tool_authoring.read(tool_dir) # normalized dict (id/description/ops/secrets/...) @@ -260,12 +264,17 @@ def _copy_tool_dir(src: Path, root: Path, tool_id: str) -> Path: """Copy ``src`` into ``root/`` (rejecting a clash), rolling back a half-copy on failure. ``symlinks=True`` copies links verbatim rather than dereferencing them, so a link in the source can't materialize outside-file CONTENT into the managed (possibly synced) tools dir.""" + manifest_files.validate(src) dest = root / tool_id if dest.exists(): raise ValueError(f"a folder named '{tool_id}' already exists in the tools dir") dest.parent.mkdir(parents=True, exist_ok=True) try: shutil.copytree(src, dest, ignore=_IGNORE, symlinks=True) + manifest_files.validate(dest) + except ValueError: + shutil.rmtree(dest, ignore_errors=True) + raise except shutil.Error as exc: shutil.rmtree(dest, ignore_errors=True) # don't leave a half-copied dir that wedges retries raise ValueError("could not copy all of the tool's files") from exc diff --git a/admin/toolyard_ops.py b/admin/toolyard_ops.py index 53145c4..17393b0 100644 --- a/admin/toolyard_ops.py +++ b/admin/toolyard_ops.py @@ -7,11 +7,8 @@ directories (``tool_dirs``) that the panel's tool editor can add anywhere on the server. -Phase 5: the runner mints an E_SECRET and registers the tool with SPS at -boot, so secret VALUES no longer flow through the panel. The panel still -shows "is this secret provisioned?" (via the SPS ``provisioned_fields`` -call) and supports operator provisioning via the SPS ``write_secret`` -op; never sees plaintext on the way. +The runner registers tools with SPS at boot. Secret values never flow +through this module; provisioning is host-side and inspection may be unknown. """ from __future__ import annotations @@ -25,11 +22,42 @@ from toolyard.cli import _load_state, _save_state from toolyard.config import discover from toolyard.config import load as load_tool -from toolyard.runner import RunningTool, get_runner +from toolyard.runner import RunningTool, RunnerStartCleanupError, get_runner from . import settings +def state_lock(): + """Use the runner's cross-process lock or fail closed until integrated.""" + from toolyard import cli + lock = getattr(cli, "state_lock", None) + if lock is None: + raise RuntimeError("Runner state_lock API unavailable; integrate runner repair first") + return lock() + + +class UnrecordedRuntimeError(RuntimeError): + def __init__(self, running): + super().__init__("Start could not be recorded and cleanup failed; operator recovery required") + self.running = running + + +def _record_or_rollback(state, tool_id, running, runner): + state[tool_id] = asdict(running) + try: + _save_state(state) + except Exception: + try: + runner.stop(running) + except Exception: + raise UnrecordedRuntimeError(running) from None + # Private state may have published before channel-view publication + # failed. Clear the rolled-back intent as well, under the same lock. + del state[tool_id] + _save_state(state) + raise + + def _all_defs(tools_root: str, tool_dirs=()) -> dict: """Every ToolDef keyed by id, from the tools root plus each explicit tool dir (an explicit dir wins over a root tool of the same id).""" @@ -50,24 +78,29 @@ def list_tools(tools_root: str, tool_dirs=()) -> list[dict]: is True for tools registered via ``tool_dirs`` (the panel can unregister those); tools discovered under the tools root are managed on the filesystem.""" state = _load_state() - dir_set = {str(Path(p)) for p in (tool_dirs or ())} + dir_set = {str(Path(p).resolve()) for p in (tool_dirs or ())} tools = [] for d in _all_defs(tools_root, tool_dirs).values(): record = state.get(d.id) alive = False if record: - running = RunningTool(**record) - alive = get_runner(running.backend).is_alive(running) + try: + running = RunningTool(**record) + alive = get_runner(running.backend).is_alive(running) + except Exception: + alive = None # inspection failure is not proof of death tools.append({ "id": d.id, "type": d.type, "port": d.port, "path": str(d.path), "running": bool(record), "alive": alive, + "state": "unknown" if alive is None else "running" if alive else "crashed" if record else "stopped", + "health": "unknown" if alive or alive is None else "stopped", "backend": record["backend"] if record else None, - "removable": str(d.path) in dir_set, + "removable": str(Path(d.path).resolve()) in dir_set, }) return tools -def start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") -> None: +def _start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") -> None: """Start the tool via the runner. Secrets are resolved by the tool at boot (Phase 5: SPS pull), so the panel hands the runner nothing but the tool definition.""" @@ -82,31 +115,59 @@ def start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") -> recorded_runner = get_runner(recorded.backend) if recorded_runner.is_alive(recorded): return # genuinely already running - recorded_runner.stop(recorded) + try: + recorded_runner.stop(recorded) + except RunnerStartCleanupError as exc: + state[tool_id] = asdict(exc.running) + _save_state(state) + raise del state[tool_id] _save_state(state) - running = get_runner(backend).start(defs[tool_id]) - state[tool_id] = asdict(running) - _save_state(state) + runner = get_runner(backend) + try: + running = runner.start(defs[tool_id]) + except Exception as exc: + # RunnerStartCleanupError contract: a runtime may survive failed start. + running = getattr(exc, "running", None) + if isinstance(running, RunningTool): + _record_or_rollback(state, tool_id, running, runner) + raise + _record_or_rollback(state, tool_id, running, runner) -def stop(tool_id: str) -> None: +def _stop(tool_id: str) -> None: state = _load_state() record = state.get(tool_id) if not record: return running = RunningTool(**record) - get_runner(running.backend).stop(running) + try: + get_runner(running.backend).stop(running) + except RunnerStartCleanupError as exc: + state[tool_id] = asdict(exc.running) + _save_state(state) + raise del state[tool_id] _save_state(state) def restart(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") -> None: - stop(tool_id) - start(tool_id, tools_root, tool_dirs, backend) + with state_lock(): + _stop(tool_id) + _start(tool_id, tools_root, tool_dirs, backend) -def remove(tool_id: str, tools_root: str, tool_dirs=()) -> None: +def start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") -> None: + with state_lock(): + _start(tool_id, tools_root, tool_dirs, backend) + + +def stop(tool_id: str) -> None: + with state_lock(): + _stop(tool_id) + + +def _remove(tool_id: str, tools_root: str, tool_dirs=()) -> None: """Stop a tool if running, then delete its managed folder under ``tools_root``. Only TSR-managed tools (those that live UNDER ``tools_root``, the copy-into-managed-dir @@ -123,8 +184,13 @@ def remove(tool_id: str, tools_root: str, tool_dirs=()) -> None: raise ValueError( f"tool {tool_id!r} is not managed under the tools root; it was registered from an " "external directory; unregister it by removing that path from tool_dirs") - stop(tool_id) # kill any running process + drop its state record before deleting the code + _stop(tool_id) # caller holds the complete lifecycle lock try: shutil.rmtree(tool_path) except OSError as exc: # stopped but couldn't delete -> a clear 400, not an opaque 500 raise ValueError(f"stopped {tool_id!r} but could not delete its folder: {exc}") from exc + + +def remove(tool_id: str, tools_root: str, tool_dirs=()) -> None: + with state_lock(): + _remove(tool_id, tools_root, tool_dirs) diff --git a/admin/views/assets.py b/admin/views/assets.py index 313d300..3c456eb 100644 --- a/admin/views/assets.py +++ b/admin/views/assets.py @@ -12,11 +12,11 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:16px;margin-bottom:16px;} h2{font-size:16px;margin:0 0 12px;} h3{font-size:14px;margin:16px 0 8px;color:var(--muted);} -button,input,select{font:inherit;min-height:34px;border-radius:6px;border:1px solid var(--line);padding:6px 9px;background:#fff;} +button,input,select{font:inherit;min-height:38px;border-radius:6px;border:1px solid var(--line);padding:6px 9px;background:#fff;} button{background:#f3f5f8;cursor:pointer;} -button[type=submit],button:not([type]){background:var(--accent);border-color:var(--accent);color:#fff;} +button.primary,a.button.primary,#tool-form button[type=submit],.login button[type=submit]{background:var(--accent);border-color:var(--accent);color:#fff;} button:disabled{opacity:.5;cursor:not-allowed;} -a.button{display:inline-flex;align-items:center;min-height:34px;border-radius:6px;border:1px solid var(--line);padding:6px 9px;background:#f3f5f8;color:var(--ink);text-decoration:none;} +a.button{display:inline-flex;align-items:center;min-height:38px;border-radius:6px;border:1px solid var(--line);padding:6px 9px;background:#f3f5f8;color:var(--ink);text-decoration:none;} table{width:100%;border-collapse:collapse;} td,th{border-top:1px solid var(--line);padding:8px;text-align:left;vertical-align:middle;} code{background:#eef1f5;padding:2px 5px;border-radius:5px;overflow-wrap:anywhere;} @@ -68,11 +68,159 @@ .filterbar{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;} .filterbar input[type=search]{flex:1;min-width:200px;padding:6px 10px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);} .filterbar select{padding:6px 10px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);} +select,.filterbar select{-webkit-appearance:none;appearance:none;height:38px;min-height:38px;padding-right:30px;color:var(--ink);background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);background-position:calc(100% - 14px) 16px,calc(100% - 9px) 16px;background-size:5px 5px;background-repeat:no-repeat;} .audit-latest{background:#fff9df;} +:focus-visible{outline:3px solid #0d6efd;outline-offset:3px;} +[data-caller-policy]{cursor:pointer;} +dialog{max-width:min(520px,90vw);border:1px solid var(--line);border-radius:8px;padding:24px;color:var(--ink);} +dialog .actions{justify-content:flex-end;gap:20px;} +dialog::backdrop{background:#17203366;} +@media(max-width:1000px){main{padding:14px;}section{overflow-x:auto;}header{flex-wrap:wrap;gap:12px;}} """ _JS = """ +document.addEventListener('DOMContentLoaded',function(){ + document.querySelectorAll('form[data-confirm]').forEach(form=>{ + let accepted=false; + form.addEventListener('submit',e=>{ + if(accepted)return;e.preventDefault(); + const dialog=document.createElement('dialog'), message=document.createElement('p'); + message.textContent=form.dataset.confirm;dialog.setAttribute('aria-label',form.dataset.confirm);dialog.append(message); + const cancel=document.createElement('button'), proceed=document.createElement('button'); + cancel.textContent='Cancel';cancel.autofocus=true; + proceed.textContent=form.dataset.confirm.split('?')[0]; + const actions=document.createElement('div');actions.className='actions'; + actions.append(cancel,proceed);dialog.append(actions);document.body.append(dialog); + cancel.onclick=()=>dialog.close(); + proceed.onclick=()=>{accepted=true;dialog.close();form.requestSubmit();}; + dialog.addEventListener('close',()=>dialog.remove());dialog.showModal();cancel.focus(); + }); + }); + document.querySelectorAll('form[data-confirm],form[data-mutation]').forEach(form=>{ + let inFlight=false, uncertain=false; + const scope=form.closest('tr')||form.closest('section')||form; + if(document.querySelector('[data-mutation-outcome="unknown"]')){ + scope.dataset.mutationUnknown='true';scope.querySelectorAll('button').forEach(b=>b.disabled=true); + } + form.addEventListener('submit',async e=>{ + if(e.defaultPrevented)return; + e.preventDefault();if(inFlight||uncertain||scope.dataset.mutationUnknown==='true')return; + inFlight=true; + const body=new URLSearchParams(new FormData(form)); + const buttons=Array.from(form.querySelectorAll('button')); + buttons.forEach(b=>b.disabled=true); + const status=document.createElement('p');status.setAttribute('role','status'); + status.textContent='Applying requested change…';form.append(status); + const controller=new AbortController(), timer=setTimeout(()=>controller.abort(),30000); + try{ + const response=await fetch(form.action,{method:'POST',body,signal:controller.signal}); + if(!response.ok||new URL(response.url).pathname==='/login')throw Error('No acknowledgement'); + if(response.headers?.get('X-Toolstack-Mutation-Outcome')==='unknown')throw Error('Unknown stage receipt'); + const html=await response.text(); + if(!html.includes('
'))throw Error('No acknowledgement'); + const returned=new DOMParser().parseFromString(html,'text/html'); + if(returned.querySelector('[data-mutation-outcome="unknown"]'))throw Error('Unknown stage receipt'); + document.open();document.write(html);document.close(); + }catch(error){ + scope.dataset.mutationUnknown='true';scope.querySelectorAll('button').forEach(b=>b.disabled=true); + uncertain=true;status.textContent='Outcome unknown. Target and inputs retained. Refresh state before deciding on another action; nothing was replayed.'; + const refresh=document.createElement('a');refresh.className='button'; + const path=new URL(form.action).pathname, target=form.querySelector('[name=catalog_target]'); + refresh.href=target?'/tools?tool='+encodeURIComponent(target.value): + path.startsWith('/toolyard/tools/')?'/tools?tool='+encodeURIComponent(decodeURIComponent(path.split('/')[3])): + path.startsWith('/callers/')?'/#callers':path.startsWith('/audit/')?'/#audit':'/tools'; + refresh.textContent='Refresh state';status.after(refresh); + }finally{clearTimeout(timer);inFlight=false;} + }); + }); +}); +document.addEventListener('DOMContentLoaded', function(){ + const form=document.getElementById('policy-form'); + if(!form || form.dataset.loaded!=='true') return; + const save=form.querySelector('button[type=submit]'), target=form.dataset.target; + const status=document.createElement('p');status.setAttribute('role','status'); + form.querySelector('section').append(status); + const compare=document.createElement('button');compare.type='button';compare.textContent='Reload to compare';compare.hidden=true; + status.after(compare); + const currentPage=document.createElement('a');currentPage.href=form.action;currentPage.textContent='Load current policy page';currentPage.hidden=true;compare.after(currentPage); + const fields=()=>Array.from(form.querySelectorAll('select[name^="op__"]')); + const snapshot=()=>JSON.stringify(fields().map(s=>[s.name,s.value])); + let baseline=snapshot(), unknown=false, busy=false, blocked=false; + const sessionMessage='Session expired · Sign in in another tab, then Reload to compare. Draft retained.'; + async function request(url,options,format='text'){ + const controller=new AbortController(), timer=setTimeout(()=>controller.abort(),15000); + try{const response=await fetch(url,{...options,signal:controller.signal}); + if(response.url && new URL(response.url,location.href).pathname==='/login')throw new Error('Session expired'); + const data=await response[format]();return {response,data}; + }finally{clearTimeout(timer);} + } + const dirty=()=>snapshot()!==baseline; + function state(text){status.textContent=text+' · '+target;} + form.addEventListener('change',()=>{if(!blocked)state(unknown?'Save outcome unknown · Reload to compare before retrying':(dirty()?'Unsaved changes':'No unsaved changes'));}); + function resume(){if(dirty()&&!busy&&!unknown&&!blocked&&!status.textContent.includes('rejected'))state('Resume policy draft');} + document.addEventListener('visibilitychange',()=>{if(!document.hidden)resume();}); + window.addEventListener('pageshow',resume); + window.addEventListener('beforeunload',e=>{if(dirty()||busy||unknown){e.preventDefault();e.returnValue='';}}); + function leaveDraft(proceed){ + const d=document.createElement('dialog'), title=document.createElement('h2'); + title.textContent='Leave policy draft · '+target+'?';d.setAttribute('aria-label',title.textContent);d.append(title); + const stay=document.createElement('button');stay.textContent='Stay';stay.autofocus=true; + const discard=document.createElement('button');discard.textContent='Discard draft'; + discard.disabled=busy; + const actions=document.createElement('div');actions.className='actions'; + actions.append(stay,discard);d.append(actions);document.body.append(d); + stay.onclick=()=>d.close();discard.onclick=()=>{baseline=snapshot();unknown=false;d.close();proceed();}; + d.addEventListener('close',()=>d.remove());d.showModal();stay.focus(); + } + document.addEventListener('click',e=>{ + const link=e.target.closest('a[href]');if(!link||(!dirty()&&!unknown&&!busy))return; + e.preventDefault();leaveDraft(()=>{location.href=link.href;}); + }); + document.addEventListener('submit',e=>{ + if(e.target===form||(!dirty()&&!unknown&&!busy))return; + e.preventDefault();const destination=e.target;leaveDraft(()=>destination.requestSubmit()); + },true); + form.addEventListener('submit',async e=>{ + e.preventDefault();if(busy||unknown||blocked)return; + busy=true;save.disabled=true;state('Saving'); + const body=new URLSearchParams(new FormData(form)); + fields().forEach(s=>s.disabled=true); + try{ + const {response,data:result}=await request(form.action,{method:'POST',headers:{Accept:'application/json'},body},'json'); + if(response.ok && result.outcome==='saved' && result.target===target){baseline=snapshot();form.querySelectorAll('.saved-effect').forEach(n=>n.remove());state('Policy saved');} + else if((response.status===400||response.status===403)&&result.outcome==='rejected'){blocked=true;compare.hidden=false;state('Save rejected · Draft retained. '+(result.error||'Reload to compare before saving.'));} + else throw new Error('No receipt'); + }catch(error){blocked=true;compare.hidden=false; + if(error.message==='Session expired')state('Save rejected · '+sessionMessage); + else{unknown=true;state('Save outcome unknown · Draft retained. Reload to compare before retrying');}} + finally{busy=false;fields().forEach(s=>s.disabled=false);save.disabled=unknown||blocked;save.classList.toggle('primary',!save.disabled);compare.classList.toggle('primary',save.disabled);if(save.disabled)compare.focus();} + }); + compare.onclick=async()=>{ + if(busy)return;busy=true;save.disabled=true; + compare.disabled=true;state('Loading saved policy for comparison'); + try{ + const {response,data:html}=await request(form.action,{cache:'no-store'}); + const doc=new DOMParser().parseFromString(html,'text/html'); + const loaded=doc.querySelector('#policy-form[data-loaded="true"]'); + if(!response.ok||!loaded||loaded.dataset.target!==target)throw new Error('Load failed'); + const saved=Array.from(loaded.querySelectorAll('select[name^="op__"]')); + if(saved.length!==fields().length||fields().some(s=>!saved.some(v=>v.name===s.name)))throw new Error('Operation set changed'); + const matches=fields().every(s=>saved.some(v=>v.name===s.name&&v.value===s.value)); + baseline=JSON.stringify(fields().map(s=>[s.name,saved.find(v=>v.name===s.name).value])); + form.querySelector('[name=_csrf]').value=loaded.querySelector('[name=_csrf]').value; + unknown=false;blocked=false;save.disabled=false;compare.hidden=true;currentPage.hidden=true;currentPage.className=''; + if(matches){baseline=snapshot();state('Saved policy matches draft');} + else{state('Saved policy differs · Draft retained. Review before saving'); + fields().forEach(s=>{let note=s.parentElement.querySelector('.saved-effect');if(!note){note=document.createElement('small');note.className='saved-effect';s.after(note);}note.textContent=' Saved: '+saved.find(v=>v.name===s.name).value;});} + }catch(error){blocked=true; + const prior=unknown?'Save outcome unknown · ':''; + if(error.message==='Session expired')state(prior+sessionMessage); + else if(error.message==='Operation set changed'){currentPage.hidden=false;currentPage.className='button';state(prior+'Operation set changed · Draft retained. Load the current policy page to review available operations; leaving requires discarding this draft.');} + else state(prior+'Comparison unavailable · Draft retained. Retry load'); + }finally{busy=false;save.disabled=unknown||blocked;compare.disabled=false;save.classList.toggle('primary',!save.disabled);compare.classList.toggle('primary',save.disabled);if(save.disabled)(currentPage.hidden?compare:currentPage).focus();else save.focus();} + }; +}); function setAll(effect){document.querySelectorAll('select[name^="op__"]').forEach(function(s){s.value=effect;});} function setTool(tool,effect){document.querySelectorAll('select[name^="op__'+tool+'__"]').forEach(function(s){s.value=effect;});} function showTab(name){ @@ -81,6 +229,21 @@ document.querySelectorAll('.tab-button').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-tab')===name);}); } +document.addEventListener('DOMContentLoaded', function(){ + document.querySelectorAll('[data-caller-policy]').forEach(function(row){ + row.addEventListener('dblclick', function(event){ + // Nested actions keep their own meaning; never turn a revoke/rotate into navigation. + if(event.target.closest('a,button,input,select,textarea,form,summary,[role="button"],[role="menu"],[contenteditable]')) return; + row.querySelector('[data-policy-link]').click(); + }); + row.addEventListener('keydown', function(event){ + if(event.key==='Enter' && event.target===row && !event.repeat){ + event.preventDefault(); + row.querySelector('[data-policy-link]').click(); + } + }); + }); +}); document.addEventListener('DOMContentLoaded', function(){ var tabs = document.querySelectorAll('.tab-button'); if(!tabs.length) return; @@ -213,6 +376,8 @@ card.querySelector('.op-body-kind').value = o.body_kind||'none'; card.querySelector('.op-body-content-type').value = o.body_content_type||''; card._body_substitution = o.body_substitution; + // Nonvisual metadata: retain absence versus an explicit empty allowlist. + if (Object.prototype.hasOwnProperty.call(o, 'approval_fields')) card._approval_fields = o.approval_fields; card.querySelector('.op-redact-body').checked = !!o.redact_response_body; card.querySelector('.op-redact-headers').checked = !!o.redact_response_headers; var args = card.querySelector('.args'); @@ -271,7 +436,7 @@ extract_path: r.querySelector('.rule-extract-path').value, match_status: r.querySelector('.rule-match-status').value}; }).filter(function(r){return r.secret_name || r.extract_path;}); - return {name: card.querySelector('.op-name').value, + var operation = {name: card.querySelector('.op-name').value, risk: card.querySelector('.op-risk').value, description: card.querySelector('.op-desc').value, verb: card.querySelector('.op-verb').value, @@ -289,6 +454,8 @@ required: r.querySelector('.arg-req').checked, description: r.querySelector('.arg-desc').value}; })}; + if (Object.prototype.hasOwnProperty.call(card, '_approval_fields')) operation.approval_fields = card._approval_fields; + return operation; } function secRow(s){ s = s||{}; diff --git a/admin/views/callers.py b/admin/views/callers.py index 2a4cb99..a4aff04 100644 --- a/admin/views/callers.py +++ b/admin/views/callers.py @@ -10,6 +10,7 @@ def caller_tools_view(*, user, csrf, caller, all_tools, enabled, error=None) -> rows = "".join( "" f"" f"{esc(tool)}" f"{len(ops)} operation{'' if len(ops) == 1 else 's'}" @@ -23,7 +24,7 @@ def caller_tools_view(*, user, csrf, caller, all_tools, enabled, error=None) -> "Back" f"Tools for {esc(caller)}" f"Edit policy" - "" + "" "" "

Enabling a tool lets you grant its operations on the " "policy page. Disabling a tool revokes the caller's access (its granted operations " @@ -35,22 +36,22 @@ def caller_tools_view(*, user, csrf, caller, all_tools, enabled, error=None) -> return page(f"Tools · {caller}", body, user=user, csrf=csrf) -def policy_view(*, user, csrf, caller, ops_by_tool, current, has_tools=True, error=None) -> str: +def policy_view(*, user, csrf, caller, ops_by_tool, current, has_tools=True, error=None, loaded=True) -> str: err = f"

{esc(error)}
" if error else "" tool_policies = current.get("tools", {}) sections = [ - f"{err}
", + f"{err}", _csrf_field(csrf), "
" f"Back" f"Policy for {esc(caller)}" - "" - "" - "" - "" + f"" "
", ] - if not ops_by_tool: + if not loaded: + sections.append(f"
Policy unavailable · {esc(caller)}. " + f"Retry load
") + if loaded and not ops_by_tool: if has_tools: msg = ("No tools enabled for this caller yet. " f"Enable tools first, " @@ -65,7 +66,7 @@ def policy_view(*, user, csrf, caller, ops_by_tool, current, has_tools=True, err for op in ops: name = op["op"] effect = current_ops.get(name, "deny") - select = (f"" + "".join( f"" for v in ("allow", "review", "deny")) @@ -81,9 +82,6 @@ def policy_view(*, user, csrf, caller, ops_by_tool, current, has_tools=True, err sections.append( "
" f"

{esc(tool)}

" - f"" - f"" - f"" "
" "" f"{''.join(op_rows)}
OperationRiskDescriptionEffect
" diff --git a/admin/views/dashboard.py b/admin/views/dashboard.py index 59de805..c122062 100644 --- a/admin/views/dashboard.py +++ b/admin/views/dashboard.py @@ -12,7 +12,7 @@ def _broker_section(broker: dict, log_tail: str, config: dict, csrf: str) -> str: if broker["running"]: healthy = broker["healthy"] - cls, word = ("ok", "healthy") if healthy else ("bad", "unhealthy") + cls, word = ("muted", "health endpoint responded") if healthy else ("bad", "health observation unavailable") status = (f"running · {word} " f"pid {esc(broker['pid'])} · port {esc(broker['port'])}") else: @@ -20,7 +20,10 @@ def _broker_section(broker: dict, log_tail: str, config: dict, csrf: str) -> str def btn(action: str, label: str, disabled: bool) -> str: d = " disabled" if disabled else "" - return (f"" + target = f"local broker 127.0.0.1:{broker.get('port') or config.get('port', '')}" + confirmation = (f" data-confirm='{esc('Restart ' + target + '? Reloads the configured tool catalog and interrupts broker availability and active calls.')}'" + if action == "restart" else "") + return (f"" f"{_csrf_field(csrf)}
") running = broker["running"] @@ -61,18 +64,18 @@ def token_reveal_banner(lead: str, token: str) -> str: def _callers_section(callers, csrf: str) -> str: rows = "".join( - "" + f"" f"{esc(c['name'])}" f"{'revoked' if c['revoked_at'] else 'active'}" "" - f"Policy" + f"Policy" f"Tools" f"
" + f"data-confirm='{esc('Rotate token for ' + c['name'] + '? Existing token stops working; update clients using this caller. The new token is shown once.')}'>" f"{_csrf_field(csrf)}" f"
" - f"
{_csrf_field(csrf)}" + f"{_csrf_field(csrf)}" f"
" "" for c in callers @@ -156,7 +159,7 @@ def _audit_section(events, csrf: str) -> str: bar = _filterbar("audit-table", "Filter by caller / tool / event...", "All outcomes", sorted({e["outcome"] for e in events})) clear = ("
" + "data-confirm='Clear the local broker audit log? Deletes retained audit history; this cannot be undone.'>" f"{_csrf_field(csrf)}
") return ("

Audit

" + clear + "
" + bar + "" diff --git a/admin/views/layout.py b/admin/views/layout.py index ef4185a..245e818 100644 --- a/admin/views/layout.py +++ b/admin/views/layout.py @@ -23,9 +23,11 @@ def _csrf_field(csrf: str) -> str: def page(title: str, body: str, *, user: str | None, csrf: str = "", nav: str = "") -> str: if user: - links = "".join( - f"{esc(label)}" - for href, label, key in _NAV_LINKS) + nav_links = [] + for href, label, key in _NAV_LINKS: + active = ' class="active"' if nav == key else "" + nav_links.append(f"{esc(label)}") + links = "".join(nav_links) header = ( f"

Toolstack Admin

" f"{_csrf_field(csrf)}" diff --git a/admin/views/tools.py b/admin/views/tools.py index 47b14f0..fbd0e59 100644 --- a/admin/views/tools.py +++ b/admin/views/tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from ..catalog_observation import target_verified from .assets import _TOOL_EDITOR_JS from .components import _alerts @@ -24,17 +25,21 @@ def _secret_backend_note(sps: dict | None) -> str: return "" -def tools_view(*, user, csrf, tools, tools_root, banner=None, error=None) -> str: - def action(tool_id, act, label, disabled): +def tools_view(*, user, csrf, tools, tools_root, banner=None, error=None, catalog=None, broker_port=None, focused=False, mutation_outcome=None) -> str: + def action(tool_id, act, label, disabled, primary=False): d = " disabled" if disabled else "" - return (f"" - f"{_csrf_field(csrf)}") + confirmation = (f" data-confirm='{esc('Restart ' + tool_id + '? Stops then starts trusted tool code; interrupts active calls. Requests are not replayed.')}'" + if act == "restart" else "") + return (f"" + f"{_csrf_field(csrf)}") rows = "" for t in tools: - if t["running"]: - state = ("running" if t["alive"] else "running?") - cls = "ok" if t["alive"] else "bad" + if t.get("alive") is None: + state, cls = "Unknown · observation unavailable", "muted" + elif t["running"]: + state = ("Alive" if t["alive"] else "Failed · process absent (running record)") + cls = "muted" if t["alive"] else "bad" else: state, cls = "stopped", "muted" running = t["running"] @@ -52,36 +57,47 @@ def action(tool_id, act, label, disabled): # An external (tool_dirs) tool is unregistered (files left on disk); a managed tool under # the tools root is deleted. Show the action for both, with the matching verb + warning. if t.get("removable"): - confirm, label = "Unregister this tool? Its files stay on disk.", "Unregister" + confirm, label = f"Unregister {t['id']}? Its files stay on disk.", "Unregister" else: - confirm, label = "Remove this tool? This deletes its folder and cannot be undone.", "Remove" + confirm, label = f"Remove {t['id']}? This deletes its managed folder and cannot be undone.", "Remove" remove = ( f"" + f"data-confirm='{esc(confirm)}'>" f"{_csrf_field(csrf)}" ) + registered = target_verified(catalog, t['id']) + registration = "Catalog verified" if registered else "Registration unknown" + register = "" + if not registered and broker_port is not None: + question = f"Restart local broker 127.0.0.1:{broker_port} for {t['id']}? Reloads catalog; interrupts broker availability and active calls." + register = (f"" + f"{_csrf_field(csrf)}" + f"") rows += ( "" - f"" + f"" f"" - f"" + f"" f"" "" ) rows = rows or "" + refresh = f"/tools?tool={esc(tools[0]['id'])}" if focused and len(tools) == 1 else "/tools" body = ( - _alerts(banner, error) + ("" if mutation_outcome == 'unknown' else '') + + _alerts(banner, error) + "
Back" f"

Tools

root: {esc(tools_root)}" "Author a tool" - "Add from source
" + f"Add from sourceRefresh state" "
TimeEventOutcomeReqDetails
{esc(t['id'])}{esc(t['id'])}{esc(t['port'])}{esc(state)}{esc(state)}
{registration}
{esc(t.get('path', ''))}" f"Edit" - f"{action(t['id'], 'start', 'Start', alive)}" + f"{action(t['id'], 'start', 'Start', alive, focused and not running)}" f"{action(t['id'], 'stop', 'Stop', not running)}" - f"{action(t['id'], 'restart', 'Restart', not running)}" + f"{action(t['id'], 'restart', 'Restart', False, focused and running and not alive)}" + f"{register}" f"{update}" f"{remove}" "
No tools defined yet.
" f"{rows}
ToolPortStateDirectoryActions
" "

Starting a tool resolves its secrets and runs it. A newly added or " @@ -100,16 +116,16 @@ def tool_add_view(*, user, csrf, source_value="", repo_value="", error=None) -> "

Back" "

Add a tool from a source

" "

Copy a tool that already has a toolyard.toml into the tools " - "root, so the broker auto-discovers it. Its source is recorded so you can Update " + "root. Catalog registration requires a separate broker restart. Its source is recorded so you can Update " "(re-pull) it later. To hand-author a manifest instead, use " "Author a tool.

" "

From a local folder

" - f"
{_csrf_field(csrf)}" + f"{_csrf_field(csrf)}" "" "" - "
" + "
" "

From a Git repository

" f"
{_csrf_field(csrf)}" @@ -198,7 +214,7 @@ def tool_editor_view(*, user, csrf, mode, tool, dir_value="", backend=None, erro "" "
" "Cancel
" - "

Saving writes toolyard.toml to the directory and registers it. " + "

Saving writes toolyard.toml to the directory. " "Restart the broker (dashboard) to pick it up, then grant a caller access in its policy.

" "
" f"" diff --git a/broker/approval.py b/broker/approval.py index 29abc0c..6ffe3f7 100644 --- a/broker/approval.py +++ b/broker/approval.py @@ -1,8 +1,9 @@ """Approval contract types (the broker side of the approval-surface adapter). -`OperationCard` is the redacted prompt the broker hands a surface; it describes -the operation and may include bounded, redacted arguments so similar calls are -distinguishable. `SurfaceState` is the normalized answer the broker reads back. +`OperationCard` is the allowlisted prompt the broker hands a surface; it describes +the operation and includes visible omissions and a private-request binding digest. +Selected arbitrary text is NOT guaranteed secret-free. `SurfaceState` is the +normalized answer the broker reads back. See docs/approval-surface-adapter.md. """ @@ -27,9 +28,12 @@ class OperationCard: op: str risk: str reason: str # why policy routed this to review - justification: str | None = None # the agent's (redacted) reason, shown to the human - details: str | None = None # redacted arguments, shown so similar calls are distinguishable + justification: str | None = None # legacy field; Nod intentionally never displays this + details: str | None = None # structural allowlisted summary with explicit omissions endpoint: str | None = None # rest tools: "VERB host /path/{template}", never hydrated values + installation_id: str | None = None + request_digest: str | None = None + operation_generation: str | None = None @dataclass(frozen=True) @@ -42,7 +46,9 @@ class SurfaceState: def build_card(request_id: int, caller: str, tool: str, op: str, risk: str, reason: str, justification: str | None = None, - details: str | None = None, endpoint: str | None = None) -> OperationCard: + details: str | None = None, endpoint: str | None = None, + installation_id: str | None = None, request_digest: str | None = None, + operation_generation: str | None = None) -> OperationCard: label = f"{tool}.{op}" return OperationCard( request_id=request_id, @@ -55,4 +61,7 @@ def build_card(request_id: int, caller: str, tool: str, op: str, risk: str, justification=justification, details=details, endpoint=endpoint, + installation_id=installation_id, + request_digest=request_digest, + operation_generation=operation_generation, ) diff --git a/broker/approval_summary.py b/broker/approval_summary.py new file mode 100644 index 0000000..e1ca3ce --- /dev/null +++ b/broker/approval_summary.py @@ -0,0 +1,97 @@ +"""Allowlisted approval presentation, not a claim that arbitrary text is secret-free. + +Pointers are RFC6901 paths relative to arguments. Justification is NEVER shown. +The complete canonical action is private state; only its digest reaches Nod. +""" +import hashlib +import json +import re +import secrets +from itertools import islice + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + + +def bound_request(caller_id, tool, op, generation, arguments, reason, nonce=None): + action = canonical({"caller_id": caller_id, "tool": tool, "op": op, + "operation_generation": generation, "arguments": arguments, "reason": reason, + # Private random binding context prevents the public digest + # becoming an offline guessing oracle for a short password. + "nonce": nonce if nonce is not None else secrets.token_hex(32)}) + return action, hashlib.sha256(action.encode()).hexdigest() + + +def validate_fields(fields): + if (not isinstance(fields, list) or len(fields) > 32 + or any(not isinstance(p, str) or not p.startswith("/") or "*" in p or len(p) > 256 + or re.search(r"~(?![01])", p) for p in fields) + or len(set(fields)) != len(fields)): + raise ValueError("approval_fields must be <=32 unique non-root RFC6901 pointers (<=256 characters); '*' is forbidden") + return tuple(fields) + + +def sensitive(name): + name = re.sub(r"[^a-z0-9]", "", name.lower()) + return any(part in name for part in ("password", "passwd", "passphrase", "secret", "token", "credential", + "apikey", "authorization", "cookie", "privatekey", "session", "bearer")) or name in {"pwd", "auth", "accesskey", "key", "pin", "otp", "ssn", "reason", "justification"} + + +def _mask(value, depth=0): + if depth >= 4 and isinstance(value, (dict, list)): + return "[OMITTED: nesting limit]" + if isinstance(value, dict): + result = {} + for key, child in islice(value.items(), 16): + if len(key) > 64: + result["[OMITTED: long field name]"] = "[MASKED]" + else: + result[key] = "[MASKED]" if sensitive(key) else _mask(child, depth + 1) + if len(value) > 16: + result["[OMITTED]"] = f"{len(value)-16} additional fields" + return result + if isinstance(value, list): + result = [_mask(child, depth + 1) for child in value[:16]] + if len(value) > 16: + result.append(f"[OMITTED: {len(value)-16} additional items]") + return result + if isinstance(value, str) and len(value) > 256: + return value[:256] + "[TRUNCATED: remaining text omitted]" + return value + + +def summarize(arguments, fields): + entries = [] + size_omitted = 0 + omissions = ["Fields outside configured pointers are omitted.", "Agent justification is omitted."] + if not fields: + omissions.insert(0, "ALL arguments omitted: no approval_fields configured.") + for pointer in fields: + parts = [p.replace("~1", "/").replace("~0", "~") for p in pointer[1:].split("/")] + value = arguments + try: + for part in parts: + if isinstance(value, list): + if not re.fullmatch(r"0|[1-9][0-9]*", part): + raise KeyError(part) + value = value[int(part)] + else: + value = value[part] + safe = "[MASKED]" if any(sensitive(part) for part in parts) else _mask(value) + except (KeyError, IndexError, TypeError, ValueError): + safe = "[OMITTED: configured field not present]" + entry = {"field": pointer, "value": safe} + # Bound each field independently so an inflated early value cannot hide + # later, small fields. Reserve space for the envelope and omission counts. + if len(canonical(entry)) > 1000 or len(canonical(entries + [entry])) > 3600: + size_omitted += 1 + entry = {"field": pointer, "value": "[OMITTED: summary size limit]"} + if len(canonical(entries + [entry])) <= 3600: + entries.append(entry) + if size_omitted: + omissions.append(f"Configured fields: {len(fields)}; values shown: {len(fields)-size_omitted}; " + f"omitted for summary size: {size_omitted} (labels included where space permits).") + if size_omitted == len(fields): + omissions.append("ALL configured field values omitted: summary size limit; 0 shown.") + return canonical({"selected_fields": entries, "omissions": omissions}) diff --git a/broker/bounds.py b/broker/bounds.py new file mode 100644 index 0000000..379180c --- /dev/null +++ b/broker/bounds.py @@ -0,0 +1,122 @@ +"""Serial HTTP transport bounds; no request worker threads or signal handlers.""" +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +import http.client +import io +import time +import urllib.request + + +class LimitExceeded(RuntimeError): + pass + + +class DeadlineReader(io.RawIOBase): + def __init__(self, sock, deadline, inactivity, *, own=False, reader=None): + self.sock, self.deadline, self.inactivity, self.own = sock, deadline, inactivity, own + self.reader = reader + + def readable(self): + return True + + def readinto(self, buffer): + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("transport deadline exceeded") + self.sock.settimeout(min(self.inactivity, remaining)) + return self.reader.readinto(buffer) if self.reader is not None else self.sock.recv_into(buffer) + + def close(self): + if self.reader is not None: + self.reader.close() + elif self.own: + self.sock.close() + super().close() + + +@dataclass +class Budget: + deadline: float + remaining: int + inactivity: float + + +_budget = ContextVar("toolstack_response_budget", default=None) + + +@contextmanager +def operation_bounds(duration, size): + token = _budget.set(Budget(time.monotonic() + duration, size, min(duration, 5.0))) + try: + yield + if time.monotonic() > _budget.get().deadline: + raise TimeoutError("transport deadline exceeded") + finally: + _budget.reset(token) + + +class _Response(http.client.HTTPResponse): + def __init__(self, sock, *args, **kwargs): + budget = _budget.get() + if budget is not None: + # Preserve standard socket.makefile reference lifetime, including + # TLS sockets (which cannot be duplicated). + raw_sock = sock + class SocketView: + def makefile(self, mode): + return io.BufferedReader(DeadlineReader(raw_sock, budget.deadline, + budget.inactivity, reader=raw_sock.makefile("rb", buffering=0))) + sock = SocketView() + super().__init__(sock, *args, **kwargs) + + +class _Connection(http.client.HTTPConnection): + response_class = _Response + + +class DeadlineHTTPHandler(urllib.request.HTTPHandler): + def http_open(self, req): + budget = _budget.get() + if budget is not None: + remaining = budget.deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("transport deadline exceeded") + req.timeout = min(req.timeout, remaining, budget.inactivity) + return self.do_open(_Connection, req) + + +class _SecureConnection(http.client.HTTPSConnection): + response_class = _Response + + +class DeadlineHTTPSHandler(urllib.request.HTTPSHandler): + def https_open(self, req): + budget = _budget.get() + if budget is not None: + remaining = budget.deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("transport deadline exceeded") + req.timeout = min(req.timeout, remaining, budget.inactivity) + return self.do_open(_SecureConnection, req, context=self._context) + + +def chunks(response): + """Incremental bytes including chunked bodies, capped across one execution.""" + budget = _budget.get() + if budget is None: + raise RuntimeError("missing execution budget") + while True: + if time.monotonic() >= budget.deadline: + raise TimeoutError("transport deadline exceeded") + chunk = response.read1(min(4096, budget.remaining + 1)) + if not chunk: + return + budget.remaining -= len(chunk) + if budget.remaining < 0: + raise LimitExceeded("tool response size exceeded") + yield chunk + + +def read_response(response): + return b"".join(chunks(response)) diff --git a/broker/brokerctl.py b/broker/brokerctl.py index b0577b0..d5724b2 100644 --- a/broker/brokerctl.py +++ b/broker/brokerctl.py @@ -27,12 +27,13 @@ import shlex import signal import subprocess +import sqlite3 from pathlib import Path from . import operations, request_lifecycle from .audit import AuditLog, stderr_sink from .context import BrokerContext -from .store import Store +from .store import Store, default_db_path from .surface_nod import NodSurface @@ -172,6 +173,43 @@ def list_requests(args) -> None: store.close() +def prune_payloads(args): + store = _store(args) + try: + count = store.prune_payloads() + operations.record_admin_event(store, args.operator, "payloads_pruned", {"count": count, "retention_days": 7}) + print(json.dumps({"pruned": count, "note": "WAL/backups may retain bytes; not secure erasure"})) + finally: + store.close() + + +def reconcile_offline(args): + store = _store(args) + try: + count = store.reconcile_offline(confirmed_offline=args.confirm_offline) + operations.record_admin_event(store, args.operator, "execution_reconciled", {"unknown_count": count}) + print(json.dumps({"unknown": count, "replayed": 0})) + finally: + store.close() + + +def backup(args): + if not Path(args.db or default_db_path()).is_file(): + raise FileNotFoundError("backup source database must already exist") + store = _store(args) + try: + destination = store.backup_to(args.destination) + operations.record_admin_event(store, args.operator, "state_backed_up", {"new_destination": True}) + print(json.dumps({"snapshot": destination})) + finally: + store.close() + + +def restore_new(args): + destination = Store.restore_new(args.snapshot, args.destination, confirmed_offline=args.confirm_offline) + print(json.dumps({"restored": destination, "tokens_invalidated": True, "policies_cleared": True, "replayed": 0})) + + def audit(args) -> None: store = _store(args) try: @@ -256,6 +294,21 @@ def main() -> None: p.add_argument("--limit", type=int, default=50) p.set_defaults(func=list_requests) + p = sub.add_parser("prune-payloads", parents=[common, operator], help="manually prune terminal payloads older than seven days; keep receipts/keys") + p.set_defaults(func=prune_payloads) + p = sub.add_parser("reconcile-offline", parents=[common, operator], help="STOP all writers first; mark stale received/executing unknown, NEVER replay") + p.add_argument("--confirm-offline", action="store_true", required=True) + p.set_defaults(func=reconcile_offline) + + p = sub.add_parser("backup", parents=[common, operator], help="SQLite WAL-consistent private snapshot to a NEW destination only") + p.add_argument("--destination", required=True) + p.set_defaults(func=backup) + p = sub.add_parser("restore-new", help="STOP all writers; restore a snapshot to NEW destination, invalidate authority; no replay") + p.add_argument("--snapshot", required=True) + p.add_argument("--destination", required=True) + p.add_argument("--confirm-offline", action="store_true", required=True) + p.set_defaults(func=restore_new) + p = sub.add_parser("audit", parents=[common]) p.add_argument("--request-id", type=int) p.add_argument("--correlation-id") @@ -267,7 +320,10 @@ def main() -> None: p.set_defaults(func=reload) args = parser.parse_args() - args.func(args) + try: + args.func(args) + except (OSError, ValueError, sqlite3.Error) as exc: + raise SystemExit(f"operation refused: {type(exc).__name__}") from None if __name__ == "__main__": diff --git a/broker/context.py b/broker/context.py index 66478ec..b1b1af3 100644 --- a/broker/context.py +++ b/broker/context.py @@ -23,3 +23,4 @@ class BrokerContext: surface: object | None = None # an ApprovalSurface (NodSurface / FakeSurface); None = no review approval_ttl: float = 3600.0 # broker-side approval timeout (seconds) rate_limiter: object | None = None # a RateLimiter, or None to disable + catalog_observation_id: str | None = None diff --git a/broker/gateway.py b/broker/gateway.py index d019ee8..e421941 100644 --- a/broker/gateway.py +++ b/broker/gateway.py @@ -19,6 +19,7 @@ from . import mcp from . import policy as policy_rules from . import request_lifecycle as lifecycle +import re from .identity import authenticate, token_fingerprint # lifecycle terminal status -> (request.* audit event_type, audit outcome word). A single @@ -49,13 +50,21 @@ lifecycle.EXPIRED: 200, # only seen on a status query, never on submit lifecycle.FAILED: 502, lifecycle.UNAVAILABLE: 503, + lifecycle.RECEIVED: 202, + lifecycle.EXECUTING: 202, + lifecycle.UNKNOWN: 502, + lifecycle.RESULT_EXPIRED: 200, + lifecycle.CONFLICT: 409, + lifecycle.INVALID: 400, } +_REQUEST_TERMINAL[lifecycle.UNKNOWN] = ("unknown", "unknown") # HTTP status -> audit outcome word (for the egress event) _AUDIT_OUTCOME = { 200: "ok", 202: "accepted", 400: "invalid", 401: "denied", 403: "denied", 404: "not_found", 429: "rate_limited", 502: "failed", 503: "unavailable", } +_AUDIT_OUTCOME[409] = "conflict" @dataclass(frozen=True) @@ -69,7 +78,7 @@ def make_correlation_id(headers: dict) -> str: """Propagate a caller-supplied correlation id, or mint one. Length-capped so a caller cannot smuggle a large value into our logs.""" supplied = (headers.get("x-correlation-id") or "").strip() - return supplied[:64] if supplied else uuid.uuid4().hex + return supplied if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", supplied) else uuid.uuid4().hex def handle(method: str, path: str, headers: dict, body, ctx) -> Response: @@ -96,7 +105,11 @@ def handle(method: str, path: str, headers: dict, body, ctx) -> Response: def _route(method, path, headers, body, ctx, correlation_id) -> Response: if method == "GET" and path == HEALTH_PATH: - return Response(200, {"status": "ok"}, correlation_id) + body = {"status": "ok"} + observation = getattr(ctx, "catalog_observation_id", None) + if observation: + body["catalog_observation_id"] = observation + return Response(200, body, correlation_id) bearer = headers.get("authorization") caller = authenticate(ctx.store, bearer) @@ -114,7 +127,7 @@ def _route(method, path, headers, body, ctx, correlation_id) -> Response: if method == "POST" and path.startswith(ACTIONS_PREFIX): if ctx.rate_limiter is not None and not ctx.rate_limiter.allow(caller.id): return Response(429, {"error": "rate_limited"}, correlation_id) - return _action(path, body, ctx, caller, correlation_id) + return _action(path, body, ctx, caller, correlation_id, idempotency_key=headers.get("idempotency-key")) if method == "POST" and path == MCP_PATH: # Same authority as the action API: authenticated above; policy/approval/audit and rate @@ -122,7 +135,7 @@ def _route(method, path, headers, body, ctx, correlation_id) -> Response: # the initialize/list/ping handshake, mirroring GET /v1/tools, which is unmetered). # mcp.handle returns the HTTP status so a throttle (429) / internal error (500) # audits with the right outcome via _AUDIT_OUTCOME. - status, mcp_body = mcp.handle(body, ctx, caller, correlation_id) + status, mcp_body = mcp.handle(body, ctx, caller, correlation_id, idempotency_key=headers.get("idempotency-key")) return Response(status, mcp_body, correlation_id) if method == "GET" and path == TOOLS_PATH: @@ -180,13 +193,17 @@ def _request_status(path, ctx, caller, correlation_id) -> Response: # Only a pending_approval request can transition to terminal here; a re-poll of an # already-resolved request must not re-emit its terminal event. was_pending = req["status"] == "pending_approval" - outcome = lifecycle.resolve_request(ctx, int(rid)) + if ctx.store.authorized_request(caller, int(rid)) is None: + return Response(403, {"status": "denied", "error": "authority"}, correlation_id) + outcome = lifecycle.resolve_request(ctx, int(rid), caller=caller) + if ctx.store.authorized_request(caller, int(rid)) is None: + return Response(403, {"status": "denied", "error": "authority"}, correlation_id) if was_pending: _audit_request_terminal(ctx, outcome, correlation_id, req["tool"], req["op"]) return Response(200, _outcome_body(outcome), correlation_id) -def _action(path, body, ctx, caller, correlation_id) -> Response: +def _action(path, body, ctx, caller, correlation_id, idempotency_key=None) -> Response: spec = path[len(ACTIONS_PREFIX):] parts = spec.split(".") if len(parts) != 2 or not all(parts): @@ -201,14 +218,8 @@ def _action(path, body, ctx, caller, correlation_id) -> Response: if not isinstance(arguments, dict): return Response(400, {"error": "invalid", "detail": "arguments must be an object"}, correlation_id) - tool_op = ctx.registry.lookup(tool, op) - if tool_op is not None and tool_op.type == "rest": - invalid = _validate_rest_arguments(tool_op, arguments) - if invalid is not None: - return Response(400, {"error": "invalid_envelope", "detail": invalid}, correlation_id) - outcome = lifecycle.submit(ctx, caller, tool, op, arguments, correlation_id, - reason=body.get("reason")) + reason=body.get("reason"), idempotency_key=idempotency_key) _audit_request_terminal(ctx, outcome, correlation_id, tool, op) return Response(_OUTCOME_STATUS[outcome.status], _outcome_body(outcome), correlation_id) @@ -218,6 +229,10 @@ def _audit_request_terminal(ctx, outcome, correlation_id, tool, op) -> None: that just reached a terminal state. No-op for a still-pending outcome or a missing request row, so callers must guard re-polls (don't re-emit for an already-resolved request); see `_request_status`.""" + if outcome.reused: + ctx.audit.record("request", "receipt_reused", outcome.status, correlation_id, + request_id=outcome.request_id, details={"tool": tool, "op": op}) + return mapped = _REQUEST_TERMINAL.get(outcome.status) if mapped is None or outcome.request_id is None: return @@ -228,6 +243,12 @@ def _audit_request_terminal(ctx, outcome, correlation_id, tool, op) -> None: def _outcome_body(outcome) -> dict: body = {"status": outcome.status} + if outcome.idempotency_key is not None: + body["idempotency_key"] = outcome.idempotency_key + if outcome.payloads_pruned: + body["payloads_pruned"] = True + if outcome.reused: + body["reused"] = True if outcome.request_id is not None: body["request_id"] = outcome.request_id if outcome.result is not None: diff --git a/broker/identity.py b/broker/identity.py index deffa7a..6c94667 100644 --- a/broker/identity.py +++ b/broker/identity.py @@ -12,13 +12,14 @@ from __future__ import annotations import hashlib -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass(frozen=True) class Caller: id: int name: str + token_hash: str | None = field(default=None, compare=False, repr=False) def bearer_token(authorization_header: str | None) -> str | None: diff --git a/broker/mcp.py b/broker/mcp.py index e6ceb0f..0af2bb0 100644 --- a/broker/mcp.py +++ b/broker/mcp.py @@ -35,7 +35,9 @@ # Outcome statuses that make an MCP tools/call an error result. `pending_approval` is NOT # one of them; it is in-progress, not failed (mirrors the HTTP action flow). -_MCP_FAIL = {lifecycle.DENIED, lifecycle.FAILED, lifecycle.UNAVAILABLE, lifecycle.EXPIRED} +_MCP_FAIL = {lifecycle.DENIED, lifecycle.FAILED, lifecycle.UNAVAILABLE, lifecycle.EXPIRED, + lifecycle.RECEIVED, lifecycle.EXECUTING, lifecycle.UNKNOWN, lifecycle.CONFLICT, + lifecycle.INVALID, lifecycle.RESULT_EXPIRED} class _MethodNotFound(Exception): @@ -71,13 +73,13 @@ def _input_schema(args: list) -> dict: def _with_reason(schema: dict) -> dict: """Advertise the optional `_reason` justification on a review tool's input schema, - the MCP analogue of the CLI's --reason. Stripped before the tool runs; shown to the - human approver and audited (redacted).""" + the MCP analogue of the CLI's --reason. Legacy private metadata, stripped + before the tool runs; omitted from approval and audit text.""" props = dict(schema.get("properties", {})) props["_reason"] = { "type": "string", - "description": ("Why you need this, shown to the human approver and recorded in " - "the audit log. Recommended on review ops; not passed to the tool."), + "description": ("Legacy private request metadata; omitted from approval and audit text. " + "Not passed to the tool. Do not include secrets."), } return {**schema, "type": "object", "properties": props} @@ -131,7 +133,12 @@ def _resolve_name(ctx, name: str): return None, None -def _call_tool(ctx, caller, correlation_id, params) -> dict: +class _IdempotencyConflict(Exception): + def __init__(self, result): + self.result = result + + +def _call_tool(ctx, caller, correlation_id, params, idempotency_key=None) -> dict: # Local import breaks an import cycle: gateway imports this module to route /mcp, and # these two helpers shape/audit the outcome identically to the HTTP action path. Reusing # them (rather than duplicating) is what guarantees "audit works the same" (AC). @@ -157,14 +164,22 @@ def _call_tool(ctx, caller, correlation_id, params) -> dict: return _result(f'unknown tool "{name}"', is_error=True) # `_reason` is adapter metadata, not a tool argument: pull it out (never forwarded to - # the tool) and pass it as the broker's justification; rides to the approver, audited. + # the tool) and keep it as private request metadata, not approval/audit text. args = dict(arguments) reason = args.pop("_reason", None) reason = reason if isinstance(reason, str) and reason.strip() else None + meta = params.get("_meta", {}) + if not isinstance(meta, dict): + raise _InvalidParams("_meta must be an object") + key = meta.get("toolstack/idempotency-key") + if idempotency_key is not None: + if key is not None and key != idempotency_key: + raise _InvalidParams("conflicting idempotency metadata") + key = idempotency_key # The SAME path as _action: submit applies policy/approval and writes the same # audit trail (request.received, policy.decision_*, then the terminal request.* below). - outcome = lifecycle.submit(ctx, caller, tool, op, args, correlation_id, reason=reason) + outcome = lifecycle.submit(ctx, caller, tool, op, args, correlation_id, reason=reason, idempotency_key=key) _audit_request_terminal(ctx, outcome, correlation_id, tool, op) # Least privilege at the RESULT layer only: a denied or unknown op reads as "unknown @@ -173,22 +188,26 @@ def _call_tool(ctx, caller, correlation_id, params) -> dict: if outcome.status in (lifecycle.DENIED, lifecycle.NOT_FOUND): return _result(f'unknown tool "{name}"', is_error=True) body = _outcome_body(outcome) - return _result(json.dumps(body, indent=2), is_error=outcome.status in _MCP_FAIL) + if outcome.status == lifecycle.CONFLICT: + raise _IdempotencyConflict(_result(json.dumps(body), is_error=True)) + tool_error = isinstance(outcome.result, dict) and outcome.result.get("isError") is True + return _result(json.dumps(body, indent=2), is_error=outcome.status in _MCP_FAIL or tool_error) -def _dispatch(ctx, caller, correlation_id, method: str, params: dict): +def _dispatch(ctx, caller, correlation_id, method: str, params: dict, idempotency_key=None): if method == "initialize": return { "protocolVersion": params.get("protocolVersion", PROTOCOL_VERSION), "capabilities": {"tools": {}}, "serverInfo": {"name": "toolstack-broker", "version": "0.1.0"}, + "instructions": 'Supply tools/call params._meta["toolstack/idempotency-key"] (1-128 letters/digits/_/-). Matching retries return the same receipt, never replay. Poll the request receipt; unknown is not retry permission.', } if method == "ping": return {} if method == "tools/list": return {"tools": _list_tools(ctx, caller)} if method == "tools/call": - return _call_tool(ctx, caller, correlation_id, params) + return _call_tool(ctx, caller, correlation_id, params, idempotency_key=idempotency_key) raise _MethodNotFound(method) @@ -196,7 +215,7 @@ def _error(mid, code: int, message: str) -> dict: return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}} -def handle(body, ctx, caller, correlation_id) -> tuple[int, dict]: +def handle(body, ctx, caller, correlation_id, idempotency_key=None) -> tuple[int, dict]: """Handle one MCP JSON-RPC message posted to /mcp. Returns ``(http_status, body)``: the gateway uses the status for the response AND for the audit outcome word. Almost everything is HTTP 200 with a JSON-RPC envelope (results and JSON-RPC method errors @@ -219,9 +238,11 @@ def handle(body, ctx, caller, correlation_id) -> tuple[int, dict]: return 200, ({} if is_notification else _error(mid, -32602, "params must be an object")) try: - result = _dispatch(ctx, caller, correlation_id, method, params) + result = _dispatch(ctx, caller, correlation_id, method, params, idempotency_key=idempotency_key) except _RateLimited: return 429, ({} if is_notification else _error(mid, -32000, "rate_limited")) + except _IdempotencyConflict as exc: + return 409, ({} if is_notification else {"jsonrpc": "2.0", "id": mid, "result": exc.result}) except _MethodNotFound as exc: return 200, ({} if is_notification else _error(mid, -32601, f"method not found: {exc}")) except _InvalidParams as exc: diff --git a/broker/operations.py b/broker/operations.py index 61a9b3f..bac4816 100644 --- a/broker/operations.py +++ b/broker/operations.py @@ -102,9 +102,11 @@ def _cancel_pending_approvals(store: Store, caller_id: int, surface, operator: s process and may hold no live surface, so the store marking IS the security guarantee; the surface withdrawal is opportunistic card hygiene.""" rows = store.pending_approvals_for_caller(caller_id) + cancelled = 0 for row in rows: - store.update_approval(row["id"], status="cancelled") - store.update_request(row["request_id"], status="expired", arguments_json=None) + if not store.finish_pending(row["request_id"], "expired", "cancelled"): + continue + cancelled += 1 if surface is not None: try: surface.cancel(row["surface_ref"]) @@ -114,7 +116,7 @@ def _cancel_pending_approvals(store: Store, caller_id: int, surface, operator: s row["correlation_id"], row["request_id"], {"reason": reason, "operator": operator, "tool": row["tool"], "op": row["op"]}) - return len(rows) + return cancelled def revoke_caller(store: Store, name: str, operator: str, surface=None) -> int: @@ -130,11 +132,19 @@ def revoke_caller(store: Store, name: str, operator: str, surface=None) -> int: def set_policy(store: Store, name: str, allow, review, operator: str, deny=None) -> None: caller = require_caller(store, name) policy = build_policy(allow, review, deny) - enabled = store.policy_for(caller["id"]).get("enabled") # preserve tool-enablement + before = store.policy_for(caller["id"]) + enabled = before.get("enabled") # preserve tool-enablement if enabled: policy["enabled"] = enabled store.set_policy(caller["id"], policy) - record_admin_event(store, operator, "policy_changed", {"name": name}) + def grants(value): + return {f"{tool}.{op}": effect + for tool, ops in value.get("tools", {}).items() + for op, effect in ops.items()} + old, new = grants(before), grants(policy) + delta = [{"operation": key, "before": old.get(key), "after": new.get(key)} + for key in sorted(old.keys() | new.keys()) if old.get(key) != new.get(key)] + record_admin_event(store, operator, "policy_changed", {"name": name, "grant_delta": delta}) def issue_token(store: Store, name: str, operator: str) -> str: diff --git a/broker/policy.py b/broker/policy.py index 7bbf1b6..e473deb 100644 --- a/broker/policy.py +++ b/broker/policy.py @@ -14,19 +14,8 @@ REVIEW = "review" DENY = "deny" -# Broker-injected secret-cache ops: always allowed for every caller regardless of policy. -# These are infrastructure ops (the tool's SecretClient refreshes its in-memory cache -# from SPS), not user-facing actions; a policy gate would force every operator to -# remember to grant them per caller, and there is no scenario where a caller should be -# denied the ability to refresh a tool's secret cache. The audit log still records the -# call (broker/registry.py / broker/runtime.py record every dispatch). -_AUTO_ALLOW_OPS = frozenset({"refresh", "refresh_one"}) - - def decide(policy: dict, tool: str, op: str) -> str: """Return ALLOW, REVIEW, or DENY for a (tool, op) under a caller policy.""" - if op in _AUTO_ALLOW_OPS: - return ALLOW rules = policy.get("tools", {}).get(tool, {}) return _norm(rules.get(op)) diff --git a/broker/registry.py b/broker/registry.py index 34be2af..dd818db 100644 --- a/broker/registry.py +++ b/broker/registry.py @@ -12,10 +12,13 @@ from __future__ import annotations import re +import hashlib +import json import tomllib from dataclasses import dataclass from pathlib import Path from urllib.parse import urlsplit +from .approval_summary import validate_fields # Tool transports the broker knows how to route. "api" -> POST /v1/actions/; "mcp" -> # streamable-HTTP MCP client; "rest" -> generic REST forwarder. An unknown type is rejected @@ -40,6 +43,8 @@ class ToolOp: path_template: str | None = None base_url_host: str | None = None body_kind: str | None = None + generation: str = "" + approval_fields: tuple[str, ...] = () class Registry: @@ -50,14 +55,14 @@ def __init__(self, catalog: dict | None = None) -> None: @staticmethod def _add_toml(catalog: dict, toml_path: Path) -> None: - with open(toml_path, "rb") as f: - data = tomllib.load(f) + raw = toml_path.read_bytes() + data = tomllib.loads(raw.decode("utf-8")) tool_id = data.get("id") # Fail closed at load (naming the file + id) on a missing/invalid id, like the port # check below. The admin panel enforces this charset; a hand-written toolyard.toml is # the unchecked path, where a dotted id would become a catalog key and break tool.op # policy routing only later. - if not (isinstance(tool_id, str) and _ID_RE.match(tool_id)): + if not (isinstance(tool_id, str) and _ID_RE.fullmatch(tool_id)): raise ValueError( f"{toml_path}: invalid tool id {tool_id!r} (letters, digits, _ or - only; " f"no dots: a dot breaks tool.op policy routing)" @@ -85,6 +90,11 @@ def _add_toml(catalog: dict, toml_path: Path) -> None: rest_host = _rest_base_url_host(toml_path, data) if tool_type == "rest" else None ops = {} for o in data.get("operations", []): + name = o.get("name") + if not isinstance(name, str) or not _ID_RE.fullmatch(name): + raise ValueError(f"{toml_path}: invalid operation name") + if name in ops: + raise ValueError(f"{toml_path}: duplicate operation {name!r}") rest_fields = {} if tool_type == "rest": verb = o.get("verb") @@ -108,6 +118,7 @@ def _add_toml(catalog: dict, toml_path: Path) -> None: "risk": o.get("risk", "unknown"), "description": o.get("description", ""), "args": o.get("args", []), + "approval_fields": list(validate_fields(o.get("approval_fields", []))), **rest_fields, } # Auto-inject refresh + refresh_one for tools that have [[secrets]] declared. @@ -120,6 +131,8 @@ def _add_toml(catalog: dict, toml_path: Path) -> None: if isinstance(data.get("secrets"), list) and any( isinstance(s, dict) and s.get("name") for s in data["secrets"] ): + if {"refresh", "refresh_one"} & ops.keys(): + raise ValueError(f"{toml_path}: reserved refresh operation collision") ops["refresh"] = { "risk": "read", "description": "Re-pull every declared secret from the backend into the tool's in-memory cache.", @@ -133,7 +146,9 @@ def _add_toml(catalog: dict, toml_path: Path) -> None: } # NOTE: data["secrets"] is still not read directly beyond the auto-injection above; # the runner reads it for SPS registration and the tool reads it for handler wiring. - catalog[tool_id] = {"port": port, "type": tool_type, "ops": ops} + catalog[tool_id] = {"port": port, "type": tool_type, "ops": ops, + "manifest_path": str(toml_path.resolve()), + "manifest_digest": hashlib.sha256(raw).hexdigest()} @classmethod def from_tools_root(cls, root: str | Path) -> "Registry": @@ -162,19 +177,46 @@ def lookup(self, tool: str, op: str) -> ToolOp | None: entry = self._catalog.get(tool) if entry is None or op not in entry["ops"]: return None + # An edited/deleted manifest invalidates the loaded immutable catalog. + # Reload is explicit; do not execute the old definition against new config. + if "manifest_path" in entry: + try: + current = hashlib.sha256(Path(entry["manifest_path"]).read_bytes()).hexdigest() + except OSError: + return None + if current != entry["manifest_digest"]: + return None meta = entry["ops"][op] return ToolOp( tool, op, meta["risk"], entry["port"], entry["type"], meta.get("verb"), meta.get("path_template"), meta.get("base_url_host"), meta.get("body_kind"), + hashlib.sha256(json.dumps(entry, sort_keys=True).encode()).hexdigest(), + tuple(meta.get("approval_fields", ())), ) + def observation_fingerprint(self, root=None, tool_dirs=()) -> str: + """Private loaded-catalog identity; never hash secret blocks or expose on health.""" + payload = { + "root": str(Path(root).resolve()) if root else None, + "tool_dirs": [str(Path(p).resolve()) for p in tool_dirs], + "catalog": {name: {k: value for k, value in entry.items() + if k != "manifest_digest"} + for name, entry in self._catalog.items()}, + } + return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest() + + def tool_ids(self) -> list[str]: + """Membership of this loaded projection, including tools with no operations.""" + return sorted(self._catalog) + def describe(self, tool: str, op: str) -> dict | None: entry = self._catalog.get(tool) if entry is None or op not in entry["ops"]: return None meta = entry["ops"][op] return {"tool": tool, "op": op, "risk": meta["risk"], - "description": meta["description"], "args": meta["args"]} + "description": meta["description"], "args": meta["args"], + "approval_fields": list(meta.get("approval_fields", ()))} def list_ops(self) -> list[dict]: ops = [] diff --git a/broker/request_lifecycle.py b/broker/request_lifecycle.py index 4552733..bf5ad0c 100644 --- a/broker/request_lifecycle.py +++ b/broker/request_lifecycle.py @@ -8,8 +8,8 @@ an approval. The broker enforces its OWN timeout (fail closed) and owns approval truth. -Arguments are persisted only while a request is pending approval (needed to run it -after approval) and cleared at any terminal state. They are never audited. +Arguments are private while received/pending/executing and cleared at terminal +states. Key tombstones never authorize replay, including after pruning/recovery. """ from __future__ import annotations @@ -22,8 +22,8 @@ from . import approval from . import policy as policy_rules -from .redaction import redact, redact_request -from .runtime import ToolUnreachable +from .approval_summary import summarize +from .runtime import NotDispatched OK = "ok" PENDING = "pending_approval" @@ -32,6 +32,12 @@ FAILED = "failed" EXPIRED = "expired" UNAVAILABLE = "unavailable" +RECEIVED = "received" +EXECUTING = "executing" +UNKNOWN = "unknown" +CONFLICT = "conflict" +INVALID = "invalid" +RESULT_EXPIRED = "result_expired" @dataclass(frozen=True) @@ -45,34 +51,72 @@ class Outcome: approver: str | None = None # who decided (from the approval surface) note: str | None = None # the approver's note, surfaced back to the agent decided_at: str | None = None # when the human answered, per the surface (ISO 8601) + idempotency_key: str | None = None + payloads_pruned: bool = False + reused: bool = False log = logging.getLogger(__name__) -def submit(ctx, caller, tool, op, arguments, correlation_id, reason=None) -> Outcome: +def submit(ctx, caller, tool, op, arguments, correlation_id, reason=None, idempotency_key=None) -> Outcome: + try: + ctx.store.validate_idempotency_key(idempotency_key) + except ValueError: + return Outcome(INVALID, error="invalid_idempotency_key") + out = _submit(ctx, caller, tool, op, arguments, correlation_id, reason, idempotency_key) + return replace(out, idempotency_key=idempotency_key) + + +def _submit(ctx, caller, tool, op, arguments, correlation_id, reason, idempotency_key): sweep_expired(ctx) # lazy GC of stale approvals, the broker has no background worker audit = ctx.audit tool_op = ctx.registry.lookup(tool, op) - if tool_op is None: + if tool_op is None and idempotency_key is None: audit.record("registry", "tool_lookup_failed", NOT_FOUND, correlation_id, details={"tool": tool, "op": op}) return Outcome(NOT_FOUND) - request_id = ctx.store.create_request(correlation_id, caller.id, tool, op, "received") - received = {"tool": tool, "op": op, "risk": tool_op.risk} + if not isinstance(arguments, dict) or (reason is not None and not isinstance(reason, str)): + return Outcome(INVALID, error="invalid_action") + reason = reason if reason and reason.strip() else None + try: + req, created, conflict = ctx.store.reserve_action(caller, correlation_id, tool, op, arguments, + reason, tool_op.generation if tool_op else "", idempotency_key) + except (ValueError, TypeError): + return Outcome(INVALID, error="invalid_action_or_key") + except PermissionError: + return Outcome(DENIED, reason="authority") + request_id = req["id"] + if conflict: + return Outcome(CONFLICT, request_id=request_id, error="idempotency_conflict") + if not created: + # A retry NEVER advances received or polls approval. Explicit status poll + # is the only approval-advancing route. Crashes are not replay permission. + return replace(_outcome_from_request(req, ctx.store.approval_for_request(request_id)), reused=True) + if tool_op is None: + ctx.store.update_request(request_id, status="not_found", arguments_json=None) + return Outcome(NOT_FOUND, request_id=request_id) + received = {"tool": tool, "op": op, "risk": tool_op.risk, "has_idempotency_key": idempotency_key is not None} if reason: - received["reason"] = redact(reason) # caller-supplied free text; bounded + masked + received["reason_provided"] = True # never log arbitrary free text audit.record("request", "received", "accepted", correlation_id, request_id=request_id, details=received) decision = policy_rules.decide(ctx.store.policy_for(caller.id), tool, op) if decision == policy_rules.DENY: - ctx.store.update_request(request_id, status="denied") + ctx.store.update_request(request_id, status="denied", arguments_json=None) audit.record("policy", "decision_deny", DENIED, correlation_id, request_id=request_id, details={"tool": tool, "op": op}) return Outcome(DENIED, request_id=request_id, reason="policy") + if tool_op.type == "rest": + from .gateway import _validate_rest_arguments + invalid = _validate_rest_arguments(tool_op, arguments) + if invalid is not None: + ctx.store.update_request(request_id, status="invalid", error="invalid_envelope", arguments_json=None) + return Outcome(INVALID, request_id=request_id, error="invalid_envelope", detail=invalid) + if decision == policy_rules.REVIEW: audit.record("policy", "decision_review_required", PENDING, correlation_id, request_id=request_id, details={"tool": tool, "op": op, "risk": tool_op.risk}) @@ -82,7 +126,10 @@ def submit(ctx, caller, tool, op, arguments, correlation_id, reason=None) -> Out # allow audit.record("policy", "decision_allow", "ok", correlation_id, request_id=request_id, details={"tool": tool, "op": op}) - return execute_request(ctx, request_id, tool, op, arguments, correlation_id, caller.name) + current_op = ctx.registry.lookup(tool, op) + if not ctx.store.claim_received(request_id, current_op.generation if current_op else None, caller, req["request_digest"]): + return _outcome_from_request(ctx.store.request(request_id)) + return execute_request(ctx, request_id, tool, op, arguments, correlation_id, caller.name, claimed_op=current_op) def _open_approval(ctx, request_id, caller_name, tool, op, tool_op, arguments, correlation_id, @@ -94,11 +141,13 @@ def _open_approval(ctx, request_id, caller_name, tool, op, tool_op, arguments, c return Outcome(UNAVAILABLE, request_id=request_id, error="approval_unavailable") # keep arguments for deferred execution (control-plane store, never audited) - ctx.store.update_request(request_id, arguments_json=json.dumps(arguments)) - # the agent's reason (redacted) rides along to the human on the card + req = ctx.store.request(request_id) + digest = req["request_digest"] # atomically bound at initial reservation card = approval.build_card(request_id, caller_name, tool, op, tool_op.risk, - reason="policy review", justification=redact(reason), - details=redact_request(arguments), endpoint=_rest_endpoint(tool_op)) + reason="policy review", justification=None, + details=summarize(arguments, tool_op.approval_fields), endpoint=_rest_endpoint(tool_op), + installation_id=ctx.store.installation_id, request_digest=digest, + operation_generation=tool_op.generation) try: ref = ctx.surface.open(card) except Exception as exc: @@ -108,16 +157,19 @@ def _open_approval(ctx, request_id, caller_name, tool, op, tool_op, arguments, c request_id=request_id, details={"tool": tool, "op": op, "error": type(exc).__name__}) return Outcome(UNAVAILABLE, request_id=request_id, error="approval_unavailable") - ctx.store.create_approval(request_id, ref, time.time() + ctx.approval_ttl) + ctx.store.create_approval(request_id, ref, time.time() + ctx.approval_ttl, request_digest=digest) ctx.store.update_request(request_id, status="pending_approval") ctx.audit.record("approval", "opened", PENDING, correlation_id, request_id=request_id, details={"tool": tool, "op": op, "risk": tool_op.risk}) return Outcome(PENDING, request_id=request_id) -def execute_request(ctx, request_id, tool, op, arguments, correlation_id, caller_name) -> Outcome: +def execute_request(ctx, request_id, tool, op, arguments, correlation_id, caller_name, *, claimed_op=None) -> Outcome: audit = ctx.audit - tool_op = ctx.registry.lookup(tool, op) + req = ctx.store.request(request_id) + if req is None or req["status"] != "executing" or claimed_op is None: + return Outcome(FAILED, request_id=request_id, error="execution_not_claimed") + tool_op = claimed_op or ctx.registry.lookup(tool, op) if tool_op is None: ctx.store.update_request(request_id, status="failed", error="tool_unavailable", arguments_json=None) @@ -125,12 +177,11 @@ def execute_request(ctx, request_id, tool, op, arguments, correlation_id, caller request_id=request_id, details={"tool": tool, "op": op, "error": "unregistered"}) return Outcome(FAILED, request_id=request_id, error="tool_unavailable") - ctx.store.update_request(request_id, status="running") audit.record("runtime", "execution_started", "accepted", correlation_id, request_id=request_id, details={"tool": tool, "op": op}) try: result = ctx.runtime.execute(tool_op, arguments, request_id, caller_name) - except ToolUnreachable as exc: # the broker couldn't reach the tool: it's probably not running + except NotDispatched as exc: # positive evidence action connection was refused detail = _error_detail(exc) log.warning("tool %s.%s unreachable: %s", tool, op, detail) ctx.store.update_request(request_id, status="failed", error="tool_unreachable", @@ -139,15 +190,15 @@ def execute_request(ctx, request_id, tool, op, arguments, correlation_id, caller request_id=request_id, details={"tool": tool, "op": op, "error": "unreachable", "detail": detail}) return Outcome(FAILED, request_id=request_id, error="tool_unreachable", detail=detail) - except Exception as exc: # the tool ran but errored; a tool failure must not crash the broker + except Exception as exc: # dispatch may have happened; never promise safe replay detail = _error_detail(exc) - log.warning("tool %s.%s failed: %s", tool, op, detail) - ctx.store.update_request(request_id, status="failed", error="tool_failed", + log.warning("tool %s.%s outcome unknown: %s", tool, op, detail) + ctx.store.update_request(request_id, status="unknown", error="execution_outcome_unknown", arguments_json=None) - audit.record("runtime", "execution_failed", FAILED, correlation_id, + audit.record("runtime", "execution_unknown", UNKNOWN, correlation_id, request_id=request_id, details={"tool": tool, "op": op, "error": type(exc).__name__, "detail": detail}) - return Outcome(FAILED, request_id=request_id, error="tool_failed", detail=detail) + return Outcome(UNKNOWN, request_id=request_id, error="execution_outcome_unknown", detail=detail) ctx.store.update_request(request_id, status="completed", result_json=json.dumps(result), arguments_json=None) @@ -172,16 +223,17 @@ def _rest_endpoint(tool_op) -> str | None: def _error_detail(exc: Exception) -> str: - text = f"{type(exc).__name__}: {exc}" - return redact(text)[:500] + # Exception messages can contain workload bodies, paths or credentials. + # Stable classification only; heuristic masking is not a secrecy boundary. + return type(exc).__name__ -def _expire_approval(ctx, approval_id, request_id, surface_ref, tool, op, correlation_id) -> None: +def _expire_approval(ctx, approval_id, request_id, surface_ref, tool, op, correlation_id, now=None) -> bool: """Mark an approval (and its request) expired, withdraw it from the surface (best effort), and audit. Shared by the per-request timeout in `resolve_request` and the lazy `sweep_expired`, one place that fails an approval closed.""" - ctx.store.update_approval(approval_id, status="expired") - ctx.store.update_request(request_id, status="expired", arguments_json=None) + if not ctx.store.finish_pending(request_id, "expired", "expired", deadline_only=True, now=now): + return False if ctx.surface is not None: try: ctx.surface.cancel(surface_ref) @@ -189,6 +241,7 @@ def _expire_approval(ctx, approval_id, request_id, surface_ref, tool, op, correl pass ctx.audit.record("approval", "expired", EXPIRED, correlation_id, request_id=request_id, details={"tool": tool, "op": op}) + return True def sweep_expired(ctx, now=None) -> int: @@ -198,13 +251,14 @@ def sweep_expired(ctx, now=None) -> int: GC requests nobody polls again. Returns the number expired.""" now = time.time() if now is None else now rows = ctx.store.expired_pending_approvals(now) + count = 0 for row in rows: - _expire_approval(ctx, row["id"], row["request_id"], row["surface_ref"], - row["tool"], row["op"], row["correlation_id"]) - return len(rows) + count += bool(_expire_approval(ctx, row["id"], row["request_id"], row["surface_ref"], + row["tool"], row["op"], row["correlation_id"], now=now)) + return count -def resolve_request(ctx, request_id, now=None) -> Outcome: +def resolve_request(ctx, request_id, now=None, caller=None) -> Outcome: """Advance a pending-approval request: enforce the broker timeout, poll the surface, and execute on a confirmed approval. Terminal requests are returned as-is. `now` is injectable for testing the timeout. @@ -229,10 +283,19 @@ def resolve_request(ctx, request_id, now=None) -> Outcome: # ignoring any later surface decision. if now >= approval_row["expires_at"]: _expire_approval(ctx, approval_row["id"], request_id, approval_row["surface_ref"], - req["tool"], req["op"], correlation_id) - return Outcome(EXPIRED, request_id=request_id) + req["tool"], req["op"], correlation_id, now=now) + return _outcome_from_request(ctx.store.request(request_id), ctx.store.approval_for_request(request_id)) state = ctx.surface.poll(approval_row["surface_ref"]) + after_poll = max(now, time.time()) + current_req = ctx.store.request(request_id) + current_approval = ctx.store.approval_for_request(request_id) + if current_req["status"] != "pending_approval" or current_approval["status"] != "pending": + return _outcome_from_request(current_req, current_approval) + if after_poll >= current_approval["expires_at"]: + _expire_approval(ctx, current_approval["id"], request_id, current_approval["surface_ref"], + req["tool"], req["op"], correlation_id, now=after_poll) + return _outcome_from_request(ctx.store.request(request_id), ctx.store.approval_for_request(request_id)) if state.outcome in (approval.APPROVED, approval.REJECTED): # the messenger reported a human decision, recorded distinctly from the # broker's own approved/rejected (which the broker owns and could override). @@ -241,37 +304,40 @@ def resolve_request(ctx, request_id, now=None) -> Outcome: details={"tool": req["tool"], "op": req["op"], "outcome": state.outcome}) if state.outcome == approval.APPROVED: - ctx.store.update_approval(approval_row["id"], status="approved", - approver=state.approver, note=state.note, - decided_at=state.decided_at) + tool_op = ctx.registry.lookup(req["tool"], req["op"]) + # Polling may block while another process changes authority or cancels. + if not ctx.store.claim_approval(request_id, tool_op.generation if tool_op else None, + state, max(now, time.time()), caller=caller): + return _outcome_from_request(ctx.store.request(request_id), + ctx.store.approval_for_request(request_id)) ctx.audit.record("approval", "approved", "ok", correlation_id, request_id=request_id, details={"tool": req["tool"], "op": req["op"], "approver": state.approver}) args = json.loads(req["arguments_json"] or "{}") caller_name = ctx.store.caller_name(req["caller_id"]) out = execute_request(ctx, request_id, req["tool"], req["op"], args, - correlation_id, caller_name) + correlation_id, caller_name, claimed_op=tool_op) return replace(out, approver=state.approver, note=state.note, decided_at=state.decided_at) if state.outcome == approval.REJECTED: - ctx.store.update_approval(approval_row["id"], status="rejected", - approver=state.approver, note=state.note, - decided_at=state.decided_at) - ctx.store.update_request(request_id, status="denied", error="approval_rejected", - arguments_json=None) - ctx.audit.record("approval", "rejected", DENIED, correlation_id, request_id=request_id, + changed = ctx.store.finish_pending(request_id, "denied", "rejected", error="approval_rejected", + state=state, now=max(now, time.time())) + if not changed: + return _outcome_from_request(ctx.store.request(request_id), ctx.store.approval_for_request(request_id)) + current = ctx.store.approval_for_request(request_id) + out = _outcome_from_request(ctx.store.request(request_id), current) + ctx.audit.record("approval", current["status"], out.status, correlation_id, request_id=request_id, details={"tool": req["tool"], "op": req["op"], "approver": state.approver}) - return Outcome(DENIED, request_id=request_id, reason="approval_rejected", - approver=state.approver, note=state.note, decided_at=state.decided_at) + return out if state.outcome in (approval.EXPIRED, approval.CANCELLED): - ctx.store.update_approval(approval_row["id"], status=state.outcome) - ctx.store.update_request(request_id, status="expired", arguments_json=None) + if not ctx.store.finish_pending(request_id, "expired", state.outcome, now=max(now, time.time())): + return _outcome_from_request(ctx.store.request(request_id), ctx.store.approval_for_request(request_id)) ctx.audit.record("approval", "expired", EXPIRED, correlation_id, request_id=request_id, details={"tool": req["tool"], "op": req["op"]}) - return Outcome(EXPIRED, request_id=request_id) + return _outcome_from_request(ctx.store.request(request_id), ctx.store.approval_for_request(request_id)) - return Outcome(PENDING, request_id=request_id) + return _outcome_from_request(ctx.store.request(request_id), ctx.store.approval_for_request(request_id)) def _outcome_from_request(req, approval_row=None) -> Outcome: @@ -279,12 +345,15 @@ def _outcome_from_request(req, approval_row=None) -> Outcome: result = json.loads(req["result_json"]) if req["result_json"] else None mapping = { "completed": OK, "denied": DENIED, "expired": EXPIRED, "failed": FAILED, - "pending_approval": PENDING, "running": PENDING, "received": PENDING, + "pending_approval": PENDING, "running": EXECUTING, "executing": EXECUTING, + "received": RECEIVED, "unknown": UNKNOWN, "result_expired": RESULT_EXPIRED, + "not_found": NOT_FOUND, "invalid": INVALID, } reason = "approval_rejected" if req["error"] == "approval_rejected" else None approver = approval_row["approver"] if approval_row else None note = approval_row["note"] if approval_row else None decided_at = approval_row["decided_at"] if approval_row else None - return Outcome(mapping.get(status, PENDING), request_id=req["id"], + return Outcome(mapping.get(status, UNKNOWN), request_id=req["id"], result=result, error=req["error"], reason=reason, - approver=approver, note=note, decided_at=decided_at) + approver=approver, note=note, decided_at=decided_at, + idempotency_key=req["idempotency_key"], payloads_pruned=req["pruned_at"] is not None) diff --git a/broker/runtime.py b/broker/runtime.py index e39cf5e..f90e781 100644 --- a/broker/runtime.py +++ b/broker/runtime.py @@ -32,7 +32,8 @@ ``docs/message-contracts.md``. Unreachable or non-2xx tools, and JSON-RPC protocol errors, raise, which the -request lifecycle maps to 502. (A *handled* tool error rides back in-band: a 2xx +request lifecycle conservatively maps to unknown/502 unless non-dispatch is +positively established. (A *handled* tool error rides back in-band: a 2xx body for an api tool, an ``isError`` result for an mcp tool; neither raises, both reach the caller, mirroring the api path's "tool returns 200 + error body".) """ @@ -41,22 +42,30 @@ import json import os +import math import urllib.error import urllib.request from .registry import ToolOp +from .bounds import DeadlineHTTPHandler, operation_bounds, read_response, chunks class ToolUnreachable(RuntimeError): - """The broker could not reach the tool at all (connection refused, DNS, or timeout), - i.e. the tool process is probably not running. Kept distinct from a tool that ran and - returned an error, so the lifecycle can report `tool_unreachable` vs `tool_failed`.""" + """Legacy transport marker, NOT proof of non-dispatch (timeouts can follow effects). + + Lifecycle conservatively reports unknown unless the stronger NotDispatched + subtype supplies positive connection-refused evidence. + """ # Protocol version the broker advertises in `initialize`. A streamable-HTTP MCP # server negotiates down if it speaks an older one; we send a recent dated version. MCP_PROTOCOL_VERSION = "2025-06-18" +class NotDispatched(ToolUnreachable): + """Positive transport evidence no action connection was established.""" + + class _NoRedirect(urllib.request.HTTPRedirectHandler): """Never auto-follow a tool-issued redirect. The response (including its Location) is controlled by the tool, so following a 3xx would let the tool steer the broker to an @@ -67,17 +76,17 @@ def redirect_request(self, *args, **kwargs): return None -# One opener for every tool hop, with redirect-following disabled. Built once; openers are -# thread-safe for concurrent .open() calls. -_OPENER = urllib.request.build_opener(_NoRedirect) +# Every tool hop is direct loopback: never route channel credentials through an +# ambient proxy or follow tool-issued redirects. Built once for all transports. +_OPENER = urllib.request.build_opener( + urllib.request.ProxyHandler({}), _NoRedirect, DeadlineHTTPHandler()) def _env_tool_secret(tool_id: str) -> str | None: """The shared secret the broker presents to ``tool_id``. - Sourced from the toolyard state file: each running tool has the E_SECRET - the runner minted at start. The HttpRuntime constructor accepts an - ``e_secret_lookup`` callable; production wires it to ToolState.e_secret_for. + Sourced only from dedicated channel state, never private runner/SPS state. + The HttpRuntime constructor accepts a ``tool_secret`` callable. This function is the default used when the caller didn't inject one. Returns None when the tool isn't in the state file (not running, or started @@ -90,10 +99,10 @@ def _env_tool_secret(tool_id: str) -> str | None: in Phase 5; it is no longer consulted. Operators who provisioned that env var in /etc/toolstack/admin.env or the systemd unit should remove it -- the state file is now the single source of truth, and a leftover env value - would have silently shadowed the real E_SECRET, producing ``channel_secret_mismatch`` + would have silently shadowed the channel secret, producing ``channel_secret_mismatch`` on every call.""" from .tool_state import ToolState - val = ToolState().e_secret_for(tool_id) + val = ToolState().channel_secret_for(tool_id) return val.strip() if val else None @@ -111,10 +120,17 @@ def __init__(self, timeout: float | None = None, tool_secret=_env_tool_secret) - timeout = float(os.environ.get("TOOLSTACK_TOOL_TIMEOUT", "30")) except ValueError: timeout = 30.0 + if not math.isfinite(timeout) or not 0 < timeout <= 300: + raise ValueError("tool timeout must be finite and within (0, 300] seconds") self._timeout = timeout + self._response_max = 32 * 1024 * 1024 self._tool_secret = tool_secret def execute(self, tool_op: ToolOp, arguments: dict, request_id: int, caller_name: str) -> dict: + with operation_bounds(self._timeout, self._response_max): + return self._execute(tool_op, arguments, request_id, caller_name) + + def _execute(self, tool_op, arguments, request_id, caller_name): if tool_op.type == "api": return self._execute_api(tool_op, arguments, request_id, caller_name) if tool_op.type == "mcp": @@ -142,11 +158,13 @@ def _execute_api(self, tool_op: ToolOp, arguments: dict, request_id: int, caller req = urllib.request.Request(url, data=payload, headers=headers, method="POST") try: with _OPENER.open(req, timeout=self._timeout) as resp: - body = resp.read() + body = read_response(resp) except urllib.error.HTTPError as exc: exc.close() raise RuntimeError(f"tool returned HTTP {exc.code}") except urllib.error.URLError as exc: + if isinstance(exc.reason, ConnectionRefusedError): + raise NotDispatched("connection refused") from None raise ToolUnreachable(f"tool unreachable: {exc.reason}") try: return json.loads(body) @@ -171,13 +189,15 @@ def _execute_rest(self, tool_op: ToolOp, arguments: dict, request_id: int, calle req = urllib.request.Request(url, data=payload, headers=headers, method="POST") try: with _OPENER.open(req, timeout=self._timeout) as resp: - body = resp.read() + body = read_response(resp) except urllib.error.HTTPError as exc: try: - body = exc.read() + body = read_response(exc) finally: exc.close() except urllib.error.URLError as exc: + if isinstance(exc.reason, ConnectionRefusedError): + raise NotDispatched("connection refused") from None raise ToolUnreachable(f"tool unreachable: {exc.reason}") try: parsed = json.loads(body) @@ -249,15 +269,19 @@ def _mcp_request(self, base, secret, session, protocol, msg_id, method, params): req = urllib.request.Request(base, data=body, headers=headers, method="POST") try: with _OPENER.open(req, timeout=self._timeout) as resp: - raw = resp.read() ctype = resp.headers.get("Content-Type", "") new_session = resp.headers.get("Mcp-Session-Id") + if "text/event-stream" in ctype: + message = self._read_sse(resp, msg_id) + else: + message = self._parse_jsonrpc(read_response(resp), ctype) except urllib.error.HTTPError as exc: exc.close() raise RuntimeError(f"tool returned HTTP {exc.code}") except urllib.error.URLError as exc: raise ToolUnreachable(f"tool unreachable: {exc.reason}") - message = self._parse_jsonrpc(raw, ctype) + if message.get("jsonrpc") != "2.0" or type(message.get("id")) is not type(msg_id) or message.get("id") != msg_id: + raise RuntimeError("tool returned mismatched JSON-RPC response") if "error" in message: err = message["error"] or {} raise RuntimeError(f"tool MCP error: {err.get('message', err)}") @@ -277,10 +301,38 @@ def _mcp_notify(self, base, secret, session, protocol, method, params): req = urllib.request.Request(base, data=body, headers=headers, method="POST") try: with _OPENER.open(req, timeout=self._timeout) as resp: - resp.read() - except (urllib.error.HTTPError, urllib.error.URLError): + read_response(resp) + except urllib.error.HTTPError as exc: + exc.close() + except urllib.error.URLError: pass # notification delivery is not acknowledged; the tools/call below is the real probe + @staticmethod + def _read_sse(resp, msg_id): + # Keep only one bounded event; stop at the matching response even if the + # server leaves its stream open. Ignore notifications and other IDs. + pending = b"" + data = [] + for chunk in chunks(resp): + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + line = line.rstrip(b"\r") + if line.startswith(b"data:"): + data.append(line[5:].lstrip()) + elif not line: + if data: + try: + msg = json.loads(b"\n".join(data)) + except (ValueError, UnicodeError): + msg = None + if (isinstance(msg, dict) and msg.get("jsonrpc") == "2.0" + and type(msg.get("id")) is type(msg_id) and msg.get("id") == msg_id + and ("result" in msg or "error" in msg)): + return msg + data = [] + raise RuntimeError("tool returned no matching JSON-RPC response") + @staticmethod def _parse_jsonrpc(raw: bytes, ctype: str) -> dict: """A streamable-HTTP MCP server answers a POST with either a single JSON diff --git a/broker/server.py b/broker/server.py index afea62a..b541882 100644 --- a/broker/server.py +++ b/broker/server.py @@ -21,6 +21,9 @@ import os import signal import uuid +import io +import re +import time from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -32,11 +35,36 @@ from .runtime import HttpRuntime from .store import Store from .surface_nod import NodSurface +from .bounds import DeadlineReader DEFAULT_HOST = "127.0.0.1" # loopback by default, see the module docstring DEFAULT_PORT = 8765 MAX_BODY_BYTES = 64 * 1024 # cap request bodies to bound memory use DEFAULT_REST_BODY_MAX = 20 * 1024 * 1024 +INPUT_TOTAL_SECONDS = 10.0 +INPUT_IDLE_SECONDS = 3.0 +OUTPUT_TOTAL_SECONDS = 10.0 +OUTPUT_WRITE_SECONDS = 3.0 # conservative total per sendall, not a throughput guarantee +MAX_HEADER_BYTES = 64 * 1024 + + +class _HeaderTooLarge(ValueError): + pass + + +class _InputReader(io.BufferedReader): + # readline is used for the request line and headers; body uses read(). + def __init__(self, raw): + super().__init__(raw) + self.header_remaining = MAX_HEADER_BYTES + + def readline(self, size=-1): + cap = self.header_remaining + 1 + line = super().readline(min(size, cap) if size >= 0 else cap) + self.header_remaining -= len(line) + if self.header_remaining < 0: + raise _HeaderTooLarge() + return line def _is_loopback(host: str) -> bool: @@ -97,6 +125,94 @@ class _Handler(BaseHTTPRequestHandler): server_version = "broker" sys_version = "" + def setup(self): + super().setup() + self.rfile.close() + self.rfile = _InputReader(DeadlineReader(self.connection, + time.monotonic() + INPUT_TOTAL_SECONDS, INPUT_IDLE_SECONDS)) + + def handle_one_request(self): + # Same serial BaseHTTPRequestHandler flow, but bounded reads and JSON + # errors on timeouts/framing failures, including before headers exist. + self.close_connection = True + self.request_version = "HTTP/1.0" + self.requestline = "" + self.command = None + self._response_started = False + try: + self.raw_requestline = self.rfile.readline(65537) + if len(self.raw_requestline) > 65536: + self.send_error(414) + return + if not self.raw_requestline or not self.parse_request(): + return + self.close_connection = True # no pipelining/reused input budget + method = getattr(self, "do_" + self.command, None) + if method is None: + self.send_error(501) + return + method() + self.wfile.flush() + except TimeoutError: + if self._response_started: + self._output_failed() + else: + self.send_error(408) + except _HeaderTooLarge: + self.send_error(431) + except (ConnectionError, OSError): + if self._response_started: + self._output_failed() + # peer disappeared; never retry the action + + def _output_failed(self): + self.close_connection = True + try: + self._ctx().audit.record("gateway", "output_error", "failed", + self._response_correlation_id, + details={"status": self._response_status}) + except Exception: + pass + + def _output_timeout(self): + remaining = self._output_deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError() + self.connection.settimeout(min(OUTPUT_WRITE_SECONDS, remaining)) + + def _send_json(self, status, payload, correlation_id): + self._response_status = status + self._response_correlation_id = correlation_id + self._output_deadline = time.monotonic() + OUTPUT_TOTAL_SECONDS + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.send_header("X-Correlation-Id", correlation_id) + self.send_header("Connection", "close") + # sendall may commit any prefix before failing, even in end_headers. + # From here onward, failure is output failure, never a new HTTP reply. + self._response_started = True + self._output_timeout() + self.end_headers() + self._output_timeout() + self.wfile.write(payload) + + def send_error(self, code, message=None, explain=None): + # Do not reflect parser exceptions, malformed request lines or values. + names = {408: "request_timeout", 413: "body_too_large", 500: "internal_error"} + payload = json.dumps({"error": names.get(code, "invalid_request")}).encode() + correlation_id = uuid.uuid4().hex + self.close_connection = True + try: + self._ctx().audit.record("gateway", "transport_error", "failed" if code >= 500 else "invalid", + correlation_id, details={"status": code}) + except Exception: + pass # unavailable audit storage must not turn refusal into execution + try: + self._send_json(code, payload, correlation_id) + except (OSError, TimeoutError): + self._output_failed() + def _ctx(self) -> BrokerContext: return self.server.ctx # type: ignore[attr-defined] @@ -106,46 +222,59 @@ def _body_cap(self) -> int: rest_max = int(os.environ.get("TOOLSTACK_REST_BODY_MAX", str(DEFAULT_REST_BODY_MAX))) except ValueError: rest_max = DEFAULT_REST_BODY_MAX - return (rest_max * 4 // 3) + (64 * 1024) + return (max(0, min(rest_max, DEFAULT_REST_BODY_MAX)) * 4 // 3) + (64 * 1024) return MAX_BODY_BYTES def _read_body(self): """Return the parsed JSON body: {} when absent, None on malformed JSON (the gateway maps a non-dict body to 400).""" - length = int(self.headers.get("Content-Length") or 0) + lengths = self.headers.get_all("Content-Length", []) + if len(self.headers.get_all("Idempotency-Key", [])) > 1: + raise ValueError("duplicate idempotency key") + if self.headers.get_all("Transfer-Encoding") or len(lengths) > 1: + raise ValueError("invalid framing") + value = lengths[0] if lengths else "0" + if not re.fullmatch(r"[0-9]{1,10}", value): + raise ValueError("invalid framing") + length = int(value) if length <= 0: return {} cap = self._body_cap() if length > cap: return _TooLarge(cap) raw = self.rfile.read(length) - if not raw: - return {} + if len(raw) != length: + raise ValueError("incomplete body") try: return json.loads(raw) - except json.JSONDecodeError: + except RecursionError: + raise ValueError("invalid framing") from None + except (ValueError, UnicodeError): return None def _respond(self) -> None: - body = self._read_body() + try: + body = self._read_body() + except ValueError: + self.send_error(400) + return if isinstance(body, _TooLarge): correlation_id = uuid.uuid4().hex + try: + self._ctx().audit.record("gateway", "transport_error", "invalid", correlation_id, + details={"status": 413}) + except Exception: + pass payload = json.dumps({"error": "body_too_large", "limit_bytes": body.limit}).encode("utf-8") - self.send_response(413) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.send_header("X-Correlation-Id", correlation_id) - self.end_headers() - self.wfile.write(payload) + self._send_json(413, payload, correlation_id) + return + try: + response = handle(self.command, self.path, dict(self.headers), body, self._ctx()) + except Exception: + self.send_error(500) return - response = handle(self.command, self.path, dict(self.headers), body, self._ctx()) payload = json.dumps(response.body).encode("utf-8") - self.send_response(response.status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.send_header("X-Correlation-Id", response.correlation_id) - self.end_headers() - self.wfile.write(payload) + self._send_json(response.status, payload, response.correlation_id) # Every method goes through the same fail-closed gateway. do_GET = _respond @@ -208,6 +337,7 @@ def build_server( server.tools_root = tools_root # type: ignore[attr-defined] server.tool_dirs = tuple(tool_dirs or ()) # type: ignore[attr-defined] server.reload_registry = lambda: _reload_registry(server) # type: ignore[attr-defined] + _publish_catalog_receipt(server) return server @@ -216,11 +346,24 @@ def _catalog_counts(registry: Registry) -> tuple[int, int]: return len(catalog), len(registry.list_ops()) +def _publish_catalog_receipt(server) -> None: + observation = uuid.uuid4().hex + server.ctx.store.set_catalog_receipt({ + "observation_id": observation, + "fingerprint": server.ctx.registry.observation_fingerprint(server.tools_root, server.tool_dirs), + }) + server.ctx.catalog_observation_id = observation + + def _reload_registry(server) -> Registry: + # Invalidate before parsing: failed reload must not certify the previous catalog. + server.ctx.catalog_observation_id = None + server.ctx.store.set_catalog_receipt(None) old = server.ctx.registry before_tools, before_ops = _catalog_counts(old) new = Registry.from_sources(server.tools_root, server.tool_dirs) server.ctx.registry = new + _publish_catalog_receipt(server) after_tools, after_ops = _catalog_counts(new) server.ctx.audit.record( "registry", "reloaded", "ok", uuid.uuid4().hex, diff --git a/broker/store.py b/broker/store.py index 9dd2219..9bcaa64 100644 --- a/broker/store.py +++ b/broker/store.py @@ -7,15 +7,20 @@ ``":memory:"`` (WAL is skipped there). Tables: callers, tokens (hashed), caller_policies, requests, approvals, audit_events. -A request's arguments/results are stored only transiently: arguments while it is -pending approval (needed to run it after approval), the result on completion, and -arguments are cleared at any terminal state. They are never written to audit. +Arguments/private action bindings exist while received/pending/executing and are +cleared at terminal states. Results are manually prunable after seven days. +Caller/key/payload-digest tombstones stay to forbid replay; values never enter audit. """ from __future__ import annotations import json import os +import re +import stat +import uuid +import tempfile +from urllib.parse import quote import sqlite3 import time from pathlib import Path @@ -23,6 +28,10 @@ from .identity import Caller SCHEMA = """ +CREATE TABLE IF NOT EXISTS broker_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS callers ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, @@ -82,11 +91,42 @@ def default_db_path() -> str: return str(Path(state) / "toolstack" / "broker" / "broker.sqlite3") +def _private_parent(path, create=True): + parent_path = Path(path).parent + for ancestor in parent_path.resolve().parents: + try: + info = ancestor.stat() + except FileNotFoundError: + continue + sticky_system_tmp = info.st_uid == 0 and info.st_mode & stat.S_ISVTX + if info.st_uid not in (0, os.getuid()) or (info.st_mode & 0o022 and not sticky_system_tmp): + raise ValueError("database parent has an unsafe writable ancestor") + if create: + parent_path.mkdir(mode=0o700, parents=True, exist_ok=True) + parent = parent_path.lstat() + if (not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.getuid() or parent.st_mode & 0o077): + raise ValueError("database parent must be an owned private directory (0700)") + return parent_path + + class Store: def __init__(self, db_path: str | None = None) -> None: path = db_path or default_db_path() if path != ":memory:": - Path(path).parent.mkdir(parents=True, exist_ok=True) + _private_parent(path) + # Precreate privately so SQLite's WAL/SHM inherit 0600, independent + # of ambient umask. Reject links; same-owner attackers are not isolated. + for candidate in (path, path + "-wal", path + "-shm", path + "-journal"): + if candidate != path and not os.path.lexists(candidate): + continue + fd = os.open(candidate, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1: + raise ValueError("database state must be a regular file owned by this user") + os.fchmod(fd, 0o600) + finally: + os.close(fd) self._conn = sqlite3.connect(path, check_same_thread=False) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA foreign_keys = ON") @@ -94,14 +134,52 @@ def __init__(self, db_path: str | None = None) -> None: # short-lived connections share this file (one writer + many readers) # without "database is locked"; busy_timeout serializes the rare write # collision. WAL does not apply to an in-memory DB (used in tests). - if path != ":memory:": - self._conn.execute("PRAGMA journal_mode = WAL") self._conn.execute("PRAGMA busy_timeout = 5000") - self._conn.executescript(SCHEMA) - self._conn.commit() + try: + # Serialize schema inspection and DDL across broker/admin startup. + # executescript implicitly commits, so execute each static statement. + self._conn.execute("BEGIN IMMEDIATE") + if self._conn.execute("PRAGMA user_version").fetchone()[0] > 3: + raise ValueError("database schema is newer than this broker supports") + for statement in SCHEMA.split(";"): + if statement.strip(): + self._conn.execute(statement) + columns = {r[1] for r in self._conn.execute("PRAGMA table_info(requests)")} + for name in ("operation_generation", "policy_binding", "action_json", "request_digest", "idempotency_key", "payload_digest", "pruned_at"): + if name not in columns: + kind = "REAL" if name == "pruned_at" else "TEXT" + self._conn.execute(f"ALTER TABLE requests ADD COLUMN {name} {kind}") + approval_columns = {r[1] for r in self._conn.execute("PRAGMA table_info(approvals)")} + if "request_digest" not in approval_columns: + self._conn.execute("ALTER TABLE approvals ADD COLUMN request_digest TEXT") + self._conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS requests_caller_key ON requests(caller_id,idempotency_key) WHERE idempotency_key IS NOT NULL") + self._conn.execute("INSERT OR IGNORE INTO broker_metadata(key,value) VALUES ('installation_uuid',?)", (str(uuid.uuid4()),)) + self._conn.execute("PRAGMA user_version = 3") + self._conn.commit() + if path != ":memory:": + self._conn.execute("PRAGMA journal_mode = WAL") + except BaseException: + self._conn.rollback() + self._conn.close() + raise # --- callers & tokens --------------------------------------------------- + def set_catalog_receipt(self, receipt: dict | None) -> None: + with self._conn: + self._conn.execute("INSERT OR REPLACE INTO broker_metadata(key,value) VALUES (?,?)", + ("catalog_receipt", json.dumps(receipt))) + + def catalog_receipt(self) -> dict | None: + row = self._conn.execute("SELECT value FROM broker_metadata WHERE key=?", + ("catalog_receipt",)).fetchone() + return json.loads(row[0]) if row else None + + @property + def installation_id(self) -> str: + value = self._conn.execute("SELECT value FROM broker_metadata WHERE key='installation_uuid'").fetchone()[0] + return str(uuid.UUID(value)) + def add_caller(self, name: str) -> int: cur = self._conn.execute( "INSERT INTO callers (name, created_at) VALUES (?, ?)", (name, time.time()) @@ -153,7 +231,7 @@ def caller_by_token_hash(self, token_hash: str) -> Caller | None: """, (token_hash,), ).fetchone() - return Caller(id=row["id"], name=row["name"]) if row else None + return Caller(id=row["id"], name=row["name"], token_hash=token_hash) if row else None # --- policy ------------------------------------------------------------- @@ -167,6 +245,8 @@ def set_policy(self, caller_id: int, policy: dict) -> None: """, (caller_id, json.dumps(policy), time.time()), ) + self._conn.execute("UPDATE approvals SET status='cancelled' WHERE status='pending' AND request_id IN (SELECT id FROM requests WHERE caller_id=? AND status='pending_approval')", (caller_id,)) + self._conn.execute("UPDATE requests SET status='denied', error='approval_invalidated', arguments_json=NULL, action_json=NULL,updated_at=? WHERE caller_id=? AND status='pending_approval'", (time.time(), caller_id)) self._conn.commit() def policy_for(self, caller_id: int) -> dict: @@ -200,7 +280,10 @@ def request(self, request_id: int) -> sqlite3.Row | None: def update_request(self, request_id: int, **fields) -> None: """Update whitelisted request columns. Column names come from a fixed allowlist (never user input), so the f-string is safe.""" - allowed = ("status", "arguments_json", "result_json", "error") + if fields.get("status") in {"completed", "denied", "expired", "failed", "unknown", "result_expired", "not_found", "invalid"}: + fields["action_json"] = None + fields["arguments_json"] = None + allowed = ("status", "arguments_json", "result_json", "error", "operation_generation", "policy_binding", "action_json", "request_digest") cols = [c for c in fields if c in allowed] if not cols: return @@ -217,13 +300,287 @@ def caller_name(self, caller_id: int) -> str | None: ).fetchone() return row["name"] if row else None + def _live(self, caller_id, caller=None): + row = self._conn.execute("SELECT revoked_at FROM callers WHERE id=?", (caller_id,)).fetchone() + if row is None or row[0] is not None or (caller is not None and caller.id != caller_id): + return False + if caller is not None and caller.token_hash is not None: + return self._conn.execute("SELECT 1 FROM tokens WHERE caller_id=? AND token_hash=? AND revoked_at IS NULL", + (caller_id, caller.token_hash)).fetchone() is not None + return self.active_token_count(caller_id) > 0 + + @staticmethod + def _bound(req, generation): + from .approval_summary import bound_request, canonical + try: + action = json.loads(req["action_json"] or "null") + if not isinstance(action, dict): + return False + expected, digest = bound_request(req["caller_id"], req["tool"], req["op"], generation, + action.get("arguments"), action.get("reason"), action.get("nonce")) + return (canonical(action) == expected and digest == req["request_digest"] + and canonical(action.get("arguments")) == canonical(json.loads(req["arguments_json"] or "null"))) + except (ValueError, TypeError): + return False + + def authorized_request(self, caller, request_id): + """Read receipt and current authority in one snapshot; no network work.""" + from .policy import decide, DENY + self._conn.execute("BEGIN") + try: + req = self.request(request_id) + allowed = (req is not None and req["caller_id"] == caller.id and self._live(caller.id, caller) + and decide(self.policy_for(caller.id), req["tool"], req["op"]) != DENY) + self._conn.commit() + return req if allowed else None + except BaseException: + self._conn.rollback() + raise + + def reserve_action(self, caller, correlation_id, tool, op, arguments, reason, generation, key=None): + """Return (request, created, conflict); only creator may advance received. + + Private payload digest excludes generation: matching retries after a + catalog reload still refer to the original receipt, never a new effect. + """ + import hashlib + from .approval_summary import canonical, bound_request + from .policy import decide, DENY + self.validate_idempotency_key(key) + payload = canonical({"tool": tool, "op": op, "arguments": arguments, "reason": reason}) + digest = hashlib.sha256(payload.encode()).hexdigest() + action, binding = bound_request(caller.id, tool, op, generation, arguments, reason) + self._conn.execute("BEGIN IMMEDIATE") + try: + if not self._live(caller.id, caller): + raise PermissionError("authority revoked") + policy = self.policy_for(caller.id) + prior = self._conn.execute("SELECT * FROM requests WHERE caller_id=? AND idempotency_key=?", (caller.id, key)).fetchone() if key is not None else None + if prior is not None: + if decide(policy, prior["tool"], prior["op"]) == DENY: + raise PermissionError("authority revoked") + self._conn.commit() + return prior, False, prior["payload_digest"] != digest + now = time.time() + cur = self._conn.execute("INSERT INTO requests(correlation_id,caller_id,tool,op,status,created_at,updated_at,arguments_json,operation_generation,policy_binding,action_json,request_digest,idempotency_key,payload_digest) VALUES (?,?,?,?,'received',?,?,?,?,?,?,?,?,?)", + (correlation_id, caller.id, tool, op, now, now, canonical(arguments), generation, + json.dumps(policy, sort_keys=True), action, binding, key, digest)) + req = self.request(cur.lastrowid) + self._conn.commit() + return req, True, False + except BaseException: + self._conn.rollback() + raise + + @staticmethod + def validate_idempotency_key(key): + if key is not None and (not isinstance(key, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", key)): + raise ValueError("invalid idempotency key") + return key + + def claim_received(self, request_id, generation, caller, expected_digest): + from .policy import decide, ALLOW + self._conn.execute("BEGIN IMMEDIATE") + try: + req = self.request(request_id) + valid = (req is not None and req["status"] == "received" + and self._live(req["caller_id"], caller) and generation + and req["operation_generation"] == generation + and req["request_digest"] == expected_digest and self._bound(req, generation)) + if valid: + policy = self.policy_for(req["caller_id"]) + valid = (json.dumps(policy, sort_keys=True) == req["policy_binding"] + and decide(policy, req["tool"], req["op"]) == ALLOW) + if valid: + self._conn.execute("UPDATE requests SET status='executing', updated_at=? WHERE id=? AND status='received'", (time.time(), request_id)) + elif req is not None and req["status"] == "received": + self._conn.execute("UPDATE requests SET status='denied', error='authority_changed', arguments_json=NULL,action_json=NULL,updated_at=? WHERE id=?", (time.time(), request_id)) + self._conn.commit() + return bool(valid) + except BaseException: + self._conn.rollback() + raise + + def claim_approval(self, request_id, generation, state, now, caller=None) -> bool: + """Commit point: revocation/cancellation wins until this transaction claims. + + No network I/O inside the write transaction. Once running, cancellation + cannot promise to undo a tool effect. Never replay running rows on restart. + """ + from .policy import decide, REVIEW + self._conn.execute("BEGIN IMMEDIATE") + try: + req = self.request(request_id) + app = self.approval_for_request(request_id) + valid = (req is not None and app is not None + and req["status"] == "pending_approval" and app["status"] == "pending" + and app["expires_at"] > now + and self._live(req["caller_id"], caller) + and generation and req["operation_generation"] == generation + and req["request_digest"] and req["request_digest"] == app["request_digest"]) + if valid: + valid = self._bound(req, generation) + if valid: + policy = self.policy_for(req["caller_id"]) + valid = (req["policy_binding"] == json.dumps(policy, sort_keys=True) + and decide(policy, req["tool"], req["op"]) == REVIEW) + if not valid: + # Never overwrite a concurrent terminal/cancelled transition. + if req and req["status"] == "pending_approval": + expired = app is not None and app["status"] == "pending" and app["expires_at"] <= now + self._conn.execute("UPDATE requests SET status=?, error=?, arguments_json=NULL, action_json=NULL, updated_at=? WHERE id=?", + ("expired" if expired else "denied", None if expired else "approval_invalidated", now, request_id)) + self._conn.execute("UPDATE approvals SET status=? WHERE request_id=? AND status='pending'", + ("expired" if expired else "cancelled", request_id)) + self._conn.commit() + return False + self._conn.execute("UPDATE requests SET status='executing', updated_at=? WHERE id=?", (now, request_id)) + self._conn.execute("UPDATE approvals SET status='approved', approver=?, note=?, decided_at=? WHERE id=?", + (state.approver, state.note, state.decided_at, app["id"])) + self._conn.commit() + return True + except BaseException: + self._conn.rollback() + raise + + def finish_pending(self, request_id, status, approval_status, *, error=None, + state=None, now=None, deadline_only=False) -> bool: + """Conditionally finish both rows; stale polls/sweeps cannot undo a claim. + + Used by all pending terminal paths, including out-of-process revocation. + The approved/running commit point wins over later cancellation attempts. + """ + now = time.time() if now is None else now + self._conn.execute("BEGIN IMMEDIATE") + try: + req = self.request(request_id) + app = self.approval_for_request(request_id) + if (req is None or app is None or req["status"] != "pending_approval" + or app["status"] != "pending" + or (deadline_only and app["expires_at"] > now)): + self._conn.commit() + return False + # Broker deadline wins over any delayed human rejection/cancellation. + if now >= app["expires_at"]: + status, approval_status, error = "expired", "expired", None + elif (self._conn.execute("SELECT revoked_at FROM callers WHERE id=?", (req["caller_id"],)).fetchone()[0] is not None + or self.active_token_count(req["caller_id"]) == 0): + status, approval_status, error = "expired", "cancelled", None + self._conn.execute("UPDATE requests SET status=?, error=?, arguments_json=NULL, action_json=NULL, updated_at=? WHERE id=?", + (status, error, now, request_id)) + self._conn.execute("UPDATE approvals SET status=?, approver=?, note=?, decided_at=? WHERE id=?", + (approval_status, state.approver if state else None, + state.note if state else None, state.decided_at if state else None, app["id"])) + self._conn.commit() + return True + except BaseException: + self._conn.rollback() + raise + + def reconcile_offline(self, *, confirmed_offline=False): + """Explicit operator assertion all brokers/writers stopped; never replay.""" + if not confirmed_offline: + raise ValueError("stop all writers and explicitly confirm offline reconciliation") + with self._conn: + cur = self._conn.execute("UPDATE requests SET status='unknown',error='offline_reconciled',arguments_json=NULL,action_json=NULL,result_json=NULL,updated_at=? WHERE status IN ('received','executing','running')", (time.time(),)) + return cur.rowcount + + def prune_payloads(self, now=None): + """Manual seven-day pruning; immutable caller/key/digest tombstones stay.""" + now = time.time() if now is None else now + with self._conn: + self._conn.execute("UPDATE approvals SET note=NULL WHERE request_id IN (SELECT id FROM requests WHERE updated_at < ? AND pruned_at IS NULL AND status IN ('completed','denied','expired','failed','unknown','result_expired','not_found','invalid'))", (now - 7 * 86400,)) + cur = self._conn.execute("UPDATE requests SET status=CASE WHEN status='completed' THEN 'result_expired' ELSE status END,arguments_json=NULL,action_json=NULL,result_json=NULL,pruned_at=? WHERE updated_at < ? AND pruned_at IS NULL AND status IN ('completed','denied','expired','failed','unknown','result_expired','not_found','invalid')", (now, now - 7 * 86400)) + return cur.rowcount + + def backup_to(self, destination): + """SQLite snapshot includes committed WAL; new private destination ONLY.""" + return self._snapshot_new(self._conn, destination) + + @staticmethod + def _snapshot_new(source, destination, transform=None): + destination = str(destination) + parent = _private_parent(destination) + if os.path.lexists(destination): + raise FileExistsError("destination already exists; never overwrite state") + fd, stage = tempfile.mkstemp(prefix=".broker-snapshot-", dir=parent) + os.fchmod(fd, 0o600) + os.close(fd) + try: + target = sqlite3.connect(stage) + try: + deadline = time.monotonic() + 30 + def progress(status, remaining, total): + if time.monotonic() > deadline: + raise TimeoutError("SQLite snapshot deadline exceeded") + source.backup(target, pages=128, progress=progress, sleep=0.01) + target.execute("PRAGMA journal_mode=DELETE") + target.commit() + finally: + target.close() + if transform is not None: + transform(stage) + fd = os.open(stage, os.O_RDONLY | os.O_NOFOLLOW) + try: + os.fsync(fd) + finally: + os.close(fd) + # Atomic no-clobber publication. An existing/link/hardlink/racing + # destination is refused by the kernel, never truncated or chmodded. + os.link(stage, destination, follow_symlinks=False) + return destination + finally: + for suffix in ("", "-wal", "-shm", "-journal"): + Path(stage + suffix).unlink(missing_ok=True) # only our unique stage + + @classmethod + def restore_new(cls, snapshot, destination, *, confirmed_offline=False): + """Operator-exclusive restore to NEW destination; no execution or replay. + + Caller must stop all writers. This is an explicit operator assertion, + not guessed PID ownership or isolation against arbitrary same-owner code. + """ + if not confirmed_offline: + raise ValueError("stop all writers and explicitly confirm offline restore") + _private_parent(destination) + if os.path.lexists(destination): + raise FileExistsError("restore destination exists; never overwrite state") + _private_parent(snapshot, create=False) + info = Path(snapshot).lstat() + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or info.st_nlink != 1 or info.st_mode & 0o077): + raise ValueError("snapshot must be a private owned regular file, not a link") + def sanitize(stage): + store = cls(stage) + try: + now = time.time() + store._conn.execute("BEGIN IMMEDIATE") + store._conn.execute("UPDATE approvals SET status='cancelled',note=NULL WHERE status='pending'") + store._conn.execute("UPDATE requests SET status='expired',error='restore_cancelled',arguments_json=NULL,action_json=NULL,result_json=NULL,updated_at=? WHERE status='pending_approval'", (now,)) + store._conn.execute("UPDATE requests SET status='unknown',error='restore_unknown',arguments_json=NULL,action_json=NULL,result_json=NULL,updated_at=? WHERE status IN ('received','executing','running')", (now,)) + store._conn.execute("UPDATE tokens SET revoked_at=? WHERE revoked_at IS NULL", (now,)) + store._conn.execute("UPDATE caller_policies SET policy_json='{}',updated_at=?", (now,)) + store._conn.execute("UPDATE broker_metadata SET value=? WHERE key='installation_uuid'", (str(uuid.uuid4()),)) + store._conn.commit() + store.append_audit(now, "admin", "restored_offline", "ok", None, None, + {"tokens_invalidated": True, "policies_cleared": True, "replayed": 0}) + store._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + store._conn.execute("PRAGMA journal_mode=DELETE") + finally: + store.close() + source = sqlite3.connect(f"file:{quote(str(Path(snapshot).absolute()), safe='/')}?mode=ro", uri=True) + try: + return cls._snapshot_new(source, destination, transform=sanitize) + finally: + source.close() + # --- approvals ---------------------------------------------------------- - def create_approval(self, request_id: int, surface_ref: str, expires_at: float) -> int: + def create_approval(self, request_id: int, surface_ref: str, expires_at: float, request_digest=None) -> int: cur = self._conn.execute( - "INSERT INTO approvals (request_id, surface_ref, status, expires_at, created_at) " - "VALUES (?, ?, 'pending', ?, ?)", - (request_id, surface_ref, expires_at, time.time()), + "INSERT INTO approvals (request_id, surface_ref, status, expires_at, created_at, request_digest) " + "VALUES (?, ?, 'pending', ?, ?, ?)", + (request_id, surface_ref, expires_at, time.time(), request_digest), ) self._conn.commit() return int(cur.lastrowid) @@ -349,6 +706,7 @@ def list_tokens(self, include_revoked: bool = False) -> list[sqlite3.Row]: ).fetchall() def revoke_token_by_prefix(self, prefix: str) -> int: + self._validate_token_prefix(prefix) cur = self._conn.execute( "UPDATE tokens SET revoked_at = ? WHERE token_hash LIKE ? AND revoked_at IS NULL", (time.time(), prefix + "%"), @@ -359,11 +717,17 @@ def revoke_token_by_prefix(self, prefix: str) -> int: def caller_ids_for_token_prefix(self, prefix: str) -> list[int]: """Distinct caller ids owning any token matching ``prefix`` (revoked or not). Used after a token revocation to find which callers may now be tokenless.""" + self._validate_token_prefix(prefix) rows = self._conn.execute( "SELECT DISTINCT caller_id FROM tokens WHERE token_hash LIKE ?", (prefix + "%",) ).fetchall() return [r["caller_id"] for r in rows] + @staticmethod + def _validate_token_prefix(prefix: str) -> None: + if not isinstance(prefix, str) or not re.fullmatch(r"[0-9a-f]{12,64}", prefix): + raise ValueError("token prefix must be 12–64 lowercase hexadecimal characters") + def active_token_count(self, caller_id: int) -> int: """How many non-revoked tokens a caller still has.""" row = self._conn.execute( diff --git a/broker/surface_nod.py b/broker/surface_nod.py index b76ab6d..037c4e3 100644 --- a/broker/surface_nod.py +++ b/broker/surface_nod.py @@ -4,8 +4,8 @@ poll -> GET {base}/api/v1/requests/{id}/decision (issuer token, requests:read) cancel -> POST {base}/api/v1/requests/{id}/cancel (issuer token; best effort) -It maps an OperationCard to nod's CreateDecisionRequest (no raw args/secrets; -push text redacted) and nod's decision back to a normalized SurfaceState. The +It maps an OperationCard to nod's CreateDecisionRequest (allowlisted structural +summary, not a secret-free guarantee) and nod's decision back to SurfaceState. The broker owns approval truth: `poll` is authoritative; nod is the messenger. Contract pinned against nod v1.0.1 (github.com/batteryshark/nod @ 01d535d), @@ -31,8 +31,9 @@ decision -> {option_id, option_kind, option_label, text?, actor_user_id?, actor_device_id?, signature?, resolved_at}. option_kind ∈ {approve, approve_with_text, reject, reject_with_text, - dismiss, open, custom} (snake_case). We map approve* -> - APPROVED, reject* -> REJECTED, everything else -> not-an-approval (PENDING). + dismiss, open, custom} (snake_case). Only the exact issued + pairs (approve, approve), (reject, reject_with_text), in resolved state, + become APPROVED/REJECTED. Every returned request identifier must match. cancel -> only the creating issuer token may cancel its request (else 403); best effort, since the broker has already recorded the outcome. @@ -46,16 +47,64 @@ from __future__ import annotations import json +import math import os +import re +import uuid import urllib.error +import urllib.parse import urllib.request from . import approval from .approval import OperationCard, SurfaceState +from .bounds import DeadlineHTTPHandler, DeadlineHTTPSHandler, operation_bounds, read_response + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect, + DeadlineHTTPHandler(), DeadlineHTTPSHandler()) + + +def _reference(ref): + if not isinstance(ref, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", ref): + raise ValueError("invalid Nod request identifier") + return ref + + +def _matching_ids(value, ref): + if isinstance(value, dict): + for key, child in value.items(): + if key == "request_id" and child != ref: + return False + if key == "request" and isinstance(child, dict) and "id" in child and child["id"] != ref: + return False + if not _matching_ids(child, ref): + return False + elif isinstance(value, list): + return all(_matching_ids(child, ref) for child in value) + return True class NodSurface: def __init__(self, base_url, issuer_token, *, channel="default", timeout=15.0): + try: + if not isinstance(base_url, str) or any(c.isspace() or ord(c) < 32 for c in base_url): + raise ValueError + url = urllib.parse.urlsplit(base_url) + if (url.scheme not in {"http", "https"} or not url.hostname + or url.username is not None or url.password is not None + or "?" in base_url or "#" in base_url or "\\" in base_url): + raise ValueError + if url.port is not None and not 0 < url.port <= 65535: + raise ValueError + except (ValueError, TypeError): + raise ValueError("invalid Nod base URL") from None + if not math.isfinite(timeout) or not 0 < timeout <= 60: + raise ValueError("Nod timeout must be finite and within (0,60] seconds") self._base = base_url.rstrip("/") self._token = issuer_token self._channel = channel @@ -80,11 +129,22 @@ def _call(self, method: str, path: str, body: dict | None = None) -> dict: headers={"Content-Type": "application/json", "Authorization": f"Bearer {self._token}"}, ) - with urllib.request.urlopen(req, timeout=self._timeout) as resp: - raw = resp.read() - return json.loads(raw) if raw else {} + with operation_bounds(self._timeout, 1024 * 1024): + try: + with _OPENER.open(req, timeout=self._timeout) as resp: + raw = read_response(resp) + except urllib.error.HTTPError as exc: + exc.close() + raise + result = json.loads(raw) if raw else {} + if not isinstance(result, dict): + raise ValueError("invalid Nod response") + return result def open(self, card: OperationCard) -> str: + namespace = str(uuid.UUID(card.installation_id)) if card.installation_id else None + if namespace is None: + raise ValueError("persistent installation identity required") payload = { "channel_id": self._channel, "title": card.title, @@ -101,20 +161,28 @@ def open(self, card: OperationCard) -> str: {"id": "reject", "label": "Reject", "kind": "reject_with_text", "destructive": True}, ], - "dedupe_key": str(card.request_id), # retry-safe open + "dedupe_key": f"toolstack:{namespace}:{card.request_id}", "notification": {"redact": True}, # nothing sensitive on a lock screen } md = [] - if card.justification: - md.append(f"**Agent's reason:** {card.justification}") + # Free-text reason is never a trusted summary, even if pattern-redacted. + # Structural presentation is produced by the broker's allowlist path. + if card.request_digest: + payload["fields"].append({"label": "Full request SHA-256", "value": card.request_digest}) + if card.operation_generation: + payload["fields"].append({"label": "Operation generation", "value": card.operation_generation}) if card.details: - md.append(f"**Request the agent sent:**\n```json\n{card.details}\n```") + md.append(f"**Allowlisted summary (not the complete request):**\n```json\n{card.details}\n```") if md: payload["body_markdown"] = "\n\n".join(md) result = self._call("POST", "/api/v1/requests", payload) - return str(result["request_id"]) + ref = _reference(result.get("request_id")) + if not _matching_ids(result, ref): + raise ValueError("Nod returned mismatched request identifiers") + return ref def poll(self, ref: str) -> SurfaceState: + _reference(ref) try: data = self._call("GET", f"/api/v1/requests/{ref}/decision") except urllib.error.HTTPError as exc: @@ -124,10 +192,13 @@ def poll(self, ref: str) -> SurfaceState: raise RuntimeError(f"nod poll failed: HTTP {exc.code}") except urllib.error.URLError as exc: raise RuntimeError(f"nod unreachable: {exc.reason}") + if not isinstance(data, dict) or data.get("request_id") != ref or not _matching_ids(data, ref): + raise ValueError("Nod returned mismatched request identifiers") return _to_state(data) def cancel(self, ref: str) -> None: try: + _reference(ref) self._call("POST", f"/api/v1/requests/{ref}/cancel") except Exception: pass # best effort; the broker has already recorded the local outcome @@ -137,17 +208,17 @@ def _to_state(data: dict) -> SurfaceState: status = data.get("status", "pending") if status == "expired": return SurfaceState(approval.EXPIRED) - if status in ("cancelled", "canceled"): + if status == "cancelled": return SurfaceState(approval.CANCELLED) decision = data.get("decision") - if not decision: + if status != "resolved" or not isinstance(decision, dict): return SurfaceState(approval.PENDING) - kind = (decision.get("option_kind") or "").lower() + pair = (decision.get("option_id"), decision.get("option_kind")) approver = decision.get("actor_user_id") note = decision.get("text") decided_at = decision.get("resolved_at") # nod's decision time (ISO 8601) -> decided_at - if kind.startswith("approve"): + if pair == ("approve", "approve"): return SurfaceState(approval.APPROVED, approver, note, decided_at) - if kind.startswith("reject"): + if pair == ("reject", "reject_with_text"): return SurfaceState(approval.REJECTED, approver, note, decided_at) return SurfaceState(approval.PENDING) # dismiss / other -> not an approval diff --git a/broker/tests/test_approval.py b/broker/tests/test_approval.py index ddfc076..da550e6 100644 --- a/broker/tests/test_approval.py +++ b/broker/tests/test_approval.py @@ -95,20 +95,22 @@ def test_surface_decision_received_is_audited(self): for e in ctx.store.audit_events(request_id=out.request_id)} self.assertIn(("approval", "surface_decision_received"), pairs) - def test_agent_reason_rides_to_the_card(self): + def test_agent_reason_is_not_disclosed_on_card(self): surface = FakeSurface(approval.PENDING) ctx, caller = self._setup(surface) lifecycle.submit(ctx, caller, "echo", "shout", {}, CID, reason="please skip") - self.assertEqual(surface.opened[0].justification, "please skip") + self.assertIsNone(surface.opened[0].justification) + self.assertNotIn("please skip", surface.opened[0].details) def test_request_details_ride_to_the_card_and_distinguish_calls(self): surface = FakeSurface(approval.PENDING) ctx, caller = self._setup(surface) lifecycle.submit(ctx, caller, "echo", "shout", {"body": {"dueDate": "2026-07-01"}}, CID) lifecycle.submit(ctx, caller, "echo", "shout", {"body": {"assignedTo": "bob"}}, "corr-2") - self.assertIn("2026-07-01", surface.opened[0].details) - self.assertIn("bob", surface.opened[1].details) - self.assertNotEqual(surface.opened[0].details, surface.opened[1].details) + self.assertNotIn("2026-07-01", surface.opened[0].details) + self.assertNotIn("bob", surface.opened[1].details) + self.assertIn("ALL arguments omitted", surface.opened[0].details) + self.assertNotEqual(surface.opened[0].request_digest, surface.opened[1].request_digest) def test_rest_review_card_carries_endpoint_template_not_values(self): surface = FakeSurface(approval.PENDING) diff --git a/broker/tests/test_approval_security.py b/broker/tests/test_approval_security.py new file mode 100644 index 0000000..6b717a2 --- /dev/null +++ b/broker/tests/test_approval_security.py @@ -0,0 +1,235 @@ +"""Security 10/14: synthetic credentials; no real Nod/SPS or external writes.""" +import json +import hashlib +import os +import sqlite3 +import tempfile +import unittest +import urllib.error +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from unittest.mock import patch + +from broker import approval, request_lifecycle as lifecycle +from broker.approval_summary import summarize, validate_fields, bound_request +from broker.identity import authenticate +from broker.registry import Registry +from broker.store import Store, SCHEMA +from broker.surface_nod import NodSurface +from client import toolstack +from .support import BrokerTestCase, seed_caller +from .test_boundaries import serving + + +class Summary(BrokerTestCase): + def test_oversized_first_field_does_not_hide_later_decisive_field(self): + text = summarize({'a': ['x' * 256] * 16, 'b': 'decisive-fake'}, ('/a', '/b')) + data = json.loads(text) + self.assertEqual(data['selected_fields'], [ + {'field': '/a', 'value': '[OMITTED: summary size limit]'}, + {'field': '/b', 'value': 'decisive-fake'}]) + self.assertIn('values shown: 1', text) + self.assertIn('omitted for summary size: 1', text) + self.assertLessEqual(len(text), 4000) + + def test_all_oversized_values_are_explicit_even_when_labels_do_not_fit(self): + for character in ('x', '\u2603'): + fields = validate_fields(['/' + character * 253 + f'{i:02}' for i in range(32)]) + arguments = {p[1:]: ['x' * 256] * 16 for p in fields} + text = summarize(arguments, fields) + self.assertLessEqual(len(text), 4000) + self.assertIn('ALL configured field values omitted', text) + self.assertIn('Configured fields: 32; values shown: 0', text) + self.assertIn('omitted for summary size: 32', text) + self.assertNotIn('Additional configured', text) + self.assertTrue(all(e['value'] == '[OMITTED: summary size limit]' + for e in json.loads(text)['selected_fields'])) + + def test_total_budget_counts_fields_that_cannot_fit_and_keeps_masks(self): + fields = [f'/field{i}' for i in range(30)] + ['/password', '/reason'] + args = {p[1:]: 'x' * 256 for p in fields} + args.update(password='fake-private-password', reason='fake-private-justification') + text = summarize(args, validate_fields(fields)) + self.assertLessEqual(len(text), 4000) + self.assertIn('omitted for summary size:', text) + self.assertNotIn('fake-private', text) + masked = json.loads(summarize(args, ['/password', '/reason']))['selected_fields'] + self.assertEqual([e['value'] for e in masked], ['[MASKED]', '[MASKED]']) + + def test_recursive_short_secret_masks_but_hostname_survives(self): + text = summarize({"destination": "api.example.com", "body": { + "password": "tiny", "reason": "tiny-why", "nested": [{"api_key": "abc", "label": "safe"}]}}, + ["/destination", "/body"]) + self.assertIn("api.example.com", text) + self.assertIn("[MASKED]", text) + for secret in ("tiny", "tiny-why", "abc"): + self.assertNotIn(secret, text) + self.assertIn("safe", text) + + def test_sensitive_ancestor_masks_explicit_child_pointer(self): + self.assertNotIn("short", summarize({"credentials": {"label": "short"}}, ["/credentials/label"])) + + def test_omitted_decisive_input_and_justification_bound_not_disclosed(self): + ctx = self.make_ctx(catalog={"fake": {"send": "write"}}) + token = seed_caller(ctx.store, review=["fake.send"]) + caller = authenticate(ctx.store, f"Bearer {token}") + first = lifecycle.submit(ctx, caller, "fake", "send", {"danger": "x" * 10000 + "decisive"}, "a", reason="short-password") + second = lifecycle.submit(ctx, caller, "fake", "send", {"danger": "x" * 10000 + "different"}, "b", reason="other") + card = ctx.surface.opened[0] + self.assertIn("ALL arguments omitted", card.details) + self.assertNotIn("decisive", card.details) + self.assertNotIn("short-password", repr(card)) + self.assertIsNone(card.justification) + self.assertNotEqual(card.request_digest, ctx.surface.opened[1].request_digest) + self.assertIn("short-password", ctx.store.request(first.request_id)["action_json"]) + stored = ctx.store.request(first.request_id)["action_json"] + self.assertEqual(hashlib.sha256(stored.encode()).hexdigest(), card.request_digest) + self.assertNotIn(json.loads(stored)["nonce"], repr(card)) + self.assertNotIn("short-password", json.dumps(ctx.store.audit_events())) + ctx.surface.set(approval.APPROVED) + ctx.registry._catalog["fake"]["port"] = 9999 + self.assertNotEqual(lifecycle.resolve_request(ctx, first.request_id).status, lifecycle.OK) + self.assertEqual(ctx.runtime.calls, []) + + def test_stored_arguments_changed_after_card_cannot_execute(self): + ctx = self.make_ctx(catalog={"fake": {"send": "write"}}) + token = seed_caller(ctx.store, review=["fake.send"]) + caller = authenticate(ctx.store, f"Bearer {token}") + out = lifecycle.submit(ctx, caller, "fake", "send", {"action": "original"}, "a") + ctx.store.update_request(out.request_id, arguments_json='{"action":"replacement"}') + ctx.surface.set(approval.APPROVED) + self.assertEqual(lifecycle.resolve_request(ctx, out.request_id).status, lifecycle.DENIED) + self.assertEqual(ctx.runtime.calls, []) + + def test_canonical_binding_does_not_confuse_boolean_and_integer(self): + ctx = self.make_ctx(catalog={"fake": {"send": "write"}}) + token = seed_caller(ctx.store, review=["fake.send"]) + caller = authenticate(ctx.store, f"Bearer {token}") + out = lifecycle.submit(ctx, caller, "fake", "send", {"value": True}, "a") + ctx.store.update_request(out.request_id, arguments_json='{"value":1}') + ctx.surface.set(approval.APPROVED) + self.assertEqual(lifecycle.resolve_request(ctx, out.request_id).status, lifecycle.DENIED) + self.assertEqual(ctx.runtime.calls, []) + + def test_replacing_request_binding_cannot_rebind_existing_approval(self): + ctx = self.make_ctx(catalog={"fake": {"send": "write"}}) + token = seed_caller(ctx.store, review=["fake.send"]) + caller = authenticate(ctx.store, f"Bearer {token}") + out = lifecycle.submit(ctx, caller, "fake", "send", {"action": "original"}, "a") + generation = ctx.registry.lookup("fake", "send").generation + action, digest = bound_request(caller.id, "fake", "send", generation, {"action": "replacement"}, None) + ctx.store.update_request(out.request_id, arguments_json='{"action":"replacement"}', + action_json=action, request_digest=digest) + ctx.surface.set(approval.APPROVED) + self.assertEqual(lifecycle.resolve_request(ctx, out.request_id).status, lifecycle.DENIED) + self.assertEqual(ctx.runtime.calls, []) + + def test_truncation_nesting_size_and_missing_labels_are_explicit(self): + text = summarize({"message": "z" * 2000, "items": list(range(100))}, ["/message", "/items", "/missing"]) + self.assertIn("TRUNCATED", text) + self.assertIn("additional items", text) + self.assertIn("not present", text) + self.assertLess(len(text), 6000) + + def test_manifest_pointer_contract_is_loaded_and_described(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "toolyard.toml" + path.write_text('id="fake"\n[entrypoint]\nport=4600\n[[operations]]\nname="send"\napproval_fields=["/destination","/body/title"]\n') + catalog = {} + Registry._add_toml(catalog, path) + registry = Registry(catalog) + self.assertEqual(registry.lookup("fake", "send").approval_fields, ("/destination", "/body/title")) + self.assertEqual(registry.describe("fake", "send")["approval_fields"], ["/destination", "/body/title"]) + self.assertEqual(validate_fields(["/"]), ("/",)) + for bad in ([""], ["/*"], ["/a/*"], ["/literal*key"], ["missing-slash"], ["/bad~2"], ["/x", "/x"], "not-list", ["/x"] * 33): + with self.assertRaises(ValueError): + validate_fields(bad) + + +class NamespaceAndRedirect(unittest.TestCase): + def test_v1_fixture_upgrades_without_reusing_unbound_approval(self): + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "broker.db") + old = sqlite3.connect(path) + old.executescript(SCHEMA) + old.execute("DROP TABLE broker_metadata") # actual v1 had no namespace table + old.execute("ALTER TABLE requests ADD COLUMN operation_generation TEXT") + old.execute("ALTER TABLE requests ADD COLUMN policy_binding TEXT") + old.execute("PRAGMA user_version=1") + old.execute("INSERT INTO callers VALUES (1,'legacy',0,NULL)") + old.execute("INSERT INTO tokens VALUES ('fake-hash',1,0,NULL)") + old.execute("INSERT INTO requests(id,correlation_id,caller_id,tool,op,status,arguments_json,created_at,updated_at,operation_generation,policy_binding) VALUES (1,'legacy',1,'fake','send','pending_approval','{}',0,0,'fixture','{}')") + old.execute("INSERT INTO approvals VALUES (1,1,'old-ref','pending',99999999999,NULL,NULL,NULL,0)") + old.commit() + old.close() + store = Store(path) + try: + self.assertEqual(store.caller_name(1), "legacy") + self.assertEqual(store._conn.execute("PRAGMA user_version").fetchone()[0], 3) + self.assertTrue(store.installation_id) + self.assertFalse(store.claim_approval(1, "fixture", approval.SurfaceState(approval.APPROVED), 1)) + self.assertEqual(store.request(1)["status"], "denied") + self.assertIsNone(store.request(1)["arguments_json"]) + finally: + store.close() + + def test_installation_uuid_persists_across_connections_and_restarts(self): + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "broker.db") + store = Store(path) + first = store.installation_id + other = Store(path) + try: + self.assertEqual(other.installation_id, first) + finally: + other.close() + store.close() + store = Store(path) + fresh = Store(str(Path(d) / "other.db")) + try: + self.assertEqual(store.installation_id, first) + self.assertNotEqual(fresh.installation_id, first) + finally: + store.close() + fresh.close() + + def test_authenticated_client_and_nod_never_follow_redirect(self): + hits = [] + class Trap(BaseHTTPRequestHandler): + def do_GET(self): + hits.append(self.headers.get("Authorization")) + self.send_response(200) + self.end_headers() + self.wfile.write(b"{}") + def log_message(self, *args): + pass + with serving(HTTPServer(("127.0.0.1", 0), Trap)) as target: + class Redirect(BaseHTTPRequestHandler): + code = 302 + same_origin = False + def do_GET(self): + if self.path == "/trap": + hits.append(self.headers.get("Authorization")) + self.send_response(200) + self.end_headers() + return + self.send_response(type(self).code) + port = self.server.server_address[1] if type(self).same_origin else target + self.send_header("Location", f"http://127.0.0.1:{port}/trap") + self.end_headers() + def log_message(self, *args): + pass + with serving(HTTPServer(("127.0.0.1", 0), Redirect)) as source: + base = f"http://127.0.0.1:{source}" + for same_origin in (False, True): + Redirect.same_origin = same_origin + for code in (301, 302, 303, 307, 308): + Redirect.code = code + with patch.dict(os.environ, {"TOOLSTACK_URL": base, "TOOLSTACK_TOKEN": "fake-client-token"}): + status, body = toolstack._send("GET", "/test") + self.assertEqual(status, code) + self.assertEqual(body["error"], "redirect_refused") + with self.assertRaises(urllib.error.HTTPError) as caught: + NodSurface(base, "fake-issuer-token")._call("GET", "/test") + caught.exception.close() + self.assertEqual(hits, []) diff --git a/broker/tests/test_boundaries.py b/broker/tests/test_boundaries.py new file mode 100644 index 0000000..eab2aab --- /dev/null +++ b/broker/tests/test_boundaries.py @@ -0,0 +1,364 @@ +"""Synthetic two-connection state and transport regression checks.""" +import concurrent.futures +import sqlite3 +import tempfile +import threading +import unittest +import json +import http.client +import socket +import time +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest.mock import patch +from pathlib import Path + +from broker import approval, request_lifecycle as lifecycle +from broker.identity import authenticate +from broker.audit import AuditLog +from broker.store import Store, SCHEMA +from broker.runtime import HttpRuntime +from broker.registry import ToolOp +from broker.bounds import operation_bounds, LimitExceeded +from broker.server import build_server +from broker.gateway import make_correlation_id +from broker.gateway import handle +from .support import make_ctx, seed_caller + + +class Persistence(unittest.TestCase): + def test_failed_migration_rolls_back_all_ddl(self): + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "broker.db") + with patch("broker.store.SCHEMA", SCHEMA + "CREATE TABLE broken("), self.assertRaises(sqlite3.Error): + Store(path) + conn = sqlite3.connect(path) + try: + self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0], 0) + self.assertEqual(conn.execute("SELECT name FROM sqlite_master").fetchall(), []) + finally: + conn.close() + + def test_legacy_migration_serializes_two_openers(self): + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "broker.db") + conn = sqlite3.connect(path) + conn.executescript(SCHEMA) + conn.execute("INSERT INTO callers VALUES (1,'legacy',0,NULL)") + conn.execute("INSERT INTO tokens VALUES ('fake-hash',1,0,NULL)") + conn.execute("INSERT INTO requests VALUES (1,'legacy',1,'echo','say','pending_approval','{}',NULL,NULL,0,0)") + conn.execute("INSERT INTO approvals VALUES (1,1,'fake-ref','pending',99999999999,NULL,NULL,NULL,0)") + conn.commit() + conn.close() + barrier = threading.Barrier(2) + def open_store(): + barrier.wait(timeout=5) + store = Store(path) + try: + return store.caller_name(1), store._conn.execute("PRAGMA user_version").fetchone()[0] + finally: + store.close() + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(open_store) for _ in range(2)] + self.assertEqual([f.result(timeout=10) for f in futures], [("legacy", 3)] * 2) + store = Store(path) + try: + self.assertFalse(store.claim_approval(1, "new-generation", approval.SurfaceState(approval.APPROVED), 1)) + self.assertEqual(store.request(1)["error"], "approval_invalidated") + finally: + store.close() + + def test_newer_schema_refused_without_schema_changes(self): + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "broker.db") + conn = sqlite3.connect(path) + conn.execute("PRAGMA user_version=99") + conn.close() + with self.assertRaisesRegex(ValueError, "newer"): + Store(path) + conn = sqlite3.connect(path) + try: + self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0], 99) + self.assertEqual(conn.execute("SELECT name FROM sqlite_master").fetchall(), []) + finally: + conn.close() + + def test_existing_public_parent_refused_not_chmodded(self): + with tempfile.TemporaryDirectory() as d: + parent = Path(d) / "shared" + parent.mkdir(mode=0o755) + parent.chmod(0o755) # fixture must remain unsafe even under umask077 + self.assertEqual(parent.stat().st_mode & 0o777, 0o755) + with self.assertRaisesRegex(ValueError, "private directory"): + Store(str(parent / "broker.db")) + self.assertEqual(parent.stat().st_mode & 0o777, 0o755) + self.assertFalse((parent / "broker.db").exists()) + + def test_private_parent_under_writable_ancestor_refused(self): + with tempfile.TemporaryDirectory() as d: + shared = Path(d) / "shared" + shared.mkdir(mode=0o777) + shared.chmod(0o777) + private = shared / "private" + private.mkdir(mode=0o700) + with self.assertRaisesRegex(ValueError, "unsafe writable ancestor"): + Store(str(private / "broker.db")) + self.assertEqual(shared.stat().st_mode & 0o777, 0o777) + + def test_late_terminal_polls_preserve_second_connection_cancellation(self): + for decision in (approval.APPROVED, approval.REJECTED, approval.CANCELLED, + approval.EXPIRED, approval.PENDING): + with self.subTest(decision=decision), tempfile.TemporaryDirectory() as d: + ctx = make_ctx(catalog={"echo": {"say": "low"}}) + ctx.store.close() + + path = str(Path(d) / "broker.db") + ctx.store = Store(path) + ctx.audit = AuditLog(ctx.store, sink=None) + other = Store(path) + try: + token = seed_caller(ctx.store, review=["echo.say"]) + caller = authenticate(ctx.store, f"Bearer {token}") + out = lifecycle.submit(ctx, caller, "echo", "say", {}, "race") + def poll(ref): + self.assertTrue(other.finish_pending(out.request_id, "expired", "cancelled")) + return approval.SurfaceState(decision) + ctx.surface.poll = poll + result = lifecycle.resolve_request(ctx, out.request_id) + self.assertEqual(result.status, lifecycle.EXPIRED) + self.assertEqual(ctx.store.approval_for_request(out.request_id)["status"], "cancelled") + self.assertEqual(ctx.runtime.calls, []) + finally: + other.close() + ctx.store.close() + + +@contextmanager +def serving(server): + worker = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + worker.start() + try: + yield server.server_address[1] + finally: + server.shutdown() + server.server_close() + worker.join(timeout=2) + + +class Transport(unittest.TestCase): + def test_denied_rest_skips_operation_specific_validation(self): + ctx = make_ctx(catalog={"fake": {"send": "write"}}, tool_type="rest", rest_meta={"body_kind": "binary"}) + try: + token = seed_caller(ctx.store) + body = {"arguments": {"body": "not base64!"}} + response = handle("POST", "/v1/actions/fake.send", {"Authorization": f"Bearer {token}"}, body, ctx) + self.assertEqual(response.status, 403) + self.assertNotIn("base64", json.dumps(response.body)) + finally: + ctx.store.close() + + def test_header_cap_and_unexpected_error_are_sanitized(self): + server = build_server(port=0, db_path=":memory:", audit_sink=None) + try: + with serving(server) as port: + with patch("broker.server.MAX_HEADER_BYTES", 128): + with socket.create_connection(("127.0.0.1", port), timeout=1) as sock: + sock.sendall(b"GET /v1/health HTTP/1.0\r\nX: " + b"x" * 256 + b"\r\n\r\n") + self.assertIn(b"431", sock.makefile("rb").read().split(b"\r\n")[0]) + with patch("broker.server.handle", side_effect=RuntimeError("fake-sensitive-value")): + with socket.create_connection(("127.0.0.1", port), timeout=1) as sock: + sock.sendall(b"GET /v1/health HTTP/1.0\r\n\r\n") + response = http.client.HTTPResponse(sock) + response.begin() + self.assertEqual(response.status, 500) + self.assertNotIn(b"fake-sensitive-value", response.read()) + correlation = response.getheader("X-Correlation-Id") + response.close() + event = server.ctx.store.audit_events()[-1] + self.assertEqual(event["correlation_id"], correlation) + self.assertEqual(event["details"], {"status": 500}) + finally: + server.ctx.store.close() + + def test_slow_body_and_body_size_cap(self): + with patch("broker.server.INPUT_TOTAL_SECONDS", 0.15), patch("broker.server.INPUT_IDLE_SECONDS", 0.08): + server = build_server(port=0, db_path=":memory:", audit_sink=None) + try: + with serving(server) as port: + with socket.create_connection(("127.0.0.1", port), timeout=1) as sock: + sock.sendall(b"POST /v1/actions/x.y HTTP/1.0\r\nContent-Length: 100\r\n\r\n{") + response = sock.makefile("rb").read() + self.assertIn(b"408", response.split(b"\r\n")[0]) + with socket.create_connection(("127.0.0.1", port), timeout=1) as sock: + sock.sendall(b"POST /mcp HTTP/1.0\r\nContent-Length: 99999999\r\n\r\n") + self.assertIn(b"413", sock.makefile("rb").read().split(b"\r\n")[0]) + with socket.create_connection(("127.0.0.1", port), timeout=1) as sock: + sock.sendall(b"GET /v1/health HTTP/1.0\r\n\r\n") + self.assertIn(b"200", sock.makefile("rb").read().split(b"\r\n")[0]) + finally: + server.ctx.store.close() + + def test_jsonrpc_response_id_mismatch_is_refused(self): + class WrongID(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"jsonrpc":"2.0","id":999,"result":{}}') + def log_message(self, *args): + pass + with serving(HTTPServer(("127.0.0.1", 0), WrongID)) as port: + with operation_bounds(1, 4096), self.assertRaisesRegex(RuntimeError, "mismatched"): + HttpRuntime()._mcp_request(f"http://127.0.0.1:{port}/mcp", None, None, None, 2, "tools/call", {}) + + def test_second_connection_revocation_during_rejected_poll_wins(self): + with tempfile.TemporaryDirectory() as d: + ctx = make_ctx(catalog={"echo": {"say": "low"}}) + ctx.store.close() + ctx.store = Store(str(Path(d) / "broker.db")) + ctx.audit = AuditLog(ctx.store, sink=None) + other = Store(str(Path(d) / "broker.db")) + try: + token = seed_caller(ctx.store, review=["echo.say"]) + caller = authenticate(ctx.store, f"Bearer {token}") + out = lifecycle.submit(ctx, caller, "echo", "say", {}, "race") + def poll(ref): + other.revoke_caller("hermes") + return approval.SurfaceState(approval.REJECTED) + ctx.surface.poll = poll + result = lifecycle.resolve_request(ctx, out.request_id) + self.assertEqual(result.status, lifecycle.EXPIRED) + self.assertEqual(ctx.store.approval_for_request(out.request_id)["status"], "cancelled") + self.assertEqual(ctx.runtime.calls, []) + finally: + other.close() + ctx.store.close() + + def test_bad_framing_has_stable_json_and_releases_serial_broker(self): + server = build_server(port=0, db_path=":memory:", audit_sink=None) + try: + with serving(server) as port: + for framing in (b"Content-Length: -1\r\n", b"Content-Length: x\r\n", + b"Content-Length: 0\r\nContent-Length: 0\r\n", + b"Transfer-Encoding: chunked\r\n", + b"Idempotency-Key: first\r\nIdempotency-Key: second\r\n"): + with socket.create_connection(("127.0.0.1", port), timeout=1) as sock: + sock.sendall(b"POST /v1/actions/x.y HTTP/1.0\r\n" + framing + b"\r\n") + response = sock.makefile("rb").read() + self.assertIn(b"400", response.split(b"\r\n")[0]) + self.assertTrue(response.endswith(b'{"error": "invalid_request"}')) + finally: + server.ctx.store.close() + + def test_absolute_slow_header_deadline_and_idle_deadline(self): + with patch("broker.server.INPUT_TOTAL_SECONDS", 0.15), patch("broker.server.INPUT_IDLE_SECONDS", 0.08): + server = build_server(port=0, db_path=":memory:", audit_sink=None) + try: + with serving(server) as port: + for drip in (False, True): + start = time.monotonic() + with socket.create_connection(("127.0.0.1", port), timeout=1) as sock: + sock.sendall(b"GET /v1/health HTTP/1.0\r\nX: ") + if drip: + for _ in range(4): + time.sleep(0.03) + try: + sock.sendall(b"a") + except OSError: + break + response = http.client.HTTPResponse(sock) + response.begin() + self.assertEqual(response.status, 408) + self.assertEqual(json.loads(response.read()), {"error": "request_timeout"}) + response.close() + self.assertLess(time.monotonic() - start, 0.8) + finally: + server.ctx.store.close() + + def test_correlation_id_alphabet(self): + for value in ("hello world", "a:b", "a\r\nb", "x" * 65, "é"): + self.assertRegex(make_correlation_id({"x-correlation-id": value}), r"^[0-9a-f]{32}$") + self.assertEqual(make_correlation_id({"x-correlation-id": "safe_1-ID"}), "safe_1-ID") + + def test_runtime_total_deadline_includes_trickled_headers_and_body(self): + for phase in ("headers", "body"): + class Slow(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + try: + if phase == "headers": + self.connection.sendall(b"HTTP/1.0 200 OK\r\nX: ") + else: + self.send_response(200) + self.end_headers() + for _ in range(15): + self.connection.sendall(b" ") + time.sleep(0.02) + except OSError: + pass + def log_message(self, *args): + pass + with serving(HTTPServer(("127.0.0.1", 0), Slow)) as port: + runtime = HttpRuntime(timeout=0.12, tool_secret=lambda _: None) + start = time.monotonic() + with self.assertRaises((TimeoutError, RuntimeError)): + runtime.execute(ToolOp("fake", "say", "read", port, "api"), {}, 1, "test") + self.assertLess(time.monotonic() - start, 0.5) + + def test_runtime_response_cap(self): + class Large(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.send_response(200) + self.end_headers() + self.wfile.write(b"x" * 1024) + def log_message(self, *args): + pass + with serving(HTTPServer(("127.0.0.1", 0), Large)) as port: + runtime = HttpRuntime(tool_secret=lambda _: None) + runtime._response_max = 64 + with self.assertRaises(LimitExceeded): + runtime.execute(ToolOp("fake", "say", "read", port, "api"), {}, 1, "test") + + def test_incremental_sse_matches_id_without_waiting_for_close(self): + class SSE(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + for mid in (999, 2): + msg = {"jsonrpc": "2.0", "id": mid, "result": {"isError": True}} + self.wfile.write(b"data: " + json.dumps(msg).encode() + b"\n\n") + self.wfile.flush() + time.sleep(0.25) + def log_message(self, *args): + pass + with serving(HTTPServer(("127.0.0.1", 0), SSE)) as port: + runtime = HttpRuntime() + start = time.monotonic() + with operation_bounds(0.15, 4096): + result, _ = runtime._mcp_request(f"http://127.0.0.1:{port}/mcp", None, None, None, 2, "tools/call", {}) + self.assertTrue(result["isError"]) + self.assertLess(time.monotonic() - start, 0.15) + + def test_stale_sweep_cannot_expire_claimed_execution(self): + with tempfile.TemporaryDirectory() as d: + ctx = make_ctx(catalog={"echo": {"say": "low"}}) + ctx.store.close() + path = str(Path(d) / "broker.db") + ctx.store = Store(path) + ctx.audit = AuditLog(ctx.store, sink=None) + other = Store(path) + try: + token = seed_caller(ctx.store, review=["echo.say"]) + caller = authenticate(ctx.store, f"Bearer {token}") + out = lifecycle.submit(ctx, caller, "echo", "say", {}, "race") + op = ctx.registry.lookup("echo", "say") + self.assertTrue(other.claim_approval(out.request_id, op.generation, + approval.SurfaceState(approval.APPROVED), 1)) + self.assertFalse(ctx.store.finish_pending(out.request_id, "expired", "expired", now=1e12)) + self.assertEqual(ctx.store.request(out.request_id)["status"], "executing") + finally: + other.close() + ctx.store.close() diff --git a/broker/tests/test_gateway.py b/broker/tests/test_gateway.py index 9a49b0d..c18f5bf 100644 --- a/broker/tests/test_gateway.py +++ b/broker/tests/test_gateway.py @@ -208,7 +208,7 @@ def test_rate_limit_returns_429(self): self.assertEqual(handle("POST", "/v1/actions/echo.say", h, {"arguments": {}}, ctx).status, 200) self.assertEqual(handle("POST", "/v1/actions/echo.say", h, {"arguments": {}}, ctx).status, 429) - def test_reason_is_redacted_in_audit(self): + def test_reason_is_omitted_in_audit(self): ctx = self.make_ctx(catalog={"echo": {"say": "low"}}) token = seed_caller(ctx.store, "hermes", allow=["echo.say"]) reason = "rotate token " + "Z" * 40 @@ -216,7 +216,8 @@ def test_reason_is_redacted_in_audit(self): {"arguments": {}, "reason": reason}, ctx) blob = json.dumps(ctx.audit.events()) self.assertNotIn("Z" * 40, blob) - self.assertIn("[redacted]", blob) + self.assertNotIn("rotate token", blob) + self.assertIn('"reason_provided": true', blob) class Discovery(BrokerTestCase): diff --git a/broker/tests/test_lifecycle.py b/broker/tests/test_lifecycle.py index 77bb989..6183cde 100644 --- a/broker/tests/test_lifecycle.py +++ b/broker/tests/test_lifecycle.py @@ -47,22 +47,22 @@ def test_unknown_tool_is_not_found_without_a_request_row(self): self.assertEqual(out.status, lifecycle.NOT_FOUND) self.assertIsNone(out.request_id) - def test_tool_failure_maps_to_failed(self): + def test_unproven_tool_failure_maps_to_unknown(self): # Registered, the tool ran but raised -> a generic tool_failed. ctx, caller = self._setup(catalog={"boom": {"now": "low"}}, allow=["boom.now"]) out = lifecycle.submit(ctx, caller, "boom", "now", {}, CID) - self.assertEqual(out.status, lifecycle.FAILED) - self.assertEqual(out.error, "tool_failed") + self.assertEqual(out.status, lifecycle.UNKNOWN) + self.assertEqual(out.error, "execution_outcome_unknown") self.assertIn("RuntimeError", out.detail) - self.assertEqual(ctx.store.request(out.request_id)["status"], "failed") + self.assertEqual(ctx.store.request(out.request_id)["status"], "unknown") def test_unreachable_tool_maps_to_tool_unreachable(self): # The broker couldn't reach the tool (process not running) -> a distinct, diagnostic # error so the caller knows to start it, not a generic tool_failed. ctx, caller = self._setup(catalog={"down": {"now": "low"}}, allow=["down.now"]) out = lifecycle.submit(ctx, caller, "down", "now", {}, CID) - self.assertEqual(out.status, lifecycle.FAILED) - self.assertEqual(out.error, "tool_unreachable") + self.assertEqual(out.status, lifecycle.UNKNOWN) + self.assertEqual(out.error, "execution_outcome_unknown") self.assertIn("ToolUnreachable", out.detail) def test_arguments_never_appear_in_audit(self): diff --git a/broker/tests/test_mcp.py b/broker/tests/test_mcp.py index 03b5576..8bc4dcf 100644 --- a/broker/tests/test_mcp.py +++ b/broker/tests/test_mcp.py @@ -34,6 +34,13 @@ def _tool_result(response): class Auth(BrokerTestCase): + def test_nested_tool_error_propagates_to_outer_mcp(self): + ctx = self.make_ctx(catalog=CATALOG) + token = seed_caller(ctx.store, allow=["echo.say"]) + ctx.runtime.execute = lambda *args: {"isError": True, "content": []} + response = _mcp(ctx, token, "tools/call", {"name": "echo__say", "arguments": {}}) + self.assertTrue(response.body["result"]["isError"]) + def test_unauthenticated_mcp_is_401(self): # Same boundary as the action API: only GET /v1/health is open; /mcp needs a token. r = handle("POST", "/mcp", {}, {"jsonrpc": "2.0", "id": 1, "method": "ping"}, self.make_ctx()) @@ -187,7 +194,7 @@ def test_review_parks_nonblocking_then_resolves(self): self.assertEqual(ctx.runtime.calls, []) # tool has NOT run yet # the agent's reason rode to the human approver (redacted) - self.assertIn("please review", surface.opened[0].justification) + self.assertIsNone(surface.opened[0].justification) # resolution uses the regular request poll surface.set(approval.APPROVED, approver="owner") diff --git a/broker/tests/test_operations.py b/broker/tests/test_operations.py index 7c8fa2f..4fcb025 100644 --- a/broker/tests/test_operations.py +++ b/broker/tests/test_operations.py @@ -119,7 +119,7 @@ def test_missing_caller_raises_lookup_error(self): call() def test_revoke_unknown_token_is_noop_without_audit(self): - count = operations.revoke_token(self.store, "deadbeef", "op") + count = operations.revoke_token(self.store, "deadbeef1234", "op") self.assertEqual(count, 0) self.assertEqual(self._admin_events("token_revoked"), []) diff --git a/broker/tests/test_policy.py b/broker/tests/test_policy.py index 4704e4b..231a57b 100644 --- a/broker/tests/test_policy.py +++ b/broker/tests/test_policy.py @@ -26,16 +26,15 @@ def test_empty_policy_denies(self): def test_unknown_effect_denies(self): self.assertEqual(policy.decide({"tools": {"x": {"y": "maybe"}}}, "x", "y"), policy.DENY) - def test_refresh_op_auto_allowed_for_any_caller(self): - # refresh + refresh_one are broker-injected infrastructure ops; a per-caller - # policy gate would force every operator to remember to grant them and there - # is no scenario where a caller should be denied a secret-cache refresh. The - # audit log still records every dispatch. + def test_refresh_requires_explicit_policy(self): + # Infrastructure names are not authority; missing and explicit deny win. empty = {} - some_policy = {"tools": {"gcal": {"refresh": "deny"}}} # explicit deny is overridden + some_policy = {"tools": {"gcal": {"refresh": "deny"}}} for op in ("refresh", "refresh_one"): - self.assertEqual(policy.decide(empty, "gcal", op), policy.ALLOW) - self.assertEqual(policy.decide(some_policy, "gcal", op), policy.ALLOW) + self.assertEqual(policy.decide(empty, "gcal", op), policy.DENY) + self.assertEqual(policy.decide(some_policy, "gcal", op), policy.DENY) + explicit = {"tools": {"gcal": {op: "allow"}}} + self.assertEqual(policy.decide(explicit, "gcal", op), policy.ALLOW) if __name__ == "__main__": diff --git a/broker/tests/test_receipts.py b/broker/tests/test_receipts.py new file mode 100644 index 0000000..ff2fe0a --- /dev/null +++ b/broker/tests/test_receipts.py @@ -0,0 +1,264 @@ +"""Fake effects and private temporary SQLite state, never automatic replay.""" +import concurrent.futures +import json +import tempfile +import threading +import time +import unittest +from pathlib import Path + +from broker import request_lifecycle as life, approval +from broker.audit import AuditLog +from broker.gateway import handle +from broker.identity import authenticate, hash_token +from broker.store import Store +from broker.runtime import NotDispatched +from .support import BrokerTestCase, make_ctx, seed_caller + + +class Receipts(BrokerTestCase): + def test_known_non_dispatch_is_failed_and_bad_keys_never_dispatch(self): + ctx, caller = self.setup_action() + def refused(*args): + raise NotDispatched("fake closed local port") + ctx.runtime.execute = refused + first = life.submit(ctx, caller, "echo", "say", {}, "a", idempotency_key="known") + self.assertEqual(first.status, life.FAILED) + self.assertEqual(life.submit(ctx, caller, "echo", "say", {}, "b", idempotency_key="known").status, life.FAILED) + for key in ("", "bad key", "*", "x"*129, 1, {"bad": "key"}): + out = life.submit(ctx, caller, "echo", "say", {}, "bad", idempotency_key=key) + self.assertEqual(out.status, life.INVALID) + self.assertIsNone(out.idempotency_key) + + def setup_action(self): + ctx = self.make_ctx(catalog={"echo": {"say": "read"}}) + token = seed_caller(ctx.store, allow=["echo.say"]) + return ctx, authenticate(ctx.store, f"Bearer {token}") + + def test_matching_retries_conflict_cross_caller_and_generation_behavior(self): + ctx, caller = self.setup_action() + first = life.submit(ctx, caller, "echo", "say", {"a": 1, "b": 2}, "a", idempotency_key="retry-1") + ctx.registry._catalog["echo"]["port"] = 9999 + repeat = life.submit(ctx, caller, "echo", "say", {"b": 2, "a": 1}, "b", idempotency_key="retry-1") + self.assertEqual((repeat.request_id, repeat.result), (first.request_id, first.result)) + conflict = life.submit(ctx, caller, "echo", "say", {"a": True, "b": 2}, "c", idempotency_key="retry-1") + self.assertEqual(conflict.status, life.CONFLICT) + token = seed_caller(ctx.store, "other", allow=["echo.say"]) + other = authenticate(ctx.store, f"Bearer {token}") + independent = life.submit(ctx, other, "echo", "say", {}, "d", idempotency_key="retry-1") + self.assertNotEqual(independent.request_id, first.request_id) + self.assertEqual(len(ctx.runtime.calls), 2) + + def test_private_reason_is_part_of_private_digest(self): + ctx, caller = self.setup_action() + life.submit(ctx, caller, "echo", "say", {}, "a", reason="private-short", idempotency_key="key") + self.assertEqual(life.submit(ctx, caller, "echo", "say", {}, "b", reason="changed", idempotency_key="key").status, life.CONFLICT) + self.assertNotIn("private-short", json.dumps(ctx.store.audit_events())) + + def test_authority_rechecked_before_sensitive_repeat_and_poll(self): + ctx, caller = self.setup_action() + out = life.submit(ctx, caller, "echo", "say", {"private": "test-value"}, "a", idempotency_key="key") + ctx.store.set_policy(caller.id, {}) + repeat = life.submit(ctx, caller, "echo", "say", {"private": "test-value"}, "b", idempotency_key="key") + self.assertEqual(repeat.status, life.DENIED) + self.assertIsNone(repeat.result) + response = handle("GET", f"/v1/requests/{out.request_id}", {"Authorization": "Bearer token-for-hermes"}, None, ctx) + self.assertEqual(response.status, 403) + self.assertNotIn("test-value", json.dumps(response.body)) + + def test_historical_completed_receipt_allow_to_review_then_withdraw_and_exact_token_revoke(self): + ctx, caller = self.setup_action() + action = "/v1/actions/echo.say" + payload = {"arguments": {"private": "synthetic-historical-result"}} + headers = {"Authorization": "Bearer token-for-hermes", "Idempotency-Key": "historical-key"} + first = handle("POST", action, headers, payload, ctx) + self.assertEqual(first.status, 200) + self.assertEqual(first.body["status"], life.OK) + receipt = f"/v1/requests/{first.body['request_id']}" + + def assert_same_result(auth): + for method, path, body in (("POST", action, payload), ("GET", receipt, None)): + response = handle(method, path, {**headers, "Authorization": auth}, body, ctx) + self.assertEqual(response.status, 200) + self.assertEqual(response.body["status"], life.OK) + self.assertEqual(response.body["request_id"], first.body["request_id"]) + self.assertEqual(response.body["result"], first.body["result"]) + self.assertEqual(len(ctx.runtime.calls), 1) + self.assertEqual(ctx.surface.opened, []) # review gates NEW effects only + + def assert_withheld(auth, status): + for method, path, body in (("POST", action, payload), ("GET", receipt, None)): + response = handle(method, path, {**headers, "Authorization": auth}, body, ctx) + self.assertEqual(response.status, status) + self.assertNotIn("result", response.body) + self.assertNotIn("synthetic-historical-result", json.dumps(response.body)) + self.assertEqual(len(ctx.runtime.calls), 1) + + ctx.store.set_policy(caller.id, {"tools": {"echo": {"say": "review"}}}) + assert_same_result("Bearer token-for-hermes") + for policy in ({"tools": {"echo": {"say": "deny"}}}, {}): + with self.subTest(policy=policy): + ctx.store.set_policy(caller.id, policy) + assert_withheld("Bearer token-for-hermes", 403) + + ctx.store.set_policy(caller.id, {"tools": {"echo": {"say": "review"}}}) + ctx.store.add_token(caller.id, hash_token("other-active-fake-token")) + ctx.store.revoke_token(caller.token_hash) + self.assertEqual(ctx.store.active_token_count(caller.id), 1) + assert_withheld("Bearer token-for-hermes", 401) + assert_same_result("Bearer other-active-fake-token") + + def test_immediate_claim_rechecks_token_even_with_other_active_token(self): + ctx, caller = self.setup_action() + ctx.store.add_token(caller.id, hash_token("other-active-token")) + original = ctx.audit.record + def record(component, event, *args, **kwargs): + original(component, event, *args, **kwargs) + if event == "decision_allow": + ctx.store.revoke_token(caller.token_hash) + ctx.audit.record = record + out = life.submit(ctx, caller, "echo", "say", {}, "a", idempotency_key="key") + self.assertEqual(out.status, life.DENIED) + self.assertEqual(ctx.runtime.calls, []) + + def test_immediate_claim_rechecks_generation_and_policy(self): + for mutation in ("policy", "generation"): + with self.subTest(mutation=mutation): + ctx, caller = self.setup_action() + original = ctx.audit.record + def record(component, event, *args, **kwargs): + original(component, event, *args, **kwargs) + if event == "decision_allow": + if mutation == "policy": + ctx.store.set_policy(caller.id, {}) + else: + ctx.registry._catalog["echo"]["port"] = 9999 + ctx.audit.record = record + self.assertEqual(life.submit(ctx, caller, "echo", "say", {}, "a").status, life.DENIED) + self.assertEqual(ctx.runtime.calls, []) + + def test_possible_dispatch_timeout_unknown_never_replayed(self): + ctx, caller = self.setup_action() + effects = [] + def execute(*args): + effects.append("effect") + raise TimeoutError("fake-sensitive-error") + ctx.runtime.execute = execute + first = life.submit(ctx, caller, "echo", "say", {}, "a", idempotency_key="key") + repeat = life.submit(ctx, caller, "echo", "say", {}, "b", idempotency_key="key") + self.assertEqual((first.status, repeat.status), (life.UNKNOWN, life.UNKNOWN)) + self.assertEqual(first.request_id, repeat.request_id) + self.assertEqual(effects, ["effect"]) + self.assertNotIn("fake-sensitive-error", repr(first)) + + def test_received_is_not_approval_pending_and_key_never_resumes_it(self): + ctx, caller = self.setup_action() + op = ctx.registry.lookup("echo", "say") + req, _, _ = ctx.store.reserve_action(caller, "a", "echo", "say", {}, None, op.generation, "key") + repeat = life.submit(ctx, caller, "echo", "say", {}, "b", idempotency_key="key") + self.assertEqual(repeat.status, life.RECEIVED) + self.assertEqual(req["id"], repeat.request_id) + self.assertEqual(ctx.runtime.calls, []) + + def test_manual_prune_boundary_tombstones_match_conflict_and_other_caller(self): + ctx, caller = self.setup_action() + first = life.submit(ctx, caller, "echo", "say", {}, "a", idempotency_key="key") + now = time.time() + cutoff = now - 7 * 86400 + ctx.store._conn.execute("UPDATE requests SET updated_at=? WHERE id=?", (cutoff, first.request_id)) + ctx.store._conn.commit() + self.assertEqual(ctx.store.prune_payloads(now), 0) + self.assertEqual(ctx.store.prune_payloads(now + 1), 1) + self.assertEqual(ctx.store.prune_payloads(now + 2), 0) + repeat = life.submit(ctx, caller, "echo", "say", {}, "b", idempotency_key="key") + self.assertEqual(repeat.status, life.RESULT_EXPIRED) + self.assertTrue(repeat.payloads_pruned) + self.assertIsNone(repeat.result) + self.assertEqual(repeat.request_id, first.request_id) + self.assertEqual(life.submit(ctx, caller, "echo", "say", {"different": 1}, "c", idempotency_key="key").status, life.CONFLICT) + token = seed_caller(ctx.store, "other", allow=["echo.say"]) + other = authenticate(ctx.store, f"Bearer {token}") + self.assertEqual(life.submit(ctx, other, "echo", "say", {}, "d", idempotency_key="key").status, life.OK) + self.assertEqual(len(ctx.runtime.calls), 2) + + def test_active_payloads_never_pruned(self): + ctx, caller = self.setup_action() + op = ctx.registry.lookup("echo", "say") + req, _, _ = ctx.store.reserve_action(caller, "a", "echo", "say", {}, None, op.generation, "key") + executing, _, _ = ctx.store.reserve_action(caller, "b", "echo", "say", {}, None, op.generation, "executing") + self.assertTrue(ctx.store.claim_received(executing["id"], op.generation, caller, executing["request_digest"])) + ctx.store.set_policy(caller.id, {"tools": {"echo": {"say": "review"}}}) + pending = life.submit(ctx, caller, "echo", "say", {}, "c", idempotency_key="pending") + self.assertEqual(pending.status, life.PENDING) + self.assertEqual(ctx.store.prune_payloads(time.time() + 30 * 86400), 0) + for request_id in (req["id"], executing["id"], pending.request_id): + self.assertIsNotNone(ctx.store.request(request_id)["action_json"]) + + def test_http_and_native_mcp_key_contract_conflict409(self): + ctx, caller = self.setup_action() + headers = {"Authorization": "Bearer token-for-hermes", "Idempotency-Key": "key"} + first = handle("POST", "/v1/actions/echo.say", headers, {"arguments": {}}, ctx) + self.assertEqual(first.body["idempotency_key"], "key") + self.assertNotIn(ctx.store.request(first.body["request_id"])["payload_digest"], json.dumps(first.body)) + conflict = handle("POST", "/v1/actions/echo.say", headers, {"arguments": {"new": 1}}, ctx) + self.assertEqual(conflict.status, 409) + msg = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { + "name": "echo__say", "arguments": {}, "_meta": {"toolstack/idempotency-key": "key"}}} + reply = handle("POST", "/mcp", {"Authorization": "Bearer token-for-hermes"}, msg, ctx) + self.assertEqual(json.loads(reply.body["result"]["content"][0]["text"])["request_id"], first.body["request_id"]) + msg["params"]["arguments"] = {"changed": True} + self.assertEqual(handle("POST", "/mcp", {"Authorization": "Bearer token-for-hermes"}, msg, ctx).status, 409) + self.assertEqual(len(ctx.runtime.calls), 1) + + +class ConcurrentReceipts(unittest.TestCase): + def test_two_store_connections_one_claim_effect_and_offline_reconcile_only(self): + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "broker.db") + setup = Store(path) + token = seed_caller(setup, allow=["echo.say"]) + setup.close() + barrier = threading.Barrier(2) + release = threading.Event() + effects = [] + class Runtime: + def execute(self, *args): + effects.append("effect") + if not release.wait(3): + raise TimeoutError("test synchronization failed") + return {"done": True} + def submit(): + ctx = make_ctx(catalog={"echo": {"say": "read"}}, runtime=Runtime()) + ctx.store.close() + ctx.store = Store(path) + ctx.audit = AuditLog(ctx.store, sink=None) + try: + caller = authenticate(ctx.store, f"Bearer {token}") + barrier.wait(timeout=5) + out = life.submit(ctx, caller, "echo", "say", {}, "race", idempotency_key="key") + if out.status in {life.RECEIVED, life.EXECUTING}: + release.set() + return out + finally: + ctx.store.close() + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(submit) for _ in range(2)] + results = [future.result(timeout=10) for future in futures] + self.assertEqual(effects, ["effect"]) + self.assertEqual(results[0].request_id, results[1].request_id) + self.assertIn(life.OK, {out.status for out in results}) + store = Store(path) + try: + caller = authenticate(store, f"Bearer {token}") + req, _, _ = store.reserve_action(caller, "a", "echo", "say", {}, None, "generation", "stale") + self.assertTrue(store.claim_received(req["id"], "generation", caller, req["request_digest"])) + other = Store(path) + self.assertEqual(other.request(req["id"])["status"], "executing") + other.close() + with self.assertRaises(ValueError): + store.reconcile_offline() + self.assertEqual(store.reconcile_offline(confirmed_offline=True), 1) + self.assertEqual(store.request(req["id"])["status"], "unknown") + self.assertEqual(effects, ["effect"]) + finally: + store.close() diff --git a/broker/tests/test_registry.py b/broker/tests/test_registry.py index 225fd18..9a9e5aa 100644 --- a/broker/tests/test_registry.py +++ b/broker/tests/test_registry.py @@ -258,6 +258,11 @@ def test_slash_in_id_raises(self): self._load('id = "my/tool"\ntype = "api"\n[entrypoint]\nport = 4700\n' '[[operations]]\nname = "say"\nrisk = "read"\n') + def test_toml_escaped_trailing_newline_in_id_raises(self): + with self.assertRaises(ValueError): + self._load('id = "echo\\n"\ntype = "api"\n[entrypoint]\nport = 4700\n' + '[[operations]]\nname = "say"\nrisk = "read"\n') + def test_missing_id_raises(self): with self.assertRaises(ValueError): self._load('type = "api"\n[entrypoint]\nport = 4700\n' diff --git a/broker/tests/test_rest_e2e.py b/broker/tests/test_rest_e2e.py index 9026775..a7c5a6d 100644 --- a/broker/tests/test_rest_e2e.py +++ b/broker/tests/test_rest_e2e.py @@ -6,11 +6,13 @@ """ import json +import os import shutil import socket import tempfile import threading import unittest +from unittest.mock import patch from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -43,6 +45,9 @@ def _send(self, status, obj): payload = json.dumps(obj).encode() self.send_response(status) self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + if self.path == "/login": + self.send_header("X-Session", "rotated-token") self.end_headers() self.wfile.write(payload) @@ -92,6 +97,9 @@ def _free_port() -> int: class RestE2E(unittest.TestCase): def setUp(self): + channel_env = patch.dict(os.environ, {"TOOLSTACK_CHANNEL_SECRET": "chan"}) + channel_env.start() + self.addCleanup(channel_env.stop) self.tmp = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) self.root = Path(self.tmp) @@ -102,13 +110,19 @@ def setUp(self): _Upstream.seen = [] self.upstream = HTTPServer(("127.0.0.1", 0), _Upstream) self._start(self.upstream) - self.addCleanup(self.upstream.server_close) self.upstream_port = self.upstream.server_address[1] def _start(self, server): thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() - self.addCleanup(server.shutdown) + def stop(): + try: + server.shutdown() + finally: + server.server_close() + thread.join(3) + self.assertFalse(thread.is_alive(), "fixture server thread did not stop") + self.addCleanup(stop) return thread def _forwarder(self, base_url=None, secrets=None): @@ -136,6 +150,8 @@ def _forwarder(self, base_url=None, secrets=None): verb = "POST" path = "/login" body_kind = "text" +redact_response_body = true +redact_response_headers = true secret_update_rules = [ {{ secret_name = "auth_token", response_type = "json", extract_path = "session.token", match_status = "200" }}, ] @@ -149,13 +165,12 @@ def _forwarder(self, base_url=None, secrets=None): secrets if secrets is not None else self.secrets, timeout=2, max_body=1024 * 1024) self._start(server) - self.addCleanup(server.server_close) return port def _runtime(self): - # Channel secret shared with the forwarder set via $TOOLSTACK_E_SECRET + # Fake channel secret shared via scoped $TOOLSTACK_CHANNEL_SECRET # (the broker runtime sends it as X-Toolstack-Secret; the forwarder - # verifies the header matches). Same key tests use everywhere. + # verifies the header matches), separate from the fake SDK credentials. return HttpRuntime(timeout=3, tool_secret=lambda tool_id: "chan") def _op(self, port, name="get_item", verb="GET", path="/items/{item_id}", body_kind="none"): @@ -201,6 +216,9 @@ def test_login_writeback_writes_via_SDK(self): self.assertEqual(result["status"], 200) self.assertEqual(secrets.writes, [("auth_token", "rotated-token")]) self.assertEqual(secrets.table["auth_token"], "rotated-token") + self.assertNotIn("rotated-token", result["body"]) + self.assertNotIn("rotated-token", json.dumps(result["headers"])) + self.assertNotIn("x-session", {name.lower() for name in result["headers"]}) def test_upstream_unreachable_maps_to_tool_unreachable(self): port = self._forwarder(base_url="http://127.0.0.1:1") diff --git a/broker/tests/test_runtime.py b/broker/tests/test_runtime.py index c875988..6b89f60 100644 --- a/broker/tests/test_runtime.py +++ b/broker/tests/test_runtime.py @@ -325,24 +325,24 @@ class EnvToolSecret(unittest.TestCase): """ def test_reads_from_state_file(self): - fake = mock.Mock(e_secret_for=mock.Mock(return_value="sekret")) + fake = mock.Mock(channel_secret_for=mock.Mock(return_value="sekret")) with mock.patch("broker.tool_state.ToolState", return_value=fake): self.assertEqual(_env_tool_secret("echo"), "sekret") - fake.e_secret_for.assert_called_once_with("echo") + fake.channel_secret_for.assert_called_once_with("echo") def test_missing_tool_returns_none(self): - fake = mock.Mock(e_secret_for=mock.Mock(return_value=None)) + fake = mock.Mock(channel_secret_for=mock.Mock(return_value=None)) with mock.patch("broker.tool_state.ToolState", return_value=fake): self.assertIsNone(_env_tool_secret("absent")) def test_empty_value_is_none(self): - fake = mock.Mock(e_secret_for=mock.Mock(return_value="")) + fake = mock.Mock(channel_secret_for=mock.Mock(return_value="")) with mock.patch("broker.tool_state.ToolState", return_value=fake): self.assertIsNone(_env_tool_secret("ghost")) def test_strips_surrounding_whitespace(self): # the tool reads its copy through .strip(); the broker must match or every call 401s - fake = mock.Mock(e_secret_for=mock.Mock(return_value=" abc\n")) + fake = mock.Mock(channel_secret_for=mock.Mock(return_value=" abc\n")) with mock.patch("broker.tool_state.ToolState", return_value=fake): self.assertEqual(_env_tool_secret("echo"), "abc") @@ -350,7 +350,7 @@ def test_legacy_env_var_is_ignored(self): # The retired TOOLSTACK_TOOL_SECRET_ env var must NOT shadow the # state file. Pin this so a future change can't re-introduce the bug # (a stray /etc/toolstack/admin.env value producing channel_secret_mismatch). - fake = mock.Mock(e_secret_for=mock.Mock(return_value="from_state_file")) + fake = mock.Mock(channel_secret_for=mock.Mock(return_value="from_state_file")) with mock.patch.dict(os.environ, {"TOOLSTACK_TOOL_SECRET_ECHO": "from_env"}): with mock.patch("broker.tool_state.ToolState", return_value=fake): self.assertEqual(_env_tool_secret("echo"), "from_state_file") diff --git a/broker/tests/test_runtime_proxy.py b/broker/tests/test_runtime_proxy.py new file mode 100644 index 0000000..c7ea668 --- /dev/null +++ b/broker/tests/test_runtime_proxy.py @@ -0,0 +1,107 @@ +"""Import-time proxy isolation with only owned loopback endpoints/fake secrets.""" +from contextlib import contextmanager, ExitStack +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +import subprocess +import sys +import threading +import unittest + +from .test_runtime import _FakeTool, _FakeRestForwarder, _FakeMcpTool + + +_PROBE = """ +import sys +import urllib.request + +# This process has only the synthetic environment supplied by the test. Verify +# the proxy is effective before importing runtime and constructing its opener. +assert urllib.request.getproxies()["http"] == sys.argv[4] +assert not urllib.request.proxy_bypass("127.0.0.1") +from broker.runtime import HttpRuntime, _OPENER, _NoRedirect +from broker.bounds import DeadlineHTTPHandler +from broker.registry import ToolOp + +runtime = HttpRuntime(timeout=2, tool_secret=lambda _: "fake-channel-only") +for kind, port in zip(("api", "rest", "mcp"), sys.argv[1:4]): + result = runtime.execute(ToolOp("fake", "say", "read", int(port), kind), + {"message": "fake-body-only"}, 7, "fake-caller") + assert isinstance(result, dict) +assert any(isinstance(h, _NoRedirect) for h in _OPENER.handlers) +assert any(isinstance(h, DeadlineHTTPHandler) for h in _OPENER.handlers) +assert not any(isinstance(h, urllib.request.ProxyHandler) and h.proxies + for h in _OPENER.handlers) +""" + + +class RuntimeProxy(unittest.TestCase): + @contextmanager + def serving(self, handler): + # Bound accepted reads too, so an interrupted probe cannot leave a + # fixture handler waiting indefinitely for its request body. + class Bounded(handler): + def setup(self): + self.request.settimeout(2) + super().setup() + server = HTTPServer(("127.0.0.1", 0), Bounded) + worker = threading.Thread(target=server.serve_forever, + kwargs={"poll_interval": 0.01}) + worker.start() + try: + yield server.server_address[1], Bounded + finally: + server.shutdown() + server.server_close() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(server.socket.fileno(), -1) + + def test_all_tool_hops_ignore_proxy_captured_at_import(self): + class ProxyTrap(BaseHTTPRequestHandler): + received = [] + + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.received.append((self.path, self.headers.get("X-Toolstack-Secret"), body)) + self.send_response(502) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *args): + pass + + with ExitStack() as stack: + api_port, api = stack.enter_context(self.serving(_FakeTool)) + rest_port, rest = stack.enter_context(self.serving(_FakeRestForwarder)) + mcp_port, mcp = stack.enter_context(self.serving(_FakeMcpTool)) + proxy_port, proxy = stack.enter_context(self.serving(ProxyTrap)) + # Subclass-local fixture state: do not mutate the imported fixtures. + api.received = rest.received = None + rest.status = 200 + rest.response = {"status": 200, "headers": {}, "body": "ok"} + mcp.received = [] + mcp.mode, mcp.tool_result = "json", None + mcp.force_error, mcp.is_error, mcp.bad_init = None, False, False + proxy_url = f"http://127.0.0.1:{proxy_port}" + result = subprocess.run( + [sys.executable, "-c", _PROBE, str(api_port), str(rest_port), str(mcp_port), proxy_url], + cwd=Path(__file__).resolve().parents[2], + env={"HTTP_PROXY": proxy_url, "NO_PROXY": "", "PYTHONDONTWRITEBYTECODE": "1"}, + capture_output=True, text=True, timeout=15, + ) + self.assertEqual(proxy.received, [], "tool traffic reached the proxy trap") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(api.received["path"], "/v1/actions/say") + self.assertEqual(rest.received["path"], "/sendrequest") + for received in (api.received, rest.received): + self.assertEqual(received["shared_secret"], "fake-channel-only") + self.assertEqual(received["body"]["arguments"], {"message": "fake-body-only"}) + self.assertEqual([r["method"] for r in mcp.received], + ["initialize", "notifications/initialized", "tools/call"]) + self.assertEqual([r["secret"] for r in mcp.received], ["fake-channel-only"] * 3) + self.assertEqual(mcp.received[-1]["message"]["params"]["arguments"], + {"message": "fake-body-only"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/broker/tests/test_server.py b/broker/tests/test_server.py index 671bab3..a4b2b10 100644 --- a/broker/tests/test_server.py +++ b/broker/tests/test_server.py @@ -96,7 +96,11 @@ def test_bound_to_localhost_only(self): def test_health_ok(self): status, body, headers = self._req("GET", "/v1/health") self.assertEqual(status, 200) - self.assertEqual(body, {"status": "ok"}) + self.assertEqual(body["status"], "ok") + self.assertEqual(set(body), {"status", "catalog_observation_id"}) + self.assertRegex(body["catalog_observation_id"], r"^[0-9a-f]{32}$") + self.assertEqual(body["catalog_observation_id"], + self.server.ctx.store.catalog_receipt()["observation_id"]) self.assertIn("X-Correlation-Id", headers) def test_server_header_does_not_leak_version(self): diff --git a/broker/tests/test_snapshots.py b/broker/tests/test_snapshots.py new file mode 100644 index 0000000..6865205 --- /dev/null +++ b/broker/tests/test_snapshots.py @@ -0,0 +1,178 @@ +"""Temporary WAL snapshot/restore rehearsals; no service or real authority.""" +import hashlib +import os +import tempfile +import unittest +import sqlite3 +import time +import io +import json +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from broker import request_lifecycle as life +from broker import brokerctl +from broker.audit import AuditLog +from broker.identity import authenticate, hash_token +from broker.store import Store +from .support import make_ctx, seed_caller + + +class Snapshots(unittest.TestCase): + def test_operator_commands_parse_and_remain_offline(self): + with tempfile.TemporaryDirectory() as d: + source = Path(d) / "source.db" + store = Store(str(source)) + seed_caller(store, allow=["echo.say"]) + store.close() + snapshot = Path(d) / "backup.db" + restored = Path(d) / "restored.db" + commands = [ + ["backup", "--db", str(source), "--destination", str(snapshot)], + ["prune-payloads", "--db", str(source)], + ["reconcile-offline", "--db", str(source), "--confirm-offline"], + ["restore-new", "--snapshot", str(snapshot), "--destination", str(restored), "--confirm-offline"], + ] + for args in commands: + with patch("sys.argv", ["brokerctl"] + args), redirect_stdout(io.StringIO()) as output: + brokerctl.main() + self.assertIsInstance(json.loads(output.getvalue()), dict) + state = Store(str(restored)) + try: + self.assertIsNone(authenticate(state, "Bearer token-for-hermes")) + finally: + state.close() + + def test_publish_race_never_overwrites_competing_destination(self): + with tempfile.TemporaryDirectory() as d: + store = Store(str(Path(d) / "source.db")) + destination = Path(d) / "racing.db" + original_link = os.link + def racing_link(source, target, **kwargs): + destination.write_bytes(b"COMPETING FILE") + destination.chmod(0o600) + return original_link(source, target, **kwargs) + try: + with patch("broker.store.os.link", side_effect=racing_link), self.assertRaises(FileExistsError): + store.backup_to(destination) + self.assertEqual(destination.read_bytes(), b"COMPETING FILE") + self.assertEqual(list(Path(d).glob(".broker-snapshot-*")), []) + finally: + store.close() + + def test_newer_snapshot_refused_and_no_destination_published(self): + with tempfile.TemporaryDirectory() as d: + snapshot = Path(d) / "future.db" + conn = sqlite3.connect(snapshot) + conn.execute("PRAGMA user_version=99") + conn.close() + snapshot.chmod(0o600) + before = snapshot.read_bytes() + destination = Path(d) / "new.db" + with self.assertRaises(ValueError): + Store.restore_new(snapshot, destination, confirmed_offline=True) + self.assertFalse(destination.exists()) + self.assertEqual(snapshot.read_bytes(), before) + self.assertEqual(list(Path(d).glob(".broker-snapshot-*")), []) + + def test_populated_wal_backup_restore_new_revokes_authority_and_never_replays(self): + with tempfile.TemporaryDirectory() as d: + ctx = make_ctx(catalog={"echo": {"say": "read", "review": "write"}}) + ctx.store.close() + source = Path(d) / "source.db" + ctx.store = Store(str(source)) + ctx.audit = AuditLog(ctx.store, sink=None) + try: + token = seed_caller(ctx.store, allow=["echo.say"], review=["echo.review"]) + caller = authenticate(ctx.store, f"Bearer {token}") + completed = life.submit(ctx, caller, "echo", "say", {"fake": "value"}, "a", idempotency_key="done") + pruned = life.submit(ctx, caller, "echo", "say", {"fake": "old"}, "old", idempotency_key="pruned") + ctx.store._conn.execute("UPDATE requests SET updated_at=? WHERE id=?", (time.time() - 8*86400, pruned.request_id)) + ctx.store._conn.commit() + self.assertEqual(ctx.store.prune_payloads(), 1) + pending = life.submit(ctx, caller, "echo", "review", {"fake": "sensitive"}, "b", idempotency_key="pending") + op = ctx.registry.lookup("echo", "say") + active, _, _ = ctx.store.reserve_action(caller, "c", "echo", "say", {}, None, op.generation, "active") + self.assertTrue(ctx.store.claim_received(active["id"], op.generation, caller, active["request_digest"])) + self.assertGreater(Path(str(source) + "-wal").stat().st_size, 0) + namespace = ctx.store.installation_id + snapshot = Path(d) / "snapshots" / "backup.db" + ctx.store.backup_to(snapshot) + self.assertEqual(snapshot.stat().st_mode & 0o777, 0o600) + self.assertEqual(snapshot.parent.stat().st_mode & 0o777, 0o700) + self.assertEqual(snapshot.stat().st_nlink, 1) + snapshot_hash = hashlib.sha256(snapshot.read_bytes()).hexdigest() + finally: + ctx.store.close() # operator-exclusive restore: original writers stopped + restored = Path(d) / "restored" / "new.db" + with self.assertRaises(ValueError): + Store.restore_new(snapshot, restored) + self.assertFalse(restored.exists()) + self.assertFalse(restored.parent.exists()) + Store.restore_new(snapshot, restored, confirmed_offline=True) + self.assertEqual(hashlib.sha256(snapshot.read_bytes()).hexdigest(), snapshot_hash) + ctx.store = Store(str(restored)) + ctx.audit = AuditLog(ctx.store, sink=None) + try: + self.assertIsNone(authenticate(ctx.store, f"Bearer {token}")) + self.assertEqual(ctx.store.policy_for(caller.id), {}) + self.assertNotEqual(ctx.store.installation_id, namespace) + self.assertEqual(ctx.store.request(pending.request_id)["status"], "expired") + self.assertEqual(ctx.store.approval_for_request(pending.request_id)["status"], "cancelled") + self.assertIsNone(ctx.store.request(pending.request_id)["action_json"]) + self.assertEqual(ctx.store.request(active["id"])["status"], "unknown") + self.assertEqual(ctx.store.request(completed.request_id)["result_json"], '{"echoed": {"fake": "value"}}') + # Explicit new authority after review, never inherited on restore. + ctx.store.add_token(caller.id, hash_token("new-fake-token")) + ctx.store.set_policy(caller.id, {"tools": {"echo": {"say": "allow", "review": "review"}}}) + renewed = authenticate(ctx.store, "Bearer new-fake-token") + self.assertEqual(life.submit(ctx, renewed, "echo", "say", {}, "d", idempotency_key="active").status, life.UNKNOWN) + self.assertEqual(life.submit(ctx, renewed, "echo", "review", {"fake": "sensitive"}, "e", idempotency_key="pending").status, life.EXPIRED) + repeat = life.submit(ctx, renewed, "echo", "say", {"fake": "value"}, "f", idempotency_key="done") + self.assertEqual((repeat.request_id, repeat.result), (completed.request_id, completed.result)) + self.assertEqual(life.submit(ctx, renewed, "echo", "say", {"fake": "old"}, "g", idempotency_key="pruned").status, life.RESULT_EXPIRED) + self.assertEqual(life.submit(ctx, renewed, "echo", "say", {"fake": "new"}, "h", idempotency_key="pruned").status, life.CONFLICT) + self.assertEqual(len(ctx.runtime.calls), 2) # original completed effects only + finally: + ctx.store.close() + + def test_existing_file_symlink_hardlink_and_unsafe_destinations_refused_unchanged(self): + with tempfile.TemporaryDirectory() as d: + store = Store(str(Path(d) / "source.db")) + try: + snapshot = Path(d) / "snapshot.db" + store.backup_to(snapshot) + target = Path(d) / "existing.db" + target.write_bytes(b"DO NOT CHANGE") + target.chmod(0o640) + symlink = Path(d) / "linked.db" + symlink.symlink_to(target) + hardlink = Path(d) / "hardlinked.db" + os.link(target, hardlink) + for destination in (target, symlink, hardlink): + before = target.read_bytes(), target.stat().st_mode, target.stat().st_mtime_ns + with self.assertRaises(FileExistsError): + store.backup_to(destination) + with self.assertRaises(FileExistsError): + Store.restore_new(snapshot, destination, confirmed_offline=True) + self.assertEqual((target.read_bytes(), target.stat().st_mode, target.stat().st_mtime_ns), before) + unsafe = Path(d) / "shared" + unsafe.mkdir(mode=0o755) + unsafe.chmod(0o755) # fixture must remain unsafe even under umask077 + self.assertEqual(unsafe.stat().st_mode & 0o777, 0o755) + with self.assertRaises(ValueError): + store.backup_to(unsafe / "new.db") + with self.assertRaises(ValueError): + Store.restore_new(snapshot, unsafe / "new.db", confirmed_offline=True) + self.assertEqual(unsafe.stat().st_mode & 0o777, 0o755) + self.assertFalse((unsafe / "new.db").exists()) + writable = Path(d) / "writable" + writable.mkdir() + writable.chmod(0o777) + with self.assertRaises(ValueError): + store.backup_to(writable / "missing" / "new.db") + self.assertFalse((writable / "missing").exists()) + self.assertEqual(list(Path(d).glob(".broker-snapshot-*")), []) + finally: + store.close() diff --git a/broker/tests/test_surface_nod.py b/broker/tests/test_surface_nod.py index 1147b9f..8173048 100644 --- a/broker/tests/test_surface_nod.py +++ b/broker/tests/test_surface_nod.py @@ -8,9 +8,14 @@ For a check against a *real* nod server, see test_surface_nod_live.py.""" import json +import subprocess +import sys import threading import unittest +from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from unittest.mock import patch from broker import approval from broker.approval import OperationCard @@ -19,6 +24,7 @@ CARD = OperationCard( request_id=7, title="Approve echo.shout for caller hermes", caller="hermes", tool="echo", op="shout", risk="destructive", reason="policy review", + installation_id="10000000-0000-4000-8000-000000000001", ) # nod v1.0.1 CreateDecisionRequest accepts exactly these top-level fields @@ -101,7 +107,7 @@ def _pending_view(): def _resolved_view(option_kind, *, text=None, actor="owner"): """A real nod RequestDecisionView for a resolved request.""" return {"request_id": "nod-123", "status": "resolved", - "decision": {"request_id": "nod-123", "option_id": option_kind, + "decision": {"request_id": "nod-123", "option_id": "reject" if option_kind.startswith("reject") else "approve", "option_kind": option_kind, "option_label": option_kind.title(), "text": text, "actor_user_id": actor, "actor_device_id": "device-1", "signature": None, @@ -133,7 +139,7 @@ def test_open_posts_redacted_card_with_issuer_token(self): self.assertIsNone(_FakeNod.last_reject) # passed nod's strict create contract self.assertEqual(_FakeNod.created["auth"], "Bearer issuer-tok") body = _FakeNod.created["body"] - self.assertEqual(body["dedupe_key"], "7") # retry-safe + self.assertEqual(body["dedupe_key"], "toolstack:10000000-0000-4000-8000-000000000001:7") self.assertEqual(body["title"], CARD.title) self.assertTrue(body["notification"]["redact"]) self.assertNotIn("arguments", body) # no raw args in the card @@ -142,6 +148,7 @@ def test_open_includes_redacted_request_in_body_markdown(self): card = OperationCard( request_id=7, title="Approve echo.shout for caller hermes", caller="hermes", tool="echo", op="shout", risk="write", reason="policy review", + installation_id=CARD.installation_id, details='{\n "body": {\n "dueDate": "2026-07-01"\n }\n}') self.surface.open(card) body = _FakeNod.created["body"] @@ -153,6 +160,7 @@ def test_open_includes_endpoint_field_when_present(self): request_id=7, title="Approve jira.login for caller hermes", caller="hermes", tool="jira", op="login", risk="write", reason="policy review", endpoint="POST api.example.test /login", + installation_id=CARD.installation_id, ) self.surface.open(card) fields = {f["label"]: f["value"] for f in _FakeNod.created["body"]["fields"]} @@ -180,6 +188,11 @@ def test_open_never_sends_callback_url(self): self.surface.open(CARD) self.assertNotIn("callback_url", _FakeNod.created["body"]) + def test_open_never_discloses_free_text_justification(self): + from dataclasses import replace + self.surface.open(replace(CARD, justification="tiny-password")) + self.assertNotIn("tiny-password", json.dumps(_FakeNod.created["body"])) + def test_poll_pending(self): self.assertEqual(self.surface.poll("nod-123").outcome, approval.PENDING) @@ -190,11 +203,52 @@ def test_poll_approved(self): self.assertEqual(state.approver, "owner") self.assertEqual(state.decided_at, "2026-06-17T00:00:00.000Z") # nod resolved_at + def test_only_exact_issued_pair_and_resolved_status_can_approve(self): + for status, option_id, kind in (("pending", "approve", "approve"), + ("RESOLVED", "approve", "approve"), + ("resolved", "other", "approve"), + ("resolved", "approve", "approve_extra"), + ("resolved", "approve", "APPROVE"), + ("resolved", "reject", "reject")): + with self.subTest(status=status, option_id=option_id, kind=kind): + body = _resolved_view("approve") + body["status"] = status + body["decision"].update(option_id=option_id, option_kind=kind) + _FakeNod.decision = body + self.assertEqual(self.surface.poll("nod-123").outcome, approval.PENDING) + + def test_mismatched_top_and_any_returned_nested_request_ids_are_refused(self): + for where in ("top", "decision", "per_user", "request"): + with self.subTest(where=where): + body = _resolved_view("approve") + if where == "top": + body["request_id"] = "other" + elif where == "decision": + body["decision"]["request_id"] = "other" + elif where == "per_user": + body["decisions"] = [{"user_id": "owner", "decision": {"request_id": "other"}}] + else: + body["request"] = {"id": "other"} + _FakeNod.decision = body + with self.assertRaises(ValueError): + self.surface.poll("nod-123") + + def test_absent_optional_nested_request_does_not_invent_wire_fields(self): + body = _resolved_view("approve") + self.assertNotIn("request", body) # v1.0.1 RequestDecisionView has no request wrapper + _FakeNod.decision = body + self.assertEqual(self.surface.poll("nod-123").outcome, approval.APPROVED) + + def test_open_mismatched_nested_request_identifier_is_refused(self): + from unittest.mock import patch + with patch.object(self.surface, "_call", return_value={"request_id": "nod-123", "request": {"id": "other"}}): + with self.assertRaises(ValueError): + self.surface.open(CARD) + def test_poll_approved_with_text(self): _FakeNod.decision = _resolved_view("approve_with_text", text="lgtm") state = self.surface.poll("nod-123") - self.assertEqual(state.outcome, approval.APPROVED) - self.assertEqual(state.note, "lgtm") + self.assertEqual(state.outcome, approval.PENDING) # kind was never issued def test_poll_rejected(self): _FakeNod.decision = _resolved_view("reject_with_text", text="nope") @@ -207,11 +261,11 @@ def test_poll_dismiss_is_not_an_approval(self): self.assertEqual(self.surface.poll("nod-123").outcome, approval.PENDING) def test_poll_expired(self): - _FakeNod.decision = {"status": "expired", "decision": None} + _FakeNod.decision = {"request_id": "nod-123", "status": "expired", "decision": None} self.assertEqual(self.surface.poll("nod-123").outcome, approval.EXPIRED) def test_poll_cancelled(self): - _FakeNod.decision = {"status": "cancelled", "decision": None} + _FakeNod.decision = {"request_id": "nod-123", "status": "cancelled", "decision": None} self.assertEqual(self.surface.poll("nod-123").outcome, approval.CANCELLED) def test_cancel_is_best_effort(self): @@ -219,5 +273,108 @@ def test_cancel_is_best_effort(self): self.assertIsNone(self.surface.cancel("nod-123")) +class NodOriginIsolation(unittest.TestCase): + def test_invalid_base_urls_refused_before_contact_with_generic_error(self): + from broker.surface_nod import _OPENER + with patch.object(_OPENER, 'open') as contact: + for url in ('file:///fake-private', 'ftp://example.test', 'http:///missing', + 'https://', 'https://fake-user:fake-pass@example.test', + 'http://example.test?fake-token', 'http://example.test#fake-token', + 'http://example.test?', 'http://example.test#', + 'http://example.test:invalid', 'http://example.test:65536', + 'http://example.test:0', 'http://[bad', ' http://example.test', + 'http://example.test/\nfake', 'http://example.test\\fake', None): + with self.subTest(url=url), self.assertRaises(ValueError) as caught: + NodSurface(url, 'fake-issuer-only') + self.assertEqual(str(caught.exception), 'invalid Nod base URL') + contact.assert_not_called() + + @contextmanager + def serving(self, handler): + class Bounded(handler): + def setup(self): + self.request.settimeout(2) + super().setup() + server = HTTPServer(('127.0.0.1', 0), Bounded) + worker = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': 0.01}) + worker.start() + try: + yield server.server_address[1], Bounded + finally: + server.shutdown() + server.server_close() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(server.socket.fileno(), -1) + + def test_import_time_proxy_ignored_for_normal_reverse_proxy_path_flow(self): + class FakeNod(_FakeNod): + received = [] + decision = _resolved_view('approve') + + def record(self): + self.received.append((self.path, self.headers.get('Authorization'))) + if self.path.startswith('/nod/'): + self.path = self.path[len('/nod'):] + + def do_POST(self): + self.record() + super().do_POST() + + def do_GET(self): + self.record() + super().do_GET() + + class ProxyTrap(BaseHTTPRequestHandler): + received = [] + + def do_POST(self): + self.received.append((self.path, self.headers.get('Authorization'), + self.rfile.read(int(self.headers.get('Content-Length', '0'))))) + self.send_response(502) + self.send_header('Content-Length', '0') + self.end_headers() + + do_GET = do_POST + + def log_message(self, *args): + pass + + probe = ''' +import sys, urllib.request +assert urllib.request.getproxies()['http'] == sys.argv[2] +assert not urllib.request.proxy_bypass('127.0.0.1') +from broker.surface_nod import NodSurface, _OPENER, _NoRedirect +from broker.bounds import DeadlineHTTPHandler, DeadlineHTTPSHandler +from broker.tests.test_surface_nod import CARD +from broker import approval +surface = NodSurface(sys.argv[1], 'fake-issuer-only', timeout=2) +ref = surface.open(CARD) +assert ref == 'nod-123' +assert surface.poll(ref).outcome == approval.APPROVED +surface.cancel(ref) +assert any(isinstance(h, _NoRedirect) for h in _OPENER.handlers) +assert any(isinstance(h, DeadlineHTTPHandler) for h in _OPENER.handlers) +assert any(isinstance(h, DeadlineHTTPSHandler) for h in _OPENER.handlers) +assert not any(isinstance(h, urllib.request.ProxyHandler) and h.proxies for h in _OPENER.handlers) +''' + with self.serving(FakeNod) as (port, nod), self.serving(ProxyTrap) as (proxy_port, proxy): + proxy_url = f'http://127.0.0.1:{proxy_port}' + result = subprocess.run( + [sys.executable, '-c', probe, f'http://127.0.0.1:{port}/nod/', proxy_url], + cwd=Path(__file__).resolve().parents[2], + env={'HTTP_PROXY': proxy_url, 'HTTPS_PROXY': proxy_url, 'NO_PROXY': '', + 'PYTHONDONTWRITEBYTECODE': '1'}, + capture_output=True, text=True, timeout=10) + self.assertEqual(proxy.received, []) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(nod.received, [ + ('/nod/api/v1/requests', 'Bearer fake-issuer-only'), + ('/nod/api/v1/requests/nod-123/decision', 'Bearer fake-issuer-only'), + ('/nod/api/v1/requests/nod-123/cancel', 'Bearer fake-issuer-only')]) + self.assertEqual(nod.created['body']['title'], CARD.title) + self.assertEqual(nod.created['auth'], 'Bearer fake-issuer-only') + + if __name__ == "__main__": unittest.main() diff --git a/broker/tests/test_surface_nod_live.py b/broker/tests/test_surface_nod_live.py index 4ea7fe1..1b4c036 100644 --- a/broker/tests/test_surface_nod_live.py +++ b/broker/tests/test_surface_nod_live.py @@ -57,6 +57,7 @@ def setUp(self): caller="tsr-selftest", tool="echo", op="ping", risk="read", reason="surface_nod live contract test", justification="automated open->poll->cancel cycle; safe to reject", + installation_id=__import__("uuid").uuid4().hex, ) self.ref = None diff --git a/broker/tests/test_tool_state.py b/broker/tests/test_tool_state.py index 461ba7b..0826a1d 100644 --- a/broker/tests/test_tool_state.py +++ b/broker/tests/test_tool_state.py @@ -24,12 +24,12 @@ def test_e_secret_for_known(self): d = Path(tempfile := __import__("tempfile").mkdtemp()) self.addCleanup(__import__("shutil").rmtree, d, ignore_errors=True) sp = d / "state.json" - _write(sp, {"echo": {"e_secret": "es-1", "tool_id": "echo"}, - "echo-mcp": {"e_secret": "es-2", "tool_id": "echo-mcp"}}) + _write(sp, {"echo": {"channel_secret": "es-1", "tool_id": "echo"}, + "echo-mcp": {"channel_secret": "es-2", "tool_id": "echo-mcp"}}) ts = ToolState(path=sp) - self.assertEqual(ts.e_secret_for("echo"), "es-1") - self.assertEqual(ts.e_secret_for("echo-mcp"), "es-2") - self.assertIsNone(ts.e_secret_for("none")) + self.assertEqual(ts.channel_secret_for("echo"), "es-1") + self.assertEqual(ts.channel_secret_for("echo-mcp"), "es-2") + self.assertIsNone(ts.channel_secret_for("none")) def test_unknown_tool_returns_none(self): d = Path(tempfile := __import__("tempfile").mkdtemp()) @@ -37,42 +37,42 @@ def test_unknown_tool_returns_none(self): sp = d / "state.json" _write(sp, {}) ts = ToolState(path=sp) - self.assertIsNone(ts.e_secret_for("anything")) + self.assertIsNone(ts.channel_secret_for("anything")) def test_missing_file_is_silent(self): d = Path(tempfile := __import__("tempfile").mkdtemp()) self.addCleanup(__import__("shutil").rmtree, d, ignore_errors=True) ts = ToolState(path=d / "nope.json") - self.assertIsNone(ts.e_secret_for("echo")) + self.assertIsNone(ts.channel_secret_for("echo")) def test_mtime_invalidation(self): d = Path(tempfile := __import__("tempfile").mkdtemp()) self.addCleanup(__import__("shutil").rmtree, d, ignore_errors=True) sp = d / "state.json" - _write(sp, {"echo": {"e_secret": "es-1"}}) + _write(sp, {"echo": {"channel_secret": "es-1"}}) ts = ToolState(path=sp) - self.assertEqual(ts.e_secret_for("echo"), "es-1") + self.assertEqual(ts.channel_secret_for("echo"), "es-1") # mtime advance + new value -> tool_state reruns and reads the new value. time.sleep(0.05) - _write(sp, {"echo": {"e_secret": "es-2"}}) - self.assertEqual(ts.e_secret_for("echo"), "es-2") + _write(sp, {"echo": {"channel_secret": "es-2"}}) + self.assertEqual(ts.channel_secret_for("echo"), "es-2") def test_record_without_e_secret_skipped(self): d = Path(tempfile := __import__("tempfile").mkdtemp()) self.addCleanup(__import__("shutil").rmtree, d, ignore_errors=True) sp = d / "state.json" - _write(sp, {"echo": {"tool_id": "echo"}, # no e_secret - "alive": {"e_secret": "es-2"}}) + _write(sp, {"echo": {"e_secret": "must-never-read"}, + "alive": {"channel_secret": "es-2"}}) ts = ToolState(path=sp) - self.assertIsNone(ts.e_secret_for("echo")) - self.assertEqual(ts.e_secret_for("alive"), "es-2") + self.assertIsNone(ts.channel_secret_for("echo")) + self.assertEqual(ts.channel_secret_for("alive"), "es-2") def test_tool_ids_lists_registered(self): d = Path(tempfile := __import__("tempfile").mkdtemp()) self.addCleanup(__import__("shutil").rmtree, d, ignore_errors=True) sp = d / "state.json" - _write(sp, {"echo": {"e_secret": "es-1"}, - "echo-mcp": {"e_secret": "es-2"}}) + _write(sp, {"echo": {"channel_secret": "es-1"}, + "echo-mcp": {"channel_secret": "es-2"}}) ts = ToolState(path=sp) self.assertEqual(sorted(ts.tool_ids()), ["echo", "echo-mcp"]) diff --git a/broker/tests/test_transport_security.py b/broker/tests/test_transport_security.py new file mode 100644 index 0000000..5f2e7a8 --- /dev/null +++ b/broker/tests/test_transport_security.py @@ -0,0 +1,158 @@ +"""Finite, owned HTTP/fake-socket regressions for input and output phases.""" +import http.client +import io +import json +import threading +import unittest +from http.server import HTTPServer +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from broker.server import _Handler + + +class FakeSocket: + def __init__(self, request, fail_write=None, error=TimeoutError): + self.input = io.BytesIO(request) + self.output = bytearray() + self.fail_write = fail_write + self.error = error + self.writes = 0 + self.timeouts = [] + + def makefile(self, *args): + return io.BytesIO() + + def recv_into(self, buffer): + return self.input.readinto(buffer) + + def settimeout(self, timeout): + self.timeouts.append(timeout) + + def sendall(self, data): + self.writes += 1 + if self.writes == self.fail_write: + self.output.extend(data[:16]) + raise self.error("private-output-error") + self.output.extend(data) + + +class TransportSecurity(unittest.TestCase): + def test_deep_json_is_sanitized_and_serial_listener_recovers(self): + audit = Mock() + server = HTTPServer(("127.0.0.1", 0), _Handler) + server.ctx = SimpleNamespace(audit=audit) + worker = threading.Thread(target=server.serve_forever, + kwargs={"poll_interval": 0.01}) + worker.start() + try: + with patch("broker.server.handle") as dispatch, patch.object(server, "handle_error") as errors: + for body in (b"[" * 2000, b"[" * 2000 + b"0" + b"]" * 2000): + with self.subTest(body_length=len(body)): + conn = http.client.HTTPConnection(*server.server_address, timeout=2) + try: + conn.request("POST", "/v1/requests", body=body) + response = conn.getresponse() + self.assertEqual(response.status, 400) + self.assertEqual(json.loads(response.read()), {"error": "invalid_request"}) + correlation = response.getheader("X-Correlation-Id") + audit.record.assert_called_with("gateway", "transport_error", "invalid", + correlation, details={"status": 400}) + finally: + conn.close() + dispatch.assert_not_called() + errors.assert_not_called() + dispatch.return_value = SimpleNamespace(status=200, body={"ok": True}, correlation_id="recovery") + conn = http.client.HTTPConnection(*server.server_address, timeout=2) + try: + conn.request("GET", "/v1/health") + response = conn.getresponse() + self.assertEqual(response.status, 200) + self.assertEqual(json.loads(response.read()), {"ok": True}) + finally: + conn.close() + finally: + server.shutdown() + server.server_close() + worker.join(timeout=2) + self.assertFalse(worker.is_alive()) + self.assertEqual(server.socket.fileno(), -1) + + def test_partial_response_failure_never_sends_second_status_or_invalidates_action(self): + for fail_write in (1, 2): + for error in (TimeoutError, BrokenPipeError): + with self.subTest(fail_write=fail_write, error=error): + audit = Mock() + sock = FakeSocket(b"POST /v1/actions/fake.send HTTP/1.0\r\nContent-Length: 2\r\n\r\n{}", + fail_write, error) + def completed(*args): + audit.record("execution", "completed", "ok", "test-action", details={"status": 200}) + return SimpleNamespace(status=200, body={"ok": True}, correlation_id="test-action") + with patch("broker.server.handle", side_effect=completed) as dispatch: + _Handler(sock, ("127.0.0.1", 1), SimpleNamespace(ctx=SimpleNamespace(audit=audit))) + dispatch.assert_called_once() + self.assertEqual(sock.writes, fail_write) + self.assertEqual(sock.output.count(b"HTTP/1.0"), 1) + self.assertNotIn(b"408", sock.output) + self.assertEqual(audit.record.call_count, 2) + audit.record.assert_called_with("gateway", "output_error", "failed", "test-action", + details={"status": 200}) + self.assertNotIn("private-output-error", str(audit.record.call_args_list)) + + def test_output_budget_replaces_input_timeout_and_spans_both_writes(self): + for header_duration in (0.4, 0.6): + with self.subTest(header_duration=header_duration): + clock = [0.0] + audit = Mock() + sock = FakeSocket(b"GET /v1/health HTTP/1.0\r\n\r\n") + sendall = sock.sendall + def timed_send(data): + sendall(data) + clock[0] += header_duration + sock.sendall = timed_send + response = SimpleNamespace(status=200, body={"ok": True}, correlation_id="budget") + with patch("broker.server.time.monotonic", side_effect=lambda: clock[0]), \ + patch("broker.server.INPUT_TOTAL_SECONDS", 0.01), \ + patch("broker.server.OUTPUT_TOTAL_SECONDS", 0.5), \ + patch("broker.server.OUTPUT_WRITE_SECONDS", 0.3), \ + patch("broker.server.handle", return_value=response): + _Handler(sock, ("127.0.0.1", 1), SimpleNamespace(ctx=SimpleNamespace(audit=audit))) + self.assertEqual(sock.timeouts[:2], [0.01, 0.3]) + if header_duration < 0.5: + self.assertEqual(sock.writes, 2) + self.assertAlmostEqual(sock.timeouts[-1], 0.1) + audit.record.assert_not_called() + else: + self.assertEqual(sock.writes, 1) + audit.record.assert_called_once_with("gateway", "output_error", "failed", "budget", + details={"status": 200}) + + def test_refusal_output_failure_is_sanitized_without_second_response(self): + for status, request in ( + (400, b"POST /v1/requests HTTP/1.0\r\nContent-Length: -1\r\n\r\n"), + (413, b"POST /v1/requests HTTP/1.0\r\nContent-Length: 999999\r\n\r\n"), + (500, b"GET /v1/health HTTP/1.0\r\n\r\n"), + ): + for fail_write in (1, 2): + with self.subTest(status=status, fail_write=fail_write): + audit = Mock() + sock = FakeSocket(request, fail_write) + with patch("broker.server.handle", side_effect=RuntimeError("private-action-error")) as dispatch: + _Handler(sock, ("127.0.0.1", 1), SimpleNamespace(ctx=SimpleNamespace(audit=audit))) + self.assertEqual(dispatch.call_count, int(status == 500)) + self.assertEqual(sock.writes, fail_write) + self.assertEqual(sock.output.count(b"HTTP/1.0"), 1) + self.assertEqual(audit.record.call_count, 2) + first, last = audit.record.call_args_list + self.assertEqual(first.args[:3], ("gateway", "transport_error", + "failed" if status == 500 else "invalid")) + self.assertEqual(last.args[:3], ("gateway", "output_error", "failed")) + self.assertEqual(first.args[3], last.args[3]) + self.assertEqual(first.kwargs, {"details": {"status": status}}) + self.assertEqual(last.kwargs, first.kwargs) + self.assertNotIn("private-", str(audit.record.call_args_list)) + self.assertNotIn(b"private-", sock.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/broker/tests/test_uplift.py b/broker/tests/test_uplift.py new file mode 100644 index 0000000..9cbac65 --- /dev/null +++ b/broker/tests/test_uplift.py @@ -0,0 +1,128 @@ +"""Focused uplift regressions; fake credentials and temporary state only.""" +import json +import os +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from broker import approval, request_lifecycle as lifecycle +from broker.identity import authenticate +from broker.registry import Registry +from broker import operations +from broker.store import Store +from broker.tool_state import ToolState +from .support import BrokerTestCase, seed_caller + + +class Claim(BrokerTestCase): + def scenario(self, mutate): + ctx = self.make_ctx(catalog={"echo": {"say": "low"}}) + token = seed_caller(ctx.store, review=["echo.say"]) + caller = authenticate(ctx.store, f"Bearer {token}") + out = lifecycle.submit(ctx, caller, "echo", "say", {"secret": "fake"}, "test") + def poll(ref): + mutate(ctx, out.request_id) + return approval.SurfaceState(approval.APPROVED) + ctx.surface.poll = poll + result = lifecycle.resolve_request(ctx, out.request_id) + self.assertNotEqual(result.status, lifecycle.OK) + self.assertEqual(ctx.runtime.calls, []) + + def test_revoke_during_poll(self): + self.scenario(lambda ctx, rid: ctx.store.revoke_caller("hermes")) + + def test_cancel_during_poll(self): + self.scenario(lambda ctx, rid: ctx.store.update_request(rid, status="denied", arguments_json=None)) + + def test_policy_change_during_poll(self): + self.scenario(lambda ctx, rid: ctx.store.set_policy(1, {})) + + def test_generation_change_during_poll(self): + self.scenario(lambda ctx, rid: ctx.registry._catalog["echo"].update(port=9999)) + + def test_deadline_passes_during_poll(self): + def expire(ctx, rid): + ctx.store._conn.execute("UPDATE approvals SET expires_at=? WHERE request_id=?", (time.time()-1, rid)) + ctx.store._conn.commit() + self.scenario(expire) + + +class State(unittest.TestCase): + def test_grant_delta_is_audited_without_values(self): + store = Store(":memory:") + try: + seed_caller(store, allow=["echo.say"]) + operations.set_policy(store, "hermes", [], ["echo.say"], "test") + event = store.audit_events()[-1] + self.assertEqual(event["details"]["grant_delta"], [ + {"operation": "echo.say", "before": "allow", "after": "review"}]) + finally: + store.close() + + def test_schema_migration_is_repeatable(self): + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "broker.sqlite3") + store = Store(path) + store.close() + store = Store(path) + try: + self.assertEqual(store._conn.execute("PRAGMA user_version").fetchone()[0], 3) + self.assertIn("operation_generation", {r[1] for r in store._conn.execute("PRAGMA table_info(requests)")}) + finally: + store.close() + + def test_manifest_drift_fails_lookup_closed(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "toolyard.toml" + path.write_text('id="echo"\n[entrypoint]\nport=4600\n[[operations]]\nname="say"\n') + catalog = {} + Registry._add_toml(catalog, path) + registry = Registry(catalog) + self.assertIsNotNone(registry.lookup("echo", "say")) + path.write_text(path.read_text().replace("4600", "4601")) + self.assertIsNone(registry.lookup("echo", "say")) + + def test_private_db_and_sidecars(self): + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "private" / "broker.sqlite3") + store = Store(path) + store.add_caller("sidecar-fixture") + try: + self.assertEqual(os.stat(Path(path).parent).st_mode & 0o777, 0o700) + for candidate in (path, path + "-wal", path + "-shm"): + self.assertEqual(os.stat(candidate).st_mode & 0o777, 0o600) + finally: + store.close() + + def test_channel_path_never_private_state(self): + with tempfile.TemporaryDirectory() as d: + private = Path(d) / "state.json" + private.write_text(json.dumps({"x": {"e_secret": "fake-private"}})) + with patch.dict(os.environ, {"TOOLSTACK_TOOLYARD_STATE": str(private)}, clear=True): + self.assertIsNone(ToolState().channel_secret_for("x")) + private.with_name("channels.json").write_text(json.dumps({"x": {"channel_secret": "fake-channel"}})) + self.assertEqual(ToolState().channel_secret_for("x"), "fake-channel") + + def test_prefix_rejects_wildcards_and_short_values(self): + store = Store(":memory:") + try: + for prefix in ("%", "_", "abcd", "a"*11, "A"*12, "g"*12): + for fn in (store.revoke_token_by_prefix, store.caller_ids_for_token_prefix): + with self.assertRaises(ValueError): + fn(prefix) + finally: + store.close() + + def test_duplicate_invalid_and_reserved_operation(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "toolyard.toml" + for operations, secrets in ((["say", "say"], False), (["bad/name"], False), (["refresh"], True)): + text = 'id="echo"\n[entrypoint]\nport=4600\n' + text += ''.join(f'[[operations]]\nname="{op}"\n' for op in operations) + if secrets: + text += '[[secrets]]\nname="fake"\n' + path.write_text(text) + with self.assertRaises(ValueError): + Registry._add_toml({}, path) diff --git a/broker/tool_state.py b/broker/tool_state.py index ee371e3..27d8c51 100644 --- a/broker/tool_state.py +++ b/broker/tool_state.py @@ -1,19 +1,7 @@ -"""Read toolyard's state file to discover the E_SECRET each running tool was -provisioned with. +"""Read ONLY dedicated broker-to-tool channel credentials from channels.json. -Phase 4 re-sources ``X-Toolstack-Secret`` from the per-tool E_SECRET (which -the runner minted at start, registered with SPS, and wrote to the state file). -This module is the broker-side reader. - -The state file is host-local-trust (lives next to the broker, owned by the -runner service account, never crosses a network boundary). The broker does -NOT learn the E_SECRET from SPS -- that would put SP_SECRET (broad) on the -broker, which is the wrong direction. - -Lazy + mtime-memoized: a second call within the same mtime is a dict -lookup; a state change (the runner restart-supersedes) is picked up on the -next call. Operator restarts of the broker are idempotent because the file -is unchanged. +Never read private runner state or SPS credentials. Separate files do not +isolate unrestricted processes owned by the same OS user. """ from __future__ import annotations @@ -24,11 +12,14 @@ def _default_state_path() -> Path: + channel = os.environ.get("TOOLSTACK_TOOLYARD_CHANNEL_STATE") + if channel: + return Path(channel) state = os.environ.get("TOOLSTACK_TOOLYARD_STATE") if state: - return Path(state) + return Path(state).with_name("channels.json") base = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state") - return Path(base) / "toolstack" / "toolyard" / "state.json" + return Path(base) / "toolstack" / "toolyard" / "channels.json" class ToolState: @@ -37,8 +28,8 @@ def __init__(self, path: str | Path | None = None) -> None: self._mtime: float | None = None self._by_id: dict[str, str] = {} - def e_secret_for(self, tool_id: str) -> str | None: - """Return the E_SECRET for `tool_id`, or None if the tool is not + def channel_secret_for(self, tool_id: str) -> str | None: + """Return the channel secret for `tool_id`, or None if the tool is not registered or the state file is missing. Picks up state changes via the file's mtime on each call (cheap; the file is small).""" self._refresh_if_stale() @@ -63,11 +54,12 @@ def _refresh_if_stale(self) -> None: with self._path.open() as f: state = json.load(f) self._by_id = { - tid: rec.get("e_secret") or "" + tid: rec["channel_secret"] for tid, rec in state.items() - if isinstance(rec, dict) and rec.get("e_secret") + if isinstance(rec, dict) and isinstance(rec.get("channel_secret"), str) + and rec["channel_secret"].strip() } self._mtime = mtime - except (OSError, ValueError): + except (OSError, ValueError, AttributeError): self._by_id = {} # Leave mtime untouched so we retry on the next call. diff --git a/client/mcp_server.py b/client/mcp_server.py index bd6b9a2..583c0b3 100644 --- a/client/mcp_server.py +++ b/client/mcp_server.py @@ -8,8 +8,8 @@ Review ops advertise an optional `_reason` string in their input schema, the MCP analogue of the CLI's `--reason`. `tools/call` pulls `_reason` out of the arguments -(it is never forwarded to the tool) and sends it as the broker's justification, so it -reaches the human approver and the audit log. +(it is never forwarded to the tool). It is private pending-request metadata, +never shown to the human approver or copied into audit text. Config (env): TOOLSTACK_URL, TOOLSTACK_TOKEN / TOOLSTACK_TOKEN_FILE (shared with the CLI). Stdlib only. @@ -22,19 +22,21 @@ import time import urllib.error -from .toolstack import _FAIL, _send +from .toolstack import _FAIL, _WAITING, _send +import math +from http.client import HTTPException PROTOCOL_VERSION = "2024-11-05" _JSON_TYPES = {"string", "integer", "number", "boolean", "object", "array"} -def _call(method: str, path: str, body=None): +def _call(method: str, path: str, body=None, *, idempotency_key=None): # Same broker call as the CLI (client.toolstack._send); only the unreachable # case differs: surface it as a JSON-RPC error rather than exiting. try: - return _send(method, path, body) + return _send(method, path, body, idempotency_key=idempotency_key) if idempotency_key is not None else _send(method, path, body) except urllib.error.URLError as exc: - raise RuntimeError(f"broker unreachable: {exc.reason}") + raise RuntimeError("broker unreachable; outcome may be unknown; never retry with a new key") from None def _input_schema(args: list) -> dict: @@ -56,13 +58,13 @@ def _input_schema(args: list) -> dict: def _with_reason(schema: dict) -> dict: """Advertise the optional `_reason` justification on a review tool's input schema. - It is the MCP analogue of the CLI's --reason: shown to the human approver and - audited, and stripped by `_call_tool` before the tool runs (never a tool arg).""" + It is legacy private metadata, stripped before the tool runs and never + included in the approval summary or audit text.""" props = dict(schema.get("properties", {})) props["_reason"] = { "type": "string", - "description": ("Why you need this, shown to the human approver and recorded " - "in the audit log. Recommended on review ops; not passed to the tool."), + "description": ("Legacy private request metadata; omitted from approval and audit text. " + "Not passed to the tool. Do not include secrets."), } return {**schema, "type": "object", "properties": props} @@ -82,6 +84,7 @@ def dispatch(self, method: str, params: dict): "protocolVersion": params.get("protocolVersion", PROTOCOL_VERSION), "capabilities": {"tools": {}}, "serverInfo": {"name": "toolstack", "version": "0.1.0"}, + "instructions": 'Supply tools/call params._meta["toolstack/idempotency-key"] (1-128 letters/digits/_/-). Reuse only with the same action. Unknown outcomes are not retry permission.', } if method == "ping": return {} @@ -117,18 +120,30 @@ def _call_tool(self, params: dict) -> dict: tool, op = self._names[name] # `_reason` is adapter metadata, not a tool argument: pull it out (so it is # never forwarded to the tool) and pass it as the broker's justification, - # the MCP equivalent of the CLI's --reason. It rides to the human approver - # on review ops and is audited (redacted). + # the MCP equivalent of the CLI's --reason. It stays private to the broker + # on review ops; audit records only whether metadata was provided. args = dict(arguments) reason = args.pop("_reason", None) body = {"arguments": args} if isinstance(reason, str) and reason.strip(): body["reason"] = reason - _, resp = _call("POST", f"/v1/actions/{tool}.{op}", body) - if resp.get("status") == "pending_approval": + meta = params.get("_meta", {}) + if not isinstance(meta, dict): + raise ValueError("_meta must be an object") + key = meta.get("toolstack/idempotency-key") + try: + _, resp = _call("POST", f"/v1/actions/{tool}.{op}", body, idempotency_key=key) if key is not None else _call("POST", f"/v1/actions/{tool}.{op}", body) + except (OSError, RuntimeError, HTTPException, json.JSONDecodeError) as exc: + resp = {"status": "unknown", "error": "broker_transport_error", "detail": type(exc).__name__, + "source": "client", "note": "Use original key and exact payload; never auto-resubmit with a new key."} + if resp.get("status") in _WAITING: resp = self._poll(resp["request_id"]) + if key is not None: + resp.setdefault("idempotency_key", key) status = resp.get("status") - is_error = status in _FAIL or (status is None and "error" in resp) + nested = resp.get("result") + is_error = (status in _FAIL or (status is None and "error" in resp) + or (isinstance(nested, dict) and nested.get("isError") is True)) return _result(json.dumps(resp, indent=2), is_error=is_error) def _poll(self, request_id) -> dict: @@ -136,17 +151,25 @@ def _poll(self, request_id) -> dict: # isError:true: the call has NOT completed, even though the request may still # be pending on the broker. Otherwise an MCP agent reads a timed-out call as a # successful one. + if not math.isfinite(self._poll_timeout) or not 0 <= self._poll_timeout <= 86400: + raise ValueError("invalid poll timeout") deadline = time.monotonic() + self._poll_timeout while True: - _, resp = _call("GET", f"/v1/requests/{request_id}") - if resp.get("status") != "pending_approval": + try: + _, resp = _call("GET", f"/v1/requests/{request_id}") + except (OSError, RuntimeError, HTTPException, json.JSONDecodeError) as exc: + return {"status": "unknown", "request_id": request_id, "source": "client", + "error": "broker_transport_error", "detail": type(exc).__name__} + resp.setdefault("request_id", request_id) + if resp.get("status") not in _WAITING: return resp if time.monotonic() >= deadline: return {**resp, "status": "timeout", "request_id": request_id, + "broker_status": resp.get("status"), "note": (f"wait timed out after {self._poll_timeout:g}s; the " - "request is still pending approval on the broker and " + f"request is still {resp.get('status')} on the broker and " "has not completed")} - time.sleep(2.0) + time.sleep(min(2.0, max(0, deadline - time.monotonic()))) def _result(text: str, is_error: bool = False) -> dict: @@ -164,7 +187,7 @@ def _handle(server: Server, message: dict): return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {exc}"}} except Exception as exc: # surface as a JSON-RPC error, never crash the server - return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32603, "message": str(exc)}} + return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32603, "message": type(exc).__name__}} def serve(stdin, stdout) -> None: diff --git a/client/tests/test_mcp.py b/client/tests/test_mcp.py index 93e7e3e..6fe8997 100644 --- a/client/tests/test_mcp.py +++ b/client/tests/test_mcp.py @@ -55,6 +55,23 @@ def test_call_allowed(self): self.assertFalse(r["isError"]) self.assertIn("echoed", r["content"][0]["text"]) + def test_nested_tool_error_is_not_reported_as_success(self): + self.server.ctx.runtime.execute = lambda *args: {"isError": True, "content": []} + r = self.mcp.dispatch("tools/call", {"name": "echo__say", "arguments": {}}) + self.assertTrue(r["isError"]) + + def test_idempotency_metadata_never_forwarded_and_conflict_is_error(self): + params = {"name": "echo__say", "arguments": {"m": "hi"}, "_meta": {"toolstack/idempotency-key": "same-key"}} + first = self.mcp.dispatch("tools/call", params) + repeat = self.mcp.dispatch("tools/call", params) + self.assertFalse(first["isError"]) + self.assertFalse(repeat["isError"]) + self.assertEqual(len(self.server.ctx.runtime.calls), 1) + self.assertEqual(self.server.ctx.runtime.calls[0][2], {"m": "hi"}) + params["arguments"] = {"m": "different"} + self.assertTrue(self.mcp.dispatch("tools/call", params)["isError"]) + self.assertEqual(len(self.server.ctx.runtime.calls), 1) + def test_call_review_blocks_then_approves_with_note(self): self.surface.set(approval.APPROVED, approver="alice", note="ok by me") r = self.mcp.dispatch("tools/call", {"name": "echo__skip", "arguments": {}}) @@ -84,13 +101,13 @@ def test_review_schema_advertises_reason_only_on_review(self): # ...an allow op does not (no human reads it) self.assertNotIn("_reason", tools["echo__say"]["inputSchema"].get("properties", {})) - def test_review_call_forwards_reason_to_approver_and_strips_it(self): + def test_review_call_keeps_reason_private_and_strips_it(self): self.surface.set(approval.APPROVED, approver="alice", note="ok") r = self.mcp.dispatch("tools/call", {"name": "echo__skip", "arguments": {"_reason": "skip the ad break"}}) self.assertFalse(r["isError"]) - # the justification rode to the human on the approval card (redacted)... - self.assertIn("skip the ad break", self.surface.opened[-1].justification) + # Free-text justification never rides to the approval card... + self.assertIsNone(self.surface.opened[-1].justification) # ...and _reason was NOT forwarded to the tool self.assertNotIn("_reason", r["content"][0]["text"]) diff --git a/client/tests/test_receipts.py b/client/tests/test_receipts.py new file mode 100644 index 0000000..dd13b87 --- /dev/null +++ b/client/tests/test_receipts.py @@ -0,0 +1,46 @@ +import argparse +import io +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch +from client import toolstack, mcp_server + + +class Receipts(unittest.TestCase): + def test_lost_response_reports_unknown_with_original_key_without_retry(self): + with patch.object(toolstack, "_send", side_effect=TimeoutError("fake-sensitive")) as send: + status, out = toolstack._request("POST", "/v1/actions/echo.say", {}, idempotency_key="same-key") + self.assertEqual((status, out["status"], out["idempotency_key"]), (502, "unknown", "same-key")) + self.assertNotIn("fake-sensitive", str(out)) + self.assertEqual(send.call_count, 1) + + def test_cli_key_is_request_metadata(self): + args = argparse.Namespace(spec="echo.say", args='{"a":1}', args_file=None, + reason=None, wait=False, timeout=1, idempotency_key="same-key") + with patch.object(toolstack, "_request", return_value=(200, {"status": "ok", "request_id": 7})) as send, redirect_stdout(io.StringIO()): + toolstack.cmd_call(args) + send.assert_called_once_with("POST", "/v1/actions/echo.say", {"arguments": {"a": 1}}, idempotency_key="same-key") + + def test_received_executing_wait_but_unknown_never_loops(self): + with patch.object(toolstack, "_request", return_value=(200, {"status": "executing", "request_id": 7})), redirect_stdout(io.StringIO()): + timed = toolstack._poll_until_done(7, 0) + self.assertEqual((timed["status"], timed["broker_status"]), ("timeout", "executing")) + with patch.object(toolstack, "_request", return_value=(200, {"status": "unknown", "request_id": 7})) as read: + self.assertEqual(toolstack._poll_until_done(7, 1)["status"], "unknown") + read.assert_called_once() + for status in ("received", "executing", "unknown", "result_expired", "conflict"): + with redirect_stdout(io.StringIO()), self.assertRaises(SystemExit): + toolstack._finish({"status": status, "request_id": 7}) + + def test_mcp_received_wait_is_bounded_and_truthful(self): + server = mcp_server.Server(poll_timeout=0) + with patch.object(mcp_server, "_call", return_value=(202, {"status": "received", "request_id": 7})): + out = server._poll(7) + self.assertEqual((out["status"], out["broker_status"]), ("timeout", "received")) + + def test_invalid_key_is_refused_before_transport(self): + with patch.object(toolstack._OPENER, "open") as opened: + for key in ("", "bad key", "a:b", "*", "x"*129, "a\r\nb"): + with self.assertRaises(ValueError): + toolstack._send("POST", "/test", {}, idempotency_key=key) + opened.assert_not_called() diff --git a/client/toolstack.py b/client/toolstack.py index addb96c..b2b5af2 100755 --- a/client/toolstack.py +++ b/client/toolstack.py @@ -28,6 +28,9 @@ import os import sys import time +import re +import math +from http.client import HTTPException import urllib.error import urllib.request @@ -36,7 +39,16 @@ # a CLIENT-side outcome: the wait gave up, but the request may still be pending on # the broker; it must NOT read as success. _FAIL = {"denied", "expired", "failed", "unavailable", "invalid", "not_found", - "rate_limited", "timeout"} + "rate_limited", "timeout", "received", "executing", "unknown", "result_expired", "conflict"} +_WAITING = {"pending_approval", "received", "executing"} + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +_OPENER = urllib.request.build_opener(_NoRedirect) def _base() -> str: @@ -57,22 +69,28 @@ def _token() -> str | None: return None -def _send(method: str, path: str, body=None): +def _send(method: str, path: str, body=None, *, idempotency_key=None): """Send a request to the broker and return (status, parsed-json). Raises urllib.error.URLError if the broker is unreachable; callers decide how to surface that (the CLI exits; the MCP server returns a JSON-RPC error). Shared with client.mcp_server so the broker HTTP call lives in exactly one place.""" data = json.dumps(body).encode("utf-8") if body is not None else None headers = {"Content-Type": "application/json"} + if idempotency_key is not None: + if not isinstance(idempotency_key, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", idempotency_key): + raise ValueError("invalid idempotency key") + headers["Idempotency-Key"] = idempotency_key token = _token() if token: headers["Authorization"] = f"Bearer {token}" req = urllib.request.Request(_base() + path, data=data, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=_timeout()) as resp: + with _OPENER.open(req, timeout=_timeout()) as resp: return resp.status, json.loads(resp.read() or b"{}") except urllib.error.HTTPError as exc: try: + if 300 <= exc.code < 400: + return exc.code, {"status": "unknown", "error": "redirect_refused"} return exc.code, json.loads(exc.read() or b"{}") finally: exc.close() @@ -82,26 +100,23 @@ def _timeout() -> float: """Per-call broker timeout in seconds (default 30). Override with TOOLSTACK_CLIENT_TIMEOUT for slow tools or links.""" try: - return float(os.environ.get("TOOLSTACK_CLIENT_TIMEOUT", "30")) + value = float(os.environ.get("TOOLSTACK_CLIENT_TIMEOUT", "30")) except ValueError: - return 30.0 + value = 30.0 + if not math.isfinite(value) or not 0 < value <= 300: + raise ValueError("client timeout must be finite and within (0,300]") + return value -def _request(method: str, path: str, body=None): +def _request(method: str, path: str, body=None, *, idempotency_key=None): try: - return _send(method, path, body) - except urllib.error.URLError as exc: - # Name the error class too: a timeout vs a refused connection vs DNS failure all arrive - # as URLError subclasses, and the distinction matters when diagnosing. - print(f"cannot reach broker at {_base()}: {type(exc).__name__}: {exc.reason}", - file=sys.stderr) - # The most common cause is "I installed the CLIs but never started a broker." Say so, - # and point at the ways to start one (installing this package does not run anything). - print("Is a broker running at that address? Start one from the admin app (Start broker), " - "the Docker one-box (deploy/docker), the systemd service (deploy/), or directly with " - "`python3 -m broker.server`. Point elsewhere with TOOLSTACK_URL.", - file=sys.stderr) - raise SystemExit(2) + return _send(method, path, body, idempotency_key=idempotency_key) if idempotency_key is not None else _send(method, path, body) + except (OSError, HTTPException, json.JSONDecodeError) as exc: + response = {"status": "unknown", "error": "broker_transport_error", "detail": type(exc).__name__, + "source": "client", "note": "Use the receipt or original key and exact payload; never auto-resubmit with a new key."} + if idempotency_key is not None: + response["idempotency_key"] = idempotency_key + return 502, response def _print(obj) -> None: @@ -111,7 +126,7 @@ def _print(obj) -> None: def _finish(resp: dict) -> None: _print(resp) status = resp.get("status") - if status in _FAIL or (status is None and "error" in resp): + if status in _FAIL or (status is None and "error" in resp) or (isinstance(resp.get("result"), dict) and resp["result"].get("isError") is True): raise SystemExit(1) @@ -123,17 +138,21 @@ def _poll_until_done(request_id: int, timeout: float, interval: float = 2.0) -> as success. The request usually outlives this wait (the broker's approval TTL is far longer than the default client timeout), so the result carries the request id to resume with `toolstack wait `.""" + if not math.isfinite(timeout) or not 0 <= timeout <= 86400: + raise ValueError("wait timeout must be finite and within [0,86400]") deadline = time.monotonic() + timeout while True: _, resp = _request("GET", f"/v1/requests/{request_id}") - if resp.get("status") != "pending_approval": + resp.setdefault("request_id", request_id) + if resp.get("status") not in _WAITING: return resp if time.monotonic() >= deadline: return {**resp, "status": "timeout", "request_id": request_id, + "broker_status": resp.get("status"), "note": (f"client wait timed out after {timeout:g}s; the request is " f"still pending on the broker. Resume with " f"`toolstack wait {request_id}`")} - time.sleep(interval) + time.sleep(min(interval, max(0, deadline - time.monotonic()))) def cmd_tools(args) -> None: @@ -186,9 +205,14 @@ def cmd_call(args) -> None: body = {"arguments": _load_arguments(args)} if args.reason: body["reason"] = args.reason - _, resp = _request("POST", f"/v1/actions/{args.spec}", body) - if args.wait and resp.get("status") == "pending_approval": + key = getattr(args, "idempotency_key", None) + if key is None: + print("No idempotency key: resubmission can repeat effects. Prefer --idempotency-key; never auto-retry uncertain outcomes.", file=sys.stderr) + _, resp = _request("POST", f"/v1/actions/{args.spec}", body, idempotency_key=key) if key is not None else _request("POST", f"/v1/actions/{args.spec}", body) + if args.wait and resp.get("status") in _WAITING: resp = _poll_until_done(resp["request_id"], args.timeout) + if key is not None: + resp.setdefault("idempotency_key", key) _finish(resp) @@ -218,8 +242,9 @@ def main() -> None: c.add_argument("args", nargs="?", default=None, help="JSON arguments object (prefer stdin/heredoc or --args-file)") c.add_argument("--args-file", help="read JSON arguments from a file (shell-safe)") - c.add_argument("--reason", help="justification (use only for review ops / retries)") - c.add_argument("--wait", action="store_true", help="poll to the outcome if review-required") + c.add_argument("--reason", help="legacy private metadata, NOT shown to approver; do not include secrets") + c.add_argument("--idempotency-key", help="caller-scoped stable retry key: 1–128 letters, digits, _ or -; reuse only with the exact same action") + c.add_argument("--wait", action="store_true", help="bounded wait for received/approval/executing requests; never replay") c.add_argument("--timeout", type=float, default=300, metavar="SECONDS", help="client-side wait for approval (default 300); on timeout, exits " "non-zero with status=timeout and the request id to resume") @@ -233,7 +258,10 @@ def main() -> None: w.set_defaults(func=cmd_wait) args = parser.parse_args() - args.func(args) + try: + args.func(args) + except (OSError, ValueError, RuntimeError) as exc: + raise SystemExit(f"request failed: {type(exc).__name__}; outcome may be unknown; use the receipt/original key, never auto-resubmit") from None if __name__ == "__main__": diff --git a/deploy/README.md b/deploy/README.md index 9715318..90e3f09 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,181 +1,97 @@ -# Deploying Toolstack - -This directory holds the operator-facing deployment artifacts for a real (homelab / -single-host) install via **systemd**. It deploys the **admin web app**, which in turn -supervises the **broker**; the toolyard and tools run as the admin app starts them. - -> **On a laptop?** For a self-contained, no-server install, use the one-box Docker setup -> instead: [`docker/`](docker/); `docker compose up` brings up the whole stack with an -> encrypted local vault. See [docker/README.md](docker/README.md). - -| File | What it is | -|---|---| -| [`toolstack-admin.service`](toolstack-admin.service) | systemd unit **template** for the admin panel (+ the broker it supervises). Ships with placeholders; copy and edit, don't symlink. | -| [`toolstack-admin.env.example`](toolstack-admin.env.example) | example `EnvironmentFile`: the site config (admin / toolyard runner). Copy, fill in, `chmod 600`. | -| [`redeploy-toolstack`](redeploy-toolstack) | one command to pull + refresh the venv + restart the service + restart registered tools. | - -The broker, toolyard, and client are stdlib-only Python (3.11+). The **admin app is the -one component with runtime dependencies** (FastAPI + uvicorn), so it runs from its own -virtualenv. See [admin/README.md](../admin/README.md) for what the panel does. - -## Prerequisites - -- **Python 3.11+** (the broker/toolyard use `tomllib`). -- A **service account**: a dedicated `toolstack` user + group (`sudo useradd --system --user-group --shell /usr/sbin/nologin toolstack`), or point the unit's `User=` / `Group=` at an account you already have. -- An **admin virtualenv** with the package + panel deps: `python3 -m venv admin/.venv && admin/.venv/bin/pip install -e '.[vault]' -r admin/requirements.txt` (run from the checkout root). Installing the package puts `brokerctl` / `toolstack` / `toolyard` in `admin/.venv/bin` alongside the admin. (`deploy/install.sh` and `redeploy-toolstack` do this for you.) -- A **secret backend** (SPS) — phase 5: a separate SPS service holds tool - secrets; tools pull them at boot over TLS/TCP. Configure via - [`sps.env.example`](sps.env.example) (Infisical / Hashicorp Vault / - local encrypted file). The runner + tooltalk path is one line each. -- For production tool isolation: **Docker** (`TOOLSTACK_RUNNER=docker`); the `process` runner is dev-only. The docker runner needs a little extra setup, see "Docker tool runner" below. -- For human approvals: a reachable **[nod](https://github.com/batteryshark/nod)** instance and an issuer token, configured from the dashboard, not this env file (see "Broker config" below). - -## Install - -**The quick way:** from the checkout, `sudo deploy/install.sh` does everything below in one -shot, creating the service user, building the venv, prompting for the admin password, and -installing + enabling the unit. Override the account with `TOOLSTACK_USER=`. The manual -steps follow for when you want to do it by hand. - -1. **Lay down the code** at your install root (the examples assume `/opt/toolstack`, owned by a `toolstack` user, change to taste) and build the admin venv (above). -2. **Create the state dir and set the admin password** (the panel fails closed; it refuses to start without one). The service keeps everything it writes under `/var/lib/toolstack` (the unit's `StateDirectory` owns it); create it now so the password lands where the service will read it: - ```bash - sudo install -d -o toolstack -g toolstack -m700 /var/lib/toolstack - sudo -u toolstack env XDG_CONFIG_HOME=/var/lib XDG_STATE_HOME=/var/lib \ - admin/.venv/bin/python -m admin set-password - ``` - The `XDG_*` values match the unit (see its "State location" block); every manual `admin` / `brokerctl` command needs them so it resolves the same paths as the running service. -3. **Site config**: copy and fill in the env file, then lock it down (it can hold an Infisical host / paths): - ```bash - sudo install -D -m600 deploy/toolstack-admin.env.example /etc/toolstack/admin.env - sudoedit /etc/toolstack/admin.env - ``` -4. **Install the unit**: copy the template and edit every line marked `EDIT:` (the service account and the install root, which repeats in `WorkingDirectory` + the three `Exec` lines): - ```bash - sudo cp deploy/toolstack-admin.service /etc/systemd/system/toolstack-admin.service - sudoedit /etc/systemd/system/toolstack-admin.service - sudo systemctl daemon-reload - sudo systemctl enable --now toolstack-admin - ``` -5. **Reach the panel** over a tunnel that terminates TLS (Tailscale Serve, SSH, etc.); it binds `127.0.0.1` only and has no TLS of its own. Never bind it to a public interface. - -The broker's own run-config (its port, DB path, the nod URL/token/channel, approval TTL, -rate limit) is **not** in the env file; the admin app stores it in `broker.toml` and you -edit it from the dashboard's **Config** page. (`admin/broker_config.py` is the source of truth.) - -## Docker tool runner - -With `TOOLSTACK_RUNNER=docker`, the service shells out to `docker build` / `docker run`, which -needs two things the unit's sandbox otherwise denies: - -1. **Access to the docker socket.** Grant the service the `docker` group, or every build is - `permission denied ... unix:///var/run/docker.sock`. Uncomment `SupplementaryGroups=docker` - in the unit (or `sudo usermod -aG docker toolstack`), then **reload and restart** so the new - group takes effect: - ```bash - sudo systemctl daemon-reload && sudo systemctl restart toolstack-admin - ``` - The restart matters: a group change doesn't reach the already-running service until it - re-execs. -2. **A writable docker config.** `ProtectHome=true` hides `~/.docker`, so the unit sets - `DOCKER_CONFIG=/var/lib/toolstack/.docker` (under the state dir). Nothing to do; this just - silences the `Error loading config file: ~/.docker/config.json` warning. - -Each tool you run under docker must carry a `Dockerfile` or an `image=` in its `toolyard.toml` -(a process `command` alone won't build); the bundled samples do. - -## Supervision model (why `ExecStartPost`, and why it's not an orphan bug) - -`ExecStart` runs the **admin panel**, the long-lived, systemd-tracked process. The -**broker is not a child of it**. It is supervised out of band by a state file (PID + port -under the admin XDG state dir), exactly like the toolyard's `ProcessRunner`: it runs in its -own process group, started and stopped through `admin.supervisor`. `ExecStartPost` -auto-starts it on boot (idempotent: it no-ops if a healthy broker is already recorded) and -`ExecStopPost` tears it down; init reaps it. You can also start/stop/restart it from the -dashboard. - -**Trade-off:** because systemd doesn't track the broker, a broker *crash* won't trigger a -systemd restart on its own; the dashboard surfaces broker health, and the unit's -`Restart=on-failure` only covers the panel. If you'd rather systemd own the broker directly, -run `python -m broker.server` as its own unit (with the same `EnvironmentFile`) and drop the -`ExecStartPost`/`ExecStopPost` lines. - -## Redeploying - -From anywhere in the checkout: - -```bash -deploy/redeploy-toolstack [--pull] [--skip-venv] [--skip-service] [--skip-tools] +# Deployment (trusted host only) + +See [runtime matrix](../docs/runtime-matrix.md). Source templates are not evidence +of a deployed, hardened installation. No privileged mutable Python helper is supported. +Release use requires a dedicated Toolstack VM containing only Toolstack and trusted +tools under its account. Agents must remain outside the host with broker-only access. +Same-machine/account agents are local/dev only and do not isolate secrets. + +## Native Linux recipe + +Python 3.11+ and OpenSSL are required. Review `install.sh` before executing as root: +it creates an account, changes checkout ownership, installs into a dedicated venv, +sets a password interactively and enables services. These templates have **not** been installed or run in the reported validation. +Only trusted process tools may run under the shared service account. Do not add +sudoers permissions for `toolyard.netguard`, Docker group membership, or remove +`NoNewPrivileges=true` to work around unsupported backends. + +`uv.lock` is authoritative. `admin/requirements-lock.txt` is its complete deployment +export, used as requirements AND constraints by install, redeploy and Docker builds. +The vault dependency is `cryptography>=50.0.2,<51`, locked to **50.0.2**. +Use the current lock and minimum fixed line, not the obsolete 45.x / `<46` range; +see [dependency evidence and limits](../docs/security-contracts.md#dependency-and-validation-evidence). +Regenerate using the command in `admin/requirements.txt`. Install is non-editable; +admin runs from the checkout under systemd, while the installed wheel must also work +without that checkout. Redeploy updates both; stop workloads first for upgrades. + +## SPS bootstrap (offline) + +`sps.env` is TOML despite its suffix; never source it or load it as a systemd +EnvironmentFile. `/etc/toolstack/sps-runtime.env` is a separate, mode-0600 systemd +file for `SP_VAULT_PASSPHRASE` (use a fake value in development). The installer +creates `/etc/toolstack`; unprivileged users must not try to create it themselves. + +After install, before enabling SPS for the first time, as root: + +```sh +umask 077 +/opt/toolstack/admin/.venv/bin/python /opt/toolstack/deploy/bootstrap-sps.py +chown toolstack:toolstack /etc/toolstack/sps.env /etc/toolstack/sps.key +chown toolstack:toolstack /etc/toolstack/sps.crt /etc/toolstack/sps-ca.crt ``` -It optionally fast-forwards git, refreshes the admin venv, restarts the service (admin + -broker), and restarts every tool in the saved run-config. It loads the same site env as the -unit (`TOOLSTACK_ENV_FILE`, default `/etc/toolstack/admin.env`) so the tool restarts use the -deployment's runner / secret backend. - -Two safety steps wrap the restart: it **snapshots the broker DB** first (an online SQLite -backup under `.../broker/backups/`, newest 7 kept) and then **health-gates** the broker -(`/v1/health`) and panel (`/login`). If the stack doesn't come back up it stops there -rather than restarting tools against a down broker. Run it as the `toolstack` user (it -reads the broker's state and DB directly); `sudo` is used only for `systemctl`. - -## Verify - -```bash -systemctl status toolstack-admin # the panel is active (running) -curl -s 127.0.0.1:8765/v1/health # the broker -> {"status":"ok"} -curl -s 127.0.0.1:8780/login -o /dev/null -w '%{http_code}\n' # the panel -> 200 +Adjust account and install root to match the installer. Bootstrap creates only TLS +material/config, not a vault. Vault lives in `/var/lib/toolstack/sps.vault.json`, +audit in `/var/log/toolstack/sps.audit`; `/etc` remains read-only in the services. +Provision `sps init-vault` and `sps vault-set` as the service user with the venv, +explicit `--config /etc/toolstack/sps.env`, and the same passphrase as the SPS +runtime file. Keep SPS **stopped during all direct provisioning**. Never put real +values on command lines, in git, or in shell history. Restrict runtime-file ownership +to root and mode 0600; systemd reads it before dropping privileges. + +Set `TOOLSTACK_SPS_ENV=/etc/toolstack/sps.env` in admin.env if required by the +resolved runner configuration (see SPS docs); use verified TLS and explicit CA path. +Verify the runner's resolved SPS configuration and CA path before deployment. +Start SPS, then admin; check journal and health before launching trusted tools. +Private umask/directories do not isolate processes sharing the same uid. + +## Updates, backup and restore + +`redeploy-toolstack` snapshots SQLite with the SQLite backup API before restarting +services (seven snapshots retained). These contain sensitive arguments/results; +directories are private and copies need equivalent protection. A SQLite snapshot +alone is NOT a complete installation backup. Back up config, TLS, encrypted vault, +its separately protected passphrase, tool manifests and application version as a +consistent offline set after stopping tools/admin/SPS. Do not copy a live SQLite +file without its WAL; use the backup API or stop all writers first. + +Broker schema 3 includes caller-scoped receipts, manual retention and offline recovery. +Use explicit private paths (examples below are placeholders), with the installed +version's Python environment. See the [complete maintenance contract](../docs/security-contracts.md#manual-retention-and-offline-recovery): + +```sh +python3 -m broker.brokerctl prune-payloads --db /private/state/broker.db +python3 -m broker.brokerctl backup --db /private/state/broker.db --destination /private/backups/snapshot.db ``` -Then open the dashboard: the broker card shows healthy, and the audit view fills as -requests flow. - -## First run: provision an agent (headless) -The dashboard can do all of this, but a fresh box can be bootstrapped entirely from the -CLI. `brokerctl` is the operator tool; run it as the service user with the same `XDG_*` -paths as the unit so it opens the broker's DB (define a small helper to avoid repeating it): +Pruning is manual: terminal payloads must be **strictly older than seven days**. +Caller/key/digest tombstones remain; active work is not pruned. WAL and backups can +retain bytes, so this is not secure erasure. Backup uses SQLite's backup API, +including committed WAL, and publishes only to a **NEW destination**. -```bash -ctl() { sudo -u toolstack env XDG_CONFIG_HOME=/var/lib XDG_STATE_HOME=/var/lib \ - admin/.venv/bin/brokerctl "$@"; } +**Stop ALL broker/admin/CLI writers** before either offline command: -ctl create-caller --name my-agent --allow echo_api.echo # the agent identity + an initial grant -ctl set-policy --name my-agent --review some_tool.write # (optional) route an op through approval -ctl issue-token --name my-agent # prints the bearer token; copy it once +```sh +python3 -m broker.brokerctl reconcile-offline --db /private/state/broker.db --confirm-offline +python3 -m broker.brokerctl restore-new --snapshot /private/backups/snapshot.db --destination /private/recovery/new.db --confirm-offline ``` -Give the token to the agent as its `Authorization: Bearer ...`. Tighten or widen access later -with `set-policy` (`--allow` / `--review` / `--deny`), and rotate with `revoke-token` / -`issue-token`. (`ctl` uses the broker's default DB path; if you -changed `db_path` in `broker.toml`, pass `--db `.) - -## Backups - -Everything persistent lives under the state dir (`/var/lib/toolstack`). Two things are worth -backing up off-box: - -- **Broker SQLite** (`/var/lib/toolstack/broker/broker.sqlite3`): callers, tokens, policies, - request history, audit log. Take a *consistent* copy while the broker runs with SQLite's - online backup (`redeploy-toolstack` does this automatically before each restart): - ```bash - sudo -u toolstack sqlite3 /var/lib/toolstack/broker/broker.sqlite3 \ - ".backup '/var/lib/toolstack/broker/backups/manual-$(date +%F).sqlite3'" - ``` -- **Config + secrets**: `broker.toml` (run-config; holds the nod issuer token) and, with the - encrypted-vault backend, `vault.json` (both under `/var/lib/toolstack/admin/` and - `/var/lib/toolstack/`). The vault is useless without its passphrase; store that separately. - -Restore is a file copy back into place with the service stopped. There is no DB-migration -framework; restore into the same (or a forward-compatible) version. - -## Upgrading from an earlier install (state location) - -This template pins all state under `/var/lib/toolstack` (`StateDirectory` + `XDG_*_HOME=/var/lib`). -Earlier templates relied on the service user's `$HOME` (`~/.local/state/toolstack/...`, or an -even older `.../tsr` path). Admin and broker both read `db_path` from `broker.toml`, so an -upgrade isn't data loss; it just points at a different SQLite file than the old `$HOME` one. -To keep your history, stop the service, move the old DB to -`/var/lib/toolstack/broker/broker.sqlite3` (and the old `broker.toml` / `vault.json` under -`/var/lib/toolstack/`), then start it. Or update `db_path` on the dashboard **Config** page to -point at wherever the old DB lives. No DB-migration framework; assume a fresh DB across a -major upgrade if you don't migrate. +Confirmation asserts offline operation; it does not detect or stop writers. +Reconcile marks received/executing work unknown without replay. Restore cancels +pending approvals, marks received/executing work unknown, revokes **ALL tokens**, +clears **ALL policies**, and rotates the installation UUID. Source DB and backup +are never overwritten; restore neither switches the running DB nor replays effects. +Post-snapshot keys/effects are unknown to a stale backup: reconcile external history +before restoring authority or replaying queues. Reissuing tokens alone restores no +grants. Broker recovery does not restore SPS/provider state; test the complete +offline backup set with fake data before operational use. diff --git a/deploy/bootstrap-sps.py b/deploy/bootstrap-sps.py new file mode 100644 index 0000000..42c9b23 --- /dev/null +++ b/deploy/bootstrap-sps.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Offline SPS bootstrap; never starts a server or provisions a secret value.""" +import argparse +import os +from pathlib import Path +import subprocess +import sys + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--config', default='/etc/toolstack/sps.env') + p.add_argument('--state-dir', default='/var/lib/toolstack') + p.add_argument('--log-dir', default='/var/log/toolstack') + args = p.parse_args() + os.umask(0o077) + config = Path(args.config).resolve() + state = Path(args.state_dir).resolve() + logs = Path(args.log_dir).resolve() + if config.exists(): + p.error('config exists; refusing to overwrite') + for directory in (config.parent, state, logs): + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + env = dict(os.environ) + env.pop('SP_VAULT_PASSPHRASE', None) + env.pop('SP_VAULT_PASSPHRASE_FILE', None) + subprocess.run([sys.executable, '-m', 'sps.cli', 'init', '--config', str(config), + '--state-dir', str(state), '--audit-log', str(logs / 'sps.audit')], + env=env, check=True) + config.chmod(0o600) + print('Bootstrap complete; set ownership and provision the vault offline before starting SPS.') + + +if __name__ == '__main__': + main() diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index a7ffec2..e5cabf3 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -1,10 +1,3 @@ -# Copy to .env (cp .env.example .env) and fill in. Read by docker-compose.yml. -# .env is gitignored; keep your real values out of git. - -# Admin login password (required). The admin won't serve without it (no default creds). -# Set once on first run; change later with the admin UI or `python -m admin set-password`. -TOOLSTACK_ADMIN_PASSWORD=change-me - -# Phase 5: secrets resolve through SPS. Configure the SPS side via -# /etc/toolstack/sps.env (see deploy/sps.env.example). The vault passphrase -# for the localfile plugin lives there, not here. +# Experimental scaffold only, not a supported SPS deployment. +# No environment-password shortcut: initialize persistent config with admin set-password. +TOOLSTACK_RUNNER=process diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 5b4a23c..e418080 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -26,9 +26,10 @@ COPY broker/ broker/ COPY toolyard/ toolyard/ COPY client/ client/ COPY admin/ admin/ +COPY sps/ sps/ +COPY toolstack_forwarder/ toolstack_forwarder/ COPY tools/ tools/ -RUN pip install --no-cache-dir -e '.[vault]' \ - && pip install --no-cache-dir -r admin/requirements.txt +RUN pip install --no-cache-dir '.[admin,vault]' -r admin/requirements.txt -c admin/requirements-lock.txt ENV XDG_CONFIG_HOME=/data/config \ XDG_STATE_HOME=/data/state \ @@ -51,4 +52,4 @@ EXPOSE 8765 8780 # The admin process is the only foreground process; it supervises the broker + tools # as subprocesses (mirroring the toolyard's ProcessRunner). -CMD ["/usr/local/bin/admin", "serve", "--host", "0.0.0.0", "--port", "8780"] +CMD ["/usr/local/bin/admin", "serve", "--port", "8780"] diff --git a/deploy/docker/README.md b/deploy/docker/README.md index 8e9d2d7..d8e68c3 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -1,80 +1,20 @@ -# Toolstack one-box (Docker, for a laptop) - -Run the whole stack (broker + admin + toolyard) in a single container on your laptop, -no home-lab server and no external secret service required. State persists in `./data`. - -This is the **laptop** path. For a server install, see [../README.md](../README.md) (systemd). - -## What's in the box - -One image runs `python -m admin serve`. The admin (a loopback web UI) supervises the -**broker** as a subprocess and runs **tools** with the **process runner**, so the broker -and tools share one container loopback. (Tools run as processes, not nested containers: -docker-out-of-docker would publish tool ports to the host loopback, which the in-container -broker couldn't reach. Less isolation, but correct and simple for one user.) - -Secrets use the **encrypted vault** ([T-025](../../toolyard/secrets.py)) by default, a -local file encrypted with your passphrase. The broker never sees a secret. - -## Quick start - -```bash -cd deploy/docker -cp .env.example .env -# edit .env: set TOOLSTACK_ADMIN_PASSWORD and TOOLSTACK_VAULT_PASSPHRASE -docker compose up -d --build -``` - -Then: - -1. Open the admin UI at **http://127.0.0.1:8780** and log in (user `admin`, your password). -2. Click **Start broker**. It comes up on `127.0.0.1:8765`. -3. **Add a caller** and mint a token (this is your agent's bearer token). -4. **Author a tool** (or use the bundled `echo`), grant the caller an op (allow / review). -5. Point your agent at the broker: `TOOLSTACK_URL=http://127.0.0.1:8765`, - `TOOLSTACK_TOKEN=` (HTTP action API, or the broker-native MCP at `POST /mcp`). - -Provision a tool's secret into the vault from inside the box: - -```bash -docker compose exec toolstack sh -c 'printf "%s" "the-secret" | toolyard vault-set ' -``` - -## The operator CLI (`brokerctl`) - -`brokerctl` (and `toolstack` / `toolyard`) live **inside** the container, not on the host, so -they won't show up under `/opt/toolstack` or an `admin/.venv`. The admin UI covers everything -`brokerctl` does, but if you want the CLI, run it in the box (its `XDG_*` already point at -`/data`, so it opens the right DB): - -```bash -docker compose exec toolstack brokerctl list-callers -docker compose exec toolstack brokerctl create-caller --name my-agent --allow echo_api.echo -``` - -## The trust boundary (important) - -Inside the container the broker and admin bind `0.0.0.0` so Docker can reach them. The -boundary is the **publish mapping**: compose publishes both ports to `127.0.0.1` on the -host *only*. Do not change the host side to `0.0.0.0`; that would expose the broker / -admin on your network. (This mirrors how tool containers already bind `0.0.0.0` + publish -to host loopback via `TOOLSTACK_BIND`.) - -> ⚠️ If you bypass compose and use `docker run`, you **must** prefix every `-p` with -> `127.0.0.1:`, e.g. `-p 127.0.0.1:8765:8765`. A bare `-p 8765:8765` defaults to -> publishing on **all** interfaces (`0.0.0.0`), exposing the broker (bearer-token-only) and -> the admin on your LAN. The compose file already does this correctly; the trap is only on -> hand-written `docker run`. - -## State & lifecycle - -Everything lives under `./data` (mounted at `/data`): admin config (password hash, -session key, broker config), the broker SQLite DB + audit, the encrypted vault, and -toolyard state. Back up `./data` to back up your whole setup. `docker compose down` -stops the box but keeps `./data`; delete `./data` to start fresh. - -## Adding your own tools - -Author tools into a directory under the volume (e.g. `/data/tools//`) so they -persist, then add that directory in the admin's **broker config** (tool dirs). A process -tool needs its code present in that directory; the panel writes its `toolyard.toml`. +# Experimental image scaffold — NOT supported SPS deployment + +Dockerfile now copies SPS and the forwarder, installs the complete pinned deployment +export, and invokes the packaged `admin serve --port 8780` (no unsupported --host +argument). This has not been built or started with a Docker daemon in this lane. + +Compose is opt-in under the `experimental` profile. It does not supervise SPS and +does not provide proven runner registration/recovery. Do not use it for the supported +release. See [runtime matrix](../../docs/runtime-matrix.md) and native deployment. +The image's process runner does not isolate hostile tools. + +Before any future Docker/SPS support claim, prove: SPS lifecycle, TLS server name/CA +matching the reachable service, explicit runner config and passphrase wiring, private +state/log volumes, offline vault provisioning, separate broker channel credentials, +restart/recovery and negative credential tests. Do not mount a Docker socket or +publish SPS publicly. Docker network egress is not loopback isolation. + +`TOOLSTACK_ADMIN_PASSWORD` is not a password bootstrap mechanism: the actual admin +requires `admin set-password` with persistent XDG config before serving. Example +env carries no real password. No default credentials are supplied. diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index bedc5cc..39aa809 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -1,8 +1,9 @@ -# Toolstack one-box for a laptop. `docker compose up -d` brings up broker + admin + -# toolyard in a single container; state persists in ./data. Copy .env.example to .env -# and set a password first. See README.md. +# Experimental scaffold only: no proven SPS wiring/lifecycle. Not supported release. +# Explicit experimental profile required; see README.md before any future test. services: toolstack: + # Explicitly opt-in: no proven SPS supervisor/runner lifecycle here. + profiles: [experimental] build: context: ../.. # the repo root (the image needs the whole stack) dockerfile: deploy/docker/Dockerfile diff --git a/deploy/install.sh b/deploy/install.sh index cc3e340..d595e65 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -9,10 +9,10 @@ # and installs + enables the systemd unit. It chowns the checkout to the service user, so # run it from where the deployment should live (e.g. /opt/toolstack), not a personal clone. # -# This gives a working basic install (process runner + file secret backend). For the docker -# runner, the encrypted vault, or Infisical, fill in /etc/toolstack/admin.env afterwards -# (see deploy/README.md) and restart the service. +# Trusted process tools only; SPS requires offline provisioning after installation. +# Supported release boundary is a dedicated Toolstack VM with agents outside it. set -euo pipefail +umask 077 [ "$(id -u)" -eq 0 ] || { echo "run as root: sudo deploy/install.sh" >&2; exit 1; } @@ -21,7 +21,7 @@ SERVICE_USER="${TOOLSTACK_USER:-toolstack}" UNIT="/etc/systemd/system/toolstack-admin.service" VENV_PY="$REPO_ROOT/admin/.venv/bin/python" VENV_PIP="$REPO_ROOT/admin/.venv/bin/pip" -# admin is run from the checkout root (it is not a pip-distributed package), so every admin +# admin service is run from the checkout root (also distributed in the wheel), so every admin # invocation below cd's there first, matching the unit's WorkingDirectory. run_admin() { sudo -u "$SERVICE_USER" env XDG_CONFIG_HOME=/var/lib XDG_STATE_HOME=/var/lib \ sh -c "cd '$REPO_ROOT' && $1"; } @@ -46,12 +46,13 @@ if [ ! -x "$VENV_PY" ]; then sudo -u "$SERVICE_USER" python3 -m venv "$REPO_ROOT/admin/.venv" fi echo "==> Installing the package + admin dependencies" -sudo -u "$SERVICE_USER" sh -c "cd '$REPO_ROOT' && '$VENV_PIP' install --quiet -e '.[vault]' -r admin/requirements.txt" +sudo -u "$SERVICE_USER" sh -c "cd '$REPO_ROOT' && '$VENV_PIP' install --quiet '.[admin,vault]' -r admin/requirements.txt -c admin/requirements-lock.txt" # 4. State + log dirs (the unit's StateDirectory/ReadWritePaths keep them; pre-create # so set-password + the SPS audit logger land right instead of crashing on first write) install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 700 /var/lib/toolstack install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 750 /var/log/toolstack +install -d -o root -g "$SERVICE_GROUP" -m 750 /etc/toolstack # 5. Admin password (the panel fails closed without one) if run_admin "'$VENV_PY' -c 'from admin import settings; raise SystemExit(0 if settings.read_password_hash() else 1)'"; then @@ -86,14 +87,14 @@ if [ -f /etc/toolstack/sps.env ]; then systemctl enable --now toolstack-sps else echo "==> /etc/toolstack/sps.env not found; enabling toolstack-sps unit without autostart." - echo " Run: sudo -u $SERVICE_USER python3 -m sps.cli init --config /etc/toolstack/sps.env" + echo " Bootstrap as root using deploy/bootstrap-sps.py; see deploy/README.md." echo " then: sudo systemctl start toolstack-sps" systemctl enable toolstack-sps fi cat < Installing the package + admin dependencies" # Editable install of the root package too, so brokerctl/toolstack/toolyard land in the venv # alongside the admin (the bootstrap + redeploy use admin/.venv/bin/brokerctl). - "$VENV_PIP" install -e '.[vault]' -r "$REPO_ROOT/admin/requirements.txt" + "$VENV_PIP" install '.[admin,vault]' -r "$REPO_ROOT/admin/requirements.txt" -c "$REPO_ROOT/admin/requirements-lock.txt" fi if [[ "$SKIP_SERVICE" -eq 0 ]]; then @@ -120,7 +121,7 @@ import os, sqlite3, sys, time from pathlib import Path db = Path(sys.argv[1]) backups = db.parent / "backups" -backups.mkdir(parents=True, exist_ok=True) +backups.mkdir(parents=True, exist_ok=True, mode=0o700) dest = backups / f"{db.stem}-{time.strftime('%Y%m%d-%H%M%S')}.sqlite3" src = sqlite3.connect(str(db)) try: diff --git a/deploy/sps.env.example b/deploy/sps.env.example index a352697..ed8b478 100644 --- a/deploy/sps.env.example +++ b/deploy/sps.env.example @@ -15,8 +15,7 @@ SP_AUDIT_LOG = "/var/log/toolstack/sps.audit" SP_PLUGIN = "localfile" [localfile] -# Path to the encrypted vault file. Will be created on first start if missing -# (and the passphrase env var below is set). +# Path to encrypted vault. Initialize/provision offline with SPS stopped. VAULT_FILE = "/var/lib/toolstack/sps.vault.json" # OR for Infisical: @@ -40,3 +39,4 @@ VAULT_FILE = "/var/lib/toolstack/sps.vault.json" #SP_VAULT_PASSPHRASE = # ...or: #SP_VAULT_PASSPHRASE_FILE = /etc/toolstack/sps.vault.passphrase +# Use an owner-owned mode-0600 file in a mode-0700 parent; canonical path, no links. diff --git a/deploy/toolstack-admin.env.example b/deploy/toolstack-admin.env.example index 083dfcf..e24599a 100644 --- a/deploy/toolstack-admin.env.example +++ b/deploy/toolstack-admin.env.example @@ -7,8 +7,11 @@ # runs with the process runner. Set what you need. # --- Toolyard: how tools run ------------------------------------------------ -# Runner for tool containers: "process" (dev) or "docker" (production). -#TOOLSTACK_RUNNER=docker +# Trusted process tools only. Docker/Seatbelt+SPS are unsupported. +TOOLSTACK_RUNNER=process +TOOLSTACK_SPS_ENV=/etc/toolstack/sps.env +TOOLSTACK_SPS_CA=/etc/toolstack/sps-ca.crt +TOOLSTACK_SPS_VERIFY=1 # --- Admin panel knobs (optional) -------------------------------------------- # Login username (default: admin). Set the password with `python -m admin set-password`. diff --git a/deploy/toolstack-admin.service b/deploy/toolstack-admin.service index 3aedf8e..8df51d2 100644 --- a/deploy/toolstack-admin.service +++ b/deploy/toolstack-admin.service @@ -59,6 +59,10 @@ RestartSec=3 # /toolstack/... itself). redeploy-toolstack parses these Environment= lines out of # this file, so the out-of-band CLI resolves the identical paths; keep them in sync. StateDirectory=toolstack +StateDirectoryMode=0700 +UMask=0077 +LogsDirectory=toolstack +LogsDirectoryMode=0700 Environment=XDG_STATE_HOME=/var/lib Environment=XDG_CONFIG_HOME=/var/lib @@ -82,17 +86,9 @@ LockPersonality=true # EDIT: point this at your install root (the same root as the Exec lines above). ReadWritePaths=/opt/toolstack -# --- Docker tool runner (only when TOOLSTACK_RUNNER=docker) -------------------- -# The runner shells out to `docker build` / `docker run`, which needs two things the -# rest of this unit otherwise denies: -# 1. The docker socket. Grant the service the docker group (uncomment the line below, -# then `systemctl daemon-reload && systemctl restart toolstack-admin`). Without it -# every build/run is "permission denied ... unix:///var/run/docker.sock". -# 2. A writable docker config. ProtectHome=true hides ~/.docker, so point DOCKER_CONFIG -# at the state dir (set unconditionally below; harmless for the process runner, it -# just stops the "Error loading config file: ~/.docker/config.json" warning). -#SupplementaryGroups=docker -Environment=DOCKER_CONFIG=/var/lib/toolstack/.docker +# Trusted process mode only. Do not grant Docker group or sudo helper access. +# Docker/Seatbelt+SPS and mutable Linux privileged helpers remain unsupported. +Environment=TOOLSTACK_RUNNER=process [Install] WantedBy=multi-user.target diff --git a/deploy/toolstack-sps.service b/deploy/toolstack-sps.service index 44d600e..68cc6ad 100644 --- a/deploy/toolstack-sps.service +++ b/deploy/toolstack-sps.service @@ -9,7 +9,8 @@ User=toolstack Group=toolstack # EDIT: absolute path to the Toolstack checkout (same root as the ExecStart line below). WorkingDirectory=/opt/toolstack -EnvironmentFile=-/etc/toolstack/sps.env +# sps.env is TOML, NOT a systemd EnvironmentFile. +EnvironmentFile=-/etc/toolstack/sps-runtime.env # EDIT: absolute path to the admin venv's python (install.sh creates it at admin/.venv; # the SPS plugin runs in the same venv so infisical/cryptography deps are available). ExecStart=/opt/toolstack/admin/.venv/bin/python -m sps.cli serve --config /etc/toolstack/sps.env @@ -17,6 +18,10 @@ Restart=on-failure RestartSec=3 StateDirectory=toolstack +StateDirectoryMode=0700 +UMask=0077 +LogsDirectory=toolstack +LogsDirectoryMode=0700 Environment=XDG_STATE_HOME=/var/lib Environment=XDG_CONFIG_HOME=/var/lib diff --git a/desktop/README.md b/desktop/README.md index 407953b..bda90fa 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -8,7 +8,7 @@ this app started is stopped; an admin you already had running is left alone. It uses [pywebview](https://pywebview.flowrl.com/): the operating system's own webview (macOS WebKit / Linux WebKit2GTK / Windows WebView2), **not** a bundled Chromium, so the -shell stays tiny. The "slick" look comes from the admin UI itself (see [T-028]). +shell stays tiny. The implemented Clear desk appearance comes from the admin UI itself. ## Run @@ -33,6 +33,8 @@ a token, exactly as in the web admin. Point your agent at the broker on `127.0.0 `Stack.stop()` stops the admin **only if this app started it**. - **Window** (pywebview, imported lazily): a single window onto the admin URL (`TOOLSTACK_ADMIN_URL`, default `http://127.0.0.1:8780`). +- **Startup failure**: a local error surface offers Retry and Close. Retry waits for + readiness; cleanup never stops an existing admin the shell does not own. Loopback only: the window talks to the admin on `127.0.0.1`; nothing new is exposed. @@ -40,11 +42,14 @@ Loopback only: the window talks to the admin on `127.0.0.1`; nothing new is expo `python3 -m desktop` is the runnable shell. To ship a double-clickable bundle, wrap it with [briefcase](https://briefcase.readthedocs.io/) or py2app; that's a distribution step, out of -scope here. For a heavier, more product-grade shell (system tray, `.dmg`, auto-update), the -Tauri path discussed on [T-027] remains an option to graduate to. +scope here. The separate [native Mac app](../macapp/README.md) has a local +development-signing recipe, not a distribution release. ## Not yet +- Trusted keyboard/Escape, VoiceOver, native webview focus and Close-during-retry + acceptance remain open. Offscreen synthetic interaction is not live acceptance. + - Auto-starting the broker (today you click **Start broker** in the window, same as the web admin and the [Docker one-box](../deploy/docker/README.md)). - A native first-run password dialog (today: the one-time `python3 -m admin set-password`). diff --git a/desktop/app.py b/desktop/app.py index 22c5845..4925dc7 100644 --- a/desktop/app.py +++ b/desktop/app.py @@ -18,9 +18,11 @@ from __future__ import annotations import os +import html import subprocess import sys import time +import threading import urllib.request DEFAULT_URL = "http://127.0.0.1:8780" @@ -78,11 +80,12 @@ def ensure_up(self, tries: int = 120, delay: float = 0.25) -> None: self._proc = subprocess.Popen(self._serve_cmd) for _ in range(tries): if self._proc.poll() is not None: # exited before it served + code = self._proc.returncode self._proc = None raise RuntimeError( - "the admin exited before it became healthy. Run `python3 -m admin serve` " - "to see why; most often no password is set yet " - "(`python3 -m admin set-password`)." + f"Admin exited with status {code} before /login responded. " + "Diagnostics: run `python3 -m admin serve` to inspect the cause. " + "If it reports an unset password, use `python3 -m admin set-password`." ) if admin_healthy(self.url): return @@ -105,13 +108,7 @@ def stop(self) -> None: def main() -> None: stack = Stack(_admin_url()) - try: - stack.ensure_up() - except RuntimeError as exc: - # A double-clicked app has no terminal, but still print for the CLI case. The usual - # cause is "no admin password set"; that message says exactly what to run. - print(f"toolstack-desktop: {exc}", file=sys.stderr) - raise SystemExit(1) + bridge = None try: import webview # lazy: the one place the GUI dependency is needed except ModuleNotFoundError: @@ -120,10 +117,71 @@ def main() -> None: # create_window is inside the finally too: if opening the window fails (e.g. no GUI # session), still stop the admin we started rather than orphaning it. try: - webview.create_window(WINDOW_TITLE, stack.url, width=1180, height=820) + try: + stack.ensure_up() + except (RuntimeError, OSError) as exc: + bridge = StartupRecovery(stack) + bridge.window = webview.create_window( + "Toolstack · Startup", html=startup_error(stack.url, str(exc)), + js_api=bridge, width=620, height=360) + else: + webview.create_window(WINDOW_TITLE, stack.url, width=1180, height=820) webview.start() # blocks until the window is closed finally: - stack.stop() # stop the admin we started; an already-running one is untouched + if bridge is not None: + bridge.shutdown() + else: + stack.stop() # an already-running admin is untouched + + +def startup_error(url: str, cause: str) -> str: + """Local error surface; no admin or policy backend is needed to render it.""" + return f"""Startup unavailable + +

Admin unavailable

{html.escape(url)}

+

{html.escape(cause)}

+

Diagnostics: run python3 -m admin serve to inspect startup output.

+ + + """ + + +class StartupRecovery: + def __init__(self, stack: Stack) -> None: + self.stack = stack + self.window = None + self._retry_lock = threading.Lock() + self._closed = threading.Event() + + def close(self) -> None: + self._closed.set() + self.window.destroy() + + def shutdown(self) -> None: + self._closed.set() + # A retry owns startup until it completes; never stop concurrently with its spawn. + with self._retry_lock: + self.stack.stop() + + def retry(self) -> None: + if not self._retry_lock.acquire(blocking=False): + return + try: + if self._closed.is_set(): + return + try: + self.stack.ensure_up() + except (RuntimeError, OSError) as exc: + if not self._closed.is_set(): + self.window.load_html(startup_error(self.stack.url, str(exc))) + else: + if not self._closed.is_set(): + self.window.resize(1180, 820) + self.window.load_url(self.stack.url) + finally: + self._retry_lock.release() if __name__ == "__main__": diff --git a/desktop/tests/test_app.py b/desktop/tests/test_app.py index 393e5cb..078453d 100644 --- a/desktop/tests/test_app.py +++ b/desktop/tests/test_app.py @@ -11,6 +11,7 @@ from unittest import mock from desktop.app import DEFAULT_URL, Stack, _admin_url, _port_of, admin_healthy +from desktop.app import main, StartupRecovery, startup_error def _free_port() -> int: @@ -116,5 +117,66 @@ def test_env_override(self): self.assertEqual(_admin_url(), "http://127.0.0.1:8799") +class StartupSurface(unittest.TestCase): + def test_retry_latch_and_close_during_startup(self): + entered, release = threading.Event(), threading.Event() + stack = mock.Mock(url='http://127.0.0.1:1') + def wait_for_release(): + entered.set() + if not release.wait(3): + raise RuntimeError('test release timeout') + stack.ensure_up.side_effect = wait_for_release + bridge = StartupRecovery(stack) + bridge.window = mock.Mock() + worker = threading.Thread(target=bridge.retry) + worker.start() + try: + self.assertTrue(entered.wait(2)) + bridge.retry() + stack.ensure_up.assert_called_once() + bridge.close() + finally: + release.set() + worker.join(3) + bridge.shutdown() + self.assertFalse(worker.is_alive()) + bridge.window.load_url.assert_not_called() + stack.stop.assert_called_once() + bridge.retry() + stack.ensure_up.assert_called_once() + + def test_double_click_failure_renders_before_main_and_cleans_up(self): + stack = mock.Mock(url="http://127.0.0.1:1") + stack.ensure_up.side_effect = RuntimeError("exit status 7") + webview = mock.Mock() + with mock.patch("desktop.app.Stack", return_value=stack), mock.patch.dict( + sys.modules, {"webview": webview}): + main() + args, kwargs = webview.create_window.call_args + self.assertEqual(args, ("Toolstack · Startup",)) + self.assertIn(stack.url, kwargs["html"]) + self.assertIn("exit status 7", kwargs["html"]) + self.assertIn("Diagnostics", kwargs["html"]) + self.assertIn("Retry", kwargs["html"]) + webview.start.assert_called_once() + stack.stop.assert_called_once() + + def test_retry_failure_stays_local_then_success_opens_admin(self): + stack = mock.Mock(url="http://127.0.0.1:1") + stack.ensure_up.side_effect = [OSError("unavailable"), None] + bridge = StartupRecovery(stack) + bridge.window = mock.Mock() + bridge.retry() + bridge.window.load_url.assert_not_called() + self.assertIn("unavailable", bridge.window.load_html.call_args.args[0]) + bridge.retry() + bridge.window.load_url.assert_called_once_with(stack.url) + bridge.close() + bridge.window.destroy.assert_called_once() + + def test_error_is_escaped(self): + self.assertNotIn("''' + html=html.replace('STYLE',style).replace('SCREENS',json.dumps(screens).replace('Toolstack comparison · revision 02

Toolstack / Three directions

Recommendation: 01 · Clear desk · Revision 02 · Selection pending

Full desktop-size gallery · all 81 frames

' + for d,(name,merit,trade) in DIRECTIONS.items(): + board+=f'

{E(name)}

{E(merit)}
Tradeoff: {E(trade)}

' + for s,f,n,label in [('web','grant',1,'Admin web · rejected policy save'),('shell','add',2,'Desktop shell · broker restart'),('mac','recover',0,'Native Mac · crashed tool')]: + key=f'{d}-{s}-{f}-{n}';board+=f'
{screens[key]}
{label} ↗
' + board+='
' + board+='
'; (ROOT/'overview.html').write_text(board) + print('Revision 02: 81 SVGs, self-contained gallery and comparison overview') + +if __name__=='__main__':build() diff --git a/docs/ui-directions/capture.py b/docs/ui-directions/capture.py new file mode 100644 index 0000000..32fa72c --- /dev/null +++ b/docs/ui-directions/capture.py @@ -0,0 +1,37 @@ +"""Optional offscreen evidence using already-installed Chrome; no services or app launch.""" +from pathlib import Path +import subprocess +import tempfile +import json + +ROOT = Path(__file__).resolve().parent +CHROME = Path('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') +if not CHROME.exists(): + raise SystemExit('No installed renderer; use the standalone SVGs. Do not install one.') +captures = ROOT / 'captures' +captures.mkdir(exist_ok=True) +results = [] +with tempfile.TemporaryDirectory(prefix='toolstack-ui-', dir='/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode') as profile: + base = [str(CHROME), '--headless', '--disable-gpu', '--no-first-run', '--use-mock-keychain', '--password-store=basic', + '--no-default-browser-check', '--disable-background-networking', + '--disable-component-update', '--disable-sync', '--hide-scrollbars', + f'--user-data-dir={profile}', '--window-size=1440,900'] + for direction in 'abc': + for surface, flow, step in [('web','grant',1), ('shell','add',2), ('mac','recover',0)]: + name = f'{direction}-{surface}-{flow}-{step}' + result = subprocess.run(base + [f'--screenshot={captures / (name + ".png")}', + (ROOT / 'visuals' / (name + '.svg')).as_uri()], capture_output=True, text=True, timeout=25) + results.append({'frame':name, 'returncode':result.returncode, + 'png_exists':(captures / (name + '.png')).exists(), + 'stderr':result.stderr}) + # Exercise initial hash routing and JS at the gallery level, still file-only. + result = subprocess.run(base + ['--dump-dom', (ROOT/'gallery.html').as_uri()+'#c-mac-add-0'], + capture_output=True, text=True, timeout=25) + (ROOT/'evidence'/'gallery-dom.html').write_text(result.stdout) + results.append({'gallery_returncode':result.returncode, + 'gallery_populated': '
{ + const b=e.getBBox();return {id:e.dataset.id,parent:e.dataset.parent,kind:e.dataset.kind, + text:e.tagName==='text'?e.textContent:'',x:b.x,y:b.y,w:b.width,h:b.height};}); + const map=Object.fromEntries(nodes.map(n=>[n.id,n])); + const ancestor=(a,b)=>{let p=b.parent;while(p){if(p===a.id)return true;p=map[p]?.parent}return false}; + const inside=(a,b)=>a.x>=b.x-.5&&a.y>=b.y-.5&&a.x+a.w<=b.x+b.w+.5&&a.y+a.h<=b.y+b.h+.5; + const errors=[]; + for(const a of nodes){ + if(!inside(a,{x:0,y:0,w:1440,h:900}))errors.push({type:'viewport overflow',a}); + if(a.parent&&!inside(a,map[a.parent]))errors.push({type:'parent overflow',a,parent:map[a.parent]}); + } + for(let i=0;i.5&&dy>.5)errors.push({type:'unallowed overlap',a,b,dx,dy}); + } + return {nodes,errors}; +} +for(const [key,source] of Object.entries(frames)){ + document.querySelector('#stage').innerHTML=source; + const {nodes,errors}=check(document.querySelector('svg')); + results.push({frame:key,elements:nodes.length,errors,geometry:nodes}); + if(errors.length)failures.push(key); +} +// Regression: a root-level footer crosses a rail. It must be rejected. +document.querySelector('#stage').innerHTML='Native adaptation footer overlaps the rail'; +const regression=check(document.querySelector('svg')).errors.some(e=>e.type==='unallowed overlap'); +document.querySelector('#result').textContent=JSON.stringify({frames:results.length,failures,regression_footer_detected:regression,allowed_overlays:'Explicit ancestor/descendant containment only; edge tolerance 0.5px',results}); +document.querySelector('#stage').remove(); +''' +page='
'
+with tempfile.TemporaryDirectory(prefix='toolstack-geometry-',dir='/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode') as tmp:
+ p=Path(tmp);(p/'check.html').write_text(page)
+ cmd=['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome','--headless','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking','--disable-component-update','--disable-sync',f'--user-data-dir={p/"profile"}','--dump-dom',(p/'check.html').as_uri()]
+ run=subprocess.run(cmd,capture_output=True,text=True,timeout=40)
+ (ROOT/'evidence/geometry-renderer.log').write_text(run.stderr)
+ assert run.returncode==0
+ match=re.search(r'
(.*?)
',run.stdout,re.S) + assert match,'No geometry output' + result=json.loads(html.unescape(match.group(1))) + (ROOT/'evidence/geometry.json').write_text(json.dumps(result,indent=2)) + print(json.dumps({k:v for k,v in result.items() if k!='results'},indent=2)) + for frame in result['results']: + if frame['errors']:print(frame['frame'],json.dumps(frame['errors'][:3])) + assert result['frames']==81 and not result['failures'] and result['regression_footer_detected'] diff --git a/docs/ui-directions/evidence/README.md b/docs/ui-directions/evidence/README.md new file mode 100644 index 0000000..a37c305 --- /dev/null +++ b/docs/ui-directions/evidence/README.md @@ -0,0 +1,12 @@ +# Evidence index — revision 02 + +- `geometry.json`: all 81 current frames, every rendered element rectangle, explicit parent hierarchy, zero violations, detected synthetic footer regression. +- `geometry-renderer.log`: existing headless Chrome diagnostics; successful geometry completion is recorded separately. +- `verification.json`: current static checks and SHA-256 fingerprints of all nine visually inspected PNGs. +- `render-results.json`: current nine PNG render results and gallery hash-route check. +- `gallery-dom.html`: current offscreen gallery DOM, not a running Toolstack page. +- `screens.json`: current SVGs used as geometry input. +- `revision-02-mockups.json`: current bundled Norman collector facts; static scanner is not visual proof. +- `admin.json`, `desktop.json`, `macapp.json`: original source collector results at unchanged source baseline `06df1b1`; still source-only, not new live checks. + +Earlier mockup/capture evidence lives only in `../iterations/01-superseded.zip`. No image in current `../captures/` is an iteration-01 capture. macOS display-link/task-policy warnings in headless logs did not prevent generation; they are not evidence of product rendering faults. diff --git a/docs/ui-directions/evidence/admin.json b/docs/ui-directions/evidence/admin.json new file mode 100644 index 0000000..0aa92c1 --- /dev/null +++ b/docs/ui-directions/evidence/admin.json @@ -0,0 +1,19 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 1, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [] + } +} diff --git a/docs/ui-directions/evidence/desktop.json b/docs/ui-directions/evidence/desktop.json new file mode 100644 index 0000000..a114e76 --- /dev/null +++ b/docs/ui-directions/evidence/desktop.json @@ -0,0 +1,19 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 0, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [] + } +} diff --git a/docs/ui-directions/evidence/gallery-dom.html b/docs/ui-directions/evidence/gallery-dom.html new file mode 100644 index 0000000..58a776f --- /dev/null +++ b/docs/ui-directions/evidence/gallery-dom.html @@ -0,0 +1,2 @@ + +Toolstack directions · revision 02

Toolstack / Directions · revision 02

Comparison overview

Explicit task sequence. Tradeoff: More stage navigation for experts.

03 · Guided ledger / mac / add / 0Toolstack OperatorDesign mockup · 02Local folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel
diff --git a/docs/ui-directions/evidence/geometry-renderer.log b/docs/ui-directions/evidence/geometry-renderer.log new file mode 100644 index 0000000..7b6f86f --- /dev/null +++ b/docs/ui-directions/evidence/geometry-renderer.log @@ -0,0 +1,12 @@ +[89270:42609794:1024/033837.974400:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +Trying to load the allocator multiple times. This is *not* supported. +[89270:42609794:1024/033837.976043:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[89270:42609794:1024/033837.976126:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[89270:42609794:1024/033837.976175:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[89270:42609794:1024/033837.976225:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[89270:42609794:1024/033837.976277:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[89270:42609794:1024/033837.976334:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670 +[89264:42609742:1024/033838.099667:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4) +[89264:42609742:1024/033838.099678:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4) +[89264:42609742:1024/033838.117080:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4) +[89264:42609742:1024/033838.117089:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4) diff --git a/docs/ui-directions/evidence/geometry.json b/docs/ui-directions/evidence/geometry.json new file mode 100644 index 0000000..50da042 --- /dev/null +++ b/docs/ui-directions/evidence/geometry.json @@ -0,0 +1,27395 @@ +{ + "frames": 81, + "failures": [], + "regression_footer_detected": true, + "allowed_overlays": "Explicit ancestor/descendant containment only; edge tolerance 0.5px", + "results": [ + { + "frame": "a-web-grant-0", + "elements": 27, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 96, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 96, + "y": 154, + "w": 252.046875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 96, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 192 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Tool access", + "x": 134, + "y": 271, + "w": 80.390625, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 780, + "y": 271, + "w": 170.859375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Operation policy", + "x": 134, + "y": 314, + "w": 112.59375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Not granted", + "x": 780, + "y": 314, + "w": 82.09375, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 373, + "w": 180, + "h": 38 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Enable notes-demo", + "x": 132, + "y": 383, + "w": 146.296875, + "h": 18 + }, + { + "id": "e26", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 373, + "w": 144, + "h": 38 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Cancel", + "x": 326, + "y": 383, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "a-web-grant-1", + "elements": 34, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 96, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 96, + "y": 154, + "w": 77.828125, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 96, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 291 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 256, + "w": 1200, + "h": 42 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 134, + "y": 267, + "w": 200.046875, + "h": 18 + }, + { + "id": "e20", + "parent": "e17", + "kind": "text", + "text": "read_note", + "x": 120, + "y": 323, + "w": 77.65625, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 340, + "y": 314, + "w": 136, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Deny", + "x": 352, + "y": 324, + "w": 38.63128662109375, + "h": 18.2421875 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 485, + "y": 314, + "w": 136, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "Human review", + "x": 497, + "y": 324, + "w": 106.870361328125, + "h": 18 + }, + { + "id": "e25", + "parent": "e17", + "kind": "control", + "text": "", + "x": 630, + "y": 314, + "w": 136, + "h": 38 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Allow", + "x": 642, + "y": 324, + "w": 40.917236328125, + "h": 18 + }, + { + "id": "e27", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 367, + "w": 1200, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "write_note", + "x": 134, + "y": 380, + "w": 72.59375, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Deny", + "x": 780, + "y": 380, + "w": 35.140625, + "h": 18 + }, + { + "id": "e30", + "parent": "e17", + "kind": "text", + "text": "Unsaved changes", + "x": 120, + "y": 425, + "w": 114.6875, + "h": 17 + }, + { + "id": "e31", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 472, + "w": 180, + "h": 38 + }, + { + "id": "e32", + "parent": "e31", + "kind": "text", + "text": "Retry save", + "x": 132, + "y": 482, + "w": 78.703125, + "h": 18.2421875 + }, + { + "id": "e33", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 472, + "w": 144, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Keep editing", + "x": 326, + "y": 482, + "w": 93.671875, + "h": 18.24609375 + } + ] + }, + { + "frame": "a-web-grant-2", + "elements": 25, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 96, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 96, + "y": 154, + "w": 163.921875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 96, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 190 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "read_note", + "x": 134, + "y": 271, + "w": 69.734375, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 780, + "y": 271, + "w": 133.265625, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "write_note", + "x": 134, + "y": 314, + "w": 72.59375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Deny", + "x": 780, + "y": 314, + "w": 35.140625, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 371, + "w": 180, + "h": 38 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Back to callers", + "x": 132, + "y": 381, + "w": 109.5625, + "h": 18 + } + ] + }, + { + "frame": "a-web-add-0", + "elements": 27, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 96, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 96, + "y": 154, + "w": 222.296875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 96, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 286 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Folder path on admin host", + "x": 120, + "y": 258, + "w": 184.671875, + "h": 18 + }, + { + "id": "e19", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 286, + "w": 1200, + "h": 42 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 132, + "y": 298, + "w": 224.609375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "text", + "text": "Copy into managed tools", + "x": 120, + "y": 343, + "w": 170.53125, + "h": 18 + }, + { + "id": "e22", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 415, + "w": 190, + "h": 38 + }, + { + "id": "e23", + "parent": "e22", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 132, + "y": 425, + "w": 133.46875, + "h": 18.08203125 + }, + { + "id": "e24", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 467, + "w": 180, + "h": 38 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Add from folder", + "x": 132, + "y": 477, + "w": 117.6484375, + "h": 18 + }, + { + "id": "e26", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 467, + "w": 144, + "h": 38 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Cancel", + "x": 326, + "y": 477, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "a-web-add-1", + "elements": 29, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 96, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 96, + "y": 154, + "w": 261.484375, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 96, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 276 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Folder", + "x": 134, + "y": 271, + "w": 43.34375, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Added", + "x": 780, + "y": 271, + "w": 44.71875, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Process", + "x": 134, + "y": 314, + "w": 54.765625, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Stopped", + "x": 780, + "y": 314, + "w": 58.21875, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 344, + "w": 1200, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Broker catalog", + "x": 134, + "y": 357, + "w": 99.40625, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Not registered", + "x": 780, + "y": 357, + "w": 98.265625, + "h": 18 + }, + { + "id": "e27", + "parent": "e17", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 120, + "y": 408, + "w": 238.046875, + "h": 18 + }, + { + "id": "e28", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 457, + "w": 180, + "h": 38 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Start weather-demo", + "x": 132, + "y": 467, + "w": 151.1875, + "h": 18 + } + ] + }, + { + "frame": "a-web-add-2", + "elements": 29, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 96, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 96, + "y": 154, + "w": 266.25, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 96, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 254 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Broker", + "x": 134, + "y": 271, + "w": 44.875, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 780, + "y": 271, + "w": 97.265625, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Catalog update", + "x": 134, + "y": 314, + "w": 104.78125, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "weather-demo", + "x": 780, + "y": 314, + "w": 100.109375, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 363, + "w": 1200, + "h": 42 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 134, + "y": 374, + "w": 303.859375, + "h": 18 + }, + { + "id": "e26", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 435, + "w": 210, + "h": 38 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Restart local broker", + "x": 132, + "y": 445, + "w": 146.869140625, + "h": 18 + }, + { + "id": "e28", + "parent": "e17", + "kind": "control", + "text": "", + "x": 344, + "y": 435, + "w": 144, + "h": 38 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Not now", + "x": 356, + "y": 445, + "w": 62.261016845703125, + "h": 18 + } + ] + }, + { + "frame": "a-web-recover-0", + "elements": 28, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 96, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 96, + "y": 154, + "w": 376.046875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 96, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 234 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Process", + "x": 134, + "y": 271, + "w": 54.765625, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 780, + "y": 271, + "w": 110.328125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Runner record", + "x": 134, + "y": 314, + "w": 97.546875, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Running", + "x": 780, + "y": 314, + "w": 56.015625, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 120, + "y": 360, + "w": 168.484375, + "h": 18 + }, + { + "id": "e25", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 415, + "w": 180, + "h": 38 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Review restart", + "x": 132, + "y": 425, + "w": 107.8125, + "h": 18 + }, + { + "id": "e27", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 415, + "w": 144, + "h": 38 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Refresh state", + "x": 326, + "y": 425, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "a-web-recover-1", + "elements": 27, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 96, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 96, + "y": 154, + "w": 274.125, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 96, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 250 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Runner", + "x": 134, + "y": 271, + "w": 48.9375, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Process", + "x": 780, + "y": 271, + "w": 54.765625, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 321, + "w": 1200, + "h": 66 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 134, + "y": 332, + "w": 332.609375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 134, + "y": 356, + "w": 241.375, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 431, + "w": 180, + "h": 38 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Restart notes-demo", + "x": 132, + "y": 441, + "w": 150.375, + "h": 18 + }, + { + "id": "e26", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 431, + "w": 144, + "h": 38 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Cancel", + "x": 326, + "y": 441, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "a-web-recover-2", + "elements": 23, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 96, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 96, + "y": 154, + "w": 339.171875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 96, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 228 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 66 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 134, + "y": 269, + "w": 251.890625, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 134, + "y": 293, + "w": 303.203125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 120, + "y": 349, + "w": 260.96875, + "h": 18 + }, + { + "id": "e22", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 409, + "w": 180, + "h": 38 + }, + { + "id": "e23", + "parent": "e22", + "kind": "text", + "text": "Refresh state", + "x": 132, + "y": 419, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "a-shell-grant-0", + "elements": 27, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 96, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 96, + "y": 154, + "w": 252.046875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 96, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 192 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Tool access", + "x": 134, + "y": 271, + "w": 80.390625, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 780, + "y": 271, + "w": 170.859375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Operation policy", + "x": 134, + "y": 314, + "w": 112.59375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Not granted", + "x": 780, + "y": 314, + "w": 82.09375, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 373, + "w": 180, + "h": 38 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Enable notes-demo", + "x": 132, + "y": 383, + "w": 146.296875, + "h": 18 + }, + { + "id": "e26", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 373, + "w": 144, + "h": 38 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Cancel", + "x": 326, + "y": 383, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "a-shell-grant-1", + "elements": 34, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 96, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 96, + "y": 154, + "w": 77.828125, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 96, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 291 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 256, + "w": 1200, + "h": 42 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 134, + "y": 267, + "w": 200.046875, + "h": 18 + }, + { + "id": "e20", + "parent": "e17", + "kind": "text", + "text": "read_note", + "x": 120, + "y": 323, + "w": 77.65625, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 340, + "y": 314, + "w": 136, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Deny", + "x": 352, + "y": 324, + "w": 38.63128662109375, + "h": 18.2421875 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 485, + "y": 314, + "w": 136, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "Human review", + "x": 497, + "y": 324, + "w": 106.870361328125, + "h": 18 + }, + { + "id": "e25", + "parent": "e17", + "kind": "control", + "text": "", + "x": 630, + "y": 314, + "w": 136, + "h": 38 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Allow", + "x": 642, + "y": 324, + "w": 40.917236328125, + "h": 18 + }, + { + "id": "e27", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 367, + "w": 1200, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "write_note", + "x": 134, + "y": 380, + "w": 72.59375, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Deny", + "x": 780, + "y": 380, + "w": 35.140625, + "h": 18 + }, + { + "id": "e30", + "parent": "e17", + "kind": "text", + "text": "Unsaved changes", + "x": 120, + "y": 425, + "w": 114.6875, + "h": 17 + }, + { + "id": "e31", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 472, + "w": 180, + "h": 38 + }, + { + "id": "e32", + "parent": "e31", + "kind": "text", + "text": "Retry save", + "x": 132, + "y": 482, + "w": 78.703125, + "h": 18.2421875 + }, + { + "id": "e33", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 472, + "w": 144, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Keep editing", + "x": 326, + "y": 482, + "w": 93.671875, + "h": 18.24609375 + } + ] + }, + { + "frame": "a-shell-grant-2", + "elements": 25, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 96, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 96, + "y": 154, + "w": 163.921875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 96, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 190 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "read_note", + "x": 134, + "y": 271, + "w": 69.734375, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 780, + "y": 271, + "w": 133.265625, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "write_note", + "x": 134, + "y": 314, + "w": 72.59375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Deny", + "x": 780, + "y": 314, + "w": 35.140625, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 371, + "w": 180, + "h": 38 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Back to callers", + "x": 132, + "y": 381, + "w": 109.5625, + "h": 18 + } + ] + }, + { + "frame": "a-shell-add-0", + "elements": 27, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 96, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 96, + "y": 154, + "w": 222.296875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 96, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 286 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Folder path on admin host", + "x": 120, + "y": 258, + "w": 184.671875, + "h": 18 + }, + { + "id": "e19", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 286, + "w": 1200, + "h": 42 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 132, + "y": 298, + "w": 224.609375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "text", + "text": "Copy into managed tools", + "x": 120, + "y": 343, + "w": 170.53125, + "h": 18 + }, + { + "id": "e22", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 415, + "w": 190, + "h": 38 + }, + { + "id": "e23", + "parent": "e22", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 132, + "y": 425, + "w": 133.46875, + "h": 18.08203125 + }, + { + "id": "e24", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 467, + "w": 180, + "h": 38 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Add from folder", + "x": 132, + "y": 477, + "w": 117.6484375, + "h": 18 + }, + { + "id": "e26", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 467, + "w": 144, + "h": 38 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Cancel", + "x": 326, + "y": 477, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "a-shell-add-1", + "elements": 29, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 96, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 96, + "y": 154, + "w": 261.484375, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 96, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 276 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Folder", + "x": 134, + "y": 271, + "w": 43.34375, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Added", + "x": 780, + "y": 271, + "w": 44.71875, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Process", + "x": 134, + "y": 314, + "w": 54.765625, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Stopped", + "x": 780, + "y": 314, + "w": 58.21875, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 344, + "w": 1200, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Broker catalog", + "x": 134, + "y": 357, + "w": 99.40625, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Not registered", + "x": 780, + "y": 357, + "w": 98.265625, + "h": 18 + }, + { + "id": "e27", + "parent": "e17", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 120, + "y": 408, + "w": 238.046875, + "h": 18 + }, + { + "id": "e28", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 457, + "w": 180, + "h": 38 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Start weather-demo", + "x": 132, + "y": 467, + "w": 151.1875, + "h": 18 + } + ] + }, + { + "frame": "a-shell-add-2", + "elements": 29, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 96, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 96, + "y": 154, + "w": 266.25, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 96, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 254 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Broker", + "x": 134, + "y": 271, + "w": 44.875, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 780, + "y": 271, + "w": 97.265625, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Catalog update", + "x": 134, + "y": 314, + "w": 104.78125, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "weather-demo", + "x": 780, + "y": 314, + "w": 100.109375, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 363, + "w": 1200, + "h": 42 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 134, + "y": 374, + "w": 303.859375, + "h": 18 + }, + { + "id": "e26", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 435, + "w": 210, + "h": 38 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Restart local broker", + "x": 132, + "y": 445, + "w": 146.869140625, + "h": 18 + }, + { + "id": "e28", + "parent": "e17", + "kind": "control", + "text": "", + "x": 344, + "y": 435, + "w": 144, + "h": 38 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Not now", + "x": 356, + "y": 445, + "w": 62.261016845703125, + "h": 18 + } + ] + }, + { + "frame": "a-shell-recover-0", + "elements": 28, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 96, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 96, + "y": 154, + "w": 376.046875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 96, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 234 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Process", + "x": 134, + "y": 271, + "w": 54.765625, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 780, + "y": 271, + "w": 110.328125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 301, + "w": 1200, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Runner record", + "x": 134, + "y": 314, + "w": 97.546875, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Running", + "x": 780, + "y": 314, + "w": 56.015625, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 120, + "y": 360, + "w": 168.484375, + "h": 18 + }, + { + "id": "e25", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 415, + "w": 180, + "h": 38 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Review restart", + "x": 132, + "y": 425, + "w": 107.8125, + "h": 18 + }, + { + "id": "e27", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 415, + "w": 144, + "h": 38 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Refresh state", + "x": 326, + "y": 425, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "a-shell-recover-1", + "elements": 27, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 96, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 96, + "y": 154, + "w": 274.125, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 96, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 250 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 43 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Runner", + "x": 134, + "y": 271, + "w": 48.9375, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Process", + "x": 780, + "y": 271, + "w": 54.765625, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 321, + "w": 1200, + "h": 66 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 134, + "y": 332, + "w": 332.609375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 134, + "y": 356, + "w": 241.375, + "h": 18 + }, + { + "id": "e24", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 431, + "w": 180, + "h": 38 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Restart notes-demo", + "x": 132, + "y": 441, + "w": 150.375, + "h": 18 + }, + { + "id": "e26", + "parent": "e17", + "kind": "control", + "text": "", + "x": 314, + "y": 431, + "w": 144, + "h": 38 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Cancel", + "x": 326, + "y": 441, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "a-shell-recover-2", + "elements": 23, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 96, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e15", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 96, + "y": 154, + "w": 339.171875, + "h": 33 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 96, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 96, + "y": 238, + "w": 1248, + "h": 228 + }, + { + "id": "e18", + "parent": "e17", + "kind": "container", + "text": "", + "x": 120, + "y": 258, + "w": 1200, + "h": 66 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 134, + "y": 269, + "w": 251.890625, + "h": 18 + }, + { + "id": "e20", + "parent": "e18", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 134, + "y": 293, + "w": 303.203125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 120, + "y": 349, + "w": 260.96875, + "h": 18 + }, + { + "id": "e22", + "parent": "e17", + "kind": "control", + "text": "", + "x": 120, + "y": 409, + "w": 180, + "h": 38 + }, + { + "id": "e23", + "parent": "e22", + "kind": "text", + "text": "Refresh state", + "x": 132, + "y": 419, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "a-mac-grant-0", + "elements": 30, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 302, + "y": 153, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 302, + "y": 185, + "w": 224.625, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 302, + "y": 227, + "w": 262.40625, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 192 + }, + { + "id": "e21", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 286, + "w": 788, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Tool access", + "x": 340, + "y": 299, + "w": 80.390625, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 759.4000244140625, + "y": 299, + "w": 170.859375, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 329, + "w": 788, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Operation policy", + "x": 340, + "y": 342, + "w": 112.59375, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Not granted", + "x": 759.4000244140625, + "y": 342, + "w": 82.09375, + "h": 18 + }, + { + "id": "e27", + "parent": "e20", + "kind": "control", + "text": "", + "x": 934, + "y": 401, + "w": 180, + "h": 32 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Enable notes-demo", + "x": 946, + "y": 409, + "w": 130.109375, + "h": 17 + }, + { + "id": "e29", + "parent": "e20", + "kind": "control", + "text": "", + "x": 776, + "y": 401, + "w": 144, + "h": 32 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Cancel", + "x": 788, + "y": 409, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "a-mac-grant-1", + "elements": 37, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 302, + "y": 153, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 302, + "y": 185, + "w": 69.328125, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 302, + "y": 227, + "w": 262.40625, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 291 + }, + { + "id": "e21", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 284, + "w": 788, + "h": 42 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 340, + "y": 295, + "w": 200.046875, + "h": 18 + }, + { + "id": "e23", + "parent": "e20", + "kind": "text", + "text": "read_note", + "x": 326, + "y": 351, + "w": 77.65625, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "control", + "text": "", + "x": 546, + "y": 342, + "w": 136, + "h": 32 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Deny", + "x": 558, + "y": 350, + "w": 34.1715087890625, + "h": 17 + }, + { + "id": "e26", + "parent": "e20", + "kind": "control", + "text": "", + "x": 691, + "y": 342, + "w": 136, + "h": 32 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Human review", + "x": 703, + "y": 350, + "w": 94.875, + "h": 17 + }, + { + "id": "e28", + "parent": "e20", + "kind": "control", + "text": "", + "x": 836, + "y": 342, + "w": 136, + "h": 32 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Allow", + "x": 848, + "y": 350, + "w": 36.3125, + "h": 17 + }, + { + "id": "e30", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 395, + "w": 788, + "h": 43 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "write_note", + "x": 340, + "y": 408, + "w": 72.59375, + "h": 18 + }, + { + "id": "e32", + "parent": "e30", + "kind": "text", + "text": "Deny", + "x": 759.4000244140625, + "y": 408, + "w": 35.140625, + "h": 18 + }, + { + "id": "e33", + "parent": "e20", + "kind": "text", + "text": "Unsaved changes", + "x": 326, + "y": 453, + "w": 114.6875, + "h": 17 + }, + { + "id": "e34", + "parent": "e20", + "kind": "control", + "text": "", + "x": 934, + "y": 500, + "w": 180, + "h": 32 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Retry save", + "x": 946, + "y": 508, + "w": 70.09375, + "h": 17 + }, + { + "id": "e36", + "parent": "e20", + "kind": "control", + "text": "", + "x": 776, + "y": 500, + "w": 144, + "h": 32 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Keep editing", + "x": 788, + "y": 508, + "w": 83.4375, + "h": 17 + } + ] + }, + { + "frame": "a-mac-grant-2", + "elements": 28, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 302, + "y": 153, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 302, + "y": 185, + "w": 145.953125, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 302, + "y": 227, + "w": 262.40625, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 190 + }, + { + "id": "e21", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 286, + "w": 788, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "read_note", + "x": 340, + "y": 299, + "w": 69.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 759.4000244140625, + "y": 299, + "w": 133.265625, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 329, + "w": 788, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "write_note", + "x": 340, + "y": 342, + "w": 72.59375, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Deny", + "x": 759.4000244140625, + "y": 342, + "w": 35.140625, + "h": 18 + }, + { + "id": "e27", + "parent": "e20", + "kind": "control", + "text": "", + "x": 934, + "y": 399, + "w": 180, + "h": 32 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Back to callers", + "x": 946, + "y": 407, + "w": 97.703125, + "h": 17 + } + ] + }, + { + "frame": "a-mac-add-0", + "elements": 30, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 302, + "y": 153, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 302, + "y": 185, + "w": 198.15625, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 302, + "y": 227, + "w": 194.53125, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 286 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "Folder path on admin host", + "x": 326, + "y": 286, + "w": 184.671875, + "h": 18 + }, + { + "id": "e22", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 314, + "w": 788, + "h": 42 + }, + { + "id": "e23", + "parent": "e22", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 338, + "y": 326, + "w": 224.609375, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "text", + "text": "Copy into managed tools", + "x": 326, + "y": 371, + "w": 170.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e20", + "kind": "control", + "text": "", + "x": 326, + "y": 443, + "w": 190, + "h": 32 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 338, + "y": 451, + "w": 118.640625, + "h": 17 + }, + { + "id": "e27", + "parent": "e20", + "kind": "control", + "text": "", + "x": 934, + "y": 495, + "w": 180, + "h": 32 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Add from folder", + "x": 946, + "y": 503, + "w": 104.6650390625, + "h": 17 + }, + { + "id": "e29", + "parent": "e20", + "kind": "control", + "text": "", + "x": 776, + "y": 495, + "w": 144, + "h": 32 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Cancel", + "x": 788, + "y": 503, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "a-mac-add-1", + "elements": 32, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 302, + "y": 153, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 302, + "y": 185, + "w": 233.015625, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 302, + "y": 227, + "w": 194.53125, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 276 + }, + { + "id": "e21", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 286, + "w": 788, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Folder", + "x": 340, + "y": 299, + "w": 43.34375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Added", + "x": 759.4000244140625, + "y": 299, + "w": 44.71875, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 329, + "w": 788, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Process", + "x": 340, + "y": 342, + "w": 54.765625, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Stopped", + "x": 759.4000244140625, + "y": 342, + "w": 58.21875, + "h": 18 + }, + { + "id": "e27", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 372, + "w": 788, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Broker catalog", + "x": 340, + "y": 385, + "w": 99.40625, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Not registered", + "x": 759.4000244140625, + "y": 385, + "w": 98.265625, + "h": 18 + }, + { + "id": "e30", + "parent": "e20", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 326, + "y": 436, + "w": 238.046875, + "h": 18 + }, + { + "id": "e31", + "parent": "e20", + "kind": "control", + "text": "", + "x": 934, + "y": 485, + "w": 180, + "h": 32 + }, + { + "id": "e32", + "parent": "e31", + "kind": "text", + "text": "Start weather-demo", + "x": 946, + "y": 493, + "w": 134.5, + "h": 17 + } + ] + }, + { + "frame": "a-mac-add-2", + "elements": 32, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 302, + "y": 153, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 302, + "y": 185, + "w": 237.34375, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 302, + "y": 227, + "w": 194.53125, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 254 + }, + { + "id": "e21", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 286, + "w": 788, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Broker", + "x": 340, + "y": 299, + "w": 44.875, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 759.4000244140625, + "y": 299, + "w": 97.265625, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 329, + "w": 788, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Catalog update", + "x": 340, + "y": 342, + "w": 104.78125, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "weather-demo", + "x": 759.4000244140625, + "y": 342, + "w": 100.109375, + "h": 18 + }, + { + "id": "e27", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 391, + "w": 788, + "h": 42 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 340, + "y": 402, + "w": 303.859375, + "h": 18 + }, + { + "id": "e29", + "parent": "e20", + "kind": "control", + "text": "", + "x": 904, + "y": 463, + "w": 210, + "h": 32 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Restart local broker", + "x": 916, + "y": 471, + "w": 130.848388671875, + "h": 17 + }, + { + "id": "e31", + "parent": "e20", + "kind": "control", + "text": "", + "x": 746, + "y": 463, + "w": 144, + "h": 32 + }, + { + "id": "e32", + "parent": "e31", + "kind": "text", + "text": "Not now", + "x": 758, + "y": 471, + "w": 55.234375, + "h": 17 + } + ] + }, + { + "frame": "a-mac-recover-0", + "elements": 31, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 302, + "y": 153, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 302, + "y": 185, + "w": 335.421875, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 302, + "y": 227, + "w": 178.234375, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 234 + }, + { + "id": "e21", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 286, + "w": 788, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Process", + "x": 340, + "y": 299, + "w": 54.765625, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 759.4000244140625, + "y": 299, + "w": 110.328125, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 329, + "w": 788, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Runner record", + "x": 340, + "y": 342, + "w": 97.546875, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Running", + "x": 759.4000244140625, + "y": 342, + "w": 56.015625, + "h": 18 + }, + { + "id": "e27", + "parent": "e20", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 326, + "y": 388, + "w": 168.484375, + "h": 18 + }, + { + "id": "e28", + "parent": "e20", + "kind": "control", + "text": "", + "x": 934, + "y": 443, + "w": 180, + "h": 32 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Review restart", + "x": 946, + "y": 451, + "w": 96.0625, + "h": 17 + }, + { + "id": "e30", + "parent": "e20", + "kind": "control", + "text": "", + "x": 776, + "y": 443, + "w": 144, + "h": 32 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Refresh state", + "x": 788, + "y": 451, + "w": 89.09375, + "h": 17 + } + ] + }, + { + "frame": "a-mac-recover-1", + "elements": 30, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 302, + "y": 153, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 302, + "y": 185, + "w": 244.234375, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 302, + "y": 227, + "w": 178.234375, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 250 + }, + { + "id": "e21", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 286, + "w": 788, + "h": 43 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Runner", + "x": 340, + "y": 299, + "w": 48.9375, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Process", + "x": 759.4000244140625, + "y": 299, + "w": 54.765625, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 349, + "w": 788, + "h": 66 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 340, + "y": 360, + "w": 332.609375, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 340, + "y": 384, + "w": 241.375, + "h": 18 + }, + { + "id": "e27", + "parent": "e20", + "kind": "control", + "text": "", + "x": 934, + "y": 459, + "w": 180, + "h": 32 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Restart notes-demo", + "x": 946, + "y": 467, + "w": 133.78125, + "h": 17 + }, + { + "id": "e29", + "parent": "e20", + "kind": "control", + "text": "", + "x": 776, + "y": 459, + "w": 144, + "h": 32 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Cancel", + "x": 788, + "y": 467, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "a-mac-recover-2", + "elements": 26, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 1440, + "h": 54 + }, + { + "id": "e9", + "parent": "e8", + "kind": "control", + "text": "", + "x": 405, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 417, + "y": 83, + "w": 46.203125, + "h": 17 + }, + { + "id": "e11", + "parent": "e8", + "kind": "control", + "text": "", + "x": 555, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 567, + "y": 83, + "w": 35.390625, + "h": 17 + }, + { + "id": "e13", + "parent": "e8", + "kind": "control", + "text": "", + "x": 705, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Activity", + "x": 717, + "y": 83, + "w": 50.6051025390625, + "h": 17 + }, + { + "id": "e15", + "parent": "e8", + "kind": "control", + "text": "", + "x": 855, + "y": 75, + "w": 140, + "h": 32 + }, + { + "id": "e16", + "parent": "e15", + "kind": "text", + "text": "Config", + "x": 867, + "y": 83, + "w": 44.171875, + "h": 17 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 302, + "y": 153, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 302, + "y": 185, + "w": 302.3125, + "h": 29 + }, + { + "id": "e19", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 302, + "y": 227, + "w": 178.234375, + "h": 18 + }, + { + "id": "e20", + "parent": "e1", + "kind": "container", + "text": "", + "x": 302, + "y": 266, + "w": 836, + "h": 228 + }, + { + "id": "e21", + "parent": "e20", + "kind": "container", + "text": "", + "x": 326, + "y": 286, + "w": 788, + "h": 66 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 340, + "y": 297, + "w": 251.890625, + "h": 18 + }, + { + "id": "e23", + "parent": "e21", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 340, + "y": 321, + "w": 303.203125, + "h": 18 + }, + { + "id": "e24", + "parent": "e20", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 326, + "y": 377, + "w": 260.96875, + "h": 18 + }, + { + "id": "e25", + "parent": "e20", + "kind": "control", + "text": "", + "x": 934, + "y": 437, + "w": 180, + "h": 32 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Refresh state", + "x": 946, + "y": 445, + "w": 89.09375, + "h": 17 + } + ] + }, + { + "frame": "b-web-grant-0", + "elements": 36, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 121, + "w": 196, + "h": 38 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e14", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 127, + "w": 55.125, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 176, + "w": 93.078125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 224, + "w": 98.109375, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 497, + "y": 192, + "w": 252.046875, + "h": 33 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e26", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 192 + }, + { + "id": "e27", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Tool access", + "x": 535, + "y": 309, + "w": 80.390625, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 995.6500244140625, + "y": 309, + "w": 170.859375, + "h": 18 + }, + { + "id": "e30", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Operation policy", + "x": 535, + "y": 352, + "w": 112.59375, + "h": 18 + }, + { + "id": "e32", + "parent": "e30", + "kind": "text", + "text": "Not granted", + "x": 995.6500244140625, + "y": 352, + "w": 82.09375, + "h": 18 + }, + { + "id": "e33", + "parent": "e26", + "kind": "control", + "text": "", + "x": 521, + "y": 411, + "w": 180, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Enable notes-demo", + "x": 533, + "y": 421, + "w": 146.296875, + "h": 18 + }, + { + "id": "e35", + "parent": "e26", + "kind": "control", + "text": "", + "x": 715, + "y": 411, + "w": 144, + "h": 38 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Cancel", + "x": 727, + "y": 421, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "b-web-grant-1", + "elements": 43, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 121, + "w": 196, + "h": 38 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e14", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 127, + "w": 55.125, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 176, + "w": 93.078125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 224, + "w": 98.109375, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 497, + "y": 192, + "w": 77.828125, + "h": 33 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e26", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 291 + }, + { + "id": "e27", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 294, + "w": 863, + "h": 42 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 535, + "y": 305, + "w": 200.046875, + "h": 18 + }, + { + "id": "e29", + "parent": "e26", + "kind": "text", + "text": "read_note", + "x": 521, + "y": 361, + "w": 77.65625, + "h": 18 + }, + { + "id": "e30", + "parent": "e26", + "kind": "control", + "text": "", + "x": 741, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Deny", + "x": 753, + "y": 362, + "w": 38.63128662109375, + "h": 18.2421875 + }, + { + "id": "e32", + "parent": "e26", + "kind": "control", + "text": "", + "x": 886, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Human review", + "x": 898, + "y": 362, + "w": 106.870361328125, + "h": 18 + }, + { + "id": "e34", + "parent": "e26", + "kind": "control", + "text": "", + "x": 1031, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Allow", + "x": 1043, + "y": 362, + "w": 40.917236328125, + "h": 18 + }, + { + "id": "e36", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 405, + "w": 863, + "h": 43 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "write_note", + "x": 535, + "y": 418, + "w": 72.59375, + "h": 18 + }, + { + "id": "e38", + "parent": "e36", + "kind": "text", + "text": "Deny", + "x": 995.6500244140625, + "y": 418, + "w": 35.140625, + "h": 18 + }, + { + "id": "e39", + "parent": "e26", + "kind": "text", + "text": "Unsaved changes", + "x": 521, + "y": 463, + "w": 114.6875, + "h": 17 + }, + { + "id": "e40", + "parent": "e26", + "kind": "control", + "text": "", + "x": 521, + "y": 510, + "w": 180, + "h": 38 + }, + { + "id": "e41", + "parent": "e40", + "kind": "text", + "text": "Retry save", + "x": 533, + "y": 520, + "w": 78.703125, + "h": 18.2421875 + }, + { + "id": "e42", + "parent": "e26", + "kind": "control", + "text": "", + "x": 715, + "y": 510, + "w": 144, + "h": 38 + }, + { + "id": "e43", + "parent": "e42", + "kind": "text", + "text": "Keep editing", + "x": 727, + "y": 520, + "w": 93.671875, + "h": 18.24609375 + } + ] + }, + { + "frame": "b-web-grant-2", + "elements": 34, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 121, + "w": 196, + "h": 38 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e14", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 127, + "w": 55.125, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 176, + "w": 93.078125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 224, + "w": 98.109375, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 497, + "y": 192, + "w": 163.921875, + "h": 33 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e26", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 190 + }, + { + "id": "e27", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "read_note", + "x": 535, + "y": 309, + "w": 69.734375, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 995.6500244140625, + "y": 309, + "w": 133.265625, + "h": 18 + }, + { + "id": "e30", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "write_note", + "x": 535, + "y": 352, + "w": 72.59375, + "h": 18 + }, + { + "id": "e32", + "parent": "e30", + "kind": "text", + "text": "Deny", + "x": 995.6500244140625, + "y": 352, + "w": 35.140625, + "h": 18 + }, + { + "id": "e33", + "parent": "e26", + "kind": "control", + "text": "", + "x": 521, + "y": 409, + "w": 180, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Back to callers", + "x": 533, + "y": 419, + "w": 109.5625, + "h": 18 + } + ] + }, + { + "frame": "b-web-add-0", + "elements": 38, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 497, + "y": 192, + "w": 222.296875, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 286 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Folder path on admin host", + "x": 521, + "y": 296, + "w": 184.671875, + "h": 18 + }, + { + "id": "e30", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 324, + "w": 863, + "h": 42 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 533, + "y": 336, + "w": 224.609375, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "text", + "text": "Copy into managed tools", + "x": 521, + "y": 381, + "w": 170.53125, + "h": 18 + }, + { + "id": "e33", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 453, + "w": 190, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 533, + "y": 463, + "w": 133.46875, + "h": 18.08203125 + }, + { + "id": "e35", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 505, + "w": 180, + "h": 38 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Add from folder", + "x": 533, + "y": 515, + "w": 117.6484375, + "h": 18 + }, + { + "id": "e37", + "parent": "e28", + "kind": "control", + "text": "", + "x": 715, + "y": 505, + "w": 144, + "h": 38 + }, + { + "id": "e38", + "parent": "e37", + "kind": "text", + "text": "Cancel", + "x": 727, + "y": 515, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "b-web-add-1", + "elements": 40, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 497, + "y": 192, + "w": 261.484375, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 276 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Folder", + "x": 535, + "y": 309, + "w": 43.34375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Added", + "x": 995.6500244140625, + "y": 309, + "w": 44.71875, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Process", + "x": 535, + "y": 352, + "w": 54.765625, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Stopped", + "x": 995.6500244140625, + "y": 352, + "w": 58.21875, + "h": 18 + }, + { + "id": "e35", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 382, + "w": 863, + "h": 43 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Broker catalog", + "x": 535, + "y": 395, + "w": 99.40625, + "h": 18 + }, + { + "id": "e37", + "parent": "e35", + "kind": "text", + "text": "Not registered", + "x": 995.6500244140625, + "y": 395, + "w": 98.265625, + "h": 18 + }, + { + "id": "e38", + "parent": "e28", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 521, + "y": 446, + "w": 238.046875, + "h": 18 + }, + { + "id": "e39", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 495, + "w": 180, + "h": 38 + }, + { + "id": "e40", + "parent": "e39", + "kind": "text", + "text": "Start weather-demo", + "x": 533, + "y": 505, + "w": 151.1875, + "h": 18 + } + ] + }, + { + "frame": "b-web-add-2", + "elements": 40, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 497, + "y": 192, + "w": 266.25, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 254 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Broker", + "x": 535, + "y": 309, + "w": 44.875, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 995.6500244140625, + "y": 309, + "w": 97.265625, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Catalog update", + "x": 535, + "y": 352, + "w": 104.78125, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "weather-demo", + "x": 995.6500244140625, + "y": 352, + "w": 100.109375, + "h": 18 + }, + { + "id": "e35", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 401, + "w": 863, + "h": 42 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 535, + "y": 412, + "w": 303.859375, + "h": 18 + }, + { + "id": "e37", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 473, + "w": 210, + "h": 38 + }, + { + "id": "e38", + "parent": "e37", + "kind": "text", + "text": "Restart local broker", + "x": 533, + "y": 483, + "w": 146.869140625, + "h": 18 + }, + { + "id": "e39", + "parent": "e28", + "kind": "control", + "text": "", + "x": 745, + "y": 473, + "w": 144, + "h": 38 + }, + { + "id": "e40", + "parent": "e39", + "kind": "text", + "text": "Not now", + "x": 757, + "y": 483, + "w": 62.260986328125, + "h": 18 + } + ] + }, + { + "frame": "b-web-recover-0", + "elements": 39, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 497, + "y": 192, + "w": 376.046875, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 234 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Process", + "x": 535, + "y": 309, + "w": 54.765625, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 995.6500244140625, + "y": 309, + "w": 110.328125, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Runner record", + "x": 535, + "y": 352, + "w": 97.546875, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Running", + "x": 995.6500244140625, + "y": 352, + "w": 56.015625, + "h": 18 + }, + { + "id": "e35", + "parent": "e28", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 521, + "y": 398, + "w": 168.484375, + "h": 18 + }, + { + "id": "e36", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 453, + "w": 180, + "h": 38 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Review restart", + "x": 533, + "y": 463, + "w": 107.8125, + "h": 18 + }, + { + "id": "e38", + "parent": "e28", + "kind": "control", + "text": "", + "x": 715, + "y": 453, + "w": 144, + "h": 38 + }, + { + "id": "e39", + "parent": "e38", + "kind": "text", + "text": "Refresh state", + "x": 727, + "y": 463, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "b-web-recover-1", + "elements": 38, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 497, + "y": 192, + "w": 274.125, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 250 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Runner", + "x": 535, + "y": 309, + "w": 48.9375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Process", + "x": 995.6500244140625, + "y": 309, + "w": 54.765625, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 359, + "w": 863, + "h": 66 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 535, + "y": 370, + "w": 332.609375, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 535, + "y": 394, + "w": 241.375, + "h": 18 + }, + { + "id": "e35", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 469, + "w": 180, + "h": 38 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Restart notes-demo", + "x": 533, + "y": 479, + "w": 150.375, + "h": 18 + }, + { + "id": "e37", + "parent": "e28", + "kind": "control", + "text": "", + "x": 715, + "y": 469, + "w": 144, + "h": 38 + }, + { + "id": "e38", + "parent": "e37", + "kind": "text", + "text": "Cancel", + "x": 727, + "y": 479, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "b-web-recover-2", + "elements": 34, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 497, + "y": 192, + "w": 339.171875, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 228 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 66 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 535, + "y": 307, + "w": 251.890625, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 535, + "y": 331, + "w": 303.203125, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 521, + "y": 387, + "w": 260.96875, + "h": 18 + }, + { + "id": "e33", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 447, + "w": 180, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Refresh state", + "x": 533, + "y": 457, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "b-shell-grant-0", + "elements": 36, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 121, + "w": 196, + "h": 38 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e14", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 127, + "w": 55.125, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 176, + "w": 93.078125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 224, + "w": 98.109375, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 497, + "y": 192, + "w": 252.046875, + "h": 33 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e26", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 192 + }, + { + "id": "e27", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Tool access", + "x": 535, + "y": 309, + "w": 80.390625, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 995.6500244140625, + "y": 309, + "w": 170.859375, + "h": 18 + }, + { + "id": "e30", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Operation policy", + "x": 535, + "y": 352, + "w": 112.59375, + "h": 18 + }, + { + "id": "e32", + "parent": "e30", + "kind": "text", + "text": "Not granted", + "x": 995.6500244140625, + "y": 352, + "w": 82.09375, + "h": 18 + }, + { + "id": "e33", + "parent": "e26", + "kind": "control", + "text": "", + "x": 521, + "y": 411, + "w": 180, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Enable notes-demo", + "x": 533, + "y": 421, + "w": 146.296875, + "h": 18 + }, + { + "id": "e35", + "parent": "e26", + "kind": "control", + "text": "", + "x": 715, + "y": 411, + "w": 144, + "h": 38 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Cancel", + "x": 727, + "y": 421, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "b-shell-grant-1", + "elements": 43, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 121, + "w": 196, + "h": 38 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e14", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 127, + "w": 55.125, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 176, + "w": 93.078125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 224, + "w": 98.109375, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 497, + "y": 192, + "w": 77.828125, + "h": 33 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e26", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 291 + }, + { + "id": "e27", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 294, + "w": 863, + "h": 42 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 535, + "y": 305, + "w": 200.046875, + "h": 18 + }, + { + "id": "e29", + "parent": "e26", + "kind": "text", + "text": "read_note", + "x": 521, + "y": 361, + "w": 77.65625, + "h": 18 + }, + { + "id": "e30", + "parent": "e26", + "kind": "control", + "text": "", + "x": 741, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Deny", + "x": 753, + "y": 362, + "w": 38.63128662109375, + "h": 18.2421875 + }, + { + "id": "e32", + "parent": "e26", + "kind": "control", + "text": "", + "x": 886, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Human review", + "x": 898, + "y": 362, + "w": 106.870361328125, + "h": 18 + }, + { + "id": "e34", + "parent": "e26", + "kind": "control", + "text": "", + "x": 1031, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Allow", + "x": 1043, + "y": 362, + "w": 40.917236328125, + "h": 18 + }, + { + "id": "e36", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 405, + "w": 863, + "h": 43 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "write_note", + "x": 535, + "y": 418, + "w": 72.59375, + "h": 18 + }, + { + "id": "e38", + "parent": "e36", + "kind": "text", + "text": "Deny", + "x": 995.6500244140625, + "y": 418, + "w": 35.140625, + "h": 18 + }, + { + "id": "e39", + "parent": "e26", + "kind": "text", + "text": "Unsaved changes", + "x": 521, + "y": 463, + "w": 114.6875, + "h": 17 + }, + { + "id": "e40", + "parent": "e26", + "kind": "control", + "text": "", + "x": 521, + "y": 510, + "w": 180, + "h": 38 + }, + { + "id": "e41", + "parent": "e40", + "kind": "text", + "text": "Retry save", + "x": 533, + "y": 520, + "w": 78.703125, + "h": 18.2421875 + }, + { + "id": "e42", + "parent": "e26", + "kind": "control", + "text": "", + "x": 715, + "y": 510, + "w": 144, + "h": 38 + }, + { + "id": "e43", + "parent": "e42", + "kind": "text", + "text": "Keep editing", + "x": 727, + "y": 520, + "w": 93.671875, + "h": 18.24609375 + } + ] + }, + { + "frame": "b-shell-grant-2", + "elements": 34, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 121, + "w": 196, + "h": 38 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e14", + "parent": "e11", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 127, + "w": 55.125, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 176, + "w": 93.078125, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 224, + "w": 98.109375, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 497, + "y": 192, + "w": 163.921875, + "h": 33 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e26", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 190 + }, + { + "id": "e27", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "read_note", + "x": 535, + "y": 309, + "w": 69.734375, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 995.6500244140625, + "y": 309, + "w": 133.265625, + "h": 18 + }, + { + "id": "e30", + "parent": "e26", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "write_note", + "x": 535, + "y": 352, + "w": 72.59375, + "h": 18 + }, + { + "id": "e32", + "parent": "e30", + "kind": "text", + "text": "Deny", + "x": 995.6500244140625, + "y": 352, + "w": 35.140625, + "h": 18 + }, + { + "id": "e33", + "parent": "e26", + "kind": "control", + "text": "", + "x": 521, + "y": 409, + "w": 180, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Back to callers", + "x": 533, + "y": 419, + "w": 109.5625, + "h": 18 + } + ] + }, + { + "frame": "b-shell-add-0", + "elements": 38, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 497, + "y": 192, + "w": 222.296875, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 286 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Folder path on admin host", + "x": 521, + "y": 296, + "w": 184.671875, + "h": 18 + }, + { + "id": "e30", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 324, + "w": 863, + "h": 42 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 533, + "y": 336, + "w": 224.609375, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "text", + "text": "Copy into managed tools", + "x": 521, + "y": 381, + "w": 170.53125, + "h": 18 + }, + { + "id": "e33", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 453, + "w": 190, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 533, + "y": 463, + "w": 133.46875, + "h": 18.08203125 + }, + { + "id": "e35", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 505, + "w": 180, + "h": 38 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Add from folder", + "x": 533, + "y": 515, + "w": 117.6484375, + "h": 18 + }, + { + "id": "e37", + "parent": "e28", + "kind": "control", + "text": "", + "x": 715, + "y": 505, + "w": 144, + "h": 38 + }, + { + "id": "e38", + "parent": "e37", + "kind": "text", + "text": "Cancel", + "x": 727, + "y": 515, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "b-shell-add-1", + "elements": 40, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 497, + "y": 192, + "w": 261.484375, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 276 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Folder", + "x": 535, + "y": 309, + "w": 43.34375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Added", + "x": 995.6500244140625, + "y": 309, + "w": 44.71875, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Process", + "x": 535, + "y": 352, + "w": 54.765625, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Stopped", + "x": 995.6500244140625, + "y": 352, + "w": 58.21875, + "h": 18 + }, + { + "id": "e35", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 382, + "w": 863, + "h": 43 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Broker catalog", + "x": 535, + "y": 395, + "w": 99.40625, + "h": 18 + }, + { + "id": "e37", + "parent": "e35", + "kind": "text", + "text": "Not registered", + "x": 995.6500244140625, + "y": 395, + "w": 98.265625, + "h": 18 + }, + { + "id": "e38", + "parent": "e28", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 521, + "y": 446, + "w": 238.046875, + "h": 18 + }, + { + "id": "e39", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 495, + "w": 180, + "h": 38 + }, + { + "id": "e40", + "parent": "e39", + "kind": "text", + "text": "Start weather-demo", + "x": 533, + "y": 505, + "w": 151.1875, + "h": 18 + } + ] + }, + { + "frame": "b-shell-add-2", + "elements": 40, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 497, + "y": 192, + "w": 266.25, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 254 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Broker", + "x": 535, + "y": 309, + "w": 44.875, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 995.6500244140625, + "y": 309, + "w": 97.265625, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Catalog update", + "x": 535, + "y": 352, + "w": 104.78125, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "weather-demo", + "x": 995.6500244140625, + "y": 352, + "w": 100.109375, + "h": 18 + }, + { + "id": "e35", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 401, + "w": 863, + "h": 42 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 535, + "y": 412, + "w": 303.859375, + "h": 18 + }, + { + "id": "e37", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 473, + "w": 210, + "h": 38 + }, + { + "id": "e38", + "parent": "e37", + "kind": "text", + "text": "Restart local broker", + "x": 533, + "y": 483, + "w": 146.869140625, + "h": 18 + }, + { + "id": "e39", + "parent": "e28", + "kind": "control", + "text": "", + "x": 745, + "y": 473, + "w": 144, + "h": 38 + }, + { + "id": "e40", + "parent": "e39", + "kind": "text", + "text": "Not now", + "x": 757, + "y": 483, + "w": 62.260986328125, + "h": 18 + } + ] + }, + { + "frame": "b-shell-recover-0", + "elements": 39, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 497, + "y": 192, + "w": 376.046875, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 234 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Process", + "x": 535, + "y": 309, + "w": 54.765625, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 995.6500244140625, + "y": 309, + "w": 110.328125, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 339, + "w": 863, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Runner record", + "x": 535, + "y": 352, + "w": 97.546875, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Running", + "x": 995.6500244140625, + "y": 352, + "w": 56.015625, + "h": 18 + }, + { + "id": "e35", + "parent": "e28", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 521, + "y": 398, + "w": 168.484375, + "h": 18 + }, + { + "id": "e36", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 453, + "w": 180, + "h": 38 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Review restart", + "x": 533, + "y": 463, + "w": 107.8125, + "h": 18 + }, + { + "id": "e38", + "parent": "e28", + "kind": "control", + "text": "", + "x": 715, + "y": 453, + "w": 144, + "h": 38 + }, + { + "id": "e39", + "parent": "e38", + "kind": "text", + "text": "Refresh state", + "x": 727, + "y": 463, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "b-shell-recover-1", + "elements": 38, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 497, + "y": 192, + "w": 274.125, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 250 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Runner", + "x": 535, + "y": 309, + "w": 48.9375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Process", + "x": 995.6500244140625, + "y": 309, + "w": 54.765625, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 359, + "w": 863, + "h": 66 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 535, + "y": 370, + "w": 332.609375, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 535, + "y": 394, + "w": 241.375, + "h": 18 + }, + { + "id": "e35", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 469, + "w": 180, + "h": 38 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Restart notes-demo", + "x": 533, + "y": 479, + "w": 150.375, + "h": 18 + }, + { + "id": "e37", + "parent": "e28", + "kind": "control", + "text": "", + "x": 715, + "y": 469, + "w": 144, + "h": 38 + }, + { + "id": "e38", + "parent": "e37", + "kind": "text", + "text": "Cancel", + "x": 727, + "y": 479, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "b-shell-recover-2", + "elements": 34, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e11", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 220, + "h": 796 + }, + { + "id": "e12", + "parent": "e11", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 130, + "w": 51.828125, + "h": 18 + }, + { + "id": "e13", + "parent": "e11", + "kind": "container", + "text": "", + "x": 12, + "y": 169, + "w": 196, + "h": 38 + }, + { + "id": "e14", + "parent": "e13", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 178, + "w": 39.75, + "h": 18 + }, + { + "id": "e15", + "parent": "e11", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 226, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e16", + "parent": "e11", + "kind": "text", + "text": "Config", + "x": 28, + "y": 274, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e17", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 104, + "w": 245, + "h": 796 + }, + { + "id": "e18", + "parent": "e17", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 127, + "w": 42.21875, + "h": 20 + }, + { + "id": "e19", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 166, + "w": 221, + "h": 38 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 176, + "w": 109.984375, + "h": 18 + }, + { + "id": "e21", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 214, + "w": 221, + "h": 38 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 224, + "w": 91.734375, + "h": 18 + }, + { + "id": "e23", + "parent": "e17", + "kind": "control", + "text": "", + "x": 232, + "y": 262, + "w": 221, + "h": 38 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 272, + "w": 86.453125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e26", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 497, + "y": 192, + "w": 339.171875, + "h": 33 + }, + { + "id": "e27", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e28", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 276, + "w": 911, + "h": 228 + }, + { + "id": "e29", + "parent": "e28", + "kind": "container", + "text": "", + "x": 521, + "y": 296, + "w": 863, + "h": 66 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 535, + "y": 307, + "w": 251.890625, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 535, + "y": 331, + "w": 303.203125, + "h": 18 + }, + { + "id": "e32", + "parent": "e28", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 521, + "y": 387, + "w": 260.96875, + "h": 18 + }, + { + "id": "e33", + "parent": "e28", + "kind": "control", + "text": "", + "x": 521, + "y": 447, + "w": 180, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Refresh state", + "x": 533, + "y": 457, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "b-mac-grant-0", + "elements": 33, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 81, + "w": 196, + "h": 38 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 87, + "w": 55.125, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 134, + "w": 82.796875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 182, + "w": 87.203125, + "h": 17 + }, + { + "id": "e20", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e21", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 497, + "y": 157, + "w": 224.625, + "h": 29 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 192 + }, + { + "id": "e24", + "parent": "e23", + "kind": "container", + "text": "", + "x": 521, + "y": 258, + "w": 863, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Tool access", + "x": 535, + "y": 271, + "w": 80.390625, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 995.6500244140625, + "y": 271, + "w": 170.859375, + "h": 18 + }, + { + "id": "e27", + "parent": "e23", + "kind": "container", + "text": "", + "x": 521, + "y": 301, + "w": 863, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Operation policy", + "x": 535, + "y": 314, + "w": 112.59375, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Not granted", + "x": 995.6500244140625, + "y": 314, + "w": 82.09375, + "h": 18 + }, + { + "id": "e30", + "parent": "e23", + "kind": "control", + "text": "", + "x": 1204, + "y": 373, + "w": 180, + "h": 32 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Enable notes-demo", + "x": 1216, + "y": 381, + "w": 130.109375, + "h": 17 + }, + { + "id": "e32", + "parent": "e23", + "kind": "control", + "text": "", + "x": 1046, + "y": 373, + "w": 144, + "h": 32 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Cancel", + "x": 1058, + "y": 381, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "b-mac-grant-1", + "elements": 40, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 81, + "w": 196, + "h": 38 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 87, + "w": 55.125, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 134, + "w": 82.796875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 182, + "w": 87.203125, + "h": 17 + }, + { + "id": "e20", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e21", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 497, + "y": 157, + "w": 69.328125, + "h": 29 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 291 + }, + { + "id": "e24", + "parent": "e23", + "kind": "container", + "text": "", + "x": 521, + "y": 256, + "w": 863, + "h": 42 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 535, + "y": 267, + "w": 200.046875, + "h": 18 + }, + { + "id": "e26", + "parent": "e23", + "kind": "text", + "text": "read_note", + "x": 521, + "y": 323, + "w": 77.65625, + "h": 18 + }, + { + "id": "e27", + "parent": "e23", + "kind": "control", + "text": "", + "x": 741, + "y": 314, + "w": 136, + "h": 32 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Deny", + "x": 753, + "y": 322, + "w": 34.1715087890625, + "h": 17 + }, + { + "id": "e29", + "parent": "e23", + "kind": "control", + "text": "", + "x": 886, + "y": 314, + "w": 136, + "h": 32 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Human review", + "x": 898, + "y": 322, + "w": 94.875, + "h": 17 + }, + { + "id": "e31", + "parent": "e23", + "kind": "control", + "text": "", + "x": 1031, + "y": 314, + "w": 136, + "h": 32 + }, + { + "id": "e32", + "parent": "e31", + "kind": "text", + "text": "Allow", + "x": 1043, + "y": 322, + "w": 36.3125, + "h": 17 + }, + { + "id": "e33", + "parent": "e23", + "kind": "container", + "text": "", + "x": 521, + "y": 367, + "w": 863, + "h": 43 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "write_note", + "x": 535, + "y": 380, + "w": 72.59375, + "h": 18 + }, + { + "id": "e35", + "parent": "e33", + "kind": "text", + "text": "Deny", + "x": 995.6500244140625, + "y": 380, + "w": 35.140625, + "h": 18 + }, + { + "id": "e36", + "parent": "e23", + "kind": "text", + "text": "Unsaved changes", + "x": 521, + "y": 425, + "w": 114.6875, + "h": 17 + }, + { + "id": "e37", + "parent": "e23", + "kind": "control", + "text": "", + "x": 1204, + "y": 472, + "w": 180, + "h": 32 + }, + { + "id": "e38", + "parent": "e37", + "kind": "text", + "text": "Retry save", + "x": 1216, + "y": 480, + "w": 70.09375, + "h": 17 + }, + { + "id": "e39", + "parent": "e23", + "kind": "control", + "text": "", + "x": 1046, + "y": 472, + "w": 144, + "h": 32 + }, + { + "id": "e40", + "parent": "e39", + "kind": "text", + "text": "Keep editing", + "x": 1058, + "y": 480, + "w": 83.4375, + "h": 17 + } + ] + }, + { + "frame": "b-mac-grant-2", + "elements": 31, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 81, + "w": 196, + "h": 38 + }, + { + "id": "e10", + "parent": "e9", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Callers", + "x": 242, + "y": 87, + "w": 55.125, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "writer-demo", + "x": 244, + "y": 134, + "w": 82.796875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "reader-demo", + "x": 244, + "y": 182, + "w": 87.203125, + "h": 17 + }, + { + "id": "e20", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 497, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e21", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 497, + "y": 157, + "w": 145.953125, + "h": 29 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 497, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e23", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 190 + }, + { + "id": "e24", + "parent": "e23", + "kind": "container", + "text": "", + "x": 521, + "y": 258, + "w": 863, + "h": 43 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "read_note", + "x": 535, + "y": 271, + "w": 69.734375, + "h": 18 + }, + { + "id": "e26", + "parent": "e24", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 995.6500244140625, + "y": 271, + "w": 133.265625, + "h": 18 + }, + { + "id": "e27", + "parent": "e23", + "kind": "container", + "text": "", + "x": 521, + "y": 301, + "w": 863, + "h": 43 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "write_note", + "x": 535, + "y": 314, + "w": 72.59375, + "h": 18 + }, + { + "id": "e29", + "parent": "e27", + "kind": "text", + "text": "Deny", + "x": 995.6500244140625, + "y": 314, + "w": 35.140625, + "h": 18 + }, + { + "id": "e30", + "parent": "e23", + "kind": "control", + "text": "", + "x": 1204, + "y": 371, + "w": 180, + "h": 32 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Back to callers", + "x": 1216, + "y": 379, + "w": 97.703125, + "h": 17 + } + ] + }, + { + "frame": "b-mac-add-0", + "elements": 35, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e10", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 129, + "w": 196, + "h": 38 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 87, + "w": 42.21875, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 134, + "w": 97.71875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 182, + "w": 81.5, + "h": 17 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 222, + "w": 221, + "h": 32 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 230, + "w": 76.765625, + "h": 17 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 497, + "y": 157, + "w": 198.15625, + "h": 29 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 286 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Folder path on admin host", + "x": 521, + "y": 258, + "w": 184.671875, + "h": 18 + }, + { + "id": "e27", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 286, + "w": 863, + "h": 42 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 533, + "y": 298, + "w": 224.609375, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "text", + "text": "Copy into managed tools", + "x": 521, + "y": 343, + "w": 170.53125, + "h": 18 + }, + { + "id": "e30", + "parent": "e25", + "kind": "control", + "text": "", + "x": 521, + "y": 415, + "w": 190, + "h": 32 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 533, + "y": 423, + "w": 118.640625, + "h": 17 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1204, + "y": 467, + "w": 180, + "h": 32 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Add from folder", + "x": 1216, + "y": 475, + "w": 104.6650390625, + "h": 17 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1046, + "y": 467, + "w": 144, + "h": 32 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Cancel", + "x": 1058, + "y": 475, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "b-mac-add-1", + "elements": 37, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e10", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 129, + "w": 196, + "h": 38 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 87, + "w": 42.21875, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 134, + "w": 97.71875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 182, + "w": 81.5, + "h": 17 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 222, + "w": 221, + "h": 32 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 230, + "w": 76.765625, + "h": 17 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 497, + "y": 157, + "w": 233.015625, + "h": 29 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 276 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 258, + "w": 863, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Folder", + "x": 535, + "y": 271, + "w": 43.34375, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Added", + "x": 995.6500244140625, + "y": 271, + "w": 44.71875, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 301, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Process", + "x": 535, + "y": 314, + "w": 54.765625, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Stopped", + "x": 995.6500244140625, + "y": 314, + "w": 58.21875, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 344, + "w": 863, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Broker catalog", + "x": 535, + "y": 357, + "w": 99.40625, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Not registered", + "x": 995.6500244140625, + "y": 357, + "w": 98.265625, + "h": 18 + }, + { + "id": "e35", + "parent": "e25", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 521, + "y": 408, + "w": 238.046875, + "h": 18 + }, + { + "id": "e36", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1204, + "y": 457, + "w": 180, + "h": 32 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Start weather-demo", + "x": 1216, + "y": 465, + "w": 134.5, + "h": 17 + } + ] + }, + { + "frame": "b-mac-add-2", + "elements": 37, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e10", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 129, + "w": 196, + "h": 38 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 87, + "w": 42.21875, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 134, + "w": 97.71875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 182, + "w": 81.5, + "h": 17 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 222, + "w": 221, + "h": 32 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 230, + "w": 76.765625, + "h": 17 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 497, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 497, + "y": 157, + "w": 237.34375, + "h": 29 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 497, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 254 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 258, + "w": 863, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Broker", + "x": 535, + "y": 271, + "w": 44.875, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 995.6500244140625, + "y": 271, + "w": 97.265625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 301, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Catalog update", + "x": 535, + "y": 314, + "w": 104.78125, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "weather-demo", + "x": 995.6500244140625, + "y": 314, + "w": 100.109375, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 363, + "w": 863, + "h": 42 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 535, + "y": 374, + "w": 303.859375, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1174, + "y": 435, + "w": 210, + "h": 32 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Restart local broker", + "x": 1186, + "y": 443, + "w": 130.848388671875, + "h": 17 + }, + { + "id": "e36", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1016, + "y": 435, + "w": 144, + "h": 32 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Not now", + "x": 1028, + "y": 443, + "w": 55.234375, + "h": 17 + } + ] + }, + { + "frame": "b-mac-recover-0", + "elements": 36, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e10", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 129, + "w": 196, + "h": 38 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 87, + "w": 42.21875, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 134, + "w": 97.71875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 182, + "w": 81.5, + "h": 17 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 222, + "w": 221, + "h": 32 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 230, + "w": 76.765625, + "h": 17 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 497, + "y": 157, + "w": 335.421875, + "h": 29 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 234 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 258, + "w": 863, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Process", + "x": 535, + "y": 271, + "w": 54.765625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 995.6500244140625, + "y": 271, + "w": 110.328125, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 301, + "w": 863, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Runner record", + "x": 535, + "y": 314, + "w": 97.546875, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Running", + "x": 995.6500244140625, + "y": 314, + "w": 56.015625, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 521, + "y": 360, + "w": 168.484375, + "h": 18 + }, + { + "id": "e33", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1204, + "y": 415, + "w": 180, + "h": 32 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Review restart", + "x": 1216, + "y": 423, + "w": 96.0625, + "h": 17 + }, + { + "id": "e35", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1046, + "y": 415, + "w": 144, + "h": 32 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Refresh state", + "x": 1058, + "y": 423, + "w": 89.09375, + "h": 17 + } + ] + }, + { + "frame": "b-mac-recover-1", + "elements": 35, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e10", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 129, + "w": 196, + "h": 38 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 87, + "w": 42.21875, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 134, + "w": 97.71875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 182, + "w": 81.5, + "h": 17 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 222, + "w": 221, + "h": 32 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 230, + "w": 76.765625, + "h": 17 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 497, + "y": 157, + "w": 244.234375, + "h": 29 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 250 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 258, + "w": 863, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Runner", + "x": 535, + "y": 271, + "w": 48.9375, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Process", + "x": 995.6500244140625, + "y": 271, + "w": 54.765625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 321, + "w": 863, + "h": 66 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 535, + "y": 332, + "w": 332.609375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 535, + "y": 356, + "w": 241.375, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1204, + "y": 431, + "w": 180, + "h": 32 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Restart notes-demo", + "x": 1216, + "y": 439, + "w": 133.78125, + "h": 17 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1046, + "y": 431, + "w": 144, + "h": 32 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Cancel", + "x": 1058, + "y": 439, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "b-mac-recover-2", + "elements": 31, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 220, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Callers", + "x": 28, + "y": 90, + "w": 51.828125, + "h": 18 + }, + { + "id": "e10", + "parent": "e8", + "kind": "container", + "text": "", + "x": 12, + "y": 129, + "w": 196, + "h": 38 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "Tools", + "x": 28, + "y": 138, + "w": 39.75, + "h": 18 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Requests", + "x": 28, + "y": 186, + "w": 70, + "h": 18.08203125 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 28, + "y": 234, + "w": 49.625, + "h": 18.24609375 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 220, + "y": 64, + "w": 245, + "h": 836 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Tools", + "x": 242, + "y": 87, + "w": 42.21875, + "h": 20 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 126, + "w": 221, + "h": 32 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "weather-demo", + "x": 244, + "y": 134, + "w": 97.71875, + "h": 17 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 174, + "w": 221, + "h": 32 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "notes-demo", + "x": 244, + "y": 182, + "w": 81.5, + "h": 17 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 232, + "y": 222, + "w": 221, + "h": 32 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "echo-demo", + "x": 244, + "y": 230, + "w": 76.765625, + "h": 17 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 497, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 497, + "y": 157, + "w": 302.3125, + "h": 29 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 497, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 497, + "y": 238, + "w": 911, + "h": 228 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 521, + "y": 258, + "w": 863, + "h": 66 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 535, + "y": 269, + "w": 251.890625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 535, + "y": 293, + "w": 303.203125, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 521, + "y": 349, + "w": 260.96875, + "h": 18 + }, + { + "id": "e30", + "parent": "e25", + "kind": "control", + "text": "", + "x": 1204, + "y": 409, + "w": 180, + "h": 32 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Refresh state", + "x": 1216, + "y": 417, + "w": 89.09375, + "h": 17 + } + ] + }, + { + "frame": "c-web-grant-0", + "elements": 35, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 129, + "w": 111.921875, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 184, + "w": 94.703125, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 240, + "w": 91.77972412109375, + "h": 18.2421875 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 296, + "w": 108.97503662109375, + "h": 18.2421875 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 304, + "y": 192, + "w": 252.046875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 192 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Tool access", + "x": 342, + "y": 309, + "w": 80.390625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 904.4000244140625, + "y": 309, + "w": 170.859375, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Operation policy", + "x": 342, + "y": 352, + "w": 112.59375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Not granted", + "x": 904.4000244140625, + "y": 352, + "w": 82.09375, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 411, + "w": 180, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Enable notes-demo", + "x": 340, + "y": 421, + "w": 146.296875, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 411, + "w": 144, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Cancel", + "x": 534, + "y": 421, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "c-web-grant-1", + "elements": 42, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 129, + "w": 111.921875, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 184, + "w": 94.703125, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 240, + "w": 91.77972412109375, + "h": 18.2421875 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 296, + "w": 108.97503662109375, + "h": 18.2421875 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 304, + "y": 192, + "w": 77.828125, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 291 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 294, + "w": 1048, + "h": 42 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 342, + "y": 305, + "w": 200.046875, + "h": 18 + }, + { + "id": "e28", + "parent": "e25", + "kind": "text", + "text": "read_note", + "x": 328, + "y": 361, + "w": 77.65625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "control", + "text": "", + "x": 548, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Deny", + "x": 560, + "y": 362, + "w": 38.63128662109375, + "h": 18.2421875 + }, + { + "id": "e31", + "parent": "e25", + "kind": "control", + "text": "", + "x": 693, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e32", + "parent": "e31", + "kind": "text", + "text": "Human review", + "x": 705, + "y": 362, + "w": 106.870361328125, + "h": 18 + }, + { + "id": "e33", + "parent": "e25", + "kind": "control", + "text": "", + "x": 838, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Allow", + "x": 850, + "y": 362, + "w": 40.917236328125, + "h": 18 + }, + { + "id": "e35", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 405, + "w": 1048, + "h": 43 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "write_note", + "x": 342, + "y": 418, + "w": 72.59375, + "h": 18 + }, + { + "id": "e37", + "parent": "e35", + "kind": "text", + "text": "Deny", + "x": 904.4000244140625, + "y": 418, + "w": 35.140625, + "h": 18 + }, + { + "id": "e38", + "parent": "e25", + "kind": "text", + "text": "Unsaved changes", + "x": 328, + "y": 463, + "w": 114.6875, + "h": 17 + }, + { + "id": "e39", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 510, + "w": 180, + "h": 38 + }, + { + "id": "e40", + "parent": "e39", + "kind": "text", + "text": "Retry save", + "x": 340, + "y": 520, + "w": 78.703125, + "h": 18.2421875 + }, + { + "id": "e41", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 510, + "w": 144, + "h": 38 + }, + { + "id": "e42", + "parent": "e41", + "kind": "text", + "text": "Keep editing", + "x": 534, + "y": 520, + "w": 93.671875, + "h": 18.24609375 + } + ] + }, + { + "frame": "c-web-grant-2", + "elements": 33, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 129, + "w": 111.921875, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 184, + "w": 94.703125, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 240, + "w": 91.77972412109375, + "h": 18.2421875 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 296, + "w": 108.97503662109375, + "h": 18.2421875 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 304, + "y": 192, + "w": 163.921875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 190 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "read_note", + "x": 342, + "y": 309, + "w": 69.734375, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 904.4000244140625, + "y": 309, + "w": 133.265625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "write_note", + "x": 342, + "y": 352, + "w": 72.59375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Deny", + "x": 904.4000244140625, + "y": 352, + "w": 35.140625, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 409, + "w": 180, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Back to callers", + "x": 340, + "y": 419, + "w": 109.5625, + "h": 18 + } + ] + }, + { + "frame": "c-web-add-0", + "elements": 35, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 129, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 184, + "w": 89.80859375, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 240, + "w": 83.5, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 296, + "w": 146.82421875, + "h": 18.24609375 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 304, + "y": 192, + "w": 222.296875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 286 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Folder path on admin host", + "x": 328, + "y": 296, + "w": 184.671875, + "h": 18 + }, + { + "id": "e27", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 324, + "w": 1048, + "h": 42 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 340, + "y": 336, + "w": 224.609375, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "text", + "text": "Copy into managed tools", + "x": 328, + "y": 381, + "w": 170.53125, + "h": 18 + }, + { + "id": "e30", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 453, + "w": 190, + "h": 38 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 340, + "y": 463, + "w": 133.46875, + "h": 18.08203125 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 505, + "w": 180, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Add from folder", + "x": 340, + "y": 515, + "w": 117.6484375, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 505, + "w": 144, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Cancel", + "x": 534, + "y": 515, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "c-web-add-1", + "elements": 37, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 129, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 184, + "w": 89.80859375, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 240, + "w": 83.5, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 296, + "w": 146.82421875, + "h": 18.24609375 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 304, + "y": 192, + "w": 261.484375, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 276 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Folder", + "x": 342, + "y": 309, + "w": 43.34375, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Added", + "x": 904.4000244140625, + "y": 309, + "w": 44.71875, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Process", + "x": 342, + "y": 352, + "w": 54.765625, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Stopped", + "x": 904.4000244140625, + "y": 352, + "w": 58.21875, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 382, + "w": 1048, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Broker catalog", + "x": 342, + "y": 395, + "w": 99.40625, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Not registered", + "x": 904.4000244140625, + "y": 395, + "w": 98.265625, + "h": 18 + }, + { + "id": "e35", + "parent": "e25", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 328, + "y": 446, + "w": 238.046875, + "h": 18 + }, + { + "id": "e36", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 495, + "w": 180, + "h": 38 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Start weather-demo", + "x": 340, + "y": 505, + "w": 151.1875, + "h": 18 + } + ] + }, + { + "frame": "c-web-add-2", + "elements": 37, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 129, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 184, + "w": 89.80859375, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 240, + "w": 83.5, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 296, + "w": 146.82421875, + "h": 18.24609375 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 304, + "y": 192, + "w": 266.25, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 254 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Broker", + "x": 342, + "y": 309, + "w": 44.875, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 904.4000244140625, + "y": 309, + "w": 97.265625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Catalog update", + "x": 342, + "y": 352, + "w": 104.78125, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "weather-demo", + "x": 904.4000244140625, + "y": 352, + "w": 100.109375, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 401, + "w": 1048, + "h": 42 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 342, + "y": 412, + "w": 303.859375, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 473, + "w": 210, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Restart local broker", + "x": 340, + "y": 483, + "w": 146.869140625, + "h": 18 + }, + { + "id": "e36", + "parent": "e25", + "kind": "control", + "text": "", + "x": 552, + "y": 473, + "w": 144, + "h": 38 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Not now", + "x": 564, + "y": 483, + "w": 62.260986328125, + "h": 18 + } + ] + }, + { + "frame": "c-web-recover-0", + "elements": 36, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 129, + "w": 127.515625, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 184, + "w": 118.234375, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 240, + "w": 128.4375, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 296, + "w": 131.609375, + "h": 18 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 304, + "y": 192, + "w": 376.046875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 234 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Process", + "x": 342, + "y": 309, + "w": 54.765625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 904.4000244140625, + "y": 309, + "w": 110.328125, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Runner record", + "x": 342, + "y": 352, + "w": 97.546875, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Running", + "x": 904.4000244140625, + "y": 352, + "w": 56.015625, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 328, + "y": 398, + "w": 168.484375, + "h": 18 + }, + { + "id": "e33", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 453, + "w": 180, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Review restart", + "x": 340, + "y": 463, + "w": 107.8125, + "h": 18 + }, + { + "id": "e35", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 453, + "w": 144, + "h": 38 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Refresh state", + "x": 534, + "y": 463, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "c-web-recover-1", + "elements": 35, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 129, + "w": 127.515625, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 184, + "w": 118.234375, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 240, + "w": 128.4375, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 296, + "w": 131.609375, + "h": 18 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 304, + "y": 192, + "w": 274.125, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 250 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Runner", + "x": 342, + "y": 309, + "w": 48.9375, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Process", + "x": 904.4000244140625, + "y": 309, + "w": 54.765625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 359, + "w": 1048, + "h": 66 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 342, + "y": 370, + "w": 332.609375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 342, + "y": 394, + "w": 241.375, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 469, + "w": 180, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Restart notes-demo", + "x": 340, + "y": 479, + "w": 150.375, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 469, + "w": 144, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Cancel", + "x": 534, + "y": 479, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "c-web-recover-2", + "elements": 31, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Admin", + "x": 110, + "y": 11, + "w": 125.34375, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 129, + "w": 127.515625, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 184, + "w": 118.234375, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 240, + "w": 128.4375, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 296, + "w": 131.609375, + "h": 18 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 304, + "y": 192, + "w": 339.171875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 228 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 66 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 342, + "y": 307, + "w": 251.890625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 342, + "y": 331, + "w": 303.203125, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 328, + "y": 387, + "w": 260.96875, + "h": 18 + }, + { + "id": "e30", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 447, + "w": 180, + "h": 38 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Refresh state", + "x": 340, + "y": 457, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "c-shell-grant-0", + "elements": 35, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 129, + "w": 111.921875, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 184, + "w": 94.703125, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 240, + "w": 91.77972412109375, + "h": 18.2421875 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 296, + "w": 108.97503662109375, + "h": 18.2421875 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 304, + "y": 192, + "w": 252.046875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 192 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Tool access", + "x": 342, + "y": 309, + "w": 80.390625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 904.4000244140625, + "y": 309, + "w": 170.859375, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Operation policy", + "x": 342, + "y": 352, + "w": 112.59375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Not granted", + "x": 904.4000244140625, + "y": 352, + "w": 82.09375, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 411, + "w": 180, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Enable notes-demo", + "x": 340, + "y": 421, + "w": 146.296875, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 411, + "w": 144, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Cancel", + "x": 534, + "y": 421, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "c-shell-grant-1", + "elements": 42, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 129, + "w": 111.921875, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 184, + "w": 94.703125, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 240, + "w": 91.77972412109375, + "h": 18.2421875 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 296, + "w": 108.97503662109375, + "h": 18.2421875 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 304, + "y": 192, + "w": 77.828125, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 291 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 294, + "w": 1048, + "h": 42 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 342, + "y": 305, + "w": 200.046875, + "h": 18 + }, + { + "id": "e28", + "parent": "e25", + "kind": "text", + "text": "read_note", + "x": 328, + "y": 361, + "w": 77.65625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "control", + "text": "", + "x": 548, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Deny", + "x": 560, + "y": 362, + "w": 38.63128662109375, + "h": 18.2421875 + }, + { + "id": "e31", + "parent": "e25", + "kind": "control", + "text": "", + "x": 693, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e32", + "parent": "e31", + "kind": "text", + "text": "Human review", + "x": 705, + "y": 362, + "w": 106.870361328125, + "h": 18 + }, + { + "id": "e33", + "parent": "e25", + "kind": "control", + "text": "", + "x": 838, + "y": 352, + "w": 136, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Allow", + "x": 850, + "y": 362, + "w": 40.917236328125, + "h": 18 + }, + { + "id": "e35", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 405, + "w": 1048, + "h": 43 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "write_note", + "x": 342, + "y": 418, + "w": 72.59375, + "h": 18 + }, + { + "id": "e37", + "parent": "e35", + "kind": "text", + "text": "Deny", + "x": 904.4000244140625, + "y": 418, + "w": 35.140625, + "h": 18 + }, + { + "id": "e38", + "parent": "e25", + "kind": "text", + "text": "Unsaved changes", + "x": 328, + "y": 463, + "w": 114.6875, + "h": 17 + }, + { + "id": "e39", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 510, + "w": 180, + "h": 38 + }, + { + "id": "e40", + "parent": "e39", + "kind": "text", + "text": "Retry save", + "x": 340, + "y": 520, + "w": 78.703125, + "h": 18.2421875 + }, + { + "id": "e41", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 510, + "w": 144, + "h": 38 + }, + { + "id": "e42", + "parent": "e41", + "kind": "text", + "text": "Keep editing", + "x": 534, + "y": 520, + "w": 93.671875, + "h": 18.24609375 + } + ] + }, + { + "frame": "c-shell-grant-2", + "elements": 33, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 129, + "w": 111.921875, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 184, + "w": 94.703125, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 240, + "w": 91.77972412109375, + "h": 18.2421875 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 296, + "w": 108.97503662109375, + "h": 18.2421875 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 163, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 304, + "y": 192, + "w": 163.921875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 237, + "w": 262.40625, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 190 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "read_note", + "x": 342, + "y": 309, + "w": 69.734375, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 904.4000244140625, + "y": 309, + "w": 133.265625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "write_note", + "x": 342, + "y": 352, + "w": 72.59375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Deny", + "x": 904.4000244140625, + "y": 352, + "w": 35.140625, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 409, + "w": 180, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Back to callers", + "x": 340, + "y": 419, + "w": 109.5625, + "h": 18 + } + ] + }, + { + "frame": "c-shell-add-0", + "elements": 35, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 129, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 184, + "w": 89.80859375, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 240, + "w": 83.5, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 296, + "w": 146.82421875, + "h": 18.24609375 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 304, + "y": 192, + "w": 222.296875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 286 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Folder path on admin host", + "x": 328, + "y": 296, + "w": 184.671875, + "h": 18 + }, + { + "id": "e27", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 324, + "w": 1048, + "h": 42 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 340, + "y": 336, + "w": 224.609375, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "text", + "text": "Copy into managed tools", + "x": 328, + "y": 381, + "w": 170.53125, + "h": 18 + }, + { + "id": "e30", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 453, + "w": 190, + "h": 38 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 340, + "y": 463, + "w": 133.46875, + "h": 18.08203125 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 505, + "w": 180, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Add from folder", + "x": 340, + "y": 515, + "w": 117.6484375, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 505, + "w": 144, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Cancel", + "x": 534, + "y": 515, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "c-shell-add-1", + "elements": 37, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 129, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 184, + "w": 89.80859375, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 240, + "w": 83.5, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 296, + "w": 146.82421875, + "h": 18.24609375 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 304, + "y": 192, + "w": 261.484375, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 276 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Folder", + "x": 342, + "y": 309, + "w": 43.34375, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Added", + "x": 904.4000244140625, + "y": 309, + "w": 44.71875, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Process", + "x": 342, + "y": 352, + "w": 54.765625, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Stopped", + "x": 904.4000244140625, + "y": 352, + "w": 58.21875, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 382, + "w": 1048, + "h": 43 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Broker catalog", + "x": 342, + "y": 395, + "w": 99.40625, + "h": 18 + }, + { + "id": "e34", + "parent": "e32", + "kind": "text", + "text": "Not registered", + "x": 904.4000244140625, + "y": 395, + "w": 98.265625, + "h": 18 + }, + { + "id": "e35", + "parent": "e25", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 328, + "y": 446, + "w": 238.046875, + "h": 18 + }, + { + "id": "e36", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 495, + "w": 180, + "h": 38 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Start weather-demo", + "x": 340, + "y": 505, + "w": 151.1875, + "h": 18 + } + ] + }, + { + "frame": "c-shell-add-2", + "elements": 37, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 129, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 184, + "w": 89.80859375, + "h": 18 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 240, + "w": 83.5, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 296, + "w": 146.82421875, + "h": 18.24609375 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 163, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 304, + "y": 192, + "w": 266.25, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 237, + "w": 194.53125, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 254 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Broker", + "x": 342, + "y": 309, + "w": 44.875, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 904.4000244140625, + "y": 309, + "w": 97.265625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Catalog update", + "x": 342, + "y": 352, + "w": 104.78125, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "weather-demo", + "x": 904.4000244140625, + "y": 352, + "w": 100.109375, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 401, + "w": 1048, + "h": 42 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 342, + "y": 412, + "w": 303.859375, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 473, + "w": 210, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Restart local broker", + "x": 340, + "y": 483, + "w": 146.869140625, + "h": 18 + }, + { + "id": "e36", + "parent": "e25", + "kind": "control", + "text": "", + "x": 552, + "y": 473, + "w": 144, + "h": 38 + }, + { + "id": "e37", + "parent": "e36", + "kind": "text", + "text": "Not now", + "x": 564, + "y": 483, + "w": 62.260986328125, + "h": 18 + } + ] + }, + { + "frame": "c-shell-recover-0", + "elements": 36, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 129, + "w": 127.515625, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 184, + "w": 118.234375, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 240, + "w": 128.4375, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 296, + "w": 131.609375, + "h": 18 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 304, + "y": 192, + "w": 376.046875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 234 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Process", + "x": 342, + "y": 309, + "w": 54.765625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 904.4000244140625, + "y": 309, + "w": 110.328125, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 339, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Runner record", + "x": 342, + "y": 352, + "w": 97.546875, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Running", + "x": 904.4000244140625, + "y": 352, + "w": 56.015625, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 328, + "y": 398, + "w": 168.484375, + "h": 18 + }, + { + "id": "e33", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 453, + "w": 180, + "h": 38 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Review restart", + "x": 340, + "y": 463, + "w": 107.8125, + "h": 18 + }, + { + "id": "e35", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 453, + "w": 144, + "h": 38 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Refresh state", + "x": 534, + "y": 463, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "c-shell-recover-1", + "elements": 35, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 129, + "w": 127.515625, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 184, + "w": 118.234375, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 240, + "w": 128.4375, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 296, + "w": 131.609375, + "h": 18 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 304, + "y": 192, + "w": 274.125, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 250 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Runner", + "x": 342, + "y": 309, + "w": 48.9375, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Process", + "x": 904.4000244140625, + "y": 309, + "w": 54.765625, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 359, + "w": 1048, + "h": 66 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 342, + "y": 370, + "w": 332.609375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 342, + "y": 394, + "w": 241.375, + "h": 18 + }, + { + "id": "e32", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 469, + "w": 180, + "h": 38 + }, + { + "id": "e33", + "parent": "e32", + "kind": "text", + "text": "Restart notes-demo", + "x": 340, + "y": 479, + "w": 150.375, + "h": 18 + }, + { + "id": "e34", + "parent": "e25", + "kind": "control", + "text": "", + "x": 522, + "y": 469, + "w": 144, + "h": 38 + }, + { + "id": "e35", + "parent": "e34", + "kind": "text", + "text": "Cancel", + "x": 534, + "y": 479, + "w": 51.546875, + "h": 18 + } + ] + }, + { + "frame": "c-shell-recover-2", + "elements": 31, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 40 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 14, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack \u00b7 desktop shell", + "x": 110, + "y": 11, + "w": 187.125, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 12, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 40, + "w": 1440, + "h": 64 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "\u25a4 Toolstack", + "x": 28, + "y": 58, + "w": 126.65499877929688, + "h": 27 + }, + { + "id": "e10", + "parent": "e8", + "kind": "text", + "text": "Dashboard", + "x": 285, + "y": 63, + "w": 86.171875, + "h": 20 + }, + { + "id": "e11", + "parent": "e8", + "kind": "text", + "text": "Tools", + "x": 430, + "y": 63, + "w": 41.734375, + "h": 20 + }, + { + "id": "e12", + "parent": "e8", + "kind": "text", + "text": "Config", + "x": 575, + "y": 63, + "w": 52.140625, + "h": 20 + }, + { + "id": "e13", + "parent": "e8", + "kind": "text", + "text": "Local admin", + "x": 1110, + "y": 64, + "w": 86.90625, + "h": 18 + }, + { + "id": "e14", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 104, + "w": 260, + "h": 796 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 129, + "w": 127.515625, + "h": 21 + }, + { + "id": "e16", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 174, + "w": 228, + "h": 38 + }, + { + "id": "e17", + "parent": "e16", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 184, + "w": 118.234375, + "h": 18.08203125 + }, + { + "id": "e18", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 230, + "w": 228, + "h": 38 + }, + { + "id": "e19", + "parent": "e18", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 240, + "w": 128.4375, + "h": 18 + }, + { + "id": "e20", + "parent": "e14", + "kind": "control", + "text": "", + "x": 16, + "y": 286, + "w": 228, + "h": 38 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 296, + "w": 131.609375, + "h": 18 + }, + { + "id": "e22", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 163, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e23", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 304, + "y": 192, + "w": 339.171875, + "h": 33 + }, + { + "id": "e24", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 237, + "w": 178.234375, + "h": 18 + }, + { + "id": "e25", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 276, + "w": 1096, + "h": 228 + }, + { + "id": "e26", + "parent": "e25", + "kind": "container", + "text": "", + "x": 328, + "y": 296, + "w": 1048, + "h": 66 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 342, + "y": 307, + "w": 251.890625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 342, + "y": 331, + "w": 303.203125, + "h": 18 + }, + { + "id": "e29", + "parent": "e25", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 328, + "y": 387, + "w": 260.96875, + "h": 18 + }, + { + "id": "e30", + "parent": "e25", + "kind": "control", + "text": "", + "x": 328, + "y": 447, + "w": 180, + "h": 38 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Refresh state", + "x": 340, + "y": 457, + "w": 100, + "h": 18 + } + ] + }, + { + "frame": "c-mac-grant-0", + "elements": 29, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 89, + "w": 111.921875, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 142, + "w": 84.46875, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 198, + "w": 81.78382873535156, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 254, + "w": 96.95277404785156, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Enable notes-demo", + "x": 304, + "y": 157, + "w": 224.625, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 192 + }, + { + "id": "e20", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 258, + "w": 1048, + "h": 43 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "Tool access", + "x": 342, + "y": 271, + "w": 80.390625, + "h": 18 + }, + { + "id": "e22", + "parent": "e20", + "kind": "text", + "text": "Disabled for writer-demo", + "x": 904.4000244140625, + "y": 271, + "w": 170.859375, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 301, + "w": 1048, + "h": 43 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "Operation policy", + "x": 342, + "y": 314, + "w": 112.59375, + "h": 18 + }, + { + "id": "e25", + "parent": "e23", + "kind": "text", + "text": "Not granted", + "x": 904.4000244140625, + "y": 314, + "w": 82.09375, + "h": 18 + }, + { + "id": "e26", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1196, + "y": 373, + "w": 180, + "h": 32 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Enable notes-demo", + "x": 1208, + "y": 381, + "w": 130.109375, + "h": 17 + }, + { + "id": "e28", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1038, + "y": 373, + "w": 144, + "h": 32 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Cancel", + "x": 1050, + "y": 381, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "c-mac-grant-1", + "elements": 36, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 89, + "w": 111.921875, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 142, + "w": 84.46875, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 198, + "w": 81.78382873535156, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 254, + "w": 96.95277404785156, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Policy", + "x": 304, + "y": 157, + "w": 69.328125, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 291 + }, + { + "id": "e20", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 256, + "w": 1048, + "h": 42 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "Save rejected \u00b7 Draft retained", + "x": 342, + "y": 267, + "w": 200.046875, + "h": 18 + }, + { + "id": "e22", + "parent": "e19", + "kind": "text", + "text": "read_note", + "x": 328, + "y": 323, + "w": 77.65625, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "control", + "text": "", + "x": 548, + "y": 314, + "w": 136, + "h": 32 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "Deny", + "x": 560, + "y": 322, + "w": 34.1715087890625, + "h": 17 + }, + { + "id": "e25", + "parent": "e19", + "kind": "control", + "text": "", + "x": 693, + "y": 314, + "w": 136, + "h": 32 + }, + { + "id": "e26", + "parent": "e25", + "kind": "text", + "text": "Human review", + "x": 705, + "y": 322, + "w": 94.875, + "h": 17 + }, + { + "id": "e27", + "parent": "e19", + "kind": "control", + "text": "", + "x": 838, + "y": 314, + "w": 136, + "h": 32 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Allow", + "x": 850, + "y": 322, + "w": 36.3125, + "h": 17 + }, + { + "id": "e29", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 367, + "w": 1048, + "h": 43 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "write_note", + "x": 342, + "y": 380, + "w": 72.59375, + "h": 18 + }, + { + "id": "e31", + "parent": "e29", + "kind": "text", + "text": "Deny", + "x": 904.4000244140625, + "y": 380, + "w": 35.140625, + "h": 18 + }, + { + "id": "e32", + "parent": "e19", + "kind": "text", + "text": "Unsaved changes", + "x": 328, + "y": 425, + "w": 114.6875, + "h": 17 + }, + { + "id": "e33", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1196, + "y": 472, + "w": 180, + "h": 32 + }, + { + "id": "e34", + "parent": "e33", + "kind": "text", + "text": "Retry save", + "x": 1208, + "y": 480, + "w": 70.09375, + "h": 17 + }, + { + "id": "e35", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1038, + "y": 472, + "w": 144, + "h": 32 + }, + { + "id": "e36", + "parent": "e35", + "kind": "text", + "text": "Keep editing", + "x": 1050, + "y": 480, + "w": 83.4375, + "h": 17 + } + ] + }, + { + "frame": "c-mac-grant-2", + "elements": 27, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Caller access", + "x": 24, + "y": 89, + "w": 111.921875, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Enable tool", + "x": 28, + "y": 142, + "w": 84.46875, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Edit policy", + "x": 28, + "y": 198, + "w": 81.78382873535156, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Saved policy", + "x": 28, + "y": 254, + "w": 96.95277404785156, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Callers / writer-demo", + "x": 304, + "y": 125, + "w": 157.6875, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Saved policy", + "x": 304, + "y": 157, + "w": 145.953125, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "writer-demo / notes-demo / read_note", + "x": 304, + "y": 199, + "w": 262.40625, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 190 + }, + { + "id": "e20", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 258, + "w": 1048, + "h": 43 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "read_note", + "x": 342, + "y": 271, + "w": 69.734375, + "h": 18 + }, + { + "id": "e22", + "parent": "e20", + "kind": "text", + "text": "Allow \u00b7 Saved 10:42", + "x": 904.4000244140625, + "y": 271, + "w": 133.265625, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 301, + "w": 1048, + "h": 43 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "write_note", + "x": 342, + "y": 314, + "w": 72.59375, + "h": 18 + }, + { + "id": "e25", + "parent": "e23", + "kind": "text", + "text": "Deny", + "x": 904.4000244140625, + "y": 314, + "w": 35.140625, + "h": 18 + }, + { + "id": "e26", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1196, + "y": 371, + "w": 180, + "h": 32 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Back to callers", + "x": 1208, + "y": 379, + "w": 97.703125, + "h": 17 + } + ] + }, + { + "frame": "c-mac-add-0", + "elements": 29, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 89, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 142, + "w": 79.93603515625, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 198, + "w": 74.546875, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 254, + "w": 130.80908203125, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Add a local folder", + "x": 304, + "y": 157, + "w": 198.15625, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 286 + }, + { + "id": "e20", + "parent": "e19", + "kind": "text", + "text": "Folder path on admin host", + "x": 328, + "y": 258, + "w": 184.671875, + "h": 18 + }, + { + "id": "e21", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 286, + "w": 1048, + "h": 42 + }, + { + "id": "e22", + "parent": "e21", + "kind": "text", + "text": "/srv/demo-tools/weather-demo", + "x": 340, + "y": 298, + "w": 224.609375, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "text", + "text": "Copy into managed tools", + "x": 328, + "y": 343, + "w": 170.53125, + "h": 18 + }, + { + "id": "e24", + "parent": "e19", + "kind": "control", + "text": "", + "x": 328, + "y": 415, + "w": 190, + "h": 32 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Import OpenAPI\u2026", + "x": 340, + "y": 423, + "w": 118.640625, + "h": 17 + }, + { + "id": "e26", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1196, + "y": 467, + "w": 180, + "h": 32 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Add from folder", + "x": 1208, + "y": 475, + "w": 104.6650390625, + "h": 17 + }, + { + "id": "e28", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1038, + "y": 467, + "w": 144, + "h": 32 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Cancel", + "x": 1050, + "y": 475, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "c-mac-add-1", + "elements": 31, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 89, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 142, + "w": 79.93603515625, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 198, + "w": 74.546875, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 254, + "w": 130.80908203125, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Start weather-demo", + "x": 304, + "y": 157, + "w": 233.015625, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 276 + }, + { + "id": "e20", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 258, + "w": 1048, + "h": 43 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "Folder", + "x": 342, + "y": 271, + "w": 43.34375, + "h": 18 + }, + { + "id": "e22", + "parent": "e20", + "kind": "text", + "text": "Added", + "x": 904.4000244140625, + "y": 271, + "w": 44.71875, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 301, + "w": 1048, + "h": 43 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "Process", + "x": 342, + "y": 314, + "w": 54.765625, + "h": 18 + }, + { + "id": "e25", + "parent": "e23", + "kind": "text", + "text": "Stopped", + "x": 904.4000244140625, + "y": 314, + "w": 58.21875, + "h": 18 + }, + { + "id": "e26", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 344, + "w": 1048, + "h": 43 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Broker catalog", + "x": 342, + "y": 357, + "w": 99.40625, + "h": 18 + }, + { + "id": "e28", + "parent": "e26", + "kind": "text", + "text": "Not registered", + "x": 904.4000244140625, + "y": 357, + "w": 98.265625, + "h": 18 + }, + { + "id": "e29", + "parent": "e19", + "kind": "text", + "text": "Process runner \u00b7 Trusted tools only", + "x": 328, + "y": 408, + "w": 238.046875, + "h": 18 + }, + { + "id": "e30", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1196, + "y": 457, + "w": 180, + "h": 32 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Start weather-demo", + "x": 1208, + "y": 465, + "w": 134.5, + "h": 17 + } + ] + }, + { + "frame": "c-mac-add-2", + "elements": 31, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Local folder", + "x": 24, + "y": 89, + "w": 98.33055114746094, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Add folder", + "x": 28, + "y": 142, + "w": 79.93603515625, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Start tool", + "x": 28, + "y": 198, + "w": 74.546875, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Register in broker", + "x": 28, + "y": 254, + "w": 130.80908203125, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Tools / weather-demo", + "x": 304, + "y": 125, + "w": 162.53125, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Restart local broker?", + "x": 304, + "y": 157, + "w": 237.34375, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Local admin / weather-demo", + "x": 304, + "y": 199, + "w": 194.53125, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 254 + }, + { + "id": "e20", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 258, + "w": 1048, + "h": 43 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "Broker", + "x": 342, + "y": 271, + "w": 44.875, + "h": 18 + }, + { + "id": "e22", + "parent": "e20", + "kind": "text", + "text": "127.0.0.1:8765", + "x": 904.4000244140625, + "y": 271, + "w": 97.265625, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 301, + "w": 1048, + "h": 43 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "Catalog update", + "x": 342, + "y": 314, + "w": 104.78125, + "h": 18 + }, + { + "id": "e25", + "parent": "e23", + "kind": "text", + "text": "weather-demo", + "x": 904.4000244140625, + "y": 314, + "w": 100.109375, + "h": 18 + }, + { + "id": "e26", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 363, + "w": 1048, + "h": 42 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Interrupts broker availability and active calls.", + "x": 342, + "y": 374, + "w": 303.859375, + "h": 18 + }, + { + "id": "e28", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1166, + "y": 435, + "w": 210, + "h": 32 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Restart local broker", + "x": 1178, + "y": 443, + "w": 130.848388671875, + "h": 17 + }, + { + "id": "e30", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1008, + "y": 435, + "w": 144, + "h": 32 + }, + { + "id": "e31", + "parent": "e30", + "kind": "text", + "text": "Not now", + "x": 1020, + "y": 443, + "w": 55.234375, + "h": 17 + } + ] + }, + { + "frame": "c-mac-recover-0", + "elements": 30, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 89, + "w": 127.515625, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 142, + "w": 105.546875, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 198, + "w": 114.484375, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 254, + "w": 117, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "notes-demo \u00b7 Process absent", + "x": 304, + "y": 157, + "w": 335.421875, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 234 + }, + { + "id": "e20", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 258, + "w": 1048, + "h": 43 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "Process", + "x": 342, + "y": 271, + "w": 54.765625, + "h": 18 + }, + { + "id": "e22", + "parent": "e20", + "kind": "text", + "text": "Not alive \u00b7 10:42", + "x": 904.4000244140625, + "y": 271, + "w": 110.328125, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 301, + "w": 1048, + "h": 43 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "Runner record", + "x": 342, + "y": 314, + "w": 97.546875, + "h": 18 + }, + { + "id": "e25", + "parent": "e23", + "kind": "text", + "text": "Running", + "x": 904.4000244140625, + "y": 314, + "w": 56.015625, + "h": 18 + }, + { + "id": "e26", + "parent": "e19", + "kind": "text", + "text": "Failure \u00b7 Process absent", + "x": 328, + "y": 360, + "w": 168.484375, + "h": 18 + }, + { + "id": "e27", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1196, + "y": 415, + "w": 180, + "h": 32 + }, + { + "id": "e28", + "parent": "e27", + "kind": "text", + "text": "Review restart", + "x": 1208, + "y": 423, + "w": 96.0625, + "h": 17 + }, + { + "id": "e29", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1038, + "y": 415, + "w": 144, + "h": 32 + }, + { + "id": "e30", + "parent": "e29", + "kind": "text", + "text": "Refresh state", + "x": 1050, + "y": 423, + "w": 89.09375, + "h": 17 + } + ] + }, + { + "frame": "c-mac-recover-1", + "elements": 29, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 89, + "w": 127.515625, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 142, + "w": 105.546875, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 198, + "w": 114.484375, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 254, + "w": 117, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Restart notes-demo?", + "x": 304, + "y": 157, + "w": 244.234375, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 250 + }, + { + "id": "e20", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 258, + "w": 1048, + "h": 43 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "Runner", + "x": 342, + "y": 271, + "w": 48.9375, + "h": 18 + }, + { + "id": "e22", + "parent": "e20", + "kind": "text", + "text": "Process", + "x": 904.4000244140625, + "y": 271, + "w": 54.765625, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 321, + "w": 1048, + "h": 66 + }, + { + "id": "e24", + "parent": "e23", + "kind": "text", + "text": "Stops the recorded instance; starts notes-demo.", + "x": 342, + "y": 332, + "w": 332.609375, + "h": 18 + }, + { + "id": "e25", + "parent": "e23", + "kind": "text", + "text": "Previous requests are not replayed.", + "x": 342, + "y": 356, + "w": 241.375, + "h": 18 + }, + { + "id": "e26", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1196, + "y": 431, + "w": 180, + "h": 32 + }, + { + "id": "e27", + "parent": "e26", + "kind": "text", + "text": "Restart notes-demo", + "x": 1208, + "y": 439, + "w": 133.78125, + "h": 17 + }, + { + "id": "e28", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1038, + "y": 431, + "w": 144, + "h": 32 + }, + { + "id": "e29", + "parent": "e28", + "kind": "text", + "text": "Cancel", + "x": 1050, + "y": 439, + "w": 45.84375, + "h": 17 + } + ] + }, + { + "frame": "c-mac-recover-2", + "elements": 25, + "errors": [], + "geometry": [ + { + "id": "e1", + "parent": "", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 900 + }, + { + "id": "e2", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 0, + "w": 1440, + "h": 64 + }, + { + "id": "e3", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 20, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e4", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 42, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e5", + "parent": "e2", + "kind": "decoration", + "text": "", + "x": 64, + "y": 20, + "w": 12, + "h": 12 + }, + { + "id": "e6", + "parent": "e2", + "kind": "text", + "text": "Toolstack Operator", + "x": 110, + "y": 23, + "w": 145.0625, + "h": 18 + }, + { + "id": "e7", + "parent": "e2", + "kind": "text", + "text": "Design mockup \u00b7 02", + "x": 1130, + "y": 24, + "w": 128.796875, + "h": 17 + }, + { + "id": "e8", + "parent": "e1", + "kind": "container", + "text": "", + "x": 0, + "y": 64, + "w": 260, + "h": 836 + }, + { + "id": "e9", + "parent": "e8", + "kind": "text", + "text": "Crash recovery", + "x": 24, + "y": 89, + "w": 127.515625, + "h": 21 + }, + { + "id": "e10", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 134, + "w": 228, + "h": 32 + }, + { + "id": "e11", + "parent": "e10", + "kind": "text", + "text": "1 Inspect failure", + "x": 28, + "y": 142, + "w": 105.546875, + "h": 17 + }, + { + "id": "e12", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 190, + "w": 228, + "h": 32 + }, + { + "id": "e13", + "parent": "e12", + "kind": "text", + "text": "2 Confirm restart", + "x": 28, + "y": 198, + "w": 114.484375, + "h": 17 + }, + { + "id": "e14", + "parent": "e8", + "kind": "control", + "text": "", + "x": 16, + "y": 246, + "w": 228, + "h": 32 + }, + { + "id": "e15", + "parent": "e14", + "kind": "text", + "text": "3 Check outcome", + "x": 28, + "y": 254, + "w": 117, + "h": 17 + }, + { + "id": "e16", + "parent": "e1", + "kind": "text", + "text": "Tools / notes-demo", + "x": 304, + "y": 125, + "w": 144.28125, + "h": 18.08203125 + }, + { + "id": "e17", + "parent": "e1", + "kind": "text", + "text": "Restart outcome unknown", + "x": 304, + "y": 157, + "w": 302.3125, + "h": 29 + }, + { + "id": "e18", + "parent": "e1", + "kind": "text", + "text": "Local admin / notes-demo", + "x": 304, + "y": 199, + "w": 178.234375, + "h": 18 + }, + { + "id": "e19", + "parent": "e1", + "kind": "container", + "text": "", + "x": 304, + "y": 238, + "w": 1096, + "h": 228 + }, + { + "id": "e20", + "parent": "e19", + "kind": "container", + "text": "", + "x": 328, + "y": 258, + "w": 1048, + "h": 66 + }, + { + "id": "e21", + "parent": "e20", + "kind": "text", + "text": "Connection lost before restart result.", + "x": 342, + "y": 269, + "w": 251.890625, + "h": 18 + }, + { + "id": "e22", + "parent": "e20", + "kind": "text", + "text": "Outcome unknown \u00b7 Refresh before retrying.", + "x": 342, + "y": 293, + "w": 303.203125, + "h": 18 + }, + { + "id": "e23", + "parent": "e19", + "kind": "text", + "text": "Last observed: process absent \u00b7 10:42", + "x": 328, + "y": 349, + "w": 260.96875, + "h": 18 + }, + { + "id": "e24", + "parent": "e19", + "kind": "control", + "text": "", + "x": 1196, + "y": 409, + "w": 180, + "h": 32 + }, + { + "id": "e25", + "parent": "e24", + "kind": "text", + "text": "Refresh state", + "x": 1208, + "y": 417, + "w": 89.09375, + "h": 17 + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/ui-directions/evidence/macapp.json b/docs/ui-directions/evidence/macapp.json new file mode 100644 index 0000000..8e43262 --- /dev/null +++ b/docs/ui-directions/evidence/macapp.json @@ -0,0 +1,24 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 0, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [ + { + "file": "Sources/ToolstackApp/AppModel.swift", + "call_sites": 2 + } + ] + } +} diff --git a/docs/ui-directions/evidence/render-results.json b/docs/ui-directions/evidence/render-results.json new file mode 100644 index 0000000..355504a --- /dev/null +++ b/docs/ui-directions/evidence/render-results.json @@ -0,0 +1,61 @@ +[ + { + "frame": "a-web-grant-1", + "returncode": 0, + "png_exists": true, + "stderr": "[89419:42610694:1024/033854.966996:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89419:42610694:1024/033854.967317:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89419:42610694:1024/033854.967380:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89419:42610694:1024/033854.967429:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89419:42610694:1024/033854.967477:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89419:42610694:1024/033854.967548:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89419:42610694:1024/033854.967611:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\nTrying to load the allocator multiple times. This is *not* supported.\n50360 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/a-web-grant-1.png\n[89415:42610644:1024/033855.092325:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89415:42610644:1024/033855.092337:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n" + }, + { + "frame": "a-shell-add-2", + "returncode": 0, + "png_exists": true, + "stderr": "[89435:42610981:1024/033855.352987:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89435:42610981:1024/033855.353293:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89435:42610981:1024/033855.353352:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89435:42610981:1024/033855.353417:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89435:42610981:1024/033855.353477:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89435:42610981:1024/033855.353521:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\nTrying to load the allocator multiple times. This is *not* supported.\n[89431:42610943:1024/033855.472875:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89431:42610943:1024/033855.472887:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n51932 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/a-shell-add-2.png\n[89431:42610943:1024/033855.479566:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89431:42610943:1024/033855.479575:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n" + }, + { + "frame": "a-mac-recover-0", + "returncode": 0, + "png_exists": true, + "stderr": "Trying to load the allocator multiple times. This is *not* supported.\n[89448:42611163:1024/033855.763941:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89448:42611163:1024/033855.764257:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89448:42611163:1024/033855.764322:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89448:42611163:1024/033855.764385:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89448:42611163:1024/033855.764435:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89448:42611163:1024/033855.764490:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n43724 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/a-mac-recover-0.png\n[89444:42611125:1024/033855.854884:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89444:42611125:1024/033855.854893:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n" + }, + { + "frame": "b-web-grant-1", + "returncode": 0, + "png_exists": true, + "stderr": "[89478:42611405:1024/033856.136928:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89478:42611405:1024/033856.137251:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89478:42611405:1024/033856.137314:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89478:42611405:1024/033856.137373:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89478:42611405:1024/033856.137434:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89478:42611405:1024/033856.137480:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\nTrying to load the allocator multiple times. This is *not* supported.\n59196 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/b-web-grant-1.png\n[89462:42611367:1024/033856.265932:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89462:42611367:1024/033856.265941:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n" + }, + { + "frame": "b-shell-add-2", + "returncode": 0, + "png_exists": true, + "stderr": "[89494:42611593:1024/033856.538369:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89494:42611593:1024/033856.538660:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89494:42611593:1024/033856.538707:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89494:42611593:1024/033856.538770:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89494:42611593:1024/033856.538816:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89494:42611593:1024/033856.538877:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\nTrying to load the allocator multiple times. This is *not* supported.\n[89490:42611560:1024/033856.659962:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89490:42611560:1024/033856.659972:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n62859 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/b-shell-add-2.png\n" + }, + { + "frame": "b-mac-recover-0", + "returncode": 0, + "png_exists": true, + "stderr": "[89507:42611781:1024/033856.927829:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89507:42611781:1024/033856.928170:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89507:42611781:1024/033856.928239:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89507:42611781:1024/033856.928291:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89507:42611781:1024/033856.928355:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89507:42611781:1024/033856.928408:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\nTrying to load the allocator multiple times. This is *not* supported.\n[89503:42611742:1024/033857.047515:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89503:42611742:1024/033857.047525:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n52571 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/b-mac-recover-0.png\n[89503:42611742:1024/033857.053982:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89503:42611742:1024/033857.053988:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n" + }, + { + "frame": "c-web-grant-1", + "returncode": 0, + "png_exists": true, + "stderr": "[89520:42611963:1024/033857.342209:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\nTrying to load the allocator multiple times. This is *not* supported.\n[89520:42611963:1024/033857.345580:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89520:42611963:1024/033857.347089:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89520:42611963:1024/033857.347164:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89520:42611963:1024/033857.347207:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89520:42611963:1024/033857.347246:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89516:42611928:1024/033857.479290:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89516:42611928:1024/033857.479306:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n61737 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/c-web-grant-1.png\n[89516:42611928:1024/033857.486078:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89516:42611928:1024/033857.486086:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n" + }, + { + "frame": "c-shell-add-2", + "returncode": 0, + "png_exists": true, + "stderr": "[89543:42612171:1024/033857.772701:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\nTrying to load the allocator multiple times. This is *not* supported.\n[89543:42612171:1024/033857.776435:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89543:42612171:1024/033857.776529:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89543:42612171:1024/033857.776598:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89543:42612171:1024/033857.776650:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89543:42612171:1024/033857.776700:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89536:42612133:1024/033857.894272:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89536:42612133:1024/033857.894283:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n63045 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/c-shell-add-2.png\n[89536:42612133:1024/033857.900798:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89536:42612133:1024/033857.900805:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n" + }, + { + "frame": "c-mac-recover-0", + "returncode": 0, + "png_exists": true, + "stderr": "[89564:42612372:1024/033858.184611:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89564:42612372:1024/033858.184900:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89564:42612372:1024/033858.184962:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89564:42612372:1024/033858.185014:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89564:42612372:1024/033858.185055:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89564:42612372:1024/033858.185103:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\nTrying to load the allocator multiple times. This is *not* supported.\n[89560:42612335:1024/033858.254629:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89560:42612335:1024/033858.254638:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n50186 bytes written to file /Users/batteryshark/Projects/workstack/toolstack/.worktrees/ui-design/docs/ui-directions/captures/c-mac-recover-0.png\n[89560:42612335:1024/033858.260469:ERROR:base/process/process_mac.cc:45] task_policy_set TASK_CATEGORY_POLICY: (os/kern) invalid argument (4)\n[89560:42612335:1024/033858.260476:ERROR:base/process/process_mac.cc:90] task_policy_set TASK_SUPPRESSION_POLICY: (os/kern) invalid argument (4)\n" + }, + { + "gallery_returncode": 0, + "gallery_populated": true, + "stderr": "Trying to load the allocator multiple times. This is *not* supported.\n[89577:42612552:1024/033858.542461:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89577:42612552:1024/033858.542755:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89577:42612552:1024/033858.542820:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89577:42612552:1024/033858.542863:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89577:42612552:1024/033858.542923:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n[89577:42612552:1024/033858.542966:ERROR:ui/display/mac/cv_display_link_mac.mm:188] CVDisplayLinkCreateWithCGDisplay failed. CVReturn: -6670\n" + } +] \ No newline at end of file diff --git a/docs/ui-directions/evidence/revision-02-mockups.json b/docs/ui-directions/evidence/revision-02-mockups.json new file mode 100644 index 0000000..c07a203 --- /dev/null +++ b/docs/ui-directions/evidence/revision-02-mockups.json @@ -0,0 +1,32 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [ + { + "file": "evidence/gallery-dom.html", + "inputs": 4, + "required": 0, + "labels": 4 + }, + { + "file": "gallery.html", + "inputs": 4, + "required": 0, + "labels": 4 + } + ] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 0, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [] + } +} diff --git a/docs/ui-directions/evidence/screens.json b/docs/ui-directions/evidence/screens.json new file mode 100644 index 0000000..b8cc16b --- /dev/null +++ b/docs/ui-directions/evidence/screens.json @@ -0,0 +1 @@ +{"a-web-grant-0": "01 \u00b7 Clear desk / web / grant / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "a-web-grant-1": "01 \u00b7 Clear desk / web / grant / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "a-web-grant-2": "01 \u00b7 Clear desk / web / grant / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "a-web-add-0": "01 \u00b7 Clear desk / web / add / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "a-web-add-1": "01 \u00b7 Clear desk / web / add / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "a-web-add-2": "01 \u00b7 Clear desk / web / add / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "a-web-recover-0": "01 \u00b7 Clear desk / web / recover / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "a-web-recover-1": "01 \u00b7 Clear desk / web / recover / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "a-web-recover-2": "01 \u00b7 Clear desk / web / recover / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state", "a-shell-grant-0": "01 \u00b7 Clear desk / shell / grant / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "a-shell-grant-1": "01 \u00b7 Clear desk / shell / grant / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "a-shell-grant-2": "01 \u00b7 Clear desk / shell / grant / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "a-shell-add-0": "01 \u00b7 Clear desk / shell / add / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "a-shell-add-1": "01 \u00b7 Clear desk / shell / add / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "a-shell-add-2": "01 \u00b7 Clear desk / shell / add / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "a-shell-recover-0": "01 \u00b7 Clear desk / shell / recover / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "a-shell-recover-1": "01 \u00b7 Clear desk / shell / recover / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "a-shell-recover-2": "01 \u00b7 Clear desk / shell / recover / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state", "a-mac-grant-0": "01 \u00b7 Clear desk / mac / grant / 0Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "a-mac-grant-1": "01 \u00b7 Clear desk / mac / grant / 1Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "a-mac-grant-2": "01 \u00b7 Clear desk / mac / grant / 2Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "a-mac-add-0": "01 \u00b7 Clear desk / mac / add / 0Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "a-mac-add-1": "01 \u00b7 Clear desk / mac / add / 1Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "a-mac-add-2": "01 \u00b7 Clear desk / mac / add / 2Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "a-mac-recover-0": "01 \u00b7 Clear desk / mac / recover / 0Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "a-mac-recover-1": "01 \u00b7 Clear desk / mac / recover / 1Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "a-mac-recover-2": "01 \u00b7 Clear desk / mac / recover / 2Toolstack OperatorDesign mockup \u00b7 02CallersToolsActivityConfigTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state", "b-web-grant-0": "02 \u00b7 Operator workbench / web / grant / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "b-web-grant-1": "02 \u00b7 Operator workbench / web / grant / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "b-web-grant-2": "02 \u00b7 Operator workbench / web / grant / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "b-web-add-0": "02 \u00b7 Operator workbench / web / add / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "b-web-add-1": "02 \u00b7 Operator workbench / web / add / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "b-web-add-2": "02 \u00b7 Operator workbench / web / add / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "b-web-recover-0": "02 \u00b7 Operator workbench / web / recover / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "b-web-recover-1": "02 \u00b7 Operator workbench / web / recover / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "b-web-recover-2": "02 \u00b7 Operator workbench / web / recover / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state", "b-shell-grant-0": "02 \u00b7 Operator workbench / shell / grant / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "b-shell-grant-1": "02 \u00b7 Operator workbench / shell / grant / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "b-shell-grant-2": "02 \u00b7 Operator workbench / shell / grant / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "b-shell-add-0": "02 \u00b7 Operator workbench / shell / add / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "b-shell-add-1": "02 \u00b7 Operator workbench / shell / add / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "b-shell-add-2": "02 \u00b7 Operator workbench / shell / add / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "b-shell-recover-0": "02 \u00b7 Operator workbench / shell / recover / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "b-shell-recover-1": "02 \u00b7 Operator workbench / shell / recover / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "b-shell-recover-2": "02 \u00b7 Operator workbench / shell / recover / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state", "b-mac-grant-0": "02 \u00b7 Operator workbench / mac / grant / 0Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "b-mac-grant-1": "02 \u00b7 Operator workbench / mac / grant / 1Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "b-mac-grant-2": "02 \u00b7 Operator workbench / mac / grant / 2Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "b-mac-add-0": "02 \u00b7 Operator workbench / mac / add / 0Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "b-mac-add-1": "02 \u00b7 Operator workbench / mac / add / 1Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "b-mac-add-2": "02 \u00b7 Operator workbench / mac / add / 2Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "b-mac-recover-0": "02 \u00b7 Operator workbench / mac / recover / 0Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "b-mac-recover-1": "02 \u00b7 Operator workbench / mac / recover / 1Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "b-mac-recover-2": "02 \u00b7 Operator workbench / mac / recover / 2Toolstack OperatorDesign mockup \u00b7 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state", "c-web-grant-0": "03 \u00b7 Guided ledger / web / grant / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "c-web-grant-1": "03 \u00b7 Guided ledger / web / grant / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "c-web-grant-2": "03 \u00b7 Guided ledger / web / grant / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "c-web-add-0": "03 \u00b7 Guided ledger / web / add / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "c-web-add-1": "03 \u00b7 Guided ledger / web / add / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "c-web-add-2": "03 \u00b7 Guided ledger / web / add / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "c-web-recover-0": "03 \u00b7 Guided ledger / web / recover / 0Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "c-web-recover-1": "03 \u00b7 Guided ledger / web / recover / 1Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "c-web-recover-2": "03 \u00b7 Guided ledger / web / recover / 2Toolstack AdminDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state", "c-shell-grant-0": "03 \u00b7 Guided ledger / shell / grant / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "c-shell-grant-1": "03 \u00b7 Guided ledger / shell / grant / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "c-shell-grant-2": "03 \u00b7 Guided ledger / shell / grant / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "c-shell-add-0": "03 \u00b7 Guided ledger / shell / add / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "c-shell-add-1": "03 \u00b7 Guided ledger / shell / add / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "c-shell-add-2": "03 \u00b7 Guided ledger / shell / add / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "c-shell-recover-0": "03 \u00b7 Guided ledger / shell / recover / 0Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "c-shell-recover-1": "03 \u00b7 Guided ledger / shell / recover / 1Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "c-shell-recover-2": "03 \u00b7 Guided ledger / shell / recover / 2Toolstack \u00b7 desktop shellDesign mockup \u00b7 02\u25a4 ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state", "c-mac-grant-0": "03 \u00b7 Guided ledger / mac / grant / 0Toolstack OperatorDesign mockup \u00b7 02Caller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel", "c-mac-grant-1": "03 \u00b7 Guided ledger / mac / grant / 1Toolstack OperatorDesign mockup \u00b7 02Caller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected \u00b7 Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing", "c-mac-grant-2": "03 \u00b7 Guided ledger / mac / grant / 2Toolstack OperatorDesign mockup \u00b7 02Caller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow \u00b7 Saved 10:42write_noteDenyBack to callers", "c-mac-add-0": "03 \u00b7 Guided ledger / mac / add / 0Toolstack OperatorDesign mockup \u00b7 02Local folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI\u2026Add from folderCancel", "c-mac-add-1": "03 \u00b7 Guided ledger / mac / add / 1Toolstack OperatorDesign mockup \u00b7 02Local folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner \u00b7 Trusted tools onlyStart weather-demo", "c-mac-add-2": "03 \u00b7 Guided ledger / mac / add / 2Toolstack OperatorDesign mockup \u00b7 02Local folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now", "c-mac-recover-0": "03 \u00b7 Guided ledger / mac / recover / 0Toolstack OperatorDesign mockup \u00b7 02Crash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demonotes-demo \u00b7 Process absentLocal admin / notes-demoProcessNot alive \u00b7 10:42Runner recordRunningFailure \u00b7 Process absentReview restartRefresh state", "c-mac-recover-1": "03 \u00b7 Guided ledger / mac / recover / 1Toolstack OperatorDesign mockup \u00b7 02Crash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel", "c-mac-recover-2": "03 \u00b7 Guided ledger / mac / recover / 2Toolstack OperatorDesign mockup \u00b7 02Crash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown \u00b7 Refresh before retrying.Last observed: process absent \u00b7 10:42Refresh state"} \ No newline at end of file diff --git a/docs/ui-directions/evidence/verification.json b/docs/ui-directions/evidence/verification.json new file mode 100644 index 0000000..15a745b --- /dev/null +++ b/docs/ui-directions/evidence/verification.json @@ -0,0 +1,54 @@ +{ + "iteration": 2, + "origin": "toolstack-ui-directions-20261024", + "principal_rejections": 0, + "svg_frames": 81, + "one_primary_per_frame": true, + "overview_inline_frames": 9, + "per_view_walkthroughs": 81, + "geometry_frames": 81, + "geometry_violations": 0, + "footer_regression_detected": true, + "gallery_js_syntax": "pass", + "product_diff": "empty", + "owned_paths_only": true, + "captures": { + "b-web-grant-1.png": { + "sha256": "51f1e2b7dab14d3468f7250814b7ad24c6c6a6b09dfc6da51db1418075f5ee5c", + "visually_inspected_fullsize": true + }, + "a-mac-recover-0.png": { + "sha256": "10d38efd014877af4938ad6917f87f10ef85866e405a8b42fa14c2c20846e4da", + "visually_inspected_fullsize": true + }, + "b-mac-recover-0.png": { + "sha256": "29198edf265349184ffc378d61a189b57d9208b053ba52258b79595f441e6d52", + "visually_inspected_fullsize": true + }, + "a-shell-add-2.png": { + "sha256": "1fab432255fa1e99f96277d401d8bf7754f756556a0a0ef799cc760a329bcd11", + "visually_inspected_fullsize": true + }, + "c-web-grant-1.png": { + "sha256": "8de95fafea3b3868edea8ed188ddb8c3f5e9d847fa9aaeadfc8843633495d50d", + "visually_inspected_fullsize": true + }, + "c-mac-recover-0.png": { + "sha256": "50999f5767333509a771e224275268aff2c8bbaba9c434a4add95854f816ed34", + "visually_inspected_fullsize": true + }, + "a-web-grant-1.png": { + "sha256": "1507577ab3fcb43439fb7dbfef2dfbbd67e2ca2170399524c6feed492f2b5c06", + "visually_inspected_fullsize": true + }, + "c-shell-add-2.png": { + "sha256": "f6790f844101993e57ad27fe78d6f16e0e815cc89792e6136639505975581593", + "visually_inspected_fullsize": true + }, + "b-shell-add-2.png": { + "sha256": "5b96f6e3c0b2227983cdbba77069745f7440153bcc333f72efdf86d10749996e", + "visually_inspected_fullsize": true + } + }, + "limits": "Static geometry and visual mockup checks; no measured usability, native execution, product runtime or laptop reflow certification." +} diff --git a/docs/ui-directions/gallery.html b/docs/ui-directions/gallery.html new file mode 100644 index 0000000..b9d1aad --- /dev/null +++ b/docs/ui-directions/gallery.html @@ -0,0 +1 @@ +Toolstack directions · revision 02

Toolstack / Directions · revision 02

Comparison overview

\ No newline at end of file diff --git a/docs/ui-directions/iterations/01-superseded.zip b/docs/ui-directions/iterations/01-superseded.zip new file mode 100644 index 0000000..ba52ce8 Binary files /dev/null and b/docs/ui-directions/iterations/01-superseded.zip differ diff --git a/docs/ui-directions/overview.html b/docs/ui-directions/overview.html new file mode 100644 index 0000000..7d8232d --- /dev/null +++ b/docs/ui-directions/overview.html @@ -0,0 +1 @@ +Toolstack comparison · revision 02

Toolstack / Three directions

Recommendation: 01 · Clear desk · Revision 02 · Selection pending

Full desktop-size gallery · all 81 frames

01 · Clear desk

Familiar navigation; lowest migration cost.
Tradeoff: Less persistent collection context.

01 · Clear desk / web / grant / 1Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing
Admin web · rejected policy save ↗
01 · Clear desk / shell / add / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now
Desktop shell · broker restart ↗
01 · Clear desk / mac / recover / 0Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state
Native Mac · crashed tool ↗

02 · Operator workbench

Selected entity stays visible.
Tradeoff: More width and scanning.

02 · Operator workbench / web / grant / 1Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing
Admin web · rejected policy save ↗
02 · Operator workbench / shell / add / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now
Desktop shell · broker restart ↗
02 · Operator workbench / mac / recover / 0Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state
Native Mac · crashed tool ↗

03 · Guided ledger

Explicit task sequence.
Tradeoff: More stage navigation for experts.

03 · Guided ledger / web / grant / 1Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing
Admin web · rejected policy save ↗
03 · Guided ledger / shell / add / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now
Desktop shell · broker restart ↗
03 · Guided ledger / mac / recover / 0Toolstack OperatorDesign mockup · 02Crash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state
Native Mac · crashed tool ↗
\ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-add-0.svg b/docs/ui-directions/visuals/a-mac-add-0.svg new file mode 100644 index 0000000..211360c --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-add-0.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / add / 0Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-add-1.svg b/docs/ui-directions/visuals/a-mac-add-1.svg new file mode 100644 index 0000000..7b6966d --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-add-1.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / add / 1Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-add-2.svg b/docs/ui-directions/visuals/a-mac-add-2.svg new file mode 100644 index 0000000..612e024 --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-add-2.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / add / 2Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-grant-0.svg b/docs/ui-directions/visuals/a-mac-grant-0.svg new file mode 100644 index 0000000..9d41041 --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-grant-0.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / grant / 0Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-grant-1.svg b/docs/ui-directions/visuals/a-mac-grant-1.svg new file mode 100644 index 0000000..de88694 --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-grant-1.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / grant / 1Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-grant-2.svg b/docs/ui-directions/visuals/a-mac-grant-2.svg new file mode 100644 index 0000000..f44e0ae --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-grant-2.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / grant / 2Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-recover-0.svg b/docs/ui-directions/visuals/a-mac-recover-0.svg new file mode 100644 index 0000000..4846667 --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-recover-0.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / recover / 0Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-recover-1.svg b/docs/ui-directions/visuals/a-mac-recover-1.svg new file mode 100644 index 0000000..5ce8acb --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-recover-1.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / recover / 1Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-mac-recover-2.svg b/docs/ui-directions/visuals/a-mac-recover-2.svg new file mode 100644 index 0000000..485c06d --- /dev/null +++ b/docs/ui-directions/visuals/a-mac-recover-2.svg @@ -0,0 +1 @@ +01 · Clear desk / mac / recover / 2Toolstack OperatorDesign mockup · 02CallersToolsActivityConfigTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-add-0.svg b/docs/ui-directions/visuals/a-shell-add-0.svg new file mode 100644 index 0000000..21b72b6 --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-add-0.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / add / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-add-1.svg b/docs/ui-directions/visuals/a-shell-add-1.svg new file mode 100644 index 0000000..c7c2566 --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-add-1.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / add / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-add-2.svg b/docs/ui-directions/visuals/a-shell-add-2.svg new file mode 100644 index 0000000..7ed4781 --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-add-2.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / add / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-grant-0.svg b/docs/ui-directions/visuals/a-shell-grant-0.svg new file mode 100644 index 0000000..e38eef0 --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-grant-0.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / grant / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-grant-1.svg b/docs/ui-directions/visuals/a-shell-grant-1.svg new file mode 100644 index 0000000..d8cb8b0 --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-grant-1.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / grant / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-grant-2.svg b/docs/ui-directions/visuals/a-shell-grant-2.svg new file mode 100644 index 0000000..f9e03c8 --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-grant-2.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / grant / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-recover-0.svg b/docs/ui-directions/visuals/a-shell-recover-0.svg new file mode 100644 index 0000000..ac12691 --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-recover-0.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / recover / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-recover-1.svg b/docs/ui-directions/visuals/a-shell-recover-1.svg new file mode 100644 index 0000000..1a6a182 --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-recover-1.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / recover / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-shell-recover-2.svg b/docs/ui-directions/visuals/a-shell-recover-2.svg new file mode 100644 index 0000000..ba5ab4b --- /dev/null +++ b/docs/ui-directions/visuals/a-shell-recover-2.svg @@ -0,0 +1 @@ +01 · Clear desk / shell / recover / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-add-0.svg b/docs/ui-directions/visuals/a-web-add-0.svg new file mode 100644 index 0000000..41857e7 --- /dev/null +++ b/docs/ui-directions/visuals/a-web-add-0.svg @@ -0,0 +1 @@ +01 · Clear desk / web / add / 0Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-add-1.svg b/docs/ui-directions/visuals/a-web-add-1.svg new file mode 100644 index 0000000..2f28fe9 --- /dev/null +++ b/docs/ui-directions/visuals/a-web-add-1.svg @@ -0,0 +1 @@ +01 · Clear desk / web / add / 1Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-add-2.svg b/docs/ui-directions/visuals/a-web-add-2.svg new file mode 100644 index 0000000..38bfbe9 --- /dev/null +++ b/docs/ui-directions/visuals/a-web-add-2.svg @@ -0,0 +1 @@ +01 · Clear desk / web / add / 2Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-grant-0.svg b/docs/ui-directions/visuals/a-web-grant-0.svg new file mode 100644 index 0000000..a5030c3 --- /dev/null +++ b/docs/ui-directions/visuals/a-web-grant-0.svg @@ -0,0 +1 @@ +01 · Clear desk / web / grant / 0Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-grant-1.svg b/docs/ui-directions/visuals/a-web-grant-1.svg new file mode 100644 index 0000000..c5c3b24 --- /dev/null +++ b/docs/ui-directions/visuals/a-web-grant-1.svg @@ -0,0 +1 @@ +01 · Clear desk / web / grant / 1Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-grant-2.svg b/docs/ui-directions/visuals/a-web-grant-2.svg new file mode 100644 index 0000000..94d7c78 --- /dev/null +++ b/docs/ui-directions/visuals/a-web-grant-2.svg @@ -0,0 +1 @@ +01 · Clear desk / web / grant / 2Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-recover-0.svg b/docs/ui-directions/visuals/a-web-recover-0.svg new file mode 100644 index 0000000..e372b2a --- /dev/null +++ b/docs/ui-directions/visuals/a-web-recover-0.svg @@ -0,0 +1 @@ +01 · Clear desk / web / recover / 0Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-recover-1.svg b/docs/ui-directions/visuals/a-web-recover-1.svg new file mode 100644 index 0000000..6327a1f --- /dev/null +++ b/docs/ui-directions/visuals/a-web-recover-1.svg @@ -0,0 +1 @@ +01 · Clear desk / web / recover / 1Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/a-web-recover-2.svg b/docs/ui-directions/visuals/a-web-recover-2.svg new file mode 100644 index 0000000..0e20a9b --- /dev/null +++ b/docs/ui-directions/visuals/a-web-recover-2.svg @@ -0,0 +1 @@ +01 · Clear desk / web / recover / 2Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-add-0.svg b/docs/ui-directions/visuals/b-mac-add-0.svg new file mode 100644 index 0000000..cca1c36 --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-add-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / add / 0Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-add-1.svg b/docs/ui-directions/visuals/b-mac-add-1.svg new file mode 100644 index 0000000..ad4853f --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-add-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / add / 1Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-add-2.svg b/docs/ui-directions/visuals/b-mac-add-2.svg new file mode 100644 index 0000000..f6bea37 --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-add-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / add / 2Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-grant-0.svg b/docs/ui-directions/visuals/b-mac-grant-0.svg new file mode 100644 index 0000000..8204c03 --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-grant-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / grant / 0Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-grant-1.svg b/docs/ui-directions/visuals/b-mac-grant-1.svg new file mode 100644 index 0000000..6d2fcc6 --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-grant-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / grant / 1Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-grant-2.svg b/docs/ui-directions/visuals/b-mac-grant-2.svg new file mode 100644 index 0000000..7171543 --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-grant-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / grant / 2Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-recover-0.svg b/docs/ui-directions/visuals/b-mac-recover-0.svg new file mode 100644 index 0000000..8a23002 --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-recover-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / recover / 0Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-recover-1.svg b/docs/ui-directions/visuals/b-mac-recover-1.svg new file mode 100644 index 0000000..c3978ad --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-recover-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / recover / 1Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-mac-recover-2.svg b/docs/ui-directions/visuals/b-mac-recover-2.svg new file mode 100644 index 0000000..4cb0418 --- /dev/null +++ b/docs/ui-directions/visuals/b-mac-recover-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / mac / recover / 2Toolstack OperatorDesign mockup · 02CallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-add-0.svg b/docs/ui-directions/visuals/b-shell-add-0.svg new file mode 100644 index 0000000..72d33ee --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-add-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / add / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-add-1.svg b/docs/ui-directions/visuals/b-shell-add-1.svg new file mode 100644 index 0000000..0c30f45 --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-add-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / add / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-add-2.svg b/docs/ui-directions/visuals/b-shell-add-2.svg new file mode 100644 index 0000000..da5d568 --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-add-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / add / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-grant-0.svg b/docs/ui-directions/visuals/b-shell-grant-0.svg new file mode 100644 index 0000000..c9a221a --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-grant-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / grant / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-grant-1.svg b/docs/ui-directions/visuals/b-shell-grant-1.svg new file mode 100644 index 0000000..cc87c94 --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-grant-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / grant / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-grant-2.svg b/docs/ui-directions/visuals/b-shell-grant-2.svg new file mode 100644 index 0000000..8e50ecb --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-grant-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / grant / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-recover-0.svg b/docs/ui-directions/visuals/b-shell-recover-0.svg new file mode 100644 index 0000000..05a5d5a --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-recover-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / recover / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-recover-1.svg b/docs/ui-directions/visuals/b-shell-recover-1.svg new file mode 100644 index 0000000..8b0ad78 --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-recover-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / recover / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-shell-recover-2.svg b/docs/ui-directions/visuals/b-shell-recover-2.svg new file mode 100644 index 0000000..6457c16 --- /dev/null +++ b/docs/ui-directions/visuals/b-shell-recover-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / shell / recover / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-add-0.svg b/docs/ui-directions/visuals/b-web-add-0.svg new file mode 100644 index 0000000..0ab606f --- /dev/null +++ b/docs/ui-directions/visuals/b-web-add-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / add / 0Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-add-1.svg b/docs/ui-directions/visuals/b-web-add-1.svg new file mode 100644 index 0000000..fd1655a --- /dev/null +++ b/docs/ui-directions/visuals/b-web-add-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / add / 1Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-add-2.svg b/docs/ui-directions/visuals/b-web-add-2.svg new file mode 100644 index 0000000..cdbaf74 --- /dev/null +++ b/docs/ui-directions/visuals/b-web-add-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / add / 2Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-grant-0.svg b/docs/ui-directions/visuals/b-web-grant-0.svg new file mode 100644 index 0000000..e4581db --- /dev/null +++ b/docs/ui-directions/visuals/b-web-grant-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / grant / 0Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-grant-1.svg b/docs/ui-directions/visuals/b-web-grant-1.svg new file mode 100644 index 0000000..def8434 --- /dev/null +++ b/docs/ui-directions/visuals/b-web-grant-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / grant / 1Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-grant-2.svg b/docs/ui-directions/visuals/b-web-grant-2.svg new file mode 100644 index 0000000..205e87f --- /dev/null +++ b/docs/ui-directions/visuals/b-web-grant-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / grant / 2Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigCallerswriter-demoreader-demoCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-recover-0.svg b/docs/ui-directions/visuals/b-web-recover-0.svg new file mode 100644 index 0000000..b20737b --- /dev/null +++ b/docs/ui-directions/visuals/b-web-recover-0.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / recover / 0Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-recover-1.svg b/docs/ui-directions/visuals/b-web-recover-1.svg new file mode 100644 index 0000000..c2ba228 --- /dev/null +++ b/docs/ui-directions/visuals/b-web-recover-1.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / recover / 1Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/b-web-recover-2.svg b/docs/ui-directions/visuals/b-web-recover-2.svg new file mode 100644 index 0000000..93e15c4 --- /dev/null +++ b/docs/ui-directions/visuals/b-web-recover-2.svg @@ -0,0 +1 @@ +02 · Operator workbench / web / recover / 2Toolstack AdminDesign mockup · 02▤ ToolstackLocal adminCallersToolsRequestsConfigToolsweather-demonotes-demoecho-demoTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-add-0.svg b/docs/ui-directions/visuals/c-mac-add-0.svg new file mode 100644 index 0000000..7dbdfe0 --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-add-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / add / 0Toolstack OperatorDesign mockup · 02Local folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-add-1.svg b/docs/ui-directions/visuals/c-mac-add-1.svg new file mode 100644 index 0000000..9b3b9e2 --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-add-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / add / 1Toolstack OperatorDesign mockup · 02Local folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-add-2.svg b/docs/ui-directions/visuals/c-mac-add-2.svg new file mode 100644 index 0000000..cf51afc --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-add-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / add / 2Toolstack OperatorDesign mockup · 02Local folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-grant-0.svg b/docs/ui-directions/visuals/c-mac-grant-0.svg new file mode 100644 index 0000000..f2f96b3 --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-grant-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / grant / 0Toolstack OperatorDesign mockup · 02Caller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-grant-1.svg b/docs/ui-directions/visuals/c-mac-grant-1.svg new file mode 100644 index 0000000..f3cce6d --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-grant-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / grant / 1Toolstack OperatorDesign mockup · 02Caller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-grant-2.svg b/docs/ui-directions/visuals/c-mac-grant-2.svg new file mode 100644 index 0000000..315d564 --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-grant-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / grant / 2Toolstack OperatorDesign mockup · 02Caller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-recover-0.svg b/docs/ui-directions/visuals/c-mac-recover-0.svg new file mode 100644 index 0000000..2c14f50 --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-recover-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / recover / 0Toolstack OperatorDesign mockup · 02Crash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-recover-1.svg b/docs/ui-directions/visuals/c-mac-recover-1.svg new file mode 100644 index 0000000..8e50976 --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-recover-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / recover / 1Toolstack OperatorDesign mockup · 02Crash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-mac-recover-2.svg b/docs/ui-directions/visuals/c-mac-recover-2.svg new file mode 100644 index 0000000..45e9ed6 --- /dev/null +++ b/docs/ui-directions/visuals/c-mac-recover-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / mac / recover / 2Toolstack OperatorDesign mockup · 02Crash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-add-0.svg b/docs/ui-directions/visuals/c-shell-add-0.svg new file mode 100644 index 0000000..cd0feaa --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-add-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / add / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-add-1.svg b/docs/ui-directions/visuals/c-shell-add-1.svg new file mode 100644 index 0000000..d5ce2fa --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-add-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / add / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-add-2.svg b/docs/ui-directions/visuals/c-shell-add-2.svg new file mode 100644 index 0000000..ad399c2 --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-add-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / add / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-grant-0.svg b/docs/ui-directions/visuals/c-shell-grant-0.svg new file mode 100644 index 0000000..a5c1849 --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-grant-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / grant / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-grant-1.svg b/docs/ui-directions/visuals/c-shell-grant-1.svg new file mode 100644 index 0000000..c400031 --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-grant-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / grant / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-grant-2.svg b/docs/ui-directions/visuals/c-shell-grant-2.svg new file mode 100644 index 0000000..e5b7b37 --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-grant-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / grant / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-recover-0.svg b/docs/ui-directions/visuals/c-shell-recover-0.svg new file mode 100644 index 0000000..da0d93b --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-recover-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / recover / 0Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-recover-1.svg b/docs/ui-directions/visuals/c-shell-recover-1.svg new file mode 100644 index 0000000..31111a3 --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-recover-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / recover / 1Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-shell-recover-2.svg b/docs/ui-directions/visuals/c-shell-recover-2.svg new file mode 100644 index 0000000..e832f0b --- /dev/null +++ b/docs/ui-directions/visuals/c-shell-recover-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / shell / recover / 2Toolstack · desktop shellDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-add-0.svg b/docs/ui-directions/visuals/c-web-add-0.svg new file mode 100644 index 0000000..c6744d2 --- /dev/null +++ b/docs/ui-directions/visuals/c-web-add-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / add / 0Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoAdd a local folderLocal admin / weather-demoFolder path on admin host/srv/demo-tools/weather-demoCopy into managed toolsImport OpenAPI…Add from folderCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-add-1.svg b/docs/ui-directions/visuals/c-web-add-1.svg new file mode 100644 index 0000000..f389db6 --- /dev/null +++ b/docs/ui-directions/visuals/c-web-add-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / add / 1Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoStart weather-demoLocal admin / weather-demoFolderAddedProcessStoppedBroker catalogNot registeredProcess runner · Trusted tools onlyStart weather-demo \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-add-2.svg b/docs/ui-directions/visuals/c-web-add-2.svg new file mode 100644 index 0000000..bc65550 --- /dev/null +++ b/docs/ui-directions/visuals/c-web-add-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / add / 2Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminLocal folder1 Add folder2 Start tool3 Register in brokerTools / weather-demoRestart local broker?Local admin / weather-demoBroker127.0.0.1:8765Catalog updateweather-demoInterrupts broker availability and active calls.Restart local brokerNot now \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-grant-0.svg b/docs/ui-directions/visuals/c-web-grant-0.svg new file mode 100644 index 0000000..0bc5e36 --- /dev/null +++ b/docs/ui-directions/visuals/c-web-grant-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / grant / 0Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoEnable notes-demowriter-demo / notes-demo / read_noteTool accessDisabled for writer-demoOperation policyNot grantedEnable notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-grant-1.svg b/docs/ui-directions/visuals/c-web-grant-1.svg new file mode 100644 index 0000000..9ab20a4 --- /dev/null +++ b/docs/ui-directions/visuals/c-web-grant-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / grant / 1Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoPolicywriter-demo / notes-demo / read_noteSave rejected · Draft retainedread_noteDenyHuman reviewAllowwrite_noteDenyUnsaved changesRetry saveKeep editing \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-grant-2.svg b/docs/ui-directions/visuals/c-web-grant-2.svg new file mode 100644 index 0000000..7516312 --- /dev/null +++ b/docs/ui-directions/visuals/c-web-grant-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / grant / 2Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCaller access1 Enable tool2 Edit policy3 Saved policyCallers / writer-demoSaved policywriter-demo / notes-demo / read_noteread_noteAllow · Saved 10:42write_noteDenyBack to callers \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-recover-0.svg b/docs/ui-directions/visuals/c-web-recover-0.svg new file mode 100644 index 0000000..a5d122c --- /dev/null +++ b/docs/ui-directions/visuals/c-web-recover-0.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / recover / 0Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demonotes-demo · Process absentLocal admin / notes-demoProcessNot alive · 10:42Runner recordRunningFailure · Process absentReview restartRefresh state \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-recover-1.svg b/docs/ui-directions/visuals/c-web-recover-1.svg new file mode 100644 index 0000000..657bac5 --- /dev/null +++ b/docs/ui-directions/visuals/c-web-recover-1.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / recover / 1Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart notes-demo?Local admin / notes-demoRunnerProcessStops the recorded instance; starts notes-demo.Previous requests are not replayed.Restart notes-demoCancel \ No newline at end of file diff --git a/docs/ui-directions/visuals/c-web-recover-2.svg b/docs/ui-directions/visuals/c-web-recover-2.svg new file mode 100644 index 0000000..283963f --- /dev/null +++ b/docs/ui-directions/visuals/c-web-recover-2.svg @@ -0,0 +1 @@ +03 · Guided ledger / web / recover / 2Toolstack AdminDesign mockup · 02▤ ToolstackDashboardToolsConfigLocal adminCrash recovery1 Inspect failure2 Confirm restart3 Check outcomeTools / notes-demoRestart outcome unknownLocal admin / notes-demoConnection lost before restart result.Outcome unknown · Refresh before retrying.Last observed: process absent · 10:42Refresh state \ No newline at end of file diff --git a/docs/ui-directions/walkthrough.md b/docs/ui-directions/walkthrough.md new file mode 100644 index 0000000..e1b5cd9 --- /dev/null +++ b/docs/ui-directions/walkthrough.md @@ -0,0 +1,979 @@ +# Revision 02 — per-view Norman walkthrough + +All 81 views are recorded below. These are source-design counts, not observed usability. Pictured SVG controls do not execute product actions. One primary activation advances each populated fixture; add-folder requires an additional field entry from blank. Full proposed paths remain grant 6, add/start/register 7, recover 5, including navigation and final verification not all depicted. Reading, waits, retries and login are excluded. No speed or reduction claim. + +Q1 earned place; Q2 findable signifier; Q3 source-counted task cost and primary; Q4 visible state; Q5 first use / hundredth use / interruption. Interruption behavior is a design requirement, not persistence evidence. + +A entry: familiar top navigation / native tabs. B entry: persistent collection selection / native source list. C entry: task-stage rail / native source list. Shell content is the web composition. No independent shell policy model. + +## a-web-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## a-web-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## a-web-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## a-web-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## a-web-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## a-web-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## a-web-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## a-web-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## a-web-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. + +## a-shell-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## a-shell-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## a-shell-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## a-shell-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## a-shell-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## a-shell-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## a-shell-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## a-shell-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## a-shell-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. + +## a-mac-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## a-mac-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## a-mac-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## a-mac-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## a-mac-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## a-mac-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## a-mac-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## a-mac-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## a-mac-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. + +## b-web-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## b-web-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## b-web-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## b-web-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## b-web-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## b-web-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## b-web-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## b-web-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## b-web-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. + +## b-shell-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## b-shell-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## b-shell-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## b-shell-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## b-shell-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## b-shell-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## b-shell-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## b-shell-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## b-shell-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. + +## b-mac-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## b-mac-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## b-mac-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## b-mac-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## b-mac-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## b-mac-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## b-mac-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## b-mac-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## b-mac-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. + +## c-web-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## c-web-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## c-web-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## c-web-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## c-web-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## c-web-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## c-web-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## c-web-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## c-web-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. + +## c-shell-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## c-shell-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## c-shell-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## c-shell-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## c-shell-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## c-shell-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## c-shell-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## c-shell-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## c-shell-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. + +## c-mac-grant-0 + +- Q1: Enablement gate. + +- Q2: Named Enable button beside Disabled state. + +- Q3: **Enable notes-demo** — 1 primary activation from this populated frame. + +- Q4: Disabled → policy only on acknowledged enable. + +- Q5: First: Disabled label teaches gate. Hundredth: Direct enable opens policy. Interruption: Retain caller/tool on failed enable. + +## c-mac-grant-1 + +- Q1: Rejected-save recovery. + +- Q2: Retry save beside retained policy. + +- Q3: **Retry save** — 1 primary activation from this populated frame. + +- Q4: Save rejected; unsaved changes retained. + +- Q5: First: Named scope identifies the edit. Hundredth: Retry without reopening menu. Interruption: Stay in editor; keep draft on failure. + +## c-mac-grant-2 + +- Q1: Mutation receipt. + +- Q2: Back to callers below saved rows. + +- Q3: **Back to callers** — 1 primary activation from this populated frame. + +- Q4: Saved time and per-operation effects. + +- Q5: First: Receipt confirms exact grant. Hundredth: Return without rereading guidance. Interruption: Receipt stays tied to caller. + +## c-mac-add-0 + +- Q1: Host-path import. + +- Q2: Labeled path and Add from folder. + +- Q3: **Add from folder** — 1 primary activation from this populated frame; 2 including path entry from blank. + +- Q4: Populated host path; copy mode. + +- Q5: First: Host label avoids client-path ambiguity. Hundredth: Enter one path; no tutorial. Interruption: Keep path and import draft on failure. + +## c-mac-add-1 + +- Q1: Explicit code execution. + +- Q2: Start weather-demo below stopped state. + +- Q3: **Start weather-demo** — 1 primary activation from this populated frame. + +- Q4: Folder added; stopped; catalog unregistered. + +- Q5: First: Separate Start from import. Hundredth: Named Start is direct. Interruption: Preserve added folder on start failure. + +## c-mac-add-2 + +- Q1: Broker interruption decision. + +- Q2: Restart local broker beside Not now. + +- Q3: **Restart local broker** — 1 primary activation from this populated frame. + +- Q4: Broker endpoint and catalog target visible. + +- Q5: First: Consequence warning remains. Hundredth: One named confirmation. Interruption: Not now retains registration stage. + +## c-mac-recover-0 + +- Q1: Recover actual process failure. + +- Q2: Review restart under observed failure. + +- Q3: **Review restart** — 1 primary activation from this populated frame. + +- Q4: Not alive separate from running record. + +- Q5: First: Failure points to recovery. Hundredth: Immediate recovery entry. Interruption: Refresh retains selected tool. + +## c-mac-recover-1 + +- Q1: Target-specific restart consent. + +- Q2: Named Restart beside Cancel. + +- Q3: **Restart notes-demo** — 1 primary activation from this populated frame. + +- Q4: Stop/start consequence; no request replay. + +- Q5: First: Target and consequence before commit. Hundredth: One confirmation. Interruption: Cancel returns to same failed tool. + +## c-mac-recover-2 + +- Q1: Reconcile lost response. + +- Q2: Refresh state beside Unknown outcome. + +- Q3: **Refresh state** — 1 primary activation from this populated frame. + +- Q4: Connection lost; outcome unknown. + +- Q5: First: Unknown does not mean safe retry. Hundredth: Refresh rather than blind restart. Interruption: Retain last observation and target. diff --git a/docs/ui-directions/write_walkthrough.py b/docs/ui-directions/write_walkthrough.py new file mode 100644 index 0000000..529574d --- /dev/null +++ b/docs/ui-directions/write_walkthrough.py @@ -0,0 +1,25 @@ +"""Produce an explicit source-design Norman record for every revised view.""" +from build_gallery import ROOT, DIRECTIONS, FLOWS, PRIMARY + +notes={ + ('grant',0):('Enablement gate','Named Enable button beside Disabled state','Disabled → policy only on acknowledged enable','Disabled label teaches gate','Direct enable opens policy','Retain caller/tool on failed enable'), + ('grant',1):('Rejected-save recovery','Retry save beside retained policy','Save rejected; unsaved changes retained','Named scope identifies the edit','Retry without reopening menu','Stay in editor; keep draft on failure'), + ('grant',2):('Mutation receipt','Back to callers below saved rows','Saved time and per-operation effects','Receipt confirms exact grant','Return without rereading guidance','Receipt stays tied to caller'), + ('add',0):('Host-path import','Labeled path and Add from folder','Populated host path; copy mode','Host label avoids client-path ambiguity','Enter one path; no tutorial','Keep path and import draft on failure'), + ('add',1):('Explicit code execution','Start weather-demo below stopped state','Folder added; stopped; catalog unregistered','Separate Start from import','Named Start is direct','Preserve added folder on start failure'), + ('add',2):('Broker interruption decision','Restart local broker beside Not now','Broker endpoint and catalog target visible','Consequence warning remains','One named confirmation','Not now retains registration stage'), + ('recover',0):('Recover actual process failure','Review restart under observed failure','Not alive separate from running record','Failure points to recovery','Immediate recovery entry','Refresh retains selected tool'), + ('recover',1):('Target-specific restart consent','Named Restart beside Cancel','Stop/start consequence; no request replay','Target and consequence before commit','One confirmation','Cancel returns to same failed tool'), + ('recover',2):('Reconcile lost response','Refresh state beside Unknown outcome','Connection lost; outcome unknown','Unknown does not mean safe retry','Refresh rather than blind restart','Retain last observation and target')} +out=['# Revision 02 — per-view Norman walkthrough', + 'All 81 views are recorded below. These are source-design counts, not observed usability. Pictured SVG controls do not execute product actions. One primary activation advances each populated fixture; add-folder requires an additional field entry from blank. Full proposed paths remain grant 6, add/start/register 7, recover 5, including navigation and final verification not all depicted. Reading, waits, retries and login are excluded. No speed or reduction claim.', + 'Q1 earned place; Q2 findable signifier; Q3 source-counted task cost and primary; Q4 visible state; Q5 first use / hundredth use / interruption. Interruption behavior is a design requirement, not persistence evidence.', + 'A entry: familiar top navigation / native tabs. B entry: persistent collection selection / native source list. C entry: task-stage rail / native source list. Shell content is the web composition. No independent shell policy model.'] +for d in DIRECTIONS: + for s in ('web','shell','mac'): + for f in FLOWS: + for n in range(3): + key=f'{d}-{s}-{f}-{n}';earned,find,state,first,hundred,interrupt=notes[f,n] + out += [f'## {key}',f'- Q1: {earned}.',f'- Q2: {find}.',f'- Q3: **{PRIMARY[f,n]}** — 1 primary activation from this populated frame'+('; 2 including path entry from blank.' if (f,n)==('add',0) else '.'),f'- Q4: {state}.',f'- Q5: First: {first}. Hundredth: {hundred}. Interruption: {interrupt}.'] +(ROOT/'walkthrough.md').write_text('\n\n'.join(out)+'\n') +print('81 explicit per-view Norman records written') diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 000b8d0..80480d9 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -1,386 +1,70 @@ -# Toolstack: Walkthrough - -How Toolstack works end to end: the architecture, the request lifecycle, and the security -properties with the evidence behind them. Companion docs: [PROJECT.md](../PROJECT.md) -(design notes), [plan.md](../plan.md) (component design), -[component-decomposition.md](component-decomposition.md) (diagrams), and -[admin/README.md](../admin/README.md) (the control panel). - -## TL;DR - -Toolstack is a brokered tool layer for agents: the agent reaches one broker, which -authenticates it, checks policy, gates sensitive operations on human approval, and forwards -approved calls to tools that hold their own secrets. The agent-side `toolstack` CLI (with an -MCP adapter and a skill) sits on top, and an operator runs the whole stack from a loopback -admin app: web, plus a native macOS app and a desktop shell. The full vertical slice runs: - -> **agent → broker (auth + policy) → human approval in nod → tool execution with -> its own workload secrets, and the broker never sees a secret.** - -The broker, toolyard, and client are **zero-dependency stdlib Python** (Docker only -for the production tool runner), runnable with `python3`. The admin app is the one -component that carries runtime deps (FastAPI + uvicorn, in its own venv). It is -**hardened for a real single-host install** (systemd state-dir + sandbox, admin login -throttle + bind/SSRF guards, runtime timeouts + partial-failure cleanup, pinned deps, -DB backups; see [deploy/README.md](../deploy/README.md)); the narrower remaining items -are in [Deferred & caveats](#deferred--caveats). - -## Contents - -1. [The one idea](#the-one-idea) -2. [Architecture at a glance](#architecture-at-a-glance) -3. [What's built](#whats-built) -4. [The request lifecycle, end to end](#the-request-lifecycle-end-to-end) -5. [Security properties & evidence](#security-properties--evidence) -6. [Run it](#run-it) -7. [Test it](#test-it) -8. [Design decisions to confirm](#design-decisions-to-confirm) -9. [Deferred & caveats](#deferred--caveats) -10. [File map](#file-map) - ---- - -## The one idea - -> **Trust agents with action, not access.** The agent can *ask*; the broker -> *decides*; tools *execute*; secrets live with the tools. The agent can reach the -> broker and nothing else. - -Everything below serves that sentence. The security comes from **physical -boundaries** (one ingress, localhost binding, secrets resolved at the workload), -not from rules on paper. - ---- - -## Architecture at a glance - -```mermaid -flowchart TB - subgraph Untrusted["Agent host, untrusted"] - Agent["Agent / client
holds only a low-power broker token"] - end - Ingress["Tailnet / VPN ingress
(the ONLY path in)"] - subgraph BrokerHost["Broker host, authority boundary"] - Broker["Broker (one process)
auth · policy · request lifecycle
approval orchestration · audit"] - DB[("SQLite
callers · tokens · requests
approvals · audit")] - Admin["Admin web app
operator · loopback only
runs the broker · clients · tools · audit"] - end - subgraph Workload["Tool runtime, execution boundary"] - Toolyard["Toolyard
container lifecycle + secret resolution
(not in the request path)"] - Tools["Tool containers
127.0.0.1:port · secrets at /run/secrets"] - end - Nod["nod
approval surface (external)"] - Vault["Secret backend
Infisical / SOPS (external)"] - - Agent --> Ingress --> Broker - Broker --> DB - Broker -->|forward approved call| Tools - Toolyard -->|start + inject secrets| Tools - Broker <-->|open / read decision| Nod - Toolyard -->|resolve secrets| Vault - Broker -. never on the secret path .-> Vault - Admin -. supervise process .-> Broker - Admin --> DB -``` - -**Trust boundaries:** agent → broker (tailnet, one-caller bearer token); broker → -tool container (localhost); toolyard → secret backend (per-tool identity on the -host); tool → secret backend (none: it reads files); broker → nod (issuer token on -the broker host); operator → broker (`brokerctl`, or the **admin web app**: both -direct on the host, the panel over loopback with a login). - -The broker is **one process with internal module seams** (not a service mesh). The -request path is broker → tool container *directly*; the toolyard starts tools and -injects their secrets but is not a proxy. The admin app is not in the request path: -it **supervises the broker process** and reads/writes the same SQLite file directly -(the broker has no admin API), so it never widens the attack surface the agent sees. - ---- - -## What's built - -### You build these - -| Component | Where | Role | -|---|---|---| -| **Broker** | [broker/](../broker/) | Authority boundary: auth, policy, request lifecycle, approval orchestration, audit, admin. One process, one SQLite file. | -| **Toolyard** | [toolyard/](../toolyard/) | Execution boundary: reads `toolyard.toml`, resolves secrets, runs tools (process or docker). | -| **Tool template** | [tools/echo_api/](../tools/echo_api/) | A sample standalone api tool that reads its own secret. | -| **Agent client** | [client/](../client/) | The generic `toolstack` CLI + skill an agent uses to discover and call tools (lazy discovery; hybrid with optional per-domain skills). | -| **Admin web app** | [admin/](../admin/) | Operator control panel (FastAPI): supervises the broker process, manages callers/tokens/policies, authors/edits/removes tools, shows requests + audit. Loopback-only; the one component with runtime deps. | - -Broker internal modules (each keeps its "owns / must not" rule): - -| Module | Responsibility | -|---|---| -| [gateway.py](../broker/gateway.py) | HTTP ingress/egress, routing, correlation ids, body validation, rate-limit + redaction entry points (liveness probes not audited) | -| [identity.py](../broker/identity.py) | callers + SHA-256-hashed bearer tokens; fail-closed `authenticate` | -| [policy.py](../broker/policy.py) | `allow` / `review` / `deny`, default-deny (pure) | -| [registry.py](../broker/registry.py) | reads tool/op/risk/port from `toolyard.toml`; ignores `[[secrets]]`; discovers tools from the tools root **and** explicit tool dirs | -| [request_lifecycle.py](../broker/request_lifecycle.py) | registry → policy → execute, with the approval detour and deferred execution | -| [approval.py](../broker/approval.py) | the operation card + normalized surface state (adapter contract) | -| [surface_nod.py](../broker/surface_nod.py) | `NodSurface`: the HTTP adapter to nod (open / poll / cancel) | -| [store.py](../broker/store.py) | SQLite persistence (WAL, so the broker and admin app share the file) + operator/audit queries | -| [operations.py](../broker/operations.py) | operator mutations (create/revoke caller, set policy, issue/revoke token) + `admin.*` audit, shared by `brokerctl` and the admin app | -| [audit.py](../broker/audit.py) | append-only audit log (stderr sink when serving) | -| [ratelimit.py](../broker/ratelimit.py) · [redaction.py](../broker/redaction.py) | per-caller rate limiting · free-text redaction | -| [server.py](../broker/server.py) · [brokerctl.py](../broker/brokerctl.py) | HTTP transport (binds localhost) · operator CLI | - -### The admin web app - -Because the broker exposes **no admin API** (only health / actions / tools / requests), -the panel reaches broker state two ways: it **supervises the broker process** -(`posix_spawn` / `killpg` / `/v1/health`, mirroring the toolyard runner) from a -persisted run-config, and it opens the broker's SQLite `Store` **directly**, mutating -through `broker.operations`, the same path `brokerctl` uses, so the panel and the CLI -write **one** `admin.*` audit trail. It authors tools by writing a `toolyard.toml` -into a directory you name (registered via `TOOLSTACK_TOOLS_DIRS`, picked up on the next -broker restart). It binds `127.0.0.1` only, gates every page on a signed session -(scrypt password + HMAC cookie) with a CSRF token on every POST, and keeps secrets off -the browser (nod token write-only/masked; tool secret *values* are never entered there). -Modules: [admin/README.md](../admin/README.md). - -### You deploy these (external) - -- **nod**: the self-hosted approval surface ([batteryshark/nod](https://github.com/batteryshark/nod)). -- **Secret backend**: a dev TOML `FileBackend`, a local encrypted `VaultBackend`, or - `InfisicalBackend` (HTTP); all shipped. -- **Tailnet**: Tailscale Serve (or any VPN); the ingress boundary. - ---- - -## The request lifecycle, end to end - -1. Agent `POST /v1/actions/.` with a bearer token (over the tailnet). -2. **Gateway** authenticates the token → caller (else `401`); rate-limits the - caller (else `429`). -3. **Registry** resolves the tool/op (else `404`). -4. **Policy** decides: - - **deny** → `403`, request recorded `denied`. - - **allow** → **execute now**: forward to the tool container, return `200` + result. - - **review** → open a **nod** decision, persist the request `pending_approval`, - return `202` + `request_id` (or `503` if no surface configured). -5. For review, the agent polls `GET /v1/requests/`: - - broker enforces its **own timeout** (past deadline → `expired`, fail closed); - - else polls nod: **approved** → execute → `ok`; **rejected** → `denied`; - still pending → `pending_approval`. -6. Every step is appended to the **audit log**; the operator reads it with - `brokerctl audit`. - -Arguments are persisted only while a request is pending approval (needed to run it -later), cleared at a terminal state, and **never audited**. - -The agent discovers callable ops via `GET /v1/tools` and `GET /v1/tools/.` (policy-filtered: -it only sees what it may use) and uses the `toolstack` client to call them. A -reviewed request's status response carries the approver's **note** back, so the -human's reason for approving or rejecting reaches the agent. - ---- - -## Security properties & evidence - -Each invariant, how it's enforced, and the test that proves it. - -| Property | Enforcement | Evidence | -|---|---|---| -| Agent reaches only the broker | binds `127.0.0.1` (not configurable); tailnet is the sole ingress | `test_server.test_bound_to_localhost_only`; live demos | -| Fail closed | no caller → `401`; no policy grant → `403` (default-deny); no surface → `503`; timeout → `expired`; tool down → `502` | `test_gateway`, `test_policy`, `test_lifecycle`, `test_approval`, `test_runtime` | -| Secrets never on the control plane | broker holds no backend credential; registry ignores `[[secrets]]`; toolyard injects secrets to the tool | `test_registry.test_registry_is_secret_unaware`; `toolyard...test_runner` (tool reads secret, value not returned); the demos show **0** occurrences in audit | -| Tokens hashed; revocation immediate | only SHA-256 stored; per-request token+caller check | `test_identity` (revoked token/caller denied); `test_brokerctl` (revoke → next auth denied) | -| Redaction | args/results never audited; `reason` redacted; tokens never logged | `test_lifecycle.test_arguments_never_appear_in_audit`, `test_gateway.test_reason_is_redacted_in_audit` | -| Broker owns approval truth; timer wins | poll is authoritative; broker timeout ignores a late approval | `test_approval.test_timeout_fails_closed_even_if_surface_says_approved`, `...gates_execution`, `...rejection_denies` | -| Every decision auditable (4 questions) | gateway/request/policy/runtime/approval/admin events, queryable by request id; liveness probes excluded as noise | `test_lifecycle.test_audit_trail_answers_the_questions`; `test_operations` (admin.* events); `test_gateway.test_health_is_not_audited` | -| Abuse control | per-caller fixed-window rate limit | `test_ratelimit`, `test_gateway.test_rate_limit_returns_429` | -| Admin panel is contained | binds `127.0.0.1`; scrypt password + HMAC session + CSRF on every POST; fail-closed (no password set → refuses to start); nod token / tool secrets never rendered back | `admin...test_auth` (session/CSRF), `test_app` (login gate, CSRF rejection), `test_broker_config` (token masked) | - -The "four audit questions" (what was asked / decided & by whom / actually ran / -which credential) are answerable via `brokerctl audit --request-id `: a request -carries `request.received` → a `policy.*`/`approval.*` decision → `runtime.*` execution -→ a terminal `request.{completed,denied,failed,expired}`, and each authenticated call -emits `identity.token_validated` with a non-reversible token fingerprint (the "which -credential" answer); a rejected bearer emits `identity.token_rejected`. - ---- - -## Run it - -Two ways, both from the repo root: the **admin panel** (easiest: it runs the broker -and tools for you) or the **CLI** (zero-dependency, scriptable). - -### The admin panel - -```bash -python3 -m venv admin/.venv && admin/.venv/bin/pip install -r admin/requirements.txt -admin/.venv/bin/python -m admin set-password # required (fail closed) -admin/.venv/bin/python -m admin serve # http://127.0.0.1:8780 -``` - -Then in the browser: set the run-config (tools root, nod URL/token), **Start** the -broker, create a caller (copy its one-time token), edit its policy, and add/start -tools. Only the panel uses its venv; the broker, toolyard, and client it drives are -still zero-dependency stdlib. - -### The CLI (zero dependencies: Python 3 only) - -```bash -# 1. start a tool (toolyard resolves its secret from the dev backend and runs it) -cp secrets.example.toml secrets.toml -python3 -m toolyard.cli up echo --secrets secrets.toml -python3 -m toolyard.cli ls - -# 2. create a caller (token printed once) -python3 -m broker.brokerctl create-caller --name hermes --allow echo.say - -# 3. start the broker pointed at the tools root (rate limit on by default) -TOOLSTACK_TOOLS_ROOT=tools python3 -m broker.server # listens on 127.0.0.1:8765 -``` - -```bash -# 4. call it (another shell) -TOKEN= -curl -s -X POST http://127.0.0.1:8765/v1/actions/echo.say \ - -H "Authorization: Bearer $TOKEN" -d '{"arguments": {"m": "hi"}}' -# -> {"status":"ok","request_id":1,"result":{"echoed":{"m":"hi"}}} - -python3 -m broker.brokerctl audit --request-id 1 # the full trail -python3 -m toolyard.cli down echo -``` - -**Approval flow** needs a nod (or a stand-in). Set `TOOLSTACK_NOD_URL` + -`TOOLSTACK_NOD_TOKEN` on the broker and give the caller a `--review .`; -without a surface, review ops return `503`. Key env vars: `TOOLSTACK_BROKER_PORT`, -`TOOLSTACK_BROKER_DB`, `TOOLSTACK_TOOLS_ROOT`, `TOOLSTACK_TOOLS_DIRS` (extra per-tool -dirs, `os.pathsep`-separated), `TOOLSTACK_NOD_URL`/`_TOKEN`, `TOOLSTACK_APPROVAL_TTL`, -`TOOLSTACK_RATE_LIMIT`. Operator commands and the tailnet step are in -[broker/README.md](../broker/README.md) and [toolyard/README.md](../toolyard/README.md). - ---- - -## Test it - -```bash -python3 -m unittest discover -s broker/tests -t . # 219 tests (1 live-nod skipped) -python3 -m unittest discover -s toolyard/tests -t . # 78 tests (19 skipped by default: vault, docker e2e, live-Infisical) -python3 -m unittest discover -s client/tests -t . # 23 tests (CLI + MCP, vs a real broker) -python3 -m unittest discover -s desktop/tests -t . # 9 tests (desktop shell lifecycle) -admin/.venv/bin/python -m unittest discover -s admin/tests -t . # 184 tests (needs admin/.venv; incl. the JSON /api) -TOOLSTACK_TEST_DOCKER=1 python3 -m unittest toolyard.tests.test_runner # + the opt-in real-container tests -pip install -e '.[vault]' && python3 -m unittest discover -s toolyard/tests -t . # + the encrypted-vault tests -``` - -The native macOS app has its own `swift test` suite (32 tests; needs Xcode): - -```bash -cd macapp && swift test # ToolstackKit: ApiClient, Keychain store, response decoding -``` - -Coverage by area: identity/auth + revocation, policy + default-deny, the full -request lifecycle (allow/deny/review/unknown/tool-failure + status transitions), -registry secret-unawareness and multi-dir discovery, HTTP forwarding, approval -orchestration (approve / reject / timeout / no-surface) and the nod HTTP mapping, -the gateway routes and hardening (429 + redaction + health not audited), the shared -operator mutations, and the toolyard (config / secrets / process runner end-to-end / -docker). The admin suite covers auth / session / CSRF, the run-config round-trip, a -**real broker** start/stop via the supervisor, tool authoring (and that the written -`toolyard.toml` is consumable by the broker registry), and a full login → -create-caller → set-policy flow via FastAPI's TestClient. - ---- - -## Design decisions to confirm - -These are judgment calls made along the way, worth a deliberate yes/no: - -1. **Collapse to deployment reality.** The earlier 9-service decomposition became - module seams inside one broker process. *Why:* security from physical - boundaries, not paper rules; far simpler to deploy/understand. -2. **Zero-dependency stdlib Python** (no web framework; `http.server`). *Why:* "just - `python3`", boring, junior-readable. Confirm this still fits as the broker grows. -3. **`toolyard.toml` (not `toolyard.yaml`).** *Why:* YAML would force a PyYAML - dependency on both broker and toolyard; TOML is stdlib (`tomllib`). *This deviates - from the original docs*; confirm you're happy with TOML. -4. **Caller model, no profiles.** Identity is caller + token (the old design's - profiles were dropped). Confirm a single policy bundle per caller is enough. -5. **nod via a pluggable adapter; poll-only by design.** Resolution is poll-based - (`GET /v1/requests/`). A push/callback fast-path is deliberately not built - and not planned: nod posts callbacks unauthenticated, so a broker receiver would - let anyone forge an approval. Confirm poll-only is the resolution model you want. -6. **Process + Docker runners; dev file secret backend.** Process backend is the - zero-infra default; Docker is the production path; Infisical (HTTP) and a local - encrypted vault are the production secret backends. -7. **Rate limit default 120/caller/min.** Tune to taste (`TOOLSTACK_RATE_LIMIT`). -8. **Admin app carries deps (FastAPI) and supervises the broker.** It's the one - component allowed runtime deps (its own venv), runs the broker as a child process, - and reads/writes the SQLite file directly (the broker has no admin API). *Why:* a - real control plane for homelab use without burdening the stdlib core. Confirm - you're happy with a deps-carrying panel alongside the zero-dep core. -9. **Tools can live at any server path; the panel writes only the manifest.** "Add a - tool" writes a `toolyard.toml` into a directory you name (registered via - `TOOLSTACK_TOOLS_DIRS`); you supply the code or image. *Why:* you chose any-path - over root-only. Confirm that's still the model you want. - ---- - -## Deferred & caveats - -Honest list of what is **not** done; none block the slice working, but they matter -before a real deployment: - -- **Secrets touch host disk transiently.** The process runner writes secrets to a - `0700` temp dir; the docker runner bind-mounts a host dir, both removed on stop. - Production hardening: inject into a container **tmpfs** at start (no host disk). -- **Approval `deliver` callback** deliberately not built and not planned: a design - decision, not a gap. `poll` is the sole source of approval truth; a receiver of - nod's unauthenticated callback would be forgeable (anyone reaching it could forge - an "approved"). Poll-only closes that hole. -- **Expiry/revocation are lazy, not a background worker.** A pending request expires on - its next poll, on any new `submit` (which sweeps all stale approvals), or via - `brokerctl sweep`; revoking a caller/token cancels its pending approvals eagerly. There - is still no always-on background thread; an unattended, never-swept request lingers - until the next submit or a manual sweep (fine: the single-threaded broker stays simple). -- **Single-threaded broker serving.** The broker's `HTTPServer` serves one request at - a time. SQLite is now in **WAL mode**, so the admin app's short-lived connections - safely coexist with the broker's long-lived one; but concurrent *serving inside the - broker* would still need a connection pool + locking (the in-memory rate limiter too). -- **Admin app is single-operator.** One login (scrypt password + HMAC session); no - multi-user, roles, or SSO. Loopback-only and no TLS of its own; reach it over a - tunnel that terminates TLS, never a public bind. -- **No DB migrations.** The schema grew across phases via `CREATE TABLE` / new - columns; assume a **fresh DB** when upgrading a dev instance (delete the sqlite file). -- **nod API contract: verified.** The create / decision-read / cancel shapes in - `surface_nod.py` are pinned against nod **v1.0.1** (`nod-proto` crate, commit - `01d535d`): every request field and response key was checked against that - source and the base routing live-probed against a running instance. The default - test suite exercises the mapping against a wire-faithful fake; an opt-in live - test (`test_surface_nod_live.py`, skipped unless `TOOLSTACK_NOD_URL` / - `TOOLSTACK_NOD_TOKEN` are set) drives a real open→poll→cancel cycle. Re-verify - if you bump the nod version; the create body is `deny_unknown_fields`. -- **Rate limiter is in-memory, per process.** Resets on restart; not shared across - broker instances. -- **Temporary grants / JIT elevation** not implemented (policy is static - allow/review/deny). - ---- - -## File map - -``` -PROJECT.md design notes (mission, architecture, principles) -plan.md component-by-component design + invariants -README.md front door -secrets.example.toml dev secret backend template (copy to secrets.toml) -docs/ - walkthrough.md this document - component-decomposition.md diagrams + trust boundaries - message-contracts.md boundary wire contracts, outcomes, audit taxonomy - approval-surface-adapter.md the approval-surface "SDK" (write another surface) - coding-standards.md clean-code expectations -broker/ the authority boundary (see module table above; + operations.py) + tests/ -toolyard/ config.py · secrets.py · runner.py · cli.py + tests/ -tools/echo_api/ sample tool: app.py · toolyard.toml · Dockerfile -client/ agent client: toolstack.py (CLI) · mcp_server.py (MCP) · SKILL.md + tests/ -admin/ control panel (FastAPI): server · views · auth · supervisor · - broker_config · tool_authoring · toolyard_ops + tests/ + .venv (gitignored) -deploy/ systemd unit template · env example · redeploy script · README (real install) -pyproject.toml packages the stdlib CLIs (toolstack/brokerctl/toolyard) for `pip install -e .` -``` +# Toolstack walkthrough: trusted process + SPS + +Read [deployment](../deploy/README.md) and [runtime matrix](runtime-matrix.md) first. +This is a fake-config development recipe, not hostile-code isolation or a production +certification. Use Python 3.11+, a dedicated venv, temporary directories and loopback. + +1. Install the built wheel with the exported deployment requirements/constraints + in an isolated venv. Run from a directory outside the checkout to verify imports. +2. Set `umask 077`. Create private config/state/log directories. Set XDG_CONFIG_HOME + and XDG_STATE_HOME to the temporary directories before invoking any component. +3. Run `deploy/bootstrap-sps.py` with `--config TEMP/config/sps.env`, + `--state-dir TEMP/state` and `--log-dir TEMP/log`. This makes development TLS + material with localhost/127.0.0.1 SAN; never disable certificate verification. +4. With SPS stopped, export an explicitly fake `SP_VAULT_PASSPHRASE`, run + `sps init-vault --config TEMP/config/sps.env`, then pipe a fake value to + `sps vault-set --config TEMP/config/sps.env demo API_KEY`. Do not use live providers. +5. Start `sps serve --config TEMP/config/sps.env` on loopback. Set + TOOLSTACK_SPS_BOOT_ID_PATH under TEMP/state to avoid system locations. + Configure the runner's SPS config/CA using its resolved environment + contract; registration settings and child settings must agree. +6. Run `admin set-password` interactively and `admin serve --port 8780`. + The web panel is the reference operator flow: configure broker/tools root, + start broker, import a trusted process tool, declare its SPS mapping, and start it. + Create a fake caller, explicitly grant its operation and exercise it over loopback. +7. Stop tools/admin/SPS and remove temporary data after testing. This recipe is not + itself a test result; see the [reported validation boundary](security-contracts.md#dependency-and-validation-evidence) + for the exercised restart/recovery flow and its limits. + +## Authority and limits + +The broker authorizes actions; SPS authorizes a registered tool's read/write mappings. +SP_SECRET is broad runner registration authority. E_SECRET is per-registration +workload capability. Broker uses independently random channel credentials from +dedicated channels.json, never E_SECRET/private state. No filename can isolate the +same unrestricted OS user. Tools can return secrets in results; writeback does not +automatically redact responses. Configure forwarder response redaction explicitly. + +Policy is explicit allow/review/deny with missing grants denied in the repaired +broker. Verify the integrated broker version: historical refresh-name exemptions +must not be relied on. Human approval is polled, not pushed execution authority. +SQLite/audit data can contain sensitive operational information and is not a +tamper-proof record. Redaction patterns do not prove arbitrary arguments secret-free. + +SPS decrypts localfile data in memory and tools cache plaintext values. Restart loses +registrations; runner recovery must restore current grants and retry failures. Stop +SPS before direct localfile provisioning to avoid stale competing writers. Native +and desktop surfaces have separate contracts and validation; full parity is not claimed. + +Schema3 caller-scoped receipts, manual pruning and new-only restore remain integrated +in the current product boundary `78ea904` (unchanged at `8e4bb2e`). For a fake call, choose a stable nonsecret key: +`toolstack call echo.say '{"a":1}' --idempotency-key walkthrough-1 --wait`. +Matching caller/key/payload returns old receipt, NEVER new execution; changed +payload conflicts409. Current allow OR review permits historical reads; deny or +revoked presented token withholds them. Unknown is uncertain effect, not permission +to retry with a new key. CLI incomplete/unknown/expired/conflict exits nonzero. + +See [exact retention/offline commands](security-contracts.md#manual-retention-and-offline-recovery) +before maintenance: prune terminal payloads strictly older than seven days; preserve +tombstones; do not copy only the DB while WAL is live. Stop ALL broker/admin/CLI +writers before --confirm-offline reconcile/restore. Restore only to a NEW private +destination; all tokens revoked, all policies cleared, UUID rotated. Original source +is untouched, no effects replayed. Keep authority revoked until external history +(including post-snapshot effects/keys) is reconciled; never blindly replay queues. +These commands do not restore SPS/provider state or guarantee secure erasure. + +Use the current lock (`cryptography` 50.0.2; supported range `>=50.0.2,<51`), not +the historical 45.x range. The reported local fake-data checks do not certify a +production deployment or host isolation. The accepted Linux functional witness +includes real process/SPS checks and bounded Docker build/start, but not this +recipe's clean installed deployment; see the [milestones and cleanup limits](runtime-matrix.md). diff --git a/macapp/Package.swift b/macapp/Package.swift index ab42898..def110c 100644 --- a/macapp/Package.swift +++ b/macapp/Package.swift @@ -12,6 +12,6 @@ let package = Package( .target(name: "ToolstackKit"), .executableTarget(name: "ToolstackApp", dependencies: ["ToolstackKit"], resources: [.copy("Resources/MenuBarIcon.png")]), - .testTarget(name: "ToolstackKitTests", dependencies: ["ToolstackKit"]), + .testTarget(name: "ToolstackKitTests", dependencies: ["ToolstackKit", "ToolstackApp"]), ] ) diff --git a/macapp/README.md b/macapp/README.md index 81b93b2..120a7d0 100644 --- a/macapp/README.md +++ b/macapp/README.md @@ -1,9 +1,13 @@ # Toolstack: native macOS operator app A first-class native SwiftUI app for the operator surface (no webview), talking to the admin's -**JSON operator API** (`admin/api.py`) over loopback or a tailnet. It does everything the web -panel does, natively: sign in, run/stop the broker, manage callers/tokens/policies, author and -edit tools, set secret values, and watch requests + the audit log. +**JSON operator API** (`admin/api.py`) over loopback or a tailnet. Full web parity is +not proved. The approved Clear desk design is implemented and built. Strict canonical +decoders separate nullable health, liveness and recorded run intent. Failed loads +gate saves; rejected/unknown saves retain drafts, with three-way preservation and +comparison before retry. Mutations are scoped, stale responses are suppressed and +effects are never automatically replayed. Secret provisioning is host-side only; +obsolete value-provisioning UI is removed and the old API returns 410. ## Layout @@ -29,26 +33,42 @@ Point it at a running admin (start one with `python3 -m admin serve`, or the one shell). Default `http://127.0.0.1:8780`; override with `TOOLSTACK_ADMIN_URL`. The signed login is remembered across launches in the Keychain. -For a distributable build, `packaging/build-app.sh` produces the `.app` (icon included) and -`packaging/sign-and-notarize.sh` signs + notarizes it with a Developer ID (credentials live in -a gitignored `secrets/`). +For later LOCAL development only, see [packaging](packaging/README.md): existing +Apple Development identity, offline signing, and separate strict/deep verification. +A release build and separate local signature verification are recorded below; +do not use the historical notary script. ## Verification split (important) -`ToolstackKit` is verified by `swift test` (requests, bearer auth, error mapping, snake_case -decoding). The **SwiftUI views are not**; there's no way to see a window in the build -environment, so the look/feel is confirmed by running it in Xcode. +The parent recorded 69 passing native tests at `b0a175`, integrated as `162ca5f`, +and an actual unsigned release build (14.74 seconds), then signing with an existing +approved Apple Development identity and a separate strict/deep verification PASS. +The app identifier is `com.toolstack.operator`. Native source is unchanged by the +integrated web source `54ce947`. This proves local signature consistency only. + +Offscreen renders and fake-service tests do not establish trusted keyboard/Escape, +VoiceOver, native focus, window Close, named/default Cancel or comprehensive hit-target +acceptance. Those live checks, real backend/native wire verification and live Keychain +proof remain open. No activation, installation, notarization, Gatekeeper assessment +or distribution acceptance is claimed. ## Features -The full operator surface over the admin API: +Implemented feature areas, not a verified parity/acceptance claim: - **Broker**: start / stop / restart + health. - **Tools**: add from a local folder or GitHub, author a manifest in-app (api / mcp), - edit description + secret declarations, set secret values, per-tool start/stop/restart, and + edit description + secret declarations, per-tool start/stop/restart, and update a tool from its source. - **Callers**: create, issue / rotate / revoke tokens, enable tools, and edit policy per operation. - **Activity**: a pane over the request + audit log; plus the active secret-backend view. -The login persists in the Keychain, and the app ships as a signed, notarized `.app`. +Catalog membership, separate add/start/register stages, ownership-sensitive removal +and caller header hints follow the [shared operator contracts](../admin/README.md#catalog-removal-and-authoring). +Policy adapters preserve advanced/unavailable grants without changing the broker's +full-replacement semantics. Unknown removal metadata leaves filesystem outcome unknown. + +Login uses Keychain. No signed/notarized distribution readiness is claimed. +Same-account Mac development is not secret isolation; see +[security contracts and pending gates](../docs/security-contracts.md). diff --git a/macapp/Sources/ToolstackApp/AppModel.swift b/macapp/Sources/ToolstackApp/AppModel.swift index 51d7610..4da0fcd 100644 --- a/macapp/Sources/ToolstackApp/AppModel.swift +++ b/macapp/Sources/ToolstackApp/AppModel.swift @@ -15,15 +15,100 @@ final class AppModel: ObservableObject { @Published var banner: String? // e.g. a freshly minted token to copy once @Published var error: String? @Published var busy = false + @Published private(set) var effectInFlight = false + @Published var toolsError: String? + private(set) var generation = UUID() + @Published var policyDrafts: [String: PolicyDraft] = [:] + @Published var enabledDrafts: [String: Set] = [:] + @Published var enabledOutcomes: [String: MutationResult] = [:] + @Published var enabledBaselines: [String: Set] = [:] + struct Confirmation { + let title: String + let effect: String + let action: () async -> Void + } + @Published var confirmation: Confirmation? + @Published var uncertainMutation: String? + @Published var continuationTarget: String? + @Published private(set) var catalogObservation: CatalogObservation = .unknown + private var catalogRevision = UUID() + private var unknownRemovalTarget: String? + func invalidateCatalog() { catalogRevision = UUID(); catalogObservation = .unknown } + func registrationObserved(for id: String) -> Bool { + catalogObservation.verified && catalogObservation.toolIDs.contains(id) && toolsError == nil && tools.contains { $0.id == id } + } + func refreshCatalog() async { + guard !effectInFlight else { return } + invalidateCatalog() + let epoch = generation; let revision = catalogRevision; let activeClient = client + var listed = false + do { + let rows = try await activeClient.listTools() + guard epoch == generation, revision == catalogRevision else { return } + tools = rows; toolsError = nil + listed = true + let observation = try await activeClient.catalogObservation() + guard epoch == generation, revision == catalogRevision else { return } + catalogObservation = observation + } catch { + guard epoch == generation, revision == catalogRevision else { return } + catalogObservation = .unknown + if !listed { toolsError = "Tool listing unavailable. Retained rows are last-known observations." } + } + } + + func writePolicy(_ draft: PolicyDraft) async -> PolicyDraft { + let epoch = generation + let activeClient = client + var result = draft + guard result.beginSave() else { return result } + do { + let receipt = try await activeClient.setPolicy(caller: draft.caller, + allow: draft.effects.filter { $0.value == .allow }.map(\.key), + review: draft.effects.filter { $0.value == .review }.map(\.key)) + guard receipt.name == draft.caller else { + throw ApiError.decoding("Policy receipt caller does not match the draft") + } + result.compare(receipt) + } catch { result.failed(error) } + if epoch == generation { policyDrafts[draft.caller] = result } + return result + } /// The admin URL the user signs in against, editable on the login screen, remembered /// across launches. Works for a local admin (the default) or a remote one (a tailnet / /// tunnelled homeserver), as long as it's reachable. @Published var serverURL: String private var client: ApiClient + private var tokenStorage: TokenStorage = .keychain + private var refreshTask: Task? + private var draftServerURL: String? private static let urlDefaultsKey = "ToolstackAdminURL" + init(client: ApiClient, serverURL: String, tokenStorage: TokenStorage = .keychain) { + self.client = client; self.serverURL = serverURL; self.draftServerURL = serverURL + self.tokenStorage = tokenStorage + } + + func clearAdminState() { + generation = UUID() + refreshTask?.cancel(); refreshTask = nil + authenticated = false; busy = false; effectInFlight = false + uncertainMutation = nil; confirmation = nil; tools = []; toolsError = nil + broker = nil; callers = []; error = nil; banner = nil + config = nil; audit = nil; secretBackend = nil + policyDrafts = [:]; enabledDrafts = [:]; enabledOutcomes = [:]; enabledBaselines = [:] + draftServerURL = nil + continuationTarget = nil + unknownRemovalTarget = nil + invalidateCatalog() + } init() { + if ProcessInfo.processInfo.arguments.contains("--snapshot-directory") { + self.serverURL = "http://fake-admin.invalid" + self.client = ApiClient(baseURL: URL(string: "http://fake-admin.invalid")!) + return + } let saved = UserDefaults.standard.string(forKey: AppModel.urlDefaultsKey) self.serverURL = saved ?? AppModel.adminURL().absoluteString self.client = ApiClient(baseURL: AppModel.adminURL()) // rebuilt from serverURL on login @@ -43,11 +128,18 @@ final class AppModel: ObservableObject { return } serverURL = trimmed + if let previous = draftServerURL, previous != trimmed { + clearAdminState() + } + draftServerURL = trimmed UserDefaults.standard.set(trimmed, forKey: AppModel.urlDefaultsKey) client = ApiClient(baseURL: url) // point at whatever admin the user entered + let activeClient = client + let epoch = generation await run { - let token = try await self.client.login(password: password) - TokenStore.save(token, account: trimmed) // remember it for next launch + let token = try await activeClient.login(password: password) + guard epoch == self.generation else { return } + self.tokenStorage.save(token, trimmed) // remember it for next launch self.authenticated = true await self.refreshAll() } @@ -57,77 +149,209 @@ final class AppModel: ObservableObject { /// call; enter only on success. A 401 means it expired → forget it; any other error (admin /// unreachable) keeps it for next time and just stays on the login screen. func restoreSession() async { - defer { restoring = false } + if CommandLine.arguments.contains("--snapshot-directory") { return } + let epoch = generation + defer { if epoch == generation { restoring = false } } let saved = serverURL.trimmingCharacters(in: .whitespaces) guard let url = URL(string: saved), url.host != nil, - let token = TokenStore.load(account: saved) else { return } + let token = tokenStorage.load(saved) else { return } let restored = ApiClient(baseURL: url) await restored.setToken(token) + guard epoch == generation else { return } do { - _ = try await restored.brokerStatus() // authed probe - client = restored - authenticated = true - await refreshAll() + try await acceptRestoredSession(restored, account: saved) } catch ApiError.unauthorized { - TokenStore.delete(account: saved) + guard epoch == generation else { return } + tokenStorage.delete(saved) } catch { // admin unreachable / transient; keep the token, let the user sign in manually } } + /// Validates an already supplied client; credential loading stays in restoreSession. + func acceptRestoredSession(_ restored: ApiClient, account: String) async throws { + let epoch = generation + _ = try await restored.brokerStatus() + guard epoch == generation else { return } + client = restored + draftServerURL = account + authenticated = true + await refreshAll() + } + func logout() async { - await client.setToken(nil) - TokenStore.delete(account: serverURL) - authenticated = false - broker = nil; callers = []; banner = nil + let account = draftServerURL ?? serverURL + let oldClient = client + clearAdminState() + restoring = false + tokenStorage.delete(account) + await oldClient.setToken(nil) } func refreshAll() async { - await refreshBroker() - await refreshCallers() - await refreshTools() + if CommandLine.arguments.contains("--snapshot-directory") { return } + guard authenticated else { return } + if let refreshTask { await refreshTask.value; return } + let epoch = generation + let task = Task { @MainActor in + await self.refreshBroker() + guard epoch == self.generation, self.authenticated, !Task.isCancelled else { return } + await self.refreshCallers() + guard epoch == self.generation, self.authenticated, !Task.isCancelled else { return } + await self.refreshTools() + } + refreshTask = task + await task.value + if epoch == generation { refreshTask = nil } + } + + /// Called on activation and each active-window timer tick. Observations only: + /// drafts, sheet identity and uncertain mutation outcomes are left intact. + func refreshWhileActive(_ active: Bool) async { + guard active, authenticated, !effectInFlight, !busy else { return } + await refreshAll() + } + + func prepareCallerConfirmation(_ name: String) async -> Bool { + let epoch = generation + guard authenticated, !effectInFlight, uncertainMutation == nil else { return false } + await refreshAll() + guard epoch == generation, authenticated, error == nil, + !effectInFlight, uncertainMutation == nil else { return false } + return callers.contains { $0.name == name && $0.isActive } } - func refreshTools() async { - await run { self.tools = try await self.client.listTools() } + @discardableResult + func refreshTools() async -> Bool { + if CommandLine.arguments.contains("--snapshot-directory") { return true } + invalidateCatalog() + let epoch = generation; let revision = catalogRevision + let activeClient = client + do { + let rows = try await activeClient.listTools() + guard epoch == generation, revision == catalogRevision else { return false } + tools = rows; toolsError = nil; return true + } catch { + guard epoch == generation, revision == catalogRevision else { return false } + toolsError = "Tool listing unavailable. Retained rows are last-known observations." + return false + } } /// Start / stop / restart a tool, then refresh so its row reflects the new run state. - func toolAction(id: String, action: String) async { - await run { - _ = try await self.client.toolAction(id: id, action: action) - await self.refreshTools() + func toolAction(id: String, action: String, confirmed: Bool = false) async { + guard uncertainMutation == nil, !effectInFlight else { return } + if action == "restart", !confirmed { + let epoch = generation + confirmation = Confirmation(title: "Restart \(id)?", effect: "Stops and starts \(id), interrupting active calls. Requests will not be replayed.") { + guard epoch == self.generation else { return } + await self.toolAction(id: id, action: action, confirmed: true) + } + return } + effectInFlight = true + invalidateCatalog() + let epoch = generation + let activeClient = client + defer { if epoch == generation { effectInFlight = false } } + var acknowledged = false + do { + _ = try await activeClient.toolAction(id: id, action: action) + guard epoch == generation else { return } + acknowledged = true + let rows = try await activeClient.listTools() + guard epoch == generation else { return } + tools = rows; toolsError = nil + } catch { + guard epoch == generation else { return } + if acknowledged || MutationResult(error: error) == .unknown { uncertainMutation = "\(action.capitalized) \(id): outcome unknown" } + else { self.error = "\(action.capitalized) \(id) rejected. Reload state before retrying." } + } + } + + func compareMutationState() async { + guard !effectInFlight, uncertainMutation != nil else { return } + effectInFlight = true + let epoch = generation; let revision = catalogRevision + let activeClient = client + defer { if epoch == generation { effectInFlight = false } } + do { + let latestBroker = try await activeClient.brokerStatus() + guard epoch == generation, revision == catalogRevision else { return } + let latestTools = try await activeClient.listTools() + guard epoch == generation, revision == catalogRevision else { return } + broker = latestBroker; tools = latestTools; toolsError = nil + banner = "State reloaded. Review the broker and tool observations before another action." + if let target = unknownRemovalTarget { + banner = "Rechecked \(target): \(latestTools.contains { $0.id == target } ? "still listed (possibly another source)" : "not listed"). Filesystem outcome remains unknown." + unknownRemovalTarget = nil + } + uncertainMutation = nil + } catch { if epoch == generation, revision == catalogRevision { self.error = "Could not reload state. Outcome remains unknown." } } } func updateTool(id: String, description: String, secrets: [SecretDecl]) async { + invalidateCatalog() + let epoch = generation; let activeClient = client await run { - _ = try await self.client.updateTool(id: id, description: description, secrets: secrets) + _ = try await activeClient.updateTool(id: id, description: description, secrets: secrets) + guard epoch == self.generation else { return } await self.refreshTools() + guard epoch == self.generation else { return } self.banner = "Saved \(id). Restart the tool if you changed its secrets." } } /// Re-pull a tool from its recorded source, keeping the operator's description/secret edits. func resyncTool(id: String) async { + invalidateCatalog() + let epoch = generation; let activeClient = client await run { - _ = try await self.client.resyncTool(id: id) + _ = try await activeClient.resyncTool(id: id) + guard epoch == self.generation else { return } await self.refreshTools() + guard epoch == self.generation else { return } self.banner = "Updated \(id) from its source. Restart the broker if its entrypoint or operations changed." } } + func requestRemoval(_ tool: ToolInfo) { + guard let external = tool.removable, !effectInFlight, uncertainMutation == nil else { return } + let epoch = generation + confirmation = Confirmation(title: "\(external ? "Unregister" : "Remove") \(tool.id)?", + effect: external ? "Stops and unregisters \(tool.id). Files in \(tool.path ?? "its external folder") are retained. Other sources may still supply this id." : "Stops \(tool.id) and deletes its managed folder \(tool.path ?? tool.id). This cannot be undone.") { + guard epoch == self.generation else { return } + await self.removeTool(id: tool.id) + } + } func removeTool(id: String) async { - await run { - _ = try await self.client.deleteTool(id: id) - await self.refreshTools() - self.banner = "Removed \(id). Restart the broker to drop it from the registry." + guard !effectInFlight, uncertainMutation == nil else { return } + effectInFlight = true + error = nil + banner = nil + invalidateCatalog() + let epoch = generation; let activeClient = client + defer { if epoch == generation { effectInFlight = false } } + do { + let receipt = try await activeClient.removeToolReceipt(id: id) + guard epoch == generation else { return } + guard receipt.removed == id, let deleted = receipt.deleted else { + unknownRemovalTarget = id + uncertainMutation = "Remove/unregister \(id): filesystem outcome unknown. Reload and compare before another action." + return + } + banner = deleted ? "Removed \(id)'s managed folder." : "Unregistered \(id); files retained. Other sources may still supply this id." + _ = await refreshTools() + } catch { + guard epoch == generation else { return } + if MutationResult(error: error) == .unknown { unknownRemovalTarget = id; uncertainMutation = "Remove/unregister \(id): outcome unknown" } + else { self.error = "Remove/unregister \(id) rejected. \((error as? ApiError)?.message ?? "Reload before another action.")" } } } /// Banner after a tool is added (any source): it lands on disk but needs a broker restart to register. private func addedBanner(_ id: String, authored: Bool = false) -> String { - "Added \(id)\(authored ? " (authored)" : ""). Restart the broker to register it, then grant a caller access." + "Added \(id)\(authored ? " (authored)" : ""). Folder copied; start separately, then review broker registration." } /// Outcome of adding from a local folder: it landed, or the folder has no toolyard.toml (so the @@ -137,30 +361,44 @@ final class AppModel: ObservableObject { /// Add a tool by copying a local folder into the broker's tools dir. A folder without a /// toolyard.toml returns `.needsManifest` so the caller can open the authoring sheet. func addTool(source: String) async -> AddOutcome { + invalidateCatalog() + let epoch = generation; let activeClient = client busy = true error = nil - defer { busy = false } + defer { if epoch == generation { busy = false } } do { - let created = try await client.addTool(source: source) + let created = try await activeClient.addTool(source: source) + guard epoch == generation else { return .failed } + continuationTarget = created.id await refreshTools() + guard epoch == generation else { return .failed } banner = addedBanner(created.id) return .added } catch ApiError.http(422, _) { + guard epoch == generation else { return .failed } return .needsManifest } catch ApiError.unauthorized { + guard epoch == generation else { return .failed } authenticated = false; error = ApiError.unauthorized.message; return .failed } catch let apiError as ApiError { + guard epoch == generation else { return .failed } error = apiError.message; return .failed } catch let other { + guard epoch == generation else { return .failed } error = other.localizedDescription; return .failed } } /// Author a tool from a folder of code: copy it in and write the manifest you built in the app. func authorTool(source: String, manifest: [String: Any]) async -> Bool { - await run { - let created = try await self.client.addToolWithManifest(source: source, manifest: manifest) + invalidateCatalog() + let epoch = generation; let activeClient = client + return await run { + let created = try await activeClient.addToolWithManifest(source: source, manifest: manifest) + guard epoch == self.generation else { return false } + self.continuationTarget = created.id await self.refreshTools() + guard epoch == self.generation else { return false } self.banner = self.addedBanner(created.id, authored: true) return true } ?? false @@ -168,120 +406,198 @@ final class AppModel: ObservableObject { /// Add a tool by cloning a git repo (optionally a subdir, at a branch/tag) into the tools dir. func addToolFromGitHub(repo: String, subdir: String, ref: String) async { + invalidateCatalog() + let epoch = generation; let activeClient = client busy = true error = nil - defer { busy = false } + defer { if epoch == generation { busy = false } } do { - let created = try await client.addToolFromGitHub(repo: repo, subdir: subdir, ref: ref) + let created = try await activeClient.addToolFromGitHub(repo: repo, subdir: subdir, ref: ref) + guard epoch == generation else { return } + continuationTarget = created.id await refreshTools() + guard epoch == generation else { return } banner = addedBanner(created.id) } catch ApiError.http(422, _) { + guard epoch == generation else { return } error = "That repo/subdir has no toolyard.toml. Point at one that does, or add it from a local folder to author it." } catch ApiError.unauthorized { + guard epoch == generation else { return } authenticated = false; error = ApiError.unauthorized.message } catch let apiError as ApiError { + guard epoch == generation else { return } error = apiError.message } catch let other { + guard epoch == generation else { return } error = other.localizedDescription } } @Published var secretBackend: SecretBackend? + func parseOpenAPI(_ spec: String) async -> ParsedOpenAPI? { + let activeClient = client + return await run { try await activeClient.parseOpenAPI(spec) } + } func refreshSecretBackend() async { - await run { self.secretBackend = try await self.client.secretBackend() } + if CommandLine.arguments.contains("--snapshot-directory") { return } + let activeClient = client + let epoch = generation + if let value = await run({ try await activeClient.secretBackend() }), epoch == generation { secretBackend = value } } @Published var audit: AuditResponse? func refreshAudit() async { - await run { self.audit = try await self.client.audit(limit: 100) } - } - - func clearAudit() async { - await run { - _ = try await self.client.clearAudit() - await self.refreshAudit() - } + if CommandLine.arguments.contains("--snapshot-directory") { return } + let activeClient = client + let epoch = generation + if let value = await run({ try await activeClient.audit(limit: 100) }), epoch == generation { audit = value } } /// Load a tool's secret set/unset status (returns it rather than storing, used by the sheet). func secretStatus(toolId: String) async -> SecretStatus? { - await run { try await self.client.secretStatus(toolId: toolId) } - } - - /// Provision a secret value into the vault. Returns whether it succeeded (the value is never stored here). - func setSecretValue(toolId: String, field: String, value: String) async -> Bool { - await run { try await self.client.setSecretValue(toolId: toolId, field: field, value: value) } ?? false + let activeClient = client + return await run { try await activeClient.secretStatus(toolId: toolId) } } @Published var config: BrokerConfigInfo? func refreshConfig() async { - await run { self.config = try await self.client.config() } + if CommandLine.arguments.contains("--snapshot-directory") { return } + let activeClient = client + let epoch = generation + if let value = await run({ try await activeClient.config() }), epoch == generation { config = value } } func saveConfig(port: Int, toolsRoot: String, nodURL: String, nodChannel: String, nodToken: String?, approvalTTL: Int, rateLimit: Int) async { + invalidateCatalog() + let epoch = generation; let activeClient = client await run { - self.config = try await self.client.saveConfig( + let value = try await activeClient.saveConfig( port: port, toolsRoot: toolsRoot, nodURL: nodURL, nodChannel: nodChannel, nodToken: nodToken, approvalTTL: approvalTTL, rateLimit: rateLimit) + guard epoch == self.generation else { return } + self.config = value self.banner = "Saved broker config. Restart the broker to apply." } } /// Load one caller's policy + enabled tools for the editors (returns it rather than storing). func loadPolicy(for caller: String) async -> PolicyResponse? { - await run { try await self.client.policy(for: caller) } + let activeClient = client + return await run { try await activeClient.policy(for: caller) } } - func setEnabledTools(caller: String, enabled: [String]) async { - await run { - _ = try await self.client.setEnabledTools(caller: caller, enabled: enabled) - await self.refreshCallers() + func setEnabledTools(caller: String, enabled: [String]) async -> MutationResult { + let epoch = generation; let activeClient = client + do { + _ = try await activeClient.setEnabledTools(caller: caller, enabled: enabled) + guard epoch == generation else { return .unknown } + return .success + } catch { return epoch == generation ? MutationResult(error: error) : .unknown } + } + + func rotateToken(caller: String, confirmed: Bool = false) async { + if !confirmed { + let epoch = generation + guard await prepareCallerConfirmation(caller), epoch == generation else { return } + confirmation = Confirmation(title: "Rotate token for \(caller)?", effect: "The existing token stops working. Update clients using \(caller).") { + guard epoch == self.generation else { return } + await self.rotateToken(caller: caller, confirmed: true) + } + return } - } - - func rotateToken(caller: String) async { + let epoch = generation; let activeClient = client await run { - let result = try await self.client.rotateToken(caller: caller) + let result = try await activeClient.rotateToken(caller: caller) + guard epoch == self.generation else { return } self.banner = "New token for \(result.name). Copy it now, it won't be shown again:\n\(result.token)" await self.refreshCallers() } } func revokeCaller(_ caller: String) async { + let epoch = generation; let activeClient = client await run { - _ = try await self.client.revokeCaller(caller) + _ = try await activeClient.revokeCaller(caller) + guard epoch == self.generation else { return } await self.refreshCallers() } } - func savePolicy(caller: String, allow: [String], review: [String], deny: [String] = []) async { - await run { - _ = try await self.client.setPolicy(caller: caller, allow: allow, review: review, deny: deny) + func savePolicy(caller: String, allow: [String], review: [String], deny: [String] = []) async -> Bool { + let epoch = generation; let activeClient = client + return await run { + _ = try await activeClient.setPolicy(caller: caller, allow: allow, review: review, deny: deny) + guard epoch == self.generation else { return false } await self.refreshCallers() - } + return true + } ?? false } func refreshBroker() async { - await run { self.broker = try await self.client.brokerStatus() } - } - - func brokerAction(_ action: String) async { - await run { self.broker = try await self.client.brokerAction(action) } + if CommandLine.arguments.contains("--snapshot-directory") { return } + let activeClient = client + let epoch = generation + if let value = await run({ try await activeClient.brokerStatus() }), epoch == generation { broker = value } + else if epoch == generation { invalidateCatalog() } + } + + func brokerAction(_ action: String, confirmed: Bool = false) async { + guard uncertainMutation == nil, !effectInFlight else { return } + if action == "restart", !confirmed { + let epoch = generation + confirmation = Confirmation(title: "Restart broker at \(serverURL)?", effect: "Interrupts broker availability and active calls. Reloads the tool catalog; requests will not be replayed.") { + guard epoch == self.generation else { return } + await self.brokerAction(action, confirmed: true) + } + return + } + effectInFlight = true + invalidateCatalog() + let revision = catalogRevision + let epoch = generation + let activeClient = client + defer { if epoch == generation { effectInFlight = false } } + var acknowledged = false + do { + _ = try await activeClient.brokerAction(action) + guard epoch == generation else { return } + acknowledged = true + let status = try await activeClient.brokerStatus() + guard epoch == generation else { return } + let rows = try await activeClient.listTools() + guard epoch == generation else { return } + broker = status; tools = rows; toolsError = nil + if action == "restart" { + banner = "Broker restarted. Registration observation pending." + let observation = (try? await activeClient.catalogObservation()) ?? .unknown + guard epoch == generation else { return } + if revision == catalogRevision { catalogObservation = observation } + } + } catch { + guard epoch == generation else { return } + if acknowledged || MutationResult(error: error) == .unknown { uncertainMutation = "Broker \(serverURL): \(action) outcome unknown" } + else { self.error = "Broker \(action) rejected. Reload state before retrying." } + } } func refreshCallers() async { - await run { self.callers = try await self.client.listCallers().callers } + if CommandLine.arguments.contains("--snapshot-directory") { return } + let activeClient = client + let epoch = generation + if let value = await run({ try await activeClient.listCallers().callers }), epoch == generation { callers = value } } func createCaller(name: String) async { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } + let epoch = generation; let activeClient = client await run { - let created = try await self.client.createCaller(name: trimmed) + let created = try await activeClient.createCaller(name: trimmed) + guard epoch == self.generation else { return } self.banner = "Token for \(created.name). Copy it now, it won't be shown again:\n\(created.token)" await self.refreshCallers() } @@ -291,16 +607,21 @@ final class AppModel: ObservableObject { /// Returns the operation's value (nil if it threw); a Void operation returns an ignored Void?. @discardableResult private func run(_ operation: @escaping () async throws -> T) async -> T? { + let epoch = generation busy = true error = nil - defer { busy = false } + defer { if epoch == generation { busy = false } } do { - return try await operation() + let result = try await operation() + guard epoch == generation else { return nil } + return result } catch let apiError as ApiError { + guard epoch == generation else { return nil } if case .unauthorized = apiError { authenticated = false } error = apiError.message return nil } catch let other { + guard epoch == generation else { return nil } error = other.localizedDescription return nil } diff --git a/macapp/Sources/ToolstackApp/NativeSnapshots.swift b/macapp/Sources/ToolstackApp/NativeSnapshots.swift new file mode 100644 index 0000000..5bda510 --- /dev/null +++ b/macapp/Sources/ToolstackApp/NativeSnapshots.swift @@ -0,0 +1,65 @@ +import AppKit +import SwiftUI +import ToolstackKit + +/// Offscreen, fake-only product view rendering; never enters the application's scene lifecycle. +@MainActor enum NativeSnapshots { + static func run(directory: String) { + NSApplication.shared.setActivationPolicy(.prohibited) + do { + let model = AppModel() + let decoder = JSONDecoder() + model.tools = try decoder.decode(ToolsResponse.self, from: Data(#"{"tools":[{"id":"notes-demo","type":"api","running":true,"alive":false,"state":"crashed","health":"stopped","ops":[{"op":"read_note","risk":"read","description":"Read a note"},{"op":"write_note","risk":"write","description":"Write a note"}]}]}"#.utf8)).tools + let response = try decoder.decode(PolicyResponse.self, from: Data(#"{"name":"writer-demo","enabled":["notes-demo"],"policy":{"tools":{"notes-demo":{"read_note":"allow"}}}}"#.utf8)) + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + let contracts = CommandLine.arguments.contains("--contract-snapshots") + let states = contracts ? ["added", "registration-unknown", "unregister-unknown"] : ["load-failed", "save-rejected", "save-unknown", "draft", "recovery", "recovery-unknown"] + for state in states { + model.uncertainMutation = state == "recovery-unknown" ? "Restart notes-demo: outcome unknown" : nil + if contracts { + let alive = state != "added" + model.tools = try decoder.decode(ToolsResponse.self, from: Data("{\"tools\":[{\"id\":\"notes-demo\",\"running\":\(alive),\"alive\":\(alive),\"removable\":true,\"path\":\"/fake/notes-demo\"}]}".utf8)).tools + model.continuationTarget = state == "unregister-unknown" ? nil : "notes-demo" + if state == "unregister-unknown" { model.uncertainMutation = "Unregister notes-demo: outcome unknown. Filesystem outcome unknown." } + } + var draft = PolicyDraft(caller: "writer-demo") + if state == "load-failed" { draft.loadFailed() } + else { + draft.load(response) + draft.set(.review, for: "notes-demo.write_note") + if state != "draft" { + _ = draft.beginSave() + draft.failed(state == "save-rejected" ? ApiError.http(status: 400, message: "fixture validation") : ApiError.transport("fixture timeout")) + } + } + for width in [1440, 1100] { + let surface = contracts || state.hasPrefix("recovery") ? AnyView(OperatorView(selection: 1)) : AnyView(PolicyEditor(caller: "writer-demo", fixture: draft)) + let view = surface + .environmentObject(model).environment(\.colorScheme, .light) + .environment(\.controlActiveState, .active) + .frame(width: CGFloat(width), height: 900) + .background(Color(nsColor: .windowBackgroundColor)) + let host = NSHostingView(rootView: view) + host.frame = NSRect(x: 0, y: 0, width: width, height: 900) + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: .aqua) + window.contentView = host + host.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.15)) + host.layoutSubtreeIfNeeded() + guard let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { throw SnapshotError.render } + host.cacheDisplay(in: host.bounds, to: bitmap) + guard let png = bitmap.representation(using: .png, properties: [:]) else { throw SnapshotError.render } + try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent("\(state)-\(width).png")) + func geometry(_ view: NSView) -> [[String: String]] { + [["class": String(describing: type(of: view)), "frame": NSStringFromRect(view.convert(view.bounds, to: host))]] + view.subviews.flatMap(geometry) + } + let data = try JSONSerialization.data(withJSONObject: geometry(host), options: [.prettyPrinted, .sortedKeys]) + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("\(state)-\(width)-geometry.json")) + } + } + print("Rendered \(states.count * 2) offscreen SwiftUI views with NSView geometry; no live focus or VoiceOver assertion.") + } catch { fputs("Snapshot failure: \(error)\n", stderr); exit(1) } + } + enum SnapshotError: Error { case render } +} diff --git a/macapp/Sources/ToolstackApp/TokenStore.swift b/macapp/Sources/ToolstackApp/TokenStore.swift index 62ed837..b815f7f 100644 --- a/macapp/Sources/ToolstackApp/TokenStore.swift +++ b/macapp/Sources/ToolstackApp/TokenStore.swift @@ -1,6 +1,16 @@ import Foundation import Security +/// Injectable credential operations; tests never need the real Keychain. +struct TokenStorage { + var save: (String, String) -> Void + var load: (String) -> String? + var delete: (String) -> Void + static let keychain = TokenStorage(save: { TokenStore.save($0, account: $1) }, + load: { TokenStore.load(account: $0) }, + delete: { TokenStore.delete(account: $0) }) +} + /// Persists the operator's bearer token in the macOS Keychain, keyed by the admin URL (so a token /// for one admin doesn't leak to another). A signed/notarized app reads its own items without a /// prompt; an unsigned `swift run` build changes signature each rebuild, so macOS may prompt to diff --git a/macapp/Sources/ToolstackApp/ToolstackApp.swift b/macapp/Sources/ToolstackApp/ToolstackApp.swift index 683628f..a5c2da5 100644 --- a/macapp/Sources/ToolstackApp/ToolstackApp.swift +++ b/macapp/Sources/ToolstackApp/ToolstackApp.swift @@ -33,6 +33,12 @@ enum AppImages { @main struct ToolstackApp: App { + init() { + if let index = CommandLine.arguments.firstIndex(of: "--snapshot-directory"), CommandLine.arguments.count > index + 1 { + NativeSnapshots.run(directory: CommandLine.arguments[index + 1]) + exit(0) + } + } @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var model = AppModel() @@ -60,11 +66,11 @@ struct MenuBarMenu: View { Divider() if model.authenticated { Button("Start broker") { Task { await model.brokerAction("start") } } - .disabled(model.broker?.running == true || model.busy) + .disabled(model.broker?.running == true || model.busy || model.effectInFlight || model.uncertainMutation != nil) Button("Stop broker") { Task { await model.brokerAction("stop") } } - .disabled(model.broker?.running != true || model.busy) + .disabled(model.broker?.running != true || model.busy || model.effectInFlight || model.uncertainMutation != nil) Button("Restart broker") { Task { await model.brokerAction("restart") } } - .disabled(model.busy) + .disabled(model.busy || model.effectInFlight || model.uncertainMutation != nil) Divider() } Button("Open Toolstack Operator") { @@ -85,18 +91,36 @@ struct MenuBarMenu: View { struct ContentView: View { @EnvironmentObject var model: AppModel + @Environment(\.scenePhase) private var scenePhase var body: some View { Group { if model.restoring { ProgressView("Connecting...").frame(maxWidth: .infinity, maxHeight: .infinity) } else if model.authenticated { - OperatorView() + OperatorView().id(model.generation) } else { LoginView() } } .task { await model.restoreSession() } // resume a saved session on launch + .task(id: scenePhase == .active && model.authenticated) { + guard scenePhase == .active, model.authenticated, + !CommandLine.arguments.contains("--snapshot-directory") else { return } + while !Task.isCancelled { + await model.refreshWhileActive(true) + do { try await Task.sleep(for: .seconds(20)) } + catch { return } + } + } + .alert(model.confirmation?.title ?? "Confirm", isPresented: Binding( + get: { model.confirmation != nil }, set: { if !$0 { model.confirmation = nil } } + ), presenting: model.confirmation) { confirmation in + Button("Cancel", role: .cancel) {} + Button(confirmation.title.replacingOccurrences(of: "?", with: ""), role: .destructive) { + Task { await confirmation.action() } + } + } message: { confirmation in Text(confirmation.effect) } .overlay(alignment: .top) { if let error = model.error { Text(error) @@ -142,10 +166,20 @@ struct LoginView: View { struct OperatorView: View { @EnvironmentObject var model: AppModel + @State var selection = 0 var body: some View { VStack(spacing: 0) { BrokerBar() + if let uncertain = model.uncertainMutation { + HStack { + Text(uncertain) + Spacer() + Button("Reload and compare") { Task { await model.compareMutationState() } } + .buttonStyle(.borderedProminent) + .disabled(model.effectInFlight) + }.padding() + } Divider() if let banner = model.banner { HStack(alignment: .top) { @@ -155,14 +189,23 @@ struct OperatorView: View { .buttonStyle(.borderless).foregroundStyle(.secondary) } .padding(10).frame(maxWidth: .infinity, alignment: .leading) - .background(.green.opacity(0.15)) - } - TabView { - CallersPane().tabItem { Label("Callers", systemImage: "person.2") } - ToolsPane().tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") } - ActivityPane().tabItem { Label("Activity", systemImage: "list.bullet.rectangle") } - ConfigPane().tabItem { Label("Config", systemImage: "gearshape") } + .background(.blue.opacity(0.08)) } + Picker("Section", selection: $selection) { + Text("Callers").tag(0) + Text("Tools").tag(1) + Text("Activity").tag(2) + Text("Config").tag(3) + }.pickerStyle(.segmented).labelsHidden().frame(maxWidth: 560).frame(height: 32).padding(10) + Divider() + Group { + switch selection { + case 1: ToolsPane() + case 2: ActivityPane() + case 3: ConfigPane() + default: CallersPane() + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) } .task { await model.refreshAll() } } @@ -178,8 +221,8 @@ struct BrokerBar: View { HStack(spacing: 8) { Image(systemName: "server.rack").foregroundStyle(.secondary) Text("Broker").font(.headline) - Circle().fill(running ? .green : .secondary).frame(width: 9, height: 9) - Text(running ? "running" : "stopped").font(.callout).foregroundStyle(.secondary) + Circle().fill(Color.secondary).frame(width: 9, height: 9) + Text(model.broker == nil ? "Unknown" : running ? "Alive" : "Stopped").font(.callout).foregroundStyle(.secondary) if let port = model.broker?.port { Text(verbatim: ":\(port)").font(.caption).foregroundStyle(.secondary) // no thousands separator } @@ -195,16 +238,18 @@ struct BrokerBar: View { private func control(_ symbol: String, _ help: String, disabled: Bool, _ action: @escaping () async -> Void) -> some View { Button { Task { await action() } } label: { - Image(systemName: symbol).frame(width: 16) + Image(systemName: symbol).frame(width: 32, height: 32) } .buttonStyle(.bordered) .help(help) - .disabled(disabled || model.busy) + .accessibilityLabel(help) + .disabled(disabled || model.busy || model.effectInFlight || model.uncertainMutation != nil) } } struct CallersPane: View { @EnvironmentObject var model: AppModel + @State private var selection: Set = [] @State private var newName = "" @State private var editing: Caller? // caller whose policy is being edited @State private var enablingTools: Caller? // caller whose enabled-tools are being set @@ -217,7 +262,7 @@ struct CallersPane: View { Button("Add") { Task { await model.createCaller(name: newName); newName = "" } } .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty || model.busy) } - Table(model.callers) { + Table(model.callers, selection: $selection) { TableColumn("Caller", value: \.name) TableColumn("Status") { caller in Text(caller.isActive ? "active" : "revoked") @@ -227,15 +272,26 @@ struct CallersPane: View { if caller.isActive { Menu { Button("Enabled tools...") { enablingTools = caller } - Button("Edit policy...") { editing = caller } + Button(model.policyDrafts[caller.name] == nil ? "Edit policy…" : "Resume policy draft · \(caller.name)") { editing = caller } Button("Rotate token") { Task { await model.rotateToken(caller: caller.name) } } Divider() - Button("Revoke caller", role: .destructive) { revoking = caller } + Button("Revoke caller", role: .destructive) { Task { + if await model.prepareCallerConfirmation(caller.name) { revoking = caller } + } } } label: { Image(systemName: "ellipsis.circle") } .menuStyle(.borderlessButton).fixedSize() } } } + // Native table primary actions handle double-click and Return, excluding controls. + .contextMenu(forSelectionType: Int.self) { ids in + if let caller = Caller.policyTarget(in: model.callers, selection: ids) { + Button(model.policyDrafts[caller.name] == nil ? "Edit policy…" : "Resume policy draft · \(caller.name)") { editing = caller } + } + } primaryAction: { ids in + editing = Caller.policyTarget(in: model.callers, selection: ids) + } + .help("Double-click an active caller or press Return to open policy settings") } .padding() .navigationTitle("Callers") @@ -280,18 +336,70 @@ func riskBadge(_ risk: String) -> some View { struct ToolsPane: View { @EnvironmentObject var model: AppModel @State private var editing: ToolInfo? - @State private var managingSecrets: ToolInfo? @State private var addingTool = false - @State private var removingTool: ToolInfo? var body: some View { VStack(alignment: .leading, spacing: 0) { headerBar + if let failure = model.toolsError { + HStack { + Text(failure) + Spacer() + Button("Retry listing") { Task { await model.refreshTools() } } + }.padding() + } Divider() + if let target = model.continuationTarget { + VStack(alignment: .leading, spacing: 12) { + Text("Tools / \(target)").font(.title2.bold()) + Text(model.registrationObserved(for: target) ? "Registration observed · Verified" : "Registration observation · Unknown") + if let tool = model.tools.first(where: { $0.id == target }), model.toolsError == nil { + Text("Process: \(tool.observedStatus)") + HStack { + Spacer() + Button("Refresh observation") { Task { await model.refreshCatalog() } } + if model.uncertainMutation != nil { + Text("Reload and compare the pending outcome first.").foregroundStyle(.secondary) + } else if tool.alive == false { + Button("Start \(target)") { Task { await model.toolAction(id: target, action: "start") } } + .buttonStyle(.borderedProminent).help("Executes this trusted tool; does not grant access or restart the broker") + } else if tool.alive == true && !model.registrationObserved(for: target) { + Button("Review broker restart") { Task { await model.brokerAction("restart") } } + .buttonStyle(.borderedProminent) + } else if model.registrationObserved(for: target) { + Button("Done") { model.continuationTarget = nil }.buttonStyle(.borderedProminent) + } + }.disabled(model.effectInFlight || model.uncertainMutation != nil) + } else { + Button("Refresh observation") { Task { await model.refreshCatalog() } } + .disabled(model.effectInFlight) + } + }.padding(20).frame(maxWidth: 840).frame(maxWidth: .infinity) + Divider() + } + if model.continuationTarget == nil, model.toolsError == nil, let failed = model.tools.first(where: { $0.alive == false && $0.running }) { + VStack(alignment: .leading, spacing: 12) { + Text("\(failed.id) · Process absent").font(.title2.bold()) + HStack { Text("Process"); Spacer(); Text("Not alive") } + HStack { Text("Runner record"); Spacer(); Text("Running") } + HStack { + Text("Failed").foregroundStyle(.orange) + Spacer() + Button("Refresh state") { Task { await model.refreshTools() } } + if model.uncertainMutation == nil { + Button("Review restart") { Task { await model.toolAction(id: failed.id, action: "restart") } } + .buttonStyle(.borderedProminent) + .disabled(model.effectInFlight) + } + } + }.padding(20).frame(maxWidth: 840).controlSize(.large) + .frame(maxWidth: .infinity).padding(.vertical, 16) + Divider() + } if model.tools.isEmpty { VStack(spacing: 6) { Image(systemName: "wrench.and.screwdriver").font(.largeTitle).foregroundStyle(.secondary) - Text("No tools registered").foregroundStyle(.secondary) + Text(model.toolsError == nil ? "No tools found" : "Tool listing unknown").foregroundStyle(.secondary) Text("Author tools into the broker's tools dir, then restart it.") .font(.caption).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) @@ -312,7 +420,11 @@ struct ToolsPane: View { Task { await model.resyncTool(id: tool.id) } } } - Button("Remove tool...", role: .destructive) { removingTool = tool } + if let external = tool.removable { + Button(external ? "Unregister external folder…" : "Remove managed folder…", role: .destructive) { model.requestRemoval(tool) } + } else { + Text("Folder ownership unknown · Removal unavailable") + } } label: { Image(systemName: "ellipsis.circle") } .menuStyle(.borderlessButton).fixedSize() .help("Manage this tool") @@ -330,9 +442,6 @@ struct ToolsPane: View { HStack { Text("Secrets").font(.caption.bold()).foregroundStyle(.secondary) Spacer() - if model.secretBackend?.name == "vault" && !tool.secrets.isEmpty { - Button("Set values...") { managingSecrets = tool }.font(.caption) - } } if tool.secrets.isEmpty { Text("No secrets declared.").font(.caption).foregroundStyle(.secondary) @@ -341,17 +450,20 @@ struct ToolsPane: View { } } label: { HStack(spacing: 8) { - Circle().fill(tool.running ? .green : .secondary).frame(width: 8, height: 8) + Text(model.toolsError == nil ? tool.observedStatus : "Unknown · Last known row").font(.caption).foregroundStyle(model.toolsError == nil && tool.observedStatus == "Failed" ? .orange : .secondary) Text(tool.id).bold() + if model.toolsError == nil && tool.alive == false && tool.running { + Text("Process absent · Runner record running").font(.caption) + } Text(tool.type).font(.caption).foregroundStyle(.secondary) Spacer() - toolControl("play.fill", "Start", disabled: tool.running) { + toolControl("play.fill", "Start \(tool.id)", disabled: tool.alive == true) { await model.toolAction(id: tool.id, action: "start") } - toolControl("stop.fill", "Stop", disabled: !tool.running) { + toolControl("stop.fill", "Stop \(tool.id)", disabled: !tool.running) { await model.toolAction(id: tool.id, action: "stop") } - toolControl("arrow.clockwise", "Restart", disabled: false) { + toolControl("arrow.clockwise", "Restart \(tool.id)", disabled: false) { await model.toolAction(id: tool.id, action: "restart") } } @@ -363,21 +475,12 @@ struct ToolsPane: View { .sheet(item: $editing) { tool in ToolEditor(tool: tool).environmentObject(model) } - .sheet(item: $managingSecrets) { tool in - SecretValuesSheet(tool: tool).environmentObject(model) + .task { + let epoch = model.generation + await model.refreshTools() + guard epoch == model.generation else { return } + await model.refreshSecretBackend() } - .confirmationDialog("Remove tool?", isPresented: removingToolBinding, presenting: removingTool) { tool in - Button("Remove \(tool.id)", role: .destructive) { - Task { await model.removeTool(id: tool.id) } - } - } message: { tool in - Text("Deletes \(tool.id)'s folder from the broker's tools dir. This can't be undone.") - } - .task { await model.refreshTools(); await model.refreshSecretBackend() } - } - - private var removingToolBinding: Binding { - Binding(get: { removingTool != nil }, set: { if !$0 { removingTool = nil } }) } private var headerBar: some View { @@ -413,10 +516,11 @@ struct ToolsPane: View { // A compact per-row lifecycle button. .borderless so a tap hits the button, not the disclosure. private func toolControl(_ symbol: String, _ help: String, disabled: Bool, _ action: @escaping () async -> Void) -> some View { - Button { Task { await action() } } label: { Image(systemName: symbol) } + Button { Task { await action() } } label: { Image(systemName: symbol).frame(width: 32, height: 32).contentShape(Rectangle()) } .buttonStyle(.borderless) .help(help) - .disabled(disabled || model.busy) + .accessibilityLabel(help) + .disabled(disabled || model.busy || model.effectInFlight || model.uncertainMutation != nil) } private func opRow(_ op: OpInfo) -> some View { @@ -450,21 +554,50 @@ struct PolicyEditor: View { @EnvironmentObject var model: AppModel let caller: String @Environment(\.dismiss) private var dismiss - @State private var effects: [String: Effect] = [:] // "tool.op" -> effect - @State private var enabled: Set = [] + @State private var draft: PolicyDraft @State private var loaded = false + @State private var loadFailed = false + @State private var leaving = false + @State private var resumed = false + @State private var discarded = false + @State private var identity: UUID? + + init(caller: String, fixture: PolicyDraft? = nil) { + self.caller = caller + _draft = State(initialValue: fixture ?? PolicyDraft(caller: caller)) + _loaded = State(initialValue: fixture != nil && fixture?.phase != .loadFailed) + _loadFailed = State(initialValue: fixture?.phase == .loadFailed) + _resumed = State(initialValue: fixture?.dirty == true) + } - private var enabledTools: [ToolInfo] { model.tools.filter { enabled.contains($0.id) } } + private var enabledTools: [ToolInfo] { model.tools.filter { draft.enabled.contains($0.id) } } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Policy: \(caller)").font(.title2.bold()) Spacer() - Button("Allow all") { setAll(.allow) }.disabled(!loaded || effects.isEmpty) - Button("Deny all") { setAll(.deny) }.disabled(!loaded || effects.isEmpty) } - if !loaded { + if resumed { Text("Resume policy draft · \(caller)").font(.callout) } + if draft.phase == .rejected { Text("Save rejected · Draft retained").foregroundStyle(.red) } + if draft.phase == .unknown { + Text("Save outcome unknown · Draft retained") + Button("Reload and compare") { Task { + let epoch = model.generation + if let response = await model.loadPolicy(for: caller) { + guard epoch == model.generation else { return } + draft.compare(response) + model.policyDrafts[caller] = draft + } + } }.buttonStyle(.borderedProminent) + } + if draft.phase == .conflict { Text("Reloaded policy differs from this draft. Review before saving.") } + if draft.phase == .saved { Text("Policy matches saved state for \(caller).") } + if loadFailed { + Text("Could not load policy for \(caller). Your policy has not been changed.") + Button("Retry") { Task { await load() } }.buttonStyle(.borderedProminent) + Spacer() + } else if !loaded { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) } else if enabledTools.isEmpty { VStack(spacing: 6) { @@ -483,14 +616,30 @@ struct PolicyEditor: View { } HStack { Spacer() - Button("Cancel") { dismiss() } - Button("Save") { Task { await save() } } - .keyboardShortcut(.defaultAction).disabled(!loaded || model.busy) + Button("Close") { if draft.dirty { leaving = true } else { dismiss() } } + .keyboardShortcut(.cancelAction) + .disabled(draft.phase == .saving) + Button(draft.phase == .rejected ? "Retry save" : "Save") { Task { await save() } } + .buttonStyle(.borderedProminent) + .frame(minHeight: 32).contentShape(Rectangle()) + .keyboardShortcut(.defaultAction).disabled(!loaded || !draft.canSave || model.busy) } } .padding() - .frame(minWidth: 560, minHeight: 480) + .frame(minWidth: 560, idealWidth: 760, maxWidth: 840, minHeight: 480, idealHeight: 560, maxHeight: 620) + .controlSize(.large) + .tint(.blue) .task { await load() } + .interactiveDismissDisabled(draft.dirty) + .confirmationDialog("Discard policy draft for \(caller)?", isPresented: $leaving) { + Button("Stay", role: .cancel) {} + Button("Discard draft for \(caller)", role: .destructive) { + discarded = true + model.policyDrafts[caller] = nil + dismiss() + } + } + .onDisappear { if identity == model.generation && draft.dirty && !discarded { model.policyDrafts[caller] = draft } } } @ViewBuilder @@ -507,37 +656,44 @@ struct PolicyEditor: View { Picker("", selection: bind("\(tool.id).\(op.op)")) { ForEach(Effect.allCases) { Text($0.rawValue.capitalized).tag($0) } }.pickerStyle(.segmented).labelsHidden() + .accessibilityLabel("\(caller) / \(tool.id) / \(op.op) policy") + .disabled(!draft.canSave) }.padding(.vertical, 2) } private func bind(_ key: String) -> Binding { - Binding(get: { effects[key] ?? .deny }, set: { effects[key] = $0 }) - } - - private func setAll(_ effect: Effect) { - for key in Array(effects.keys) { effects[key] = effect } + Binding(get: { draft.effects[key] ?? .deny }, set: { + draft.set($0, for: key) + model.policyDrafts[caller] = draft + }) } private func load() async { - await model.refreshTools() - guard let resp = await model.loadPolicy(for: caller) else { loaded = true; return } - enabled = Set(resp.enabled) - var ops: [String: Effect] = [:] - for tool in enabledTools { - for op in tool.ops { ops["\(tool.id).\(op.op)"] = resp.policy.effect(tool: tool.id, op: op.op) } + if CommandLine.arguments.contains("--snapshot-directory") { return } + let epoch = model.generation + identity = epoch + loadFailed = false + loaded = false + if let saved = model.policyDrafts[caller] { + draft = saved; loaded = true; resumed = true; return } - effects = ops + guard await model.refreshTools() else { loadFailed = true; return } + guard epoch == model.generation else { return } + guard let resp = await model.loadPolicy(for: caller) else { loadFailed = true; return } + guard epoch == model.generation else { return } + draft.load(resp) loaded = true } private func save() async { - var allow: [String] = [], review: [String] = [] - // allow/review listed; deny == omitted (absent is denied) - for (key, effect) in effects { - if effect == .allow { allow.append(key) } else if effect == .review { review.append(key) } - } - await model.savePolicy(caller: caller, allow: allow, review: review) - dismiss() + guard draft.canSave else { return } + let pending = draft + let epoch = model.generation + _ = draft.beginSave() + let result = await model.writePolicy(pending) + guard epoch == model.generation else { return } + draft = result + if draft.phase == .saved { model.policyDrafts[caller] = nil; dismiss() } } } @@ -549,13 +705,49 @@ struct EnabledToolsEditor: View { @Environment(\.dismiss) private var dismiss @State private var selected: Set = [] @State private var loaded = false + @State private var failed = false + @State private var saving = false + @State private var outcome: MutationResult? + @State private var showPolicy = false + @State private var comparedConflict = false + @State private var initial: Set = [] + @State private var leaving = false + @State private var resumed = false var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Enabled tools: \(caller)").font(.title2.bold()) - Text("Which tools this caller may use. The policy editor shows ops for these.") - .font(.caption).foregroundStyle(.secondary) - if !loaded { + if resumed { Text("Resume enabled-tools draft · \(caller)") } + if comparedConflict { Text("Remote enablements changed. Local toggles merged; review before saving.") } + else if outcome == .rejected { Text("Save rejected · Selection retained").foregroundStyle(.red) } + if outcome == .unknown { + Text("Save outcome unknown · Reload before retrying") + Button("Reload and compare") { Task { + let epoch = model.generation + if let response = await model.loadPolicy(for: caller) { + guard epoch == model.generation else { return } + if Set(response.enabled) == selected { + model.enabledDrafts[caller] = nil + model.enabledOutcomes[caller] = nil + model.enabledBaselines[caller] = nil + initial = selected + showPolicy = true + } else { + selected = EnabledToolsMerge.reconcile(baseline: initial, local: selected, remote: Set(response.enabled)) + initial = Set(response.enabled) + model.enabledBaselines[caller] = initial + comparedConflict = true + model.enabledDrafts[caller] = selected + outcome = .rejected + model.enabledOutcomes[caller] = .rejected + } + } + } } + } + if failed { + Text("Could not load enabled tools for \(caller).") + Button("Retry") { Task { await load() } } + } else if !loaded { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) } else if model.tools.isEmpty { Text("No tools registered.").foregroundStyle(.secondary) @@ -568,34 +760,83 @@ struct EnabledToolsEditor: View { Text(tool.type).font(.caption).foregroundStyle(.secondary) } } + .disabled(saving || outcome == .unknown) } } HStack { Spacer() - Button("Cancel") { dismiss() } - Button("Save") { Task { await save() } } - .keyboardShortcut(.defaultAction).disabled(!loaded || model.busy) + Button("Close") { if selected != initial { leaving = true } else { dismiss() } } + .keyboardShortcut(.cancelAction) + .disabled(saving) + Button("Save and edit policy") { Task { await save() } } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction).disabled(!loaded || saving || outcome == .unknown) } } .padding() .frame(minWidth: 420, minHeight: 360) .task { await load() } + .sheet(isPresented: $showPolicy, onDismiss: { dismiss() }) { + PolicyEditor(caller: caller).environmentObject(model) + } + .interactiveDismissDisabled(selected != initial) + .confirmationDialog("Discard enabled-tools draft for \(caller)?", isPresented: $leaving) { + Button("Stay", role: .cancel) {} + Button("Discard draft for \(caller)", role: .destructive) { + model.enabledDrafts[caller] = nil + model.enabledOutcomes[caller] = nil + model.enabledBaselines[caller] = nil + dismiss() + } + } } private func toggle(_ id: String) -> Binding { Binding(get: { selected.contains(id) }, - set: { if $0 { selected.insert(id) } else { selected.remove(id) } }) + set: { + if $0 { selected.insert(id) } else { selected.remove(id) } + model.enabledDrafts[caller] = selected + model.enabledBaselines[caller] = initial + }) } private func load() async { - await model.refreshTools() - if let resp = await model.loadPolicy(for: caller) { selected = Set(resp.enabled) } + let epoch = model.generation + failed = false + loaded = false + guard await model.refreshTools() else { failed = true; return } + guard epoch == model.generation else { return } + guard let resp = await model.loadPolicy(for: caller) else { failed = true; return } + guard epoch == model.generation else { return } + initial = model.enabledBaselines[caller] ?? Set(resp.enabled) + selected = model.enabledDrafts[caller] ?? initial + resumed = model.enabledDrafts[caller] != nil + outcome = model.enabledOutcomes[caller] + if resumed && outcome != .unknown { + selected = EnabledToolsMerge.reconcile(baseline: initial, local: selected, remote: Set(resp.enabled)) + initial = Set(resp.enabled) + model.enabledBaselines[caller] = initial + model.enabledDrafts[caller] = selected + } loaded = true } private func save() async { - await model.setEnabledTools(caller: caller, enabled: Array(selected)) - dismiss() + guard loaded, !saving, outcome != .unknown else { return } + saving = true + let epoch = model.generation + let result = await model.setEnabledTools(caller: caller, enabled: Array(selected)) + guard epoch == model.generation else { return } + outcome = result + model.enabledOutcomes[caller] = outcome + saving = false + if outcome == .success { + model.enabledDrafts[caller] = nil + model.enabledBaselines[caller] = nil + model.enabledOutcomes[caller] = nil + initial = selected + showPolicy = true + } } } @@ -718,86 +959,12 @@ struct EditableSecret: Identifiable { init() { name = ""; field = ""; writable = false; item = "" } } -/// Provision a tool's secret VALUES into the local vault. Values are write-only: typed into a -/// SecureField, sent, and never shown back; the sheet only knows set/unset status per field. -struct SecretValuesSheet: View { - let tool: ToolInfo - @EnvironmentObject var model: AppModel - @Environment(\.dismiss) private var dismiss - @State private var status: SecretStatus? - @State private var entries: [String: String] = [:] // field -> the value being typed - @State private var saving: String? // field currently being saved - @State private var note: String? - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Secret values: \(tool.id)").font(.title2.bold()) - Text("Stored in the local encrypted vault. Values are write-only: they're saved, never " - + "shown back. Restart the tool to pick up a change.") - .font(.caption).foregroundStyle(.secondary) - if status == nil { - ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(tool.secrets) { secretRow($0) } - } - HStack { - if let note { Text(note).font(.caption).foregroundStyle(.secondary).lineLimit(2) } - Spacer() - Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) - } - } - .padding().frame(minWidth: 540, minHeight: 360) - .task { status = await model.secretStatus(toolId: tool.id) } - } - - private func secretRow(_ secret: SecretDecl) -> some View { - let isSet = status?.provisioned.contains(secret.field) ?? false - return HStack(alignment: .center, spacing: 10) { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text(secret.name).bold() - Image(systemName: "arrow.right").font(.caption2).foregroundStyle(.secondary) - Text(secret.field).font(.caption).foregroundStyle(.secondary) - Text(isSet ? "set" : "not set").font(.caption2) - .padding(.horizontal, 5).padding(.vertical, 1) - .background((isSet ? Color.green : .orange).opacity(0.2), in: .capsule) - } - SecureField("new value", text: binding(secret.field)) - .textFieldStyle(.roundedBorder).frame(maxWidth: 300) - .onSubmit { Task { await save(secret.field) } } - } - Spacer() - Button(saving == secret.field ? "Saving..." : "Save") { Task { await save(secret.field) } } - .disabled((entries[secret.field] ?? "").isEmpty || saving != nil) - }.padding(.vertical, 3) - } - - private func binding(_ field: String) -> Binding { - Binding(get: { entries[field] ?? "" }, set: { entries[field] = $0 }) - } - - private func save(_ field: String) async { - let value = entries[field] ?? "" - guard !value.isEmpty else { return } - saving = field - let ok = await model.setSecretValue(toolId: tool.id, field: field, value: value) - saving = nil - if ok { - entries[field] = "" // clear the typed value - note = "Saved \(field)." - status = await model.secretStatus(toolId: tool.id) // refresh set/unset - } else { - note = model.error - } - } -} - /// Add a tool by pointing at a folder that contains a toolyard.toml. The path is on the ADMIN's /// machine: for a local admin that's this Mac (use "Choose..."); for a remote/Docker admin, type a /// path it can see. The folder is copied into the broker's managed tools dir. struct AddToolSheet: View { enum Mode: String, CaseIterable, Identifiable { - case folder = "Local folder", github = "GitHub" + case folder = "Admin host folder", github = "GitHub" var id: String { rawValue } } @@ -808,7 +975,6 @@ struct AddToolSheet: View { @State private var repo = "" @State private var subdir = "" @State private var ref = "" - @State private var picking = false @State private var addError: String? @State private var authoringFor: String? // set when a picked folder has no toolyard.toml @@ -844,9 +1010,6 @@ struct AddToolSheet: View { } .padding() .frame(minWidth: 520, minHeight: 230) - .fileImporter(isPresented: $picking, allowedContentTypes: [.folder]) { result in - if case .success(let url) = result { path = url.path } // a local pick fills the field - } } @ViewBuilder private var folderFields: some View { @@ -855,7 +1018,8 @@ struct AddToolSheet: View { .font(.caption).foregroundStyle(.secondary) HStack { TextField("/path/to/tool-folder", text: $path).textFieldStyle(.roundedBorder) - Button("Choose...") { picking = true } + Button("Author a tool…") { authoringFor = path } + .disabled(path.trimmingCharacters(in: .whitespaces).isEmpty) } } @@ -949,6 +1113,9 @@ struct ToolAuthoringForm: View { @State private var operations: [EditableOp] = [EditableOp()] @State private var secrets: [EditableSecret] = [] @State private var saveError: String? + @State private var importing = false + @State private var specification = "" + @State private var authHeaders: [ParsedAuthHeader] = [] private let risks = ["read", "write", "destructive"] // matches admin RISK_CHOICES private let toolTypes = ["api", "mcp", "rest"] // matches admin TOOL_TYPES @@ -964,8 +1131,8 @@ struct ToolAuthoringForm: View { Spacer() } Text("Author a tool").font(.title2.bold()) - Text("No toolyard.toml in \(source). Describe the tool. The folder is copied into the " - + "broker's tools dir with the manifest you write here.") + Button("Import OpenAPI…") { importing = true } + Text("Admin host folder: \(source). Creating copies this folder with the reviewed manifest.") .font(.caption).foregroundStyle(.secondary).lineLimit(2) Form { Section("Tool") { @@ -976,6 +1143,12 @@ struct ToolAuthoringForm: View { TextField("description (optional)", text: $description, axis: .vertical).lineLimit(1...3) if type == "rest" { TextField("base URL, e.g. https://api.example.com/v1", text: $baseURL) + ForEach(authHeaders) { header in + Text("\(header.name): \(header.value)").font(.caption.monospaced()) + } + if !authHeaders.isEmpty { + Text("Header templates for calls—not automatically sent").font(.caption).foregroundStyle(.secondary) + } } } Section { @@ -1008,6 +1181,36 @@ struct ToolAuthoringForm: View { } .padding() .frame(minWidth: 580, minHeight: 560) + .sheet(isPresented: $importing) { + VStack(alignment: .leading, spacing: 12) { + Text("Import OpenAPI · Review draft").font(.title2.bold()) + Text("Paste the specification. Parsing only prepares an editable draft.") + TextEditor(text: $specification).font(.body.monospaced()) + if let error = model.error { Text(error).foregroundStyle(.red) } + HStack { + Spacer() + Button("Cancel") { importing = false }.keyboardShortcut(.cancelAction) + Button("Parse to draft") { Task { await importDraft() } } + .buttonStyle(.borderedProminent).disabled(specification.isEmpty || model.busy) + } + }.padding().frame(width: 600, height: 420) + } + } + + private func importDraft() async { + guard let parsed = await model.parseOpenAPI(specification) else { return } + type = "rest"; baseURL = parsed.baseUrl; authHeaders = parsed.authHeaders + secrets = parsed.secrets.map(EditableSecret.init(from:)) + operations = parsed.operations.map { source in + var op = EditableOp() + op.name = source.name; op.verb = source.verb; op.path = source.path + op.risk = source.risk; op.description = source.description + op.bodyKind = source.bodyKind; op.bodyContentType = source.bodyContentType ?? "" + op.allowedHeaders = source.allowedHeaders.map { EditableHeader(name: $0) } + op.args = source.args.map { EditableArg(name: $0.name, type: $0.type, required: $0.required) } + return op + } + importing = false } private var entrypointHint: String { @@ -1200,12 +1403,9 @@ struct ToolAuthoringForm: View { "operations": operations.compactMap { op -> [String: Any]? in guard !s(op.name).isEmpty else { return nil } if type == "rest" { - var row: [String: Any] = [ - "name": s(op.name), "verb": s(op.verb), "path": s(op.path), - "description": s(op.description), "body_kind": s(op.bodyKind), - ] let headers = op.allowedHeaders.map { s($0.name) }.filter { !$0.isEmpty } - if !headers.isEmpty { row["allowed_headers"] = headers } + var row = RestAuthoringPayload.operation(name: s(op.name), verb: s(op.verb), path: s(op.path), + description: s(op.description), bodyKind: s(op.bodyKind), allowedHeaders: headers) let rules = op.secretUpdateRules.compactMap { rule -> [String: Any]? in let target = s(rule.secretName) guard !target.isEmpty else { return nil } @@ -1249,9 +1449,6 @@ struct ActivityPane: View { }.pickerStyle(.segmented).labelsHidden().frame(maxWidth: 220) Spacer() if model.busy { ProgressView().controlSize(.small) } - Button { Task { await model.clearAudit() } } label: { Image(systemName: "trash") } - .help("Clear audit log") - .disabled(model.busy || (model.audit?.audit.isEmpty ?? true)) Button { Task { await model.refreshAudit() } } label: { Image(systemName: "arrow.clockwise") } .help("Refresh") } diff --git a/macapp/Sources/ToolstackKit/ApiClient.swift b/macapp/Sources/ToolstackKit/ApiClient.swift index baf8503..5185b00 100644 --- a/macapp/Sources/ToolstackKit/ApiClient.swift +++ b/macapp/Sources/ToolstackKit/ApiClient.swift @@ -48,6 +48,11 @@ public actor ApiClient { try await send("GET", "/api/broker") } + public func catalogObservation() async throws -> CatalogObservation { + guard let token, !token.isEmpty else { throw ApiError.unauthorized } + return try await send("GET", "/api/catalog") + } + /// `action` is "start", "stop", or "restart". Returns the resulting status. public func brokerAction(_ action: String) async throws -> BrokerStatus { try await send("POST", "/api/broker/\(action)", body: [:]) @@ -102,14 +107,9 @@ public actor ApiClient { try await send("GET", "/api/audit", query: [URLQueryItem(name: "limit", value: String(limit))]) } - @discardableResult - public func clearAudit() async throws -> Bool { - let result: OkResult = try await send("DELETE", "/api/audit") - return result.ok - } - public func listTools() async throws -> [ToolInfo] { let response: ToolsResponse = try await send("GET", "/api/tools") + if let error = response.error { throw ApiError.decoding("Tool listing unavailable: \(error)") } return response.tools } @@ -167,27 +167,23 @@ public actor ApiClient { try await send("POST", "/api/tools/\(id)/update", body: [:]) } - /// Remove a TSR-managed tool (stops it, deletes its folder under the tools root). Returns the - /// removed id. Throws `.http(400)` for a tool registered from an external dir (not deletable). + /// Compatibility id-only result. Use removeToolReceipt to distinguish managed deletion, + /// external unregistration (files retained), and legacy unknown filesystem outcomes. @discardableResult public func deleteTool(id: String) async throws -> String { - let result: RemovedTool = try await send("DELETE", "/api/tools/\(id)") + let result = try await removeToolReceipt(id: id) return result.removed } + public func removeToolReceipt(id: String) async throws -> RemovedTool { + try await send("DELETE", "/api/tools/\(id)") + } + /// Set/unset status of a tool's declared secret fields (vault only; never the values). public func secretStatus(toolId: String) async throws -> SecretStatus { try await send("GET", "/api/tools/\(toolId)/secrets") } - /// Provision a secret VALUE into the local vault (write-only). Returns whether it was set. - @discardableResult - public func setSecretValue(toolId: String, field: String, value: String) async throws -> Bool { - let result: SetSecretResult = try await send("POST", "/api/tools/\(toolId)/secrets", - body: ["field": field, "value": value]) - return result.set - } - public func policy(for caller: String) async throws -> PolicyResponse { try await send("GET", "/api/callers/\(caller)/policy") } diff --git a/macapp/Sources/ToolstackKit/CatalogObservation.swift b/macapp/Sources/ToolstackKit/CatalogObservation.swift new file mode 100644 index 0000000..11451dc --- /dev/null +++ b/macapp/Sources/ToolstackKit/CatalogObservation.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct CatalogObservation: Decodable, Equatable, Sendable { + public let observationID: String? + public let toolIDs: [String] + public var verified: Bool { observationID != nil } + public static let unknown = CatalogObservation(observationID: nil) + private init(observationID: String?) { self.observationID = observationID; self.toolIDs = [] } + enum CodingKeys: String, CodingKey { case status, observationId, toolIds } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let status = try? values.decode(String.self, forKey: .status) + let id = try? values.decode(String.self, forKey: .observationId) + let ids = try? values.decode([String].self, forKey: .toolIds) + observationID = status == "verified" && ids != nil && id?.isEmpty == false && id!.unicodeScalars.allSatisfy({ CharacterSet(charactersIn: "0123456789abcdefABCDEF").contains($0) }) ? id : nil + toolIDs = observationID != nil ? ids! : [] + } +} diff --git a/macapp/Sources/ToolstackKit/EnabledToolsMerge.swift b/macapp/Sources/ToolstackKit/EnabledToolsMerge.swift new file mode 100644 index 0000000..d12812b --- /dev/null +++ b/macapp/Sources/ToolstackKit/EnabledToolsMerge.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum EnabledToolsMerge { + public static func reconcile(baseline: Set, local: Set, remote: Set) -> Set { + remote.union(local.subtracting(baseline)).subtracting(baseline.subtracting(local)) + } +} diff --git a/macapp/Sources/ToolstackKit/Models.swift b/macapp/Sources/ToolstackKit/Models.swift index 0e9f64f..613190e 100644 --- a/macapp/Sources/ToolstackKit/Models.swift +++ b/macapp/Sources/ToolstackKit/Models.swift @@ -24,6 +24,12 @@ public struct Caller: Codable, Sendable, Identifiable, Equatable { public let name: String public let revokedAt: Double? // null when active (extra columns are ignored) public var isActive: Bool { revokedAt == nil } + + /// Resolve the table's primary action from fresh rows, never a stale selection. + public static func policyTarget(in callers: [Caller], selection: Set) -> Caller? { + guard selection.count == 1, let id = selection.first else { return nil } + return callers.first { $0.id == id && $0.isActive } + } } public struct CallersResponse: Codable, Sendable { @@ -83,10 +89,21 @@ public struct ToolInfo: Codable, Sendable, Identifiable, Equatable { public let port: Int? public let running: Bool public let ops: [OpInfo] + public let alive: Bool? + public let state: String? + public let health: String? + /// true = external tool_dirs entry; false = managed root; nil = unknown. + public let removable: Bool? + public let path: String? + public var observedStatus: String { + if alive == true { return "Alive" } + if alive == false { return running ? "Failed" : "Stopped" } + return "Unknown" + } public let secrets: [SecretDecl] public let source: ToolSource? // nil for hand-authored tools (no sidecar) → no Update - enum CodingKeys: String, CodingKey { case id, type, description, port, running, ops, secrets, source } + enum CodingKeys: String, CodingKey { case id, type, description, port, running, ops, secrets, source, alive, state, health, removable, path } // Tolerate an admin that doesn't report `description` / `ops` / `running` / `secrets` / `source` // (e.g. an older version, or a different deployment) rather than failing the whole response. @@ -97,6 +114,11 @@ public struct ToolInfo: Codable, Sendable, Identifiable, Equatable { description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" port = try c.decodeIfPresent(Int.self, forKey: .port) running = try c.decodeIfPresent(Bool.self, forKey: .running) ?? false + alive = try c.decodeIfPresent(Bool.self, forKey: .alive) + state = try c.decodeIfPresent(String.self, forKey: .state) + health = try c.decodeIfPresent(String.self, forKey: .health) + removable = try c.decodeIfPresent(Bool.self, forKey: .removable) + path = try c.decodeIfPresent(String.self, forKey: .path) ops = try c.decodeIfPresent([OpInfo].self, forKey: .ops) ?? [] secrets = try c.decodeIfPresent([SecretDecl].self, forKey: .secrets) ?? [] source = try c.decodeIfPresent(ToolSource.self, forKey: .source) @@ -122,13 +144,15 @@ public struct CreatedTool: Codable, Sendable { /// Result of removing a managed tool (`DELETE /api/tools/{id}`). public struct RemovedTool: Codable, Sendable { public let removed: String + public let deleted: Bool? } /// The active secret backend (deployment-wide). `path` (file/vault) or /// `host`/`environment`/`defaultVault`/`identityConfigured` (Infisical) are present /// depending on `name`. public struct SecretBackend: Codable, Sendable { - public let name: String // "file" | "vault" | "infisical" + public let plugin: String + public var name: String { plugin } public let path: String? public let host: String? public let environment: String? @@ -282,12 +306,9 @@ public struct SecretStatus: Codable, Sendable, Equatable { public let backend: String public let settable: Bool public let fields: [String] - public let provisioned: [String] -} - -public struct SetSecretResult: Codable, Sendable { - public let field: String - public let set: Bool + public let provisioned: [String]? + public let status: String? + public var availability: String { provisioned == nil ? "Unknown" : "Observed" } } /// What went wrong talking to the admin API. `unauthorized` is special-cased so the UI can diff --git a/macapp/Sources/ToolstackKit/MutationResult.swift b/macapp/Sources/ToolstackKit/MutationResult.swift new file mode 100644 index 0000000..f2beb6d --- /dev/null +++ b/macapp/Sources/ToolstackKit/MutationResult.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum MutationResult: Equatable, Sendable { + case success, rejected, unknown + public init(error: Error) { + switch error { + case ApiError.unauthorized: self = .rejected + case ApiError.http(let status, _) where [400, 401, 403, 404, 405, 422].contains(status): self = .rejected + default: self = .unknown + } + } +} diff --git a/macapp/Sources/ToolstackKit/PolicyDraft.swift b/macapp/Sources/ToolstackKit/PolicyDraft.swift new file mode 100644 index 0000000..0b88d33 --- /dev/null +++ b/macapp/Sources/ToolstackKit/PolicyDraft.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Memory-only editing state. Unknown mutation outcomes require a fresh comparison. +public struct PolicyDraft: Sendable { + public enum Phase: Equatable, Sendable { case loading, loadFailed, editing, saving, rejected, unknown, conflict, saved } + public let caller: String + public var effects: [String: Effect] = [:] + public private(set) var enabled: Set = [] + public private(set) var baseline: [String: Effect] = [:] + public private(set) var phase: Phase = .loading + public var canSave: Bool { [.editing, .rejected, .conflict].contains(phase) } + public var dirty: Bool { effects != baseline } + public init(caller: String) { self.caller = caller } + public mutating func loadFailed() { phase = .loadFailed } + public mutating func load(_ response: PolicyResponse) { + baseline = Self.flatten(response.policy) + effects = baseline + enabled = Set(response.enabled) + phase = .editing + } + public mutating func beginSave() -> Bool { + guard canSave else { return false } + phase = .saving + return true + } + public mutating func saved() { baseline = effects; phase = .saved } + public mutating func failed(_ error: Error) { + phase = MutationResult(error: error) == .rejected ? .rejected : .unknown + } + public mutating func compare(_ response: PolicyResponse) { + let remote = Self.flatten(response.policy) + enabled = Set(response.enabled) + if remote == effects { baseline = remote; phase = .saved } + else { + let edited = Set(baseline.keys).union(effects.keys).filter { baseline[$0] != effects[$0] } + var merged = remote + for key in edited { merged[key] = effects[key] } + effects = merged + baseline = remote; phase = .conflict + } + } + private static func flatten(_ policy: Policy) -> [String: Effect] { + var result: [String: Effect] = [:] + for (tool, ops) in policy.tools { + for (op, raw) in ops where raw != "deny" { result["\(tool).\(op)"] = Effect(rawValue: raw) ?? .deny } + } + return result + } + public mutating func set(_ effect: Effect, for key: String) { + if effect == .deny { effects.removeValue(forKey: key) } else { effects[key] = effect } + } +} diff --git a/macapp/Sources/ToolstackKit/RestAuthoringPayload.swift b/macapp/Sources/ToolstackKit/RestAuthoringPayload.swift new file mode 100644 index 0000000..440038b --- /dev/null +++ b/macapp/Sources/ToolstackKit/RestAuthoringPayload.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Existing admin authoring contract. Header templates are call hints, never stored credentials. +public enum RestAuthoringPayload { + public static func operation(name: String, verb: String, path: String, description: String, + bodyKind: String, allowedHeaders: [String]) -> [String: Any] { + var row: [String: Any] = ["name": name, "verb": verb, "path": path, + "description": description, "body_kind": bodyKind] + if !allowedHeaders.isEmpty { row["allowed_headers"] = allowedHeaders } + return row + } +} diff --git a/macapp/Tests/ToolstackKitTests/ActiveRefreshTests.swift b/macapp/Tests/ToolstackKitTests/ActiveRefreshTests.swift new file mode 100644 index 0000000..e7d9efc --- /dev/null +++ b/macapp/Tests/ToolstackKitTests/ActiveRefreshTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import ToolstackKit +@testable import ToolstackApp + +final class ActiveRefreshTests: XCTestCase { + private let account = "http://refresh.invalid" + private func client(pending: Bool = false) -> ApiClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = pending ? [PendingTransport.self] : [StubURLProtocol.self] + return ApiClient(baseURL: URL(string: account)!, session: URLSession(configuration: configuration), token: "FAKE-TEST-TOKEN") + } + private func stub() { + StubURLProtocol.requestCount = 0 + StubURLProtocol.handler = { request in + let json: String + switch request.url!.path { + case "/api/broker": json = #"{"running":true}"# + case "/api/callers": json = #"{"callers":[{"id":1,"name":"demo","revoked_at":123}]}"# + case "/api/tools": json = #"{"tools":[]}"# + default: XCTFail("Unexpected request: \(request)"); json = "{}" + } + return (200, [:], Data(json.utf8)) + } + } + @MainActor func testActivationAndActiveTickObserveRevocationPreservingDraftsAndConfirmation() async throws { + stub() + let model = AppModel(client: client(), serverURL: account) + model.authenticated = true + model.callers = try JSONDecoder().decode(CallersResponse.self, from: Data(#"{"callers":[{"id":1,"name":"demo"}]}"#.utf8)).callers + var draft = PolicyDraft(caller: "demo") + draft.set(.allow, for: "demo.read") + model.policyDrafts["demo"] = draft + model.enabledDrafts["demo"] = ["demo"] + model.confirmation = .init(title: "Existing confirmation", effect: "Keep", action: {}) + model.uncertainMutation = "Outcome unknown" + let epoch = model.generation + await model.refreshWhileActive(false) + XCTAssertEqual(StubURLProtocol.requestCount, 0) + await model.refreshWhileActive(true) + XCTAssertEqual(StubURLProtocol.requestCount, 3) + XCTAssertFalse(try XCTUnwrap(model.callers.first).isActive) + XCTAssertEqual(model.policyDrafts["demo"]?.effects, draft.effects) + XCTAssertEqual(model.enabledDrafts["demo"], ["demo"]) + XCTAssertEqual(model.generation, epoch, "Stable view identity keeps open editors mounted") + XCTAssertEqual(model.confirmation?.title, "Existing confirmation") + XCTAssertEqual(model.uncertainMutation, "Outcome unknown") + await model.refreshWhileActive(true) + XCTAssertEqual(StubURLProtocol.requestCount, 6) + } + @MainActor func testRevokedCallerCannotPresentRevokeOrRotateConfirmation() async { + stub() + let model = AppModel(client: client(), serverURL: account) + model.authenticated = true + let allowed = await model.prepareCallerConfirmation("demo") + XCTAssertFalse(allowed) + await model.rotateToken(caller: "demo") + XCTAssertNil(model.confirmation) + XCTAssertEqual(StubURLProtocol.requestCount, 6) + } + @MainActor func testConcurrentRefreshesCoalesce() async { + PendingTransport.count = 0 + let model = AppModel(client: client(pending: true), serverURL: account) + model.authenticated = true + let started = expectation(description: "Broker read held") + PendingTransport.started = { + switch PendingTransport.count { + case 1: started.fulfill() + case 2: PendingTransport.pending?.respond(200, #"{"callers":[]}"#) + case 3: PendingTransport.pending?.respond(200, #"{"tools":[]}"#) + default: XCTFail("Overlapping refresh") + } + } + let first = Task { await model.refreshAll() } + await fulfillment(of: [started], timeout: 2) + let joined = expectation(description: "Second refresh joined") + let second = Task { joined.fulfill(); await model.refreshAll() } + await fulfillment(of: [joined], timeout: 2) + XCTAssertEqual(PendingTransport.count, 1) + PendingTransport.pending?.respond(200, #"{"running":true}"#) + await first.value; await second.value + XCTAssertEqual(PendingTransport.count, 3) + } + @MainActor func testSignOutDeletesStoredTokenClearsClientAndReturnsToLoginWithoutServerLogout() async throws { + stub() + let api = client() + var tokens = [account: "FAKE-TEST-TOKEN"] + var deleted: [String] = [] + let storage = TokenStorage(save: { tokens[$1] = $0 }, load: { tokens[$0] }, delete: { + deleted.append($0); tokens[$0] = nil + }) + let model = AppModel(client: api, serverURL: account, tokenStorage: storage) + model.authenticated = true + await model.refreshWhileActive(true) + XCTAssertEqual(StubURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer FAKE-TEST-TOKEN") + let count = StubURLProtocol.requestCount + await model.logout() + XCTAssertEqual(deleted, [account]); XCTAssertNil(storage.load(account)) + let authorized = await api.isAuthenticated + XCTAssertFalse(authorized) + XCTAssertFalse(model.authenticated); XCTAssertFalse(model.restoring) + XCTAssertTrue(model.callers.isEmpty); XCTAssertNil(model.broker) + await model.refreshWhileActive(true) + await model.refreshAll() + XCTAssertEqual(StubURLProtocol.requestCount, count, "Sign-out is client-side only; no logout endpoint or signed-out refresh") + _ = try await api.brokerStatus() + XCTAssertNil(StubURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization")) + } + @MainActor func testSignOutDuringRefreshStopsFollowupsAndLatePublication() async { + PendingTransport.count = 0 + let model = AppModel(client: client(pending: true), serverURL: account, + tokenStorage: .init(save: { _, _ in }, load: { _ in nil }, delete: { _ in })) + model.authenticated = true + let started = expectation(description: "Broker read held") + PendingTransport.started = { started.fulfill() } + let task = Task { await model.refreshWhileActive(true) } + await fulfillment(of: [started], timeout: 2) + await model.logout() + PendingTransport.pending?.respond(200, #"{"running":true}"#) + await task.value + await model.refreshWhileActive(true) + XCTAssertEqual(PendingTransport.count, 1) + XCTAssertNil(model.broker); XCTAssertTrue(model.callers.isEmpty) + } + override func tearDown() { + StubURLProtocol.handler = nil; StubURLProtocol.requestCount = 0 + StubURLProtocol.lastRequest = nil + PendingTransport.pending = nil; PendingTransport.started = nil + } +} diff --git a/macapp/Tests/ToolstackKitTests/ApiClientTests.swift b/macapp/Tests/ToolstackKitTests/ApiClientTests.swift index 21ef711..dceda9e 100644 --- a/macapp/Tests/ToolstackKitTests/ApiClientTests.swift +++ b/macapp/Tests/ToolstackKitTests/ApiClientTests.swift @@ -7,14 +7,18 @@ final class StubURLProtocol: URLProtocol { static var handler: ((URLRequest) -> (Int, [String: String], Data))? static var lastRequest: URLRequest? static var lastBody: Data? + static var failure: Error? + static var requestCount = 0 override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func stopLoading() {} override func startLoading() { + Self.requestCount += 1 StubURLProtocol.lastRequest = request StubURLProtocol.lastBody = Self.readBody(request) + if let failure = Self.failure { client?.urlProtocol(self, didFailWithError: failure); return } let (status, headers, data) = StubURLProtocol.handler!(request) let resp = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers)! @@ -56,6 +60,58 @@ final class ApiClientTests: XCTestCase { StubURLProtocol.handler = nil StubURLProtocol.lastRequest = nil StubURLProtocol.lastBody = nil + StubURLProtocol.failure = nil + StubURLProtocol.requestCount = 0 + } + + func testImportedHeaderDeclarationPayloadIsNotStaticInjection() async throws { + let row = RestAuthoringPayload.operation(name: "read", verb: "GET", path: "/notes", + description: "Read", bodyKind: "none", allowedHeaders: ["Authorization"]) + StubURLProtocol.handler = { _ in (200, [:], Data(#"{"id":"notes","type":"rest","description":"","path":"/fake/notes"}"#.utf8)) } + _ = try await makeClient().addToolWithManifest(source: "/fake/source", manifest: [ + "id": "notes", "type": "rest", "operations": [row], + "secrets": [["name": "api_token", "field": "API_TOKEN", "writable": false]] + ]) + let body = try bodyJSON() + let manifest = try XCTUnwrap(body["manifest"] as? [String: Any]) + let operation = try XCTUnwrap((manifest["operations"] as? [[String: Any]])?.first) + XCTAssertEqual(operation["allowed_headers"] as? [String], ["Authorization"]) + XCTAssertNil(manifest["auth_headers"]) + XCTAssertNil(operation["headers"]) + XCTAssertEqual((manifest["secrets"] as? [[String: Any]])?.first?["field"] as? String, "API_TOKEN") + } + + func testPolicyTimeoutIsUnknownAndNotReplayed() async throws { + StubURLProtocol.failure = URLError(.timedOut) + do { + _ = try await makeClient().setPolicy(caller: "writer", allow: ["notes.read"], review: []) + XCTFail("Expected timeout") + } catch { + XCTAssertEqual(MutationResult(error: error), .unknown) + XCTAssertEqual(StubURLProtocol.requestCount, 1) + XCTAssertEqual(StubURLProtocol.lastRequest?.httpMethod, "PUT") + } + } + + func testPolicyHTTPRejectionRetainsDraft() async throws { + StubURLProtocol.handler = { _ in (400, [:], Data(#"{"detail":"validation"}"#.utf8)) } + var draft = PolicyDraft(caller: "writer") + do { + _ = try await makeClient().setPolicy(caller: "writer", allow: [], review: []) + XCTFail("Expected rejection") + } catch { + draft.failed(error) + XCTAssertEqual(draft.phase, .rejected) + XCTAssertEqual(draft.caller, "writer") + } + } + + func testPolicyLoadFailureCannotSave() async throws { + StubURLProtocol.handler = { _ in (503, [:], Data(#"{"detail":"unavailable"}"#.utf8)) } + var draft = PolicyDraft(caller: "writer") + do { draft.load(try await makeClient().policy(for: "writer")); XCTFail("Expected failed load") } + catch { draft.loadFailed() } + XCTAssertFalse(draft.beginSave()) } func testLoginSendsPasswordUnauthedAndStoresToken() async throws { @@ -219,7 +275,7 @@ final class ApiClientTests: XCTestCase { func testSecretBackendDecodes() async throws { StubURLProtocol.handler = { _ in - (200, [:], Data(#"{"name":"infisical","host":"https://infisical.example.test","default_vault":"ToolServer","organization_slug":"org","identity_configured":true}"#.utf8)) + (200, [:], Data(#"{"plugin":"infisical","host":"https://infisical.example.test","default_vault":"ToolServer","organization_slug":"org","identity_configured":true}"#.utf8)) } let backend = try await makeClient(token: "t").secretBackend() XCTAssertEqual(backend.name, "infisical") @@ -311,26 +367,15 @@ final class ApiClientTests: XCTestCase { } func testSecretStatusDecodes() async throws { - let json = #"{"backend":"vault","settable":true,"fields":["API_KEY"],"provisioned":["API_KEY"]}"# + let json = #"{"backend":"localfile","settable":false,"fields":["API_KEY"],"provisioned":null,"status":"UNKNOWN"}"# StubURLProtocol.handler = { _ in (200, [:], Data(json.utf8)) } let s = try await makeClient(token: "t").secretStatus(toolId: "echo") - XCTAssertTrue(s.settable) - XCTAssertEqual(s.provisioned, ["API_KEY"]) + XCTAssertFalse(s.settable) + XCTAssertNil(s.provisioned) XCTAssertEqual(StubURLProtocol.lastRequest?.httpMethod, "GET") XCTAssertEqual(StubURLProtocol.lastRequest?.url?.path, "/api/tools/echo/secrets") } - func testSetSecretValuePostsFieldAndValue() async throws { - StubURLProtocol.handler = { _ in (200, [:], Data(#"{"field":"API_KEY","set":true}"#.utf8)) } - let ok = try await makeClient(token: "t").setSecretValue(toolId: "echo", field: "API_KEY", value: "s3cr3t") - XCTAssertTrue(ok) - XCTAssertEqual(StubURLProtocol.lastRequest?.httpMethod, "POST") - XCTAssertEqual(StubURLProtocol.lastRequest?.url?.path, "/api/tools/echo/secrets") - let body = try bodyJSON() - XCTAssertEqual(body["field"] as? String, "API_KEY") - XCTAssertEqual(body["value"] as? String, "s3cr3t") - } - func testAuditDecodesEventsRequestsAndDetails() async throws { let json = #"{"audit":[{"id":1,"at":1.5,"component":"admin","event_type":"tool_created","# + #""outcome":"ok","correlation_id":"c1","request_id":null,"details":{"tool":"echo","n":2}}],"# @@ -347,14 +392,6 @@ final class ApiClientTests: XCTestCase { XCTAssertEqual(StubURLProtocol.lastRequest?.url?.query, "limit=100") // query support works } - func testClearAuditSendsDelete() async throws { - StubURLProtocol.handler = { _ in (200, [:], Data(#"{"ok":true}"#.utf8)) } - let ok = try await makeClient(token: "t").clearAudit() - XCTAssertTrue(ok) - XCTAssertEqual(StubURLProtocol.lastRequest?.httpMethod, "DELETE") - XCTAssertEqual(StubURLProtocol.lastRequest?.url?.path, "/api/audit") - } - func testResyncToolPostsToUpdatePath() async throws { let json = #"{"id":"weather","type":"api","description":"wx","path":"/data/tools/weather"}"# StubURLProtocol.handler = { _ in (200, [:], Data(json.utf8)) } diff --git a/macapp/Tests/ToolstackKitTests/CallerActivationTests.swift b/macapp/Tests/ToolstackKitTests/CallerActivationTests.swift new file mode 100644 index 0000000..71ee216 --- /dev/null +++ b/macapp/Tests/ToolstackKitTests/CallerActivationTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import ToolstackKit + +final class CallerActivationTests: XCTestCase { + let active = Caller(id: 1, name: "writer-demo", revokedAt: nil) + let revoked = Caller(id: 2, name: "revoked-demo", revokedAt: 1) + + func testPrimaryActionResolvesActiveCaller() { + XCTAssertEqual(Caller.policyTarget(in: [active, revoked], selection: [1]), active) + } + + func testRevokedMissingAndAmbiguousSelectionsDoNothing() { + for selection: Set in [[], [2], [99], [1, 2]] { + XCTAssertNil(Caller.policyTarget(in: [active, revoked], selection: selection)) + } + } + + func testSelectionUsesFreshRevocationState() { + let updated = Caller(id: active.id, name: active.name, revokedAt: 2) + XCTAssertNil(Caller.policyTarget(in: [updated], selection: [active.id])) + } +} diff --git a/macapp/Tests/ToolstackKitTests/CanonicalContractTests.swift b/macapp/Tests/ToolstackKitTests/CanonicalContractTests.swift new file mode 100644 index 0000000..c0e8f0d --- /dev/null +++ b/macapp/Tests/ToolstackKitTests/CanonicalContractTests.swift @@ -0,0 +1,52 @@ +import Foundation +import XCTest +@testable import ToolstackKit + +final class CanonicalContractTests: XCTestCase { + func testOwnershipUnknownIsNotManaged() throws { + let decoder = JSONDecoder() + let unknown = try decoder.decode(ToolInfo.self, from: Data(#"{"id":"unknown"}"#.utf8)) + XCTAssertNil(unknown.removable) + let external = try decoder.decode(ToolInfo.self, from: Data(#"{"id":"external","removable":true,"path":"/fake/external"}"#.utf8)) + XCTAssertEqual(external.removable, true) + XCTAssertEqual(external.path, "/fake/external") + } + // Source regression guard only; behavioral proof is in transport/state tests. + func testUnsupportedProvisioningSignifiersRemoved() throws { + let sources = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Sources") + let paths = ["ToolstackApp/ToolstackApp.swift", "ToolstackApp/AppModel.swift", "ToolstackKit/ApiClient.swift", "ToolstackKit/Models.swift"] + for path in paths { + let text = try String(contentsOf: sources.appendingPathComponent(path)) + for forbidden in ["setSecretValue", "SetSecretResult", "SecretValuesSheet", "Set values...", "clearAudit"] { + XCTAssertFalse(text.contains(forbidden), "\(path): \(forbidden)") + } + } + } + func testCanonicalNativeContract() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("admin/tests/fixtures/native_api.json")) + let fixture = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let backend = try decoder.decode(SecretBackend.self, from: JSONSerialization.data(withJSONObject: fixture["backend"]!)) + XCTAssertEqual(backend.plugin, "localfile") + let secrets = try decoder.decode(SecretStatus.self, from: JSONSerialization.data(withJSONObject: fixture["secret_status"]!)) + XCTAssertNil(secrets.provisioned) + XCTAssertEqual(secrets.availability, "Unknown") + XCTAssertEqual(secrets.status, "UNKNOWN") + XCTAssertFalse(secrets.settable) + let states = try XCTUnwrap(fixture["tool_states"] as? [[String: Any]]) + let expected = ["Alive", "Failed", "Stopped", "Unknown"] + for (index, state) in states.enumerated() { + var row = state + row["id"] = "fake-tool" + let tool = try decoder.decode(ToolInfo.self, from: JSONSerialization.data(withJSONObject: row)) + XCTAssertEqual(tool.observedStatus, expected[index]) + XCTAssertEqual(tool.state, state["state"] as? String) + XCTAssertEqual(tool.health, state["health"] as? String) + } + } +} diff --git a/macapp/Tests/ToolstackKitTests/CorrectiveTests.swift b/macapp/Tests/ToolstackKitTests/CorrectiveTests.swift new file mode 100644 index 0000000..64edf4d --- /dev/null +++ b/macapp/Tests/ToolstackKitTests/CorrectiveTests.swift @@ -0,0 +1,145 @@ +import XCTest +@testable import ToolstackKit +@testable import ToolstackApp + +final class PendingTransport: URLProtocol { + static var started: (() -> Void)? + static var pending: PendingTransport? + static var count = 0 + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + override func startLoading() { + Self.count += 1; Self.pending = self; Self.started?() + } + func failAfterEffect() { + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(#"{"detail":"receipt lost after effect"}"#.utf8)) + client?.urlProtocolDidFinishLoading(self) + } +} + +final class CorrectiveTests: XCTestCase { + private func stubClient() -> ApiClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return ApiClient(baseURL: URL(string: "http://fake.invalid")!, session: URLSession(configuration: configuration)) + } + override func tearDown() { StubURLProtocol.handler = nil; StubURLProtocol.failure = nil } + + @MainActor func testCommittedPolicy500ThenObservedMatchDoesNotReplay() async throws { + var writes = 0 + let receipt = Data(#"{"name":"writer","enabled":["notes"],"policy":{"tools":{"notes":{"read":"allow"}}}}"#.utf8) + StubURLProtocol.handler = { request in + if request.httpMethod == "PUT" { writes += 1; return (500, [:], Data(#"{"detail":"lost acknowledgement"}"#.utf8)) } + return (200, [:], receipt) + } + let model = AppModel(client: stubClient(), serverURL: "http://fake.invalid") + var draft = PolicyDraft(caller: "writer") + draft.load(try JSONDecoder().decode(PolicyResponse.self, from: receipt)) + draft = await model.writePolicy(draft) + XCTAssertEqual(draft.phase, .unknown) + XCTAssertFalse(draft.canSave) + let observed = await model.loadPolicy(for: "writer") + draft.compare(try XCTUnwrap(observed)) + XCTAssertEqual(draft.phase, .saved) + _ = await model.writePolicy(draft) + XCTAssertEqual(writes, 1) + } + + @MainActor func testListingErrorPreservesRowsAndFailedCompareRetainsUnknown() async throws { + let row = try JSONDecoder().decode(ToolInfo.self, from: Data(#"{"id":"retained","running":true,"alive":true}"#.utf8)) + StubURLProtocol.handler = { request in + if request.url?.path == "/api/broker" { return (200, [:], Data(#"{"running":true}"#.utf8)) } + return (200, [:], Data(#"{"tools":[],"error":"fixture inspection failure"}"#.utf8)) + } + let model = AppModel(client: stubClient(), serverURL: "http://fake.invalid") + model.tools = [row] + let loaded = await model.refreshTools() + XCTAssertFalse(loaded); XCTAssertEqual(model.tools.map(\.id), ["retained"]) + XCTAssertNotNil(model.toolsError) + model.uncertainMutation = "Restart retained: unknown" + await model.compareMutationState() + XCTAssertNotNil(model.uncertainMutation); XCTAssertFalse(model.effectInFlight) + } + + @MainActor func testSameIdentityUnauthorizedPreservesDraftAndEnabledComparisonInputs() async { + StubURLProtocol.handler = { _ in (401, [:], Data()) } + let model = AppModel(client: stubClient(), serverURL: "http://fake.invalid") + model.policyDrafts["writer"] = PolicyDraft(caller: "writer") + model.enabledDrafts["writer"] = ["hidden", "local"] + model.enabledBaselines["writer"] = ["hidden"] + model.enabledOutcomes["writer"] = .unknown + let response = await model.loadPolicy(for: "writer") + XCTAssertNil(response) + XCTAssertNotNil(model.policyDrafts["writer"]) + XCTAssertEqual(model.enabledDrafts["writer"], ["hidden", "local"]) + XCTAssertEqual(model.enabledOutcomes["writer"], .unknown) + } + + func testEnablementMergeCases() { + XCTAssertEqual(EnabledToolsMerge.reconcile(baseline: ["a"], local: ["a"], remote: ["a", "new"]), ["a", "new"]) + XCTAssertEqual(EnabledToolsMerge.reconcile(baseline: ["a", "removed"], local: ["a", "removed"], remote: ["a"]), ["a"]) + XCTAssertEqual(EnabledToolsMerge.reconcile(baseline: ["disable", "hidden"], local: ["hidden"], remote: ["disable", "hidden", "new"]), ["hidden", "new"]) + XCTAssertEqual(EnabledToolsMerge.reconcile(baseline: ["a"], local: ["a", "b"], remote: ["a", "b"]), ["a", "b"]) + } + @MainActor func testEnablement500ThenMatchingReadNeedsNoSecondWrite() async throws { + var writes = 0 + StubURLProtocol.handler = { request in + if request.httpMethod == "PUT" { writes += 1; return (500, [:], Data()) } + return (200, [:], Data(#"{"name":"writer","enabled":["hidden","local"],"policy":{"tools":{}}}"#.utf8)) + } + let model = AppModel(client: stubClient(), serverURL: "http://fake.invalid") + let result = await model.setEnabledTools(caller: "writer", enabled: ["hidden", "local"]) + XCTAssertEqual(result, .unknown) + let read = await model.loadPolicy(for: "writer") + XCTAssertEqual(Set(try XCTUnwrap(read).enabled), ["hidden", "local"]) + XCTAssertEqual(writes, 1) + } + func testConservativeHTTPClassification() { + for code in [408, 409, 429, 500, 502, 503, 504] { + XCTAssertEqual(MutationResult(error: ApiError.http(status: code, message: "fake")), .unknown) + } + XCTAssertEqual(MutationResult(error: ApiError.http(status: 400, message: "validation")), .rejected) + XCTAssertEqual(MutationResult(error: ApiError.unauthorized), .rejected) + } + func testThreeWayEnablementPreservesRemoteChanges() { + XCTAssertEqual(EnabledToolsMerge.reconcile(baseline: ["a", "hidden", "removed"], local: ["hidden", "removed", "local"], remote: ["a", "hidden", "new"]), ["hidden", "new", "local"]) + } + @MainActor func testToolAndBrokerPendingEffectsAreSingleFlight() async { + for broker in [false, true] { + PendingTransport.count = 0 + let started = expectation(description: "POST pending") + PendingTransport.started = { started.fulfill() } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [PendingTransport.self] + let model = AppModel(client: ApiClient(baseURL: URL(string: "http://fake.invalid")!, session: URLSession(configuration: config)), serverURL: "http://fake.invalid") + let task = Task { + if broker { await model.brokerAction("restart", confirmed: true) } + else { await model.toolAction(id: "fake", action: "restart", confirmed: true) } + } + await fulfillment(of: [started], timeout: 2) + XCTAssertTrue(model.effectInFlight) + await model.brokerAction("restart", confirmed: true) + await model.toolAction(id: "fake", action: "restart", confirmed: true) + XCTAssertEqual(PendingTransport.count, 1) + PendingTransport.pending?.failAfterEffect() + await task.value + XCTAssertNotNil(model.uncertainMutation) + XCTAssertFalse(model.effectInFlight) + await model.toolAction(id: "fake", action: "restart", confirmed: true) + XCTAssertEqual(PendingTransport.count, 1) + } + PendingTransport.pending = nil; PendingTransport.started = nil + } + @MainActor func testAdminScopedStateResetWithoutCredentialAccess() { + let model = AppModel(client: ApiClient(baseURL: URL(string: "http://fake.invalid")!), serverURL: "http://fake.invalid") + model.uncertainMutation = "unknown"; model.busy = true; model.banner = "fake"; model.error = "fake" + model.confirmation = .init(title: "fake", effect: "fake", action: {}) + model.policyDrafts["writer"] = PolicyDraft(caller: "writer") + model.clearAdminState() + XCTAssertNil(model.uncertainMutation); XCTAssertNil(model.confirmation) + XCTAssertNil(model.error); XCTAssertNil(model.banner); XCTAssertFalse(model.busy) + XCTAssertTrue(model.tools.isEmpty); XCTAssertTrue(model.policyDrafts.isEmpty) + } +} diff --git a/macapp/Tests/ToolstackKitTests/NativeContractTests.swift b/macapp/Tests/ToolstackKitTests/NativeContractTests.swift new file mode 100644 index 0000000..7b9f3e7 --- /dev/null +++ b/macapp/Tests/ToolstackKitTests/NativeContractTests.swift @@ -0,0 +1,210 @@ +import XCTest +@testable import ToolstackKit +@testable import ToolstackApp + +final class NativeContractTests: XCTestCase { + private func client(pending: Bool = false, token: String? = "FAKE") -> ApiClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = pending ? [PendingTransport.self] : [StubURLProtocol.self] + return ApiClient(baseURL: URL(string: "http://fake.invalid")!, session: URLSession(configuration: config), token: token) + } + private let tool = #"{"id":"demo","type":"api","running":true,"alive":true,"removable":true,"path":"/fake/demo"}"# + override func tearDown() { + StubURLProtocol.handler = nil; PendingTransport.started = nil; PendingTransport.pending = nil + } + func testCatalogAuthenticatedStrictDecodeAndNoAnonymousRequest() async throws { + var requests = 0 + for json in [#"{"status":"verified","observation_id":"ab12","tool_ids":["demo"]}"#, #"{"status":"verified"}"#, #"{"status":"verified","observation_id":"not hex","tool_ids":["demo"]}"#, #"{"status":"unknown","observation_id":"ab12","tool_ids":["demo"]}"#, "{}"] { + StubURLProtocol.handler = { request in + requests += 1 + XCTAssertEqual(request.url?.path, "/api/catalog") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer FAKE") + return (200, [:], Data(json.utf8)) + } + let result = try await client().catalogObservation() + XCTAssertEqual(result.verified, json == #"{"status":"verified","observation_id":"ab12","tool_ids":["demo"]}"#) + } + do { _ = try await client(token: nil).catalogObservation(); XCTFail("Anonymous read") } + catch { XCTAssertEqual(error as? ApiError, .unauthorized) } + XCTAssertEqual(requests, 5) + } + @MainActor func testCatalogNeedsPresentTargetAndFailuresInvalidate() async { + let model = AppModel(client: client(), serverURL: "http://fake.invalid") + var catalogStatus = 200 + var catalogBody = #"{"status":"verified","observation_id":"ab12","tool_ids":["demo"]}"# + StubURLProtocol.handler = { request in + if request.url?.path == "/api/tools" { return (200, [:], Data("{\"tools\":[\(self.tool)]}".utf8)) } + return (catalogStatus, [:], Data(catalogBody.utf8)) + } + await model.refreshCatalog() + XCTAssertTrue(model.registrationObserved(for: "demo")) + XCTAssertFalse(model.registrationObserved(for: "absent")) + catalogStatus = 404 + await model.refreshCatalog() + XCTAssertFalse(model.catalogObservation.verified) + catalogStatus = 200 + for body in ["not JSON", "{}", #"{"status":"unknown"}"#] { + catalogBody = body + await model.refreshCatalog() + XCTAssertFalse(model.catalogObservation.verified) + } + catalogBody = #"{"status":"verified","observation_id":"ab12","tool_ids":["demo"]}"# + await model.refreshCatalog() + _ = await model.refreshTools() + XCTAssertFalse(model.catalogObservation.verified) + } + @MainActor func testRegistrationRequiresMembershipInSameCatalogSnapshot() async { + let model = AppModel(client: client(), serverURL: "http://fake.invalid") + var catalogBody = "{}" + StubURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "GET") + if request.url?.path == "/api/tools" { + // Discovery can outlive the registry snapshot; this tool has zero operations. + return (200, [:], Data(#"{"tools":[{"id":"A","ops":[]}]}"#.utf8)) + } + XCTAssertEqual(request.url?.path, "/api/catalog") + return (200, [:], Data(catalogBody.utf8)) + } + for ids in ["[]", "[\"B\"]", "[\"A\"]"] { + catalogBody = "{\"status\":\"verified\",\"observation_id\":\"ab12\",\"tool_ids\":\(ids)}" + await model.refreshCatalog() + XCTAssertTrue(model.tools.contains { $0.id == "A" }) + XCTAssertTrue(model.catalogObservation.verified) + XCTAssertEqual(model.registrationObserved(for: "A"), ids == "[\"A\"]") + XCTAssertFalse(model.registrationObserved(for: "B")) + } + for suffix in ["", ",\"tool_ids\":null", ",\"tool_ids\":\"A\"", ",\"tool_ids\":[\"A\",1]", ",\"tool_ids\":{}"] { + catalogBody = "{\"status\":\"verified\",\"observation_id\":\"ab12\"\(suffix)}" + await model.refreshCatalog() + XCTAssertEqual(model.catalogObservation, .unknown) + XCTAssertFalse(model.registrationObserved(for: "A")) + } + } + @MainActor func testNamedOwnershipConfirmationsAndReceipts() async throws { + let unknown = AppModel(client: client(), serverURL: "http://fake.invalid") + unknown.requestRemoval(try JSONDecoder().decode(ToolInfo.self, from: Data(#"{"id":"unknown"}"#.utf8))) + XCTAssertNil(unknown.confirmation) + for external in [true, false] { + let model = AppModel(client: client(), serverURL: "http://fake.invalid") + let row = try JSONDecoder().decode(ToolInfo.self, from: Data("{\"id\":\"demo\",\"removable\":\(external),\"path\":\"/fake/demo\"}".utf8)) + model.requestRemoval(row) + XCTAssertEqual(model.confirmation?.title, external ? "Unregister demo?" : "Remove demo?") + XCTAssertTrue(model.confirmation?.effect.contains(external ? "retained" : "deletes") == true) + StubURLProtocol.handler = { request in + if request.httpMethod == "DELETE" { return (200, [:], Data("{\"removed\":\"demo\",\"deleted\":\(!external)}".utf8)) } + return (200, [:], Data("{\"tools\":[\(self.tool)]}".utf8)) + } + await model.confirmation?.action() + XCTAssertTrue(model.banner?.contains(external ? "files retained" : "managed folder") == true) + if external { XCTAssertTrue(model.banner?.contains("Other sources") == true) } + } + } + @MainActor func testUnknownDeleteReceiptsAnd500NeverReplayBeforeComparison() async { + for (status, json) in [(200, #"{"removed":"demo"}"#), (200, "garbled"), (500, #"{"detail":"after effect"}"#)] { + var deletes = 0 + StubURLProtocol.handler = { request in + if request.httpMethod == "DELETE" { deletes += 1; return (status, [:], Data(json.utf8)) } + if request.url?.path == "/api/broker" { return (200, [:], Data(#"{"running":true}"#.utf8)) } + return (200, [:], Data(#"{"tools":[]}"#.utf8)) + } + let model = AppModel(client: client(), serverURL: "http://fake.invalid") + await model.removeTool(id: "demo") + XCTAssertTrue(model.uncertainMutation?.contains("demo") == true) + await model.removeTool(id: "demo") + XCTAssertEqual(deletes, 1) + await model.compareMutationState() + XCTAssertNil(model.uncertainMutation) + XCTAssertTrue(model.banner?.contains("Filesystem outcome remains unknown") == true) + XCTAssertEqual(deletes, 1) + } + } + @MainActor func testLegacyUnsupportedRemovalIsRejected() async { + StubURLProtocol.handler = { _ in (400, [:], Data(#"{"detail":"external unsupported"}"#.utf8)) } + let model = AppModel(client: client(), serverURL: "http://fake.invalid") + await model.removeTool(id: "demo") + XCTAssertTrue(model.error?.contains("rejected") == true) + XCTAssertNil(model.banner) + } + @MainActor func testComparisonClearsListingErrorWithoutMutationReplay() async { + var requests: [String] = [] + var listingWorks = false + StubURLProtocol.handler = { request in + let path = request.url!.path + requests.append("\(request.httpMethod!) \(path)") + if path == "/api/broker" { return (200, [:], Data(#"{"running":true}"#.utf8)) } + if request.httpMethod == "POST" { return (200, [:], Data(self.tool.utf8)) } + return (listingWorks ? 200 : 500, [:], Data(#"{"tools":[{"id":"fresh"}]}"#.utf8)) + } + let model = AppModel(client: client(), serverURL: "http://fake.invalid") + _ = await model.refreshTools() + XCTAssertNotNil(model.toolsError) + await model.toolAction(id: "demo", action: "start") + XCTAssertNotNil(model.uncertainMutation) + XCTAssertNotNil(model.toolsError) + listingWorks = true + await model.compareMutationState() + XCTAssertNil(model.toolsError) + XCTAssertNil(model.uncertainMutation) + XCTAssertEqual(model.tools.map(\.id), ["fresh"]) + XCTAssertEqual(model.broker?.running, true) + XCTAssertFalse(model.catalogObservation.verified) + XCTAssertFalse(model.registrationObserved(for: "fresh")) + XCTAssertFalse(model.effectInFlight) + XCTAssertEqual(requests, ["GET /api/tools", "POST /api/tools/demo/start", "GET /api/tools", "GET /api/broker", "GET /api/tools"]) + } + @MainActor func testAddStartRestartAreSeparateAndOnlyCatalogVerifies() async { + var effects: [String] = [] + var alive = false + StubURLProtocol.handler = { request in + let path = request.url!.path + if request.httpMethod == "POST" { effects.append(path) } + if path == "/api/tools", request.httpMethod == "POST" { return (200, [:], Data(#"{"id":"demo","type":"api","description":"","path":"/fake/demo"}"#.utf8)) } + if path == "/api/tools/demo/start" { alive = true; return (200, [:], Data(self.tool.utf8)) } + if path == "/api/tools" { return (200, [:], Data("{\"tools\":[{\"id\":\"demo\",\"running\":\(alive),\"alive\":\(alive)}]}".utf8)) } + if path == "/api/catalog" { return (200, [:], Data(#"{"status":"verified","observation_id":"ab12","tool_ids":["demo"]}"#.utf8)) } + return (200, [:], Data(#"{"running":true}"#.utf8)) + } + let model = AppModel(client: client(), serverURL: "http://fake.invalid") + _ = await model.addTool(source: "/fake/demo") + XCTAssertEqual(model.continuationTarget, "demo") + XCTAssertEqual(effects, ["/api/tools"]) + XCTAssertFalse(model.registrationObserved(for: "demo")) + await model.toolAction(id: "demo", action: "start") + XCTAssertEqual(effects.count, 2) + XCTAssertFalse(model.registrationObserved(for: "demo")) + await model.brokerAction("restart") + XCTAssertEqual(effects.count, 2) + await model.confirmation?.action() + XCTAssertEqual(effects, ["/api/tools", "/api/tools/demo/start", "/api/broker/restart"]) + XCTAssertTrue(model.registrationObserved(for: "demo")) + } + @MainActor func testLateCatalogAndDeleteDoNotTouchNewIdentity() async throws { + for catalog in [true, false] { + PendingTransport.count = 0 + let first = expectation(description: "first pending") + let second = expectation(description: "catalog pending") + PendingTransport.started = { + if PendingTransport.count == 1 { first.fulfill() } + else if PendingTransport.count == 2 { second.fulfill() } + else { PendingTransport.pending?.respond(500, "{}") } + } + let model = AppModel(client: client(pending: true), serverURL: "http://fake.invalid") + let task = Task { if catalog { await model.refreshCatalog() } else { await model.removeTool(id: "demo") } } + await fulfillment(of: [first], timeout: 2) + if catalog { + PendingTransport.pending?.respond(200, "{\"tools\":[\(tool)]}") + await fulfillment(of: [second], timeout: 2) + } else { + second.fulfill() + await fulfillment(of: [second], timeout: 2) + } + model.clearAdminState(); model.banner = "B" + PendingTransport.pending?.respond(200, catalog ? #"{"status":"verified","observation_id":"ab12","tool_ids":["demo"]}"# : #"{"removed":"demo","deleted":false}"#) + await task.value + XCTAssertEqual(model.banner, "B") + XCTAssertFalse(model.catalogObservation.verified) + XCTAssertTrue(model.tools.isEmpty) + XCTAssertNil(model.uncertainMutation) + } + } +} diff --git a/macapp/Tests/ToolstackKitTests/PolicyDraftTests.swift b/macapp/Tests/ToolstackKitTests/PolicyDraftTests.swift new file mode 100644 index 0000000..e3d5016 --- /dev/null +++ b/macapp/Tests/ToolstackKitTests/PolicyDraftTests.swift @@ -0,0 +1,123 @@ +import XCTest +@testable import ToolstackKit +@testable import ToolstackApp + +final class PolicyDraftTests: XCTestCase { + private func response() throws -> PolicyResponse { + try JSONDecoder().decode(PolicyResponse.self, from: Data(#"{"name":"writer","enabled":["notes","other"],"policy":{"tools":{"notes":{"read":"allow"},"other":{"write":"review"}}}}"#.utf8)) + } + func testLoadGate() { + var draft = PolicyDraft(caller: "writer") + XCTAssertFalse(draft.beginSave()) + draft.loadFailed() + XCTAssertFalse(draft.canSave) + XCTAssertFalse(draft.beginSave()) + } + func testRejectedSaveRetainsTargetAndOtherGrants() throws { + var draft = PolicyDraft(caller: "writer") + draft.load(try response()) + draft.set(.review, for: "notes.read") + XCTAssertTrue(draft.beginSave()) + draft.failed(ApiError.http(status: 400, message: "validation rejected")) + XCTAssertEqual(draft.caller, "writer") + XCTAssertEqual(draft.effects["other.write"], .review) + XCTAssertEqual(draft.effects["notes.read"], .review) + XCTAssertTrue(draft.dirty) + XCTAssertTrue(draft.canSave) + } + func testUnknownRequiresCompareBeforeRetry() throws { + var draft = PolicyDraft(caller: "writer") + draft.load(try response()) + draft.set(.allow, for: "notes.write") + XCTAssertTrue(draft.beginSave()) + draft.failed(ApiError.transport("timeout")) + XCTAssertEqual(draft.phase, .unknown) + XCTAssertFalse(draft.beginSave()) + draft.compare(try response()) + XCTAssertEqual(draft.phase, .conflict) + XCTAssertEqual(draft.effects["notes.write"], .allow) + } + func testCompareMatchesWithoutReplay() throws { + var draft = PolicyDraft(caller: "writer") + draft.load(try response()) + XCTAssertTrue(draft.beginSave()) + draft.failed(ApiError.decoding("lost receipt")) + draft.compare(try response()) + XCTAssertEqual(draft.phase, .saved) + XCTAssertFalse(draft.canSave) + } + func testComparisonPreservesConcurrentUneditedGrants() throws { + var draft = PolicyDraft(caller: "writer") + draft.load(try response()) + draft.set(.review, for: "notes.read") + let remote = try JSONDecoder().decode(PolicyResponse.self, from: Data(#"{"name":"writer","enabled":["notes","other"],"policy":{"tools":{"notes":{"read":"allow"},"other":{"write":"allow","new":"review"}}}}"#.utf8)) + draft.compare(remote) + XCTAssertEqual(draft.effects["notes.read"], .review) + XCTAssertEqual(draft.effects["other.write"], .allow) + XCTAssertEqual(draft.effects["other.new"], .review) + } + func testMemoryDraftRetainsTargetAcrossNavigation() throws { + var draft = PolicyDraft(caller: "writer") + draft.load(try response()) + draft.set(.review, for: "notes.read") + let memory = [draft.caller: draft] + let resumed = try XCTUnwrap(memory["writer"]) + XCTAssertTrue(resumed.dirty) + XCTAssertEqual(resumed.effects["notes.read"], .review) + XCTAssertNil(memory["another-caller"]) + } + @MainActor func testWriteUsesMatchingAndDivergentReceiptsIncludingEnabledState() async throws { + for (policy, expectedPhase) in [ + (#"{"notes":{"read":"review"},"other":{"write":"review"}}"#, PolicyDraft.Phase.saved), + (#"{"notes":{"read":"allow"},"other":{"write":"review"}}"#, .conflict), + (#"{"other":{"write":"review"}}"#, .conflict) + ] { + var requests = 0 + StubURLProtocol.handler = { request in + requests += 1 + XCTAssertEqual(request.httpMethod, "PUT") + XCTAssertEqual(request.url?.path, "/api/callers/writer/policy") + return (200, [:], Data("{\"name\":\"writer\",\"enabled\":[\"remote-only\"],\"policy\":{\"tools\":\(policy)}}".utf8)) + } + let model = makeModel() + var draft = PolicyDraft(caller: "writer") + draft.load(try response()) + draft.set(.review, for: "notes.read") + let result = await model.writePolicy(draft) + XCTAssertEqual(result.phase, expectedPhase) + XCTAssertEqual(result.enabled, ["remote-only"]) + XCTAssertEqual(result.effects["notes.read"], .review) + XCTAssertEqual(result.effects["other.write"], .review) + XCTAssertEqual(result.dirty, expectedPhase == .conflict) + XCTAssertEqual(model.policyDrafts["writer"]?.phase, expectedPhase) + XCTAssertEqual(requests, 1, "Use the receipt; no second GET or replay") + } + } + @MainActor func testWrongCallerReceiptIsUnknownAndDoesNotApply() async throws { + var requests = 0 + StubURLProtocol.handler = { _ in + requests += 1 + return (200, [:], Data(#"{"name":"someone-else","enabled":["alien"],"policy":{"tools":{}}}"#.utf8)) + } + let model = makeModel() + var draft = PolicyDraft(caller: "writer") + draft.load(try response()) + draft.set(.review, for: "notes.read") + let result = await model.writePolicy(draft) + XCTAssertEqual(result.phase, .unknown) + XCTAssertEqual(result.effects, draft.effects) + XCTAssertEqual(result.baseline, draft.baseline) + XCTAssertEqual(result.enabled, draft.enabled) + XCTAssertFalse(result.canSave) + let retry = await model.writePolicy(result) + XCTAssertEqual(retry.phase, .unknown) + XCTAssertEqual(requests, 1) + XCTAssertNil(model.policyDrafts["someone-else"]) + } + @MainActor private func makeModel() -> AppModel { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubURLProtocol.self] + return AppModel(client: ApiClient(baseURL: URL(string: "http://fake.invalid")!, session: URLSession(configuration: config)), serverURL: "http://fake.invalid") + } + override func tearDown() { StubURLProtocol.handler = nil; StubURLProtocol.failure = nil } +} diff --git a/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift b/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift new file mode 100644 index 0000000..04b6dff --- /dev/null +++ b/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift @@ -0,0 +1,244 @@ +import XCTest +@testable import ToolstackKit +@testable import ToolstackApp + +extension PendingTransport { + func respond(_ status: Int, _ json: String) { + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(json.utf8)) + client?.urlProtocolDidFinishLoading(self) + } +} + +final class StalePublicationTests: XCTestCase { + @MainActor private func model() -> AppModel { + PendingTransport.count = 0 + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [PendingTransport.self] + return AppModel(client: ApiClient(baseURL: URL(string: "http://admin-a.invalid")!, session: URLSession(configuration: configuration)), serverURL: "http://admin-a.invalid") + } + @MainActor private func seedB(_ model: AppModel) throws { + model.clearAdminState() + model.serverURL = "http://admin-b.invalid" + model.authenticated = true + model.banner = "B banner"; model.error = "B error"; model.busy = true + model.callers = try JSONDecoder().decode(CallersResponse.self, from: Data(#"{"callers":[{"id":2,"name":"B"}]}"#.utf8)).callers + model.broker = BrokerStatus(running: false, port: 2222) + model.tools = try JSONDecoder().decode(ToolsResponse.self, from: Data(#"{"tools":[{"id":"B-tool"}]}"#.utf8)).tools + model.policyDrafts["B"] = PolicyDraft(caller: "B") + } + @MainActor private func assertB(_ model: AppModel) { + XCTAssertTrue(model.authenticated); XCTAssertTrue(model.busy) + XCTAssertEqual(model.banner, "B banner"); XCTAssertEqual(model.error, "B error") + XCTAssertEqual(model.callers.map(\.name), ["B"]) + XCTAssertEqual(model.tools.map(\.id), ["B-tool"]) + XCTAssertEqual(model.broker?.port, 2222) + XCTAssertEqual(Set(model.policyDrafts.keys), ["B"]) + XCTAssertNil(model.audit); XCTAssertNil(model.config); XCTAssertNil(model.secretBackend) + XCTAssertEqual(PendingTransport.count, 1, "Old task must not start follow-up requests") + } + @MainActor func testDelayedReadsDoNotPublishIntoResetIdentity() async throws { + let cases: [(String, (AppModel) async -> Void)] = [ + (#"{"callers":[{"id":1,"name":"A"}]}"#, { await $0.refreshCallers() }), + (#"{"running":true,"port":1111}"#, { await $0.refreshBroker() }), + (#"{"audit":[],"requests":[]}"#, { await $0.refreshAudit() }), + (#"{"plugin":"A"}"#, { await $0.refreshSecretBackend() }), + (#"{"port":1111,"toolsRoot":"/fake","nodUrl":"fake","nodToken":"not set","nodChannel":"fake","approvalTtl":1,"rateLimit":1}"#, { await $0.refreshConfig() }), + (#"{"tools":[{"id":"A-tool"}]}"#, { _ = await $0.refreshTools() }) + ] + for (json, operation) in cases { + let model = model() + let started = expectation(description: "A read pending") + PendingTransport.started = { if PendingTransport.count == 1 { started.fulfill() } else { PendingTransport.pending?.respond(500, "{}") } } + let task = Task { await operation(model) } + await fulfillment(of: [started], timeout: 2) + try seedB(model) + PendingTransport.pending?.respond(200, json) + await task.value + assertB(model) + } + } + @MainActor func testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh() async throws { + for rotate in [false, true] { + let model = model() + let started = expectation(description: "A mutation pending") + PendingTransport.started = { if PendingTransport.count == 1 { started.fulfill() } else { PendingTransport.pending?.respond(500, "{}") } } + let task = Task { + if rotate { await model.rotateToken(caller: "A", confirmed: true) } + else { await model.createCaller(name: "A") } + } + await fulfillment(of: [started], timeout: 2) + try seedB(model) + PendingTransport.pending?.respond(200, #"{"name":"A","token":"FAKE-ONE-TIME-A"}"#) + await task.value + assertB(model) + } + } + @MainActor func testLateUnauthorizedDoesNotSignOutB() async throws { + let model = model() + let started = expectation(description: "A pending") + PendingTransport.started = { if PendingTransport.count == 1 { started.fulfill() } else { PendingTransport.pending?.respond(500, "{}") } } + let task = Task { await model.refreshCallers() } + await fulfillment(of: [started], timeout: 2) + try seedB(model) + PendingTransport.pending?.respond(401, #"{"detail":"expired A"}"#) + await task.value + assertB(model) + } + @MainActor func testLatePolicyReadReturnsNoConsumableValue() async throws { + let model = model() + let started = expectation(description: "A policy pending") + PendingTransport.started = { if PendingTransport.count == 1 { started.fulfill() } else { PendingTransport.pending?.respond(500, "{}") } } + let task = Task { await model.loadPolicy(for: "A") } + await fulfillment(of: [started], timeout: 2) + try seedB(model) + PendingTransport.pending?.respond(200, #"{"name":"A","enabled":[],"policy":{"tools":{}}}"#) + let result = await task.value + XCTAssertNil(result) + assertB(model) + } + @MainActor func testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp() async throws { + let created = #"{"id":"A","type":"api","description":"A","path":"/fake/A"}"# + let cases: [(String, (AppModel) async -> Void)] = [ + (created, { _ = await $0.addTool(source: "/fake/A") }), + (created, { _ = await $0.authorTool(source: "/fake/A", manifest: [:]) }), + (created, { await $0.addToolFromGitHub(repo: "fake", subdir: "", ref: "") }), + (created, { await $0.resyncTool(id: "A") }), + (#"{"id":"A","description":"A","secrets":[]}"#, { await $0.updateTool(id: "A", description: "A", secrets: []) }), + (#"{"removed":"A"}"#, { await $0.removeTool(id: "A") }), + (#"{"port":1111,"toolsRoot":"/fake","nodUrl":"fake","nodToken":"not set","nodChannel":"fake","approvalTtl":1,"rateLimit":1}"#, { await $0.saveConfig(port: 1111, toolsRoot: "/fake", nodURL: "fake", nodChannel: "fake", nodToken: nil, approvalTTL: 1, rateLimit: 1) }) + ] + for (json, operation) in cases { + let model = model() + let started = expectation(description: "A effect pending") + PendingTransport.started = { if PendingTransport.count == 1 { started.fulfill() } else { PendingTransport.pending?.respond(500, "{}") } } + let task = Task { await operation(model) } + await fulfillment(of: [started], timeout: 2) + try seedB(model) + PendingTransport.pending?.respond(200, json) + await task.value + assertB(model) + } + } + @MainActor func testLateRestoreProofDoesNotReplaceBSession() async throws { + let model = model() + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [PendingTransport.self] + let restored = ApiClient(baseURL: URL(string: "http://admin-a.invalid")!, session: URLSession(configuration: configuration)) + let started = expectation(description: "A restore proof pending") + PendingTransport.started = { if PendingTransport.count == 1 { started.fulfill() } else { PendingTransport.pending?.respond(500, "{}") } } + let task = Task { try await model.acceptRestoredSession(restored, account: "http://admin-a.invalid") } + await fulfillment(of: [started], timeout: 2) + try seedB(model) + PendingTransport.pending?.respond(200, #"{"running":true,"port":1111}"#) + try await task.value + assertB(model) + } + @MainActor func testLatePolicyWriteCannotRepopulateDrafts() async throws { + let model = model() + let receipt = #"{"name":"A","enabled":[],"policy":{"tools":{}}}"# + var draft = PolicyDraft(caller: "A") + draft.load(try JSONDecoder().decode(PolicyResponse.self, from: Data(receipt.utf8))) + let started = expectation(description: "A policy write pending") + PendingTransport.started = { if PendingTransport.count == 1 { started.fulfill() } else { PendingTransport.pending?.respond(500, "{}") } } + let epoch = model.generation + let task = Task { await model.writePolicy(draft) } + await fulfillment(of: [started], timeout: 2) + try seedB(model) + PendingTransport.pending?.respond(200, receipt) + _ = await task.value + XCTAssertNotEqual(epoch, model.generation, "Old editor continuation must ignore its returned draft") + assertB(model) + } + @MainActor func testPreActionRefreshCannotOverwriteAcknowledgedActionListing() async throws { + for staleStatus in [200, 500] { + let model = model() + let first = expectation(description: "old refresh") + let action = expectation(description: "action") + let listing = expectation(description: "post-action listing") + PendingTransport.started = { + switch PendingTransport.count { + case 1: first.fulfill() + case 2: action.fulfill() + case 3: listing.fulfill() + default: XCTFail("Unexpected request"); PendingTransport.pending?.respond(500, "{}") + } + } + let epoch = model.generation + let oldTask = Task { await model.refreshTools() } + await fulfillment(of: [first], timeout: 2) + let old = try XCTUnwrap(PendingTransport.pending) + let actionTask = Task { await model.toolAction(id: "demo", action: "start") } + await fulfillment(of: [action], timeout: 2) + PendingTransport.pending?.respond(200, #"{"id":"demo"}"#) + await fulfillment(of: [listing], timeout: 2) + PendingTransport.pending?.respond(200, #"{"tools":[{"id":"fresh"}]}"#) + await actionTask.value + old.respond(staleStatus, #"{"tools":[{"id":"stale"}]}"#) + let accepted = await oldTask.value + XCTAssertFalse(accepted) + XCTAssertEqual(model.tools.map(\.id), ["fresh"]) + XCTAssertNil(model.toolsError) + XCTAssertEqual(model.generation, epoch) + XCTAssertFalse(model.effectInFlight) + XCTAssertEqual(PendingTransport.count, 3) + } + } + @MainActor func testStaleRefreshCannotClearNewerListingError() async throws { + let model = model() + let first = expectation(description: "old refresh") + let second = expectation(description: "new refresh") + PendingTransport.started = { + if PendingTransport.count == 1 { first.fulfill() } else { second.fulfill() } + } + let oldTask = Task { await model.refreshTools() } + await fulfillment(of: [first], timeout: 2) + let old = try XCTUnwrap(PendingTransport.pending) + let newTask = Task { await model.refreshTools() } + await fulfillment(of: [second], timeout: 2) + PendingTransport.pending?.respond(500, "{}") + _ = await newTask.value + let freshError = try XCTUnwrap(model.toolsError) + old.respond(200, #"{"tools":[{"id":"stale"}]}"#) + _ = await oldTask.value + XCTAssertEqual(model.toolsError, freshError) + XCTAssertTrue(model.tools.isEmpty) + XCTAssertEqual(PendingTransport.count, 2) + } + @MainActor func testComparisonCannotClearNewerRevisionErrorOrResetIdentity() async throws { + for reset in [false, true] { + let model = model() + model.uncertainMutation = "start unknown" + let broker = expectation(description: "comparison broker") + let list = expectation(description: "comparison list") + let refresh = expectation(description: "new refresh") + PendingTransport.started = { + switch PendingTransport.count { + case 1: broker.fulfill() + case 2: list.fulfill() + case 3: refresh.fulfill() + default: XCTFail("Unexpected request") + } + } + let comparison = Task { await model.compareMutationState() } + await fulfillment(of: [broker], timeout: 2) + PendingTransport.pending?.respond(200, #"{"running":true}"#) + await fulfillment(of: [list], timeout: 2) + let old = try XCTUnwrap(PendingTransport.pending) + if reset { model.clearAdminState(); model.uncertainMutation = "B unknown" } + let newer = Task { await model.refreshTools() } + await fulfillment(of: [refresh], timeout: 2) + PendingTransport.pending?.respond(500, "{}") + _ = await newer.value + let freshError = try XCTUnwrap(model.toolsError) + old.respond(200, #"{"tools":[{"id":"stale"}]}"#) + await comparison.value + XCTAssertEqual(model.toolsError, freshError) + XCTAssertTrue(model.tools.isEmpty) + XCTAssertNil(model.broker) + XCTAssertEqual(model.uncertainMutation, reset ? "B unknown" : "start unknown") + XCTAssertFalse(model.effectInFlight) + } + } + override func tearDown() { PendingTransport.pending = nil; PendingTransport.started = nil } +} diff --git a/macapp/evidence/contracts-native/added-1100-geometry.json b/macapp/evidence/contracts-native/added-1100-geometry.json new file mode 100644 index 0000000..7ab59a6 --- /dev/null +++ b/macapp/evidence/contracts-native/added-1100-geometry.json @@ -0,0 +1,122 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1011, 16}, {77, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{986, 118}, {98, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{664, 260}, {146, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{818, 260}, {132, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 315}, {1100, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 315}, {1100, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 315}, {1059, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 315}, {1059, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{972, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{972, 319}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1012, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1012, 319}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1052, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1052, 319}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 315}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 328}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 305}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/contracts-native/added-1100.png b/macapp/evidence/contracts-native/added-1100.png new file mode 100644 index 0000000..d70fcf4 Binary files /dev/null and b/macapp/evidence/contracts-native/added-1100.png differ diff --git a/macapp/evidence/contracts-native/added-1440-geometry.json b/macapp/evidence/contracts-native/added-1440-geometry.json new file mode 100644 index 0000000..b6829ba --- /dev/null +++ b/macapp/evidence/contracts-native/added-1440-geometry.json @@ -0,0 +1,122 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1351, 16}, {77, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1326, 118}, {98, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{834, 260}, {146, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{988, 260}, {132, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 315}, {1440, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 315}, {1440, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 315}, {1399, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 315}, {1399, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1312, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1312, 319}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1352, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1352, 319}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1392, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1392, 319}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 315}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 328}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 305}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/contracts-native/added-1440.png b/macapp/evidence/contracts-native/added-1440.png new file mode 100644 index 0000000..8e7f041 Binary files /dev/null and b/macapp/evidence/contracts-native/added-1440.png differ diff --git a/macapp/evidence/contracts-native/registration-unknown-1100-geometry.json b/macapp/evidence/contracts-native/registration-unknown-1100-geometry.json new file mode 100644 index 0000000..5601cb3 --- /dev/null +++ b/macapp/evidence/contracts-native/registration-unknown-1100-geometry.json @@ -0,0 +1,122 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1011, 16}, {77, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{986, 118}, {98, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{642, 260}, {146, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{796, 260}, {154, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 305}, {1100, 595}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 315}, {1100, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 315}, {1100, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 315}, {1059, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 315}, {1059, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{972, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{972, 319}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1012, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1012, 319}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1052, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1052, 319}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 315}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 328}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 305}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/contracts-native/registration-unknown-1100.png b/macapp/evidence/contracts-native/registration-unknown-1100.png new file mode 100644 index 0000000..ce6a0fd Binary files /dev/null and b/macapp/evidence/contracts-native/registration-unknown-1100.png differ diff --git a/macapp/evidence/contracts-native/registration-unknown-1440-geometry.json b/macapp/evidence/contracts-native/registration-unknown-1440-geometry.json new file mode 100644 index 0000000..134f0e2 --- /dev/null +++ b/macapp/evidence/contracts-native/registration-unknown-1440-geometry.json @@ -0,0 +1,122 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1351, 16}, {77, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1326, 118}, {98, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{812, 260}, {146, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{966, 260}, {154, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 305}, {1440, 595}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 315}, {1440, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 315}, {1440, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 315}, {1399, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 315}, {1399, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1312, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1312, 319}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1352, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1352, 319}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1392, 319}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1392, 319}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 315}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 328}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 305}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/contracts-native/registration-unknown-1440.png b/macapp/evidence/contracts-native/registration-unknown-1440.png new file mode 100644 index 0000000..26d73fd Binary files /dev/null and b/macapp/evidence/contracts-native/registration-unknown-1440.png differ diff --git a/macapp/evidence/contracts-native/unregister-unknown-1100-geometry.json b/macapp/evidence/contracts-native/unregister-unknown-1100-geometry.json new file mode 100644 index 0000000..d8b9f75 --- /dev/null +++ b/macapp/evidence/contracts-native/unregister-unknown-1100-geometry.json @@ -0,0 +1,118 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1011, 16}, {77, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{935, 72}, {149, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{410, 127}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{410, 127}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{410, 127}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{986, 174}, {98, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 207}, {1100, 693}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 207}, {1100, 693}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 207}, {1100, 693}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 207}, {1100, 693}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 207}, {1100, 693}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 207}, {1100, 693}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 217}, {1100, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 217}, {1100, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 217}, {1059, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 217}, {1059, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{972, 221}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{972, 221}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1012, 221}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1012, 221}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1052, 221}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1052, 221}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 217}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 230}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 207}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/contracts-native/unregister-unknown-1100.png b/macapp/evidence/contracts-native/unregister-unknown-1100.png new file mode 100644 index 0000000..b0c1743 Binary files /dev/null and b/macapp/evidence/contracts-native/unregister-unknown-1100.png differ diff --git a/macapp/evidence/contracts-native/unregister-unknown-1440-geometry.json b/macapp/evidence/contracts-native/unregister-unknown-1440-geometry.json new file mode 100644 index 0000000..cefe0b4 --- /dev/null +++ b/macapp/evidence/contracts-native/unregister-unknown-1440-geometry.json @@ -0,0 +1,118 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1351, 16}, {77, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1275, 72}, {149, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{580, 127}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{580, 127}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{580, 127}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1326, 174}, {98, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 207}, {1440, 693}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 207}, {1440, 693}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 207}, {1440, 693}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 207}, {1440, 693}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 207}, {1440, 693}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 207}, {1440, 693}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 217}, {1440, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 217}, {1440, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 217}, {1399, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 217}, {1399, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1312, 221}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1312, 221}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1352, 221}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1352, 221}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1392, 221}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1392, 221}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 217}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 230}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 207}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/contracts-native/unregister-unknown-1440.png b/macapp/evidence/contracts-native/unregister-unknown-1440.png new file mode 100644 index 0000000..d336145 Binary files /dev/null and b/macapp/evidence/contracts-native/unregister-unknown-1440.png differ diff --git a/macapp/evidence/corrective-norman-source.json b/macapp/evidence/corrective-norman-source.json new file mode 100644 index 0000000..145e5d3 --- /dev/null +++ b/macapp/evidence/corrective-norman-source.json @@ -0,0 +1,24 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 0, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [ + { + "file": "ToolstackApp/AppModel.swift", + "call_sites": 2 + } + ] + } +} diff --git a/macapp/evidence/corrective-validation.log b/macapp/evidence/corrective-validation.log new file mode 100644 index 0000000..c1b0e39 --- /dev/null +++ b/macapp/evidence/corrective-validation.log @@ -0,0 +1,133 @@ +Building for debugging... +[0/6] Write sources +[2/6] Write swift-version--58304C5D6DBC2206.txt +[4/8] Compiling ToolstackApp AppModel.swift +[5/8] Emitting module ToolstackApp +[6/10] Compiling ToolstackApp NativeSnapshots.swift +[7/10] Compiling ToolstackApp ToolstackApp.swift +[7/11] Write Objects.LinkFileList +[8/11] Linking ToolstackApp +[9/11] Applying ToolstackApp +[11/13] Emitting module ToolstackKitTests +[12/13] Compiling ToolstackKitTests CorrectiveTests.swift +[12/14] Write Objects.LinkFileList +[13/14] Linking ToolstackPackageTests +Build complete! (3.73s) +Test Suite 'Selected tests' started at 2026-10-24 10:14:34.852. +Test Suite 'ToolstackPackageTests.xctest' started at 2026-10-24 10:14:34.852. +Test Suite 'ApiClientTests' started at 2026-10-24 10:14:34.852. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolFromGitHubOmitsEmptyOptionals]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolFromGitHubOmitsEmptyOptionals]' passed (0.004 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolFromGitHubPostsRepoSubdirRef]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolFromGitHubPostsRepoSubdirRef]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolNoManifestMapsTo422]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolNoManifestMapsTo422]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolPostsSource]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolPostsSource]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAuditDecodesEventsRequestsAndDetails]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAuditDecodesEventsRequestsAndDetails]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAuthedRequestSendsBearer]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAuthedRequestSendsBearer]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAuthorToolPostsSourceAndManifest]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAuthorToolPostsSourceAndManifest]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testConfigDecodesMasked]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testConfigDecodesMasked]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testCreateCallerSendsBodyAndParsesToken]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testCreateCallerSendsBodyAndParsesToken]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testDeleteToolSendsDELETE]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testDeleteToolSendsDELETE]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testGetPolicyDecodes]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testGetPolicyDecodes]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testHttpErrorCarriesServerDetail]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testHttpErrorCarriesServerDetail]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testImportedHeaderDeclarationPayloadIsNotStaticInjection]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testImportedHeaderDeclarationPayloadIsNotStaticInjection]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListCallersDecodesSnakeCase]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListCallersDecodesSnakeCase]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesDescription]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesDescription]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesGitHubSource]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesGitHubSource]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesOps]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesOps]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesSecrets]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesSecrets]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsToleratesMissingOps]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsToleratesMissingOps]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsToleratesNullSource]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsToleratesNullSource]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testLoginSendsPasswordUnauthedAndStoresToken]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testLoginSendsPasswordUnauthedAndStoresToken]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testParseOpenAPIPostsSpecAndDecodesRestDraft]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testParseOpenAPIPostsSpecAndDecodesRestDraft]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyHTTPRejectionRetainsDraft]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyHTTPRejectionRetainsDraft]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyLoadFailureCannotSave]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyLoadFailureCannotSave]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyTimeoutIsUnknownAndNotReplayed]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyTimeoutIsUnknownAndNotReplayed]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testResyncToolPostsToUpdatePath]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testResyncToolPostsToUpdatePath]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testRevokeCallerPosts]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testRevokeCallerPosts]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testRotateTokenPostsAndReturnsNewToken]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testRotateTokenPostsAndReturnsNewToken]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSaveConfigSendsTokenOnlyWhenProvided]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSaveConfigSendsTokenOnlyWhenProvided]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSecretBackendDecodes]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSecretBackendDecodes]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSecretStatusDecodes]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSecretStatusDecodes]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSetEnabledToolsPutsList]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSetEnabledToolsPutsList]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSetPolicySendsAllowReviewDenyViaPut]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSetPolicySendsAllowReviewDenyViaPut]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testToolActionPostsToActionPath]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testToolActionPostsToActionPath]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testUnauthorizedMapsToApiError]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testUnauthorizedMapsToApiError]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testUpdateToolPostsDescriptionAndSecrets]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testUpdateToolPostsDescriptionAndSecrets]' passed (0.000 seconds). +Test Suite 'ApiClientTests' passed at 2026-10-24 10:14:34.869. + Executed 36 tests, with 0 failures (0 unexpected) in 0.015 (0.016) seconds +Test Suite 'CorrectiveTests' started at 2026-10-24 10:14:34.869. +Test Case '-[ToolstackKitTests.CorrectiveTests testAdminScopedStateResetWithoutCredentialAccess]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testAdminScopedStateResetWithoutCredentialAccess]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CorrectiveTests testCommittedPolicy500ThenObservedMatchDoesNotReplay]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testCommittedPolicy500ThenObservedMatchDoesNotReplay]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CorrectiveTests testConservativeHTTPClassification]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testConservativeHTTPClassification]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CorrectiveTestswarning: '--build-system native' has been deprecated and will be removed in a future release; please report an issue at https://github.com/swiftlang/swift-package-manager/issues if you are unable to adopt the default build system. + testEnablement500ThenMatchingReadNeedsNoSecondWrite]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testEnablement500ThenMatchingReadNeedsNoSecondWrite]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CorrectiveTests testEnablementMergeCases]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testEnablementMergeCases]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CorrectiveTests testListingErrorPreservesRowsAndFailedCompareRetainsUnknown]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testListingErrorPreservesRowsAndFailedCompareRetainsUnknown]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CorrectiveTests testSameIdentityUnauthorizedPreservesDraftAndEnabledComparisonInputs]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testSameIdentityUnauthorizedPreservesDraftAndEnabledComparisonInputs]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CorrectiveTests testThreeWayEnablementPreservesRemoteChanges]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testThreeWayEnablementPreservesRemoteChanges]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CorrectiveTests testToolAndBrokerPendingEffectsAreSingleFlight]' started. +Test Case '-[ToolstackKitTests.CorrectiveTests testToolAndBrokerPendingEffectsAreSingleFlight]' passed (0.001 seconds). +Test Suite 'CorrectiveTests' passed at 2026-10-24 10:14:34.872. + Executed 9 tests, with 0 failures (0 unexpected) in 0.003 (0.003) seconds +Test Suite 'PolicyDraftTests' started at 2026-10-24 10:14:34.872. +Test Case '-[ToolstackKitTests.PolicyDraftTests testCompareMatchesWithoutReplay]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testCompareMatchesWithoutReplay]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testComparisonPreservesConcurrentUneditedGrants]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testComparisonPreservesConcurrentUneditedGrants]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testLoadGate]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testLoadGate]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testMemoryDraftRetainsTargetAcrossNavigation]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testMemoryDraftRetainsTargetAcrossNavigation]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testRejectedSaveRetainsTargetAndOtherGrants]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testRejectedSaveRetainsTargetAndOtherGrants]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testUnknownRequiresCompareBeforeRetry]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testUnknownRequiresCompareBeforeRetry]' passed (0.000 seconds). +Test Suite 'PolicyDraftTests' passed at 2026-10-24 10:14:34.872. + Executed 6 tests, with 0 failures (0 unexpected) in 0.000 (0.001) seconds +Test Suite 'ToolstackPackageTests.xctest' passed at 2026-10-24 10:14:34.872. + Executed 51 tests, with 0 failures (0 unexpected) in 0.018 (0.020) seconds +Test Suite 'Selected tests' passed at 2026-10-24 10:14:34.873. + Executed 51 tests, with 0 failures (0 unexpected) in 0.018 (0.021) seconds diff --git a/macapp/evidence/native-contract-first-test-failure.log b/macapp/evidence/native-contract-first-test-failure.log new file mode 100644 index 0000000..db3a07b --- /dev/null +++ b/macapp/evidence/native-contract-first-test-failure.log @@ -0,0 +1,69 @@ +[0/1] Planning build +Building for debugging... +[0/9] Write sources +[3/9] Write swift-version--58304C5D6DBC2206.txt +[5/12] Compiling ToolstackKit ApiClient.swift +[6/12] Compiling ToolstackKit CatalogObservation.swift +[7/12] Emitting module ToolstackKit +[8/13] Compiling ToolstackKit Models.swift +[9/16] Compiling ToolstackApp AppModel.swift +[10/16] Compiling ToolstackApp NativeSnapshots.swift +[11/16] Emitting module ToolstackApp +[12/17] Compiling ToolstackApp ToolstackApp.swift +[12/17] Write Objects.LinkFileList +[13/17] Linking ToolstackApp +[14/17] Applying ToolstackApp +[16/21] Compiling ToolstackKitTests ApiClientTests.swift +[17/21] Compiling ToolstackKitTests CanonicalContractTests.swift +[18/21] Compiling ToolstackKitTests CorrectiveTests.swift +[19/21] Emitting module ToolstackKitTests +[20/24] Compiling ToolstackKitTests NativeContractTests.swift +[21/24] Compiling ToolstackKitTests PolicyDraftTests.swift +[22/24] Compiling ToolstackKitTests StalePublicationTests.swift +[22/24] Write Objects.LinkFileList +[23/24] Linking ToolstackPackageTests +Build complete! (5.25s) +Test Suite 'Selected tests' started at 2026-10-24 10:38:46.467. +Test Suite 'ToolstackPackageTests.xctest' started at 2026-10-24 10:38:46.468. +Test Suite 'NativeContractTests' started at 2026-10-24 10:38:46.468. +Test Case '-[ToolstackKitTests.NativeContractTests testAddStartRestartAreSeparateAndOnlyCatalogVerifies]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testAddStartRestartAreSeparateAndOnlyCatalogVerifies]' passed (0.006 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogAuthenticatedStrictDecodeAndNoAnonymousRequest]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogAuthenticatedStrictDecodeAndNoAnonymousRequest]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogNeedsPresentTargetAndFailuresInvalidate]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogNeedsPresentTargetAndFailuresInvalidate]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testLateCatalogAndDeleteDoNotTouchNewIdentity]' started. +/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-mac/macapp/Tests/ToolstackKitTests/NativeContractTests.swift:121: error: -[ToolstackKitTests.NativeContractTests testLateCatalogAndDeleteDoNotTouchNewIdentity] : Failed due to unwaited expectation 'catalog pending'. +Test Case '-[ToolstackKitTests.NativeContractTests testLateCatalogAndDeleteDoNotTouchNewIdentity]' failed (0.536 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testLegacyUnsupportedRemovalIsRejected]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testLegacyUnsupportedRemovalIsRejected]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testNamedOwnershipConfirmationsAndReceipts]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testNamedOwnershipConfirmationsAndReceipts]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testUnknownDeleteReceiptsAnd500NeverReplayBeforeComparison]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testUnknownDeleteReceiptsAnd500NeverReplayBeforeComparison]' passed (0.003 seconds). +Test Suite 'NativeContractTests' failed at 2026-10-24 10:38:47.016. + Executed 7 tests, with 1 failure (1 unexpected) in 0.548 (0.548) seconds +Test Suite 'StalePublicationTests' started at 2026-10-24 10:38:47.016. +Test Case '-[ToolstackKitTests.StalePublicationTests testDelayedReadsDoNotPublishIntoResetIdentity]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testDelayedReadsDoNotPublishIntoResetIdentity]' passed (0.003 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyReadReturnsNoConsumableValue]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyReadReturnsNoConsumableValue]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyWriteCannotRepopulateDrafts]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyWriteCannotRepopulateDrafts]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateRestoreProofDoesNotReplaceBSession]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateRestoreProofDoesNotReplaceBSession]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateUnauthorizedDoesNotSignOutB]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateUnauthorizedDoesNotSignOutB]' passed (0.000 seconds). +Test Suite 'StalePublicationTests' passed at 2026-10-24 warning: '--build-system native' has been deprecated and will be removed in a future release; please report an issue at https://github.com/swiftlang/swift-package-manager/issues if you are unable to adopt the default build system. +10:38:47.024. + Executed 7 tests, with 0 failures (0 unexpected) in 0.008 (0.008) seconds +Test Suite 'ToolstackPackageTests.xctest' failed at 2026-10-24 10:38:47.024. + Executed 14 tests, with 1 failure (1 unexpected) in 0.555 (0.556) seconds +Test Suite 'Selected tests' failed at 2026-10-24 10:38:47.024. + Executed 14 tests, with 1 failure (1 unexpected) in 0.555 (0.557) seconds +Note: Some test targets reported failures: + - ToolstackPackageTests (XCTest) diff --git a/macapp/evidence/native-contract-norman-source.json b/macapp/evidence/native-contract-norman-source.json new file mode 100644 index 0000000..145e5d3 --- /dev/null +++ b/macapp/evidence/native-contract-norman-source.json @@ -0,0 +1,24 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 0, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [ + { + "file": "ToolstackApp/AppModel.swift", + "call_sites": 2 + } + ] + } +} diff --git a/macapp/evidence/native-contract-render.log b/macapp/evidence/native-contract-render.log new file mode 100644 index 0000000..47f45d5 --- /dev/null +++ b/macapp/evidence/native-contract-render.log @@ -0,0 +1 @@ +Rendered 6 offscreen SwiftUI views with NSView geometry; no live focus or VoiceOver assertion. diff --git a/macapp/evidence/native-contract-validation.log b/macapp/evidence/native-contract-validation.log new file mode 100644 index 0000000..e8832ff --- /dev/null +++ b/macapp/evidence/native-contract-validation.log @@ -0,0 +1,55 @@ +Building for debugging... +[0/6] Write sources +[2/6] Write swift-version--58304C5D6DBC2206.txt +[4/8] Emitting module ToolstackApp +[5/8] Compiling ToolstackApp AppModel.swift +[6/9] Compiling ToolstackApp ToolstackApp.swift +[6/10] Write Objects.LinkFileList +[7/10] Linking ToolstackApp +[8/10] Applying ToolstackApp +[10/12] Compiling ToolstackKitTests NativeContractTests.swift +[11/12] Emitting module ToolstackKitTests +[11/13] Write Objects.LinkFileList +[12/13] Linking ToolstackPackageTests +Build complete! (3.81s) +Test Suite 'Selected tests' started at 2026-10-24 10:40:47.984. +Test Suite 'ToolstackPackageTests.xctest' started at 2026-10-24 10:40:47.984. +Test Suite 'NativeContractTests' started at 2026-10-24 10:40:47.984. +Test Case '-[ToolstackKitTests.NativeContractTests testAddStartRestartAreSeparateAndOnlyCatalogVerifies]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testAddStartRestartAreSeparateAndOnlyCatalogVerifies]' passed (0.005 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogAuthenticatedStrictDecodeAndNoAnonymousRequest]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogAuthenticatedStrictDecodeAndNoAnonymousRequest]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogNeedsPresentTargetAndFailuresInvalidate]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogNeedsPresentTargetAndFailuresInvalidate]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testLateCatalogAndDeleteDoNotTouchNewIdentity]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testLateCatalogAndDeleteDoNotTouchNewIdentity]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testLegacyUnsupportedRemovalIsRejected]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testLegacyUnsupportedRemovalIsRejected]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testNamedOwnershipConfirmationsAndReceipts]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testNamedOwnershipConfirmationsAndReceipts]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testUnknownDeleteReceiptsAnd500NeverReplayBeforeComparison]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testUnknownDeleteReceiptsAnd500NeverReplayBeforeComparison]' passed (0.001 seconds). +Test Suite 'NativeContractTests' passed at 2026-10-24 10:40:47.997. + Executed 7 tests, with 0 failures (0 unexpected) in 0.012 (0.013) seconds +Test Suite 'StalePublicationTests' started at 2026-10-24 10:40:47.997. +Test Case '-[ToolstackKitTests.StalePublicationTests testDelayedReadsDoNotPublishIntoResetIdentity]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testDelayedReadsDoNotPublishIntoResetIdentity]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyReadReturnsNoConsumableValue]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyReadReturnsNoConsumableValue]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyWriteCannotRepopulateDrafts]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyWriteCannotRepopulateDrafts]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateRestoreProofDoesNotReplaceBSession]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateRestoreProofDoesNotReplaceBSession]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateUnauthorizedDoesNotSignOutB]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateUnauthorizedDoesNotSignOutB]' passed (0.000 seconds). +Test Suite 'StalePublicationTests' passed at 2026-10-24 10:40:48.003. + Executed 7 tests, with 0 failures (0 unexpected) in 0.006 (0.006) seconds +Test Suite 'ToolstackPackageTests.xctest' passed at 2026-10-24 10:40:48.003. + Executed 14 tests, with 0 failures (0 unexpected) in 0.018 (0.019) seconds +Test Suite 'Selected tests' passed at 2026warning: '--build-system native' has been deprecated and will be removed in a future release; please report an issue at https://github.com/swiftlang/swift-package-manager/issues if you are unable to adopt the default build system. +-10-24 10:40:48.003. + Executed 14 tests, with 0 failures (0 unexpected) in 0.018 (0.020) seconds diff --git a/macapp/evidence/native-membership-validation.log b/macapp/evidence/native-membership-validation.log new file mode 100644 index 0000000..b789db0 --- /dev/null +++ b/macapp/evidence/native-membership-validation.log @@ -0,0 +1,62 @@ +Building for debugging... +[0/7] Write sources +[3/7] Write swift-version--58304C5D6DBC2206.txt +[5/9] Compiling ToolstackKit CatalogObservation.swift +[6/9] Emitting module ToolstackKit +[7/10] Compiling ToolstackKit ApiClient.swift +[8/13] Compiling ToolstackApp AppModel.swift +[9/13] Compiling ToolstackApp NativeSnapshots.swift +[10/13] Emitting module ToolstackApp +[11/14] Compiling ToolstackApp ToolstackApp.swift +[11/16] Write Objects.LinkFileList +[12/16] Linking ToolstackApp +[13/16] Applying ToolstackApp +[15/18] Emitting module ToolstackKitTests +[16/18] Compiling ToolstackKitTests CorrectiveTests.swift +[17/19] Compiling ToolstackKitTests NativeContractTests.swift +[17/19] Write Objects.LinkFileList +[18/19] Linking ToolstackPackageTests +Build complete! (3.97s) +Test Suite 'Selected tests' started at 2026-10-24 10:55:39.362. +Test Suite 'ToolstackPackageTests.xctest' started at 2026-10-24 10:55:39.362. +Test Suite 'NativeContractTests' started at 2026-10-24 10:55:39.362. +Test Case '-[ToolstackKitTests.NativeContractTests testAddStartRestartAreSeparateAndOnlyCatalogVerifies]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testAddStartRestartAreSeparateAndOnlyCatalogVerifies]' passed (0.006 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogAuthenticatedStrictDecodeAndNoAnonymousRequest]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogAuthenticatedStrictDecodeAndNoAnonymousRequest]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogNeedsPresentTargetAndFailuresInvalidate]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testCatalogNeedsPresentTargetAndFailuresInvalidate]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testLateCatalogAndDeleteDoNotTouchNewIdentity]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testLateCatalogAndDeleteDoNotTouchNewIdentity]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testLegacyUnsupportedRemovalIsRejected]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testLegacyUnsupportedRemovalIsRejected]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testNamedOwnershipConfirmationsAndReceipts]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testNamedOwnershipConfirmationsAndReceipts]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testRegistrationRequiresMembershipInSameCatalogSnapshot]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testRegistrationRequiresMembershipInSameCatalogSnapshot]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.NativeContractTests testUnknownDeleteReceiptsAnd500NeverReplayBeforeComparison]' started. +Test Case '-[ToolstackKitTests.NativeContractTests testUnknownDeleteReceiptsAnd500NeverReplayBeforeComparison]' passed (0.001 seconds). +Test Suite 'NativeContractTests' passed at 2026-10-24 10:55:39.377. + Executed 8 tests, with 0 failures (0 unexpected) in 0.014 (0.015) seconds +Test Suite 'StalePublicationTests' started at 2026-10-24 10:55:39.377. +Test Case '-[ToolstackKitTests.StalePublicationTests testDelayedReadsDoNotPublishIntoResetIdentity]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testDelayedReadsDoNotPublishIntoResetIdentity]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyReadReturnsNoConsumableValue]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyReadReturnsNoConsumableValue]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyWriteCannotRepopulateDrafts]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyWriteCannotRepopulateDrafts]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateRestoreProofDoesNotReplaceBSession]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateRestoreProofDoesNotReplaceBSession]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateUnauthorizedDoesNotSignOutB]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateUnauthorizedDoesNotSignOutB]' passed (0.000 seconds). +Test Suite 'StalePublicationTests' passed at 2026-10-24 10:55:39.384. + Executed 7 tests, wwarning: '--build-system native' has been deprecated and will be removed in a future release; please report an issue at https://github.com/swiftlang/swift-package-manager/issues if you are unable to adopt the default build system. +ith 0 failures (0 unexpected) in 0.006 (0.007) seconds +Test Suite 'ToolstackPackageTests.xctest' passed at 2026-10-24 10:55:39.384. + Executed 15 tests, with 0 failures (0 unexpected) in 0.021 (0.021) seconds +Test Suite 'Selected tests' passed at 2026-10-24 10:55:39.384. + Executed 15 tests, with 0 failures (0 unexpected) in 0.021 (0.022) seconds diff --git a/macapp/evidence/native-render.log b/macapp/evidence/native-render.log new file mode 100644 index 0000000..c7fed96 --- /dev/null +++ b/macapp/evidence/native-render.log @@ -0,0 +1 @@ +Rendered 12 offscreen SwiftUI views with NSView geometry; no live focus or VoiceOver assertion. diff --git a/macapp/evidence/native/draft-1100-geometry.json b/macapp/evidence/native/draft-1100-geometry.json new file mode 100644 index 0000000..1daf5ba --- /dev/null +++ b/macapp/evidence/native/draft-1100-geometry.json @@ -0,0 +1,206 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{146, 216}, {808, 484}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{146, 216}, {808, 484}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{146, 216}, {808, 484}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{146, 216}, {808, 484}}" + }, + { + "class" : "BackdropView", + "frame" : "{{146, 216}, {808, 28}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{146, 216}, {808, 484}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{146, 216}, {808, 484}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 254}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 254}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{162, 254}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{162, 254}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{162, 295}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{162, 295}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{162, 295}, {213, 28}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 329}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 329}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{162, 329}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{162, 329}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{162, 370}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{162, 370}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{162, 370}, {213, 28}}" + }, + { + "class" : "NSScrollPocket", + "frame" : "{{146, 216}, {808, 28}}" + }, + { + "class" : "PocketMask", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "PocketBlur", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "_NSLayerBasedFillColorView", + "frame" : "{{146, 243}, {0, 1}}" + }, + { + "class" : "LuminanceAdjustment", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "BackdropView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{146, 244}, {0, 0}}" + }, + { + "class" : "_NSScrollViewFloatingSubviewsContainerView", + "frame" : "{{146, 216}, {808, 484}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 216}, {808, 28}}" + }, + { + "class" : "NSBannerView", + "frame" : "{{146, 216}, {808, 28}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{146, 216}, {808, 28}}" + }, + { + "class" : "_NSBannerDecorationView", + "frame" : "{{146, 216}, {808, 28}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 216}, {808, 28}}" + }, + { + "class" : "ListTableHeaderView", + "frame" : "{{162, 216}, {776, 28}}" + }, + { + "class" : "NSHostingView>", + "frame" : "{{162, 216}, {776, 28}}" + }, + { + "class" : "NSScroller", + "frame" : "{{129, 216}, {17, 0}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{825, 714}, {63, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{896, 714}, {58, 28}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/draft-1100.png b/macapp/evidence/native/draft-1100.png new file mode 100644 index 0000000..486bb8e Binary files /dev/null and b/macapp/evidence/native/draft-1100.png differ diff --git a/macapp/evidence/native/draft-1440-geometry.json b/macapp/evidence/native/draft-1440-geometry.json new file mode 100644 index 0000000..8dbabb0 --- /dev/null +++ b/macapp/evidence/native/draft-1440-geometry.json @@ -0,0 +1,206 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{316, 216}, {808, 484}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{316, 216}, {808, 484}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{316, 216}, {808, 484}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{316, 216}, {808, 484}}" + }, + { + "class" : "BackdropView", + "frame" : "{{316, 216}, {808, 28}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{316, 216}, {808, 484}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{316, 216}, {808, 484}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 254}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 254}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{332, 254}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{332, 254}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{332, 295}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{332, 295}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{332, 295}, {213, 28}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 329}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 329}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{332, 329}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{332, 329}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{332, 370}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{332, 370}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{332, 370}, {213, 28}}" + }, + { + "class" : "NSScrollPocket", + "frame" : "{{316, 216}, {808, 28}}" + }, + { + "class" : "PocketMask", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "PocketBlur", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "_NSLayerBasedFillColorView", + "frame" : "{{316, 243}, {0, 1}}" + }, + { + "class" : "LuminanceAdjustment", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "BackdropView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{316, 244}, {0, 0}}" + }, + { + "class" : "_NSScrollViewFloatingSubviewsContainerView", + "frame" : "{{316, 216}, {808, 484}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 216}, {808, 28}}" + }, + { + "class" : "NSBannerView", + "frame" : "{{316, 216}, {808, 28}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{316, 216}, {808, 28}}" + }, + { + "class" : "_NSBannerDecorationView", + "frame" : "{{316, 216}, {808, 28}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 216}, {808, 28}}" + }, + { + "class" : "ListTableHeaderView", + "frame" : "{{332, 216}, {776, 28}}" + }, + { + "class" : "NSHostingView>", + "frame" : "{{332, 216}, {776, 28}}" + }, + { + "class" : "NSScroller", + "frame" : "{{299, 216}, {17, 0}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{995, 714}, {63, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1066, 714}, {58, 28}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/draft-1440.png b/macapp/evidence/native/draft-1440.png new file mode 100644 index 0000000..f2d6579 Binary files /dev/null and b/macapp/evidence/native/draft-1440.png differ diff --git a/macapp/evidence/native/load-failed-1100-geometry.json b/macapp/evidence/native/load-failed-1100-geometry.json new file mode 100644 index 0000000..119c344 --- /dev/null +++ b/macapp/evidence/native/load-failed-1100-geometry.json @@ -0,0 +1,18 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{146, 217}, {61, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{825, 714}, {63, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{896, 714}, {58, 28}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/load-failed-1100.png b/macapp/evidence/native/load-failed-1100.png new file mode 100644 index 0000000..1aeebc1 Binary files /dev/null and b/macapp/evidence/native/load-failed-1100.png differ diff --git a/macapp/evidence/native/load-failed-1440-geometry.json b/macapp/evidence/native/load-failed-1440-geometry.json new file mode 100644 index 0000000..1587ffb --- /dev/null +++ b/macapp/evidence/native/load-failed-1440-geometry.json @@ -0,0 +1,18 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{316, 217}, {61, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{995, 714}, {63, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1066, 714}, {58, 28}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/load-failed-1440.png b/macapp/evidence/native/load-failed-1440.png new file mode 100644 index 0000000..828c6bf Binary files /dev/null and b/macapp/evidence/native/load-failed-1440.png differ diff --git a/macapp/evidence/native/recovery-1100-geometry.json b/macapp/evidence/native/recovery-1100-geometry.json new file mode 100644 index 0000000..ec0c051 --- /dev/null +++ b/macapp/evidence/native/recovery-1100-geometry.json @@ -0,0 +1,122 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1011, 16}, {77, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{410, 71}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{986, 118}, {98, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{718, 276}, {109, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{835, 276}, {115, 28}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 341}, {1100, 559}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 341}, {1100, 559}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 341}, {1100, 559}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 341}, {1100, 559}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 341}, {1100, 559}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 341}, {1100, 559}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 351}, {1100, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 351}, {1100, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 351}, {1059, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 351}, {1059, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{972, 355}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{972, 355}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1012, 355}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1012, 355}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1052, 355}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1052, 355}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 351}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 364}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 341}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/recovery-1100.png b/macapp/evidence/native/recovery-1100.png new file mode 100644 index 0000000..109627f Binary files /dev/null and b/macapp/evidence/native/recovery-1100.png differ diff --git a/macapp/evidence/native/recovery-1440-geometry.json b/macapp/evidence/native/recovery-1440-geometry.json new file mode 100644 index 0000000..4ff9607 --- /dev/null +++ b/macapp/evidence/native/recovery-1440-geometry.json @@ -0,0 +1,122 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1351, 16}, {77, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{580, 71}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1326, 118}, {98, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{888, 276}, {109, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1005, 276}, {115, 28}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 341}, {1440, 559}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 341}, {1440, 559}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 341}, {1440, 559}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 341}, {1440, 559}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 341}, {1440, 559}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 341}, {1440, 559}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 351}, {1440, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 351}, {1440, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 351}, {1399, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 351}, {1399, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1312, 355}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1312, 355}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1352, 355}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1352, 355}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1392, 355}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1392, 355}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 351}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 364}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 341}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/recovery-1440.png b/macapp/evidence/native/recovery-1440.png new file mode 100644 index 0000000..881955d Binary files /dev/null and b/macapp/evidence/native/recovery-1440.png differ diff --git a/macapp/evidence/native/recovery-unknown-1100-geometry.json b/macapp/evidence/native/recovery-unknown-1100-geometry.json new file mode 100644 index 0000000..6797a96 --- /dev/null +++ b/macapp/evidence/native/recovery-unknown-1100-geometry.json @@ -0,0 +1,122 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1011, 16}, {77, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{935, 72}, {149, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{410, 127}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{410, 127}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{410, 127}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{986, 174}, {98, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{841, 332}, {109, 28}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 397}, {1100, 503}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 397}, {1100, 503}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 397}, {1100, 503}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 397}, {1100, 503}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 397}, {1100, 503}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 397}, {1100, 503}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 407}, {1100, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 407}, {1100, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 407}, {1059, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 407}, {1059, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{972, 411}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{972, 411}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1012, 411}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1012, 411}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1052, 411}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1052, 411}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 407}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 420}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 397}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/recovery-unknown-1100.png b/macapp/evidence/native/recovery-unknown-1100.png new file mode 100644 index 0000000..46c6abe Binary files /dev/null and b/macapp/evidence/native/recovery-unknown-1100.png differ diff --git a/macapp/evidence/native/recovery-unknown-1440-geometry.json b/macapp/evidence/native/recovery-unknown-1440-geometry.json new file mode 100644 index 0000000..7f58372 --- /dev/null +++ b/macapp/evidence/native/recovery-unknown-1440-geometry.json @@ -0,0 +1,122 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{168, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{232, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{296, 8}, {56, 40}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1351, 16}, {77, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1275, 72}, {149, 24}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{580, 127}, {280, 24}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{580, 127}, {280, 24}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{580, 127}, {280, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1326, 174}, {98, 24}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1011, 332}, {109, 28}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{0, 397}, {1440, 503}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{0, 397}, {1440, 503}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{0, 397}, {1440, 503}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{0, 397}, {1440, 503}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{0, 397}, {1440, 503}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{0, 397}, {1440, 503}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{0, 407}, {1440, 40}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{0, 407}, {1440, 40}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{25, 407}, {1399, 40}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{25, 407}, {1399, 40}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1312, 411}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1312, 411}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1352, 411}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1352, 411}, {32, 32}}" + }, + { + "class" : "SwiftUIAppKitButton", + "frame" : "{{1392, 411}, {32, 32}}" + }, + { + "class" : "ContentViewHost", + "frame" : "{{1392, 411}, {32, 32}}" + }, + { + "class" : "NSButton", + "frame" : "{{12, 407}, {13, 40}}" + }, + { + "class" : "NSButtonImageView", + "frame" : "{{12, 420}, {13, 13}}" + }, + { + "class" : "NSScroller", + "frame" : "{{-17, 397}, {17, 0}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/recovery-unknown-1440.png b/macapp/evidence/native/recovery-unknown-1440.png new file mode 100644 index 0000000..50c9478 Binary files /dev/null and b/macapp/evidence/native/recovery-unknown-1440.png differ diff --git a/macapp/evidence/native/save-rejected-1100-geometry.json b/macapp/evidence/native/save-rejected-1100-geometry.json new file mode 100644 index 0000000..ee503ac --- /dev/null +++ b/macapp/evidence/native/save-rejected-1100-geometry.json @@ -0,0 +1,206 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{146, 244}, {808, 456}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{146, 244}, {808, 456}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{146, 244}, {808, 456}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{146, 244}, {808, 456}}" + }, + { + "class" : "BackdropView", + "frame" : "{{146, 244}, {808, 28}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{146, 244}, {808, 456}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{146, 244}, {808, 456}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 282}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 282}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{162, 282}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{162, 282}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{162, 323}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{162, 323}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{162, 323}, {213, 28}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 357}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 357}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{162, 357}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{162, 357}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{162, 398}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{162, 398}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{162, 398}, {213, 28}}" + }, + { + "class" : "NSScrollPocket", + "frame" : "{{146, 244}, {808, 28}}" + }, + { + "class" : "PocketMask", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "PocketBlur", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "_NSLayerBasedFillColorView", + "frame" : "{{146, 271}, {0, 1}}" + }, + { + "class" : "LuminanceAdjustment", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "BackdropView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{146, 272}, {0, 0}}" + }, + { + "class" : "_NSScrollViewFloatingSubviewsContainerView", + "frame" : "{{146, 244}, {808, 456}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 244}, {808, 28}}" + }, + { + "class" : "NSBannerView", + "frame" : "{{146, 244}, {808, 28}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{146, 244}, {808, 28}}" + }, + { + "class" : "_NSBannerDecorationView", + "frame" : "{{146, 244}, {808, 28}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 244}, {808, 28}}" + }, + { + "class" : "ListTableHeaderView", + "frame" : "{{162, 244}, {776, 28}}" + }, + { + "class" : "NSHostingView>", + "frame" : "{{162, 244}, {776, 28}}" + }, + { + "class" : "NSScroller", + "frame" : "{{129, 244}, {17, 0}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{791, 714}, {63, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{862, 714}, {92, 28}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/save-rejected-1100.png b/macapp/evidence/native/save-rejected-1100.png new file mode 100644 index 0000000..7aaeae3 Binary files /dev/null and b/macapp/evidence/native/save-rejected-1100.png differ diff --git a/macapp/evidence/native/save-rejected-1440-geometry.json b/macapp/evidence/native/save-rejected-1440-geometry.json new file mode 100644 index 0000000..8d593c5 --- /dev/null +++ b/macapp/evidence/native/save-rejected-1440-geometry.json @@ -0,0 +1,206 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{316, 244}, {808, 456}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{316, 244}, {808, 456}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{316, 244}, {808, 456}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{316, 244}, {808, 456}}" + }, + { + "class" : "BackdropView", + "frame" : "{{316, 244}, {808, 28}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{316, 244}, {808, 456}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{316, 244}, {808, 456}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 282}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 282}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{332, 282}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{332, 282}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{332, 323}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{332, 323}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{332, 323}, {213, 28}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 357}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 357}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{332, 357}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{332, 357}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{332, 398}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{332, 398}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{332, 398}, {213, 28}}" + }, + { + "class" : "NSScrollPocket", + "frame" : "{{316, 244}, {808, 28}}" + }, + { + "class" : "PocketMask", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "PocketBlur", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "_NSLayerBasedFillColorView", + "frame" : "{{316, 271}, {0, 1}}" + }, + { + "class" : "LuminanceAdjustment", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "BackdropView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{316, 272}, {0, 0}}" + }, + { + "class" : "_NSScrollViewFloatingSubviewsContainerView", + "frame" : "{{316, 244}, {808, 456}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 244}, {808, 28}}" + }, + { + "class" : "NSBannerView", + "frame" : "{{316, 244}, {808, 28}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{316, 244}, {808, 28}}" + }, + { + "class" : "_NSBannerDecorationView", + "frame" : "{{316, 244}, {808, 28}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 244}, {808, 28}}" + }, + { + "class" : "ListTableHeaderView", + "frame" : "{{332, 244}, {776, 28}}" + }, + { + "class" : "NSHostingView>", + "frame" : "{{332, 244}, {776, 28}}" + }, + { + "class" : "NSScroller", + "frame" : "{{299, 244}, {17, 0}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{961, 714}, {63, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1032, 714}, {92, 28}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/save-rejected-1440.png b/macapp/evidence/native/save-rejected-1440.png new file mode 100644 index 0000000..eac61b5 Binary files /dev/null and b/macapp/evidence/native/save-rejected-1440.png differ diff --git a/macapp/evidence/native/save-unknown-1100-geometry.json b/macapp/evidence/native/save-unknown-1100-geometry.json new file mode 100644 index 0000000..fb1bcd5 --- /dev/null +++ b/macapp/evidence/native/save-unknown-1100-geometry.json @@ -0,0 +1,210 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1100, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{146, 244}, {153, 28}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{146, 284}, {808, 416}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{146, 284}, {808, 416}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{146, 284}, {808, 416}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{146, 284}, {808, 416}}" + }, + { + "class" : "BackdropView", + "frame" : "{{146, 284}, {808, 28}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{146, 284}, {808, 416}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{146, 284}, {808, 416}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 322}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 322}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{162, 322}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{162, 322}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{162, 363}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{162, 363}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{162, 363}, {213, 28}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 397}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 397}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{162, 397}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{162, 397}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{162, 438}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{162, 438}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{162, 438}, {213, 28}}" + }, + { + "class" : "NSScrollPocket", + "frame" : "{{146, 284}, {808, 28}}" + }, + { + "class" : "PocketMask", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "PocketBlur", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "_NSLayerBasedFillColorView", + "frame" : "{{146, 311}, {0, 1}}" + }, + { + "class" : "LuminanceAdjustment", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "BackdropView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{146, 312}, {0, 0}}" + }, + { + "class" : "_NSScrollViewFloatingSubviewsContainerView", + "frame" : "{{146, 284}, {808, 416}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{146, 284}, {808, 28}}" + }, + { + "class" : "NSBannerView", + "frame" : "{{146, 284}, {808, 28}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{146, 284}, {808, 28}}" + }, + { + "class" : "_NSBannerDecorationView", + "frame" : "{{146, 284}, {808, 28}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{146, 284}, {808, 28}}" + }, + { + "class" : "ListTableHeaderView", + "frame" : "{{162, 284}, {776, 28}}" + }, + { + "class" : "NSHostingView>", + "frame" : "{{162, 284}, {776, 28}}" + }, + { + "class" : "NSScroller", + "frame" : "{{129, 284}, {17, 0}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{825, 714}, {63, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{896, 714}, {58, 28}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/save-unknown-1100.png b/macapp/evidence/native/save-unknown-1100.png new file mode 100644 index 0000000..842f924 Binary files /dev/null and b/macapp/evidence/native/save-unknown-1100.png differ diff --git a/macapp/evidence/native/save-unknown-1440-geometry.json b/macapp/evidence/native/save-unknown-1440-geometry.json new file mode 100644 index 0000000..d7373ad --- /dev/null +++ b/macapp/evidence/native/save-unknown-1440-geometry.json @@ -0,0 +1,210 @@ +[ + { + "class" : "NSHostingView>>, _EnvironmentKeyWritingModifier>, _EnvironmentKeyWritingModifier>, _FrameLayout>, _BackgroundStyleModifier>>", + "frame" : "{{0, 0}, {1440, 900}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{316, 244}, {153, 28}}" + }, + { + "class" : "AppKitPlatformViewHost>>", + "frame" : "{{316, 284}, {808, 416}}" + }, + { + "class" : "ListCoreScrollView", + "frame" : "{{316, 284}, {808, 416}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{316, 284}, {808, 416}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{316, 284}, {808, 416}}" + }, + { + "class" : "BackdropView", + "frame" : "{{316, 284}, {808, 28}}" + }, + { + "class" : "ListCoreClipView", + "frame" : "{{316, 284}, {808, 416}}" + }, + { + "class" : "SwiftUIOutlineListView", + "frame" : "{{316, 284}, {808, 416}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 322}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 322}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{332, 322}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{332, 322}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{332, 363}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{332, 363}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{332, 363}, {213, 28}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 397}, {808, 75}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 397}, {808, 75}}" + }, + { + "class" : "ListTableCellView", + "frame" : "{{332, 397}, {776, 75}}" + }, + { + "class" : "CellHostingView>", + "frame" : "{{332, 397}, {776, 75}}" + }, + { + "class" : "AppKitPlatformViewHost>", + "frame" : "{{332, 438}, {213, 28}}" + }, + { + "class" : "SwiftUISegmentedControl", + "frame" : "{{332, 438}, {213, 28}}" + }, + { + "class" : "_NSCoreHostingView", + "frame" : "{{332, 438}, {213, 28}}" + }, + { + "class" : "NSScrollPocket", + "frame" : "{{316, 284}, {808, 28}}" + }, + { + "class" : "PocketMask", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "PocketBlur", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "_NSLayerBasedFillColorView", + "frame" : "{{316, 311}, {0, 1}}" + }, + { + "class" : "LuminanceAdjustment", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "BackdropView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "_NSScrollViewContentBackgroundView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSVisualEffectView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "NSView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "PortalView", + "frame" : "{{316, 312}, {0, 0}}" + }, + { + "class" : "_NSScrollViewFloatingSubviewsContainerView", + "frame" : "{{316, 284}, {808, 416}}" + }, + { + "class" : "ListTableRowView", + "frame" : "{{316, 284}, {808, 28}}" + }, + { + "class" : "NSBannerView", + "frame" : "{{316, 284}, {808, 28}}" + }, + { + "class" : "NSHardPocketView", + "frame" : "{{316, 284}, {808, 28}}" + }, + { + "class" : "_NSBannerDecorationView", + "frame" : "{{316, 284}, {808, 28}}" + }, + { + "class" : "_NSTableRowSeparatorDrawingView", + "frame" : "{{316, 284}, {808, 28}}" + }, + { + "class" : "ListTableHeaderView", + "frame" : "{{332, 284}, {776, 28}}" + }, + { + "class" : "NSHostingView>", + "frame" : "{{332, 284}, {776, 28}}" + }, + { + "class" : "NSScroller", + "frame" : "{{299, 284}, {17, 0}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{995, 714}, {63, 28}}" + }, + { + "class" : "_FocusRingView", + "frame" : "{{1066, 714}, {58, 28}}" + } +] \ No newline at end of file diff --git a/macapp/evidence/native/save-unknown-1440.png b/macapp/evidence/native/save-unknown-1440.png new file mode 100644 index 0000000..cd5eb80 Binary files /dev/null and b/macapp/evidence/native/save-unknown-1440.png differ diff --git a/macapp/evidence/norman-source.json b/macapp/evidence/norman-source.json new file mode 100644 index 0000000..145e5d3 --- /dev/null +++ b/macapp/evidence/norman-source.json @@ -0,0 +1,24 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 0, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [ + { + "file": "ToolstackApp/AppModel.swift", + "call_sites": 2 + } + ] + } +} diff --git a/macapp/evidence/stale-publication-first-build-failure.log b/macapp/evidence/stale-publication-first-build-failure.log new file mode 100644 index 0000000..2ea941f --- /dev/null +++ b/macapp/evidence/stale-publication-first-build-failure.log @@ -0,0 +1,60 @@ +[0/1] Planning build +Building for debugging... +[0/7] Write sources +[2/7] Write swift-version--58304C5D6DBC2206.txt +[4/9] Compiling ToolstackApp AppModel.swift +[5/9] Emitting module ToolstackApp +[6/11] Compiling ToolstackApp NativeSnapshots.swift +[7/11] Compiling ToolstackApp ToolstackApp.swift +[7/12] Write Objects.LinkFileList +[8/12] Linking ToolstackApp +[9/12] Applying ToolstackApp +[11/14] Compiling ToolstackKitTests CorrectiveTests.swift +[12/14] Emitting module ToolstackKitTests +[13/15] Compiling ToolstackKitTests StalePublicationTests.swift +/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-mac/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift:56:47: error: missing argument for parameter #1 in call + 4 | + 5 | extension PendingTransport { + 6 | func respond(_ status: Int = 200, _ json: String) { + | `- note: 'respond' declared here + 7 | client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + 8 | client?.urlProtocol(self, didLoad: Data(json.utf8)) + : + 54 | await fulfillment(of: [started], timeout: 2) + 55 | try seedB(model) + 56 | PendingTransport.pending?.respond(json) + | `- error: missing argument for parameter #1 in call + 57 | await task.value + 58 | assertB(model) + +/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-mac/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift:72:47: error: missing argument for parameter #1 in call + 4 | + 5 | extension PendingTransport { + 6 | func respond(_ status: Int = 200, _ json: String) { + | `- note: 'respond' declared here + 7 | client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + 8 | client?.urlProtocol(self, didLoad: Data(json.utf8)) + : + 70 | await fulfillment(of: [started], timeout: 2) + 71 | try seedB(model) + 72 | PendingTransport.pending?.respond(#"{"name":"A","token":"FAKE-ONE-TIME-A"}"#) + | `- error: missing argument for parameter #1 in call + 73 | await task.value + 74 | assertB(model) + +/Users/batteryshark/Projects/workstack/toolstack/.worktrees/clear-desk-mac/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift:95:43: error: missing argument for parameter #1 in call + 4 | + 5 | extension PendingTransport { + 6 | func respond(_ status: Int = 200, _ json: String) { + | `- note: 'respond' declared here + 7 | client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + 8 | client?.urlProtocol(self, didLoad: Data(json.utf8)) + : + 93 | await fulfillment(of: [started], timeout: 2) + 94 | try seedB(model) + 95 | PendingTransport.pending?.respond(#"{"name":"A","enabled":[],"policy":{"tools":{}}}"#) + | `- error: missing argument for parameter #1 in call + 96 | let result = await task.value + 97 | XCTAssertNil(result) +warning: '--build-system native' has been deprecated and will be removed in a future release; please report an issue at https://github.com/swiftlang/swift-package-manager/issues if you are unable to adopt the default build system. +error: fatalError diff --git a/macapp/evidence/stale-publication-norman-source.json b/macapp/evidence/stale-publication-norman-source.json new file mode 100644 index 0000000..145e5d3 --- /dev/null +++ b/macapp/evidence/stale-publication-norman-source.json @@ -0,0 +1,24 @@ +{ + "forms": { + "caveat": "static tag count; cannot see runtime-generated fields, component-library inputs, or attributes set from code.", + "files": [] + }, + "targets": { + "caveat": "literal px values below 24 (WCAG 2.5.8 floor) on selectors naming button/input/a/[role=]; cannot see computed styles, CSS-in-JS, relative units, or resolve SCSS nesting.", + "findings": [] + }, + "flags": { + "caveat": "static string match; 'in help/docs' means substring presence in a doc surface or a usage/help line, not proof the flag is explained.", + "defined": 0, + "undocumented": [] + }, + "state": { + "caveat": "counts storage-API call sites only; cannot tell what is stored or whether interface state is actually persisted.", + "files": [ + { + "file": "ToolstackApp/AppModel.swift", + "call_sites": 2 + } + ] + } +} diff --git a/macapp/evidence/stale-publication-validation.log b/macapp/evidence/stale-publication-validation.log new file mode 100644 index 0000000..b3327bc --- /dev/null +++ b/macapp/evidence/stale-publication-validation.log @@ -0,0 +1,40 @@ +Building for debugging... +[0/7] Write sources +[2/7] Write swift-version--58304C5D6DBC2206.txt +[4/9] Emitting module ToolstackApp +[5/9] Compiling ToolstackApp AppModel.swift +[6/11] Compiling ToolstackApp ToolstackApp.swift +[7/11] Compiling ToolstackApp NativeSnapshots.swift +[7/12] Write Objects.LinkFileList +[8/12] Linking ToolstackApp +[9/12] Applying ToolstackApp +[11/14] Compiling ToolstackKitTests CorrectiveTests.swift +[12/14] Emitting module ToolstackKitTests +[13/15] Compiling ToolstackKitTests StalePublicationTests.swift +[13/15] Write Objects.LinkFileList +[14/15] Linking ToolstackPackageTests +Build complete! (3.42s) +warning: '--build-system native' has been deprecated and will be removed in a future release; please report an issue at https://github.com/swiftlang/swift-package-manager/issues if you are unable to adopt the default build system. +Test Suite 'Selected tests' started at 2026-10-24 10:25:12.083. +Test Suite 'ToolstackPackageTests.xctest' started at 2026-10-24 10:25:12.083. +Test Suite 'StalePublicationTests' started at 2026-10-24 10:25:12.083. +Test Case '-[ToolstackKitTests.StalePublicationTests testDelayedReadsDoNotPublishIntoResetIdentity]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testDelayedReadsDoNotPublishIntoResetIdentity]' passed (0.006 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateCreateAndRotateReceiptsNeverPublishTokenOrRefresh]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyReadReturnsNoConsumableValue]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyReadReturnsNoConsumableValue]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyWriteCannotRepopulateDrafts]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLatePolicyWriteCannotRepopulateDrafts]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateRestoreProofDoesNotReplaceBSession]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateRestoreProofDoesNotReplaceBSession]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateToolAndConfigAcknowledgementsDoNotPublishOrFollowUp]' passed (0.002 seconds). +Test Case '-[ToolstackKitTests.StalePublicationTests testLateUnauthorizedDoesNotSignOutB]' started. +Test Case '-[ToolstackKitTests.StalePublicationTests testLateUnauthorizedDoesNotSignOutB]' passed (0.000 seconds). +Test Suite 'StalePublicationTests' passed at 2026-10-24 10:25:12.095. + Executed 7 tests, with 0 failures (0 unexpected) in 0.011 (0.011) seconds +Test Suite 'ToolstackPackageTests.xctest' passed at 2026-10-24 10:25:12.095. + Executed 7 tests, with 0 failures (0 unexpected) in 0.011 (0.011) seconds +Test Suite 'Selected tests' passed at 2026-10-24 10:25:12.095. + Executed 7 tests, with 0 failures (0 unexpected) in 0.011 (0.012) seconds diff --git a/macapp/evidence/swift-final-build.log b/macapp/evidence/swift-final-build.log new file mode 100644 index 0000000..5899dde --- /dev/null +++ b/macapp/evidence/swift-final-build.log @@ -0,0 +1,11 @@ +[0/1] Planning build +Building for debugging... +[0/4] Write sources +[1/4] Write swift-version--58304C5D6DBC2206.txt +[3/6] Emitting module ToolstackApp +[4/6] Compiling ToolstackApp ToolstackApp.swift +[4/7] Write Objects.LinkFileList +[5/7] Linking ToolstackApp +[6/7] Applying ToolstackApp +Build complete! (2.77s) +warning: '--build-system native' has been deprecated and will be removed in a future release; please report an issue at https://github.com/swiftlang/swift-package-manager/issues if you are unable to adopt the default build system. diff --git a/macapp/evidence/swift-validation.log b/macapp/evidence/swift-validation.log new file mode 100644 index 0000000..6a0c76e --- /dev/null +++ b/macapp/evidence/swift-validation.log @@ -0,0 +1,124 @@ +[0/1] Planning build +Building for debugging... +[0/9] Write sources +[3/9] Write swift-version--58304C5D6DBC2206.txt +[5/11] Compiling ToolstackKit RestAuthoringPayload.swift +[6/11] Emitting module ToolstackKit +[7/17] Emitting module ToolstackApp +[8/17] Compiling ToolstackApp AppModel.swift +[9/17] Compiling ToolstackApp NativeSnapshots.swift +[10/18] Emitting module ToolstackKitTests +[13/19] Compiling ToolstackKitTests PolicyDraftTests.swift +[13/19] Write Objects.LinkFileList +[14/19] Linking ToolstackPackageTests +[16/19] Compiling ToolstackApp ToolstackApp.swift +[16/19] Write Objects.LinkFileList +[17/19] Linking ToolstackApp +[18/19] Applying ToolstackApp +Build complete! (3.19s) +Test Suite 'Selected tests' started at 2026-10-24 10:02:11.039. +Test Suite 'ToolstackPackageTests.xctest' started at 2026-10-24 10:02:11.040. +Test Suite 'ApiClientTests' started at 2026-10-24 10:02:11.040. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolFromGitHubOmitsEmptyOptionals]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolFromGitHubOmitsEmptyOptionals]' passed (0.004 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolFromGitHubPostsRepoSubdirRef]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolFromGitHubPostsRepoSubdirRef]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolNoManifestMapsTo422]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolNoManifestMapsTo422]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolPostsSource]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAddToolPostsSource]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAuditDecodesEventsRequestsAndDetails]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAuditDecodesEventsRequestsAndDetails]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAuthedRequestSendsBearer]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAuthedRequestSendsBearer]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testAuthorToolPostsSourceAndManifest]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testAuthorToolPostsSourceAndManifest]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testConfigDecodesMasked]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testConfigDecodesMasked]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testCreateCallerSendsBodyAndParsesToken]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testCreateCallerSendsBodyAndParsesToken]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testDeleteToolSendsDELETE]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testDeleteToolSendsDELETE]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testGetPolicyDecodes]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testGetPolicyDecodes]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testHttpErrorCarriesServerDetail]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testHttpErrorCarriesServerDetail]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testImportedHeaderDeclarationPayloadIsNotStaticInjection]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testImportedHeaderDeclarationPayloadIsNotStaticInjection]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListCallersDecodesSnakeCase]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListCallersDecodesSnakeCase]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesDescription]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesDescription]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesGitHubSource]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesGitHubSource]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesOps]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesOps]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesSecrets]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsDecodesSecrets]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsToleratesMissingOps]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsToleratesMissingOps]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsToleratesNullSource]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testListToolsToleratesNullSource]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testLoginSendsPasswordUnauthedAndStoresToken]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testLoginSendsPasswordUnauthedAndStoresToken]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testParseOpenAPIPostsSpecAndDecodesRestDraft]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testParseOpenAPIPostsSpecAndDecodesRestDraft]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyHTTPRejectionRetainsDraft]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyHTTPRejectionRetainsDraft]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyLoadFailureCannotSave]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyLoadFailureCannotSave]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyTimeoutIsUnknownAndNotReplayed]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testPolicyTimeoutIsUnknownAndNotReplayed]' passed (0.001 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testResyncToolPostsToUpdatePath]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testResyncToolPostsToUpdatePath]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testRevokeCallerPosts]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testRevokeCallerPosts]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testRotateTokenPostsAndReturnsNewToken]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testRotateTokenPostsAndReturnsNewToken]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSaveConfigSendsTokenOnlyWhenProvided]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSaveConfigSendsTokenOnlyWhenProvided]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSecretBackendDecodes]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSecretBackendDecodes]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSecretStatusDecodes]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSecretStatusDecodes]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSetEnabledToolsPutsList]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSetEnabledToolsPutsList]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testSetPolicySendsAllowReviewDenyViaPut]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testSetPolicySendsAllowReviewDenyViaPut]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testToolActionPostsToActionPath]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testToolActionPostsToActionPath]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testUnauthorizedMapsToApiError]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testUnauthorizedMapsToApiError]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.ApiClientTests testUpdateToolPostsDescriptionAndSecrets]' started. +Test Case '-[ToolstackKitTests.ApiClientTests testUpdateToolPostsDescriptionAndSecrets]' passed (0.000 seconds). +Test Suite 'ApiClientTests' passed at 2026-10-24 10:02:11.055. + Executed 36 tests, with 0 failures (0 unexpected) in 0.015 (0.016) seconds +Test Suite 'CanonicalContractTests' started at 2026-10-24 10:02:11.055. +Test Case '-[ToolstackKitTests.CanonicalContractTests testCanonicalNativeContract]' started. +Test Case '-[ToolstackKitTests.CanonicalContractTests testCanonicalNativeContract]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CanonicalContractTests testOwnershipUnknownIsNotManaged]' started. +Test Case '-[ToolstackKitTests.CanonicalContractTests testOwnershipUnknownIsNotManaged]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.CanonicalContractTests testUnsupportedProvisioningSignifiersRemoved]' started. +Test Case '-[ToolstackKitTests.CanonicalContractTests testUnsupportedProvisioningSignifiersRemoved]' passed (0.002 seconds). +Test Suite 'CanonicalContractTests' passed at 2026-10-24 10warning: '--build-system native' has been deprecated and will be removed in a future release; please report an issue at https://github.com/swiftlang/swift-package-manager/issues if you are unable to adopt the default build system. +:02:11.058. + Executed 3 tests, with 0 failures (0 unexpected) in 0.002 (0.002) seconds +Test Suite 'PolicyDraftTests' started at 2026-10-24 10:02:11.058. +Test Case '-[ToolstackKitTests.PolicyDraftTests testCompareMatchesWithoutReplay]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testCompareMatchesWithoutReplay]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testComparisonPreservesConcurrentUneditedGrants]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testComparisonPreservesConcurrentUneditedGrants]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testLoadGate]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testLoadGate]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testMemoryDraftRetainsTargetAcrossNavigation]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testMemoryDraftRetainsTargetAcrossNavigation]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testRejectedSaveRetainsTargetAndOtherGrants]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testRejectedSaveRetainsTargetAndOtherGrants]' passed (0.000 seconds). +Test Case '-[ToolstackKitTests.PolicyDraftTests testUnknownRequiresCompareBeforeRetry]' started. +Test Case '-[ToolstackKitTests.PolicyDraftTests testUnknownRequiresCompareBeforeRetry]' passed (0.000 seconds). +Test Suite 'PolicyDraftTests' passed at 2026-10-24 10:02:11.058. + Executed 6 tests, with 0 failures (0 unexpected) in 0.000 (0.001) seconds +Test Suite 'ToolstackPackageTests.xctest' passed at 2026-10-24 10:02:11.058. + Executed 45 tests, with 0 failures (0 unexpected) in 0.017 (0.019) seconds +Test Suite 'Selected tests' passed at 2026-10-24 10:02:11.058. + Executed 45 tests, with 0 failures (0 unexpected) in 0.017 (0.019) seconds diff --git a/macapp/packaging/README.md b/macapp/packaging/README.md index ae60d33..7ca7773 100644 --- a/macapp/packaging/README.md +++ b/macapp/packaging/README.md @@ -1,74 +1,52 @@ -# Packaging the Toolstack operator app - -Turns the SwiftPM executable into a distributable, signed, notarized macOS `.app`. - -## 1. Build the bundle (no credentials needed) - -```sh -./packaging/build-app.sh # → "build/Toolstack Operator.app" (unsigned) -open "build/Toolstack Operator.app" # run it -``` - -`build-app.sh` does `swift build -c release`, then assembles `Toolstack Operator.app/Contents/` -(`MacOS/ToolstackApp` + `Info.plist`). Because it's a real bundle, the app activates as a normal -windowed app; the `AppDelegate` activation-policy shim only mattered for `swift run`. - -## 2. Sign + notarize (one command) - -Notarization uses an **App Store Connect API key**: no Apple ID or app-specific password, so it -can't trip the account lock that `notarytool store-credentials` password auth sometimes does. - -Two gitignored files supply the config (both already set up on this Mac): - -- **`packaging/signing.env`**: `DEVELOPER_ID_APP` (the "Developer ID Application: ..." identity) + `TEAM_ID`. -- **`secrets/secrets.env`**: `APP_STORE_CONNECT_API_ISSUER_ID` + `APP_STORE_CONNECT_API_KEY_PATH` - (path to your `AuthKey_.p8`; the key-id is read from that filename). - -Create the key once at **App Store Connect → Users and Access → Integrations → App Store Connect -API**, download the `.p8` into `macapp/secrets/`, and put its issuer id + path in `secrets/secrets.env`. - -Then, every release, one command (builds, signs, notarizes, staples): +# Local development packaging + +The approved path is **LOCAL Apple Development signing**, not distribution. +The approved Clear desk UI is built. A parent local build, Apple Development signing +and separate strict/deep verification passed; see [bounded evidence](../../docs/mac-local-signing.md). +An existing approved identity is required on the machine performing the recipe. +Current evidence binds the product to `78ea904` (unchanged at `8e4bb2e`). The +isolated-copy fixture's codesign-only HOME repair also passed real signing and +separate verification while OFF; this does not establish live app acceptance. +See the linked evidence for the first failed hands attempt and fresh readiness gate. + +From `macapp/`, build the existing bundle with +`bash packaging/build-app.sh` (output: `build/Toolstack Operator.app`). Then use an +explicit, already-existing approved identity; no identity discovery or account setup +is performed by these scripts: ```sh -./packaging/sign-and-notarize.sh +bash packaging/sign-local.sh --identity 'Apple Development: APPROVED EXISTING IDENTITY' --app 'build/Toolstack Operator.app' +bash packaging/verify-local.sh 'build/Toolstack Operator.app' ``` -It signs with Hardened Runtime + a secure timestamp, submits with the API key, waits, staples the -ticket, and finally zips the **stapled** bundle to `build/ToolstackOperator.zip`; that zip is the -deliverable (the ticket travels inside the `.app`, so it passes Gatekeeper offline; the recipient -unzips and drags it to `/Applications`). `spctl --assess` at the end should report **accepted / -Notarized Developer ID**, i.e. it opens on any Mac with no Gatekeeper warning. - -### Entitlements - -None are needed for this app: it isn't sandboxed (Developer ID, not App Store), and under Hardened -Runtime both outgoing network connections (to the admin) and access to the app's **own** Keychain -items (the saved bearer token) are allowed without an entitlement. Add -`--entitlements packaging/Toolstack.entitlements` to the `codesign` line only if a future feature -needs a specific Hardened-Runtime exception (e.g. JIT). - -## 3. Distribute - -`build/ToolstackOperator.zip` (produced in step 2) is the artifact; hand it out as-is. If you'd -rather ship a disk image instead of a zip, wrap the stapled app in a signed `.dmg`: - -```sh -hdiutil create -volname "Toolstack" -srcfolder "build/Toolstack Operator.app" \ - -ov -format UDZO build/Toolstack.dmg -codesign --sign "$DEVELOPER_ID_APP" build/Toolstack.dmg # sign the dmg too if you ship it -``` - -## Versioning - -Bump `CFBundleShortVersionString` (marketing, e.g. `0.2.0`) and `CFBundleVersion` (build number) in -`packaging/Info.plist` per release. The bundle id `com.toolstack.operator` matches the Keychain -service the app uses to remember your login. Keep them in sync. - -## Icons - -- **App icon**: generated at build time (`sips` + `iconutil`) from `packaging/AppIcon-source.png` - (a 1024×1024 PNG). Replace that file and rebuild to rebrand; no `.icns` is committed. -- **Menu-bar (systray) glyph**: `Sources/ToolstackApp/Resources/MenuBarIcon.png`, a 36×36 - *template* image (alpha = the glyph, RGB ignored) so macOS tints it for the light/dark menu bar. - It's a SwiftPM resource loaded via `Bundle.module`; `build-app.sh` copies the resource bundle into - the `.app` so it resolves there too. +Alternatively set `TOOLSTACK_LOCAL_SIGN_IDENTITY` explicitly for `sign-local.sh`. +The app path defaults to `macapp/build/Toolstack Operator.app`, relative to the +script, not the caller's directory. Both scripts accept paths containing spaces. +Signing requires an existing bundle; it never builds. It uses +`codesign --force --sign IDENTITY --timestamp=none`, without hardened runtime, +ad-hoc fallback, secure timestamp or network submission. Verification is a +**separate command**, `codesign --verify --deep --strict --verbose=2 APP`; +run it and require success independently. Missing bundle/identity or failed +codesign exits nonzero. Sign success alone is not verification evidence. + +Neither script sources `signing.env`, `secrets.env` or any credential file, invokes +notarytool/spctl, enumerates identities, nor changes certificates/accounts. +The signing tool itself uses the existing local keychain identity; these scripts +do not export its key. Stub tests are argv/failure contracts, not real signing proof. + +Strict/deep verification proves signature consistency only: not functional behavior, +Keychain compatibility, notarization, Gatekeeper acceptance or distribution safety. +See [local evidence and remaining checks](../../docs/mac-local-signing.md). + +## Historical distribution script — not the default + +`sign-and-notarize.sh` is retained unchanged as historical distribution automation. +It **builds, sources private configuration, signs with Developer ID, submits to +Apple, staples and assesses Gatekeeper**. Do not execute or source it for this +release's local path. Its presence establishes no configured credentials, +distributable artifact or successful notarization. Developer ID, notary API keys, +account changes and distribution are out of scope. + +Bundle identifier, version, Info.plist and icon assets are unchanged. The builder +generates the app icon from `AppIcon-source.png` and copies SwiftPM resources; +signature verification does not certify visual behavior or resource completeness. diff --git a/macapp/packaging/build-app.sh b/macapp/packaging/build-app.sh index ca980e2..ef44ef6 100755 --- a/macapp/packaging/build-app.sh +++ b/macapp/packaging/build-app.sh @@ -4,7 +4,7 @@ # # ./packaging/build-app.sh # -> "build/Toolstack Operator.app" (unsigned) # -# Then sign + notarize with ./packaging/sign-and-notarize.sh (needs your Developer ID). +# Later, local-only: bash packaging/sign-local.sh, then separately verify-local.sh. set -euo pipefail cd "$(dirname "$0")/.." @@ -43,4 +43,5 @@ plutil -lint "${CONTENTS}/Info.plist" >/dev/null echo "✓ built ${OUT}" echo " unsigned, run it with: open \"${OUT}\"" -echo " ship it with: ./packaging/sign-and-notarize.sh" +echo " LOCAL only: bash packaging/sign-local.sh --identity 'Apple Development: ...'" +echo " verify separately: bash packaging/verify-local.sh \"${OUT}\"" diff --git a/macapp/packaging/sign-local.sh b/macapp/packaging/sign-local.sh new file mode 100644 index 0000000..26d76ef --- /dev/null +++ b/macapp/packaging/sign-local.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# LOCAL development only. Never builds, sources credentials or notarizes. +set -euo pipefail +IDENTITY="${TOOLSTACK_LOCAL_SIGN_IDENTITY:-}" +APP="$(cd "$(dirname "$0")/.." && pwd)/build/Toolstack Operator.app" +usage() { echo "Usage: $0 [--identity 'Apple Development: ...'] [--app /path/to/existing.app]"; } +while [ "$#" -gt 0 ]; do + case "$1" in + --identity|--app) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + if [ "$1" = --identity ]; then IDENTITY="$2"; else APP="$2"; fi + shift 2 ;; + --help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac +done +case "$IDENTITY" in + 'Apple Development: '?*) ;; + *) echo 'error: explicit existing Apple Development identity required' >&2; exit 2 ;; +esac +case "$APP" in *.app) ;; *) echo 'error: expected an existing .app directory' >&2; exit 2 ;; esac +[ -d "$APP/Contents" ] || { echo 'error: app bundle missing' >&2; exit 1; } +codesign --force --sign "$IDENTITY" --timestamp=none "$APP" +echo 'LOCAL signing finished; run verify-local.sh separately before use.' diff --git a/macapp/packaging/verify-local.sh b/macapp/packaging/verify-local.sh new file mode 100644 index 0000000..17af749 --- /dev/null +++ b/macapp/packaging/verify-local.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Independent signature check only: no signing or Gatekeeper assessment. +set -euo pipefail +APP="$(cd "$(dirname "$0")/.." && pwd)/build/Toolstack Operator.app" +if [ "$#" -gt 1 ]; then echo "Usage: $0 [/path/to/existing.app]" >&2; exit 2; fi +if [ "$#" -eq 1 ]; then APP="$1"; fi +case "$APP" in *.app) ;; *) echo 'error: expected an existing .app directory' >&2; exit 2 ;; esac +[ -d "$APP/Contents" ] || { echo 'error: app bundle missing' >&2; exit 1; } +codesign --verify --deep --strict --verbose=2 "$APP" diff --git a/pyproject.toml b/pyproject.toml index 558a814..a2a63c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,4 @@ -# Packaging for Toolstack's stdlib components (broker, toolyard, client), so their -# CLIs install as real commands (`toolstack`, `brokerctl`, `toolyard`) instead of -# only `python3 -m `. The agent skill and the docs use the bare commands. -# -# pip install -e . # editable: the commands track your checkout -# toolstack --help -# -# Runtime stays ZERO-dependency (these are stdlib-only); setuptools is a build-time -# dep only. The admin web app is intentionally NOT packaged here; it carries the -# project's only runtime deps (FastAPI/uvicorn), in admin/requirements.txt + its own venv. +# Complete distribution; optional deployment dependencies are locked by uv.lock. [build-system] requires = ["setuptools>=61"] @@ -18,21 +9,24 @@ name = "toolstack" version = "0.1.0" description = "Toolstack (TSR): a brokered, action-without-access tool layer for agents" requires-python = ">=3.11" # tomllib (broker.registry / toolyard.config) is 3.11+ -dependencies = [] # stdlib only +dependencies = [] # core CLIs are stdlib-only; deployment uses extras classifiers = [ "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", ] [project.optional-dependencies] +admin = ["fastapi==0.137.1", "uvicorn==0.49.0", "PyYAML==6.0.3"] +test = ["httpx==0.28.1"] # The SPS `localfile` plugin uses scrypt (stdlib) + Fernet AEAD. `cryptography` is # imported lazily so the SPS stays zero-dependency unless the localfile plugin is # actually configured; opt in with `pip install '.[vault]'`. Upper bound caps the major # so a future cryptography release can't silently break a deploy; widen it deliberately # after testing the plugin against the new major. (The original `[vault]` extra # applied to the historical `toolyard/secrets.py`; that module is now removed.) -vault = ["cryptography>=42,<46"] +vault = ["cryptography>=50.0.2,<51"] [project.scripts] +admin = "admin.__main__:main" toolstack = "client.toolstack:main" # the agent's generic tool client (CLI) toolstack-mcp = "client.mcp_server:main" # the same client as an MCP stdio server brokerctl = "broker.brokerctl:main" # operator CLI (callers/policies/tokens/sweep) @@ -41,9 +35,8 @@ toolstack-forwarder = "toolstack_forwarder.__main__:main" # generic rest forwar sps = "sps.cli:main" # the secrets procurement service [tool.setuptools] -# Explicit list (not auto-discovery) so the board dirs (.ordna/, tasks/) and the -# dep-carrying admin package are never swept into the distribution. -packages = ["broker", "toolyard", "client", "toolstack_forwarder", "sps", "sps.plugins"] - - +# Explicit list prevents tests and unrelated project directories entering wheels. +packages = ["admin", "admin.views", "broker", "toolyard", "client", "toolstack_forwarder", "sps", "sps.plugins"] +[tool.setuptools.package-data] +client = ["SKILL.md"] diff --git a/scripts/check-vertical-slice.py b/scripts/check-vertical-slice.py new file mode 100644 index 0000000..0c81b96 --- /dev/null +++ b/scripts/check-vertical-slice.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Run the opt-in real-release integration fixture with a scrubbed environment.""" +import os +from pathlib import Path +import subprocess +import signal +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +TEMP = Path('/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode') + +if __name__ == '__main__': + os.umask(0o077) + with tempfile.TemporaryDirectory(prefix='toolstack-e2e-', dir=TEMP) as directory: + env = {'PATH': '/usr/bin:/bin:/usr/sbin:/sbin', 'HOME': directory, + 'TMPDIR': directory, 'PYTHONPATH': str(ROOT), + 'TOOLSTACK_E2E_ROOT': directory, 'PYTHONUNBUFFERED': '1', + 'PYTHONNOUSERSITE': '1', + **{key: str(Path(directory) / key.lower()) for key in + ('XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR')}} + child = subprocess.Popen([sys.executable, str(ROOT / 'tests/integration/vertical_slice.py')], + cwd=ROOT, env=env) + try: + code = child.wait(timeout=270) + except (subprocess.TimeoutExpired, KeyboardInterrupt): + # Let the fixture's finally stop independently grouped services before + # TemporaryDirectory removes their files. Never broad-kill a session. + if child.poll() is None: + child.send_signal(signal.SIGTERM) + code = child.wait(timeout=30) + raise SystemExit(code) diff --git a/sps/audit.py b/sps/audit.py index 22d6b12..70f9e1d 100644 --- a/sps/audit.py +++ b/sps/audit.py @@ -9,7 +9,10 @@ import json import time +import os +import threading from pathlib import Path +from .paths import private_parent, validate, open_private # Audit fields we accept. Anything else is silently dropped (defense in depth). # Notably `esecret`, `spsecret`, `value`, and any other secret-bearing kwarg is @@ -19,21 +22,49 @@ class AuditLogger: - def __init__(self, path: str | Path) -> None: + def __init__(self, path: str | Path, *, max_bytes: int = 4 * 1024 * 1024) -> None: + if max_bytes < 1024: + raise ValueError("audit max_bytes must be at least 1024") + self._max_bytes = max_bytes self._path = Path(path) + self._lock = threading.Lock() # If the path looks writable, ensure its parent exists. For tests that # pass ephemeral paths we want creation to fail loudly rather than # silently no-op. - self._path.parent.mkdir(parents=True, exist_ok=True) + private_parent(self._path) + validate(self._path) + validate(str(self._path) + ".1") - def event(self, action: str, *, tool_id: str = "", secret_name: str = "") -> None: + def event(self, action: str, *, tool_id: str = "", secret_name: str = "", + outcome: str = "ok", count: int | None = None) -> None: """Append one event line. Extra kwargs are silently dropped so a future caller's accidental `esecret=...` cannot leak the value.""" - rec: dict = {"ts": int(time.time()), "action": action} + rec: dict = {"ts": int(time.time()), "action": action[:64], "outcome": outcome[:64]} if tool_id: - rec["tool_id"] = tool_id + rec["tool_id"] = tool_id[:128] if secret_name: - rec["secret_name"] = secret_name - with open(self._path, "a", encoding="utf-8") as f: - f.write(json.dumps(rec) + "\n") - f.flush() + rec["secret_name"] = secret_name[:128] + if count is not None: + rec["count"] = max(0, min(int(count), 2**31 - 1)) + payload = (json.dumps(rec) + "\n").encode("utf-8") + if len(payload) > self._max_bytes: + raise ValueError("audit event exceeds retention limit") + with self._lock: + validate(str(self._path) + ".1") + fd = open_private(self._path, os.O_WRONLY | os.O_APPEND | os.O_CREAT) + try: + if os.fstat(fd).st_size + len(payload) > self._max_bytes: + validate(self._path) + validate(str(self._path) + ".1") + os.replace(self._path, str(self._path) + ".1") + os.close(fd) + fd = -1 + fd = open_private(self._path, os.O_WRONLY | os.O_APPEND | os.O_CREAT) + except BaseException: + if fd != -1: + os.close(fd) + raise + with os.fdopen(fd, "ab") as f: + f.write(payload) + f.flush() + os.fsync(f.fileno()) diff --git a/sps/cli.py b/sps/cli.py index 9e00bc4..2419615 100644 --- a/sps/cli.py +++ b/sps/cli.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse +import json import os import secrets import subprocess @@ -23,6 +24,29 @@ from .store import ToolRegistrationStore +def init_runtime_paths(config: str | Path, *, state_dir: str | Path | None = None, + audit_log: str | Path | None = None) -> tuple[Path, Path]: + """Pure bootstrap path selection: (vault file, audit log). + + Production config separates read-only configuration from mutable state; + arbitrary config locations stay self-contained unless explicitly overridden. + """ + config = Path(config) + production = config == Path("/etc/toolstack/sps.env") + state = Path(state_dir) if state_dir is not None else ( + Path("/var/lib/toolstack") if production else config.parent) + audit_path = Path(audit_log) if audit_log is not None else ( + Path("/var/log/toolstack/sps.audit") if production else config.parent / "sps-audit.jsonl") + return state / "sps.vault.json", audit_path + + +def _mkdir_private(path: Path) -> None: + """Create missing directories privately; never chmod shared existing parents.""" + if not path.exists(): + _mkdir_private(path.parent) + path.mkdir(mode=0o700, exist_ok=True) + + # Where SPS writes its boot identity so long-running tools can detect a restart # and re-register. Overridable for tests (TOOLSTACK_SPS_BOOT_ID_PATH). _DEFAULT_BOOT_ID_PATH = ( @@ -39,14 +63,8 @@ def _write_boot_id(path: Path, boot_id: str | None = None) -> str: when it changes.""" if boot_id is None: boot_id = secrets.token_hex(16) - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(boot_id + "\n", encoding="utf-8") - try: - os.chmod(tmp, 0o644) - except OSError: - pass # best-effort; not critical for the watchdog - os.replace(tmp, path) + from .paths import atomic_write + atomic_write(path, (boot_id + "\n").encode("utf-8"), public=True) return boot_id @@ -66,10 +84,11 @@ def maybe_generate_tls_material(cert_path: str, key_path: str, ca_path: str, sub "-days", "365", "-subj", subj, "-addext", "subjectAltName=IP:127.0.0.1,DNS:localhost", ] - subprocess.run(cmd, check=True, capture_output=True) + subprocess.run(cmd, check=True, capture_output=True, umask=0o077) # Copy the cert as the CA bundle (self-signed == own CA in dev). if not os.path.exists(ca_path): - with open(cert_path, "rb") as f, open(ca_path, "wb") as g: + fd = os.open(ca_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with open(cert_path, "rb") as f, os.fdopen(fd, "wb") as g: g.write(f.read()) return True @@ -78,6 +97,11 @@ def _cmd_serve(args) -> None: """Run the SPS server. Mode-0600 re-check fails closed.""" cfg_path = args.config cfg = cfgmod.load_config(cfg_path) # raises ConfigModeError or FileNotFoundError + import ssl as _ssl + if not cfg.sp_tls_cert or not cfg.sp_tls_key: + raise ValueError("SPS requires TLS certificate and key") + ssl_ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(certfile=cfg.sp_tls_cert, keyfile=cfg.sp_tls_key) plugin = plugin_loader.load_plugin(cfg) plugin.connect() audit_log_path = cfg.sp_audit_log or "/var/log/toolstack/sps.audit" @@ -90,11 +114,6 @@ def _cmd_serve(args) -> None: plugin=plugin, boot_id=boot_id, ) - import ssl as _ssl - ssl_ctx = None - if cfg.sp_tls_cert and cfg.sp_tls_key and os.path.exists(cfg.sp_tls_cert) and os.path.exists(cfg.sp_tls_key): - ssl_ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER) - ssl_ctx.load_cert_chain(certfile=cfg.sp_tls_cert, keyfile=cfg.sp_tls_key) server = build_server(ctx, host=cfg.sp_host, port=cfg.sp_port, ssl_ctx=ssl_ctx) # Announce a fresh boot_id so the toolyard watchdog (admin side) can detect # this restart and re-register every running tool. Without this, SPS's @@ -153,7 +172,8 @@ def _cmd_init_vault(args) -> None: passphrase = os.environ.get("SP_VAULT_PASSPHRASE") file_path = os.environ.get("SP_VAULT_PASSPHRASE_FILE") if file_path: - passphrase = Path(file_path).read_text().strip() + from .paths import read_private + passphrase = read_private(file_path).strip() elif not passphrase and sys.stdin.isatty(): passphrase = getpass.getpass("vault passphrase: ") elif not passphrase: @@ -178,13 +198,18 @@ def _cmd_init(args) -> None: """Generate a starter sps.env + cert/key/CA. Idempotent on the env file.""" import shutil cfg_path = args.config - if os.path.exists(cfg_path): + if os.path.lexists(cfg_path): print(f"{cfg_path}: already exists; not overwriting", file=sys.stderr) sys.exit(2) if shutil.which("openssl") is None: print("openssl is required for `sps init`", file=sys.stderr) sys.exit(3) base_dir = os.path.dirname(cfg_path) or "." + vault_file, audit_log = init_runtime_paths( + cfg_path, state_dir=getattr(args, "state_dir", None), + audit_log=getattr(args, "audit_log", None)) + for directory in (Path(base_dir), vault_file.parent, audit_log.parent): + _mkdir_private(directory) cert = os.path.join(base_dir, "sps.crt") key = os.path.join(base_dir, "sps.key") ca = os.path.join(base_dir, "sps-ca.crt") @@ -193,26 +218,23 @@ def _cmd_init(args) -> None: # SP_SECRET: 32 random bytes -> 64 hex chars (way above the 32-char lower # bound). SP_PLUGIN defaults to localfile for the dev path. secret = os.urandom(32).hex() - # Derive runtime paths from --config's directory so a test init --config /tmp/x.env - # never writes to the production /var/lib/toolstack or /var/log/toolstack paths. - # base_dir above is already this; the audit log + localfile vault file live next - # to sps.env so the env file is fully self-contained. - audit_log = os.path.join(base_dir, "sps-audit.jsonl") - vault_file = os.path.join(base_dir, "sps.vault.json") content = ( 'SP_HOST = "127.0.0.1"\n' 'SP_PORT = "8743"\n' - f'SP_TLS_CERT = "{cert}"\n' - f'SP_TLS_KEY = "{key}"\n' - f'SP_TLS_CA = "{ca}"\n' + f'SP_TLS_CERT = {json.dumps(cert)}\n' + f'SP_TLS_KEY = {json.dumps(key)}\n' + f'SP_TLS_CA = {json.dumps(ca)}\n' f'SP_SECRET = "{secret}"\n' - f'SP_AUDIT_LOG = "{audit_log}"\n' + f'SP_AUDIT_LOG = {json.dumps(str(audit_log))}\n' 'SP_PLUGIN = "localfile"\n' '\n' '[localfile]\n' - f'VAULT_FILE = "{vault_file}"\n' + f'VAULT_FILE = {json.dumps(str(vault_file))}\n' ) - with open(cfg_path, "w") as f: + # Exclusive creation prevents a concurrent init (or dangling symlink) from + # overwriting existing configuration. Sensitive bytes are private at birth. + fd = os.open(cfg_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: f.write(content) os.chmod(cfg_path, 0o600) # When the chosen plugin is localfile, `vault-set` / `vault-get` need the actual @@ -273,6 +295,8 @@ def main() -> None: sub = parser.add_subparsers(dest="cmd", required=True) p_init = sub.add_parser("init", help="write a starter sps.env (cert + key + CA)") p_init.add_argument("--config", default="/etc/toolstack/sps.env") + p_init.add_argument("--state-dir", help="directory for sps.vault.json (default: /var/lib/toolstack for production config, otherwise beside config)") + p_init.add_argument("--audit-log", help="audit log file (default: /var/log/toolstack/sps.audit for production config, otherwise beside config)") p_init.set_defaults(func=_cmd_init) p_iv = sub.add_parser("init-vault", help="create the localfile vault file (separate from init)") p_iv.add_argument("--config", default="/etc/toolstack/sps.env") diff --git a/sps/client.py b/sps/client.py index 2bd43f1..0e65630 100644 --- a/sps/client.py +++ b/sps/client.py @@ -11,6 +11,8 @@ import ssl import threading from typing import Iterable +from .config import strict_bool +from .transport import Budget, valid_timeout, _Socket, MAX_RESPONSE, connect_socket class SPSError(RuntimeError): @@ -37,6 +39,10 @@ class BadResponse(SPSError): """The response envelope was malformed (Bad request or otherwise).""" +class TransportError(SPSError, OSError): + """Socket/TLS failure; also OSError-compatible for existing callers.""" + + # Map each fixed error message to the typed exception class. _ERROR_MAP = { "Unauthorized": AuthError, @@ -47,27 +53,39 @@ class BadResponse(SPSError): } +def _default_context(**kwargs): + try: + return ssl.create_default_context(**kwargs) + except OSError as exc: + raise TransportError(type(exc).__name__) from None + + class SPSClient: def __init__(self, host: str, port: int, *, sp_secret: str | None = None, esecret: str | None = None, ca_file: str | None = None, - verify: bool = True, timeout: float = 15.0) -> None: + verify: bool = True, timeout: float = 15.0, + max_response_bytes: int = MAX_RESPONSE) -> None: self.host = host self.port = port self.sp_secret = sp_secret self.esecret = esecret - self._timeout = timeout + self._timeout = valid_timeout(timeout) + if type(max_response_bytes) is not int or not 1024 <= max_response_bytes <= 32 * 1024 * 1024: + raise ValueError("invalid response limit") + self._max_response_bytes = max_response_bytes + verify = strict_bool(verify) # Build the client TLS context. If `verify` is False (dev with # self-signed cert), accept any cert. Otherwise require `ca_file`. self._ctx: ssl.SSLContext | None if not verify: - ctx = ssl.create_default_context() + ctx = _default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE self._ctx = ctx elif ca_file: - self._ctx = ssl.create_default_context(cafile=ca_file) + self._ctx = _default_context(cafile=ca_file) else: - self._ctx = ssl.create_default_context() # system trust store + self._ctx = _default_context() # system trust store self._lock = threading.Lock() # ---- runner-facing (SP) ----------------------------------------------- @@ -86,16 +104,29 @@ def unregister(self, tool_id: str) -> None: # ---- tool-facing (ES) ----------------------------------------------- def get_secrets(self, tool_id: str) -> dict: - return self._call({"op": "get_secrets", + body = self._call({"op": "get_secrets", "toolid": tool_id, "esecret": self._require("es")}) + self._validate_secrets(body) + return body def get_secret(self, tool_id: str, name: str) -> str: body = self._call({"op": "get_secret", "toolid": tool_id, "name": name, "esecret": self._require("es")}) - return body.get("secrets", {}).get(name, "") + self._validate_secrets(body) + if name not in body["secrets"]: + raise BadResponse("missing requested secret") + return body["secrets"][name] + + @staticmethod + def _validate_secrets(body): + if (not isinstance(body, dict) or body.get("status") != "ok" or + not isinstance(body.get("secrets"), dict) or + any(not isinstance(k, str) or not isinstance(v, str) + for k, v in body["secrets"].items())): + raise BadResponse("invalid secret response") def write_secret(self, tool_id: str, name: str, value: str) -> None: self._call({"op": "write_secret", @@ -120,24 +151,43 @@ def _call(self, msg: dict) -> dict: # Serialise: one connection at a time per client instance. This is # not a multi-thread client; the wire spec is one-message-per-socket # and per-process usages are short-lived. - with self._lock: + budget = Budget(self._timeout, self._max_response_bytes) + if not self._lock.acquire(timeout=budget.remaining()): + raise TransportError("SPS operation deadline exceeded") + try: payload = (json.dumps(msg) + "\n").encode("utf-8") - with socket.create_connection((self.host, self.port), timeout=self._timeout) as raw_sock: - assert self._ctx is not None - with self._ctx.wrap_socket(raw_sock, server_hostname=self.host) as s: - s.sendall(payload) - f = s.makefile("rb") - line = f.readline() - if not line: - raise SPSError("server closed connection without a response") try: - resp = json.loads(line.decode("utf-8").rstrip("\n")) - except json.JSONDecodeError as exc: - raise SPSError(f"server returned invalid JSON: {exc}") + with connect_socket(self.host, self.port, budget) as raw_sock: + budget.remaining() + assert self._ctx is not None + raw_sock.settimeout(budget.idle()) + with self._ctx.wrap_socket(raw_sock, server_hostname=self.host, + do_handshake_on_connect=False) as s: + s.settimeout(budget.idle()) + s.do_handshake() + budget.remaining() + bounded = _Socket(s, budget) + bounded.sendall(payload) + with bounded.makefile("rb") as f: + line = f.readline(self._max_response_bytes + 1) + budget.count(len(line)) + if not line.endswith(b"\n"): + raise ValueError("invalid response framing") + resp = json.loads(line.decode("utf-8")) + except OSError as exc: + raise TransportError(type(exc).__name__) from None + except ValueError: + raise BadResponse("invalid response framing or JSON") from None + if not isinstance(resp, dict): + raise BadResponse("invalid response envelope") status = resp.get("status") if status == "ok": return resp if status == "error": - msg_str = resp.get("message", "Bad request") - raise _ERROR_MAP.get(msg_str, SPSError)(msg_str) - raise SPSError(f"server returned unknown envelope: {resp}") + msg_str = resp.get("message") + if not isinstance(msg_str, str) or msg_str not in _ERROR_MAP: + raise BadResponse("invalid error response") + raise _ERROR_MAP[msg_str](msg_str) + raise BadResponse("invalid response envelope") + finally: + self._lock.release() diff --git a/sps/config.py b/sps/config.py index 86cca7e..e0dbbf0 100644 --- a/sps/config.py +++ b/sps/config.py @@ -12,6 +12,43 @@ import tomllib from dataclasses import dataclass from pathlib import Path +from urllib.parse import urlsplit +import ipaddress + + +def strict_bool(value: bool | str) -> bool: + """Shared TLS verification parser. Invalid values never disable verification.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("1", "true", "yes", "on"): + return True + if normalized in ("0", "false", "no", "off"): + return False + raise ValueError("expected a strict boolean") + + +def parse_bool(value: bool | str, *, name: str = "boolean") -> bool: + try: + return strict_bool(value) + except ValueError as exc: + raise ValueError(f"{name}: expected a strict boolean") from exc + + +def validate_provider_url(value: str) -> str: + parsed = urlsplit(value) + host = parsed.hostname + local = host == "localhost" + try: + local = local or ipaddress.ip_address(host or "").is_loopback + except ValueError: + pass + if (not host or parsed.username or parsed.password or parsed.query or + parsed.fragment or parsed.scheme not in ("https", "http") or + (parsed.scheme == "http" and not local)): + raise ValueError("provider URL requires HTTPS (HTTP allowed only on loopback)") + return value.rstrip("/") class ConfigModeError(RuntimeError): diff --git a/sps/handlers.py b/sps/handlers.py index 0db61c4..661fc3d 100644 --- a/sps/handlers.py +++ b/sps/handlers.py @@ -90,7 +90,7 @@ def check_sp(ctx: HandlerContext, msg: dict[str, Any]) -> None: raise AuthError(f"{_SP_PREFIX} mismatch") -def check_es(ctx: HandlerContext, tool_id: str, msg: dict[str, Any]) -> None: +def check_es(ctx: HandlerContext, tool_id: str, msg: dict[str, Any]) -> Registration: """Verify the E_SECRET matches the registered tool. Raises AuthError / ToolNotFound on any failure.""" val = msg.get(_ES_PREFIX) @@ -101,6 +101,7 @@ def check_es(ctx: HandlerContext, tool_id: str, msg: dict[str, Any]) -> None: raise ToolNotFound(tool_id) if not constant_time_eq(val, rec.esecret): raise AuthError(f"{_ES_PREFIX} mismatch") + return rec # ---- helpers ---------------------------------------------------------------- @@ -111,6 +112,7 @@ def parse_cs_tuples(entries: Any) -> tuple[dict, ...]: if not isinstance(entries, list): raise BadRegistration("secrets must be a JSON array") out: list[dict] = [] + names = set() for e in entries: if not isinstance(e, dict): raise BadRegistration("each secret entry must be an object") @@ -118,10 +120,13 @@ def parse_cs_tuples(entries: Any) -> tuple[dict, ...]: field = e.get("field") if not isinstance(name, str) or not name: raise BadRegistration("secret name must be a non-empty string") + if name in names: + raise BadRegistration("duplicate secret name") + names.add(name) if not isinstance(field, str) or not field: raise BadRegistration("secret field must be a non-empty string") item = e.get("item") - if item is not None and not isinstance(item, str): + if item is not None and (not isinstance(item, str) or not item): raise BadRegistration("secret item must be a string or null") writable = e.get("writable", False) if not isinstance(writable, bool): @@ -146,11 +151,10 @@ def handle_register(ctx: HandlerContext, msg: dict[str, Any]) -> dict[str, Any]: check_sp(ctx, msg) tool_id = _field(msg, "toolid") esecret = msg.get("esecret") - if not isinstance(esecret, str) or not _HEX_RE.match(esecret) or len(esecret) < 32: + if not isinstance(esecret, str) or not _HEX_RE.fullmatch(esecret) or len(esecret) < 32: raise BadRegistration("esecret must be a hex string of at least 32 chars") entries = parse_cs_tuples(msg.get("secrets")) ctx.store.register(tool_id, Registration(esecret=esecret, secret_entries=entries)) - ctx.audit.event("register", tool_id=tool_id) return {"status": "ok"} @@ -158,14 +162,21 @@ def handle_unregister(ctx: HandlerContext, msg: dict[str, Any]) -> dict[str, Any check_sp(ctx, msg) tool_id = _field(msg, "toolid") ctx.store.unregister(tool_id) - ctx.audit.event("unregister", tool_id=tool_id) return {"status": "ok"} def handle_get_secrets(ctx: HandlerContext, msg: dict[str, Any]) -> dict[str, Any]: + from .transport import _active, Budget + token = _active.set(Budget(getattr(ctx.plugin, "timeout", 15.0))) + try: + return _get_secrets_bounded(ctx, msg) + finally: + _active.reset(token) + + +def _get_secrets_bounded(ctx: HandlerContext, msg: dict[str, Any]) -> dict[str, Any]: tool_id = _field(msg, "toolid") - check_es(ctx, tool_id, msg) - rec = ctx.store.get(tool_id) + rec = check_es(ctx, tool_id, msg) if rec is None: # belt-and-suspenders; check_es already raised raise ToolNotFound(tool_id) body: dict[str, str] = {} @@ -182,8 +193,7 @@ def handle_get_secrets(ctx: HandlerContext, msg: dict[str, Any]) -> dict[str, An def handle_get_secret(ctx: HandlerContext, msg: dict[str, Any]) -> dict[str, Any]: tool_id = _field(msg, "toolid") name = _field(msg, "name") - check_es(ctx, tool_id, msg) - rec = ctx.store.get(tool_id) + rec = check_es(ctx, tool_id, msg) if rec is None: raise ToolNotFound(tool_id) match = next((e for e in rec.secret_entries if e.get("name") == name), None) @@ -200,8 +210,7 @@ def handle_get_secret(ctx: HandlerContext, msg: dict[str, Any]) -> dict[str, Any def handle_write_secret(ctx: HandlerContext, msg: dict[str, Any]) -> dict[str, Any]: tool_id = _field(msg, "toolid") name = _field(msg, "name") - check_es(ctx, tool_id, msg) - rec = ctx.store.get(tool_id) + rec = check_es(ctx, tool_id, msg) if rec is None: raise ToolNotFound(tool_id) match = next((e for e in rec.secret_entries if e.get("name") == name), None) diff --git a/sps/paths.py b/sps/paths.py new file mode 100644 index 0000000..b7b6bca --- /dev/null +++ b/sps/paths.py @@ -0,0 +1,84 @@ +"""Private SPS state paths on a trusted local filesystem. + +No protection against a hostile same-account process changing directories. +Existing unsafe objects are refused, never repaired by chmod. +""" +import os +import stat +import tempfile +from pathlib import Path + + +def private_parent(path): + parent = Path(os.path.abspath(path)).parent + current = Path(parent.anchor) + for component in parent.parts[1:]: + current /= component + try: + current.mkdir(mode=0o700) + except FileExistsError: + pass + info = current.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid not in (0, os.getuid()): + raise PermissionError("unsafe SPS directory") + sticky_root = info.st_uid == 0 and info.st_mode & stat.S_ISVTX + if info.st_mode & 0o022 and not sticky_root: + raise PermissionError("unsafe SPS ancestor") + info = parent.lstat() + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise PermissionError("unsafe SPS parent") + return parent + + +def check_file(info, *, public=False): + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or + info.st_uid != os.getuid() or info.st_mode & (0o022 if public else 0o077)): + raise PermissionError("unsafe SPS file") + + +def validate(path, *, public=False): + private_parent(path) + try: + info = Path(path).lstat() + except FileNotFoundError: + return + check_file(info, public=public) + + +def open_private(path, flags, *, public=False): + validate(path, public=public) + fd = os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600) + try: + check_file(os.fstat(fd), public=public) + except BaseException: + os.close(fd) + raise + return fd + + +def read_private(path): + with os.fdopen(open_private(path, os.O_RDONLY), "r", encoding="utf-8") as source: + return source.read() + + +def atomic_write(path, data, *, public=False): + path = Path(path) + validate(path, public=public) + fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "wb") as output: + if public: + os.fchmod(output.fileno(), 0o644) + output.write(data) + output.flush() + os.fsync(output.fileno()) + validate(path, public=public) + os.replace(tmp, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(tmp): + os.unlink(tmp) diff --git a/sps/plugins/hashicorp_vault.py b/sps/plugins/hashicorp_vault.py index 689de0c..1e6cff5 100644 --- a/sps/plugins/hashicorp_vault.py +++ b/sps/plugins/hashicorp_vault.py @@ -3,27 +3,29 @@ Stdlib HTTP only. Auth via `X-Vault-Token`. The mount is configurable (default `secret`); path lookup is `/v1//data/`. -Writeback uses POST (KV-v2 accepts POST for create or patch with a full -data object). +Writeback reads the complete item, preserves siblings and POSTs with the +observed KV-v2 version as compare-and-set. Conflicts fail without replay. """ from __future__ import annotations import json import urllib.error import urllib.request +from urllib.parse import quote, unquote -from ..config import VaultBlock +from ..config import VaultBlock, validate_provider_url from .base import SPSSecretsPlugin +from .. import transport class HashicorpVaultPlugin(SPSSecretsPlugin): def __init__(self, block: VaultBlock, *, timeout: float = 15.0) -> None: if not block.url or not block.token: raise ValueError("HashicorpVaultPlugin requires url/token") - self.base = block.url.rstrip("/") + self.base = validate_provider_url(block.url) self.token = block.token self.mount = block.mount.strip("/") - self.timeout = timeout + self.timeout = transport.valid_timeout(timeout) def connect(self): return self # no pre-connect; X-Vault-Token rides every request @@ -32,21 +34,43 @@ def _headers(self) -> dict: return {"X-Vault-Token": self.token, "Accept": "application/json"} def _path(self, item: str) -> str: - return f"{self.base}/v1/{self.mount}/data/{item.strip('/')}" + def encode(path): + parts = path.split("/") + if any(not p or unquote(p) in (".", "..") or + any(c in unquote(p) for c in "?#/\\") for p in parts): + raise ValueError("unsafe Vault path") + return "/".join(quote(p, safe="") for p in parts) + return f"{self.base}/v1/{encode(self.mount)}/data/{encode(item)}" + @transport.operation def get_secret(self, field: str, item: str) -> str: url = self._path(item) req = urllib.request.Request(url, headers=self._headers(), method="GET") - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - body = json.loads(resp.read().decode("utf-8")) + with transport.urlopen(req, timeout=transport._active.get().idle()) as resp: + body = json.loads(transport.read_response(resp).decode("utf-8")) data = (body.get("data") or {}).get("data") or {} if field not in data: raise KeyError(f"Vault {url} has no field {field!r}") return str(data[field]) + @transport.operation def write_secret(self, field: str, item: str, value: str) -> None: url = self._path(item) - payload = {"data": {field: value}} + read = urllib.request.Request(url, headers=self._headers(), method="GET") + try: + with transport.urlopen(read, timeout=transport._active.get().idle()) as resp: + current = json.loads(transport.read_response(resp).decode("utf-8"))["data"] + data = dict(current["data"]) + version = current["metadata"]["version"] + if type(version) is not int or version < 1: + raise ValueError("invalid Vault version") + except urllib.error.HTTPError as exc: + exc.close() + if exc.code != 404: + raise + data, version = {}, 0 + data[field] = value + payload = {"data": data, "options": {"cas": version}} req = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), @@ -54,8 +78,8 @@ def write_secret(self, field: str, item: str, value: str) -> None: method="POST", ) try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - resp.read() + with transport.urlopen(req, timeout=transport._active.get().idle()) as resp: + transport.read_response(resp) except urllib.error.HTTPError as exc: exc.close() raise RuntimeError(f"Vault write {url}: HTTP {exc.code}") from exc diff --git a/sps/plugins/infisical.py b/sps/plugins/infisical.py index 11f1dbc..42342c3 100644 --- a/sps/plugins/infisical.py +++ b/sps/plugins/infisical.py @@ -15,30 +15,33 @@ import urllib.parse import urllib.request -from ..config import InfisicalBlock +from ..config import InfisicalBlock, validate_provider_url from .base import SPSSecretsPlugin +from .. import transport class InfisicalPlugin(SPSSecretsPlugin): def __init__(self, block: InfisicalBlock, *, timeout: float = 15.0) -> None: if not block.host or not block.client_id or not block.client_secret: raise ValueError("InfisicalPlugin requires host/client_id/client_secret") - self.host = block.host.rstrip("/") + self.host = validate_provider_url(block.host) self.vault = block.vault self.environment = block.environment self.organization_slug = block.organization_slug self.client_id = block.client_id self.client_secret = block.client_secret - self.timeout = timeout + self.timeout = transport.valid_timeout(timeout) self._token: tuple[str, float] | None = None self._project_id: str | None = None + @transport.operation def connect(self): self._access_token() # pre-warm so first get_secret doesn't pay login cost self._ensure_project_id() return self # ---- ABC ---------------------------------------------------------------- + @transport.operation def get_secret(self, field: str, item: str) -> str: path = "/" + (item or self.vault).strip("/") if (item or self.vault) else "/" params = { @@ -48,18 +51,20 @@ def get_secret(self, field: str, item: str) -> str: "viewSecretValue": "true", "expandSecretReferences": "true", "includeImports": "true", + "type": "shared", } - body = self._http("GET", "/api/v4/secrets", params=params) - for secret in self._iter(body): - if secret.get("secretKey") == field: - value = secret.get("secretValue") - if not isinstance(value, str): - raise ValueError( - f"Infisical {self.vault}{path}/{field} has no string value" - ) - return value - raise KeyError(f"Infisical secret {self.vault}{path}/{field} not found") + # Official v4 getSecretByNameV4 contract (see handoff evidence). + # References/import lookup stay server-side; never list the folder. + body = self._http("GET", f"/api/v4/secrets/{urllib.parse.quote(field, safe='')}", params=params) + secret = body.get("secret") if isinstance(body, dict) else None + if not isinstance(secret, dict) or secret.get("secretKey") != field: + raise KeyError("Infisical narrow response did not match requested secret") + value = secret.get("secretValue") + if not isinstance(value, str) or secret.get("secretValueHidden") is True: + raise ValueError("Infisical narrow response has no readable string value") + return value + @transport.operation def write_secret(self, field: str, item: str, value: str) -> None: path = "/" + (item or self.vault).strip("/") if (item or self.vault) else "/" body = { @@ -98,7 +103,12 @@ def _ensure_project_id(self) -> str: if self._project_id: return self._project_id body = self._http("GET", "/api/v1/projects") - for p in body.get("projects") or []: + projects = [p for p in body.get("projects") or [] if isinstance(p, dict)] + exact = [p for p in projects if self.vault in (p.get("id"), p.get("_id"))] + matches = exact or [p for p in projects if self.vault in (p.get("name"), p.get("slug"))] + if len(matches) > 1: + raise ValueError("ambiguous Infisical project selector") + for p in matches: if not isinstance(p, dict): continue ids = {p.get(k) for k in ("id", "_id", "name", "slug")} @@ -110,19 +120,6 @@ def _ensure_project_id(self) -> str: return pid raise KeyError(f"Infisical project {self.vault!r} not found") - @staticmethod - def _iter(body): - if not isinstance(body, dict): - return - for s in body.get("secrets") or []: - if isinstance(s, dict): - yield s - for imp in body.get("imports") or []: - if isinstance(imp, dict): - for s in imp.get("secrets") or []: - if isinstance(s, dict): - yield s - def _http(self, method, path, *, params=None, json_body=None, authless=False): url = self.host + path if params: @@ -138,8 +135,8 @@ def _http(self, method, path, *, params=None, json_body=None, authless=False): transient: RuntimeError | None = None for attempt in range(3): try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - return json.loads(resp.read().decode("utf-8")) + with transport.urlopen(req, timeout=transport._active.get().idle()) as resp: + return json.loads(transport.read_response(resp).decode("utf-8")) except urllib.error.HTTPError as exc: code = exc.code exc.close() @@ -155,11 +152,16 @@ def _http(self, method, path, *, params=None, json_body=None, authless=False): f"Infisical {method} {path}: HTTP {code} (transient)" ) except urllib.error.URLError as exc: - transient = RuntimeError( - f"Infisical {method} {path}: {exc.reason} (network)" - ) + transient = RuntimeError("SPS provider network failure") + if method != "GET": + raise RuntimeError("SPS provider mutation outcome unknown") from None if attempt < 2: - time.sleep(0.25 * (2 ** attempt)) + budget = transport._active.get() + delay = 0.25 * (2 ** attempt) + if delay >= budget.remaining(): + raise TimeoutError("SPS operation deadline exceeded") + time.sleep(delay) + budget.remaining() raise transient or RuntimeError( f"Infisical {method} {path}: exhausted retries" ) diff --git a/sps/plugins/localfile.py b/sps/plugins/localfile.py index 7cd3007..d63df03 100644 --- a/sps/plugins/localfile.py +++ b/sps/plugins/localfile.py @@ -15,9 +15,14 @@ import hashlib import json import os +import tempfile +import fcntl +import threading +from contextlib import contextmanager from pathlib import Path from ..config import LocalFileBlock +from ..paths import atomic_write, open_private, private_parent, read_private, validate from .base import SPSSecretsPlugin @@ -52,15 +57,19 @@ def _vault_key(passphrase: str, salt: bytes, n: int = _SCRYPT_N, def _vault_passphrase() -> str: - """Read the passphrase from SP_VAULT_PASSPHRASE (or its _FILE variant).""" + """Read SP_VAULT_PASSPHRASE, or its owner-private _FILE variant. + + Files must satisfy SPS private-file and canonical private-parent checks. + Unsafe permissions or links are refused, never repaired. + """ direct = os.environ.get("SP_VAULT_PASSPHRASE") if direct: return direct path = os.environ.get("SP_VAULT_PASSPHRASE_FILE") if path: - value = Path(path).read_text(encoding="utf-8").strip() + value = read_private(path).strip() if not value: - raise ValueError(f"{path}: vault passphrase file is empty") + raise ValueError("vault passphrase file is empty") return value raise ValueError( "localfile SPS plugin needs SP_VAULT_PASSPHRASE or SP_VAULT_PASSPHRASE_FILE" @@ -79,13 +88,19 @@ def _write_vault(path: Path, salt: bytes, key: bytes, data: dict, "ciphertext": token.decode("ascii"), } blob = json.dumps(envelope, indent=2).encode("utf-8") - tmp = path.with_name(path.name + ".tmp") - fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + atomic_write(path, blob) + + +@contextmanager +def _file_lock(path): + # A stable sidecar inode, never the atomically replaced vault inode. + validate(path) + fd = open_private(str(path) + ".lock", os.O_RDWR | os.O_CREAT) try: - os.write(fd, blob) + fcntl.flock(fd, fcntl.LOCK_EX) + yield finally: os.close(fd) - os.replace(tmp, path) class _Vault: @@ -95,11 +110,13 @@ class _Vault: def __init__(self, path: str | Path, passphrase: str) -> None: self._path = Path(path) + self._mutex = threading.RLock() + validate(str(self._path) + ".lock") if not self._path.exists(): raise FileNotFoundError( f"no vault at {self._path}: create one with `python3 -m sps.cli init`" ) - envelope = json.loads(self._path.read_text(encoding="utf-8")) + envelope = json.loads(read_private(self._path)) if envelope.get("version") != _VAULT_VERSION: raise RuntimeError( f"vault {self._path}: unsupported version {envelope.get('version')!r} " @@ -126,30 +143,52 @@ def _decrypt(self, token: str) -> dict: def _persist(self) -> None: _write_vault(self._path, self._salt, self._key, self._data, self._params) + def _reload(self): + envelope = json.loads(read_private(self._path)) + if (base64.urlsafe_b64decode(envelope["salt"]) != self._salt or + envelope["version"] != _VAULT_VERSION or + tuple(int(envelope[k]) for k in ("n", "r", "p")) != self._params): + raise RuntimeError("vault encryption parameters changed; reconnect required") + self._data = self._decrypt(envelope["ciphertext"]) + def get(self, tool_id: str, field: str): - return self._data.get(tool_id, {}).get(field) + with self._mutex, _file_lock(self._path): + self._reload() + return self._data.get(tool_id, {}).get(field) def has(self, tool_id: str, field: str) -> bool: - return field in self._data.get(tool_id, {}) + return field in self.list_fields(tool_id) def set(self, tool_id: str, field: str, value: str) -> None: - self._data.setdefault(tool_id, {})[field] = value - self._persist() + with self._mutex, _file_lock(self._path): + self._reload() + previous = self._data + candidate = {key: dict(fields) for key, fields in previous.items()} + candidate.setdefault(tool_id, {})[field] = value + self._data = candidate + try: + self._persist() + except BaseException: + self._data = previous + raise def list_fields(self, tool_id: str) -> tuple[str, ...]: - return tuple(self._data.get(tool_id, {}).keys()) + with self._mutex, _file_lock(self._path): + self._reload() + return tuple(self._data.get(tool_id, {}).keys()) @classmethod def init(cls, path: str | Path, passphrase: str) -> "_Vault": if not passphrase: raise ValueError("vault passphrase must not be empty") p = Path(path) - if p.exists(): - raise FileExistsError(f"vault already exists at {p}") - p.parent.mkdir(parents=True, exist_ok=True) + private_parent(p) salt = os.urandom(16) params = (_SCRYPT_N, _SCRYPT_R, _SCRYPT_P) - _write_vault(p, salt, _vault_key(passphrase, salt, *params), {}, params) + with _file_lock(p): + if p.exists(): + raise FileExistsError(f"vault already exists at {p}") + _write_vault(p, salt, _vault_key(passphrase, salt, *params), {}, params) return cls(p, passphrase) diff --git a/sps/server.py b/sps/server.py index 719be98..5beff91 100644 --- a/sps/server.py +++ b/sps/server.py @@ -6,10 +6,9 @@ multiplexing, or binary framing -- JSON-line framing is the entire message boundary. -The stream handler reads at most 1 MiB through -`socket.makefile().readline()`, and oversized requests are rejected with a -`Bad request` error envelope + close. Per-connection timeout enforced via -`socket.settimeout`. +The stream handler reads at most 1 MiB with an absolute body deadline. +Oversized requests are rejected with a `Bad request` error envelope + close. +TLS negotiation is admitted to a bounded worker pool, never run on accept. """ from __future__ import annotations @@ -17,6 +16,9 @@ import socket import socketserver import ssl +import threading +import hashlib +import time from dataclasses import dataclass from typing import Any @@ -26,12 +28,14 @@ from .store import ToolRegistrationStore from .wire import ( OversizedBodyError, + MESSAGE_SET, err_envelope, - read_one_json, + read_socket_json, write_one_json, ) SOCKET_TIMEOUT = 5.0 # seconds per connection: a slow client cannot hold the server +BODY_TIMEOUT = 5.0 # absolute JSON-line read budget, not reset by dribbled bytes @dataclass @@ -68,71 +72,146 @@ def setup(self) -> None: pass def handle(self) -> None: + msg = {} + op = "invalid" try: - msg = read_one_json(self.rfile) - except (OversizedBodyError, ValueError): - write_one_json(self.wfile, err_envelope("Bad request")) - return - - if not msg or "op" not in msg or not isinstance(msg.get("op"), str): - write_one_json(self.wfile, err_envelope("Bad request")) - return - - op = msg.get("op") - handler = _HANDLER_BY_OP.get(op) - if handler is None: - write_one_json(self.wfile, err_envelope("Bad request")) - return - - try: - response = handler(self.ctx, msg) + msg = read_socket_json(self.connection, timeout=BODY_TIMEOUT) + self.connection.settimeout(SOCKET_TIMEOUT) + if not isinstance(msg, dict) or not isinstance(msg.get("op"), str): + raise ValueError("invalid envelope") + op = msg["op"] if msg["op"] in _HANDLER_BY_OP else "invalid" + handler = _HANDLER_BY_OP.get(op) + if handler is None: + raise ValueError("unknown op") + try: + response = handler(self.ctx, msg) + except ValueError as exc: + raise handlers.BackendError("invalid backend result") from exc except handlers.AuthError: - write_one_json(self.wfile, err_envelope("Unauthorized")) - return - except handlers.BadRegistration: - write_one_json(self.wfile, err_envelope("Bad request")) - return - except handlers.ToolNotFound: - write_one_json(self.wfile, err_envelope("Not found")) - return - except handlers.SecretNotFound: - write_one_json(self.wfile, err_envelope("Not found")) - return + response = err_envelope("Unauthorized") + except (handlers.BadRegistration, ValueError): + response = err_envelope("Bad request") + except (handlers.ToolNotFound, handlers.SecretNotFound): + response = err_envelope("Not found") except handlers.NotWritable: - write_one_json(self.wfile, err_envelope("Not writable")) - return + response = err_envelope("Not writable") except handlers.BackendError: - write_one_json(self.wfile, err_envelope("Backend error")) - return + response = err_envelope("Backend error") + except (TimeoutError, OSError): + self.connection.settimeout(SOCKET_TIMEOUT) + response = err_envelope("Bad request") except Exception: - self.ctx.audit.event("internal_error") - write_one_json(self.wfile, err_envelope("Backend error")) - return - - write_one_json(self.wfile, response) - if op in ("get_secrets", "get_secret", "write_secret"): - self.ctx.audit.event( - op, - tool_id=msg.get("toolid", ""), - secret_name=msg.get("name", ""), - ) + response = err_envelope("Backend error") + finally: + # read_socket_json changes the timeout even on malformed input. + self.connection.settimeout(SOCKET_TIMEOUT) + # Fail closed on audit failure: do not release values or a success reply. + # A completed backend mutation cannot be rolled back; callers must not + # automatically replay after a lost/error response. + try: + def identity(key): + value = msg.get(key) if isinstance(msg, dict) else None + return ("sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest() + if isinstance(value, str) and value else "") + outcome = ("ok" if response.get("status") == "ok" else + response.get("message")) + if outcome not in MESSAGE_SET and outcome != "ok": + outcome = "Backend error" + self.ctx.audit.event(op, outcome=outcome, + tool_id=identity("toolid"), secret_name=identity("name")) + except Exception: + response = err_envelope("Backend error") + try: + write_one_json(self.wfile, response) + except OSError: + pass # disconnected peer: never emit request/exception tracebacks class ThreadingTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True + def __init__(self, *args, max_workers=32, **kwargs): + if max_workers < 1: + raise ValueError("max_workers must be positive") + self.request_queue_size = max(self.request_queue_size, max_workers) + self._slots = threading.BoundedSemaphore(max_workers) + self._reject_lock = threading.Lock() + self._rejects = {"admission": 0, "transport": 0} + self._last_reject_audit = float("-inf") + self.ssl_ctx = None + super().__init__(*args, **kwargs) + + def process_request(self, request, client_address): + if not self._slots.acquire(blocking=False): + self.shutdown_request(request) + self._record_reject("admission") + return + try: + super().process_request(request, client_address) + except BaseException: + self._slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + self._flush_rejects() + request.settimeout(SOCKET_TIMEOUT) + if self.ssl_ctx is not None: + request = self.ssl_ctx.wrap_socket(request, server_side=True, + do_handshake_on_connect=False) + request.do_handshake() + super().process_request_thread(request, client_address) + except (OSError, ssl.SSLError): + self._record_reject("transport") + self.shutdown_request(request) + finally: + self._flush_rejects() + self._slots.release() + + def _record_reject(self, kind): + # Constant-space saturating counters only: no disk I/O on accept. + with self._reject_lock: + self._rejects[kind] = min(self._rejects[kind] + 1, 2**31 - 1) + + def _flush_rejects(self): + # Best-effort operational summaries, at most two writes/second across + # all workers. Counts saturate and may be lost on failure/exit; these + # are not durable per-rejection application audit records. + with self._reject_lock: + now = time.monotonic() + if now - self._last_reject_audit < 1 or not any(self._rejects.values()): + return + counts = self._rejects.copy() + self._rejects = dict.fromkeys(counts, 0) + self._last_reject_audit = now + for kind, count in counts.items(): + if count: + try: + self.RequestHandlerClass.ctx.audit.event( + kind, outcome="rejected", count=count) + except Exception: + pass + + def handle_error(self, request, client_address): + # socketserver's default prints exception text/tracebacks. Nothing + # request-derived belongs on stderr, including disconnect failures. + pass + def build_server(ctx: AppContext, host: str = "127.0.0.1", port: int = 0, *, ssl_ctx: ssl.SSLContext | None = None, - server_class: type = ThreadingTCPServer): - """Build a TCP server bound to (host, port). Pass `ssl_ctx` for - production (server-side TLSContext wrapping SPS_TLS_CERT/KEY). For - in-process tests without TLS, omit `ssl_ctx`.""" + server_class: type = ThreadingTCPServer, + allow_plaintext_for_tests: bool = False): + """Build a bounded TCP server. TLS handshakes run inside admitted workers. + Plaintext requires the explicit allow_plaintext_for_tests opt-in.""" + if ssl_ctx is None and not allow_plaintext_for_tests: + raise ValueError("SPS requires TLS; plaintext is test-only") + if not issubclass(server_class, ThreadingTCPServer): + raise ValueError("SPS requires bounded handshake worker server") class _Bound(_StreamHandler): pass _Bound.ctx = ctx server = server_class((host, port), _Bound) - if ssl_ctx is not None: - server.socket = ssl_ctx.wrap_socket(server.socket, server_side=True) + server.ssl_ctx = ssl_ctx return server diff --git a/sps/store.py b/sps/store.py index f2eacc8..88fe91a 100644 --- a/sps/store.py +++ b/sps/store.py @@ -8,29 +8,41 @@ from __future__ import annotations from dataclasses import dataclass +from types import MappingProxyType +from threading import RLock +from typing import Mapping @dataclass(frozen=True) class Registration: """A registered tool's record: the E_SECRET plus the CS_TUPLE list.""" esecret: str - secret_entries: tuple[dict, ...] + secret_entries: tuple[Mapping, ...] + + def __post_init__(self): + object.__setattr__(self, "secret_entries", tuple( + MappingProxyType(dict(entry)) for entry in self.secret_entries)) class ToolRegistrationStore: def __init__(self) -> None: self._by_id: dict[str, Registration] = {} + self._lock = RLock() def register(self, tool_id: str, rec: Registration) -> None: """Insert or replace. Re-registration is the same call: the runner does this on every tool start (TOML secret values may have changed).""" - self._by_id[tool_id] = rec + with self._lock: + self._by_id[tool_id] = rec def unregister(self, tool_id: str) -> None: - self._by_id.pop(tool_id, None) + with self._lock: + self._by_id.pop(tool_id, None) def get(self, tool_id: str) -> Registration | None: - return self._by_id.get(tool_id) + with self._lock: + return self._by_id.get(tool_id) def ids(self) -> tuple[str, ...]: - return tuple(self._by_id.keys()) + with self._lock: + return tuple(self._by_id.keys()) diff --git a/sps/tests/test_adjudicated_defects.py b/sps/tests/test_adjudicated_defects.py new file mode 100644 index 0000000..251232f --- /dev/null +++ b/sps/tests/test_adjudicated_defects.py @@ -0,0 +1,161 @@ +"""Bounded offline regressions for lead-adjudicated SPS followup.""" +import io +import json +import os +from pathlib import Path +import socket +import ssl +import stat +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +from sps import handlers, server, wire +from sps.audit import AuditLogger +from sps.client import SPSClient, BadResponse, SPSError, TransportError +from sps.plugins.localfile import _Vault +from sps.store import Registration, ToolRegistrationStore + + +class Defects(unittest.TestCase): + def test_registration_validation_and_unicode_auth(self): + entry = {"name": "key", "field": "field"} + for entries in ([entry, entry], [{**entry, "item": ""}]): + with self.assertRaises(handlers.BadRegistration): + handlers.parse_cs_tuples(entries) + ctx = SimpleNamespace(config=SimpleNamespace(sp_secret="fake"), + store=ToolRegistrationStore()) + with self.assertRaises(handlers.BadRegistration): + handlers.handle_register(ctx, {"spsecret": "fake", "toolid": "tool", + "esecret": "a" * 32 + "\n", "secrets": []}) + ctx.store.register("tool", Registration("fake", ())) + for credential in ("é", "\ud800"): + with self.assertRaises(handlers.AuthError): + handlers.check_sp(ctx, {"spsecret": credential}) + with self.assertRaises(handlers.AuthError): + handlers.check_es(ctx, "tool", {"esecret": credential}) + + def test_wire_exact_byte_cap_and_required_newline(self): + limit = wire.MAX_BODY_BYTES + good = b'{}' + b' ' * (limit - 3) + b'\n' + for payload, error in ((good, None), (good[:-1] + b' \n', wire.OversizedBodyError), + (b'{}', ValueError), (good[:-1], ValueError), + (b'{}' + b' ' * (limit - 1), wire.OversizedBodyError)): + for reader in ("file", "socket"): + if reader == "file": + read = lambda: wire.read_one_json(io.BytesIO(payload)) + else: + sock = mock.Mock() + chunks = [payload[i:i+65536] for i in range(0, len(payload), 65536)] + sock.recv.side_effect = chunks + [b''] + read = lambda: wire.read_socket_json(sock, timeout=5) + if error: + with self.assertRaises(error): + read() + else: + self.assertEqual(read(), {}) + with self.assertRaises(wire.OversizedBodyError): + wire.read_one_json(io.StringIO('"' + 'é' * (limit // 2) + '"\n')) + + def test_client_secret_shape_and_empty_value(self): + client = SPSClient("localhost", 1, esecret="fake") + for body in ({}, [], {"status": "ok"}, {"status": "ok", "secrets": []}, + {"status": "ok", "secrets": {}}, + {"status": "ok", "secrets": {"key": 1}}): + with mock.patch.object(client, "_call", return_value=body): + with self.assertRaises(BadResponse): + client.get_secret("tool", "key") + with mock.patch.object(client, "_call", return_value={"status": "ok", "secrets": {"key": ""}}): + self.assertEqual(client.get_secret("tool", "key"), "") + + def test_client_fixed_transport_and_envelope_errors(self): + client = SPSClient("localhost", 1) + for failure in (ConnectionRefusedError("never-log"), ssl.SSLError("never-log"), TimeoutError("never-log")): + with mock.patch("sps.client.connect_socket", side_effect=failure): + with self.assertRaises(TransportError) as caught: + client.ping() + self.assertIsInstance(caught.exception, (SPSError, OSError)) + self.assertEqual(str(caught.exception), type(failure).__name__) + for payload in (b'[]\n', b'{"status":"error","message":"never-log"}\n', + b'{"status":"other","value":"never-log"}\n', b'\xff\n', b'{}'): + connection = mock.MagicMock() + connection.__enter__.return_value = connection + connection.makefile.return_value = io.BytesIO(payload) + with mock.patch("sps.client.connect_socket", return_value=connection), mock.patch.object(client._ctx, "wrap_socket", return_value=connection), mock.patch("sps.client._Socket", return_value=connection): + with self.assertRaises(BadResponse) as caught: + client.ping() + self.assertNotIn("never-log", str(caught.exception)) + + def test_admission_has_no_audit_io_and_summaries_rate_bound(self): + audit = mock.Mock() + handler = type("Handler", (), {"ctx": SimpleNamespace(audit=audit)}) + instance = server.ThreadingTCPServer(("127.0.0.1", 0), handler, max_workers=32) + self.addCleanup(instance.server_close) + self.assertGreaterEqual(instance.request_queue_size, 32) + for _ in range(32): + instance._slots.acquire() + with mock.patch.object(instance, "shutdown_request"): + for _ in range(100): + instance.process_request(mock.Mock(), ("127.0.0.1", 0)) + audit.event.assert_not_called() + instance._record_reject("transport") + with mock.patch("sps.server.time.monotonic", return_value=1): + instance._flush_rejects() + instance._record_reject("transport") + instance._flush_rejects() + self.assertEqual(audit.event.call_count, 2) + audit.event.assert_any_call("admission", outcome="rejected", count=100) + audit.event.assert_any_call("transport", outcome="rejected", count=1) + with mock.patch("sps.server.time.monotonic", return_value=2), mock.patch.object(audit, "event", side_effect=OSError("fake")): + instance._flush_rejects() # operational failure is best effort + + def test_reply_timeout_outcome_and_disconnect_sanitization(self): + for failure in (ValueError("fake"), wire.OversizedBodyError("fake"), None): + handler = object.__new__(server._StreamHandler) + handler.connection = mock.Mock() + handler.wfile = mock.Mock() + handler.ctx = SimpleNamespace(audit=mock.Mock(), boot_id="fake") + with mock.patch("sps.server.read_socket_json", side_effect=failure, return_value={"op": "ping"}), mock.patch.dict(server._HANDLER_BY_OP, {"ping": lambda *_: {"status": "ok", "message": "never-log"}}), mock.patch("sps.server.write_one_json", side_effect=BrokenPipeError("never-log")): + handler.handle() + self.assertEqual(handler.connection.settimeout.call_args.args, (server.SOCKET_TIMEOUT,)) + self.assertEqual(handler.ctx.audit.event.call_args.kwargs["outcome"], "ok" if failure is None else "Bad request") + with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: + server.ThreadingTCPServer.handle_error(None, None, None) + self.assertEqual(stderr.getvalue(), "") + + def test_audit_rotation_is_bounded_private_and_failure_propagates(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "audit" + audit = AuditLogger(path, max_bytes=4096) + for _ in range(200): + audit.event("ping", tool_id="x" * 128) + self.assertEqual(set(p.name for p in Path(temp).iterdir()), {"audit", "audit.1"}) + for file in Path(temp).iterdir(): + self.assertLessEqual(file.stat().st_size, 4096) + self.assertEqual(file.stat().st_mode & 0o777, 0o600) + for line in file.read_text().splitlines(): + json.loads(line) + with mock.patch("sps.audit.os.fsync", side_effect=OSError("fake")): + with self.assertRaises(OSError): + audit.event("ping") + + def test_vault_parent_fsync_and_legacy_parameter_reload(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "vault" + vault = _Vault.init(path, "fake-pass") + envelope = json.loads(path.read_text()) + for key in ("n", "r", "p"): + envelope[key] = str(envelope[key]) + path.write_text(json.dumps(envelope)) + vault = _Vault(path, "fake-pass") + self.assertIsNone(vault.get("tool", "field")) + observed = [] + fsync = os.fsync + def sync(fd): + observed.append(stat.S_ISDIR(os.fstat(fd).st_mode)) + return fsync(fd) + with mock.patch("sps.plugins.localfile.os.fsync", side_effect=sync): + vault.set("tool", "field", "fake") + self.assertEqual(observed, [False, True]) + self.assertEqual(vault.get("tool", "field"), "fake") diff --git a/sps/tests/test_audit.py b/sps/tests/test_audit.py index b843d45..14cb6ed 100644 --- a/sps/tests/test_audit.py +++ b/sps/tests/test_audit.py @@ -36,7 +36,7 @@ def test_event_without_optional_fields_omits_them(self): with open(f.name) as fp: line = fp.read().strip() rec = json.loads(line) - self.assertEqual(rec, {"ts": rec["ts"], "action": "startup"}) + self.assertEqual(rec, {"ts": rec["ts"], "action": "startup", "outcome": "ok"}) self.assertNotIn("tool_id", rec) self.assertNotIn("secret_name", rec) @@ -75,4 +75,4 @@ def test_invalid_kwargs_are_silently_dropped(self): # extra fields; verify the produced line has nothing else. with open(f.name) as fp: rec = json.loads(fp.read().strip()) - self.assertEqual(set(rec.keys()), {"ts", "action"}) + self.assertEqual(set(rec.keys()), {"ts", "action", "outcome"}) diff --git a/sps/tests/test_cli.py b/sps/tests/test_cli.py index e1806af..a0afeac 100644 --- a/sps/tests/test_cli.py +++ b/sps/tests/test_cli.py @@ -7,6 +7,175 @@ from unittest import mock from sps.cli import maybe_generate_tls_material +from sps.cli import init_runtime_paths, main +from sps.config import load_config + + +class Bootstrap(unittest.TestCase): + def test_production_and_temporary_path_selection_is_pure(self): + # No filesystem operations: production paths must never be created here. + self.assertEqual(init_runtime_paths("/etc/toolstack/sps.env"), ( + Path("/var/lib/toolstack/sps.vault.json"), Path("/var/log/toolstack/sps.audit"))) + self.assertEqual(init_runtime_paths("/temporary/config/sps.env"), ( + Path("/temporary/config/sps.vault.json"), Path("/temporary/config/sps-audit.jsonl"))) + self.assertEqual(init_runtime_paths("local.env"), ( + Path("sps.vault.json"), Path("sps-audit.jsonl"))) + for config in ("/etc/toolstack/sps.env", "/temporary/config/sps.env"): + self.assertEqual(init_runtime_paths(config, state_dir="/explicit/state", audit_log="/explicit/log/audit"), ( + Path("/explicit/state/sps.vault.json"), Path("/explicit/log/audit"))) + self.assertEqual(init_runtime_paths("/etc/toolstack/sps.env", state_dir="/explicit/state")[1], + Path("/var/log/toolstack/sps.audit")) + self.assertEqual(init_runtime_paths("/temporary/config/sps.env", audit_log="/explicit/audit")[0], + Path("/temporary/config/sps.vault.json")) + + def _init(self, config, *options, passphrase=None): + # Never inherit credential environment; only fake test passphrases. + env = {} if passphrase is None else {"SP_VAULT_PASSPHRASE": passphrase} + def fake_tls(cert, key, ca, subject): + for path in (cert, key, ca): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as output: + output.write("fake TLS fixture") + import contextlib, io + with mock.patch.dict(os.environ, env, clear=True), mock.patch("shutil.which", return_value="fake-openssl"), \ + mock.patch("sps.cli.maybe_generate_tls_material", side_effect=fake_tls), \ + mock.patch("sys.argv", ["sps", "init", "--config", str(config), *map(str, options)]), \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + main() + + def test_explicit_paths_private_creation_and_kept_existing_vault(self): + from sps.plugins.localfile import _Vault + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + config = root / "config" / "sps.env" + state = root / "state" + audit = root / "logs" / "audit.jsonl" + state.mkdir(mode=0o700) + vault = _Vault.init(state / "sps.vault.json", "fake-pass") + vault.set("fake-tool", "field", "fake-value") + before = (state / "sps.vault.json").read_bytes() + self._init(config, "--state-dir", state, "--audit-log", audit, passphrase="fake-pass") + cfg = load_config(config) + self.assertEqual(cfg.localfile.vault_file, str(state / "sps.vault.json")) + self.assertEqual(cfg.sp_audit_log, str(audit)) + self.assertEqual((state / "sps.vault.json").read_bytes(), before) + for directory in (config.parent, state, audit.parent): + self.assertEqual(directory.stat().st_mode & 0o777, 0o700) + for file in (config, state / "sps.vault.json", config.parent / "sps.key"): + self.assertEqual(file.stat().st_mode & 0o777, 0o600) + config_before = config.read_bytes() + with self.assertRaises(SystemExit) as error: + self._init(config, "--state-dir", root / "unused") + self.assertEqual(error.exception.code, 2) + self.assertEqual(config.read_bytes(), config_before) + self.assertFalse((root / "unused").exists()) + + def test_temp_config_defaults_stay_adjacent_and_initialize_private_vault(self): + with tempfile.TemporaryDirectory() as temp: + config = Path(temp) / "nested" / 'quoted"config' / "sps.env" + self._init(config, passphrase="fake-pass") + cfg = load_config(config) + self.assertEqual(cfg.localfile.vault_file, str(config.parent / "sps.vault.json")) + self.assertEqual(cfg.sp_audit_log, str(config.parent / "sps-audit.jsonl")) + self.assertEqual(Path(cfg.localfile.vault_file).stat().st_mode & 0o777, 0o600) + self.assertEqual(config.parent.parent.stat().st_mode & 0o777, 0o700) + + def test_dangling_config_symlink_is_not_overwritten(self): + with tempfile.TemporaryDirectory() as temp: + config = Path(temp) / "sps.env" + target = Path(temp) / "missing" + config.symlink_to(target) + with self.assertRaises(SystemExit): + self._init(config) + self.assertTrue(config.is_symlink()) + self.assertFalse(target.exists()) + + +class InitVaultPassphrase(unittest.TestCase): + def setUp(self): + from types import SimpleNamespace + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.key = self.root / "fake-passphrase" + self.key.write_text(" fake-file-passphrase\n") + self.key.chmod(0o600) + self.vault = self.root / "fake-vault" + self.args = SimpleNamespace(config="fake-config", force=True) + self.cfg = SimpleNamespace(sp_plugin="localfile", + localfile=SimpleNamespace(vault_file=str(self.vault))) + + def invoke(self, env): + from sps.cli import _cmd_init_vault + with mock.patch.dict(os.environ, env, clear=True), \ + mock.patch("sps.cli.cfgmod.load_config", return_value=self.cfg): + _cmd_init_vault(self.args) + + def test_valid_file_wins_over_direct_environment(self): + # Stop after selection, before any vault creation or KDF. + with mock.patch("sps.plugins.localfile._Vault.init", side_effect=RuntimeError("selected")) as init: + with self.assertRaisesRegex(RuntimeError, "selected"): + self.invoke({"SP_VAULT_PASSPHRASE_FILE": str(self.key), + "SP_VAULT_PASSPHRASE": "fake-direct-passphrase"}) + init.assert_called_once_with(str(self.vault), passphrase="fake-file-passphrase") + + def test_unsafe_file_never_reads_or_changes_vault_even_with_force(self): + for kind in ("0644", "symlink", "hardlink", "fifo", "linked-parent", "missing", "empty"): + with self.subTest(kind=kind): + directory = self.root / kind + directory.mkdir(mode=0o700) + key = directory / "key" + if kind == "symlink": + key.symlink_to(self.key) + elif kind == "hardlink": + os.link(self.key, key) + elif kind == "fifo": + os.mkfifo(key, 0o600) + elif kind == "linked-parent": + directory.rmdir() + directory.symlink_to(self.root, target_is_directory=True) + key = directory / self.key.name + elif kind != "missing": + key.write_text("" if kind == "empty" else "fake-secret") + key.chmod(0o644 if kind == "0644" else 0o600) + before = key.lstat() if kind != "missing" else None + for existing in (False, True): + if existing: + self.vault.write_bytes(b"fake-existing-vault") + self.vault.chmod(0o600) + with mock.patch("sps.plugins.localfile._Vault.init") as init, \ + mock.patch("sps.paths.os.fdopen", wraps=os.fdopen) as reader: + with self.assertRaises((PermissionError, FileNotFoundError, SystemExit)) as error: + self.invoke({"SP_VAULT_PASSPHRASE_FILE": str(key), + "SP_VAULT_PASSPHRASE": "fake-direct"}) + init.assert_not_called() + if kind != "empty": + reader.assert_not_called() + else: + self.assertEqual(str(error.exception), + "vault passphrase not provided (set SP_VAULT_PASSPHRASE or stdin it)") + if existing: + self.assertEqual(self.vault.read_bytes(), b"fake-existing-vault") + self.assertEqual(self.vault.stat().st_mode & 0o777, 0o600) + self.vault.unlink() + else: + self.assertFalse(self.vault.exists()) + if before: + self.assertEqual(key.lstat().st_mode, before.st_mode) + if kind == "hardlink": + key.unlink() + + def test_direct_environment_and_interactive_fallbacks(self): + for env, tty, expected in (({"SP_VAULT_PASSPHRASE": "fake-direct"}, True, "fake-direct"), + ({}, True, "fake-prompt"), ({}, False, "fake-stdin")): + with self.subTest(env=env, tty=tty), \ + mock.patch("sys.stdin.isatty", return_value=tty), \ + mock.patch("sys.stdin.readline", return_value="fake-stdin\n"), \ + mock.patch("getpass.getpass", return_value="fake-prompt"), \ + mock.patch("sps.plugins.localfile._Vault.init", side_effect=RuntimeError("selected")) as init: + with self.assertRaisesRegex(RuntimeError, "selected"): + self.invoke(env) + init.assert_called_once_with(str(self.vault), passphrase=expected) class TlsMaterial(unittest.TestCase): diff --git a/sps/tests/test_client.py b/sps/tests/test_client.py index 09e78d9..4988c2d 100644 --- a/sps/tests/test_client.py +++ b/sps/tests/test_client.py @@ -56,7 +56,7 @@ def write_secret(self, field, item, value): class _FakeAudit: - def event(self, action, *, tool_id="", secret_name=""): + def event(self, action, *, tool_id="", secret_name="", outcome="ok"): pass diff --git a/sps/tests/test_plugin_hashicorp.py b/sps/tests/test_plugin_hashicorp.py index 6c341d8..83c7d96 100644 --- a/sps/tests/test_plugin_hashicorp.py +++ b/sps/tests/test_plugin_hashicorp.py @@ -11,8 +11,9 @@ class _StubResponse: def __init__(self, payload): self._b = json.dumps(payload).encode() - def read(self): - return self._b + def read(self, size=-1): + result, self._b = self._b[:size], self._b[size:] + return result def __enter__(self): return self @@ -30,7 +31,7 @@ class RoundTrip(unittest.TestCase): def test_get_secret_kv_v2(self): secrets = {"data": {"data": {"API_KEY": "V"}}} - with mock.patch("urllib.request.urlopen", return_value=_StubResponse(secrets)): + with mock.patch("sps.transport.urlopen", return_value=_StubResponse(secrets)): plugin = HashicorpVaultPlugin(self.BLOCK) plugin.connect() self.assertEqual(plugin.get_secret("API_KEY", "echo"), "V") @@ -39,15 +40,17 @@ def test_write_secret_kv_v2_posts_full_data_object(self): captured: list[tuple[str, str, bytes]] = [] def fake_urlopen(req, timeout=None): captured.append((req.method, req.full_url, req.data)) + if req.method == "GET": + return _StubResponse({"data": {"data": {"sibling": "preserve"}, "metadata": {"version": 7}}}) return _StubResponse({}) - with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + with mock.patch("sps.transport.urlopen", side_effect=fake_urlopen): plugin = HashicorpVaultPlugin(self.BLOCK) plugin.connect() plugin.write_secret("API_KEY", "echo", "V-new") - method, url, body = captured[0] + method, url, body = captured[1] self.assertEqual(method, "POST") self.assertIn("/v1/secret/data/echo", url) - self.assertEqual(json.loads(body.decode()), {"data": {"API_KEY": "V-new"}}) + self.assertEqual(json.loads(body.decode()), {"data": {"API_KEY": "V-new", "sibling": "preserve"}, "options": {"cas": 7}}) def test_requests_carry_x_vault_token(self): # urllib.request.Request normalizes header names via email-message style @@ -58,7 +61,7 @@ def test_requests_carry_x_vault_token(self): def fake_urlopen(req, timeout=None): captured.append(dict(req.headers)) return _StubResponse(secrets) - with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + with mock.patch("sps.transport.urlopen", side_effect=fake_urlopen): plugin = HashicorpVaultPlugin(self.BLOCK) plugin.connect() plugin.get_secret("API_KEY", "echo") @@ -68,7 +71,7 @@ def fake_urlopen(req, timeout=None): def test_missing_field_raises_keyerror(self): secrets = {"data": {"data": {}}} - with mock.patch("urllib.request.urlopen", return_value=_StubResponse(secrets)): + with mock.patch("sps.transport.urlopen", return_value=_StubResponse(secrets)): plugin = HashicorpVaultPlugin(self.BLOCK) plugin.connect() with self.assertRaises(KeyError): diff --git a/sps/tests/test_plugin_infisical.py b/sps/tests/test_plugin_infisical.py index 5b184bd..2f76733 100644 --- a/sps/tests/test_plugin_infisical.py +++ b/sps/tests/test_plugin_infisical.py @@ -1,6 +1,9 @@ """plugins/infisical: round-trip against mocked HTTP.""" import json import unittest +import urllib.parse +import urllib.error +import io from unittest import mock from sps.config import InfisicalBlock @@ -9,11 +12,11 @@ class _StubResponse: def __init__(self, payload, code=200): - self._payload = payload + self._stream = io.BytesIO(json.dumps(payload).encode()) self.code = code - def read(self): - return json.dumps(self._payload).encode() + def read(self, size=-1): + return self._stream.read(size) def __enter__(self): return self @@ -35,17 +38,29 @@ def test_get_secret(self): seq = iter([ {"accessToken": "T-1", "expiresIn": 600}, {"projects": [{"id": "P1", "name": "ToolServer"}]}, - {"secrets": [{"secretKey": "API_KEY", "secretValue": "V"}], "imports": []}, + # Official getSecretByNameV4 response projection, not a folder. + # https://infisical.com/docs/api-reference/endpoints/secrets/read.md + # OpenAPI 3.0.3, info.version 0.0.1; read 2026-10-24. + {"secret": {"secretKey": "API_KEY", "secretValue": "V"}}, ]) def fake_urlopen(req, timeout=None): + if req.method == "GET" and "/api/v4/" in req.full_url: + url = urllib.parse.urlsplit(req.full_url) + self.assertEqual(url.path, "/api/v4/secrets/API_KEY") + self.assertEqual(urllib.parse.parse_qs(url.query), { + "projectId": ["P1"], "environment": ["prod"], + "secretPath": ["/echo"], "viewSecretValue": ["true"], + "expandSecretReferences": ["true"], "includeImports": ["true"], + "type": ["shared"], + }) try: return _StubResponse(next(seq)) except StopIteration: self.fail("urlopen called too many times") plugin = InfisicalPlugin(self.BLOCK) - with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + with mock.patch("sps.transport.urlopen", side_effect=fake_urlopen): plugin.connect() value = plugin.get_secret("API_KEY", "echo") self.assertEqual(value, "V") @@ -67,7 +82,7 @@ def fake_urlopen(req, timeout=None): self.fail(f"unexpected extra urlopen call: {req.method} {req.full_url}") plugin = InfisicalPlugin(self.BLOCK) - with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + with mock.patch("sps.transport.urlopen", side_effect=fake_urlopen): plugin.connect() plugin.write_secret("API_KEY", "echo", "rotated") patch_body = bodies[-1] @@ -86,7 +101,31 @@ def fake_urlopen(req, timeout=None): return _StubResponse(next(seq)) plugin = InfisicalPlugin(self.BLOCK) - with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + with mock.patch("sps.transport.urlopen", side_effect=fake_urlopen): plugin.connect() with self.assertRaises(KeyError): plugin.get_secret("MISSING", "echo") + + def test_narrow_response_validation_and_no_list_fallback(self): + plugin = InfisicalPlugin(self.BLOCK) + plugin._project_id = "P1" + for response in ({"secrets": [{"secretKey": "KEY", "secretValue": "sibling"}]}, + {"secret": {"secretKey": "OTHER", "secretValue": "fake"}}, + {"secret": {"secretKey": "KEY", "secretValue": 42}}, + {"secret": {"secretKey": "KEY", "secretValue": "", "secretValueHidden": True}}): + with mock.patch.object(plugin, "_http", return_value=response) as call: + with self.assertRaises((KeyError, ValueError)): + plugin.get_secret("KEY", "folder") + self.assertEqual(call.call_count, 1) + self.assertEqual(call.call_args.args[:2], ("GET", "/api/v4/secrets/KEY")) + + def test_encoded_name_and_unsupported_endpoint_never_falls_back(self): + plugin = InfisicalPlugin(self.BLOCK) + plugin._project_id = "P1" + plugin._token = ("fake", float("inf")) + error = urllib.error.HTTPError("https://inf.example.com", 404, "unsupported", {}, io.BytesIO()) + with mock.patch("sps.transport.urlopen", side_effect=error) as call: + with self.assertRaises(RuntimeError): + plugin.get_secret("key/?#", "nested/folder") + self.assertEqual(call.call_count, 1) + self.assertIn("/api/v4/secrets/key%2F%3F%23?", call.call_args.args[0].full_url) diff --git a/sps/tests/test_plugin_localfile.py b/sps/tests/test_plugin_localfile.py index 120a7fb..312d557 100644 --- a/sps/tests/test_plugin_localfile.py +++ b/sps/tests/test_plugin_localfile.py @@ -3,10 +3,102 @@ import shutil import tempfile import unittest +from unittest.mock import patch from pathlib import Path from sps.config import LocalFileBlock -from sps.plugins.localfile import LocalFilePlugin, _Vault +from sps.plugins.localfile import LocalFilePlugin, _Vault, _vault_passphrase + + +class PassphraseFile(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()).resolve() + self.addCleanup(shutil.rmtree, self.tmp) + self.key = self.tmp / "fake.key" + self.key.write_text(" generated-fake-value\n", encoding="utf-8") + self.key.chmod(0o600) + + def read_key(self, path=None, direct=None): + env = {"SP_VAULT_PASSPHRASE_FILE": str(path or self.key)} + if direct is not None: + env["SP_VAULT_PASSPHRASE"] = direct + with patch.dict(os.environ, env, clear=True): + return _vault_passphrase() + + def test_private_file_and_empty_direct_fallback(self): + self.assertEqual(self.read_key(), "generated-fake-value") + self.assertEqual(self.read_key(direct=""), "generated-fake-value") + self.assertEqual(self.key.stat().st_mode & 0o777, 0o600) + + def test_direct_precedence_does_not_lookup_file(self): + with patch("sps.plugins.localfile.read_private") as reader: + self.assertEqual(self.read_key(direct=" direct value "), " direct value ") + reader.assert_not_called() + + def assert_refused_before_open(self, path): + # Proves no content read or FIFO open (which could otherwise hang). + with patch("sps.paths.os.open") as opener: + with self.assertRaises(PermissionError): + self.read_key(path) + opener.assert_not_called() + + def test_public_mode_refused_without_repair(self): + self.key.chmod(0o644) + self.assert_refused_before_open(self.key) + self.assertEqual(self.key.stat().st_mode & 0o777, 0o644) + + def test_symlink_refused(self): + link = self.tmp / "linked.key" + link.symlink_to(self.key) + self.assert_refused_before_open(link) + self.assertTrue(link.is_symlink()) + self.assertEqual(self.key.stat().st_mode & 0o777, 0o600) + + def test_hardlink_refused(self): + link = self.tmp / "hard.key" + os.link(self.key, link) + self.assert_refused_before_open(link) + self.assertEqual(self.key.stat().st_nlink, 2) + + def test_fifo_refused(self): + fifo = self.tmp / "fifo.key" + os.mkfifo(fifo, 0o600) + self.assert_refused_before_open(fifo) + + def test_foreign_owner_refused(self): + original = Path.lstat + + def fake_owner(path, *args, **kwargs): + info = original(path, *args, **kwargs) + if path == self.key: + fields = list(info) + fields[4] = os.getuid() + 1 + return os.stat_result(fields) + return info + + with patch.object(Path, "lstat", fake_owner): + self.assert_refused_before_open(self.key) + self.assertEqual(self.key.stat().st_uid, os.getuid()) + + def test_linked_parent_refused(self): + link = self.tmp / "linked-parent" + link.symlink_to(self.tmp, target_is_directory=True) + self.assert_refused_before_open(link / self.key.name) + + def test_nonprivate_parent_refused_without_repair(self): + self.tmp.chmod(0o755) + self.assert_refused_before_open(self.key) + self.assertEqual(self.tmp.stat().st_mode & 0o777, 0o755) + + def test_missing_file(self): + with self.assertRaises(FileNotFoundError): + self.read_key(self.tmp / "missing.key") + + def test_empty_file_generic_error(self): + self.key.write_text(" \n", encoding="utf-8") + with self.assertRaises(ValueError) as raised: + self.read_key() + self.assertEqual(str(raised.exception), "vault passphrase file is empty") class EncryptedVault(unittest.TestCase): diff --git a/sps/tests/test_register.py b/sps/tests/test_register.py index 5942102..a086cc5 100644 --- a/sps/tests/test_register.py +++ b/sps/tests/test_register.py @@ -63,7 +63,7 @@ def test_register_success(self): resp = handle_register(ctx, _reg_msg()) self.assertEqual(resp["status"], "ok") self.assertEqual(store.get("echo").esecret, "e" + "1" * 63) # type: ignore[union-attr] - self.assertIn(("register", "echo", ""), audit.events) + self.assertEqual(audit.events, []) # transport owns outcome auditing def test_register_requires_sp_auth(self): ctx = _ctx() @@ -122,7 +122,7 @@ def test_reregister_overwrites_and_audits(self): handle_register(ctx, _reg_msg(esecret="a" * 64)) handle_register(ctx, _reg_msg(esecret="b" * 64)) self.assertEqual(store.get("echo").esecret, "b" * 64) # type: ignore[union-attr] - self.assertEqual(sum(1 for e in audit.events if e[0] == "register"), 2) + self.assertEqual(audit.events, []) class Unregister(unittest.TestCase): @@ -134,4 +134,4 @@ def test_unregister_drops_and_audits(self): resp = handle_unregister(ctx, {"op": "unregister", "spsecret": "sp-1", "toolid": "echo"}) self.assertEqual(resp["status"], "ok") self.assertIsNone(store.get("echo")) - self.assertIn(("unregister", "echo", ""), audit.events) + self.assertEqual(audit.events, []) diff --git a/sps/tests/test_security_bounds.py b/sps/tests/test_security_bounds.py new file mode 100644 index 0000000..9279711 --- /dev/null +++ b/sps/tests/test_security_bounds.py @@ -0,0 +1,325 @@ +"""Bounded fake transports and direct-path refusal; no live providers.""" +import io +import json +import os +import socket +import socketserver +import ssl +import tempfile +import threading +import time +import unittest +import urllib.error +from pathlib import Path +from unittest import mock + +from sps import transport +from sps.audit import AuditLogger +from sps.cli import _write_boot_id +from sps.client import SPSClient, BadResponse, TransportError +from sps.config import VaultBlock, InfisicalBlock +from sps.paths import atomic_write, private_parent +from sps.plugins.localfile import _Vault, _file_lock +from sps.plugins.hashicorp_vault import HashicorpVaultPlugin +from sps.plugins.infisical import InfisicalPlugin + + +class SecurityBounds(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def listener(self, respond, tls=None): + class Handler(socketserver.BaseRequestHandler): + def handle(inner): + inner.request.settimeout(1) + try: + if tls is not None: + inner.request = tls.wrap_socket(inner.request, server_side=True) + inner.request.recv(65536) + respond(inner.request) + except OSError: + pass + finally: + inner.request.close() + server = socketserver.TCPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": .01}) + worker.start() + def close(): + server.shutdown() + server.server_close() + worker.join(2) + self.assertFalse(worker.is_alive()) + self.addCleanup(close) + return server.server_address + + def test_private_new_directories_under_umask_and_refusals(self): + old = os.umask(0o022) + try: + target = self.root / "one/two/state" + atomic_write(target, b"original") + finally: + os.umask(old) + for directory in (target.parent, target.parent.parent): + self.assertEqual(directory.stat().st_mode & 0o777, 0o700) + for mode in (0o755, 0o777): + target.parent.chmod(mode) + with self.assertRaises(PermissionError): + atomic_write(target, b"changed") + self.assertEqual(target.read_bytes(), b"original") + self.assertEqual(target.parent.stat().st_mode & 0o777, mode) + target.parent.chmod(0o700) + target.parent.parent.chmod(0o770) + with self.assertRaises(PermissionError): + private_parent(target) + + def test_audit_public_and_backup_refused_unchanged(self): + path = self.root / "audit" + path.write_bytes(b"original") + path.chmod(0o644) + with self.assertRaises(PermissionError): + AuditLogger(path) + self.assertEqual(path.read_bytes(), b"original") + self.assertEqual(path.stat().st_mode & 0o777, 0o644) + path.chmod(0o600) + logger = AuditLogger(path, max_bytes=1024) + backup = Path(str(path) + ".1") + backup.symlink_to(path) + with self.assertRaises(PermissionError): + logger.event("refused") + self.assertEqual(path.read_bytes(), b"original") + + def test_lock_aliases_and_foreign_files_refused_without_chmod(self): + path = self.root / "vault" + lock = Path(str(path) + ".lock") + victim = self.root / "victim" + victim.write_bytes(b"unchanged") + victim.chmod(0o644) + for hard in (False, True): + if hard: + os.link(victim, lock) + else: + lock.symlink_to(victim) + with self.assertRaises((PermissionError, OSError)): + with _file_lock(path): + self.fail("unsafe lock admitted") + lock.unlink() + self.assertEqual(victim.read_bytes(), b"unchanged") + self.assertEqual(victim.stat().st_mode & 0o777, 0o644) + from sps.paths import check_file + from types import SimpleNamespace + info = victim.stat() + with self.assertRaises(PermissionError): + check_file(SimpleNamespace(st_mode=info.st_mode, st_nlink=1, st_uid=os.getuid()+1)) + + def test_symlink_parent_vault_modes_and_failed_write(self): + alias = self.root / "alias" + alias.symlink_to(self.root, target_is_directory=True) + with self.assertRaises(PermissionError): + atomic_write(alias / "state", b"no") + vault = _Vault.init(self.root / "vault", "fake-passphrase") + before = vault._path.read_bytes() + vault._path.chmod(0o644) + with self.assertRaises(PermissionError): + vault.set("tool", "field", "new") + self.assertEqual(vault._path.read_bytes(), before) + vault._path.chmod(0o600) + with mock.patch("sps.paths.os.replace", side_effect=OSError("fake failure")): + with self.assertRaises(OSError): + vault.set("tool", "field", "new") + self.assertEqual(vault._path.read_bytes(), before) + self.assertEqual(list(self.root.glob("*.tmp")), []) + + def test_boot_fixed_temp_link_trap_is_not_touched(self): + victim = self.root / "victim" + victim.write_bytes(b"unchanged") + path = self.root / "boot" + trap = self.root / "boot.tmp" + trap.symlink_to(victim) + _write_boot_id(path, "fake-id") + self.assertEqual(victim.read_bytes(), b"unchanged") + self.assertTrue(trap.is_symlink()) + self.assertEqual(path.read_text(), "fake-id\n") + self.assertEqual(path.stat().st_mode & 0o777, 0o644) + + def test_provider_trickled_headers_and_body_then_recovery(self): + from sps.tests.test_client import _gen_self_signed, _tls_server_context + cert, key, ca = _gen_self_signed(self.root) + tls = _tls_server_context(cert, key) + verified = ssl.create_default_context(cafile=ca) + for secure in (False, True): + for headers in (True, False): + with self.subTest(secure=secure, headers=headers): + count = [0] + def respond(sock): + count[0] += 1 + if count[0] == 1: + if not headers: + sock.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 200\r\n\r\n") + for _ in range(100): + sock.sendall(b"x") + time.sleep(.01) + else: + body = b'{"data":{"data":{"key":""}}}' + sock.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body) + host, port = self.listener(respond, tls=tls if secure else None) + scheme = "https" if secure else "http" + plugin = HashicorpVaultPlugin(VaultBlock(url=f"{scheme}://{host}:{port}", token="fake", mount="secret"), timeout=.25) + readers = [] + original = transport._Reader.__init__ + def capture(reader, sock, budget): + original(reader, sock, budget) + readers.append(reader) + with mock.patch("sps.transport.ssl.create_default_context", return_value=verified), mock.patch.object(transport._Reader, "__init__", capture): + start = time.monotonic() + with self.assertRaises(RuntimeError): + plugin.get_secret("key", "item") + elapsed = time.monotonic()-start + self.assertGreaterEqual(elapsed, .18) + self.assertLess(elapsed, .9) + plugin.timeout = 1 + self.assertEqual(plugin.get_secret("key", "item"), "") + self.assertTrue(readers) + for reader in readers: + self.assertTrue(reader.closed) + self.assertTrue(reader._raw.closed) + self.assertEqual(reader.sock.fileno(), -1) + + def test_provider_delayed_healthy_bodies_http_and_verified_tls(self): + from sps.tests.test_client import _gen_self_signed, _tls_server_context + cert, key, ca = _gen_self_signed(self.root) + tls = _tls_server_context(cert, key) + verified = ssl.create_default_context(cafile=ca) + self.assertEqual(verified.verify_mode, ssl.CERT_REQUIRED) + self.assertTrue(verified.check_hostname) + for secure in (False, True): + for chunked in (False, True): + for value in ("fake-value", "v" * 40000): + with self.subTest(secure=secure, chunked=chunked, size=len(value)): + body = json.dumps({"data": {"data": {"key": value}}}).encode() + def respond(sock): + framing = (b"Transfer-Encoding: chunked" if chunked else + b"Content-Length: " + str(len(body)).encode()) + sock.sendall(b"HTTP/1.1 200 OK\r\n" + framing + b"\r\n\r\n") + time.sleep(.1) + for offset in range(0, len(body), 4093): + part = body[offset:offset+4093] + if chunked: + sock.sendall(f"{len(part):x}\r\n".encode() + part + b"\r\n") + else: + sock.sendall(part) + if chunked: + sock.sendall(b"0\r\n\r\n") + host, port = self.listener(respond, tls=tls if secure else None) + scheme = "https" if secure else "http" + plugin = HashicorpVaultPlugin(VaultBlock(url=f"{scheme}://{host}:{port}", token="fake", mount="secret"), timeout=2) + readers = [] + original = transport._Reader.__init__ + def capture(reader, sock, budget): + original(reader, sock, budget) + readers.append(reader) + with mock.patch("sps.transport.ssl.create_default_context", return_value=verified), mock.patch.object(transport._Reader, "__init__", capture): + start = time.monotonic() + self.assertEqual(plugin.get_secret("key", "item"), value) + self.assertGreaterEqual(time.monotonic()-start, .09) + self.assertEqual(len(readers), 1) + self.assertTrue(readers[0].closed) + self.assertTrue(readers[0]._raw.closed) + self.assertEqual(readers[0].sock.fileno(), -1) + + def test_late_dns_connection_closed_before_credentials(self): + host, port = self.listener(lambda sock: None) + original = socket.getaddrinfo + def late(*args, **kwargs): + time.sleep(.06) + return original(*args, **kwargs) + plugin = HashicorpVaultPlugin(VaultBlock(url=f"http://{host}:{port}", token="fake", mount="secret"), timeout=.02) + with mock.patch("socket.getaddrinfo", side_effect=late), mock.patch.object(transport._Socket, "sendall") as send: + with self.assertRaises(RuntimeError): + plugin.get_secret("key", "item") + send.assert_not_called() + client = SPSClient(host, port, timeout=.02) + with mock.patch("socket.getaddrinfo", side_effect=late), mock.patch.object(client._ctx, "wrap_socket") as tls: + with self.assertRaises(TransportError): + client.ping() + tls.assert_not_called() + + def test_provider_aggregate_cap_retry_budget_and_no_mutation_replay(self): + budget = transport.Budget(1) + token = transport._active.set(budget) + try: + transport.read_response(io.BytesIO(b"x" * (transport.MAX_RESPONSE - 1))) + with self.assertRaises(ValueError): + transport.read_response(io.BytesIO(b"xx")) + finally: + transport._active.reset(token) + plugin = InfisicalPlugin(InfisicalBlock(host="http://127.0.0.1", client_id="fake", client_secret="fake", vault="fake"), timeout=.1) + plugin._project_id = "fake" + plugin._token = ("fake", time.time()+600) + error = urllib.error.HTTPError("never-reflect", 503, "never-reflect", {}, io.BytesIO()) + with mock.patch("sps.transport.urlopen", side_effect=error) as call: + with self.assertRaises(RuntimeError): + plugin.get_secret("key", "item") + self.assertEqual(call.call_count, 1) + with mock.patch("sps.transport.urlopen", side_effect=urllib.error.URLError("never-reflect")) as call: + with self.assertRaises(RuntimeError) as caught: + plugin.write_secret("key", "item", "fake") + self.assertNotIn("never-reflect", str(caught.exception)) + self.assertEqual(call.call_count, 1) + + def test_client_response_cap_and_lock_budget(self): + client = SPSClient("127.0.0.1", 1, timeout=.03, max_response_bytes=1024) + client._lock.acquire() + try: + with self.assertRaises(TransportError): + client.ping() + finally: + client._lock.release() + for timeout in (0, -1, float("inf"), float("nan"), 301): + with self.assertRaises(ValueError): + SPSClient("localhost", 1, timeout=timeout) + for cap in (None, 1023, 32*1024*1024+1): + with self.assertRaises(ValueError): + SPSClient("localhost", 1, max_response_bytes=cap) + + def test_client_tls_oversize_and_trickle_recover(self): + from sps.tests.test_client import _gen_self_signed, _tls_server_context + cert, key, ca = _gen_self_signed(self.root) + tls = _tls_server_context(cert, key) + for oversize in (True, False): + count = [0] + def respond(sock): + count[0] += 1 + if count[0] == 1: + if oversize: + sock.sendall(b"x" * 1025) + else: + for _ in range(50): + sock.sendall(b" ") + time.sleep(.01) + else: + sock.sendall(b'{"status":"ok"}\n') + host, port = self.listener(respond, tls=tls) + client = SPSClient(host, port, ca_file=ca, timeout=.15, max_response_bytes=1024) + start = time.monotonic() + with self.assertRaises(BadResponse if oversize else TransportError): + client.ping() + elapsed = time.monotonic()-start + if not oversize: + self.assertGreaterEqual(elapsed, .10) + self.assertLess(elapsed, .8) + client._timeout = 1 + self.assertEqual(client.ping(), {"status": "ok"}) + + def test_provider_redirect_not_followed(self): + contacted = [] + host, port = self.listener(lambda sock: contacted.append(True)) + destination = f"http://{host}:{port}/trap" + def redirect(sock): + sock.sendall(b"HTTP/1.1 302 Found\r\nLocation: " + destination.encode() + b"\r\nContent-Length: 0\r\n\r\n") + origin, origin_port = self.listener(redirect) + plugin = HashicorpVaultPlugin(VaultBlock(url=f"http://{origin}:{origin_port}", token="fake", mount="secret")) + with self.assertRaises(RuntimeError): + plugin.get_secret("key", "item") + self.assertEqual(contacted, []) diff --git a/sps/tests/test_tool_sdk.py b/sps/tests/test_tool_sdk.py index d262f6d..100a50d 100644 --- a/sps/tests/test_tool_sdk.py +++ b/sps/tests/test_tool_sdk.py @@ -45,9 +45,7 @@ def test_refresh_one(self): "status": "ok", "secrets": {"a": "1", "b": "2"}, } c = SecretClient.from_env("echo") - cli.return_value.get_secret.return_value = { - "status": "ok", "secrets": {"a": "1-new"}, - } + cli.return_value.get_secret.return_value = "1-new" c.refresh("a") self.assertEqual(c.get("a"), "1-new") self.assertEqual(c.get("b"), "2") diff --git a/sps/tests/test_uplift_security.py b/sps/tests/test_uplift_security.py new file mode 100644 index 0000000..8d425fb --- /dev/null +++ b/sps/tests/test_uplift_security.py @@ -0,0 +1,247 @@ +"""Offline regressions: fake capabilities/providers and isolated local state.""" +import concurrent.futures +import io +import json +import multiprocessing +import os +from pathlib import Path +import socket +import tempfile +import threading +import time +from types import SimpleNamespace +import unittest +import urllib.error +from unittest import mock + +from sps import handlers +from sps.audit import AuditLogger +from sps.config import parse_bool, validate_provider_url, VaultBlock, InfisicalBlock +from sps.plugins.hashicorp_vault import HashicorpVaultPlugin +from sps.plugins.infisical import InfisicalPlugin +from sps.plugins.localfile import _Vault +from sps.server import build_server +from sps.server import ThreadingTCPServer +from sps.store import Registration, ToolRegistrationStore +from sps.wire import read_one_json + + +def _writer(path, field): + _Vault(path, "fake-pass").set("tool", field, field) + + +class Security(unittest.TestCase): + def test_absolute_body_deadline_defeats_dribble_and_audits_reject(self): + audit = mock.Mock() + ctx = SimpleNamespace(audit=audit, boot_id="fake") + server = build_server(ctx, allow_plaintext_for_tests=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + stop = threading.Event() + def dribble(conn): + try: + while not stop.wait(0.02): + conn.sendall(b" ") + except OSError: + pass + with mock.patch("sps.server.BODY_TIMEOUT", 0.2), mock.patch("sps.server.SOCKET_TIMEOUT", 2): + thread.start() + try: + with socket.create_connection(server.server_address, timeout=2) as conn: + sender = threading.Thread(target=dribble, args=(conn,)) + started = time.monotonic() + sender.start() + try: + response = read_one_json(conn.makefile("rb")) + self.assertEqual(response["message"], "Bad request") + self.assertLess(time.monotonic() - started, 1.5) + audit.event.assert_called_with("invalid", outcome="Bad request", tool_id="", secret_name="") + finally: + stop.set() + sender.join(2) + with socket.create_connection(server.server_address, timeout=2) as conn: + conn.sendall(b'{"op":"ping"}\n') + self.assertEqual(read_one_json(conn.makefile("rb"))["status"], "ok") + finally: + server.shutdown() + server.server_close() + thread.join(2) + + def test_missing_tls_fails_before_provider_connect(self): + from sps.cli import _cmd_serve + for cert, key in (("", ""), ("/nonexistent-fake-cert", "/nonexistent-fake-key")): + cfg = SimpleNamespace(sp_tls_cert=cert, sp_tls_key=key) + with mock.patch("sps.cli.cfgmod.load_config", return_value=cfg), mock.patch("sps.cli.plugin_loader.load_plugin") as load: + with self.assertRaises((ValueError, FileNotFoundError)): + _cmd_serve(SimpleNamespace(config="unused-fake-config")) + load.assert_not_called() + + def test_vault_cas_conflict_is_not_retried_or_blindly_overwritten(self): + from sps.tests.test_plugin_hashicorp import _StubResponse + conflict = urllib.error.HTTPError("https://example.com", 400, "CAS mismatch", {}, io.BytesIO()) + plugin = HashicorpVaultPlugin(VaultBlock("https://example.com", "fake")) + with mock.patch("sps.transport.urlopen", side_effect=[ + _StubResponse({"data": {"data": {"sibling": "fake"}, "metadata": {"version": 3}}}), conflict]) as call: + with self.assertRaises(RuntimeError): + plugin.write_secret("field", "item", "new-fake") + self.assertEqual(call.call_count, 2) + payload = json.loads(call.call_args.args[0].data) + self.assertEqual(payload["options"], {"cas": 3}) + self.assertEqual(payload["data"]["sibling"], "fake") + + def test_handshake_admission_and_timeout_release_slot(self): + from sps.tests.test_wire_end_to_end import _gen_self_signed, _tls_context + class OneWorker(ThreadingTCPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, max_workers=1, **kwargs) + with tempfile.TemporaryDirectory() as temp: + cert, key = _gen_self_signed(Path(temp)) + ctx = SimpleNamespace(audit=mock.Mock(), boot_id="fake") + server = build_server(ctx, ssl_ctx=_tls_context(cert, key), server_class=OneWorker) + admitted = threading.Event() + original = server.ssl_ctx.wrap_socket + def wrap(*args, **kwargs): + admitted.set() + return original(*args, **kwargs) + with mock.patch("sps.server.SOCKET_TIMEOUT", 0.3), mock.patch.object(server.ssl_ctx, "wrap_socket", side_effect=wrap): + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + slow = socket.create_connection(server.server_address) + try: + self.assertTrue(admitted.wait(2)) + with socket.create_connection(server.server_address, timeout=1) as excess: + self.assertEqual(excess.recv(1), b"") + # The listener is not doing this handshake: it accepted and + # rejected the excess peer while the sole worker was busy. + self.assertTrue(server._slots.acquire(timeout=2)) + server._slots.release() + self.assertGreater(ctx.audit.event.call_count, 0) + finally: + slow.close() + server.shutdown() + server.server_close() + thread.join() + + def test_boolean_and_provider_urls(self): + for value in (True, "1", "True", "YES", "on"): + self.assertTrue(parse_bool(value)) + for value in (False, "0", "False", "NO", "off"): + self.assertFalse(parse_bool(value)) + for value in (None, 1, "", "typo"): + with self.assertRaises(ValueError): + parse_bool(value) + for url in ("http://example.com", "ftp://localhost", "https://u:p@example.com", "https://example.com?q=x"): + with self.assertRaises(ValueError): + validate_provider_url(url) + for url in ("http://127.0.0.1", "http://[::1]", "https://example.com"): + self.assertEqual(validate_provider_url(url), url) + + def test_vault_paths(self): + plugin = HashicorpVaultPlugin(VaultBlock("https://example.com", "fake")) + for item in ("../other", "a/./b", "a?b", "a#b", "%2e%2e/a", "a//b", "a/%2f/b"): + with self.assertRaises(ValueError): + plugin._path(item) + self.assertTrue(plugin._path("a space/child").endswith("a%20space/child")) + + def test_authenticated_snapshot_is_used_and_deeply_frozen(self): + store = ToolRegistrationStore() + entry = {"name": "key", "field": "old", "item": "tool", "writable": False} + old = Registration("fake-old", (entry,)) + store.register("tool", old) + entry["field"] = "mutated" + with self.assertRaises(TypeError): + old.secret_entries[0]["field"] = "mutated" + original = store.get + def replacing(tool): + rec = original(tool) + store.register(tool, Registration("fake-new", ({**entry, "field": "new"},))) + return rec + plugin = mock.Mock() + plugin.get_secret.return_value = "fake-value" + with mock.patch.object(store, "get", side_effect=replacing) as get: + response = handlers.handle_get_secret(SimpleNamespace(store=store, plugin=plugin), + {"toolid": "tool", "esecret": "fake-old", "name": "key"}) + self.assertEqual(get.call_count, 1) + plugin.get_secret.assert_called_once_with("old", "tool") + self.assertEqual(response["secrets"]["key"], "fake-value") + + def test_infisical_ambiguous_names_and_exact_id(self): + plugin = InfisicalPlugin(InfisicalBlock("https://example.com", "target", client_id="fake", client_secret="fake")) + projects = {"projects": [{"id": "1", "name": "target"}, {"id": "2", "slug": "target"}]} + with mock.patch.object(plugin, "_http", return_value=projects): + with self.assertRaises(ValueError): + plugin._ensure_project_id() + projects["projects"].append({"id": "target", "name": "other"}) + with mock.patch.object(plugin, "_http", return_value=projects): + self.assertEqual(plugin._ensure_project_id(), "target") + duplicate = InfisicalPlugin(InfisicalBlock("https://example.com", "target", client_id="fake", client_secret="fake")) + with mock.patch.object(duplicate, "_http", return_value={"projects": [{"id": "target"}, {"_id": "target"}]}): + with self.assertRaises(ValueError): + duplicate._ensure_project_id() + + def test_localfile_concurrent_processes_and_rollback(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "vault" + first = _Vault.init(path, "fake-pass") + processes = [multiprocessing.get_context("spawn").Process(target=_writer, args=(path, str(i))) for i in range(4)] + for process in processes: + process.start() + for process in processes: + process.join(15) + self.assertEqual(process.exitcode, 0) + self.assertEqual(set(first.list_fields("tool")), {"0", "1", "2", "3"}) + with mock.patch("sps.plugins.localfile.os.replace", side_effect=OSError("fake failure")): + with self.assertRaises(OSError): + first.set("tool", "lost", "fake") + self.assertNotIn("lost", first._data["tool"]) + self.assertIsNone(first.get("tool", "lost")) + self.assertEqual(list(Path(temp).glob("*.tmp")), []) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + second = _Vault(path, "fake-pass") + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + list(pool.map(lambda i: (first if i % 2 else second).set("tool", f"thread-{i}", "fake"), range(8))) + self.assertEqual(len(first.list_fields("tool")), 12) + from sps.plugins import localfile + replace = localfile.os.replace + observed = [] + def check_private(src, dst): + observed.append(src) + self.assertEqual(os.stat(src).st_mode & 0o777, 0o600) + return replace(src, dst) + with mock.patch.object(localfile.os, "replace", side_effect=check_private): + first.set("tool", "check", "fake") + first.set("tool", "check", "fake2") + self.assertNotEqual(*observed) + + def test_rejects_and_audit_failure_before_reply(self): + with tempfile.TemporaryDirectory() as temp: + audit = AuditLogger(Path(temp) / "audit") + ctx = SimpleNamespace(audit=audit, boot_id="fake", + config=SimpleNamespace(sp_secret="fake-master"), + store=ToolRegistrationStore()) + with self.assertRaises(ValueError): + build_server(ctx) + server = build_server(ctx, allow_plaintext_for_tests=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + for body in (b"[]\n", b"not-json\n", b'{"op":"unknown","value":"never-log"}\n'): + with socket.create_connection(server.server_address) as conn: + conn.sendall(body) + self.assertEqual(read_one_json(conn.makefile("rb"))["message"], "Bad request") + records = Path(temp, "audit").read_text() + self.assertNotIn("never-log", records) + with socket.create_connection(server.server_address) as conn: + conn.sendall(b'{"op":"register","spsecret":"never-log","toolid":"fake-tool"}\n') + self.assertEqual(read_one_json(conn.makefile("rb"))["message"], "Unauthorized") + records = Path(temp, "audit").read_text() + self.assertIn("Unauthorized", records) + self.assertNotIn("never-log", records) + self.assertNotIn("fake-tool", records) + with mock.patch.object(audit, "event", side_effect=OSError("disk full")): + with socket.create_connection(server.server_address) as conn: + conn.sendall(b'{"op":"ping"}\n') + self.assertEqual(read_one_json(conn.makefile("rb"))["message"], "Backend error") + finally: + server.shutdown() + server.server_close() + thread.join() diff --git a/sps/tests/test_wire_end_to_end.py b/sps/tests/test_wire_end_to_end.py index a89bb54..63e644a 100644 --- a/sps/tests/test_wire_end_to_end.py +++ b/sps/tests/test_wire_end_to_end.py @@ -47,7 +47,7 @@ class _FakeAudit: def __init__(self): self.events: list[tuple[str, str, str]] = [] - def event(self, action, *, tool_id="", secret_name=""): + def event(self, action, *, tool_id="", secret_name="", outcome="ok"): self.events.append((action, tool_id, secret_name)) @@ -70,6 +70,17 @@ def _send(port: int, msg: dict) -> dict: class Wire(unittest.TestCase): + def test_sdk_refresh_real_wire_client_returns_string(self): + from sps.client import SPSClient + from sps.tool_sdk import SecretClient + registrar = SPSClient("127.0.0.1", self.port, sp_secret="sp-1", + ca_file=self.cert) + registrar.register("sdk", "a" * 64, [{"name": "key", "field": "API_KEY"}]) + sdk = SecretClient("sdk", host="127.0.0.1", port=self.port, + esecret="a" * 64, ca_file=self.cert) + self.assertEqual(sdk.refresh("key"), "V") + self.assertEqual(sdk.get("key"), "V") + def setUp(self): self.tmp = Path(tempfile.mkdtemp()) self.addCleanup(__import__("shutil").rmtree, self.tmp, ignore_errors=True) diff --git a/sps/tool_sdk.py b/sps/tool_sdk.py index e1f376b..7da23e5 100644 --- a/sps/tool_sdk.py +++ b/sps/tool_sdk.py @@ -20,6 +20,7 @@ from typing import Optional from sps.client import SPSClient +from sps.config import strict_bool _DEFAULT_HOST = "127.0.0.1" @@ -59,7 +60,7 @@ def from_env(cls, tool_id: str) -> "SecretClient": host = os.environ.get("TOOLSTACK_SPS_HOST", _DEFAULT_HOST) port = int(os.environ.get("TOOLSTACK_SPS_PORT", str(_DEFAULT_PORT))) ca_file = os.environ.get("TOOLSTACK_SPS_CA") - verify = os.environ.get("TOOLSTACK_SPS_VERIFY", "1") == "1" + verify = strict_bool(os.environ.get("TOOLSTACK_SPS_VERIFY", "1")) return cls(tool_id, host=host, port=port, esecret=esecret, ca_file=ca_file, verify=verify) @@ -75,8 +76,7 @@ def cache_get(self, name: str) -> Optional[str]: def refresh(self, name: str) -> str: """Force-refresh one secret from SPS and update the cache.""" - body = self._cli.get_secret(self._tool_id, name) - value = body.get("secrets", {}).get(name, "") + value = self._cli.get_secret(self._tool_id, name) self._cache[name] = value return value diff --git a/sps/transport.py b/sps/transport.py new file mode 100644 index 0000000..053902a --- /dev/null +++ b/sps/transport.py @@ -0,0 +1,188 @@ +"""Finite SPS socket budgets. DNS remains a synchronous OS operation. + +Late resolver/connect results are closed before credentials are transmitted. +No retries here: a failed mutation response has an unknown outcome. +""" +import contextvars +import functools +import http.client +import io +import math +import socket +import ssl +import time +import urllib.request + +MAX_RESPONSE = 8 * 1024 * 1024 +_active = contextvars.ContextVar("sps_budget", default=None) + + +def valid_timeout(value): + if isinstance(value, bool) or not math.isfinite(value) or not 0 < value <= 300: + raise ValueError("timeout must be finite and in (0, 300]") + return value + + +class Budget: + def __init__(self, timeout, cap=MAX_RESPONSE): + self.end = time.monotonic() + valid_timeout(timeout) + self.left = cap + + def remaining(self): + remaining = self.end - time.monotonic() + if remaining <= 0: + raise TimeoutError("SPS operation deadline exceeded") + return remaining + + def idle(self): + return min(5.0, self.remaining()) + + def count(self, size): + self.left -= size + if self.left < 0: + raise ValueError("SPS response exceeds limit") + + +def operation(fn): + @functools.wraps(fn) + def call(self, *args, **kwargs): + if _active.get() is not None: + return fn(self, *args, **kwargs) + token = _active.set(Budget(self.timeout)) + try: + return fn(self, *args, **kwargs) + except KeyError: + raise KeyError("SPS provider value unavailable") from None + except ValueError: + raise ValueError("SPS provider invalid response") from None + except Exception: + raise RuntimeError("SPS provider operation failed; mutation outcome may be unknown") from None + finally: + _active.reset(token) + return call + + +def connect_socket(host, port, budget): + budget.remaining() + addresses = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM) + budget.remaining() # OS resolver is not hard-preemptible. + for family, kind, proto, _, address in addresses: + raw = socket.socket(family, kind, proto) + try: + raw.settimeout(budget.idle()) + raw.connect(address) + budget.remaining() + return raw + except OSError: + raw.close() + budget.remaining() + raise OSError("SPS connection failed") + + +class _Reader(io.RawIOBase): + def __init__(self, sock, budget): + self.sock, self.budget = sock, budget + # Own an ordinary SocketIO reference, not merely a Python socket + # reference: urllib closes the connection socket after response headers. + # socket.makefile keeps the descriptor alive until this reader closes, + # for both TCP and SSLSocket (which does not support dup()). + self._raw = sock.makefile("rb", buffering=0) + + def readable(self): + return True + + def readinto(self, target): + self.sock.settimeout(self.budget.idle()) + size = self._raw.readinto(target) + self.budget.remaining() + return size + + def close(self): + try: + if hasattr(self, "_raw"): + self._raw.close() + finally: + super().close() + + +class _Socket: + def __init__(self, sock, budget): + self.sock, self.budget = sock, budget + + def __getattr__(self, name): + return getattr(self.sock, name) + + def makefile(self, *args, **kwargs): + reader = _Reader(self.sock, self.budget) + try: + return io.BufferedReader(reader) + except BaseException: + reader.close() + raise + + def sendall(self, data): + view = memoryview(data) + while view: + self.sock.settimeout(self.budget.idle()) + size = self.sock.send(view[:65536]) + self.budget.remaining() + if not size: + raise OSError("SPS connection closed") + view = view[size:] + + +class _HTTP(http.client.HTTPConnection): + def connect(self): + budget = _active.get() + raw = connect_socket(self.host, self.port, budget) + try: + budget.remaining() # DNS/connect cannot be preempted; refuse late result. + if isinstance(self, _HTTPS): + raw.settimeout(budget.idle()) + raw = self._context.wrap_socket(raw, server_hostname=self.host, + do_handshake_on_connect=False) + raw.settimeout(budget.idle()) + raw.do_handshake() + budget.remaining() + self.sock = _Socket(raw, budget) + except BaseException: + raw.close() + raise + + +class _HTTPS(_HTTP, http.client.HTTPSConnection): + pass + + +class _HTTPHandler(urllib.request.HTTPHandler): + def http_open(self, req): + return self.do_open(_HTTP, req) + + +class _HTTPSHandler(urllib.request.HTTPSHandler): + def https_open(self, req): + return self.do_open(_HTTPS, req, context=ssl.create_default_context()) + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def urlopen(req, timeout=None): + # Ignore ambient proxies: credentials go only to the configured origin. + return urllib.request.build_opener(urllib.request.ProxyHandler({}), + _HTTPHandler(), _HTTPSHandler(), _NoRedirect()).open(req, timeout=timeout) + + +def read_response(resp): + budget = _active.get() + chunks = [] + while True: + budget.remaining() + chunk = resp.read( min(65536, budget.left + 1)) + budget.remaining() + budget.count(len(chunk)) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) diff --git a/sps/wire.py b/sps/wire.py index 3fd066a..c391a2f 100644 --- a/sps/wire.py +++ b/sps/wire.py @@ -15,6 +15,8 @@ import hmac import json +import io +import time from typing import Any # 1 MiB -- the wire spec's explicit cap. @@ -38,7 +40,37 @@ class OversizedBodyError(ValueError): def constant_time_eq(a: str, b: str) -> bool: """Constant-time equality for auth secret comparisons.""" - return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8")) + try: + return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8")) + except UnicodeError: + return False + + +def read_socket_json(sock, *, timeout: float) -> dict[str, Any]: + """Read one capped JSON line with an absolute monotonic body deadline. + + Do not use buffered readline here: it can hide unlimited successful + partial reads underneath a socket inactivity timeout. The caller owns + the handshake deadline and restores its reply inactivity timeout. + """ + deadline = time.monotonic() + timeout + raw = bytearray() + while len(raw) <= MAX_BODY_BYTES: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("SPS body deadline exceeded") + sock.settimeout(remaining) + chunk = sock.recv(min(65536, READLINE_LIMIT - len(raw))) + if not chunk: + break + line, separator, _ = chunk.partition(b"\n") + raw.extend(line) + if separator: + raw.extend(b"\n") + break + if len(raw) > MAX_BODY_BYTES: + raise OversizedBodyError("SPS body too large") + return read_one_json(io.BytesIO(raw)) def read_one_json(infile) -> dict[str, Any]: @@ -47,10 +79,17 @@ def read_one_json(infile) -> dict[str, Any]: may use text). The wire format is UTF-8 bytes + `\\n` either way. Raises `OversizedBodyError` if the line exceeds MAX_BODY_BYTES; raises `ValueError` on invalid JSON or non-UTF-8 input. Returns `{}` on EOF. + The cap includes the required newline; nonempty EOF without newline is + invalid framing, even when the prefix is otherwise valid JSON. """ raw = infile.readline(READLINE_LIMIT) if not raw: return {} + size = len(raw) if isinstance(raw, bytes) else len(raw.encode("utf-8")) + if size > MAX_BODY_BYTES: + raise OversizedBodyError("SPS body too large") + if not raw.endswith(b"\n" if isinstance(raw, bytes) else "\n"): + raise ValueError("SPS requires newline framing") if isinstance(raw, bytes): if raw.strip() == b"": return {} @@ -86,28 +125,6 @@ def write_one_json(outfile, obj: dict) -> None: outfile.flush() -def read_one_json(infile) -> dict[str, Any]: - """Read one JSON line from `infile`. Raises `OversizedBodyError` if the - line exceeds MAX_BODY_BYTES; raises `ValueError` on invalid JSON. - Returns `{}` (sentinel) if the line is empty/EOF.""" - raw = infile.readline(READLINE_LIMIT) - if not raw: - return {} - if isinstance(raw, bytes): - if len(raw) > MAX_BODY_BYTES: - raise OversizedBodyError(f"body exceeds {MAX_BODY_BYTES} bytes") - try: - return json.loads(raw.decode("utf-8")) - except UnicodeDecodeError as exc: - raise ValueError(f"body not UTF-8: {exc}") from exc - if len(raw) > MAX_BODY_BYTES: - raise OversizedBodyError(f"body exceeds {MAX_BODY_BYTES} bytes") - try: - return json.loads(raw) - except json.JSONDecodeError as exc: - raise ValueError(f"body not valid JSON: {exc}") from exc - - def err_envelope(message: str) -> dict[str, Any]: if message not in MESSAGE_SET: raise ValueError(f"unknown error message {message!r}") diff --git a/tests/integration/BACKEND_NATIVE.md b/tests/integration/BACKEND_NATIVE.md new file mode 100644 index 0000000..1f68b7e --- /dev/null +++ b/tests/integration/BACKEND_NATIVE.md @@ -0,0 +1,29 @@ +# Authenticated backend → native HTTP proof + +From the repository root, using the existing offline admin Python environment: + +```sh +PYTHONPATH=. python tests/integration/backend_native_wire.py +``` + +Requires the installed Xcode toolchain and existing FastAPI/uvicorn/cryptography dependencies. +This is separate from the MacNod fixture in README.md. It compiles all current +`macapp/Sources/ToolstackKit/*.swift` directly, with Foundation and a private module +cache, then invokes the actual ApiClient against real ephemeral loopback uvicorn +and broker servers. No response JSON is mocked and no file transport is used. + +Fake credentials, manifests, SQLite, HOME and XDG state live in resolved temporary +scratch. Only the admin startup reconciler is patched; SPS is skipped. No tool +processes are started. Each native invocation has a 15-second timeout, compilation +120 seconds, startup/join five seconds. Server threads, sockets and stores are +closed in finally blocks; scratch is removed. Run in a disposable process because +the script intentionally changes its environment. + +Checks: anonymous listTools receives 401; real login; zero-operation same-snapshot +membership; verified empty/B snapshots exclude cached ID A; missing receipt, +malformed manifest, stale receipt and old live listener return unknown; external +removal decodes deleted=false and retains files while unregistering the source; +managed removal decodes deleted=true and removes the directory. Catalog malformed +source is requested directly because listTools correctly rejects that source. + +This is a backend/native client seam proof, not an AppKit, Keychain, Nod or UI run. diff --git a/tests/integration/INSTALLED_STACK.md b/tests/integration/INSTALLED_STACK.md new file mode 100644 index 0000000..1f814db --- /dev/null +++ b/tests/integration/INSTALLED_STACK.md @@ -0,0 +1,83 @@ +# Installed-wheel acceptance (parent-only runtime) + +The worker implemented and pure-tested this harness without launching its runtime. +Parent review and the sole actual run determine acceptance; unit success is not a +synthetic fullstack acceptance result. + +Parent must hash the retained `toolstack-0.1.0-py3-none-any.whl` in +`toolstack-release-artifacts-parent.PLguZR/artifacts/python/`, create a fresh venv, +and install that wheel plus `-r admin/requirements.txt -c admin/requirements-lock.txt`. +The expected SHA256 is below. No rebuild or source installation is appropriate. +The parent already prepared `toolstack-installed-stack-parent.cC4Qlj/venv`. + +After review, invoke once, under an independent 300-second outer bound: + +```sh +/bin/python -I -B /absolute/tests/integration/installed_stack.py \ + --root \ + --wheel-sha256 177b2ced1cd0500da68fd228284f50b74f2c941571ba0db8d606bc18617207c5 \ + --run single +``` + +Use canonical `/private/var/...`, not symlinked `/var/...`. Root must not exist. +The hash argument is a parent attestation, not verification of wheel bytes by the +helper. Six package origins must resolve within the fresh installed prefix: +admin, broker, client, sps, toolyard, toolstack_forwarder. All service commands +use the venv interpreter with `-I`, private cwd, constructed environment, and no +PYTHONPATH. No httpx dependency. Harness HTTP disables proxies and redirects. + +## Actual sequence + +* Owned fake Nod HTTP child binds literal loopback port 0; records bounded cards, + deduplicates by payload/key, and remains pending until a private control file + supplies a distinct fixture credential and matching opaque reference. +* Real SPS localfile vault and generated TLS; real ProcessRunner, state lock, + persisted tool state, independent SPS/channel credentials, installed SecretClient. +* Persisted caller/token hash and review/deny policy; real admin HTTP login, + configuration TTL save with external tool directories, admin-owned broker start. +* Real broker HTTP review and retry; exact approval options, digest/generation, + private-field omission, no preapproval effect. Explicit fake approval followed + by real broker poll executes the SDK tool and proves one effect/one card. +* Broker stop/reap via admin before admin restart. Restart recovers TTL, tool + directories, policy, live tool, token and completed receipt; denied action and + matching retry prove no replay. + +The fixture is **fake Nod**, not real signature verification, a real Nod release +test, human approval evidence, or a sandbox/security isolation proof. SPS restart +is deliberately omitted; required recovery is admin/broker persisted state. +Linux installer and systemd remain **UNTESTED**. No GUI, VM, host services, +keychain, trusted Nod, deployment installer, or production edits are involved. + +## Bounds, evidence and limits + +Runtime sequence has a 240-second alarm and a separate 30-second cleanup alarm. +HTTP reads have finite timeouts and 1 MiB bounds. Fixture card/effect counts are +bounded. Cleanup uses exact PID birth plus boot checks, never process patterns; +unknown identity refuses signals. Admin API stops broker first, with an exact-PID +fallback. Runner owns its tool cleanup (including its internal partial-start path). +Popen children are waited/reaped. Final PASS requires completed checks, positively +absent recorded PIDs and closed selected listeners. Any uncertain cleanup fails. +Parent must independently verify cleanup even after failure or outer timeout. + +Private 0600 evidence includes checks, origins/hashes, process identities, ports, +phase, outcome, and failure **type** only. Root and private fixtures/config/logs are +retained; those fixture files contain fake credentials and must not be published. +Only `evidence.json` and process metadata are intended as redacted receipts. +Loaded-package hashes describe the harness process, not child import tracing. + +Known seams for parent review: selected service ports have the usual bind-close +race; root ancestors are checked rather than opened descriptor-relatively. An +admin start response lost after spawning is reconciled from the private state, +exact parent PID, boot/birth and interpreter/module argv before any cleanup +adoption. A persisted intent precedes the sole start POST; it is never resent. +Missing/unknown state fails closed and retains admin for parent investigation; +it does not infer absence or orphan a possible broker by killing admin. An outer +hard kill can interrupt evidence writing/cleanup. No automatic +second acceptance attempt is authorized. Setup/provenance imports precede the +runtime alarm; the parent's 300-second bound covers the complete invocation. + +## Pure battery + +Run `test_installed_stack.py` with the existing Sdpb0R Python 3.11 runtime, +`env -i`, private HOME/TMPDIR, `PYTHONDONTWRITEBYTECODE=1`, `-I -B`. +Tests mock HTTP handler streams and process probes; no listeners or Popen. diff --git a/tests/integration/LIVE_UI.md b/tests/integration/LIVE_UI.md new file mode 100644 index 0000000..15dd43f --- /dev/null +++ b/tests/integration/LIVE_UI.md @@ -0,0 +1,114 @@ +# LIVE acceptance — KEEP OFF + +Code/setup only. Parent HIGH accepted the pin code/tests at 42e37b3 after independent +12 pure tests PASS (0.012s, toolstack-fixture-parent.4x7z39) and actual binary hash +match. Parent sends READY plus the unchanged eight plain steps to chief +`toolstack-live-ui-hands-20261024`; chief alone handles the principal's ONE card. +Never launch the fixture until chief confirms the principal is ready. No +fixture/app/window/listener has been started by this repair worker. + +The first admitted parent attempt stopped at COPY re-signing before native or +desktop launch: private HOME prevented lookup of the existing authorized identity. +Parent reports successful cleanup (children empty, backend stopped, no errors or +cleanup uncertainty; independently absent PID/listener and unchanged original). +This was not a principal rejection or a three-surface/manual result. No permission +prompt was observed by the agent, which had no screen-control tools; this does not +establish that no prompt ever appeared. The principal must report ANY prompt to +chief immediately: stop and clean up, with no bypass. Parent will test actual +private COPY signing and verification with apps OFF, then request new chief READY +confirmation. No automatic fixture/app/listener relaunch is authorized. + +`python tests/integration/live_ui_fixture.py --prepare` reports installed metadata +without importing GUI modules or creating files. Default and help are also inert. +Future admitted backend command: `python tests/integration/live_ui_fixture.py --serve +--root /private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/toolstack-live-ui-UNIQUE`. +Use a private interpreter with admin pinned requirements. Backend stops after 20 +minutes or Ctrl-C. It leaves its private root for evidence; no automatic deletion. +SPS_SKIP=1 is fake-fixture development mode, **not isolation**. Do not start broker, +tools, add real configuration, or invoke providers/Nod from this fixture. + +Future app launch requires explicit `--serve --native-app APP --sign-identity +'Apple Development: EXISTING IDENTITY'` and/or `--desktop`. Before later launching, the helper verifies strict +deep signature and executable SHA256 +`28cf9127f93d4ffd30fe7d91d120b862d4e2fc1c3a845b50210bd6a369b18600` for +`Contents/MacOS/ToolstackApp` in the current artifact: +`/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/toolstack-release-artifacts-parent.PLguZR/source/macapp/build/Toolstack Operator.app`. +This development-signed release-build artifact was built from production source +`78ea9049a3370688b721e7ff3bda31a8b3f184c3`. Parent reports signing with existing +Apple Development identity (name and team withheld), followed by a +separate strict deep verification PASS (valid on disk/designated requirement). +The executable hash was independently read and confirmed for this pin repair; +signature verification was not repeated here. Original artifact remains OFF, +unlaunched, uninstalled and not notarized. This test/doc-only repair changes no +production wheel/native bytes and does not claim a new signed build or a Linux +full-archive qualification of the repair commit. + +Parent's actual Linux fourth run at source 78 reported `proof_complete:true` +(onebox/API/MCP Docker Linux and 19 process SPS checks; admin HTTP 200), but the +overall run exited 1 with `restored:false`: three image-user observations remain +unknown, so images and the owned guest proof root were retained. Chief's matching +answer to `toolstack-linux-proof-cleanup-20261024` accepts the fourth Linux functional +witness (Docker/start, all 19 SPS checks, admin HTTP 200). Cleanup remains INCOMPLETE; +it is not itself a release gate, but must be reported before final acceptance. +VM130 stays STOPPED, 8192 MiB / 2 CPUs / onboot=0; production remains healthy. +Parent reports the ONE cleanup-only attempt c1ViDD finished exit 1 at unknown-root +preflight before any images, with removed=[]; all three images and the old root +remain retained. No retry or another boot is authorized. Independent parent checks +confirmed VM130 STOPPED, 8192 MiB / 2 CPUs / onboot=0 and production healthy. +Fixture stays OFF until +chief confirms principal readiness; neither hands nor cleanup PASS is claimed. +Scope remains 68 IDs / Plan 3 of 5 / 0 rejections / one-PR acceptance. + +The helper rejects external bundle links/special files, copies the verified original +under the owned root, changes only the COPY bundle identifier to unique +`com.toolstack.operator.fixture.`, signs locally with that same explicit +existing identity and `--timestamp=none`, then separately verifies. The original +artifact is unchanged; this is an ephemeral test copy, not a release deliverable. +It records the original hash, copy identifier/path and unique account URL. +Native URL includes `#toolstack-fixture-` (HTTP drops the fragment), passed +both through constructed environment and `-ToolstackAdminURL`. Isolated bundle +preferences and unique fake account avoid real preferences/account reads. +Never use open/AppleScript. Helper owns Popen handles and records PID/start +identity using `toolyard.runner._process_identity`; terminate/wait 5s/kill/wait 5s +only identity-matching owned children. For desktop it uses the private interpreter +`-m desktop` with plain fixture URL after bounded backend readiness. No real credential +environment may be inherited. Require a newly allocated fake URL; never enter an +existing real server URL. Only that URL's fake Keychain item may be created and +removed by signout/current API; never enumerate/export/delete other items. + +App/backend environments retain private HOME, XDG and TMP settings. Only +`/usr/bin/codesign` commands receive a copy of that allowlisted environment with +HOME set from `pwd.getpwuid(os.getuid()).pw_dir`, solely to locate the existing +authorized signing identity. No ambient environment, credentials or arbitrary +HOME/keychain override is inherited; native/desktop launch environments stay private. + +## Manual checklist (eight steps) + +1. After admission, record browser/webview plain fake URL and native unique fragment URL/isolated copy identifier; login on all three using admin / FAKE-live-ui-only-password; record versions and observed surface. +2. Tab/Shift-Tab every control: visible focus, logical order, no trap. Inspect actual web 38px/native 32px minimum hit geometry; do not infer full accessibility compliance. +3. Edit FAKE-policy draft, navigate/retry as supported, verify preservation and fresh-comparison unknown state. Record running-intent and stale-comparison cases not run when unavailable without production hooks. +4. Open confirmations; Escape and default Cancel must dismiss without mutation. Check policy/token/tool state before and after. +5. Rotate FAKE-rotate, revoke FAKE-revoke, remove fake-zero and fake-external: verify named target, confirmation and effects; external declaration removal must preserve its directory. +6. Manually toggle VoiceOver as needed; inspect names, roles, order, current state and announcements on all surfaces. Restore manual toggle; no persistent global setting changes. +7. Check native and webview focus transitions and titlebar Close; record window closure separately from process exit (native may retain menu process, then Quit owned process). Exercise startup Retry/Close only with parent-owned bounded failure setup; otherwise mark not run. +8. Sign out each surface, clean only URL-scoped fake login items, Quit owned apps, stop fixture and verify owned PID identities/listener quiescence; preserve manual results and remove only owned root when evidence is retained. + +## Observation record (fill manually, never synthesize) + +For each step and each surface: `surface | step | pass/fail/not-run | observed +behavior | evidence | PID/start identity | notes`. Initial state: every step NOT RUN. +No synthetic CGEvent/XCTest result counts as manual acceptance. +Runtime metadata verified without GUI imports using approved temp +`toolstack-live-ui-runtime.m7oo4s/venv/bin/python`: Python 3.11.17 (parent), +FastAPI 0.137.1, uvicorn 0.49.0, pywebview 6.2.1, Cocoa/WebKit/pyobjc-core 12.2.2. +Parent exact pins/logs are adjacent to that venv. Current gaps: admitted bounded +HTTP smoke, running-intent and startup-retry reproduction, +all live observations. Failed server thread or uncertain cleanup is never PASS. +No claim is made that shared WebKit XPC processes exit. Default desktop startup +failure/retry behavior is product-owned and must be observed manually later. + +Scoped TERM/HUP/INT handlers close admission and route through owned cleanup; +previous handlers are restored. Stop is checked before copy/sign/launch and +during readiness/wait. Cleanup refuses malformed PID, PID <=1, handle mismatch +or unknown/reused start identity. A failed evidence save occurs only after all +owned child cleanup attempts and backend join; uncertainty remains reportable. diff --git a/tests/integration/PROCESS_SPS.md b/tests/integration/PROCESS_SPS.md new file mode 100644 index 0000000..8ef05e6 --- /dev/null +++ b/tests/integration/PROCESS_SPS.md @@ -0,0 +1,44 @@ +# Finite process/SPS proof + +Run with Python 3.11 and cryptography **50.0.2**, with the installed toolstack +dependencies. No download, Nod, broker, provider, Docker driver, or VM helper is +part of this fixture. With no `--root` it has no effects. + +Private child invocation is `tool ROOT`, emitted only by the fixture manifest. +It validates an owned 0700 root, a private regular marker bound to its device +and inode, HOME, manifest and CA placement, loopback SPS host, and verified TLS +before creating the SDK. Bare `tool` is rejected. The constructed PATH includes +`/usr/sbin:/sbin` for native boot identity tools; boot identity must be available +before any service spawn. A narrow transparent posix_spawn observer records the +real runner child PID/start/boot, allowing partial-start cleanup to be checked. +It changes neither spawn arguments nor product behavior. + +The root must be an absent absolute path below a caller-owned 0700 directory. +Ancestors are traversed with no-follow directory descriptors. All generated +configuration, fake encrypted vault, credentials, logs, and state are private +under that root. Preserve the root on failure; it contains fake credentials and +must remain private. Only `result.json` is intended for sharing. + +Container owner entry argv (inside the already installed image): + +``` +python /proof/process_sps.py --root /data/process-sps +``` + +Mount this script read-only at `/proof/process_sps.py`; `/data` must be owned by +the invoking container user, mode 0700, with `process-sps` absent. Installed +`sps`, `admin`, and `toolyard` imports must be available (for source in `/app`, +invoke with `PYTHONPATH=/app`). The fixture derives child import roots from those +modules. Network NONE is compatible with its literal loopback traffic. + +The 100-second work alarm leads to bounded owned-child cleanup. SPS restarts +exactly once. ProcessRunner owns the tool spawn/process group; SPS children use +new sessions. Result records checks, boot/start identities, ports, PIDs, versions, +and cleanup status. A failed assertion preserves completed checks and failure +type without exception text that could contain credentials. No automatic effect +replay occurs; only readiness is retried. Root evidence remains for its owner to +remove after review. + +Scope: Linux process userland inside a throwaway container, **not** bare-host +systemd installation, account/isolation acceptance, or production providers. +Installation and final Linux execution gates remain separate. diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..9216088 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,57 @@ +# Local real-release vertical slice + +Run from this checkout with the already-cached Python 3.11.17 environment: + +```sh +env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin \ + HOME=/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode \ + TMPDIR=/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode \ + UV_CACHE_DIR=/Users/batteryshark/.cache/uv UV_PYTHON_DOWNLOADS=never \ + /Users/batteryshark/.local/bin/uv run --offline --no-project \ + --python /Users/batteryshark/.local/share/uv/python/cpython-3.11.17-macos-aarch64-none/bin/python3.11 \ + --with cryptography==50.0.2 --with fastapi==0.137.1 --with uvicorn==0.49.0 \ + --with PyYAML==6.0.3 --with httpx==0.28.1 python scripts/check-vertical-slice.py +``` + +This is intentionally opt-in (not unittest discovery): it downloads the public +Nod v1.0.1 Apple Silicon macOS release, verifies the pinned archive SHA-256 and +rejects links, special members, absolute/traversal archive paths before extraction. +It never invokes an installed Nod. It requires network access only for that +published artifact; runtime calls use dynamic literal loopback ports. + +The launcher creates one private temporary directory under the approved OpenCode +temporary root and constructs a fresh child environment. The fixture clears its +own environment too. Certificates, fake SPS config/passphrase, encrypted vault, +fake tokens, private runner state, channels.json, logs and Nod data stay there. +No APNs configuration or push tokens are supplied. The enrolled software P-256 +device is synthetic; Nod itself is not faked. The tool is a test-owned HTTP +fixture using the real SecretClient with certificate verification enabled. + +The result prints nonsecret process IDs/start identities, ports, digests and +checks. Every owned long-lived process has an identity-checked bounded teardown; +the launcher removes temporary data after fixture exit. Expected warning: the +first restart-recovery pass withholds the fixture manifest, so real registration +is incomplete; restoring it allows the same-boot watchdog retry to complete. +No model polling or automatic action replay is involved. + +Scope: Mac **development fixture only**, trusted same-owner process runner, not +host isolation. The supported target is dedicated-VM trusted tools with external +agents; no VM, installation or deployment is tested here. Broker restart and +persisted policy are covered. Full admin HTTP/login restart and persisted admin +configuration are now covered through a real admin child, fake-password login, +authenticated JSON and HTML config saves, API-owned broker start/stop, admin +stop/restart and fresh login. An external tool directory must survive both saves +and restart; policies/grants and tool discoverability persist. The broker is +stopped/reaped through its owning admin before admin restart; this does not test +adoption of an orphan broker. The running fixture tool keeps its identity. +The explicit watchdog failure/retry phase runs while admin is stopped to prevent +background recovery races. Real admin startup also uses its ordinary watchdog. + +The first review call uses a stable CLI idempotency key. Matching retries before +and after approval reuse the same receipt/result, one card and one effect; +mismatch is HTTP 409 and nonzero CLI. Card checks preserve an allowlisted safe +destination, mask a fake credential, visibly omit other fields and exclude the +private reason; audit excludes both private markers. Unknown outcomes are not +interpreted as proof of absent effects. This fixture does not induce a transport +loss, restore a backup, or test pruning. The launcher budget is 270 seconds plus +30 seconds for interrupted cleanup. All XDG paths and umask077 are explicit. diff --git a/tests/integration/backend_native_wire.py b/tests/integration/backend_native_wire.py new file mode 100644 index 0000000..52ce0aa --- /dev/null +++ b/tests/integration/backend_native_wire.py @@ -0,0 +1,143 @@ +"""Finite real HTTP seam proof; run with the existing offline admin dependencies.""" +import os +import socket +import subprocess +import tempfile +import threading +import time +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +SWIFT = r''' +import Foundation +@main struct Wire { + static func main() async throws { + let a = CommandLine.arguments + let client = ApiClient(baseURL: URL(string: a[1])!) + do { _ = try await client.listTools(); fatalError("anonymous accepted") } + catch ApiError.unauthorized {} + try await client.login(password: "FAKE-wire-password") + if a[2] == "delete" { + let r = try await client.removeToolReceipt(id: a[3]) + precondition(r.removed == a[3] && r.deleted == (a[4] == "true")) + } else { + let tools = a[4] == "malformed" ? [] : try await client.listTools() + let c = try await client.catalogObservation() + precondition(c.verified == (a[2] == "verified")) + precondition(c.toolIDs.sorted() == a[3].split(separator: ",").map(String.init).sorted()) + if a[4] == "initial" { precondition(Set(tools.map { $0.id }) == Set(c.toolIDs)) } + if a[4] == "rejectA" { precondition(!c.toolIDs.contains("a")) } + } + print("PASS native HTTP " + a[2] + " " + a[3]) + } +} +''' + + +def main(): + with tempfile.TemporaryDirectory(prefix="toolstack-wire-") as temp: + root = Path(temp).resolve() + os.environ.update(HOME=str(root), XDG_CONFIG_HOME=str(root/'config'), + XDG_STATE_HOME=str(root/'state'), TOOLSTACK_SPS_SKIP='1', + TOOLSTACK_SPS_ENV=str(root/'absent.env')) + from admin import auth, settings, broker_config + from admin.broker_config import BrokerRunConfig + from admin.server import create_app + from broker.server import build_server + import uvicorn + source = root/'Wire.swift' + source.write_text(SWIFT) + binary = root/'wire' + subprocess.run(['xcrun', 'swiftc', '-j', '2', '-module-cache-path', str(root/'modules'), + '-o', str(binary), *map(str, sorted((ROOT/'macapp/Sources/ToolstackKit').glob('*.swift'))), + str(source)], check=True, timeout=120, + env={**os.environ, 'DEVELOPER_DIR': '/Applications/Xcode.app/Contents/Developer'}) + tools = root/'tools' + folder = tools/'a' + folder.mkdir(parents=True) + manifest = folder/'toolyard.toml' + text = 'id="a"\ntype="api"\n[entrypoint]\nport=43210\n' + manifest.write_text(text) # intentionally zero operations + broker = build_server(port=0, host='127.0.0.1', db_path=str(root/'broker.db'), tools_root=str(tools)) + bt = threading.Thread(target=broker.serve_forever) + bt.start() + config = BrokerRunConfig(db_path=str(root/'broker.db'), tools_root=str(tools), port=broker.server_address[1]) + broker_config.save(config) + settings.write_password_hash(auth.hash_password('FAKE-wire-password')) + sock = socket.socket() + sock.bind(('127.0.0.1', 0)) + url = f'http://127.0.0.1:{sock.getsockname()[1]}' + server = None + at = None + def phase(status, ids='', extra='rejectA'): + subprocess.run([str(binary), url, status, ids, extra], check=True, timeout=15) + try: + with mock.patch('admin.server.start_reconciler_thread', return_value=None): + app = create_app() + server = uvicorn.Server(uvicorn.Config(app, log_level='error')) + at = threading.Thread(target=lambda: server.run(sockets=[sock])) + at.start() + deadline = time.monotonic() + 5 + while not server.started: + if time.monotonic() > deadline: raise RuntimeError('admin startup timed out') + time.sleep(.01) + print(f'PID {os.getpid()} broker/admin threads; real loopback HTTP', flush=True) + phase('verified', 'a', 'initial') + manifest.unlink() + broker.reload_registry() + phase('verified') + manifest.write_text(text.replace('id="a"', 'id="b"')) + broker.reload_registry() + phase('verified', 'b') + receipt = broker.ctx.store.catalog_receipt() + broker.ctx.store.set_catalog_receipt(None) + phase('unknown') + broker.ctx.store.set_catalog_receipt(receipt) + manifest.write_text('invalid = [') + phase('unknown', '', 'malformed') + manifest.write_text(text) + broker.reload_registry() + old = broker.ctx.store.catalog_receipt() + broker.reload_registry() + broker.ctx.store.set_catalog_receipt(old) + phase('unknown') + second = build_server(port=0, host='127.0.0.1', db_path=config.db_path, + tools_root=str(tools)) + second_thread = threading.Thread(target=second.serve_forever) + second_thread.start() + try: + # Admin still probes the first live listener; second owns the receipt. + phase('unknown') + finally: + second.shutdown() + second_thread.join(5) + second.server_close() + second.ctx.store.close() + assert not second_thread.is_alive() + external = root/'external' + external.mkdir() + external_manifest = external/'toolyard.toml' + external_text = text.replace('id="a"', 'id="external"') + external_manifest.write_text(external_text) + config.tool_dirs = [str(external)] + broker_config.save(config) + phase('delete', 'external', 'false') + assert external_manifest.read_text() == external_text + assert broker_config.load().tool_dirs == [] + phase('delete', 'a', 'true') + assert not folder.exists() + finally: + if server: server.should_exit = True + if at: at.join(5) + sock.close() + broker.shutdown() + bt.join(5) + broker.server_close() + broker.ctx.store.close() + assert not bt.is_alive() and (at is None or not at.is_alive()) + print('CLEANUP: both server threads stopped; sockets/store closed', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/installed_stack.py b/tests/integration/installed_stack.py new file mode 100644 index 0000000..4cc8480 --- /dev/null +++ b/tests/integration/installed_stack.py @@ -0,0 +1,650 @@ +"""Bounded installed-wheel acceptance with explicitly fake Nod. Import inert.""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import stat +import sys +import uuid +import hmac +import signal +import socket +import subprocess +import time +import shlex +import urllib.request +import urllib.error +from http.server import BaseHTTPRequestHandler, HTTPServer +from dataclasses import asdict + +WHEEL_SHA256 = '177b2ced1cd0500da68fd228284f50b74f2c941571ba0db8d606bc18617207c5' +MODULES = ('admin', 'broker', 'client', 'sps', 'toolyard', 'toolstack_forwarder') +RUN_BUDGET_SECONDS = 240 +CLEANUP_BUDGET_SECONDS = 30 +MAX_BODY = 1024 * 1024 + + +class BoundaryError(RuntimeError): + """A boundary could not be proved. Messages contain no supplied values.""" + + +class BudgetExpired(BaseException): + """Must bypass per-resource cleanup error handling.""" + + +def private_root(path: Path) -> Path: + """Create exactly one absent child, refusing symlinks in every ancestor. + + Canonical absolute paths are intentional: macOS /var aliases must be expanded + by the parent before invocation. An existing private directory is not reused. + """ + if not path.is_absolute() or '..' in path.parts: + raise BoundaryError('absolute canonical root required') + for ancestor in reversed(path.parents): + info = ancestor.lstat() + if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise BoundaryError('unsafe root ancestor') + parent = path.parent.stat() + if parent.st_uid != os.getuid() or parent.st_mode & 0o077: + raise BoundaryError('parent must be owned and private') + # mkdir supplies no-clobber semantics, including dangling leaf symlinks. + path.mkdir(mode=0o700) + return path + + +def private_json(path: Path, value: dict) -> None: + """Exclusive creation; never follow or replace a pre-existing evidence file.""" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, sort_keys=True, indent=2) + stream.write('\n') + + +def environment(root: Path) -> dict[str, str]: + """Construct from literals, never by copying parent credentials or proxies.""" + return { + 'HOME': str(root), 'TMPDIR': str(root / 'tmp'), + 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin', + 'PYTHONNOUSERSITE': '1', 'PYTHONDONTWRITEBYTECODE': '1', + 'PYTHONUNBUFFERED': '1', + **{key: str(root / key.lower()) for key in ( + 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR')}, + } + + +def installed_origins(prefix: Path, source: Path, finder=None) -> dict: + finder = finder or importlib.util.find_spec + prefix, source = prefix.resolve(), source.resolve() + result = {} + for name in MODULES: + spec = finder(name) + if spec is None or not spec.origin: + raise BoundaryError('missing concrete installed module') + origin = Path(spec.origin).resolve() + if not origin.is_relative_to(prefix) or origin.is_relative_to(source): + raise BoundaryError('module outside installed prefix') + for entry in spec.submodule_search_locations or (): + location = Path(entry).resolve() + if not location.is_relative_to(prefix) or location.is_relative_to(source): + raise BoundaryError('package search path outside installed prefix') + result[name] = {'origin': str(origin), + 'sha256': hashlib.sha256(origin.read_bytes()).hexdigest()} + return result + + +def may_signal(pid, recorded_identity, recorded_boot, current_identity, current_boot): + """Unknown identity is never absence and never permission to signal.""" + return (isinstance(pid, int) and not isinstance(pid, bool) and pid > 1 + and recorded_identity is not None and recorded_boot is not None + and current_identity is not None and current_boot is not None + and recorded_identity == current_identity and recorded_boot == current_boot) + + +def body_length(raw: str) -> int: + if not raw or not raw.isascii() or not raw.isdecimal(): + raise BoundaryError('invalid body length') + if len(raw) > 7: + raise BoundaryError('oversize body') + length = int(raw) + if length > MAX_BODY: + raise BoundaryError('oversize body') + return length + + +def api(base, path, token='', body=None, key=None): + if not base.startswith('http://127.0.0.1:'): + raise BoundaryError('nonloopback HTTP refused') + headers = {'Content-Type': 'application/json'} + if token: + headers['Authorization'] = 'Bearer ' + token + if key: + headers['Idempotency-Key'] = key + request = urllib.request.Request(base + path, headers=headers, + data=None if body is None else json.dumps(body).encode()) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + try: + response = opener.open(request, timeout=3) + except urllib.error.HTTPError as error: + response = error + with response: + data = response.read(MAX_BODY + 1) + if len(data) > MAX_BODY: + raise BoundaryError('oversize response') + return json.loads(data or b'null') + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + raise BoundaryError('redirect refused') + + +def ready(fn, seconds=12): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + try: + result = fn() + if result: + return result + except (OSError, ValueError, RuntimeError): + pass + time.sleep(.05) + raise BoundaryError('readiness deadline') + + +def decision(ref, approved): + return {'request_id': ref, 'status': 'resolved' if approved else 'pending', + 'decision': {'option_id': 'approve', 'option_kind': 'approve', + 'actor_user_id': 'explicit-fake-fixture'} if approved else None} + + +class FixtureHandler(BaseHTTPRequestHandler): + def setup(self): + super().setup() + self.connection.settimeout(2) + + def log_message(self, *args): + pass + + def respond(self, code, data): + raw = json.dumps(data).encode() + self.send_response(code) + self.send_header('Content-Length', str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + self.dispatch(False) + + def do_POST(self): + self.dispatch(True) + + def dispatch(self, post): + server = self.server + expected = server.credential + actual = self.headers.get('Authorization' if server.kind == 'nod' else 'X-Toolstack-Secret', '') + if server.kind == 'nod': + expected = 'Bearer ' + expected + if not hmac.compare_digest(actual, expected): + return self.respond(401, {}) + try: + if self.headers.get('Transfer-Encoding'): + raise BoundaryError('transfer encoding refused') + payload = None + if post: + length = body_length(self.headers.get('Content-Length', '')) + raw = self.rfile.read(length) + if len(raw) != length: + raise BoundaryError('short body') + payload = json.loads(raw) + if server.kind == 'tool': + if not post or self.path != '/v1/actions/check': + return self.respond(404, {}) + value = server.sdk.refresh('proof') + effects = server.root / 'effects' + count = int(effects.read_text()) if effects.exists() else 0 + if count >= 4: + raise BoundaryError('effect bound') + effects.write_text(str(count + 1)) + return self.respond(200, {'secret_gate': value.startswith('FAKE-SLICE-'), 'operation': 'check'}) + if post and self.path == '/api/v1/requests': + key = payload['dedupe_key'] + if key in server.cards and server.cards[key][1] != payload: + return self.respond(409, {}) + if key not in server.cards: + if len(server.cards) >= 4: + raise BoundaryError('card bound') + server.cards[key] = (uuid.uuid4().hex, payload) + private_json(server.root / ('card-' + str(len(server.cards)) + '.json'), payload) + if len(server.cards) == 1: + private_json(server.root / 'nod-reference.json', {'request_id': server.cards[key][0]}) + return self.respond(200, {'request_id': server.cards[key][0]}) + for ref, _ in server.cards.values(): + if not post and self.path == '/api/v1/requests/' + ref + '/decision': + control = server.root / 'approve.json' + approved = False + if control.exists(): + value = json.loads(control.read_text()) + approved = hmac.compare_digest(value.get('credential', ''), server.control) and value.get('request_id') == ref + return self.respond(200, decision(ref, approved)) + self.respond(404, {}) + except (ValueError, KeyError, OSError, BoundaryError): + self.respond(400, {}) + + +def fixture(kind): + root = Path(os.environ['HOME']) + marker = json.loads((root / 'marker.json').read_text()) + if marker['kind'] != 'installed-stack': + raise BoundaryError('fixture marker missing') + signal.alarm(RUN_BUDGET_SECONDS + CLEANUP_BUDGET_SECONDS) + server = HTTPServer(('127.0.0.1', 0 if kind == 'nod' else int(os.environ['TOOLSTACK_PORT'])), FixtureHandler) + server.kind, server.root = kind, root + if kind == 'nod': + config = json.loads((root / 'fixture.json').read_text()) + server.credential, server.control = config['issuer'], config['control'] + server.cards = {} + private_json(root / 'nod-ready.json', {'port': server.server_port}) + else: + from sps.tool_sdk import SecretClient + server.sdk = SecretClient.from_env('slice') + server.credential = os.environ['TOOLSTACK_CHANNEL_SECRET'] + try: + server.serve_forever(poll_interval=.1) + finally: + server.server_close() + + +def interrupted(signum, frame): + if signum == signal.SIGALRM: + raise BudgetExpired() + raise BoundaryError('bounded run interrupted') + + +def absent(pid): + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + return False + + +def parse_processes(text): + rows = {} + for line in text.splitlines(): + parts = line.strip().split(None, 2) + if len(parts) != 3: + raise BoundaryError('process inventory malformed') + try: + argv = shlex.split(parts[2]) + except ValueError: + argv = [] # unrelated opaque command; never eligible for adoption + rows[int(parts[0])] = (int(parts[1]), argv) + return rows + + +def process_inventory(): + result = subprocess.run(['/bin/ps', '-axo', 'pid=,ppid=,args='], + capture_output=True, text=True, timeout=2) + if result.returncode: + raise BoundaryError('process inventory failed') + return parse_processes(result.stdout) + + +def reconcile_broker(intent, state, rows, identity, boot, executable): + """Pure adoption gate. Missing state is uncertainty, never inferred absence.""" + parent = intent['admin'] + if not may_signal(parent['pid'], parent['identity'], parent['boot'], + identity(parent['pid']), boot): + raise BoundaryError('broker reconciliation parent unknown') + if not isinstance(state, dict) or state.get('port') != intent['port']: + raise BoundaryError('broker reconciliation state unknown') + pid = state.get('pid') + if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1: + raise BoundaryError('broker reconciliation pid invalid') + row = rows.get(pid) + if row != (parent['pid'], [executable, '-m', 'broker.server']): + raise BoundaryError('broker reconciliation ownership unknown') + birth = identity(pid) + if birth is None or boot != parent['boot']: + raise BoundaryError('broker reconciliation birth unknown') + return pid, birth + + +def start_once(intent, persist, post): + """Persist before exactly one POST; a lost reply propagates for reconciliation.""" + persist(intent) + return post() + + +def stop_once(stop_api, is_absent, persist_absence): + result = stop_api() + if result.get('running') is not False or not is_absent(): + raise BoundaryError('broker stop unconfirmed') + persist_absence() + + +def acceptance(root, origins, wheel): + from toolyard.runner import ProcessRunner, _process_identity, _boot_id + from toolyard.cli import state_lock, _save_state + from toolyard.config import load + from sps.cli import maybe_generate_tls_material + from sps.plugins.localfile import _Vault + from sps.client import SPSClient + from broker.store import Store + from broker.identity import hash_token + from admin import auth, settings, broker_config + + evidence = {'outcome': 'FAIL', 'phase': 'setup', 'wheel_sha256_parent_attestation': wheel, + 'installed_origins': origins, 'checks': [], 'processes': [], 'ports': {}, 'cleanup': []} + records = [] + running = None + supervised = None + admin = None + operator = None + intent = None + retain_admin = False + runner = ProcessRunner() + boot = _boot_id() + env = environment(root) + for key, value in env.items(): + if key.startswith('XDG_') or key == 'TMPDIR': + Path(value).mkdir(mode=0o700) + os.environ.clear() + os.environ.update(env) + os.chdir(root) + + def check(name, condition): + if not condition: + evidence['failed_check'] = name[:160] + raise BoundaryError(name) + evidence['checks'].append(name) + + def record(label, pid, proc=None, identity=None): + entry = {'label': label, 'pid': int(pid), 'identity': identity or _process_identity(pid), 'boot': boot} + records.append((entry, proc)) + evidence['processes'].append(entry) + private_json(root / ('process-' + str(len(records)) + '.json'), entry) + check('known birth and boot: ' + label, entry['pid'] > 1 and entry['identity'] is not None and boot is not None) + return entry + + def start(label, argv, extra=None): + with open(root / (label + '.log'), 'xb') as log: + proc = subprocess.Popen(argv, cwd=root, env={**env, **(extra or {})}, stdout=log, stderr=log, start_new_session=True) + entry = record(label, proc.pid, proc) + return entry, proc + + def stop(entry, proc=None): + if proc is not None and proc.poll() is not None: + return + if absent(entry['pid']): + if proc is not None: + proc.wait(timeout=.2) + return + for sig in (signal.SIGTERM, signal.SIGKILL): + if not may_signal(entry['pid'], entry['identity'], entry['boot'], _process_identity(entry['pid']), _boot_id()): + raise BoundaryError('unknown identity cleanup refused') + os.kill(entry['pid'], sig) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + if proc is not None: + proc.poll() + else: + try: + os.waitpid(entry['pid'], os.WNOHANG) + except ChildProcessError: + pass + if absent(entry['pid']): + return + time.sleep(.05) + raise BoundaryError('exit unconfirmed') + + def port(label): + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + value = sock.getsockname()[1] + evidence['ports'][label] = value + return value + + def broker_stop(): + nonlocal supervised, intent + if supervised is not None: + check('broker identity before API stop', may_signal(supervised['pid'], supervised['identity'], supervised['boot'], _process_identity(supervised['pid']), _boot_id())) + stop_once(lambda: api(admin_url, '/api/broker/stop', operator, {}), + lambda: absent(supervised['pid']), + lambda: private_json(root / ('broker-stop-' + str(len(records)) + '.json'), {'absent': True})) + check('broker stopped and reaped', True) + supervised = None + intent = None + + def broker_start(): + nonlocal supervised, intent + state_path = settings.state_dir() / 'broker.state.json' + check('broker state absent before start', not state_path.exists()) + intent = {'port': bp, 'admin': dict(admin[0]), 'state_path': str(state_path)} + result = start_once(intent, + lambda value: private_json(root / ('broker-intent-' + str(len(records)) + '.json'), value), + lambda: api(admin_url, '/api/broker/start', operator, {})) + supervised = record('admin-owned-broker', result['pid']) + check('broker healthy', result['healthy']) + + def admin_start(label): + child = start(label, [sys.executable, '-I', '-m', 'admin', 'serve', '--port', str(ap)], runner_env) + token = ready(lambda: api(admin_url, '/api/login', body={'password': password}).get('token')) + return child, token + + old_handlers = {sig: signal.signal(sig, interrupted) for sig in (signal.SIGALRM, signal.SIGTERM, signal.SIGINT)} + signal.alarm(RUN_BUDGET_SECONDS) + try: + issuer, control = ('FAKE-' + uuid.uuid4().hex for _ in range(2)) + private_json(root / 'fixture.json', {'issuer': issuer, 'control': control}) + helper = str(Path(__file__).resolve()) + start('fake-nod', [sys.executable, '-I', helper, '--fake-nod']) + nod_port = ready(lambda: json.loads((root / 'nod-ready.json').read_text())['port']) + evidence['ports']['nod'] = nod_port + nod_url = f'http://127.0.0.1:{nod_port}' + sp, tp, bp, ap = (port(name) for name in ('sps', 'tool', 'broker', 'admin')) + broker_url, admin_url = f'http://127.0.0.1:{bp}', f'http://127.0.0.1:{ap}' + cert, keyfile, ca = (str(root / name) for name in ('sps.crt', 'sps.key', 'ca.crt')) + maybe_generate_tls_material(cert, keyfile, ca, '/CN=localhost') + sp_secret, passphrase = ('FAKE-' + uuid.uuid4().hex for _ in range(2)) + fake = 'FAKE-SLICE-' + uuid.uuid4().hex + vaultfile = root / 'vault.json' + _Vault.init(vaultfile, passphrase) + _Vault(vaultfile, passphrase).set('slice', 'PROOF', fake) + config = root / 'sps.env' + config.write_text('\n'.join(f'{k} = {json.dumps(v)}' for k, v in { + 'SP_HOST': '127.0.0.1', 'SP_PORT': str(sp), 'SP_SECRET': sp_secret, + 'SP_TLS_CERT': cert, 'SP_TLS_KEY': keyfile, 'SP_TLS_CA': ca, + 'SP_AUDIT_LOG': str(root / 'sps.audit'), 'SP_PLUGIN': 'localfile'}.items()) + + '\n[localfile]\nVAULT_FILE = ' + json.dumps(str(vaultfile)) + '\n') + start('sps', [sys.executable, '-I', '-m', 'sps.cli', 'serve', '--config', str(config)], + {'SP_VAULT_PASSPHRASE': passphrase, 'TOOLSTACK_SPS_BOOT_ID_PATH': str(root / 'sps.boot')}) + registrar = SPSClient('127.0.0.1', sp, sp_secret=sp_secret, ca_file=ca, timeout=2) + check('SPS verified TLS boot', bool(ready(registrar.ping)['boot_id'])) + toolpath = root / 'external' / 'slice' + toolpath.mkdir(parents=True) + command = shlex.quote(sys.executable) + ' -I ' + shlex.quote(helper) + ' --tool' + manifest = toolpath / 'toolyard.toml' + manifest.write_text('id = "slice"\ntype = "api"\n[entrypoint]\nport = ' + str(tp) + + '\ncommand = ' + json.dumps(command) + '\n[[operations]]\nname = "check"\nrisk = "read"\napproval_fields = ["/destination", "/credential"]\n[[operations]]\nname = "blocked"\nrisk = "read"\n[[secrets]]\nname = "proof"\nfield = "PROOF"\n') + runner_env = {'TOOLSTACK_SPS_ENV': str(config), 'TOOLSTACK_SPS_VERIFY': 'true', + 'TOOLSTACK_TOOLYARD_CHANNEL_STATE': str(root / 'channels.json'), 'TOOLSTACK_BROKER_HOST': '127.0.0.1'} + os.environ.update(runner_env) + with state_lock(): + try: + running = runner.start(load(manifest)) + except BaseException as error: + partial = getattr(error, 'running', None) + if partial is not None and partial.handle: + running = partial + record('partial-runner-tool', partial.handle, identity=partial.process_identity) + raise + record('runner-tool', running.handle, identity=running.process_identity) + _save_state({'slice': asdict(running)}) + check('independent credentials', running.e_secret != running.channel_secret) + check('tool ready', ready(lambda: api(f'http://127.0.0.1:{tp}', '/missing') == {})) + db = str(root / 'broker.sqlite') + agent_token = 'FAKE-' + uuid.uuid4().hex + store = Store(db) + caller = store.add_caller('fake-agent') + store.add_token(caller, hash_token(agent_token)) + policy = {'tools': {'slice': {'check': 'review', 'blocked': 'deny'}}} + store.set_policy(caller, policy) + store.close() + (root / 'tools').mkdir() + password = 'FAKE-' + uuid.uuid4().hex + settings.write_password_hash(auth.hash_password(password)) + settings.rotate_session_secret() + broker_config.save(broker_config.BrokerRunConfig(port=bp, db_path=db, tools_root=str(root / 'tools'), + tool_dirs=[str(toolpath)], nod_url=nod_url, nod_token=issuer, nod_channel='slice', rate_limit=0)) + admin, operator = admin_start('admin') + saved = api(admin_url, '/api/config', operator, {'approval_ttl': 420}) + check('config save preserves external tools', saved['tool_dirs'] == [str(toolpath)]) + broker_start() + evidence['phase'] = 'review' + private, reason = ('FAKE-private-' + uuid.uuid4().hex for _ in range(2)) + body = {'arguments': {'destination': 'safe-fixture', 'credential': private}, 'reason': reason} + def call(): + return api(broker_url, '/v1/actions/slice.check', agent_token, body, 'installed-proof-1') + pending = call() + request_id = pending['request_id'] + check('review pending', pending['status'] == 'pending_approval') + retry = call() + check('pending receipt deduplicated', retry['request_id'] == request_id and retry['reused']) + check('no preapproval effect', not (root / 'effects').exists()) + card = json.loads((root / 'card-1.json').read_text()) + check('exact approval options', {(o['id'], o['kind']) for o in card['options']} == {('approve', 'approve'), ('reject', 'reject_with_text')}) + fields = {f['label']: f['value'] for f in card['fields']} + check('full digest and generation', len(fields['Full request SHA-256']) == 64 and bool(fields['Operation generation'])) + check('private action omitted', private not in json.dumps(card) and reason not in json.dumps(card)) + # The broker receipt records the opaque surface reference in its store; + # the fixture publishes a control-only reference file on creation. + ref = json.loads((root / 'nod-reference.json').read_text())['request_id'] + private_json(root / 'approve.json', {'credential': control, 'request_id': ref}) + result = ready(lambda: (r if (r := api(broker_url, '/v1/requests/' + str(request_id), agent_token))['status'] == 'ok' else None)) + check('real TLS secret gates tool result', result['result']['secret_gate'] is True) + retry = call() + check('completed receipt deduplicated', retry['reused'] and retry['request_id'] == request_id and retry['result'] == result['result']) + check('exactly one effect and card', (root / 'effects').read_text() == '1' and not (root / 'card-2.json').exists()) + broker_stop() + stop(*admin) + evidence['phase'] = 'restart' + admin, operator = admin_start('admin-restart') + persisted = api(admin_url, '/api/config', operator) + check('admin config recovered', persisted['approval_ttl'] == 420 and persisted['tool_dirs'] == [str(toolpath)]) + check('live tool recovered', runner.is_alive(running)) + check('policy recovered', api(admin_url, '/api/callers/fake-agent/policy', operator)['policy'] == policy) + broker_start() + retry = call() + check('token and completed receipt recovered', retry['reused'] and retry['request_id'] == request_id and retry['result'] == result['result']) + denied = api(broker_url, '/v1/actions/slice.blocked', agent_token, {'arguments': {}}) + check('deny policy survives restart', denied['status'] == 'denied') + check('restart causes no replay', (root / 'effects').read_text() == '1') + broker_stop() + evidence['outcome'] = 'PASS' + except BaseException as error: + evidence['failure_stage'] = evidence['phase'] + evidence['failuretype'] = type(error).__name__ + finally: + evidence['last_flow_stage'] = evidence['phase'] + evidence['phase'] = 'cleanup' + signal.alarm(CLEANUP_BUDGET_SECONDS) + # Ignore further user interrupts during the finite teardown, retain alarm. + signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + try: + if intent is not None and supervised is None: + try: + state = json.loads(Path(intent['state_path']).read_text()) + pid, birth = reconcile_broker(intent, state, process_inventory(), + _process_identity, _boot_id(), sys.executable) + supervised = record('reconciled-broker', pid, identity=birth) + except Exception as error: + retain_admin = True + evidence['outcome'] = 'FAIL' + evidence['cleanup'].append({'label': 'broker-reconcile', 'failuretype': type(error).__name__}) + if supervised is not None: + try: + broker_stop() + except Exception: + try: + stop(supervised) + check('fallback broker absent', absent(supervised['pid'])) + private_json(root / 'broker-fallback-absence.json', {'absent': True}) + intent = None + except Exception as error: + retain_admin = True + evidence['cleanup'].append({'label': 'broker-fallback', 'failuretype': type(error).__name__}) + if running is not None: + try: + check('tool identity before runner stop', may_signal(int(running.handle), running.process_identity, running.boot_id, _process_identity(running.handle), _boot_id())) + runner.stop(running) + except Exception as error: + evidence['cleanup'].append({'label': 'runner-stop', 'failuretype': type(error).__name__}) + for entry, proc in reversed(records): + try: + if retain_admin and admin is not None and entry['pid'] == admin[0]['pid']: + raise BoundaryError('admin retained for unresolved broker ownership') + stop(entry, proc) + evidence['cleanup'].append({'label': entry['label'], 'absent': absent(entry['pid'])}) + except Exception as error: + evidence['cleanup'].append({'label': entry['label'], 'failuretype': type(error).__name__}) + for value in evidence['ports'].values(): + with socket.socket() as sock: + sock.settimeout(.2) + check('owned listener gone', sock.connect_ex(('127.0.0.1', value)) != 0) + check('all recorded identities absent', all(absent(entry['pid']) for entry, _ in records)) + check('cleanup complete', all(item.get('absent') is True for item in evidence['cleanup'])) + except BaseException as error: + evidence['outcome'] = 'FAIL' + evidence['cleanup_failuretype'] = type(error).__name__ + finally: + signal.alarm(0) + for sig, handler in old_handlers.items(): + signal.signal(sig, handler) + evidence['phase'] = 'finished' + evidence['loaded_package_modules'] = {} + for name, module in tuple(sys.modules.items()): + if name.split('.')[0] not in MODULES or not getattr(module, '__file__', None): + continue + origin = Path(module.__file__).resolve() + if not origin.is_relative_to(Path(sys.prefix).resolve()): + evidence['outcome'] = 'FAIL' + evidence['provenance_failure'] = True + continue + evidence['loaded_package_modules'][name] = {'origin': str(origin), 'sha256': hashlib.sha256(origin.read_bytes()).hexdigest()} + private_json(root / 'evidence.json', evidence) + return 0 if evidence['outcome'] == 'PASS' else 1 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, required=True) + parser.add_argument('--wheel-sha256', required=True, choices=[WHEEL_SHA256]) + parser.add_argument('--run', required=True, choices=['single']) + args = parser.parse_args(argv) + if not sys.flags.isolated or sys.prefix == sys.base_prefix: + raise BoundaryError('isolated fresh virtual environment required') + # An argument pin is a parent attestation, not proof of wheel bytes. The + # parent must hash its retained wheel before installing it into the venv. + origins = installed_origins(Path(sys.prefix), Path(__file__).resolve().parents[2]) + root = private_root(args.root) + os.umask(0o077) + private_json(root / 'marker.json', {'run_id': uuid.uuid4().hex, 'kind': 'installed-stack'}) + return acceptance(root, origins, args.wheel_sha256) + + +if __name__ == '__main__': + try: + if sys.argv[1:] in (['--fake-nod'], ['--tool']): + fixture('nod' if sys.argv[1] == '--fake-nod' else 'tool') + else: + raise SystemExit(main()) + except Exception as error: + print('installed-stack: ' + type(error).__name__, file=sys.stderr) + raise SystemExit(2) diff --git a/tests/integration/live_ui_fixture.py b/tests/integration/live_ui_fixture.py new file mode 100644 index 0000000..a1e4812 --- /dev/null +++ b/tests/integration/live_ui_fixture.py @@ -0,0 +1,314 @@ +"""Fake-only manual acceptance backend. Default/prepare never create a listener.""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import hashlib +import importlib.metadata +import json +import os +import plistlib +import pwd +import shutil +import signal +import stat +import subprocess +from pathlib import Path +import socket +import sys +import threading +import time +import uuid +from unittest.mock import patch + +SOURCE = Path(__file__).resolve().parents[2] +TEMP = Path('/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode') +PASSWORD = 'FAKE-live-ui-only-password' +APP_HASH = '28cf9127f93d4ffd30fe7d91d120b862d4e2fc1c3a845b50210bd6a369b18600' + + +def environment(root: Path) -> dict[str, str]: + return {'PATH': '/usr/bin:/bin:/usr/sbin:/sbin', 'HOME': str(root / 'home'), + 'XDG_CONFIG_HOME': str(root / 'config'), + 'XDG_STATE_HOME': str(root / 'state'), 'TMPDIR': str(root / 'tmp'), + 'PYTHONPATH': str(SOURCE), 'PYTHONDONTWRITEBYTECODE': '1', + 'TOOLSTACK_SPS_SKIP': '1', 'TOOLSTACK_SPS_ENV': str(root / 'absent.env')} + + +def validate_root(value: str) -> Path: + root = Path(value) + if not root.is_absolute() or root.parent != TEMP or not root.name.startswith('toolstack-live-ui-'): + raise ValueError(f'root must be a new toolstack-live-ui-* direct child of {TEMP}') + if root.exists() or root.is_symlink(): + raise ValueError('root must be absent; refusing adoption') + for parent in (TEMP, *TEMP.parents): + if parent.is_symlink(): + raise ValueError('symlink ancestor refused') + return root + + +def runtime_report() -> dict: + found = {} + for name in ('fastapi', 'uvicorn', 'pywebview', 'pyobjc-core', 'pyobjc-framework-Cocoa', 'pyobjc-framework-WebKit'): + try: + found[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + found[name] = 'MISSING' + return found + + +def bootstrap(root: Path): + from admin import auth, settings, broker_config + from broker import operations + from broker.store import Store + for name in ('home', 'config', 'state', 'tmp', 'tools', 'external'): + (root / name).mkdir(mode=0o700) + # Declarations only: nothing executable and no broker or tool is started. + for name, folder in [('fake-zero', root / 'tools' / 'fake-zero'), + ('fake-read', root / 'tools' / 'fake-read'), + ('fake-external', root / 'external')]: + folder.mkdir(mode=0o700, exist_ok=True) + text = f'id="{name}"\ntype="api"\n[entrypoint]\nport=1\n' + if name == 'fake-read': + text += '\n[[operations]]\nname="read"\nrisk="read"\ndescription="FAKE declaration only"\n' + (folder / 'toolyard.toml').write_text(text) + broker_config.save(broker_config.BrokerRunConfig( + port=0, db_path=str(root / 'fake.sqlite'), tools_root=str(root / 'tools'), + tool_dirs=[str(root / 'external')])) + settings.write_password_hash(auth.hash_password(PASSWORD)) + store = Store(str(root / 'fake.sqlite')) + try: + for name in ('FAKE-policy', 'FAKE-rotate', 'FAKE-revoke'): + operations.create_caller(store, name, ['fake-read.read'], [], 'FAKE-bootstrap') + store.set_catalog_receipt(None) + finally: + store.close() + + +def check_stop(stop): + if stop is not None and stop.is_set(): + raise InterruptedError('fixture stop requested; admission closed') + + +@contextmanager +def stop_handlers(): + stop = threading.Event() + previous = {} + def handler(signum, frame): + stop.set() + try: + for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + previous[sig] = signal.signal(sig, handler) + yield stop + finally: + for sig, old in previous.items(): + signal.signal(sig, old) + + +def native_copy(app: Path, root: Path, identity: str, env: dict, stop=None): + """Called only after future admission; never edit the original artifact.""" + if not identity.startswith('Apple Development:'): + raise ValueError('explicit existing Apple Development identity required') + if app.is_symlink() or not app.is_dir(): + raise ValueError('app must be a real directory') + app = app.resolve() + for item in app.rglob('*'): + mode = item.lstat().st_mode + if stat.S_ISLNK(mode): + if not item.resolve().is_relative_to(app): + raise ValueError('external bundle link') + elif not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)): + raise ValueError('special bundle file') + def command(*args): + check_stop(stop) + command_env = dict(env) + owner_home = pwd.getpwuid(os.getuid()).pw_dir + if not owner_home or not Path(owner_home).is_absolute() or owner_home == '/': + raise ValueError('OS account owner home must be an absolute non-root path') + command_env['HOME'] = owner_home + return subprocess.run(['/usr/bin/codesign', *args], check=True, timeout=30, + env=command_env, capture_output=True, text=True) + command('--verify', '--deep', '--strict', str(app)) + signature = command('-dv', '--verbose=4', str(app)) + if f'Authority={identity}' not in signature.stderr.splitlines(): + raise ValueError('original signing identity mismatch') + binary = app / 'Contents/MacOS/ToolstackApp' + if hashlib.sha256(binary.read_bytes()).hexdigest() != APP_HASH: + raise ValueError('original executable hash mismatch') + copy = root / 'Fixture.app' + check_stop(stop) + shutil.copytree(app, copy, symlinks=True) + info = copy / 'Contents/Info.plist' + if info.is_symlink(): + raise ValueError('Info.plist must not be a link') + data = plistlib.loads(info.read_bytes()) + identifier = 'com.toolstack.operator.fixture.' + uuid.uuid4().hex + data['CFBundleIdentifier'] = identifier + info.write_bytes(plistlib.dumps(data)) + command('--force', '--deep', '--sign', identity, '--timestamp=none', str(copy)) + command('--verify', '--deep', '--strict', str(copy)) + return copy / 'Contents/MacOS/ToolstackApp', identifier + + +def native_url(url): + return url + '#toolstack-fixture-' + uuid.uuid4().hex + + +def launch(argv, env, children, identity, record, stop=None): + check_stop(stop) + proc = subprocess.Popen(argv, env=env, cwd=SOURCE) + child = {'proc': proc, 'pid': proc.pid, 'identity': None} + children.append(child) + if type(proc.pid) is int and proc.pid > 1: + child['identity'] = identity(proc.pid) + record() # persist ownership before any subsequent action + if child['identity'] is None: + raise RuntimeError('child identity unavailable; cleanup uncertain') + + +def stop_child(child, identity): + proc = child['proc'] + if proc.poll() is not None: + proc.wait(timeout=5) + return 'reaped' + def current(): + return (type(child['pid']) is int and child['pid'] > 1 + and type(proc.pid) is int and proc.pid == child['pid'] + and child['identity'] is not None and identity(child['pid']) == child['identity']) + if not current(): + return 'uncertain: identity mismatch; not signalled' + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + if not current(): + return 'uncertain: identity changed before kill' + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + return 'uncertain: kill wait expired' + return 'reaped' + + +def wait_ready(server, thread, errors, seconds=10, stop=None): + check_stop(stop) + deadline = time.monotonic() + seconds + while not server.started: + check_stop(stop) + if errors or not thread.is_alive() or time.monotonic() >= deadline: + raise RuntimeError('backend readiness failed; no app launch') + time.sleep(.02) + if errors or not thread.is_alive(): + raise RuntimeError('backend exited during readiness') + + +def serve(root: Path, native_app=None, sign_identity=None, desktop=False): + with stop_handlers() as stop: + try: + _serve(root, native_app, sign_identity, desktop, stop) + except InterruptedError: + pass # controlled stop; owned cleanup already ran + + +def _serve(root, native_app, sign_identity, desktop, stop): + check_stop(stop) + os.umask(0o077) + # Exclusive mkdir; never follow/adopt a pre-existing root. + root.mkdir(mode=0o700) + os.environ.clear() + os.environ.update(environment(root)) + sys.path.insert(0, str(SOURCE)) + bootstrap(root) + from admin.server import create_app + from toolyard.runner import _process_identity + import uvicorn + # Suppress only automatic reconciliation, not routes/auth/product responses. + with patch('admin.server.start_reconciler_thread', return_value=None): + app = create_app() + with socket.socket() as sock: + check_stop(stop) + sock.bind(('127.0.0.1', 0)) + url = f'http://127.0.0.1:{sock.getsockname()[1]}' + record = {'url': url, 'pid': os.getpid(), 'identity': _process_identity(os.getpid()), + 'budget_seconds': 1200, 'fake_password': PASSWORD, 'runtime': runtime_report()} + children, errors = [], [] + def save(): + record['children'] = [{k: v for k, v in child.items() if k != 'proc'} for child in children] + (root / 'fixture.json').write_text(json.dumps(record, indent=2)) + save() + server = uvicorn.Server(uvicorn.Config(app, host='127.0.0.1', log_level='warning', + timeout_graceful_shutdown=5)) + def run(): + try: + server.run(sockets=[sock]) + except BaseException as exc: + errors.append(repr(exc)) + thread = threading.Thread(target=run, daemon=True) + try: + check_stop(stop) + thread.start() + deadline = time.monotonic() + 1200 + print(json.dumps(record), flush=True) + wait_ready(server, thread, errors, stop=stop) + if native_app: + check_stop(stop) + binary, identifier = native_copy(Path(native_app), root, sign_identity, environment(root), stop) + account = native_url(url) + record['native'] = {'original': str(native_app), 'original_sha256': APP_HASH, + 'copy': str(binary), 'bundle_id': identifier, 'account_url': account} + save() + wait_ready(server, thread, errors, stop=stop) + launch([str(binary), '-ToolstackAdminURL', account], + {**environment(root), 'TOOLSTACK_ADMIN_URL': account}, children, _process_identity, save, stop) + if desktop: + wait_ready(server, thread, errors, stop=stop) + launch([sys.executable, '-m', 'desktop'], + {**environment(root), 'TOOLSTACK_ADMIN_URL': url}, children, _process_identity, save, stop) + while thread.is_alive() and time.monotonic() < deadline and not stop.is_set(): + stop.wait(.1) + if errors or not thread.is_alive(): + raise RuntimeError(f'backend exited unexpectedly: {errors}') + finally: + for child in reversed(children): + try: + child['cleanup'] = stop_child(child, _process_identity) + except Exception as exc: + child['cleanup'] = 'uncertain: ' + repr(exc) + server.should_exit = True + if thread.ident is not None: + thread.join(7) + record.update(backend_stopped=not thread.is_alive(), backend_errors=errors, + cleanup_uncertain=thread.is_alive() or bool(errors) or + any(c.get('cleanup') != 'reaped' for c in children)) + save() + (root / 'stopped.json').write_text(json.dumps(record, indent=2)) + if record['cleanup_uncertain']: + raise RuntimeError('fixture cleanup uncertain; see private fixture/stopped records') + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group() + mode.add_argument('--prepare', action='store_true', help='report only; creates nothing') + mode.add_argument('--serve', action='store_true', help='future parent/chief hands admission ONLY') + parser.add_argument('--root', help='absent approved-temp child; required for serve') + parser.add_argument('--native-app', help='existing verified original; future private copy only') + parser.add_argument('--sign-identity', help='explicit existing Apple Development identity') + parser.add_argument('--desktop', action='store_true', help='future owned pywebview launch') + args = parser.parse_args(argv) + if (args.native_app or args.sign_identity or args.desktop) and not args.serve: + parser.error('app flags require --serve') + if bool(args.native_app) != bool(args.sign_identity): + parser.error('--native-app and --sign-identity are required together') + if not args.serve: + print(json.dumps({'status': 'OFF', 'runtime': runtime_report(), + 'apps_launched': False, 'listener': False}, indent=2)) + return + if not args.root: + parser.error('--serve requires --root') + serve(validate_root(args.root), args.native_app, args.sign_identity, args.desktop) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/process_sps.py b/tests/integration/process_sps.py new file mode 100644 index 0000000..c5ddd92 --- /dev/null +++ b/tests/integration/process_sps.py @@ -0,0 +1,381 @@ +"""Finite fake-data process/SPS release proof; requires an explicitly owned root.""" +from __future__ import annotations +import argparse +from dataclasses import asdict +from datetime import datetime, timedelta, timezone +import ipaddress +import json +import os +from pathlib import Path +import platform +import shlex +import signal +import socket +import stat +import subprocess +import sys +import time +import urllib.request +import urllib.error +import uuid + + +def private_root(raw): + path = Path(raw) + if not path.is_absolute() or '..' in path.parts: + raise ValueError('absolute normalized root required') + fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + try: + for part in path.parts[1:-1]: + nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = nxt + info = os.fstat(fd) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise ValueError('parent must be owned mode 0700') + os.mkdir(path.name, 0o700, dir_fd=fd) + finally: + os.close(fd) + return path + + +def tool(raw): + root = Path(raw) + assert root.is_absolute() and str(root) == os.environ.get('HOME') + fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + try: + for part in root.parts[1:]: + assert part not in ('.', '..') + nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = nxt + info = os.fstat(fd) + assert info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o700 + markerfd = os.open('fixture.marker', os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd) + with os.fdopen(markerfd) as stream: + mi = os.fstat(stream.fileno()) + assert stat.S_ISREG(mi.st_mode) and mi.st_uid == os.getuid() and stat.S_IMODE(mi.st_mode) == 0o600 + assert json.load(stream) == {'device': info.st_dev, 'inode': info.st_ino} + finally: + os.close(fd) + assert os.environ.get('TOOLSTACK_SPS_HOST') == '127.0.0.1' + assert os.environ.get('TOOLSTACK_TOOL_CONFIG') == str(root / 'external/slice/toolyard.toml') + assert os.environ.get('TOOLSTACK_SPS_CA') == str(root / 'sps.crt') + for path in (root / 'external', root / 'external/slice', root / 'external/slice/toolyard.toml', root / 'sps.crt'): + assert not path.is_symlink() and path.stat().st_uid == os.getuid() + import hmac + from http.server import HTTPServer, BaseHTTPRequestHandler + from sps.tool_sdk import SecretClient + assert os.environ.get('TOOLSTACK_SPS_VERIFY') in ('1', 'true') + sdk = SecretClient.from_env('slice') + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if not hmac.compare_digest(self.headers.get('X-Toolstack-Secret', ''), os.environ['TOOLSTACK_CHANNEL_SECRET']): + self.send_response(401) + self.end_headers() + return + value = sdk.refresh('proof') + data = json.dumps({'string': isinstance(value, str), 'gate': isinstance(value, str) and value.startswith('FAKE-SLICE-')}).encode() + self.send_response(200) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + def log_message(self, *args): + pass + HTTPServer(('127.0.0.1', int(os.environ['TOOLSTACK_PORT'])), Handler).serve_forever() + + +def main(root): + import cryptography + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from sps.client import SPSClient, AuthError, NotFound + from sps.plugins.localfile import _Vault + from toolyard.cli import state_lock, _save_state + from toolyard.config import load + from toolyard.runner import ProcessRunner, _process_identity, _boot_id, reregister_all_with_sps_detailed + from admin.sps_watchdog import SPSWatchdog + from types import SimpleNamespace + assert cryptography.__version__ == '50.0.2' + os.umask(0o077) + temp = private_root(root) + # Resolve import roots from installed modules, never from the script location. + import sps, toolyard, admin + sources = list(dict.fromkeys(str(Path(m.__file__).resolve().parent.parent) for m in (sps, toolyard, admin))) + info = temp.stat() + (temp / 'fixture.marker').write_text(json.dumps({'device': info.st_dev, 'inode': info.st_ino})) + env = {'PATH': '/usr/bin:/bin:/usr/sbin:/sbin', 'HOME': str(temp), 'TMPDIR': str(temp), 'TMP': str(temp), 'TEMP': str(temp), + 'PYTHONPATH': os.pathsep.join(sources), 'PYTHONNOUSERSITE': '1', 'PYTHONUNBUFFERED': '1'} + for name in ('CONFIG', 'STATE', 'DATA', 'CACHE', 'RUNTIME'): + p = temp / name.lower() + p.mkdir(mode=0o700) + env['XDG_' + name + '_HOME' if name != 'RUNTIME' else 'XDG_RUNTIME_DIR'] = str(p) + os.environ.clear() + os.environ.update(env) + evidence = {'platform': platform.platform(), 'python': platform.python_version(), 'cryptography': cryptography.__version__, 'checks': [], 'processes': [], 'cleanup': 'unknown', 'status': 'FAIL'} + children = [] + running = None + runner = ProcessRunner() + sensitive = [] + spawned = [] + stage = 'preflight' + def check(name, condition=True): + if not condition: + raise AssertionError(name) + evidence['checks'].append(name) + def start(label): + with open(temp / (label + '.log'), 'ab') as log: + p = subprocess.Popen([sys.executable, '-m', 'sps.cli', 'serve', '--config', str(config)], env={**env, 'SP_VAULT_PASSPHRASE': password, 'TOOLSTACK_SPS_BOOT_ID_PATH': str(temp / 'boot')}, cwd=temp, stdout=log, stderr=log, start_new_session=True) + children.append((p, _process_identity(p.pid))) + evidence['processes'].append({'label': label, 'pid': p.pid, 'identity': children[-1][1]}) + return p + def stop(p, identity): + if p.poll() is None: + assert p.pid > 1 and identity and _process_identity(p.pid) == identity + os.killpg(p.pid, signal.SIGTERM) + try: + p.wait(timeout=3) + except subprocess.TimeoutExpired: + assert _process_identity(p.pid) == identity + os.killpg(p.pid, signal.SIGKILL) + p.wait(timeout=3) + p.wait(timeout=1) + def ready(fn): + deadline = time.monotonic() + 10 + while True: + try: + return fn() + except (OSError, RuntimeError, ValueError): + if time.monotonic() >= deadline: + raise RuntimeError('readiness deadline') from None + time.sleep(.05) + def interrupt(*args): + raise KeyboardInterrupt() + for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGALRM): + signal.signal(sig, interrupt) + signal.alarm(100) + try: + check('host boot identity preflight', bool(_boot_id())) + def port(): + with socket.socket() as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + sp, tp = port(), port() + evidence['ports'] = {'sps': sp, 'tool': tp} + def certificate(prefix): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, 'localhost')]) + now = datetime.now(timezone.utc) + cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1)).not_valid_after(now + timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension(x509.SubjectAlternativeName([x509.DNSName('localhost'), x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), critical=False).sign(key, hashes.SHA256())) + ca = temp / (prefix + '.crt') + ca.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + kp = temp / (prefix + '.key') + kp.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) + return str(ca), str(kp) + ca, key = certificate('sps') + badca, _ = certificate('unknown') + secret, password, fake, changed = [prefix + uuid.uuid4().hex for prefix in ('FAKE-SP-', 'FAKE-PASSWORD-', 'FAKE-SLICE-', 'FAKE-OTHER-')] + sensitive.extend([secret, password, fake, changed]) + vaultfile = temp / 'vault.json' + _Vault.init(vaultfile, password) + vault = _Vault(vaultfile, password) + vault.set('slice', 'PROOF', fake) + config = temp / 'sps.env' + config.write_text('\n'.join(f'{k} = {json.dumps(v)}' for k, v in {'SP_HOST': '127.0.0.1', 'SP_PORT': str(sp), 'SP_SECRET': secret, 'SP_TLS_CERT': ca, 'SP_TLS_KEY': key, 'SP_TLS_CA': ca, 'SP_AUDIT_LOG': str(temp / 'sps.audit'), 'SP_PLUGIN': 'localfile'}.items()) + '\n[localfile]\nVAULT_FILE = ' + json.dumps(str(vaultfile)) + '\n') + stage = 'initial SPS' + sps = start('sps') + registrar = SPSClient('127.0.0.1', sp, sp_secret=secret, ca_file=ca, timeout=2) + boot = ready(registrar.ping)['boot_id'] + check('verified TLS ping') + try: + SPSClient('127.0.0.1', sp, sp_secret=secret, ca_file=badca, timeout=2).ping() + except Exception as exc: + check('unknown CA rejected', str(exc) == 'SSLCertVerificationError') + else: + raise AssertionError('unknown CA accepted') + toolpath = temp / 'external' / 'slice' + toolpath.mkdir(parents=True) + (temp / 'tools').mkdir() + manifest = toolpath / 'toolyard.toml' + command = shlex.join([sys.executable, str(Path(__file__).resolve()), 'tool', str(temp)]) + manifest.write_text(f'id = "slice"\ntype = "api"\n[entrypoint]\nport = {tp}\ncommand = {json.dumps(command)}\n[[operations]]\nname = "check"\nrisk = "read"\n[[secrets]]\nname = "proof"\nfield = "PROOF"\n') + os.environ.update({'TOOLSTACK_SPS_ENV': str(config), 'TOOLSTACK_SPS_VERIFY': 'true', 'TOOLSTACK_TOOLYARD_CHANNEL_STATE': str(temp / 'channels.json')}) + stage = 'runner start' + # Transparent observation of the real spawn; arguments and environment + # are neither retained nor modified. Restore even on partial failure. + original_spawn = os.posix_spawn + def observed_spawn(*args, **kwargs): + pid = original_spawn(*args, **kwargs) + record = {'label': 'runner spawn', 'pid': pid, 'identity': _process_identity(pid), 'boot': _boot_id()} + spawned.append(record) + evidence['processes'].append(record) + return pid + try: + os.posix_spawn = observed_spawn + with state_lock(): + running = runner.start(load(manifest)) + _save_state({'slice': asdict(running)}) + finally: + os.posix_spawn = original_spawn + stage = 'channel checks' + sensitive.extend([running.e_secret, running.channel_secret]) + evidence['processes'].append({'label': 'tool', 'pid': int(running.handle), 'identity': running.process_identity, 'boot': running.boot_id}) + check('independent credentials', running.e_secret != running.channel_secret) + publication = (temp / 'channels.json').read_text() + check('channel publication excludes SPS credentials', secret not in publication and running.e_secret not in publication) + def request(credential): + req = urllib.request.Request(f'http://127.0.0.1:{tp}/check', data=b'{}', headers={'X-Toolstack-Secret': credential}) + with urllib.request.urlopen(req, timeout=3) as response: + return json.load(response) + check('SDK refresh string and safe boolean', ready(lambda: request(running.channel_secret)) == {'string': True, 'gate': True}) + try: + request(running.e_secret) + except urllib.error.HTTPError as exc: + check('SPS credential rejected by channel', exc.code == 401) + exc.close() + else: + raise AssertionError('cross-use accepted') + try: + SPSClient('127.0.0.1', sp, esecret=running.channel_secret, ca_file=ca, timeout=2).get_secrets('slice') + except AuthError: + check('channel credential rejected by SPS') + else: + raise AssertionError('cross-use accepted') + stage = 'restart and recovery' + stop(*children[0]) + sps = start('sps-restart') + newboot = ready(registrar.ping)['boot_id'] + evidence['boots'] = [boot, newboot] + check('restart new boot', boot != newboot) + client = SPSClient('127.0.0.1', sp, esecret=running.e_secret, ca_file=ca, timeout=2) + try: + client.get_secrets('slice') + except (AuthError, NotFound): + check('registrations lost') + else: + raise AssertionError('registration survived') + attempts = [] + def recover(**kwargs): + attempts.append(newboot) + if len(attempts) == 1: + hidden = manifest.with_suffix('.hidden') + manifest.rename(hidden) + try: + result = reregister_all_with_sps_detailed(**kwargs) + finally: + hidden.rename(manifest) + check('actual first recovery incomplete', not result.complete) + return result + result = reregister_all_with_sps_detailed(**kwargs) + check('actual second recovery complete', result.complete and result.count == 1) + return result + watchdog = SPSWatchdog(config=SimpleNamespace(tools_root=str(temp / 'tools'), tool_dirs=[str(toolpath)]), probe=lambda: registrar.ping()['boot_id'], re_register_fn=recover, sps_env_path=str(config)) + watchdog._check_once() + check('incomplete boot unacknowledged', watchdog._last_seen is None) + watchdog._check_once() + check('same boot retry', attempts == [newboot, newboot] and watchdog._last_seen == newboot) + check('new TLS retrieval', client.get_secret('slice', 'proof') == fake) + vault.set('slice', 'PROOF', changed) + check('persisted change affects next SDK refresh', request(running.channel_secret) == {'string': True, 'gate': False}) + check('same live tool identity', runner.is_alive(running) and _process_identity(int(running.handle)) == running.process_identity) + check('vault encrypted', all(v.encode() not in vaultfile.read_bytes() for v in (fake, changed))) + evidence['status'] = 'PASS' + except BaseException as exc: + evidence['failure_type'] = type(exc).__name__ + evidence['failure_stage'] = stage + finally: + signal.alarm(0) + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, signal.SIG_IGN) + clean = True + if running: + try: + runner.stop(running) + deadline = time.monotonic() + 4 + while True: + try: + pid, _ = os.waitpid(int(running.handle), os.WNOHANG) + except ChildProcessError: + break + if pid: + break + if time.monotonic() >= deadline: + raise RuntimeError('tool reap deadline') + time.sleep(.05) + with state_lock(): + _save_state({}) + except BaseException: + clean = False + for record in spawned: + try: + pid, identity = record['pid'], record['identity'] + assert pid > 1 and identity and record['boot'] == _boot_id() + current = _process_identity(pid) + if current is not None: + assert current == identity + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + 3 + killed = False + while True: + try: + reaped, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + assert _process_identity(pid) is None + break + if reaped: + break + if time.monotonic() >= deadline: + if killed: + raise RuntimeError('partial child reap deadline') + assert _process_identity(pid) == identity + os.kill(pid, signal.SIGKILL) + killed = True + deadline = time.monotonic() + 3 + time.sleep(.05) + record['cleanup'] = 'reaped and absent' + assert _process_identity(pid) is None + except BaseException: + clean = False + for child, identity in reversed(children): + try: + stop(child, identity) + except BaseException: + clean = False + logs = list(temp.rglob('*.log')) + list(temp.rglob('*.audit')) + if running: + logs.append(Path(running.log_path)) + try: + check('logs and audit exclude all fake values and credentials', all(value not in p.read_text() for p in logs for value in sensitive)) + except BaseException: + evidence['status'] = 'FAIL' + evidence['cleanup'] = 'reaped' if clean else 'unknown' + if not clean: + evidence['status'] = 'FAIL' + (temp / 'result.json').write_text(json.dumps(evidence, indent=2)) + print(json.dumps(evidence)) + return 0 if evidence['status'] == 'PASS' else 1 + + +if __name__ == '__main__': + def early_interrupt(*args): + raise KeyboardInterrupt() + for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGALRM): + signal.signal(sig, early_interrupt) + if len(sys.argv) == 3 and sys.argv[1] == 'tool': + tool(sys.argv[2]) + else: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', required=True) + args = parser.parse_args() + try: + code = main(args.root) + except BaseException as exc: + print(json.dumps({'status': 'FAIL', 'failure_stage': 'setup-or-reporting', 'failure_type': type(exc).__name__, 'cleanup': 'unknown; inspect private evidence'})) + code = 1 + finally: + signal.alarm(0) + sys.exit(code) diff --git a/tests/integration/test_installed_stack.py b/tests/integration/test_installed_stack.py new file mode 100644 index 0000000..40a5704 --- /dev/null +++ b/tests/integration/test_installed_stack.py @@ -0,0 +1,165 @@ +"""Pure boundary tests: no listeners, network, or subprocess creation.""" +import importlib.util +import os +import io +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('installed_stack', Path(__file__).with_name('installed_stack.py')) +subject = importlib.util.module_from_spec(spec) +spec.loader.exec_module(subject) + + +class Boundaries(unittest.TestCase): + def test_import_is_inert(self): + with patch('subprocess.Popen', side_effect=AssertionError('spawn')), patch('socket.socket', side_effect=AssertionError('socket')): + spec.loader.exec_module(subject) + + def test_environment_allowlist(self): + with patch.dict(os.environ, {'HTTPS_PROXY': 'secret', 'TOKEN': 'secret'}): + env = subject.environment(Path('/private/example')) + self.assertNotIn('TOKEN', env) + self.assertNotIn('HTTPS_PROXY', env) + self.assertNotIn('PYTHONPATH', env) + self.assertTrue(all(v.startswith('/private/example') for k, v in env.items() if k.startswith('XDG_'))) + + def test_root_no_clobber_and_symlink(self): + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory).resolve() + root = subject.private_root(parent / 'proof') + self.assertEqual(root.stat().st_mode & 0o777, 0o700) + with self.assertRaises(FileExistsError): + subject.private_root(root) + (parent / 'link').symlink_to(root) + with self.assertRaises(subject.BoundaryError): + subject.private_root(parent / 'link' / 'child') + + def test_evidence_exclusive_private(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'evidence' + subject.private_json(path, {'outcome': 'INCOMPLETE'}) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + with self.assertRaises(FileExistsError): + subject.private_json(path, {}) + + def test_provenance(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + origin = root / 'module.py' + origin.write_text('# fixture\n') + finder = lambda _: SimpleNamespace(origin=str(origin), submodule_search_locations=[]) + self.assertEqual(len(subject.installed_origins(root, root / 'source', finder)), 6) + with self.assertRaises(subject.BoundaryError): + subject.installed_origins(root / 'venv', root, finder) + + def test_unknown_identity_refuses_signal(self): + self.assertTrue(subject.may_signal(42, 'birth', 'boot', 'birth', 'boot')) + for args in [(42, None, 'boot', None, 'boot'), (1, 'a', 'b', 'a', 'b'), + (42, 'a', 'b', 'other', 'b'), (42, 'a', None, 'a', None)]: + self.assertFalse(subject.may_signal(*args)) + + def test_body_bounds(self): + self.assertEqual(subject.body_length(str(subject.MAX_BODY)), subject.MAX_BODY) + for raw in ['-1', '+1', ' 1', '1048577', '9' * 100, '1']: + with self.assertRaises(subject.BoundaryError): + subject.body_length(raw) + + def test_budget(self): + self.assertLessEqual(subject.RUN_BUDGET_SECONDS, 240) + self.assertLessEqual(subject.CLEANUP_BUDGET_SECONDS, 30) + + def test_fake_decision_mapping(self): + self.assertEqual(subject.decision('opaque', False)['status'], 'pending') + resolved = subject.decision('opaque', True) + self.assertEqual(resolved['request_id'], 'opaque') + self.assertEqual(resolved['decision']['option_kind'], 'approve') + + def test_fake_http_without_socket(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + server = SimpleNamespace(kind='nod', root=root, credential='fake-issuer', control='separate-control', cards={}) + def request(path, payload=None, token='fake-issuer'): + handler = object.__new__(subject.FixtureHandler) + handler.server, handler.path = server, path + raw = json.dumps(payload).encode() if payload is not None else b'' + handler.headers = {'Authorization': 'Bearer ' + token, 'Content-Length': str(len(raw))} + handler.rfile = io.BytesIO(raw) + result = [] + handler.respond = lambda code, data: result.append((code, data)) + handler.dispatch(payload is not None) + return result[0] + payload = {'dedupe_key': 'fixture', 'options': []} + self.assertEqual(request('/api/v1/requests', payload, 'wrong')[0], 401) + code, result = request('/api/v1/requests', payload) + ref = result['request_id'] + self.assertEqual(code, 200) + self.assertEqual(request('/api/v1/requests', payload)[1], result) + self.assertEqual(len(server.cards), 1) + endpoint = '/api/v1/requests/' + ref + '/decision' + self.assertEqual(request(endpoint)[1]['status'], 'pending') + control = root / 'approve.json' + control.write_text(json.dumps({'credential': 'fake-issuer', 'request_id': ref})) + self.assertEqual(request(endpoint)[1]['status'], 'pending') + control.write_text(json.dumps({'credential': 'separate-control', 'request_id': ref})) + self.assertEqual(request(endpoint)[1]['status'], 'resolved') + self.assertEqual(request('/api/v1/requests', {'dedupe_key': 'fixture', 'changed': True})[0], 409) + + def test_absence_is_positive(self): + with patch.object(subject.os, 'kill', side_effect=ProcessLookupError): + self.assertTrue(subject.absent(42)) + with patch.object(subject.os, 'kill', return_value=None): + self.assertFalse(subject.absent(42)) + with patch.object(subject.os, 'kill', side_effect=PermissionError): + with self.assertRaises(PermissionError): + subject.absent(42) + + def test_interrupt_and_deadline(self): + with self.assertRaises(subject.BudgetExpired): + subject.interrupted(subject.signal.SIGALRM, None) + with self.assertRaises(subject.BoundaryError): + subject.interrupted(15, None) + with patch.object(subject.time, 'monotonic', side_effect=[0, 13]): + with self.assertRaises(subject.BoundaryError): + subject.ready(lambda: None) + + def test_lost_start_once_and_reconcile(self): + intent = {'admin': {'pid': 42, 'identity': 'admin-birth', 'boot': 'boot'}, 'port': 12345} + events = [] + def lost(): + events.append('POST') + raise TimeoutError() + with self.assertRaises(TimeoutError): + subject.start_once(intent, lambda _: events.append('persist'), lost) + self.assertEqual(events, ['persist', 'POST']) + rows = subject.parse_processes('43 42 /venv/bin/python -m broker.server\n') + identities = {42: 'admin-birth', 43: 'broker-birth'} + result = subject.reconcile_broker(intent, {'pid': 43, 'port': 12345}, rows, + identities.get, 'boot', '/venv/bin/python') + self.assertEqual(result, (43, 'broker-birth')) + stopped = [] + subject.stop_once(lambda: stopped.append('stop') or {'running': False}, + lambda: True, lambda: stopped.append('clear')) + self.assertEqual(stopped, ['stop', 'clear']) + with self.assertRaises(subject.BoundaryError): + subject.stop_once(lambda: {'running': False}, lambda: False, + lambda: self.fail('cleared before absence')) + with self.assertRaises(subject.BudgetExpired): + subject.stop_once(lambda: subject.interrupted(subject.signal.SIGALRM, None), + lambda: True, lambda: self.fail('deadline swallowed')) + for changed in [None, {'pid': 43, 'port': 9}]: + with self.assertRaises(subject.BoundaryError): + subject.reconcile_broker(intent, changed, rows, identities.get, 'boot', '/venv/bin/python') + for bad_rows, identity in [({43: (99, rows[43][1])}, identities.get), + (rows, lambda pid: identities.get(pid) if pid == 42 else None), + ({43: (42, ['/foreign/python', '-m', 'broker.server'])}, identities.get)]: + with patch.object(subject.os, 'kill', side_effect=AssertionError('signal refused')): + with self.assertRaises(subject.BoundaryError): + subject.reconcile_broker(intent, {'pid': 43, 'port': 12345}, bad_rows, identity, 'boot', '/venv/bin/python') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/integration/test_live_ui_fixture.py b/tests/integration/test_live_ui_fixture.py new file mode 100644 index 0000000..a5ce420 --- /dev/null +++ b/tests/integration/test_live_ui_fixture.py @@ -0,0 +1,239 @@ +"""Pure fakes: no sockets, signing, app launches or GUI imports.""" +import importlib.util +from pathlib import Path +import subprocess +import unittest +from unittest.mock import Mock, MagicMock, patch + +spec = importlib.util.spec_from_file_location('fixture', Path(__file__).with_name('live_ui_fixture.py')) +f = importlib.util.module_from_spec(spec) +spec.loader.exec_module(f) + + +class FixtureTests(unittest.TestCase): + def test_off_and_flags(self): + with patch.object(f, 'serve') as serve, patch('builtins.print'): + f.main([]) + f.main(['--prepare']) + for args in (['--desktop'], ['--prepare', '--native-app', 'a'], + ['--serve', '--native-app', 'a']): + with self.assertRaises(SystemExit): + f.main(args) + serve.assert_not_called() + + def test_unique_account_and_environment(self): + a, b = f.native_url('http://127.0.0.1:123'), f.native_url('http://127.0.0.1:123') + self.assertNotEqual(a, b) + self.assertEqual(a.split('#')[0], 'http://127.0.0.1:123') + with patch.dict(f.os.environ, {'REAL_TOKEN': 'never'}, clear=True): + self.assertNotIn('REAL_TOKEN', f.environment(Path('/fake'))) + + def test_reuse_not_signalled(self): + p = Mock(pid=42) + p.poll.return_value = None + self.assertIn('uncertain', f.stop_child({'proc': p, 'pid': 42, 'identity': 'old'}, lambda _: 'new')) + p.terminate.assert_not_called() + p.kill.assert_not_called() + + def test_finite_cleanup(self): + p = Mock(pid=42) + p.poll.return_value = None + p.wait.side_effect = [subprocess.TimeoutExpired('fake', 5), 0] + self.assertEqual(f.stop_child({'proc': p, 'pid': 42, 'identity': 'same'}, lambda _: 'same'), 'reaped') + p.terminate.assert_called_once() + p.kill.assert_called_once() + self.assertEqual([c.kwargs for c in p.wait.call_args_list], [{'timeout': 5}, {'timeout': 5}]) + + def test_failed_readiness(self): + with self.assertRaises(RuntimeError): + f.wait_ready(Mock(started=False), Mock(is_alive=lambda: False), ['failed']) + with self.assertRaises(RuntimeError): + f.wait_ready(Mock(started=True), Mock(is_alive=lambda: True), ['failed']) + + def test_unsafe_pids_and_unknown_identity(self): + for pid, actual, start in [(1, 1, 's'), (0, 0, 's'), (-1, -1, 's'), + ('42', 42, 's'), (True, 42, 's'), + (42, 43, 's'), (42, 42, None)]: + p = Mock(pid=actual) + p.poll.return_value = None + self.assertIn('uncertain', f.stop_child({'proc': p, 'pid': pid, 'identity': start}, lambda _: 's')) + p.terminate.assert_not_called() + p.kill.assert_not_called() + + def test_signal_stop_closes_admission_and_cleans_thread(self): + for sig in (f.signal.SIGTERM, f.signal.SIGHUP): + handlers = {} + def install(number, handler): + handlers[number] = handler + return 'previous' + server = Mock(started=True) + thread = Mock(ident=42) + thread.is_alive.return_value = False + root = MagicMock() + child = Mock(pid=42) + child.poll.return_value = None + modules = {'admin': MagicMock(), 'admin.server': MagicMock(), + 'toolyard.runner': MagicMock(), 'uvicorn': MagicMock()} + modules['uvicorn'].Server.return_value = server + modules['toolyard.runner']._process_identity.return_value = 'start' + def ready(*args, **kwargs): + handlers[sig](sig, None) # fake invocation, never an actual OS signal + with patch.dict(f.sys.modules, modules), patch.dict(f.os.environ), \ + patch.object(f.sys, 'path', list(f.sys.path)), \ + patch.object(f.os, 'umask'), patch.object(f, 'bootstrap'), \ + patch.object(f, 'environment', return_value={}), \ + patch.object(f, 'runtime_report', return_value={}), \ + patch.object(f.socket, 'socket', return_value=MagicMock()), \ + patch.object(f.threading, 'Thread', return_value=thread), \ + patch.object(f.signal, 'signal', side_effect=install), \ + patch.object(f, 'wait_ready', side_effect=ready), \ + patch.object(f.subprocess, 'Popen') as popen, \ + patch.object(f, 'native_copy') as copy, patch('builtins.print'): + f.serve(root, 'fake.app', 'Apple Development: FAKE') + copy.assert_not_called() + popen.assert_not_called() + self.assertTrue(server.should_exit) + thread.join.assert_called_once_with(7) + self.assertTrue(all(value == 'previous' for value in handlers.values())) + + def test_launch_records_before_return(self): + children = [] + p = Mock(pid=42) + with patch.object(f.subprocess, 'Popen', return_value=p): + f.launch(['fake'], {}, children, lambda _: 'start', lambda: self.assertEqual(children[0]['identity'], 'start')) + + def test_controlled_stop_unknown_cleanup_propagates(self): + root = MagicMock() + server = Mock(started=True) + thread = Mock(ident=42) + thread.is_alive.return_value = False + children = [{'proc': Mock(pid=pid), 'pid': pid, 'identity': 'start'} + for pid in (42, 43)] + modules = {'admin': MagicMock(), 'admin.server': MagicMock(), + 'toolyard.runner': MagicMock(), 'uvicorn': MagicMock()} + modules['uvicorn'].Server.return_value = server + modules['toolyard.runner']._process_identity.return_value = 'start' + def stopped_launch(argv, env, owned, identity, record, stop): + owned.extend(children) + stop.set() + raise InterruptedError('controlled stop') + with patch.dict(f.sys.modules, modules), patch.dict(f.os.environ), \ + patch.object(f.sys, 'path', list(f.sys.path)), \ + patch.object(f.os, 'umask'), patch.object(f, 'bootstrap'), \ + patch.object(f, 'environment', return_value={}), \ + patch.object(f, 'runtime_report', return_value={}), \ + patch.object(f.socket, 'socket', return_value=MagicMock()), \ + patch.object(f.threading, 'Thread', return_value=thread), \ + patch.object(f.signal, 'signal'), patch.object(f, 'wait_ready'), \ + patch.object(f, 'launch', side_effect=stopped_launch), \ + patch.object(f, 'stop_child', side_effect=['uncertain: unknown identity', 'reaped']) as cleanup, \ + patch('builtins.print'): + with self.assertRaisesRegex(RuntimeError, 'cleanup uncertain'): + f.serve(root, desktop=True) + self.assertEqual(cleanup.call_count, 2) + self.assertEqual([call.args[0]['pid'] for call in cleanup.call_args_list], [43, 42]) + thread.join.assert_called_once_with(7) + self.assertTrue(server.should_exit) + records = root.__truediv__.return_value.write_text.call_args_list + self.assertTrue(f.json.loads(records[-1].args[0])['cleanup_uncertain']) + self.assertIn(unittest.mock.call('stopped.json'), root.__truediv__.call_args_list) + + def test_stop_after_spawn_records_then_cleanup(self): + for sig in (f.signal.SIGTERM, f.signal.SIGHUP): + handlers = {} + def install(number, handler): + handlers[number] = handler + return None + p = Mock(pid=42) + p.poll.return_value = None + children = [] + with patch.object(f.signal, 'signal', side_effect=install), \ + patch.object(f.subprocess, 'Popen', return_value=p) as popen: + with f.stop_handlers() as stop: + try: + f.launch(['fake'], {}, children, lambda _: 'start', + lambda: handlers[sig](sig, None), stop) + with self.assertRaises(InterruptedError): + f.launch(['second'], {}, children, lambda _: 'start', Mock(), stop) + finally: + for child in children: + self.assertEqual(f.stop_child(child, lambda _: 'start'), 'reaped') + popen.assert_called_once() + p.terminate.assert_called_once() + p.wait.assert_called_once_with(timeout=5) + + def test_native_hash_gate_rejects_old_and_wrong_hash(self): + self.assertEqual(f.APP_HASH, '28cf9127f93d4ffd30fe7d91d120b862d4e2fc1c3a845b50210bd6a369b18600') + for digest in ('9092236f5acec2deb7f83377cba9ea3c57551a4b585f95fb72de4cf829bca15b', '0' * 64): + with self.subTest(digest=digest), \ + patch.object(Path, 'is_symlink', return_value=False), \ + patch.object(Path, 'is_dir', return_value=True), \ + patch.object(Path, 'resolve', lambda p: p), \ + patch.object(Path, 'rglob', return_value=[]), \ + patch.object(Path, 'read_bytes', return_value=b'fake'), \ + patch.object(Path, 'write_bytes') as write, \ + patch.object(f.hashlib, 'sha256', return_value=Mock(hexdigest=lambda: digest)), \ + patch.object(f.shutil, 'copytree') as copy, \ + patch.object(f.subprocess, 'run', return_value=Mock(stderr='Authority=Apple Development: FAKE\n')) as command: + with self.assertRaisesRegex(ValueError, 'original executable hash mismatch'): + f.native_copy(Path('/fake/Original.app'), Path('/fake/root'), 'Apple Development: FAKE', {}) + copy.assert_not_called() + write.assert_not_called() + self.assertEqual([call.args[0][1] for call in command.call_args_list], ['--verify', '-dv']) + + def test_native_copy_order_and_metadata(self): + app, root = Path('/fake/Original.app'), Path('/fake/root') + events = [] + env = f.environment(root) + original_env = dict(env) + def command(argv, **kwargs): + self.assertEqual(argv[0], '/usr/bin/codesign') + self.assertEqual(kwargs['env'], {**original_env, 'HOME': '/fake/owner'}) + self.assertNotIn('FAKE_SECRET', kwargs['env']) + events.append(argv) + return Mock(stderr='Authority=Apple Development: FAKE\n') + with patch.object(Path, 'is_symlink', return_value=False), \ + patch.object(Path, 'is_dir', return_value=True), \ + patch.object(Path, 'resolve', lambda p: p), \ + patch.object(Path, 'rglob', return_value=[]), \ + patch.object(Path, 'read_bytes', return_value=b'fake'), \ + patch.object(Path, 'write_bytes') as write, \ + patch.object(f.hashlib, 'sha256', return_value=Mock(hexdigest=lambda: f.APP_HASH)), \ + patch.object(f.plistlib, 'loads', return_value={'CFBundleIdentifier': 'original'}), \ + patch.object(f.shutil, 'copytree', side_effect=lambda *a, **k: events.append('copy')), \ + patch.object(f.subprocess, 'run', side_effect=command), \ + patch.object(f.pwd, 'getpwuid', return_value=Mock(pw_dir='/fake/owner')), \ + patch.dict(f.os.environ, {'FAKE_SECRET': 'never'}, clear=True): + binary, identifier = f.native_copy(app, root, 'Apple Development: FAKE', env) + self.assertEqual(env, original_env) + with patch.object(f.subprocess, 'Popen', return_value=Mock(pid=42)) as popen: + f.launch(['fake'], env, [], lambda _: 'start', Mock()) + self.assertIs(popen.call_args.kwargs['env'], env) + self.assertEqual(popen.call_args.kwargs['env'], original_env) + self.assertEqual(events[0][1:4], ['--verify', '--deep', '--strict']) + self.assertEqual(events[2], 'copy') + self.assertIn('--timestamp=none', events[3]) + self.assertEqual(events[4][1], '--verify') + self.assertTrue(identifier.startswith('com.toolstack.operator.fixture.')) + self.assertTrue(binary.is_relative_to(root)) + self.assertIn(identifier.encode(), write.call_args.args[0]) + + def test_invalid_owner_home_stops_before_codesign(self): + for home in ('', 'relative', '/'): + with self.subTest(home=home), \ + patch.object(Path, 'is_symlink', return_value=False), \ + patch.object(Path, 'is_dir', return_value=True), \ + patch.object(Path, 'resolve', lambda p: p), \ + patch.object(Path, 'rglob', return_value=[]), \ + patch.object(f.pwd, 'getpwuid', return_value=Mock(pw_dir=home)), \ + patch.object(f.subprocess, 'run') as command, \ + patch.object(f.shutil, 'copytree') as copy: + with self.assertRaisesRegex(ValueError, 'OS account owner home'): + f.native_copy(Path('/fake/Original.app'), Path('/fake/root'), + 'Apple Development: FAKE', {}) + command.assert_not_called() + copy.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/integration/vertical_slice.py b/tests/integration/vertical_slice.py new file mode 100644 index 0000000..769d6c1 --- /dev/null +++ b/tests/integration/vertical_slice.py @@ -0,0 +1,519 @@ +"""Real local vertical slice. Not a host-isolation or deployment acceptance test. + +Only published Nod is downloaded; every runtime request is literal loopback. +All children inherit a constructed environment, never the invoking shell's. +""" +from __future__ import annotations + +import base64 +from dataclasses import asdict +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import signal +import socket +import subprocess +import sys +import tarfile +import time +import urllib.error +import urllib.request +import uuid + +ROOT = Path(__file__).resolve().parents[2] +URL = 'https://github.com/batteryshark/nod/releases/download/v1.0.1/nod-server-v1.0.1-aarch64-apple-darwin.tar.gz' +DIGEST = '43cf90c0043759f3ace7beb13c76878ada19efed9f9dbced536cf674556d4bf0' +APPROVED = Path('/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode') + + +def free_port(): + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + return sock.getsockname()[1] + + +def api(base, path, token='', body=None, method=None): + assert base.startswith('http://127.0.0.1:') + headers = {'Content-Type': 'application/json'} + if token: + headers['Authorization'] = 'Bearer ' + token + req = urllib.request.Request(base + path, headers=headers, + data=json.dumps(body).encode() if body is not None else None, + method=method) + with urllib.request.urlopen(req, timeout=3) as response: + return json.loads(response.read() or b'null') + + +def ready(fn, processes): + deadline = time.monotonic() + 12 + last = None + while time.monotonic() < deadline: + assert all(p.poll() is None for p in processes), 'owned service exited during readiness' + try: + return fn() + except (OSError, ValueError, RuntimeError) as exc: + last = exc + time.sleep(.05) # bounded service readiness only, not agent/model polling + raise RuntimeError('readiness deadline') from last + + +def fixture_tool(): + import hmac + from http.server import BaseHTTPRequestHandler, HTTPServer + from sps.tool_sdk import SecretClient + sdk = SecretClient.from_env('slice') + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + expected = os.environ['TOOLSTACK_CHANNEL_SECRET'] + authorized = hmac.compare_digest(self.headers.get('X-Toolstack-Secret', ''), expected) + if not authorized: + self.send_response(401) + self.end_headers() + return + # Fetch anew over verified TLS each invocation, not merely a startup cache. + value = sdk.refresh('proof') + with open(Path(os.environ['HOME']) / 'effects', 'a') as effect: + effect.write('effect\n') + # Secret affects a safe boolean; neither value nor reversible encoding is returned. + result = {'secret_gate': value.startswith('FAKE-SLICE-'), 'operation': self.path.rsplit('/', 1)[-1]} + data = json.dumps(result).encode() + self.send_response(200) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *args): + pass + + HTTPServer(('127.0.0.1', int(os.environ['TOOLSTACK_PORT'])), Handler).serve_forever() + + +def main(): + import cryptography + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from broker.identity import hash_token + from broker.store import Store + from sps.cli import maybe_generate_tls_material + from sps.client import SPSClient, AuthError, NotFound + from sps.plugins.localfile import _Vault + from toolyard.cli import state_lock, _save_state + from toolyard.config import load + from toolyard.runner import ProcessRunner, _process_identity, reregister_all_with_sps_detailed + from admin.sps_watchdog import SPSWatchdog + from types import SimpleNamespace + + temp = Path(os.environ['TOOLSTACK_E2E_ROOT']).resolve() + assert temp.is_relative_to(APPROVED) and temp != APPROVED + assert cryptography.__version__ == '50.0.2' + os.umask(0o077) + base_env = {'PATH': '/usr/bin:/bin:/usr/sbin:/sbin', 'HOME': str(temp), + 'TMPDIR': str(temp), 'PYTHONPATH': str(ROOT), 'PYTHONUNBUFFERED': '1', + 'PYTHONNOUSERSITE': '1', + **{name: str(temp / name.lower()) for name in + ('XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR')}} + for name, value in base_env.items(): + if name.startswith('XDG_'): + Path(value).mkdir(mode=0o700, exist_ok=True) + os.environ.clear() + os.environ.update(base_env) + processes = [] + evidence = {'nod_release': 'v1.0.1', 'archive_sha256': DIGEST, + 'python': sys.version.split()[0], 'cryptography': cryptography.__version__, + 'checks': [], 'processes': []} + runner = ProcessRunner() + running = None + admin = None + operator = None + supervised = None + + def check(name, condition=True): + assert condition, name + evidence['checks'].append(name) + + def start(label, argv, extra=None): + log = open(temp / (label + '.log'), 'ab') + try: + proc = subprocess.Popen(argv, cwd=ROOT, env={**base_env, **(extra or {})}, + stdout=log, stderr=log, start_new_session=True) + finally: + log.close() + identity = _process_identity(proc.pid) + assert identity is not None + processes.append((proc, identity, label)) + evidence['processes'].append({'label': label, 'pid': proc.pid, 'identity': identity}) + return proc + + def stop(proc): + record = next(x for x in processes if x[0] is proc) + if proc.poll() is None: + assert proc.pid > 1 and _process_identity(proc.pid) == record[1] + os.killpg(proc.pid, signal.SIGTERM) + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + assert _process_identity(proc.pid) == record[1] + os.killpg(proc.pid, signal.SIGKILL) + proc.wait(timeout=3) + + def cli(*args, success=True): + result = subprocess.run([sys.executable, '-m', 'client.toolstack', *args], + env={**base_env, 'TOOLSTACK_URL': broker_url, + 'TOOLSTACK_TOKEN': agent_token}, cwd=ROOT, + capture_output=True, text=True, timeout=10) + assert result.returncode == (0 if success else 1), 'CLI unexpected exit (output withheld)' + check('CLI output contains no fake secret', fake not in result.stdout + result.stderr) + return json.loads(result.stdout) + + def interrupted(signum, frame): + raise KeyboardInterrupt('fixture interrupted; cleaning owned processes') + + signal.signal(signal.SIGTERM, interrupted) + try: + # HTTPS public artifact only; do not consult installed Nod or existing data. + archive = temp / 'nod.tar.gz' + with urllib.request.urlopen(URL, timeout=45) as response: + blob = response.read(64 * 1024 * 1024 + 1) + assert len(blob) <= 64 * 1024 * 1024 + assert hashlib.sha256(blob).hexdigest() == DIGEST + archive.write_bytes(blob) + with tarfile.open(archive) as tar: + members = tar.getmembers() + for member in members: + path = PurePosixPath(member.name) + assert not path.is_absolute() and '..' not in path.parts + assert member.isfile() or member.isdir(), 'links/special archive members refused' + assert member.size <= 64 * 1024 * 1024, 'oversize archive member' + assert sum(m.size for m in members) <= 128 * 1024 * 1024 + tar.extractall(temp / 'release', filter='data') + binary, = list((temp / 'release').rglob('nod-server')) + binary.chmod(0o700) + evidence['nod_binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest() + np, sp, tp, bp = (free_port() for _ in range(4)) + nod_url = f'http://127.0.0.1:{np}' + broker_url = f'http://127.0.0.1:{bp}' + evidence['ports'] = {'nod': np, 'sps_tls': sp, 'tool': tp, 'broker': bp} + admin_token = 'FAKE-nod-admin-' + uuid.uuid4().hex + nod = start('nod', [str(binary)], {'NOD_BIND': f'127.0.0.1:{np}', + 'NOD_ADMIN_TOKEN': admin_token, 'NOD_DATABASE_URL': f'sqlite://{temp}/nod.sqlite', + 'NOD_DATA_DIR': str(temp / 'nod-data'), 'NOD_CALLBACK_ALLOWED_ORIGINS': ''}) + ready(lambda: api(nod_url, '/health'), [nod]) + api(nod_url, '/api/v1/admin/users', admin_token, {'id': 'fake-user', 'name': 'Fake local user'}) + api(nod_url, '/api/v1/admin/channels', admin_token, {'id': 'slice', 'name': 'Fake slice'}) + code = api(nod_url, '/api/v1/admin/users/fake-user/enrollment-codes', admin_token, + {'expires_in_seconds': 600})['code'] + issuer = api(nod_url, '/api/v1/admin/issuer-tokens', admin_token, + {'name': 'fake-issuer', 'scopes': ['requests:write', 'requests:read']})['token'] + key = ec.generate_private_key(ec.SECP256R1()) + b64 = lambda value: base64.urlsafe_b64encode(value).decode().rstrip('=') + public = b64(key.public_key().public_bytes(serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint)) + enrolled = api(nod_url, '/api/v1/enroll', body={'code': code, 'device_name': 'FAKE software device', + 'platform': 'linux', 'signing_key': {'key_id': 'fake-key', + 'algorithm': 'p256_ecdsa_sha256', 'public_key': public}}) + device_token, device_id = enrolled['token'], enrolled['device_id'] + api(nod_url, '/api/v1/devices/me/subscriptions/slice', device_token, + {'subscribed': True}, 'PUT') + + cert, keyfile, ca = (str(temp / name) for name in ('sps.crt', 'sps.key', 'ca.crt')) + maybe_generate_tls_material(cert, keyfile, ca, '/CN=localhost') + config = temp / 'sps.env' + vaultfile = temp / 'vault.json' + sp_secret = 'FAKE-SP-' + uuid.uuid4().hex + passphrase = 'FAKE-PASSPHRASE-' + uuid.uuid4().hex + fake = 'FAKE-SLICE-' + uuid.uuid4().hex + _Vault.init(vaultfile, passphrase) + vault = _Vault(vaultfile, passphrase) + vault.set('slice', 'PROOF', fake) + config.write_text('\n'.join(f'{k} = {json.dumps(v)}' for k, v in { + 'SP_HOST': '127.0.0.1', 'SP_PORT': str(sp), 'SP_SECRET': sp_secret, + 'SP_TLS_CERT': cert, 'SP_TLS_KEY': keyfile, 'SP_TLS_CA': ca, + 'SP_AUDIT_LOG': str(temp / 'sps.audit'), 'SP_PLUGIN': 'localfile'}.items()) + + f'\n[localfile]\nVAULT_FILE = {json.dumps(str(vaultfile))}\n') + config.chmod(0o600) + sps_env = {'SP_VAULT_PASSPHRASE': passphrase, + 'TOOLSTACK_SPS_BOOT_ID_PATH': str(temp / 'sps.boot')} + sps = start('sps', [sys.executable, '-m', 'sps.cli', 'serve', '--config', str(config)], sps_env) + registrar = SPSClient('127.0.0.1', sp, sp_secret=sp_secret, ca_file=ca, timeout=2) + boot = ready(registrar.ping, [sps])['boot_id'] + check('verified TLS ping') + toolpath = temp / 'external/slice' + toolpath.mkdir(parents=True) + manifest = toolpath / 'toolyard.toml' + manifest.write_text(f'''id = "slice" +type = "api" +[entrypoint] +port = {tp} +command = "{sys.executable} {Path(__file__).resolve()} tool" +[[operations]] +name = "check" +risk = "read" +approval_fields = ["/destination", "/credential"] +[[operations]] +name = "blocked" +risk = "read" +[[secrets]] +name = "proof" +field = "PROOF" +''') + runner_env = {'TOOLSTACK_SPS_ENV': str(config), 'TOOLSTACK_SPS_VERIFY': 'true', + 'TOOLSTACK_TOOLYARD_CHANNEL_STATE': str(temp / 'channels.json')} + os.environ.update(runner_env) + with state_lock(): + running = runner.start(load(manifest)) + _save_state({'slice': asdict(running)}) + evidence['processes'].append({'label': 'runner-tool', 'pid': int(running.handle), + 'identity': running.process_identity}) + check('independent credentials', running.e_secret != running.channel_secret) + channels = (temp / 'channels.json').read_text() + check('channel-only publication', running.e_secret not in channels and sp_secret not in channels) + try: + SPSClient('127.0.0.1', sp, esecret=running.channel_secret, ca_file=ca).get_secrets('slice') + except AuthError: + check('channel credential rejected by SPS') + else: + raise AssertionError('channel credential authenticated SPS') + request = urllib.request.Request(f'http://127.0.0.1:{tp}/v1/actions/check', data=b'{}', + headers={'X-Toolstack-Secret': running.e_secret}) + try: + urllib.request.urlopen(request, timeout=3) + except urllib.error.HTTPError as exc: + check('SPS credential rejected by tool channel', exc.code == 401) + exc.close() + else: + raise AssertionError('SPS key authenticated tool channel') + + db = str(temp / 'broker.sqlite') + agent_token = 'FAKE-agent-' + uuid.uuid4().hex + store = Store(db) + caller = store.add_caller('fake-agent') + store.add_token(caller, hash_token(agent_token)) + policy = {'tools': {'slice': {'check': 'review', 'blocked': 'deny', 'refresh': 'deny'}}} + store.set_policy(caller, policy) + store.close() + # The real admin owns broker creation from the beginning; never adopt a PID. + from admin import auth, settings, broker_config + (temp / 'tools').mkdir() + password = 'FAKE-operator-' + uuid.uuid4().hex + settings.write_password_hash(auth.hash_password(password)) + settings.rotate_session_secret() + broker_config.save(broker_config.BrokerRunConfig( + port=bp, db_path=db, tools_root=str(temp / 'tools'), + tool_dirs=[str(toolpath)], nod_url=nod_url, nod_token=issuer, + nod_channel='slice', rate_limit=0)) + ap = free_port() + admin_url = f'http://127.0.0.1:{ap}' + evidence['ports']['admin'] = ap + admin_env = {**runner_env, 'TOOLSTACK_BROKER_HOST': '127.0.0.1'} + + def admin_start(label): + child = start(label, [sys.executable, '-m', 'admin', 'serve', '--port', str(ap)], admin_env) + ready(lambda: urllib.request.urlopen(admin_url + '/login', timeout=2).close(), [child]) + token = api(admin_url, '/api/login', body={'password': password})['token'] + return child, token + + def broker_start(): + nonlocal supervised + status = api(admin_url, '/api/broker/start', operator, {}) + supervised = (status['pid'], _process_identity(status['pid'])) + assert status['healthy'] and supervised[1] is not None + evidence['processes'].append({'label': 'admin-owned-broker', 'pid': supervised[0], 'identity': supervised[1]}) + + def broker_stop(): + nonlocal supervised + assert supervised and _process_identity(supervised[0]) == supervised[1] + status = api(admin_url, '/api/broker/stop', operator, {}) + check('admin API stops and reaps owned broker', not status['running'] and _process_identity(supervised[0]) is None) + supervised = None + + admin, operator = admin_start('admin') + saved = api(admin_url, '/api/config', operator, {'approval_ttl': 420}) + check('JSON config save preserves external tool_dirs', saved['tool_dirs'] == [str(toolpath)]) + import httpx + import re + with httpx.Client(base_url=admin_url, trust_env=False, timeout=5) as web: + web.cookies.set('toolstack_admin_session', operator) + page = web.get('/config') + csrf = re.search(r"name=['\"]_csrf['\"] value=['\"]([^'\"]+)", page.text).group(1) + response = web.post('/config', data={'_csrf': csrf, 'approval_ttl': '421'}) + check('authenticated HTML config save', response.status_code == 200) + check('HTML config preserves external tool_dirs', api(admin_url, '/api/config', operator)['tool_dirs'] == [str(toolpath)]) + broker_start() + denied = cli('call', 'slice.blocked', '{}', success=False) + check('explicit deny policy', denied['status'] == 'denied') + denied = cli('call', 'slice.refresh', '{}', success=False) + check('explicit refresh deny', denied['status'] == 'denied') + private = 'FAKE-private-' + uuid.uuid4().hex + reason = 'FAKE-reason-' + uuid.uuid4().hex + arguments = json.dumps({'destination': 'safe-fixture-destination', 'credential': private, 'unselected': 'omitted-fixture'}) + call = ('call', 'slice.check', arguments, '--reason', reason, '--idempotency-key', 'fixture-review-1') + pending = cli(*call) + check('review remains pending', pending['status'] == 'pending_approval') + request_id = pending['request_id'] + retry = cli(*call) + check('matching preapproval retry reuses receipt', retry['request_id'] == request_id and retry['reused']) + conflict = cli('call', 'slice.check', '{"different":true}', '--idempotency-key', 'fixture-review-1', success=False) + check('mismatching key payload conflicts', conflict.get('status') == 'conflict') + req = urllib.request.Request(broker_url + '/v1/actions/slice.check', data=b'{"arguments":{"different":true}}', + headers={'Authorization': 'Bearer ' + agent_token, 'Content-Type': 'application/json', 'Idempotency-Key': 'fixture-review-1'}) + try: + urllib.request.urlopen(req, timeout=3) + except urllib.error.HTTPError as exc: + check('mismatching payload HTTP 409', exc.code == 409) + exc.close() + else: + raise AssertionError('conflicting payload accepted') + listed = api(nod_url, '/api/v1/requests', device_token)['requests'] + nod_request, = listed + card = json.dumps(nod_request) + check('card omits private credential and reason', private not in card and reason not in card) + check('card preserves safe destination and visible omissions', 'safe-fixture-destination' in card and 'Fields outside configured pointers are omitted.' in card and '[MASKED]' in card) + check('no preapproval tool effect', not (temp / 'effects').exists()) + ref = nod_request['id'] + check('published Nod issued exact approval option pairs', + {(o['id'], o['kind']) for o in nod_request['options']} == {('approve', 'approve'), ('reject', 'reject_with_text')}) + digest = nod_request.get('signing', {}).get('request_digest') or nod_request['request_digest'] + nonce = str(uuid.uuid4()) + signed_at = datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') + text = 'FAKE local software approval' + payload = '\n'.join(['nod-decision-v1', f'request_id:{ref}', f'request_digest:{digest}', + 'option_id:approve', 'option_kind:approve', 'user_id:fake-user', + f'device_id:{device_id}', 'key_id:fake-key', f'nonce:{nonce}', + f'signed_at:{signed_at}', f'text_sha256:{hashlib.sha256(text.encode()).hexdigest()}', '']) + signature = b64(key.sign(payload.encode(), ec.ECDSA(hashes.SHA256()))) + resolved = api(nod_url, f'/api/v1/requests/{ref}/options/approve', device_token, + {'text': text, 'signature': {'key_id': 'fake-key', + 'algorithm': 'p256_ecdsa_sha256', 'nonce': nonce, 'signed_at': signed_at, + 'request_digest': digest, 'signature': signature}}) + check('real Nod verified software P256 decision', + resolved['request']['decision']['signature']['verified'] is True) + result = cli('wait', str(request_id), '--timeout', '3') + check('agent poll executes real secret-dependent tool', + result['status'] == 'ok' and result['result']['secret_gate'] is True) + retry = cli(*call) + check('matching postapproval retry reuses result', retry['request_id'] == request_id and retry['result'] == result['result'] and retry['reused']) + check('retry causes exactly one effect', (temp / 'effects').read_text() == 'effect\n') + check('retry creates no extra Nod card', len(api(nod_url, '/api/v1/requests', device_token)['requests']) == 1) + + # Stop through the owning HTTP service before restarting that service. + broker_stop() + stop(admin) + + # Restart SPS: registry is deliberately lost, vault stays encrypted on disk. + stop(sps) + sps = start('sps-restart', [sys.executable, '-m', 'sps.cli', 'serve', '--config', str(config)], sps_env) + new_boot = ready(registrar.ping, [sps])['boot_id'] + check('SPS restart new boot', boot != new_boot) + toolclient = SPSClient('127.0.0.1', sp, esecret=running.e_secret, ca_file=ca) + try: + toolclient.get_secrets('slice') + except (AuthError, NotFound): + check('restart clears registrations') + else: + raise AssertionError('registration survived restart') + attempts = [] + + def recover(**kwargs): + # Controlled actual registration failure: withhold the owned manifest + # for exactly the first pass, rather than forging a recovery result. + attempts.append(new_boot) + if len(attempts) == 1: + hidden = manifest.with_suffix('.hidden') + manifest.rename(hidden) + try: + result = reregister_all_with_sps_detailed(**kwargs) + finally: + hidden.rename(manifest) + check('actual recovery first pass incomplete', not result.complete) + return result + result = reregister_all_with_sps_detailed(**kwargs) + check('actual recovery second pass complete', result.complete and result.count == 1) + return result + + watchdog = SPSWatchdog(config=SimpleNamespace(tools_root=str(temp / 'tools'), tool_dirs=[str(toolpath)]), + probe=lambda: registrar.ping()['boot_id'], re_register_fn=recover, + sps_env_path=str(config)) + watchdog._check_once() + check('failed boot not acknowledged', watchdog._last_seen is None) + watchdog._check_once() + check('same boot retries once', attempts == [new_boot, new_boot] and watchdog._last_seen == new_boot) + check('recovered real TLS secret retrieval', toolclient.get_secret('slice', 'proof') == fake) + # Change persisted vault data: safe result must change on the next SDK refresh. + vault.set('slice', 'PROOF', 'FAKE-OTHER-' + uuid.uuid4().hex) + from broker.runtime import HttpRuntime + from broker.registry import Registry + op = Registry.from_sources(str(temp / 'tools'), [str(toolpath)]).lookup('slice', 'check') + safe = HttpRuntime(tool_secret=lambda _: running.channel_secret).execute(op, {}, 9001, 'fixture') + check('vault value affects tool result without echo', safe['secret_gate'] is False) + check('local vault encrypted', fake.encode() not in vaultfile.read_bytes()) + admin, operator = admin_start('admin-restart') + persisted = api(admin_url, '/api/config', operator) + check('actual admin restart reads persisted config', persisted['approval_ttl'] == 421 and persisted['tool_dirs'] == [str(toolpath)]) + check('admin restart retains live tool identity', runner.is_alive(running)) + check('admin restart retains persisted policy/grants', api(admin_url, '/api/callers/fake-agent/policy', operator)['policy'] == policy) + check('external tool recoverable through admin API', any(t['id'] == 'slice' for t in api(admin_url, '/api/tools', operator)['tools'])) + broker_start() + check('policy persists after broker restart', cli('call', 'slice.blocked', '{}', success=False)['status'] == 'denied') + for path in [settings.state_dir() / 'broker.log', temp / 'admin.log', temp / 'admin-restart.log', temp / 'sps.audit', + Path(running.log_path)]: + check('no fake secret in ' + path.name, fake not in path.read_text()) + import sqlite3 + with sqlite3.connect(db) as connection: + dump = '\n'.join(connection.iterdump()) + check('no fake value in broker database including audit/results', fake not in dump) + audit = json.dumps(api(admin_url, '/api/audit', operator)['audit']) + check('audit omits private credential and reason', private not in audit and reason not in audit) + broker_stop() + evidence['outcome'] = 'PASS' + finally: + cleanup_errors = [] + if supervised is not None: + try: + broker_stop() + except BaseException as exc: + cleanup_errors.append(type(exc).__name__ + ': admin-owned-broker') + # Independent fallback if the HTTP control plane itself failed. + # Stop/reap the known admin parent, then signal only the recorded + # broker identity; its orphan is reaped by the OS, not waitpid here. + try: + if admin is not None: + stop(admin) + pid, identity = supervised + for sig in (signal.SIGTERM, signal.SIGKILL): + current = _process_identity(pid) + if current is None: + break + assert current == identity, 'broker PID identity changed; refusing signal' + os.killpg(pid, sig) + deadline = time.monotonic() + 3 + while _process_identity(pid) == identity and time.monotonic() < deadline: + time.sleep(.05) # bounded teardown, never agent polling + assert _process_identity(pid) is None, 'owned broker exit unconfirmed' + except BaseException as fallback: + cleanup_errors.append(type(fallback).__name__ + ': broker fallback') + try: + if running is not None: + runner.stop(running) + check('runner tool stopped', not runner.is_alive(running)) + except BaseException as exc: + cleanup_errors.append(type(exc).__name__ + ': runner-tool') + for proc, identity, label in reversed(processes): + try: + stop(proc) + assert proc.poll() is not None + except BaseException as exc: + cleanup_errors.append(type(exc).__name__ + ': ' + label) + evidence['teardown'] = ('all owned processes stopped; temporary files removed by launcher' + if not cleanup_errors else cleanup_errors) + print(json.dumps(evidence, indent=2), flush=True) + assert not cleanup_errors, cleanup_errors + + +if __name__ == '__main__': + if sys.argv[1:] == ['tool']: + fixture_tool() + else: + main() diff --git a/tests/release/check_release.py b/tests/release/check_release.py new file mode 100644 index 0000000..752cae7 --- /dev/null +++ b/tests/release/check_release.py @@ -0,0 +1,111 @@ +"""One wheel build + isolated Python 3.11 installation, no source-path imports. + +Run with Python 3.11+ and uv available. No services, Docker, providers or real secrets. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import shutil +import sys +import tempfile +import zipfile + +ROOT = Path(__file__).resolve().parents[2] + + +def child_environment(parent, base): + """Pure allowlist: never inherit application config or credential settings.""" + allowed = ('PATH', 'TMPDIR', 'SYSTEMROOT', 'WINDIR', 'LANG', 'LC_ALL', + 'LC_CTYPE', 'UV_CACHE_DIR', 'UV_PYTHON_INSTALL_DIR') + env = {key: parent[key] for key in allowed if key in parent} + env.update(HOME=str(base), XDG_CONFIG_HOME=str(base / 'config'), + XDG_STATE_HOME=str(base / 'state'), XDG_CACHE_HOME=str(base / 'cache'), + XDG_DATA_HOME=str(base / 'data'), XDG_RUNTIME_DIR=str(base / 'runtime'), + PYTHONNOUSERSITE='1', TOOLSTACK_SPS_SKIP='1', + UV_CONCURRENT_BUILDS='1', UV_CONCURRENT_INSTALLS='1') + return env + + +def run(*args, cwd=ROOT, env=None): + subprocess.run(list(map(str, args)), cwd=cwd, env=env, check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--artifact-dir', type=Path, help='retain successful wheel and proof JSON') + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='toolstack-release-') as tmp: + base = Path(tmp) + env = child_environment(os.environ, base) + for key in ('XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', + 'XDG_DATA_HOME', 'XDG_RUNTIME_DIR'): + Path(env[key]).mkdir(mode=0o700) + run('uv', 'build', '--python', '3.11', '--wheel', '--out-dir', base / 'dist', env=env) + wheel, = (base / 'dist').glob('*.whl') + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + for package in ('admin', 'admin/views', 'broker', 'client', 'toolyard', + 'sps', 'sps/plugins', 'toolstack_forwarder'): + for source in (ROOT / package).glob('*.py'): + assert f'{package}/{source.name}' in names, source + assert 'client/SKILL.md' in names + assert 'admin/views/assets.py' in names # UI CSS/JS are Python constants + assert not any('/tests/' in n or '.env' in n or '__pycache__' in n for n in names) + entrypoints = next(n for n in names if n.endswith('.dist-info/entry_points.txt')) + assert 'admin = admin.__main__:main' in archive.read(entrypoints).decode() + run('uv', 'venv', '--python', '3.11', base / 'venv', env=env) + python = base / 'venv/bin/python' + run('uv', 'pip', 'install', '--python', python, wheel, + '-r', ROOT / 'admin/requirements.txt', '-c', ROOT / 'admin/requirements-lock.txt', env=env) + script = ''' +import importlib, pathlib, sys +assert sys.version_info[:2] == (3, 11), sys.version +for name in ('admin', 'admin.views.assets', 'admin.server', 'broker', 'client', + 'toolyard', 'sps', 'sps.plugins.localfile', 'toolstack_forwarder.__main__'): + module = importlib.import_module(name) + assert pathlib.Path(module.__file__).is_relative_to(sys.prefix), module.__file__ +from admin.server import create_app +from admin import auth, settings +settings.write_password_hash(auth.hash_password('fake-release-password-only')) +assert create_app().routes +from sps.plugins.localfile import _Vault +vault = pathlib.Path.home() / 'fake.vault' +_Vault.init(str(vault), passphrase='fake-release-only') +v = _Vault(str(vault), 'fake-release-only') +v.set('demo', 'API_KEY', 'fake-only') +assert v.has('demo', 'API_KEY') +print('Python 3.11 installed wheel imports/admin app/localfile smoke OK') +''' + run(python, '-I', '-c', script, cwd=base, env=env) + for command in ('admin', 'sps', 'brokerctl', 'toolstack', 'toolyard'): + run(base / 'venv/bin' / command, '--help', cwd=base, env=env) + # Forwarder has no argparse/help mode: absent config must fail before networking. + result = subprocess.run([str(base / 'venv/bin/toolstack-forwarder')], cwd=base, + env=env, capture_output=True, text=True) + assert result.returncode != 0 and 'toolyard.toml' in result.stderr, result.stderr + run('uv', 'pip', 'check', '--python', python, cwd=base, env=env) + if args.artifact_dir: + args.artifact_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(wheel, args.artifact_dir / wheel.name) + report = { + 'source_revision': subprocess.check_output( + ['git', 'rev-parse', 'HEAD'], cwd=ROOT, env=env, text=True).strip(), + 'python': subprocess.check_output([str(python), '--version'], env=env, text=True).strip(), + 'wheel': wheel.name, + 'sha256': hashlib.sha256(wheel.read_bytes()).hexdigest(), + 'checks': ['wheel contents and entrypoint', 'isolated clean dependency install', + 'installed-path imports with -I', 'admin app factory', + 'fake localfile vault write/presence', 'CLI help', + 'forwarder missing-config rejection', 'uv pip check'], + 'result': 'passed', + 'scope': 'Packaging smoke only; no listeners, providers, services or full E2E', + } + (args.artifact_dir / 'proof.json').write_text(json.dumps(report, indent=2) + '\n') + print('Release packaging checks passed') + + +if __name__ == '__main__': + main() diff --git a/tests/release/evidence/integrated-wheel.json b/tests/release/evidence/integrated-wheel.json new file mode 100644 index 0000000..10efed8 --- /dev/null +++ b/tests/release/evidence/integrated-wheel.json @@ -0,0 +1,20 @@ +{ + "source_revision": "bcca15731eac4a4a082b6fe907ea05d1e5236347", + "python": "Python 3.11.17", + "wheel": "toolstack-0.1.0-py3-none-any.whl", + "sha256": "1fadade5c4890a0bf65a4a567167a81d524e7738cb000e3dcac37075602a568d", + "checks": [ + "wheel contents and entrypoint", + "isolated clean dependency install (18 packages)", + "installed-path imports with -I", + "admin app factory with fake private password", + "fake localfile vault write/presence", + "admin/sps/brokerctl/toolstack/toolyard CLI help", + "forwarder missing-config rejection", + "uv pip check" + ], + "result": "passed", + "artifact_directory": "/private/var/folders/pz/lrml3ths3mg3mf4bb75rmcvc0000gn/T/opencode/toolstack-packaging-integrated", + "scope": "Packaging smoke only; no listeners, providers, services, full E2E, native signing or retention proof", + "harness_note": "Fake password fixture added after first integrated attempt correctly rejected missing admin credentials; successful wheel not rebuilt afterward." +} diff --git a/tests/release/generate_legacy_vault.py b/tests/release/generate_legacy_vault.py new file mode 100644 index 0000000..562aaff --- /dev/null +++ b/tests/release/generate_legacy_vault.py @@ -0,0 +1,21 @@ +"""Run with uv run --no-project --with cryptography==45.0.7 python this-file. + +Creates only a fake regression fixture, using the production constructor/KDF. +""" +import sys +import tempfile +from pathlib import Path + +import cryptography + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from sps.plugins.localfile import _Vault + +assert cryptography.__version__ == "45.0.7", cryptography.__version__ +with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "vault.json" + vault = _Vault.init(path, "fake-compatibility-passphrase") + vault.set("fake-tool", "token", "fake-old-value") + vault.set("fake-tool", "retained", "fake-retained") + Path(__file__).with_name("vault-45.0.7.json").write_bytes(path.read_bytes()) +print(sys.version, cryptography.__version__) diff --git a/tests/release/test_deployment.py b/tests/release/test_deployment.py new file mode 100644 index 0000000..489bf5a --- /dev/null +++ b/tests/release/test_deployment.py @@ -0,0 +1,56 @@ +"""Cheap source-contract checks; no daemon or installed service access.""" +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import tomllib +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +class DeploymentContracts(unittest.TestCase): + def test_offline_bootstrap_writable_paths(self): + with tempfile.TemporaryDirectory(prefix='toolstack-bootstrap-') as tmp: + root = Path(tmp).resolve() # macOS /var aliases /private/var + config = root / 'etc/sps.env' + subprocess.run([sys.executable, str(ROOT / 'deploy/bootstrap-sps.py'), + '--config', str(config), '--state-dir', str(root / 'state'), + '--log-dir', str(root / 'log')], cwd=ROOT, check=True, + capture_output=True, text=True, timeout=30) + data = tomllib.loads(config.read_text()) + self.assertEqual(data['localfile']['VAULT_FILE'], str(root / 'state/sps.vault.json')) + self.assertEqual(data['SP_AUDIT_LOG'], str(root / 'log/sps.audit')) + self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600) + self.assertFalse((root / 'state/sps.vault.json').exists()) + + def test_wheel_entrypoint_and_packages(self): + project = tomllib.loads((ROOT / 'pyproject.toml').read_text()) + self.assertEqual(project['project']['scripts']['admin'], 'admin.__main__:main') + self.assertIn('admin.views', project['tool']['setuptools']['packages']) + + def test_all_installers_consume_same_export(self): + for path in ('deploy/install.sh', 'deploy/redeploy-toolstack', 'deploy/docker/Dockerfile'): + source = (ROOT / path).read_text() + self.assertIn('admin/requirements.txt', source) + self.assertIn('admin/requirements-lock.txt', source) + + def test_private_units_and_no_toml_environmentfile(self): + for component in ('admin', 'sps'): + source = (ROOT / f'deploy/toolstack-{component}.service').read_text() + for setting in ('UMask=0077', 'StateDirectoryMode=0700', 'NoNewPrivileges=true'): + self.assertIn(setting, source) + sps = (ROOT / 'deploy/toolstack-sps.service').read_text() + self.assertNotIn('EnvironmentFile=-/etc/toolstack/sps.env', sps) + + def test_docker_contents_and_command(self): + source = (ROOT / 'deploy/docker/Dockerfile').read_text() + self.assertIn('COPY sps/ sps/', source) + self.assertIn('COPY toolstack_forwarder/ toolstack_forwarder/', source) + self.assertNotIn('"--host"', source) + self.assertIn('profiles: [experimental]', (ROOT / 'deploy/docker/docker-compose.yml').read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/release/test_environment.py b/tests/release/test_environment.py new file mode 100644 index 0000000..f520fb2 --- /dev/null +++ b/tests/release/test_environment.py @@ -0,0 +1,45 @@ +"""Pure release child-environment checks: no imports of Toolstack or subprocesses.""" +from pathlib import Path +import unittest + +from check_release import child_environment + + +class ChildEnvironment(unittest.TestCase): + def test_rejects_credentials_and_application_configuration(self): + parent = {key: 'must-not-inherit' for key in ( + 'SP_SECRET', 'SP_VAULT_PASSPHRASE', 'SP_VAULT_PASSPHRASE_FILE', + 'SP_CONFIG', 'TOOLSTACK_SPS_ENV', 'TOOLSTACK_E_SECRET', + 'TOOLSTACK_CHANNEL_SECRET', 'TOOLSTACK_TOOLYARD_STATE', + 'SPS_CONFIG', 'AWS_SECRET_ACCESS_KEY', 'VAULT_TOKEN', + 'INFISICAL_TOKEN', 'UV_INDEX_URL', 'HTTPS_PROXY', + 'PYTHONPATH', 'PYTHONHOME', 'UNEXPECTED_FUTURE_SETTING')} + original = parent.copy() + env = child_environment(parent, Path('/fake/private/release')) + self.assertTrue(set(parent).isdisjoint(env)) + self.assertEqual(parent, original) + self.assertEqual(env['TOOLSTACK_SPS_SKIP'], '1') + + def test_preserves_only_explicit_runtime_paths(self): + parent = {key: '/fake/' + key for key in ( + 'PATH', 'TMPDIR', 'UV_CACHE_DIR', 'UV_PYTHON_INSTALL_DIR')} + env = child_environment(parent, Path('/fake/private/release')) + for key, value in parent.items(): + self.assertEqual(env[key], value) + + def test_home_and_all_xdg_paths_are_temporary_from_start(self): + base = Path('/fake/private/release') + parent = {key: '/real/operator' for key in ( + 'HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', + 'XDG_DATA_HOME', 'XDG_RUNTIME_DIR')} + env = child_environment(parent, base) + self.assertEqual(env['HOME'], str(base)) + for key in parent.keys() - {'HOME'}: + self.assertTrue(Path(env[key]).is_relative_to(base)) + self.assertEqual(env['PYTHONNOUSERSITE'], '1') + self.assertEqual(env['UV_CONCURRENT_BUILDS'], '1') + self.assertEqual(env['UV_CONCURRENT_INSTALLS'], '1') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/release/test_signing.py b/tests/release/test_signing.py new file mode 100644 index 0000000..acb85aa --- /dev/null +++ b/tests/release/test_signing.py @@ -0,0 +1,96 @@ +"""Local signing command contracts only; never invoke real Apple tools.""" +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +PACKAGING = ROOT / 'macapp/packaging' +IDENTITY = 'Apple Development: FAKE Identity (FAKE123)' + + +class LocalSigning(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='local-sign-contract-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.root.chmod(0o700) + self.app = self.root / 'Fake Operator.app' + (self.app / 'Contents').mkdir(parents=True) + self.log = self.root / 'argv.json' + self.bin = self.root / 'bin' + self.bin.mkdir() + # No shell/real codesign fallback: this is the only command named codesign. + import sys + stub = self.bin / 'codesign' + stub.write_text(f'#!{sys.executable}\n' + 'import json, os, sys\n' + 'with open(os.environ["ARGV_LOG"], "w") as f: json.dump(sys.argv[1:], f)\n' + 'sys.exit(int(os.environ.get("FAKE_EXIT", "0")))\n') + stub.chmod(0o700) + for command in ('security', 'xcrun', 'spctl', 'swift', 'curl', 'ditto'): + trap = self.bin / command + trap.write_text('#!/bin/bash\nexit 99\n') + trap.chmod(0o700) + self.env = {'PATH': f'{self.bin}:/usr/bin:/bin:/usr/sbin:/sbin', + 'HOME': str(self.root), 'TMPDIR': str(self.root), + 'ARGV_LOG': str(self.log)} + + def run_script(self, name, *args, env=None): + return subprocess.run(['/bin/bash', str(PACKAGING / name), *map(str, args)], + cwd=self.root, env=env or self.env, + capture_output=True, text=True, timeout=5) + + def test_explicit_identity_and_spaces_exact_argv(self): + result = self.run_script('sign-local.sh', '--identity', IDENTITY, '--app', self.app) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(self.log.read_text()), + ['--force', '--sign', IDENTITY, '--timestamp=none', str(self.app)]) + + def test_environment_identity_and_argument_override(self): + env = dict(self.env, TOOLSTACK_LOCAL_SIGN_IDENTITY=IDENTITY) + self.assertEqual(self.run_script('sign-local.sh', '--app', self.app, env=env).returncode, 0) + self.assertEqual(self.run_script('sign-local.sh', '--identity', IDENTITY, + '--app', self.app, env=dict(env, TOOLSTACK_LOCAL_SIGN_IDENTITY='-')).returncode, 0) + + def test_verification_independent_strict_deep(self): + result = self.run_script('verify-local.sh', self.app) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(self.log.read_text()), + ['--verify', '--deep', '--strict', '--verbose=2', str(self.app)]) + + def test_failures_propagate_without_fallback(self): + for name, args in (('sign-local.sh', ('--identity', IDENTITY, '--app', self.app)), + ('verify-local.sh', (self.app,))): + with self.subTest(name=name): + result = self.run_script(name, *args, env=dict(self.env, FAKE_EXIT='42')) + self.assertEqual(result.returncode, 42) + + def test_missing_invalid_inputs_never_call_codesign(self): + cases = [('sign-local.sh', ['--app', self.app]), + ('sign-local.sh', ['--identity', '-','--app', self.app]), + ('sign-local.sh', ['--identity', 'Developer ID Application: FAKE','--app', self.app]), + ('sign-local.sh', ['--identity', IDENTITY,'--app', self.root / 'missing.app']), + ('sign-local.sh', ['--identity']), + ('sign-local.sh', ['--build']), + ('verify-local.sh', [self.root / 'missing.app']), + ('verify-local.sh', [self.app, self.app])] + for name, args in cases: + with self.subTest(name=name, args=args): + self.assertNotEqual(self.run_script(name, *args).returncode, 0) + self.assertFalse(self.log.exists()) + + def test_no_private_sources_or_distribution_commands(self): + # Inspect only the new scripts, never open any private credential files. + for name in ('sign-local.sh', 'verify-local.sh'): + source = (PACKAGING / name).read_text() + for forbidden in ('signing.env', 'secrets.env', 'notarytool', 'spctl', + 'security ', 'xcrun ', 'swift ', 'source ', 'eval ', + 'sign-and-notarize.sh', '--options runtime'): + self.assertNotIn(forbidden, source) + self.assertNotRegex(source, r'(?m)^\s*\.\s') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/release/test_vault_compatibility.py b/tests/release/test_vault_compatibility.py new file mode 100644 index 0000000..659ca4a --- /dev/null +++ b/tests/release/test_vault_compatibility.py @@ -0,0 +1,32 @@ +"""Real legacy envelope regression; fixture generated with pinned cryptography 45.0.7.""" +import json +from pathlib import Path +import tempfile +import unittest + +from sps.plugins.localfile import _Vault + + +class LegacyVaultCompatibility(unittest.TestCase): + def test_read_update_reopen_legacy_envelope(self): + fixture = Path(__file__).with_name("vault-45.0.7.json") + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "vault.json" + path.write_bytes(fixture.read_bytes()) + path.chmod(0o600) + original = json.loads(path.read_text()) + vault = _Vault(path, "fake-compatibility-passphrase") + self.assertEqual(vault.get("fake-tool", "token"), "fake-old-value") + self.assertEqual(vault.get("fake-tool", "retained"), "fake-retained") + vault.set("fake-tool", "token", "fake-updated-value") + reopened = _Vault(path, "fake-compatibility-passphrase") + self.assertEqual(reopened.get("fake-tool", "token"), "fake-updated-value") + self.assertEqual(reopened.get("fake-tool", "retained"), "fake-retained") + updated = json.loads(path.read_text()) + for key in ("version", "kdf", "salt", "n", "r", "p"): + self.assertEqual(original[key], updated[key]) + self.assertNotEqual(original["ciphertext"], updated["ciphertext"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_vm_proof_adapter.py b/tests/release/test_vm_proof_adapter.py new file mode 100644 index 0000000..7727579 --- /dev/null +++ b/tests/release/test_vm_proof_adapter.py @@ -0,0 +1,356 @@ +"""Fake filesystem and command tests for the finite VM proof adapter.""" + +from pathlib import Path +import contextlib +import io +import os +import stat +import tempfile +import unittest + +from vm_proof_adapter import ( + SHUTDOWN_SUBPROCESS_TIMEOUT, + StopLatch, + begin_build, + cancel_owned_child, + create_record_start, + dry_run_plan, + main, + prepare_run_root, + parse_starttime, + run_guard, + timeout_for, + write_private, +) +from vm_proof_guard import ( + PROOF_PS_FORMAT, + NOD_HEALTH_BODY, + PARENT_MEM_AVAILABLE_KIB, + BaselineContainer, + Completed, + GuardConfig, + GuardRefusal, + ProofRecords, + agent_ready_argv, + onebox_build_argv, +) + +RUN = "vmproof-20261024T150000Z-a1b2c3d4" +FULL_ID = "ab" * 32 +PRESSURE = ( + "some avg10=0.00 avg60=0.00 avg300=0.00 total=1\n" + "full avg10=0.00 avg60=0.00 avg300=0.00 total=1\n" +) +FREE = ( + " total used free shared buff/cache available\n" + "Mem: 7950 1000 500 10 2000 6952\n" +) + + +def config() -> GuardConfig: + return GuardConfig(RUN, (BaselineContainer("nod", "healthy"),)) + + +class FakeIO: + def __init__(self): + self.calls = [] + self.timeouts = [] + self.writes = [] + self.events = [] + self.vm130 = "stopped" + self.memory = 8192 + self.admit = None + self.lease = 0.0 + self.start_code = 0 + self.agent_code = 0 + self.fail = None + self.container_ids = [] + self.child_list = [] + self.observed = {} + self.low_after_start = False + self.pressure = PRESSURE + self.nod = NOD_HEALTH_BODY + self.prod = "nod\tUp 11 days (healthy)" + self.spawned = [] + + def run(self, argv, timeout): + self.calls.append(argv) + self.timeouts.append(timeout) + self.events.append(("run", argv)) + if self.fail is not None: + exc = self.fail + self.fail = None + raise exc + if timeout != timeout_for(argv): + raise AssertionError((argv, timeout)) + if argv == ("qm", "set", "130", "--memory", "4096"): + if self.vm130 != "stopped": + return Completed(1, "") + self.memory = 4096 + return Completed(0, "") + if argv == ("qm", "start", "130"): + if self.start_code == 0: + self.vm130 = "running" + return Completed(self.start_code, "") + if argv == ("qm", "shutdown", "130", "--timeout", "90"): + self.vm130 = "stopped" + return Completed(0, "") + if argv == ("qm", "set", "130", "--memory", "8192"): + if self.vm130 != "stopped": + return Completed(1, "") + self.memory = 8192 + return Completed(0, "") + if argv == ("qm", "status", "130"): + return Completed(0, f"status: {self.vm130}") + if argv == ("qm", "status", "100"): + return Completed(0, "status: running") + if argv == ("qm", "config", "130"): + return Completed(0, f"memory: {self.memory}\ncores: 2\nonboot: 0\nagent: 1\n") + if argv[0] == "curl": + return Completed(0, self.nod) + if argv[0] == "free": + return Completed(0, FREE) + if argv[:4] == ("qm", "guest", "exec", "100"): + return Completed(0, self.prod) + if argv == agent_ready_argv(): + return Completed(self.agent_code, "") + if argv[:4] == ("qm", "guest", "exec", "130"): + return self._guest(argv[7:]) + if argv[:1] == ("kill",): + self.observed.pop(int(argv[2]), None) + return Completed(0, "") + raise AssertionError(argv) + + def _guest(self, inner): + if inner[:2] == ("docker", "create"): + return Completed(0, FULL_ID + "\n") + if inner[:2] == ("docker", "start"): + return Completed(0, FULL_ID) + if inner == ("docker", "ps", "-a", "--no-trunc", "--format", PROOF_PS_FORMAT): + return Completed(0, "") + if inner[:5] == ("docker", "ps", "-a", "--no-trunc", "--filter"): + return Completed(0, "") + return Completed(0, "") + + def read(self, path): + if path == "/proc/meminfo": + kib = 1 if self.low_after_start and ("qm", "start", "130") in self.calls else PARENT_MEM_AVAILABLE_KIB + return f"MemAvailable: {kib} kB\n" + if path == "/proc/pressure/memory": + return self.pressure + return None + + def lease_age(self): + return self.lease + + def admit_text(self): + return self.admit + + def identity(self): + return (44, 100) + + def children(self): + return tuple(self.child_list) + + def observe(self, pid): + return self.observed.get(pid) + + def write_new(self, name, text): + if name in {item[0] for item in self.writes}: + raise GuardRefusal("no-clobber") + self.writes.append((name, text)) + + def record_container(self, container_id): + self.events.append(("record", container_id)) + self.container_ids.append(container_id) + + def records(self): + return ProofRecords(RUN, tuple(self.container_ids), ()) + + def spawn(self, argv, timeout): + self.spawned.append((argv, timeout)) + self.child_list.append((55, 200)) + self.observed[55] = 200 + return (55, 200) + + +def run(io, ticks=4): + return run_guard(io, config(), StopLatch(), lambda: 0.0, lambda _seconds: None, max_ticks=ticks) + + +class AdapterSafety(unittest.TestCase): + def test_ready_is_written_before_resize_and_without_admit_nothing_starts(self): + io = FakeIO() + result = run(io, ticks=2) + self.assertIn("ready", [name for name, _text in io.writes]) + self.assertNotIn(("qm", "set", "130", "--memory", "4096"), io.calls) + self.assertNotIn("lease", [name for name, _text in io.writes]) + self.assertNotIn("admit", [name for name, _text in io.writes]) + self.assertIn("tick-limit", result.reasons) + self.assertFalse(result.restored) + self.assertIn("no-owned-transition", result.reasons) + + def test_lost_lease_and_bad_production_and_unknown_do_not_resize(self): + leased = FakeIO() + leased.lease = 100 + leased.admit = RUN + self.assertIn("parent-lease-stale", run(leased, ticks=2).reasons) + self.assertNotIn(("qm", "set", "130", "--memory", "4096"), leased.calls) + bad = FakeIO() + bad.prod = "nod\tUp 11 days (unhealthy)" + bad.admit = RUN + self.assertIn("production-unhealthy:nod", run(bad, ticks=1).reasons) + unknown = FakeIO() + unknown.pressure = None + unknown.admit = RUN + self.assertIn("pressure-unknown", run(unknown, ticks=1).reasons) + self.assertNotIn(("qm", "start", "130"), unknown.calls) + + def test_running_guest_and_low_headroom_do_not_write_ready_or_resize(self): + running = FakeIO() + running.vm130 = "running" + running.admit = RUN + run(running, ticks=1) + self.assertNotIn("ready", [name for name, _text in running.writes]) + self.assertNotIn(("qm", "set", "130", "--memory", "4096"), running.calls) + tight = FakeIO() + tight.admit = RUN + + def tight_read(path): + if path == "/proc/meminfo": + return "MemAvailable: 2097152 kB\n" + return FakeIO.read(tight, path) + + tight.read = tight_read + run(tight, ticks=1) + self.assertNotIn(("qm", "set", "130", "--memory", "4096"), tight.calls) + + def test_start_failure_after_resize_restores_without_force(self): + io = FakeIO() + io.admit = RUN + io.start_code = 1 + result = run(io, ticks=2) + self.assertEqual(io.calls.count(("qm", "start", "130")), 1) + self.assertIn(("qm", "set", "130", "--memory", "8192"), io.calls) + self.assertLess( + io.calls.index(("qm", "set", "130", "--memory", "4096")), + io.calls.index(("qm", "start", "130")), + ) + self.assertNotIn(("qm", "stop", "130"), io.calls) + self.assertNotIn("--forceStop", [part for call in io.calls for part in call]) + self.assertEqual(io.memory, 8192) + self.assertIn("start-failed", result.reasons) + self.assertTrue(result.ask_parent or result.restored) + + def test_guest_agent_failure_shuts_down_once_with_finite_timeout(self): + io = FakeIO() + io.admit = RUN + io.agent_code = 1 + result = run(io, ticks=10) + self.assertIn("guest-agent-unavailable", result.reasons) + self.assertEqual(io.calls.count(("qm", "shutdown", "130", "--timeout", "90")), 1) + shutdown = io.calls.index(("qm", "shutdown", "130", "--timeout", "90")) + self.assertEqual(io.timeouts[shutdown], SHUTDOWN_SUBPROCESS_TIMEOUT) + self.assertNotIn(("qm", "stop", "130"), io.calls) + self.assertEqual(io.memory, 8192) + + def test_low_memory_after_start_rolls_back_and_does_not_build(self): + io = FakeIO() + io.admit = RUN + io.low_after_start = True + result = run(io, ticks=4) + self.assertIn("mem-floor", result.reasons) + self.assertIn(("qm", "shutdown", "130", "--timeout", "90"), io.calls) + self.assertFalse(any("build" in call for call in io.calls)) + + def test_exception_funnels_to_rollback_without_start(self): + io = FakeIO() + io.admit = RUN + io.fail = RuntimeError("probe failed") + result = run(io, ticks=2) + self.assertTrue(result.ask_parent) + self.assertIn("exception", result.reasons) + self.assertNotIn(("qm", "start", "130"), io.calls) + + def test_late_create_and_build_are_refused_after_stop(self): + io = FakeIO() + latch = StopLatch() + latch.trip("late") + with self.assertRaises(GuardRefusal): + create_record_start(io, latch, ("docker", "create", "image")) + with self.assertRaises(GuardRefusal): + begin_build(io, latch, onebox_build_argv(RUN)) + self.assertEqual(io.calls, []) + self.assertEqual(io.spawned, []) + + def test_create_records_full_id_before_start(self): + io = FakeIO() + container_id = create_record_start(io, StopLatch(), ("docker", "create", "--label", "x", "image")) + self.assertEqual(container_id, FULL_ID) + record_at = io.events.index(("record", FULL_ID)) + start_at = next(i for i, event in enumerate(io.events) if event[0] == "run" and event[1][7:9] == ("docker", "start")) + self.assertLess(record_at, start_at) + short = FakeIO() + + def short_run(argv, timeout): + short.calls.append(argv) + if argv[7:9] == ("docker", "create"): + return Completed(0, "a" * 12) + return Completed(0, "") + + short.run = short_run + with self.assertRaises(GuardRefusal): + create_record_start(short, StopLatch(), ("docker", "create", "image")) + self.assertFalse(any(len(call) > 8 and call[7:9] == ("docker", "start") for call in short.calls)) + + def test_cancel_uses_exact_pid_and_starttime_only(self): + io = FakeIO() + io.observed[44] = 101 + self.assertEqual(cancel_owned_child(io, 44, 100), "not-signalled") + self.assertEqual(io.calls, []) + io.observed[44] = 100 + + def observe_forever(pid): + return 100 if pid == 44 else None + + io.observe = observe_forever + self.assertEqual(cancel_owned_child(io, 44, 100), "still-alive") + self.assertEqual(io.calls, [("kill", "-TERM", "44"), ("kill", "-KILL", "44")]) + + def test_private_root_is_absent_only_and_refuses_symlink_and_clobber(self): + with tempfile.TemporaryDirectory() as tmp: + root = prepare_run_root(tmp, RUN) + self.assertEqual(stat.S_IMODE(os.stat(root).st_mode), 0o700) + write_private(root, "state", "container " + FULL_ID) + self.assertEqual(stat.S_IMODE(os.stat(os.path.join(root, "state")).st_mode), 0o600) + with self.assertRaises(GuardRefusal): + prepare_run_root(tmp, RUN) + with self.assertRaises(GuardRefusal): + write_private(root, "state", "overwrite") + with self.assertRaises(GuardRefusal): + write_private(root, "lease", "no") + link_parent = os.path.join(tmp, "linked") + os.symlink(tmp, link_parent) + with self.assertRaises(GuardRefusal): + prepare_run_root(link_parent, "vmproof-20261024T150001Z-a1b2c3d4") + os.symlink(os.path.join(root, "state"), os.path.join(root, "log")) + with self.assertRaises(GuardRefusal): + write_private(root, "log", "through-symlink") + + def test_starttime_parser_and_dry_run_do_not_touch_a_host(self): + self.assertEqual(parse_starttime("44 (python foo) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 0 0 1 0 99"), 99) + self.assertIsNone(parse_starttime("not a stat")) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(main([]), 0) + self.assertEqual(main(["--execute", "--root", "/tmp/not-pve", "--run-id", RUN]), 2) + plan = dry_run_plan(RUN) + self.assertIn("no host command is executed", plan) + self.assertIn("never writes lease", plan) + self.assertIn("100", plan) + source = Path(__file__).with_name("vm_proof_adapter.py").read_text() + self.assertNotIn("import subprocess", source) + self.assertNotIn("subprocess.run", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_vm_proof_checkout.py b/tests/release/test_vm_proof_checkout.py new file mode 100644 index 0000000..414c0d2 --- /dev/null +++ b/tests/release/test_vm_proof_checkout.py @@ -0,0 +1,236 @@ +"""Actual fixed inventory, fake bounded commands/NSS/stat; never sudo or Git.""" +import contextlib +import io +import json +from pathlib import Path +import stat +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import vm_proof_payload as p +import vm_proof_guard as g +from vm_proof_driver import GuestProof, Source +from vm_proof_adapter import StopLatch + + +class InventoryHarness(contextlib.ExitStack): + path = "/srv/actual-checkout" + + def __enter__(self): + super().__enter__() + self.calls = [] + self.admin = "User=admin\nGroup=admin\nWorkingDirectory=" + self.path + "\n" + self.sps = "User=\nGroup=\nWorkingDirectory=\n" + self.fail = None + self.stat_text = "admin:admin 755 " + self.path + self.info = SimpleNamespace(st_uid=1000, st_gid=1000, st_mode=stat.S_IFDIR | 0o755, st_dev=1, st_ino=2) + self.enter_context(patch.object(p, "bounded", side_effect=self.command)) + self.directory = self.enter_context(patch.object(p, "safe_directory", side_effect=lambda _: self.info)) + account = SimpleNamespace(pw_name="admin", pw_uid=1000, pw_gid=1000) + self.user = self.enter_context(patch.object(p.pwd, "getpwuid", return_value=account)) + self.enter_context(patch.object(p.pwd, "getpwnam", return_value=account)) + self.enter_context(patch.object(p.grp, "getgrnam", return_value=SimpleNamespace(gr_gid=1000))) + self.group = self.enter_context(patch.object(p.grp, "getgrgid", return_value=SimpleNamespace(gr_name="admin", gr_gid=1000))) + return self + + def command(self, argv, root, seconds=30): + self.calls.append(argv) + assert seconds == 5 and root == Path("/") + if self.fail and self.fail in argv: + raise ValueError("RAW_SECRET_STDERR_DO_NOT_PERSIST") + if argv[0] == "systemctl": + assert argv[1:4] == ("show", "-p", "User,Group,WorkingDirectory") + return self.admin if argv[-1] == "toolstack-admin.service" else self.sps + if argv[0] == "stat": + return self.stat_text + assert argv[:6] == ("sudo", "-n", "-u", "admin", "--", "git") + if "--show-toplevel" in argv: + return self.path + "\n" + if "--abbrev-ref" in argv: + return "owner-branch\n" + if "rev-parse" in argv: + return "a" * 40 + "\n" + if "submodule" in argv: + return "" + return " M owner-work\n?? .env\n R old -> new\n" + + +class CheckoutTests(unittest.TestCase): + def refusal(self, fake, step, reason=None, checkout="discover"): + with self.assertRaises(p.InventoryFailure) as caught: + p.inventory(checkout) + envelope = caught.exception.envelope + self.assertTrue(p.inventory_error(envelope)) + self.assertEqual(envelope["step"], step) + if reason: + self.assertEqual(envelope["reason"], reason) + self.assertNotIn("RAW_SECRET", json.dumps(envelope)) + return envelope + + def test_actual_sequence_owner_and_dirty_paths(self): + with InventoryHarness() as fake: + result = p.inventory("discover") + self.assertEqual(result["owner"], "admin") + self.assertEqual(result["sps"], {"state": "unknown-or-absent"}) + self.assertEqual(result["status"], " M owner-work\n?? .env\n R old -> new\n") + self.assertEqual([c[0] for c in fake.calls], ["systemctl", "systemctl", "stat"] + ["sudo"] * 5) + self.assertEqual(result["group"], "admin") + self.assertEqual(result["mode"], 0o755) + self.assertTrue(all(call.args == (Path(fake.path),) for call in fake.directory.call_args_list)) + + def test_missing_ambiguous_and_unsafe_service_identity(self): + for text in ("User=admin\nGroup=admin\n", "User=admin\nUser=root\nGroup=admin\nWorkingDirectory=/srv/repo\n", + "User=admin\nGroup=admin\nWorkingDirectory=\n", + "User=admin;id\nGroup=admin\nWorkingDirectory=/srv/repo\n", + "User=admin\nGroup=admin\nWorkingDirectory=/srv/../repo\n"): + with self.subTest(text=text), InventoryHarness() as fake: + fake.admin = text + self.refusal(fake, "serviceprops", "invalid-metadata") + self.assertEqual(len(fake.calls), 1) + + def test_known_path_verified_against_actual(self): + with InventoryHarness() as fake: + self.refusal(fake, "serviceprops", checkout="/opt/guessed") + with InventoryHarness() as fake: + self.assertEqual(p.inventory(fake.path)["path"], fake.path) + + def test_unknown_root_and_mismatched_owners(self): + with InventoryHarness() as fake: + fake.user.side_effect = KeyError("RAW_SECRET") + self.refusal(fake, "statowner", "owner-unknown") + with InventoryHarness() as fake: + fake.user.return_value.pw_uid = 0 + self.refusal(fake, "statowner", "owner-unknown") + with InventoryHarness() as fake: + fake.info.st_uid = 0 + self.refusal(fake, "statowner", "owner-unknown") + with InventoryHarness() as fake: + fake.stat_text = "root:root 755 " + fake.path + self.refusal(fake, "statowner", "invalid-metadata") + with InventoryHarness() as fake: + fake.directory.side_effect = OSError("unsafe symlink") + self.refusal(fake, "statowner", "unsafe-directory") + with InventoryHarness() as fake: + fake.group.side_effect = KeyError("unknown gid") + self.refusal(fake, "statowner", "owner-unknown") + + def test_directory_owner_is_independent_of_service_user_and_group(self): + for user, group in (("serviceuser", "servicegroup"), ("root", "root"), ("", "")): + with self.subTest(user=user), InventoryHarness() as fake: + fake.admin = f"User={user}\nGroup={group}\nWorkingDirectory={fake.path}\n" + result = p.inventory("discover") + self.assertEqual(result["service"]["User"], user) + self.assertEqual(result["service"]["Group"], group) + self.assertEqual(result["owner"], "admin") + self.assertEqual(result["group"], "admin") + self.assertTrue(all(c[:6] == ("sudo", "-n", "-u", "admin", "--", "git") + for c in fake.calls if c[0] == "sudo")) + + def test_directory_identity_change_refused_before_git(self): + for field, value in (("st_uid", 2000), ("st_gid", 2000), ("st_ino", 99), + ("st_dev", 99), ("st_mode", stat.S_IFDIR | 0o700)): + for change_call, stage in ((2, "statowner"), (3, "git-root")): + with self.subTest(field=field, stage=stage), InventoryHarness() as fake: + changed = SimpleNamespace(**vars(fake.info)) + setattr(changed, field, value) + fake.directory.side_effect = [fake.info] * (change_call - 1) + [changed] + self.refusal(fake, stage, "unsafe-directory") + self.assertFalse(any(c[0] == "sudo" for c in fake.calls)) + + def test_denied_sudo_and_git_failure_exact_step_no_retry(self): + for token, step in (("sudo", "git-root"), ("HEAD", "git-head"), ("status", "git-status"), + ("toolstack-admin.service", "serviceprops"), ("stat", "statowner")): + with self.subTest(token=token), InventoryHarness() as fake: + fake.fail = token + self.refusal(fake, step, "command-failed") + self.assertEqual(sum(token in c for c in fake.calls), 1) + + def test_optional_sps_failure_explicit_unknown(self): + with InventoryHarness() as fake: + fake.fail = "toolstack-sps.service" + self.assertEqual(p.inventory("discover")["sps"], {"state": "unknown-or-absent"}) + + def test_invalid_git_stdout_never_enters_failure_envelope(self): + for token, step in (("--show-toplevel", "git-root"), ("HEAD", "git-head")): + with InventoryHarness() as fake: + original = fake.command + def command(argv, root, seconds=30): + if token in argv: + return "RAW_SECRET_STDOUT_DO_NOT_PERSIST" + return original(argv, root, seconds) + with patch.object(p, "bounded", side_effect=command): + self.refusal(fake, step, "invalid-metadata") + + def test_clean_environment_does_not_inherit_credentials(self): + with patch.dict(p.os.environ, {"SECRET_TOKEN": "RAW_SECRET", "GIT_CONFIG_COUNT": "1", "SSH_AUTH_SOCK": "/secret"}): + env = p.clean_env(Path("/")) + self.assertNotIn("SECRET_TOKEN", env) + self.assertNotIn("SSH_AUTH_SOCK", env) + self.assertNotIn("GIT_CONFIG_COUNT", env) + self.assertEqual(env["GIT_CONFIG_GLOBAL"], "/dev/null") + + def test_generated_symlinks_and_mixed_descendants_are_not_inspected(self): + # Fake descendant metadata is deliberately incompatible with the old + # scanner. No caller may enumerate or stat these repository contents. + descendants = [SimpleNamespace(name="admin/.venv/bin/python", st_mode=stat.S_IFLNK, + target=p.sys.executable), + SimpleNamespace(name="dirty", st_uid=2000, st_gid=3000)] + with InventoryHarness(), patch.object(p.os, "scandir", return_value=descendants) as scan, \ + patch.object(Path, "lstat", side_effect=AssertionError("descendant metadata read")) as lstat: + result = p.inventory("discover") + self.assertEqual(result["owner"], "admin") + scan.assert_not_called() + lstat.assert_not_called() + + def test_checkout_path_symlink_is_refused_without_following(self): + safe_directory = p.safe_directory + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + (root / "link").symlink_to(root, target_is_directory=True) + with InventoryHarness() as fake: + fake.admin = f"User=admin\nGroup=admin\nWorkingDirectory={root / 'link'}\n" + fake.directory.side_effect = safe_directory + self.refusal(fake, "statowner", "unsafe-directory") + self.assertFalse(any(c[0] in ("sudo", "stat") for c in fake.calls)) + + def test_guest_cli_emits_only_safe_envelope(self): + with InventoryHarness() as fake: + fake.fail = "toolstack-admin.service" + output = io.StringIO() + with patch.object(p.sys, "argv", ["guest", "vmproof-20261024T161732Z-b35e26e6", "inventory", "discover", "serviceprops", "{}"]), contextlib.redirect_stdout(output): + self.assertEqual(p.guest_main(), 1) + self.assertTrue(p.inventory_error(json.loads(output.getvalue()))) + self.assertNotIn("RAW_SECRET", output.getvalue()) + + def test_qga_error_reaches_private_state_before_raise(self): + envelope = {"error": "inventory-failed", "step": "git-head", "reason": "command-failed", + "metadata": {"path": "/srv/actual-checkout", "owner": "admin"}} + checkpoints = [] + wire = json.dumps({"exited": 1, "exitcode": 1, "out-data": json.dumps(envelope)}) + io_fake = SimpleNamespace(config=SimpleNamespace(run_id="vmproof-20261024T161732Z-b35e26e6"), + latch=StopLatch(), run=lambda *a, **kw: g.completed_from_guest(wire)) + proof = GuestProof(io_fake, Source("a" * 40, "b" * 64, b"")) + io_fake.checkpoint = lambda: checkpoints.append(json.loads(json.dumps(proof.state()))) + proof.inventory_stage = {"step": "git-head", "state": "started"} + with self.assertRaises(g.GuardRefusal): + proof.call(("fake-qga",)) + self.assertEqual(checkpoints[-1]["inventory_failure"], envelope) + self.assertFalse(checkpoints[-1]["complete"]) + self.assertFalse(checkpoints[-1]["root_attempted"]) + self.assertEqual(checkpoints[-1]["transferred"], 0) + for bad in (dict(envelope, stderr="RAW_SECRET"), dict(envelope, reason="RAW_SECRET"), + dict(envelope, step="arbitrary"), dict(envelope, metadata={"env": "RAW_SECRET"})): + self.assertFalse(p.inventory_error(bad)) + io_fake.run = lambda *a, **kw: g.Completed(1, json.dumps(bad)) + proof.inventory_failure = None + count = len(checkpoints) + with self.assertRaises(g.GuardRefusal): + proof.call(("fake-qga",)) + self.assertIsNone(proof.inventory_failure) + self.assertEqual(len(checkpoints), count) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_vm_proof_cleanup.py b/tests/release/test_vm_proof_cleanup.py new file mode 100644 index 0000000..bed0a4a --- /dev/null +++ b/tests/release/test_vm_proof_cleanup.py @@ -0,0 +1,140 @@ +"""Image-user regression through real transport code and fake Popen pipes only.""" +import json +import os +from pathlib import Path +import tempfile +import unittest +from types import SimpleNamespace + +import vm_proof_guard as g +from vm_proof_adapter import StopLatch, timeout_for +from vm_proof_linux import LinuxSubprocessIO +from test_vm_proof_linux import Child + + +RUN = "vmproof-20261024T182625Z-2b3a3e9a" +IMAGES = ( + "sha256:fb8e78e8c31cdc5fb7a8ca893d58a56358efe152145e8433ff23e5837418c9c3", + "sha256:bc4444aff2d536d62ca0ef3ec113dda922db2abe4c17724bfb1c805fbdda582d", + "sha256:b9e50c2fafe2ba9da97191f9c1120c8bf1b2d91096141bbefe9822a18f375cf4", +) +WARNING = "WARNING: Ignoring custom format, because both --format and --quiet are set." + + +class ImageUsers(unittest.TestCase): + def exercise(self, image=IMAGES[0], *, host_error="", guest_error="", users="", + inspect_text=None, inspect_code=0, remove_code=0): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + root = str(Path(temp.name).resolve()) + os.chmod(root, 0o700) + calls = [] + users_cmd = ("docker", "ps", "-a", "--no-trunc", "--filter", + "ancestor=" + image, "--format", "{{.ID}}") + inspect_cmd = ("docker", "image", "inspect", "--format", g.IMAGE_INSPECT_FORMAT, image) + remove_cmd = ("docker", "image", "rm", image) + if inspect_text is None: + inspect_text = "\t".join((image, json.dumps([sorted(g.run_tags(RUN))[0]]), + json.dumps({g.LABEL_KEY: RUN}))) + + def spawn(argv, **kwargs): + self.assertEqual(argv[0], "/usr/sbin/qm") + self.assertEqual(argv[1:4], ("guest", "exec", "130")) + self.assertNotIn("shell", kwargs) + inner = argv[7:] + calls.append(inner) + if inner == users_cmd: + text, code, error = users, 0, guest_error + elif inner == inspect_cmd: + text, code, error = inspect_text, inspect_code, "" + elif inner == remove_cmd: + text, code, error = "", remove_code, "" + else: + self.fail("unexpected command: " + repr(inner)) + body = json.dumps({"exited": 1, "exitcode": code, + "out-data": text, "err-data": error}).encode() + return Child(body, host_error.encode() if inner == users_cmd else b"") + + config = g.GuardConfig(RUN, (g.BaselineContainer("nod", "healthy"),)) + io = LinuxSubprocessIO(root, config, StopLatch(), "discover", popen=spawn, + observe=lambda pid: 123) + self.addCleanup(io.close) + # Isolate transport; the existing guard suite separately tests health/lease. + io.check = lambda: None + io.owned_start = True + + def permit(argv, data): + g.rollback_command_allowed(tuple(argv), RUN) + return data is None and tuple(argv[7:]) in (users_cmd, inspect_cmd, remove_cmd) + + io.proof = SimpleNamespace(permit=permit) + reasons = [] + g._remove_image(lambda argv: io.run(argv, timeout_for(argv)), RUN, image, reasons) + self.assertFalse(io.active) + self.assertFalse(io.unresolved_child) + return reasons, calls + + def test_corrected_full_id_query_all_three_ids(self): + for image in IMAGES: + with self.subTest(image=image): + reasons, calls = self.exercise(image) + self.assertEqual(reasons, []) + self.assertEqual(len(calls), 3) + self.assertEqual(calls[-1], ("docker", "image", "rm", image)) + self.assertNotIn("-aq", calls[0]) + self.assertEqual(calls[0][-2:], ("--format", "{{.ID}}")) + + def test_warning_remains_unknown_at_both_transport_layers(self): + for field in ("host_error", "guest_error"): + with self.subTest(field=field): + reasons, calls = self.exercise(**{field: WARNING}) + self.assertEqual(reasons, ["image-users-unknown:" + IMAGES[0]]) + self.assertEqual(len(calls), 1) + + def test_foreign_or_malformed_user_output_prevents_deletion(self): + for users in ("f" * 64 + "\n", "unexpected output"): + reasons, calls = self.exercise(users=users) + self.assertEqual(reasons, ["image-in-use:" + IMAGES[0]]) + self.assertEqual(len(calls), 1) + + def test_foreign_shared_or_unknown_image_identity_prevents_deletion(self): + good_tags = [sorted(g.run_tags(RUN))[0]] + for image, tags, labels in ( + (IMAGES[1], good_tags, {g.LABEL_KEY: RUN}), + (IMAGES[0], ["foreign:latest"], {g.LABEL_KEY: RUN}), + (IMAGES[0], good_tags + ["shared:latest"], {g.LABEL_KEY: RUN}), + (IMAGES[0], good_tags, {g.LABEL_KEY: "another-run"}), + (IMAGES[0], [], {g.LABEL_KEY: RUN}), + (IMAGES[0], None, {}), + ): + text = "\t".join((image, json.dumps(tags), json.dumps(labels))) + reasons, calls = self.exercise(inspect_text=text) + self.assertEqual(reasons, ["image-not-owned:" + IMAGES[0]]) + self.assertEqual(len(calls), 2) + + def test_inspect_error_is_not_positive_absence(self): + reasons, calls = self.exercise(inspect_code=1) + self.assertEqual(reasons, ["image-not-owned:" + IMAGES[0]]) + self.assertEqual(len(calls), 2) + + def test_remove_failure_reported_without_retry(self): + reasons, calls = self.exercise(remove_code=1) + self.assertEqual(reasons, ["image-rm-failed:" + IMAGES[0]]) + self.assertEqual(len(calls), 3) + + def test_exact_allowlist_rejects_quiet_conflict_force_and_prune(self): + corrected = ("docker", "ps", "-a", "--no-trunc", "--filter", + "ancestor=" + IMAGES[0], "--format", "{{.ID}}") + g.rollback_command_allowed(g._guest("130", g.PRODUCTION_TIMEOUT, corrected), RUN) + for inner in ( + (*corrected[:2], "-aq", *corrected[3:]), + (*corrected[:-1], "{{.Names}}"), + ("docker", "image", "rm", "--force", IMAGES[0]), + ("docker", "image", "prune"), + ): + with self.assertRaises(g.GuardRefusal): + g.rollback_command_allowed(g._guest("130", g.PRODUCTION_TIMEOUT, inner), RUN) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_vm_proof_cleanup_controller.py b/tests/release/test_vm_proof_cleanup_controller.py new file mode 100644 index 0000000..bea1400 --- /dev/null +++ b/tests/release/test_vm_proof_cleanup_controller.py @@ -0,0 +1,586 @@ +"""Cleanup controller qualification: fake VM/child and disposable fake artifacts.""" +import copy +import dataclasses +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from types import SimpleNamespace +from contextlib import redirect_stdout +from unittest.mock import patch + +import vm_proof_cleanup as c +import vm_proof_guard as g +import vm_proof_payload as p +from vm_proof_adapter import StopLatch, run_guard +from test_vm_proof_adapter import FakeIO +from test_vm_proof_linux import Child + +NEW = 'vmproof-20261024T220000Z-01234567' + + +def config(): + return g.GuardConfig(NEW, tuple(g.BaselineContainer(n, 'healthy') for n in + ['nod', *['service' + str(i) for i in range(15)]])) + + +def snapshot(): + jobs, evidence, containers = [], [], [] + for kind in p.CONTAINER_KINDS: + if kind in p.KINDS: + evidence.append({'step': 'build-' + kind, 'result': { + 'image': c.IMAGES[p.KINDS.index(kind)], 'tag': p.tag_for(c.OLD_RUN, kind), 'label': c.OLD_RUN}}) + cid = format(len(containers) + 1, '064x') + containers.append(cid) + evidence.extend([{'step': 'create-' + kind, 'result': {'container': cid}}, + {'step': 'start-' + kind, 'result': {}}]) + for row in evidence: + jobs.append({'key': row['step'], 'pid': 100, 'identity': {'pid': 100, 'start': 50, 'boot': 'a' * 36}, + 'exitcode': 0}) + evidence.append({'step': 'extract-offline-units', 'result': {'commit': c.COMMIT, 'sha256': c.DIGEST}}) + reasons = ['image-users-unknown:' + image for image in c.IMAGES] + return {'config': dict(dataclasses.asdict(config()), run_id=c.OLD_RUN), + 'state': {'owned_resize': True, 'owned_start': True, 'shutdown_sent': True, 'children': [], + 'containers': [], 'images': [], 'stop': 'proof-complete', + 'baseline_vm130': {'status': 'stopped', 'memory_mib': 8192, 'cores': 2, 'onboot': 0}, + 'proof': {'pending': None, 'sync_inflight': None, 'inventory_stage': None, + 'inventory_failure': None, 'complete': True, 'root_attempted': True, + 'leftovers': reasons, 'images': list(c.IMAGES), 'marker': dict(c.MARKER), + 'root': str(p.root_for(c.OLD_RUN)), 'commit': c.COMMIT, 'sha256': c.DIGEST, + 'jobs': jobs, 'containers': containers}}, + 'result': {'ask_parent': True, 'phase': 'monitor', 'reasons': ['proof-complete', *reasons], + 'restored': False, 'proof_complete': True}, + 'proof-evidence': evidence, 'controller': {'pid': 999991, 'start': 100}, + 'lease': {'pid': 999991, 'start': 100, 'run': c.OLD_RUN, 'monotonic': 1}, + 'pid': c.OLD_RUN + '\n999992 200\n'} + + +class Records(unittest.TestCase): + def test_absent_control_root_is_required(self): + with tempfile.TemporaryDirectory() as tmp: + parent = str(Path(tmp).resolve()) + first = c.prepare_run_root(parent, NEW) + with self.assertRaises((g.GuardRefusal, OSError)): + c.prepare_run_root(parent, NEW) + self.assertTrue(Path(first).is_dir()) + + def test_valid_fixed_snapshot_and_distinct_control(self): + self.assertEqual(c.validate(snapshot(), NEW), (999991, 999992)) + with self.assertRaises(g.GuardRefusal): + c.validate(snapshot(), c.OLD_RUN) + + def test_unknown_inflight_and_identity_drift_refused(self): + mutations = [ + ('state.children', [123]), ('state.shutdown_sent', False), + ('state.proof.pending', {'pid': None}), ('state.proof.sync_inflight', 'remove'), + ('state.proof.jobs', []), ('state.proof.marker.ino', 99), + ('state.proof.marker.run', NEW), ('state.proof.images', []), + ('state.proof.complete', False), ('state.proof.leftovers', []), + ('state.proof.inventory_failure', {}), ('state.proof.root', '/foreign'), + ('state.baseline_vm130.memory_mib', 4096), ('result.proof_complete', False), + ('result.reasons', ['owned-child-exit-unconfirmed']), ('config.run_id', NEW), + ('lease.run', NEW), ('controller.start', None), ('pid', 'bad'), + ] + for path, value in mutations: + with self.subTest(path=path): + data = snapshot() + target = data + keys = path.split('.') + for key in keys[:-1]: + target = target[key] + target[keys[-1]] = value + with self.assertRaises((g.GuardRefusal, TypeError, KeyError, ValueError)): + c.validate(data, NEW) + + def test_image_evidence_drift_refused(self): + for field, value in [('image', c.IMAGES[1]), ('tag', 'foreign:latest'), ('label', NEW)]: + data = snapshot() + data['proof-evidence'][0]['result'][field] = value + with self.assertRaises(g.GuardRefusal): + c.validate(data, NEW) + + def private(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + root = Path(temp.name).resolve() + os.chmod(root, 0o700) + files = c.linux.PrivateFiles(str(root)) + for key, value in snapshot().items(): + files.create(key, value if key == 'pid' else json.dumps(value)) + files.close() + return root + + def test_private_snapshot_and_no_mutation(self): + root = self.private() + before = {f.name: f.read_bytes() for f in root.iterdir()} + with patch.object(c, 'OLD_ROOT', str(root)): + self.assertEqual(c.load_prior(NEW)['state']['proof']['marker'], c.MARKER) + self.assertEqual(before, {f.name: f.read_bytes() for f in root.iterdir()}) + + def test_private_records_refuse_links_modes_missing_malformed(self): + for problem in ('symlink', 'hardlink', 'mode', 'missing', 'malformed', 'rootmode'): + with self.subTest(problem=problem): + root = self.private() + target = root / 'state' + if problem == 'symlink': + target.rename(root / 'saved') + target.symlink_to(root / 'saved') + elif problem == 'hardlink': + os.link(target, root / 'saved') + elif problem == 'mode': + target.chmod(0o644) + elif problem == 'missing': + target.unlink() + elif problem == 'rootmode': + root.chmod(0o755) + else: + target.write_text('{') + with patch.object(c, 'OLD_ROOT', str(root)): + with self.assertRaises((g.GuardRefusal, OSError, ValueError)): + c.load_prior(NEW) + + def test_live_or_unknown_prior_helper_refused(self): + root = self.private() + original = os.stat + for outcome in (object(), PermissionError()): + def observe(path, *a, **kw): + if str(path).startswith('/proc/'): + if isinstance(outcome, Exception): + raise outcome + return outcome + return original(path, *a, **kw) + with patch.object(c, 'OLD_ROOT', str(root)), patch.object(c.os, 'stat', side_effect=observe): + with self.assertRaises((g.GuardRefusal, PermissionError)): + c.load_prior(NEW) + + def test_default_and_dry_run_have_no_host_or_snapshot_access(self): + with patch.object(c.linux, 'host_gate', side_effect=AssertionError), \ + patch.object(c, 'load_prior', side_effect=AssertionError), redirect_stdout(io.StringIO()): + self.assertEqual(c.cli([]), 0) + self.assertEqual(c.cli(['controller', '--execute', '--dry-run']), 0) + + +class CleanupFake(FakeIO): + def __init__(self): + super().__init__() + self.config = config() + self.latch = StopLatch() + self.clock = lambda: 0 + self.deadline = 2400 + self.cleanup = False + self.cleanup_deadline = None + self.proof_cleanup_deadline = None + self.admit = NEW + self.prod = '\n'.join(b.name + '\tUp (healthy)' for b in self.config.baseline) + self.proof = c.CleanupProof(self, snapshot()) + self.fault = None + self.saved = [] + + records = c.CleanupIO.records + metadata_proof = c.CleanupIO.metadata_proof + + def checkpoint(self): + self.saved.append(copy.deepcopy(self.proof.state())) + + def begin_cleanup(self): + self.latch.trip('cleanup') + self.cleanup = True + self.cleanup_deadline = 300 + + def _guest(self, inner): + if inner[:2] == ('python3', '-c'): + self.assert_fixed(inner) + action = inner[4] + if self.fault == action: + return g.Completed(1, '') + return g.Completed(0, json.dumps(c.MARKER if action == 'cleanup-check' + else {'removed': str(p.root_for(c.OLD_RUN))})) + if inner[:2] == ('docker', 'ps'): + return g.Completed(1 if self.fault == 'users-unknown' else 0, + 'f' * 64 if self.fault == 'users-foreign' else '') + if inner[:3] == ('docker', 'image', 'inspect'): + image = inner[-1] + tags = [p.tag_for(c.OLD_RUN, p.KINDS[c.IMAGES.index(image)])] + if self.fault == 'shared': + tags.append('foreign:latest') + return g.Completed(0, '\t'.join((image, json.dumps(tags), json.dumps({g.LABEL_KEY: c.OLD_RUN})))) + if inner[:3] == ('docker', 'image', 'rm'): + return g.Completed(1 if self.fault == 'rm' else 0, '') + raise AssertionError(inner) + + def assert_fixed(self, inner): + assert inner[2] == c.GUEST_CODE and inner[3] == c.OLD_RUN + assert inner[4] in ('cleanup-check', 'cleanup-remove') + assert inner[5:] == (str(c.MARKER['dev']), str(c.MARKER['ino'])) + + +class Lifecycle(unittest.TestCase): + def test_actual_cleanup_binding_fake_pipes_and_stderr_refusal(self): + for warning in (False, True): + with tempfile.TemporaryDirectory() as tmp: + calls = [] + def spawn(argv, **kwargs): + inner = argv[7:] + calls.append(inner) + if inner[:2] == ('python3', '-c'): + result = c.MARKER if inner[4] == 'cleanup-check' else {'removed': str(p.root_for(c.OLD_RUN))} + text = json.dumps(result) + elif inner[:3] == ('docker', 'image', 'inspect'): + image = inner[-1] + text = '\t'.join((image, json.dumps([p.tag_for(c.OLD_RUN, p.KINDS[c.IMAGES.index(image)])]), + json.dumps({g.LABEL_KEY: c.OLD_RUN}))) + else: + text = '' + error = 'warning' if warning and inner[:2] == ('docker', 'ps') else '' + return Child(json.dumps({'exited': 1, 'exitcode': 0, 'out-data': text, 'err-data': error}).encode()) + real = c.CleanupIO(str(Path(tmp).resolve()), config(), StopLatch(), snapshot(), + popen=spawn, observe=lambda pid: 123, clock=lambda: 0) + try: + real.owned_start = True + real.proof.ready = True + real.begin_cleanup() + reasons = real.proof.cleanup() + self.assertEqual(bool(reasons), warning) + self.assertEqual(real.proof.completed, not warning) + self.assertEqual(len(calls), 2 if warning else 11) + self.assertEqual(real.active, {}) + self.assertFalse(real.unresolved_child) + finally: + real.close() + + def test_cleanup_binding_inherits_psi_grace_sustained_and_memory_floor(self): + for field in ('avg10', 'avg60', 'memory'): + with tempfile.TemporaryDirectory() as tmp: + now = [0] + real = c.CleanupIO(str(Path(tmp).resolve()), config(), StopLatch(), snapshot(), clock=lambda: now[0]) + try: + real.lease_age = lambda: 0 + real.pressure_state.begin_boot(0) + def read(path): + if path == '/proc/meminfo': + return 'MemAvailable: %d kB' % (1 if field == 'memory' else 8000000) + return ('some avg10=0 avg60=0 avg300=0 total=0\n' + 'full avg10=%d avg60=%d avg300=0 total=0' % + (3 if field == 'avg10' else 0, 2 if field == 'avg60' else 0)) + real.read = read + if field == 'memory': + with self.assertRaises(g.GuardRefusal): + real.check() + else: + now[0] = 89 + real.check() + now[0] = 90 + if field == 'avg60': + with self.assertRaises(g.GuardRefusal): + real.check() + else: + real.check() + now[0] = 119 + real.check() + now[0] = 120 + with self.assertRaises(g.GuardRefusal): + real.check() + self.assertTrue(real.latch.closed()) + finally: + real.close() + + def run_fake(self, fake): + return run_guard(fake, fake.config, fake.latch, fake.clock, lambda s: None, max_ticks=4) + + def test_one_boot_old_targets_new_rollback_and_no_proof_calls(self): + fake = CleanupFake() + result = self.run_fake(fake) + self.assertTrue(result.restored) + self.assertFalse(result.ask_parent) + self.assertTrue(fake.proof.completed) + self.assertEqual(fake.records(), g.ProofRecords(NEW, (), ())) + self.assertEqual(fake.proof.removed, list(c.IMAGES)) + self.assertEqual(fake.calls.count(('qm', 'start', '130')), 1) + self.assertLess(fake.calls.index(('qm', 'set', '130', '--memory', '4096')), + fake.calls.index(('qm', 'start', '130'))) + self.assertLess(fake.calls.index(('qm', 'shutdown', '130', '--timeout', '90')), + fake.calls.index(('qm', 'set', '130', '--memory', '8192'))) + self.assertEqual((fake.vm130, fake.memory), ('stopped', 8192)) + self.assertNotIn('proof-complete', result.reasons) + for command in fake.calls: + if command[:4] == ('qm', 'guest', 'exec', '130') and command != g.agent_ready_argv(): + inner = command[7:] + self.assertTrue(inner[:2] == ('python3', '-c') or inner[:2] == ('docker', 'ps') + or inner[:3] in (('docker', 'image', 'inspect'), ('docker', 'image', 'rm'))) + self.assertFalse(command[:3] in (('qm', 'set', '100'), ('qm', 'start', '100'), ('qm', 'shutdown', '100'))) + + def test_doubt_stops_guest_sequence_and_still_shuts_down(self): + for fault in ('cleanup-check', 'users-unknown', 'users-foreign', 'shared', 'rm', 'cleanup-remove'): + with self.subTest(fault=fault): + fake = CleanupFake() + fake.fault = fault + result = self.run_fake(fake) + self.assertTrue(result.ask_parent) + self.assertFalse(fake.proof.completed) + self.assertEqual((fake.vm130, fake.memory), ('stopped', 8192)) + calls = [cmd for cmd in fake.calls if cmd[:4] == ('qm', 'guest', 'exec', '130')] + if fault != 'cleanup-remove': + self.assertNotIn(fake.proof.root_command('cleanup-remove'), calls) + before = len(fake.calls) + self.assertTrue(fake.proof.cleanup()) + self.assertEqual(len(fake.calls), before) + + def test_prod_unknown_or_stale_lease_cannot_boot(self): + for name, value in [('prod', ''), ('nod', '{}'), ('lease', 91), ('vm130', 'running'), ('memory', 6000)]: + fake = CleanupFake() + setattr(fake, name, value) + self.run_fake(fake) + self.assertNotIn(('qm', 'start', '130'), fake.calls) + self.assertFalse(fake.proof.attempted) + + def test_memory_trip_after_start_skips_targets_but_restores(self): + fake = CleanupFake() + fake.low_after_start = True + self.run_fake(fake) + self.assertFalse(fake.proof.attempted) + self.assertEqual((fake.vm130, fake.memory), ('stopped', 8192)) + + def test_normal_guard_mismatch_check_unchanged(self): + with self.assertRaises(g.GuardRefusal): + g.rollback(None, config(), g.ProofRecords(c.OLD_RUN, (), ())) + + def test_cleanup_io_denies_normal_metadata_and_build(self): + with tempfile.TemporaryDirectory() as tmp: + root = str(Path(tmp).resolve()) + real = c.CleanupIO(root, config(), StopLatch(), snapshot(), popen=lambda *a, **kw: self.fail('spawn')) + try: + real.owned_start = True + for command in (g.unit_list_argv(), g.listen_argv(), g.process_meta_argv(), + g._guest('130', '30', ('docker', 'create', 'image'))): + with self.assertRaises(g.GuardRefusal): + real.allowed(command) + self.assertEqual(real.records(), g.ProofRecords(NEW, (), ())) + finally: + real.close() + + +class AnchoredRoot(unittest.TestCase): + def test_marker_change_during_scan_refuses_before_deletion(self): + root, marker = self.fixture() + original = os.listdir + changed = [False] + def mutate(fd): + if not changed[0]: + changed[0] = True + (root / 'owner').write_text('{}') + return original(fd) + with patch.object(p.os, 'listdir', side_effect=mutate): + with self.assertRaises(ValueError): + self.call(root, marker) + self.assertTrue((root / 'src' / 'fake').exists()) + + def test_foreign_owner_device_and_nested_mount_refused(self): + for field in ('uid', 'dev', 'mount'): + root, marker = self.fixture() + original = os.stat + def observed(name, *a, **kw): + info = original(name, *a, **kw) + if name == 'fake' and kw.get('dir_fd') is not None and field != 'mount': + values = list(info) + values[4 if field == 'uid' else 2] += 1 + return os.stat_result(values) + return info + target = (root / 'src' / 'fake').stat().st_ino + def mount(fd): + return 'foreign' if field == 'mount' and os.fstat(fd).st_ino == target else 'same' + with patch.object(p.os, 'stat', side_effect=observed), patch.object(p, 'cleanup_mount', side_effect=mount): + with self.assertRaises(ValueError): + p.cleanup_root(root, c.OLD_RUN, marker['dev'], marker['ino'], remove=True) + self.assertTrue((root / 'src' / 'fake').exists()) + + def test_file_replacement_between_scan_and_delete_is_retained(self): + root, marker = self.fixture() + original = os.unlink + swapped = [False] + def unlink(name, *a, **kw): + if not swapped[0]: + swapped[0] = True + (root / 'payload.tar').rename(root.parent / 'saved-tar') + (root / 'payload.tar').write_text('replacement must survive') + return original(name, *a, **kw) + with patch.object(p.os, 'unlink', side_effect=unlink): + with self.assertRaises(ValueError): + self.call(root, marker) + self.assertEqual((root / 'payload.tar').read_text(), 'replacement must survive') + + def fixture(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + parent = Path(temp.name).resolve() + root = parent / 'owned' + root.mkdir(mode=0o700) + marker = {'run': c.OLD_RUN, 'dev': root.stat().st_dev, 'ino': root.stat().st_ino} + for name in ('owner', 'payload-meta', 'payload.tar'): + p.private_write(root / name, json.dumps(marker).encode() if name == 'owner' else b'fake') + for name in ('src', 'home', 'tmp', 'config', 'state', 'cache', 'data', 'runtime', 'docker-config', 'jobs'): + (root / name).mkdir() + (root / 'src' / 'fake').write_text('fake artifact') + return root, marker + + def call(self, root, marker, remove=True): + with patch.object(p, 'cleanup_mount', return_value='fake-mount'): + return p.cleanup_root(root, c.OLD_RUN, marker['dev'], marker['ino'], remove=remove) + + def test_valid_check_then_fd_relative_remove(self): + root, marker = self.fixture() + self.assertEqual(self.call(root, marker, False), marker) + self.assertTrue(root.exists()) + self.assertEqual(self.call(root, marker), {'removed': str(root)}) + self.assertFalse(root.exists()) + + def test_wrong_marker_inode_unexpected_entry_symlink_hardlink_special(self): + for problem in ('marker', 'inode', 'extra', 'symlink', 'hardlink', 'fifo', 'owner-mode'): + with self.subTest(problem=problem): + root, marker = self.fixture() + outside = root.parent / 'protected' + outside.write_text('untouched') + if problem == 'marker': + (root / 'owner').write_text('{}') + elif problem == 'inode': + marker['ino'] += 1 + elif problem == 'extra': + (root / 'unexpected').write_text('retain') + elif problem == 'symlink': + (root / 'src' / 'link').symlink_to(outside) + elif problem == 'hardlink': + os.link(outside, root / 'src' / 'link') + elif problem == 'fifo': + os.mkfifo(root / 'src' / 'fifo') + else: + (root / 'owner').chmod(0o644) + with self.assertRaises((ValueError, OSError)): + self.call(root, marker) + self.assertTrue(root.exists()) + self.assertEqual(outside.read_text(), 'untouched') + + def test_mount_and_root_identity_swap_refused(self): + root, marker = self.fixture() + with patch.object(p, 'cleanup_mount', side_effect=['root', 'foreign']): + with self.assertRaises(ValueError): + p.cleanup_root(root, c.OLD_RUN, marker['dev'], marker['ino'], remove=True) + original = p.os.listdir + changed = [False] + def swap(fd): + result = original(fd) + if not changed[0]: + changed[0] = True + root.rename(root.parent / 'old') + root.mkdir(mode=0o700) + (root / 'protected').write_text('untouched') + return result + with patch.object(p.os, 'listdir', side_effect=swap): + with self.assertRaises(ValueError): + self.call(root, marker) + self.assertEqual((root / 'protected').read_text(), 'untouched') + self.assertTrue((root.parent / 'old' / 'src' / 'fake').exists()) + + +class Supervision(unittest.TestCase): + def run_case(self, *, ready=True, mismatch=False, identity=True, exit_after=2, wait_timeout=False, + stopped=False, identity_drift=False): + now = [0] + class Files: + writes = [] + leases = [] + def read(self, name): + if not ready: + raise FileNotFoundError() + return 'wrong' if mismatch else f'{NEW}\n42 100\n' + def create(self, name, text): + self.writes.append((name, text)) + def publish_lease(self, record): + self.leases.append(record) + class Child: + pid = 42 + waited = False + def poll(self): + return 0 if now[0] >= exit_after else None + def wait(self, timeout): + self.waited = True + assert timeout == 400 + if wait_timeout: + raise subprocess.TimeoutExpired('fake', timeout) + return 0 + files, child = Files(), Child() + def sleep(seconds): + now[0] += seconds + code = c.supervise(child, files, NEW, {'pid': 1, 'start': 1}, + observe=lambda pid: (101 if identity_drift and now[0] else 100) if identity else None, + clock=lambda: now[0], sleep=sleep, stopped=lambda: stopped) + return code, child, files + + def test_success_positive_reap(self): + code, child, files = self.run_case() + self.assertEqual(code, 0) + self.assertTrue(child.waited) + self.assertEqual(files.writes, [('admit', NEW)]) + + def test_lease_deadline_still_waits_cleanup(self): + code, child, files = self.run_case(exit_after=99999) + self.assertTrue(child.waited) + self.assertTrue(all(row['monotonic'] < 2400 for row in files.leases)) + self.assertEqual(code, 0) # controller separately verifies the durable result + + def test_unknown_identity_ready_race_and_timeout_never_complete(self): + for kwargs in ({'identity': False}, {'mismatch': True}, {'ready': False}, + {'exit_after': 99999, 'wait_timeout': True}): + code, child, files = self.run_case(**kwargs) + self.assertEqual(code, 2) + self.assertTrue(child.waited) + if not kwargs.get('wait_timeout'): + self.assertEqual(files.writes, []) + + def test_signal_and_reused_guard_identity_close_lease_and_reap(self): + for kwargs in ({'stopped': True}, {'identity_drift': True, 'exit_after': 10}): + code, child, files = self.run_case(**kwargs) + self.assertEqual(code, 2) + self.assertTrue(child.waited) + self.assertLessEqual(len(files.leases), 1) + + def test_controller_snapshots_before_spawn_and_checks_final_result(self): + for complete in (True, False): + with tempfile.TemporaryDirectory() as tmp: + root = str(Path(tmp).resolve()) + data = snapshot() + data['config']['baseline'] = list(data['config']['baseline']) + order = [] + def prior(run): + order.append('snapshot') + return data + def prepare(parent, run): + order.append('root') + return root + def spawn(cmd, **kw): + order.append('spawn') + self.assertNotIn('--archive', cmd) + self.assertNotIn('--checkout', cmd) + self.assertIn(NEW, cmd) + self.assertEqual(Path(cmd[1]).name, 'vm_proof_cleanup.py') + return object() + def supervise(child, files, *a, **kw): + self.assertEqual(json.loads(files.read('inventory')), json.loads(json.dumps(data))) + files.create('result', json.dumps({'cleanup_complete': complete, 'restored': True, 'ask_parent': False})) + files.create('state', json.dumps({'children': []})) + return 0 + args = SimpleNamespace(run_id=NEW, baseline=[b.name + ':healthy' for b in config().baseline]) + with patch.object(c, 'load_prior', side_effect=prior), patch.object(c, 'prepare_run_root', side_effect=prepare), \ + patch.object(c.linux, 'identity', return_value=100), patch.object(c.subprocess, 'Popen', side_effect=spawn), \ + patch.object(c, 'supervise', side_effect=supervise): + if complete: + self.assertEqual(c.controller(args, config()), 0) + else: + with self.assertRaises(g.GuardRefusal): + c.controller(args, config()) + self.assertEqual(order, ['snapshot', 'root', 'spawn']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/release/test_vm_proof_driver.py b/tests/release/test_vm_proof_driver.py new file mode 100644 index 0000000..d571c21 --- /dev/null +++ b/tests/release/test_vm_proof_driver.py @@ -0,0 +1,452 @@ +"""Local stdlib archive/receiver and real binding tests; Popen is fake only.""" +import base64 +import hashlib +import io +import json +import os +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +import vm_proof_guard as g +import vm_proof_payload as p +from vm_proof_adapter import StopLatch, _safe_rollback, run_guard +from vm_proof_driver import GuestProof, Source, GUEST_CODE +from vm_proof_linux import LinuxSubprocessIO +from test_vm_proof_linux import Child +from test_vm_proof_adapter import RUN, PRESSURE, FREE, config + +COMMIT = "b" * 40 +BOOT = "12345678-1234-1234-1234-123456789abc" + + +def archive(extra=(), commit=COMMIT, filler=0): + output = io.BytesIO() + entries = [(name, b"FROM python:3.12-slim\n") for name in p.DOCKERFILES.values()] + entries.append((p.PROCESS_FIXTURE, b"# synthetic archive fixture; never executed\n")) + entries += [("deploy/" + name, b"NoNewPrivileges=true\nStateDirectory=toolstack\nLogsDirectory=toolstack\n") + for name in ("toolstack-admin.service", "toolstack-sps.service")] + if filler: + entries.append(("filler", b"x" * filler)) + with tarfile.open(fileobj=output, mode="w", format=tarfile.PAX_FORMAT, pax_headers={"comment": commit}) as tf: + for name, data in entries: + member = tarfile.TarInfo(name) + member.size = len(data) + tf.addfile(member, io.BytesIO(data)) + for member in extra: + tf.addfile(member, io.BytesIO(b"")) + return output.getvalue() + + +def source(filler=0): + data = archive(filler=filler) + return Source(COMMIT, hashlib.sha256(data).hexdigest(), data) + + +def process_result(): + # Fake wire evidence only; no claim that fixture ran in these unit tests. + return {"status": "PASS", "cleanup": "reaped", "checks": list(p.PROCESS_CHECKS), + "platform": "Linux-fake", "python": "3.12.1", "cryptography": "50.0.2", + "boots": ["first", "second"], "ports": {"sps": 12345, "tool": 12346}, + "processes": [{"label": "sps", "pid": 10, "identity": "start10"}, + {"label": "runner spawn", "pid": 11, "identity": "start11", "boot": BOOT, "cleanup": "reaped and absent"}, + {"label": "tool", "pid": 11, "identity": "start11", "boot": BOOT}, + {"label": "sps-restart", "pid": 12, "identity": "start12"}]} + + +class ArchiveReceiver(unittest.TestCase): + def test_checksum_commit_member_type_and_escape_rejected(self): + data = archive() + digest = hashlib.sha256(data).hexdigest() + self.assertTrue(p.validate_archive(data, COMMIT, digest)) + for commit, sha in (("c" * 40, digest), (COMMIT, "0" * 64)): + with self.assertRaises(ValueError): + p.validate_archive(data, commit, sha) + for name, kind in (("/escape", tarfile.REGTYPE), ("a/../escape", tarfile.REGTYPE), + ("escape", tarfile.SYMTYPE), ("escape", tarfile.LNKTYPE), + ("escape", tarfile.FIFOTYPE), (".git/config", tarfile.REGTYPE), + ("deploy/docker/.env", tarfile.REGTYPE)): + member = tarfile.TarInfo(name) + member.type = kind + member.linkname = "/outside" + bad = archive([member]) + with self.subTest(name=name, kind=kind), self.assertRaises(ValueError): + p.validate_archive(bad, COMMIT, hashlib.sha256(bad).hexdigest()) + + def test_absent_root_chunks_no_retry_extract_and_owner_preservation(self): + src = source(p.CHUNK + 1) + with tempfile.TemporaryDirectory() as tmp: + parent = Path(tmp).resolve() + root = parent / "owned" + owner = parent / "preexisting" + owner.mkdir() + (owner / ".env").write_text("never-read-owner-data") + with patch.object(p, "root_for", return_value=root): + marker = p.dispatch(RUN, "init", [src.commit, src.sha256, str(len(src.data))], b"") + self.assertEqual(marker["run"], RUN) + with self.assertRaises(FileExistsError): + p.dispatch(RUN, "init", [src.commit, src.sha256, str(len(src.data))], b"") + for offset in range(0, len(src.data), p.CHUNK): + chunk = src.data[offset:offset + p.CHUNK] + args = [str(offset), hashlib.sha256(chunk).hexdigest()] + receipt = p.dispatch(RUN, "chunk", args, base64.b64encode(chunk)) + self.assertEqual(receipt, {"offset": offset + len(chunk)}) + with self.assertRaises(ValueError): + p.dispatch(RUN, "chunk", args, base64.b64encode(chunk)) + result = p.dispatch(RUN, "extract", [], b"") + self.assertEqual(result["commit"], COMMIT) + self.assertTrue((root / "src/deploy/docker/Dockerfile").is_file()) + with self.assertRaises(FileExistsError): + p.dispatch(RUN, "extract", [], b"") + with self.assertRaises(ValueError): + p.dispatch(RUN, "remove", [str(marker["dev"]), "0"], b"") + p.dispatch(RUN, "remove", [str(marker["dev"]), str(marker["ino"])], b"") + self.assertTrue((owner / ".env").exists()) + self.assertFalse(root.exists()) + + def test_source_file_nofollow_and_private_metadata(self): + src = source() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + path = root / "source.tar" + path.write_bytes(src.data) + path.chmod(0o600) + self.assertEqual(Source.load(str(path), COMMIT, src.sha256), src) + (root / "link").symlink_to(path) + with self.assertRaises(OSError): + Source.load(str(root / "link"), COMMIT, src.sha256) + path.chmod(0o644) + with self.assertRaises(g.GuardRefusal): + Source.load(str(path), COMMIT, src.sha256) + + def test_one_absent_job_identity_blocks_retry_and_fake_bootstrap_asserts(self): + src = source() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() / "owned" + with patch.object(p, "root_for", return_value=root), patch.object(p, "process_identity", return_value={"pid": 55, "start": 123, "boot": BOOT}), patch.object(p, "build", return_value={"image": "sha256:" + "a" * 64}) as build: + p.dispatch(RUN, "init", [src.commit, src.sha256, str(len(src.data))], b"") + p.dispatch(RUN, "build", ["onebox"], b"") + with self.assertRaises(FileExistsError): + p.dispatch(RUN, "build", ["onebox"], b"") + build.assert_called_once() + self.assertIn("assert shutil.which('admin')", p.ONEBOX_CHECK) + self.assertIn("assert", p.ECHO_CHECK) + self.assertNotIn("getpass", p.ONEBOX_CHECK) + + def test_chunk_corruption_oversize_and_duplicate_archive_rejected(self): + duplicate = tarfile.TarInfo("deploy/docker/Dockerfile") + bad = archive([duplicate]) + with self.assertRaises(ValueError): + p.validate_archive(bad, COMMIT, hashlib.sha256(bad).hexdigest()) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() / "owned" + src = source() + p.initialize(root, RUN, COMMIT, src.sha256, len(src.data)) + for content, digest in ((b"!invalid!", "0" * 64), + (base64.b64encode(b"fake"), "0" * 64), + (base64.b64encode(b"x" * (p.CHUNK + 1)), "0" * 64)): + with self.assertRaises(ValueError): + p.receive_chunk(root, 0, digest, content) + self.assertEqual((root / "payload.tar").stat().st_size, 0) + + def test_dirty_inventory_metadata_only_no_checkout_mutation(self): + from test_vm_proof_checkout import InventoryHarness + with InventoryHarness() as fake: + result = p.inventory("discover") + self.assertIn("?? .env", result["status"]) + calls = [c for c in fake.calls if c[0] == "sudo"] + self.assertTrue(all(c[:6] == ("sudo", "-n", "-u", "admin", "--", "git") for c in calls)) + self.assertTrue(all("--no-optional-locks" in c and "core.fsmonitor=false" in c for c in calls)) + self.assertFalse(any(word in c for c in calls for word in ("diff", "reset", "checkout", "fetch"))) + + +class FakePVE: + """Popen responder emulates documented QGA wire; never evaluates guest code.""" + def __init__(self): + self.calls = [] + self.input_chunks = [] + self.vm = "stopped" + self.memory = 8192 + self.jobs = {} + self.job_sequence = [] + self.images = [] + self.containers = {} + self.polls = 0 + self.low_during_build = False + self.bad_prod_during_build = False + self.fail_build = False + self.lose_pid = False + self.late_trip = None + self.used_image = False + self.wrong_image_label = False + self.lost_lease = False + + def __call__(self, argv, **kwargs): + argv = (Path(argv[0]).name, *argv[1:]) + self.calls.append(argv) + data = kwargs["stdin"].read() if hasattr(kwargs["stdin"], "read") else None + text = self.command(argv, data) + return Child(text.encode()) + + def command(self, argv, data): + if argv[:2] == ("free", "-m"): + return FREE + if argv[0] == "curl": + return g.NOD_HEALTH_BODY + if argv[:2] == ("qm", "status"): + return "status: " + ("running" if argv[2] == "100" else self.vm) + if argv[:2] == ("qm", "config"): + return f"memory: {self.memory}\ncores: 2\nonboot: 0\nagent: 1" + if argv[:2] == ("qm", "set"): + self.memory = int(argv[-1]) + return "" + if argv[:2] == ("qm", "start"): + self.vm = "running" + return "" + if argv[:2] == ("qm", "shutdown"): + self.vm = "stopped" + return "" + if argv[:3] == ("qm", "guest", "exec-status"): + self.polls += 1 + job = self.jobs[int(argv[-1])] + if job["polls"] == 0: + job["polls"] += 1 + return '{"exited": false}' + failed = self.fail_build and job["action"] == "build" + return json.dumps({"exited": True, "exitcode": 1 if failed else 0, + "out-data": json.dumps(job["result"])}) + if argv[:3] == ("qm", "guest", "exec"): + inner = argv[argv.index("--") + 1:] + if argv[3] == "100": + bad = self.bad_prod_during_build and self.polls > 0 + return self.qga("nod\tUp (unhealthy)" if bad else "nod\tUp (healthy)") + if inner[:3] == ("python3", "-c", GUEST_CODE): + run, action, *args = inner[3:] + assert run == RUN + result = self.action(action, args, data) + if "--synchronous" in argv: + pid = 500 + len(self.jobs) + self.jobs[pid] = {"action": action, "polls": 0, "result": result, "key": action + "-" + args[0]} + self.job_sequence.append(action + "-" + args[0]) + if self.late_trip and action == "create": + self.late_trip() + return '{}' if self.lose_pid else json.dumps({"pid": pid}) + return self.qga(json.dumps(result)) + if inner == ("docker", "ps", "-a", "--no-trunc", "--format", g.PROOF_PS_FORMAT): + return self.qga("\n".join(f"{cid}\t{RUN}\tproof" for cid in self.containers)) + if inner[:5] == ("docker", "ps", "-a", "--no-trunc", "--filter"): + return self.qga("f" * 64 if self.used_image else "") + if inner[:2] == ("docker", "stop"): + return self.qga(inner[-1]) + if inner[:2] == ("docker", "rm"): + self.containers.pop(inner[-1]) + return self.qga("") + if inner[:3] == ("docker", "image", "inspect"): + image = next(i for i in self.images if i["image"] == inner[-1]) + return self.qga(image["image"] + "\t" + json.dumps([image["tag"]]) + "\t" + json.dumps({g.LABEL_KEY: "other" if self.wrong_image_label else RUN})) + if inner[:3] == ("docker", "image", "rm"): + self.images = [i for i in self.images if i["image"] != inner[-1]] + return self.qga("") + return self.qga("unit metadata") + raise AssertionError(argv) + + @staticmethod + def qga(text): + return json.dumps({"exited": 1, "exitcode": 0, "out-data": text}) + + def action(self, action, args, data): + if action == "inventory": + return {"head": "a" * 40, "branch": "owner", "path": "/opt/toolstack", "root": "/opt/toolstack", + "owner": "admin", "uid": 1000, "gid": 1000, "submodules": "", "status": " M owner-work\n?? .env\n"} + if action == "init": + return {"run": RUN, "dev": 1, "ino": 2} + if action == "chunk": + self.input_chunks.append(data) + block = base64.b64decode(data, validate=True) + assert hashlib.sha256(block).hexdigest() == args[1] + return {"offset": int(args[0]) + len(block)} + if action == "extract": + return {"commit": self.source.commit, "sha256": self.source.sha256, "members": 5} + if action == "identity": + return {"pid": int(args[1]), "start": 123, "boot": BOOT} + if action == "build": + image = {"image": "sha256:" + str(len(self.images) + 1) * 64, "tag": p.tag_for(RUN, args[0]), "label": RUN} + self.images.append(image) + return image + if action == "create": + cid = str(len(self.containers) + 1) * 64 + self.containers[cid] = args[0] + return {"container": cid, "kind": args[0], "label": RUN} + if action == "start": + if args[0] == "process-sps": + return {"checked": "process-sps", "exit": 0, "proof": process_result()} + return {"checked": args[0], "http": 200} if args[0] == "serve" else {"checked": args[0], "exit": 0} + if action == "remove": + return {"removed": str(p.root_for(RUN))} + raise AssertionError(action) + + +class BoundDriver(unittest.TestCase): + def test_inventory_failure_qga_nonzero_persists_exact_step_before_shutdown(self): + io, host = self.setup_io() + original = host.action + envelope = {"error": "inventory-failed", "step": "git-status", "reason": "command-failed", + "metadata": {"path": "/srv/actual-checkout", "owner": "admin"}} + stages = [] + def action(name, args, data): + if name == "inventory": + state = json.loads(io.files.read("state"))["proof"] + self.assertEqual(state["inventory_stage"]["step"], args[1]) + self.assertEqual(state["inventory_stage"]["state"], "started") + stages.append(args[1]) + if args[1] == "git-status": + return envelope + return original(name, args, data) + host.action = action + host.qga = lambda text: json.dumps({"exited": 1, "exitcode": 1 if json.loads(text).get("error") else 0, + "out-data": text}) if text.startswith("{") else FakePVE.qga(text) + result = run_guard(io, config(), io.latch, io.clock, lambda s: None, max_ticks=8) + self.assertTrue(result.ask_parent) + self.assertTrue(result.restored) + state = json.loads(io.files.read("state"))["proof"] + self.assertEqual(state["inventory_failure"], envelope) + self.assertEqual(stages, list(p.INVENTORY_STEPS[:-1])) + self.assertFalse(state["root_attempted"]) + self.assertFalse(state["complete"]) + self.assertEqual(state["transferred"], 0) + self.assertEqual(host.job_sequence, []) + self.assertEqual(host.calls.count(("qm", "shutdown", "130", "--timeout", "90")), 1) + self.assertEqual(host.memory, 8192) + + def setup_io(self, filler=0): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = str(Path(tmp.name).resolve()) + os.chmod(root, 0o700) + host = FakePVE() + host.source = source(filler) + latch = StopLatch() + io = LinuxSubprocessIO(root, config(), latch, "discover", popen=host, + observe=lambda pid: 123, source=host.source) + self.addCleanup(io.close) + io.lease_age = lambda: None if host.lost_lease and host.polls else 0 + io.admit_text = lambda: RUN + def read(path): + if path == "/proc/meminfo": + return "MemAvailable: %d kB" % (1 if host.low_during_build and host.polls else 7100000) + return PRESSURE + io.read = read + io.proof.sleep = lambda seconds: None + return io, host + + def test_full_real_binding_sequence_dirty_owner_chunks_async_and_cleanup(self): + io, host = self.setup_io(p.CHUNK + 1) + result = run_guard(io, config(), io.latch, io.clock, lambda s: None, max_ticks=8) + self.assertFalse(result.ask_parent, result) + self.assertTrue(result.restored) + self.assertTrue(io.proof.completed) + self.assertGreater(len(host.input_chunks), 1) + self.assertTrue(all(len(c) < 1024 * 1024 for c in host.input_chunks)) + self.assertEqual(b"".join(base64.b64decode(c) for c in host.input_chunks), host.source.data) + self.assertEqual([s for s in host.job_sequence if s.startswith("build-")], ["build-onebox", "build-echo-api", "build-echo-mcp"]) + self.assertEqual(len(io.proof.containers), 5) + self.assertEqual(host.job_sequence[-4:], ["create-process-sps", "start-process-sps", "create-serve", "start-serve"]) + evidence = json.loads(io.files.read("proof-evidence")) + self.assertEqual(next(e["result"]["proof"] for e in evidence if e["step"] == "start-process-sps"), process_result()) + self.assertTrue(all(len(cid) == 64 for cid in io.proof.containers)) + self.assertEqual(len(io.proof.images), 3) + self.assertEqual(host.containers, {}) + self.assertEqual(host.images, []) + self.assertIn(" M owner-work", json.loads(io.files.read("owner-inventory"))["status"]) + shutdown = host.calls.index(("qm", "shutdown", "130", "--timeout", "90")) + remove_image = max(i for i, argv in enumerate(host.calls) if "rm" in argv and "image" in argv) + self.assertLess(remove_image, shutdown) + self.assertEqual(host.calls.count(("qm", "shutdown", "130", "--timeout", "90")), 1) + self.assertNotIn(("qm", "stop", "130"), host.calls) + self.assertEqual(host.memory, 8192) + # Exec-status only for recorded QGA job IDs, with full production probes + # between polls, and guest start jobs after durable create completion. + polls = [i for i, c in enumerate(host.calls) if c[:3] == ("qm", "guest", "exec-status")] + for left, right in zip(polls, polls[1:]): + self.assertTrue(any(c == g.production_ps_argv() for c in host.calls[left + 1:right])) + self.assertFalse(any("reset" in c or "clean" in c or "prune" in c for c in host.calls)) + + def test_memory_and_production_loss_during_build_fence_without_retry(self): + for failure in ("low_during_build", "bad_prod_during_build", "lost_lease"): + io, host = self.setup_io() + setattr(host, failure, True) + result = run_guard(io, config(), io.latch, io.clock, lambda s: None, max_ticks=8) + self.assertTrue(result.ask_parent) + self.assertFalse(io.proof.completed) + self.assertEqual(host.job_sequence, ["build-onebox"]) + self.assertIsNotNone(io.proof.pending) + self.assertIn(("qm", "shutdown", "130", "--timeout", "90"), host.calls) + self.assertFalse(any("remove" in c for c in host.calls)) + + def test_missing_qga_pid_and_build_failure_are_no_retry_unknown(self): + for failure in ("lose_pid", "fail_build"): + io, host = self.setup_io() + setattr(host, failure, True) + result = run_guard(io, config(), io.latch, io.clock, lambda s: None, max_ticks=8) + self.assertTrue(result.ask_parent) + self.assertEqual(host.job_sequence, ["build-onebox"]) + self.assertEqual(host.calls.count(("qm", "shutdown", "130", "--timeout", "90")), 1) + + def test_stop_during_create_never_starts_unrecorded_late_container(self): + io, host = self.setup_io() + host.late_trip = lambda: io.latch.trip("injected-stop") + result = run_guard(io, config(), io.latch, io.clock, lambda s: None, max_ticks=8) + self.assertTrue(result.ask_parent) + self.assertEqual(host.job_sequence, ["build-onebox", "create-onebox"]) + self.assertIsNotNone(io.proof.pending) + self.assertTrue(io.latch.closed()) + self.assertNotIn("start-onebox", host.job_sequence) + + def test_fixed_allowlist_rejects_raw_shell_unadmitted_jobs_and_status_pid(self): + io, host = self.setup_io() + io.owned_start = True + for command in (("qm", "guest", "exec-status", "130", "999"), + ("qm", "guest", "exec", "130", "--synchronous", "0", "--", "/bin/sh", "-c", "touch /owner"), + io.proof.command("build", ("onebox",), asynchronous=True)): + with self.assertRaises(g.GuardRefusal): + io.run(command, 45) + self.assertEqual(host.calls, []) + + def test_source_driver_no_admit_does_not_autostart_or_transfer(self): + io, host = self.setup_io() + io.admit_text = lambda: None + result = run_guard(io, config(), io.latch, io.clock, lambda s: None, max_ticks=1) + self.assertFalse(result.restored) + self.assertFalse(io.proof.root_attempted) + self.assertFalse(host.input_chunks) + self.assertFalse(any(c[:2] in (("qm", "set"), ("qm", "start"), ("qm", "shutdown")) for c in host.calls)) + + def test_cleanup_allowlist_cannot_be_used_for_late_build(self): + io, host = self.setup_io() + io.owned_start = True + io.begin_cleanup() + command = io.proof.command("build", ("onebox",), asynchronous=True) + io.proof.expected = (command, None) + with self.assertRaises(g.GuardRefusal): + io.run(command, 45) + self.assertEqual(host.calls, []) + io.latch.trip("stop") + with self.assertRaises(g.GuardRefusal): + io.proof.job("build", "onebox") + self.assertEqual(host.calls, []) + + def test_used_or_mislabeled_images_preserved_cleanup_still_shuts_down(self): + for fault in ("used_image", "wrong_image_label"): + io, host = self.setup_io() + setattr(host, fault, True) + result = run_guard(io, config(), io.latch, io.clock, lambda s: None, max_ticks=8) + self.assertTrue(result.ask_parent) + self.assertEqual(len(host.images), 3) + self.assertIn(("qm", "shutdown", "130", "--timeout", "90"), host.calls) + self.assertFalse(any("remove" in c for c in host.calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_vm_proof_guard.py b/tests/release/test_vm_proof_guard.py new file mode 100644 index 0000000..4a9a8f1 --- /dev/null +++ b/tests/release/test_vm_proof_guard.py @@ -0,0 +1,483 @@ +"""Fake-command tests for the VM proof guard. No host, SSH, or Docker access.""" + +from pathlib import Path +import json +import unittest + +from vm_proof_guard import ( + PROOF_PS_FORMAT, + LABEL_KEY, + HEADROOM_MIB, + HEAVY_BUILD_ATTEMPTS, + MEMORY_FLOOR_MIB, + NOD_HEALTH_BODY, + PARENT_FREE_AVAILABLE_MIB, + PARENT_MEM_AVAILABLE_KIB, + BaselineContainer, + Completed, + GuardConfig, + GuardRefusal, + ListedContainer, + ProofRecords, + Sample, + Vm130Config, + admin_serve_argv, + agent_ready_argv, + assess, + canonical_container_id, + completed_from_guest, + image_owned, + nod_health_ok, + content_check_argv, + drive, + echo_build_argv, + guard_signal_argv, + listen_argv, + loopback_probe_argv, + main, + onebox_build_argv, + parse_free_available_mib, + parse_memavailable_kib, + parse_pressure, + process_meta_argv, + recovery_commands, + redact_qm_config, + rollback, + rollback_command_allowed, + select_owned, + set_password_argv, + start_attached_argv, + start_commands, + start_recorded_argv, + start_gate, + unit_list_argv, +) + +ROOT = Path(__file__).resolve().parents[2] +RUN = "vmproof-20261024T150000Z-a1b2c3d4" +OWNED = "a" * 64 +OTHER = "b" * 64 +SHORT = "a" * 12 +IMAGE = "sha256:" + "c" * 64 +SECRET = "cipassword: stage1-must-not-store" +BASELINE = (BaselineContainer("nod", "healthy"),) + + +def config() -> GuardConfig: + return GuardConfig(RUN, BASELINE) + + +def sample(**overrides) -> Sample: + values = dict( + mem_available_kib=PARENT_MEM_AVAILABLE_KIB, + pressure_readable=True, + full_avg10=0.0, + full_avg60=0.0, + vm100_status="running", + nod_body=NOD_HEALTH_BODY, + production=(("nod", "Up 11 days (healthy)"),), + lease_age_seconds=0.0, + elapsed_seconds=0.0, + ) + values.update(overrides) + return Sample(**values) + + +def stopped_vm(**overrides) -> Vm130Config: + values = dict(status="stopped", memory_mib=8192, cores=2, onboot=0) + values.update(overrides) + return Vm130Config(**values) + + +class FakeHost: + def __init__(self, *, shutdown_sticks=False, status_unknown=False, list_fails=False, + stop_fails=False, image_in_use=False): + self.calls = [] + self.vm130 = "running" + self.memory = 4096 + self.shutdown_sticks = shutdown_sticks + self.status_unknown = status_unknown + self.list_fails = list_fails + self.stop_fails = stop_fails + self.image_in_use = image_in_use + self.containers = {OWNED: RUN, OTHER: ""} + + def run(self, argv): + self.calls.append(argv) + if argv == ("qm", "status", "130"): + text = "status: mystery" if self.status_unknown else f"status: {self.vm130}" + return Completed(0, text) + if argv == ("qm", "status", "100"): + return Completed(0, "status: running") + if argv == ("qm", "config", "130"): + return Completed(0, f"memory: {self.memory}\ncores: 2\nonboot: 0\nagent: 1\n{SECRET}\n") + if argv == ("qm", "shutdown", "130", "--timeout", "90"): + if not self.shutdown_sticks: + self.vm130 = "stopped" + return Completed(0, "") + if argv == ("qm", "set", "130", "--memory", "8192"): + if self.vm130 != "stopped": + return Completed(1, "refusing live memory change") + self.memory = 8192 + return Completed(0, "") + inner = argv[7:] + if inner == ("docker", "ps", "-a", "--no-trunc", "--format", PROOF_PS_FORMAT): + if self.list_fails: + return Completed(1, "") + lines = [f"{cid}\t{label}\tname-{cid[:4]}" for cid, label in self.containers.items()] + return Completed(0, "\n".join(lines)) + if inner[:4] == ("docker", "stop", "--time", "10"): + if self.stop_fails: + return Completed(1, "stop failed") + return Completed(0, inner[4]) + if inner[:2] == ("docker", "rm"): + self.containers.pop(inner[2], None) + return Completed(0, "") + if inner[:5] == ("docker", "ps", "-a", "--no-trunc", "--filter"): + return Completed(0, f"{OTHER}\n" if self.image_in_use else "") + if inner[:3] == ("docker", "image", "inspect"): + tags = json.dumps([f"toolstack-vmproof:{RUN}"]) + labels = json.dumps({LABEL_KEY: RUN}) + return Completed(0, f"{IMAGE}\t{tags}\t{labels}") + if inner[:3] == ("docker", "image", "rm"): + return Completed(0, "") + if argv[0] == "curl": + return Completed(0, NOD_HEALTH_BODY) + if inner == ("docker", "ps", "--format", "{{.Names}}\t{{.Status}}"): + return Completed(0, "nod\tUp 11 days (healthy)") + raise AssertionError(argv) + + +def records() -> ProofRecords: + return ProofRecords(RUN, (OWNED,), (IMAGE,)) + + +def run_rollback(host: FakeHost): + return rollback(host, config(), records()) + + +class Assess(unittest.TestCase): + def test_parent_baseline_continues(self): + trip = assess(sample(), config()) + self.assertFalse(trip.rollback) + self.assertEqual(trip.reasons, ()) + + def test_low_memory_rolls_back(self): + trip = assess(sample(mem_available_kib=MEMORY_FLOOR_MIB * 1024 - 1), config()) + self.assertTrue(trip.rollback) + self.assertIn("mem-floor", trip.reasons) + self.assertEqual(drive((sample(), sample(mem_available_kib=1)), config()), "rollback") + + def test_floor_boundary_is_below_not_equal(self): + trip = assess(sample(mem_available_kib=MEMORY_FLOOR_MIB * 1024), config()) + self.assertFalse(trip.rollback) + + def test_bad_nod_health_rolls_back(self): + trip = assess(sample(nod_body='{"ok":false,"service":"nod"}'), config()) + self.assertIn("nod-unhealthy", trip.reasons) + + def test_unknown_probes_roll_back(self): + cases = ( + sample(mem_available_kib=None), + sample(pressure_readable=False, full_avg10=None), + sample(vm100_status=None), + sample(nod_body=None), + sample(production=None), + sample(lease_age_seconds=None), + ) + expected = ( + "mem-unknown", "pressure-unknown", "vm100-unknown", + "nod-unknown", "production-unknown", "parent-lease-unknown", + ) + for item, reason in zip(cases, expected): + with self.subTest(reason=reason): + self.assertIn(reason, assess(item, config()).reasons) + + def test_vm100_stopped_and_unhealthy_container(self): + self.assertIn("vm100-not-running", assess(sample(vm100_status="stopped"), config()).reasons) + bad = sample(production=(("nod", "Up 11 days (unhealthy)"),)) + self.assertIn("production-unhealthy:nod", assess(bad, config()).reasons) + + def test_full_pressure_stall_rolls_back_some_does_not(self): + self.assertFalse(assess(sample(full_avg10=0.01), config()).rollback) + self.assertIn("pressure-preboot-unsettled", assess(sample(full_avg10=2.01), config()).reasons) + parsed = parse_pressure( + "some avg10=1.00 avg60=0.00 avg300=0.00 total=46239720\n" + "full avg10=0.00 avg60=0.00 avg300=0.00 total=41255963\n" + ) + self.assertEqual(parsed.some_avg10, 1.0) + self.assertFalse(assess(sample(full_avg10=parsed.full_avg10), config()).rollback) + + def test_lease_and_window_boundaries(self): + self.assertFalse(assess(sample(lease_age_seconds=90), config()).rollback) + self.assertIn("parent-lease-stale", assess(sample(lease_age_seconds=91), config()).reasons) + self.assertFalse(assess(sample(elapsed_seconds=40 * 60 - 1), config()).rollback) + self.assertIn("window-expired", assess(sample(elapsed_seconds=40 * 60), config()).reasons) + + def test_nonfinite_probes_and_bad_baseline_fail_closed(self): + self.assertIsNone(parse_pressure( + "some avg10=0.00 avg60=0.00 avg300=0.00 total=1\n" + "full avg10=nan avg60=0.00 avg300=0.00 total=1\n" + )) + self.assertIsNone(parse_pressure( + "some avg10=0.00 avg60=0.00 avg300=0.00 total=1\n" + "full avg10=inf avg60=0.00 avg300=0.00 total=1\n" + )) + self.assertIn("pressure-unknown", assess(sample(full_avg10=float("nan")), config()).reasons) + self.assertIn("window-unknown", assess(sample(elapsed_seconds=float("-inf")), config()).reasons) + self.assertIn("parent-lease-unknown", assess(sample(lease_age_seconds=float("nan")), config()).reasons) + self.assertIn("free-unknown", assess(sample(free_available_mib=None), config()).reasons) + self.assertIn("probe-config-invalid", assess(sample(), GuardConfig(RUN, BASELINE, window_seconds=0)).reasons) + self.assertIn("production-unknown", assess(sample(), GuardConfig(RUN, (BaselineContainer("nod", "yes"),))).reasons) + self.assertTrue(nod_health_ok('{"service": "nod", "ok": true}')) + self.assertFalse(nod_health_ok('{"ok": true, "service": "nod", "extra": 1}')) + self.assertFalse(assess(sample(nod_body='{"ok": true, "service": "nod"}'), config()).rollback) + + def test_empty_baseline_is_unknown(self): + trip = assess(sample(), GuardConfig(RUN, ())) + self.assertIn("production-unknown", trip.reasons) + + def test_parent_meminfo_and_free_parsers(self): + self.assertEqual(parse_memavailable_kib("MemAvailable: 7194384 kB\n"), PARENT_MEM_AVAILABLE_KIB) + text = " total used free shared buff/cache available\nMem: 7950 1000 500 10 2000 6952\n" + self.assertEqual(parse_free_available_mib(text), PARENT_FREE_AVAILABLE_MIB) + self.assertIsNone(parse_pressure("not pressure")) + + +class OwnedFilter(unittest.TestCase): + def test_both_id_and_label_required(self): + listed = ( + ListedContainer(OWNED, RUN, "proof"), + ListedContainer(OTHER, "", "preexisting"), + ListedContainer("d" * 64, RUN, "unrecorded"), + ListedContainer(SHORT, RUN, "truncated"), + ListedContainer("not-an-id", RUN, "bad"), + ) + recorded = (OWNED, "e" * 12) + selection = select_owned(RUN, recorded, listed) + self.assertEqual(selection.stop, (OWNED,)) + self.assertIn(f"not-owned:{'d' * 64}", selection.mismatch) + self.assertIn("malformed-id:truncated", selection.mismatch) + self.assertIn("malformed-id:bad", selection.mismatch) + self.assertIn("recorded-id-rejected:" + "e" * 12, selection.mismatch) + self.assertNotIn(SHORT, selection.stop) + self.assertNotIn(OTHER, selection.stop) + + def test_image_id_tag_and_label_must_all_match(self): + tags = json.dumps([f"toolstack-vmproof-echo-mcp:{RUN}"]) + labels = json.dumps({LABEL_KEY: RUN}) + self.assertTrue(image_owned(f"{IMAGE}\t{tags}\t{labels}", RUN, IMAGE)) + shared = json.dumps([f"toolstack-vmproof:{RUN}", "python:3.12-slim"]) + self.assertFalse(image_owned(f"{IMAGE}\t{shared}\t{labels}", RUN, IMAGE)) + self.assertFalse(image_owned(f"{IMAGE}\t{tags}\t{{}}", RUN, IMAGE)) + self.assertIsNone(canonical_container_id(SHORT)) + self.assertIsNone(canonical_container_id(IMAGE)) + + +class RollbackMachine(unittest.TestCase): + def test_command_order_filters_preexisting_and_hides_config_secret(self): + host = FakeHost() + report = run_rollback(host) + self.assertTrue(report.restored) + self.assertFalse(report.ask_parent) + self.assertNotIn(SECRET, repr(report)) + stop = self._index(host, ("docker", "stop", "--time", "10", OWNED)) + remove = self._index(host, ("docker", "rm", OWNED)) + image = self._index(host, ("docker", "image", "rm", IMAGE)) + shutdown = self._index(host, ("qm", "shutdown", "130", "--timeout", "90")) + statuses = [i for i, call in enumerate(host.calls) if call == ("qm", "status", "130")] + self.assertEqual(len([i for i in statuses if i > shutdown]), 2) # final restore recheck + restore = self._index(host, ("qm", "set", "130", "--memory", "8192")) + confirm = self._index(host, ("qm", "status", "100")) + self.assertLess(stop, remove) + self.assertLess(remove, image) + self.assertLess(image, shutdown) + self.assertLess(shutdown, restore) + self.assertLess(restore, confirm) + self.assertNotIn(("docker", "stop", "--time", "10", OTHER), [call[7:] for call in host.calls]) + self._assert_no_forbidden(host) + + def test_idempotent_second_cleanup_does_not_repeat_mutations(self): + host = FakeHost() + first = run_rollback(host) + self.assertTrue(first.restored) + host.calls.clear() + second = run_rollback(host) + self.assertTrue(second.restored) + joined = [" ".join(call) for call in host.calls] + self.assertFalse(any("shutdown" in call or "docker stop" in call or "--memory" in call for call in joined)) + self.assertIn(("qm", "status", "100"), host.calls) + self._assert_no_forbidden(host) + + def test_graceful_shutdown_failure_does_not_poweroff_or_restore(self): + host = FakeHost(shutdown_sticks=True) + report = run_rollback(host) + self.assertFalse(report.restored) + self.assertTrue(report.ask_parent) + self.assertIn("graceful-shutdown-failed", report.reasons) + self.assertNotIn(("qm", "set", "130", "--memory", "8192"), host.calls) + self._assert_no_forbidden(host) + self.assertIn(("qm", "status", "100"), host.calls) + + def test_failed_shutdown_retry_remains_graceful(self): + host = FakeHost(shutdown_sticks=True) + run_rollback(host) + host.calls.clear() + second = run_rollback(host) + self.assertIn("graceful-shutdown-failed", second.reasons) + self.assertEqual([call for call in host.calls if call[:2] == ("qm", "shutdown")], [ + ("qm", "shutdown", "130", "--timeout", "90"), + ]) + self._assert_no_forbidden(host) + + def test_unknown_vm_status_changes_nothing(self): + host = FakeHost(status_unknown=True) + report = run_rollback(host) + self.assertTrue(report.ask_parent) + self.assertIn("vm130-status-unknown", report.reasons) + self.assertNotIn(("qm", "shutdown", "130", "--timeout", "90"), host.calls) + self._assert_no_forbidden(host) + + def test_unknown_container_list_does_not_stop_unidentified_containers(self): + host = FakeHost(list_fails=True) + report = run_rollback(host) + self.assertIn("owned-list-unknown", report.reasons) + self.assertFalse(any(call[7:9] == ("docker", "stop") for call in host.calls if len(call) > 8)) + self.assertIn(("qm", "shutdown", "130", "--timeout", "90"), host.calls) + + def test_docker_stop_failure_does_not_kill(self): + host = FakeHost(stop_fails=True) + report = run_rollback(host) + self.assertIn(f"docker-stop-failed:{OWNED}", report.reasons) + self.assertFalse(any("kill" in call for call in host.calls)) + self.assertIn(("qm", "shutdown", "130", "--timeout", "90"), host.calls) + + def test_image_in_use_is_not_removed(self): + host = FakeHost(image_in_use=True) + report = run_rollback(host) + self.assertIn(f"image-in-use:{IMAGE}", report.reasons) + self.assertFalse(any(call[7:10] == ("docker", "image", "rm") for call in host.calls if len(call) > 9)) + + def test_allowlist_rejects_production_and_broad_cleanup(self): + rejected = ( + ("qm", "stop", "130"), + ("qm", "set", "100", "--memory", "8192"), + ("qm", "set", "130", "--memory", "4096"), + ("qm", "shutdown", "130", "--timeout", "90", "--forceStop", "1"), + ("qm", "guest", "exec", "120", "--timeout", "15", "--", "docker", "ps"), + ("qm", "guest", "exec", "100", "--timeout", "15", "--", "docker", "stop", OWNED), + ("qm", "guest", "exec", "130", "--timeout", "30", "--", "docker", "kill", OWNED), + ("qm", "guest", "exec", "130", "--timeout", "30", "--", "docker", "system", "prune", "-f"), + ("kill", "-9", "-1"), + ) + for argv in rejected: + with self.subTest(argv=argv): + with self.assertRaises(GuardRefusal): + rollback_command_allowed(argv, RUN) + + def _index(self, host, inner): + for index, argv in enumerate(host.calls): + if argv == inner or argv[7:] == inner: + return index + self.fail(inner) + + def _assert_no_forbidden(self, host): + for argv in host.calls: + self.assertNotIn("stop", argv[1:2]) + self.assertNotIn("prune", argv) + self.assertNotIn("120", argv) + self.assertNotIn("--forceStop", argv) + self.assertNotEqual(argv[:2], ("qm", "stop")) + + +class StartAndIdentity(unittest.TestCase): + def test_parent_baseline_start_is_allowed_and_ordered(self): + gate = start_gate(sample(), config(), stopped_vm()) + self.assertFalse(gate.rollback) + self.assertEqual(start_commands(gate), ( + ("qm", "set", "130", "--memory", "4096"), + ("qm", "start", "130"), + )) + + def test_headroom_between_floor_and_reservation_refuses_start(self): + gate = start_gate(sample(mem_available_kib=2048 * 1024), config(), stopped_vm()) + self.assertIn("mem-headroom", gate.reasons) + self.assertNotIn("mem-floor", gate.reasons) + self.assertEqual(start_commands(gate), ()) + self.assertEqual(HEADROOM_MIB, 5632) + + def test_resized_stopped_guest_recovers_without_start(self): + self.assertEqual(recovery_commands(stopped_vm(memory_mib=4096)), ( + ("qm", "set", "130", "--memory", "8192"), + )) + self.assertEqual(recovery_commands(stopped_vm()), ()) + self.assertIsNone(recovery_commands(stopped_vm(status="running", memory_mib=4096))) + self.assertIsNone(recovery_commands(stopped_vm(memory_mib=4096, cores=4))) + + def test_signal_requires_exact_start_identity(self): + self.assertEqual(guard_signal_argv(44, 100, 44, 100), ("kill", "-TERM", "44")) + self.assertIsNone(guard_signal_argv(44, 100, 44, 101)) + self.assertIsNone(guard_signal_argv(44, 100, None, None)) + self.assertIsNone(guard_signal_argv(1, 100, 1, 100)) + + def test_config_redaction_and_secret_guest_payload(self): + self.assertNotIn(SECRET, redact_qm_config(f"memory: 8192\n{SECRET}\n")) + payload = json.dumps({"exitcode": 0, "exited": 1, "out-data": SECRET}) + self.assertEqual(completed_from_guest(payload).stdout, "") + self.assertEqual(completed_from_guest(payload).code, 1) + + def test_main_refuses_host_execution(self): + self.assertEqual(main([]), 2) + self.assertIn("host execution refused", "guard library; host execution refused") + source = (ROOT / "tests/release/vm_proof_guard.py").read_text() + self.assertNotIn("subprocess", source) + self.assertNotIn("os.system", source) + + +class ProofArgv(unittest.TestCase): + def test_builds_are_unique_root_context_and_one_heavy_attempt(self): + self.assertEqual(HEAVY_BUILD_ATTEMPTS, 1) + onebox = onebox_build_argv(RUN) + echo = echo_build_argv(RUN, "tools/echo_mcp/Dockerfile") + self.assertEqual(onebox[-1], ".") + self.assertIn("deploy/docker/Dockerfile", onebox) + self.assertIn("tools/echo_mcp/Dockerfile", echo) + self.assertEqual(echo[-1], ".") + self.assertNotIn("compose", onebox + echo) + with self.assertRaises(GuardRefusal): + echo_build_argv(RUN, "deploy/docker/Dockerfile") + + def test_runtime_is_loopback_without_socket_or_password(self): + secret = "fake-password-not-in-argv" + root = "/var/tmp/toolstack-vm-proof/" + RUN + data = root + "/data" + image = f"toolstack-vmproof:{RUN}" + argv = set_password_argv(RUN, image, data, root) + admin_serve_argv(RUN, image, data, root, 18080) + argv += content_check_argv(RUN, image) + loopback_probe_argv(18080, "/login") + text = " ".join(argv) + self.assertNotIn(secret, text) + self.assertNotIn("0.0.0.0:", text) + self.assertNotIn("docker.sock", text) + self.assertNotIn("--rm", argv) + self.assertIn("127.0.0.1:18080:8780", text) + self.assertIn("--network none", text) + self.assertIn("--restart no", text) + self.assertEqual(start_recorded_argv(OWNED), ("docker", "start", OWNED)) + with self.assertRaises(GuardRefusal): + start_recorded_argv(SHORT) + self.assertEqual(start_attached_argv(OWNED)[:3], ("docker", "start", "-a")) + with self.assertRaises(GuardRefusal): + admin_serve_argv(RUN, image, "/opt/toolstack", root, 18080) + with self.assertRaises(GuardRefusal): + loopback_probe_argv(18080, "/v1/health") + + def test_boot_inventory_commands_have_no_journal_or_env(self): + listed = " ".join(unit_list_argv() + listen_argv() + process_meta_argv() + agent_ready_argv()) + self.assertNotIn("journalctl", listed) + self.assertNotIn("args", process_meta_argv()[-1]) + self.assertIn("comm", process_meta_argv()[-1]) + self.assertIn("/bin/true", agent_ready_argv()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_vm_proof_inventory.py b/tests/release/test_vm_proof_inventory.py new file mode 100644 index 0000000..4c767f9 --- /dev/null +++ b/tests/release/test_vm_proof_inventory.py @@ -0,0 +1,136 @@ +"""Fake-command tests for VM source inventory. No checkout or host is changed.""" + +from pathlib import Path +import unittest + +from vm_proof_inventory import ( + STAGE2_STEPS, + TESTED_ARCHIVE_SHA256, + TESTED_COMMIT, + UNEXECUTED_GATES, + CheckoutInventory, + InventoryRefusal, + accept_archive_sha256, + archive_command, + in_place_commands, + inventory_commands, + inventory_from, + metadata_only, + parse_unit, + removal_command, + temp_checkout_commands, + update_route, +) + +ROOT = Path(__file__).resolve().parents[2] +PROOF = "/var/tmp/toolstack-vm-proof/vmproof-20261024T150000Z-a1b2c3d4" +DEST = PROOF + "/src" +PREEXISTING = "/opt/toolstack" +HEAD = "a" * 40 + + +def known(paths=()): + return CheckoutInventory(PREEXISTING, True, HEAD, "main", paths) + + +class InventoryStrategy(unittest.TestCase): + def test_inventory_commands_are_status_and_hash_only(self): + commands = inventory_commands(PREEXISTING) + self.assertTrue(metadata_only(commands)) + joined = " ".join(" ".join(item) for item in commands) + self.assertIn("rev-parse HEAD", joined) + self.assertIn("--porcelain=v1", joined) + self.assertNotIn("diff", joined) + self.assertNotIn(".env", joined) + + def test_dirty_checkout_is_preserved_even_if_parent_approves_overwrite(self): + inventory = inventory_from(PREEXISTING, HEAD, "main", " M deploy/docker/.env\n?? secrets.toml\n", True) + self.assertEqual(inventory.dirty_paths, ("deploy/docker/.env", "secrets.toml")) + route = update_route(inventory, rollback_bundle_saved=True, parent_approved_inplace=True) + self.assertEqual(route.kind, "temp-checkout") + self.assertEqual(route.reason, "dirty-preserve") + with self.assertRaises(InventoryRefusal): + in_place_commands(route, PREEXISTING, PROOF + "/old.bundle", TESTED_COMMIT) + + def test_unknown_checkout_refuses_update(self): + inventory = inventory_from(PREEXISTING, None, None, "", False) + route = update_route(inventory, rollback_bundle_saved=True, parent_approved_inplace=True) + self.assertEqual(route.kind, "refuse") + self.assertFalse(inventory.known) + + def test_clean_default_is_temp_checkout_until_rollback_and_approval(self): + clean = known() + default = update_route(clean, rollback_bundle_saved=True, parent_approved_inplace=False) + self.assertEqual(default.reason, "temp-extract-only") + missing = update_route(clean, rollback_bundle_saved=False, parent_approved_inplace=True) + self.assertEqual(missing.reason, "rollback-not-recorded") + approved = update_route(clean, rollback_bundle_saved=True, parent_approved_inplace=True) + self.assertEqual(approved.kind, "temp-checkout") + + def test_temp_checkout_and_removal_cannot_touch_preexisting_tree(self): + commands = temp_checkout_commands(PROOF + "/tested.tar", DEST, PROOF, PREEXISTING) + self.assertEqual(commands[1][:3], ("tar", "-xf", PROOF + "/tested.tar")) + self.assertNotIn("reset", " ".join(" ".join(item) for item in commands)) + self.assertNotIn("clean", " ".join(" ".join(item) for item in commands)) + self.assertTrue(accept_archive_sha256(TESTED_ARCHIVE_SHA256 + " tested.tar\n")) + self.assertFalse(accept_archive_sha256(HEAD)) + with self.assertRaises(InventoryRefusal): + temp_checkout_commands(PROOF + "/tested.tar", PREEXISTING, PROOF, PREEXISTING) + with self.assertRaises(InventoryRefusal): + removal_command(PREEXISTING, PROOF, PREEXISTING) + with self.assertRaises(InventoryRefusal): + removal_command(PROOF, PROOF, PREEXISTING) + self.assertEqual(removal_command(DEST, PROOF, PREEXISTING), ("rm", "-rf", DEST)) + + def test_archive_is_exact_commit_and_not_a_branch_history(self): + command = archive_command(PREEXISTING, PROOF + "/tested.tar", TESTED_COMMIT) + self.assertEqual(command[:4], ("git", "-C", PREEXISTING, "archive")) + self.assertIn("--format=tar", command) + self.assertNotIn("push", command) + self.assertNotIn("--all", command) + self.assertEqual(TESTED_ARCHIVE_SHA256, "817da51460438e431c301d0691bb2165df85dcb519e614cdecd2f22aa0729764") + with self.assertRaises(InventoryRefusal): + archive_command(PREEXISTING, PROOF + "/tested.tar", "5ec463b") + + def test_in_place_update_is_refused(self): + route = update_route(known(), rollback_bundle_saved=True, parent_approved_inplace=True) + with self.assertRaises(InventoryRefusal): + in_place_commands(route, PREEXISTING, PROOF + "/tested.tar", TESTED_COMMIT) + self.assertEqual(TESTED_COMMIT, "5ec463bc53c051255d6126e0e87329fad2207096") + + +class UnitAndStageBoundary(unittest.TestCase): + def test_sps_and_admin_templates_keep_writable_paths_without_install(self): + sps = parse_unit((ROOT / "deploy/toolstack-sps.service").read_text()) + admin = parse_unit((ROOT / "deploy/toolstack-admin.service").read_text()) + self.assertEqual(sps.read_write, ("/var/lib/toolstack", "/var/log/toolstack")) + self.assertEqual(sps.state_directory, "toolstack") + self.assertEqual(sps.logs_directory, "toolstack") + self.assertEqual(sps.xdg_state, "/var/lib") + self.assertEqual(sps.xdg_config, "/var/lib") + self.assertTrue(sps.no_new_privileges) + self.assertNotIn("-/etc/toolstack/sps.env", sps.environment_files) + self.assertIn("-/etc/toolstack/sps-runtime.env", sps.environment_files) + self.assertFalse(sps.mentions_docker_socket) + self.assertFalse(sps.mentions_sudo) + self.assertEqual(admin.runner, "process") + self.assertEqual(admin.read_write, ("/opt/toolstack",)) + self.assertTrue(admin.no_new_privileges) + self.assertFalse(admin.mentions_docker_socket) + self.assertFalse(admin.mentions_sudo) + + def test_stage2_order_and_unexecuted_installation_gates(self): + self.assertLess(STAGE2_STEPS.index("start-guard-before-resize"), STAGE2_STEPS.index("qm-set-4096-while-stopped")) + self.assertLess(STAGE2_STEPS.index("inventory-status-hash-paths"), STAGE2_STEPS.index("archive-tested-commit")) + self.assertLess( + STAGE2_STEPS.index("one-heavy-onebox-build"), + STAGE2_STEPS.index("owned-cleanup-graceful-shutdown-restore-8192"), + ) + self.assertIn("deploy/install.sh", UNEXECUTED_GATES) + self.assertIn("Docker+SPS runner certification", UNEXECUTED_GATES) + self.assertIn("process SPS TLS/restart/recovery on this VM", UNEXECUTED_GATES) + self.assertNotIn("systemctl", " ".join(STAGE2_STEPS)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_vm_proof_lease.py b/tests/release/test_vm_proof_lease.py new file mode 100644 index 0000000..7011a20 --- /dev/null +++ b/tests/release/test_vm_proof_lease.py @@ -0,0 +1,153 @@ +"""Actual private-file publication and real CLI with fake PVE only.""" +import fcntl +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import vm_proof_linux as linux +import vm_proof_guard as g +import test_vm_proof_driver as driver_tests +from vm_proof_adapter import StopLatch, run_guard +from test_vm_proof_adapter import RUN, PRESSURE, config + + +class LeasePublication(unittest.TestCase): + def make(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + io = linux.LinuxSubprocessIO(str(Path(temp.name).resolve()), config(), StopLatch(), "discover", + observe=lambda pid: 123, clock=lambda: 100) + self.addCleanup(io.close) + io.files.create("controller", json.dumps({"pid": 42, "start": 123})) + self.record = {"pid": 42, "start": 123, "run": RUN, "monotonic": 99} + io.files.create("lease", json.dumps(self.record)) + io.read = lambda path: "MemAvailable: 7000000 kB" if path == "/proc/meminfo" else PRESSURE + return io + + def test_old_writer_lock_deterministically_causes_false_resource_trip(self): + io = self.make() + fd = io.files.file("lease", os.O_WRONLY) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertIsNone(io.lease_age()) + with self.assertRaises(g.GuardRefusal): + io.check() + evidence = json.loads(io.files.read("state"))["fast_probe"] + self.assertEqual(evidence["reasons"], ["publication-locked"]) + self.assertEqual(evidence["memory_kib"], 7000000) + self.assertEqual(evidence["psi_full_avg10"], 0) + self.assertTrue(evidence["controller_identity_valid"]) + finally: + os.close(fd) + + def test_atomic_reader_observes_complete_old_then_new_while_temp_locked(self): + io = self.make() + old_fd = io.files.file("lease", os.O_RDONLY) + self.addCleanup(os.close, old_fd) + write = io.files._write + def held_write(fd, data): + if fd == io.state_fd: + return write(fd, data) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertEqual(io.lease_age(), 1) + io.check() + write(fd, data) + with patch.object(io.files, "_write", side_effect=held_write): + io.files.publish_lease(dict(self.record, monotonic=100)) + self.assertEqual(io.lease_age(), 0) + self.assertEqual(json.loads(os.read(old_fd, 4096)), self.record) + self.assertFalse((Path(io.root) / ".lease-next").exists()) + self.assertNotEqual(os.fstat(old_fd).st_ino, os.stat(Path(io.root) / "lease").st_ino) + + def test_unknown_temp_and_invalid_existing_lease_are_preserved(self): + for fault in ("temp", "symlink", "mode", "hardlink", "malformed", "other-owner"): + io = self.make() + root = Path(io.root) + if fault == "temp": + (root / ".lease-next").write_text("unknown") + elif fault == "symlink": + (root / "lease").rename(root / "original") + (root / "lease").symlink_to(root / "original") + elif fault == "mode": + (root / "lease").chmod(0o644) + elif fault == "hardlink": + os.link(root / "lease", root / "other") + else: + (root / "lease").write_text("bad" if fault == "malformed" else json.dumps(dict(self.record, pid=99))) + before = (root / "lease").read_bytes() + with self.assertRaises((OSError, ValueError, g.GuardRefusal)): + io.files.publish_lease(dict(self.record, monotonic=100)) + self.assertEqual((root / "lease").read_bytes(), before) + if fault == "temp": + self.assertEqual((root / ".lease-next").read_text(), "unknown") + + def test_failed_publish_removes_only_owned_temp(self): + io = self.make() + with patch.object(linux.os, "replace", side_effect=OSError("injected")): + with self.assertRaises(OSError): + io.files.publish_lease(dict(self.record, monotonic=100)) + self.assertFalse((Path(io.root) / ".lease-next").exists()) + self.assertEqual(json.loads(io.files.read("lease")), self.record) + + def test_each_fast_probe_cause_is_bounded_and_reason_qualified(self): + for cause in ("memory-unknown", "memory-low", "pressure-unknown", "pressure-preboot-unsettled", "lease-stale", + "lease-unknown", "controller-unknown", "controller-dead-or-reused", "lease-clock-invalid", "deadline"): + io = self.make() + if cause.startswith("memory"): + io.read = lambda path: ("sensitive-not-recorded" if cause == "memory-unknown" else "MemAvailable: 1 kB") if path == "/proc/meminfo" else PRESSURE + elif cause.startswith("pressure"): + io.read = lambda path: "MemAvailable: 7000000 kB" if path == "/proc/meminfo" else ("sensitive-not-recorded" if cause == "pressure-unknown" else PRESSURE.replace("full avg10=0", "full avg10=3")) + elif cause == "controller-dead-or-reused": + io.observe = lambda pid: None + elif cause == "deadline": + io.deadline = 100 + elif cause in ("lease-stale", "lease-clock-invalid"): + (Path(io.root) / "lease").write_text(json.dumps(dict(self.record, monotonic=0 if cause == "lease-stale" else 101))) + else: + (Path(io.root) / ("controller" if cause == "controller-unknown" else "lease")).unlink() + with self.subTest(cause=cause), self.assertRaises(g.GuardRefusal): + io.check() + self.assertEqual(io.fast_probe["reasons"], [cause]) + self.assertNotIn("sensitive-not-recorded", io.files.read("state")) + + +class CLIOutcome(unittest.TestCase): + setup_io = driver_tests.BoundDriver.setup_io + + def test_real_cli_start_abort_no_admit_and_all_jobs_complete(self): + for scenario in ("start-abort", "no-admit", "no-proof", "complete"): + io, host = self.setup_io() + if scenario == "start-abort": + io.lease_age = lambda: None if host.memory == 4096 else 0 + elif scenario == "no-admit": + io.admit_text = lambda: None + elif scenario == "no-proof": + io.proof = None + io.checkout = "/opt/toolstack" + def factory(root, cfg, latch, checkout, source): + io.latch = latch + return io + def bounded_guard(io, cfg, latch, clock, sleeper): + return run_guard(io, cfg, latch, io.clock, lambda seconds: None, max_ticks=8) + argv = ["guard", "--execute", "--live", "--run-id", RUN, "--root", "/var/tmp/" + RUN, + "--checkout", "discover", "--baseline", "nod:healthy", "--archive", "/fake/source.tar", + "--source-commit", host.source.commit, "--archive-sha256", host.source.sha256] + with patch.object(linux, "host_gate"), patch("vm_proof_driver.Source.load", return_value=host.source), \ + patch.object(linux, "LinuxSubprocessIO", side_effect=factory), \ + patch.object(linux, "run_guard", side_effect=bounded_guard), \ + patch.object(linux.signal, "signal"), patch.object(io, "close"): + code = linux.cli(argv) + result = json.loads(io.files.read("result")) + self.assertEqual(code, 0 if scenario == "complete" else 1) + self.assertEqual(result["proof_complete"], scenario == "complete") + self.assertEqual(result["ask_parent"], scenario != "complete") + self.assertEqual(result["restored"], scenario != "no-admit") + if scenario != "complete": + self.assertIn("proof-incomplete", result["reasons"]) + self.assertFalse(host.jobs) + if scenario == "start-abort": + self.assertEqual(host.memory, 8192) + self.assertNotIn(("qm", "start", "130"), host.calls) diff --git a/tests/release/test_vm_proof_linux.py b/tests/release/test_vm_proof_linux.py new file mode 100644 index 0000000..069462e --- /dev/null +++ b/tests/release/test_vm_proof_linux.py @@ -0,0 +1,254 @@ +"""Real binding exercised with injected Popen pipes: never launches a process.""" +import json +import os +from pathlib import Path +import signal +import tempfile +import unittest +from unittest.mock import patch + +import vm_proof_guard as g +from vm_proof_adapter import StopLatch, _safe_rollback, run_guard +from vm_proof_linux import LinuxSubprocessIO, PrivateFiles, open_directory +from test_vm_proof_adapter import FakeIO, RUN, config, run + + +class Child: + pid = 424242 + + def __init__(self, output=b"", error=b"", hung=False): + self.stdout = self.pipe(output) + self.stderr = self.pipe(error) + self.code = None if hung else 0 + self.signals = [] + + @staticmethod + def pipe(data): + r, w = os.pipe() + os.write(w, data) + os.close(w) + return os.fdopen(r, "rb", buffering=0) + + def poll(self): + return self.code + + def wait(self, timeout): + return self.code + + def send_signal(self, sig): + self.signals.append(sig) + self.code = -sig + + +class Ownership(unittest.TestCase): + def test_parent_repro_external_running_never_mutates(self): + io = FakeIO() + io.vm130 = "running" + io.admit = None + result = run(io, ticks=1) + self.assertFalse(result.restored) + self.assertFalse(any(c[:2] in (("qm", "set"), ("qm", "start"), ("qm", "shutdown")) for c in io.calls)) + + def test_external_running_with_admit_still_refuses(self): + io = FakeIO() + io.vm130, io.admit = "running", RUN + run(io, ticks=1) + self.assertFalse(any(c[:2] in (("qm", "set"), ("qm", "shutdown")) for c in io.calls)) + + def test_external_resize_between_readiness_and_admit(self): + io = FakeIO() + def admit(): + io.memory = 6000 + return RUN + io.admit_text = admit + result = run(io, ticks=2) + self.assertFalse(result.restored) + self.assertFalse(any(c[:2] in (("qm", "set"), ("qm", "start"), ("qm", "shutdown")) for c in io.calls)) + + def test_owned_but_external_shape_drift_never_restores(self): + for memory, state, start in ((6000, "stopped", False), (8192, "running", True), (4096, "running", False)): + io = FakeIO() + io.owned_resize, io.owned_start = True, start + io.memory, io.vm130 = memory, state + result = _safe_rollback(io, config()) + self.assertTrue(result.ask_parent) + self.assertFalse(any(c[:2] in (("qm", "set"), ("qm", "shutdown")) for c in io.calls)) + + def test_resize_lost_reply_restores_only_owned_stopped_4096(self): + io = FakeIO() + io.admit = RUN + original = io.run + def command(argv, timeout): + result = original(argv, timeout) + if argv == ("qm", "set", "130", "--memory", "4096"): + return g.Completed(1, "") + return result + io.run = command + result = run(io, ticks=2) + self.assertTrue(result.restored) + self.assertIn(("qm", "set", "130", "--memory", "8192"), io.calls) + self.assertNotIn(("qm", "shutdown", "130", "--timeout", "90"), io.calls) + + def test_latch_closed_before_cleanup_on_all_exits(self): + io = FakeIO() + latch = StopLatch() + io.begin_cleanup = lambda: self.assertTrue(latch.closed()) + run_guard(io, config(), latch, lambda: 0, lambda s: None, max_ticks=1) + + def test_signal_during_owned_lifecycle_shuts_down_once(self): + io = FakeIO() + io.admit = RUN + latch = StopLatch() + original = io.run + def command(argv, timeout): + result = original(argv, timeout) + if argv == ("qm", "start", "130"): + latch.trip("signal") + return result + io.run = command + result = run_guard(io, config(), latch, lambda: 0, lambda s: None, max_ticks=3) + self.assertIn("signal", result.reasons) + self.assertEqual(io.calls.count(("qm", "shutdown", "130", "--timeout", "90")), 1) + self.assertEqual(io.memory, 8192) + + def test_incomplete_production_baseline_cannot_admit_resize(self): + io = FakeIO() + io.admit = RUN + io.prod += "\nother\tUp (healthy)" + result = run(io, ticks=2) + self.assertIn("production-baseline-incomplete", result.reasons) + self.assertFalse(any(c[:2] == ("qm", "set") for c in io.calls)) + + +class Binding(unittest.TestCase): + def make(self, child, clock=None): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + os.chmod(temp.name, 0o700) + calls = [] + def spawn(*a, **kw): + calls.append((a, kw)) + return child + io = LinuxSubprocessIO(str(Path(temp.name).resolve()), config(), StopLatch(), "/opt/toolstack", + popen=spawn, observe=lambda pid: 123, **({"clock": clock} if clock else {})) + self.addCleanup(io.close) + # Only process transport tests suppress probes; no CLI or host gate bypass. + io.check = lambda: None + return io, calls + + def test_real_qga_json_decoded_not_returned_as_stdout(self): + body = json.dumps({"exited": 1, "exitcode": 0, "out-data": "nod\tUp (healthy)\n"}).encode() + io, calls = self.make(Child(body)) + result = io.run(g.production_ps_argv(), 45) + self.assertEqual(result.stdout, "nod\tUp (healthy)\n") + self.assertEqual(calls[0][0][0][0], "/usr/sbin/qm") + self.assertNotIn("shell", calls[0][1]) + + def test_already_exited_short_child_needs_no_signal(self): + child = Child(b"status: stopped") + io, _ = self.make(child) + io.observe = lambda pid: None + self.assertEqual(io.run(("qm", "status", "130"), 15), g.Completed(0, "status: stopped")) + self.assertEqual(child.signals, []) + + def test_stderr_or_qga_stderr_is_unknown_without_logging_content(self): + for child in (Child(b"ok", b"secret-shaped diagnostic"), + Child(json.dumps({"exited": 1, "exitcode": 0, "out-data": "ok", "err-data": "diagnostic"}).encode())): + io, _ = self.make(child) + self.assertEqual(io.run(g.production_ps_argv(), 45), g.Completed(1, "")) + + def test_qga_pending_malformed_and_truncated_are_unknown(self): + for payload in (b"not-json", b'{"pid": 33}', b'{"exited":1,"exitcode":0,"out-truncated":true}'): + io, _ = self.make(Child(payload)) + self.assertEqual(io.run(g.production_ps_argv(), 45), g.Completed(1, "")) + + def test_hung_owned_child_timeout_signals_exact_identity(self): + now = [0] + def clock(): + now[0] += 20 + return now[0] + child = Child(hung=True) + io, _ = self.make(child, clock) + self.assertEqual(io.run(g.production_ps_argv(), 45).code, 1) + self.assertEqual(child.signals, [signal.SIGTERM]) + + def test_reused_pid_is_never_signalled(self): + child = Child(hung=True) + io, _ = self.make(child) + io.observe = lambda pid: 999 + io.cancel(child, 123) + self.assertEqual(child.signals, []) + child.stdout.close() + child.stderr.close() + + def test_no_guest_build_create_or_production_mutation_can_start(self): + child = Child() + io, calls = self.make(child) + io.owned_start = True + for command in (("qm", "stop", "130"), ("qm", "set", "100", "--memory", "4096"), + g._guest("130", "30", ("docker", "create", "image")), + g._guest("130", "1200", g.onebox_build_argv(RUN))): + with self.assertRaises(g.GuardRefusal): + io.run(command, 45) + self.assertEqual(calls, []) + child.stdout.close() + child.stderr.close() + + def test_dead_controller_and_pid_reuse_invalidate_lease(self): + child = Child() + io, _ = self.make(child) + io.files.create("controller", json.dumps({"pid": 42, "start": 10})) + io.files.create("lease", json.dumps({"run": RUN, "pid": 42, "start": 10, "monotonic": io.clock()})) + for observation in (None, 11): + io.observe = lambda pid: observation + self.assertIsNone(io.lease_age()) + child.stdout.close() + child.stderr.close() + + def test_fast_memory_trip_never_spawns_slow_qga_probe(self): + child = Child() + io, calls = self.make(child) + io.read = lambda path: "MemAvailable: 1 kB" if path == "/proc/meminfo" else "" + io.lease_age = lambda: 0 + io.check = lambda: LinuxSubprocessIO.check(io) + with self.assertRaises(g.GuardRefusal): + io.run(g.production_ps_argv(), 45) + self.assertTrue(io.latch.closed()) + self.assertEqual(calls, []) + child.stdout.close() + child.stderr.close() + + def test_memory_trip_during_hung_child_cancels_without_waiting_timeout(self): + child = Child(hung=True) + io, calls = self.make(child) + probes = [0] + def read(path): + if path == "/proc/meminfo": + probes[0] += 1 + return "MemAvailable: %d kB" % (7000000 if probes[0] == 1 else 1) + return "some avg10=0 avg60=0 avg300=0 total=0\nfull avg10=0 avg60=0 avg300=0 total=0" + io.read, io.lease_age = read, lambda: 0 + io.check = lambda: LinuxSubprocessIO.check(io) + self.assertEqual(io.run(g.production_ps_argv(), 45).code, 1) + self.assertEqual(len(calls), 1) + self.assertTrue(io.latch.closed()) + self.assertEqual(child.signals, [signal.SIGTERM]) + + def test_files_refuse_symlink_ancestor_and_no_clobber(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + os.symlink(root, root / "link") + with self.assertRaises(OSError): + open_directory(str(root / "link")) + files = PrivateFiles(str(root)) + try: + files.create("ready", "original") + with self.assertRaises(FileExistsError): + files.create("ready", "overwrite") + self.assertEqual(files.read("ready"), "original") + finally: + files.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_vm_proof_pressure.py b/tests/release/test_vm_proof_pressure.py new file mode 100644 index 0000000..e5ce1d6 --- /dev/null +++ b/tests/release/test_vm_proof_pressure.py @@ -0,0 +1,213 @@ +"""Chief PSI rule: pure boundaries and real binding with fake PVE only.""" +import dataclasses +import json +import unittest + +import vm_proof_guard as g +from vm_proof_adapter import _fresh, _start_guest, _safe_rollback +import test_vm_proof_driver as driver_tests +from test_vm_proof_guard import sample, config + + +def psi(avg10=3, avg60=0): + return g.Pressure(0, avg10, avg60) + + +def text(avg10=3, avg60=0): + return ("some avg10=0 avg60=0 avg300=0 total=0\n" + f"full avg10={avg10} avg60={avg60} avg300=0 total=0\n") + + +class PressureBoundaries(unittest.TestCase): + def tracker(self): + state = g.PressureState() + state.begin_boot(100) + return state + + def test_grace_does_not_accumulate_and_exact_duration(self): + state = self.tracker() + for now in (100, 150, 189.999): + self.assertFalse(state.observe(now, psi(50, 50)).rollback) + self.assertIsNone(state.high_since) + self.assertFalse(state.observe(190, psi()).rollback) + self.assertEqual(state.high_since, 190) + self.assertFalse(state.observe(219.999, psi()).rollback) + self.assertEqual(state.observe(220, psi()).reasons, ("pressure-full-sustained",)) + + def test_exact_avg10_resets_and_positive_below_threshold_passes(self): + state = self.tracker() + state.observe(190, psi()) + self.assertFalse(state.observe(219, psi(2)).rollback) + self.assertIsNone(state.high_since) + for now, value in ((220, 0.18), (230, 0.001), (240, 2)): + self.assertFalse(state.observe(now, psi(value)).rollback) + state.observe(250, psi(2.00001)) + self.assertFalse(state.observe(279.999, psi()).rollback) + self.assertTrue(state.observe(280, psi()).rollback) + + def test_avg60_strict_and_grace_boundary(self): + state = self.tracker() + self.assertFalse(state.observe(189.999, psi(0, 99)).rollback) + self.assertFalse(state.observe(190, psi(0, 1)).rollback) + self.assertEqual(state.observe(190, psi(0, 1.00001)).reasons, ("pressure-full-avg60",)) + + def test_unknown_resets_and_clock_fault_cannot_recover(self): + for bad in (None, psi(float("nan")), psi(1, float("inf")), psi(-1)): + state = self.tracker() + state.observe(190, psi()) + self.assertTrue(state.observe(210, bad).rollback) + self.assertIsNone(state.high_since) + self.assertFalse(state.observe(220, psi()).rollback) + self.assertEqual(state.high_since, 220) + for now in (189, float("nan"), float("inf"), -1): + state = self.tracker() + state.observe(190, psi()) + self.assertTrue(state.observe(now, psi(0)).rollback) + self.assertIsNone(state.high_since) + self.assertTrue(state.observe(250, psi(0)).rollback) + state = self.tracker() + state.observe(210, None) + self.assertTrue(state.observe(200, psi(0)).rollback) + state = self.tracker() + g.assess(sample(elapsed_seconds=190, full_avg10=3), config(), state) + self.assertEqual(state.high_since, 190) + self.assertTrue(g.assess(sample(elapsed_seconds=210, full_avg60=None), config(), state).rollback) + self.assertIsNone(state.high_since) + + def test_preboot_conservative_and_no_reboot_grace(self): + state = g.PressureState() + self.assertFalse(state.observe(0, psi(2, 1)).rollback) + self.assertEqual(state.observe(1, psi()).reasons, ("pressure-preboot-unsettled",)) + self.assertEqual(state.observe(31, psi()).reasons, ("pressure-full-sustained",)) + self.assertTrue(state.observe(32, psi(0, 1.01)).rollback) + state.begin_boot(33) + with self.assertRaises(g.GuardRefusal): + state.begin_boot(34) + + def test_malformed_missing_duplicate_negative_nonfinite(self): + for bad in ("", "full avg10=0", text().replace("avg60=0", "avg60=nan"), + text().replace("avg10=3", "avg10=-1"), text(0, "inf"), + text() + text(), text().replace("total=0", "total=0 total=0")): + self.assertIsNone(g.parse_pressure(bad), bad) + self.assertEqual(g.parse_pressure(text(0.18, 1.01)).full_avg60, 1.01) + + def test_grace_never_bypasses_other_guards_or_missing_avg60(self): + state = self.tracker() + trip = state.observe(101, psi(99, 99)) + base = sample(full_avg10=99, full_avg60=99, pressure_trip=trip) + self.assertFalse(g.assess(base, config()).rollback) + for fields in ({"mem_available_kib": 1536 * 1024 - 1}, {"nod_body": "{}"}, + {"vm100_status": None}, {"production": None}, {"lease_age_seconds": 91}, + {"pressure_readable": False}, {"full_avg60": None}, {"free_available_mib": None}): + self.assertTrue(g.assess(dataclasses.replace(base, **fields), config()).rollback, fields) + + +class BoundPressure(unittest.TestCase): + setup_io = driver_tests.BoundDriver.setup_io + + def ready(self): + io, host = self.setup_io() + self.now = 100 + io.clock = lambda: self.now + io.deadline = 2500 + self.values = (0.18, 0) + io.read = lambda path: "MemAvailable: 7100000 kB" if path == "/proc/meminfo" else text(*self.values) + return io, host + + def test_start_spawn_has_durable_origin_and_during_start_grace(self): + io, host = self.ready() + original = io.popen + def spawn(argv, **kwargs): + if tuple(argv[1:]) == ("start", "130"): + state = json.loads(io.files.read("state"))["pressure"] + self.assertEqual(state["boot_monotonic"], 100) + self.values = (20, 20) + self.now = 110 + return original(argv, **kwargs) + io.popen = spawn + self.assertTrue(_start_guest(io, io.latch, io.config, 0)) + self.assertFalse(io.latch.closed()) + self.assertEqual(io.pressure_state.boot_monotonic, 100) + with self.assertRaises(g.GuardRefusal): + io.run(("qm", "start", "130"), 30) + self.assertEqual(host.calls.count(("qm", "start", "130")), 1) + + def test_fast_fresh_driver_share_history_and_rollback_still_restores(self): + io, host = self.ready() + self.assertTrue(_start_guest(io, io.latch, io.config, 0)) + self.values = (3, 0) + self.now = 190 + io.check() + self.now = 210 + value, _ = _fresh(io, io.config, 110) + self.assertFalse(g.assess(value, io.config).rollback) + self.now = 219.999 + io.proof.health() + self.assertEqual(io.pressure_state.high_since, 190) + self.now = 220 + with self.assertRaises(g.GuardRefusal): + io.proof.health() + snapshot = json.loads(io.files.read("state"))["pressure"] + self.assertEqual(snapshot["observation"]["reason"], "pressure-full-sustained") + io.begin_cleanup() + self.assertTrue(_safe_rollback(io, io.config).restored) + self.assertEqual((host.vm, host.memory), ("stopped", 8192)) + + def test_preboot_high_blocks_all_effects(self): + for values in ((3, 0), (0, 1.001)): + io, host = self.ready() + self.values = values + self.assertFalse(_start_guest(io, io.latch, io.config, 0)) + self.assertIsNone(io.pressure_state.boot_monotonic) + self.assertFalse(host.calls) + + def test_lost_start_reply_has_finite_origin_and_no_second_boot(self): + io, host = self.ready() + original = host.command + def command(argv, data): + if argv == ("qm", "start", "130"): + host.vm = "running" + raise OSError("fake lost spawn response") + return original(argv, data) + host.command = command + with self.assertRaises(OSError): + _start_guest(io, io.latch, io.config, 0) + self.assertEqual(io.pressure_state.boot_monotonic, 100) + self.now = 190 + self.values = (0, 1.001) + with self.assertRaises(g.GuardRefusal): + io.check() + self.assertIn("pressure-full-avg60", io.fast_probe["reasons"]) + with self.assertRaises(g.GuardRefusal): + io.run(("qm", "start", "130"), 30) + io.begin_cleanup() + self.assertTrue(_safe_rollback(io, io.config).restored) + + def test_preflight_failure_never_creates_boot_origin(self): + io, host = self.ready() + io.owned_start = True + host.memory = 8192 # qm start final preflight requires stopped4096. + with self.assertRaises(g.GuardRefusal): + io.run(("qm", "start", "130"), 30) + self.assertIsNone(io.pressure_state.boot_monotonic) + self.assertNotIn(("qm", "start", "130"), host.calls) + + def test_driver_grace_still_observes_bad_nod_and_production(self): + for bad in ("nod", "production", "vm100", "unknown-psi", "memory"): + io, host = self.ready() + self.assertTrue(_start_guest(io, io.latch, io.config, 0)) + self.values = (20, 20) + original = host.command + def command(argv, data): + if bad == "nod" and argv[0] == "curl": + return "{}" + if bad == "production" and argv == g.production_ps_argv(): + return host.qga("nod\tUp (unhealthy)") + if bad == "vm100" and argv == ("qm", "status", "100"): + return "unknown" + return original(argv, data) + host.command = command + if bad in ("unknown-psi", "memory"): + io.read = lambda path: ("MemAvailable: 1 kB" if bad == "memory" else "MemAvailable: 7100000 kB") if path == "/proc/meminfo" else ("invalid" if bad == "unknown-psi" else text(20, 20)) + with self.assertRaises(g.GuardRefusal): + io.proof.health() diff --git a/tests/release/test_vm_proof_process_sps.py b/tests/release/test_vm_proof_process_sps.py new file mode 100644 index 0000000..8baa52b --- /dev/null +++ b/tests/release/test_vm_proof_process_sps.py @@ -0,0 +1,140 @@ +"""Fixed guest payload command/result tests. No Docker or fixture is executed.""" +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import vm_proof_payload as p +import test_vm_proof_driver as driver_tests +from test_vm_proof_driver import RUN, source, process_result +from vm_proof_adapter import run_guard +from test_vm_proof_adapter import config + + +class ProcessPayload(unittest.TestCase): + def make(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + root = Path(temp.name).resolve() / "proof" + src = source() + p.initialize(root, RUN, src.commit, src.sha256, len(src.data)) + script = root / "src" / p.PROCESS_FIXTURE + script.parent.mkdir(parents=True, mode=0o700) + p.private_write(script, b"# synthetic test file, never executed") + image = {"image": "sha256:" + "a" * 64, "tag": p.tag_for(RUN, "onebox"), "label": RUN} + p.private_write(root / "image-onebox", json.dumps(image).encode()) + self.image, self.cid = image, "c" * 64 + return root, script + + def test_create_exact_mounts_network_env_existing_image_and_record(self): + root, script = self.make() + with patch.object(p, "inspect_image", return_value=self.image), patch.object(p, "bounded", return_value=self.cid) as command: + result = p.create(root, RUN, "process-sps") + argv = command.call_args.args[0] + self.assertEqual(argv[:2], ("docker", "create")) + self.assertEqual(argv[argv.index("--network") + 1], "none") + self.assertEqual([argv[i + 1] for i, a in enumerate(argv) if a == "--mount"], + [f"type=bind,src={root}/data,dst=/data", f"type=bind,src={script},dst=/proof/process_sps.py,readonly"]) + self.assertEqual(argv[argv.index("--entrypoint"):], ("--entrypoint", "python3", self.image["image"], "/proof/process_sps.py", "--root", "/data/process-sps")) + self.assertIn("TOOLSTACK_SPS_SKIP=0", argv) + self.assertNotIn("TOOLSTACK_SPS_SKIP=1", argv) + self.assertIn("PYTHONPATH=/app", argv) + self.assertNotIn("-p", argv) + self.assertNotIn("/var/run/docker.sock", " ".join(argv)) + self.assertEqual(json.loads(p.read_private(root / "container-process-sps")), result) + self.assertFalse((root / "data/process-sps").exists()) + self.assertEqual(p.KINDS, ("onebox", "echo-api", "echo-mcp")) + + def test_refuse_preexisting_root_and_unsafe_fixture_before_docker(self): + for fault in ("existing", "dangling-root", "script-link", "parent-link", "directory", "root-marker", "image"): + root, script = self.make() + if fault == "existing": + (root / "data/process-sps").mkdir() + elif fault == "dangling-root": + (root / "data/process-sps").symlink_to(root / "absent") + elif fault == "script-link": + script.unlink() + script.symlink_to(root / "owner") + elif fault == "parent-link": + script.parent.rename(script.parent.with_name("original")) + script.parent.symlink_to(script.parent.with_name("original")) + elif fault == "directory": + script.unlink() + script.mkdir() + elif fault == "root-marker": + (root / "owner").write_text('{}') + with patch.object(p, "bounded") as command, patch.object(p, "inspect_image", return_value={} if fault == "image" else self.image): + with self.subTest(fault=fault), self.assertRaises((OSError, ValueError)): + p.create(root, RUN, "process-sps") + command.assert_not_called() + + def start(self, root, result, exitcode="0", identity=None): + path = root / "container-process-sps" + if not path.exists(): + p.private_write(path, json.dumps({"container": self.cid, "kind": "process-sps", "label": RUN}).encode()) + responses = [self.cid + "\t" + RUN if identity is None else identity, + json.dumps(result), exitcode] + with patch.object(p, "bounded", side_effect=responses) as command: + value = p.start_container(root, RUN, "process-sps", self.cid) + self.assertEqual(command.call_args_list[1].args, (("docker", "start", "-a", self.cid), root, 135)) + return value + + def test_start_parses_actual_json_and_records_all_19_checks(self): + root, _ = self.make() + result = process_result() + self.assertEqual(len(result["checks"]), 19) + self.assertEqual(self.start(root, result), {"checked": "process-sps", "exit": 0, "proof": result}) + + def test_start_rejects_wrong_status_missing_checks_cleanup_identity_and_exit(self): + for fault in ("status", "checks", "cleanup", "process-cleanup", "process-id", "platform", "crypto", "nonzero", "label", "no-json-proof"): + root, _ = self.make() + result = process_result() + if fault == "status": + result["status"] = "FAIL" + elif fault == "checks": + result["checks"].pop(10) + elif fault == "cleanup": + result["cleanup"] = "unknown" + elif fault == "process-cleanup": + result["processes"][1].pop("cleanup") + elif fault == "process-id": + result["processes"][1]["pid"] = 1 + elif fault == "platform": + result["platform"] = "Darwin-fake" + elif fault == "crypto": + result["cryptography"] = "49.0.0" + elif fault == "no-json-proof": + result = None + with self.subTest(fault=fault), self.assertRaises(ValueError): + self.start(root, result, "1" if fault == "nonzero" else "0", "wrong-label" if fault == "label" else None) + + def test_reject_unrecorded_container_and_new_build_kind(self): + root, _ = self.make() + with patch.object(p, "bounded") as command: + with self.assertRaises(FileNotFoundError): + p.start_container(root, RUN, "process-sps", self.cid) + with patch.object(p, "root_for", return_value=root): + with self.assertRaises(ValueError): + p.dispatch(RUN, "build", ["process-sps"], b"") + command.assert_not_called() + self.assertFalse((root / "jobs/build-process-sps").exists()) + + +class DriverResult(unittest.TestCase): + setup_io = driver_tests.BoundDriver.setup_io + + def test_invalid_process_proof_stops_before_serve_and_rolls_back(self): + io, host = self.setup_io() + original = host.action + def action(name, args, data): + result = original(name, args, data) + if name == "start" and args[0] == "process-sps": + result["proof"]["cleanup"] = "unknown" + return result + host.action = action + run_guard(io, config(), io.latch, io.clock, lambda s: None, max_ticks=8) + self.assertFalse(io.proof.completed) + self.assertNotIn("create-serve", host.job_sequence) + self.assertEqual(host.memory, 8192) + self.assertEqual(host.calls.count(("qm", "shutdown", "130", "--timeout", "90")), 1) diff --git a/tests/release/test_vm_proof_publication.py b/tests/release/test_vm_proof_publication.py new file mode 100644 index 0000000..252670d --- /dev/null +++ b/tests/release/test_vm_proof_publication.py @@ -0,0 +1,285 @@ +"""Real local files/flocks and deterministic interleavings; no host effects.""" +import fcntl +import json +import os +from pathlib import Path +import threading +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import vm_proof_linux as linux +import vm_proof_guard as g +import test_vm_proof_lease as lease_tests + + +class PublicationBoundaries(unittest.TestCase): + make = lease_tests.LeasePublication.make + + def test_open_before_fstat_serializes_replacement_and_valid_snapshots(self): + io = self.make() + publisher = linux.PrivateFiles(io.root) + self.addCleanup(publisher.close) + opened, contended = threading.Event(), threading.Event() + errors = [] + reader = threading.get_ident() + real_open, real_flock = os.open, fcntl.flock + def publish(): + try: + self.assertTrue(opened.wait(2)) + publisher.publish_lease(dict(self.record, monotonic=100)) + except BaseException as exc: + errors.append(exc) + worker = threading.Thread(target=publish) + def flock(fd, flags): + try: + return real_flock(fd, flags) + except BlockingIOError: + if threading.get_ident() != reader and flags & fcntl.LOCK_EX: + contended.set() + raise + def open_before_fstat(name, flags, *args, **kwargs): + fd = real_open(name, flags, *args, **kwargs) + if name == "lease" and threading.get_ident() == reader and not opened.is_set(): + opened.set() + self.assertTrue(contended.wait(2)) + # Old inode is still linked: writer cannot replace between open + # and PrivateFiles.file's first fstat any more. + self.assertEqual(os.fstat(fd).st_nlink, 1) + return fd + with patch.object(linux.os, "open", side_effect=open_before_fstat), \ + patch.object(linux.fcntl, "flock", side_effect=flock): + worker.start() + try: + self.assertEqual(io.lease_age(), 1) + finally: + worker.join(2) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(io.lease_age(), 0) + self.assertEqual(json.loads(io.files.read("lease"))["monotonic"], 100) + # Repeated legitimate publications never authorize a detached snapshot. + for _ in range(3): + publisher.publish_lease(dict(self.record, monotonic=100)) + self.assertEqual(io.lease_age(), 0) + io.check() + + def test_uncooperative_removal_or_replacement_never_accepts_unlinked_fd(self): + for action in ("remove", "replace"): + with self.subTest(action=action): + io = self.make() + root = Path(io.root) + if action == "replace": + (root / "forged").write_text(json.dumps(self.record)) + (root / "forged").chmod(0o600) + real_open = os.open + def intercept(name, flags, *args, **kwargs): + fd = real_open(name, flags, *args, **kwargs) + if name == "lease": + if action == "replace": + os.replace(root / "forged", root / "lease") + else: + (root / "lease").unlink() + return fd + with patch.object(linux.os, "open", side_effect=intercept): + with self.assertRaises(g.GuardRefusal): + io.files.read("lease") + + def test_current_path_is_verified_even_when_old_inode_stays_linked(self): + io = self.make() + root = Path(io.root) + real_read = os.read + def changed_path(fd, size): + data = real_read(fd, size) + (root / "lease").rename(root / "detached") + (root / "lease").write_bytes(data) + (root / "lease").chmod(0o600) + return data + with patch.object(linux.os, "read", side_effect=changed_path): + with self.assertRaises(g.GuardRefusal): + io.files.read("lease") + + def test_unsafe_absent_stale_and_invalid_identity_remain_fail_closed(self): + for fault in ("hardlink", "symlink", "mode", "uid", "absent", "stale", "wrong-run", + "wrong-pid", "wrong-start", "dead", "reused", "nonfinite"): + with self.subTest(fault=fault): + io = self.make() + root = Path(io.root) + if fault == "hardlink": + os.link(root / "lease", root / "other") + elif fault == "symlink": + (root / "lease").rename(root / "original") + (root / "lease").symlink_to(root / "original") + elif fault == "mode": + (root / "lease").chmod(0o644) + elif fault == "absent": + (root / "lease").unlink() + elif fault in ("dead", "reused"): + io.observe = lambda pid: None if fault == "dead" else 999 + elif fault != "uid": + record = dict(self.record) + record.update({"stale": {"monotonic": 0}, "nonfinite": {"monotonic": float("nan")}, + "wrong-run": {"run": "other"}, "wrong-pid": {"pid": 99}, + "wrong-start": {"start": 999}}[fault]) + (root / "lease").write_text(json.dumps(record)) + if fault == "uid": + real_fstat = os.fstat + lease_ino = (root / "lease").stat().st_ino + def foreign_uid(fd): + info = real_fstat(fd) + if info.st_ino == lease_ino: + fields = {k: getattr(info, k) for k in dir(info) if k.startswith("st_")} + fields["st_uid"] += 1 + return SimpleNamespace(**fields) + return info + with patch.object(linux.os, "fstat", side_effect=foreign_uid): + self.assertIsNone(io.lease_age()) + else: + with self.assertRaises(g.GuardRefusal): + io.check() + + def test_ready_and_admit_absent_during_staging_then_complete(self): + for target in ("ready", "admit"): + io = self.make() + data = "identity-complete\n" + write = io.files._write + def staging(fd, text): + with self.assertRaises(FileNotFoundError): + io.files.read(target) + write(fd, text) + with self.assertRaises(FileNotFoundError): + io.files.read(target) + with patch.object(io.files, "_write", side_effect=staging): + io.files.create(target, data) + self.assertEqual(io.files.read(target), data) + self.assertEqual((Path(io.root) / target).stat().st_nlink, 1) + self.assertFalse((Path(io.root) / ("." + target + "-next")).exists()) + + def test_link_claim_reader_waits_until_single_link(self): + io = self.make() + reader_files = linux.PrivateFiles(io.root) + self.addCleanup(reader_files.close) + linked, contended = threading.Event(), threading.Event() + results, errors = [], [] + main = threading.get_ident() + real_link, real_flock = os.link, fcntl.flock + def reader(): + try: + self.assertTrue(linked.wait(2)) + results.append(reader_files.read("ready")) + except BaseException as exc: + errors.append(exc) + worker = threading.Thread(target=reader) + def flock(fd, flags): + try: + return real_flock(fd, flags) + except BlockingIOError: + if threading.get_ident() != main: + contended.set() + raise + def link(*args, **kwargs): + real_link(*args, **kwargs) + self.assertEqual((Path(io.root) / "ready").stat().st_nlink, 2) + linked.set() + self.assertTrue(contended.wait(2)) + with patch.object(linux.os, "link", side_effect=link), patch.object(linux.fcntl, "flock", side_effect=flock): + worker.start() + try: + io.files.create("ready", "complete-ready") + finally: + worker.join(2) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(results, ["complete-ready"]) + + def test_existing_ready_or_staging_never_overwritten(self): + for fault in ("target", "symlink", "staging"): + io = self.make() + root = Path(io.root) + path = root / (".ready-next" if fault == "staging" else "ready") + if fault == "symlink": + path.symlink_to("absent-foreign") + else: + path.write_text("foreign") + path.chmod(0o600) + with self.assertRaises(FileExistsError): + io.files.create("ready", "replacement") + self.assertTrue(path.is_symlink()) if fault == "symlink" else self.assertEqual(path.read_text(), "foreign") + if fault != "staging": + self.assertFalse((root / ".ready-next").exists()) + + def test_staging_cleanup_requires_positive_identity_and_safe_shape(self): + for fault in ("own", "replaced", "hardlinked", "symlink", "mode"): + io = self.make() + root = Path(io.root) + staging = root / ".ready-next" + write = io.files._write + def fail(fd, data): + write(fd, data) + if fault == "replaced": + staging.rename(root / "retained-original") + staging.write_text("foreign") + elif fault == "hardlinked": + os.link(staging, root / "other") + elif fault == "symlink": + staging.rename(root / "retained-original") + staging.symlink_to("retained-original") + elif fault == "mode": + staging.chmod(0o644) + raise OSError("injected") + with patch.object(io.files, "_write", side_effect=fail), self.assertRaises(OSError): + io.files.create("ready", "record") + self.assertFalse((root / "ready").exists()) + self.assertEqual(staging.exists(), fault != "own") + + def test_claim_changed_staging_is_preserved_not_unlinked(self): + io = self.make() + root = Path(io.root) + staging = root / ".ready-next" + real_link = os.link + def replace_stage(*args, **kwargs): + real_link(*args, **kwargs) + staging.rename(root / "retained") + staging.write_text("foreign") + staging.chmod(0o600) + with patch.object(linux.os, "link", side_effect=replace_stage), self.assertRaises(g.GuardRefusal): + io.files.create("ready", "original") + self.assertEqual(staging.read_text(), "foreign") + with self.assertRaises(g.GuardRefusal): + io.files.read("ready") # Unknown two-link claim is not tolerated. + + def test_controller_refuses_malformed_ready_without_waiting_or_renewing(self): + with tempfile.TemporaryDirectory() as tmp: + root = str(Path(tmp).resolve()) + files = linux.PrivateFiles(root) + try: + files.create("ready", "mismatched-identity") + finally: + files.close() + args = SimpleNamespace(run_id=lease_tests.RUN, checkout="discover", source_commit="a" * 40, + archive_sha256="b" * 64, archive="/fake", baseline=[]) + child = SimpleNamespace(pid=42, poll=lambda: None) + with patch.object(linux, "prepare_run_root", return_value=root), \ + patch.object(linux, "identity", return_value=123), \ + patch.object(linux.subprocess, "Popen", return_value=child), \ + patch.object(linux.PrivateFiles, "publish_lease") as renew, \ + patch.object(linux.time, "sleep") as sleep: + with self.assertRaisesRegex(g.GuardRefusal, "ready identity mismatch"): + linux.controller(args) + renew.assert_not_called() + sleep.assert_not_called() + + def test_stuck_publication_lock_has_finite_acquisition_bound(self): + io = self.make() + other = linux.PrivateFiles(io.root) + self.addCleanup(other.close) + with other._publication_lock(exclusive=True), patch.object(linux.time, "sleep") as sleep: + with self.assertRaises(BlockingIOError): + io.files.read("lease") + self.assertEqual(sleep.call_count, 19) + self.assertEqual(io.lease_age(), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/vault-45.0.7.json b/tests/release/vault-45.0.7.json new file mode 100644 index 0000000..d9e3c5d --- /dev/null +++ b/tests/release/vault-45.0.7.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "kdf": "scrypt", + "salt": "Su7gAKY5c7lxeDbRwLqR7A==", + "n": 16384, + "r": 8, + "p": 1, + "ciphertext": "gAAAAABq3IOUjzddhrZdPmkJUEpJE8LR2ZXZkGphZBFX46LIJP4GeepD0IfZ2DM16fIeWcCXiKTr3Mfi7WzokRCln8mBMr76Hc4we6crVM-h6EcY-0sENzBZRL5vlfRv0kFJ6Ihpoyv45vUGcvk-ZvzzMgbliMFES8KZvjmoa0-Jqh__XZLqIJg=" +} \ No newline at end of file diff --git a/tests/release/vm_proof_adapter.py b/tests/release/vm_proof_adapter.py new file mode 100644 index 0000000..a7ce6ab --- /dev/null +++ b/tests/release/vm_proof_adapter.py @@ -0,0 +1,483 @@ +"""Finite VM 130 state machine. CLI binds LinuxSubprocessIO; default is dry-run.""" + +from __future__ import annotations + +import os +import stat +import sys +import threading +from dataclasses import dataclass + +from vm_proof_guard import ( + GUEST_TIMEOUT, + NOD_CURL, + PRODUCTION_VMID, + PROOF_VMID, + TEST_MEMORY_MIB, + GuardConfig, + GuardRefusal, + ProofRecords, + PressureState, + Sample, + Vm130Config, + agent_ready_argv, + assess, + canonical_container_id, + guard_signal_argv, + parse_allowlisted_config, + parse_free_available_mib, + parse_memavailable_kib, + parse_pressure, + parse_qm_status, + production_ps_argv, + require_run_id, + rollback, + start_gate, +) + +PVE_PARENT = "/var/tmp/toolstack-vm-proof" +SAMPLE_INTERVAL = 5 +MAX_TICKS = 40 * 60 // SAMPLE_INTERVAL +AGENT_WAIT_TICKS = 6 +SHUTDOWN_SUBPROCESS_TIMEOUT = 100 +BUILD_SUBPROCESS_TIMEOUT = 1210 +PRIVATE_FILES = frozenset({"config", "state", "ready", "log", "pid"}) + + +class StopLatch: + def __init__(self) -> None: + self.reason: str | None = None + self.lock = threading.RLock() + + def closed(self) -> bool: + return self.reason is not None + + def trip(self, reason: str) -> None: + with self.lock: + if self.reason is None: + self.reason = reason + + def before_effect(self) -> None: + if self.closed(): + raise GuardRefusal("stop latch is closed") + + +@dataclass(frozen=True) +class AdapterResult: + ask_parent: bool + phase: str + reasons: tuple[str, ...] + restored: bool + + +def timeout_for(argv: tuple[str, ...]) -> int: + if argv[:2] == ("qm", "shutdown"): + return SHUTDOWN_SUBPROCESS_TIMEOUT + if argv[:2] == ("qm", "start"): + return 30 + if argv[:2] in {("qm", "set"), ("qm", "status"), ("qm", "config")}: + return 15 + if argv[:1] == ("curl",): + return 10 + if argv[:1] == ("free",): + return 5 + if argv[:2] == ("kill", "-TERM") or argv[:2] == ("kill", "-KILL"): + if len(argv) == 3 and argv[2].isdigit() and int(argv[2]) > 1: + return 5 + if argv[:3] == ("qm", "guest", "exec-status") or (argv[:3] == ("qm", "guest", "exec") and "--synchronous" in argv): + return 45 + if argv[:3] == ("qm", "guest", "exec") and "build" in argv: + return BUILD_SUBPROCESS_TIMEOUT + if argv[:3] == ("qm", "guest", "exec"): + return 45 + if argv[:1] == ("docker",): + return 60 + raise GuardRefusal("command has no finite timeout") + + +def guest_exec(inner: tuple[str, ...], timeout: str) -> tuple[str, ...]: + if not timeout.isdigit() or int(timeout) <= 0 or int(timeout) > 1200: + raise GuardRefusal("guest exec timeout is not a finite bound") + return ("qm", "guest", "exec", PROOF_VMID, "--timeout", timeout, "--", *inner) + + +def prepare_run_root(parent: str, run_id: str) -> str: + require_run_id(run_id) + if not parent.startswith("/") or ".." in parent.split("/"): + raise GuardRefusal("parent path is not absolute") + try: + parent_fd = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + except OSError as exc: + raise GuardRefusal("parent is not a real directory") from exc + try: + try: + os.mkdir(run_id, 0o700, dir_fd=parent_fd) + except FileExistsError as exc: + raise GuardRefusal("run root is not absent") from exc + leaf = os.open(run_id, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd) + try: + os.fchmod(leaf, 0o700) + info = os.fstat(leaf) + if not stat.S_ISDIR(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o700: + raise GuardRefusal("run root mode is not 0700") + finally: + os.close(leaf) + finally: + os.close(parent_fd) + return parent.rstrip("/") + "/" + run_id + + +def write_private(root: str, name: str, text: str) -> None: + if name not in PRIVATE_FILES: + raise GuardRefusal("helper does not write that file") + if name in {"lease", "admit"}: + raise GuardRefusal("helper does not write lease or admit") + payload = text if text.endswith("\n") else text + "\n" + root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + try: + fd = os.open( + name, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600, dir_fd=root_fd, + ) + except OSError as exc: + raise GuardRefusal("private file already exists") from exc + try: + os.write(fd, payload.encode()) + os.fsync(fd) + os.fchmod(fd, 0o600) + if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600: + raise GuardRefusal("private file mode is not 0600") + finally: + os.close(fd) + finally: + os.close(root_fd) + + +def parse_starttime(stat_text: str) -> int | None: + end = stat_text.rfind(")") + if end < 0: + return None + fields = stat_text[end + 1:].split() + if len(fields) <= 19 or not fields[19].isdigit(): + return None + value = int(fields[19]) + return value if value > 0 else None + + +def ready_text(run_id: str, pid: int, start: int) -> str: + require_run_id(run_id) + if pid <= 1 or start <= 0: + raise GuardRefusal("ready identity is not a private process") + return f"{run_id}\n{pid} {start}\n" + + +def cancel_owned_child(io, pid: int, start: int) -> str: + observed = io.observe(pid) + argv = guard_signal_argv(pid, start, pid if observed == start else None, observed) + if argv is None: + return "not-signalled" + io.run(argv, timeout_for(argv)) + if io.observe(pid) != start: + return "termed" + io.run(("kill", "-KILL", str(pid)), timeout_for(("kill", "-KILL", str(pid)))) + if io.observe(pid) == start: + return "still-alive" + return "killed" + + +def create_record_start(io, latch: StopLatch, create_argv: tuple[str, ...]) -> str: + latch.before_effect() + wrapped = guest_exec(create_argv, GUEST_TIMEOUT) + created = io.run(wrapped, timeout_for(wrapped)) + container_id = canonical_container_id(created.stdout) + if created.code != 0 or container_id is None: + latch.trip("create-id-unknown") + raise GuardRefusal("container id was not 64 hex characters") + io.record_container(container_id) + latch.before_effect() + start = guest_exec(("docker", "start", container_id), GUEST_TIMEOUT) + started = io.run(start, timeout_for(start)) + if started.code != 0: + latch.trip("container-start-failed") + raise GuardRefusal("recorded container did not start") + return container_id + + +def begin_build(io, latch: StopLatch, build_argv: tuple[str, ...]) -> tuple[int, int]: + latch.before_effect() + if build_argv[:2] != ("docker", "build"): + raise GuardRefusal("build fence accepts only docker build") + wrapped = guest_exec(build_argv, "1200") + return io.spawn(wrapped, timeout_for(wrapped)) + + +def run_guard(io, config: GuardConfig, latch: StopLatch, clock, sleeper, *, max_ticks: int = MAX_TICKS) -> AdapterResult: + if max_ticks <= 0 or max_ticks > MAX_TICKS: + raise GuardRefusal("tick budget is not finite") + phase = "watch" + ready = False + agent_until = None + reasons: list[str] = [] + started = clock() + io.owned_resize = False + io.owned_start = False + try: + pid, start = io.identity() + io.write_new("pid", ready_text(config.run_id, pid, start)) + for tick in range(max_ticks): + if latch.closed(): + break + elapsed = clock() - started + sample, vm130 = _fresh(io, config, elapsed) + trip = assess(sample, config) + if trip.rollback: + latch.trip(trip.reasons[0]) + reasons.extend(trip.reasons) + break + if io.owned_resize and (vm130.memory_mib not in (4096, 8192) + or vm130.cores != 2 or vm130.onboot != 0): + latch.trip("ownership-or-shape-drift") + break + if not ready and not start_gate(sample, config, vm130).rollback: + io.write_new("ready", ready_text(config.run_id, pid, start)) + ready = True + if phase == "watch": + if not (ready and io.admit_text() == config.run_id): + if tick + 1 == max_ticks: + latch.trip("tick-limit") + break + sleeper(SAMPLE_INTERVAL) + continue + phase = "start" + if phase == "start": + if not _start_guest(io, latch, config, clock() - started): + break + phase = "agent" + agent_until = tick + AGENT_WAIT_TICKS + continue + if phase == "agent": + probed = io.run(agent_ready_argv(), timeout_for(agent_ready_argv())) + if probed.code == 0: + phase = "monitor" + # Fixed live sequence: metadata then guarded source/Docker proof. + if hasattr(io, "metadata_proof"): + io.metadata_proof(latch) + latch.trip("proof-complete") + break + continue + if agent_until is not None and tick >= agent_until: + latch.trip("guest-agent-unavailable") + break + sleeper(SAMPLE_INTERVAL) + continue + if tick + 1 == max_ticks: + latch.trip("tick-limit") + break + sleeper(SAMPLE_INTERVAL) + latch.trip("finished") + if hasattr(io, "begin_cleanup"): + io.begin_cleanup() + _cancel_children(io) + report = _safe_rollback(io, config) + ask = report.ask_parent + except Exception: + latch.trip(latch.reason or "exception") + if hasattr(io, "begin_cleanup"): + io.begin_cleanup() + _cancel_children(io) + report = _safe_rollback(io, config) + reasons.append("exception") + ask = True + if latch.reason: + reasons.append(latch.reason) + merged = tuple(dict.fromkeys(reasons + list(report.reasons))) + return AdapterResult(ask, phase, merged, report.restored) + + +def dry_run_plan(run_id: str) -> str: + require_run_id(run_id) + root = f"/var/tmp/{run_id}" + return "\n".join(( + "dry-run: no host command is executed", + f"python3 vm_proof_adapter.py controller --execute --live --root {root} --run-id {run_id} --checkout discover --source-commit COMMIT40 --archive-sha256 SHA64 --archive /PRIVATE/source.tar --baseline nod:healthy", + "guard never writes lease or admit; foreground controller owns both", + "controller PID and Linux starttime must match; lease age over 90 seconds rolls back", + "ready acknowledgement and a fresh gate are required before qm set 130 --memory 4096", + "confirm stopped, memory 4096, cores 2, onboot 0, then qm start 130", + "guest agent /bin/true must answer within 6 samples or the helper rolls back", + "one qm shutdown 130 --timeout 90 with subprocess timeout 100; no qm stop or forceStop", + "qm set 130 --memory 8192 only after status stopped and cores/onboot are unchanged", + "payload: exact parent-supplied git commit/archive sha256, validated before boot and guest extraction", + "fixed async build/create/start sequence only; no arbitrary commands or in-place owner updates", + )) + + +def main(argv: list[str] | None = None) -> int: + from vm_proof_linux import cli + return cli(argv) + + +def _fresh(io, config: GuardConfig, elapsed: float) -> tuple[Sample, Vm130Config]: + if hasattr(io, "note_sample"): + io.note_sample() + meminfo = io.read("/proc/meminfo") + pressure_text = io.read("/proc/pressure/memory") + pressure = parse_pressure(pressure_text) if pressure_text is not None else None + if not hasattr(io, "pressure_state"): + io.pressure_state = PressureState() + pressure_trip = io.pressure_state.observe(io.clock() if hasattr(io, "clock") else elapsed, pressure) + if hasattr(io, "checkpoint"): + io.checkpoint() + available = parse_memavailable_kib(meminfo or "") + if available is None or available < 1536 * 1024 or pressure_trip.rollback: + # Known local emergency must not wait on QGA/HTTP production probes. + return Sample(available, pressure is not None, + None if pressure is None else pressure.full_avg10, + None, None, None, io.lease_age(), elapsed, None, + None if pressure is None else pressure.full_avg60, + pressure_trip), Vm130Config(None, None, None, None) + free = io.run(("free", "-m"), timeout_for(("free", "-m"))) + vm100 = io.run(("qm", "status", PRODUCTION_VMID), timeout_for(("qm", "status", PRODUCTION_VMID))) + vm130 = io.run(("qm", "status", PROOF_VMID), timeout_for(("qm", "status", PROOF_VMID))) + cfg = io.run(("qm", "config", PROOF_VMID), timeout_for(("qm", "config", PROOF_VMID))) + nod = io.run(NOD_CURL, timeout_for(NOD_CURL)) + prod = io.run(production_ps_argv(), timeout_for(production_ps_argv())) + sample = Sample( + mem_available_kib=parse_memavailable_kib(meminfo or ""), + pressure_readable=pressure is not None, + full_avg10=None if pressure is None else pressure.full_avg10, + full_avg60=None if pressure is None else pressure.full_avg60, + pressure_trip=pressure_trip, + vm100_status=parse_qm_status(vm100.stdout) if vm100.code == 0 else None, + nod_body=nod.stdout if nod.code == 0 else None, + production=_production_rows(prod.stdout) if prod.code == 0 else None, + lease_age_seconds=io.lease_age(), + elapsed_seconds=elapsed, + free_available_mib=parse_free_available_mib(free.stdout) if free.code == 0 else None, + ) + parsed = parse_allowlisted_config(cfg.stdout) if cfg.code == 0 else None + status = parse_qm_status(vm130.stdout) if vm130.code == 0 else None + if parsed is None: + return sample, Vm130Config(status, None, None, None) + return sample, Vm130Config(status, parsed.memory_mib, parsed.cores, parsed.onboot) + + +def _start_guest(io, latch: StopLatch, config: GuardConfig, elapsed: float) -> bool: + latch.before_effect() + sample, vm130 = _fresh(io, config, elapsed) + gate = start_gate(sample, config, vm130) + if sample.production is None or {name for name, _ in sample.production} != {item.name for item in config.baseline}: + latch.trip("production-baseline-incomplete") + return False + if gate.rollback: + latch.trip(gate.reasons[0] if gate.reasons else "start-gate") + return False + latch.before_effect() + resize = ("qm", "set", PROOF_VMID, "--memory", str(TEST_MEMORY_MIB)) + # Record intent before issuing a command whose result can be lost. + io.baseline_snapshot = vm130 + io.owned_resize = True + if hasattr(io, "checkpoint"): + io.checkpoint() + if io.run(resize, timeout_for(resize)).code != 0: + latch.trip("resize-failed") + return False + status = io.run(("qm", "status", PROOF_VMID), timeout_for(("qm", "status", PROOF_VMID))) + cfg = io.run(("qm", "config", PROOF_VMID), timeout_for(("qm", "config", PROOF_VMID))) + parsed = parse_allowlisted_config(cfg.stdout) if cfg.code == 0 else None + confirmed = ( + status.code == 0 and parse_qm_status(status.stdout) == "stopped" + and parsed is not None + and parsed.memory_mib == TEST_MEMORY_MIB + and parsed.cores == 2 + and parsed.onboot == 0 + ) + if not confirmed: + latch.trip("resize-unconfirmed") + return False + latch.before_effect() + start = ("qm", "start", PROOF_VMID) + io.owned_start = True + if hasattr(io, "checkpoint"): + io.checkpoint() + if io.run(start, timeout_for(start)).code != 0: + latch.trip("start-failed") + return False + return True + + +def _production_rows(text: str) -> tuple[tuple[str, str], ...]: + rows = [] + for line in text.splitlines(): + if "\t" not in line: + continue + name, state = line.split("\t", 1) + rows.append((name, state)) + return tuple(rows) + + +def _cancel_children(io) -> None: + if hasattr(io, "cancel_all"): + io.cancel_all() + return + for pid, start in io.children(): + try: + cancel_owned_child(io, pid, start) + except Exception: + continue + + +def _safe_rollback(io, config: GuardConfig): + from vm_proof_guard import RollbackReport + if not getattr(io, "owned_resize", False): + return RollbackReport(False, False, ("no-owned-transition",), ()) + try: + status = io.run(("qm", "status", "130"), 15) + current = io.run(("qm", "config", "130"), 15) + shape = parse_allowlisted_config(current.stdout) if current.code == 0 else None + state = parse_qm_status(status.stdout) if status.code == 0 else None + if (shape is None or shape.memory_mib not in (4096, 8192) + or shape.cores != 2 or shape.onboot != 0 or state is None + or (state == "running" and (not io.owned_start or shape.memory_mib != 4096))): + return RollbackReport(False, True, ("ownership-or-shape-drift",), ()) + proof = getattr(io, "proof", None) + proof_reasons = [] + if proof is not None and state == "running": + try: + proof_reasons = proof.cleanup() + except Exception: + proof_reasons = ["proof-cleanup-unknown: shutdown fence required"] + elif proof is not None and proof.root_attempted: + proof_reasons = ["guest already stopped: owned proof artifacts may remain"] + report = rollback(_Runner(io), config, io.records(), skip_guest_cleanup=proof is not None) + if proof_reasons: + from dataclasses import replace + report = replace(report, ask_parent=True, restored=False, + reasons=(*report.reasons, *proof_reasons)) + return report + except Exception: + # Final production observations remain read-only even if rollback failed. + from vm_proof_guard import _confirm_production + reasons = ["rollback-failed"] + try: + _confirm_production(_Runner(io).run, config, reasons) + except Exception: + reasons.append("production-confirmation-unknown") + return RollbackReport(False, True, tuple(reasons), ()) + + +def rollback_failure(): + from vm_proof_guard import RollbackReport + return RollbackReport(False, True, ("rollback-failed",), ()) + + +class _Runner: + def __init__(self, io) -> None: + self.io = io + + def run(self, argv: tuple[str, ...]): + return self.io.run(argv, timeout_for(argv)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release/vm_proof_cleanup.py b/tests/release/vm_proof_cleanup.py new file mode 100644 index 0000000..c5d7d81 --- /dev/null +++ b/tests/release/vm_proof_cleanup.py @@ -0,0 +1,347 @@ +"""One fixed prior-proof cleanup; inert by default. Parent live admission required.""" +import argparse +import dataclasses +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +import vm_proof_guard as g +import vm_proof_linux as linux +import vm_proof_payload as payload +from vm_proof_adapter import StopLatch, prepare_run_root, run_guard, timeout_for +from vm_proof_driver import GUEST_CODE + +OLD_RUN = 'vmproof-20261024T182625Z-2b3a3e9a' +OLD_ROOT = '/var/tmp/' + OLD_RUN +MARKER = {'run': OLD_RUN, 'dev': 2049, 'ino': 2097223} +IMAGES = ( + 'sha256:fb8e78e8c31cdc5fb7a8ca893d58a56358efe152145e8433ff23e5837418c9c3', + 'sha256:bc4444aff2d536d62ca0ef3ec113dda922db2abe4c17724bfb1c805fbdda582d', + 'sha256:b9e50c2fafe2ba9da97191f9c1120c8bf1b2d91096141bbefe9822a18f375cf4', +) +COMMIT = '78ea9049a3370688b721e7ff3bda31a8b3f184c3' +DIGEST = '70aa41ab2f4fc04aece1661b773af93b29458a8d82a3048cacb29a13aa4350a4' +RECORDS = ('config', 'state', 'result', 'proof-evidence', 'controller', 'lease', 'pid') + + +def require(ok, reason): + if not ok: + raise g.GuardRefusal(reason) + + +def validate(snapshot, control): + """No CLI targets. The old approved identity is corroborated by owned records.""" + g.require_run_id(control) + require(control != OLD_RUN, 'new control run required') + require(set(snapshot) == set(RECORDS), 'snapshot record set') + config, state, result = (snapshot[k] for k in ('config', 'state', 'result')) + proof = state['proof'] + reasons = ['image-users-unknown:' + image for image in IMAGES] + require(config['run_id'] == OLD_RUN and len(config['baseline']) == 16, + 'prior config identity/baseline') + require(set(config) == {'run_id', 'baseline', 'window_seconds', 'lease_stale_seconds'} + and config['window_seconds'] == g.WINDOW_SECONDS + and config['lease_stale_seconds'] == g.LEASE_STALE_SECONDS, 'prior guard limits') + require(state['owned_resize'] is True and state['owned_start'] is True + and state['shutdown_sent'] is True and state['children'] == [] + and state['containers'] == [] and state['images'] == [] + and state['stop'] == 'proof-complete' + and state['baseline_vm130'] == {'status': 'stopped', 'memory_mib': 8192, 'cores': 2, 'onboot': 0}, + 'prior transition not fenced') + require(result == {'ask_parent': True, 'phase': 'monitor', 'reasons': ['proof-complete', *reasons], + 'restored': False, 'proof_complete': True}, 'prior result not recognized') + require(proof['pending'] is None and proof['sync_inflight'] is None + and proof['inventory_stage'] is None and proof['inventory_failure'] is None + and proof['complete'] is True and proof['root_attempted'] is True + and proof['leftovers'] == reasons and proof['images'] == list(IMAGES) + and proof['marker'] == MARKER and proof['root'] == str(payload.root_for(OLD_RUN)) + and proof['commit'] == COMMIT and proof['sha256'] == DIGEST, + 'prior proof identity/inflight unknown') + expected_jobs = {action + '-' + kind for action in ('create', 'start') + for kind in (*payload.KINDS, 'process-sps', 'serve')} + expected_jobs |= {'build-' + kind for kind in payload.KINDS} + jobs = proof['jobs'] + require(len(jobs) == len(expected_jobs) and {j['key'] for j in jobs} == expected_jobs, + 'prior jobs incomplete') + for job in jobs: + ident = job['identity'] + require(type(job['pid']) is int and job['pid'] > 1 and type(job['exitcode']) is int and job['exitcode'] == 0 + and ident['pid'] == job['pid'] and type(ident['start']) is int and ident['start'] > 0 + and isinstance(ident['boot'], str) and len(ident['boot']) == 36, + 'prior job identity/outcome unknown') + evidence = snapshot['proof-evidence'] + require(len(evidence) == len(expected_jobs) + 1 and + {e['step'] for e in evidence} == expected_jobs | {'extract-offline-units'}, 'evidence steps') + rows = {e['step']: e['result'] for e in evidence} + require(rows['extract-offline-units']['commit'] == COMMIT and + rows['extract-offline-units']['sha256'] == DIGEST, 'source evidence identity') + for kind, image in zip(payload.KINDS, IMAGES): + require(rows['build-' + kind] == {'image': image, 'tag': payload.tag_for(OLD_RUN, kind), + 'label': OLD_RUN}, 'image evidence identity') + containers = [rows['create-' + kind]['container'] for kind in (*payload.KINDS, 'process-sps', 'serve')] + require(proof['containers'] == containers and len(set(containers)) == 5 + and all(g.canonical_container_id(c) for c in containers), 'container records') + owner, lease = snapshot['controller'], snapshot['lease'] + require(set(owner) == {'pid', 'start'} and all(type(v) is int and v > 1 for v in owner.values()), + 'prior controller identity') + require(lease['run'] == OLD_RUN and all(lease[k] == owner[k] for k in owner) + and g._finite_nonnegative(lease['monotonic']), 'prior lease identity') + lines = snapshot['pid'].splitlines() + require(len(lines) == 2 and lines[0] == OLD_RUN, 'prior guard identity') + guard = tuple(map(int, lines[1].split())) + require(len(guard) == 2 and all(v > 1 for v in guard), 'prior guard identity') + return (owner['pid'], guard[0]) + + +def load_prior(control): + files = linux.PrivateFiles(OLD_ROOT) + try: + snapshot = {} + for name in RECORDS: + text = files._read(name, snapshot=True) + snapshot[name] = text if name == 'pid' else json.loads(text) + for pid in validate(snapshot, control): + # Only ENOENT proves absence; permission/errors/reused/live PID refuse. + try: + os.stat('/proc/' + str(pid)) + except FileNotFoundError: + continue + raise g.GuardRefusal('prior helper not positively absent') + again = linux.open_directory(OLD_ROOT) + try: + require((os.fstat(again).st_dev, os.fstat(again).st_ino) == + (os.fstat(files.fd).st_dev, os.fstat(files.fd).st_ino), 'prior root changed') + finally: + os.close(again) + return snapshot + finally: + files.close() + + +class CleanupProof: + """Old target authority only; never supplies physical VM rollback records.""" + root_attempted = True + + def __init__(self, io, snapshot): + validate(snapshot, io.config.run_id) + self.io = io + self.expected = None + self.ready = self.attempted = self.completed = False + self.leftovers = [] + self.removed = [] + + def state(self): + return {'mode': 'cleanup-only', 'target_run': OLD_RUN, 'marker': MARKER, + 'images': list(IMAGES), 'removed': self.removed, 'complete': self.completed, + 'attempted': self.attempted, 'ready': self.ready, 'leftovers': self.leftovers} + + def root_command(self, action): + require(action in ('cleanup-check', 'cleanup-remove'), 'fixed cleanup action') + return ('qm', 'guest', 'exec', '130', '--timeout', '30', '--', 'python3', '-c', + GUEST_CODE, OLD_RUN, action, str(MARKER['dev']), str(MARKER['ino'])) + + def permit(self, argv, data): + return (data is None and tuple(argv) == self.expected and self.io.owned_start + and self.io.cleanup and self.io.latch.closed()) + + def emit(self, argv): + argv = tuple(argv) + require(self.io.clock() < self.io.proof_cleanup_deadline, 'cleanup target budget') + if argv not in (self.root_command('cleanup-check'), self.root_command('cleanup-remove')): + g.rollback_command_allowed(argv, OLD_RUN) + require(argv[7:] in { + inner for image in IMAGES for inner in ( + ('docker', 'ps', '-a', '--no-trunc', '--filter', 'ancestor=' + image, '--format', '{{.ID}}'), + ('docker', 'image', 'inspect', '--format', g.IMAGE_INSPECT_FORMAT, image), + ('docker', 'image', 'rm', image))}, 'not a fixed cleanup command') + with self.io.latch.lock: + require(self.expected is None, 'concurrent cleanup') + self.expected = argv + try: + return self.io.run(argv, timeout_for(argv)) + finally: + self.expected = None + + def cleanup(self): + require(self.io.cleanup and self.io.latch.closed(), 'cleanup admission open') + if not self.ready or self.attempted: + return ['cleanup-not-admitted-or-already-attempted'] + self.attempted = True + self.io.checkpoint() + self.io.proof_cleanup_deadline = min(self.io.cleanup_deadline, self.io.clock() + 45) + try: + checked = self.emit(self.root_command('cleanup-check')) + require(checked.code == 0 and json.loads(checked.stdout) == MARKER, 'root preflight unknown') + for image in IMAGES: + reasons = [] + g._remove_image(self.emit, OLD_RUN, image, reasons) + require(not reasons, ';'.join(reasons)) + self.removed.append(image) + self.io.checkpoint() + removed = self.emit(self.root_command('cleanup-remove')) + require(removed.code == 0 and json.loads(removed.stdout) == + {'removed': str(payload.root_for(OLD_RUN))}, 'root removal unconfirmed') + self.completed = True + except g.GuardRefusal as exc: + self.leftovers.append(str(exc)) + except Exception: + # No retries and no more guest effects; physical rollback still runs. + self.leftovers.append('cleanup-refused-or-unknown: retained artifacts; no retry') + finally: + self.io.proof_cleanup_deadline = None + self.io.checkpoint() + return self.leftovers + + +class CleanupIO(linux.LinuxSubprocessIO): + def __init__(self, root, config, latch, snapshot, **kwargs): + validate(snapshot, config.run_id) + super().__init__(root, config, latch, 'discover', **kwargs) + self.proof = CleanupProof(self, snapshot) + + def records(self): + return g.ProofRecords(self.config.run_id, (), ()) + + def metadata_proof(self, latch): + sample, shape = linux._fresh(self, self.config, 2400 - (self.deadline - self.clock())) + require(not g.assess(sample, self.config).rollback and + (shape.status, shape.memory_mib, shape.cores, shape.onboot) == ('running', 4096, 2, 0), + 'cleanup health/shape') + latch.before_effect() + self.proof.ready = True + # Avoid the normal adapter's proof-complete label. + latch.trip('cleanup-only-ready') + self.checkpoint() + + def allowed(self, argv, input_data=None): + # Narrow the inherited metadata allowlist, never broaden it. + if argv[:3] == ('qm', 'guest', 'exec') and argv[3] == '130': + if argv != g.agent_ready_argv() and not self.proof.permit(argv, input_data): + raise g.GuardRefusal('cleanup-only guest command refused') + return super().allowed(argv, input_data) + + +def supervise(child, files, run, owner, *, observe=linux.identity, clock=time.monotonic, + sleep=time.sleep, stopped=lambda: False): + """Stop renewing at 40 min; reap the exact Popen child through cleanup bound.""" + valid = False + admitted = False + deadline = clock() + 2400 + try: + start = observe(child.pid) + valid = start is not None + while valid and child.poll() is None and clock() < deadline: + require(not stopped(), 'controller signal') + require(observe(child.pid) == start, 'guard identity changed') + if not admitted: + try: + require(files.read('ready') == f'{run}\n{child.pid} {start}\n', 'ready identity mismatch') + files.create('admit', run) + admitted = True + except FileNotFoundError: + pass + files.publish_lease(dict(owner, run=run, monotonic=clock())) + sleep(2) + except (Exception, KeyboardInterrupt): + valid = False + # No signal to an unknown/reused PID. Lease expiry <=90 + cleanup <=300. + try: + code = child.wait(timeout=400) + except subprocess.TimeoutExpired: + return 2 # explicitly uncertain; never report completion + return code if valid and admitted and not stopped() else 2 + + +def controller(args, config): + snapshot = load_prior(args.run_id) # before creating a new root or spawning + require(snapshot['config']['baseline'] == [dataclasses.asdict(b) for b in config.baseline], + 'baseline differs from prior approved set') + root = prepare_run_root('/var/tmp', args.run_id) + files = linux.PrivateFiles(root) + stopped = [False] + handlers = {} + try: + start = linux.identity(os.getpid()) + require(start is not None, 'controller identity unknown') + owner = {'pid': os.getpid(), 'start': start} + files.create('inventory', json.dumps(snapshot)) + files.create('controller', json.dumps(owner)) + files.create('lease', json.dumps(dict(owner, run=args.run_id, monotonic=time.monotonic()))) + cmd = [sys.executable, str(Path(__file__).resolve()), 'guard', '--execute', '--live', '--run-id', args.run_id] + for entry in args.baseline: + cmd += ['--baseline', entry] + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + handlers[sig] = signal.signal(sig, lambda signum, frame: stopped.__setitem__(0, True)) + require(not stopped[0], 'controller signal before spawn') + child = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True, + env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C', 'HOME': root}) + code = supervise(child, files, args.run_id, owner, stopped=lambda: stopped[0]) + if code: + return code + result = json.loads(files.read('result')) + state = json.loads(files.read('state')) + require(result['cleanup_complete'] is True and result['restored'] is True + and result['ask_parent'] is False and state['children'] == [], 'cleanup result incomplete') + return 0 + finally: + for sig, handler in handlers.items(): + signal.signal(sig, handler) + files.close() + + +def cli(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', nargs='?', choices=('controller', 'guard')) + parser.add_argument('--execute', action='store_true') + parser.add_argument('--live', action='store_true') + parser.add_argument('--dry-run', action='store_true') + parser.add_argument('--run-id') + parser.add_argument('--baseline', action='append', default=[]) + args = parser.parse_args(argv) + if args.dry_run or not args.execute: + print('dry-run: cleanup-only; fixed prior run ' + OLD_RUN + '; no commands or writes') + return 0 + try: + require(args.live and args.mode and args.run_id, 'explicit mode/live/new run required') + g.require_run_id(args.run_id) + require(args.run_id != OLD_RUN, 'new control run required') + config = linux.baseline_config(args) + require(len(config.baseline) == 16, 'all 16 production baseline entries required') + linux.host_gate() + if args.mode == 'controller': + return controller(args, config) + root = '/var/tmp/' + args.run_id + files = linux.PrivateFiles(root) + try: + snapshot = json.loads(files.read('inventory')) + finally: + files.close() + require(snapshot['config']['baseline'] == [dataclasses.asdict(b) for b in config.baseline], 'baseline mismatch') + latch = StopLatch() + io = CleanupIO(root, config, latch, snapshot) + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, lambda signum, frame: latch.trip('signal')) + try: + result = run_guard(io, config, latch, time.monotonic, time.sleep) + complete = io.proof.completed and not io.active and not io.unresolved_child + if not complete: + result = dataclasses.replace(result, ask_parent=True, restored=False, + reasons=(*result.reasons, 'cleanup-incomplete-or-child-unknown')) + io.checkpoint() + io.files.create('result', json.dumps(dict(dataclasses.asdict(result), mode='cleanup-only', + target_run=OLD_RUN, cleanup_complete=complete))) + return 0 if complete and result.restored and not result.ask_parent else 1 + finally: + io.close() + except (OSError, ValueError, KeyError, TypeError, AttributeError, IndexError, g.GuardRefusal): + print('cleanup refused/uncertain; inspect private records; no retry', file=sys.stderr) + return 2 + + +if __name__ == '__main__': + raise SystemExit(cli()) diff --git a/tests/release/vm_proof_driver.py b/tests/release/vm_proof_driver.py new file mode 100644 index 0000000..8232e39 --- /dev/null +++ b/tests/release/vm_proof_driver.py @@ -0,0 +1,353 @@ +"""Fixed QGA async source/Docker proof, serialized with the monitor stop latch.""" +import base64 +import dataclasses +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import time + +import vm_proof_guard as g +import vm_proof_payload as payload +from vm_proof_adapter import _fresh + +# Code is the installed/reviewed helper, not an arbitrary --script input. +GUEST_CODE = Path(payload.__file__).read_text() + + +@dataclasses.dataclass(frozen=True) +class Source: + commit: str + sha256: str + data: bytes + + @classmethod + def load(cls, path, commit, digest): + from vm_proof_linux import open_directory + if not path or not commit or not digest: + raise g.GuardRefusal("archive/commit/sha256 required") + parent, name = os.path.split(path) + fd = open_directory(parent) + try: + info = os.fstat(fd) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077: + raise g.GuardRefusal("archive parent must be private and owned") + file_fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd) + with os.fdopen(file_fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid() + or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > payload.MAX_ARCHIVE): + raise g.GuardRefusal("unsafe archive file") + data = stream.read(payload.MAX_ARCHIVE + 1) + finally: + os.close(fd) + payload.validate_archive(data, commit, digest) + return cls(commit, digest, data) + + +class GuestProof: + def __init__(self, io, source, sleeper=time.sleep): + self.io, self.source, self.sleep = io, source, sleeper + self.run = io.config.run_id + self.expected = None + self.pending = None + self.sync_inflight = None + self.jobs = [] + self.attempted = set() + self.containers = [] + self.images = [] + self.marker = None + self.root_attempted = False + self.transferred = 0 + self.completed = False + self.evidence = [] + self.leftovers = [] + self.inventory_stage = None + self.inventory_failure = None + + def state(self): + return {"pending": self.pending, "sync_inflight": self.sync_inflight, "jobs": self.jobs, "containers": self.containers, + "images": self.images, "root": str(payload.root_for(self.run)), "root_attempted": self.root_attempted, + "marker": self.marker, "complete": self.completed, "leftovers": self.leftovers, + "transferred": self.transferred, "inventory_stage": self.inventory_stage, + "inventory_failure": self.inventory_failure, + "artifact_paths": [str(payload.root_for(self.run) / name) for name in + ("owner", "payload-meta", "payload.tar", "src", "home", "tmp", "config", "state", "cache", "data", "runtime", "docker-config", "jobs")], + "build_cache": "may remain; no prune; base/shared images protected", + "commit": self.source.commit, "sha256": self.source.sha256} + + def health(self): + sample, shape = _fresh(self.io, self.io.config, 2400 - (self.io.deadline - self.io.clock())) + trip = g.assess(sample, self.io.config) + if trip.rollback or (shape.status, shape.memory_mib, shape.cores, shape.onboot) != ("running", 4096, 2, 0): + self.io.latch.trip("proof-health-trip") + raise g.GuardRefusal("proof-health-trip") + + def permit(self, argv, input_data): + if not self.io.owned_start or self.expected != (tuple(argv), input_data): + return False + if not self.io.cleanup: + return not self.io.latch.closed() + if input_data is not None or not self.io.latch.closed(): + return False + if self.marker and tuple(argv) == self.command("remove", (self.marker["dev"], self.marker["ino"])): + return self.pending is None and self.sync_inflight in (None, "remove") + if tuple(argv[:4]) != ("qm", "guest", "exec", "130"): + return False + try: + g.rollback_command_allowed(tuple(argv), self.run) + except g.GuardRefusal: + return False + inner = tuple(argv[7:]) + if inner[:2] in (("docker", "stop"), ("docker", "rm")): + return inner[-1] in self.containers + if inner[:3] == ("docker", "image", "rm"): + return inner[-1] in self.images + return inner[:2] in (("docker", "ps"), ("docker", "image")) + + def command(self, action, args=(), asynchronous=False, stdin=False): + # Call sites are a fixed sequence; argv data never becomes shell/code. + if action not in {"inventory", "init", "chunk", "extract", "identity", "build", "create", "start", "remove"}: + raise g.GuardRefusal("unknown proof action") + options = ("--synchronous", "0") if asynchronous else ("--timeout", "30") + if stdin: + if action != "chunk": + raise g.GuardRefusal("stdin only for bounded archive chunks") + options += ("--pass-stdin", "1") + return ("qm", "guest", "exec", "130", *options, "--", "python3", "-c", GUEST_CODE, self.run, action, *map(str, args)) + + def call(self, argv, data=None, cleanup=False): + with self.io.latch.lock: + if not cleanup: + self.io.latch.before_effect() + if self.expected is not None: + raise g.GuardRefusal("concurrent guest admission") + if data is not None and (not isinstance(data, bytes) or len(data) > 1024 * 1024): + raise g.GuardRefusal("QGA stdin limit") + self.expected = (tuple(argv), data) + try: + result = self.io.run(argv, 45, input_data=data) + finally: + self.expected = None + try: + obj = json.loads(result.stdout) + except (ValueError, TypeError): + obj = None + if (self.inventory_stage is not None and payload.inventory_error(obj) + and obj["step"] == self.inventory_stage["step"]): + self.inventory_failure = obj + self.io.checkpoint() + raise g.GuardRefusal("inventory-failed:" + obj["step"] + ":" + obj["reason"]) + if result.code: + raise g.GuardRefusal("guest command outcome unknown/failed") + if not isinstance(obj, dict) or "error" in obj: + raise g.GuardRefusal("guest response malformed/failed") + return obj + + def sync(self, action, args=(), data=None, cleanup=False): + mutation = action in {"init", "chunk", "extract", "remove"} + if mutation: + self.sync_inflight = action + self.io.checkpoint() + result = self.call(self.command(action, args, stdin=data is not None), data, cleanup) + if mutation: + self.sync_inflight = None + self.io.checkpoint() + return result + + def job(self, action, kind, extra=()): + self.io.latch.before_effect() + key = action + "-" + kind + if key in self.attempted or self.pending is not None: + raise g.GuardRefusal("job retry/concurrency refused") + self.health() + with self.io.latch.lock: + self.io.latch.before_effect() + self.attempted.add(key) + # Record intent BEFORE QGA: a lost PID reply is an inflight unknown. + self.pending = {"key": key, "pid": None, "identity": None, "started": self.io.clock()} + self.io.checkpoint() + reply = self.call(self.command(action, (kind, *extra), asynchronous=True)) + if set(reply) != {"pid"} or type(reply["pid"]) is not int or reply["pid"] <= 1: + raise g.GuardRefusal("QGA PID missing/invalid") + self.pending["pid"] = reply["pid"] + self.io.checkpoint() + # Capture wrapper starttime/boot-id BEFORE exec-status polling. A fast + # completed wrapper leaves its absent-only identity record in the root. + for _ in range(6): + self.health() + identity = self.sync("identity", (key, reply["pid"])) + if identity == {"pending": True}: + self.sleep(1) + continue + if (identity.get("pid") != reply["pid"] or type(identity.get("start")) is not int + or identity["start"] <= 0 or not re.fullmatch(r"[0-9a-f-]{36}", identity.get("boot", ""))): + raise g.GuardRefusal("guest process identity unknown") + self.pending["identity"] = identity + self.io.checkpoint() + break + else: + raise g.GuardRefusal("guest identity deadline") + deadline = self.pending["started"] + (1200 if action == "build" else 150) + for _ in range(240): + self.health() # Full VM100/Nod/container health, not only host memory. + if self.io.clock() >= deadline: + raise g.GuardRefusal("job deadline") + response = self.call(("qm", "guest", "exec-status", "130", str(reply["pid"]))) + if response.get("exited") in (False, 0) and "exited" in response: + if set(response) != {"exited"}: + raise g.GuardRefusal("unexpected pending QGA status") + self.sleep(5) + continue + if response.get("exited") not in (True, 1) or type(response.get("exitcode")) is not int: + raise g.GuardRefusal("unknown QGA completion") + # QGA completion fences the wrapper. Its timeout handler waits for its + # exact Docker CLI child. Failed/unknown builds still retain tag/cache + # uncertainty and are never retried. + finished = dict(self.pending, exitcode=response["exitcode"]) + if response["exitcode"] or response.get("err-data") or response.get("out-truncated"): + self.leftovers.append("failed-job:" + key) + self.jobs.append(finished) + self.pending = None + self.io.checkpoint() + raise g.GuardRefusal("guest job failed; no retry") + result = json.loads(response.get("out-data", "")) + if not isinstance(result, dict) or "error" in result: + raise g.GuardRefusal("invalid guest job result") + # Update durable identity record before releasing pending or starting + # anything. A malformed result stays inflight-unknown for shutdown. + if action == "build": + if (not g.canonical_image_id(result.get("image", "")) or result.get("label") != self.run + or result.get("tag") != payload.tag_for(self.run, kind)): + raise g.GuardRefusal("image result ownership") + self.images.append(result["image"]) + if action == "create": + if not g.canonical_container_id(result.get("container", "")) or result.get("label") != self.run or result.get("kind") != kind: + raise g.GuardRefusal("container result ownership") + self.containers.append(result["container"]) + self.jobs.append(finished) + self.evidence.append({"step": key, "result": result}) + self.pending = None + self.io.checkpoint() + return result + raise g.GuardRefusal("job polling bound") + + def execute(self): + self.health() + # Metadata precedes the first guest path/source mutation. Dirty work is + # recorded unchanged; there is no in-place-update operation in this driver. + inventory = {} + for step in payload.INVENTORY_STEPS: + self.health() + self.inventory_stage = {"step": step, "state": "started", "metadata": + {k: v for k, v in inventory.items() if + (k == "path" and payload.inventory_path(v)) or + (k == "owner" and payload.inventory_owner(v))}} + self.io.checkpoint() # Durable exact intent even if QGA loses its reply. + context = {k: v for k, v in inventory.items() if k in + {"path", "owner", "group", "service", "uid", "gid", "mode", "dev", "ino"}} + try: + inventory.update(self.sync("inventory", (self.io.checkout, step, json.dumps(context)))) + except Exception: + if self.inventory_failure is None: + self.inventory_failure = {"error": "inventory-failed", "step": step, + "reason": "transport-unknown", + "metadata": self.inventory_stage["metadata"]} + self.io.checkpoint() + raise + self.inventory_stage["state"] = "complete" + self.io.checkpoint() + if not re.fullmatch(r"[0-9a-f]{40}", inventory.get("head", "").strip()): + raise g.GuardRefusal("unknown owner checkout") + if (not payload.inventory_path(inventory.get("path")) or + not payload.inventory_owner(inventory.get("owner")) or + type(inventory.get("uid")) is not int or inventory["uid"] <= 0 or + inventory.get("root", "").strip() != inventory["path"] or + not all(isinstance(inventory.get(k), str) for k in ("branch", "status", "submodules"))): + raise g.GuardRefusal("incomplete owner inventory") + self.inventory_stage = None + self.io.files.create("owner-inventory", json.dumps(inventory)) + self.health() + self.root_attempted = True + self.io.checkpoint() + self.marker = self.sync("init", (self.source.commit, self.source.sha256, len(self.source.data))) + if (self.marker.get("run") != self.run or type(self.marker.get("ino")) is not int + or type(self.marker.get("dev")) is not int): + raise g.GuardRefusal("guest root identity unknown") + self.io.checkpoint() + for offset in range(0, len(self.source.data), payload.CHUNK): + self.health() + block = self.source.data[offset:offset + payload.CHUNK] + reply = self.sync("chunk", (offset, hashlib.sha256(block).hexdigest()), base64.b64encode(block)) + if reply != {"offset": offset + len(block)}: + raise g.GuardRefusal("chunk receipt mismatch") + self.transferred = reply["offset"] + self.io.checkpoint() + self.health() + result = self.sync("extract") + if result.get("commit") != self.source.commit or result.get("sha256") != self.source.sha256: + raise g.GuardRefusal("extracted source identity") + self.evidence.append({"step": "extract-offline-units", "result": result}) + for kind in payload.KINDS: + self.job("build", kind) + created = self.job("create", kind) + self.job("start", kind, (created["container"],)) + process = self.job("create", "process-sps") + result = self.job("start", "process-sps", (process["container"],)) + if result.get("checked") != "process-sps" or result.get("exit") != 0: + raise g.GuardRefusal("process-SPS proof missing") + payload.validate_process_result(result.get("proof")) + server = self.job("create", "serve") + result = self.job("start", "serve", (server["container"],)) + if result.get("http") != 200 or result.get("checked") != "serve": + raise g.GuardRefusal("entrypoint proof missing") + self.completed = True + self.io.files.create("proof-evidence", json.dumps(self.evidence)) + self.io.checkpoint() + + def cleanup(self): + """Called only with latch CLOSED and VM ownership/shape checked. + + If a command/job could still be writing, do NOT race it with deletion. + The owned VM shutdown is the final fence; report artifacts as leftovers. + """ + if not self.io.cleanup or not self.io.latch.closed(): + raise g.GuardRefusal("cleanup without closed admission") + reasons = list(self.leftovers) + if (self.pending is not None or self.sync_inflight is not None + or self.root_attempted and self.marker is None or self.leftovers): + self.leftovers = reasons + ["inflight-unknown: shutdown fence required; guest artifacts retained"] + self.io.checkpoint() + return self.leftovers + end = self.io.clock() + 45 + def emit(argv): + if self.io.clock() >= end: + raise g.GuardRefusal("proof cleanup budget") + # Existing narrow core verifies full ID+label and image ID+tag+label. + g.rollback_command_allowed(argv, self.run) + with self.io.latch.lock: + self.expected = (tuple(argv), None) + try: + from vm_proof_adapter import timeout_for + return self.io.run(argv, timeout_for(argv)) + finally: + self.expected = None + try: + self.io.proof_cleanup_deadline = end + g._cleanup_guest(emit, g.ProofRecords(self.run, tuple(self.containers), tuple(self.images)), reasons) + if self.marker is not None and not reasons and self.io.clock() < end: + removed = self.sync("remove", (self.marker["dev"], self.marker["ino"]), cleanup=True) + if removed != {"removed": str(payload.root_for(self.run))}: + reasons.append("guest-artifacts-removal-unconfirmed") + except Exception: + reasons.append("guest-cleanup-incomplete: shutdown fence required") + finally: + self.io.proof_cleanup_deadline = None + if self.images or any(j["key"].startswith("build-") for j in self.jobs) or self.leftovers: + # No build cache/base-image prune; this residual is always explicit. + self.evidence.append({"build_cache": "may remain; no prune; shared/base images preserved"}) + self.leftovers = reasons + self.io.checkpoint() + return reasons diff --git a/tests/release/vm_proof_guard.py b/tests/release/vm_proof_guard.py new file mode 100644 index 0000000..271a518 --- /dev/null +++ b/tests/release/vm_proof_guard.py @@ -0,0 +1,949 @@ +"""Fail-closed VM 130 proof guard. Library only; this file does not contact a host. + +Tests inject a fake executor. Rollback stops a container only when its recorded +full 64-character id and unique label both match, and removes an image only when +its sha256 id, run label, and exact run tag all match. It shuts VM 130 down +once and restores 8192 MiB only after that guest is stopped. + +Tripwires, any one of which rolls back: MemAvailable below 1.5 GiB, unreadable +pressure, full-memory stall, VM 100 not running, Nod health not the exact +parent body, baseline production container metadata missing or unhealthy, +unknown probe output, stale parent lease, or the resource window elapsing. +Rollback stops only containers whose recorded id and unique label both match, +shuts VM 130 down gracefully, and restores 8192 MiB only after it is stopped. +It never poweroffs a guest, prunes, or signals an unverified PID. +""" + +from __future__ import annotations + +import json +import math +import re +import sys +from dataclasses import dataclass + +PROOF_VMID = "130" +PRODUCTION_VMID = "100" +TEST_MEMORY_MIB = 4096 +RESTORE_MEMORY_MIB = 8192 +REQUIRED_CORES = 2 +REQUIRED_ONBOOT = 0 +MEMORY_FLOOR_MIB = 1536 +HEADROOM_MIB = TEST_MEMORY_MIB + MEMORY_FLOOR_MIB +NOD_HEALTH_URL = "http://192.168.1.199:8767/health" +NOD_HEALTH_BODY = '{"ok":true,"service":"nod"}' +LABEL_KEY = "toolstack.vmproof" +LEASE_STALE_SECONDS = 90 +WINDOW_SECONDS = 40 * 60 +SHUTDOWN_TIMEOUT = "90" +GUEST_TIMEOUT = "30" +PRODUCTION_TIMEOUT = "15" +HEAVY_BUILD_ATTEMPTS = 1 +STATUS_CHECKS_AFTER_SHUTDOWN = 1 +# Parent read-only baseline, 2026-10-24. Not a live reading. +PARENT_MEM_AVAILABLE_KIB = 7194384 +PARENT_FREE_AVAILABLE_MIB = 6952 + +RUN_ID_RE = re.compile(r"^vmproof-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$") +CONTAINER_ID_RE = re.compile(r"^[0-9a-f]{64}$") +IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +SECRET_MARKERS = ( + "cipassword", + "BEGIN PRIVATE", + "BEGIN OPENSSH", + "SP_VAULT_PASSPHRASE", +) + +NOD_CURL = ( + "curl", "--noproxy", "*", "-fsS", + "--connect-timeout", "2", "--max-time", "5", + NOD_HEALTH_URL, +) +PRODUCTION_PS_FORMAT = "{{.Names}}\t{{.Status}}" +PROOF_PS_FORMAT = '{{.ID}}\t{{.Label "toolstack.vmproof"}}\t{{.Names}}' +IMAGE_INSPECT_FORMAT = "{{.Id}}\t{{json .RepoTags}}\t{{json .Config.Labels}}" +BASELINE_EXPECTATIONS = frozenset({"healthy", "running"}) + + +class GuardRefusal(Exception): + """A command or identity the guard must not act on.""" + + +@dataclass(frozen=True) +class Completed: + code: int + stdout: str + + +@dataclass(frozen=True) +class BaselineContainer: + name: str + expect: str + + +@dataclass(frozen=True) +class GuardConfig: + run_id: str + baseline: tuple[BaselineContainer, ...] + window_seconds: int = WINDOW_SECONDS + lease_stale_seconds: int = LEASE_STALE_SECONDS + + +@dataclass(frozen=True) +class Sample: + mem_available_kib: int | None + pressure_readable: bool + full_avg10: float | None + vm100_status: str | None + nod_body: str | None + production: tuple[tuple[str, str], ...] | None + lease_age_seconds: float | None + elapsed_seconds: float + free_available_mib: int | None = PARENT_FREE_AVAILABLE_MIB + full_avg60: float | None = None + pressure_trip: Trip | None = None + + +@dataclass(frozen=True) +class Trip: + rollback: bool + reasons: tuple[str, ...] + + +@dataclass(frozen=True) +class Vm130Config: + status: str | None + memory_mib: int | None + cores: int | None + onboot: int | None + + +@dataclass(frozen=True) +class ListedContainer: + container_id: str + label: str + name: str + + +@dataclass(frozen=True) +class OwnedSelection: + stop: tuple[str, ...] + mismatch: tuple[str, ...] + absent: tuple[str, ...] + + +@dataclass(frozen=True) +class ProofRecords: + run_id: str + container_ids: tuple[str, ...] + image_ids: tuple[str, ...] + + +@dataclass(frozen=True) +class RollbackReport: + restored: bool + ask_parent: bool + reasons: tuple[str, ...] + commands: tuple[tuple[str, ...], ...] + + +@dataclass(frozen=True) +class Pressure: + some_avg10: float + full_avg10: float + full_avg60: float + + +class PressureState: + """One host-monotonic history per guard; never reconstructed from guest time.""" + def __init__(self): + self.boot_monotonic = None + self.high_since = None + self.last_monotonic = None + self.fault = None + self.observation = None + + def begin_boot(self, now): + if (self.boot_monotonic is not None or self.fault + or not _finite_nonnegative(now) + or self.last_monotonic is not None and now < self.last_monotonic): + self.high_since = None + self.fault = "pressure-clock-or-boot-invalid" + raise GuardRefusal(self.fault) + self.boot_monotonic = now + self.last_monotonic = now + self.high_since = None + + def observe(self, now, pressure): + reason = None + if (not _finite_nonnegative(now) + or self.last_monotonic is not None and now < self.last_monotonic): + self.fault = "pressure-clock-invalid" + if not self.fault: + self.last_monotonic = now + if self.fault: + reason = self.fault + elif pressure is None or not all(_finite_nonnegative(v) for v in + (pressure.some_avg10, pressure.full_avg10, pressure.full_avg60)): + reason = "pressure-unknown" + else: + if self.boot_monotonic is not None and now < self.boot_monotonic + 90: + self.high_since = None + else: + if pressure.full_avg10 > 2.0: + if self.high_since is None: + self.high_since = now + else: + self.high_since = None + if pressure.full_avg60 > 1.0: + reason = "pressure-full-avg60" + elif self.high_since is not None: + if now - self.high_since >= 30: + reason = "pressure-full-sustained" + elif self.boot_monotonic is None: + # No boot grace before admission; do not boot into an + # unresolved high-pressure interval of unknown onset. + reason = "pressure-preboot-unsettled" + if reason == "pressure-unknown" or self.fault: + self.high_since = None + self.observation = { + "monotonic": now if _finite_nonnegative(now) else None, + "full_avg10": pressure.full_avg10 if pressure and _finite_nonnegative(pressure.full_avg10) else None, + "full_avg60": pressure.full_avg60 if pressure and _finite_nonnegative(pressure.full_avg60) else None, + "reason": reason or "pressure-valid", + } + return Trip(reason is not None, (reason,) if reason else ()) + + def snapshot(self): + return dict(boot_monotonic=self.boot_monotonic, high_since=self.high_since, + last_monotonic=self.last_monotonic, fault=self.fault, + grace_seconds=90, avg10_above=2.0, sustained_seconds=30, + avg60_above=1.0, observation=self.observation) + + +def require_run_id(run_id: str) -> None: + if not RUN_ID_RE.fullmatch(run_id): + raise GuardRefusal("run id is not the private proof form") + + +def parse_memavailable_kib(text: str) -> int | None: + for line in text.splitlines(): + if not line.startswith("MemAvailable:"): + continue + parts = line.split() + if len(parts) >= 2 and parts[1].isdigit(): + return int(parts[1]) + return None + return None + + +def parse_free_available_mib(text: str) -> int | None: + """Cross-check only. MemAvailable remains the trip source.""" + header = None + for line in text.splitlines(): + parts = line.split() + if not parts: + continue + if parts[0] == "Mem:" and header is not None: + if header[-1].lower() != "available" or not parts[-1].isdigit(): + return None + return int(parts[-1]) + if "available" in [item.lower() for item in parts]: + header = parts + return None + + +def parse_pressure(text: str) -> Pressure | None: + some = full = None + full60 = None + seen = set() + for line in text.splitlines(): + parts = line.split() + if not parts: + continue + if parts[0] not in {"some", "full"} or parts[0] in seen: + return None + seen.add(parts[0]) + values = {} + for item in parts[1:]: + if "=" not in item: + return None + key, raw = item.split("=", 1) + if key in values: + return None + try: + values[key] = float(raw) + except ValueError: + return None + if set(values) != {"avg10", "avg60", "avg300", "total"} or not all(_finite_nonnegative(v) for v in values.values()): + return None + if parts[0] == "some": + some = values["avg10"] + elif parts[0] == "full": + full = values["avg10"] + full60 = values["avg60"] + if some is None or full is None: + return None + return Pressure(some, full, full60) + + +def nod_health_ok(body: str | None) -> bool: + """Exact ok/service fields. Whitespace may differ; extra fields fail closed.""" + if body is None: + return False + try: + data = json.loads(body) + except json.JSONDecodeError: + return False + return ( + isinstance(data, dict) + and set(data) == {"ok", "service"} + and data["ok"] is True + and data["service"] == "nod" + ) + + +def canonical_container_id(text: str) -> str | None: + text = text.strip() + if CONTAINER_ID_RE.fullmatch(text): + return text + return None + + +def canonical_image_id(text: str) -> str | None: + text = text.strip() + if IMAGE_ID_RE.fullmatch(text): + return text + return None + + +def run_tags(run_id: str) -> frozenset[str]: + require_run_id(run_id) + return frozenset({ + f"toolstack-vmproof:{run_id}", + f"toolstack-vmproof-echo-mcp:{run_id}", + f"toolstack-vmproof-echo-api:{run_id}", + }) + + +def _finite_nonnegative(value) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and value >= 0 + ) + + +def _nonnegative_int(value) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def assess(sample: Sample, config: GuardConfig, pressure_state=None) -> Trip: + require_run_id(config.run_id) + reasons: list[str] = [] + if not _nonnegative_int(config.window_seconds) or config.window_seconds <= 0: + reasons.append("probe-config-invalid") + if not _nonnegative_int(config.lease_stale_seconds) or config.lease_stale_seconds <= 0: + reasons.append("probe-config-invalid") + if not _nonnegative_int(sample.mem_available_kib): + reasons.append("mem-unknown") + elif sample.mem_available_kib < MEMORY_FLOOR_MIB * 1024: + reasons.append("mem-floor") + if not _nonnegative_int(sample.free_available_mib): + reasons.append("free-unknown") + pressure_valid = (sample.pressure_readable and _finite_nonnegative(sample.full_avg10) + and _finite_nonnegative(sample.full_avg60)) + pressure_trip = sample.pressure_trip + if pressure_trip is None: + pressure_trip = (pressure_state or PressureState()).observe( + sample.elapsed_seconds, + Pressure(0, sample.full_avg10, sample.full_avg60) if pressure_valid else None) + if not pressure_valid: + reasons.append("pressure-unknown") + reasons.extend(pressure_trip.reasons) + if sample.vm100_status is None: + reasons.append("vm100-unknown") + elif sample.vm100_status != "running": + reasons.append("vm100-not-running") + if sample.nod_body is None: + reasons.append("nod-unknown") + elif not nod_health_ok(sample.nod_body): + reasons.append("nod-unhealthy") + reasons.extend(_production_reasons(sample.production, config.baseline)) + if not _finite_nonnegative(sample.lease_age_seconds): + reasons.append("parent-lease-unknown") + elif sample.lease_age_seconds > config.lease_stale_seconds: + reasons.append("parent-lease-stale") + if not _finite_nonnegative(sample.elapsed_seconds): + reasons.append("window-unknown") + elif sample.elapsed_seconds >= config.window_seconds: + reasons.append("window-expired") + return Trip(bool(reasons), tuple(dict.fromkeys(reasons))) + + +def _production_reasons(production, baseline: tuple[BaselineContainer, ...]) -> list[str]: + if not baseline or production is None: + return ["production-unknown"] + found = dict(production) + reasons = [] + for item in baseline: + if item.expect not in BASELINE_EXPECTATIONS or not item.name or "/" in item.name: + reasons.append("production-unknown") + continue + status = found.get(item.name) + if status is None: + reasons.append(f"production-missing:{item.name}") + elif item.expect == "healthy" and "(healthy)" not in status: + reasons.append(f"production-unhealthy:{item.name}") + elif item.expect == "running" and ( + not status.startswith("Up") or "unhealthy" in status + ): + reasons.append(f"production-unhealthy:{item.name}") + return reasons + + +def start_gate(sample: Sample, config: GuardConfig, vm130: Vm130Config) -> Trip: + reasons = list(assess(sample, config).reasons) + if vm130.status != "stopped": + reasons.append("vm130-not-stopped") + if vm130.memory_mib != RESTORE_MEMORY_MIB: + reasons.append("vm130-memory-not-baseline") + if vm130.cores != REQUIRED_CORES or vm130.onboot != REQUIRED_ONBOOT: + reasons.append("vm130-shape-drift") + if sample.mem_available_kib is None or sample.mem_available_kib < HEADROOM_MIB * 1024: + reasons.append("mem-headroom") + return Trip(bool(reasons), tuple(dict.fromkeys(reasons))) + + +def start_commands(gate: Trip) -> tuple[tuple[str, ...], ...]: + if gate.rollback: + return () + commands = ( + ("qm", "set", PROOF_VMID, "--memory", str(TEST_MEMORY_MIB)), + ("qm", "start", PROOF_VMID), + ) + for command in commands: + start_command_allowed(command) + return commands + + +def recovery_commands(vm130: Vm130Config) -> tuple[tuple[str, ...], ...] | None: + """Restore memory only when the guest is stopped and its shape is unchanged.""" + if vm130.status != "stopped" or vm130.cores != REQUIRED_CORES or vm130.onboot != REQUIRED_ONBOOT: + return None + if vm130.memory_mib == RESTORE_MEMORY_MIB: + return () + if vm130.memory_mib != TEST_MEMORY_MIB: + return None + return (("qm", "set", PROOF_VMID, "--memory", str(RESTORE_MEMORY_MIB)),) + + +def select_owned( + run_id: str, + recorded_ids: tuple[str, ...], + listed: tuple[ListedContainer, ...], +) -> OwnedSelection: + require_run_id(run_id) + recorded = {cid for cid in recorded_ids if canonical_container_id(cid)} + stop: list[str] = [] + mismatch = [f"recorded-id-rejected:{cid}" for cid in recorded_ids if cid not in recorded] + seen: set[str] = set() + for item in listed: + if canonical_container_id(item.container_id) is None: + mismatch.append(f"malformed-id:{item.name}") + continue + seen.add(item.container_id) + labeled = item.label == run_id + known = item.container_id in recorded + if labeled and known: + stop.append(item.container_id) + elif labeled or known: + mismatch.append(f"not-owned:{item.container_id}") + absent = tuple(cid for cid in recorded if cid not in seen) + return OwnedSelection(tuple(stop), tuple(mismatch), absent) + + +def guard_signal_argv( + recorded_pid: int, + recorded_start: int, + observed_pid: int | None, + observed_start: int | None, +) -> tuple[str, ...] | None: + if observed_pid is None or observed_start is None: + return None + if recorded_pid <= 1 or recorded_start <= 0: + return None + if observed_pid != recorded_pid or observed_start != recorded_start: + return None + return ("kill", "-TERM", str(recorded_pid)) + + +def drive(samples: tuple[Sample, ...], config: GuardConfig) -> str: + """Return the next machine action. No sleep and no executor.""" + pressure_state = PressureState() + for sample in samples: + if assess(sample, config, pressure_state).rollback: + return "rollback" + return "continue" + + +def guest_exec_stdout(payload: str) -> Completed | None: + try: + data = json.loads(payload) + except json.JSONDecodeError: + return None + if not isinstance(data, dict) or data.get("exited") != 1: + return None + code = data.get("exitcode") + out = data.get("out-data", "") + if type(code) is not int or not isinstance(out, str) or _looks_secret(out): + return None + return Completed(code, out) + + +def redact_qm_config(text: str) -> str: + kept = [] + for line in text.splitlines(): + key = line.split(":", 1)[0].strip().lower() + if key in {"memory", "cores", "onboot", "agent"}: + kept.append(line.strip()) + return "\n".join(kept) + + +def parse_qm_status(text: str) -> str | None: + for line in text.splitlines(): + if line.startswith("status:"): + value = line.split(":", 1)[1].strip() + if value in {"running", "stopped"}: + return value + return None + return None + + +def parse_allowlisted_config(text: str) -> Vm130Config | None: + values: dict[str, str] = {} + for line in text.splitlines(): + if ":" not in line: + continue + key, raw = line.split(":", 1) + values[key.strip().lower()] = raw.strip() + try: + memory = int(values["memory"]) + cores = int(values["cores"]) + onboot = int(values["onboot"]) + except (KeyError, ValueError): + return None + return Vm130Config(None, memory, cores, onboot) + + +def rollback(executor, config: GuardConfig, records: ProofRecords, *, skip_guest_cleanup=False) -> RollbackReport: + require_run_id(config.run_id) + if records.run_id != config.run_id: + raise GuardRefusal("records belong to another proof run") + commands: list[tuple[str, ...]] = [] + reasons: list[str] = [] + + def emit(argv: tuple[str, ...]) -> Completed: + rollback_command_allowed(argv, config.run_id) + commands.append(argv) + return executor.run(argv) + + status_result = emit(("qm", "status", PROOF_VMID)) + status = parse_qm_status(status_result.stdout) if status_result.code == 0 else None + if status is None: + reasons.append("vm130-status-unknown") + prod_ok = _confirm_production(emit, config, reasons) + return _report(False, True, reasons, commands, prod_ok) + if status == "running": + if not skip_guest_cleanup: + _cleanup_guest(emit, records, reasons) + emit(("qm", "shutdown", PROOF_VMID, "--timeout", SHUTDOWN_TIMEOUT)) + status = _status_after_shutdown(emit) + if status != "stopped": + reasons.append("graceful-shutdown-failed") + prod_ok = _confirm_production(emit, config, reasons) + return _report(False, True, reasons, commands, prod_ok) + restored_memory = _restore_memory(emit, reasons) + prod_ok = _confirm_production(emit, config, reasons) + ask = (not restored_memory) or (not prod_ok) or any( + item.startswith(( + "not-owned:", "malformed-id:", "graceful-", "vm130-", "memory-", + "owned-list-", "docker-", "image-", + )) + for item in reasons + ) + return _report(restored_memory and prod_ok and not ask, ask, reasons, commands, prod_ok) + + +def _report(restored: bool, ask: bool, reasons: list[str], commands, prod_ok: bool) -> RollbackReport: + if not prod_ok: + ask = True + restored = False + return RollbackReport(restored, ask, tuple(reasons), tuple(commands)) + + +def _cleanup_guest(emit, records: ProofRecords, reasons: list[str]) -> None: + listed = emit(_guest(PROOF_VMID, GUEST_TIMEOUT, ( + "docker", "ps", "-a", "--no-trunc", "--format", PROOF_PS_FORMAT, + ))) + if listed.code != 0: + reasons.append("owned-list-unknown") + return + selection = select_owned(records.run_id, records.container_ids, _parse_ps(listed.stdout)) + reasons.extend(selection.mismatch) + for cid in selection.stop: + stopped = emit(_guest(PROOF_VMID, GUEST_TIMEOUT, ("docker", "stop", "--time", "10", cid))) + if stopped.code != 0: + reasons.append(f"docker-stop-failed:{cid}") + continue + removed = emit(_guest(PROOF_VMID, GUEST_TIMEOUT, ("docker", "rm", cid))) + if removed.code != 0: + reasons.append(f"docker-rm-failed:{cid}") + for image_id in records.image_ids: + _remove_image(emit, records.run_id, image_id, reasons) + + +def _remove_image(emit, run_id: str, image_id: str, reasons: list[str]) -> None: + if canonical_image_id(image_id) is None: + reasons.append(f"image-id-rejected:{image_id}") + return + users = emit(_guest(PROOF_VMID, PRODUCTION_TIMEOUT, ( + "docker", "ps", "-a", "--no-trunc", "--filter", f"ancestor={image_id}", + "--format", "{{.ID}}", + ))) + if users.code != 0: + reasons.append(f"image-users-unknown:{image_id}") + return + if users.stdout.strip(): + reasons.append(f"image-in-use:{image_id}") + return + inspected = emit(_guest(PROOF_VMID, PRODUCTION_TIMEOUT, ( + "docker", "image", "inspect", "--format", IMAGE_INSPECT_FORMAT, image_id, + ))) + if inspected.code != 0 or not image_owned(inspected.stdout, run_id, image_id): + reasons.append(f"image-not-owned:{image_id}") + return + removed = emit(_guest(PROOF_VMID, GUEST_TIMEOUT, ("docker", "image", "rm", image_id))) + if removed.code != 0: + reasons.append(f"image-rm-failed:{image_id}") + + +def _status_after_shutdown(emit) -> str | None: + """One status read. qm shutdown --timeout 90 already waited. No second shutdown.""" + result = emit(("qm", "status", PROOF_VMID)) + if result.code != 0: + return None + return parse_qm_status(result.stdout) + + +def _restore_memory(emit, reasons: list[str]) -> bool: + current = emit(("qm", "config", PROOF_VMID)) + parsed = parse_allowlisted_config(current.stdout) if current.code == 0 else None + if parsed is None: + reasons.append("vm130-config-unknown") + return False + if parsed.cores != REQUIRED_CORES or parsed.onboot != REQUIRED_ONBOOT: + reasons.append("vm130-shape-drift") + return False + if parsed.memory_mib == RESTORE_MEMORY_MIB: + return True + if parsed.memory_mib != TEST_MEMORY_MIB: + reasons.append("vm130-memory-drift") + return False + stopped = emit(("qm", "status", PROOF_VMID)) + if stopped.code != 0 or parse_qm_status(stopped.stdout) != "stopped": + reasons.append("vm130-not-stopped-before-restore") + return False + emit(("qm", "set", PROOF_VMID, "--memory", str(RESTORE_MEMORY_MIB))) + checked = emit(("qm", "config", PROOF_VMID)) + again = parse_allowlisted_config(checked.stdout) if checked.code == 0 else None + if again is None or again.memory_mib != RESTORE_MEMORY_MIB: + reasons.append("memory-restore-unconfirmed") + return False + if again.cores != REQUIRED_CORES or again.onboot != REQUIRED_ONBOOT: + reasons.append("vm130-shape-drift") + return False + return True + + +def _confirm_production(emit, config: GuardConfig, reasons: list[str]) -> bool: + ok = True + status = emit(("qm", "status", PRODUCTION_VMID)) + if status.code != 0 or parse_qm_status(status.stdout) != "running": + reasons.append("vm100-not-running-after-cleanup") + ok = False + nod = emit(NOD_CURL) + if nod.code != 0 or not nod_health_ok(nod.stdout): + reasons.append("nod-unhealthy-after-cleanup") + ok = False + listed = emit(_guest(PRODUCTION_VMID, PRODUCTION_TIMEOUT, ( + "docker", "ps", "--format", PRODUCTION_PS_FORMAT, + ))) + if listed.code != 0: + reasons.append("production-unknown-after-cleanup") + return False + found = dict(_parse_production_ps(listed.stdout)) + for item in config.baseline: + state = found.get(item.name) + unhealthy = ( + item.expect not in BASELINE_EXPECTATIONS + or state is None + or (item.expect == "healthy" and "(healthy)" not in state) + or (item.expect == "running" and (not state.startswith("Up") or "unhealthy" in state)) + ) + if unhealthy: + reasons.append(f"production-unhealthy-after-cleanup:{item.name}") + ok = False + return ok + + +def _parse_ps(text: str) -> tuple[ListedContainer, ...]: + listed = [] + for line in text.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + if len(parts) != 3: + continue + listed.append(ListedContainer(parts[0], parts[1], parts[2])) + return tuple(listed) + + +def _parse_production_ps(text: str) -> tuple[tuple[str, str], ...]: + rows = [] + for line in text.splitlines(): + if "\t" not in line: + continue + name, status = line.split("\t", 1) + rows.append((name, status)) + return tuple(rows) + + +def image_owned(text: str, run_id: str, image_id: str) -> bool: + """Owned only when id, label, and every tag are this run's. Shared tags stay.""" + parts = text.strip().split("\t") + if len(parts) != 3 or parts[0] != image_id or canonical_image_id(image_id) is None: + return False + try: + tags = json.loads(parts[1]) + labels = json.loads(parts[2]) + except json.JSONDecodeError: + return False + if not isinstance(tags, list) or not tags or not isinstance(labels, dict): + return False + if labels.get(LABEL_KEY) != run_id: + return False + allowed = run_tags(run_id) + return set(tags) <= allowed + + +def _looks_secret(text: str) -> bool: + lowered = text.lower() + return any(marker.lower() in lowered for marker in SECRET_MARKERS) + + +def _guest(vmid: str, timeout: str, inner: tuple[str, ...]) -> tuple[str, ...]: + return ("qm", "guest", "exec", vmid, "--timeout", timeout, "--", *inner) + + +def rollback_command_allowed(argv: tuple[str, ...], run_id: str) -> None: + require_run_id(run_id) + if argv == ("qm", "status", PROOF_VMID) or argv == ("qm", "status", PRODUCTION_VMID): + return + if argv == ("qm", "config", PROOF_VMID): + return + if argv == ("qm", "shutdown", PROOF_VMID, "--timeout", SHUTDOWN_TIMEOUT): + return + if argv == ("qm", "set", PROOF_VMID, "--memory", str(RESTORE_MEMORY_MIB)): + return + if argv == NOD_CURL: + return + if argv[:6] == ("qm", "guest", "exec", PROOF_VMID, "--timeout", GUEST_TIMEOUT) and argv[6:7] == ("--",): + _allow_proof_mutation(argv[7:], run_id) + return + if argv[:6] == ("qm", "guest", "exec", PROOF_VMID, "--timeout", PRODUCTION_TIMEOUT) and argv[6:7] == ("--",): + _allow_proof_read(argv[7:]) + return + if argv[:6] == ("qm", "guest", "exec", PRODUCTION_VMID, "--timeout", PRODUCTION_TIMEOUT) and argv[6:7] == ("--",): + if argv[7:] == ("docker", "ps", "--format", PRODUCTION_PS_FORMAT): + return + raise GuardRefusal("rollback command is outside the owned-guest allowlist") + + +def _allow_proof_mutation(inner: tuple[str, ...], run_id: str) -> None: + del run_id + if inner == ("docker", "ps", "-a", "--no-trunc", "--format", PROOF_PS_FORMAT): + return + if len(inner) == 5 and inner[:4] == ("docker", "stop", "--time", "10") and canonical_container_id(inner[4]): + return + if len(inner) == 3 and inner[:2] == ("docker", "rm") and canonical_container_id(inner[2]): + return + if len(inner) == 4 and inner[:3] == ("docker", "image", "rm") and canonical_image_id(inner[3]): + return + raise GuardRefusal("proof guest mutation is outside the owned-container allowlist") + + +def _allow_proof_read(inner: tuple[str, ...]) -> None: + if ( + len(inner) == 8 + and inner[:5] == ("docker", "ps", "-a", "--no-trunc", "--filter") + and inner[5].startswith("ancestor=") + and canonical_image_id(inner[5][len("ancestor="):]) + and inner[6:] == ("--format", "{{.ID}}") + ): + return + if ( + len(inner) == 6 + and inner[:5] == ("docker", "image", "inspect", "--format", IMAGE_INSPECT_FORMAT) + and canonical_image_id(inner[5]) + ): + return + raise GuardRefusal("proof guest read is outside the metadata allowlist") + + +def start_command_allowed(argv: tuple[str, ...]) -> None: + if argv in { + ("qm", "set", PROOF_VMID, "--memory", str(TEST_MEMORY_MIB)), + ("qm", "start", PROOF_VMID), + }: + return + raise GuardRefusal("start command is outside the stopped-VM-130 allowlist") + + +def onebox_build_argv(run_id: str) -> tuple[str, ...]: + require_run_id(run_id) + return ( + "docker", "build", "--label", f"{LABEL_KEY}={run_id}", + "-f", "deploy/docker/Dockerfile", + "-t", f"toolstack-vmproof:{run_id}", + ".", + ) + + +def echo_build_argv(run_id: str, dockerfile: str) -> tuple[str, ...]: + require_run_id(run_id) + if dockerfile not in {"tools/echo_mcp/Dockerfile", "tools/echo_api/Dockerfile"}: + raise GuardRefusal("echo build is not one of the two bundled Dockerfiles") + name = "echo-mcp" if dockerfile.endswith("echo_mcp/Dockerfile") else "echo-api" + return ( + "docker", "build", "--label", f"{LABEL_KEY}={run_id}", + "-f", dockerfile, "-t", f"toolstack-vmproof-{name}:{run_id}", ".", + ) + + +def content_check_argv(run_id: str, image: str) -> tuple[str, ...]: + """Create, do not run. The id is recorded before start; no --rm race.""" + require_run_id(run_id) + _image_ref(image, run_id) + return ( + "docker", "create", "--network", "none", + "--label", f"{LABEL_KEY}={run_id}", + "--entrypoint", "python3", image, "-c", + "import sps, toolstack_forwarder, admin, shutil; print(shutil.which('admin'))", + ) + + +def set_password_argv(run_id: str, image: str, data_dir: str, proof_root: str) -> tuple[str, ...]: + require_run_id(run_id) + _image_ref(image, run_id) + _contained(data_dir, proof_root) + return ( + "docker", "create", "-i", "--network", "none", + "--label", f"{LABEL_KEY}={run_id}", + "--mount", f"type=bind,src={data_dir},dst=/data", + "--entrypoint", "admin", image, "set-password", + ) + + +def admin_serve_argv(run_id: str, image: str, data_dir: str, proof_root: str, port: int) -> tuple[str, ...]: + require_run_id(run_id) + _image_ref(image, run_id) + _contained(data_dir, proof_root) + if not 1024 <= port <= 65535: + raise GuardRefusal("admin publish port is not dynamic") + return ( + "docker", "create", "--restart", "no", "--network", "bridge", + "--name", f"toolstack-vmproof-{run_id}", + "--label", f"{LABEL_KEY}={run_id}", + "--publish", f"127.0.0.1:{port}:8780", + "--mount", f"type=bind,src={data_dir},dst=/data", + image, + ) + + +def start_recorded_argv(container_id: str) -> tuple[str, ...]: + if canonical_container_id(container_id) is None: + raise GuardRefusal("refusing to start an id that is not 64 hex characters") + return ("docker", "start", container_id) + + +def start_attached_argv(container_id: str) -> tuple[str, ...]: + if canonical_container_id(container_id) is None: + raise GuardRefusal("refusing to attach an id that is not 64 hex characters") + return ("docker", "start", "-a", "-i", container_id) + + +def loopback_probe_argv(port: int, path: str) -> tuple[str, ...]: + if path != "/login" or not 1024 <= port <= 65535: + raise GuardRefusal("probe is not the admin login page on a dynamic loopback port") + return _guest(PROOF_VMID, PRODUCTION_TIMEOUT, ( + "curl", "--noproxy", "*", "-fsS", "--connect-timeout", "2", "--max-time", "5", + f"http://127.0.0.1:{port}{path}", + )) + + +def agent_ready_argv() -> tuple[str, ...]: + return _guest(PROOF_VMID, GUEST_TIMEOUT, ("/bin/true",)) + + +def production_ps_argv() -> tuple[str, ...]: + return _guest(PRODUCTION_VMID, PRODUCTION_TIMEOUT, ( + "docker", "ps", "--format", PRODUCTION_PS_FORMAT, + )) + + +def unit_list_argv() -> tuple[str, ...]: + return _guest(PROOF_VMID, "20", ( + "systemctl", "list-units", "--type=service", "--state=running", + "--no-pager", "--plain", "--no-legend", + )) + + +def listen_argv() -> tuple[str, ...]: + return _guest(PROOF_VMID, PRODUCTION_TIMEOUT, ("ss", "-H", "-lnt")) + + +def process_meta_argv() -> tuple[str, ...]: + return _guest(PROOF_VMID, PRODUCTION_TIMEOUT, ("ps", "-eo", "pid,ppid,user,stat,comm")) + + +def _image_ref(image: str, run_id: str) -> None: + if not image.startswith("toolstack-vmproof") or run_id not in image or ":" not in image: + raise GuardRefusal("image ref is not this run's recorded tag") + + +def _contained(path: str, root: str) -> None: + if not path.startswith("/") or not root.startswith("/") or ".." in path.split("/"): + raise GuardRefusal("proof path is not an absolute child of the proof root") + root_parts = [part for part in root.split("/") if part] + path_parts = [part for part in path.split("/") if part] + if path_parts[:len(root_parts)] != root_parts or len(path_parts) <= len(root_parts): + raise GuardRefusal("proof path escapes the proof root") + + +def completed_from_guest(payload: str) -> Completed: + parsed = guest_exec_stdout(payload) + if parsed is None: + return Completed(1, "") + return parsed + + +def main(argv: list[str] | None = None) -> int: + del argv + print("guard library; host execution refused", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release/vm_proof_inventory.py b/tests/release/vm_proof_inventory.py new file mode 100644 index 0000000..959ff55 --- /dev/null +++ b/tests/release/vm_proof_inventory.py @@ -0,0 +1,248 @@ +"""Stage-1 inventory for the VM Toolstack copy. No checkout is modified. + +Status, commit hashes, and path names only. Patch text, secrets, and .env +files are never requested. A dirty preexisting checkout is preserved. The +default update is a separate temporary checkout of the tested commit. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# Historical stage-1 fixture only. Live Source.load takes explicit commit/SHA. +TESTED_COMMIT = "5ec463bc53c051255d6126e0e87329fad2207096" +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +# sha256 of `git archive --format=tar` of TESTED_COMMIT. Tracked tree only. +TESTED_ARCHIVE_SHA256 = "817da51460438e431c301d0691bb2165df85dcb519e614cdecd2f22aa0729764" + +UNEXECUTED_GATES = ( + "deploy/install.sh", + "systemctl enable", + "systemctl start", + "useradd", + "docker compose up", + "Docker+SPS runner certification", + "process SPS TLS/restart/recovery on this VM", + "qm stop or poweroff", + "memory restore while a guest is running", + "mutation of VM 100 or any other guest", +) + +STAGE2_STEPS = ( + "resample-baseline", + "refuse-start-unless-stopped-8192-and-headroom", + "start-guard-before-resize", + "qm-set-4096-while-stopped", + "qm-start-130", + "wait-guest-agent-or-rollback", + "list-units-ports-process-metadata", + "inventory-status-hash-paths", + "archive-tested-commit", + "one-heavy-onebox-build", + "image-content-and-fake-admin-start", + "echo-builds-only-if-gate-open", + "offline-unit-parse", + "owned-cleanup-graceful-shutdown-restore-8192", + "confirm-nod-and-stop", +) + + +class InventoryRefusal(Exception): + """An update or removal that would touch owner work or escape the proof root.""" + + +@dataclass(frozen=True) +class CheckoutInventory: + path: str + known: bool + head: str | None + branch: str | None + dirty_paths: tuple[str, ...] + + +@dataclass(frozen=True) +class UpdateRoute: + kind: str + reason: str + + +@dataclass(frozen=True) +class UnitContract: + read_write: tuple[str, ...] + state_directory: str | None + logs_directory: str | None + xdg_state: str | None + xdg_config: str | None + no_new_privileges: bool + environment_files: tuple[str, ...] + runner: str | None + mentions_docker_socket: bool + mentions_sudo: bool + + +def inventory_commands(path: str) -> tuple[tuple[str, ...], ...]: + _absolute(path) + git = ("git", "-C", path) + return ( + git + ("rev-parse", "--show-toplevel"), + git + ("rev-parse", "HEAD"), + git + ("rev-parse", "--abbrev-ref", "HEAD"), + git + ("status", "--porcelain=v1", "-uall"), + git + ("submodule", "status"), + ) + + +def metadata_only(commands: tuple[tuple[str, ...], ...]) -> bool: + forbidden = {"diff", "show", "clean", "reset", "checkout", "stash", "apply", "am", "cat"} + for argv in commands: + if any(part in forbidden for part in argv): + return False + if any(part.endswith(".env") or part == ".env" for part in argv): + return False + return True + + +def parse_porcelain(text: str) -> tuple[str, ...]: + paths = [] + for line in text.splitlines(): + if len(line) < 4: + continue + paths.append(line[3:]) + return tuple(paths) + + +def inventory_from(path: str, head: str | None, branch: str | None, porcelain: str, inside: bool) -> CheckoutInventory: + if not inside or head is None or not COMMIT_RE.fullmatch(head): + return CheckoutInventory(path, False, None, None, ()) + return CheckoutInventory(path, True, head, branch, parse_porcelain(porcelain)) + + +def update_route( + inventory: CheckoutInventory, + *, + rollback_bundle_saved: bool, + parent_approved_inplace: bool, +) -> UpdateRoute: + if not inventory.known: + return UpdateRoute("refuse", "unknown-checkout") + if inventory.dirty_paths: + return UpdateRoute("temp-checkout", "dirty-preserve") + if not rollback_bundle_saved and parent_approved_inplace: + return UpdateRoute("refuse", "rollback-not-recorded") + return UpdateRoute("temp-checkout", "temp-extract-only") + + +def archive_command(source: str, archive_path: str, commit: str) -> tuple[str, ...]: + """Tracked tree of the exact commit. No branch history and no untracked files.""" + if not COMMIT_RE.fullmatch(commit): + raise InventoryRefusal("archive requires an explicit full commit") + _absolute(source) + _absolute(archive_path) + if not archive_path.endswith(".tar") or archive_path.endswith("/.tar"): + raise InventoryRefusal("archive path must name a tar file") + return ("git", "-C", source, "archive", "--format=tar", f"--output={archive_path}", commit) + + +def archive_checksum_command(archive_path: str) -> tuple[str, ...]: + _absolute(archive_path) + return ("sha256sum", "--", archive_path) + + +def accept_archive_sha256(hex_text: str) -> bool: + return hex_text.strip().split()[0] == TESTED_ARCHIVE_SHA256 + + +def temp_checkout_commands( + archive: str, + dest: str, + proof_root: str, + preexisting: str, +) -> tuple[tuple[str, ...], ...]: + _contained(dest, proof_root) + if _overlaps(dest, preexisting): + raise InventoryRefusal("temp checkout overlaps the preexisting checkout") + if not archive.endswith(".tar"): + raise InventoryRefusal("temp checkout source is not the tested tar") + return ( + ("mkdir", "-m", "0700", dest), + ("tar", "-xf", archive, "-C", dest), + ) + + +def in_place_commands( + route: UpdateRoute, + checkout: str, + bundle: str, + commit: str, +) -> tuple[tuple[str, ...], ...]: + del route, checkout, bundle, commit + raise InventoryRefusal("preexisting checkout is not an update target; extract the tested tar") + + +def removal_command(path: str, proof_root: str, preexisting: str) -> tuple[str, ...]: + _contained(path, proof_root) + if _overlaps(path, preexisting): + raise InventoryRefusal("refusing to remove a preexisting checkout") + return ("rm", "-rf", path) + + +def accept_clone_head(head: str) -> bool: + return head.strip() == TESTED_COMMIT + + +def parse_unit(text: str) -> UnitContract: + read_write: list[str] = [] + environment_files: list[str] = [] + operative: list[str] = [] + state = logs = xdg_state = xdg_config = runner = None + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + operative.append(line) + if line.startswith("ReadWritePaths="): + read_write.extend(line.split("=", 1)[1].split()) + elif line.startswith("StateDirectory="): + state = line.split("=", 1)[1] + elif line.startswith("LogsDirectory="): + logs = line.split("=", 1)[1] + elif line.startswith("EnvironmentFile="): + environment_files.append(line.split("=", 1)[1]) + elif line.startswith("Environment="): + value = line.split("=", 1)[1] + if value.startswith("XDG_STATE_HOME="): + xdg_state = value.split("=", 1)[1] + elif value.startswith("XDG_CONFIG_HOME="): + xdg_config = value.split("=", 1)[1] + elif value.startswith("TOOLSTACK_RUNNER="): + runner = value.split("=", 1)[1] + body = "\n".join(operative).lower() + return UnitContract( + tuple(read_write), state, logs, xdg_state, xdg_config, + "NoNewPrivileges=true" in operative, tuple(environment_files), runner, + "docker.sock" in body, "sudo" in body, + ) + + +def _absolute(path: str) -> None: + if not path.startswith("/") or ".." in path.split("/"): + raise InventoryRefusal("path is not absolute") + + +def _parts(path: str) -> tuple[str, ...]: + _absolute(path) + return tuple(part for part in path.split("/") if part) + + +def _contained(path: str, root: str) -> None: + child = _parts(path) + parent = _parts(root) + if child[:len(parent)] != parent or len(child) <= len(parent): + raise InventoryRefusal("path escapes the proof root") + + +def _overlaps(left: str, right: str) -> bool: + a = _parts(left) + b = _parts(right) + return a == b or a[:len(b)] == b or b[:len(a)] == a diff --git a/tests/release/vm_proof_linux.py b/tests/release/vm_proof_linux.py new file mode 100644 index 0000000..fd5f648 --- /dev/null +++ b/tests/release/vm_proof_linux.py @@ -0,0 +1,686 @@ +"""Linux/PVE binding for the finite QGA source/Docker proof. + +Default/help is inert. `controller --live` creates an ephemeral detached guard; +the foreground controller owns admission/lease. No services are installed. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import dataclasses +import fcntl +import json +import os +from pathlib import Path +import selectors +import signal +import stat +import subprocess +import sys +import tempfile +import time + +import vm_proof_guard as g +from vm_proof_adapter import ( + StopLatch, _fresh, parse_starttime, prepare_run_root, run_guard, timeout_for, +) +from vm_proof_inventory import inventory_commands + +BINARIES = {"qm": "/usr/sbin/qm", "free": "/usr/bin/free", "curl": "/usr/bin/curl"} +LIMIT = 65536 +MAX_RECORDS = 16 +FILE_IDENTITY = ("st_dev", "st_ino", "st_mode", "st_uid", "st_nlink", "st_size", "st_mtime_ns", "st_ctime_ns") + + +def identity(pid: int) -> int | None: + if pid <= 1: + return None + try: + text = Path(f"/proc/{pid}/stat").read_text() + if text[text.rfind(")") + 2:].split()[0] == "Z": + return None + return parse_starttime(text) + except (OSError, IndexError): + return None + + +def open_directory(path: str) -> int: + """Walk every ancestor without following symlinks, retaining an fd.""" + if not path.startswith("/") or any(p in (".", "..") for p in path.split("/")): + raise g.GuardRefusal("absolute canonical directory required") + fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + for part in filter(None, path.split("/")): + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = child + return fd + except BaseException: + os.close(fd) + raise + + +class PrivateFiles: + def __init__(self, root: str): + self.fd = open_directory(root) + info = os.fstat(self.fd) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + self.close() + raise g.GuardRefusal("run directory must be owned and 0700") + + def close(self): + os.close(self.fd) + + def file(self, name, flags): + if name not in {"config", "state", "ready", "pid", "lease", "admit", "controller", "log", "result", "inventory", "owner-inventory", "proof-evidence"}: + raise g.GuardRefusal("unknown private file") + fd = os.open(name, flags | os.O_NOFOLLOW, 0o600, dir_fd=self.fd) + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600: + os.close(fd) + raise g.GuardRefusal("unsafe private file") + return fd + + def create(self, name, data): + if name in {"ready", "admit"}: + return self._publish_new(name, data) + fd = self.file(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL) + try: + self._write(fd, data) + finally: + os.close(fd) + + def read(self, name): + if name in {"lease", "ready", "admit"}: + with self._publication_lock(exclusive=False): + return self._read(name, snapshot=True) + return self._read(name) + + def _read(self, name, snapshot=False): + fd = self.file(name, os.O_RDONLY) + try: + fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + before = os.fstat(fd) + data = os.read(fd, LIMIT + 1) + if len(data) > LIMIT: + raise g.GuardRefusal("private record too large") + if snapshot: + self._same_file(name, before) + if any(getattr(before, key) != getattr(os.fstat(fd), key) for key in FILE_IDENTITY): + raise g.GuardRefusal("private snapshot changed") + return data.decode() + finally: + os.close(fd) + + def _same_file(self, name, info): + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid() + or stat.S_IMODE(info.st_mode) != 0o600): + raise g.GuardRefusal("unsafe private snapshot") + current = os.stat(name, dir_fd=self.fd, follow_symlinks=False) + if any(getattr(current, key) != getattr(info, key) for key in FILE_IDENTITY): + raise g.GuardRefusal("private path changed") + + @contextmanager + def _publication_lock(self, *, exclusive): + # Separate open description per operation: threads/instances cannot + # accidentally upgrade or unlock another reader's flock. No new file. + fd = os.open(".", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=self.fd) + try: + info, root = os.fstat(fd), os.fstat(self.fd) + if ((info.st_dev, info.st_ino) != (root.st_dev, root.st_ino) + or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700): + raise g.GuardRefusal("unsafe publication directory") + # Only lock acquisition retries, never an effect or invalid record. + # A stuck publisher fails closed within 20 attempts/~0.1s. + for attempt in range(20): + try: + fcntl.flock(fd, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB) + break + except BlockingIOError: + if attempt == 19: + raise + time.sleep(0.005) + yield + finally: + os.close(fd) + + def _remove_staging(self, name, identity): + if identity is None: + return + try: + info = os.stat(name, dir_fd=self.fd, follow_symlinks=False) + if ((info.st_dev, info.st_ino) == identity and stat.S_ISREG(info.st_mode) + and info.st_uid == os.getuid() and info.st_nlink == 1 + and stat.S_IMODE(info.st_mode) == 0o600): + os.unlink(name, dir_fd=self.fd) + except FileNotFoundError: + pass + + def _publish_new(self, target, data): + # Only ready/admit are read concurrently with first publication. The + # controller and initial lease are complete before guard spawn; pid is + # written before ready and is not polled by the controller. + name = "." + target + "-next" + fd, identity = None, None + try: + fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=self.fd) + info = os.fstat(fd) + identity = (info.st_dev, info.st_ino) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600): + raise g.GuardRefusal("unsafe publication staging file") + self._write(fd, data) + with self._publication_lock(exclusive=True): + self._same_file(name, os.fstat(fd)) + # link is an atomic absent-only claim, including against a + # noncooperating creator. Readers cannot observe nlink=2. + os.link(name, target, src_dir_fd=self.fd, dst_dir_fd=self.fd, follow_symlinks=False) + linked = os.fstat(fd) + if (linked.st_nlink != 2 or not stat.S_ISREG(linked.st_mode) + or linked.st_uid != os.getuid() or stat.S_IMODE(linked.st_mode) != 0o600): + raise g.GuardRefusal("unsafe publication claim") + for path in (name, target): + current = os.stat(path, dir_fd=self.fd, follow_symlinks=False) + if any(getattr(current, key) != getattr(linked, key) for key in FILE_IDENTITY): + raise g.GuardRefusal("publication claim changed") + os.unlink(name, dir_fd=self.fd) + os.fsync(self.fd) + finally: + if fd is not None: + os.close(fd) + self._remove_staging(name, identity) + + @staticmethod + def _write(fd, data): + payload = data.encode() + if len(payload) > LIMIT: + raise g.GuardRefusal("private record too large") + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + os.lseek(fd, 0, os.SEEK_SET) + os.ftruncate(fd, 0) + while payload: + payload = payload[os.write(fd, payload):] + os.fsync(fd) + fcntl.flock(fd, fcntl.LOCK_UN) + + def publish_lease(self, record): + """Controller-only replacement, serialized with complete snapshot reads.""" + owner = json.loads(self.read("controller")) + if (type(owner.get("pid")) is not int or owner["pid"] <= 1 + or type(owner.get("start")) is not int or owner["start"] <= 0): + raise g.GuardRefusal("invalid controller record") + old_fd = self.file("lease", os.O_RDONLY) + temp_fd = None + temp_identity = None + name = ".lease-next" + try: + old_info = os.fstat(old_fd) + old = json.loads(os.read(old_fd, LIMIT + 1)) + for value in (old, record): + if (set(value) != {"pid", "start", "run", "monotonic"} + or value["pid"] != owner["pid"] or value["start"] != owner["start"] + or value["run"] != record["run"] + or not g._finite_nonnegative(value["monotonic"])): + raise g.GuardRefusal("invalid lease publication") + g.require_run_id(record["run"]) + if record["monotonic"] < old["monotonic"]: + raise g.GuardRefusal("lease clock moved backwards") + temp_fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=self.fd) + info = os.fstat(temp_fd) + temp_identity = (info.st_dev, info.st_ino) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600): + raise g.GuardRefusal("unsafe lease temporary file") + self._write(temp_fd, json.dumps(record)) + with self._publication_lock(exclusive=True): + self._same_file("lease", old_info) + self._same_file(name, os.fstat(temp_fd)) + os.replace(name, "lease", src_dir_fd=self.fd, dst_dir_fd=self.fd) + os.fsync(self.fd) + finally: + os.close(old_fd) + if temp_fd is not None: + os.close(temp_fd) + self._remove_staging(name, temp_identity) + + +class LinuxSubprocessIO: + """One serialized command stream; each wait also checks memory/lease/time. + + Guest effects come only from the fixed GuestProof sequence, with a single + recorded QGA async job at a time. No arbitrary shell/callback is accepted. + Popen/identity/clock are injected by local tests, never by CLI configuration. + """ + def __init__(self, root, config, latch, checkout, *, popen=subprocess.Popen, + observe=identity, clock=time.monotonic, source=None): + self.root, self.config, self.latch, self.checkout = root, config, latch, checkout + self.files = PrivateFiles(root) + self.popen, self.observe, self.clock = popen, observe, clock + self.deadline = clock() + 2400 + self.cleanup = False + self.cleanup_deadline = None + self.owned_resize = self.owned_start = False + self.shutdown_sent = False + self.active = {} + self.unresolved_child = False + self.full_samples = 0 + self.fast_probe = None + self.pressure_state = g.PressureState() + self.lease_observation = {"controller_identity_valid": None, "reason": "not-sampled"} + self.proof = None + if source is not None: + from vm_proof_driver import GuestProof + self.proof = GuestProof(self, source) + self.files.create("state", "{}") + self.state_fd = self.files.file("state", os.O_WRONLY) + self.files.create("log", "bounded-qga-proof\n") + self.files.create("config", json.dumps(dataclasses.asdict(config))) + + def close(self): + os.close(self.state_fd) + self.files.close() + + def identity(self): + pid = os.getpid() + start = self.observe(pid) + if start is None: + raise g.GuardRefusal("guard identity unknown") + return pid, start + + def children(self): + return tuple(self.active) + + def records(self): + return g.ProofRecords(self.config.run_id, tuple(self.proof.containers) if self.proof else (), + tuple(self.proof.images) if self.proof else ()) + + def write_new(self, name, text): + self.files.create(name, text) + + def checkpoint(self): + self.files._write(self.state_fd, json.dumps({ + "owned_resize": self.owned_resize, "owned_start": self.owned_start, + "shutdown_sent": self.shutdown_sent, "children": list(self.active), + "containers": [], "images": [], "stop": self.latch.reason, + "baseline_vm130": dataclasses.asdict(self.baseline_snapshot) if hasattr(self, "baseline_snapshot") else None, + "proof": self.proof.state() if self.proof else None, + "full_samples": self.full_samples, + "fast_probe": self.fast_probe, + "pressure": self.pressure_state.snapshot(), + })) + + def note_sample(self): + self.full_samples += 1 + if self.full_samples > 480: + self.latch.trip("sample-budget") + raise g.GuardRefusal("full sample budget exhausted") + + def read(self, path): + if path not in {"/proc/meminfo", "/proc/pressure/memory"}: + raise g.GuardRefusal("not a permitted proc probe") + try: + with open(path) as source: + return source.read(LIMIT) + except OSError: + return None + + def lease_age(self): + self.lease_observation = {"controller_identity_valid": None, "reason": "controller-unknown"} + try: + owner = json.loads(self.files.read("controller")) + if (type(owner["pid"]) is not int or type(owner["start"]) is not int + or self.observe(owner["pid"]) != owner["start"]): + self.lease_observation = {"controller_identity_valid": False, "reason": "controller-dead-or-reused"} + return None + self.lease_observation = {"controller_identity_valid": True, "reason": "lease-unknown"} + lease = json.loads(self.files.read("lease")) + if lease["run"] != self.config.run_id or lease["pid"] != owner["pid"] or lease["start"] != owner["start"]: + return None + age = self.clock() - lease["monotonic"] + self.lease_observation["reason"] = "valid" if g._finite_nonnegative(age) else "lease-clock-invalid" + return age if g._finite_nonnegative(age) else None + except BlockingIOError: + self.lease_observation["reason"] = "publication-locked" + return None + except (OSError, ValueError, KeyError, TypeError, g.GuardRefusal): + return None + + def admit_text(self): + try: + return self.files.read("admit").strip() + except OSError: + return None + + def check(self): + if self.cleanup: + if self.clock() >= self.cleanup_deadline: + raise g.GuardRefusal("cleanup budget exhausted") + return + self.latch.before_effect() + mem = g.parse_memavailable_kib(self.read("/proc/meminfo") or "") + psi = g.parse_pressure(self.read("/proc/pressure/memory") or "") + age = self.lease_age() + reasons = [] + if mem is None or mem < 1536 * 1024: + reasons.append("memory-unknown" if mem is None else "memory-low") + pressure_trip = self.pressure_state.observe(self.clock(), psi) + reasons.extend(pressure_trip.reasons) + if age is None or age > 90: + reasons.append(self.lease_observation["reason"] if age is None else "lease-stale") + if self.clock() >= self.deadline: + reasons.append("deadline") + if reasons: + self.fast_probe = {"memory_kib": mem, "psi_full_avg10": psi.full_avg10 if psi else None, + "psi_full_avg60": psi.full_avg60 if psi else None, + "lease_age": age, **self.lease_observation, "reasons": reasons} + self.latch.trip("fast-probe-or-lease-trip") + self.checkpoint() + raise g.GuardRefusal("fast-probe-or-lease-trip") + self.checkpoint() + + def begin_cleanup(self): + self.latch.trip("cleanup") + self.cleanup = True + if self.cleanup_deadline is None: + self.cleanup_deadline = self.clock() + 300 + + def allowed(self, argv, input_data=None): + if self.proof is not None and self.proof.permit(argv, input_data): + return + if input_data is not None: + raise g.GuardRefusal("stdin is only admitted by the fixed proof receiver") + if argv in {("free", "-m"), g.NOD_CURL, ("qm", "status", "100"), + ("qm", "status", "130"), ("qm", "config", "130"), g.production_ps_argv()}: + return + if argv == ("qm", "set", "130", "--memory", "4096"): + if self.owned_resize and not self.cleanup and self.admit_text() == self.config.run_id: + return + if argv == ("qm", "start", "130"): + if (self.owned_start and self.pressure_state.boot_monotonic is None + and not self.cleanup and self.admit_text() == self.config.run_id): + return + if argv == ("qm", "set", "130", "--memory", "8192"): + if self.cleanup and self.owned_resize: + return + if argv == ("qm", "shutdown", "130", "--timeout", "90"): + if self.cleanup and self.owned_start and not self.shutdown_sent: + return + if self.owned_start and argv in {g.agent_ready_argv(), g.unit_list_argv(), g.listen_argv(), g.process_meta_argv()}: + return + if self.owned_start and argv == g._guest("130", "30", ( + "docker", "ps", "-a", "--no-trunc", "--format", g.PROOF_PS_FORMAT)): + return + if self.owned_start and self.checkout != "discover" and argv in {g._guest("130", "15", c) for c in inventory_commands(self.checkout)}: + return + raise g.GuardRefusal("command not in boot-metadata allowlist") + + def cancel(self, child, start): + # Popen child + exact Linux starttime; never a guessed PID or group kill. + for sig in (signal.SIGTERM, signal.SIGKILL): + if child.poll() is not None or self.observe(child.pid) != start: + return + child.send_signal(sig) + try: + child.wait(timeout=2) + return + except subprocess.TimeoutExpired: + pass + self.unresolved_child = child.poll() is None + + def cancel_all(self): + for (pid, start), child in tuple(self.active.items()): + self.cancel(child, start) + if child.poll() is not None: + self.active.pop((pid, start), None) + + def run(self, argv, timeout, input_data=None): + argv = tuple(argv) + self.allowed(argv, input_data) + if timeout != timeout_for(argv): + raise g.GuardRefusal("timeout differs from command bound") + self.check() + expected = { + ("qm", "set", "130", "--memory", "4096"): ("stopped", 8192), + ("qm", "start", "130"): ("stopped", 4096), + ("qm", "set", "130", "--memory", "8192"): ("stopped", 4096), + ("qm", "shutdown", "130", "--timeout", "90"): ("running", 4096), + }.get(argv) + if expected: + status = self.run(("qm", "status", "130"), 15) + config = self.run(("qm", "config", "130"), 15) + shape = g.parse_allowlisted_config(config.stdout) if config.code == 0 else None + if (status.code or g.parse_qm_status(status.stdout) != expected[0] + or shape is None or (shape.memory_mib, shape.cores, shape.onboot) != (expected[1], 2, 0)): + # Intent is not ownership when the final preflight refuses to + # issue the command. Do not undo somebody else's transition. + if argv == ("qm", "set", "130", "--memory", "4096"): + self.owned_resize = False + if argv == ("qm", "start", "130"): + self.owned_start = False + self.checkpoint() + raise g.GuardRefusal("effect refused: VM ownership/shape drift") + self.check() + if argv == ("qm", "start", "130"): + # Final stopped/shape/health preflight has passed. Journal intent + # before spawn so checks DURING qm start share this finite grace. + # Failure/lost reply never resets it and never permits another boot. + self.pressure_state.begin_boot(self.clock()) + self.checkpoint() + if argv[:2] == ("qm", "shutdown"): + self.shutdown_sent = True + self.checkpoint() + transfer = None + try: + if input_data is not None: + if not isinstance(input_data, bytes) or len(input_data) > 1024 * 1024: + raise g.GuardRefusal("QGA stdin exceeds bound") + transfer = tempfile.TemporaryFile(dir=self.root) + transfer.write(input_data) + transfer.seek(0) + child = self.popen((BINARIES[argv[0]], *argv[1:]), stdin=transfer if transfer else subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, + env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "HOME": self.root}) + finally: + if transfer: + transfer.close() + start = self.observe(child.pid) + if start is None and child.poll() is not None: + # A very short command may already be reaped. No cancellation is + # needed and no PID is signalled; still drain/validate bounded output. + start = 0 + if start is None: + # Never signal without identity. Command outcome is unknown; shutdown + # remains the only owned guest fence, with the limitation reported. + self.unresolved_child = True + child.stdout.close() + child.stderr.close() + raise g.GuardRefusal("spawned-child-identity-unknown") + self.active[(child.pid, start)] = child + end = min(self.clock() + timeout, self.cleanup_deadline if self.cleanup else self.deadline) + if self.cleanup and getattr(self, "proof_cleanup_deadline", None) is not None: + end = min(end, self.proof_cleanup_deadline) + streams = selectors.DefaultSelector() + out, err = bytearray(), bytearray() + try: + for stream, target in ((child.stdout, out), (child.stderr, err)): + os.set_blocking(stream.fileno(), False) + streams.register(stream, selectors.EVENT_READ, target) + while streams.get_map() or child.poll() is None: + self.check() + if self.clock() >= end: + raise TimeoutError("command timeout") + for key, _event in streams.select(0.2): + data = os.read(key.fileobj.fileno(), 4096) + if not data: + streams.unregister(key.fileobj) + else: + key.data.extend(data) + if len(out) + len(err) > LIMIT: + raise g.GuardRefusal("command output limit") + code = child.wait(timeout=1) + if code or err: + return g.Completed(1, "") + text = out.decode("utf-8", errors="strict") + if argv[:3] == ("qm", "guest", "exec-status") or (argv[:3] == ("qm", "guest", "exec") and "--synchronous" in argv): + value = json.loads(text) + if not isinstance(value, dict): + return g.Completed(1, "") + return g.Completed(0, text) + if argv[:3] == ("qm", "guest", "exec"): + result = g.completed_from_guest(text) + payload = json.loads(text) + return g.Completed(1, "") if payload.get("err-data") or payload.get("out-truncated") else result + if argv == ("qm", "config", "130"): + text = g.redact_qm_config(text) + return g.Completed(0, text) + except (TimeoutError, OSError, ValueError, g.GuardRefusal): + self.cancel(child, start) + return g.Completed(1, "") + finally: + streams.close() + child.stdout.close() + child.stderr.close() + if child.poll() is not None: + self.active.pop((child.pid, start), None) + + def metadata_proof(self, latch): + # Single finite driver, no independent effect commands or thread races. + inventory = [] + commands = (g.unit_list_argv(), g.listen_argv(), g.process_meta_argv()) + if self.proof is None: + commands += tuple(g._guest("130", "15", c) for c in inventory_commands(self.checkout)) + for argv in commands: + latch.before_effect() + sample, shape = _fresh(self, self.config, 2400 - (self.deadline - self.clock())) + trip = g.assess(sample, self.config) + if trip.rollback or (shape.status, shape.memory_mib, shape.cores, shape.onboot) != ("running", 4096, 2, 0): + latch.trip("metadata-health-or-shape-trip") + raise g.GuardRefusal("metadata-health-or-shape-trip") + result = self.run(argv, timeout_for(argv)) + if result.code: + raise g.GuardRefusal("metadata-inventory-incomplete") + inventory.append({"argv": argv, "metadata": result.stdout}) + self.files.create("inventory", json.dumps(inventory)) + if self.proof is not None: + self.proof.execute() + + +def host_gate(): + if sys.platform != "linux" or os.geteuid() != 0 or not Path("/etc/pve").is_dir(): + raise g.GuardRefusal("execute requires root on Linux PVE") + for path in BINARIES.values(): + if not os.access(path, os.X_OK): + raise g.GuardRefusal("required PVE command unavailable") + + +def baseline_config(args): + entries = [] + for raw in args.baseline: + name, expectation = raw.rsplit(":", 1) + if not name or not all(c.isalnum() or c in "_.-" for c in name) or expectation not in g.BASELINE_EXPECTATIONS: + raise g.GuardRefusal("invalid baseline container") + entries.append(g.BaselineContainer(name, expectation)) + if not entries or len({e.name for e in entries}) != len(entries) or g.BaselineContainer("nod", "healthy") not in entries: + raise g.GuardRefusal("unique complete baseline including nod:healthy required") + return g.GuardConfig(args.run_id, tuple(entries)) + + +def cli(argv=None): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("mode", nargs="?", choices=("controller", "guard")) + p.add_argument("--dry-run", action="store_true") + p.add_argument("--execute", action="store_true") + p.add_argument("--live", action="store_true") + p.add_argument("--run-id") + p.add_argument("--root") + p.add_argument("--baseline", action="append", default=[]) + p.add_argument("--checkout") + p.add_argument("--source-commit") + p.add_argument("--archive-sha256") + p.add_argument("--archive") + args = p.parse_args(argv) + if args.dry_run or not args.execute: + print("dry-run: controller --execute --live --run-id RUN --root /var/tmp/RUN --checkout discover --source-commit COMMIT40 --archive-sha256 SHA64 --archive /PRIVATE/source.tar --baseline nod:healthy [--baseline NAME:healthy|running]; no host command is executed") + return 0 + try: + if not args.live or not args.mode or not args.root or not args.run_id or not args.checkout: + raise g.GuardRefusal("mode/live/run-id/root/checkout/baseline required") + g.require_run_id(args.run_id) + if args.root != "/var/tmp/" + args.run_id: + raise g.GuardRefusal("root must be the absent /var/tmp/RUN leaf") + if args.checkout != "discover" and (not args.checkout.startswith("/") or ".." in args.checkout.split("/")): + raise g.GuardRefusal("known absolute checkout path required") + config = baseline_config(args) + from vm_proof_driver import Source + source = Source.load(args.archive, args.source_commit, args.archive_sha256) + host_gate() + if args.mode == "controller": + return controller(args) + latch = StopLatch() + io = LinuxSubprocessIO(args.root, config, latch, args.checkout, source=source) + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, lambda signum, frame: latch.trip("signal")) + try: + result = run_guard(io, config, latch, time.monotonic, time.sleep) + if io.unresolved_child or io.active: + result = dataclasses.replace(result, ask_parent=True, restored=False, + reasons=(*result.reasons, "owned-child-exit-unconfirmed")) + proof_complete = io.proof is not None and io.proof.completed is True + if not proof_complete: + result = dataclasses.replace(result, ask_parent=True, + reasons=(*result.reasons, "proof-incomplete")) + io.checkpoint() + io.files.create("result", json.dumps(dict(dataclasses.asdict(result), proof_complete=proof_complete))) + return 1 if result.ask_parent or not result.restored else 0 + finally: + io.close() + except (OSError, ValueError, g.GuardRefusal): + print("refused or incomplete; inspect private result; no forced cleanup", file=sys.stderr) + return 2 + + +def controller(args): + # Foreground lease owner, bounded independently of guard. Not a daemonized + # heartbeat loop. Death/PID reuse of this process invalidates the lease. + root = prepare_run_root("/var/tmp", args.run_id) + files = PrivateFiles(root) + pid, start = os.getpid(), identity(os.getpid()) + if start is None: + files.close() + raise g.GuardRefusal("controller identity unknown") + owner = {"pid": pid, "start": start} + files.create("controller", json.dumps(owner)) + files.create("lease", json.dumps(dict(owner, run=args.run_id, monotonic=time.monotonic()))) + cmd = [sys.executable, str(Path(__file__).with_name("vm_proof_adapter.py")), "guard", + "--execute", "--live", "--run-id", args.run_id, "--root", root, "--checkout", args.checkout, + "--source-commit", args.source_commit, "--archive-sha256", args.archive_sha256, "--archive", args.archive] + for entry in args.baseline: + cmd += ["--baseline", entry] + child = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True, + env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "HOME": root}) + guard_start = identity(child.pid) + admitted = False + deadline = time.monotonic() + 2400 + try: + while child.poll() is None and time.monotonic() < deadline: + if guard_start is None or identity(child.pid) != guard_start: + raise g.GuardRefusal("guard identity changed") + if not admitted: + try: + expected = f"{args.run_id}\n{child.pid} {guard_start}\n" + if files.read("ready") != expected: + raise g.GuardRefusal("ready identity mismatch") + files.create("admit", args.run_id) + admitted = True + except FileNotFoundError: + pass + files.publish_lease(dict(owner, run=args.run_id, monotonic=time.monotonic())) + time.sleep(2) + # Never signal a possibly reused pid. Stopping lease is sufficient; guard + # has its own 40 minute bound plus at most 300 seconds cleanup. + return child.poll() if child.poll() is not None else 1 + finally: + files.close() diff --git a/tests/release/vm_proof_payload.py b/tests/release/vm_proof_payload.py new file mode 100644 index 0000000..67908fe --- /dev/null +++ b/tests/release/vm_proof_payload.py @@ -0,0 +1,707 @@ +"""Fixed stdlib guest receiver/worker. Also used locally to validate a git archive. + +Only guest_main executes actions. Importing this module does not touch a host. +The reviewed source, never an input file's Python, is sent as python3 -c via QGA. +""" +import base64 +import hashlib +import io +import json +import os +import pwd +import grp +from pathlib import Path, PurePosixPath +import re +import selectors +import shutil +import signal +import stat +import subprocess +import sys +import tarfile +import time +import urllib.request + +MAX_ARCHIVE = 32 * 1024 * 1024 +MAX_EXPANDED = 64 * 1024 * 1024 +MAX_MEMBERS = 10000 +CHUNK = 256 * 1024 +RUN_RE = r"vmproof-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}" +LABEL = "toolstack.vmproof" +KINDS = ("onebox", "echo-api", "echo-mcp") +CONTAINER_KINDS = (*KINDS, "process-sps", "serve") +PROCESS_FIXTURE = "tests/integration/process_sps.py" +PROCESS_CHECKS = ( + "host boot identity preflight", "verified TLS ping", "unknown CA rejected", + "independent credentials", "channel publication excludes SPS credentials", + "SDK refresh string and safe boolean", "SPS credential rejected by channel", + "channel credential rejected by SPS", "restart new boot", "registrations lost", + "actual first recovery incomplete", "incomplete boot unacknowledged", + "actual second recovery complete", "same boot retry", "new TLS retrieval", + "persisted change affects next SDK refresh", "same live tool identity", + "vault encrypted", "logs and audit exclude all fake values and credentials", +) +CURRENT_JOB = None +CHILD_SEQUENCE = 0 +DOCKERFILES = {"onebox": "deploy/docker/Dockerfile", "echo-api": "tools/echo_api/Dockerfile", "echo-mcp": "tools/echo_mcp/Dockerfile"} + + +def require(test, message): + if not test: + raise ValueError(message) + + +def validate_archive(data, commit, digest): + require(re.fullmatch(r"[0-9a-f]{40}", commit) and re.fullmatch(r"[0-9a-f]{64}", digest), "source identity") + require(0 < len(data) <= MAX_ARCHIVE and hashlib.sha256(data).hexdigest() == digest, "archive checksum/size") + with tarfile.open(fileobj=io.BytesIO(data), mode="r:") as archive: + require(archive.pax_headers.get("comment") == commit, "git archive commit metadata") + names, expanded = set(), 0 + for member in archive: + name = member.name.rstrip("/") + path = PurePosixPath(name) + require(name and not path.is_absolute() and all(p not in ("", ".", "..") for p in name.split("/")), "archive path") + require(not any(p in (".git", ".worktrees", ".env") for p in path.parts), "private/history archive path") + require(name not in names and len(names) < MAX_MEMBERS, "archive duplicates/count") + require(member.isfile() or member.isdir(), "archive link/special file") + if name == PROCESS_FIXTURE: + require(member.isfile() and 0 < member.size <= 65536, "process-SPS fixture regular/size") + require(not member.issparse() and member.size >= 0, "archive sparse/size") + expanded += member.size + require(expanded <= MAX_EXPANDED, "archive expansion") + names.add(name) + require(all(p in names for p in DOCKERFILES.values()), "missing Dockerfiles") + require(PROCESS_FIXTURE in names, "missing process-SPS fixture") + return sorted(names) + + +def safe_directory(path): + require(str(path).startswith("/"), "absolute directory") + fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + for part in Path(path).parts[1:]: + require(part not in (".", ".."), "directory traversal") + new = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = new + return os.fstat(fd) + finally: + os.close(fd) + + +def private_write(path, data): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + with os.fdopen(fd, "wb") as target: + target.write(data) + target.flush() + os.fsync(target.fileno()) + except BaseException: + raise + + +def read_private(path, limit=65536): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, "rb") as source: + info = os.fstat(source.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and info.st_uid == os.getuid(), "private regular file") + data = source.read(limit + 1) + require(len(data) <= limit, "private file bound") + return data + + +def process_identity(pid): + text = Path("/proc/%d/stat" % pid).read_text() + return {"pid": pid, "start": int(text[text.rfind(")") + 1:].split()[19]), + "boot": Path("/proc/sys/kernel/random/boot_id").read_text().strip()} + + +def clean_env(root): + if root.name == "src": + root = root.parent + return {"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C", "HOME": str(root / "home"), + "TMPDIR": str(root / "tmp"), "DOCKER_CONFIG": str(root / "docker-config"), + "XDG_CONFIG_HOME": str(root / "config"), "XDG_STATE_HOME": str(root / "state"), + "XDG_CACHE_HOME": str(root / "cache"), "XDG_DATA_HOME": str(root / "data"), + "XDG_RUNTIME_DIR": str(root / "runtime"), "GIT_OPTIONAL_LOCKS": "0", + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", + "UV_CONCURRENT_BUILDS": "1", "UV_CONCURRENT_INSTALLS": "1"} + + +def bounded(argv, root, seconds=30): + """No shell, no inherited credentials; cap both streams, never print stderr.""" + child = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, cwd=root, env=clean_env(root), close_fds=True) + identity = process_identity(child.pid) + sel = selectors.DefaultSelector() + outputs = [bytearray(), bytearray()] + deadline = time.monotonic() + seconds + try: + if CURRENT_JOB is not None: + global CHILD_SEQUENCE + CHILD_SEQUENCE += 1 + job_root = root.parent if root.name == "src" else root + private_write(job_root / "jobs" / (CURRENT_JOB + "-child-" + str(CHILD_SEQUENCE)), json.dumps(identity).encode()) + for stream, data in zip((child.stdout, child.stderr), outputs): + os.set_blocking(stream.fileno(), False) + sel.register(stream, selectors.EVENT_READ, data) + while sel.get_map() or child.poll() is None: + require(time.monotonic() < deadline, "guest command timeout") + for key, _ in sel.select(0.2): + data = os.read(key.fileobj.fileno(), 4096) + if not data: + sel.unregister(key.fileobj) + else: + key.data.extend(data) + require(sum(map(len, outputs)) <= 65536, "guest output limit") + require(child.wait(timeout=1) == 0, "guest command failed") + return bytes(outputs[0]).decode("utf-8") + finally: + for sig in (signal.SIGTERM, signal.SIGKILL): + if child.poll() is not None: + break + try: + require(process_identity(child.pid) == identity, "guest child identity changed") + child.send_signal(sig) + child.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + sel.close() + child.stdout.close() + child.stderr.close() + + +def root_for(run): + require(re.fullmatch(RUN_RE, run), "run id") + return Path("/var/tmp/toolstack-proof-" + run) + + +def verify_root(root, run): + info = safe_directory(root) + require(info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o700, "root ownership/mode") + marker = json.loads(read_private(root / "owner")) + require(marker == {"run": run, "dev": info.st_dev, "ino": info.st_ino}, "root identity") + return marker + + +def initialize(root, run, commit, digest, size): + require(re.fullmatch(r"[0-9a-f]{40}", commit) and re.fullmatch(r"[0-9a-f]{64}", digest), "payload identity") + require(0 < int(size) <= MAX_ARCHIVE, "payload size") + safe_directory(root.parent) + root.mkdir(mode=0o700) # absent-only, no exist_ok + info = root.stat() + marker = {"run": run, "dev": info.st_dev, "ino": info.st_ino} + private_write(root / "owner", json.dumps(marker).encode()) + private_write(root / "payload-meta", json.dumps({"commit": commit, "sha256": digest, "size": int(size)}).encode()) + private_write(root / "payload.tar", b"") + for name in ("home", "tmp", "config", "state", "cache", "data", "runtime", "docker-config", "jobs"): + (root / name).mkdir(mode=0o700) + for name in ("home", "tmp", "config", "state", "cache", "runtime"): + (root / "data" / name).mkdir(mode=0o700) + return marker + + +def receive_chunk(root, offset, digest, encoded): + require(len(encoded) <= (CHUNK * 4 // 3 + 8), "chunk input bound") + data = base64.b64decode(encoded, validate=True) + require(0 < len(data) <= CHUNK and hashlib.sha256(data).hexdigest() == digest, "chunk checksum") + meta = json.loads(read_private(root / "payload-meta")) + fd = os.open(root / "payload.tar", os.O_RDWR | os.O_NOFOLLOW) + with os.fdopen(fd, "r+b") as target: + info = os.fstat(target.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and info.st_uid == os.getuid() + and stat.S_IMODE(info.st_mode) == 0o600 and info.st_size == int(offset), "chunk offset/file") + require(int(offset) + len(data) <= meta["size"], "chunk total") + target.seek(0, os.SEEK_END) + target.write(data) + target.flush() + os.fsync(target.fileno()) + return {"offset": int(offset) + len(data)} + + +def extract(root): + meta = json.loads(read_private(root / "payload-meta")) + data = read_private(root / "payload.tar", MAX_ARCHIVE) + require(len(data) == meta["size"], "payload incomplete") + names = validate_archive(data, meta["commit"], meta["sha256"]) + destination = root / "src" + destination.mkdir(mode=0o700) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:") as archive: + # Never extractall: no links/specials, no ownership/mode restoration. + for member in archive: + path = destination / member.name + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + safe_directory(path.parent) + if member.isdir(): + path.mkdir(mode=0o700, exist_ok=True) + else: + private_write(path, archive.extractfile(member).read()) + if member.mode & 0o111: + path.chmod(0o700) + # Source-only/offline writable-path evidence; not systemd installation. + for unit in ("toolstack-admin.service", "toolstack-sps.service"): + text = (destination / "deploy" / unit).read_text() + require(all(s in text for s in ("NoNewPrivileges=true", "StateDirectory=toolstack", "LogsDirectory=toolstack")), "unit contract") + return {"commit": meta["commit"], "sha256": meta["sha256"], "members": len(names)} + + +def tag_for(run, kind): + require(kind in KINDS, "image kind") + return "toolstack-vmproof" + ("" if kind == "onebox" else "-" + kind) + ":" + run + + +def inspect_image(root, run, kind): + tag = tag_for(run, kind) + text = bounded(("docker", "image", "inspect", "--format", '{{.Id}}\t{{json .RepoTags}}\t{{json .Config.Labels}}', tag), root) + image, tags, labels = text.strip().split("\t") + require(re.fullmatch(r"sha256:[0-9a-f]{64}", image) and json.loads(tags) == [tag] + and json.loads(labels).get(LABEL) == run, "image ownership") + return {"image": image, "tag": tag, "label": run} + + +def build(root, run, kind): + tag = tag_for(run, kind) + # All existing IDs/tags are recorded before any build, and a colliding run tag + # refuses. No daemon config/ENV inspection and no base-image deletion. + existing = bounded(("docker", "image", "ls", "--no-trunc", "--format", "{{.ID}}\t{{.Repository}}:{{.Tag}}"), root) + require(not any(row.endswith("\t" + tag) for row in existing.splitlines()), "preexisting run tag") + private_write(root / ("before-" + kind), existing.encode()) + bounded(("docker", "build", "--quiet", "--label", LABEL + "=" + run, + "-f", DOCKERFILES[kind], "-t", tag, "."), root / "src", 1200) + result = inspect_image(root, run, kind) + require(result["image"] not in {r.split("\t")[0] for r in existing.splitlines()}, "preexisting image id") + private_write(root / ("image-" + kind), json.dumps(result).encode()) + return result + + +ONEBOX_CHECK = ( + "import sps,toolstack_forwarder,admin,shutil,pathlib; " + "assert shutil.which('admin')=='/usr/local/bin/admin'; " + "assert pathlib.Path('/app/tools/echo_api/toolyard.toml').is_file(); " + "assert pathlib.Path('/app/tools/echo_mcp/toolyard.toml').is_file(); " + "from admin import auth,settings; " + "settings.write_password_hash(auth.hash_password('vm-proof-fake-only')); " + "settings.rotate_session_secret(); assert settings.read_password_hash() is not None" +) +ECHO_CHECK = "import sps.tool_sdk,pathlib,tomllib; p=pathlib.Path('/app/toolyard.toml'); assert p.is_file(); assert tomllib.loads(p.read_text())['id']" + + +def create(root, run, kind): + require(kind in CONTAINER_KINDS, "container kind") + image_kind = "onebox" if kind in ("serve", "process-sps") else kind + if kind == "process-sps": + verify_root(root, run) + script = root / "src" / PROCESS_FIXTURE + safe_directory(script.parent) + read_private(script) # regular, no-follow, single-link, owned, bounded + data_info = safe_directory(root / "data") + require(data_info.st_uid == os.getuid() and stat.S_IMODE(data_info.st_mode) == 0o700, "private data root") + require(not os.path.lexists(root / "data/process-sps"), "process-SPS root must be absent") + recorded = json.loads(read_private(root / ("image-" + image_kind))) + require(inspect_image(root, run, image_kind) == recorded, "recorded image changed") + argv = ["docker", "create", "--name", "toolstack-" + run + "-" + kind, + "--label", LABEL + "=" + run, "--restart", "no", "--network", "bridge" if kind == "serve" else "none"] + if image_kind == "onebox": + argv += ["--mount", "type=bind,src=" + str(root / "data") + ",dst=/data"] + for key, value in {"HOME": "/data/home", "TMPDIR": "/data/tmp", "XDG_CONFIG_HOME": "/data/config", + "XDG_STATE_HOME": "/data/state", "XDG_CACHE_HOME": "/data/cache", "XDG_DATA_HOME": "/data", + "XDG_RUNTIME_DIR": "/data/runtime", "TOOLSTACK_SPS_SKIP": "0" if kind == "process-sps" else "1", "TOOLSTACK_SPS_ENV": "/data/no-sps.env"}.items(): + argv += ["-e", key + "=" + value] + else: + for key in ("HOME", "TMPDIR", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR"): + argv += ["-e", key + "=/tmp"] # private disposable container filesystem + if kind == "serve": + argv += ["-p", "127.0.0.1::8780", recorded["image"]] + elif kind == "process-sps": + argv += ["--mount", "type=bind,src=" + str(script) + ",dst=/proof/process_sps.py,readonly", + "-e", "PYTHONPATH=/app", "--entrypoint", "python3", recorded["image"], + "/proof/process_sps.py", "--root", "/data/process-sps"] + else: + argv += ["--entrypoint", "python3", recorded["image"], "-c", ONEBOX_CHECK if kind == "onebox" else ECHO_CHECK] + cid = bounded(tuple(argv), root).strip() + require(re.fullmatch(r"[0-9a-f]{64}", cid), "full container id") + result = {"container": cid, "kind": kind, "label": run} + private_write(root / ("container-" + kind), json.dumps(result).encode()) + return result + + +def validate_process_result(result): + require(isinstance(result, dict) and result.get("status") == "PASS" + and result.get("cleanup") == "reaped", "process-SPS status/cleanup") + require(result.get("checks") == list(PROCESS_CHECKS), "process-SPS required checks") + require(isinstance(result.get("platform"), str) and result["platform"].startswith("Linux-") + and isinstance(result.get("python"), str) and re.fullmatch(r"3\.[0-9]+\.[0-9]+", result["python"]) + and result.get("cryptography") == "50.0.2", "process-SPS Linux/runtime evidence") + records = result.get("processes") + require(isinstance(records, list) and len(records) == 4, "process-SPS process evidence") + by_label = {} + for record in records: + require(isinstance(record, dict) and record.get("label") in ("sps", "sps-restart", "runner spawn", "tool") + and record["label"] not in by_label and type(record.get("pid")) is int and record["pid"] > 1 + and isinstance(record.get("identity"), str) and bool(record["identity"]), "process-SPS identity") + by_label[record["label"]] = record + spawn, tool = by_label["runner spawn"], by_label["tool"] + require(spawn.get("cleanup") == "reaped and absent" and spawn["pid"] == tool["pid"] + and spawn["identity"] == tool["identity"] and isinstance(spawn.get("boot"), str) + and bool(spawn["boot"]) and spawn["boot"] == tool.get("boot") + and len({r["pid"] for r in records}) == 3, "process-SPS positive process cleanup") + boots = result.get("boots") + require(isinstance(boots, list) and len(boots) == 2 and all(isinstance(b, str) and b for b in boots) + and boots[0] != boots[1], "process-SPS restart evidence") + return result + + +def start_container(root, run, kind, cid): + require(kind in CONTAINER_KINDS and re.fullmatch(r"[0-9a-f]{64}", cid), "container start kind/id") + recorded = json.loads(read_private(root / ("container-" + kind))) + require(recorded == {"container": cid, "kind": kind, "label": run}, "container record") + identity = bounded(("docker", "inspect", "--format", '{{.Id}}\t{{index .Config.Labels "toolstack.vmproof"}}', cid), root).strip() + require(identity == cid + "\t" + run, "container ownership") + if kind != "serve": + output = bounded(("docker", "start", "-a", cid), root, 135 if kind == "process-sps" else 90) + code = bounded(("docker", "inspect", "--format", "{{.State.ExitCode}}", cid), root).strip() + require(code == "0", "content/bootstrap assertions failed") + if kind == "process-sps": + return {"checked": kind, "exit": 0, "proof": validate_process_result(json.loads(output))} + return {"checked": kind, "exit": 0} + cmd = bounded(("docker", "inspect", "--format", "{{json .Config.Cmd}}", cid), root) + require(json.loads(cmd) == ["/usr/local/bin/admin", "serve", "--port", "8780"], "image actual CMD") + bounded(("docker", "start", cid), root) + address = bounded(("docker", "port", cid, "8780/tcp"), root).strip() + require(re.fullmatch(r"127\.0\.0\.1:[0-9]{1,5}", address), "loopback publish") + port = int(address.rsplit(":", 1)[1]) + require(1024 <= port <= 65535, "dynamic port") + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + with opener.open("http://" + address + "/login", timeout=2) as response: + require(response.status == 200 and b"password" in response.read(65536).lower(), "admin login response") + return {"checked": "serve", "port": port, "http": 200, "cmd": json.loads(cmd)} + except OSError: + time.sleep(1) + raise ValueError("admin readiness deadline") + + +INVENTORY_GIT = { + "git-root": ("root", ("rev-parse", "--show-toplevel")), + "git-head": ("head", ("rev-parse", "HEAD")), + "git-branch": ("branch", ("rev-parse", "--abbrev-ref", "HEAD")), + "git-status": ("status", ("status", "--porcelain=v1", "-uall")), + "git-submodules": ("submodules", ("submodule", "status")), +} +INVENTORY_STEPS = ("serviceprops", "serviceprops-sps", "statowner", *INVENTORY_GIT) +INVENTORY_REASONS = ("command-failed", "invalid-metadata", "owner-unknown", "unsafe-directory", "transport-unknown") + + +def inventory_path(value): + return (isinstance(value, str) and len(value) <= 1024 and value.startswith("/") + and value != "/" and all(p not in ("", ".", "..") for p in value[1:].split("/")) + and all(32 <= ord(c) < 127 for c in value) and "\\" not in value) + + +def inventory_owner(value): + return isinstance(value, str) and re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", value) is not None + + +def inventory_error(value): + """Strict wire schema: never forward arbitrary exception text or extra fields.""" + return (isinstance(value, dict) and set(value) == {"error", "step", "reason", "metadata"} + and value["error"] == "inventory-failed" and value["step"] in INVENTORY_STEPS + and value["reason"] in INVENTORY_REASONS and isinstance(value["metadata"], dict) + and set(value["metadata"]) <= {"path", "owner"} + and all((inventory_path(v) if k == "path" else inventory_owner(v)) + for k, v in value["metadata"].items())) + + +class InventoryFailure(ValueError): + def __init__(self, step, reason, result): + self.envelope = {"error": "inventory-failed", "step": step, "reason": reason, + "metadata": {k: v for k, v in result.items() if + (k == "path" and inventory_path(v)) or + (k == "owner" and inventory_owner(v))}} + super().__init__("inventory-failed") + + +def service_properties(text): + fields = {} + for line in text.splitlines(): + key, sep, value = line.partition("=") + require(sep and key in {"User", "Group", "WorkingDirectory"} and key not in fields, + "service properties") + fields[key] = value + require(set(fields) == {"User", "Group", "WorkingDirectory"}, "service properties") + return fields + + +def inventory_directory_identity(info): + require(stat.S_ISDIR(info.st_mode), "ordinary directory") + return dict(uid=info.st_uid, gid=info.st_gid, mode=stat.S_IMODE(info.st_mode), + dev=info.st_dev, ino=info.st_ino) + + +def inventory(checkout, step=None, result=None): + # The controller checkpoints before EACH fixed step. The single-call form is + # for local fake-boundary tests only, not the live driver's admission sequence. + if step is None: + result = {} + for stage in INVENTORY_STEPS: + result = inventory(checkout, stage, result) + return result + require(step in INVENTORY_STEPS and isinstance(result, dict), "inventory step") + result = dict(result) + reason = "invalid-metadata" + try: + if step in ("serviceprops", "serviceprops-sps"): + unit = "toolstack-admin.service" if step == "serviceprops" else "toolstack-sps.service" + reason = "command-failed" + try: + text = bounded(("systemctl", "show", "-p", "User,Group,WorkingDirectory", unit), Path("/"), 5) + except Exception: + if step == "serviceprops-sps": + result["sps"] = {"state": "unknown-or-absent"} + return result + raise + reason = "invalid-metadata" + props = service_properties(text) + if step == "serviceprops-sps": + result["sps"] = {"state": "unknown-or-absent"} + if inventory_path(props["WorkingDirectory"]) and inventory_owner(props["User"]): + require(not props["Group"] or inventory_owner(props["Group"]), "group") + result["sps"] = dict(props, state="known") + return result + require(inventory_path(props["WorkingDirectory"]), "admin directory") + require(not props["User"] or inventory_owner(props["User"]), "admin user") + require(not props["Group"] or inventory_owner(props["Group"]), "admin group") + result.update(path=props["WorkingDirectory"], service=props) + require(checkout == "discover" or checkout == result["path"], "explicit checkout mismatch") + return result + require(inventory_path(result.get("path")), "identity") + path = Path(result["path"]) + if step == "statowner": + reason = "unsafe-directory" + identity = inventory_directory_identity(safe_directory(path)) + reason = "owner-unknown" + require(identity["uid"] > 0, "nonroot owner") + account = pwd.getpwuid(identity["uid"]) + group = grp.getgrgid(identity["gid"]) + owner = account.pw_name + require(inventory_owner(owner) and inventory_owner(group.gr_name), "known owner/group") + require(account.pw_uid == identity["uid"] and pwd.getpwnam(owner).pw_uid == identity["uid"] + and group.gr_gid == identity["gid"] and grp.getgrnam(group.gr_name).gr_gid == identity["gid"], + "owner/group resolution mismatch") + result.update(owner=owner, group=group.gr_name) + reason = "command-failed" + text = bounded(("stat", "-c", "%U:%G %a %n", "--", str(path)), Path("/"), 5).strip() + reason = "invalid-metadata" + expected = f"{owner}:{group.gr_name} {identity['mode']:o} {path}" + require(text == expected, "stat identity mismatch") + reason = "unsafe-directory" + require(inventory_directory_identity(safe_directory(path)) == identity, "directory changed") + result.update(identity) + return result + require(inventory_owner(result.get("owner")), "owner") + owner = result["owner"] + reason = "unsafe-directory" + identity = inventory_directory_identity(safe_directory(path)) + require(identity == {key: result[key] for key in identity}, "directory changed") + reason = "command-failed" + key, args = INVENTORY_GIT[step] + text = bounded(("sudo", "-n", "-u", owner, "--", "git", "--no-optional-locks", + "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", + "-C", str(path), *args), Path("/"), 5) + reason = "invalid-metadata" + if key == "head": + require(re.fullmatch(r"[0-9a-f]{40}", text.strip()), "head") + if key == "root": + require(text.strip() == str(path), "root mismatch") + result[key] = text + return result + except Exception: + raise InventoryFailure(step, reason, result) from None + + +def cleanup_mount(fd): + # st_dev alone misses same-device bind mounts. Unknown is a refusal. + text = Path('/proc/self/fdinfo/' + str(fd)).read_text() + values = [line.split()[1] for line in text.splitlines() if line.startswith('mnt_id:')] + require(len(values) == 1 and values[0].isdigit(), 'unknown mount identity') + return values[0] + + +def cleanup_root(root, run, dev, ino, *, remove=False): + """Cleanup-only metadata walk and fd-relative removal; never read source data.""" + allowed = {'owner', 'payload-meta', 'payload.tar', 'src', 'home', 'tmp', 'config', + 'state', 'cache', 'data', 'runtime', 'docker-config', 'jobs'} + parent = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + rootfd = None + owner = None + try: + for part in root.parent.parts[1:]: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + os.close(parent) + parent = child + rootfd = os.open(root.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + info = os.fstat(rootfd) + require((info.st_dev, info.st_ino) == (dev, ino) and info.st_uid == os.getuid() + and stat.S_IMODE(info.st_mode) == 0o700, 'cleanup root identity') + mount = cleanup_mount(rootfd) + require(cleanup_mount(parent) == mount, 'mounted cleanup root') + def identity(info): + base = (info.st_dev, info.st_ino, info.st_uid, info.st_mode, info.st_nlink) + return base + ((info.st_size, info.st_mtime_ns, info.st_ctime_ns) + if stat.S_ISREG(info.st_mode) else ()) + root_identity = identity(info) + def same(fd, name, expected): + require(identity(os.stat(name, dir_fd=fd, follow_symlinks=False)) == expected, + 'cleanup entry changed') + owner = os.open('owner', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=rootfd) + oi = os.fstat(owner) + require(stat.S_ISREG(oi.st_mode) and oi.st_uid == os.getuid() and oi.st_nlink == 1 + and stat.S_IMODE(oi.st_mode) == 0o600 and oi.st_dev == dev + and cleanup_mount(owner) == mount, 'cleanup owner file') + marker = json.loads(os.read(owner, 4097)) + require(marker == {'run': run, 'dev': dev, 'ino': ino}, 'cleanup owner marker') + owner_identity = identity(oi) + def marker_still_owned(): + require(identity(os.fstat(owner)) == owner_identity, 'cleanup owner changed') + same(rootfd, 'owner', owner_identity) + marker_still_owned() + count = [0] + def scan(fd, depth=0): + require(depth < 64, 'cleanup depth bound') + names = set(os.listdir(fd)) + if depth == 0: + require(names == allowed, 'cleanup top entries') + tree = {} + for name in sorted(names): + count[0] += 1 + require(count[0] <= 100000, 'cleanup entry bound') + si = os.stat(name, dir_fd=fd, follow_symlinks=False) + require(si.st_uid == os.getuid() and si.st_dev == dev + and (stat.S_ISDIR(si.st_mode) or stat.S_ISREG(si.st_mode)), + 'foreign/special cleanup entry') + require(stat.S_ISDIR(si.st_mode) or si.st_nlink == 1, 'hardlinked cleanup file') + flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + if stat.S_ISDIR(si.st_mode): + flags |= os.O_DIRECTORY + entry = os.open(name, flags, dir_fd=fd) + try: + require(identity(os.fstat(entry)) == identity(si) and cleanup_mount(entry) == mount, + 'cleanup entry identity/mount') + subtree = scan(entry, depth + 1) if stat.S_ISDIR(si.st_mode) else None + same(fd, name, identity(si)) + tree[name] = (identity(si), subtree) + finally: + os.close(entry) + require(set(os.listdir(fd)) == names, 'cleanup entries changed') + return tree + tree = scan(rootfd) + marker_still_owned() + def erase(fd, tree, top=False): + require(set(os.listdir(fd)) == set(tree), 'cleanup entries changed') + names = [name for name in tree if name != 'owner'] + ['owner'] if top else list(tree) + for name in names: + expected, subtree = tree[name] + marker_still_owned() + same(fd, name, expected) + if subtree is None: + os.unlink(name, dir_fd=fd) + else: + entry = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + try: + require(identity(os.fstat(entry)) == expected and cleanup_mount(entry) == mount, + 'cleanup directory changed') + erase(entry, subtree) + # Directory link count changes as its children are removed. + current = os.fstat(entry) + require(identity(current)[:4] == expected[:4], 'cleanup directory changed') + same(fd, name, identity(current)) + marker_still_owned() + os.rmdir(name, dir_fd=fd) + finally: + os.close(entry) + same(parent, root.name, root_identity) + if remove: + erase(rootfd, tree, top=True) + current = os.fstat(rootfd) + require(identity(current)[:4] == root_identity[:4], 'cleanup root changed') + same(parent, root.name, identity(current)) + os.rmdir(root.name, dir_fd=parent) + return {'removed': str(root)} + return marker + finally: + if owner is not None: + os.close(owner) + if rootfd is not None: + os.close(rootfd) + os.close(parent) + + +def dispatch(run, action, args, stdin): + global CURRENT_JOB + root = root_for(run) + if action in ('cleanup-check', 'cleanup-remove'): + require(len(args) == 2, 'cleanup identity arguments') + return cleanup_root(root, run, int(args[0]), int(args[1]), remove=action == 'cleanup-remove') + if action == "inventory": + require(len(args) == 3, "inventory arguments") + return inventory(args[0], args[1], json.loads(args[2])) + if action == "init": + require(len(args) == 3, "init arguments") + return initialize(root, run, *args) + marker = verify_root(root, run) + if action == "chunk": + require(len(args) == 2, "chunk arguments") + return receive_chunk(root, *args, stdin) + if action == "extract": + require(not args, "extract arguments") + return extract(root) + if action == "identity": + require(len(args) == 2 and re.fullmatch(r"build-(onebox|echo-api|echo-mcp)|(create|start)-(onebox|echo-api|echo-mcp|serve|process-sps)", args[0]), "job identity args") + path = root / "jobs" / args[0] + if not path.exists(): + return {"pending": True} + record = json.loads(read_private(path)) + require(record["pid"] == int(args[1]), "QGA job PID mismatch") + return record + if action == "remove": + require(len(args) == 2 and marker["dev"] == int(args[0]) and marker["ino"] == int(args[1]), "remove root identity") + require(shutil.rmtree.avoids_symlink_attacks, "safe removal unavailable") + shutil.rmtree(root) + return {"removed": str(root)} + require(action in ("build", "create", "start") and args + and args[0] in (KINDS if action == "build" else CONTAINER_KINDS), "fixed guest action") + job = action + "-" + args[0] + # An absent-only job identity is the retry barrier and survives fast exit. + private_write(root / "jobs" / job, json.dumps(process_identity(os.getpid())).encode()) + CURRENT_JOB = job + if action == "build": + require(len(args) == 1 and args[0] in KINDS, "build arguments") + return build(root, run, args[0]) + if action == "create": + require(len(args) == 1, "create arguments") + return create(root, run, args[0]) + require(len(args) == 2 and re.fullmatch(r"[0-9a-f]{64}", args[1]), "start arguments") + return start_container(root, run, args[0], args[1]) + + +def guest_main(): + os.umask(0o077) + try: + run, action, *args = sys.argv[1:] + # Bounded stdin only for chunks; never eval/exec input or source content. + stdin = sys.stdin.buffer.read(CHUNK * 4 // 3 + 9) if action == "chunk" else b"" + print(json.dumps(dispatch(run, action, args, stdin))) + except InventoryFailure as failure: + print(json.dumps(failure.envelope)) + return 1 + except Exception: + # No exception text: subprocess/config errors might contain credentials. + print('{"error":"guest-proof-failed"}') + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(guest_main()) diff --git a/tools/echo_api/Dockerfile b/tools/echo_api/Dockerfile index 6535136..0c0b201 100644 --- a/tools/echo_api/Dockerfile +++ b/tools/echo_api/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.12-slim WORKDIR /app -COPY app.py . -# Secrets arrive at /run/secrets (tmpfs or mount); the port comes via TOOLSTACK_PORT. -# The tool holds no broker or secret-backend credential. +# Build from repository root: docker build -f tools/echo_api/Dockerfile . +COPY tools/echo_api/app.py tools/echo_api/toolyard.toml ./ +COPY sps /app/sps +# API channel credential is independent of SPS. Docker+SPS remains unsupported. CMD ["python3", "app.py"] diff --git a/tools/echo_api/app.py b/tools/echo_api/app.py index c98a0b2..a2f99c1 100644 --- a/tools/echo_api/app.py +++ b/tools/echo_api/app.py @@ -5,11 +5,10 @@ by the SPS at boot. The runner handed it an ``E_SECRET`` and a CA bundle in the environment; the tool uses those to talk to SPS over TLS/TCP. -Defense in depth (Phase 4): if ``$TOOLSTACK_E_SECRET`` is set, the tool -requires the broker's ``X-Toolstack-Secret`` header to match it, so a -stray loopback process can't call this tool directly and bypass the -broker's policy. With no ``TOOLSTACK_E_SECRET`` (e.g. a one-off standalone -run), the check is skipped. +The broker's ``X-Toolstack-Secret`` must match the independent +``TOOLSTACK_CHANNEL_SECRET``. Missing channel configuration refuses requests; +the SPS credential is never accepted for API authentication. Unrestricted +same-owner processes remain trusted, not isolated by this mechanism. Stdlib only, so it runs as a bare process or inside a container unchanged. """ @@ -49,11 +48,10 @@ def _tool_id_from_config() -> str: def verify_broker(headers) -> bool: - """Defense in depth: compare X-Toolstack-Secret against the E_SECRET the - runner minted for this tool. No E_SECRET env -> feature off.""" - expected = os.environ.get("TOOLSTACK_E_SECRET") or "" + """Require the independent runner-minted API credential.""" + expected = os.environ.get("TOOLSTACK_CHANNEL_SECRET") or "" if not expected: - return True + return False presented = headers.get("X-Toolstack-Secret", "") return hmac.compare_digest(presented.encode("utf-8"), expected.encode("utf-8")) diff --git a/tools/echo_mcp/Dockerfile b/tools/echo_mcp/Dockerfile index 2f84d64..b801b6d 100644 --- a/tools/echo_mcp/Dockerfile +++ b/tools/echo_mcp/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.12-slim WORKDIR /app -COPY app.py . -# Secrets arrive at /run/secrets (tmpfs or mount); the port comes via TOOLSTACK_PORT. -# The tool holds no broker or secret-backend credential. Stdlib-only MCP server. +# Build from repository root: docker build -f tools/echo_mcp/Dockerfile . +COPY tools/echo_mcp/app.py tools/echo_mcp/toolyard.toml ./ +COPY sps /app/sps +# API channel credential is independent of SPS. Docker+SPS remains unsupported. CMD ["python3", "app.py"] diff --git a/tools/echo_mcp/app.py b/tools/echo_mcp/app.py index e0b297e..f518220 100644 --- a/tools/echo_mcp/app.py +++ b/tools/echo_mcp/app.py @@ -69,9 +69,9 @@ def _tool_id_from_config() -> str: def verify_broker(headers) -> bool: - expected = os.environ.get("TOOLSTACK_E_SECRET") or "" + expected = os.environ.get("TOOLSTACK_CHANNEL_SECRET") or "" if not expected: - return True + return False presented = headers.get("X-Toolstack-Secret", "") return hmac.compare_digest(presented.encode("utf-8"), expected.encode("utf-8")) diff --git a/tools/rest_demo/toolyard.toml b/tools/rest_demo/toolyard.toml index ee8ab5d..434a303 100644 --- a/tools/rest_demo/toolyard.toml +++ b/tools/rest_demo/toolyard.toml @@ -25,6 +25,8 @@ risk = "write" description = "Demo login that writes a returned token back to the tool secret store" verb = "POST" path = "/login" +redact_response_body = true +redact_response_headers = true body_kind = "text" body_content_type = "application/json" secret_update_rules = [ diff --git a/toolstack_forwarder/config.py b/toolstack_forwarder/config.py index db5e2ab..633a236 100644 --- a/toolstack_forwarder/config.py +++ b/toolstack_forwarder/config.py @@ -135,6 +135,8 @@ def _load_operations(path: Path, raw: Any, secrets: dict[str, SecretDecl]) -> di name = _string(item, "name", path, field) if not _ID_RE.match(name): raise _err(path, f"{field}.name", "must match [A-Za-z0-9][A-Za-z0-9_-]*") + if name in {"refresh", "refresh_one"}: + raise _err(path, f"{field}.name", f"reserved operation {name!r}") if name in operations: raise _err(path, f"{field}.name", f"duplicate operation {name!r}") @@ -185,6 +187,8 @@ def _load_operations(path: Path, raw: Any, secrets: dict[str, SecretDecl]) -> di raise _err(path, f"{field}.redact_response_headers", "must be a boolean") rules = _load_rules(path, field, item.get("secret_update_rules", []), writable) + if rules and not {"redact_response_body", "redact_response_headers"} <= item.keys(): + raise _err(path, field, "writeback requires explicit redact_response_body and redact_response_headers decisions") operations[name] = Operation( name=name, risk=item.get("risk", "unknown") if isinstance(item.get("risk", "unknown"), str) else "unknown", diff --git a/toolstack_forwarder/rules.py b/toolstack_forwarder/rules.py index 01d1264..691b69f 100644 --- a/toolstack_forwarder/rules.py +++ b/toolstack_forwarder/rules.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib import json import logging import re @@ -63,7 +62,7 @@ def apply_secret_update_rules(op: Operation, response: dict, except Exception as exc: raise RuleError("rule_extraction_failed", type(exc).__name__) from exc - written: list[tuple[str, str]] = [] + written: list[str] = [] for name, value, rule in extracted: try: secrets.writeback(name, value) @@ -72,11 +71,10 @@ def apply_secret_update_rules(op: Operation, response: dict, raise RuleError( "secret_update_failed", type(exc).__name__, secret=name ) from exc - fingerprint = hashlib.sha256(value.encode("utf-8")).hexdigest() - written.append((name, fingerprint)) + written.append(name) log.info( - "updated secret via SPS: %s sha256=%s rule=%s", - name, fingerprint, rule.response_type, + "updated secret via SPS: %s rule=%s", + name, rule.response_type, ) @@ -160,6 +158,6 @@ def _extract_xml(body: str, path: str, secret_name: str) -> str: return found[0].text -def _log_partial(written: list[tuple[str, str]]) -> None: +def _log_partial(written: list[str]) -> None: if written: log.warning("secret update failed after partial writes: %s", written) diff --git a/toolstack_forwarder/server.py b/toolstack_forwarder/server.py index 3f79e62..a6fb1de 100644 --- a/toolstack_forwarder/server.py +++ b/toolstack_forwarder/server.py @@ -111,18 +111,16 @@ def do_GET(self) -> None: do_PUT = do_PATCH = do_DELETE = do_GET def _verify_channel_secret(self) -> tuple[bool, str]: - # The channel credential is the E_SECRET the runner minted for - # this tool. It arrives as `X-Toolstack-Secret` from the broker, - # sourced from the toolyard state file (Phase 4). The tool - # compares the header against $TOOLSTACK_E_SECRET (set by the - # runner at start); absent -> feature off (Phase 5: no more - # FS-fallback `broker_secret` file). + # Independently minted channel credential, never the SPS key. + # Missing configuration fails closed. This does not isolate + # unrestricted processes belonging to the same OS owner. import os as _os - expected = _os.environ.get("TOOLSTACK_E_SECRET") or "" + expected = _os.environ.get("TOOLSTACK_CHANNEL_SECRET") or "" if not expected: - return True, "" + return False, "channel_secret_missing" presented = (self.headers.get("X-Toolstack-Secret") or "").strip() - if not hmac.compare_digest(presented, expected): + if (not presented.isascii() or not expected.isascii() + or not hmac.compare_digest(presented, expected)): return False, "channel_secret_mismatch" return True, "" diff --git a/toolstack_forwarder/tests/test_config.py b/toolstack_forwarder/tests/test_config.py index 268a36f..cea5764 100644 --- a/toolstack_forwarder/tests/test_config.py +++ b/toolstack_forwarder/tests/test_config.py @@ -49,6 +49,19 @@ def test_loads_valid_config_and_defaults_get_to_no_body(self): self.assertFalse(op.redact_response_body) self.assertFalse(op.redact_response_headers) + def test_refresh_dispatch_names_reserved_with_or_without_secrets(self): + for name in ("refresh", "refresh_one"): + for secrets in ("", '\n[[secrets]]\nname="fake"\nfield="FAKE"\n'): + with self.subTest(name=name, secrets=bool(secrets)), self.assertRaisesRegex( + ConfigError, "reserved operation"): + load_config(self._write(BASE.replace('name = "get_user"', f'name = "{name}"') + secrets)) + + def test_similar_names_are_not_reserved(self): + for name in ("Refresh", "refresh_two", "refresh_one_more"): + with self.subTest(name=name): + cfg = load_config(self._write(BASE.replace('name = "get_user"', f'name = "{name}"'))) + self.assertIn(name, cfg.operations) + def test_parses_redaction_flags(self): cfg = load_config(self._write(BASE + """ redact_response_body = true diff --git a/toolstack_forwarder/tests/test_rules.py b/toolstack_forwarder/tests/test_rules.py index d3c7909..f038234 100644 --- a/toolstack_forwarder/tests/test_rules.py +++ b/toolstack_forwarder/tests/test_rules.py @@ -1,7 +1,10 @@ +import hashlib import unittest +from types import SimpleNamespace +from unittest import mock from toolstack_forwarder.config import SecretUpdateRule -from toolstack_forwarder.rules import RuleError, extract_value, match_status +from toolstack_forwarder.rules import RuleError, apply_secret_update_rules, extract_value, match_status class Extractors(unittest.TestCase): @@ -42,6 +45,33 @@ def test_missing_json_path_raises_rule_extraction_failed(self): extract_value(self.rule("json", "missing"), "{}") self.assertEqual(cm.exception.code, "rule_extraction_failed") + def test_success_and_partial_failure_logs_only_metadata(self): + value = "fake-low-entropy-1234" + digest = hashlib.sha256(value.encode()).hexdigest() + op = SimpleNamespace(secret_update_rules=( + self.rule("json", "token", "first"), self.rule("json", "token", "second"))) + response = {"status": 200, "body": '{"token":"' + value + '"}'} + for partial in (False, True): + secrets = mock.Mock() + if partial: + secrets.writeback.side_effect = [None, OSError("fake")] + with self.subTest(partial=partial), self.assertLogs("toolstack_forwarder.rules", level="INFO") as logs: + if partial: + with self.assertRaises(RuleError) as caught: + apply_secret_update_rules(op, response, secrets) + self.assertEqual(caught.exception.envelope(), { + "error": "secret_update_failed", "detail": "OSError", "secret": "second"}) + else: + apply_secret_update_rules(op, response, secrets) + secrets.writeback.assert_has_calls([mock.call("first", value), mock.call("second", value)]) + text = "\n".join(logs.output) + self.assertIn("first", text) + self.assertIn("rule=json", text) + self.assertIn("partial writes" if partial else "second", text) + self.assertNotIn(value, text) + self.assertNotIn(digest, text) + self.assertNotIn("sha256", text) + if __name__ == "__main__": unittest.main() diff --git a/toolstack_forwarder/tests/test_server.py b/toolstack_forwarder/tests/test_server.py index df308c1..37dd1da 100644 --- a/toolstack_forwarder/tests/test_server.py +++ b/toolstack_forwarder/tests/test_server.py @@ -41,6 +41,13 @@ def do_POST(self): body = self.rfile.read(length) if length else b"" self.server.seen.append({"method": "POST", "path": self.path, "headers": dict(self.headers), "body": body}) + if self.path == "/v1/session": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("X-Session", "xy7") + self.end_headers() + self.wfile.write(b'{"session":{"token":"xy7"}}') + return self.send_response(200) self.send_header("Content-Type", "text/plain") self.end_headers() @@ -64,6 +71,8 @@ def get(self, name): raise KeyError(name) return table[name] def writeback(self, name, value): + self.write_secret(name, value) + def write_secret(self, name, value): table[name] = value def refresh_all(self): pass @@ -87,20 +96,24 @@ def setUp(self): self._start(self.upstream) self.addCleanup(self.upstream.server_close) # Default channel secret; tests can override via TOOLSTACK_E_SECRET. - self._prev_env = os.environ.get("TOOLSTACK_E_SECRET") - os.environ["TOOLSTACK_E_SECRET"] = "chan" + self._prev_env = os.environ.get("TOOLSTACK_CHANNEL_SECRET") + os.environ["TOOLSTACK_CHANNEL_SECRET"] = "chan" self.addCleanup(self._restore_env) def _restore_env(self): if self._prev_env is None: - os.environ.pop("TOOLSTACK_E_SECRET", None) + os.environ.pop("TOOLSTACK_CHANNEL_SECRET", None) else: - os.environ["TOOLSTACK_E_SECRET"] = self._prev_env + os.environ["TOOLSTACK_CHANNEL_SECRET"] = self._prev_env def _start(self, server): thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() - self.addCleanup(server.shutdown) + def stop(): + server.shutdown() + thread.join(3) + self.assertFalse(thread.is_alive()) + self.addCleanup(stop) return thread def _config(self, extra_ops: str = ""): @@ -174,16 +187,30 @@ def test_broker_secret_mismatch_is_401_json_when_configured(self): self.assertEqual(status, 401) self.assertEqual(body["error"], "channel_secret_mismatch") - def test_missing_e_secret_env_disables_channel_auth(self): + def test_missing_channel_env_refuses_requests(self): # No TOOLSTACK_E_SECRET -> channel auth off; any header value passes. - os.environ.pop("TOOLSTACK_E_SECRET") + os.environ.pop("TOOLSTACK_CHANNEL_SECRET") status, body = self._post( self._forwarder(), {"op": "get_item", "arguments": {"variables": {"item_id": "i1"}}}, secret="anything", ) + self.assertEqual(status, 401) + self.assertEqual(body["error"], "channel_secret_missing") + + def test_non_ascii_channel_header_fails_closed_then_valid_request_works(self): + url = self._forwarder() + payload = {"op": "get_item", "arguments": {"variables": {"item_id": "i1"}}} + with mock.patch("toolstack_forwarder.server.outbound.send") as dispatch: + status, body = self._post(url, payload, secret="é") + self.assertEqual(status, 401) + self.assertEqual(body, {"error": "channel_secret_mismatch"}) + dispatch.assert_not_called() + self.assertEqual(self.upstream.seen, []) + status, body = self._post(url, payload) self.assertEqual(status, 200) self.assertEqual(body["status"], 201) + self.assertEqual(len(self.upstream.seen), 1) def test_does_not_follow_upstream_redirect(self): cfg = self._config(""" @@ -244,6 +271,8 @@ def test_secret_update_rule_writes_through_SDK(self): allowed_headers = [] body_kind = "text" body_content_type = "application/json" +redact_response_body = false +redact_response_headers = false secret_update_rules = [ { secret_name = "auth_token", response_type = "plaintext", extract_path = "abc(def)", match_status = "200" }, ] @@ -260,6 +289,39 @@ def test_secret_update_rule_writes_through_SDK(self): # writeback saw the real body (not the redacted caller's body) self.assertEqual(secrets.table["_t"]["auth_token"], "def") + def test_login_writeback_receives_token_but_response_redacts_body_and_headers(self): + cfg = self._config(''' +[[operations]] +name = "login" +risk = "write" +verb = "POST" +path = "/session" +body_kind = "text" +body_content_type = "application/json" +redact_response_body = true +redact_response_headers = true +secret_update_rules = [ + { secret_name = "auth_token", response_type = "json", extract_path = "session.token", match_status = "200" }, +] +[[secrets]] +name = "auth_token" +field = "AUTH_TOKEN" +writable = true +''') + secrets = _fake_secrets({"auth_token": "old"}) + secrets.writeback = mock.Mock(wraps=secrets.writeback) + secrets.write_secret = mock.Mock(wraps=secrets.write_secret) + self.assertEqual(os.environ["TOOLSTACK_CHANNEL_SECRET"], "chan") + status, body = self._post(self._forwarder(cfg, secrets=secrets), + {"op": "login", "arguments": {"body": "{}"}}, secret="chan") + self.assertEqual(status, 200) + self.assertEqual(body["status"], 200) + secrets.writeback.assert_called_once_with("auth_token", "xy7") + secrets.write_secret.assert_called_once_with("auth_token", "xy7") + self.assertEqual(secrets.table["_t"]["auth_token"], "xy7") + self.assertNotIn("xy7", json.dumps(body)) + self.assertNotIn("x-session", body["headers"]) + def test_secret_update_rule_failure_partial_skips_remaining_writes(self): # Two rules; the FIRST succeeds, the SECOND's extract regex fails. # Extraction is all-or-nothing: when ANY rule's extract fails, we @@ -274,6 +336,8 @@ def test_secret_update_rule_failure_partial_skips_remaining_writes(self): allowed_headers = [] body_kind = "text" body_content_type = "application/json" +redact_response_body = false +redact_response_headers = false secret_update_rules = [ { secret_name = "auth_token", response_type = "plaintext", extract_path = "abc(def)", match_status = "200" }, { secret_name = "refresh_token", response_type = "plaintext", extract_path = "nope=([a-z]+)", match_status = "200" }, diff --git a/toolstack_forwarder/tests/test_uplift.py b/toolstack_forwarder/tests/test_uplift.py new file mode 100644 index 0000000..08810e7 --- /dev/null +++ b/toolstack_forwarder/tests/test_uplift.py @@ -0,0 +1,36 @@ +"""Pure fake regressions; no listeners or upstream calls.""" +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from toolstack_forwarder.config import load_config +from toolstack_forwarder.server import _handler + + +class ChannelTests(unittest.TestCase): + def test_channel_required_no_sps_fallback(self): + # Handler construction does not bind a socket. + handler = object.__new__(_handler(None, None, 1, 1024)) + handler.headers = {"X-Toolstack-Secret": "sps-key"} + with patch.dict(os.environ, {"TOOLSTACK_E_SECRET": "sps-key"}, clear=True): + self.assertFalse(handler._verify_channel_secret()[0]) + with patch.dict(os.environ, {"TOOLSTACK_CHANNEL_SECRET": "channel-key"}, clear=True): + self.assertFalse(handler._verify_channel_secret()[0]) + handler.headers["X-Toolstack-Secret"] = "channel-key" + self.assertTrue(handler._verify_channel_secret()[0]) + + def test_login_sample_redacted(self): + cfg = load_config(Path(__file__).resolve().parents[2] / "tools/rest_demo/toolyard.toml") + self.assertTrue(cfg.operations["login"].redact_response_body) + self.assertTrue(cfg.operations["login"].redact_response_headers) + + def test_writeback_requires_explicit_visibility(self): + source = Path(__file__).resolve().parents[2] / "tools/rest_demo/toolyard.toml" + text = source.read_text().replace("redact_response_body = true\n", "") + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "toolyard.toml" + path.write_text(text) + with self.assertRaisesRegex(ValueError, "writeback requires explicit"): + load_config(path) diff --git a/toolyard/README.md b/toolyard/README.md index f6ab9f2..4fc3bb8 100644 --- a/toolyard/README.md +++ b/toolyard/README.md @@ -79,52 +79,83 @@ hands the values to the tool, never to the broker. ## Runners -- **`process`** (default, zero infra): runs the tool as a local subprocess with its - secrets in a private `0700` dir pointed to by `$TOOLSTACK_SECRETS_DIR`. Binds - loopback only. No isolation. -- **`docker`**: runs the tool in a container with its secrets mounted at - `/run/secrets`, publishing the port to host loopback (`-p 127.0.0.1:...`). -- **`sandbox`** (OS-native, no container/VM): resolves to **`seatbelt`** on macOS and - **`bwrap`** on Linux. Both confine the tool's **network**: outbound is denied by default, - and a tool with an `egress` allowlist reaches the broker's loopback egress proxy on a - single port and nothing else (the proxy enforces the host allow/deny). The tool still - serves the broker on its loopback port. Filesystem/syscall confinement is the next step. - - `seatbelt`: wraps the launch in `sandbox-exec` with a per-tool SBPL profile. - - `bwrap`: confines egress with a per-tool cgroup v2 + nftables rule installed by the - small privileged **`netguard`** helper, and adds bubblewrap fs/pid/ipc isolation when the - host permits an unprivileged user namespace (otherwise it confines egress only and logs a - warning). `netguard` needs root for nftables/cgroups; keep the broker unprivileged with a - locked-down NOPASSWD sudoers rule for exactly that helper, e.g.: - - ``` - # /etc/sudoers.d/toolyard-netguard - broker ALL=(root) NOPASSWD: /path/to/python -m toolyard.netguard run *, \ - /path/to/python -m toolyard.netguard teardown * - ``` +- **`process`** (default): trusted subprocesses retrieve workload values through + SPS using a scoped capability, separate from the API channel key. No isolation. +- **`docker`**: experimental no-SPS lifecycle only, publishing to host loopback. + Docker+SPS and secret-bearing tool combinations are refused. +- **`sandbox`** selects **`seatbelt`** on macOS (network-only development backend, + no SPS support) or **`bwrap`** on Linux (**disabled**). Do not authorize mutable + Python application code through sudo. Legacy Linux-native records produce an + operator-cleanup error rather than invoking an unsafe helper. + +### Current supported boundary + +Process mode is **trusted tools only**. Private state is mode 0600 in a 0700 +directory, but unrestricted same-owner processes can still read it; this is not +OS isolation. The independent `TOOLSTACK_CHANNEL_SECRET` authenticates tool API +requests and is never an SPS credential. Tools refuse missing channel credentials. +The broker reads only `TOOLSTACK_TOOLYARD_CHANNEL_STATE` (default `channels.json` +beside private `state.json`), shaped as `toolid -> {channel_secret: value}`. +Runner private state retains `e_secret`; do not give the broker that file. +Docker and Seatbelt with SPS are refused pending complete lifecycle validation. + +Process tools declaring secrets refuse startup when SPS configuration is absent. +`TOOLSTACK_SPS_SKIP=1` is an explicit, logged **trusted-process development/test** +opt-out only; it does not enable secret-bearing Docker/Seatbelt/Bwrap tools. +Invalid configured SPS settings raise a configuration error, not a secretless fallback. + +The egress proxy shares one 30-second socket deadline across request ingress, +upstream send/headers/streamed body and CONNECT (5-second socket inactivity, +1 MiB request, 4 MiB response/tunnel, 16 admitted handlers). Synchronous OS DNS +is a trusted-host dependency and cannot be preempted: a resolver can retain an +admitted handler until it returns. Late resolution is refused before connection; +a late connection is closed before sending any request bytes. No hard real-time +full-operation bound is claimed. Deadline aborts and partial responses **do not +roll back upstream effects**. No resolver service or unbounded worker pool is used. +Writeback operations must explicitly choose body and header response visibility; +writeback alone does not redact a returned secret. + +The approved release boundary is a dedicated Toolstack VM with trusted tools +sharing its account and agents outside using only the broker. Mac same-host/ +same-account use is development-only, not secret isolation. Enforced per-tool +accounts/filesystems are future hardening, not this release's promise. + +`TOOLSTACK_TOOLYARD_STATE` overrides the private state path; absent a channel +override, both broker and runner use its sibling `channels.json`. Existing +destination directories must already be owner-private; linked, foreign-owned, +or broadly accessible destinations are refused without chmod. Use canonical +directory paths. Child environment is allowlisted to runtime plumbing; provider +and registration-master credentials are not inherited. Workload-specific values +should come through SPS, not ambient environment. + +Bundled echo Dockerfiles require repository-root build context, for example +`docker build -f tools/echo_api/Dockerfile .` (substitute `echo_mcp` as needed). +The Docker runner selects that context for bundled echo tools; custom tool +Dockerfiles retain tool-directory context. Echo images copy their manifests and +SDK source. This is a source packaging repair, not a validated Docker+SPS recipe; +no image build or daemon check was performed. ## CLI ```bash # start one tool (or all, if no id) with the process backend -python3 -m toolyard.cli up echo --secrets secrets.toml +python3 -m toolyard.cli up echo # or the docker backend (builds the tool's Dockerfile) -python3 -m toolyard.cli up echo --secrets secrets.toml --backend docker +# Docker is experimental and refuses SPS/secret-bearing combinations. python3 -m toolyard.cli ls python3 -m toolyard.cli down echo python3 -m toolyard.cli reload echo ``` -Defaults: `--root` = `$TOOLSTACK_TOOLS_ROOT` or `tools`; `--secrets` = -`$TOOLSTACK_SECRETS_FILE` or `secrets.toml`; `--backend` = `$TOOLSTACK_RUNNER` or +Defaults: `--root` = `$TOOLSTACK_TOOLS_ROOT` or `tools`; `--backend` = `$TOOLSTACK_RUNNER` or `process`. ## End to end with the broker ```bash -cp secrets.example.toml secrets.toml -python3 -m toolyard.cli up echo --secrets secrets.toml # start the tool +python3 -m toolyard.cli up echo # configured SPS python3 -m broker.brokerctl create-caller --name hermes --allow echo.say TOOLSTACK_TOOLS_ROOT=tools python3 -m broker.server # broker reads the registry # then POST /v1/actions/echo.say to the broker with the caller's token @@ -134,7 +165,7 @@ TOOLSTACK_TOOLS_ROOT=tools python3 -m broker.server # broker reads th ```bash python3 -m unittest discover -s toolyard/tests -t . -TOOLSTACK_TEST_DOCKER=1 python3 -m unittest toolyard.tests.test_runner # include docker e2e +# Native and Docker integration checks are opt-in; neither was run for this tranche. ``` ## Writable secrets diff --git a/toolyard/cli.py b/toolyard/cli.py index 86f8072..209b531 100644 --- a/toolyard/cli.py +++ b/toolyard/cli.py @@ -15,31 +15,120 @@ import json import logging import os +import fcntl +import tempfile +import stat +from contextlib import contextmanager from dataclasses import asdict from pathlib import Path from .config import discover -from .runner import RunningTool, get_runner +from .runner import RunningTool, RunnerStartCleanupError, get_runner def _state_path() -> Path: + explicit = os.environ.get("TOOLSTACK_TOOLYARD_STATE") + if explicit: + return Path(explicit) state = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state") return Path(state) / "toolstack" / "toolyard" / "state.json" def _load_state() -> dict: path = _state_path() + _private_parent(path) + _validate_file(path) return json.loads(path.read_text()) if path.exists() else {} +def _private_parent(path: Path) -> None: + """Create private directories, but never repair arbitrary existing permissions. + + Refuse symlinks anywhere in the parent chain; existing leaf directories must + already be owned by us and private. Shared ancestors (e.g. /tmp) aren't chmodded. + Same-owner hostile processes remain outside this trusted-host threat model. + """ + parent = path.absolute().parent + for ancestor in reversed((parent, *parent.parents)): + try: + info = ancestor.lstat() + except FileNotFoundError: + ancestor.mkdir(mode=0o700, exist_ok=True) + info = ancestor.lstat() + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise ValueError(f"unsafe state directory: {ancestor}") + info = parent.lstat() + if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & 0o077: + raise ValueError(f"state directory must be owner-private: {parent}") + + +def _validate_file(path: Path) -> None: + try: + info = path.lstat() + except FileNotFoundError: + return + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) & 0o077 or info.st_nlink != 1): + raise ValueError(f"state file must be private, regular and unlinked: {path}") + + def _save_state(state: dict) -> None: + """Publish private state then the credential-only view; caller holds state_lock.""" path = _state_path() - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(path.name + ".tmp") - tmp.write_text(json.dumps(state, indent=2)) - os.replace(tmp, path) # atomic: a torn write can't orphan the running tools' records + channel_path = Path(os.environ.get("TOOLSTACK_TOOLYARD_CHANNEL_STATE", + str(path.with_name("channels.json")))) + destinations = [os.path.abspath(p) for p in (path, channel_path, path.with_suffix(".lock"))] + if len(set(destinations)) != len(destinations): + raise ValueError("private state, channel view and lock must have different paths") + for target in (path, channel_path): + _private_parent(target) + _validate_file(target) + _atomic_private_json(path, state) + channels = {key: {"channel_secret": rec["channel_secret"]} + for key, rec in state.items() if rec.get("channel_secret")} + _atomic_private_json(channel_path, channels) + + +def _atomic_private_json(path: Path, value: dict) -> None: + _private_parent(path) + _validate_file(path) + fd, tmp = tempfile.mkstemp(prefix=".state-", dir=path.parent) + try: + with os.fdopen(fd, "w") as stream: + json.dump(value, stream, indent=2) + stream.flush() + os.fsync(stream.fileno()) + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + +@contextmanager +def state_lock(): + """Hold across the entire load / lifecycle / save transaction, not just rename.""" + path = _state_path() + if path.with_suffix(".lock") == path: + raise ValueError("private state path must differ from lock path") + _private_parent(path) + _validate_file(path.with_suffix(".lock")) + fd = os.open(path.with_suffix(".lock"), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + os.fchmod(fd, 0o600) + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + os.close(fd) +def _locked(fn): + def call(*args, **kwargs): + with state_lock(): + return fn(*args, **kwargs) + return call + + +@_locked def cmd_up(args) -> None: root = args.root or os.environ.get("TOOLSTACK_TOOLS_ROOT") or "tools" defs = {d.id: d for d in discover(root)} @@ -55,17 +144,35 @@ def cmd_up(args) -> None: if recorded_runner.is_alive(recorded): print(f"{tool_id}: already running") continue - recorded_runner.stop(recorded) + try: + recorded_runner.stop(recorded) + except RunnerStartCleanupError as exc: + state[tool_id] = asdict(exc.running) + _save_state(state) + raise del state[tool_id] # Persist the cleanup before starting. If start fails, the dead record must # not make every later `up` look successful while doing nothing. _save_state(state) - running = runner.start(defs[tool_id]) + try: + running = runner.start(defs[tool_id]) + except RunnerStartCleanupError as exc: + state[tool_id] = asdict(exc.running) + _save_state(state) + raise state[tool_id] = asdict(running) + try: + _save_state(state) + except Exception: + runner.stop(running) + del state[tool_id] + _save_state(state) + raise print(f"{tool_id}: started ({running.backend}) on 127.0.0.1:{running.port}") _save_state(state) +@_locked def cmd_down(args) -> None: state = _load_state() targets = [args.id] if args.id else list(state) @@ -74,8 +181,14 @@ def cmd_down(args) -> None: print(f"{tool_id}: not running") continue running = RunningTool(**state[tool_id]) - get_runner(running.backend).stop(running) + try: + get_runner(running.backend).stop(running) + except RunnerStartCleanupError as exc: + state[tool_id] = asdict(exc.running) + _save_state(state) + raise del state[tool_id] + _save_state(state) print(f"{tool_id}: stopped") _save_state(state) diff --git a/toolyard/egress_proxy.py b/toolyard/egress_proxy.py index 0aff859..41b7f0a 100644 --- a/toolyard/egress_proxy.py +++ b/toolyard/egress_proxy.py @@ -24,6 +24,8 @@ import logging import select import socket +import threading +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlsplit, urlunsplit @@ -31,13 +33,20 @@ _CONNECT_TIMEOUT = 30 # opening the upstream connection _TUNNEL_IDLE = 300 # a CONNECT tunnel with no traffic this long is torn down +_TOTAL_DEADLINE = 30 +_MAX_BODY = 1024 * 1024 +_MAX_RESPONSE = 4 * 1024 * 1024 -def _pump(a: socket.socket, b: socket.socket) -> None: +def _pump(a: socket.socket, b: socket.socket, deadline: float) -> None: """Splice two sockets until either closes or the tunnel goes idle.""" conns = (a, b) + transferred = 0 while True: - readable, _, errored = select.select(conns, [], conns, _TUNNEL_IDLE) + remaining = deadline - time.monotonic() + if remaining <= 0: + return + readable, _, errored = select.select(conns, [], conns, min(_TUNNEL_IDLE, remaining)) if errored or not readable: # error or idle timeout return for src in readable: @@ -48,6 +57,9 @@ def _pump(a: socket.socket, b: socket.socket) -> None: return if not chunk: return + transferred += len(chunk) + if transferred > _MAX_RESPONSE: + return try: dst.sendall(chunk) except OSError: @@ -58,9 +70,68 @@ def _handler(allow: frozenset[str]): class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" + def setup(self): + super().setup() + self.connection.settimeout(5) + self.deadline = time.monotonic() + _TOTAL_DEADLINE + self.upstream_socket = None + self.socket_lock = threading.Lock() + self.expired = False + def expire(): + with self.socket_lock: + self.expired = True + for connection in (self.connection, self.upstream_socket): + if connection is not None: + try: + connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.timer = threading.Timer(_TOTAL_DEADLINE, expire) + self.timer.daemon = True + self.timer.start() + + def finish(self): + self.timer.cancel() + self.timer.join() + super().finish() + + def _remaining(self): + remaining = self.deadline - time.monotonic() + if remaining <= 0 or self.expired: + raise TimeoutError("egress deadline") + return min(5, remaining) + + def _connect(self, address): + # OS DNS is synchronous and cannot be preempted here. Resolve first, + # then refuse late completion BEFORE connect or any request bytes. + self._remaining() + addresses = socket.getaddrinfo(*address, type=socket.SOCK_STREAM) + self._remaining() + for family, kind, proto, _, sockaddr in addresses: + upstream = socket.socket(family, kind, proto) + try: + # Publish before connect under the expiry lock: the timer + # cannot miss a newly connected/published socket. + with self.socket_lock: + upstream.settimeout(self._remaining()) + self.upstream_socket = upstream + upstream.connect(sockaddr) + upstream.settimeout(self._remaining()) + return upstream + except OSError: + upstream.close() + self._remaining() + raise OSError("upstream failed") + def log_message(self, *args) -> None: pass # denials are logged explicitly; don't spam per-request access lines + def send_error(self, *args, **kwargs): + try: + super().send_error(*args, **kwargs) + except OSError: + self.close_connection = True # deadline/disconnected peer: best effort + def _deny(self, host: str) -> None: log.warning("egress denied: %s not in allowlist", host) self.close_connection = True @@ -77,14 +148,17 @@ def do_CONNECT(self) -> None: if host not in allow: return self._deny(host) try: - upstream = socket.create_connection((host, int(port or 443)), _CONNECT_TIMEOUT) - except OSError: - return self.send_error(502, f"cannot reach {host}") + upstream = self._connect((host, int(port or 443))) + except (OSError, ValueError): + return self.send_error(502, "upstream failed") try: + self._remaining() self.send_response(200, "Connection established") self.end_headers() self.wfile.flush() - _pump(self.connection, upstream) + _pump(self.connection, upstream, self.deadline) + except OSError: + pass # expired/disconnected tunnel; never expose exception details finally: upstream.close() @@ -94,37 +168,101 @@ def _forward(self) -> None: host = parts.hostname if not host or host not in allow: return self._deny(host or "?") - length = int(self.headers.get("Content-Length", 0) or 0) + lengths = self.headers.get_all("Content-Length", []) + if self.headers.get("Transfer-Encoding") or len(lengths) > 1: + return self.send_error(400, "unsupported request framing") + raw_length = lengths[0] if lengths else "0" + if not raw_length.isascii() or not raw_length.isdecimal(): + return self.send_error(400, "invalid content length") + if len(raw_length) > 10 or int(raw_length) > _MAX_BODY: + return self.send_error(413, "request too large") + length = int(raw_length) body = self.rfile.read(length) if length else None + if length and len(body) != length: + return self.send_error(400, "incomplete request body") + if parts.scheme != "http" or parts.username or parts.password: + return self.send_error(400, "HTTP absolute URL required") origin_path = urlunsplit(("", "", parts.path or "/", parts.query, "")) headers = {k: v for k, v in self.headers.items() if k.lower() not in ("proxy-connection", "connection", "host")} + upstream = None + resp = None + started = False try: - upstream = http.client.HTTPConnection(host, parts.port or 80, timeout=_CONNECT_TIMEOUT) + upstream = http.client.HTTPConnection(host, parts.port or 80, + timeout=self._remaining()) + upstream.sock = self._connect((host, parts.port or 80)) + # Never allow HTTPConnection to reconnect after expiry. + upstream.auto_open = 0 + upstream.sock.settimeout(self._remaining()) upstream.request(self.command, origin_path, body=body, headers=headers) + upstream.sock.settimeout(self._remaining()) resp = upstream.getresponse() - data = resp.read() - except OSError: - return self.send_error(502, f"cannot reach {host}") - self.send_response(resp.status) - for key, value in resp.getheaders(): - # Content-Length is re-derived from the buffered body; hop-by-hop headers drop. - if key.lower() not in ("transfer-encoding", "connection", "content-length"): - self.send_header(key, value) - self.send_header("Content-Length", str(len(data))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(data) - upstream.close() + announced = resp.getheader("Content-Length") + if announced and (not announced.isdecimal() or int(announced) > _MAX_RESPONSE): + return self.send_error(502, "upstream response too large") + self.send_response(resp.status) + for key, value in resp.getheaders(): + if key.lower() not in ("transfer-encoding", "connection", "content-length", "keep-alive", "upgrade", "trailer"): + self.send_header(key, value) + self.send_header("Connection", "close") + self.end_headers() + started = True + total = 0 + while self.command != "HEAD": + remaining = self.deadline - time.monotonic() + if remaining <= 0: + return + if upstream.sock: + upstream.sock.settimeout(min(5, remaining)) + chunk = resp.read1(min(65536, _MAX_RESPONSE - total + 1)) + if not chunk: + break + total += len(chunk) + if total > _MAX_RESPONSE: + return + self.wfile.write(chunk) + except (OSError, ValueError, http.client.HTTPException): + if not started: + self.send_error(502, "upstream failed") + finally: + if resp is not None: + resp.close() + if upstream: + upstream.close() do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = _forward return Handler +class BoundedProxyServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, *args, **kwargs): + self.slots = threading.BoundedSemaphore(16) + super().__init__(*args, **kwargs) + + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + + def serve(port: int, allow) -> ThreadingHTTPServer: """Bind the proxy on 127.0.0.1: and return it (caller runs ``serve_forever``).""" - return ThreadingHTTPServer(("127.0.0.1", port), _handler(frozenset(allow))) + return BoundedProxyServer(("127.0.0.1", port), _handler(frozenset(allow))) def main() -> None: diff --git a/toolyard/netguard.py b/toolyard/netguard.py index 5f08760..ff75ca4 100644 --- a/toolyard/netguard.py +++ b/toolyard/netguard.py @@ -1,7 +1,8 @@ """Privileged network-egress guard for the Linux bubblewrap tool sandbox. -Run as root through a locked-down ``sudo`` rule, invoked only by the toolyard's -``BwrapRunner``. It confines ONE tool's outbound network with a per-tool cgroup v2 plus an +DISABLED for runner launch: do not authorize this mutable Python module with sudo. +A validated immutable privileged helper is required before Linux native support. +The retained implementation models ONE tool's outbound network with a per-tool cgroup v2 plus an nftables rule: the tool may answer the broker (established connections) and, when it has an egress allowlist, reach the broker's loopback egress proxy on a single port -- every other outbound packet is dropped. This is the Linux counterpart of the macOS Seatbelt profile's @@ -18,16 +19,14 @@ Kill anything left in the tool's cgroup, remove its nft rule, and remove the cgroup. Idempotent, so it is safe to call for a tool that never fully started. -Privilege containment: the tool always runs as the *invoking* user, taken from ``SUDO_UID`` / -``SUDO_GID`` (never from an argument), and the helper refuses to run as anything but root -dropping to a non-root user. A caller therefore cannot use it to gain privilege -- it only -ever lands back at the unprivileged user the broker already runs as. Root is confined to this -one small helper; the broker itself stays unprivileged. +Dropping to the invoking user does not make importing mutable application code as +root safe. These functions remain for fake lifecycle tests, not a deployment recipe. """ from __future__ import annotations import argparse +import hashlib import os import re import subprocess @@ -51,7 +50,7 @@ def _cgroup_dir(tool_id: str) -> str: def _table(tool_id: str) -> str: # one nft table per tool, named in nft's identifier charset (alnum + underscore). - return "toolyard_" + re.sub(r"[^a-z0-9_]", "_", tool_id) + return "toolyard_" + hashlib.sha256(tool_id.encode("utf-8")).hexdigest() def _ruleset(tool_id: str, proxy_port: int | None) -> str: diff --git a/toolyard/runner.py b/toolyard/runner.py index 9faa6ae..0a3b9e7 100644 --- a/toolyard/runner.py +++ b/toolyard/runner.py @@ -1,16 +1,11 @@ -"""Tool runners. - -`ProcessRunner` (dev/CI, zero infra) starts the tool as a local subprocess with -its secrets written to a private 0700 dir, pointed at by `$TOOLSTACK_SECRETS_DIR`. -`DockerRunner` (production) runs the tool in a container with its secrets mounted -at `/run/secrets`. Both keep secret values entirely off the broker. - -On stop, the secrets dir is removed; if `start` fails partway it cleans up the -(world-readable) secrets dir and any write-proxy it spawned, so a failed start never -leaks secret material on disk or orphans the proxy. Docker subprocess calls carry -timeouts so a wedged daemon can't hang the caller (start/stop runs inside an admin -request). (Hardening note: production should inject secrets into a container tmpfs at -start so they never touch host disk; the bind mount here is the simpler form.) +"""Tool runners. Process mode is for trusted same-owner tools only. +File modes and separate channel credentials are not OS isolation: another +unrestricted process owned by the same account can read private runner state. + +Process tools receive an SPS capability and an independent API channel capability. +The broker channel view contains only the latter. Response visibility still depends +on each tool: writeback is not automatic redaction. Docker/Seatbelt SPS lifecycle +is not yet validated, and the mutable privileged Linux helper launch is disabled. """ from __future__ import annotations @@ -21,13 +16,12 @@ import re import secrets as _secrets import shlex -import shutil import signal import socket import subprocess import sys import time -from dataclasses import dataclass +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Protocol @@ -59,11 +53,25 @@ log = logging.getLogger(__name__) +def _child_environment() -> dict[str, str]: + """Only runtime plumbing crosses into tools/helpers; SPS/provider masters don't. + + Custom workload credentials belong in SPS, not inherited runner environment. + This reduces accidental disclosure, not same-account filesystem authority. + """ + allowed = {"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "TZ", + "PYTHONPATH", "VIRTUAL_ENV", "PYTHONUNBUFFERED", "SSL_CERT_FILE", + "SSL_CERT_DIR", "TOOLSTACK_SPS_FAKE"} + return {key: value for key, value in os.environ.items() + if key in allowed or key.startswith("LC_")} + + def _tool_log_path(tool_def: ToolDef) -> Path: """Per-tool logfile in the tool folder (`logs/tool.log`) so tool output travels with the tool, not an opaque global state directory.""" d = tool_def.path / "logs" - d.mkdir(parents=True, exist_ok=True) + d.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(d, 0o700) return d / "tool.log" @@ -94,10 +102,35 @@ def _boot_id() -> str | None: return None +def _process_identity(pid: str | int | None) -> str | None: + if pid is None: + return None + try: + if sys.platform.startswith("linux"): + text = Path(f"/proc/{int(pid)}/stat").read_text() + return text[text.rfind(")") + 2:].split()[19] + if sys.platform == "darwin": + # Darwin proc_bsdinfo ends with uint64 start_tvsec/start_tvusec. + # Unlike ps lstart, this retains microseconds for same-boot reuse. + import ctypes + import struct + library = ctypes.CDLL("/usr/lib/libproc.dylib") + info = ctypes.create_string_buffer(136) + size = library.proc_pidinfo(int(pid), 3, 0, info, len(info)) + if size == len(info): + seconds, micros = struct.unpack_from("=QQ", info.raw, 120) + return f"{seconds}:{micros}" + return None + except (OSError, ValueError, subprocess.SubprocessError): + return None + + def _pids_are_current(running: "RunningTool") -> bool: """Whether persisted PIDs in this record belong to the current boot.""" current = _boot_id() - return current is not None and running.boot_id == current + return (current is not None and running.boot_id == current and + running.process_identity is not None and + _process_identity(running.handle) == running.process_identity) # ---- SPS re-registration helper (used by the admin watchdog) --------------- @@ -107,7 +140,39 @@ def _pids_are_current(running: "RunningTool") -> bool: # The runner re-registers on every fresh tool start; this helper re-registers # already-running tools without bouncing them, called by the admin watchdog # when it sees a new SPS boot_id. -def reregister_all_with_sps(*, tools_root: str, tool_dirs, sps_env_path: str) -> int: +def reregister_all_with_sps(**kwargs) -> int: + """Compatibility count wrapper; watchdogs should use the detailed helper.""" + return sum(error is None for error in reregister_with_sps_results(**kwargs).values()) + + +@dataclass(frozen=True) +class SPSRecoveryResult: + succeeded: tuple[str, ...] + failed: tuple[str, ...] + + @property + def complete(self) -> bool: + return not self.failed + + @property + def count(self) -> int: + return len(self.succeeded) + + +def reregister_all_with_sps_detailed(**kwargs) -> SPSRecoveryResult: + results = reregister_with_sps_results(**kwargs) + return SPSRecoveryResult(tuple(k for k, v in results.items() if v is None), + tuple(k for k, v in results.items() if v is not None)) + + +def reregister_with_sps_results(*, tools_root: str, tool_dirs, sps_env_path: str) -> dict[str, str | None]: + from .cli import state_lock + with state_lock(): + return _reregister_results(tools_root=tools_root, tool_dirs=tool_dirs, + sps_env_path=sps_env_path) + + +def _reregister_results(*, tools_root: str, tool_dirs, sps_env_path: str) -> dict[str, str | None]: """Re-register every tool currently in the toolyard state file with SPS. Idempotent: calling it for tools that are already registered is a no-op @@ -119,12 +184,12 @@ def reregister_all_with_sps(*, tools_root: str, tool_dirs, sps_env_path: str) -> """ if not os.path.exists(sps_env_path): log.warning("SPS re-register: sps.env not found at %s; skipping", sps_env_path) - return 0 + raise RunnerConfigError("SPS configuration missing; recovery must retry") from .cli import _load_state from .config import discover, load as load_tool state = _load_state() if not state: - return 0 + return {} defs: dict[str, ToolDef] = {} if tools_root: for d in discover(tools_root): @@ -136,7 +201,7 @@ def reregister_all_with_sps(*, tools_root: str, tool_dirs, sps_env_path: str) -> defs[load_tool(toml_path).id] = load_tool(toml_path) except Exception as exc: log.warning("SPS re-register: cannot load %s: %s", toml_path, exc) - count = 0 + results = {} for tool_id, rec in state.items(): if not isinstance(rec, dict): continue @@ -157,14 +222,16 @@ def reregister_all_with_sps(*, tools_root: str, tool_dirs, sps_env_path: str) -> tool_def = defs.get(tool_id) if tool_def is None: log.warning("SPS re-register: tool %s has e_secret but no tool_def", tool_id) + results[tool_id] = "tool definition missing" continue try: _sps_register(tool_def, e_secret, sps_env_path) - count += 1 + results[tool_id] = None log.info("SPS re-registered %s", tool_id) except Exception as exc: log.warning("SPS re-register %s failed: %s", tool_id, exc) - return count + results[tool_id] = "registration failed" + return results # ---- SPS integration (Phase 2) -------------------------------------------- @@ -174,20 +241,50 @@ def _mint_e_secret() -> str: return _secrets.token_hex(64) +class RunnerConfigError(ValueError): + """Invalid runner/SPS configuration (safe for ordinary exception handlers).""" + + +def strict_bool(value: str) -> bool: + from sps.config import parse_bool + try: + return parse_bool(value, name="TOOLSTACK_SPS_VERIFY") + except ValueError as exc: + raise RunnerConfigError(str(exc)) from exc + + +@dataclass(frozen=True) +class ResolvedSPS: + sp_host: str + sp_port: int + sp_secret: str = field(repr=False) + sp_tls_ca: str + verify: bool + + +def _resolve_sps(path): + from sps.config import load_config + try: + cfg = load_config(path) + return ResolvedSPS(os.environ.get("TOOLSTACK_SPS_HOST", cfg.sp_host), + int(os.environ.get("TOOLSTACK_SPS_PORT", cfg.sp_port)), + cfg.sp_secret, os.environ.get("TOOLSTACK_SPS_CA", cfg.sp_tls_ca), + strict_bool(os.environ.get("TOOLSTACK_SPS_VERIFY", "1"))) + except (OSError, ValueError, RuntimeError) as exc: + raise RunnerConfigError(f"invalid SPS configuration: {exc}") from exc + + def _check_sps_env(path: str) -> str: - """Fail closed: sps.env must be present and mode 0600 (the SPS config - module raises ConfigModeError if not). Called by `runner.start()` BEFORE - `_sps_register` so a misconfigured SPS is caught at the gate rather than - at the wire.""" + """Compatibility validation entry point; start resolves/validates once instead.""" from sps.config import ConfigModeError, load_config try: load_config(path) except ConfigModeError as exc: - raise SystemExit(f"sps.env: {exc}") from exc + raise RunnerConfigError(f"sps.env: {exc}") from exc except FileNotFoundError as exc: - raise SystemExit(f"sps.env not found at {path}: {exc}") from exc + raise RunnerConfigError(f"sps.env not found at {path}: {exc}") from exc except ValueError as exc: - raise SystemExit(f"sps.env: {exc}") from exc + raise RunnerConfigError(f"sps.env: {exc}") from exc return path @@ -195,15 +292,13 @@ def _sps_register(tool_def: ToolDef, e_secret: str, sps_env_path: str) -> None: """Open a TLS connection to SPS, verify the cert against `sp_tls_ca`, send a `register` JSON line carrying the tool's CS_TUPLE list.""" from sps.client import SPSClient - from sps.config import load_config - cfg = load_config(sps_env_path) - verify = os.environ.get("TOOLSTACK_SPS_VERIFY", "1") == "1" + cfg = _resolve_sps(sps_env_path) if isinstance(sps_env_path, str) else sps_env_path client = SPSClient( - host=os.environ.get("TOOLSTACK_SPS_HOST", cfg.sp_host), - port=int(os.environ.get("TOOLSTACK_SPS_PORT", str(cfg.sp_port))), + host=cfg.sp_host, + port=cfg.sp_port, sp_secret=cfg.sp_secret, ca_file=cfg.sp_tls_ca, - verify=verify, + verify=cfg.verify, ) cs_tuples = [ {"name": s.name, "field": s.field, "item": s.item, "writable": bool(s.writable)} @@ -218,27 +313,49 @@ def _sps_unregister(tool_id: str, sps_env_path: str) -> None: from sps.client import SPSClient from sps.config import load_config try: - cfg = load_config(sps_env_path) + cfg = _resolve_sps(sps_env_path) except Exception as exc: log.warning("SPS unregister %s: cannot load config %s: %s", tool_id, sps_env_path, exc) return try: - verify = os.environ.get("TOOLSTACK_SPS_VERIFY", "1") == "1" client = SPSClient( - host=os.environ.get("TOOLSTACK_SPS_HOST", cfg.sp_host), - port=int(os.environ.get("TOOLSTACK_SPS_PORT", str(cfg.sp_port))), + host=cfg.sp_host, + port=cfg.sp_port, sp_secret=cfg.sp_secret, ca_file=cfg.sp_tls_ca, - verify=verify, + verify=cfg.verify, ) client.unregister(tool_id) except Exception as exc: log.warning("SPS unregister %s failed: %s", tool_id, exc) -def _terminate(pid: str | int | None) -> None: - """SIGTERM a detached process group (a setpgroup=0 leader) and reap it; best-effort. - Shared by the write proxy, the egress proxy, the log follower, and start()'s cleanup.""" +def _pid_absent(pid: int) -> bool: + """Only ESRCH is positive absence; snapshot failure and EPERM are not. + + Signal zero is a non-delivering probe of this single PID, never its group. + It cannot establish the fate of arbitrary descendants of a departed leader. + """ + if pid <= 1: + raise RuntimeError("invalid process handle; retain state") + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + except OSError as exc: + raise RuntimeError("process absence unconfirmed; retain state") from exc + return False + + +def _terminate(pid: str | int | None, identity: str | None = None, + boot_id: str | None = None) -> None: + """Reap before signalling; require a positive boot/start match for each signal. + + Missing identity, permission failure and an unavailable process snapshot are + uncertainty, not evidence of exit. Reaping, positive OS absence, or a known + different boot confirm that the recorded runtime no longer exists. + This is trusted same-owner bookkeeping, not hostile-owner isolation. + """ if pid is None: return try: @@ -252,26 +369,75 @@ def _terminate(pid: str | int | None) -> None: if pid_int <= 1: log.warning("_terminate: refusing unsafe pid %r (≤1 would signal init or every process)", pid) return - try: - os.killpg(pid_int, signal.SIGTERM) - except (ProcessLookupError, PermissionError): - pass - try: - os.waitpid(pid_int, 0) - except (ChildProcessError, ProcessLookupError): - pass + def reaped(): + try: + return os.waitpid(pid_int, os.WNOHANG)[0] == pid_int + except ChildProcessError: + return False + # Never reap a potentially reused PID when a known identity differs. An + # unknown snapshot can still be resolved by a positive own-child waitpid. + def check_exit(): + current_boot = _boot_id() + if boot_id and current_boot and boot_id != current_boot: + return True # No PID probes, reaps or signals across a known reboot. + if boot_id and current_boot is None: + raise RuntimeError("process boot identity unconfirmed; retain state") + current = _process_identity(pid_int) + if identity is not None and current is not None and current != identity: + # Original leader is gone, but its group may survive. Do not reap or + # signal the replacement and do not invent a whole-cleanup receipt. + raise RuntimeError("process identity changed; retain state") + if reaped(): + return True + if not boot_id or not current_boot: + raise RuntimeError("process boot identity unconfirmed; retain state") + return _pid_absent(pid_int) -def _cleanup_partial_start(child_pid: int | None = None, - log_pid: str | None = None, - egress_pid: str | None = None) -> None: - """Best-effort cleanup when start() fails partway. Phase 5: no secrets - dir mount, no write-proxy socket dir -- just reap any zombie children - the tool wouldn't have inherited (e.g. bwrap sudo launcher, the - docker log follower). start() runs inside the long-lived admin handler; - a leaked zombie per failed start would accrue there.""" - for pid in (child_pid, log_pid, egress_pid): - _terminate(pid) + if check_exit(): + return + for sig, delay in ((signal.SIGTERM, 2), (signal.SIGKILL, 1)): + if (not boot_id or _boot_id() != boot_id or not identity or + _process_identity(pid_int) != identity): + raise RuntimeError("process identity unconfirmed; retain state") + try: + os.killpg(pid_int, sig) + except ProcessLookupError: + pass + except PermissionError as exc: + # Darwin zombies can report EPERM: reaping/ESRCH can still resolve it. + if check_exit(): + return + raise RuntimeError("process signal denied; retain state") from exc + deadline = time.monotonic() + delay + while time.monotonic() < deadline: + if check_exit(): + return + time.sleep(0.05) + if check_exit(): + return + raise RuntimeError("process stop unconfirmed; retain state") + + +def _cleanup_partial_start(running: "RunningTool") -> "RunningTool": + """Attempt every known child, preserving identities and positive exit receipts.""" + failed = False + children = [("log", running.log_pid), ("egress", running.egress_pid)] + if running.backend in ("process", "seatbelt"): + children.insert(0, ("process", running.handle or None)) + for kind, pid in children: + if pid is None or getattr(running, f"{kind}_exited"): + continue + try: + if int(pid) <= 1: + raise ValueError("invalid process handle") + _terminate(pid, getattr(running, f"{kind}_identity"), running.boot_id) + running = replace(running, **{f"{kind}_exited": True}) + except BaseException: + failed = True + if failed: + raise RunnerStartCleanupError(running) + return running @dataclass(frozen=True) @@ -293,6 +459,22 @@ class RunningTool: # A persisted PID may be recycled after reboot. Old records (without this field) # deserialize as unknown and are never probed or signalled by PID. boot_id: str | None = None + channel_secret: str | None = None + process_identity: str | None = None + log_identity: str | None = None + egress_identity: str | None = None + # Positive exit receipts (reap, ESRCH, known prior boot) survive retries. + process_exited: bool = False + log_exited: bool = False + egress_exited: bool = False + + +class RunnerStartCleanupError(RuntimeError): + """Start failed but cleanup is unconfirmed: persist running for later recovery.""" + + def __init__(self, running: RunningTool): + super().__init__(f"start cleanup failed for {running.tool_id}; retain state record") + self.running = running def _pick_free_port() -> int: @@ -316,32 +498,35 @@ def _start_egress_proxy(allow: tuple[str, ...]) -> tuple[str, int]: cmd = (f"exec {shlex.quote(sys.executable)} -m toolyard.egress_proxy " f"--port {port} {allow_args}") script = f"cd {shlex.quote(str(_REPO_ROOT))} && {cmd}" - pid = os.posix_spawn("/bin/sh", ["/bin/sh", "-c", script], os.environ, setpgroup=0) + pid = os.posix_spawn("/bin/sh", ["/bin/sh", "-c", script], _child_environment(), setpgroup=0) return str(pid), port def _stop_log_follower(running: RunningTool) -> None: - if _pids_are_current(running): - _terminate(running.log_pid) + if running.log_pid and not running.log_exited: + _terminate(running.log_pid, running.log_identity, running.boot_id) def _stop_egress_proxy(running: RunningTool) -> None: - if _pids_are_current(running): - _terminate(running.egress_pid) + if running.egress_pid and not running.egress_exited: + _terminate(running.egress_pid, running.egress_identity, running.boot_id) -def _start_docker_log_follower(container: str, log_path: Path) -> str: - fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) +def _start_docker_log_follower(container: str, log_path: Path, *, _on_spawn=None) -> str: + fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) + os.fchmod(fd, 0o600) try: pid = os.posix_spawn( "/usr/bin/env", ["env", "docker", "logs", "-f", container], - os.environ, + _child_environment(), setpgroup=0, file_actions=[(os.POSIX_SPAWN_DUP2, fd, 1), (os.POSIX_SPAWN_DUP2, fd, 2), (os.POSIX_SPAWN_CLOSE, fd)], ) + if _on_spawn is not None: + _on_spawn(str(pid)) finally: os.close(fd) return str(pid) @@ -412,34 +597,42 @@ def start(self, tool_def: ToolDef) -> RunningTool: raise ValueError(f"tool {tool_def.id} has no entrypoint.command") _check_port_free(tool_def.port) - # SPS integration: when configured (env file present, mode 0600), - # mint an ephemeral E_SECRET and register the tool with SPS so the - # tool can retrieve its secrets over TLS/TCP. When unconfigured (dev - # path), run the tool in "no-secrets" mode and let the tool report - # has_api_key: False. Tests can disable the SPS path entirely via - # TOOLSTACK_SPS_SKIP=1. + # Required secrets fail closed. Only explicit trusted-process development + # may opt out with TOOLSTACK_SPS_SKIP=1. sps_env_path = os.environ.get("TOOLSTACK_SPS_ENV", _DEFAULT_SPS_ENV) sps_active = ( os.environ.get("TOOLSTACK_SPS_SKIP") != "1" and os.path.exists(sps_env_path) ) - e_secret = _mint_e_secret() if sps_active else None - sps_registered = False + if (sps_active or tool_def.secrets) and self.backend != "process": + raise RunnerConfigError(f"{self.backend} + SPS lifecycle is not validated") if tool_def.secrets and not sps_active: - log.warning("tool %s declares secrets but SPS is not active; launching without secrets", + if os.environ.get("TOOLSTACK_SPS_SKIP") != "1": + raise RunnerConfigError("tool declares secrets but SPS configuration is absent") + log.warning("DEVELOPMENT ONLY: TOOLSTACK_SPS_SKIP=1; tool %s launches without declared secrets", tool_def.id) + channel_secret = _secrets.token_hex(64) + sps_cfg = None + e_secret = _mint_e_secret() if sps_active else None + sps_registered = False egress_pid = egress_port = None - child_pid = None log_path = _tool_log_path(tool_def) + running = RunningTool(tool_def.id, tool_def.port, self.backend, "", + log_path=str(log_path), boot_id=_boot_id(), + e_secret=e_secret, channel_secret=channel_secret) + + def mark_reaped(): + nonlocal running + running = replace(running, process_exited=True) + try: if sps_active: try: - _check_sps_env(sps_env_path) - _sps_register(tool_def, e_secret, sps_env_path) # type: ignore[arg-type] + sps_cfg = _resolve_sps(sps_env_path) + _sps_register(tool_def, e_secret, sps_cfg) sps_registered = True - except SystemExit: - # Mode-0600 failure already logged by _check_sps_env; do not - # silently keep going -- refuse to launch. + except RunnerConfigError: + # Configuration errors must not degrade to secretless launch. raise except Exception as exc: if tool_def.secrets: @@ -451,22 +644,28 @@ def start(self, tool_def: ToolDef) -> RunningTool: "continuing without SPS)", tool_def.id, exc) e_secret = None sps_registered = False + running = replace(running, e_secret=e_secret, + sps_host=sps_cfg.sp_host if sps_cfg else _DEFAULT_SPS_HOST, + sps_port=sps_cfg.sp_port if sps_cfg else _DEFAULT_SPS_PORT, + sps_ca=sps_cfg.sp_tls_ca if sps_cfg else None) policy = self._policy(tool_def) if policy.egress.allow: egress_pid, egress_port = _start_egress_proxy(policy.egress.allow) + running = replace(running, egress_pid=egress_pid) + running = replace(running, egress_identity=_process_identity(egress_pid)) env = { - **os.environ, + **_child_environment(), "TOOLSTACK_PORT": str(tool_def.port), "TOOLSTACK_TOOL_CONFIG": str(tool_def.path / "toolyard.toml"), } + env.pop("TOOLSTACK_E_SECRET", None) + env["TOOLSTACK_CHANNEL_SECRET"] = channel_secret if e_secret is not None: env["TOOLSTACK_E_SECRET"] = e_secret - env["TOOLSTACK_SPS_HOST"] = os.environ.get( - "TOOLSTACK_SPS_HOST", _DEFAULT_SPS_HOST) - env["TOOLSTACK_SPS_PORT"] = os.environ.get( - "TOOLSTACK_SPS_PORT", str(_DEFAULT_SPS_PORT)) - env["TOOLSTACK_SPS_CA"] = os.environ.get( - "TOOLSTACK_SPS_CA", _DEFAULT_SPS_CA) + env["TOOLSTACK_SPS_HOST"] = sps_cfg.sp_host + env["TOOLSTACK_SPS_PORT"] = str(sps_cfg.sp_port) + env["TOOLSTACK_SPS_CA"] = sps_cfg.sp_tls_ca + env["TOOLSTACK_SPS_VERIFY"] = "1" if sps_cfg.verify else "0" if egress_port: # Route the tool's outbound HTTP(S) through its egress proxy; the sandbox # allows outbound only to this port, so the proxy is the sole exit. @@ -477,7 +676,8 @@ def start(self, tool_def: ToolDef) -> RunningTool: # warn about; setpgroup=0 gives it its own group so stop() can killpg it. inner_script = f"cd {shlex.quote(str(tool_def.path))} && exec {_bind_interpreter(tool_def.command)}" executable, argv = self._spawn_argv(tool_def, inner_script, None, egress_port) - log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) + log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) + os.fchmod(log_fd, 0o600) try: pid = os.posix_spawn( executable, argv, env, setpgroup=0, @@ -485,58 +685,62 @@ def start(self, tool_def: ToolDef) -> RunningTool: (os.POSIX_SPAWN_DUP2, log_fd, 2), (os.POSIX_SPAWN_CLOSE, log_fd)], ) + running = replace(running, handle=str(pid)) + running = replace(running, process_identity=_process_identity(pid)) finally: os.close(log_fd) - child_pid = pid - running = RunningTool( - tool_def.id, tool_def.port, self.backend, str(pid), - log_path=str(log_path), egress_pid=egress_pid, - e_secret=e_secret, - sps_host=env.get("TOOLSTACK_SPS_HOST", _DEFAULT_SPS_HOST), - sps_port=int(env.get("TOOLSTACK_SPS_PORT", str(_DEFAULT_SPS_PORT))), - sps_ca=env.get("TOOLSTACK_SPS_CA") if e_secret else None, - boot_id=_boot_id(), - ) time.sleep(_READINESS_WAIT) - if not self.is_alive(running): + if not self.is_alive(running, _on_reaped=mark_reaped): raise RuntimeError(f"tool {tool_def.id} exited immediately on start: see {log_path}") log.info("started tool %s on 127.0.0.1:%s (pid %s, log %s, sps=%s)", tool_def.id, tool_def.port, pid, log_path, sps_registered) return running - except BaseException: - _cleanup_partial_start(child_pid, egress_pid=egress_pid) - if sps_registered: - try: - _sps_unregister(tool_def.id, sps_env_path) - except Exception: - pass + except BaseException as cause: + try: + _cleanup_partial_start(running) + except RunnerStartCleanupError as cleanup: + raise cleanup from cause + finally: + if sps_registered: + try: + _sps_unregister(tool_def.id, sps_env_path) + except Exception: + pass raise def stop(self, running: RunningTool) -> None: - if _pids_are_current(running): - pid = int(running.handle) - try: - os.killpg(pid, signal.SIGTERM) - except (ProcessLookupError, PermissionError): - pass - try: - os.waitpid(pid, 0) - except (ChildProcessError, ProcessLookupError): - pass - _stop_log_follower(running) - _stop_egress_proxy(running) - if running.e_secret: - try: - _sps_unregister( - running.tool_id, - os.environ.get("TOOLSTACK_SPS_ENV", _DEFAULT_SPS_ENV), - ) - except Exception as exc: - log.warning("SPS unregister %s on stop: %s", running.tool_id, exc) + try: + _cleanup_partial_start(running) + finally: + if running.e_secret: + try: + _sps_unregister( + running.tool_id, + os.environ.get("TOOLSTACK_SPS_ENV", _DEFAULT_SPS_ENV), + ) + except Exception: + log.warning("SPS unregister %s on stop failed", running.tool_id) log.info("stopped tool %s (pid %s)", running.tool_id, running.handle) - def is_alive(self, running: RunningTool) -> bool: + def is_alive(self, running: RunningTool, *, _on_reaped=None) -> bool: + if running.process_exited: + return False + if not running.handle: + return False # No main child spawned; stop still handles any helpers. pid = int(running.handle) + current_boot = _boot_id() + if running.boot_id and current_boot and running.boot_id != current_boot: + return False + current_identity = _process_identity(pid) + if not running.boot_id or not current_boot: + raise RunnerConfigError("process identity unknown; retain record") + if (running.process_identity is not None and current_identity is not None + and current_identity != running.process_identity): + return False + if not running.process_identity: + if _pid_absent(pid): + return False + raise RunnerConfigError("process identity unknown; retain record") # Reap-aware liveness. The tool is our own posix_spawn child, so poll it with waitpid # first: on Linux a just-exited child that has not been reaped is a zombie whose pid still # answers killpg(pid, 0) as "alive", which would let start()'s readiness check pass a tool @@ -546,19 +750,17 @@ def is_alive(self, running: RunningTool) -> bool: try: reaped, _ = os.waitpid(pid, os.WNOHANG) if reaped: + if _on_reaped is not None: + _on_reaped() return False return True # waitpid proved this is our current, live child except ChildProcessError: pass - if not _pids_are_current(running): - return False - try: - os.killpg(pid, 0) - return True - except (ProcessLookupError, PermissionError): - # On macOS a just-exited (zombie) group leader answers killpg with EPERM, not ESRCH; - # both mean dead here. (Linux's zombie case is caught by the waitpid poll above.) + if _pid_absent(pid): return False + if not _pids_are_current(running): + raise RuntimeError("process identity unconfirmed; retain record") + return True _SANDBOX_EXEC = "/usr/bin/sandbox-exec" # macOS Seatbelt wrapper @@ -625,59 +827,19 @@ def _spawn_argv(self, tool_def: ToolDef, inner_script: str, proxy_dir: str | Non _CGROUP_ROOT = "/sys/fs/cgroup" _NETGUARD_PARENT = "toolyard" # tool cgroups: /sys/fs/cgroup/toolyard/ (see netguard) -_SANDBOX_READINESS_WAIT = 1.0 # sudo + netguard + (bwrap) start is slower than a bare spawn - - -_SUDO = shutil.which("sudo") or "/usr/bin/sudo" # absolute: start() launches it via posix_spawn - - def _netguard_argv(*args: str) -> list[str]: - """The privileged netguard invocation. Run under ``sudo -n`` (non-interactive) using the - broker's own interpreter so ``-m toolyard.netguard`` resolves in the same environment. A - locked-down NOPASSWD sudoers rule for exactly this command is what keeps the broker itself - unprivileged (see ``toolyard/netguard.py``). ``sudo`` is resolved to an absolute path because - ``start()`` launches it with ``posix_spawn`` (which, unlike subprocess, does not search PATH).""" - return [_SUDO, "-n", sys.executable, "-m", "toolyard.netguard", *args] + """Compatibility entry point: mutable privileged Python launch is disabled.""" + raise RunnerConfigError("Linux native runner disabled: mutable sudo Python helper is unsafe; no validated privileged helper") @functools.lru_cache(maxsize=1) def _bwrap_usable() -> bool: - """Whether bwrap can create its (unprivileged) user namespace in the real launch context. - Some hosts block it -- e.g. Ubuntu 24.04 with ``apparmor_restrict_unprivileged_userns=1`` -- - and there the native runner still confines egress via cgroup+nft but skips bwrap's filesystem - isolation (the same scope the macOS Seatbelt runner has today). Probed once through the actual - sudo->netguard path so the answer matches how tools are launched, and cached for the process.""" - if shutil.which("bwrap") is None: - return False - probe = "bwrapprobe" - ok = False - try: - r = subprocess.run(_netguard_argv("run", "--tool", probe, "--", - "bwrap", "--dev-bind", "/", "/", "/bin/true"), - capture_output=True, text=True, timeout=15) - ok = r.returncode == 0 - except (OSError, subprocess.TimeoutExpired): - ok = False - finally: - subprocess.run(_netguard_argv("teardown", "--tool", probe), capture_output=True, timeout=15) - if not ok: - log.warning("bwrap unavailable in the sandbox launch context (unprivileged user " - "namespaces are likely restricted); the Linux native runner will confine " - "egress only, with no filesystem isolation (matching the Seatbelt runner)") - return ok + """Disabled; never probe sudo or modify host network/cgroups.""" + return False class BwrapRunner: - """Linux-native tool sandbox. Confines a tool's outbound network with a per-tool cgroup v2 + - nftables rule through the privileged ``netguard`` helper -- deny-all egress by default, new - outbound only to the tool's loopback egress proxy when it has an allowlist -- and, when the - host permits an unprivileged user namespace, additionally wraps the launch in bubblewrap for - filesystem/pid/ipc isolation. The tool runs as the broker's own (non-root) user. Lifecycle is - keyed to the cgroup (``cgroup.kill`` / reading ``cgroup.procs``), not a pid, since the launch - goes through ``sudo`` and the broker can neither signal nor reap the resulting root process. - - This is the Linux counterpart of :class:`SeatbeltRunner`; both confine the network today, with - filesystem/syscall tightening (Landlock + seccomp here) as the next step.""" + """Disabled Linux-native API, retained to diagnose legacy records honestly.""" backend = "bwrap" @@ -688,74 +850,10 @@ def _cgroup_procs(self, tool_id: str) -> str: return f"{_CGROUP_ROOT}/{_NETGUARD_PARENT}/{tool_id}/cgroup.procs" def start(self, tool_def: ToolDef) -> RunningTool: - if not tool_def.command: - raise ValueError(f"tool {tool_def.id} has no entrypoint.command") - _check_port_free(tool_def.port) - egress_pid = egress_port = None - launcher_pid = None - log_path = _tool_log_path(tool_def) - try: - policy = self._policy(tool_def) - if policy.egress.allow: - egress_pid, egress_port = _start_egress_proxy(policy.egress.allow) - # sudo scrubs the environment, so the tool's env is set inside the inner shell - # instead of inherited -- the values (paths/port/proxy URL, never secret values) - # reach the tool regardless of sudo, and the sudoers rule needs no SETENV. - env_assign = { - "TOOLSTACK_PORT": str(tool_def.port), - "TOOLSTACK_TOOL_CONFIG": str(tool_def.path / "toolyard.toml"), - } - if egress_port: - proxy_url = f"http://127.0.0.1:{egress_port}" - for var in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"): - env_assign[var] = proxy_url - assigns = " ".join(f"{k}={shlex.quote(v)}" for k, v in env_assign.items()) - inner = (f"cd {shlex.quote(str(tool_def.path))} && " - f"{assigns} exec {_bind_interpreter(tool_def.command)}") - launch = ["/bin/sh", "-c", inner] - if _bwrap_usable(): - # Share the host net ns (the cgroup+nft rule enforces egress); isolate fs/pid/ipc. - launch = ["bwrap", "--dev-bind", "/", "/", "--proc", "/proc", "--dev", "/dev", - "--unshare-pid", "--unshare-ipc", "--die-with-parent", *launch] - ng = _netguard_argv("run", "--tool", tool_def.id) - if egress_port: - ng += ["--proxy-port", str(egress_port)] - argv = [*ng, "--", *launch] - log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) - try: - launcher_pid = os.posix_spawn( - argv[0], argv, os.environ, setpgroup=0, - file_actions=[(os.POSIX_SPAWN_DUP2, log_fd, 1), - (os.POSIX_SPAWN_DUP2, log_fd, 2), - (os.POSIX_SPAWN_CLOSE, log_fd)], - ) - finally: - os.close(log_fd) - running = RunningTool(tool_def.id, tool_def.port, self.backend, tool_def.id, - log_path=str(log_path), - egress_pid=egress_pid, launcher_pid=str(launcher_pid), - boot_id=_boot_id()) - time.sleep(_SANDBOX_READINESS_WAIT) - if not self.is_alive(running): - raise RuntimeError(f"tool {tool_def.id} did not start under the sandbox: see {log_path}") - log.info("started tool %s on 127.0.0.1:%s under %s (cgroup %s, log %s)", - tool_def.id, tool_def.port, self.backend, tool_def.id, log_path) - return running - except BaseException: - subprocess.run(_netguard_argv("teardown", "--tool", tool_def.id), - capture_output=True, timeout=15) # remove the cgroup + nft rule - _cleanup_partial_start(child_pid=launcher_pid, egress_pid=egress_pid) - raise + raise RunnerConfigError("Linux native runner disabled: no validated privileged helper") def stop(self, running: RunningTool) -> None: - # cgroup.kill (via teardown) SIGKILLs the tool even though the broker can't signal it - # directly; then reap the sudo launcher. - subprocess.run(_netguard_argv("teardown", "--tool", running.handle), - capture_output=True, timeout=15) - if _pids_are_current(running): - _terminate(running.launcher_pid) - _stop_egress_proxy(running) - log.info("stopped tool %s (cgroup %s)", running.tool_id, running.handle) + raise RunnerConfigError(f"Linux native runner disabled; retain legacy record {running.tool_id}; operator cleanup required") def is_alive(self, running: RunningTool) -> bool: try: @@ -785,27 +883,44 @@ def start(self, tool_def: ToolDef) -> RunningTool: # from SPS via the env vars the runner injects. The container # only needs the toolyard.toml (for the forwarder) and the # egress proxy mount when an egress allowlist is in effect. + if tool_def.secrets or os.environ.get("TOOLSTACK_E_SECRET") or ( + os.environ.get("TOOLSTACK_SPS_SKIP") != "1" and + os.path.exists(os.environ.get("TOOLSTACK_SPS_ENV", _DEFAULT_SPS_ENV))): + raise RunnerConfigError("docker + SPS lifecycle is not validated") + channel_secret = _secrets.token_hex(64) name = None log_pid = None log_path = _tool_log_path(tool_def) + running = RunningTool(tool_def.id, tool_def.port, self.backend, "", + channel_secret=channel_secret, boot_id=_boot_id(), + log_path=str(log_path)) + + def log_spawned(pid): + nonlocal running + # Keep the handle even if identity capture or closing the log fails. + running = replace(running, log_pid=pid) + running = replace(running, log_identity=_process_identity(pid)) + try: rest_generic = tool_def.type == "rest" and tool_def.image is None image = tool_def.image or ("python:3.13-slim" if rest_generic else f"toolstack-{tool_def.id}") if tool_def.image is None and not rest_generic: - self._docker(["build", "-t", image, str(tool_def.path)], _DOCKER_BUILD_TIMEOUT, check=True) + bundled = tool_def.path.resolve() in { + (_REPO_ROOT / "tools" / "echo_api").resolve(), + (_REPO_ROOT / "tools" / "echo_mcp").resolve()} + context = _REPO_ROOT if bundled else tool_def.path + self._docker(["build", "-t", image, "-f", str(tool_def.path / "Dockerfile"), + str(context)], _DOCKER_BUILD_TIMEOUT, check=True) name = f"toolyard-{tool_def.id}" + running = replace(running, handle=name) self._docker(["rm", "-f", name], _DOCKER_RM_TIMEOUT) # clear a same-named leftover run_args = [ "run", "-d", "--name", name, "-p", f"127.0.0.1:{tool_def.port}:{tool_def.port}", "-e", f"TOOLSTACK_PORT={tool_def.port}", + "-e", f"TOOLSTACK_CHANNEL_SECRET={channel_secret}", "-e", "TOOLSTACK_BIND=0.0.0.0", # container-internal; host side stays loopback via -p ] - # Carry the SPS connection env through so the SDK can find the server. - for var in ("TOOLSTACK_E_SECRET", "TOOLSTACK_SPS_HOST", - "TOOLSTACK_SPS_PORT", "TOOLSTACK_SPS_CA"): - if var in os.environ: - run_args += ["-e", f"{var}={os.environ[var]}"] if tool_def.type == "rest": run_args += [ "-v", f"{tool_def.path / 'toolyard.toml'}:{_CONTAINER_TOOL_CONFIG}:ro", @@ -820,35 +935,48 @@ def start(self, tool_def: ToolDef) -> RunningTool: if rest_generic: run_args += ["python3", "-m", "toolstack_forwarder"] self._docker(run_args, _DOCKER_RUN_TIMEOUT, check=True) - log_pid = _start_docker_log_follower(name, log_path) - running = RunningTool(tool_def.id, tool_def.port, self.backend, name, - log_pid=log_pid, log_path=str(log_path), boot_id=_boot_id()) + log_pid = _start_docker_log_follower(name, log_path, _on_spawn=log_spawned) + if running.log_pid != log_pid: + log_spawned(log_pid) time.sleep(_READINESS_WAIT) if not self.is_alive(running): raise RuntimeError(f"tool {tool_def.id} container exited immediately: see {log_path}") log.info("started tool %s in container %s on :%s (log %s)", tool_def.id, name, tool_def.port, log_path) return running - except BaseException: + except BaseException as cause: + removed = True if name: try: - subprocess.run(["docker", "rm", "-f", name], capture_output=True, - timeout=_DOCKER_RM_TIMEOUT) - except subprocess.TimeoutExpired: - pass - _cleanup_partial_start(log_pid=log_pid) + removal = subprocess.run(["docker", "rm", "-f", name], capture_output=True, + timeout=_DOCKER_RM_TIMEOUT) + removed = removal.returncode == 0 + except BaseException: + removed = False + try: + running = _cleanup_partial_start(running) + except RunnerStartCleanupError as cleanup: + raise cleanup from cause + if not removed: + raise RunnerStartCleanupError(running) from cause raise def stop(self, running: RunningTool) -> None: - _stop_log_follower(running) + cleanup_error = None + try: + running = _cleanup_partial_start(running) + except RunnerStartCleanupError as exc: + cleanup_error = exc + running = exc.running try: r = subprocess.run(["docker", "rm", "-f", running.handle], capture_output=True, text=True, timeout=_DOCKER_RM_TIMEOUT) if r.returncode != 0: - log.warning("docker rm %s failed on stop (rc=%s): %s; container may still exist", - running.handle, r.returncode, (r.stderr or "").strip()) - except subprocess.TimeoutExpired: - log.warning("docker rm %s timed out on stop; container may still exist", running.handle) + raise RuntimeError(f"docker removal failed for {running.handle}; retain state record") + except (OSError, subprocess.TimeoutExpired, RuntimeError) as exc: + raise RunnerStartCleanupError(running) from exc + if cleanup_error: + raise cleanup_error log.info("stopped tool %s (container %s)", running.tool_id, running.handle) def is_alive(self, running: RunningTool) -> bool: diff --git a/toolyard/tests/test_cli.py b/toolyard/tests/test_cli.py index 0cfa163..b66250b 100644 --- a/toolyard/tests/test_cli.py +++ b/toolyard/tests/test_cli.py @@ -1,6 +1,10 @@ """Regression tests for persisted Toolyard lifecycle state.""" import os +import json +import socket +import subprocess +import sys import shutil import tempfile import unittest @@ -10,6 +14,7 @@ from unittest import mock from toolyard import cli +from toolyard import runner from toolyard.runner import RunningTool @@ -51,6 +56,73 @@ def test_up_replaces_dead_record(self): selected_runner.start.assert_called_once() self.assertEqual(cli._load_state()["echo"], asdict(replacement)) + def test_up_replaces_known_previous_boot_without_pid_operations(self): + stale = RunningTool("echo", 4601, "process", "123", boot_id="previous", + process_identity="old", egress_pid="124", egress_identity="old-helper") + cli._save_state({"echo": asdict(stale)}) + replacement = RunningTool("echo", 4601, "process", "456") + selected = mock.Mock() + selected.start.return_value = replacement + with mock.patch.object(cli, "get_runner", side_effect=[selected, runner.ProcessRunner()]), mock.patch.object(runner, "_boot_id", return_value="current"), mock.patch.object(runner.os, "kill") as probe, mock.patch.object(runner.os, "killpg") as signal, mock.patch.object(runner.os, "waitpid") as wait: + cli.cmd_up(SimpleNamespace(root=str(self.tools_root), id="echo", backend="process")) + probe.assert_not_called() + signal.assert_not_called() + wait.assert_not_called() + self.assertEqual(cli._load_state()["echo"], asdict(replacement)) + + +class CrossInvocationCli(unittest.TestCase): + def test_up_parent_exits_then_separate_down_confirms_absence(self): + """Real isolated CLI invocations; the detached child has a finite lifetime.""" + source = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory(prefix="cli-cross-invocation-") as directory: + root = Path(directory).resolve() + tool = root / "tools" / "owned" + tool.mkdir(parents=True) + state = root / "state.json" + receipt = root / "child.json" + child_script = tool / "child.py" + child_script.write_text( + "import os, json, time\nfrom pathlib import Path\n" + "from toolyard.runner import _process_identity, _boot_id\n" + f"Path({str(receipt)!r}).write_text(json.dumps({{'pid': os.getpid(), 'start': _process_identity(os.getpid()), 'boot': _boot_id()}}))\n" + "time.sleep(20)\n", encoding="utf-8") + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + (tool / "toolyard.toml").write_text( + f'id = "owned"\ntype = "api"\n[entrypoint]\ncommand = "python3 child.py"\nport = {port}\n', encoding="utf-8") + env = {"PATH": os.environ.get("PATH", ""), "HOME": str(root), + "TMPDIR": str(root), "PYTHONPATH": str(source), + "TOOLSTACK_SPS_SKIP": "1", "TOOLSTACK_SPS_ENV": str(root / "absent.env"), + "TOOLSTACK_TOOLYARD_STATE": str(state), + "TOOLSTACK_TOOLYARD_CHANNEL_STATE": str(root / "channels.json")} + owned = None + try: + up = subprocess.run([sys.executable, "-P", "-m", "toolyard.cli", "up", "owned", "--root", str(root / "tools"), "--backend", "process"], env=env, capture_output=True, text=True, timeout=10) + if receipt.exists(): + owned = json.loads(receipt.read_text()) + self.assertEqual(up.returncode, 0, up.stderr) + self.assertIsNotNone(owned) + record = json.loads(state.read_text())["owned"] + self.assertEqual(record["handle"], str(owned["pid"])) + self.assertEqual(record["process_identity"], owned["start"]) + self.assertEqual(record["boot_id"], owned["boot"]) + # The starting CLI has exited; this test is not the child's parent. + with self.assertRaises(ChildProcessError): + os.waitpid(owned["pid"], os.WNOHANG) + down = subprocess.run([sys.executable, "-P", "-m", "toolyard.cli", "down", "owned"], env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(down.returncode, 0, down.stderr) + self.assertEqual(json.loads(state.read_text()), {}) + self.assertTrue(runner._pid_absent(owned["pid"])) + print("CROSS_INVOCATION", json.dumps({**owned, "up_exit": up.returncode, + "down_exit": down.returncode, "own_reap": False, "positive_esrch": True}), flush=True) + finally: + if owned is None and receipt.exists(): + owned = json.loads(receipt.read_text()) + if owned is not None and not runner._pid_absent(owned["pid"]): + runner._terminate(owned["pid"], owned["start"], owned["boot"]) + if __name__ == "__main__": unittest.main() diff --git a/toolyard/tests/test_egress_proxy.py b/toolyard/tests/test_egress_proxy.py index ad82c47..9000be8 100644 --- a/toolyard/tests/test_egress_proxy.py +++ b/toolyard/tests/test_egress_proxy.py @@ -5,12 +5,15 @@ import socket import threading +import time import unittest +from unittest.mock import patch, Mock import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from toolyard.egress_proxy import serve +from toolyard import egress_proxy def _free_port() -> int: @@ -26,6 +29,17 @@ def log_message(self, *a): pass def do_GET(self): + if self.path in ("/slow-header", "/slow-body"): + try: + if self.path == "/slow-body": + self.wfile.write(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n") + for _ in range(50): + self.wfile.write(b"x") + self.wfile.flush() + time.sleep(0.04) # well below the five-second idle limit + except OSError: + pass + return body = b"origin-body" self.send_response(200) self.send_header("Content-Length", str(len(body))) @@ -38,9 +52,11 @@ def setUp(self): # An HTTP origin and a raw-TCP echo server, both on loopback; the proxy allows only # 127.0.0.1, so both are reachable and any other host is refused. self.origin = ThreadingHTTPServer(("127.0.0.1", 0), _Origin) - threading.Thread(target=self.origin.serve_forever, daemon=True).start() + self.origin.daemon_threads = False + origin_thread = threading.Thread(target=self.origin.serve_forever) + origin_thread.start() self.addCleanup(self.origin.server_close) - self.addCleanup(self.origin.shutdown) + self.addCleanup(self._stop_server, self.origin, origin_thread) self.origin_port = self.origin.server_address[1] self.echo = socket.socket() @@ -48,23 +64,158 @@ def setUp(self): self.echo.bind(("127.0.0.1", 0)) self.echo.listen(1) self.echo_port = self.echo.getsockname()[1] - threading.Thread(target=self._echo_serve, daemon=True).start() - self.addCleanup(self.echo.close) + self.echo.settimeout(0.1) + self.echo_stop = threading.Event() + self.echo_workers = [] + echo_thread = threading.Thread(target=self._echo_serve) + echo_thread.start() + self.addCleanup(self._stop_echo, echo_thread) self.proxy = serve(_free_port(), ["127.0.0.1"]) - threading.Thread(target=self.proxy.serve_forever, daemon=True).start() + self.proxy.daemon_threads = False + proxy_thread = threading.Thread(target=self.proxy.serve_forever) + proxy_thread.start() self.addCleanup(self.proxy.server_close) - self.addCleanup(self.proxy.shutdown) + self.addCleanup(self._stop_server, self.proxy, proxy_thread) self.proxy_port = self.proxy.server_address[1] def _echo_serve(self): - while True: + while not self.echo_stop.is_set(): try: conn, _ = self.echo.accept() + except socket.timeout: + continue except OSError: return - threading.Thread(target=lambda c=conn: (c.sendall(c.recv(64)), c.close()), - daemon=True).start() + conn.settimeout(2) + def echo(c=conn): + with c: + try: + c.sendall(c.recv(64)) + except OSError: + pass + worker = threading.Thread(target=echo) + self.echo_workers.append(worker) + worker.start() + + def _stop_echo(self, thread): + self.echo_stop.set() + self.echo.close() + thread.join(3) + self.assertFalse(thread.is_alive()) + for worker in self.echo_workers: + worker.join(3) + self.assertFalse(worker.is_alive()) + + def _stop_server(self, server, thread): + server.shutdown() + thread.join(3) + self.assertFalse(thread.is_alive()) + + def _raw(self, headers="", path="/"): + client = socket.create_connection(("127.0.0.1", self.proxy_port), timeout=2) + self.addCleanup(client.close) + client.sendall((f"GET http://127.0.0.1:{self.origin_port}{path} HTTP/1.1\r\n" + f"Host: 127.0.0.1\r\n{headers}\r\n").encode()) + chunks = [] + while True: + chunk = client.recv(4096) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + + def _assert_slots_recovered(self): + # server_close joins all owned handler threads (non-daemon in this fixture). + self.proxy.server_close() + acquired = 0 + try: + for _ in range(16): + self.assertTrue(self.proxy.slots.acquire(blocking=False)) + acquired += 1 + finally: + for _ in range(acquired): + self.proxy.slots.release() + + def test_trickled_headers_and_body_share_total_deadline(self): + with patch.object(egress_proxy, "_TOTAL_DEADLINE", 0.25): + for path in ("/slow-header", "/slow-body"): + started = time.monotonic() + response = self._raw(path=path) + self.assertLess(time.monotonic() - started, 1.5) + if path == "/slow-body": + self.assertIn(b"200", response) + self.assertLess(len(response.split(b"\r\n\r\n", 1)[1]), 100) + else: + self.assertNotIn(b"200 OK", response) + self._assert_slots_recovered() + + def test_duplicate_length_and_transfer_encoding_refused_before_contact(self): + with patch.object(egress_proxy.socket, "getaddrinfo", wraps=socket.getaddrinfo) as dns: + for headers in ("Content-Length: 0\r\nContent-Length: 0\r\n", + "Transfer-Encoding: chunked\r\n"): + self.assertIn(b"400", self._raw(headers)) + # Client loopback connections also resolve; no origin-port resolution. + self.assertFalse(any(c.args[:2] == ("127.0.0.1", self.origin_port) + for c in dns.call_args_list)) + self._assert_slots_recovered() + + def test_late_dns_and_connect_completion_send_zero_bytes(self): + handler = object.__new__(egress_proxy._handler(frozenset())) + handler.socket_lock = threading.Lock() + handler.expired = False + handler.upstream_socket = None + fake = Mock() + addresses = [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 1))] + def late_dns(*args, **kwargs): + handler.deadline = time.monotonic() - 1 + return addresses + handler.deadline = time.monotonic() + 10 + with patch.object(egress_proxy.socket, "getaddrinfo", side_effect=late_dns), \ + patch.object(egress_proxy.socket, "socket", return_value=fake) as create: + with self.assertRaises(TimeoutError): + handler._connect(("fake.invalid", 1)) + create.assert_not_called() + handler.deadline = time.monotonic() + 10 + def late_connect(*args): + handler.deadline = time.monotonic() - 1 + fake.connect.side_effect = late_connect + with patch.object(egress_proxy.socket, "getaddrinfo", return_value=addresses), \ + patch.object(egress_proxy.socket, "socket", return_value=fake): + with self.assertRaises(TimeoutError): + handler._connect(("fake.invalid", 1)) + fake.close.assert_called_once() + fake.sendall.assert_not_called() + + def test_late_dns_refuses_actual_forward_before_request_send(self): + from email.message import Message + handler = object.__new__(egress_proxy._handler(frozenset({"fake.invalid"}))) + handler.socket_lock = threading.Lock() + handler.expired = False + handler.upstream_socket = None + handler.deadline = time.monotonic() + 10 + handler.path = "http://fake.invalid/action" + handler.command = "POST" + handler.headers = Message() + handler.headers["Authorization"] = "Bearer fake-short" + handler.send_error = Mock() + def late_dns(*args, **kwargs): + handler.deadline = time.monotonic() - 1 + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 1))] + with patch.object(egress_proxy.socket, "getaddrinfo", side_effect=late_dns), \ + patch.object(egress_proxy.socket, "socket") as create, \ + patch.object(egress_proxy.http.client, "HTTPConnection") as connection: + handler._forward() + create.assert_not_called() + connection.return_value.request.assert_not_called() + connection.return_value.close.assert_called_once() + handler.send_error.assert_called_once_with(502, "upstream failed") + + def test_connect_pump_uses_original_remaining_budget(self): + deadline = time.monotonic() + 0.1 + with patch.object(egress_proxy.select, "select", return_value=([], [], [])) as wait: + egress_proxy._pump(Mock(), Mock(), deadline) + self.assertGreater(wait.call_args.args[3], 0) + self.assertLessEqual(wait.call_args.args[3], 0.1) def _opener(self): return urllib.request.build_opener( diff --git a/toolyard/tests/test_netguard.py b/toolyard/tests/test_netguard.py index 3118bab..d74f56d 100644 --- a/toolyard/tests/test_netguard.py +++ b/toolyard/tests/test_netguard.py @@ -19,8 +19,8 @@ def test_rejects_bad_ids(self): def test_table_name_is_nft_safe(self): # hyphens are legal in a tool id / cgroup dir but not in an nft identifier - self.assertEqual(netguard._table("tool-1"), "toolyard_tool_1") - self.assertEqual(netguard._table("echo"), "toolyard_echo") + self.assertRegex(netguard._table("tool-1"), r"^toolyard_[a-f0-9]{64}$") + self.assertRegex(netguard._table("echo"), r"^toolyard_[a-f0-9]{64}$") class RulesetTest(unittest.TestCase): @@ -39,7 +39,7 @@ def test_allowlist_permits_only_the_proxy_port(self): def test_rule_is_scoped_to_the_tools_cgroup(self): rs = netguard._ruleset("echo", None) self.assertIn('socket cgroupv2 level 2 "toolyard/echo"', rs) - self.assertIn("table inet toolyard_echo", rs) + self.assertIn("table inet " + netguard._table("echo"), rs) def test_drop_rules_follow_the_accepts(self): # order matters: established + proxy accepts must precede the catch-all drops diff --git a/toolyard/tests/test_partial_cleanup.py b/toolyard/tests/test_partial_cleanup.py new file mode 100644 index 0000000..62d3a64 --- /dev/null +++ b/toolyard/tests/test_partial_cleanup.py @@ -0,0 +1,284 @@ +"""Owned partial-start boundaries: all process/SPS operations are pure fakes.""" +from contextlib import ExitStack +from dataclasses import asdict, replace +import os +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import Mock, patch, call + +from toolyard import runner, cli + + +class PartialCleanup(unittest.TestCase): + def setUp(self): + self.stack = ExitStack() + self.addCleanup(self.stack.close) + self.kill = self.stack.enter_context(patch.object(runner.os, "killpg")) + self.probe = self.stack.enter_context(patch.object(runner.os, "kill")) + self.wait = self.stack.enter_context(patch.object(runner.os, "waitpid", return_value=(0, 0))) + self.identity = self.stack.enter_context(patch.object(runner, "_process_identity", return_value="start")) + self.stack.enter_context(patch.object(runner, "_boot_id", return_value="boot")) + self.stack.enter_context(patch.object(runner.time, "sleep")) + self.record = runner.RunningTool("fake", 123, "process", "12345", boot_id="boot", + process_identity="start", egress_pid="12346", + egress_identity="start", channel_secret="fake-channel") + + def test_exit_reaped_before_any_signal_even_without_identity(self): + self.wait.return_value = (12345, 0) + runner._terminate(12345) + self.kill.assert_not_called() + + def test_unknown_is_not_completion(self): + for identity in (None, "start"): + for result in (None, "start"): + with self.subTest(identity=identity, snapshot=result): + self.identity.return_value = result + self.wait.side_effect = ChildProcessError + with patch.object(runner.time, "monotonic", side_effect=range(0, 100, 3)): + with self.assertRaises(RuntimeError): + runner._terminate(12345, identity, "boot") + if identity is None or result is None: + self.kill.assert_not_called() + self.kill.reset_mock() + + def test_missing_boot_never_signals(self): + with self.assertRaises(RuntimeError): + runner._terminate(12345, "start") + self.kill.assert_not_called() + + def test_nonparent_stop_finishes_on_positive_absence(self): + self.wait.side_effect = ChildProcessError + self.probe.side_effect = [None, ProcessLookupError()] + runner._terminate(12345, "start", "boot") + self.kill.assert_called_once_with(12345, runner.signal.SIGTERM) + self.assertEqual(self.probe.call_args_list, [call(12345, 0), call(12345, 0)]) + + def test_absent_snapshot_needs_positive_esrch(self): + self.identity.return_value = None + self.wait.side_effect = ChildProcessError + for outcome in (None, PermissionError(), OSError("unavailable")): + with self.subTest(outcome=outcome): + self.probe.side_effect = outcome + with self.assertRaises(RuntimeError): + runner._terminate(12345, "start", "boot") + self.probe.side_effect = ProcessLookupError + runner._terminate(12345, "start", "boot") + self.assertFalse(runner.ProcessRunner().is_alive(self.record)) + # Even a missing recorded start identity can be resolved by ESRCH, + # never by the unavailable snapshot alone. + runner._terminate(12345, None, "boot") + self.kill.assert_not_called() + + def test_prior_boot_clears_all_helpers_without_any_pid_operation(self): + record = replace(self.record, boot_id="previous", log_pid="12347", log_identity="log") + cleaned = runner._cleanup_partial_start(record) + self.assertTrue(cleaned.process_exited and cleaned.log_exited and cleaned.egress_exited) + self.assertFalse(runner.ProcessRunner().is_alive(record)) + self.kill.assert_not_called() + self.probe.assert_not_called() + self.identity.assert_not_called() + self.wait.assert_not_called() + + def test_unknown_current_boot_is_not_prior_boot(self): + with patch.object(runner, "_boot_id", return_value=None): + with self.assertRaises(RuntimeError): + runner._terminate(12345, "start", "boot") + self.kill.assert_not_called() + self.probe.assert_not_called() + self.wait.assert_not_called() + + def test_reused_pid_neither_reaped_nor_signalled(self): + self.identity.return_value = "replacement" + with self.assertRaises(RuntimeError): + runner._terminate(12345, "start", "boot") + self.kill.assert_not_called() + self.wait.assert_not_called() + + def test_identity_rechecked_before_kill(self): + # Initial reap check, TERM check, post-TERM reap check, KILL check. + self.identity.side_effect = ["start", "start", "start", "replacement"] + with patch.object(runner.time, "monotonic", side_effect=[0, 3]): + with self.assertRaises(RuntimeError): + runner._terminate(12345, "start", "boot") + self.kill.assert_called_once_with(12345, runner.signal.SIGTERM) + + def test_permission_denied_is_uncertain_unless_positively_reaped(self): + self.kill.side_effect = PermissionError + with self.assertRaises(RuntimeError): + runner._terminate(12345, "start", "boot") + self.wait.side_effect = [(0, 0), (12345, 0)] + runner._terminate(12345, "start", "boot") + + def test_every_helper_attempted_and_receipts_retained(self): + record = replace(self.record, log_pid="12347", log_identity="log") + with patch.object(runner, "_terminate", side_effect=[RuntimeError("private"), None, RuntimeError("private")]) as terminate: + with self.assertRaises(runner.RunnerStartCleanupError) as error: + runner._cleanup_partial_start(record) + self.assertEqual(terminate.call_args_list, [call("12345", "start", "boot"), + call("12347", "log", "boot"), call("12346", "start", "boot")]) + self.assertTrue(error.exception.running.log_exited) + self.assertFalse(error.exception.running.process_exited) + self.assertFalse(error.exception.running.egress_exited) + self.assertNotIn("private", str(error.exception)) + + def test_positive_receipt_skips_reused_pid_on_retry(self): + record = replace(self.record, process_exited=True, egress_pid=None) + self.identity.return_value = "replacement" + runner._cleanup_partial_start(record) + self.kill.assert_not_called() + self.wait.assert_not_called() + + def start_fixture(self): + directory = self.stack.enter_context(tempfile.TemporaryDirectory()) + tool = SimpleNamespace(id="fake", port=123, path=Path(directory).resolve(), command="fake", secrets=[]) + sps_env = tool.path / "fake.env" + sps_env.touch() + self.stack.enter_context(patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "0", "TOOLSTACK_SPS_ENV": str(sps_env)})) + self.stack.enter_context(patch.object(runner, "_check_port_free")) + cfg = runner.ResolvedSPS("fake", 123, "fake-secret", "fake-ca", True) + self.stack.enter_context(patch.object(runner, "_resolve_sps", return_value=cfg)) + self.stack.enter_context(patch.object(runner, "_sps_register")) + unregister = self.stack.enter_context(patch.object(runner, "_sps_unregister")) + self.stack.enter_context(patch.object(runner.ProcessRunner, "_policy", return_value=runner.SandboxPolicy(egress=runner.EgressPolicy(allow=("fake.invalid",))))) + self.stack.enter_context(patch.object(runner, "_start_egress_proxy", return_value=("12346", 456))) + self.stack.enter_context(patch.object(runner.os, "posix_spawn", return_value=12345)) + return tool, unregister + + def test_start_failure_retains_identities_and_attempts_sps_after_first_error(self): + tool, unregister = self.start_fixture() + cause = RuntimeError("original start failure") + with patch.object(runner.ProcessRunner, "is_alive", side_effect=cause), patch.object(runner, "_terminate", side_effect=[RuntimeError(), None]) as terminate: + with self.assertRaises(runner.RunnerStartCleanupError) as error: + runner.ProcessRunner().start(tool) + self.assertIs(error.exception.__cause__, cause) + self.assertEqual(terminate.call_args_list, [call("12345", "start", "boot"), call("12346", "start", "boot")]) + self.assertTrue(error.exception.running.egress_exited) + self.assertEqual(error.exception.running.sps_ca, "fake-ca") + unregister.assert_called_once() + + def test_failure_before_main_spawn_retains_helper(self): + tool, unregister = self.start_fixture() + with patch.object(runner.ProcessRunner, "_spawn_argv", side_effect=ValueError("start")), patch.object(runner, "_terminate", side_effect=RuntimeError()) as terminate: + with self.assertRaises(runner.RunnerStartCleanupError) as error: + runner.ProcessRunner().start(tool) + self.assertEqual(error.exception.running.handle, "") + self.assertEqual(error.exception.running.egress_identity, "start") + terminate.assert_called_once_with("12346", "start", "boot") + unregister.assert_called_once() + + def test_egress_identity_captured_before_main_spawn_preparation(self): + tool, _ = self.start_fixture() + def fail_preparation(*args): + self.identity.assert_called_once_with("12346") + raise RuntimeError("preparation failed") + self.identity.return_value = None + with patch.object(runner.ProcessRunner, "_spawn_argv", side_effect=fail_preparation): + with self.assertRaises(runner.RunnerStartCleanupError) as error: + runner.ProcessRunner().start(tool) + self.assertIsNone(error.exception.running.egress_identity) + self.assertFalse(error.exception.running.egress_exited) + self.kill.assert_not_called() + + def test_readiness_reap_receipt_prevents_reuse_signal(self): + tool, unregister = self.start_fixture() + self.wait.return_value = (12345, 0) + with patch.object(runner, "_terminate", side_effect=RuntimeError()) as terminate: + with self.assertRaises(runner.RunnerStartCleanupError) as error: + runner.ProcessRunner().start(tool) + self.assertTrue(error.exception.running.process_exited) + terminate.assert_called_once_with("12346", "start", "boot") + unregister.assert_called_once() + + def test_liveness_permission_and_missing_snapshot_are_not_dead(self): + self.wait.side_effect = ChildProcessError + self.probe.side_effect = PermissionError + for identity in (None, "start"): + self.identity.return_value = identity + with self.assertRaises(RuntimeError): + runner.ProcessRunner().is_alive(self.record) + + def test_real_start_error_reaches_cli_typed_persistence(self): + tool, unregister = self.start_fixture() + state = tool.path / "state.json" + with patch.dict(os.environ, {"TOOLSTACK_TOOLYARD_STATE": str(state), "TOOLSTACK_TOOLYARD_CHANNEL_STATE": str(tool.path / "channels.json")}), patch.object(cli, "discover", return_value=[tool]), patch.object(runner.ProcessRunner, "is_alive", side_effect=RuntimeError("start")), patch.object(runner, "_terminate", side_effect=RuntimeError()): + with self.assertRaises(runner.RunnerStartCleanupError) as error: + cli.cmd_up(SimpleNamespace(root="fake", id=None, backend="process")) + self.assertEqual(cli._load_state()["fake"], asdict(error.exception.running)) + self.assertFalse(cli._load_state()["fake"]["egress_exited"]) + unregister.assert_called_once() + + def test_cli_and_admin_save_stop_progress_before_retry(self): + from admin import toolyard_ops + for stop in (lambda: cli.cmd_down(SimpleNamespace(id="fake")), + lambda: toolyard_ops.stop("fake")): + with self.subTest(stop=stop), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + with patch.dict(os.environ, {"TOOLSTACK_TOOLYARD_STATE": str(root / "state.json"), "TOOLSTACK_TOOLYARD_CHANNEL_STATE": str(root / "channels.json")}): + cli._save_state({"fake": asdict(self.record)}) + with patch.object(runner, "_terminate", side_effect=[None, RuntimeError("denied")]): + with self.assertRaises(runner.RunnerStartCleanupError): + stop() + saved = cli._load_state()["fake"] + self.assertTrue(saved["process_exited"]) + self.assertFalse(saved["egress_exited"]) + # Receipt prevents probing/signalling a subsequently reused + # main PID, while the remaining helper is still attempted. + with patch.object(runner, "_terminate") as terminate: + stop() + terminate.assert_called_once_with("12346", "start", "boot") + self.assertEqual(cli._load_state(), {}) + + def test_cli_and_admin_save_progress_when_replacing_dead_record(self): + from admin import toolyard_ops + updated = replace(self.record, process_exited=True) + fake = Mock() + fake.is_alive.return_value = False + fake.stop.side_effect = runner.RunnerStartCleanupError(updated) + tool = SimpleNamespace(id="fake") + for start in (lambda: cli.cmd_up(SimpleNamespace(id="fake", root="fake", backend="process")), + lambda: toolyard_ops.start("fake", "fake", [])): + with self.subTest(start=start), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + with patch.dict(os.environ, {"TOOLSTACK_TOOLYARD_STATE": str(root / "state.json"), "TOOLSTACK_TOOLYARD_CHANNEL_STATE": str(root / "channels.json")}), patch.object(cli, "get_runner", return_value=fake), patch.object(toolyard_ops, "get_runner", return_value=fake), patch.object(cli, "discover", return_value=[tool]), patch.object(toolyard_ops, "_all_defs", return_value={"fake": tool}): + cli._save_state({"fake": asdict(self.record)}) + with self.assertRaises(runner.RunnerStartCleanupError): + start() + self.assertEqual(cli._load_state()["fake"], asdict(updated)) + fake.start.assert_not_called() + + def test_docker_removal_failure_keeps_full_helper_record(self): + tool, _ = self.start_fixture() + tool.type, tool.image = "rest", "fake-image" + with patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "1", "TOOLSTACK_E_SECRET": ""}), patch.object(runner.DockerRunner, "_docker"), patch.object(runner, "_start_docker_log_follower", return_value="12347"), patch.object(runner.DockerRunner, "is_alive", side_effect=RuntimeError("start")), patch.object(runner.subprocess, "run", return_value=SimpleNamespace(returncode=1)), patch.object(runner, "_terminate", side_effect=RuntimeError()) as terminate: + with self.assertRaises(runner.RunnerStartCleanupError) as error: + runner.DockerRunner().start(tool) + self.assertEqual(error.exception.running.log_identity, "start") + self.assertEqual(error.exception.running.log_pid, "12347") + terminate.assert_called_once_with("12347", "start", "boot") + + def test_docker_log_close_failure_still_retains_spawned_helper(self): + tool, _ = self.start_fixture() + tool.type, tool.image = "rest", "fake-image" + with patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "1", "TOOLSTACK_E_SECRET": ""}), patch.object(runner.DockerRunner, "_docker"), patch.object(runner.os, "open", return_value=99), patch.object(runner.os, "fchmod"), patch.object(runner.os, "close", side_effect=OSError("close failed")), patch.object(runner.subprocess, "run", return_value=SimpleNamespace(returncode=0)), patch.object(runner, "_terminate", side_effect=RuntimeError()) as terminate: + with self.assertRaises(runner.RunnerStartCleanupError) as error: + runner.DockerRunner().start(tool) + self.assertEqual(error.exception.running.log_identity, "start") + self.assertEqual(error.exception.running.log_pid, "12345") + terminate.assert_called_once_with("12345", "start", "boot") + + def test_docker_stop_retains_reaped_helper_when_removal_fails(self): + record = replace(self.record, backend="docker", handle="fake-container", + log_pid="12347", log_identity="start", egress_pid=None) + with patch.object(runner, "_terminate"), patch.object(runner.subprocess, "run", return_value=SimpleNamespace(returncode=1)): + with self.assertRaises(runner.RunnerStartCleanupError) as error: + runner.DockerRunner().stop(record) + self.assertTrue(error.exception.running.log_exited) + with patch.object(runner, "_terminate") as terminate, patch.object(runner.subprocess, "run", return_value=SimpleNamespace(returncode=0)): + runner.DockerRunner().stop(error.exception.running) + terminate.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/toolyard/tests/test_runner.py b/toolyard/tests/test_runner.py index 17e310c..e9f6c89 100644 --- a/toolyard/tests/test_runner.py +++ b/toolyard/tests/test_runner.py @@ -32,18 +32,30 @@ from broker.server import build_server from toolyard.config import load from toolyard.egress_proxy import serve as _serve_egress_proxy -from toolyard.runner import (BwrapRunner, DockerRunner, ProcessRunner, RunningTool, SeatbeltRunner, +from toolyard.runner import (BwrapRunner, DockerRunner, ProcessRunner, RunningTool, SeatbeltRunner, RunnerConfigError, _SANDBOX_EXEC, _netguard_argv, _seatbelt_profile) from toolyard.sandbox import EgressPolicy, SandboxPolicy REPO = Path(__file__).resolve().parents[2] import os +_CHANNELS = {} def setUpModule(): - # Default the channel credential so the tool can boot under tests. - # Individual tests that need a clean env (`test_runner_skips_sps_when_env_skip_set`) - # unset this in their own setUp. - os.environ.setdefault('TOOLSTACK_E_SECRET', 'chan') + global _fixture_dir, _fixture_env + from toolyard.runner import _boot_id + _boot_id.cache_clear() + _boot_id() # Resolve before any subprocess/spawn mocks can pollute the cache. + _fixture_dir = tempfile.TemporaryDirectory() + _fixture_env = mock.patch.dict(os.environ, { + "TOOLSTACK_SPS_SKIP": "1", "TOOLSTACK_SPS_FAKE": "1", + "PYTHONPATH": str(REPO), + "TOOLSTACK_SPS_ENV": str(Path(_fixture_dir.name) / "absent.env"), + "TOOLSTACK_TOOLYARD_STATE": str(Path(_fixture_dir.name) / "state.json"), + "TOOLSTACK_TOOLYARD_CHANNEL_STATE": str(Path(_fixture_dir.name) / "channels.json"), + }) + _fixture_env.start() + # Foreign SPS credentials must never become channel credentials. + os.environ.setdefault('TOOLSTACK_E_SECRET', 'foreign-sps-key') # Phase 5: tool boot pulls secrets from SPS. The 12 tool-starting tests # don't exercise the SPS wire (that's covered in sps/tests); they only # need the tool subprocess to come up. TOOLSTACK_SPS_FAKE=1 makes the @@ -52,17 +64,20 @@ def setUpModule(): # tests reset os.environ under their own mock.patch.dict, so this flag never # leaks into assertions there. Production never sets this env var. os.environ.setdefault('TOOLSTACK_SPS_FAKE', '1') - # Phase 5 verify_broker reads $TOOLSTACK_E_SECRET (set just above) and the - # broker runtime reads TOOLSTACK_TOOL_SECRET_ to fill the matching - # X-Toolstack-Secret header. Set the legacy env vars here so the default - # HttpRuntime() path used by _call/_mcp_call/build_server() sends a header - # the spawned tool will accept. (TOOLSTACK_TOOL_SECRET_ECHO is also the - # name the real broker uses for the per-tool channel secret; the runner - # normally writes ToolState() instead, but the in-tree tests bypass cli.py - # and never touch that file.) SharedSecretE2E passes its own tool_secret - # callable to HttpRuntime, so this env var is ignored for that class. - os.environ.setdefault('TOOLSTACK_TOOL_SECRET_ECHO', 'chan') - os.environ.setdefault('TOOLSTACK_TOOL_SECRET_ECHO_MCP', 'chan') + # Channel fixtures use only runner-minted independent keys. + + +def tearDownModule(): + _fixture_env.stop() + _fixture_dir.cleanup() + _CHANNELS.clear() + + +def _remember_running(running): + from toolyard.cli import _atomic_private_json + _CHANNELS[running.port] = running.channel_secret + _atomic_private_json(Path(os.environ["TOOLSTACK_TOOLYARD_CHANNEL_STATE"]), + {running.tool_id: {"channel_secret": running.channel_secret}}) TOOL_TOML = REPO / "tools" / "echo_api" / "toolyard.toml" TOOL_MCP_TOML = REPO / "tools" / "echo_mcp" / "toolyard.toml" @@ -101,12 +116,12 @@ def _wait_for_tool(port: int, tries: int = 80) -> bool: def _call(port, op, arguments, request_id=1, caller="hermes"): - return HttpRuntime().execute(ToolOp("echo", op, "low", port, "api"), + return HttpRuntime(tool_secret=lambda _: _CHANNELS.get(port)).execute(ToolOp("echo", op, "low", port, "api"), arguments, request_id, caller) def _mcp_call(port, op, arguments, request_id=1, caller="hermes"): - return HttpRuntime().execute(ToolOp("echo-mcp", op, "read", port, "mcp"), + return HttpRuntime(tool_secret=lambda _: _CHANNELS.get(port)).execute(ToolOp("echo-mcp", op, "read", port, "mcp"), arguments, request_id, caller) @@ -132,6 +147,7 @@ def setUp(self): self.runner = ProcessRunner() self.running = self.runner.start(self.tool) self.addCleanup(self.runner.stop, self.running) + _remember_running(self.running) if not _wait_for_tool(self.tool.port): self.fail("echo tool did not start") @@ -162,13 +178,7 @@ class SharedSecretE2E(unittest.TestCase): SHARED = "broker-shh-456" def setUp(self): - # Phase 5: verify_broker on the tool reads $TOOLSTACK_E_SECRET and - # requires the broker's X-Toolstack-Secret header to match it. The - # test's tool_secret callable returns SHARED, so the spawned tool - # must see SHARED in its env for the matching-secret assertion to - # pass. (Module-level setUpModule sets TOOLSTACK_E_SECRET=chan for - # the other classes; SharedSecretE2E overrides to SHARED and lets - # ProcessRunnerE2E override back to chan via _prev_e bookkeeping.) + # A foreign inherited SPS key must not become the fresh channel key. self._prev_e = os.environ.get("TOOLSTACK_E_SECRET") os.environ["TOOLSTACK_E_SECRET"] = self.SHARED self.addCleanup(self._restore_e) @@ -177,6 +187,8 @@ def setUp(self): self.running = self.runner.start( self.tool) self.addCleanup(self.runner.stop, self.running) + _remember_running(self.running) + self.SHARED = self.running.channel_secret if not self._ready(): self.fail("echo tool did not start") @@ -230,6 +242,7 @@ def setUp(self): self.runner = ProcessRunner() self.running = self.runner.start(self.tool) self.addCleanup(self.runner.stop, self.running) + _remember_running(self.running) if not _wait_for_tool(self.tool.port): self.fail("echo tool did not start") @@ -287,6 +300,7 @@ def setUp(self): self.runner = ProcessRunner() self.running = self.runner.start(self.tool) # echo-mcp ships with no secrets self.addCleanup(self.runner.stop, self.running) + _remember_running(self.running) if not _wait_for_mcp_tool(self.tool.port): self.fail("echo-mcp tool did not start") @@ -404,6 +418,8 @@ def fake_docker(args, timeout, *, check=False): return subprocess.CompletedProcess(["docker", *args], 0, stdout="true\n", stderr="") with mock.patch.object(DockerRunner, "_docker", side_effect=fake_docker), \ + mock.patch("toolyard.runner._start_docker_log_follower", return_value=None), \ + mock.patch.dict(os.environ, {"TOOLSTACK_E_SECRET": ""}), \ mock.patch.object(DockerRunner, "is_alive", return_value=True): DockerRunner().start(dataclasses.replace(self.tool, image="toolstack-forwarder")) run = next(args for args in calls if args[:2] == ["run", "-d"]) @@ -418,6 +434,8 @@ def fake_docker(args, timeout, *, check=False): return subprocess.CompletedProcess(["docker", *args], 0, stdout="true\n", stderr="") with mock.patch.object(DockerRunner, "_docker", side_effect=fake_docker), \ + mock.patch("toolyard.runner._start_docker_log_follower", return_value=None), \ + mock.patch.dict(os.environ, {"TOOLSTACK_E_SECRET": ""}), \ mock.patch.object(DockerRunner, "is_alive", return_value=True): DockerRunner().start(self.tool) self.assertFalse(any(args[:1] == ["build"] for args in calls)) @@ -428,7 +446,8 @@ def fake_docker(args, timeout, *, check=False): def _seatbelt_ok() -> bool: - return sys.platform == "darwin" and shutil.which("sandbox-exec") is not None + return (os.environ.get("TOOLSTACK_TEST_NATIVE") == "1" and + sys.platform == "darwin" and shutil.which("sandbox-exec") is not None) def _pid_alive(pid) -> bool: @@ -488,18 +507,18 @@ def test_terminate_refuses_non_numeric_handle(self): killpg.assert_not_called() waitpid.assert_not_called() - def test_terminate_swallows_already_gone_pids(self): - # A real runner cleanup usually calls _terminate with a pid that has just exited - # (or one we never spawned, after a crash). The existing ProcessLookupError path - # is what we want here, NOT the new <=1 refusal. + def test_terminate_retains_unknown_pids(self): + # A missing identity and no own-child reap receipt cannot prove exit. + # Do not even probe the unknown process group with a signal. from toolyard.runner import _terminate with mock.patch("toolyard.runner.os.killpg", side_effect=ProcessLookupError) as killpg, \ mock.patch("toolyard.runner.os.waitpid", side_effect=ChildProcessError) as waitpid: - _terminate(_TEST_PID) # safe sentinel value (well above any pid_max) - killpg.assert_called_once_with(_TEST_PID, signal.SIGTERM) - waitpid.assert_called_once_with(_TEST_PID, 0) + with self.assertRaises(RuntimeError): + _terminate(_TEST_PID) # unknown is not proof of exit + killpg.assert_not_called() + waitpid.assert_called_once_with(_TEST_PID, os.WNOHANG) def test_terminate_none_is_a_noop(self): # Already handled by the early `if pid is None: return`, but pinned so a future @@ -653,14 +672,8 @@ def test_only_the_proxy_port_is_reachable(self): def _bwrap_ok() -> bool: - """The Linux native runner needs nftables + cgroup v2 reachable through a non-interactive - sudo (the locked-down netguard rule). Gated so the suite still runs cleanly off-host.""" - if not sys.platform.startswith("linux") or shutil.which("nft") is None: - return False - try: - return subprocess.run(["sudo", "-n", "true"], capture_output=True, timeout=10).returncode == 0 - except (OSError, subprocess.TimeoutExpired): - return False + """Linux native backend is disabled; never probe sudo during discovery.""" + return False @unittest.skipUnless(_bwrap_ok(), "Linux + nft + non-interactive sudo required") @@ -822,6 +835,12 @@ class SpsRegistration(unittest.TestCase): `secrets` arg is supplied for backward compat (legacy host-disk path); the SPS path uses `tool_def.secrets` for the CS_TUPLE list.""" + def setUp(self): + self.resolve = mock.patch("toolyard.runner._resolve_sps", return_value=mock.Mock( + sp_host="127.0.0.1", sp_port=8743, sp_tls_ca="fake-ca", verify=True)) + self.resolve.start() + self.addCleanup(self.resolve.stop) + def _tool(self): # Same shape as the echo_api fixture but with [[secrets]] entries # so the SPS CS_TUPLE list is non-empty. @@ -879,31 +898,66 @@ def test_runner_skips_sps_when_env_skip_set(self): # Strip the module-level E_SECRET default for this test -- it asserts # the env the runner injects contains NO E_SECRET, which is only # true when the runner didn't mint one (i.e. SPS was skipped). - with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "1"}, clear=True): tool = self._tool() runner = ProcessRunner() with mock.patch("toolyard.runner._check_port_free"), \ mock.patch("toolyard.runner._sps_register") as reg, \ mock.patch.object(ProcessRunner, "is_alive", return_value=True), \ - mock.patch("os.posix_spawn", return_value=123) as spawn: + mock.patch("os.posix_spawn", return_value=123) as spawn, \ + self.assertLogs("toolyard.runner", level="WARNING") as logs: running = runner.start(tool) + self.assertIn("DEVELOPMENT ONLY", " ".join(logs.output)) reg.assert_not_called() self.assertIsNone(running.e_secret) env = spawn.call_args.args[2] self.assertNotIn("TOOLSTACK_E_SECRET", env) - def test_runner_skips_sps_when_env_file_missing(self): + def test_runner_refuses_required_secrets_when_env_file_missing(self): tool = self._tool() runner = ProcessRunner() with mock.patch("toolyard.runner._check_port_free"), \ mock.patch("toolyard.runner._sps_register") as reg, \ mock.patch.object(ProcessRunner, "is_alive", return_value=True), \ - mock.patch("os.posix_spawn", return_value=123), \ + mock.patch("os.posix_spawn", return_value=123) as spawn, \ + mock.patch("toolyard.runner._tool_log_path") as logs, \ mock.patch.dict(os.environ, {"TOOLSTACK_SPS_ENV": "/tmp/does-not-exist.env", "TOOLSTACK_SPS_SKIP": "0"}, clear=False): - running = runner.start(tool) + with self.assertRaisesRegex(RunnerConfigError, "configuration is absent"): + runner.start(tool) reg.assert_not_called() + spawn.assert_not_called() + logs.assert_not_called() + + def test_unsupported_backends_refuse_secrets_before_side_effects(self): + tool = self._tool() + for backend in (DockerRunner(), SeatbeltRunner(), BwrapRunner()): + for skip in ("0", "1"): + with self.subTest(backend=backend.backend, skip=skip), \ + mock.patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": skip, + "TOOLSTACK_SPS_ENV": str(tool.path / "missing")}), \ + mock.patch("toolyard.runner._check_port_free"), \ + mock.patch("toolyard.runner._sps_register") as register, \ + mock.patch("toolyard.runner._tool_log_path") as logs, \ + mock.patch("toolyard.runner.os.posix_spawn") as spawn, \ + mock.patch.object(DockerRunner, "_docker") as docker: + with self.assertRaises(RunnerConfigError): + backend.start(tool) + for effect in (register, logs, spawn, docker): + effect.assert_not_called() + + def test_no_secret_tool_still_starts_without_sps_config(self): + tool = self._tool_without_secrets() + with mock.patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "0", + "TOOLSTACK_SPS_ENV": str(tool.path / "missing")}), \ + mock.patch("toolyard.runner._check_port_free"), \ + mock.patch("toolyard.runner._sps_register") as register, \ + mock.patch.object(ProcessRunner, "is_alive", return_value=True), \ + mock.patch("toolyard.runner.os.posix_spawn", return_value=_TEST_PID) as spawn: + running = ProcessRunner().start(tool) self.assertIsNone(running.e_secret) + register.assert_not_called() + spawn.assert_called_once() def test_stop_calls_sps_unregister(self): tool = self._tool() @@ -913,7 +967,7 @@ def test_stop_calls_sps_unregister(self): mock.patch("toolyard.runner._sps_register"), \ mock.patch("toolyard.runner._sps_unregister") as unreg, \ mock.patch("toolyard.runner.os.killpg"), \ - mock.patch("toolyard.runner.os.waitpid"), \ + mock.patch("toolyard.runner._terminate"), \ mock.patch.object(ProcessRunner, "is_alive", return_value=True), \ mock.patch("os.posix_spawn", return_value=_TEST_PID), \ mock.patch.dict(os.environ, @@ -950,8 +1004,8 @@ def test_sps_env_config_error_is_fatal(self): tool = self._tool() runner = ProcessRunner() with mock.patch("toolyard.runner._check_port_free"), \ - mock.patch("toolyard.runner._check_sps_env", - side_effect=SystemExit("bad mode")), \ + mock.patch("toolyard.runner._resolve_sps", + side_effect=RunnerConfigError("bad mode")), \ mock.patch("toolyard.runner._sps_register"), \ mock.patch("toolyard.runner._cleanup_partial_start") as cleanup, \ mock.patch("os.posix_spawn", return_value=_TEST_PID), \ @@ -959,7 +1013,7 @@ def test_sps_env_config_error_is_fatal(self): {"TOOLSTACK_SPS_ENV": "/tmp/spfake.env", "TOOLSTACK_SPS_SKIP": "0"}, clear=False): with mock.patch("os.path.exists", return_value=True): - with self.assertRaises(SystemExit): + with self.assertRaises(RunnerConfigError): runner.start(tool) cleanup.assert_called_once() diff --git a/toolyard/tests/test_uplift.py b/toolyard/tests/test_uplift.py new file mode 100644 index 0000000..7de1926 --- /dev/null +++ b/toolyard/tests/test_uplift.py @@ -0,0 +1,288 @@ +"""Offline security regressions: temporary state and mocked processes only.""" +import json +import os +import stat +import tempfile +import unittest +import time +import multiprocessing +from dataclasses import asdict, replace +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch, Mock + +from toolyard import cli, runner, netguard +from toolyard.egress_proxy import _handler, _MAX_BODY + + +def _state_writer(directory, key): + os.environ["XDG_STATE_HOME"] = directory + for _ in range(3): + with cli.state_lock(): + state = cli._load_state() + time.sleep(0.01) + state[key] = {"channel_secret": key} + cli._save_state(state) + + +class UpliftTests(unittest.TestCase): + def test_bundled_echo_docker_context_and_packaging_fake_only(self): + from toolyard.config import load + for name in ("echo_api", "echo_mcp"): + path = runner._REPO_ROOT / "tools" / name + tool = replace(load(path / "toolyard.toml"), secrets=()) + fake = Mock(return_value=SimpleNamespace(returncode=0)) + with patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "1", "TOOLSTACK_E_SECRET": ""}), patch.object(runner.DockerRunner, "_docker", fake), patch.object(runner.DockerRunner, "is_alive", return_value=True), patch.object(runner, "_start_docker_log_follower", return_value=None), patch.object(runner.time, "sleep"): + runner.DockerRunner().start(tool) + build = next(c.args[0] for c in fake.call_args_list if c.args[0][0] == "build") + self.assertEqual(build[-1], str(runner._REPO_ROOT)) + self.assertIn(str(path / "Dockerfile"), build) + source = (path / "Dockerfile").read_text() + self.assertIn(f"tools/{name}/toolyard.toml", source) + self.assertIn("COPY sps /app/sps", source) + + def test_failed_stop_retains_record(self): + record = runner.RunningTool("a", 123, "process", "999", boot_id="boot", process_identity="start") + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_STATE_HOME": directory}, clear=True): + with cli.state_lock(): + cli._save_state({"a": asdict(record)}) + with patch.object(runner, "_boot_id", return_value="boot"), patch.object(runner, "_process_identity", return_value="start"), patch.object(runner.os, "kill"), patch.object(runner.os, "killpg", side_effect=PermissionError): + with self.assertRaisesRegex(RuntimeError, "retain state"): + cli.cmd_down(SimpleNamespace(id="a")) + self.assertIn("a", cli._load_state()) + + def test_unknown_legacy_process_identity_refuses_stop(self): + record = runner.RunningTool("a", 123, "process", "999") + with patch.object(runner, "_process_identity", return_value="live"), patch.object(runner.os, "killpg") as kill: + with self.assertRaises(runner.RunnerStartCleanupError): + runner.ProcessRunner().stop(record) + kill.assert_not_called() + + def test_explicit_state_path_matches_broker_channel_default(self): + from broker.tool_state import _default_state_path + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {}, clear=True): + path = Path(directory) / "private.json" + os.environ["TOOLSTACK_TOOLYARD_STATE"] = str(path) + with cli.state_lock(): + cli._save_state({"a": {"channel_secret": "channel", "e_secret": "sps"}}) + self.assertEqual(cli._state_path(), path) + self.assertEqual(_default_state_path(), path.with_name("channels.json")) + + def test_unsafe_channel_parent_refused_without_chmod(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {}, clear=True): + root = Path(directory) + shared = root / "shared" + shared.mkdir(mode=0o755) + shared.chmod(0o755) + os.environ["TOOLSTACK_TOOLYARD_STATE"] = str(root / "private" / "state.json") + os.environ["TOOLSTACK_TOOLYARD_CHANNEL_STATE"] = str(shared / "channels.json") + with self.assertRaisesRegex(ValueError, "owner-private"): + cli._save_state({}) + self.assertEqual(stat.S_IMODE(shared.stat().st_mode), 0o755) + self.assertFalse((shared / "channels.json").exists()) + + def test_channel_cannot_replace_private_state_or_lock(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {}, clear=True): + root = Path(directory) + os.environ["TOOLSTACK_TOOLYARD_STATE"] = str(root / "state.json") + for name in ("state.json", "state.lock", "sub/../state.json"): + os.environ["TOOLSTACK_TOOLYARD_CHANNEL_STATE"] = str(root / name) + with self.assertRaisesRegex(ValueError, "different paths"): + cli._save_state({}) + + def test_links_and_foreign_owner_refused(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "target" + target.mkdir(mode=0o700) + link = root / "linked" + link.symlink_to(target, target_is_directory=True) + with self.assertRaises(ValueError): + cli._atomic_private_json(link / "state.json", {}) + file = root / "state.json" + file.symlink_to(target / "absent") + with self.assertRaises(ValueError): + cli._atomic_private_json(file, {}) + with patch.object(cli.os, "geteuid", return_value=os.geteuid() + 1): + with self.assertRaises(ValueError): + cli._atomic_private_json(root / "other.json", {}) + + def test_child_environment_drops_master_and_provider_credentials(self): + sensitive = {"SP_SECRET": "fake", "SP_VAULT_PASSPHRASE": "fake", "SP_VAULT_PASSPHRASE_FILE": "fake", "VAULT_TOKEN": "fake", "INFISICAL_CLIENT_SECRET": "fake", "AWS_SECRET_ACCESS_KEY": "fake", "TOOLSTACK_E_SECRET": "foreign", "TOOLSTACK_CHANNEL_SECRET": "foreign"} + with patch.dict(os.environ, {**sensitive, "PATH": "/fake/bin", "LANG": "C"}, clear=True): + self.assertEqual(runner._child_environment(), {"PATH": "/fake/bin", "LANG": "C"}) + + def test_resolved_verify_normalized_once(self): + cfg = SimpleNamespace(sp_host="fake", sp_port=123, sp_secret="fake", sp_tls_ca="fake-ca") + with patch("sps.config.load_config", return_value=cfg), patch.dict(os.environ, {"TOOLSTACK_SPS_VERIFY": "YeS"}, clear=True): + resolved = runner._resolve_sps("fake.env") + self.assertIs(resolved.verify, True) + with patch.dict(os.environ, {"TOOLSTACK_SPS_VERIFY": "invalid"}, clear=True), patch("sps.client.SPSClient") as client: + runner._sps_register(SimpleNamespace(id="fake", secrets=[]), "tool-key", resolved) + self.assertIs(client.call_args.kwargs["verify"], True) + + def test_recovery_failure_is_not_complete(self): + with patch.object(runner, "reregister_with_sps_results", return_value={"a": None, "b": "failed"}): + result = runner.reregister_all_with_sps_detailed() + self.assertFalse(result.complete) + self.assertEqual(result.succeeded, ("a",)) + self.assertEqual(result.failed, ("b",)) + self.assertEqual(result.count, 1) + + def test_failed_start_cleanup_record_persisted(self): + fake = Mock() + record = runner.RunningTool("a", 123, "docker", "container", channel_secret="channel") + fake.start.side_effect = runner.RunnerStartCleanupError(record) + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_STATE_HOME": directory}), patch.object(cli, "discover", return_value=[SimpleNamespace(id="a")]), patch.object(cli, "get_runner", return_value=fake): + with self.assertRaises(runner.RunnerStartCleanupError): + cli.cmd_up(SimpleNamespace(root="fake", id=None, backend="docker")) + self.assertEqual(cli._load_state()["a"]["handle"], "container") + + def test_interprocess_complete_update_lock(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_STATE_HOME": directory}): + context = multiprocessing.get_context("spawn") + children = [context.Process(target=_state_writer, args=(directory, key)) for key in ("a", "b")] + for child in children: + child.start() + for child in children: + child.join(timeout=10) + self.assertEqual(child.exitcode, 0) + self.assertEqual(set(cli._load_state()), {"a", "b"}) + + def test_private_state_and_channel_view(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_STATE_HOME": directory}): + with cli.state_lock(): + cli._save_state({"a": {"e_secret": "sps-only", "channel_secret": "channel-only"}}) + path = cli._state_path() + channel = path.with_name("channels.json") + self.assertEqual(json.loads(channel.read_text()), {"a": {"channel_secret": "channel-only"}}) + self.assertNotIn("sps-only", channel.read_text()) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(channel.stat().st_mode), 0o600) + + def test_table_lifecycle_identity(self): + a, b = "tool-1", "tool_1" + self.assertNotEqual(netguard._table(a), netguard._table(b)) + with patch.object(netguard.subprocess, "run") as call, patch.object(netguard.os, "rmdir"), patch("builtins.open", side_effect=OSError): + netguard._teardown(a) + netguard._teardown(b) + netguard._teardown(a) + self.assertEqual([c.args[0][-1] for c in call.call_args_list], + [netguard._table(a), netguard._table(b), netguard._table(a)]) + self.assertIn(netguard._table(a), netguard._ruleset(a, None)) + self.assertNotIn(netguard._table(b), netguard._ruleset(a, None)) + + def test_netguard_interleaved_start_stop_fake_only(self): + from unittest.mock import mock_open + with patch.dict(os.environ, {"SUDO_UID": "1000", "SUDO_GID": "1000"}), patch.object(netguard.os, "geteuid", return_value=0), patch.object(netguard.os, "makedirs"), patch.object(netguard.os, "rmdir"), patch.object(netguard.os, "setgroups"), patch.object(netguard.os, "setuid"), patch.object(netguard.os, "setgid"), patch.object(netguard.os, "execvp"), patch.object(netguard, "_restore_dumpable"), patch("builtins.open", mock_open()), patch.object(netguard.subprocess, "run") as call: + netguard._run("tool-1", None, ["fake"]) + netguard._run("tool_1", None, ["fake"]) + netguard._teardown("tool-1") + netguard._teardown("tool_1") + deletes = [c.args[0][-1] for c in call.call_args_list if "delete" in c.args[0]] + self.assertEqual(deletes, [netguard._table("tool-1"), netguard._table("tool_1"), + netguard._table("tool-1"), netguard._table("tool_1")]) + + def test_no_mutable_privileged_launch(self): + with self.assertRaises(runner.RunnerConfigError): + runner._netguard_argv("run", "--tool", "a") + with self.assertRaises(runner.RunnerConfigError): + runner.BwrapRunner().start(Mock()) + + def test_strict_bool(self): + for value in ["1", "TRUE", "yes"]: + self.assertTrue(runner.strict_bool(value)) + for value in ["0", "false", "NO"]: + self.assertFalse(runner.strict_bool(value)) + with self.assertRaises(ValueError): + runner.strict_bool("typo") + + def test_reused_pid_not_signalled(self): + record = runner.RunningTool("a", 123, "process", "999", boot_id="boot", process_identity="old") + with patch.object(runner, "_boot_id", return_value="boot"), patch.object(runner, "_process_identity", return_value="new"), patch.object(runner.os, "killpg") as kill: + with self.assertRaises(runner.RunnerStartCleanupError): + runner.ProcessRunner().stop(record) + self.assertFalse(runner.ProcessRunner().is_alive(record)) + kill.assert_not_called() + + def test_bounded_kill(self): + with patch.object(runner, "_boot_id", return_value="boot"), patch.object(runner, "_process_identity", return_value="start"), patch.object(runner.os, "kill"), patch.object(runner.os, "killpg") as kill, patch.object(runner.os, "waitpid", return_value=(0, 0)), patch.object(runner.time, "monotonic", side_effect=[0, 3, 4, 6]): + with self.assertRaises(RuntimeError): + runner._terminate(999, "start", "boot") + self.assertEqual([c.args[1] for c in kill.call_args_list], [runner.signal.SIGTERM, runner.signal.SIGKILL]) + + def test_docker_failed_removal_raises(self): + record = runner.RunningTool("a", 123, "docker", "container") + with patch.object(runner.subprocess, "run", return_value=SimpleNamespace(returncode=1)), self.assertRaises(RuntimeError): + runner.DockerRunner().stop(record) + + def test_egress_invalid_length_before_read_or_contact(self): + from email.message import Message + for length in ["-1", "junk", str(_MAX_BODY + 1), "9" * 5000]: + handler = object.__new__(_handler(frozenset({"example.invalid"}))) + handler.path = "http://example.invalid/" + handler.headers = Message() + handler.headers["Content-Length"] = length + handler.send_error = Mock() + handler.rfile = Mock() + handler._forward() + handler.send_error.assert_called_once() + handler.rfile.read.assert_not_called() + + def test_batch_persists_first_before_second_fails(self): + defs = [SimpleNamespace(id="a"), SimpleNamespace(id="b")] + record = runner.RunningTool("a", 123, "process", "999", channel_secret="channel") + fake = Mock() + fake.start.side_effect = [record, RuntimeError("second failed")] + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_STATE_HOME": directory}), patch.object(cli, "discover", return_value=defs), patch.object(cli, "get_runner", return_value=fake): + with self.assertRaises(RuntimeError): + cli.cmd_up(SimpleNamespace(root="fake", id=None, backend="process")) + self.assertIn("a", cli._load_state()) + + def test_unrecordable_start_rolled_back(self): + fake = Mock() + fake.start.return_value = runner.RunningTool("a", 123, "process", "999") + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_STATE_HOME": directory}), patch.object(cli, "discover", return_value=[SimpleNamespace(id="a")]), patch.object(cli, "get_runner", return_value=fake), patch.object(cli, "_save_state", side_effect=[OSError("disk"), None]): + with self.assertRaises(OSError): + cli.cmd_up(SimpleNamespace(root="fake", id=None, backend="process")) + fake.stop.assert_called_once_with(fake.start.return_value) + + def test_sps_resolution_reused_and_channel_independent(self): + cfg = SimpleNamespace(sp_host="configured.invalid", sp_port=4444, sp_tls_ca="fake-ca", verify=False) + tool = SimpleNamespace(id="fake", port=123, path=Path("."), command="fake", secrets=[]) + with patch.object(runner, "_check_port_free"), patch.object(runner.os.path, "exists", return_value=True), patch.object(runner, "_resolve_sps", return_value=cfg) as resolve, patch.object(runner, "_sps_register") as register, patch.object(runner, "_tool_log_path", return_value=Path(os.devnull)), patch.object(runner.os, "fchmod"), patch.object(runner.os, "posix_spawn", return_value=999) as spawn, patch.object(runner, "_process_identity", return_value="start"), patch.object(runner.ProcessRunner, "is_alive", return_value=True), patch.object(runner.time, "sleep"), patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "0", "TOOLSTACK_SPS_ENV": "fake.env"}): + record = runner.ProcessRunner().start(tool) + resolve.assert_called_once_with("fake.env") + self.assertIs(register.call_args.args[2], cfg) + env = spawn.call_args.args[2] + self.assertEqual(env["TOOLSTACK_SPS_HOST"], cfg.sp_host) + self.assertEqual(env["TOOLSTACK_SPS_PORT"], "4444") + self.assertEqual(env["TOOLSTACK_SPS_CA"], "fake-ca") + self.assertEqual(env["TOOLSTACK_SPS_VERIFY"], "0") + self.assertEqual(env["TOOLSTACK_CHANNEL_SECRET"], record.channel_secret) + self.assertNotEqual(record.channel_secret, record.e_secret) + + def test_egress_stream_cap_closes_upstream(self): + from email.message import Message + from toolyard import egress_proxy + handler = object.__new__(_handler(frozenset({"example.invalid"}))) + handler.path = "http://example.invalid/" + handler.command = "GET" + handler.deadline = time.monotonic() + 30 + handler.expired = False + handler._connect = Mock(return_value=Mock()) + handler.headers = Message() + handler.wfile = Mock() + handler.send_response = handler.send_header = handler.end_headers = Mock() + upstream = Mock() + response = upstream.getresponse.return_value + response.getheader.return_value = None + response.getheaders.return_value = [] + response.status = 200 + response.read1.side_effect = [b"x" * 8, b"y"] + with patch.object(egress_proxy, "_MAX_RESPONSE", 8), patch.object(egress_proxy.http.client, "HTTPConnection", return_value=upstream): + handler._forward() + self.assertEqual(handler.wfile.write.call_count, 1) + self.assertEqual(response.read1.call_args_list[-1].args[0], 1) + upstream.close.assert_called_once() diff --git a/uv.lock b/uv.lock index 53d0a72..fcd5bbd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,47 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -72,45 +112,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "cryptography" -version = "45.0.7" +version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, - { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" }, - { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, - { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, - { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, - { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, - { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, - { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" }, - { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" }, - { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, - { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, - { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, - { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/99/4e/49199a4c82946938a3e05d2e8ad9482484ba48bbc1e809e3d506c686d051/cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde", size = 3584634, upload-time = "2025-09-01T11:14:50.593Z" }, - { url = "https://files.pythonhosted.org/packages/16/ce/5f6ff59ea9c7779dba51b84871c19962529bdcc12e1a6ea172664916c550/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34", size = 4149533, upload-time = "2025-09-01T11:14:52.091Z" }, - { url = "https://files.pythonhosted.org/packages/ce/13/b3cfbd257ac96da4b88b46372e662009b7a16833bfc5da33bb97dd5631ae/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9", size = 4385557, upload-time = "2025-09-01T11:14:53.551Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c5/8c59d6b7c7b439ba4fc8d0cab868027fd095f215031bc123c3a070962912/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae", size = 4149023, upload-time = "2025-09-01T11:14:55.022Z" }, - { url = "https://files.pythonhosted.org/packages/55/32/05385c86d6ca9ab0b4d5bb442d2e3d85e727939a11f3e163fc776ce5eb40/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b", size = 4385722, upload-time = "2025-09-01T11:14:57.319Z" }, - { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908, upload-time = "2025-09-01T11:14:58.78Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, +] + +[[package]] +name = "fastapi" +version = "0.137.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b1/e5b92c59d2c37817e77c1a8c2fc1f79cdcc04c68253e5406b43e3204cba7/fastapi-0.137.1.tar.gz", hash = "sha256:822360704230d9533d8d9475399613525968aa2f0b5bd2a3ccc9f18c88fd541c", size = 408293, upload-time = "2026-06-15T11:28:20.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/380b9a5922f4340e51c309cde09e5bd32e62f02302971bee30dc15aa0624/fastapi-0.137.1-py3-none-any.whl", hash = "sha256:64f6983c59e45c4b9fdc44e57cb8035c2451ee91ea8e8ec042aca37de7cf6b69", size = 121877, upload-time = "2026-06-15T11:28:19.523Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -122,16 +261,280 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/1b/ca81811da3d2aea724fb3c4ba6b83f50ab8f210576306ccd601973142d35/pydantic_core-2.50.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c531166c42ea7bdfecc8c50049581f05dd1993b09cc7c52bb36a14e96deaec7d", size = 2152145, upload-time = "2026-10-11T18:31:58.046Z" }, + { url = "https://files.pythonhosted.org/packages/59/20/938ef7e0541af0a314e2f9d63848dbd1656ba2ebfe5ca0469f7ebee00d5e/pydantic_core-2.50.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6d0c2183008c188e19f4906d426b293bdc4f67ab17df8e180fe16cda208fa71", size = 2045761, upload-time = "2026-10-11T18:31:59.35Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4b/456497fe2affa18f49b226e89bde31e8a204751ee343dd9beaac5438123e/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94be440c03fede26969a5ce75468e0e6a9927a1b46d9b679ee8adc1b057b0350", size = 2049557, upload-time = "2026-10-11T18:32:01.121Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/cd5dfe13928f55e236dff3a1bee06721d318ca6c955d9ca993b0a5d09f39/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c426eac0af8d1529ff8467e612b933346caec1fdc0d774f78f67a1a11e16c1", size = 2188153, upload-time = "2026-10-11T18:32:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/d0b25bc484f8d9136ea76ece69e0b2d24ab370cc2e7bbd1b79e61723b763/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028e2f212273d4a39b1ec1e0de8166b1165a65fc0f1111452a9d94fc7c625c63", size = 2388308, upload-time = "2026-10-11T18:32:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/c5/64/6513cac7c9f78492abafa01d57a69de1d246bf3085fd7b0330bf14b025b3/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6f0cc1bb9900dc558960894adeb30b0c083366fc1d69b856209fb2ca5c36fe5", size = 2431187, upload-time = "2026-10-11T18:32:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/ed/60/7f2ae89c37e69a3dc0da9ff57b0388c285542c688d05f36767770429de85/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8812592c85d0edf423f10eadcef42716d71e8219085ad9e85b775057b7306133", size = 2157623, upload-time = "2026-10-11T18:32:06.616Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/e3a71373b85a7973099b9a229a4302c7792262717e0b4997e41c01f7dd16/pydantic_core-2.50.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:bbce99252ba3167b2b6277f1829d5bf4b43b754524bddf7f944707c3db7d2253", size = 2254539, upload-time = "2026-10-11T18:32:07.968Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d5/5b620aaf40dc631d911565b64d49d3fa8422027c046a7a96dc4e4921ae98/pydantic_core-2.50.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:476f6ed8e43cd1e0b460920e23571700872b284e77331cb30c4faf459cf48a4b", size = 2267344, upload-time = "2026-10-11T18:32:09.399Z" }, + { url = "https://files.pythonhosted.org/packages/e0/48/f46baf4580d8a56ca4782aa65842e16d3cf39736b29fcce8e9c0521b3422/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cbd1b75b09e976ed0d6b6ca297675632ca35df86130088457cdc60ef36970ae", size = 2300877, upload-time = "2026-10-11T18:32:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/e7d28505e6092c1fe075838a4f793ccbe6dd676e44ab16175815ee06cfe0/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:5958c72adb417c39b12ac87525ac60b0d73315fcdc59e21f44ee4a5e2512c9ef", size = 2464270, upload-time = "2026-10-11T18:32:12.329Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/3bf63b82efd93ebc9968f9c12fc46037a28bcb452c88e4688f33e5ff7dca/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d8f9e8a6c4ab04b78d61f78627370d834eb004b2869dcb28cfffa647b4ea1980", size = 2478653, upload-time = "2026-10-11T18:32:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/26d1e4b9c2ba6a92b7dc75bbcb99ef44456b7e3af1bced868403667ea7d3/pydantic_core-2.50.1-cp311-cp311-win32.whl", hash = "sha256:4be846f55c9477f5f3ddde8f2ce941137e16862a56d018ed885d422bb6ae02f2", size = 2120246, upload-time = "2026-10-11T18:32:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/24/65/3e875e7991eaecd4d70686293ff0d8a99aa8f93dc295e6a21c3d702912fb/pydantic_core-2.50.1-cp311-cp311-win_amd64.whl", hash = "sha256:0048b6dddc8ef4b64fccaad878bd143b0c3882ea9936279dc11d613f6b7dd1bc", size = 2176332, upload-time = "2026-10-11T18:32:17.153Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5c/58dbd17c61f209d91c70f549eb7a3bbec7023e89ea12dead091462d6d658/pydantic_core-2.50.1-cp311-cp311-win_arm64.whl", hash = "sha256:6a733778df2f7087ec1100ed0b41533e4f3001976e99570fa34f57c66e7f8e3e", size = 2182471, upload-time = "2026-10-11T18:32:18.49Z" }, + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/e7/73/a23a327237d9bb985298ce47702e196f995ce850224c12c70886bd66bb5c/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:062e891facce5ca296a1c37098e5e466780457f86413894b399f0cf22934f769", size = 2162560, upload-time = "2026-10-11T18:35:06.274Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/33b37b5e82408f402a5e82649a43e7c95cd5dab7beaeef2c35cedbfd18f2/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:49c2cbb2397fe4d0987e84606e691af6cb87bc0ee1bd3e7b737f7e10b4c142f9", size = 2075250, upload-time = "2026-10-11T18:35:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/c7d5b9249466ccab90f3ce4790c2399dbfb8172ae9e74a7753dd295fe65a/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e4472072de0137ee0d8e72d6620e85939c271d2f90f6bbb4b15c24638b79f92", size = 2107891, upload-time = "2026-10-11T18:35:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/28d29bc1657195933147f772dee0558d9e0f045bbc11db7f992f40c37bb5/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ed4f3cef55164b026fefb41341b7754cc6b624c75dfe7142d2ecceb5ad21c87", size = 2268026, upload-time = "2026-10-11T18:35:13.336Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, + { url = "https://files.pythonhosted.org/packages/bd/35/002a378302ff91d2a7f149bc8d22363faf581ba40ac39d0123444315a32d/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7456d699b13954e9c0164dcb267250a10ae0dfb03e6e26d6796ab0d46e189c84", size = 2154076, upload-time = "2026-10-11T18:35:24.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5c/7a034330082c00f3c9795052bd9b161aaaf6e1951e897ac00a094823ef7e/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0d955195bbbe489ad343fcc956eacea9357b79cb22192c66cacdefcbc14b32f", size = 2085850, upload-time = "2026-10-11T18:35:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/d5b44a7d3d4eceed6ef8713a0003af56fefde6ec75915513bcdd5d730cd9/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2c634642694e6a0dad2ab1d375589fa671fd442edd5caf7d9737b8f6ca22906", size = 2259754, upload-time = "2026-10-11T18:35:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/b6/88/e01dd37301710bd8b3b2daf34ec9e2a6e83a333130831c0d468b0292a48c/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6db2fbed51a7991302e8fac498cd67e336246026d0dfa84cf5166ce1412760", size = 2268712, upload-time = "2026-10-11T18:35:32.654Z" }, + { url = "https://files.pythonhosted.org/packages/3e/43/5bb80d4a4d2b2d84612a30205662cc521a20d209c94abc079ea48b4e0df2/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:79490e33c4c0fcb933bbbcfc3a62184d8803b99f535863dfbb925e1bcb6945ad", size = 2305049, upload-time = "2026-10-11T18:35:35.392Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7b/e3ef76b269cfe4a5bd3bdcf5d7124cf05e5a8909d443b91639307116bdae/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:48569b0ade9edfbe065cad1d700175546592aebbb42f02adcebcc26e75b896fe", size = 2466001, upload-time = "2026-10-11T18:35:37.791Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7a/ea0947aadc1d9f72d4be00d2e19939611ec17363bcb5e6eb9ec7d8221c34/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5f3cae32fc46121f787cb2486de9cf95a8bf72aec5cc78f64c606fa1735a6ef5", size = 2481777, upload-time = "2026-10-11T18:35:40.287Z" }, + { url = "https://files.pythonhosted.org/packages/24/f3/d15bc0b1fb0c4f1e07b3326ffb7489f7ae70da2b56a0884d22641b0eb47c/pydantic_core-2.50.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f476456ac2bb0d937f75191494a09c83a30765fea4f70f3b404942fe25f6cdf", size = 2336787, upload-time = "2026-10-11T18:35:42.558Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "starlette" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, +] + [[package]] name = "toolstack" version = "0.1.0" source = { editable = "." } [package.optional-dependencies] +admin = [ + { name = "fastapi" }, + { name = "pyyaml" }, + { name = "uvicorn" }, +] +test = [ + { name = "httpx" }, +] vault = [ { name = "cryptography" }, ] [package.metadata] -requires-dist = [{ name = "cryptography", marker = "extra == 'vault'", specifier = ">=42,<46" }] -provides-extras = ["vault"] +requires-dist = [ + { name = "cryptography", marker = "extra == 'vault'", specifier = ">=50.0.2,<51" }, + { name = "fastapi", marker = "extra == 'admin'", specifier = "==0.137.1" }, + { name = "httpx", marker = "extra == 'test'", specifier = "==0.28.1" }, + { name = "pyyaml", marker = "extra == 'admin'", specifier = "==6.0.3" }, + { name = "uvicorn", marker = "extra == 'admin'", specifier = "==0.49.0" }, +] +provides-extras = ["admin", "test", "vault"] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +]