164 Commits
Author SHA1 Message Date
Hare 6fffe4517b feat: integrate committed Ticket event services 2026-10-31 05:53:52 +09:00
Hare ab138d72fc fix: emit complete plugin Host TypeScript dependencies 2026-10-31 05:25:03 +09:00
Hare 5627f47e2b test: verify scoped authority storage lifetime and physical initialization 2026-10-31 04:58:20 +09:00
Hare 2c1f5dfafe fix: bound Subject database retention to synchronous plugin checks 2026-10-31 04:58:19 +09:00
Hare b508fef698 feat: deliver committed Ticket events to plugin services 2026-10-31 04:58:19 +09:00
Hare 27f3b36ce5 fix: retain Workdir close confirmation across retries 2026-10-30 22:52:43 +09:00
Hare 5d0e03dc53 fix: retain Runtime close ownership until provider teardown completes
Resolve MR runtime-close-ack-before-release: keep sessions registered while a task-owned close is pending; all DELETE waiters share its result, successful teardown alone removes the entry, and provider errors permit retry. Cancelling HTTP waiters cannot cancel Local provider command teardown.

Add deterministic Local teardown/Runtime active-use tests plus authenticated Backend registry cancellation and same-session retry through actual materializer busy/removal. Focused dependent Server 26 pass, Runtime new 3 and existing close tests pass; root check, fmt, diff, names pass. Full Runtime attempt: 505 pass/63 restore-environment failures; corrected exact 63 pass separately, not a full-suite green claim. Validation logs: target/t805-validation. Base remains 0f82; no T795/T796 import.
2026-10-30 22:25:27 +09:00
Hare 4f7dcfda61 feat: add authorized Plugin Ticket data capabilities 2026-10-30 21:45:03 +09:00
Hare 1c03758fc1 fix: confirm remote Workdir close before releasing session ownership 2026-10-30 21:44:51 +09:00
Hare 0f82d6fa8f fix: normalize each Gemini response call before Tool dispatch 2026-10-30 09:47:54 +09:00
Hare 5a317e6d2a fix: bind Plugin receipts to durable Host Tool-call history identity 2026-10-30 09:06:29 +09:00
Hare 5329a93e3e test: preserve saved policy fixtures and uncertain Plugin cancellation evidence 2026-10-30 07:39:15 +09:00
Hare 94d458883e Merge remote-tracking branch 'origin/develop' into work/t-533-plugin-worker-composed
# Conflicts:
#	crates/server-api/src/lib.rs
#	crates/server-api/src/plugin.rs
#	crates/server-api/src/plugin_types.rs
#	crates/worker-runtime/Cargo.toml
#	crates/worker-runtime/src/workspace_request/plugin_delivery.rs
#	crates/workspace-server/Cargo.toml
#	crates/workspace-server/src/lib.rs
#	crates/workspace-server/src/server.rs
#	crates/workspace-server/src/server/tests/subject_authorization_tests.rs
#	crates/workspace-server/src/server_plugin_delivery.rs
#	crates/workspace-server/src/server_plugin_delivery_tests.rs
#	openapi/server-api.json
#	web/workspace/src/lib/generated/companion-api.ts
#	web/workspace/src/lib/generated/plugin-api.ts
#	web/workspace/src/lib/generated/repository-api.ts
#	web/workspace/src/lib/generated/runtime-api.ts
#	web/workspace/src/lib/generated/ticket-api.ts
#	web/workspace/src/lib/generated/workdir-api.ts
#	web/workspace/src/lib/generated/worker-launch-api.ts
2026-10-30 07:20:50 +09:00
Hare b824195b36 feat: complete Plugin controller lifecycle and owner diagnostics with real runtime coverage 2026-10-30 07:15:03 +09:00
Hare 318c1a9ce9 fix: align permission management with integrated Worker and chat operations 2026-10-30 05:30:42 +09:00
Hare 9f13446afd fix: interrupt revoked Plugin transports and enforce full control-plane exclusions 2026-10-30 05:28:51 +09:00
Hare 479bf0872a feat: bind Plugin launch selections and authenticated Runtime providers 2026-10-30 05:28:51 +09:00
Hare 36932321e0 feat: integrate authorized Plugin delivery and verified cache 2026-10-30 05:07:00 +09:00
Hare 70ce8479b2 feat: integrate asynchronous Subject chat with permission management 2026-10-30 05:05:08 +09:00
Hare 57b57aed67 feat: integrate Subject permission management with Worker contracts 2026-10-30 05:02:30 +09:00
Hare 0cd4811739 fix: supply receipt-free Plugin preparation and authenticated Host entry 2026-10-30 04:43:40 +09:00
Hare 165153c63c feat: stage exact Plugin Tools through shared Worker composition
Extract the unchanged canonical plan model for typed Runtime snapshots without a Server dependency cycle. Bind Tool consumer identities to persisted Session/call IDs, use the owner-issued context and current publication authorization, and register through FeatureInstallPlan with cooperative shutdown.

Validation so far: root cargo check --locked, portable model 6 tests/doctest, Worker actual Component adapter 7 focused tests, Server plan 9 tests via shared isolation. Full product launch owner/pin lifecycle and Remote/Embedded vertical integration remain a subsequent coherent change.
2026-10-30 04:42:53 +09:00
Hare 3a13301f75 feat: deliver exact Plugin artifacts through current Host authority
Bind remote and in-process delivery to authenticated Worker lineage, live installation permission and persisted exact pins. Verify canonical bytes and complete descriptors in a bounded Runtime-only atomic cache; cache hits reauthorize and process-crash staging never becomes visible.

Shares the exact PluginInstallation permission shape published for T-793; no HTTPS or launch/restore integration is included. Depends on the T-465 source ancestry and the shared Host execution identity helper.
2026-10-30 04:09:12 +09:00
Hare 732fe5c4c1 feat: enforce live Plugin Host authority and durable HTTPS effects 2026-10-30 04:07:19 +09:00
Hare 7c238edc1a feat: deliver exact Plugin artifacts through current Host authority
Bind remote and in-process delivery to authenticated Worker lineage, live installation permission and persisted exact pins. Verify canonical bytes and complete descriptors in a bounded Runtime-only atomic cache; cache hits reauthorize and process-crash staging never becomes visible.

Shares the exact PluginInstallation permission shape published for T-793; no HTTPS or launch/restore integration is included. Depends on the T-465 source ancestry and the shared Host execution identity helper.
2026-10-30 03:51:57 +09:00
Hare 55ad8c4265 feat: connect async chat to current Ticket assignment and saved results 2026-10-30 03:16:00 +09:00
Hare 1eecccc21e feat: compose Subject Ticket assignment with selected dependency contracts
Adapt T-778 source 6d15569 and selected T-465 plugin plans on T-803 51f0c7e0. Preserve frozen Workspace107 and append Subject108, current assignee/target checks, recipient-scoped notification recovery and public contract generation. This is a candidate composition, not target adoption.
2026-10-30 03:16:00 +09:00
Hare c65f145376 feat: expose current Plugin execution identity to trusted Hosts 2026-10-30 02:53:18 +09:00
Hare 8661505b5a feat: bind Plugin Host identity to live Worker authority 2026-10-30 02:53:18 +09:00
Hare 4f096f26b5 feat: expose current Plugin execution identity to trusted Hosts 2026-10-30 02:52:25 +09:00
Hare e7506c5eb8 feat: bind Plugin Host identity to live Worker authority 2026-10-30 02:51:35 +09:00
Hare 967274bfbd feat: add exact Plugin installation operation resource 2026-10-30 02:41:52 +09:00
Hare 6d15569b9b test: cover public Subject Ticket delegation and result evaluation 2026-10-30 01:54:50 +09:00
Hare 8810d3245e fix: append Subject migration after retained Plugin plan schema 2026-10-30 01:54:50 +09:00
Hare bd70eda4e0 fix: connect Ticket consumers to selected Worker delegation candidate 2026-10-30 01:14:29 +09:00
Hare 771120778b feat: connect restricted async chat to background Worker results
Exercise public Spawn, real bounded work, other-conversation handling, ordinary Notify and explicit publication to the originating message. Retain stable complete spawn input across acknowledgement loss and retries; verify operation and target revocation without a second result or grant registry.
2026-10-30 01:10:16 +09:00
Hare d077302b92 fix: align Worker operation grants and known-target observation
Compose the published T-803 candidate 51f0c7e019 with T-766 without target adoption. Preserve operation-use versus directional control, notify-only replies, live revocation, and T-766 receipt/provenance boundaries.
2026-10-30 01:10:16 +09:00
Hare c82113f7c4 fix: scope Ticket notification scans to each assigned Subject 2026-10-30 00:53:31 +09:00
Hare 51f0c7e019 Authorize known Worker observations without collection discovery 2026-10-30 00:37:56 +09:00
Hare 26cc5e9306 fix: supply missing Workspace permission fixtures for Memory tests 2026-10-30 00:11:02 +09:00
Hare 6733aeef01 feat: connect Subject Ticket assignments across public execution paths 2026-10-30 00:11:02 +09:00
Hare c215c72873 fix: preserve Console Notify ACK identity and request ID limits 2026-10-30 00:00:05 +09:00
Hare 87a1b6f1eb fix: authorize bound standalone Subject body and Job operations 2026-10-30 00:00:05 +09:00
Hare 882316fb3b feat: add Subject async chat with explicit durable publication 2026-10-29 22:27:23 +09:00
Hare 74ddd568eb Separate Worker operation authorization from directional control grants 2026-10-29 22:26:35 +09:00
Hare 4c2bd3ea78 fix: synchronize Feature shutdown capacity test on its storage fence 2026-10-29 10:08:09 +09:00
Hare dd312b71ee docs: localize browser evidence boundary comments 2026-10-29 09:34:30 +09:00
Hare 2141672310 test: exercise Subject permission management in Chromium 2026-10-29 09:33:58 +09:00
Hare 3600843683 feat: manage direct and delegated Subject permissions from Web
Use typed catalog labels and resource inputs, preserve unsupported entries, preview lane-specific differences and retain exact save requests for unknown-outcome reconciliation. Re-read current policy instead of treating historical receipts as authority.
2026-10-29 09:26:11 +09:00
Hare 38a953cd91 feat: expose typed Subject permission management catalog
Validate management additions in the existing CAS/replay transaction while retaining exact legacy entries in their original lane. Expose read-only account capability and test real saved-policy authorization, conflicts, replay and deletion fences.
2026-10-29 09:25:09 +09:00
Hare fc1115b167 fix: pin evaluated Plugin config after Profile overrides 2026-10-29 09:17:52 +09:00
Hare 9b7a58d5c4 feat: resolve Workspace Plugin config into immutable execution plans 2026-10-29 08:46:39 +09:00
Hare 7ad6030498 feat: add Workspace Plugin catalog and immutable artifact authority 2026-10-29 06:49:51 +09:00
Hare 5087abc5ab fix: align signed Ticket fixtures with combined native contracts 2026-10-29 05:29:18 +09:00
Hare 90398391dd test: verify Feature rollback restores typed result publication 2026-10-29 05:29:18 +09:00
Hare 6486caf992 fix: expose typed Workspace config batch contracts 2026-10-29 05:08:40 +09:00
Hare 0ce4fc04ba fix: distinguish Drive service and typed entry results 2026-10-29 05:05:20 +09:00
Hare 3812126897 fix: expose typed Workdir result references 2026-10-29 05:05:20 +09:00
Hare ec275da601 fix: combine typed domain results with Feature publication 2026-10-29 05:05:12 +09:00
Hare 5f0c960048 fix: reconcile Feature composition with neutral Worker contracts 2026-10-29 05:04:39 +09:00
Hare e35cb50f3a feat: integrate isolated Plugin Tool executor 2026-10-29 05:01:31 +09:00
Hare b8f54eeb6d fix: expose notification MR origin checks as typed entries 2026-10-28 18:19:28 +09:00
Hare 959ed7a554 feat: publish typed Ticket Objective and MR result entries 2026-10-28 18:02:39 +09:00
Hare 0666434ec8 test: align signed native fixtures with current Ticket input contracts 2026-10-28 17:59:19 +09:00
Hare 14101d45dc refactor: compose Objective and SubWorker contributions through Feature installation 2026-10-28 17:59:19 +09:00
Hare 4e2f6fcb9c fix: expose typed Workspace config batch contracts 2026-10-28 17:49:22 +09:00
Hare 043465dc6b fix: separate Drive service identity and type native result entries 2026-10-28 17:46:05 +09:00
Hare 9b5fad3970 fix: publish typed workdir result entries and current content entrances 2026-10-28 17:35:14 +09:00
Hare 45e24375d5 fix: reject unmetered private Component resource allocators
Address formal review R1 by failing closed on guest resource definitions and canonical resource.new in outer and nested Components before Store creation. Preserve shared artifact/ABI verification and imported Host connections; regress resource ceilings zero/default, 64 live handles in core start, nested definitions and fresh execution after rejection.
2026-10-28 17:14:25 +09:00
Hare d9e9c0f13d feat: execute v2 Plugin tools in isolated Wasmtime invocations
Reuse canonical artifact and ABI verification; inject live Host admission and operation adapters without product authority or ambient WASI. Bound fresh Stores, resources, CPU, common deadlines and diagnostics, and regress actual PDK/adversarial Components.
2026-10-28 16:54:53 +09:00
Hare de4159b20f fix: align signed Ticket integration fixtures with native v2
Dependent authorization/backend tests still selected collection/v1 or supplied flat Tool arguments to the native surface introduced by T-797. Use the observed v2 interface, content/observed groups and page cursor/limit coordinates. Do not add a production compatibility fallback.

Validation on the final combined tree: cargo test -p worker (1254 lib plus integration targets), -p worker-runtime (562 lib, both 20-test binary targets), and -p yoi-workspace-server (1345 lib passed, 3 existing ignored, 12 binary passed); all normal doctests passed. Signed authorization 29 and Ticket Tool/WIP backend 4 focused cases also passed after fixture repair.
2026-10-28 16:51:46 +09:00
Hare 6826db7e0c refactor: own Worker and Ticket service contracts outside providers
Move shared control/lifecycle/observation/Ticket contracts into feature::contracts. Consumers and host bindings import the neutral owner; providers retain current authorization, explicit combined routing and persisted composition.

Document stable semantic IDs and exact compatibility epochs; connect consumer requirements to the existing planner without a new resolver. Exercise consumer installation/dispatch with contract-only test providers, atomic rejection paths, and parsed-source architecture guards including negative fixtures.

Validation: worker and worker-runtime normal tests and doctests passed; root cargo check, rustdoc with warnings denied, fmt, diff and state-contract names passed. Dependent Server authorization tests require separate stale native-v1 fixture updates captured next.
2026-10-28 16:51:46 +09:00
Hare a1fbe1e754 fix: preserve native plan filters and disclose input coordinates 2026-10-28 15:24:28 +09:00
Hare 8b261cdae1 feat: define native Ticket and MR input contracts with shared domain adapters 2026-10-28 14:56:42 +09:00
Hare 881e241e67 feat: integrate v2 Plugin contracts and authoring 2026-10-28 13:53:26 +09:00
Hare 00903264f3 fix: bind deletion Job fixtures to current Subject authority
Use a real connected origin with exact request/supply permissions, assert immutable Job origin, and materialize its cleanup storage. Reuse test-only setup helpers without weakening production authorization.
2026-10-28 13:38:47 +09:00
Hare f598a36ac0 feat: integrate operation-boundary authorization
Merge reviewed source a35d9a3d45 into develop after Subject deletion. Preserve deletion fencing before Job authorization, retain both test modules, and regenerate combined API artifacts.
2026-10-28 13:38:47 +09:00
Hare 2d195f4058 feat: provide v2 Plugin Component contracts and offline validation
Validate exact Component interfaces and static Tool/DCDL schemas without guest execution. Share canonical archive packing and independent package/artifact verification; build real pure and HTTPS PDK fixtures and retire v1/Service authoring stubs.
2026-10-28 13:31:47 +09:00
Hare 06883c51b7 docs: define Workspace Plugin API contracts 2026-10-28 13:01:45 +09:00
Hare a35d9a3d45 fix: retain Host-bound Job input authority through every model supply
Bind immutable Job kind/id/input digest to retained input provenance. Recheck current Host authority after hooks and at each compactor request; restored or rewritten input cannot become a local grant or switch Job identities. Add actual Controller, lifecycle, correction and signed Host/model regressions for Journal and consolidation.
2026-10-28 12:53:48 +09:00
Hare 0710738173 fix: reconcile unfinished retention before Subject deletion 2026-10-28 12:07:16 +09:00
Hare 960a1b4364 Authorize Feature effects and protected input at operation boundaries
Remove non-MCP activation gates without expanding legacy grants. Bind resource resolution, background Job attenuation, protected prompt provenance and repeated model supply to current Host authority. Align generated contracts and regression fixtures.
2026-10-28 11:44:34 +09:00
Hare a06f6bfb3d feat: replace Subject retirement with durable explicit deletion 2026-10-28 11:18:43 +09:00
Hare 618880d38d fix: merge develop and reconcile schema histories
Preserve both published schema-103 histories and converge their Passkey results and Job input retention features transactionally at schema 104. Retain local Reviewer, integration-event, auth and sidebar fixes while adopting develop restore and E2E updates; regenerate API contracts.

Validation: schema convergence regressions, full Server/Worker/Runtime suites, server-api and merge-request tests, Web unit/component tests, root cargo check, E2E harness tests and restore/auth target compilation, generated artifacts, fmt and naming checks. Product-process E2E not rerun; live services and databases untouched.
2026-10-28 04:01:34 +09:00
Hare 79b76c8789 fix: merge worker restore into develop host lifecycle
Integrate integration/worker-restore without reviving removed Worker socket/discovery paths. Keep restore candidate allocation and canonical effects deferred until activation, with registry reclamation and observer publication fences on the in-process controller.

Preserve develop BackendJob/provenance, immutable Journal body capture and erasure, and Workdir diagnostics. Adapt obsolete socket fault tests to in-process publication and registry drift boundaries.

Validation: root cargo check; ordinary worker, worker-runtime and standalone package tests; Server restore (43) and Journal (49) filters; mcp/server-api/runtime-api/session-store tests; optional Runtime feature check; OpenAPI and six generated TypeScript checks; Web restore unit tests (15); runner tests (21); fmt, naming and diff checks. Full workspace/Server suites, E2E, live/model and fixture generation not run.
2026-10-28 00:06:17 +09:00
Hare 897c025b85 feat(journal): execute frozen body inputs with durable delivery and erasure fences 2026-10-27 08:03:43 +09:00
Hare 5b12c3e085 fix: make mobile sidebar swipes track touch gestures 2026-10-27 06:49:28 +09:00
Hare 623c31615d fix: assign unique event refs to merge integration comments 2026-10-27 06:15:48 +09:00
Hare af719e387d fix: align reviewer guidance with Bash-enabled scope 2026-10-27 06:15:48 +09:00
Hare dddb21bd70 fix: preserve possible repair during v9 Restore migration 2026-10-27 01:24:50 +09:00
Hare e8d4e964cb fix: refresh Workdir API generated contract digest 2026-10-26 21:25:46 +09:00
Hare eb24cb04dc fix: revalidate Host authority when publishing prepared restore controllers 2026-10-26 20:39:28 +09:00
Hare e84057f402 fix: retain exact restore ownership in test execution backends 2026-10-26 20:39:28 +09:00
Hare 15c3006d09 fix: propagate fallible restore publication through server factory adapters 2026-10-26 19:59:56 +09:00
Hare b3209c2012 test: verify real orphan pin repair across owned restore process death 2026-10-26 19:38:24 +09:00
Hare 6f5a424661 fix: reclaim stale allocation authority before restoring committed run artifacts 2026-10-26 19:37:26 +09:00
Hare 8d47d2670b fix: initialize reconciliation fault evidence in offline store construction 2026-10-26 14:43:38 +09:00
Hare b19de45a82 test: verify original catalog projection after receipt reconciliation 2026-10-26 14:40:58 +09:00
Hare 1e88893ef9 fix: preserve non-restore cleanup evidence through bridge refactoring 2026-10-26 14:31:45 +09:00
Hare 1fd2e09971 test: cover real partial publication and terminal receipt durability faults 2026-10-26 14:28:37 +09:00
Hare f85d831104 fix: retain committed controller paired streams across bridge admission failure 2026-10-26 14:10:02 +09:00
Hare 2b24db5a15 test: fence superseded restore replay and old Shutdown after replacement 2026-10-26 13:49:48 +09:00
Hare e1945d977c test: prove real postcommit activation timeout ownership and Stop fencing 2026-10-26 13:40:51 +09:00
Hare a10875d68e fix: retire the exact stopped Controller from runtime observation 2026-10-26 13:36:37 +09:00
Hare f35dcef4d0 test: synchronize owned runtime crashes at restore decision checkpoints 2026-10-26 13:10:54 +09:00
Hare f5ebae1134 test: verify restore response loss and owned runtime restart 2026-10-26 12:27:33 +09:00
Hare 6be4e8388b feat: recover saved restore operations by their durable decision 2026-10-26 11:50:54 +09:00
Hare 9e3f87ac52 fix: runtime retain restore facts when Stop supersedes publication 2026-10-26 11:50:54 +09:00
Hare ceb01f6eaa feat: runtime own restore continuation beyond caller timeout 2026-10-26 11:50:54 +09:00
Hare 1b13563e19 feat: runtime commit restore decisions before execution publication
Persist explicit restore commit evidence, reconcile canonical write uncertainty, and publish the same prepared controller before Accepted. Keep owned cleanup and publication failures distinct. Preserve optional transport/store feature boundaries and adapt external controller constructors.
2026-10-26 11:50:54 +09:00
Hare 848a1e9a5b feat: worker separate restored controller preparation from publication
Prepare the real receiver, full snapshot and paired streams privately; retain activation effects and startup cleanup until the host decision.
2026-10-26 11:50:54 +09:00
Hare a77453338c feat: mcp retain stdio process ownership before initialization
Allow startup cleanup to own and reap the exact spawned client across handshake failure or cancellation.
2026-10-26 11:50:54 +09:00
Hare d12bc2ea0b fix: deduplicate live and committed task updates 2026-10-26 10:13:36 +09:00
Hare a1df3742b1 fix: integrate passkey results with current dogfooding shell
Preserve authenticated home/account navigation and guarded mobile sidebar swipes. Keep both browser task selections. Complete the new account fixture contract and synchronize lifecycle probes on visible registered-key results, without changing their success or security assertions.
2026-10-26 09:50:13 +09:00
Hare d8818b776b feat: open and close mobile sidebar with guarded swipes 2026-10-26 09:39:35 +09:00
Hare 40c36ccb1d fix: restore authenticated home and account navigation 2026-10-26 09:39:35 +09:00
Hare 80c151d084 fix: fence recovered passkey results against cross-tab changes 2026-10-26 07:52:06 +09:00
Hare c6a8759e7b fix: fence activation and setup against delayed cross-tab events
Capture the shared authentication stamp as well as generation before dispatch and across response/confirmation/application awaits. Do not let stale setup failure reconciliation overwrite a newer tab's stamp. Add no-event-yet logout and account-change regressions for activation and setup, including prompt, pre-complete and post-dispatch confirmation boundaries.

Addresses MR AC4_CROSS_TAB_ACTIVATION. Validation: activation21/setup40 narrow tests; Web720 unit+625 component, check0/0/build, activation and hosting browser suites; root check/fmt/diff; both explicit real Backend browser suites including native CTAP2. Existing Server exclusions and 15 identical baseline naming findings remain unchanged. No verifier, binding, migration or rendering contract weakened.
2026-10-26 07:34:04 +09:00
Hare f17b5212c7 fix(auth): confirm passkey operations and preserve safe legacy migration
Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions.

Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
2026-10-26 07:18:23 +09:00
Hare 85f70d4bc1 test: adapt fixed Worker restore fixtures to integration lane 2026-10-26 06:20:22 +09:00
Hare b284934e34 test: add captured Codex worker restore fixture 2026-10-24 12:57:28 +09:00
Hare 2d33321fab fix: validate live fixture snapshots and shutdown boundaries 2026-10-24 12:57:28 +09:00
Hare 0975471986 fix: preserve diagnostic context in backend test assertions 2026-10-24 12:51:28 +09:00
Hare 214d6e21dd fix: assert classified workdir session closure in tests 2026-10-24 12:40:37 +09:00
Hare fca5c23345 fix: reject unrestorable and opaque fixture records 2026-10-24 12:39:22 +09:00
Hare f9b1500ba2 fix: align panel tests with current ticket case states 2026-10-24 12:33:31 +09:00
Hare 931bf3b27d test: add isolated saved worker restore scenarios 2026-10-24 12:33:31 +09:00
Hare 6bb671be21 test: add typed stopped worker fixture transport 2026-10-24 12:19:43 +09:00
Hare b8f2fa479c test: add isolated backend fixture seeding 2026-10-24 12:15:10 +09:00
Hare 1639be9841 fix: allow reviewers to read related tickets 2026-10-23 03:22:45 +09:00
Hare 907392cce8 fix: preserve restore effects and fence prepared attachment replay 2026-10-23 00:21:02 +09:00
Hare 6776973e4c fix: preserve original session coordinates through capture selection 2026-10-22 23:17:25 +09:00
Hare 73a3e674c9 feat(worker): separate restored candidates, repair, and activation 2026-10-22 22:58:57 +09:00
Hare 994e75233b feat(session): share immutable captures and bounded body transport 2026-10-22 22:25:47 +09:00
Hare 8b6e3070dd fix: align Workdir session cwd and model environment
Project alias, cwd, scope and command ceilings from actual sessions. Bind initial attachment cwd once in the shared broker/router while keeping root-relative delegation and detach generations. Preserve caller coordinates across reads, searches, checked checkout and commands; keep provider authorization and symlink boundaries.

Connect real SubWorkers, all filesystem Tools, scoped aliases, HTTP providers and limited Host grants in permanent regressions. Validated related normal suites (workdir http-client, tools, worker, worker-runtime, server-api typescript, fs-operation), workspace-server normal suite (1272 passed, 3 existing ignores), root check, product binary builds, rustdoc/doctests, generated contract checks, format and state-contract names. Test roots are checkout-local; Git parent discovery requires GIT_CEILING_DIRECTORIES at TMPDIR, confirmed against exact b860 baseline.
2026-10-22 20:46:10 +09:00
Hare b860c67ea2 fix: align Runtime restore diagnostics and socket test fixtures 2026-10-22 03:46:46 +09:00
Hare 533477c2e7 feat: unify headless execution under the Standalone Host
Expose an optional private same-uid Unix operator adapter and JSONL serve/attach CLI using the production Standalone Profile, store, restore lease and shutdown lifecycle. Remove direct Worker subprocess/discovery/readiness/controller sockets and parent socket callbacks; retain metadata-only peer registration and legacy safety readers.

Validated real Host/client input, events, provenance, disconnect, admitted shutdown and shared production restore/duplicate exclusion; normal standalone/worker/manifest/protocol/client/yoi/tui/server tests, root check, rustdoc, fmt and state-contract audit. Runtime lib: 476 passed, damaged_checkout_does_not_block_restore_but_is_rejected_when_used fails at missing_git diagnostic; unchanged restore logic, not relaxed or retried. Runtime bins/docs pass.
2026-10-22 01:29:39 +09:00
Hare 5aa2dc579a fix: integrate bounded Bash cleanup diagnostics 2026-10-22 00:46:41 +09:00
Hare 280b3d2a25 Merge branch 'develop' of /home/hare/Projects/yoi into work/T-775-cleanup-diagnostics 2026-10-22 00:24:00 +09:00
Hare ec74f4cdc4 fix: preserve Workdir failure diagnostics and audit outcomes 2026-10-22 00:16:11 +09:00
Hare 5af9dc8e4a test: focus checkout hardlink coverage on alias and symlink contracts 2026-10-21 23:57:01 +09:00
Hare ca6d4b4b72 fix: audit validated external workdir operation outcomes 2026-10-21 20:39:46 +09:00
Hare cccecbe7ca fix: integrate command output failure diagnostics 2026-10-21 06:52:23 +09:00
Hare 7a64c38493 fix: merge shared command diagnostic assertions
# Conflicts:
#	crates/workspace-server/src/server.rs
2026-10-21 06:40:26 +09:00
Hare b1361d86e9 fix: distinguish external Workdir admission dispatch and response failures
Integrate the published T773 audit contract without overwriting operation outcomes. Preserve bounded admission, send refusal, response loss/timeout and invalid result reasons independently of audit failures and request stages. Signed HTTP/SQLite/log fault tests pass (10), API logging 14 and relevant focused assertions pass. Root check, docs, generated artifacts, fmt/diff and state-contract naming checks pass. Full six-package normal suite ran with no-fail-fast but is not green: long Unix socket fixture paths and existing shared close assertion plus environment-isolation failures are recorded in target/t774 logs. Isolated focused checks resolved the latter; socket validation requires a granted shorter test root. This is a partial checkpoint, not a reviewed completion.
2026-10-21 06:39:54 +09:00
Hare 6181426abd fix: assert retained checkout outcome diagnostics 2026-10-21 06:27:54 +09:00
Hare cd331b93c6 Merge remote-tracking branch 'origin/work/T-773-workdir-audit' into work/T-774-workdir-failure-diagnostics
# Conflicts:
#	crates/workdir/src/http.rs
2026-10-21 06:13:35 +09:00
Hare 60eabea19a fix: assert retained remote session close diagnostics 2026-10-21 06:13:16 +09:00
Hare 7394ec6784 fix: retain safe Workdir open authorization and response failure causes
Keep bounded reasons and derived failure stages separate from public classification. Reject provider-authored raw error text on transport decoding. Preserve identity mismatch when cleanup fails and retain its handle for explicit close without opening again. Add signed Runtime HTTP, Registry/API logging, remote mismatch, authorization no-dispatch and deferred fault coverage. Focused tests and Workdir suite pass; full-source validation follows T773 audit contract merge.
2026-10-21 06:13:07 +09:00
Hare 28b7cc414b fix: retain external workdir results independently of audit failures 2026-10-21 06:04:39 +09:00
Hare 86d5cf9403 fix: integrate safe Drive and config failure diagnostics 2026-10-21 06:01:51 +09:00
Hare 6f0a23e5cc Merge commit '62e2b4430588bacf90a2812a0a88772fc0a00c58' into work/T-775-cleanup-diagnostics 2026-10-21 05:56:16 +09:00
Hare 2f4a46bbdb Merge commit 'a1748b552c52fd5f68de6ba852421fd778aec010' into work/T-775-cleanup-diagnostics 2026-10-21 05:55:48 +09:00
Hare 4872cf2aac fix: retain independent Bash Drop cleanup diagnostics 2026-10-21 05:55:48 +09:00
Hare 734d4b44ae Merge remote-tracking branch 'origin/work/T-772-command-output-integrity' into work/T-774-workdir-failure-diagnostics 2026-10-21 05:55:33 +09:00
Hare 6810f51f46 fix: retain safe Drive and config adapter failure diagnostics 2026-10-21 05:53:58 +09:00
Hare a1748b552c fix: validate closed-session diagnostics and fault quotas 2026-10-21 05:52:48 +09:00
Hare 62b2d27b4a fix: retain command failure stages and output collection state 2026-10-21 05:40:39 +09:00
Hare 62e2b44305 fix: retain safe Journal compaction failure diagnostics
Keep stage/reason classification in the Journal Feature without changing frozen input, metadata or fallback behavior. Exercise connection, Host read failures, size budget and secret-bearing fixtures through compaction persistence.

Validation: old freeze function with new tests fails connection and snapshot/size diagnostics (3 failures); new code passes all six selected cases and cargo test -p worker. Serialization is currently unreachable for the derived JSON-safe snapshot fields; no artificial runtime failure is claimed.
2026-10-21 05:32:07 +09:00
Hare b5c105545c fix: close unattached deferred sessions in restore rollback
Logical Workdir routing can use a Workspace session instead of the operation's raw Deferred handle, and reconstruction can fail before routing. Close the exact handles frozen in the accepted operation request after router cleanup and before releasing allocation ownership. Do not resolve aliases against a new execution or consult current Host grants for cleanup.

Reproduced on 61c20b099 with diagnostic-only test output: rollback emptied routers with provider opens 0, but raw read succeeded and opened the provider (1). Retain the SessionClosed and zero-open regression, add pre-router Metadata/Reconstruction/Binding recovery and operation-mismatch/same-alias isolation coverage. Focused Profile and deferred cases pass; full exact-source validation follows this commit before publication.
2026-10-21 04:55:38 +09:00
Hare aa0fb2f787 fix: continue workdir output reads after long-poll expiry 2026-10-21 04:48:43 +09:00
Hare 1b6d4dbfd8 fix: preserve workdir failure diagnostics across transport 2026-10-21 01:22:04 +09:00
Hare 61c20b0995 fix: adapt T-769 recovery onto current restore lane checkpoint
UNCOMPILED TRANSPORT / PRESERVATION CHECKPOINT, NOT A VALIDATED CANDIDATE. One-time recovery authorization permits publication before validation so the parent can validate this exact source in a separate Workdir. Prior compilation attempts were interrupted by Workdir-session unavailability; cause and test outcome remain unknown. No Cargo build/check/test was started in this bounded checkpoint turn.

Merge current lane 4ef7b03651 into existing source b7a22146a0. Selectively adapt typed factory failure and same-operation cleanup/replay while preserving current saved Feature restore and operation-time authorization, logical/deferred Workdirs, T765 Host/schema and T768 replay-safe Subject grant. No T770/T771 implementation.

All 19 target-relative paths and added test bodies were source-audited. Workspace fmt check, scoped rustfmt, whitespace/diff checks passed; conflict markers zero. Excluded unrelated formatter changes and untracked compiler artifacts. Local audit: target/t769-checkpoint/AUDIT.md.

Handoff to parent: compile and run focused and package/API regressions on this exact source, fix as necessary, then obtain fresh independent review. Existing MR 01a15606-a6ba-7d23-8d60-0874607399e7 only; no new review requested here. No acceptance, approval, adoption, target/develop push, integration, Ticket closure or service update is asserted.
2026-10-21 00:20:54 +09:00
Hare b7a22146a0 fix: recover confirmed worker restore failures 2026-10-20 06:00:58 +09:00
717 changed files with 191182 additions and 19400 deletions
Generated
+794 -12
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -17,6 +17,8 @@ members = [
"crates/runtime-api",
"crates/server-api",
"crates/plugin-pdk",
"crates/plugin-plan",
"crates/plugin-runtime",
"crates/yoi",
"crates/protocol",
"crates/session-metrics",
@@ -57,6 +59,8 @@ default-members = [
"crates/runtime-api",
"crates/server-api",
"crates/plugin-pdk",
"crates/plugin-plan",
"crates/plugin-runtime",
"crates/yoi",
"crates/protocol",
"crates/session-metrics",
@@ -120,6 +124,7 @@ project-record = { path = "crates/project-record" }
worker = { path = "crates/worker" }
worker-runtime = { path = "crates/worker-runtime" }
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
plugin-plan = { path = "crates/plugin-plan" }
yoi = { path = "crates/yoi" }
protocol = { path = "crates/protocol" }
session-metrics = { path = "crates/session-metrics" }
+76 -51
View File
@@ -1480,15 +1480,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
fn get_unanswered_tool_calls(&self, history: &History<A>) -> (Vec<ToolCall>, Vec<String>) {
let mut pending_calls = Vec::new();
let mut started_call_ids = Vec::new();
let mut answered_call_ids = std::collections::HashSet::new();
for item in history.items() {
if let Item::ToolResult { call_id, .. } = item {
answered_call_ids.insert(call_id.clone());
}
}
for item in history.items() {
for entry in unanswered_tool_call_entries(history) {
if let Item::ToolCall {
call_id,
name,
@@ -1496,8 +1488,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
execution_id,
status,
..
} = item
&& !answered_call_ids.contains(call_id)
} = &entry.item
{
if execution_id.is_some() || *status == Some(ItemStatus::InProgress) {
started_call_ids.push(call_id.clone());
@@ -1639,6 +1630,30 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
} = info;
tool_call = call;
context.call_id = tool_call.id.clone();
if let PreToolAction::SyntheticResult(result) = &action
&& result.tool_use_id != expected_tool_use_id
{
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
let execution_id = if matches!(action, PreToolAction::Pause) {
None
} else {
Some(context.execution_id())
};
self.append_early_tool_call(history, annotate, &tool_call, call_index, execution_id)?;
// Policy runs before this call exists in history. Only the successful
// append's annotation may bind the identity, overriding hook changes.
context.logical_call_id = history
.entries()
.last()
.and_then(|entry| self.interceptor.logical_tool_call_id(&entry.annotation));
batch.call_info_map.insert(
tool_call.id.clone(),
(tool_call.clone(), meta, tool.clone(), context.clone()),
@@ -1647,13 +1662,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
match action {
PreToolAction::Continue => {
let attempt_id = context.execution_id();
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(attempt_id.clone()),
)?;
batch
.attempt_fence
.register(tool_call.id.clone(), attempt_id.clone());
@@ -1695,34 +1703,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
batch.dispatched_any = true;
}
PreToolAction::SyntheticResult(result) => {
if result.tool_use_id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(result);
batch.dispatched_any = true;
}
PreToolAction::Skip => {
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(ToolResult::error(
&tool_call.id,
"Tool execution skipped by interceptor",
@@ -1730,13 +1714,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
batch.dispatched_any = true;
}
PreToolAction::Abort(reason) => {
self.append_early_tool_call(
history,
annotate,
&tool_call,
call_index,
Some(context.execution_id()),
)?;
batch.synthetic_results.push_back(ToolResult::error(
&tool_call.id,
format!("Tool execution aborted: {reason}"),
@@ -1745,7 +1722,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
return Err(EngineError::Aborted(reason));
}
PreToolAction::Pause => {
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
batch.dispatched_any = true;
return Ok(EarlyToolAdmission::Pause);
}
@@ -2010,10 +1986,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
let batch_id = format!("tool-batch-{}", self.tool_execution_batch_count);
self.tool_execution_batch_count += 1;
let mut logical_call_ids: HashMap<String, VecDeque<Option<String>>> = HashMap::new();
for entry in unanswered_tool_call_entries(history) {
if let Item::ToolCall { call_id, .. } = &entry.item {
logical_call_ids
.entry(call_id.clone())
.or_default()
.push_back(self.interceptor.logical_tool_call_id(&entry.annotation));
}
}
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
let mut approved_calls = Vec::new();
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
let expected_tool_use_id = tool_call.id.clone();
let logical_call_id = logical_call_ids
.get_mut(&tool_call.id)
.and_then(VecDeque::pop_front)
.flatten();
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
if !tool_call.input.is_object() {
synthetic_results.push(ToolResult::error(
@@ -2079,6 +2069,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
let tool_call = info.call;
let mut context = info.context;
context.call_id = tool_call.id.clone();
context.logical_call_id = logical_call_id;
call_info_map.insert(
tool_call.id.clone(),
(tool_call, info.meta.clone(), info.tool.clone(), context),
@@ -2098,6 +2089,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
tool_call = info.call;
let mut context = info.context;
context.call_id = tool_call.id.clone();
context.logical_call_id = logical_call_id;
call_info_map.insert(
tool_call.id.clone(),
@@ -2111,7 +2103,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
approved_calls.push((tool_call, context, Some(info.tool)));
} else {
// Unknown tools go into approved list as-is (will error at execution)
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
let mut context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
context.logical_call_id = logical_call_id;
approved_calls.push((tool_call, context, None));
}
}
@@ -4333,6 +4326,38 @@ fn item_kind(item: &Item) -> &'static str {
}
}
/// Pair each result with one preceding unanswered occurrence, never with a later
/// call that happens to reuse the provider ID. Keep the exact annotated entries
/// so dispatch and restoration resolve the same host-owned identity.
fn unanswered_tool_call_entries<A>(history: &History<A>) -> Vec<&HistoryEntry<A>> {
let mut calls = Vec::new();
let mut by_provider_id: HashMap<&str, VecDeque<usize>> = HashMap::new();
for entry in history.entries() {
match &entry.item {
Item::ToolCall { call_id, .. } => {
by_provider_id
.entry(call_id.as_str())
.or_default()
.push_back(calls.len());
calls.push(Some(entry));
}
Item::ToolResult { call_id, .. } => {
if let Some(queue) = by_provider_id.get_mut(call_id.as_str())
&& let Some(index) = queue.pop_front()
{
calls[index] = None;
}
}
_ => {}
}
}
calls.into_iter().flatten().collect()
}
#[cfg(test)]
#[path = "tests/tool_call_identity.rs"]
mod tool_call_identity_tests;
#[cfg(test)]
mod tests {
use super::*;
+10 -2
View File
@@ -309,7 +309,7 @@ pub struct ToolCallInfo<'a, A = ()> {
pub meta: ToolMeta,
/// Tool instance (for state access).
pub tool: Arc<dyn Tool>,
/// Response-local execution context for this call.
/// Execution context. The Engine binds durable identity after pre-tool policy.
pub context: ToolExecutionContext,
}
@@ -324,7 +324,7 @@ pub struct ToolResultInfo<'a, A = ()> {
pub meta: ToolMeta,
/// Tool instance (for state access).
pub tool: Arc<dyn Tool>,
/// Response-local execution context for this call.
/// Execution context. The Engine binds durable identity after pre-tool policy.
pub context: ToolExecutionContext,
}
@@ -344,6 +344,14 @@ pub struct ToolResultInfo<'a, A = ()> {
/// flows, permission checks, and other trusted host adaptation.
#[async_trait]
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
/// Pure projection of a committed ToolCall's host annotation into an opaque,
/// durable call identity. Agen neither interprets storage metadata nor derives
/// this identity from provider IDs. Early pre-tool policy runs before commit;
/// the Engine binds this value after policy, before execution.
fn logical_tool_call_id(&self, _annotation: &A) -> Option<String> {
None
}
/// Whether side effects may start before [`Self::on_assistant_turn_end`].
///
/// The conservative default preserves the historical whole-response gate.
@@ -66,11 +66,15 @@ pub(crate) struct CandidatePart {
/// Function call.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct FunctionCall {
/// Function name.
pub name: String,
/// Arguments.
pub args: Option<serde_json::Value>,
/// Partial-argument protocol is not a complete args object.
pub partial_args: Option<serde_json::Value>,
pub will_continue: Option<bool>,
}
/// Usage metadata.
@@ -110,6 +114,14 @@ pub(crate) struct SafetyRating {
// Event conversion
// ============================================================================
/// Response-local Gemini block allocation. Parts in separate SSE frames may
/// restart their array index, and function names are not call identities.
#[derive(Debug, Default)]
pub struct GeminiStreamState {
next_index: usize,
last_text_index: Option<usize>,
}
impl GeminiScheme {
/// Convert SSE data to Event.
///
@@ -120,7 +132,11 @@ impl GeminiScheme {
/// * `Ok(Some(Vec<Event>))` - Conversion succeeded.
/// * `Ok(None)` - Event ignored.
/// * `Err(ClientError)` - Parse error.
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
pub(crate) fn parse_event(
&self,
data: &str,
state: &mut GeminiStreamState,
) -> Result<Option<Vec<Event>>, ClientError> {
// Skip empty or invalid data.
if data.is_empty() || data == "[DONE]" {
return Ok(None);
@@ -144,31 +160,39 @@ impl GeminiScheme {
// Process candidates.
if let Some(candidates) = response.candidates {
for candidate in candidates {
let candidate_index = candidate.index.unwrap_or(0);
if let Some(content) = candidate.content {
if let Some(parts) = content.parts {
for (part_index, part) in parts.iter().enumerate() {
for part in &parts {
let index = state.next_index;
state.next_index += 1;
// Text delta.
if let Some(text) = &part.text {
if !text.is_empty() {
// Gemini sends no explicit BlockStart, so emit TextDelta
// directly (Timeline handles the implicit start).
events.push(Event::text_delta(part_index, text.clone()));
state.last_text_index = Some(index);
events.push(Event::text_delta(index, text.clone()));
}
}
// Function call.
if let Some(function_call) = &part.function_call {
// Function call start.
// Gemini usually sends function calls all at once, but may
// send partial calls when streaming arguments are enabled.
// Gemini has no function call IDs, so use the name as the ID.
let function_id = format!("call_{}", function_call.name);
if function_call.partial_args.is_some()
|| function_call.will_continue.is_some()
{
return Err(ClientError::Api {
status: None,
code: Some("unsupported_partial_function_call".into()),
message: "Gemini partial function arguments require an unsupported streaming protocol".into(),
retry_after: None,
});
}
// These synthetic IDs correlate calls within a response;
// durable identity still comes from Host history entries.
let function_id = format!("call_{index}");
events.push(Event::BlockStart(BlockStart {
index: candidate_index * 10 + part_index, // Composite index.
index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: function_id,
@@ -180,12 +204,13 @@ impl GeminiScheme {
if let Some(args) = &function_call.args {
let args_str = serde_json::to_string(args).unwrap_or_default();
if !args_str.is_empty() && args_str != "null" {
events.push(Event::tool_input_delta(
candidate_index * 10 + part_index,
args_str,
));
events.push(Event::tool_input_delta(index, args_str));
}
}
// Each args object is complete in this wire part. Close
// its collector before the next function call starts;
// dispatch still waits for the response-complete gate.
events.push(Event::tool_use_stop(index));
}
}
}
@@ -200,13 +225,14 @@ impl GeminiScheme {
_ => None,
};
// Text block stop.
events.push(Event::BlockStop(BlockStop {
index: candidate_index,
block_type: BlockType::Text,
stop_reason,
reasoning: None,
}));
if let Some(index) = state.last_text_index.take() {
events.push(Event::BlockStop(BlockStop {
index,
block_type: BlockType::Text,
stop_reason,
reasoning: None,
}));
}
}
}
}
@@ -240,7 +266,10 @@ mod tests {
let data =
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
let events = scheme
.parse_event(data, &mut GeminiStreamState::default())
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
@@ -260,7 +289,10 @@ mod tests {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
let events = scheme
.parse_event(data, &mut GeminiStreamState::default())
.unwrap()
.unwrap();
// Should contain a Usage event.
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
@@ -278,7 +310,10 @@ mod tests {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
let events = scheme
.parse_event(data, &mut GeminiStreamState::default())
.unwrap()
.unwrap();
// Should contain a BlockStart event.
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
@@ -309,7 +344,10 @@ mod tests {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
let events = scheme
.parse_event(data, &mut GeminiStreamState::default())
.unwrap()
.unwrap();
// Should contain a BlockStop event.
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
@@ -320,10 +358,85 @@ mod tests {
}
}
#[test]
fn same_name_calls_have_distinct_ids_and_block_indexes_across_parts_and_frames() {
use crate::llm_client::scheme::Scheme;
let scheme = GeminiScheme::new();
for frames in [
vec![
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}},{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
],
vec![
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}}]}}]}"#,
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
],
] {
let mut state = Default::default();
let mut events = Vec::new();
for frame in frames {
events.extend(scheme.parse_sse("", frame, &mut state).unwrap());
}
let calls: Vec<_> = events
.iter()
.filter_map(|event| match event {
Event::BlockStart(BlockStart {
index,
metadata: BlockMetadata::ToolUse { id, name },
..
}) => Some((*index, id.as_str(), name.as_str())),
_ => None,
})
.collect();
assert_eq!(calls, [(0, "call_0", "echo"), (1, "call_1", "echo")]);
let inputs: Vec<_> = events
.iter()
.filter_map(|event| match event {
Event::BlockDelta(delta) => match &delta.delta {
DeltaContent::InputJson(json) => Some((delta.index, json.as_str())),
_ => None,
},
_ => None,
})
.collect();
assert_eq!(
inputs,
[(0, r#"{"text":"first"}"#), (1, r#"{"text":"second"}"#)]
);
}
}
#[test]
fn partial_argument_protocol_is_not_misread_as_an_empty_complete_call() {
use crate::llm_client::scheme::Scheme;
let scheme = GeminiScheme::new();
for call in [
r#"{"name":"echo","partialArgs":[{"jsonPath":"$.text","stringValue":"part"}]}"#,
r#"{"name":"echo","willContinue":true,"args":{"text":"part"}}"#,
r#"{"name":"echo","willContinue":false}"#,
] {
let data = format!(
r#"{{"candidates":[{{"content":{{"parts":[{{"functionCall":{call}}}]}}}}]}}"#
);
assert!(
matches!(scheme.parse_sse("", &data, &mut Default::default()), Err(ClientError::Api { code: Some(code), .. }) if code == "unsupported_partial_function_call")
);
}
}
#[test]
fn test_parse_empty_data() {
let scheme = GeminiScheme::new();
assert!(scheme.parse_event("").unwrap().is_none());
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
assert!(
scheme
.parse_event("", &mut GeminiStreamState::default())
.unwrap()
.is_none()
);
assert!(
scheme
.parse_event("[DONE]", &mut GeminiStreamState::default())
.unwrap()
.is_none()
);
}
}
@@ -239,6 +239,8 @@ impl GeminiScheme {
let mut contents = Vec::new();
let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
let mut call_names: std::collections::HashMap<&str, std::collections::VecDeque<&str>> =
std::collections::HashMap::new();
for item in items {
match item {
@@ -270,8 +272,12 @@ impl GeminiScheme {
}
Item::ToolCall {
name, arguments, ..
call_id,
name,
arguments,
..
} => {
call_names.entry(call_id).or_default().push_back(name);
// Flush pending user parts first
if !pending_user_parts.is_empty() {
contents.push(GeminiContent {
@@ -309,11 +315,17 @@ impl GeminiScheme {
Some(c) => format!("{summary}\n{c}"),
None => summary.clone(),
};
// Gemini responses identify the function by its declaration
// name, never by the adapter's response-local correlation ID.
let name = call_names
.get_mut(call_id.as_str())
.and_then(std::collections::VecDeque::pop_front)
.unwrap_or(call_id.as_str());
pending_user_parts.push(GeminiPart::FunctionResponse {
function_response: GeminiFunctionResponse {
name: call_id.clone(),
name: name.to_string(),
response: GeminiFunctionResponseContent {
name: call_id.clone(),
name: name.to_string(),
content: Value::String(text),
},
},
@@ -468,6 +480,36 @@ mod tests {
assert_eq!(gemini_req.contents[0].role, "user");
assert_eq!(gemini_req.contents[1].role, "model");
assert_eq!(gemini_req.contents[2].role, "user");
let body = serde_json::to_value(gemini_req).unwrap();
assert_eq!(
body["contents"][2]["parts"][0]["functionResponse"]["name"],
"get_weather"
);
}
#[test]
fn parallel_results_resolve_function_names_not_synthetic_call_ids() {
let scheme = GeminiScheme::new();
let request = Request::new()
.item(Item::tool_call("call_0", "echo", r#"{"text":"first"}"#))
.item(Item::tool_call("call_1", "echo", r#"{"text":"second"}"#))
.item(Item::tool_result("call_1", "second"))
.item(Item::tool_result("call_0", "first"))
.item(Item::tool_call("call_0", "other", "{}"))
.item(Item::tool_result("call_0", "later"));
let body = serde_json::to_value(scheme.build_request(&request, &cap())).unwrap();
let responses: Vec<_> = body["contents"]
.as_array()
.unwrap()
.iter()
.flat_map(|content| content["parts"].as_array().unwrap())
.filter_map(|part| part.get("functionResponse"))
.collect();
assert_eq!(responses.len(), 3);
for (response, name) in responses.iter().zip(["echo", "echo", "other"]) {
assert_eq!(response["name"], name);
assert_eq!(response["response"]["name"], name);
}
}
#[test]
@@ -10,7 +10,7 @@ use crate::llm_client::{
use super::GeminiScheme;
impl Scheme for GeminiScheme {
type State = ();
type State = super::events::GeminiStreamState;
fn default_base_url(&self) -> &'static str {
"https://generativelanguage.googleapis.com"
@@ -38,9 +38,9 @@ impl Scheme for GeminiScheme {
&self,
_event_type: &str,
data: &str,
_state: &mut Self::State,
state: &mut Self::State,
) -> Result<Vec<Event>, ClientError> {
Ok(self.parse_event(data)?.unwrap_or_default())
Ok(self.parse_event(data, state)?.unwrap_or_default())
}
fn default_capability(&self) -> ModelCapability {
+250
View File
@@ -0,0 +1,250 @@
//! Engine component tests with a recording tool and scripted host annotations.
//! Persistence is represented by the append callback; Server/IPC storage is not
//! exercised here. Run with `cargo test -p agen --lib tool_call_identity_tests`.
use super::*;
use async_trait::async_trait;
use std::sync::Mutex;
#[derive(Clone)]
struct NoLlm;
#[async_trait]
impl LlmClient for NoLlm {
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
panic!("identity tests must not request a model response")
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
struct HostIdentity {
early: bool,
}
#[async_trait]
impl Interceptor<String> for HostIdentity {
fn logical_tool_call_id(&self, annotation: &String) -> Option<String> {
Some(annotation.clone())
}
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, String>,
) -> Result<PreToolAction, InterceptorError> {
if self.early {
assert!(info.history.is_empty());
}
// Pre-tool hooks may replace the context, but cannot replace persisted identity.
info.context.logical_call_id = Some("hook-forged-identity".into());
Ok(PreToolAction::Continue)
}
}
struct RecordingTool(Arc<Mutex<Vec<ToolExecutionContext>>>);
#[async_trait]
impl crate::tool::Tool for RecordingTool {
async fn execute(
&self,
_: &str,
context: ToolExecutionContext,
) -> Result<crate::tool::ToolOutput, ToolError> {
self.0.lock().unwrap().push(context);
Ok("recorded".to_string().into())
}
}
fn engine(
early: bool,
) -> (
Engine<NoLlm, Mutable, String>,
Arc<Mutex<Vec<ToolExecutionContext>>>,
) {
let mut engine = Engine::new_annotated(NoLlm);
engine.set_interceptor(HostIdentity { early });
engine.max_turns = Some(0);
engine.start_logical_run();
let observed = Arc::new(Mutex::new(Vec::new()));
let tool = Arc::new(RecordingTool(observed.clone()));
engine.register_tool(Arc::new(move || {
(crate::tool::ToolMeta::new("record"), tool.clone())
}));
engine.tool_server_handle().flush_pending();
(engine, observed)
}
fn entry(item: Item, id: &str) -> HistoryEntry<String> {
HistoryEntry::new(item, id.to_string())
}
fn call(id: &str) -> Item {
Item::tool_call_json(id, "record", json!({}))
}
#[test]
fn results_answer_only_preceding_occurrences_of_reused_provider_ids() {
let history = History::from_entries(vec![
entry(
Item::tool_result("orphan", "no preceding call"),
"orphan-result",
),
entry(call("reused"), "host-first"),
entry(call("sibling"), "host-sibling"),
entry(
Item::tool_result("sibling", "out of order"),
"sibling-result",
),
entry(Item::tool_result("reused", "answered"), "first-result"),
entry(call("reused"), "host-pending"),
entry(call("orphan"), "host-after-orphan"),
]);
let pending = unanswered_tool_call_entries(&history);
assert_eq!(
pending
.iter()
.map(|entry| entry.annotation.as_str())
.collect::<Vec<_>>(),
["host-pending", "host-after-orphan"]
);
}
#[tokio::test]
async fn early_dispatch_binds_identity_only_after_successful_append() {
for reject_append in [false, true] {
let (mut engine, observed) = engine(true);
let mut history = History::new();
let mut batch = EarlyToolExecutionBatch::new("live-batch".into(), 0);
let result = engine
.admit_early_tool_calls(
&mut history,
&mut |_| {
if reject_append {
Err("storage unavailable".into())
} else {
Ok("host-committed-entry".into())
}
},
&mut batch,
vec![crate::timeline::CollectedToolCall {
call_index: 0,
call: ToolCall {
id: "provider-call".into(),
name: "record".into(),
input: json!({}),
},
}],
)
.await;
if reject_append {
assert!(matches!(result, Err(EngineError::HistoryAppend(_))));
assert!(history.is_empty());
assert!(batch.execution_handles.is_empty());
assert!(observed.lock().unwrap().is_empty());
} else {
assert!(matches!(result, Ok(EarlyToolAdmission::Continue)));
let (_, terminal) = batch.futures.next().await.unwrap();
assert_eq!(terminal.tool_use_id, "provider-call");
let observed = observed.lock().unwrap();
assert_eq!(observed.len(), 1);
assert_eq!(
observed[0].logical_call_id.as_deref(),
Some(history.entries()[0].annotation.as_str())
);
assert_eq!(
observed[0].logical_call_id.as_deref(),
Some("host-committed-entry")
);
assert_eq!(batch.call_info_map["provider-call"].3, observed[0]);
}
}
}
#[tokio::test]
async fn normal_dispatch_and_unstarted_restore_use_the_exact_unanswered_annotation() {
for restore in [false, true] {
let (mut engine, observed) = engine(false);
let mut history = History::from_entries(vec![
entry(call("reused"), "host-answered"),
entry(
Item::tool_result("reused", "already completed"),
"answered-result",
),
]);
engine
.append_history_items(&mut history, [call("reused")], &mut |_| {
Ok("host-pending".into())
})
.unwrap();
if restore {
let persisted = serde_json::to_string(&history).unwrap();
history = serde_json::from_str(&persisted).unwrap();
}
let (pending, started) = engine.get_unanswered_tool_calls(&history);
assert_eq!(pending.len(), 1);
assert!(started.is_empty());
if restore {
engine
.run_turn_loop(&mut history, &mut |_| Ok("terminal-result".into()))
.await
.unwrap();
} else {
let result = engine
.execute_tools(&mut history, &mut |_| Ok("terminal-result".into()), pending)
.await
.unwrap();
assert!(matches!(result, ToolExecutionResult::Completed));
}
assert_eq!(observed.lock().unwrap().len(), 1);
let context = observed.lock().unwrap()[0].clone();
assert_eq!(context.call_id, "reused");
assert_eq!(context.logical_call_id.as_deref(), Some("host-pending"));
// A subsequent resume sees the terminal result, not another live attempt.
engine
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
.await
.unwrap();
assert_eq!(observed.lock().unwrap().len(), 1);
assert!(unanswered_tool_call_entries(&history).is_empty());
}
}
#[tokio::test]
async fn started_restore_with_reused_provider_id_closes_unknown_without_reexecution() {
let (mut engine, observed) = engine(false);
let mut history = History::from_entries(vec![
entry(call("reused"), "host-answered"),
entry(
Item::tool_result("reused", "already completed"),
"answered-result",
),
entry(
call("reused")
.with_tool_execution_metadata(0, Some("old-attempt".into()))
.with_status(ItemStatus::InProgress),
"host-started",
),
]);
let (pending, started) = engine.get_unanswered_tool_calls(&history);
assert!(pending.is_empty());
assert_eq!(started, ["reused"]);
engine
.run_turn_loop(&mut history, &mut |_| Ok("unknown-result".into()))
.await
.unwrap();
assert!(observed.lock().unwrap().is_empty());
assert!(matches!(
&history.entries().last().unwrap().item,
Item::ToolResult { call_id, disposition: ToolResultDisposition::OutcomeUnknown, .. }
if call_id == "reused"
));
let len = history.len();
engine
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
.await
.unwrap();
assert_eq!(history.len(), len);
assert!(observed.lock().unwrap().is_empty());
}
+14 -6
View File
@@ -336,15 +336,16 @@ pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Syn
/// Per-call context supplied by the engine when executing a tool call.
///
/// The context identifies a tool call within one assistant response's tool-call
/// batch without imposing any scheduling policy on the engine. Tool
/// implementations may use it for response-local ordering, diagnostics, or
/// correlation, but it is intentionally not a handle to engine state, history,
/// or session mutation.
/// Live batch identity and ordering are response-local. An optional opaque host
/// identity comes from the committed ToolCall annotation and survives restore.
/// Neither is a handle to engine state, history, or session mutation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolExecutionContext {
/// Provider/tool-call id for the call being executed.
pub call_id: String,
/// Opaque host identity from the committed ToolCall annotation, stable across
/// restoration. Provider call IDs and live batch IDs are not replay authority.
pub logical_call_id: Option<String>,
/// Engine-local identity shared by all tool calls from one execution batch.
pub batch_id: String,
/// Zero-based order of this call in the model-returned tool-call list.
@@ -355,11 +356,18 @@ impl ToolExecutionContext {
pub fn new(call_id: impl Into<String>, batch_id: impl Into<String>, call_index: usize) -> Self {
Self {
call_id: call_id.into(),
logical_call_id: None,
batch_id: batch_id.into(),
call_index,
}
}
/// Bind an identity supplied by the host's persisted-call annotation.
pub fn with_logical_call_id(mut self, logical_call_id: impl Into<String>) -> Self {
self.logical_call_id = Some(logical_call_id.into());
self
}
/// Identifies one live execution attempt without making the batch id a durable
/// replay or idempotency authority.
pub fn execution_id(&self) -> String {
@@ -572,7 +580,7 @@ pub trait Tool: Send + Sync {
///
/// # Arguments
/// * `input_json` - JSON-formatted arguments generated by LLM
/// * `ctx` - response-local call identity and ordering context
/// * `ctx` - per-call identity and live execution ordering context
///
/// # Returns
/// A [`ToolOutput`] with summary and optional detailed content.
+7
View File
@@ -108,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:async-chat"),
path: "profiles/async-chat.dcdl",
source: include_str!("../../../resources/profiles/async-chat.dcdl"),
description: "Explicitly connected Subject chat with bounded references and asynchronous Worker delegation.",
imports: NO_IMPORTS,
},
BuiltinProfileResource {
selector: Some("builtin:companion"),
path: "profiles/companion.dcdl",
+19
View File
@@ -39,3 +39,22 @@ fn catalog_import_metadata_has_no_cycles() {
visit(resource.path, &mut BTreeSet::new());
}
}
#[test]
fn async_chat_is_public_and_does_not_inherit_broader_profile_defaults() {
let catalog = builtin_profile_catalog_snapshot();
let path = &catalog.entrypoints["builtin:async-chat"];
let resource = BUILTIN_PROFILE_RESOURCES
.iter()
.find(|resource| resource.path == path)
.unwrap();
assert_eq!(resource.selector, Some("builtin:async-chat"));
assert!(!resource.description.is_empty());
assert!(resource.imports.is_empty());
assert!(
!catalog
.imports
.keys()
.any(|request| request.starts_with(&format!("{path}\0")))
);
}
+49
View File
@@ -4093,3 +4093,52 @@ mod tests {
assert_eq!(diagnostics[0].kind, "cycle");
}
}
#[cfg(test)]
mod plugin_preflight_tests {
use super::*;
#[test]
fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../resources/config-schema/fixtures/plugin-preflight.json"
))
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"plugin-preflight",
"plugins",
"1",
fixture["schema"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let path = VirtualPath::parse("main.dcdl").unwrap();
let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle);
for case in fixture["cases"].as_array().unwrap() {
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path.clone(),
ConfigContentType::Decodal,
case["source"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract);
match case.get("diagnostic") {
Some(kind) => {
let errors = result.unwrap_err();
assert_eq!(errors[0].kind, kind.as_str().unwrap());
assert_eq!(errors[0].path.as_str(), "main.dcdl");
}
None => {
let evaluated = result.unwrap();
assert_eq!(
evaluated.projections[0]
.data_json
.pointer("/plugins/enabled/echo/config/mode"),
Some(&case["mode"])
);
}
}
}
}
}
+3
View File
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true
publish = false
[features]
test-support = []
[dependencies]
rusqlite.workspace = true
thiserror.workspace = true
+35
View File
@@ -121,13 +121,28 @@ impl FeatureRegistration {
}
}
#[cfg(feature = "test-support")]
#[path = "open_observation.rs"]
mod open_observation;
#[cfg(feature = "test-support")]
pub use open_observation::OpenObservation;
#[derive(Clone, Debug)]
pub struct RegisteredFeature {
manager_id: Uuid,
feature_id: String,
#[cfg(feature = "test-support")]
observation: Arc<open_observation::Counters>,
}
impl RegisteredFeature {
/// Instance-local physical-operation counters for fixture assertions. The
/// observer retains no database and cannot replace or suppress durable I/O.
#[cfg(feature = "test-support")]
pub fn open_observation(&self) -> OpenObservation {
self.observation.snapshot()
}
pub fn feature_id(&self) -> &str {
&self.feature_id
}
@@ -219,6 +234,8 @@ impl FeatureStorage {
Ok(RegisteredFeature {
manager_id: self.inner.id,
feature_id,
#[cfg(feature = "test-support")]
observation: Arc::default(),
})
}
@@ -279,14 +296,32 @@ impl FeatureStorage {
}
let feature_dir = self.feature_dir(scope_id);
#[cfg(feature = "test-support")]
let mut counted_sync = |path: &Path| {
registration.observation.sync_attempt();
let result = sync(path);
if result.is_ok() {
registration.observation.sync_success();
}
result
};
#[cfg(feature = "test-support")]
durable_create_directory(&feature_dir, &mut counted_sync)?;
#[cfg(not(feature = "test-support"))]
durable_create_directory(&feature_dir, &mut sync)?;
let path = feature_dir.join(format!("{}.sqlite", registration.feature_id));
#[cfg(feature = "test-support")]
registration.observation.open_attempt();
let connection = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
)?;
#[cfg(feature = "test-support")]
registration.observation.open_success();
configure_connection(&connection)?;
migrate(&connection, &registration.feature_id, migrations)?;
#[cfg(feature = "test-support")]
registration.observation.initialized();
let connection = Arc::new(FeatureDatabaseInner {
scope_id: scope_id.to_string(),
feature_id: registration.feature_id.clone(),
@@ -0,0 +1,64 @@
use std::sync::atomic::{AtomicU64, Ordering};
/// Read-only observations for one registration (and its clones) across its scopes.
/// Counts real I/O boundaries, not API opens, and retains no physical database.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OpenObservation {
pub physical_open_attempts: u64,
pub physical_opens: u64,
pub completed_initializations: u64,
pub directory_sync_attempts: u64,
pub directory_sync_successes: u64,
}
impl OpenObservation {
pub fn since(self, before: Self) -> Self {
Self {
physical_open_attempts: self.physical_open_attempts - before.physical_open_attempts,
physical_opens: self.physical_opens - before.physical_opens,
completed_initializations: self.completed_initializations
- before.completed_initializations,
directory_sync_attempts: self.directory_sync_attempts - before.directory_sync_attempts,
directory_sync_successes: self.directory_sync_successes
- before.directory_sync_successes,
}
}
}
#[derive(Debug, Default)]
pub(super) struct Counters {
physical_open_attempts: AtomicU64,
physical_opens: AtomicU64,
completed_initializations: AtomicU64,
directory_sync_attempts: AtomicU64,
directory_sync_successes: AtomicU64,
}
impl Counters {
pub(super) fn snapshot(&self) -> OpenObservation {
OpenObservation {
physical_open_attempts: self.physical_open_attempts.load(Ordering::Relaxed),
physical_opens: self.physical_opens.load(Ordering::Relaxed),
completed_initializations: self.completed_initializations.load(Ordering::Relaxed),
directory_sync_attempts: self.directory_sync_attempts.load(Ordering::Relaxed),
directory_sync_successes: self.directory_sync_successes.load(Ordering::Relaxed),
}
}
pub(super) fn open_attempt(&self) {
self.physical_open_attempts.fetch_add(1, Ordering::Relaxed);
}
pub(super) fn open_success(&self) {
self.physical_opens.fetch_add(1, Ordering::Relaxed);
}
pub(super) fn initialized(&self) {
self.completed_initializations
.fetch_add(1, Ordering::Relaxed);
}
pub(super) fn sync_attempt(&self) {
self.directory_sync_attempts.fetch_add(1, Ordering::Relaxed);
}
pub(super) fn sync_success(&self) {
self.directory_sync_successes
.fetch_add(1, Ordering::Relaxed);
}
}
+6
View File
@@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_ignored = "0.1.14"
sha2 = "0.10"
crc32fast = "1"
wasmparser = "0.244.0"
wit-parser = "0.244.0"
wit-component = "0.244.0"
jsonschema = { version = "0.33.0", default-features = false }
secrets = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
wit-component = { version = "0.244.0", features = ["dummy-module"] }
serial_test = "3.4.0"
tempfile = { workspace = true }
+89 -5
View File
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
/// never starts a local MCP subprocess.
#[serde(default)]
pub mcp: McpConfig,
/// Omission retains the lower layer; an explicit section replaces the entire
/// selection list, including an empty list. Never unions Plugin grants.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<crate::PluginProfileConfig>,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
@@ -295,6 +299,8 @@ pub struct SubjektivFeatureConfigPartial {
#[serde(default)]
pub consolidation_tools: Option<bool>,
#[serde(default)]
pub chat: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
}
@@ -303,6 +309,7 @@ impl SubjektivFeatureConfigPartial {
Self {
enabled: other.enabled.or(self.enabled),
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
chat: merge_option(self.chat, other.chat, FeatureFlagConfigPartial::merge),
extraction: merge_option(
self.extraction,
other.extraction,
@@ -554,6 +561,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
profile: SubjektivFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(),
consolidation_tools: value.consolidation_tools.unwrap_or_default(),
chat: value.chat.map(FeatureFlagConfig::from).unwrap_or_default(),
extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true),
model: extraction.model,
@@ -574,6 +582,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
Self {
enabled: Some(value.profile.enabled),
consolidation_tools: Some(value.profile.consolidation_tools),
chat: Some(value.profile.chat.into()),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
@@ -795,11 +804,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
"unknown field in manifest: memory (removed; configure feature.memory)",
));
}
if value.get("plugins").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
));
}
if value
.get("feature")
.and_then(toml::Value::as_table)
@@ -930,6 +934,7 @@ impl WorkerManifestConfig {
),
feature: self.feature.merge(upper.feature),
mcp: merge_mcp_config(self.mcp, upper.mcp),
plugins: upper.plugins.or(self.plugins),
compaction: merge_option(
self.compaction,
upper.compaction,
@@ -1445,6 +1450,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
permissions,
feature: FeatureConfig::from(cfg.feature),
mcp: cfg.mcp,
plugins: cfg.plugins.unwrap_or_default(),
compaction,
web: cfg.web,
skills: cfg.skills,
@@ -1492,6 +1498,7 @@ mod tests {
permissions: None,
feature: FeatureConfigPartial::default(),
mcp: McpConfig::default(),
plugins: None,
session: None,
compaction: None,
web: None,
@@ -2988,6 +2995,83 @@ worker_max_turns = 7
assert_eq!(encoded["enabled"], true);
}
#[test]
fn subjektiv_chat_requires_explicit_opt_in_and_roundtrips() {
for subject in [
"",
"[feature.subjektiv]\nenabled = true\n",
"[feature.subjektiv.chat]\n",
] {
let config = WorkerManifestConfig::from_toml(subject).unwrap();
let feature = FeatureConfig::from(config.feature);
assert!(!feature.subjektiv.profile.chat.enabled);
}
assert!(
!ResolvedSubjektivFeatureConfig::default()
.profile
.chat
.enabled
);
let legacy: ResolvedSubjektivFeatureConfig = serde_json::from_value(serde_json::json!({
"profile": { "enabled": true }
}))
.unwrap();
assert!(!legacy.profile.chat.enabled);
for enabled in [true, false] {
let config = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = {enabled}\n"
)).unwrap();
let resolved = FeatureConfig::from(config.feature).subjektiv;
let json = serde_json::to_value(&resolved).unwrap();
assert_eq!(json["profile"]["chat"]["enabled"], enabled);
assert_eq!(
serde_json::from_value::<ResolvedSubjektivFeatureConfig>(json).unwrap(),
resolved
);
assert!(resolved.workspace_settings.is_none());
assert!(!resolved.execution_enabled());
let partial = SubjektivFeatureConfigPartial::from(resolved.clone());
let toml = toml::to_string(&partial).unwrap();
assert_eq!(
ResolvedSubjektivFeatureConfig::from(
toml::from_str::<SubjektivFeatureConfigPartial>(&toml).unwrap()
),
resolved
);
}
}
#[test]
fn subjektiv_chat_merge_preserves_omissions_and_honors_explicit_disable() {
let base = WorkerManifestConfig::from_toml(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = true\n[feature.subjektiv.extraction]\nthreshold = 321\n"
).unwrap();
for overlay in [
"",
"[feature.subjektiv]\nconsolidation_tools = false\n",
"[feature.subjektiv.chat]\n",
] {
let merged = base
.clone()
.merge(WorkerManifestConfig::from_toml(overlay).unwrap());
let feature = FeatureConfig::from(merged.feature);
assert!(feature.subjektiv.profile.chat.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
for enabled in [false, true] {
let overlay = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv.chat]\nenabled = {enabled}\n"
))
.unwrap();
let merged = base.clone().merge(overlay);
let feature = FeatureConfig::from(merged.feature);
assert_eq!(feature.subjektiv.profile.chat.enabled, enabled);
assert!(feature.subjektiv.profile.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
}
#[test]
fn merge_preserves_ref() {
let lower = WorkerManifestConfig {
+13 -4
View File
@@ -7,6 +7,7 @@ mod model;
pub mod model_catalog;
pub mod paths;
pub mod plugin;
mod plugin_profile;
mod profile;
mod scope;
@@ -24,6 +25,7 @@ pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
};
pub use paths::user_profiles_path;
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection, PluginTicketDataGrant};
pub use profile::{
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
/// consumer boundary.
#[serde(default)]
pub mcp: McpConfig,
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
/// their serialized fingerprint inputs.
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
pub plugins: PluginProfileConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// First-class web tools configuration. Network access remains fail-closed
@@ -412,6 +419,8 @@ pub struct SubjektivFeatureProfileConfig {
pub enabled: bool,
/// Explicit consolidation tool policy; never grants Job or subject authority.
pub consolidation_tools: bool,
/// Opt-in conversation tools; the Host must bind an explicit Subject connection.
pub chat: FeatureFlagConfig,
pub extraction: MemoryExtractionProfileConfig,
}
@@ -420,6 +429,7 @@ impl Default for SubjektivFeatureProfileConfig {
Self {
enabled: false,
consolidation_tools: false,
chat: FeatureFlagConfig::disabled(),
extraction: MemoryExtractionProfileConfig::default(),
}
}
@@ -1116,6 +1126,7 @@ impl Default for CompactionConfig {
impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some()
|| !self.plugins.is_empty()
|| self.feature.memory.workspace_settings.is_some()
|| self.feature.subjektiv.workspace_settings.is_some()
}
@@ -1595,9 +1606,7 @@ model_id = "claude-sonnet-4-20250514"
);
let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!(
error
.to_string()
.contains("dynamic Plugins are not supported"),
error.to_string().contains("unknown field `enabled`"),
"unexpected error: {error}"
);
}
@@ -1617,7 +1626,7 @@ model_id = "claude-sonnet-4-20250514"
}),
);
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
assert!(error.to_string().contains("unknown field `resolved`"));
let mut nested = base;
nested
+1 -38
View File
@@ -100,21 +100,6 @@ pub fn worker_allocation_path() -> Option<PathBuf> {
worker_allocation_path_from_runtime_dir(runtime_dir())
}
/// `<runtime_dir>/<worker_name>/` — per-Worker runtime directory.
pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name)
}
/// `<runtime_dir>/<worker_name>/sock` — Worker's Unix socket path.
///
/// The `worker` crate's `RuntimeDir::socket_path()` creates the socket inside the
/// Worker process. This function **predicts** the path for external callers that
/// know the Worker name, such as the TUI attach flow. Both should return the same
/// path.
pub fn worker_socket_path(worker_name: &str) -> Option<PathBuf> {
worker_socket_path_from_runtime_dir(runtime_dir(), worker_name)
}
// ---- internals --------------------------------------------------------------
fn resolve_config_dir_from_parts(
@@ -204,20 +189,6 @@ fn worker_allocation_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Opti
Some(runtime_dir?.join("workers.json"))
}
fn worker_runtime_dir_from_runtime_dir(
runtime_dir: Option<PathBuf>,
worker_name: &str,
) -> Option<PathBuf> {
Some(runtime_dir?.join(worker_name))
}
fn worker_socket_path_from_runtime_dir(
runtime_dir: Option<PathBuf>,
worker_name: &str,
) -> Option<PathBuf> {
Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock"))
}
/// Treats empty environment values as unset. `std::env::var` distinguishes
/// `Ok("")` from `Err(NotPresent)`, but path resolution treats both as unset
/// for intuitive behavior.
@@ -455,16 +426,8 @@ mod tests {
PathBuf::from("/sand/sessions")
);
assert_eq!(
worker_allocation_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
worker_allocation_path_from_runtime_dir(runtime_dir).unwrap(),
PathBuf::from("/sand/run/workers.json")
);
assert_eq!(
worker_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
PathBuf::from("/sand/run/foo")
);
assert_eq!(
worker_socket_path_from_runtime_dir(runtime_dir, "foo").unwrap(),
PathBuf::from("/sand/run/foo/sock")
);
}
}
File diff suppressed because it is too large Load Diff
+656
View File
@@ -0,0 +1,656 @@
//! Static validation of the exact, supported Plugin Component ABI.
//!
//! Component metadata is inferred from validated bytes, never trusted from a
//! producer's custom sections. This module does not instantiate guest code.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use wit_component::{DecodedWasm, WitPrinter};
use wit_parser::{InterfaceId, Resolve, WorldId, WorldItem, WorldKey};
const PLUGIN_WIT: &str = include_str!("../../../../resources/plugin/wit/yoi-plugin-v2.wit");
const HOST_WIT: &str =
include_str!("../../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentMetadata {
pub world: String,
pub imports: Vec<String>,
pub exports: Vec<String>,
}
/// Validates an executable Component against the embedded exact-version world.
///
/// `required_imports` is a set (duplicates are rejected); it must equal both the
/// selected world's imports and the actual Component imports. Returned names are
/// fully qualified and sorted. The world name comes from the supported contract,
/// not a producer-supplied custom section: executable Components do not encode
/// the original WIT world name.
pub fn validate_component(
bytes: &[u8],
world: &str,
required_imports: &[String],
) -> Result<ComponentMetadata, String> {
let world_name = match world {
"yoi:plugin/tool-plugin@2.0.0" => "tool-plugin",
"yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin",
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
"yoi:plugin/ticket-tool-plugin@2.1.0" => "ticket-tool-plugin",
"yoi:plugin/ticket-service-plugin@2.2.0" => "ticket-service-plugin",
_ => return Err(format!("unsupported Component world `{world}`")),
};
if !wasmparser::Parser::is_component(bytes) {
return Err("expected a WebAssembly Component, not a core module".into());
}
wasmparser::Validator::new()
.validate_all(bytes)
.map_err(|error| format!("invalid WebAssembly Component: {error}"))?;
let (actual, actual_world) = match wit_component::decode(bytes)
.map_err(|error| format!("cannot decode Component interface: {error:#}"))?
{
DecodedWasm::Component(resolve, world) => (resolve, world),
DecodedWasm::WitPackage(..) => {
return Err("expected an executable Component, not a WIT type package".into());
}
};
let (expected, expected_world) = contract(world_name)?;
let expected_imports = surface(&expected, expected_world, true)?;
let expected_exports = surface(&expected, expected_world, false)?;
let actual_imports = surface(&actual, actual_world, true)?;
let actual_exports = surface(&actual, actual_world, false)?;
let required: BTreeSet<_> = required_imports.iter().cloned().collect();
if required.len() != required_imports.len() {
return Err("duplicate required Component import in manifest".into());
}
let imports: Vec<_> = expected_imports.keys().cloned().collect();
let exports: Vec<_> = expected_exports.keys().cloned().collect();
if required != expected_imports.keys().cloned().collect() {
return Err(format!(
"manifest required_imports do not match world `{world}`: expected {imports:?}"
));
}
if actual_imports.keys().ne(expected_imports.keys()) {
return Err(format!(
"Component imports do not match world `{world}`: expected {imports:?}, found {:?}",
actual_imports.keys().collect::<Vec<_>>()
));
}
if actual_exports.keys().ne(expected_exports.keys()) {
return Err(format!(
"Component exports do not match world `{world}`: expected {exports:?}, found {:?}",
actual_exports.keys().collect::<Vec<_>>()
));
}
// Compare all decoded interfaces, including those owning referenced types,
// not only function names or the world surface. Printing preserves the
// qualified owner of type aliases and resource identity, borrow/own, field
// names/order, and nested signatures. Resolve::merge is not an ABI check.
let expected_interfaces = named_interfaces(&expected)?;
for (name, id) in named_interfaces(&actual)? {
let expected_id = expected_interfaces
.get(&name)
.ok_or_else(|| format!("unsupported Component interface `{name}`"))?;
if interface_text(&actual, id)? != interface_text(&expected, *expected_id)? {
return Err(format!("Component interface `{name}` has an incorrect ABI"));
}
}
Ok(ComponentMetadata {
world: world.to_string(),
imports,
exports,
})
}
fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
let mut resolve = Resolve::default();
resolve
.push_str("yoi-host-v2.wit", HOST_WIT)
.map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?;
let old_package = resolve
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
let package = if matches!(world_name, "ticket-tool-plugin" | "ticket-service-plugin") {
resolve
.push_str(
"yoi-host-v2.1.wit",
include_str!(
"../../../../resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit"
),
)
.map_err(|e| format!("invalid Ticket Host WIT: {e:#}"))?;
resolve
.push_str(
"yoi-plugin-v2.1.wit",
include_str!("../../../../resources/plugin/wit-ticket/yoi-plugin-v2.1.wit"),
)
.map_err(|e| format!("invalid Ticket Plugin WIT: {e:#}"))?
} else {
old_package
};
let package = if world_name == "ticket-service-plugin" {
resolve
.push_str(
"yoi-plugin-v2.2.wit",
include_str!("../../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit"),
)
.map_err(|e| format!("invalid Service WIT: {e:#}"))?
} else {
package
};
let world = resolve
.select_world(&[package], Some(world_name))
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
Ok((resolve, world))
}
fn surface(
resolve: &Resolve,
world: WorldId,
imports: bool,
) -> Result<BTreeMap<String, InterfaceId>, String> {
let items = if imports {
&resolve.worlds[world].imports
} else {
&resolve.worlds[world].exports
};
let mut names = BTreeMap::new();
for (key, item) in items {
let (WorldKey::Interface(key_id), WorldItem::Interface { id, .. }) = (key, item) else {
return Err(
"Component world contains an unsupported bare function/type or named interface"
.into(),
);
};
if key_id != id {
return Err("Component interface identity does not match its world key".into());
}
let name = resolve
.id_of(*id)
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
if names.insert(name.clone(), *id).is_some() {
return Err(format!("duplicate Component interface `{name}`"));
}
}
Ok(names)
}
fn named_interfaces(resolve: &Resolve) -> Result<BTreeMap<String, InterfaceId>, String> {
let mut names = BTreeMap::new();
for (id, _) in resolve.interfaces.iter() {
let name = resolve
.id_of(id)
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
if names.insert(name.clone(), id).is_some() {
return Err(format!("duplicate Component interface `{name}`"));
}
}
Ok(names)
}
fn interface_text(resolve: &Resolve, id: InterfaceId) -> Result<String, String> {
// Declaration order is not ABI; record fields, variant cases, parameters,
// and results remain ordered. Sorting maps also canonicalizes use aliases
// and resource methods without changing their nominal owners.
let mut resolve = resolve.clone();
resolve.interfaces[id].types.sort_keys();
resolve.interfaces[id].functions.sort_keys();
let mut printer = WitPrinter::default();
printer.emit_docs(false);
printer
.print_interface(&resolve, id)
.map_err(|error| format!("cannot inspect Component ABI: {error:#}"))?;
Ok(printer.output.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use wit_component::{ComponentEncoder, StringEncoding, dummy_module, embed_component_metadata};
use wit_parser::ManglingAndAbi;
const PURE: &str = "yoi:plugin/tool-plugin@2.0.0";
const HTTPS: &str = "yoi:plugin/https-tool-plugin@2.0.0";
const LOGGED: &str = "yoi:plugin/logged-tool-plugin@2.0.0";
const HTTPS_LOGGED: &str = "yoi:plugin/https-logged-tool-plugin@2.0.0";
fn fixture(host: &str, plugin: &str, world_name: &str) -> Vec<u8> {
let mut resolve = Resolve::default();
resolve.push_str("host.wit", host).unwrap();
let package = resolve.push_str("plugin.wit", plugin).unwrap();
let world = resolve.select_world(&[package], Some(world_name)).unwrap();
encode(&resolve, world)
}
fn encode(resolve: &Resolve, world: WorldId) -> Vec<u8> {
let mut module = dummy_module(resolve, world, ManglingAndAbi::Standard32);
embed_component_metadata(&mut module, resolve, world, StringEncoding::UTF8).unwrap();
ComponentEncoder::default()
.module(&module)
.unwrap()
.validate(true)
.encode()
.unwrap()
}
fn https_imports() -> Vec<String> {
[
"yoi:host/connections@2.0.0",
"yoi:host/errors@2.0.0",
"yoi:host/http@2.0.0",
]
.map(str::to_string)
.into()
}
fn logged_imports(https: bool) -> Vec<String> {
let mut imports = if https {
https_imports()
} else {
vec!["yoi:host/errors@2.0.0".into()]
};
imports.push("yoi:host/logging@2.0.0".into());
imports
}
#[test]
fn exact_world_components_return_sorted_metadata_without_running_guest() {
for (name, world, imports) in [
("tool-plugin", PURE, vec![]),
("https-tool-plugin", HTTPS, https_imports()),
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
assert_eq!(
validate_component(&component, world, &imports).unwrap(),
ComponentMetadata {
world: world.into(),
imports,
exports: vec!["yoi:plugin/tool@2.0.0".into()],
}
);
}
}
#[test]
fn logging_worlds_require_exact_manifest_and_actual_import_sets() {
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
let mut missing = imports.clone();
missing.retain(|name| !name.contains("/logging@"));
assert!(
validate_component(&component, world, &missing)
.unwrap_err()
.contains("manifest required_imports")
);
let plugin = PLUGIN_WIT.replace("import yoi:host/logging@2.0.0;", "");
let component = fixture(HOST_WIT, &plugin, name);
assert!(
validate_component(&component, world, &imports)
.unwrap_err()
.contains("Component imports")
);
}
}
#[test]
fn logging_world_and_interface_versions_are_exact() {
let logging = HOST_WIT
.split_once("interface logging {")
.unwrap()
.1
.replace(
"use errors.{host-error};",
"use yoi:host/errors@2.0.0.{host-error};",
);
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let supported = fixture(HOST_WIT, PLUGIN_WIT, name);
for version in ["1.0.0", "2.0.1", "9.0.0"] {
let unsupported_world = world.replace("@2.0.0", &format!("@{version}"));
assert!(
validate_component(&supported, &unsupported_world, &imports)
.unwrap_err()
.contains("unsupported Component world")
);
let host = format!(
"{HOST_WIT}\npackage yoi:host@{version} {{ interface logging {{ {logging} }}"
);
let plugin = PLUGIN_WIT.replace("logging@2.0.0", &format!("logging@{version}"));
let component = fixture(&host, &plugin, name);
assert!(
validate_component(&component, world, &imports)
.unwrap_err()
.contains("Component imports")
);
}
}
}
#[test]
fn typed_logging_nested_payload_and_signature_changes_are_rejected() {
for (from, to) in [
(
"trace, debug, info, warn, error",
"debug, trace, info, warn, error",
),
("number(f64)", "number(f32)"),
("signed(s64)", "signed(u64)"),
("value: scalar", "value: string"),
("fields: list<field>", "fields: option<field>"),
("event-code: string", "event-name: string"),
(
"message: string,\n fields:",
"message: string, extra: bool,\n fields:",
),
("log: func(input: entry)", "log: func(input: string)"),
(
"log: func(input: entry) -> result<_, host-error>",
"log: func(input: entry) -> result<string, host-error>",
),
("log: func", "extra: func(); log: func"),
] {
assert!(HOST_WIT.contains(from));
let host = HOST_WIT.replace(from, to);
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(&host, PLUGIN_WIT, name);
let error = validate_component(&component, world, &imports).unwrap_err();
assert!(
error.contains("yoi:host/logging@2.0.0") && error.contains("incorrect ABI"),
"mutation: {from} -> {to}: {error}"
);
}
}
}
#[test]
fn validated_metadata_has_serializable_public_fields() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "logged-tool-plugin");
let metadata = validate_component(&component, LOGGED, &logged_imports(false)).unwrap();
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(
json,
serde_json::json!({
"world": LOGGED,
"imports": ["yoi:host/errors@2.0.0", "yoi:host/logging@2.0.0"],
"exports": ["yoi:plugin/tool@2.0.0"],
})
);
assert_eq!(
serde_json::from_value::<ComponentMetadata>(json).unwrap(),
metadata
);
}
#[test]
fn v1_unknown_and_wrong_manifest_worlds_are_rejected() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "tool-plugin");
for world in [
"yoi:plugin/tool-plugin@1.0.0",
"yoi:plugin/tool-plugin@2.0.1",
] {
assert!(
validate_component(&component, world, &[])
.unwrap_err()
.contains("unsupported Component world")
);
}
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("Component imports")
);
for version in ["1.0.0", "9.0.0"] {
let changed = PLUGIN_WIT.replace("2.0.0", version);
let changed_host = HOST_WIT.replace("2.0.0", version);
let component = fixture(&changed_host, &changed, "tool-plugin");
assert!(
validate_component(&component, PURE, &[])
.unwrap_err()
.contains("Component exports")
);
}
}
#[test]
fn wrong_host_interface_versions_are_not_semver_compatible() {
for version in ["1.0.0", "2.0.1", "9.0.0"] {
let host = HOST_WIT.replace("@2.0.0", &format!("@{version}"));
let plugin = PLUGIN_WIT
.replace("errors@2.0.0", &format!("errors@{version}"))
.replace("connections@2.0.0", &format!("connections@{version}"))
.replace("http@2.0.0", &format!("http@{version}"))
.replace("logging@2.0.0", &format!("logging@{version}"));
let component = fixture(&host, &plugin, "https-tool-plugin");
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("Component imports")
);
}
}
#[test]
fn manifest_import_set_must_equal_actual_and_supported_world() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "https-tool-plugin");
let mut reordered = https_imports();
reordered.reverse();
assert!(validate_component(&component, HTTPS, &reordered).is_ok());
let mut duplicate = https_imports();
duplicate.push(duplicate[0].clone());
for imports in [
vec![],
vec!["wasi:filesystem/types@0.2.0".into()],
duplicate,
] {
assert!(validate_component(&component, HTTPS, &imports).is_err());
}
}
#[test]
fn unadvertised_imports_and_extra_exports_are_rejected() {
for (from, to) in [
("export tool;", ""),
(
"world tool-plugin {",
"world tool-plugin { import surprise: func();",
),
(
"world tool-plugin {",
"world tool-plugin { export surprise: func();",
),
(
"world tool-plugin {",
"world tool-plugin { import yoi:host/logging@2.0.0;",
),
] {
let plugin = PLUGIN_WIT.replace(from, to);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_err());
}
let plugin = PLUGIN_WIT.replace(
"world tool-plugin {",
"world tool-plugin { import wasi:unpublished/io@0.2.0;",
);
let host = format!(
"{HOST_WIT}\npackage wasi:unpublished@0.2.0 {{ interface io {{ read: func(); }} }}"
);
let component = fixture(&host, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_err());
}
#[test]
fn same_interface_name_with_changed_nested_signature_is_rejected() {
for (from, to) in [
("remaining-ms: u32", "remaining-ms: u64"),
("remaining-ms: u32", "time-left: u32"),
("content: option<string>", "content: list<string>"),
("summary: string", "summary: string, extra: bool"),
("input: invocation", "input: output"),
("result<output, tool-error>", "result<tool-error, output>"),
("invoke: func", "extra: func(); invoke: func"),
] {
assert!(PLUGIN_WIT.contains(from));
let plugin = PLUGIN_WIT.replace(from, to);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(
validate_component(&component, PURE, &[])
.unwrap_err()
.contains("incorrect ABI"),
"mutation: {from} -> {to}"
);
}
}
#[test]
fn host_resource_identity_ownership_and_nested_records_are_exact() {
for (from, to) in [
("borrow<connection>", "own<connection>"),
("use connections.{connection};", "resource connection;"),
("status: u16", "status: u32"),
("value: string", "value: list<u8>"),
(
"resource connection;",
"resource connection; resource other;",
),
("target: borrow<connection>", "target: borrow<other>"),
("diagnostic-id: string", "diagnostic-id: u64"),
] {
let mut host = HOST_WIT.replace(from, to);
if to == "target: borrow<other>" {
host = host.replace("interface http {", "interface http { resource other;");
}
assert_ne!(host, HOST_WIT);
let component = fixture(&host, PLUGIN_WIT, "https-tool-plugin");
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("incorrect ABI"),
"mutation: {from} -> {to}"
);
}
}
#[test]
fn interface_declaration_order_is_not_part_of_the_abi() {
let (mut resolve, world) = contract("https-tool-plugin").unwrap();
for (_, interface) in resolve.interfaces.iter_mut() {
interface.types.reverse();
interface.functions.reverse();
}
let component = encode(&resolve, world);
assert!(validate_component(&component, HTTPS, &https_imports()).is_ok());
}
#[test]
fn docs_are_not_part_of_the_abi() {
let plugin = PLUGIN_WIT.replace(
"interface tool {",
"/// Different documentation.\ninterface tool {",
);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_ok());
}
#[test]
fn core_module_wit_type_package_and_invalid_component_are_rejected() {
let (resolve, world) = contract("tool-plugin").unwrap();
let module = dummy_module(&resolve, world, ManglingAndAbi::Standard32);
assert!(
validate_component(&module, PURE, &[])
.unwrap_err()
.contains("core module")
);
let package = resolve.worlds[world].package.unwrap();
let types = wit_component::encode(&resolve, package).unwrap();
assert!(
validate_component(&types, PURE, &[])
.unwrap_err()
.contains("WIT type package")
);
let mut component = encode(&resolve, world);
component.pop();
assert!(validate_component(&component, PURE, &[]).is_err());
}
}
#[cfg(test)]
mod ticket_tests {
use super::*;
#[test]
fn additive_ticket_world_cannot_be_claimed_as_an_old_component_world() {
let (resolve, world) = contract("ticket-tool-plugin").unwrap();
let mut core =
wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
wit_component::embed_component_metadata(
&mut core,
&resolve,
world,
wit_component::StringEncoding::UTF8,
)
.unwrap();
let bytes = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
let imports: Vec<_> = surface(&resolve, world, true)
.unwrap()
.into_keys()
.collect();
let checked = validate_component(
&bytes,
crate::plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD,
&imports,
)
.unwrap();
assert!(checked.imports.contains(&"yoi:host/objects@2.1.0".into()));
assert!(
checked
.imports
.contains(&"yoi:host/ticket-read@2.1.0".into())
);
assert!(
checked
.imports
.contains(&"yoi:host/extension-data@2.1.0".into())
);
assert_eq!(checked.exports, ["yoi:plugin/tool@2.0.0"]);
assert!(
validate_component(
&bytes,
crate::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD,
&imports
)
.is_err()
);
}
}
+654
View File
@@ -0,0 +1,654 @@
//! Offline schema admission shared by package consumers. No guest, DCDL evaluator,
//! import loader, or ambient JSON Schema retriever participates in validation.
use std::collections::{BTreeMap, BTreeSet};
use decodal::{Ast, Expr, ExprId, Literal, SyntaxTokenKind as Token};
use serde_json::Value;
const MAX_SCHEMA_BYTES: usize = 64 * 1024;
const MAX_DEPTH: usize = 32;
const MAX_NODES: usize = 4096;
const TOOL_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
/// Admit a bounded, offline JSON Schema 2020-12 document (the default dialect).
///
/// Unknown keywords/dialects, resource IDs, dynamic/anchor/external references,
/// dangling references and recursive reference graphs are unsupported and rejected.
/// Local JSON Pointer references must target schema positions, not annotation data.
/// This checks the schema, not a Tool payload; callers must still validate payloads.
pub fn validate_tool_schema(schema: &Value) -> Result<(), String> {
bound_json(schema)?;
let mut graph = BTreeMap::new();
inspect_tool_schema(schema, "", &mut graph)?;
let mut complete = BTreeMap::new();
for path in graph.keys() {
check_reference_graph(path, &graph, &mut BTreeSet::new(), &mut complete, 0)?;
}
// Use the fixed built-in meta-schema, never schema-directed dialect retrieval.
jsonschema::draft202012::meta::validate(schema)
.map_err(|error| format!("invalid Tool JSON Schema: {error}"))?;
// The compiler normally skips unreferenced $defs. Compile every admitted
// schema position through one in-memory resource, so invalid regexes/formats
// cannot hide in an unused definition. These URNs are generated, not input.
const RESOURCE: &str = "urn:yoi:plugin:tool-schema";
let resource = jsonschema::Resource::from_contents(schema.clone())
.map_err(|error| format!("invalid Tool JSON Schema resource: {error}"))?;
let all_schemas = serde_json::json!({
"allOf": graph.keys().map(|path| {
serde_json::json!({"$ref": format!("{RESOURCE}#{}", uri_fragment(path))})
}).collect::<Vec<_>>()
});
jsonschema::options()
.with_draft(jsonschema::Draft::Draft202012)
.with_resource(RESOURCE, resource)
.with_retriever(NoRetrieval)
.should_validate_formats(true)
.should_ignore_unknown_formats(false)
.build(&all_schemas)
.map_err(|error| format!("invalid Tool JSON Schema: {error}"))?;
Ok(())
}
struct NoRetrieval;
impl jsonschema::Retrieve for NoRetrieval {
fn retrieve(
&self,
_uri: &jsonschema::Uri<String>,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
Err("external schema retrieval is forbidden".into())
}
}
fn bound_json(value: &Value) -> Result<(), String> {
let mut stack = vec![(value, 0)];
let mut count = 0;
while let Some((value, depth)) = stack.pop() {
count += 1;
if count > MAX_NODES || depth > MAX_DEPTH {
return Err("Tool JSON Schema exceeds static complexity limits".into());
}
match value {
Value::Array(values) => stack.extend(values.iter().map(|v| (v, depth + 1))),
Value::Object(values) => stack.extend(values.values().map(|v| (v, depth + 1))),
_ => {}
}
}
if serde_json::to_vec(value).map_err(|e| e.to_string())?.len() > MAX_SCHEMA_BYTES {
return Err("Tool JSON Schema exceeds static byte limit".into());
}
Ok(())
}
fn pointer_child(parent: &str, key: &str) -> String {
format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1"))
}
fn uri_fragment(pointer: &str) -> String {
let mut encoded = String::new();
const HEX: &[u8; 16] = b"0123456789ABCDEF";
for byte in pointer.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 15)]));
}
}
encoded
}
fn inspect_tool_schema(
schema: &Value,
path: &str,
graph: &mut BTreeMap<String, Vec<String>>,
) -> Result<(), String> {
graph.insert(path.into(), Vec::new());
if schema.is_boolean() {
return Ok(());
}
let object = schema
.as_object()
.ok_or_else(|| format!("schema at #{path} must be an object or boolean"))?;
let mut children = Vec::new();
let mut references = Vec::new();
for (key, value) in object {
let location = pointer_child(path, key);
match key.as_str() {
"$schema" => {
if !path.is_empty() || value.as_str() != Some(TOOL_DIALECT) {
return Err(format!("unsupported Tool schema dialect at #{location}"));
}
}
"$ref" => {
let reference = value
.as_str()
.ok_or_else(|| format!("$ref at #{location} must be a string"))?;
let target = reference.strip_prefix('#').ok_or_else(|| {
format!("external Tool schema reference at #{location} is forbidden")
})?;
if (!target.is_empty() && !target.starts_with('/')) || target.contains('%') {
return Err(format!(
"only local JSON Pointer $ref is supported at #{location}"
));
}
references.push(target.to_owned());
}
"$defs" | "properties" | "patternProperties" | "dependentSchemas" => {
let entries = value
.as_object()
.ok_or_else(|| format!("schema map at #{location} must be an object"))?;
for (name, child) in entries {
children.push((pointer_child(&location, name), child));
}
}
"allOf" | "anyOf" | "oneOf" | "prefixItems" => {
let entries = value
.as_array()
.ok_or_else(|| format!("schema list at #{location} must be an array"))?;
for (index, child) in entries.iter().enumerate() {
children.push((pointer_child(&location, &index.to_string()), child));
}
}
"items"
| "contains"
| "additionalProperties"
| "unevaluatedProperties"
| "unevaluatedItems"
| "propertyNames"
| "not"
| "if"
| "then"
| "else" => {
children.push((location, value));
}
// These contain data, not subschemas. A property name or literal value
// resembling a keyword must not acquire schema semantics.
"type" | "enum" | "const" | "multipleOf" | "maximum" | "exclusiveMaximum"
| "minimum" | "exclusiveMinimum" | "maxLength" | "minLength" | "pattern" | "format"
| "maxItems" | "minItems" | "uniqueItems" | "maxContains" | "minContains"
| "maxProperties" | "minProperties" | "required" | "dependentRequired" | "title"
| "description" | "default" | "examples" | "deprecated" | "readOnly" | "writeOnly"
| "$comment" => {}
_ => return Err(format!("unsupported Tool schema keyword at #{location}")),
}
}
references.extend(children.iter().map(|(path, _)| path.clone()));
graph.insert(path.into(), references);
for (path, child) in children {
inspect_tool_schema(child, &path, graph)?;
}
Ok(())
}
fn check_reference_graph(
path: &str,
graph: &BTreeMap<String, Vec<String>>,
active: &mut BTreeSet<String>,
complete: &mut BTreeMap<String, usize>,
depth: usize,
) -> Result<usize, String> {
if active.contains(path) {
return Err(format!(
"recursive Tool schema reference at #{path} is unsupported"
));
}
if depth > MAX_DEPTH {
return Err("Tool schema reference graph exceeds static depth limit".into());
}
let children = graph
.get(path)
.ok_or_else(|| format!("Tool schema reference does not target a schema: #{path}"))?;
if let Some(height) = complete.get(path) {
if depth + height > MAX_DEPTH {
return Err("Tool schema reference graph exceeds static depth limit".into());
}
return Ok(*height);
}
active.insert(path.into());
let mut height = 0;
for child in children {
height = height.max(1 + check_reference_graph(child, graph, active, complete, depth + 1)?);
}
active.remove(path);
complete.insert(path.into(), height);
Ok(height)
}
/// Admit a closed static subset of actual DCDL syntax using decodal's parser/AST.
///
/// The root must be an object. Nested closed objects, homogeneous array constraints
/// (`[...String]`), primitive types (`String`, `Bool`, `Int`, `Float`) and scalar
/// singleton literals are supported. Object fields must have unique single-part
/// paths. Imports, open objects/maps, Unknown, defaults, references, expressions,
/// calls and user functions are rejected, not evaluated. This does not validate a
/// config instance or establish that its values contain no secrets.
pub fn validate_config_schema(source: &str) -> Result<(), String> {
if source.len() > MAX_SCHEMA_BYTES {
return Err("DCDL config schema exceeds static byte limit".into());
}
let tokens = decodal::tokenize_source(source)
.map_err(|error| format!("invalid DCDL config schema: {}", error.message))?;
if tokens.len() > MAX_NODES {
return Err("DCDL config schema exceeds static token limit".into());
}
// Guard parser recursion before parsing untrusted source. Forbid operators
// outside the subset here as well, so long right-associative expressions
// cannot consume the parser stack before the AST admission check.
let mut depth = 0usize;
for (index, token) in tokens.iter().enumerate() {
match &token.kind {
Token::LBrace | Token::LBracket | Token::LParen => {
depth += 1;
if depth > MAX_DEPTH {
return Err("DCDL config schema exceeds static depth limit".into());
}
}
Token::RBrace | Token::RBracket | Token::RParen => {
depth = depth.saturating_sub(1);
}
Token::Minus
if tokens
.get(index + 1)
.is_some_and(|next| matches!(next.kind, Token::Int(_) | Token::Float(_))) => {}
Token::Ident(_)
| Token::Int(_)
| Token::Float(_)
| Token::String(_)
| Token::True
| Token::False
| Token::Semicolon
| Token::Comma
| Token::Ellipsis
| Token::Colon
| Token::Equal
| Token::Comment
| Token::Eof => {}
_ => return Err("unsupported expression in static DCDL config schema".into()),
}
}
let parsed = decodal::parse_source(source)
.map_err(|error| format!("invalid DCDL config schema: {}", error.message))?;
let root = unparenthesized(&parsed.ast, parsed.root);
if !matches!(parsed.ast.get(root).expr, Expr::Object { .. }) {
return Err("DCDL config schema root must be a closed object".into());
}
inspect_config_schema(&parsed.ast, root)
}
fn unparenthesized(ast: &Ast, mut id: ExprId) -> ExprId {
while let Expr::Parenthesized { expr } = ast.get(id).expr {
id = expr;
}
id
}
fn inspect_config_schema(ast: &Ast, id: ExprId) -> Result<(), String> {
match &ast.get(unparenthesized(ast, id)).expr {
Expr::Object { fields, rest } => {
if rest.is_some() {
return Err(
"DCDL config schema objects must be closed (no rest constraint)".into(),
);
}
let mut names = BTreeSet::new();
for field in fields {
if field.path.len() != 1 || !names.insert(&field.path[0]) {
return Err("DCDL config schema requires unique single-part field paths".into());
}
inspect_config_schema(ast, field.value)?;
}
Ok(())
}
Expr::ArrayConstraint { item } => inspect_config_schema(ast, *item),
Expr::Ident(name) if matches!(name.as_str(), "String" | "Bool" | "Int" | "Float") => Ok(()),
Expr::Literal(Literal::Float(value)) if !value.is_finite() => {
Err("DCDL config schema requires finite scalar literals".into())
}
Expr::Literal(_) => Ok(()),
Expr::Unary {
op: decodal::ast::UnaryOp::Neg,
expr,
} if matches!(ast.get(*expr).expr, Expr::Literal(Literal::Int(_)))
|| matches!(ast.get(*expr).expr, Expr::Literal(Literal::Float(v)) if v.is_finite()) =>
{
Ok(())
}
_ => Err("unsupported node in closed static DCDL config schema".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tool_schema_accepts_typed_objects_and_local_definitions() {
let schema = json!({
"$schema": TOOL_DIALECT,
"$defs": {"entry/type": {"type": "string", "minLength": 1}},
"type": "object",
"properties": {
"label": {"$ref": "#/$defs/entry~1type"},
"values": {"type": "array", "items": {"type": "integer"}}
},
"required": ["label"],
"additionalProperties": false
});
validate_tool_schema(&schema).unwrap();
for schema in [json!({}), json!(true), json!(false)] {
validate_tool_schema(&schema).unwrap();
}
}
#[test]
fn tool_schema_rejects_invalid_keyword_shapes_and_types() {
for schema in [
json!(null),
json!([]),
json!({"type": "unknown"}),
json!({"required": "label"}),
json!({"properties": []}),
json!({"properties": {"nested": {"type": 3}}}),
json!({"allOf": []}),
json!({"items": []}),
json!({"minLength": -1}),
json!({"pattern": "["}),
json!({"required": ["x", "x"]}),
json!({"format": "unknown-format"}),
] {
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
}
}
#[test]
fn tool_schema_rejects_unknown_dialects_keywords_and_nested_extensions() {
for schema in [
json!({"$schema": "https://example.invalid/schema"}),
json!({"$schema": "http://json-schema.org/draft-07/schema#"}),
json!({"typo": true}),
json!({"$id": "https://example.invalid/base"}),
json!({"$dynamicRef": "#node"}),
json!({"$anchor": "node"}),
json!({"$defs": {"unused": {"typo": true}}}),
json!({"properties": {"x": {"$schema": TOOL_DIALECT}}}),
json!({"contentSchema": {"type": "string"}}),
] {
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
}
}
#[test]
fn tool_schema_rejects_external_dangling_and_recursive_references() {
for reference in [
"https://example.invalid/schema",
"file:///etc/passwd",
"other.json",
"//example.invalid/schema",
"urn:example:schema",
"#missing",
"#/$defs/missing",
"#",
"#/%24defs/x",
] {
assert!(
validate_tool_schema(&json!({"$ref": reference})).is_err(),
"{reference}"
);
}
for schema in [
json!({"$defs": {"unused": {"$ref": "https://example.invalid/schema"}}}),
json!({"default": {"type": "string"}, "$ref": "#/default"}),
json!({"$defs": {"a": {"$ref": "#/$defs/b"}, "b": {"$ref": "#/$defs/a"}}}),
] {
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
}
}
#[test]
fn tool_schema_rejects_invalid_unreferenced_definitions() {
for schema in [
json!({"$defs": {"unused": {"pattern": "["}}}),
json!({"$defs": {"unused": {"format": "unknown-format"}}}),
json!({"$defs": {"unused": {"properties": {"x": {"pattern": "["}}}}}),
] {
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
}
}
#[test]
fn tool_schema_treats_annotations_and_property_names_as_data() {
validate_tool_schema(&json!({
"properties": {
"$ref": {"type": "string"},
"unknown": {"const": {"typo": 1}},
"space # % ~ / 日本語": {"type": "string"}
},
"default": {"$ref": "https://example.invalid/data", "typo": 3},
"examples": [{"$schema": "not a schema"}]
}))
.unwrap();
}
#[test]
fn config_schema_accepts_closed_dcdl_types_and_singletons() {
for source in [
"{}",
"label = String; enabled = Bool; retries = Int; ratio = Float",
"{ nested = { label = String }; tags = [...String]; rows = [...{ id = Int }] }",
"{ mode = \"safe\"; enabled = true; count = 3; ratio = 0.5; offset = -2; delta = -1.5 }",
"# DCDL comment\n { label = (String) }",
] {
validate_config_schema(source).unwrap_or_else(|error| panic!("{source}: {error}"));
}
}
#[test]
fn config_schema_rejects_open_untyped_and_dynamic_dcdl() {
for source in [
"Unknown",
"String",
"[...String]",
"{ x = Unknown }",
"{ ...String }",
"{ x = String; ...Unknown }",
"{ nested = { ...Int } }",
"{ x = [...Unknown] }",
"import \"remote.dcdl\"",
"{ x = import \"/etc/passwd\" }",
"let T = String in { x = T }",
"{ x = MissingType }",
"{ x = env(\"TOKEN\") }",
"{ x = String default \"hello\" }",
"{ x = (v) => v }",
"{ x = 1 + 1 }",
"{ x = [] }",
"{ x = [String] }",
"{ x = _ }",
"{ x = String; x = Int }",
"{ x.y = String }",
"{ x = String",
"{\"type\": \"object\"}",
"{ x = ---1 }",
] {
assert!(validate_config_schema(source).is_err(), "accepted {source}");
}
}
#[test]
fn schemas_reject_oversize_and_deep_inputs_without_evaluation() {
assert!(validate_config_schema(&" ".repeat(MAX_SCHEMA_BYTES + 1)).is_err());
assert!(
validate_config_schema(&format!("{}String{}", "(".repeat(1000), ")".repeat(1000)))
.is_err()
);
assert!(
validate_tool_schema(&json!({"description": "x".repeat(MAX_SCHEMA_BYTES)})).is_err()
);
let mut nested = json!({});
for _ in 0..MAX_DEPTH + 1 {
nested = json!({"not": nested});
}
assert!(validate_tool_schema(&nested).is_err());
// Each suffix is visited and cached before the next, longer chain.
let mut definitions = serde_json::Map::new();
definitions.insert("n00".into(), json!({"type": "string"}));
for index in 1..MAX_DEPTH + 2 {
definitions.insert(
format!("n{index:02}"),
json!({
"$ref": format!("#/$defs/n{:02}", index - 1)
}),
);
}
let error = validate_tool_schema(&json!({"$defs": definitions})).unwrap_err();
assert!(error.contains("depth limit"), "{error}");
}
}
/// Extension cells require a closed typed tree, not a permissive Tool schema.
/// Restricting composition/ref keywords here prevents an untyped branch or a
/// boolean schema from admitting undeclared fields. New schema shapes require
/// deliberate admission changes, never remote retrieval or guest migration.
pub fn validate_data_schema(schema: &Value) -> Result<(), String> {
validate_tool_schema(schema)?;
fn closed(node: &Value) -> Result<(), String> {
let object = node
.as_object()
.ok_or("data schema must be a typed object")?;
let kind = object
.get("type")
.and_then(Value::as_str)
.ok_or("data schema requires one explicit type")?;
let allowed: &[&str] = match kind {
"object" => &[
"type",
"description",
"properties",
"required",
"additionalProperties",
],
"array" => &[
"type",
"description",
"items",
"minItems",
"maxItems",
"uniqueItems",
],
"string" => &[
"type",
"description",
"enum",
"minLength",
"maxLength",
"pattern",
],
"number" | "integer" => &[
"type",
"description",
"enum",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
],
"boolean" | "null" => &["type", "description", "enum"],
_ => return Err("unsupported data schema type".into()),
};
if object.keys().any(|key| !allowed.contains(&key.as_str())) {
return Err("unsupported data schema keyword".into());
}
match kind {
"object" => {
if object.get("additionalProperties") != Some(&Value::Bool(false)) {
return Err("data objects must be closed".into());
}
let properties = object
.get("properties")
.and_then(Value::as_object)
.ok_or("data object requires properties")?;
for value in properties.values() {
closed(value)?;
}
if let Some(required) = object.get("required").and_then(Value::as_array) {
if required
.iter()
.any(|name| name.as_str().is_none_or(|n| !properties.contains_key(n)))
{
return Err("required data field is undeclared".into());
}
}
}
"array" => closed(
object
.get("items")
.ok_or("data arrays require typed items")?,
)?,
_ => {}
}
Ok(())
}
closed(schema)
}
/// Payload validation uses the same offline closed contract as package admission.
pub fn validate_data_value(schema: &Value, value: &Value) -> Result<(), String> {
validate_data_schema(schema)?;
if serde_json::to_vec(value)
.map_err(|_| "invalid data value")?
.len()
> 64 * 1024
{
return Err("data value exceeds 64 KiB".into());
}
let validator = jsonschema::options()
.with_draft(jsonschema::Draft::Draft202012)
.with_retriever(NoRetrieval)
.build(schema)
.map_err(|_| "invalid data schema")?;
if !validator.is_valid(value) {
return Err("data value does not match declared schema".into());
}
Ok(())
}
#[cfg(test)]
mod data_tests {
use super::*;
use serde_json::json;
#[test]
fn closed_data_schema_rejects_open_or_untyped_branches() {
for schema in [
json!(true),
json!({}),
json!({"type":"object"}),
json!({"type":"object","properties":{"nested":{}},"additionalProperties":false}),
json!({"type":"array"}),
json!({"type":"array","items":true}),
json!({"type":"object","properties":{},"additionalProperties":false,"anyOf":[{}]}),
json!({"type":"object","properties":{},"additionalProperties":false,"required":["undeclared"]}),
] {
assert!(validate_data_schema(&schema).is_err(), "{schema}");
}
}
#[test]
fn closed_data_value_enforces_nested_types_and_size() {
let schema = json!({"type":"object","additionalProperties":false,"required":["notes"],"properties":{"notes":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","maxLength":8}},"required":["text"],"additionalProperties":false}}}});
validate_data_schema(&schema).unwrap();
validate_data_value(&schema, &json!({"notes":[{"text":"ok"}]})).unwrap();
for value in [
json!({"notes":[{"text":"ok","extra":true}]}),
json!({"notes":[{"text":42}]}),
json!({"notes":[{"text":"too long text"}]}),
json!({"extra":true}),
] {
assert!(validate_data_value(&schema, &value).is_err());
}
assert!(validate_data_value(&json!({"type":"string"}), &json!("x".repeat(65536))).is_err());
}
}
+311
View File
@@ -0,0 +1,311 @@
//! Narrowing requests for Workspace-managed Plugin installations.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Profile input only, not Workspace enablement or a validated execution plan.
///
/// The Workspace owner must resolve each installation against its enabled exact
/// artifact and reject selections outside its Tool/HTTPS/logging ceiling. Neither
/// these names nor config values grant authority, select package paths, or cause
/// discovery, dynamic registration, or guest execution.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginProfileConfig {
#[serde(default)]
pub selections: Vec<PluginProfileSelection>,
}
impl PluginProfileConfig {
pub fn is_empty(&self) -> bool {
self.selections.is_empty()
}
}
/// One installation's explicit subset; omitted surfaces and capabilities are off.
///
/// `tools` and `https_slots` are package-declared names, not public Tool names,
/// URLs, host paths, or connection bindings. `config` contains non-secret override
/// candidates only: the Workspace schema validator must reject them unless the
/// package's closed typed schema permits those overrides. Deserialization alone
/// cannot certify that arbitrary package-specific values are non-secret or valid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginProfileSelection {
pub installation_id: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ticket_events: bool,
#[serde(default)]
pub logging: bool,
#[serde(default)]
pub https_slots: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ticket_read: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ticket_data_read: Vec<PluginTicketDataGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ticket_data_write: Vec<PluginTicketDataGrant>,
#[serde(default)]
pub config: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginTicketDataGrant {
pub collection: String,
pub schema_id: String,
pub schema_version: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ProfileSource, WorkerManifest, WorkerManifestConfig,
read_persisted_worker_manifest_snapshot, resolve_profile_artifact_value,
write_persisted_worker_manifest_snapshot,
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
fn partial(plugins: Value) -> WorkerManifestConfig {
serde_json::from_value(json!({
"worker": { "name": "plugin-worker" },
"plugins": plugins,
}))
.unwrap()
}
fn resolve_profile(plugins: Value) -> crate::ResolvedProfile {
let dir = tempfile::tempdir().unwrap();
resolve_profile_artifact_value(
json!({ "plugins": plugins }),
ProfileSource::Archive {
archive_id: "profile-input".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap()
}
#[test]
fn omitted_plugin_selections_and_grants_default_off() {
let omitted = WorkerManifestConfig::from_toml("[worker]\nname = 'plain'\n").unwrap();
assert!(
serde_json::to_value(&omitted)
.unwrap()
.get("plugins")
.is_none()
);
let manifest = WorkerManifest::try_from(omitted).unwrap();
assert!(manifest.plugins.selections.is_empty());
assert!(!manifest.requires_persisted_execution_snapshot());
assert!(
serde_json::to_value(manifest)
.unwrap()
.get("plugins")
.is_none()
);
let manifest = WorkerManifest::try_from(partial(json!({
"selections": [{ "installation_id": "installation-a" }]
})))
.unwrap();
let selection = &manifest.plugins.selections[0];
assert!(selection.tools.is_empty());
assert!(!selection.logging);
assert!(selection.https_slots.is_empty());
assert!(selection.config.is_empty());
assert!(manifest.requires_persisted_execution_snapshot());
}
#[test]
fn plugin_partial_merge_replaces_instead_of_unioning_and_can_clear() {
let lower = partial(json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read", "write"],
"logging": true, "https_slots": ["remote"], "config": {"limit": 10}
}] }));
let omitted = lower.clone().merge(WorkerManifestConfig::default());
assert_eq!(omitted.plugins, lower.plugins);
let upper = partial(json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read"]
}] }));
let merged = lower.clone().merge(upper.clone());
assert_eq!(merged.plugins, upper.plugins);
let round_trip: WorkerManifestConfig =
serde_json::from_value(serde_json::to_value(&merged).unwrap()).unwrap();
assert_eq!(round_trip.plugins, merged.plugins);
for empty in [json!({}), json!({ "selections": [] })] {
let cleared = lower.clone().merge(partial(empty));
assert_eq!(cleared.plugins, Some(PluginProfileConfig::default()));
let manifest = WorkerManifest::try_from(cleared).unwrap();
assert!(manifest.plugins.selections.is_empty());
}
}
#[test]
fn plugin_profile_toml_and_resolved_snapshots_preserve_typed_overrides() {
let dir = tempfile::tempdir().unwrap();
let source = r#"
[[plugins.selections]]
installation_id = "installation-a"
tools = ["lookup"]
logging = true
https_slots = ["api"]
[plugins.selections.config]
limit = 5
labels = ["one", "two"]
[plugins.selections.config.nested]
strict = true
"#;
let parsed = WorkerManifestConfig::from_toml(source).unwrap();
let authored: toml::Value = toml::from_str(source).unwrap();
let resolved = resolve_profile_artifact_value(
serde_json::to_value(authored).unwrap(),
ProfileSource::Archive {
archive_id: "typed-profile".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap();
assert_eq!(Some(&resolved.manifest.plugins), parsed.plugins.as_ref());
assert_eq!(
resolved.manifest.plugins.selections[0].config["nested"],
json!({"strict": true})
);
let snapshot = write_persisted_worker_manifest_snapshot(&resolved.manifest).unwrap();
let restored = read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
assert_eq!(restored.plugins, resolved.manifest.plugins);
assert_eq!(
write_persisted_worker_manifest_snapshot(&restored).unwrap(),
snapshot
);
assert_eq!(
serde_json::to_value(restored).unwrap(),
resolved.manifest_snapshot
);
let toml_round_trip = toml::to_string(&resolved.manifest).unwrap();
assert_eq!(
WorkerManifest::from_toml(&toml_round_trip).unwrap().plugins,
resolved.manifest.plugins
);
}
#[test]
fn plugin_fields_change_serialized_profile_fingerprint_input() {
let baseline = json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read"],
"logging": false, "https_slots": [], "config": {"limit": 5}
}] });
let fingerprint = |plugins| {
let resolved = resolve_profile(plugins);
Sha256::digest(serde_json::to_vec(&resolved.manifest_snapshot).unwrap())
};
let original = fingerprint(baseline.clone());
assert_eq!(fingerprint(baseline.clone()), original);
for (field, value) in [
("installation_id", json!("installation-b")),
("tools", json!(["write"])),
("logging", json!(true)),
("https_slots", json!(["api"])),
("config", json!({"limit": 6})),
] {
let mut changed = baseline.clone();
changed["selections"][0][field] = value;
assert_ne!(fingerprint(changed), original, "{field} was lost");
}
assert_eq!(
fingerprint(json!({})),
fingerprint(json!({"selections": []}))
);
}
#[test]
fn plugin_config_rejects_legacy_package_and_unknown_authority_fields() {
let dir = tempfile::tempdir().unwrap();
let mut invalid = vec![
json!({ "enabled": [{ "id": "project:legacy" }] }),
json!({ "packages": ["/tmp/legacy.yoi-plugin"] }),
json!({ "resolved": [{ "package_path": "/tmp/legacy.yoi-plugin" }] }),
json!({ "enable": true }),
];
for (field, value) in [
("package_path", json!("/tmp/legacy.yoi-plugin")),
("path", json!("./plugin")),
("url", json!("https://example.invalid/plugin")),
("artifact_digest", json!("sha256:uninstalled")),
("secret", json!("not-a-secret-reference")),
("data", json!(true)),
("events", json!(["changed"])),
("websocket", json!(true)),
("workdir", json!(true)),
("unknown", json!(true)),
] {
let mut selection = json!({ "installation_id": "installation-a" });
selection[field] = value;
invalid.push(json!({ "selections": [selection] }));
}
invalid.extend([
json!({ "selections": [{ "tools": ["read"] }] }),
json!({ "selections": [{ "installation_id": "a", "config": "raw" }] }),
]);
for plugins in invalid {
let partial_input = json!({ "plugins": plugins.clone() });
assert!(
serde_json::from_value::<WorkerManifestConfig>(partial_input.clone()).is_err(),
"{plugins}"
);
let toml = toml::to_string(&partial_input).unwrap();
assert!(WorkerManifestConfig::from_toml(&toml).is_err(), "{toml}");
let error = resolve_profile_artifact_value(
partial_input,
ProfileSource::Archive {
archive_id: "invalid".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap_err();
assert!(
error.to_string().contains("failed to decode Profile"),
"{error}"
);
}
}
}
#[cfg(test)]
mod service_tests {
use super::*;
use serde_json::json;
#[test]
fn service_selection_is_explicit_and_old_profiles_remain_off() {
let old: PluginProfileSelection =
serde_json::from_value(json!({"installation_id":"i", "tools":["read"]})).unwrap();
assert!(old.services.is_empty());
assert!(!old.ticket_events);
let retained = serde_json::to_value(&old).unwrap();
assert!(retained.get("services").is_none());
assert!(retained.get("ticket_events").is_none());
let selected: PluginProfileSelection = serde_json::from_value(
json!({"installation_id":"i", "services":["watch"], "ticket_events":true}),
)
.unwrap();
assert_eq!(selected.services, ["watch"]);
assert!(selected.ticket_events);
assert_ne!(serde_json::to_value(selected).unwrap(), retained);
}
}
+134
View File
@@ -652,6 +652,7 @@ fn resolve_profile_value(
permissions: profile.permissions,
feature: profile.feature,
mcp: profile.mcp,
plugins: profile.plugins,
compaction,
web: profile.web,
skills: profile.skills,
@@ -700,6 +701,8 @@ struct ProfileConfig {
#[serde(default)]
mcp: McpConfig,
#[serde(default)]
plugins: Option<crate::PluginProfileConfig>,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,
@@ -1727,6 +1730,137 @@ mod tests {
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
fn builtin_async_chat_exposes_only_opted_in_chat_and_bounded_features() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "async-chat"),
ProfileResolveOptions::with_worker_name("chat-worker"),
)
.unwrap();
let manifest = resolved.manifest;
let feature = &manifest.feature;
assert_eq!(manifest.engine.instruction, "role.async_chat");
assert!(resolved.raw_artifact["scope"].is_null());
assert!(resolved.raw_artifact["delegation_scope"].is_null());
assert!(manifest.scope.allow.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert!(manifest.mcp.stdio_servers.is_empty());
assert!(feature.subjektiv.profile.enabled);
assert!(feature.subjektiv.profile.chat.enabled);
assert!(!feature.subjektiv.profile.consolidation_tools);
assert!(!feature.subjektiv.profile.extraction.enabled);
assert!(feature.subjektiv.workspace_settings.is_none());
assert!(!feature.subjektiv.execution_enabled());
assert!(!feature.memory.profile.enabled);
assert!(!feature.memory.profile.staging_tools);
assert!(!feature.task.enabled);
assert!(!feature.sub_worker.enabled);
assert!(!feature.flow.enabled);
assert!(feature.worker.enabled);
assert!(feature.worker.direct_spawn);
assert!(!feature.workspace_worker_discovery.enabled);
assert!(feature.objective.enabled);
assert!(feature.ticket.enabled);
assert!(!feature.ticket.authoring);
assert!(!feature.ticket.thread);
assert!(!feature.ticket.intake);
assert!(!feature.ticket.workflow);
assert!(!feature.merge_request.show);
assert!(!feature.merge_request.open);
assert!(!feature.merge_request.review);
assert!(!feature.merge_request.readiness_check);
assert!(!feature.merge_request.complete);
for flag in [
feature.web,
feature.image,
feature.drive,
feature.manage_workdir,
feature.workdir_catalog,
feature.workspace_config,
feature.orchestration,
] {
assert!(!flag.enabled);
}
let permissions = manifest.permissions.unwrap();
assert_eq!(
permissions.default_action,
crate::ToolPermissionAction::Deny
);
// Exact tool names and all-argument rules let the Worker statically hide
// denied filesystem tools while its permission hook enforces each call.
let allowed = permissions
.rules
.iter()
.map(|rule| {
assert_eq!(rule.action, crate::ToolPermissionAction::Allow);
assert_eq!(rule.pattern, "*");
rule.tool.as_str()
})
.collect::<BTreeSet<_>>();
assert_eq!(allowed.len(), permissions.rules.len());
assert_eq!(
allowed,
BTreeSet::from([
"ConversationRead",
"ConversationSend",
"WorkerList",
"WorkerSpawn",
"WorkerSendInput",
"WorkerNotify",
"WorkerCancel",
"WorkerStop",
"WorkerRestore",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
"SubjektivSessionList",
"SubjektivSessionSearch",
"SubjektivSessionRead",
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
"SubjektivMemoryListChanges",
"QueryObjective",
"ShowObjective",
"QueryTicket",
"ShowTicket",
"WorkspaceTagList",
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketImplementationResultQuery",
"NotificationDiagnostics",
])
);
}
#[test]
fn builtin_profiles_do_not_implicitly_enable_subject_chat() {
for entry in builtin_profile_entrypoints() {
if entry.selector == Some("builtin:async-chat") {
continue;
}
let artifact = resolve_builtin_profile_artifact(entry.selector.unwrap())
.unwrap()
.unwrap();
let feature: FeatureConfigPartial =
serde_json::from_value(artifact["feature"].clone()).unwrap();
assert!(
!crate::FeatureConfig::from(feature)
.subjektiv
.profile
.chat
.enabled,
"{:?} must remain opted out",
entry.selector
);
}
}
#[test]
fn builtin_companion_combines_runtime_and_sub_worker_control_with_discovery() {
let tmp = TempDir::new().unwrap();
+184 -83
View File
@@ -617,7 +617,8 @@ impl fmt::Display for McpRequestStage {
}
}
/// A running initialized stdio MCP client.
/// An owned stdio MCP process, initialized by `connect` or explicitly after
/// `spawn_uninitialized` when startup cleanup must retain it before any await.
pub struct McpStdioClient {
server_name: String,
limits: McpStdioLimits,
@@ -636,6 +637,9 @@ pub struct McpStdioClient {
impl McpStdioClient {
/// Spawn, initialize, negotiate capabilities, and send notifications/initialized.
/// Initialization errors attempt shutdown, but this convenience API cannot
/// return an unreaped client on failure/cancellation. Its error is not proof
/// of cleanup; use `spawn_uninitialized` with retained ownership when needed.
pub async fn connect(
spec: McpStdioServerSpec,
limits: McpStdioLimits,
@@ -667,6 +671,21 @@ impl McpStdioClient {
async fn spawn(
spec: McpStdioServerSpec,
limits: McpStdioLimits,
) -> Result<Self, McpClientError> {
Self::spawn_uninitialized(spec, limits)
}
/// Spawn without awaiting the handshake, so a cleanup owner can retain the
/// client before initialization can fail or be cancelled. Call
/// `initialize_with_timeout` before discovery; on error/cancellation, shut
/// down this same client rather than spawning a replacement.
///
/// Once the process spawns, this returns its owner even if an expected pipe
/// is missing. Initialization then fails, but cleanup can still reap the
/// actual child rather than relying on kill-on-drop for a post-spawn error.
pub fn spawn_uninitialized(
spec: McpStdioServerSpec,
limits: McpStdioLimits,
) -> Result<Self, McpClientError> {
let redactor = spec.redactor();
let mut command = Command::new(&spec.command);
@@ -688,29 +707,23 @@ impl McpStdioClient {
McpErrorKind::Io(redactor.redact(&err.to_string())),
)
})?;
let stdin = child.stdin.take().ok_or_else(|| {
McpClientError::new(
&spec.name,
McpPhase::Spawn,
McpErrorKind::Protocol("child stdin was not piped".into()),
)
})?;
let stdout = child.stdout.take().ok_or_else(|| {
McpClientError::new(
&spec.name,
McpPhase::Spawn,
McpErrorKind::Protocol("child stdout was not piped".into()),
)
})?;
let stderr = child.stderr.take().ok_or_else(|| {
McpClientError::new(
&spec.name,
McpPhase::Spawn,
McpErrorKind::Protocol("child stderr was not piped".into()),
)
})?;
let stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let missing_pipe = if stdin.is_none() {
Some("stdin")
} else if stdout.is_none() {
Some("stdout")
} else if stderr.is_none() {
Some("stderr")
} else {
None
};
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(stdin))));
let stdin: SharedStdin =
Arc::new(Mutex::new(stdin.map(|stdin| {
Box::new(stdin) as Box<dyn AsyncWrite + Send + Unpin>
})));
let diagnostics = Arc::new(Mutex::new(BoundedDiagnostics::new(
spec.name.clone(),
limits.max_diagnostic_lines,
@@ -718,16 +731,32 @@ impl McpStdioClient {
)));
let list_changes = Arc::new(Mutex::new(BoundedListChanged::new(spec.name.clone())));
let (tx, rx) = mpsc::channel(16);
let reader_task = spawn_stdout_reader(
spec.name.clone(),
stdout,
stdin.clone(),
tx,
limits.clone(),
redactor.clone(),
list_changes.clone(),
);
let stderr_task = spawn_stderr_reader(stderr, diagnostics.clone(), limits.clone());
// Do not return an error that discards an already spawned child. Queue
// setup failure before reader events so initialization cannot succeed.
if let Some(pipe) = missing_pipe {
tx.try_send(ReaderEvent::Error(McpClientError::new(
&spec.name,
McpPhase::Spawn,
McpErrorKind::Protocol(format!("child {pipe} was not piped")),
)))
.expect("new response channel has capacity");
}
let reader_task = match stdout {
Some(stdout) => spawn_stdout_reader(
spec.name.clone(),
stdout,
stdin.clone(),
tx,
limits.clone(),
redactor.clone(),
list_changes.clone(),
),
None => tokio::spawn(async {}),
};
let stderr_task = match stderr {
Some(stderr) => spawn_stderr_reader(stderr, diagnostics.clone(), limits.clone()),
None => tokio::spawn(async {}),
};
Ok(Self {
server_name: spec.name,
@@ -746,6 +775,25 @@ impl McpStdioClient {
})
}
/// Perform the bounded handshake on an already retained client. Failure or
/// cancellation does not drop or shut down the client: its owner must do so.
/// Do not retry initialization on a partially initialized connection.
pub async fn initialize_with_timeout(&mut self) -> Result<(), McpClientError> {
let started = Instant::now();
match timeout(self.limits.startup_timeout, self.initialize()).await {
Ok(result) => result,
Err(_) => Err(McpClientError::new(
&self.server_name,
McpPhase::Initialize,
McpErrorKind::Timeout {
operation: "startup".to_string(),
elapsed: started.elapsed(),
},
)
.with_diagnostics(self.snapshot_diagnostics().await)),
}
}
async fn initialize(&mut self) -> Result<(), McpClientError> {
let result: InitializeResult = self
.request(
@@ -1080,6 +1128,8 @@ impl McpStdioClient {
}
/// Close stdin and wait for process exit, falling back to terminate and kill.
/// Errors and cancellation retain the child for another attempt on this
/// client. Only confirmed reaping releases the process handle.
pub async fn shutdown(&mut self) -> Result<ShutdownReport, McpClientError> {
self.shutdown_started = true;
let deadline = Instant::now() + self.limits.shutdown_timeout;
@@ -1091,64 +1141,55 @@ impl McpStdioClient {
.await
.is_ok();
let mut child = match self.child.take() {
Some(child) => child,
None => return Ok(ShutdownReport::already_finished()),
};
// Keep the actual process handle in the client across every await and
// error. Taking it before wait loses retry/reaping authority on cancel.
if self.child.is_none() {
return Ok(ShutdownReport::already_finished());
}
if !closed {
self.reader_task.abort();
return self.terminate_then_kill(child).await;
return self.terminate_then_kill().await;
}
let child = self.child.as_mut().expect("checked child");
if let Ok(Some(status)) = child.try_wait() {
self.reader_task.abort();
self.stderr_task.abort();
return Ok(ShutdownReport {
exit_status: Some(status),
terminated: false,
killed: false,
});
return Ok(self.finish_shutdown(status, false, false));
}
match timeout_at(deadline, child.wait()).await {
Ok(Ok(status)) => {
self.reader_task.abort();
self.stderr_task.abort();
Ok(ShutdownReport {
exit_status: Some(status),
terminated: false,
killed: false,
})
}
Ok(Ok(status)) => Ok(self.finish_shutdown(status, false, false)),
Ok(Err(err)) => Err(McpClientError::new(
&self.server_name,
McpPhase::Shutdown,
McpErrorKind::Io(self.redactor.redact(&err.to_string())),
)
.with_diagnostics(self.snapshot_diagnostics().await)),
Err(_) => self.terminate_then_kill(child).await,
Err(_) => self.terminate_then_kill().await,
}
}
async fn terminate_then_kill(
fn finish_shutdown(
&mut self,
mut child: Child,
) -> Result<ShutdownReport, McpClientError> {
let mut terminated = false;
let mut killed = false;
if send_terminate(&mut child).is_ok() {
terminated = true;
status: ExitStatus,
terminated: bool,
killed: bool,
) -> ShutdownReport {
// No await separates confirmed reaping from releasing the handle.
drop(self.child.take());
self.reader_task.abort();
self.stderr_task.abort();
ShutdownReport {
exit_status: Some(status),
terminated,
killed,
}
}
async fn terminate_then_kill(&mut self) -> Result<ShutdownReport, McpClientError> {
let child = self.child.as_mut().expect("shutdown retains child");
let terminated = send_terminate(child).is_ok();
match timeout(self.limits.kill_timeout, child.wait()).await {
Ok(Ok(status)) => {
self.reader_task.abort();
self.stderr_task.abort();
Ok(ShutdownReport {
exit_status: Some(status),
terminated,
killed,
})
}
Ok(Ok(status)) => Ok(self.finish_shutdown(status, terminated, false)),
Ok(Err(err)) => Err(McpClientError::new(
&self.server_name,
McpPhase::Shutdown,
@@ -1163,7 +1204,6 @@ impl McpStdioClient {
McpErrorKind::Io(self.redactor.redact(&err.to_string())),
)
})?;
killed = true;
let status = timeout(self.limits.kill_timeout, child.wait())
.await
.map_err(|_| {
@@ -1183,13 +1223,7 @@ impl McpStdioClient {
McpErrorKind::Io(self.redactor.redact(&err.to_string())),
)
})?;
self.reader_task.abort();
self.stderr_task.abort();
Ok(ShutdownReport {
exit_status: Some(status),
terminated,
killed,
})
Ok(self.finish_shutdown(status, terminated, true))
}
}
}
@@ -1197,10 +1231,10 @@ impl McpStdioClient {
impl Drop for McpStdioClient {
fn drop(&mut self) {
if !self.shutdown_started {
if let Some(child) = &mut self.child {
let _ = child.start_kill();
}
// An attempted shutdown is not proof of exit. Errors and cancellation
// leave the child here so Drop must still make a best-effort kill.
if let Some(child) = &mut self.child {
let _ = child.start_kill();
}
self.reader_task.abort();
self.stderr_task.abort();
@@ -1784,3 +1818,70 @@ fn send_terminate(child: &mut Child) -> Result<(), ()> {
#[cfg(test)]
mod deadline_tests;
#[cfg(all(test, unix))]
mod shutdown_retention_tests {
//! The real client and OS process are used; only the external server is a
//! local shell that acknowledges initialization and ignores TERM/EOF.
use super::*;
use std::future::{Future, poll_fn};
use std::task::Poll;
#[tokio::test]
async fn cancelled_graceful_or_terminate_wait_retries_reaping_the_same_child() {
for cancel_in_fallback in [false, true] {
let script = r#"trap '' TERM
read init || exit 1
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"retained","version":"1"}}}'
while :; do :; done
"#;
let mut client = McpStdioClient::connect(
McpStdioServerSpec::new("retained", "/bin/sh").args(["-c", script]),
McpStdioLimits {
shutdown_timeout: if cancel_in_fallback {
Duration::ZERO
} else {
Duration::from_secs(30)
},
kill_timeout: Duration::from_secs(30),
..Default::default()
},
)
.await
.expect("initialize after installing TERM handler");
let pid = client.child.as_ref().unwrap().id();
let mut shutdown = Box::pin(client.shutdown());
poll_fn(|cx| {
assert!(shutdown.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
drop(shutdown);
assert_eq!(client.child.as_ref().and_then(Child::id), pid);
assert!(client.shutdown_started);
client.limits.shutdown_timeout = Duration::from_millis(20);
client.limits.kill_timeout = Duration::from_millis(50);
let report = timeout(Duration::from_secs(2), client.shutdown())
.await
.expect("retry bounded")
.expect("retry reaps original child");
assert!(
report.exit_status.is_some(),
"not an already-finished receipt"
);
assert!(report.killed, "TERM was ignored");
assert!(client.child.is_none());
let mut status = 0;
assert_eq!(
unsafe { libc::waitpid(pid.unwrap() as libc::pid_t, &mut status, libc::WNOHANG) },
-1
);
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::ECHILD),
"shutdown, not a later observer, reaped the original process"
);
}
}
}
+17 -1
View File
@@ -1747,7 +1747,23 @@ fn ticket_event(
) -> Result<(), MergeRequestError> {
let ticket = &mr.ticket_ids[0];
let n:i64=t.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,ticket],|r|r.get(0))?;
t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",mr.merge_request_id,e.approved_source_ref)])?;
t.execute(
"INSERT INTO typed_ticket_events(
workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body,event_ref
) VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6,?7)",
params![
mr.workspace_id,
ticket,
n,
format!("worker:{}:{}", e.merged_by.runtime_id, e.merged_by.worker_id),
e.created_at.to_rfc3339(),
format!(
"Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",
mr.merge_request_id, e.approved_source_ref
),
format!("{ticket}:{n}"),
],
)?;
for (k, v) in [
("implementation_assignment_id", a),
("merge_request_id", &mr.merge_request_id),
+116 -5
View File
@@ -53,8 +53,8 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("db");
let c = Connection::open(&p).unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('W','T',-1,'comment','Requirements')",[]).unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,event_ref TEXT NOT NULL DEFAULT '',PRIMARY KEY(workspace_id,ticket_id,event_index),UNIQUE(workspace_id,ticket_id,event_ref));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref) VALUES('W','T',-1,'comment','Requirements','T:-1')",[]).unwrap();
drop(c);
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
assignment_id: "A".into(),
@@ -457,6 +457,18 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
.unwrap();
assert_eq!(replayed, merged);
let connection = Connection::open(d.path().join("db")).unwrap();
let ticket_refs = connection
.prepare("SELECT event_ref FROM typed_ticket_events WHERE event_index >= 0")
.unwrap()
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
ticket_refs,
vec!["T:0"],
"replay must not append another post"
);
let (state, active): (String, bool) = connection.query_row(
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
[],
@@ -477,6 +489,87 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
assert!(!json.contains(banned), "{banned} in {json}")
}
}
#[test]
fn integration_post_failure_rolls_back_and_same_operation_recovers_with_legacy_empty_ref() {
let (dir, store) = fixture();
open(&store);
let review = approve(&store, "source", "approved");
request(&store, "source", "pending");
let completion = CompleteMergeRequest {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
operation_id: "recover-integration".into(),
approval_event_id: review.event_id,
current_subject_ref: "source".into(),
target_ref_before: "before".into(),
target_ref_after: "after".into(),
strategy: MergeStrategy::FastForward,
resolution: ConflictResolution::None,
auth: auth(),
now: at(5),
};
let connection = Connection::open(dir.path().join("db")).unwrap();
// Retain a comment written before integration populated event_ref.
connection.execute_batch(
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref)
VALUES('W','T',0,'comment','Legacy integration','');
CREATE TRIGGER reject_integration_post BEFORE INSERT ON typed_ticket_events
WHEN NEW.heading='Merge Request integrated'
BEGIN SELECT RAISE(ABORT,'injected integration post failure'); END;",
).unwrap();
let before = store.get_by_id("W", "MR").unwrap();
let error = store.complete(completion.clone()).unwrap_err();
assert!(
error
.to_string()
.contains("injected integration post failure")
);
let failed = store.get_by_id("W", "MR").unwrap();
assert_eq!(failed.state, MergeRequestState::Open);
assert_eq!(failed.thread, before.thread);
assert!(store.authorize_review_submission("MR", "pending").is_ok());
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM typed_ticket_event_attributes",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
connection
.execute_batch("DROP TRIGGER reject_integration_post")
.unwrap();
let merged = store.complete(completion.clone()).unwrap();
assert_eq!(store.complete(completion).unwrap(), merged);
assert_eq!(
store.get_by_id("W", "MR").unwrap().state,
MergeRequestState::Merged
);
assert!(store.authorize_review_submission("MR", "pending").is_err());
let posts = connection
.prepare("SELECT event_ref,body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY event_index")
.unwrap()
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
posts.len(),
2,
"retry and replay must append exactly one post"
);
assert_eq!(posts[0], (String::new(), "Legacy integration".into()));
assert_eq!(posts[1].0, "T:1");
assert!(posts[1].1.contains("`MR`"));
assert_eq!(connection.query_row(
"SELECT value FROM typed_ticket_event_attributes WHERE event_index=1 AND key='operation_id'",
[], |row| row.get::<_, String>(0),
).unwrap(), "recover-integration");
}
#[test]
fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_returns() {
let (_d, s) = fixture();
@@ -955,7 +1048,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
let connection = Connection::open(dir.path().join("db")).unwrap();
connection
.execute_batch(
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL,'T:1')",
)
.unwrap();
connection
@@ -1082,7 +1175,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
let connection = Connection::open(dir.path().join("db")).unwrap();
connection
.execute_batch(
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL,'T:1')",
)
.unwrap();
connection
@@ -1307,6 +1400,24 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
assert_eq!(second_request.merged_result().unwrap(), &second_merge);
assert_eq!(first_request.integration_approval().unwrap(), &first);
assert_eq!(second_request.integration_approval().unwrap(), &second);
let posts = connection
.prepare(
"SELECT event.event_ref, attribute.value
FROM typed_ticket_events event
JOIN typed_ticket_event_attributes attribute USING(workspace_id,ticket_id,event_index)
WHERE attribute.key='merge_request_id' ORDER BY event.event_index",
)
.unwrap()
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
posts,
vec![("T:0".into(), "MR".into()), ("T:1".into(), "MR-2".into())]
);
}
#[test]
@@ -1324,7 +1435,7 @@ fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migrat
}"#;
connection.execute_batch(
"UPDATE typed_tickets SET workflow_state='done' WHERE workspace_id='W' AND ticket_id='T';
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body');"
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body','T:0');"
).unwrap();
connection
.execute(
+3
View File
@@ -15,3 +15,6 @@ wit-bindgen = "0.51.0"
[dev-dependencies]
tempfile.workspace = true
toml.workspace = true
wasmparser = "0.244.0"
wit-component = "0.244.0"
wit-parser = "0.244.0"
+2 -6
View File
@@ -1,9 +1,5 @@
# plugin-pdk
Guest-side JSON/WIT helpers for offline Component Model packages. Without depending on host/runtime crates, it converts Tool and Service inputs/outputs and bounds Tool output size. Using the PDK or validating a package does not authorize installation or execution in a Worker.
Guest-side Rust helpers for the versioned Component Model Tool and Service contracts. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm.
## Main entry points
- [`run_json_tool`] and [`export_component_tool!`] — typed Tool input/output boundary
- [`ToolOutput`] and [`ToolError`] — bounded output and errors
- [`ServiceOutput`] and [`export_plugin_instance!`] — instance output contracts
Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. [`export_component_service!`] adapts authored lifecycle/event handlers to the separate exact Service world without a guest task loop. The PDK does not import WASI, implement a Host, or provide Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter.
File diff suppressed because it is too large Load Diff
@@ -1,38 +0,0 @@
//! Minimal Component Model Tool plugin authoring sketch using `yoi-plugin-pdk`.
//!
//! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling
//! and package the adapted component as `plugin.component.wasm`.
use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({
world: "tool",
path: "../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[derive(Debug, Deserialize)]
struct EchoInput {
text: String,
}
#[derive(Debug, Serialize)]
struct EchoOutput<'a> {
tool: &'a str,
text: String,
}
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
}
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
use yoi_plugin_pdk::{ToolError, wit_bindgen};
wit_bindgen::generate!({
world: "ticket-service-plugin",
path: "../../resources/plugin/wit-service",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
fn start(config: String) -> Result<(), ToolError> {
if config == "invalid" {
Err(ToolError::invalid_input("bad config"))
} else {
Ok(())
}
}
fn event(_: exports::yoi::plugin::service::Envelope) -> exports::yoi::plugin::service::EventResult {
exports::yoi::plugin::service::EventResult::RetryAfter(20)
}
fn stop(_: String) -> Result<(), ToolError> {
Ok(())
}
yoi_plugin_pdk::export_component_service!(ServiceProbe, start, event, stop);
#[test]
fn service_macro_preserves_lifecycle_error_arm() {
use exports::yoi::plugin::service::Guest;
assert_eq!(
ServiceProbe::on_start("invalid".into()).unwrap_err().code,
"invalid_input"
);
ServiceProbe::on_stop("shutdown".into()).unwrap();
}
#[test]
fn service_dependencies_preserve_exact_older_pins() {
assert_eq!(
yoi_plugin_pdk::HOST_WIT,
include_str!("../../../resources/plugin/wit-service/deps/yoi-host-v2/yoi-host-v2.wit")
);
assert_eq!(
yoi_plugin_pdk::TICKET_HOST_WIT,
include_str!("../../../resources/plugin/wit-service/deps/yoi-host-v2.1/yoi-host-v2.1.wit")
);
assert_eq!(
yoi_plugin_pdk::SERVICE_WORLD,
"yoi:plugin/ticket-service-plugin@2.2.0"
);
assert_eq!(
yoi_plugin_pdk::SERVICE_WIT,
include_str!("../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit")
);
}
+157 -198
View File
@@ -1,247 +1,206 @@
//! Check the shipped templates against the real PDK and WIT for the guest target.
//! Each check owns its source and lockfile; Cargo alone shares and validates artifacts.
//! Real Rust -> core Wasm -> Component coverage for the shipped starters.
//! Only filesystem paths are relocated; WIT and guest source are unchanged.
//! Requires the wasm32-unknown-unknown Rust std target and cached Cargo dependencies.
//! No guest is instantiated and no HTTPS Host is faked by this suite.
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
use toml::Value;
use wit_parser::WorldItem;
const TEMPLATE_CARGO: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml");
const TEMPLATE_LIB: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs");
const TEMPLATE_PLUGIN: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
const SERVICE_TEMPLATE_CARGO: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml");
const SERVICE_TEMPLATE_LIB: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs");
const SERVICE_TEMPLATE_PLUGIN: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml");
// Preserve the removed document's source as a byte-identical fixture to keep the test input unchanged.
const SAMPLE_LIB: &str = include_str!("fixtures/component-tool-source.txt");
const PDK_CARGO: &str = include_str!("../Cargo.toml");
const TEMPLATES: [&str; 2] = ["rust-component-tool", "rust-component-https"];
#[test]
fn rust_component_tool_template_has_expected_files() {
let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses");
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
assert!(cargo["workspace"].is_table());
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
assert_eq!(
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
Some("../../../../crates/plugin-pdk")
);
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
assert_eq!(
plugin["runtime"]["world"].as_str(),
Some("yoi:plugin/tool@1.0.0")
);
assert!(plugin["tools"].as_array().expect("tools array").len() == 1);
assert!(TEMPLATE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!"));
assert!(TEMPLATE_LIB.contains("generate_all"));
assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
assert!(TEMPLATE_LIB.contains("ToolOutput::json"));
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
#[test]
fn rust_component_service_template_has_event_output_pattern() {
let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses");
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
assert_eq!(
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
Some("../../../../crates/plugin-pdk")
);
assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
let plugin: Value =
toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses");
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
assert_eq!(
plugin["runtime"]["world"].as_str(),
Some("yoi:plugin/instance@1.0.0")
);
assert!(
plugin["permissions"]
.as_array()
.expect("permissions array")
.iter()
.any(|permission| permission["kind"].as_str() == Some("host_api")
&& permission["api"].as_str() == Some("websocket"))
);
assert_eq!(
plugin["services"].as_array().expect("services array").len(),
1
);
let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0];
assert!(
ingress["event_kinds"]
.as_array()
.expect("event kinds")
.iter()
.any(|kind| kind.as_str() == Some("websocket_text"))
);
assert!(
ingress["sources"]
.as_array()
.expect("sources")
.iter()
.any(|source| source.as_str() == Some("websocket:wss://example.com/socket"))
);
let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0];
assert_eq!(websocket["scheme"].as_str(), Some("wss"));
assert_eq!(websocket["host"].as_str(), Some("example.com"));
assert!(
websocket["path_prefixes"]
.as_array()
.expect("websocket path prefixes")
.iter()
.any(|prefix| prefix.as_str() == Some("/socket"))
);
assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\""));
assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent"));
assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send"));
assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout"));
assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\""));
fn template_wit_copies_match_the_versioned_contract() {
let root = root();
for template in TEMPLATES {
for file in ["yoi-plugin-v2.wit", "deps/yoi-host/yoi-host-v2.wit"] {
assert_eq!(
fs::read(root.join("resources/plugin/wit").join(file)).unwrap(),
fs::read(
root.join("resources/plugin/templates")
.join(template)
.join("wit")
.join(file)
)
.unwrap(),
"{template}/{file} must be regenerated from the canonical WIT"
);
}
}
}
#[test]
fn component_tool_fixture_uses_pdk_bindings() {
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
assert!(SAMPLE_LIB.contains("wit_bindgen::generate!"));
assert!(SAMPLE_LIB.contains("generate_all"));
assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
assert!(!SAMPLE_LIB.contains("export_name"));
fn template_manifest_worlds_use_exact_versions_and_schemas() {
for template in TEMPLATES {
let path = root().join("resources/plugin/templates").join(template);
let cargo: Value =
toml::from_str(&fs::read_to_string(path.join("Cargo.toml")).unwrap()).unwrap();
assert!(cargo["workspace"].is_table());
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
let manifest: Value =
toml::from_str(&fs::read_to_string(path.join("plugin.toml")).unwrap()).unwrap();
assert_eq!(manifest["schema_version"].as_integer(), Some(2));
assert_eq!(manifest["runtime"]["kind"].as_str(), Some("wasm-component"));
assert!(
manifest["runtime"]["world"]
.as_str()
.unwrap()
.ends_with("@2.0.0")
);
for tool in manifest["tools"].as_array().unwrap() {
for schema in ["input_schema", "output_schema"] {
assert_eq!(tool[schema]["type"].as_str(), Some("object"));
assert_eq!(tool[schema]["additionalProperties"].as_bool(), Some(false));
}
}
}
}
#[test]
fn embedded_template_cargo_checks_for_wasm_target() {
cargo_check_template("rust-component-tool");
fn pure_starter_builds_real_component_without_imports() {
build_component(
"rust-component-tool",
"yoi_rust_component_tool_template",
&[],
);
}
#[test]
fn embedded_service_template_cargo_checks_for_wasm_target() {
cargo_check_template("rust-component-instance");
fn https_starter_builds_real_component_with_only_explicit_host_imports() {
build_component(
"rust-component-https",
"yoi_rust_component_https_template",
&[
"yoi:host/connections@2.0.0",
"yoi:host/errors@2.0.0",
"yoi:host/http@2.0.0",
],
);
}
fn cargo_check_template(template: &str) {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let templates_dir = crate_dir.join("../../resources/plugin/templates");
let template_dir = templates_dir.join(template);
// A sibling fixture preserves the shipped relative PDK and WIT paths verbatim.
// Never create or remove a lockfile in the shipped template: another invocation
// may be checking it at the same time, or a developer may own that lockfile.
fn build_component(template: &str, library: &str, expected_imports: &[&str]) {
let root = root();
let sdk = root.join("target/sdk");
fs::create_dir_all(&sdk).unwrap();
let source = root.join("resources/plugin/templates").join(template);
let fixture = tempfile::Builder::new()
.prefix(".cargo-check-")
.tempdir_in(&templates_dir)
.expect("temporary template fixture");
copy_template_tree(&template_dir, fixture.path(), true);
// Both templates share the PDK dependencies. A separate persistent target
// reuses those artifacts without sharing source or the outer Cargo build lock.
// Cargo fingerprints and its target lock handle changes and concurrent checks.
let executable = std::env::current_exe().expect("current test executable");
let target_dir = executable
.parent()
.and_then(|deps| deps.parent())
.expect("test executable has a profile directory")
.join("compile-tests/plugin-pdk-templates");
.prefix("build-")
.tempdir_in(&sdk)
.unwrap();
copy_tree(&source, fixture.path());
let cargo_path = fixture.path().join("Cargo.toml");
let cargo = fs::read_to_string(&cargo_path).unwrap().replace(
"../../../../crates/plugin-pdk",
root.join("crates/plugin-pdk").to_str().unwrap(),
);
fs::write(&cargo_path, cargo).unwrap();
let target = sdk.join("guest");
let output = Command::new(env!("CARGO"))
.arg("check")
.args([
"build",
"--release",
"--offline",
"--target",
"wasm32-unknown-unknown",
"--target-dir",
])
.arg(&target)
.arg("--manifest-path")
.arg(fixture.path().join("Cargo.toml"))
.arg("--target")
.arg("wasm32-unknown-unknown")
.arg("--offline")
.arg("--target-dir")
.arg(target_dir)
.arg(&cargo_path)
.current_dir(fixture.path())
.env("CARGO_TERM_COLOR", "never")
.output()
.expect("spawn cargo check for embedded template");
.expect("run guest Cargo build");
let logs = sdk.join("logs");
fs::create_dir_all(&logs).unwrap();
fs::write(logs.join(format!("{template}.stdout.log")), &output.stdout).unwrap();
fs::write(logs.join(format!("{template}.stderr.log")), &output.stderr).unwrap();
assert!(
output.status.success(),
"template {template} cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
output.status,
"{template}: cargo build failed\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let core =
fs::read(target.join(format!("wasm32-unknown-unknown/release/{library}.wasm"))).unwrap();
let component = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all())
.validate_all(&component)
.unwrap();
let wit_parser::decoding::DecodedWasm::Component(resolve, world) =
wit_component::decode(&component).unwrap()
else {
panic!("expected Component, not WIT package")
};
let world = &resolve.worlds[world];
let mut imports: Vec<_> = world
.imports
.keys()
.map(|key| resolve.name_world_key(key))
.collect();
imports.sort();
assert_eq!(imports, expected_imports);
let exports: Vec<_> = world
.exports
.keys()
.map(|key| resolve.name_world_key(key))
.collect();
assert_eq!(exports, ["yoi:plugin/tool@2.0.0"]);
let WorldItem::Interface { id, .. } = world.exports.values().next().unwrap() else {
panic!("Tool interface export required")
};
assert_eq!(
resolve.interfaces[*id].functions.keys().collect::<Vec<_>>(),
[&"invoke".to_string()]
);
// Retain actual package inputs for the shared validator/pack owner, not a mock .wasm.
let package = sdk.join("fixtures").join(template);
fs::create_dir_all(&package).unwrap();
fs::copy(source.join("plugin.toml"), package.join("plugin.toml")).unwrap();
fs::write(package.join("plugin.component.wasm"), component).unwrap();
}
fn copy_template_tree(source: &Path, destination: &Path, is_root: bool) {
let metadata = fs::symlink_metadata(source).expect("read template directory metadata");
assert!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"template input must be a real directory: {}",
source.display()
);
for entry in fs::read_dir(source).expect("read template directory") {
let entry = entry.expect("read template entry");
let path = entry.path();
let kind = entry.file_type().expect("read template entry type");
assert!(
!kind.is_symlink() && (kind.is_dir() || kind.is_file()),
"unsupported template input (including symlinks): {}",
path.display()
);
fn copy_tree(source: &Path, destination: &Path) {
fs::create_dir_all(destination).unwrap();
for entry in fs::read_dir(source).unwrap() {
let entry = entry.unwrap();
let kind = entry.file_type().unwrap();
assert!(!kind.is_symlink(), "template may not contain symlinks");
let name = entry.file_name();
// Preserve all compiler inputs, including build scripts and nested assets.
// Only omit the root build output and lockfile: each isolated consumer must
// resolve its own lockfile, as the original unlocked template check did.
if is_root
&& ((kind.is_dir() && name == "target") || (kind.is_file() && name == "Cargo.lock"))
{
if name == "target" || name == "Cargo.lock" {
continue;
}
let copied = destination.join(name);
if kind.is_dir() {
fs::create_dir(&copied).expect("create template fixture directory");
copy_template_tree(&path, &copied, false);
copy_tree(&entry.path(), &copied);
} else {
fs::copy(&path, &copied).expect("copy unmodified template input");
fs::copy(entry.path(), copied).unwrap();
}
}
}
#[test]
fn pdk_runtime_dependencies_are_guest_side_only() {
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses");
let dependencies = cargo["dependencies"]
.as_table()
.expect("dependencies table");
let forbidden = [
"worker",
"yoi-worker",
"agen",
"tui",
"yoi-tui",
"client",
"yoi-client",
"manifest",
"yoi-manifest",
"ticket",
"yoi-ticket",
];
for name in forbidden {
let cargo: Value = toml::from_str(include_str!("../Cargo.toml")).unwrap();
for dependency in cargo["dependencies"].as_table().unwrap().keys() {
assert!(
!dependencies.contains_key(name),
"PDK must not depend on host/runtime crate `{name}`"
["serde", "serde_json", "thiserror", "wit-bindgen"].contains(&dependency.as_str()),
"unexpected Host dependency {dependency}"
);
}
}
@@ -0,0 +1,47 @@
//! Compile-pass: the additive world uses the original Tool export and typed
//! imported Ticket resources; no handwritten canonical ABI or ambient authority.
use yoi_plugin_pdk::wit_bindgen;
wit_bindgen::generate!({
world: "ticket-tool-plugin",
path: "../../resources/plugin/wit-ticket",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
fn handle(
_: yoi_plugin_pdk::ToolContext,
input: String,
) -> yoi_plugin_pdk::Result<yoi_plugin_pdk::ToolOutput> {
yoi_plugin_pdk::ToolOutput::summary(input)
}
yoi_plugin_pdk::export_ticket_component_tool!(TicketProbe, handle);
#[test]
fn additive_export_uses_original_tool_result_abi() {
use exports::yoi::plugin2_0_0::tool::{Guest, Invocation};
let result = TicketProbe::invoke(Invocation {
invocation_id: "invocation".into(),
tool_name: "probe".into(),
input_json: "\"ok\"".into(),
config_json: "{}".into(),
remaining_ms: 1000,
})
.unwrap();
assert_eq!(result.summary, "ok");
let _access = yoi::host2_1_0::objects::Access::ExtensionWrite;
}
#[test]
fn old_wit_dependencies_are_exact_copies_not_augmented_versions() {
assert_eq!(
include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"),
include_str!("../../../resources/plugin/wit-ticket/deps/yoi-plugin-v2/yoi-plugin-v2.wit")
);
assert_eq!(
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"),
include_str!("../../../resources/plugin/wit-ticket/deps/yoi-host-v2/yoi-host-v2.wit")
);
assert_ne!(
yoi_plugin_pdk::TICKET_TOOL_WORLD,
yoi_plugin_pdk::TOOL_WORLD
);
}
+33 -11
View File
@@ -1,25 +1,47 @@
//! Compile-pass coverage for generated v2 bindings and the public adapter macro.
use yoi_plugin_pdk::wit_bindgen;
wit_bindgen::generate!({
world: "tool",
world: "tool-plugin",
path: "../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
struct Probe;
fn handle(
ctx: yoi_plugin_pdk::ToolContext,
input: serde_json::Value,
) -> yoi_plugin_pdk::Result<yoi_plugin_pdk::ToolOutput> {
if ctx.tool_name() != "probe" {
return Err(yoi_plugin_pdk::ToolError::unsupported_tool(ctx.tool_name()));
}
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
}
impl Guest for Probe {
fn call(tool_name: String, input_json: String) -> String {
yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| {
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
})
yoi_plugin_pdk::export_component_tool!(Probe, handle);
fn invocation(input_json: &str) -> exports::yoi::plugin::tool::Invocation {
exports::yoi::plugin::tool::Invocation {
invocation_id: "call-1".into(),
tool_name: "probe".into(),
input_json: input_json.into(),
config_json: "{}".into(),
remaining_ms: 1000,
}
}
#[test]
fn wit_bindgen_generates_current_tool_world() {
let output = <Probe as Guest>::call("probe".to_string(), r#"{"ok":true}"#.to_string());
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["summary"], "probe ok");
fn generated_adapter_returns_record_and_keeps_business_error_in_error_arm() {
use exports::yoi::plugin::tool::Guest;
let output = Probe::invoke(invocation(r#"{"ok":true}"#)).unwrap();
assert_eq!(output.summary, "probe ok");
assert_eq!(output.content, None);
assert_eq!(output.structured_json.as_deref(), Some(r#"{"ok":true}"#));
assert_eq!(
Probe::invoke(invocation("broken")).unwrap_err().code,
"invalid_input"
);
let mut input = invocation("{}");
input.tool_name = "unknown".into();
assert_eq!(Probe::invoke(input).unwrap_err().code, "unsupported_tool");
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "plugin-plan"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
manifest.workspace = true
schemars.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
+5
View File
@@ -0,0 +1,5 @@
# Portable Plugin execution plan
This crate owns the immutable [`WorkerPluginExecutionPlan`] model, validation and canonical fingerprint shared by Server persistence and Runtime launch snapshots. [`ArtifactDescriptor`] projects only the artifact metadata needed to construct or compare exact pins without depending on Server API types.
It does not own catalog admission, persistence, configuration resolution, artifact delivery or authorization. A deserialized plan or owner-supplied artifact projection is not evidence of trust or live execution authority; callers must enforce those boundaries with their owners.
@@ -0,0 +1 @@
{"format_version":1,"input":{"config":{"content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","toolchain_fingerprint":"toolchain"},"execution":{"delegation_id":"delegation","principal_id":"principal","runtime_id":"runtime","worker_id":"worker"},"plugins":[{"artifact_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","artifact_size":42,"component_world":"yoi:plugin/https-logged-tool-plugin@2.0.0","config_digest":"sha256:4bf0a2da94b0c881343726db532ddbfeeed37790af7da44e790cc0bba708654e","config_schema":{"digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","identity":"config","namespace":"example","version":"1"},"expected_last_operation":"operation","installation_id":"installation","package_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","package_id":"package","package_schema_version":2,"package_version":"1.0.0","projection":{"bindings":[{"binding_id":"binding","policy_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","slot":"api"}],"config":{"a":[{"a":false,"z":0}],"z":{"a":1,"b":2}},"grants":[{"kind":"logging"},{"kind":"https","slot":"api"}],"surfaces":["tool"],"tools":["echo","other"]},"required_imports":["https","logging"]}],"profile":{"identity":"profile:coder","projection_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"},"workspace_id":"workspace"}}
+507
View File
@@ -0,0 +1,507 @@
#![doc = include_str!("../README.md")]
use manifest::plugin;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PlanError {
#[error("invalid Plugin execution plan")]
Invalid,
#[error("Plugin execution plan conflict")]
Conflict,
}
pub type Result<T> = std::result::Result<T, PlanError>;
/// Neutral metadata used to validate and reproduce an exact pin. This excludes
/// descriptions, Tool schemas, schema source, credentials and artifact locations.
/// A projection is not proof of catalog admission or current authorization.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactIdentityProjection {
pub package_id: String,
pub version: String,
pub package_digest: String,
pub artifact_digest: String,
pub artifact_size: u32,
pub schema_version: u32,
pub world: String,
pub required_imports: Vec<String>,
pub tools: Vec<String>,
pub services: Vec<String>,
pub connections: Vec<String>,
pub requested_grants: Vec<PluginGrant>,
pub config_schema: PluginSchemaIdentity,
pub revoked: bool,
}
/// Adapts an owner's artifact metadata without coupling the portable model to
/// that owner's API. Callers must still perform admission with the owner; an
/// implementation of this trait does not establish metadata trust.
pub trait ArtifactDescriptor {
fn artifact_identity(&self) -> ArtifactIdentityProjection;
}
impl ArtifactDescriptor for ArtifactIdentityProjection {
fn artifact_identity(&self) -> ArtifactIdentityProjection {
self.clone()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ConfigProvenance {
pub content_digest: String,
pub toolchain_fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProfileProvenance {
pub identity: String,
pub projection_digest: String,
}
/// Supplied by the authenticated Host launch/delegation boundary, never by guest
/// or model input. These are identities only, not credentials or provider handles.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HostExecutionIdentity {
pub runtime_id: String,
pub worker_id: String,
pub principal_id: String,
pub delegation_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BindingPolicyReference {
pub slot: String,
pub binding_id: String,
pub policy_digest: String,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum PluginSurface {
Tool,
Service,
}
/// Exact grant identities; new operations are never implied by old pins.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginGrant {
Logging,
Https {
slot: String,
},
TicketRead,
TicketEvents,
TicketDataRead {
collection: String,
schema_id: String,
schema_version: String,
},
TicketDataWrite {
collection: String,
schema_id: String,
schema_version: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PluginSchemaIdentity {
pub identity: String,
pub version: String,
pub digest: String,
pub namespace: String,
}
/// The upstream config/schema authority must validate config values as non-secret
/// and intersect grants/surfaces with Workspace, Profile and delegation bounds.
/// Binding policies remain with their owner: only identities and digests travel.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PluginPinProjection {
pub config: Value,
pub surfaces: Vec<PluginSurface>,
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<String>,
pub grants: Vec<PluginGrant>,
pub bindings: Vec<BindingPolicyReference>,
}
/// All fields are private: construct from admitted artifact metadata, not a
/// hand-built wire snapshot. Deserialization alone does not establish admission.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginPin {
installation_id: String,
expected_last_operation: String,
package_id: String,
package_version: String,
package_digest: String,
artifact_digest: String,
artifact_size: u32,
package_schema_version: u32,
component_world: String,
required_imports: Vec<String>,
config_schema: PluginSchemaIdentity,
config_digest: String,
projection: PluginPinProjection,
}
impl WorkerPluginPin {
pub fn from_artifact(
installation_id: String,
expected_last_operation: String,
artifact: &(impl ArtifactDescriptor + ?Sized),
mut projection: PluginPinProjection,
) -> Result<Self> {
let artifact = artifact.artifact_identity();
if artifact.revoked {
return conflict();
}
if !valid_identity(&installation_id)
|| !valid_identity(&expected_last_operation)
|| !valid_digest(&artifact.package_digest)
|| !valid_digest(&artifact.artifact_digest)
|| artifact.artifact_size == 0
|| artifact.schema_version != plugin::SUPPORTED_PLUGIN_API_VERSION
|| !matches!(
artifact.world.as_str(),
plugin::PLUGIN_COMPONENT_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_SERVICE_WORLD
)
|| !valid_digest(&artifact.config_schema.digest)
|| !valid_identity(&artifact.package_id)
|| !valid_identity(&artifact.version)
|| !valid_identity(&artifact.config_schema.identity)
|| !valid_identity(&artifact.config_schema.version)
|| !valid_identity(&artifact.config_schema.namespace)
|| !projection.config.is_object()
{
return invalid();
}
projection.surfaces.sort();
projection.surfaces.dedup();
projection.tools.sort();
projection.tools.dedup();
projection.services.sort();
projection.services.dedup();
projection.grants.sort();
projection.grants.dedup();
projection.bindings.sort();
projection.config = canonical_value(&projection.config);
if (!projection.tools.is_empty() && !projection.surfaces.contains(&PluginSurface::Tool))
|| projection.tools.iter().any(|name| {
!valid_identity(name) || !artifact.tools.iter().any(|tool| *tool == *name)
})
|| (!projection.services.is_empty()
&& !projection.surfaces.contains(&PluginSurface::Service))
|| (projection.surfaces.contains(&PluginSurface::Service)
&& (projection.services.is_empty()
|| !projection.grants.contains(&PluginGrant::TicketEvents)))
|| projection.services.iter().any(|name| {
!valid_identity(name)
|| !artifact.services.contains(name)
|| artifact.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD
})
|| projection
.bindings
.windows(2)
.any(|b| b[0].slot == b[1].slot)
|| projection.bindings.iter().any(|binding| {
!valid_identity(&binding.slot)
|| !valid_identity(&binding.binding_id)
|| !valid_digest(&binding.policy_digest)
|| !artifact
.connections
.iter()
.any(|slot| *slot == binding.slot)
})
|| projection.grants.iter().any(|grant| match grant {
PluginGrant::TicketRead
| PluginGrant::TicketDataRead { .. }
| PluginGrant::TicketDataWrite { .. } => {
!matches!(
artifact.world.as_str(),
plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_SERVICE_WORLD
) || !artifact.requested_grants.contains(grant)
|| match grant {
PluginGrant::TicketDataRead {
collection,
schema_id,
schema_version,
}
| PluginGrant::TicketDataWrite {
collection,
schema_id,
schema_version,
} => [collection, schema_id, schema_version]
.iter()
.any(|v| !valid_identity(v) || v.len() > 128),
_ => false,
}
}
PluginGrant::TicketEvents => {
artifact.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|| !artifact.requested_grants.contains(grant)
|| projection.services.is_empty()
}
PluginGrant::Logging => !artifact.requested_grants.contains(&PluginGrant::Logging),
PluginGrant::Https { slot } => {
!valid_identity(slot)
|| !artifact
.requested_grants
.contains(&PluginGrant::Https { slot: slot.clone() })
|| !projection
.bindings
.iter()
.any(|binding| binding.slot == *slot)
}
})
|| projection.bindings.iter().any(|binding| {
!projection.grants.contains(&PluginGrant::Https {
slot: binding.slot.clone(),
})
})
{
return invalid();
}
let mut required_imports = artifact.required_imports.clone();
required_imports.sort();
required_imports.dedup();
Ok(Self {
installation_id,
expected_last_operation,
package_id: artifact.package_id.clone(),
package_version: artifact.version.clone(),
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
artifact_size: artifact.artifact_size,
package_schema_version: artifact.schema_version,
component_world: artifact.world.clone(),
required_imports,
config_schema: PluginSchemaIdentity {
identity: artifact.config_schema.identity.clone(),
version: artifact.config_schema.version.clone(),
digest: artifact.config_schema.digest.clone(),
namespace: artifact.config_schema.namespace.clone(),
},
config_digest: digest_json(&projection.config)?,
projection,
})
}
pub fn installation_id(&self) -> &str {
&self.installation_id
}
pub fn expected_last_operation(&self) -> &str {
&self.expected_last_operation
}
pub fn package_id(&self) -> &str {
&self.package_id
}
pub fn package_version(&self) -> &str {
&self.package_version
}
pub fn package_schema_version(&self) -> u32 {
self.package_schema_version
}
pub fn component_world(&self) -> &str {
&self.component_world
}
pub fn required_imports(&self) -> &[String] {
&self.required_imports
}
pub fn package_digest(&self) -> &str {
&self.package_digest
}
pub fn artifact_digest(&self) -> &str {
&self.artifact_digest
}
pub fn artifact_size(&self) -> u32 {
self.artifact_size
}
pub fn config_schema(&self) -> &PluginSchemaIdentity {
&self.config_schema
}
pub fn config_digest(&self) -> &str {
&self.config_digest
}
pub fn projection(&self) -> &PluginPinProjection {
&self.projection
}
pub fn matches_artifact(&self, artifact: &(impl ArtifactDescriptor + ?Sized)) -> Result<bool> {
Ok(Self::from_artifact(
self.installation_id.clone(),
self.expected_last_operation.clone(),
artifact,
self.projection.clone(),
)? == *self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginPlanInput {
pub workspace_id: String,
pub config: ConfigProvenance,
pub profile: ProfileProvenance,
pub execution: HostExecutionIdentity,
pub plugins: Vec<WorkerPluginPin>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginExecutionPlan {
format_version: u32,
input: WorkerPluginPlanInput,
}
impl WorkerPluginExecutionPlan {
pub fn canonicalize(mut input: WorkerPluginPlanInput) -> Result<Self> {
if !valid_identity(&input.workspace_id)
|| !valid_digest(&input.config.content_digest)
|| !valid_identity(&input.config.toolchain_fingerprint)
|| !valid_identity(&input.profile.identity)
|| !valid_digest(&input.profile.projection_digest)
|| !valid_identity(&input.execution.runtime_id)
|| !valid_identity(&input.execution.worker_id)
|| !valid_identity(&input.execution.principal_id)
|| input
.execution
.delegation_id
.as_ref()
.is_some_and(|id| !valid_identity(id))
|| input.plugins.len() > 128
{
return invalid();
}
input
.plugins
.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
// One selection per installation; two aliases of one package instead use
// distinct installation identities, as required by the catalog contract.
if input
.plugins
.windows(2)
.any(|pins| pins[0].installation_id == pins[1].installation_id)
{
return invalid();
}
// Public Tool names use immutable installation identities, never aliases.
let mut names = BTreeSet::new();
for pin in &input.plugins {
for tool in &pin.projection.tools {
let name = format!("{}__{}", pin.installation_id, tool);
if name.len() > 128 || !names.insert(name) {
return invalid();
}
}
}
Ok(Self {
format_version: 1,
input,
})
}
pub fn input(&self) -> &WorkerPluginPlanInput {
&self.input
}
pub fn workspace_id(&self) -> &str {
&self.input.workspace_id
}
pub fn config(&self) -> &ConfigProvenance {
&self.input.config
}
pub fn profile(&self) -> &ProfileProvenance {
&self.input.profile
}
pub fn execution(&self) -> &HostExecutionIdentity {
&self.input.execution
}
pub fn plugins(&self) -> &[WorkerPluginPin] {
&self.input.plugins
}
pub fn fingerprint(&self) -> Result<String> {
digest_json(self)
}
pub fn canonical_json(&self) -> Result<String> {
encode_canonical(self)
}
pub fn validate_canonical(&self) -> Result<()> {
if self.format_version != 1 || Self::canonicalize(self.input.clone())? != *self {
return invalid();
}
Ok(())
}
}
fn invalid<T>() -> Result<T> {
Err(PlanError::Invalid)
}
fn conflict<T>() -> Result<T> {
Err(PlanError::Conflict)
}
fn valid_identity(value: &str) -> bool {
!value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
}
fn valid_digest(value: &str) -> bool {
value.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
fn canonical_value(value: &Value) -> Value {
match value {
Value::Object(fields) => {
let mut keys: Vec<_> = fields.keys().collect();
keys.sort();
Value::Object(
keys.into_iter()
.map(|key| (key.clone(), canonical_value(&fields[key])))
.collect(),
)
}
Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
_ => value.clone(),
}
}
fn encode_canonical(value: &impl Serialize) -> Result<String> {
let value = serde_json::to_value(value).map_err(|_| PlanError::Invalid)?;
serde_json::to_string(&canonical_value(&value)).map_err(|_| PlanError::Invalid)
}
fn digest_json(value: &impl Serialize) -> Result<String> {
let bytes = encode_canonical(value)?;
Ok(hash_bytes(bytes.as_bytes()))
}
fn hash_bytes(bytes: &[u8]) -> String {
let hex: String = Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{hex}")
}
#[cfg(test)]
mod tests;
+286
View File
@@ -0,0 +1,286 @@
use super::*;
use serde_json::json;
fn digest(hex: char) -> String {
format!("sha256:{}", hex.to_string().repeat(64))
}
fn artifact() -> ArtifactIdentityProjection {
ArtifactIdentityProjection {
package_id: "package".into(),
version: "1.0.0".into(),
package_digest: digest('a'),
artifact_digest: digest('b'),
artifact_size: 42,
schema_version: 2,
world: plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD.into(),
required_imports: vec!["logging".into(), "https".into(), "logging".into()],
services: vec![],
tools: vec!["other".into(), "echo".into()],
connections: vec!["api".into()],
requested_grants: vec![
PluginGrant::Https { slot: "api".into() },
PluginGrant::Logging,
],
config_schema: PluginSchemaIdentity {
identity: "config".into(),
version: "1".into(),
digest: digest('c'),
namespace: "example".into(),
},
revoked: false,
}
}
fn projection() -> PluginPinProjection {
PluginPinProjection {
config: serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":[{"z":0,"a":false}]}"#).unwrap(),
surfaces: vec![PluginSurface::Tool, PluginSurface::Tool],
services: vec![],
tools: vec!["other".into(), "echo".into(), "other".into()],
grants: vec![
PluginGrant::Https { slot: "api".into() },
PluginGrant::Logging,
],
bindings: vec![BindingPolicyReference {
slot: "api".into(),
binding_id: "binding".into(),
policy_digest: digest('f'),
}],
}
}
fn pin(
artifact: &impl ArtifactDescriptor,
projection: PluginPinProjection,
) -> Result<WorkerPluginPin> {
WorkerPluginPin::from_artifact(
"installation".into(),
"operation".into(),
artifact,
projection,
)
}
fn input() -> WorkerPluginPlanInput {
WorkerPluginPlanInput {
workspace_id: "workspace".into(),
config: ConfigProvenance {
content_digest: digest('d'),
toolchain_fingerprint: "toolchain".into(),
},
profile: ProfileProvenance {
identity: "profile:coder".into(),
projection_digest: digest('e'),
},
execution: HostExecutionIdentity {
runtime_id: "runtime".into(),
worker_id: "worker".into(),
principal_id: "principal".into(),
delegation_id: Some("delegation".into()),
},
plugins: vec![pin(&artifact(), projection()).unwrap()],
}
}
#[test]
fn canonical_wire_and_fingerprint_preserve_persisted_format() {
let plan = WorkerPluginExecutionPlan::canonicalize(input()).unwrap();
let expected = include_str!("fixtures/canonical-plan.json").trim_end();
assert_eq!(plan.canonical_json().unwrap(), expected);
assert_eq!(
plan.fingerprint().unwrap(),
"sha256:00ee15d79098f4afa137da5d4b0b9b78acde48a6157fdda0f3327d8c122bd889"
);
let restored: WorkerPluginExecutionPlan = serde_json::from_str(expected).unwrap();
restored.validate_canonical().unwrap();
assert_eq!(restored, plan);
assert!(restored.plugins()[0].matches_artifact(&artifact()).unwrap());
}
#[test]
fn artifact_identity_changes_are_not_hidden_by_projection_adapter() {
let original = pin(&artifact(), projection()).unwrap();
let mut changed = artifact();
changed.artifact_size += 1;
assert!(!original.matches_artifact(&changed).unwrap());
changed = artifact();
changed.config_schema.digest = digest('d');
assert!(!original.matches_artifact(&changed).unwrap());
changed = artifact();
changed.revoked = true;
assert_eq!(
original.matches_artifact(&changed),
Err(PlanError::Conflict)
);
}
#[test]
fn unsupported_or_malformed_artifact_identity_is_invalid() {
for change in [
|a: &mut ArtifactIdentityProjection| a.package_digest = "sha256:ABC".into(),
|a: &mut ArtifactIdentityProjection| a.artifact_digest = "digest".into(),
|a: &mut ArtifactIdentityProjection| a.artifact_size = 0,
|a: &mut ArtifactIdentityProjection| a.schema_version = 1,
|a: &mut ArtifactIdentityProjection| a.world = "unsupported".into(),
|a: &mut ArtifactIdentityProjection| a.package_id.clear(),
|a: &mut ArtifactIdentityProjection| a.version = "bad\nversion".into(),
|a: &mut ArtifactIdentityProjection| a.config_schema.identity.clear(),
|a: &mut ArtifactIdentityProjection| a.config_schema.version.clear(),
|a: &mut ArtifactIdentityProjection| a.config_schema.digest.clear(),
|a: &mut ArtifactIdentityProjection| a.config_schema.namespace.clear(),
] {
let mut value = artifact();
change(&mut value);
assert_eq!(pin(&value, projection()), Err(PlanError::Invalid));
}
}
#[test]
fn selected_tools_grants_and_bindings_cannot_expand_artifact_metadata() {
for change in [
|p: &mut PluginPinProjection| p.config = json!([]),
|p: &mut PluginPinProjection| p.surfaces.clear(),
|p: &mut PluginPinProjection| p.tools.push("absent".into()),
|p: &mut PluginPinProjection| p.bindings.push(p.bindings[0].clone()),
|p: &mut PluginPinProjection| p.bindings[0].slot = "absent".into(),
|p: &mut PluginPinProjection| p.bindings[0].binding_id.clear(),
|p: &mut PluginPinProjection| p.bindings[0].policy_digest.clear(),
|p: &mut PluginPinProjection| p.bindings.clear(),
|p: &mut PluginPinProjection| p.grants.retain(|g| *g == PluginGrant::Logging),
] {
let mut value = projection();
change(&mut value);
assert_eq!(pin(&artifact(), value), Err(PlanError::Invalid));
}
for change in [
|a: &mut ArtifactIdentityProjection| a.tools.clear(),
|a: &mut ArtifactIdentityProjection| a.connections.clear(),
|a: &mut ArtifactIdentityProjection| a.requested_grants.clear(),
] {
let mut value = artifact();
change(&mut value);
assert_eq!(pin(&value, projection()), Err(PlanError::Invalid));
}
}
#[test]
fn malformed_provenance_and_duplicate_installations_are_invalid() {
for change in [
|i: &mut WorkerPluginPlanInput| i.workspace_id.clear(),
|i: &mut WorkerPluginPlanInput| i.config.content_digest.clear(),
|i: &mut WorkerPluginPlanInput| i.config.toolchain_fingerprint.clear(),
|i: &mut WorkerPluginPlanInput| i.profile.identity.clear(),
|i: &mut WorkerPluginPlanInput| i.profile.projection_digest.clear(),
|i: &mut WorkerPluginPlanInput| i.execution.runtime_id.clear(),
|i: &mut WorkerPluginPlanInput| i.execution.worker_id.clear(),
|i: &mut WorkerPluginPlanInput| i.execution.principal_id.clear(),
|i: &mut WorkerPluginPlanInput| i.execution.delegation_id = Some(String::new()),
|i: &mut WorkerPluginPlanInput| i.plugins.push(i.plugins[0].clone()),
|i: &mut WorkerPluginPlanInput| i.plugins = vec![i.plugins[0].clone(); 129],
] {
let mut value = input();
change(&mut value);
assert_eq!(
WorkerPluginExecutionPlan::canonicalize(value),
Err(PlanError::Invalid)
);
}
}
#[test]
fn deserialized_unknown_fields_versions_and_noncanonical_order_are_rejected() {
let plan = WorkerPluginExecutionPlan::canonicalize(input()).unwrap();
let mut wire = serde_json::to_value(&plan).unwrap();
wire["input"]["execution"]["token"] = json!("credential");
assert!(serde_json::from_value::<WorkerPluginExecutionPlan>(wire).is_err());
let mut wire = serde_json::to_value(&plan).unwrap();
wire["format_version"] = json!(2);
let unsupported: WorkerPluginExecutionPlan = serde_json::from_value(wire).unwrap();
assert_eq!(unsupported.validate_canonical(), Err(PlanError::Invalid));
let mut unsorted = plan.clone();
let mut second = unsorted.input.plugins[0].clone();
second.installation_id = "aaa".into();
unsorted.input.plugins.push(second);
assert_eq!(unsorted.validate_canonical(), Err(PlanError::Invalid));
}
#[test]
fn ticket_grants_require_exact_world_request_and_schema_namespace() {
let mut a = artifact();
let write = PluginGrant::TicketDataWrite {
collection: "notes".into(),
schema_id: "note".into(),
schema_version: "1".into(),
};
let read = PluginGrant::TicketDataRead {
collection: "notes".into(),
schema_id: "note".into(),
schema_version: "1".into(),
};
a.requested_grants
.extend([PluginGrant::TicketRead, write.clone()]);
let mut selected = projection();
selected.grants.push(write.clone());
// Even a fabricated metadata request cannot add Ticket operations to v2.0.
assert_eq!(pin(&a, selected.clone()), Err(PlanError::Invalid));
a.world = plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD.into();
let exact = pin(&a, selected.clone()).unwrap();
assert!(exact.projection().grants.contains(&write));
assert!(!exact.projection().grants.contains(&read)); // write is not read
selected.grants.push(read);
assert_eq!(pin(&a, selected), Err(PlanError::Invalid));
let mut selected = projection();
selected.grants.push(PluginGrant::TicketDataWrite {
collection: "notes".into(),
schema_id: "note".into(),
schema_version: "2".into(),
});
assert_eq!(pin(&a, selected), Err(PlanError::Invalid));
a.requested_grants.retain(|g| g != &write);
assert_eq!(exact.matches_artifact(&a), Err(PlanError::Invalid));
}
#[test]
fn service_selection_needs_exact_new_world_declared_name_and_explicit_event_grant() {
let mut artifact = artifact();
let mut selected = projection();
selected.tools.clear();
selected.bindings.clear();
selected.grants = vec![PluginGrant::TicketEvents];
selected.surfaces = vec![PluginSurface::Service];
selected.services = vec!["watch".into()];
artifact.services = vec!["watch".into()];
artifact.requested_grants.push(PluginGrant::TicketEvents);
assert_eq!(pin(&artifact, selected.clone()), Err(PlanError::Invalid));
artifact.world = plugin::PLUGIN_COMPONENT_SERVICE_WORLD.into();
let exact = pin(&artifact, selected.clone()).unwrap();
selected.services.push("undeclared".into());
assert_eq!(pin(&artifact, selected), Err(PlanError::Invalid));
artifact.services.clear();
assert_eq!(exact.matches_artifact(&artifact), Err(PlanError::Invalid));
}
#[test]
fn old_serialized_pin_does_not_acquire_service_selection_or_event_grant() {
let plan: WorkerPluginExecutionPlan =
serde_json::from_str(include_str!("fixtures/canonical-plan.json")).unwrap();
assert!(plan.plugins()[0].projection().services.is_empty());
assert!(
!plan.plugins()[0]
.projection()
.surfaces
.contains(&PluginSurface::Service)
);
assert!(
!plan.plugins()[0]
.projection()
.grants
.contains(&PluginGrant::TicketEvents)
);
// Additive default-off fields cannot mutate the exact old plan fingerprint.
assert_eq!(
plan.fingerprint().unwrap(),
"sha256:00ee15d79098f4afa137da5d4b0b9b78acde48a6157fdda0f3327d8c122bd889"
);
}
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "yoi-plugin-runtime"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
async-trait.workspace = true
manifest.workspace = true
server-api.workspace = true
sha2.workspace = true
fs4.workspace = true
tempfile.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["rt", "macros", "sync", "time", "fs", "io-util"] }
tokio-util = { version = "0.7", features = ["rt"] }
jsonschema = { version = "0.33.0", default-features = false }
wasmparser = "0.244.0"
wasmtime = { version = "=38.0.4", default-features = false, features = ["async", "component-model", "cranelift", "runtime", "std", "threads"] }
[dev-dependencies]
tempfile.workspace = true
wit-component = { version = "0.244.0", features = ["dummy-module"] }
wit-parser = "0.244.0"
wasmprinter = "0.244.0"
wat = "1"
tokio = { workspace = true, features = ["rt-multi-thread", "test-util", "net"] }
+15
View File
@@ -0,0 +1,15 @@
# yoi-plugin-runtime
Runtime-owned verification, byte caching and standalone execution of exact versioned Tool and Service Components. [`Executor`] compiles only artifacts reverified through `manifest::plugin`, then creates a fresh Store and instance for each invocation. It does not resolve catalogs, config, Worker plans or repository paths, and does not register Tools with a Worker.
[`delivery`] caches canonical bytes under the authenticated Server authority and artifact digest. Its source adapter must reauthorize the exact saved plan selection on every hit and after transfer. Byte deduplication shares neither metadata nor installation/config/grant authority. A private Runtime directory, bounded staging, process-released locking and atomic publication prevent partial files from becoming executor inputs; abandoned staging is discarded under the same lock. Cache deletion never releases Server pins. The cache is not an offline execution lease, and verified bytes still require the execution Host's fresh admission.
[`host`] is an explicit asynchronous injection boundary, not an implementation of Server authority or HTTPS. Every invocation (including pure computation) requires live admission and result-publication authorization. Providers must bind their authenticated installation/plan/subject and narrowed grants to [`host::InvocationContext`], authorize every protected operation, and make pending work cancellation-safe. There is no default provider or product allow-all fallback. Dropping a provider future must release temporary resources; an already dispatched external effect is not rolled back. Durable effect receipts, secret resolution, URL/DNS/TLS policy and real network transport belong to the Host provider, not this sandbox.
Component instantiation is not an export: all Host imports, including logging, are rejected until the export context is installed. Service start/stop exports permit only logging; event exports reuse the same Host and Ticket/data resources as Tools. [`ServiceInvocationResult`] retains Host effect observations independently of guest acknowledgement, including a dispatch whose wait is cancelled. This observation is not a second receipt authority: the driver must pause on unknown effects and commit acknowledgement through its owner. Only exact WIT imports are linked, without WASI, filesystem, environment, process, clock or network ambient authority. Call-local connection resources hold opaque provider objects; guest resource representations never identify a Server resource or issue a grant.
A single deadline covers admission, instantiation, guest execution, asynchronous Host waits, post-return and publication. Fuel bounds computation and cooperatively yields; epoch interruption cooperatively yields as well. Store memory/table/instance limits, call-local resource and operation counts, serialized payload and diagnostic budgets complement those bounds. Text/JSON overflows fail rather than returning truncated success; excess diagnostic entries are dropped and counted. Guest diagnostics are untrusted, must not contain secrets, and are separate from model output. Host adapters must not log raw request/response or credentials.
The sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity.
Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. Product integration must supply the live Server provider and owns Worker launch/restore and Service driver lifetime.
+511
View File
@@ -0,0 +1,511 @@
//! Runtime-owned byte cache, not a source of admission, configuration or grants.
//!
//! The caller supplies a private Runtime directory and an expectation from its
//! trusted Host plan, never a model-selected path or owner-supplied expectation.
//! Every resolution checks the live owner twice, including hits. The returned
//! bytes prove format/identity only; execution still requires current admission.
//! A process-released filesystem lock bounds all final and pending files together.
//! Only synced, completely verified files are renamed into the visible namespace;
//! abandoned staging files are discarded under that same lock after restart.
use async_trait::async_trait;
use manifest::plugin::{self, PluginPackageLimits};
use server_api::{PluginArtifact, PluginArtifactDeliveryMetadata, PluginArtifactDeliveryRequest};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File, OpenOptions},
io::{self, Read},
path::{Path, PathBuf},
pin::Pin,
time::Duration,
};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
pub type ArtifactStream = Pin<Box<dyn AsyncRead + Send + Unpin>>;
#[async_trait]
pub trait ArtifactSource: Send + Sync {
/// Stable authenticated Server authority, not a workspace or connection ID.
fn server_authority_identity(&self) -> &str;
async fn authorize(
&self,
request: &PluginArtifactDeliveryRequest,
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError>;
async fn fetch(
&self,
request: &PluginArtifactDeliveryRequest,
) -> Result<ArtifactStream, DeliveryError>;
}
#[derive(Debug, thiserror::Error)]
pub enum DeliveryError {
#[error("artifact delivery denied by current owner")]
Denied,
#[error("artifact owner unavailable: {0}")]
Unavailable(String),
#[error("invalid artifact delivery request")]
InvalidRequest,
#[error("artifact metadata differs from trusted Host plan")]
MetadataMismatch,
#[error("artifact exceeds delivery bound or declared size")]
TooLarge,
#[error("invalid artifact: {0}")]
InvalidArtifact(String),
#[error("cache directory must be private and Runtime-owned")]
InvalidCacheRoot,
#[error("invalid artifact cache limits")]
InvalidLimits,
#[error("artifact delivery deadline exceeded")]
Timeout,
#[error("artifact cache I/O failed: {0}")]
Io(#[from] io::Error),
}
/// Exact immutable descriptor selected by the Host, not metadata learned from a
/// delivery response. Keeping the complete descriptor also binds Tool schemas,
/// connection declarations, requested (not effective) grants and schema identity.
#[derive(Clone, Debug)]
pub struct ArtifactExpectation {
artifact: PluginArtifact,
}
impl ArtifactExpectation {
pub fn new(host_plan_artifact: PluginArtifact) -> Self {
Self {
artifact: host_plan_artifact,
}
}
pub fn descriptor(&self) -> &PluginArtifact {
&self.artifact
}
}
/// Bytes have no public constructor or mutable accessor, and contain no cached
/// owner authorization, Worker configuration, effective grants or pin ownership.
pub struct VerifiedArtifact {
bytes: Vec<u8>,
}
impl std::fmt::Debug for VerifiedArtifact {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VerifiedArtifact")
.field("byte_length", &self.bytes.len())
.finish_non_exhaustive()
}
}
impl VerifiedArtifact {
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
}
#[derive(Clone, Debug)]
pub struct CacheLimits {
pub package: PluginPackageLimits,
/// Includes the current staging download, not only published files.
pub max_cache_bytes: u64,
pub max_entries: usize,
pub delivery_timeout: Duration,
}
impl Default for CacheLimits {
fn default() -> Self {
Self {
package: PluginPackageLimits::default(),
max_cache_bytes: 256 * 1024 * 1024,
max_entries: 32,
delivery_timeout: Duration::from_secs(60),
}
}
}
#[derive(Debug)]
pub struct ArtifactCache {
root: PathBuf,
limits: CacheLimits,
}
impl ArtifactCache {
/// The root belongs exclusively to the Runtime cache. Existing roots must
/// already be private; this never fixes permissions on arbitrary directories.
pub fn new(
runtime_owned_root: impl AsRef<Path>,
limits: CacheLimits,
) -> Result<Self, DeliveryError> {
if limits.max_cache_bytes == 0
|| limits.max_entries == 0
|| limits.package.max_package_size_bytes == 0
|| limits.delivery_timeout.is_zero()
{
return Err(DeliveryError::InvalidLimits);
}
let root = runtime_owned_root.as_ref();
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(root)?;
let metadata = fs::symlink_metadata(root)?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(DeliveryError::InvalidCacheRoot);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o077 != 0 {
return Err(DeliveryError::InvalidCacheRoot);
}
}
Ok(Self {
root: fs::canonicalize(root)?,
limits,
})
}
pub async fn resolve(
&self,
source: &dyn ArtifactSource,
trusted_workspace_id: &str,
request: &PluginArtifactDeliveryRequest,
expected: &ArtifactExpectation,
) -> Result<VerifiedArtifact, DeliveryError> {
tokio::time::timeout(
self.limits.delivery_timeout,
self.resolve_inner(source, trusted_workspace_id, request, expected),
)
.await
.map_err(|_| DeliveryError::Timeout)?
}
async fn resolve_inner(
&self,
source: &dyn ArtifactSource,
workspace: &str,
request: &PluginArtifactDeliveryRequest,
expected: &ArtifactExpectation,
) -> Result<VerifiedArtifact, DeliveryError> {
let authority = source.server_authority_identity().to_owned();
let descriptor = &expected.artifact;
if authority.is_empty()
|| workspace.is_empty()
|| request.plan_id.is_empty()
|| request.plan_fingerprint.is_empty()
|| request.installation_id.is_empty()
|| !is_digest(&request.artifact_digest)
|| request.artifact_digest != descriptor.artifact_digest
|| !is_digest(&descriptor.package_digest)
{
return Err(DeliveryError::InvalidRequest);
}
let size = u64::from(descriptor.artifact_size);
if size == 0
|| size > self.limits.package.max_package_size_bytes
|| size > self.limits.max_cache_bytes
{
return Err(DeliveryError::TooLarge);
}
self.authorize(source, &authority, workspace, request, expected)
.await?;
// Lock ownership follows this future, including cancellation/deadline.
let _lock = lock_cache(self.root.join(".cache.lock")).await?;
let mut entries = self.inventory()?;
let path = self.root.join(format!(
"{}-{}.artifact",
hash(authority.as_bytes()),
&request.artifact_digest[7..]
));
let bytes = match self.read_verified(&path, expected)? {
Some(bytes) => {
self.make_room(&mut entries, Some(&path), 0, 0)?;
bytes
}
None => {
// Discard a corrupt final file only while holding the cache lock;
// no competing publisher can have repaired it since the read.
if let Some(index) = entries.iter().position(|entry| entry.path == path) {
fs::remove_file(&path)?;
entries.remove(index);
}
self.make_room(&mut entries, None, size, 1)?;
let mut stream = source.fetch(request).await?;
if source.server_authority_identity() != authority {
return Err(DeliveryError::MetadataMismatch);
}
let pending = tempfile::Builder::new()
.prefix(".pending-")
.tempfile_in(&self.root)?;
let mut file = tokio::fs::File::from_std(pending.reopen()?);
let mut written = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
// Read at most one byte beyond the declaration, even if the
// peer streams forever. Excess bytes are never written.
let remaining = (size - written + 1).min(buffer.len() as u64) as usize;
let n = stream.read(&mut buffer[..remaining]).await?;
if n == 0 {
break;
}
written += n as u64;
if written > size {
return Err(DeliveryError::TooLarge);
}
file.write_all(&buffer[..n]).await?;
}
if written != size {
return Err(DeliveryError::InvalidArtifact(
"truncated artifact stream".into(),
));
}
file.flush().await?;
file.sync_all().await?;
drop(file);
let bytes = self.read_exact_bounded(pending.path(), size)?;
self.verify(&bytes, expected)?;
// tempfile persist is an atomic same-filesystem rename. The global
// lock prevents overwrite races; no final name ever exposes a prefix.
pending.persist(&path).map_err(|error| error.error)?;
sync_directory(&self.root)?;
bytes
}
};
// Neither disk presence nor authorization before transfer proves current
// owner policy after a slow download, eviction wait or cached verification.
self.authorize(source, &authority, workspace, request, expected)
.await?;
Ok(VerifiedArtifact { bytes })
}
async fn authorize(
&self,
source: &dyn ArtifactSource,
authority: &str,
workspace: &str,
request: &PluginArtifactDeliveryRequest,
expected: &ArtifactExpectation,
) -> Result<(), DeliveryError> {
let metadata = source.authorize(request).await?;
if metadata.artifact.revoked || expected.artifact.revoked {
return Err(DeliveryError::Denied);
}
if source.server_authority_identity() != authority
|| metadata.workspace_id != workspace
|| metadata.request != *request
|| metadata.artifact != expected.artifact
{
return Err(DeliveryError::MetadataMismatch);
}
Ok(())
}
fn read_verified(
&self,
path: &Path,
expected: &ArtifactExpectation,
) -> Result<Option<Vec<u8>>, DeliveryError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(DeliveryError::InvalidCacheRoot);
}
if metadata.len() != u64::from(expected.artifact.artifact_size) {
return Ok(None);
}
let bytes = self.read_exact_bounded(path, metadata.len())?;
match self.verify(&bytes, expected) {
Ok(()) => Ok(Some(bytes)),
Err(DeliveryError::InvalidArtifact(_)) => Ok(None),
Err(error) => Err(error),
}
}
fn read_exact_bounded(&self, path: &Path, size: u64) -> Result<Vec<u8>, DeliveryError> {
let mut bytes = Vec::new();
File::open(path)?.take(size + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 != size {
return Err(DeliveryError::InvalidArtifact(
"cached artifact size changed".into(),
));
}
Ok(bytes)
}
fn verify(&self, bytes: &[u8], expected: &ArtifactExpectation) -> Result<(), DeliveryError> {
let d = &expected.artifact;
let package = plugin::verify_plugin_artifact(
bytes,
&d.package_digest,
&d.artifact_digest,
u64::from(d.artifact_size),
&self.limits.package,
)
.map_err(|error| DeliveryError::InvalidArtifact(error.message))?;
let m = &package.package.manifest;
let schema = plugin::plugin_config_schema_identity(m);
if d.package_id != m.id
|| d.name != m.name
|| d.version != m.version
|| d.description != m.description
|| d.schema_version != m.schema_version
|| d.world != m.runtime.world
|| d.required_imports != m.runtime.required_imports
|| !same_json(&d.tools, &m.tools)?
|| !same_json(&d.services, &m.services)?
|| !same_json(&d.connections, &m.connections)?
|| !same_json(&d.requested_grants, &m.requested_grants)?
|| !same_json(&d.data_collections, &m.data_collections)?
|| d.config_schema.dcdl != m.config_schema
|| d.config_schema.digest != schema.digest
|| d.config_schema.identity != schema.identity
|| d.config_schema.version != schema.version
|| d.config_schema.namespace != schema.namespace
{
return Err(DeliveryError::MetadataMismatch);
}
Ok(())
}
fn inventory(&self) -> Result<Vec<Entry>, DeliveryError> {
let mut entries = Vec::new();
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_str().ok_or(DeliveryError::InvalidCacheRoot)?;
let metadata = fs::symlink_metadata(entry.path())?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(DeliveryError::InvalidCacheRoot);
}
if name == ".cache.lock" {
continue;
}
if name.starts_with(".pending-") {
fs::remove_file(entry.path())?;
continue;
}
if !is_cache_filename(name) {
return Err(DeliveryError::InvalidCacheRoot);
}
entries.push(Entry {
path: entry.path(),
size: metadata.len(),
modified: metadata.modified()?,
});
}
// Oldest publication first; hits do not need a second metadata ledger.
entries.sort_by(|a, b| a.modified.cmp(&b.modified).then(a.path.cmp(&b.path)));
Ok(entries)
}
fn make_room(
&self,
entries: &mut Vec<Entry>,
keep: Option<&Path>,
additional_bytes: u64,
additional_entries: usize,
) -> Result<(), DeliveryError> {
let mut bytes = entries.iter().try_fold(additional_bytes, |sum, entry| {
sum.checked_add(entry.size).ok_or(DeliveryError::TooLarge)
})?;
let mut count = entries.len() + additional_entries;
for entry in entries.iter() {
if bytes <= self.limits.max_cache_bytes && count <= self.limits.max_entries {
break;
}
if keep == Some(entry.path.as_path()) {
continue;
}
fs::remove_file(&entry.path)?;
bytes -= entry.size;
count -= 1;
}
if bytes > self.limits.max_cache_bytes || count > self.limits.max_entries {
return Err(DeliveryError::TooLarge);
}
Ok(())
}
}
struct Entry {
path: PathBuf,
size: u64,
modified: std::time::SystemTime,
}
async fn lock_cache(path: PathBuf) -> Result<File, DeliveryError> {
if let Ok(metadata) = fs::symlink_metadata(&path)
&& (!metadata.is_file() || metadata.file_type().is_symlink())
{
return Err(DeliveryError::InvalidCacheRoot);
}
let mut options = OpenOptions::new();
options.create(true).truncate(false).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(path)?;
loop {
if fs4::fs_std::FileExt::try_lock_exclusive(&file)? {
return Ok(file);
}
// OS locks provide crash recovery; nonblocking acquisition keeps cancelled
// waiters from occupying Tokio blocking threads after their caller exits.
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn sync_directory(root: &Path) -> Result<(), DeliveryError> {
// Windows does not expose directory fsync through std::fs::File. Atomic
// visibility still holds there; power-loss directory durability is Unix-only.
#[cfg(unix)]
File::open(root)?.sync_all()?;
#[cfg(not(unix))]
let _ = root;
Ok(())
}
fn hash(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn is_hex(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn is_digest(value: &str) -> bool {
value.strip_prefix("sha256:").is_some_and(is_hex)
}
fn is_cache_filename(value: &str) -> bool {
value
.strip_suffix(".artifact")
.and_then(|value| value.split_once('-'))
.is_some_and(|(authority, digest)| is_hex(authority) && is_hex(digest))
}
fn same_json<A: serde::Serialize, B: serde::Serialize>(
a: &A,
b: &B,
) -> Result<bool, DeliveryError> {
let a = serde_json::to_value(a).map_err(|_| DeliveryError::MetadataMismatch)?;
let b = serde_json::to_value(b).map_err(|_| DeliveryError::MetadataMismatch)?;
Ok(a == b)
}
#[cfg(test)]
mod tests;
+996
View File
@@ -0,0 +1,996 @@
//! Cache/real filesystem/shared Component package integration. Only owner policy
//! and transport are fixtures; canonical verification, streaming limits, locking,
//! eviction and publication use production code. Subprocess actors prove lock
//! convergence and process-exit recovery, not arbitrary power-loss durability.
use super::*;
use manifest::plugin::{CanonicalPluginArtifact, PluginSourceKind};
use std::{
io::{BufRead, BufReader, Write},
process::{Child, Command, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
task::{Context, Poll},
};
use tokio::{io::ReadBuf, sync::Semaphore};
const MANIFEST: &str =
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml");
const COMPONENT: &[u8] =
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm");
fn fixture() -> (CanonicalPluginArtifact, PluginArtifact) {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("plugin.toml"), MANIFEST).unwrap();
fs::write(directory.path().join("plugin.component.wasm"), COMPONENT).unwrap();
let limits = PluginPackageLimits::default();
let package =
plugin::read_plugin_directory(directory.path(), PluginSourceKind::Explicit, &limits)
.unwrap();
let artifact = plugin::pack_plugin_package(&package, &limits).unwrap();
let m = package.package.manifest;
let schema_digest = format!("sha256:{}", hash(m.config_schema.as_bytes()));
let descriptor = PluginArtifact {
package_id: m.id.clone(),
name: m.name,
version: m.version.clone(),
description: m.description,
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
artifact_size: artifact.artifact_size.try_into().unwrap(),
schema_version: m.schema_version,
world: m.runtime.world,
required_imports: m.runtime.required_imports,
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
services: vec![],
connections: vec![],
requested_grants: vec![],
data_collections: vec![],
config_schema: server_api::PluginSchemaContribution {
identity: format!("{}/config", m.id),
version: m.version.clone(),
namespace: format!("{}/config/{}/{}", m.id, m.version, schema_digest),
digest: schema_digest,
dcdl: m.config_schema,
},
revoked: false,
revocation_reason: None,
};
(artifact, descriptor)
}
#[derive(Clone, Copy)]
enum Transfer {
Complete,
Short,
Error,
Pause,
Crash,
}
struct Source {
authority: String,
metadata: Mutex<PluginArtifactDeliveryMetadata>,
bytes: Vec<u8>,
fetches: AtomicUsize,
authorizations: AtomicUsize,
deny_on: AtomicUsize,
unavailable: AtomicUsize,
transfer: Transfer,
read_bytes: Arc<AtomicUsize>,
paused: Arc<Semaphore>,
}
impl Source {
fn new(artifact: &CanonicalPluginArtifact, descriptor: PluginArtifact) -> Self {
Self {
authority: "authenticated-server-a".into(),
metadata: Mutex::new(PluginArtifactDeliveryMetadata {
workspace_id: "workspace-a".into(),
request: PluginArtifactDeliveryRequest {
plan_id: "plan-a".into(),
plan_fingerprint: "exact-plan-fingerprint".into(),
installation_id: "installation-a".into(),
artifact_digest: artifact.artifact_digest.clone(),
},
artifact: descriptor,
}),
bytes: artifact.bytes.clone(),
fetches: AtomicUsize::new(0),
authorizations: AtomicUsize::new(0),
deny_on: AtomicUsize::new(0),
unavailable: AtomicUsize::new(0),
transfer: Transfer::Complete,
read_bytes: Arc::new(AtomicUsize::new(0)),
paused: Arc::new(Semaphore::new(0)),
}
}
fn request(&self) -> PluginArtifactDeliveryRequest {
self.metadata.lock().unwrap().request.clone()
}
fn expectation(&self) -> ArtifactExpectation {
ArtifactExpectation::new(self.metadata.lock().unwrap().artifact.clone())
}
async fn resolve(&self, cache: &ArtifactCache) -> Result<VerifiedArtifact, DeliveryError> {
cache
.resolve(self, "workspace-a", &self.request(), &self.expectation())
.await
}
}
#[async_trait]
impl ArtifactSource for Source {
fn server_authority_identity(&self) -> &str {
&self.authority
}
async fn authorize(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
let call = self.authorizations.fetch_add(1, Ordering::SeqCst) + 1;
if self.unavailable.load(Ordering::SeqCst) != 0 {
return Err(DeliveryError::Unavailable("offline fixture".into()));
}
if call == self.deny_on.load(Ordering::SeqCst) {
return Err(DeliveryError::Denied);
}
Ok(self.metadata.lock().unwrap().clone())
}
async fn fetch(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<ArtifactStream, DeliveryError> {
self.fetches.fetch_add(1, Ordering::SeqCst);
// A real bounded in-memory transport pipe fragments successful downloads.
// Fault streams replace only that transport, not the filesystem/cache.
if matches!(self.transfer, Transfer::Complete | Transfer::Short) {
let (reader, mut writer) = tokio::io::duplex(127);
let mut bytes = self.bytes.clone();
if matches!(self.transfer, Transfer::Short) {
bytes.truncate(bytes.len() / 2);
}
tokio::spawn(async move {
for chunk in bytes.chunks(61) {
if writer.write_all(chunk).await.is_err() {
break;
}
}
});
return Ok(Box::pin(CountingReader {
reader,
count: self.read_bytes.clone(),
}));
}
Ok(Box::pin(FaultReader {
bytes: self.bytes[..self.bytes.len() / 2].to_vec(),
position: 0,
transfer: self.transfer,
paused: self.paused.clone(),
}))
}
}
struct CountingReader<R> {
reader: R,
count: Arc<AtomicUsize>,
}
impl<R: AsyncRead + Unpin> AsyncRead for CountingReader<R> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buffer: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let before = buffer.filled().len();
let result = Pin::new(&mut self.reader).poll_read(cx, buffer);
self.count
.fetch_add(buffer.filled().len() - before, Ordering::SeqCst);
result
}
}
struct FaultReader {
bytes: Vec<u8>,
position: usize,
transfer: Transfer,
paused: Arc<Semaphore>,
}
impl AsyncRead for FaultReader {
fn poll_read(
mut self: Pin<&mut Self>,
_: &mut Context<'_>,
buffer: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
if self.position < self.bytes.len() {
let n = buffer.remaining().min(self.bytes.len() - self.position);
buffer.put_slice(&self.bytes[self.position..self.position + n]);
self.position += n;
return Poll::Ready(Ok(()));
}
match self.transfer {
Transfer::Error => Poll::Ready(Err(io::Error::new(
io::ErrorKind::ConnectionReset,
"interrupted fixture",
))),
Transfer::Pause => {
// Reaching this poll proves the cache consumed/wrote the prefix
// and is awaiting further input, before any publication.
self.paused.add_permits(1);
Poll::Pending
}
Transfer::Crash => std::process::exit(73),
_ => unreachable!(),
}
}
}
fn cache(directory: &tempfile::TempDir) -> ArtifactCache {
ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap()
}
fn final_files(cache: &ArtifactCache) -> Vec<PathBuf> {
fs::read_dir(&cache.root)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "artifact"))
.collect()
}
fn pending_files(cache: &ArtifactCache) -> Vec<PathBuf> {
fs::read_dir(&cache.root)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with(".pending-")
})
.collect()
}
#[tokio::test]
async fn streamed_download_and_restart_hit_return_only_verified_bytes() {
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
let verified = source.resolve(&cache).await.unwrap();
assert_eq!(verified.bytes(), artifact.bytes);
assert_eq!(fs::read(&final_files(&cache)[0]).unwrap(), artifact.bytes);
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
assert_eq!(
source.resolve(&restarted).await.unwrap().bytes(),
artifact.bytes
);
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
assert_eq!(source.authorizations.load(Ordering::SeqCst), 4);
assert!(pending_files(&cache).is_empty());
}
#[tokio::test]
async fn cached_bytes_do_not_bypass_owner_outage_revocation_or_denial() {
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
source.resolve(&cache).await.unwrap();
source.unavailable.store(1, Ordering::SeqCst);
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::Unavailable(_))
));
source.unavailable.store(0, Ordering::SeqCst);
source.deny_on.store(4, Ordering::SeqCst);
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::Denied)
));
source.metadata.lock().unwrap().artifact.revoked = true;
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::Denied)
));
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn final_authorization_denies_both_download_and_cache_hit() {
for hit in [false, true] {
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
if hit {
source.resolve(&cache).await.unwrap();
}
source
.deny_on
.store(if hit { 4 } else { 2 }, Ordering::SeqCst);
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::Denied)
));
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
// A verified blob can remain after denial, but is not usable authority.
assert_eq!(final_files(&cache).len(), 1);
}
}
#[tokio::test]
async fn response_must_bind_trusted_workspace_and_every_exact_request_field() {
for field in [
"workspace",
"plan",
"fingerprint",
"installation",
"artifact",
] {
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
let request = source.request();
let expected = source.expectation();
{
let mut metadata = source.metadata.lock().unwrap();
match field {
"workspace" => metadata.workspace_id = "another-workspace".into(),
"plan" => metadata.request.plan_id.push('x'),
"fingerprint" => metadata.request.plan_fingerprint.push('x'),
"installation" => metadata.request.installation_id.push('x'),
"artifact" => metadata.request.artifact_digest.push('x'),
_ => unreachable!(),
}
}
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
assert!(
matches!(
cache
.resolve(&source, "workspace-a", &request, &expected)
.await,
Err(DeliveryError::MetadataMismatch)
),
"{field}"
);
assert_eq!(source.fetches.load(Ordering::SeqCst), 0);
assert!(final_files(&cache).is_empty());
}
}
#[tokio::test]
async fn metadata_cannot_replace_the_host_plan_descriptor() {
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
let expected = source.expectation();
source.metadata.lock().unwrap().artifact.version = "owner-selected-version".into();
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
assert!(matches!(
cache
.resolve(&source, "workspace-a", &source.request(), &expected)
.await,
Err(DeliveryError::MetadataMismatch)
));
assert_eq!(source.fetches.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn canonical_package_must_match_complete_descriptor_not_only_digests() {
for field in [
"id",
"name",
"version",
"description",
"api",
"world",
"imports",
"tools",
"connections",
"grants",
"data_collections",
"schema_identity",
"schema_version",
"schema_digest",
"schema_namespace",
"schema_dcdl",
] {
let (artifact, mut d) = fixture();
match field {
"id" => d.package_id.push('x'),
"name" => d.name.push('x'),
"version" => d.version.push('x'),
"description" => d.description = Some("different".into()),
"api" => d.schema_version += 1,
"world" => d.world.push('x'),
"imports" => d.required_imports.push("yoi:host/logging@2.0.0".into()),
"tools" => d.tools[0].input_schema = serde_json::json!({"type":"string"}),
"connections" => d.connections.push(server_api::PluginConnectionMetadata {
name: "other".into(),
methods: vec![],
path_prefixes: vec![],
}),
"grants" => d
.requested_grants
.push(server_api::PluginGrantMetadata::Logging),
"data_collections" => d
.data_collections
.push(server_api::PluginDataSchemaMetadata {
collection: "notes".into(),
schema_id: "note".into(),
schema_version: "1".into(),
schema: serde_json::json!({"type":"string"}),
}),
"schema_identity" => d.config_schema.identity.push('x'),
"schema_version" => d.config_schema.version.push('x'),
"schema_digest" => d.config_schema.digest.push('x'),
"schema_namespace" => d.config_schema.namespace.push('x'),
"schema_dcdl" => d.config_schema.dcdl = "{ different: String }".into(),
_ => unreachable!(),
}
let source = Source::new(&artifact, d);
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
assert!(
matches!(
source.resolve(&cache).await,
Err(DeliveryError::MetadataMismatch)
),
"{field}"
);
assert!(final_files(&cache).is_empty(), "{field}");
assert!(pending_files(&cache).is_empty(), "{field}");
}
}
#[tokio::test]
async fn corrupted_or_wrong_size_cache_file_is_refetched_and_verified() {
for wrong_size in [false, true] {
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
source.resolve(&cache).await.unwrap();
let path = final_files(&cache).pop().unwrap();
let mut corrupt = artifact.bytes.clone();
if wrong_size {
corrupt.truncate(7);
} else {
corrupt[0] ^= 1;
}
fs::write(&path, corrupt).unwrap();
assert_eq!(
source.resolve(&cache).await.unwrap().bytes(),
artifact.bytes
);
assert_eq!(source.fetches.load(Ordering::SeqCst), 2);
assert_eq!(fs::read(path).unwrap(), artifact.bytes);
}
}
#[tokio::test]
async fn independent_package_and_artifact_digests_are_both_required() {
for package_digest in [false, true] {
let (artifact, mut descriptor) = fixture();
if package_digest {
descriptor.package_digest = format!("sha256:{}", "0".repeat(64));
}
let mut source = Source::new(&artifact, descriptor);
if !package_digest {
source.bytes[100] ^= 1;
}
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::InvalidArtifact(_))
));
assert!(final_files(&cache).is_empty());
}
}
#[tokio::test]
async fn declared_size_is_checked_before_fetch_and_stream_is_read_only_one_byte_past_bound() {
let (artifact, descriptor) = fixture();
let mut source = Source::new(&artifact, descriptor);
let directory = tempfile::tempdir().unwrap();
let mut limits = CacheLimits::default();
limits.package.max_package_size_bytes = artifact.artifact_size - 1;
let cache = ArtifactCache::new(directory.path().join("cache"), limits).unwrap();
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::TooLarge)
));
assert_eq!(source.fetches.load(Ordering::SeqCst), 0);
source.bytes.extend_from_slice(&vec![0; 100_000]);
let cache = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::TooLarge)
));
assert_eq!(
source.read_bytes.load(Ordering::SeqCst) as u64,
artifact.artifact_size + 1
);
assert!(final_files(&cache).is_empty());
assert!(pending_files(&cache).is_empty());
}
#[tokio::test]
async fn interrupted_and_failed_streams_never_publish_and_restart_can_retry() {
for transfer in [Transfer::Short, Transfer::Error] {
let (artifact, descriptor) = fixture();
let mut source = Source::new(&artifact, descriptor);
source.transfer = transfer;
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
let error = source.resolve(&cache).await.unwrap_err();
assert!(matches!(
error,
DeliveryError::InvalidArtifact(_) | DeliveryError::Io(_)
));
assert!(final_files(&cache).is_empty());
assert!(pending_files(&cache).is_empty());
source.transfer = Transfer::Complete;
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
assert_eq!(
source.resolve(&restarted).await.unwrap().bytes(),
artifact.bytes
);
}
}
#[tokio::test]
async fn cancellation_drops_staging_without_exposing_a_partial_final_file() {
let (artifact, descriptor) = fixture();
let mut source = Source::new(&artifact, descriptor);
source.transfer = Transfer::Pause;
let source = Arc::new(source);
let directory = tempfile::tempdir().unwrap();
let cache = Arc::new(cache(&directory));
let task = tokio::spawn({
let source = source.clone();
let cache = cache.clone();
async move { source.resolve(&cache).await }
});
tokio::time::timeout(Duration::from_secs(5), source.paused.acquire())
.await
.unwrap()
.unwrap()
.forget();
assert_eq!(pending_files(&cache).len(), 1);
assert!(final_files(&cache).is_empty());
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert!(pending_files(&cache).is_empty());
let retry = Source::new(&artifact, source.expectation().artifact);
assert_eq!(retry.resolve(&cache).await.unwrap().bytes(), artifact.bytes);
}
#[tokio::test]
async fn stalled_transfer_deadline_releases_lock_and_staging() {
let (artifact, descriptor) = fixture();
let mut source = Source::new(&artifact, descriptor);
source.transfer = Transfer::Pause;
let directory = tempfile::tempdir().unwrap();
let limits = CacheLimits {
delivery_timeout: Duration::from_millis(100),
..CacheLimits::default()
};
let cache = ArtifactCache::new(directory.path().join("cache"), limits).unwrap();
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::Timeout)
));
assert!(pending_files(&cache).is_empty());
assert!(final_files(&cache).is_empty());
source.transfer = Transfer::Complete;
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
source.resolve(&restarted).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_instances_converge_on_one_verified_publication() {
let (artifact, descriptor) = fixture();
let source = Arc::new(Source::new(&artifact, descriptor));
let directory = tempfile::tempdir().unwrap();
let first = cache(&directory);
let mut tasks = Vec::new();
for _ in 0..8 {
let cache = ArtifactCache::new(&first.root, CacheLimits::default()).unwrap();
let source = source.clone();
tasks.push(tokio::spawn(async move { source.resolve(&cache).await }));
}
for task in tasks {
assert_eq!(task.await.unwrap().unwrap().bytes(), artifact.bytes);
}
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
assert_eq!(source.authorizations.load(Ordering::SeqCst), 16);
assert_eq!(final_files(&first).len(), 1);
assert!(pending_files(&first).is_empty());
}
#[tokio::test]
async fn restart_discards_stale_partial_even_when_it_contains_a_complete_valid_artifact() {
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
fs::write(
cache.root.join(".pending-crashed-complete"),
&artifact.bytes,
)
.unwrap();
fs::write(
cache.root.join(".pending-crashed-prefix"),
&artifact.bytes[..99],
)
.unwrap();
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
assert_eq!(
source.resolve(&restarted).await.unwrap().bytes(),
artifact.bytes
);
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
assert!(pending_files(&cache).is_empty());
}
#[tokio::test]
async fn eviction_bounds_bytes_and_entry_count_independently_without_invalidating_returned_bytes() {
let (artifact, descriptor) = fixture();
for (max_cache_bytes, max_entries) in
[(artifact.artifact_size, 8), (artifact.artifact_size * 8, 1)]
{
let source = Source::new(&artifact, descriptor.clone());
let directory = tempfile::tempdir().unwrap();
let limits = CacheLimits {
max_cache_bytes,
max_entries,
..CacheLimits::default()
};
let cache = ArtifactCache::new(directory.path().join("cache"), limits).unwrap();
let held = source.resolve(&cache).await.unwrap();
let mut other = Source::new(&artifact, descriptor.clone());
other.authority = "server-b".into();
other.resolve(&cache).await.unwrap();
assert_eq!(final_files(&cache).len(), 1);
assert_eq!(
fs::metadata(&final_files(&cache)[0]).unwrap().len(),
artifact.artifact_size
);
assert_eq!(held.bytes(), artifact.bytes);
source.resolve(&cache).await.unwrap();
assert_eq!(source.fetches.load(Ordering::SeqCst), 2);
assert_eq!(final_files(&cache).len(), 1);
}
}
#[tokio::test]
async fn hashed_authority_separates_servers_while_fresh_workspace_and_worker_owners_are_required() {
let (artifact, descriptor) = fixture();
let mut source = Source::new(&artifact, descriptor.clone());
source.authority = "../../outside/opaque:server".into();
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
source.resolve(&cache).await.unwrap();
let mut other = Source::new(&artifact, descriptor.clone());
other.authority = "server-b".into();
other.resolve(&cache).await.unwrap();
assert_eq!(final_files(&cache).len(), 2);
for path in final_files(&cache) {
assert_eq!(path.parent().unwrap(), cache.root);
assert!(is_cache_filename(
path.file_name().unwrap().to_str().unwrap()
));
assert!(
!path
.file_name()
.unwrap()
.to_string_lossy()
.contains("outside")
);
}
let mut workspace_b = Source::new(&artifact, descriptor.clone());
workspace_b.authority = source.authority.clone();
workspace_b.metadata.lock().unwrap().workspace_id = "workspace-b".into();
let request = workspace_b.request();
let expected = workspace_b.expectation();
assert_eq!(
cache
.resolve(&workspace_b, "workspace-b", &request, &expected)
.await
.unwrap()
.bytes(),
artifact.bytes
);
assert_eq!(workspace_b.fetches.load(Ordering::SeqCst), 0);
assert_eq!(workspace_b.authorizations.load(Ordering::SeqCst), 2);
let mut different_worker_owner = Source::new(&artifact, descriptor);
different_worker_owner.authority = source.authority;
different_worker_owner.deny_on.store(1, Ordering::SeqCst);
assert!(matches!(
different_worker_owner.resolve(&cache).await,
Err(DeliveryError::Denied)
));
}
#[cfg(unix)]
#[tokio::test]
async fn private_root_and_non_symlink_cache_entries_are_enforced() {
use std::os::unix::fs::{PermissionsExt, symlink};
let directory = tempfile::tempdir().unwrap();
let public = directory.path().join("public");
fs::create_dir(&public).unwrap();
fs::set_permissions(&public, fs::Permissions::from_mode(0o755)).unwrap();
assert!(matches!(
ArtifactCache::new(&public, CacheLimits::default()),
Err(DeliveryError::InvalidCacheRoot)
));
let link = directory.path().join("link");
symlink(&public, &link).unwrap();
assert!(matches!(
ArtifactCache::new(&link, CacheLimits::default()),
Err(DeliveryError::InvalidCacheRoot)
));
let cache = cache(&directory);
assert_eq!(
fs::metadata(&cache.root).unwrap().permissions().mode() & 0o777,
0o700
);
let sentinel = directory.path().join("sentinel");
fs::write(&sentinel, b"do not change").unwrap();
symlink(&sentinel, cache.root.join(".pending-symlink")).unwrap();
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
assert!(matches!(
source.resolve(&cache).await,
Err(DeliveryError::InvalidCacheRoot)
));
assert_eq!(fs::read(sentinel).unwrap(), b"do not change");
}
// Deliberately corrupt the shared package's API without breaking ZIP integrity,
// so rejection must come from the common manifest verifier, not a bad checksum.
fn unsupported_api_bytes(bytes: &mut [u8]) {
let header = bytes
.windows(4)
.position(|window| window == b"PK\x03\x04")
.unwrap();
let mut offset = header;
let mut crc = None;
while &bytes[offset..offset + 4] == b"PK\x03\x04" {
let size = u32::from_le_bytes(bytes[offset + 18..offset + 22].try_into().unwrap()) as usize;
let name_size =
u16::from_le_bytes(bytes[offset + 26..offset + 28].try_into().unwrap()) as usize;
let extra =
u16::from_le_bytes(bytes[offset + 28..offset + 30].try_into().unwrap()) as usize;
let start = offset + 30 + name_size + extra;
if &bytes[offset + 30..offset + 30 + name_size] == b"plugin.toml" {
let payload = &mut bytes[start..start + size];
let index = payload
.windows(b"schema_version = 2".len())
.position(|window| window == b"schema_version = 2")
.unwrap();
payload[index + b"schema_version = ".len()] = b'9';
let mut checksum = !0_u32;
for byte in payload {
checksum ^= u32::from(*byte);
for _ in 0..8 {
checksum = (checksum >> 1) ^ (0xedb88320 & 0_u32.wrapping_sub(checksum & 1));
}
}
let checksum = (!checksum).to_le_bytes();
bytes[offset + 14..offset + 18].copy_from_slice(&checksum);
crc = Some(checksum);
}
offset = start + size;
}
while &bytes[offset..offset + 4] == b"PK\x01\x02" {
let name_size =
u16::from_le_bytes(bytes[offset + 28..offset + 30].try_into().unwrap()) as usize;
let extra =
u16::from_le_bytes(bytes[offset + 30..offset + 32].try_into().unwrap()) as usize;
let comment =
u16::from_le_bytes(bytes[offset + 32..offset + 34].try_into().unwrap()) as usize;
if &bytes[offset + 46..offset + 46 + name_size] == b"plugin.toml" {
bytes[offset + 16..offset + 20].copy_from_slice(&crc.unwrap());
}
offset += 46 + name_size + extra + comment;
}
}
#[tokio::test]
async fn unsupported_api_is_rejected_by_common_verifier_before_publication() {
let (mut artifact, mut descriptor) = fixture();
unsupported_api_bytes(&mut artifact.bytes);
artifact.artifact_digest = format!("sha256:{}", hash(&artifact.bytes));
descriptor.artifact_digest = artifact.artifact_digest.clone();
descriptor.schema_version = 9;
let source = Source::new(&artifact, descriptor);
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
let error = source.resolve(&cache).await.unwrap_err();
assert!(
matches!(error, DeliveryError::InvalidArtifact(ref message) if message.contains("unsupported schema")),
"{error:?}"
);
assert!(final_files(&cache).is_empty());
}
struct Actor {
child: Child,
lines: std::sync::mpsc::Receiver<Option<String>>,
stdout_thread: Option<std::thread::JoinHandle<()>>,
stderr_thread: Option<std::thread::JoinHandle<String>>,
text: String,
}
impl Drop for Actor {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
if let Some(thread) = self.stdout_thread.take() {
let _ = thread.join();
}
if let Some(thread) = self.stderr_thread.take() {
let _ = thread.join();
}
}
}
impl Actor {
fn spawn(root: &Path, action: &str) -> Self {
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"delivery::tests::process_cache_actor",
"--ignored",
"--nocapture",
"--test-threads=1",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
serde_json::to_writer(child.stdin.take().unwrap(), &(root, action)).unwrap();
let stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let (sender, lines) = std::sync::mpsc::channel();
let stdout_thread = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
if sender.send(Some(line.unwrap())).is_err() {
return;
}
}
let _ = sender.send(None);
});
let stderr_thread = std::thread::spawn(move || {
let mut text = String::new();
stderr.read_to_string(&mut text).unwrap();
text
});
Self {
child,
lines,
stdout_thread: Some(stdout_thread),
stderr_thread: Some(stderr_thread),
text: String::new(),
}
}
fn line(&mut self) -> Option<String> {
let line = self
.lines
.recv_timeout(Duration::from_secs(10))
.unwrap_or_else(|error| panic!("child output deadline: {error}: {}", self.text));
if let Some(line) = &line {
self.text.push_str(line);
self.text.push('\n');
}
line
}
fn ready(&mut self) {
loop {
let line = self.line().expect("child exited before readiness");
if line.contains("cache-actor-ready") {
break;
}
}
}
fn finish(&mut self) -> (std::process::ExitStatus, String) {
while self.line().is_some() {}
let status = self.child.wait().unwrap();
self.text
.push_str(&self.stderr_thread.take().unwrap().join().unwrap());
(status, std::mem::take(&mut self.text))
}
}
#[test]
#[ignore = "subprocess actor invoked by ordinary cache integration tests"]
fn process_cache_actor() {
let (root, action): (PathBuf, String) = serde_json::from_reader(io::stdin()).unwrap();
let (artifact, descriptor) = fixture();
let mut source = Source::new(&artifact, descriptor);
if action == "crash" {
source.transfer = Transfer::Crash;
}
let cache = ArtifactCache::new(root, CacheLimits::default()).unwrap();
if action == "resolve" {
let probe = OpenOptions::new()
.read(true)
.write(true)
.open(cache.root.join(".cache.lock"))
.unwrap();
assert!(!fs4::fs_std::FileExt::try_lock_exclusive(&probe).unwrap());
}
println!("cache-actor-ready");
io::stdout().flush().unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let verified = runtime.block_on(source.resolve(&cache)).unwrap();
assert_eq!(verified.bytes(), artifact.bytes);
println!(
"cache-actor-fetches={}",
source.fetches.load(Ordering::SeqCst)
);
}
#[tokio::test]
async fn independent_processes_converge_after_contending_for_same_cache_lock() {
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
let lock = lock_cache(cache.root.join(".cache.lock")).await.unwrap();
let mut first = Actor::spawn(&cache.root, "resolve");
let mut second = Actor::spawn(&cache.root, "resolve");
first.ready();
second.ready();
drop(lock);
let (a, a_text) = first.finish();
let (b, b_text) = second.finish();
assert!(a.success(), "{a_text}");
assert!(b.success(), "{b_text}");
assert_eq!(
format!("{a_text}{b_text}")
.matches("cache-actor-fetches=1")
.count(),
1
);
assert_eq!(
format!("{a_text}{b_text}")
.matches("cache-actor-fetches=0")
.count(),
1
);
assert_eq!(final_files(&cache).len(), 1);
assert!(pending_files(&cache).is_empty());
}
#[tokio::test]
async fn process_exit_before_publish_leaves_no_final_bytes_and_restart_discards_partial() {
let directory = tempfile::tempdir().unwrap();
let cache = cache(&directory);
let mut actor = Actor::spawn(&cache.root, "crash");
actor.ready();
let (status, text) = actor.finish();
assert_eq!(status.code(), Some(73), "{text}");
assert!(final_files(&cache).is_empty());
assert_eq!(pending_files(&cache).len(), 1);
let (artifact, descriptor) = fixture();
let source = Source::new(&artifact, descriptor);
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
assert_eq!(
source.resolve(&restarted).await.unwrap().bytes(),
artifact.bytes
);
assert!(pending_files(&cache).is_empty());
assert_eq!(final_files(&cache).len(), 1);
}
+745
View File
@@ -0,0 +1,745 @@
mod service;
pub use service::{CompiledService, ServiceInvocationResult, ServiceOutput};
mod ticket;
use crate::sandbox::SandboxLimits;
use crate::{
bindings,
host::{self, Connection, Host, InvocationContext, Operation, errors, http, logging},
};
use manifest::plugin::{self, PluginPackageLimits, PluginPackageManifest, PluginToolManifest};
use serde::Serialize;
use std::{sync::Arc, time::Duration};
use tokio::time::Instant;
use wasmtime::component::{Component, Linker, Resource, ResourceTable};
use wasmtime::{Config, Engine, Store};
#[derive(Clone, Debug)]
pub struct Limits {
pub payload_bytes: usize,
pub config_bytes: usize,
pub memory_bytes: usize,
pub table_elements: usize,
pub instances: usize,
pub memories: usize,
pub tables: usize,
pub resources: usize,
pub fuel: u64,
pub wall_time: Duration,
pub host_operations: usize,
pub http_body_bytes: usize,
pub http_header_bytes: usize,
pub http_header_pairs: usize,
pub log_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
payload_bytes: 64 * 1024,
config_bytes: 64 * 1024,
memory_bytes: 256 * 1024 * 1024,
table_elements: 10_000,
instances: 32,
memories: 4,
tables: 8,
resources: 32,
fuel: 10_000_000,
wall_time: Duration::from_secs(30),
host_operations: 32,
http_body_bytes: 1024 * 1024,
http_header_bytes: 16 * 1024,
http_header_pairs: 64,
log_bytes: 16 * 1024,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ExecutionError {
#[error("invalid artifact: {0}")]
Artifact(String),
#[error("unsupported Component execution policy: {0}")]
UnsupportedComponent(String),
#[error("invalid invocation: {0}")]
InvalidInput(String),
#[error("invalid output: {0}")]
InvalidOutput(String),
#[error("invocation cancelled")]
Cancelled,
#[error("invocation deadline exceeded")]
Deadline,
#[error("Host denied or failed operation: {0:?}")]
Host(errors::HostError),
#[error("guest error {code}: {message}")]
Guest { code: String, message: String },
#[error("Component trap or resource limit: {0}")]
Trap(String),
}
/// Content identity only; both digests are independently verified by the package owner.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactIdentity {
pub package_digest: String,
pub artifact_digest: String,
pub artifact_size: u64,
}
/// Immutable compiled bytes and declarations, not execution authority.
/// Private fields prevent mutable package metadata from bypassing verification.
pub struct CompiledTool {
component: Component,
manifest: PluginPackageManifest,
identity: ArtifactIdentity,
}
impl CompiledTool {
pub fn identity(&self) -> &ArtifactIdentity {
&self.identity
}
/// Declarations from the verified immutable package, not execution grants.
pub fn tools(&self) -> &[PluginToolManifest] {
&self.manifest.tools
}
}
#[derive(Debug)]
pub struct InvocationResult {
pub output: bindings::exports::yoi::plugin::tool::Output,
pub dropped_logs: u64,
}
pub struct Executor {
engine: Engine,
limits: Limits,
}
impl Executor {
/// Limits are fixed by the Host, never selected by guest input.
pub fn new(limits: Limits) -> wasmtime::Result<Self> {
let ceiling = Limits::default();
macro_rules! check_ceiling {
($($field:ident),+ $(,)?) => { $(
if limits.$field > ceiling.$field {
return Err(wasmtime::Error::msg(concat!("limit exceeds supported ceiling: ", stringify!($field))));
}
)+ };
}
check_ceiling!(
payload_bytes,
config_bytes,
memory_bytes,
table_elements,
instances,
memories,
tables,
resources,
fuel,
wall_time,
host_operations,
http_body_bytes,
http_header_bytes,
http_header_pairs,
log_bytes
);
let mut config = Config::new();
config
.async_support(true)
.wasm_component_model(true)
.consume_fuel(true)
.epoch_interruption(true)
.wasm_threads(false)
.wasm_shared_everything_threads(false);
Ok(Self {
engine: Engine::new(&config)?,
limits,
})
}
pub fn limits(&self) -> &Limits {
&self.limits
}
/// Reuses the canonical package/digest/ABI validator; no path discovery or
/// duplicate pack/hash implementation is involved. Compilation executes no guest.
pub fn compile(
&self,
artifact: &[u8],
package_digest: &str,
artifact_digest: &str,
artifact_size: u64,
) -> Result<CompiledTool, ExecutionError> {
let verified = plugin::verify_plugin_artifact(
artifact,
package_digest,
artifact_digest,
artifact_size,
&PluginPackageLimits::default(),
)
.map_err(|e| ExecutionError::Artifact(e.message))?;
let manifest = verified.package.manifest;
let component_bytes = &verified.files[&manifest.runtime.component];
crate::sandbox::check_component_resources(component_bytes)
.map_err(|e| ExecutionError::UnsupportedComponent(e.to_string()))?;
let component = Component::new(&self.engine, component_bytes)
.map_err(|e| ExecutionError::Artifact(e.to_string()))?;
Ok(CompiledTool {
component,
manifest,
identity: ArtifactIdentity {
package_digest: package_digest.into(),
artifact_digest: artifact_digest.into(),
artifact_size,
},
})
}
/// All computation and Host waits share the earlier of the caller deadline
/// and local ceiling. Dropping this future also drops its Store and resources.
pub async fn invoke(
&self,
compiled: &CompiledTool,
mut context: InvocationContext,
provider: Arc<dyn Host>,
tool_name: &str,
input_json: &str,
config_json: &str,
) -> Result<InvocationResult, ExecutionError> {
context.deadline = context.deadline.min(Instant::now() + self.limits.wall_time);
context.cancellation = context.cancellation.child_token();
let _lifetime = InvocationLifetime(context.cancellation.clone());
let deadline = context.deadline;
let cancellation = context.cancellation.clone();
let execution = self.run(
compiled,
context,
provider,
tool_name,
input_json,
config_json,
);
tokio::pin!(execution);
let mut tick = tokio::time::interval(Duration::from_millis(10));
loop {
tokio::select! {
biased;
_ = cancellation.cancelled() => return Err(ExecutionError::Cancelled),
_ = tokio::time::sleep_until(deadline) => return Err(ExecutionError::Deadline),
result = &mut execution => {
if cancellation.is_cancelled() { return Err(ExecutionError::Cancelled); }
if Instant::now() >= deadline { return Err(ExecutionError::Deadline); }
return result;
},
_ = tick.tick() => self.engine.increment_epoch(),
}
}
}
async fn run(
&self,
compiled: &CompiledTool,
context: InvocationContext,
provider: Arc<dyn Host>,
tool_name: &str,
input_json: &str,
config_json: &str,
) -> Result<InvocationResult, ExecutionError> {
if !Engine::same(&self.engine, compiled.component.engine()) {
return Err(ExecutionError::Artifact(
"Component belongs to another executor".into(),
));
}
if context.artifact != compiled.identity || context.tool_name != tool_name {
return Err(ExecutionError::InvalidInput(
"artifact/Tool does not match Host-bound context".into(),
));
}
let tool = compiled
.manifest
.tools
.iter()
.find(|t| t.name == tool_name)
.ok_or_else(|| ExecutionError::InvalidInput("undeclared Tool".into()))?;
if input_json.len() > self.limits.payload_bytes
|| config_json.len() > self.limits.config_bytes
|| context.invocation_id.len() > 1024
{
return Err(ExecutionError::InvalidInput(
"input/config/identity exceeds limit".into(),
));
}
validate_json(&tool.input_schema, input_json).map_err(ExecutionError::InvalidInput)?;
serde_json::from_str::<serde_json::Value>(config_json)
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?;
provider
.authorize(&context, Operation::Admit)
.await
.map_err(ExecutionError::Host)?;
check_current(&context).map_err(ExecutionError::Host)?;
let limits = &self.limits;
let state = State {
context: None,
provider: provider.clone(),
limits: limits.clone(),
manifest: compiled.manifest.clone(),
table: ResourceTable::new(),
operations: 0,
resources: 0,
log_bytes: 0,
dropped_logs: 0,
store_limits: SandboxLimits::new(limits),
logging_only: false,
effects: Arc::new(service::Effects::default()),
};
let mut store = Store::new(&self.engine, state);
store.limiter(|state| &mut state.store_limits);
store.set_fuel(limits.fuel).map_err(trap)?;
store
.fuel_async_yield_interval(Some(10_000))
.map_err(trap)?;
store.set_epoch_deadline(1);
store.epoch_deadline_async_yield_and_update(1);
let mut linker = Linker::new(&self.engine);
bindings::HttpsLoggedToolPlugin::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
&mut linker,
|s| s,
)
.map_err(trap)?;
if compiled.manifest.runtime.world == plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD {
use crate::host::{extension_data, objects, ticket_read};
objects::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
.map_err(trap)?;
ticket_read::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
.map_err(trap)?;
extension_data::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
.map_err(trap)?;
}
let instance = bindings::HttpsLoggedToolPlugin::instantiate_async(
&mut store,
&compiled.component,
&linker,
)
.await
.map_err(trap)?;
// No import during instantiate/start may act, including logging. Service
// on-start is a different world and has no adapter here.
check_current(&context).map_err(ExecutionError::Host)?;
store.data_mut().context = Some(context.clone());
let invocation = bindings::exports::yoi::plugin::tool::Invocation {
invocation_id: context.invocation_id.clone(),
tool_name: tool_name.into(),
input_json: input_json.into(),
config_json: config_json.into(),
remaining_ms: context
.deadline
.saturating_duration_since(Instant::now())
.as_millis()
.min(u32::MAX as u128) as u32,
};
let result = instance
.yoi_plugin_tool()
.call_invoke(&mut store, &invocation)
.await
.map_err(trap)?;
check_current(&context).map_err(ExecutionError::Host)?;
provider
.authorize(&context, Operation::Publish)
.await
.map_err(ExecutionError::Host)?;
check_current(&context).map_err(ExecutionError::Host)?;
let output = match result {
Ok(output) => {
validate_output(tool, &output, limits.payload_bytes)?;
output
}
Err(error) => {
if error.message.len().saturating_add(error.code.len()) > limits.payload_bytes {
return Err(ExecutionError::InvalidOutput(
"guest error exceeds limit".into(),
));
}
let code = if tool.error_codes.contains(&error.code) {
error.code
} else {
"plugin-failed".into()
};
#[derive(Serialize)]
struct Failure<'a> {
code: &'a str,
message: &'a str,
}
if serde_json::to_vec(&Failure {
code: &code,
message: &error.message,
})
.unwrap()
.len()
> limits.payload_bytes
{
return Err(ExecutionError::InvalidOutput(
"serialized guest error exceeds limit".into(),
));
}
return Err(ExecutionError::Guest {
code,
message: error.message,
});
}
};
Ok(InvocationResult {
output,
dropped_logs: store.data().dropped_logs,
})
}
}
fn trap(e: wasmtime::Error) -> ExecutionError {
ExecutionError::Trap(format!("{e:#}"))
}
fn validate_json(schema: &serde_json::Value, json: &str) -> Result<(), String> {
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
// Schema admission already forbids external refs/dialects in manifest::plugin.
let validator = jsonschema::options()
.with_draft(jsonschema::Draft::Draft202012)
.should_validate_formats(true)
.build(schema)
.map_err(|e| e.to_string())?;
validator.validate(&value).map_err(|e| e.to_string())
}
fn validate_output(
tool: &PluginToolManifest,
output: &bindings::exports::yoi::plugin::tool::Output,
max: usize,
) -> Result<(), ExecutionError> {
let raw_bytes = output
.summary
.len()
.saturating_add(output.content.as_ref().map_or(0, String::len))
.saturating_add(output.structured_json.as_ref().map_or(0, String::len));
if raw_bytes > max {
return Err(ExecutionError::InvalidOutput("output exceeds limit".into()));
}
// Use the complete serialized envelope budget, not separate per-field limits.
#[derive(Serialize)]
struct Envelope<'a> {
summary: &'a str,
content: &'a Option<String>,
structured_json: &'a Option<String>,
}
let bytes = serde_json::to_vec(&Envelope {
summary: &output.summary,
content: &output.content,
structured_json: &output.structured_json,
})
.unwrap();
if bytes.len() > max {
return Err(ExecutionError::InvalidOutput("output exceeds limit".into()));
}
let json = output
.structured_json
.as_deref()
.ok_or_else(|| ExecutionError::InvalidOutput("missing structured output".into()))?;
validate_json(&tool.output_schema, json).map_err(ExecutionError::InvalidOutput)
}
struct InvocationLifetime(host::CancellationToken);
impl Drop for InvocationLifetime {
fn drop(&mut self) {
self.0.cancel();
}
}
struct State {
context: Option<InvocationContext>,
provider: Arc<dyn Host>,
limits: Limits,
manifest: PluginPackageManifest,
table: ResourceTable,
operations: usize,
resources: usize,
log_bytes: usize,
dropped_logs: u64,
store_limits: SandboxLimits,
logging_only: bool,
effects: Arc<service::Effects>,
}
fn check_current(context: &InvocationContext) -> Result<(), errors::HostError> {
if context.cancellation.is_cancelled() {
return Err(host::error(errors::Code::Cancelled, "invocation cancelled"));
}
if Instant::now() >= context.deadline {
return Err(host::error(
errors::Code::DeadlineExceeded,
"invocation deadline exceeded",
));
}
Ok(())
}
impl State {
fn current(&self) -> wasmtime::Result<InvocationContext> {
let context = self
.context
.as_ref()
.ok_or_else(|| wasmtime::Error::msg("Host import outside export context"))?;
check_current(context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
Ok(context.clone())
}
fn protected_current(&self) -> wasmtime::Result<InvocationContext> {
let context = self.current()?;
if self.logging_only {
return Err(wasmtime::Error::msg(
"Service lifecycle permits logging only",
));
}
Ok(context)
}
fn operation(&mut self) -> Result<(), errors::HostError> {
if self.operations >= self.limits.host_operations {
return Err(host::error(
errors::Code::QuotaExceeded,
"Host operation limit",
));
}
self.operations += 1;
Ok(())
}
}
impl bindings::yoi::host::errors::Host for State {}
impl bindings::yoi::host::connections::HostConnection for State {
async fn drop(&mut self, resource: Resource<Connection>) -> wasmtime::Result<()> {
self.table.delete(resource)?;
self.resources -= 1;
Ok(())
}
}
impl bindings::yoi::host::connections::Host for State {
async fn open(
&mut self,
slot: String,
) -> wasmtime::Result<Result<Resource<Connection>, errors::HostError>> {
let context = self.protected_current()?;
if let Err(e) = self.operation() {
return Ok(Err(e));
}
let declared = self.manifest.connections.iter().any(|s| s.name == slot);
if !declared || !context.grants.connection_slots.contains(&slot) {
return Ok(Err(host::error(
errors::Code::Denied,
"connection slot not granted",
)));
}
if self.resources >= self.limits.resources {
return Ok(Err(host::error(
errors::Code::QuotaExceeded,
"resource limit",
)));
}
if let Err(e) = self
.provider
.authorize(&context, Operation::Open { slot: &slot })
.await
{
return Ok(Err(e));
}
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
let binding = match self.provider.open(&context, &slot).await {
Ok(v) => v,
Err(e) => return Ok(Err(e)),
};
check_current(&context).map_err(|_| wasmtime::Error::msg("late connection result"))?;
let resource = self.table.push(Connection { slot, binding })?;
self.resources += 1;
Ok(Ok(resource))
}
}
impl bindings::yoi::host::http::Host for State {
async fn send(
&mut self,
target: Resource<Connection>,
mut input: http::Request,
) -> wasmtime::Result<Result<http::Response, errors::HostError>> {
let context = self.protected_current()?;
if let Err(e) = self.operation() {
return Ok(Err(e));
}
let connection = self.table.get(&target)?;
let slot = connection.slot.clone();
let binding = connection.binding.clone();
if !context.grants.connection_slots.contains(&slot) {
return Ok(Err(host::error(
errors::Code::Denied,
"connection slot not granted",
)));
}
let declaration = self
.manifest
.connections
.iter()
.find(|s| s.name == slot)
.unwrap();
let method = match input.method {
http::Method::Get => plugin::PluginHttpMethod::Get,
http::Method::Head => plugin::PluginHttpMethod::Head,
http::Method::Post => plugin::PluginHttpMethod::Post,
http::Method::Put => plugin::PluginHttpMethod::Put,
http::Method::Patch => plugin::PluginHttpMethod::Patch,
http::Method::Delete => plugin::PluginHttpMethod::Delete,
http::Method::Options => plugin::PluginHttpMethod::Options,
};
// Canonical URL/DNS/TLS/secret and narrower binding checks belong to the
// provider. These static ceilings are not a network protection claim.
if !declaration.methods.contains(&method)
|| !declaration.path_prefixes.iter().any(|p| {
input.path == *p
|| input
.path
.strip_prefix(p)
.is_some_and(|rest| p.ends_with('/') || rest.starts_with('/'))
})
{
return Ok(Err(host::error(
errors::Code::Denied,
"request exceeds package ceiling",
)));
}
if !request_bounded(&input, &self.limits) {
return Ok(Err(host::error(
errors::Code::QuotaExceeded,
"request exceeds limit",
)));
}
input.timeout_ms = input.timeout_ms.min(15_000).min(
context
.deadline
.saturating_duration_since(Instant::now())
.as_millis()
.min(u32::MAX as u128) as u32,
);
let send_deadline = context
.deadline
.min(Instant::now() + Duration::from_millis(input.timeout_ms as u64));
match tokio::time::timeout_at(
send_deadline,
self.provider.authorize(
&context,
Operation::Send {
slot: &slot,
binding: binding.as_ref(),
request: &input,
},
),
)
.await
{
Ok(Ok(())) => (),
Ok(Err(error)) => return Ok(Err(error)),
Err(_) => {
return Ok(Err(host::error(
errors::Code::DeadlineExceeded,
"HTTP authorization deadline exceeded",
)));
}
}
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
if Instant::now() >= send_deadline {
return Ok(Err(host::error(
errors::Code::DeadlineExceeded,
"HTTP deadline exceeded before dispatch",
)));
}
let effect = self.effects.begin();
let response = match tokio::time::timeout_at(
send_deadline,
self.provider.send(&context, binding.as_ref(), input),
)
.await
{
Ok(response) => response,
Err(_) => Err(errors::HostError {
code: errors::Code::DeadlineExceeded,
message: "HTTP wait deadline exceeded".into(),
diagnostic_id: String::new(),
effect: errors::EffectObservation::Indeterminate,
}),
};
effect.finish(&response);
check_current(&context).map_err(|_| wasmtime::Error::msg("late HTTP result"))?;
Ok(response.and_then(|response| {
if response.body.len() > self.limits.http_body_bytes
|| !pairs_bounded(&response.headers, &self.limits)
{
Err(errors::HostError {
code: errors::Code::QuotaExceeded,
message: "response exceeds limit".into(),
diagnostic_id: String::new(),
effect: errors::EffectObservation::ResponseObserved,
})
} else {
Ok(response)
}
}))
}
}
fn pairs_bounded(pairs: &[http::Pair], limits: &Limits) -> bool {
pairs.len() <= limits.http_header_pairs
&& pairs
.iter()
.try_fold(0usize, |n, p| {
n.checked_add(p.name.len())?.checked_add(p.value.len())
})
.is_some_and(|n| n <= limits.http_header_bytes)
}
fn request_bounded(request: &http::Request, limits: &Limits) -> bool {
request.body.len() <= limits.http_body_bytes
&& request.path.len() <= limits.payload_bytes
&& request.effect_key.len() <= 128
&& !request.effect_key.is_empty()
&& pairs_bounded(&request.headers, limits)
&& pairs_bounded(&request.query, limits)
}
impl bindings::yoi::host::logging::Host for State {
async fn log(
&mut self,
input: logging::Entry,
) -> wasmtime::Result<Result<(), errors::HostError>> {
let context = self.current()?;
if !context.grants.logging
|| !self
.manifest
.requested_grants
.contains(&plugin::PluginRequestedGrant::Logging)
{
return Ok(Err(host::error(
errors::Code::Denied,
"logging not granted",
)));
}
let size = input
.event_code
.len()
.saturating_add(input.message.len())
.saturating_add(
input
.fields
.iter()
.map(|f| {
f.name.len().saturating_add(match &f.value {
logging::Scalar::Text(s) => s.len(),
_ => 8,
})
})
.sum::<usize>(),
);
if size > self.limits.log_bytes.saturating_sub(self.log_bytes) {
self.dropped_logs = self.dropped_logs.saturating_add(1);
return Ok(Ok(()));
}
if let Err(e) = self.operation() {
return Ok(Err(e));
}
if let Err(e) = self.provider.authorize(&context, Operation::Log).await {
return Ok(Err(e));
}
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
self.log_bytes += size;
let result = self.provider.log(&context, input).await;
check_current(&context).map_err(|_| wasmtime::Error::msg("late log result"))?;
Ok(result)
}
}
@@ -0,0 +1,437 @@
use super::*;
use crate::service_bindings::{self, exports::yoi::plugin::service as abi};
use manifest::plugin::{PluginServiceManifest, PluginTicketEventType};
use server_api::{PluginServiceEventResult, PluginTicketEventEnvelope};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// Shares compiled bytes and the existing Host/resource implementation, not authority.
pub struct CompiledService(CompiledTool);
impl CompiledService {
pub fn identity(&self) -> &ArtifactIdentity {
self.0.identity()
}
pub fn services(&self) -> &[PluginServiceManifest] {
&self.0.manifest.services
}
}
/// Guest acknowledgement is only a proposal. The driver must persist the cursor
/// through its owner and pause on an indeterminate Host effect, even on guest ack.
#[derive(Debug)]
pub struct ServiceInvocationResult {
pub outcome: Result<ServiceOutput, ExecutionError>,
pub effect: errors::EffectObservation,
}
#[derive(Debug)]
pub enum ServiceOutput {
Started,
Event(PluginServiceEventResult),
Stopped,
}
enum Call<'a> {
Start(&'a str),
Event {
envelope: &'a PluginTicketEventEnvelope,
config: &'a str,
},
Stop(&'a str),
}
/// Kept outside the Store so cancellation, timeout and traps cannot erase a
/// dispatched-but-unobserved effect. This is observation, not a receipt ledger.
#[derive(Default)]
pub(super) struct Effects {
pending: AtomicUsize,
unknown: AtomicBool,
response: AtomicBool,
}
impl Effects {
pub(super) fn begin(self: &Arc<Self>) -> EffectWait {
self.pending.fetch_add(1, Ordering::SeqCst);
EffectWait {
effects: self.clone(),
completed: false,
}
}
pub(super) fn unresolved(&self) {
self.unknown.store(true, Ordering::SeqCst);
}
pub(super) fn observe(&self, error: &errors::HostError) {
if matches!(error.effect, errors::EffectObservation::Indeterminate)
|| matches!(
error.code,
errors::Code::Indeterminate | errors::Code::ReceiptExpired
)
{
self.unknown.store(true, Ordering::SeqCst);
}
if matches!(error.effect, errors::EffectObservation::ResponseObserved) {
self.response.store(true, Ordering::SeqCst);
}
}
fn snapshot(&self) -> errors::EffectObservation {
if self.unknown.load(Ordering::SeqCst) || self.pending.load(Ordering::SeqCst) != 0 {
errors::EffectObservation::Indeterminate
} else if self.response.load(Ordering::SeqCst) {
errors::EffectObservation::ResponseObserved
} else {
errors::EffectObservation::NotDispatched
}
}
}
pub(super) struct EffectWait {
effects: Arc<Effects>,
completed: bool,
}
impl EffectWait {
pub(super) fn finish<T>(mut self, result: &Result<T, errors::HostError>) {
match result {
Ok(_) => {
self.effects.response.store(true, Ordering::SeqCst);
}
Err(e) => self.effects.observe(e),
}
self.completed = true;
}
}
impl Drop for EffectWait {
fn drop(&mut self) {
if !self.completed {
self.effects.unknown.store(true, Ordering::SeqCst);
}
self.effects.pending.fetch_sub(1, Ordering::SeqCst);
}
}
impl Executor {
pub fn compile_service(
&self,
artifact: &[u8],
package_digest: &str,
artifact_digest: &str,
artifact_size: u64,
) -> Result<CompiledService, ExecutionError> {
let compiled = self.compile(artifact, package_digest, artifact_digest, artifact_size)?;
if compiled.manifest.runtime.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD {
return Err(ExecutionError::Artifact(
"not the exact Service world".into(),
));
}
Ok(CompiledService(compiled))
}
pub async fn service_start(
&self,
compiled: &CompiledService,
context: InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
config_json: &str,
) -> ServiceInvocationResult {
self.service_call(
compiled,
context,
provider,
service_name,
Call::Start(config_json),
)
.await
}
pub async fn service_event(
&self,
compiled: &CompiledService,
context: InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
envelope: &PluginTicketEventEnvelope,
config_json: &str,
) -> ServiceInvocationResult {
self.service_call(
compiled,
context,
provider,
service_name,
Call::Event {
envelope,
config: config_json,
},
)
.await
}
pub async fn service_stop(
&self,
compiled: &CompiledService,
context: InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
reason: &str,
) -> ServiceInvocationResult {
self.service_call(
compiled,
context,
provider,
service_name,
Call::Stop(reason),
)
.await
}
async fn service_call(
&self,
compiled: &CompiledService,
mut context: InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
call: Call<'_>,
) -> ServiceInvocationResult {
let ceiling = match call {
Call::Start(_) => Duration::from_secs(5),
Call::Stop(_) => Duration::from_secs(2),
Call::Event { .. } => self.limits.wall_time,
};
context.deadline = context
.deadline
.min(Instant::now() + ceiling.min(self.limits.wall_time));
context.cancellation = context.cancellation.child_token();
let _lifetime = InvocationLifetime(context.cancellation.clone());
let effects = Arc::new(Effects::default());
let execution = self.run_service(
compiled,
&context,
provider,
service_name,
call,
effects.clone(),
);
tokio::pin!(execution);
let mut tick = tokio::time::interval(Duration::from_millis(10));
let outcome = loop {
tokio::select! {
biased;
_ = context.cancellation.cancelled() => break Err(ExecutionError::Cancelled),
_ = tokio::time::sleep_until(context.deadline) => break Err(ExecutionError::Deadline),
result = &mut execution => break result,
_ = tick.tick() => self.engine.increment_epoch(),
}
};
ServiceInvocationResult {
outcome,
effect: effects.snapshot(),
}
}
async fn run_service(
&self,
compiled: &CompiledService,
context: &InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
call: Call<'_>,
effects: Arc<Effects>,
) -> Result<ServiceOutput, ExecutionError> {
let compiled = &compiled.0;
if !Engine::same(&self.engine, compiled.component.engine())
|| context.artifact != compiled.identity
|| context.tool_name != service_name
{
return Err(ExecutionError::InvalidInput(
"Service artifact/name does not match Host context".into(),
));
}
let declaration = compiled
.manifest
.services
.iter()
.find(|s| s.name == service_name)
.ok_or_else(|| ExecutionError::InvalidInput("undeclared Service".into()))?;
if context.invocation_id.is_empty() || context.invocation_id.len() > 1024 {
return Err(ExecutionError::InvalidInput(
"invalid invocation identity".into(),
));
}
let config = match &call {
Call::Start(c) | Call::Event { config: c, .. } => Some(*c),
Call::Stop(_) => None,
};
if let Some(config) = config {
if config.len() > self.limits.config_bytes {
return Err(ExecutionError::InvalidInput("config exceeds limit".into()));
}
serde_json::from_str::<serde_json::Value>(config)
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?;
}
if let Call::Event { envelope: e, .. } = &call {
let kind = match e.event_type {
server_api::PluginTicketEventType::Changed => PluginTicketEventType::TicketChanged,
server_api::PluginTicketEventType::Deleted => PluginTicketEventType::TicketDeleted,
};
if !context.grants.ticket_events
|| e.version != 1
|| e.attempt == 0
|| !declaration.subscriptions.contains(&kind)
|| serde_json::to_vec(e)
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?
.len()
> self.limits.payload_bytes
|| [&e.event_id, &e.ticket_id, &e.domain_event_ref]
.iter()
.any(|s| s.is_empty() || s.len() > 1024)
{
return Err(ExecutionError::InvalidInput(
"invalid, oversized or ungranted Service event".into(),
));
}
}
if let Call::Stop(reason) = &call {
if reason.len() > self.limits.payload_bytes {
return Err(ExecutionError::InvalidInput(
"stop reason exceeds limit".into(),
));
}
}
provider
.authorize(context, Operation::Admit)
.await
.map_err(ExecutionError::Host)?;
check_current(context).map_err(ExecutionError::Host)?;
let limits = &self.limits;
let mut store = Store::new(
&self.engine,
State {
context: None,
provider: provider.clone(),
limits: limits.clone(),
manifest: compiled.manifest.clone(),
table: ResourceTable::new(),
operations: 0,
resources: 0,
log_bytes: 0,
dropped_logs: 0,
store_limits: SandboxLimits::new(limits),
logging_only: !matches!(call, Call::Event { .. }),
effects,
},
);
store.limiter(|s| &mut s.store_limits);
store.set_fuel(limits.fuel).map_err(trap)?;
store
.fuel_async_yield_interval(Some(10_000))
.map_err(trap)?;
store.set_epoch_deadline(1);
store.epoch_deadline_async_yield_and_update(1);
let mut linker = Linker::new(&self.engine);
service_bindings::TicketServicePlugin::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
&mut linker,
|s| s,
)
.map_err(trap)?;
let instance = service_bindings::TicketServicePlugin::instantiate_async(
&mut store,
&compiled.component,
&linker,
)
.await
.map_err(trap)?;
check_current(context).map_err(ExecutionError::Host)?;
// Instantiation/start sections never receive an export context.
store.data_mut().context = Some(context.clone());
let service = instance.yoi_plugin_service();
let output = match call {
Call::Start(config) => {
service
.call_on_start(&mut store, config)
.await
.map_err(trap)?
.map_err(guest_error)?;
ServiceOutput::Started
}
Call::Stop(reason) => {
service
.call_on_stop(&mut store, reason)
.await
.map_err(trap)?
.map_err(guest_error)?;
ServiceOutput::Stopped
}
Call::Event {
envelope: e,
config,
} => {
let input = abi::Envelope {
event_id: e.event_id.clone(),
event_type: match e.event_type {
server_api::PluginTicketEventType::Changed => {
abi::TicketEventType::TicketChanged
}
server_api::PluginTicketEventType::Deleted => {
abi::TicketEventType::TicketDeleted
}
},
event_version: e.version,
occurred_at: e.occurred_at.clone(),
ticket_ref: e.ticket_id.clone(),
domain_event_id: e.domain_event_ref.clone(),
content_digest: if e.content_digest.is_empty() {
None
} else {
Some(e.content_digest.clone())
},
correlation_id: if e.correlation_id.is_empty() {
None
} else {
Some(e.correlation_id.clone())
},
causation_id: e.causation_id.clone(),
invocation_id: context.invocation_id.clone(),
service_name: service_name.into(),
config_json: config.into(),
attempt: e.attempt,
remaining_ms: context
.deadline
.saturating_duration_since(Instant::now())
.as_millis()
.min(u32::MAX as u128) as u32,
};
let result = match service
.call_on_event(&mut store, &input)
.await
.map_err(trap)?
{
abi::EventResult::Ack => PluginServiceEventResult::Ack,
abi::EventResult::RetryAfter(delay_ms) if delay_ms <= 300_000 => {
PluginServiceEventResult::RetryAfter { delay_ms }
}
abi::EventResult::Reject(reason)
if reason.len() <= self.limits.payload_bytes =>
{
PluginServiceEventResult::Reject
}
_ => {
return Err(ExecutionError::InvalidOutput(
"Service result exceeds limit".into(),
));
}
};
ServiceOutput::Event(result)
}
};
check_current(context).map_err(ExecutionError::Host)?;
provider
.authorize(context, Operation::Publish)
.await
.map_err(ExecutionError::Host)?;
check_current(context).map_err(ExecutionError::Host)?;
Ok(output)
}
}
fn guest_error(error: abi::ServiceError) -> ExecutionError {
if error.code.len().saturating_add(error.message.len()) > 4096 {
ExecutionError::InvalidOutput("Service error exceeds limit".into())
} else {
ExecutionError::Guest {
code: "plugin-failed".into(),
message: error.message,
}
}
}
@@ -0,0 +1,476 @@
use super::*;
use crate::host::{
Ticket, TicketDataOperation as Op, TicketDataResult as Reply, extension_data as data, objects,
ticket_read,
};
use server_api::{
PluginDataAddress as Address, PluginDataCell as Cell, PluginDataCollection as Collection,
PluginDataExpectation as Expectation, PluginDataMutation as Mutation,
};
fn denied() -> errors::HostError {
host::error(errors::Code::Denied, "Ticket capability not granted")
}
fn invalid() -> errors::HostError {
host::error(errors::Code::InvalidInput, "invalid Ticket data request")
}
fn protocol() -> errors::HostError {
host::error(errors::Code::Unavailable, "invalid Ticket owner response")
}
fn collection(c: data::Collection) -> Collection {
Collection {
collection: c.collection,
schema_id: c.schema_id,
schema_version: c.schema_version,
}
}
fn address(a: data::Address) -> Address {
Address {
collection: collection(a.namespace),
key: a.key,
}
}
fn cell(c: Cell) -> data::Cell {
match c {
Cell::NeverWritten => data::Cell::NeverWritten,
Cell::Deleted { last_delete } => data::Cell::Deleted(last_delete),
Cell::Present {
value_json,
last_write,
} => data::Cell::Present(data::PresentCell {
value_json,
last_write,
}),
}
}
fn receipt(r: server_api::PluginDataReceipt) -> data::Receipt {
data::Receipt {
mutation_id: r.mutation_id,
result_digest: r.result_digest,
}
}
impl State {
fn ticket_world(&self) -> Result<(), errors::HostError> {
if !matches!(
self.manifest.runtime.world.as_str(),
plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD | plugin::PLUGIN_COMPONENT_SERVICE_WORLD
) {
return Err(denied());
}
Ok(())
}
fn data_schema(&self, c: &Collection) -> Result<&serde_json::Value, errors::HostError> {
self.manifest
.data_collections
.iter()
.find(|d| {
d.collection == c.collection
&& d.schema_id == c.schema_id
&& d.schema_version == c.schema_version
})
.map(|d| &d.schema)
.ok_or_else(denied)
}
fn data_grant(&self, ctx: &InvocationContext, c: &Collection, write: bool) -> bool {
let id = (
c.collection.clone(),
c.schema_id.clone(),
c.schema_version.clone(),
);
let requested = if write {
plugin::PluginRequestedGrant::TicketDataWrite {
collection: c.collection.clone(),
schema_id: c.schema_id.clone(),
schema_version: c.schema_version.clone(),
}
} else {
plugin::PluginRequestedGrant::TicketDataRead {
collection: c.collection.clone(),
schema_id: c.schema_id.clone(),
schema_version: c.schema_version.clone(),
}
};
self.manifest.requested_grants.contains(&requested)
&& (if write {
&ctx.grants.ticket_data_write
} else {
&ctx.grants.ticket_data_read
})
.contains(&id)
}
fn check_cell(&self, namespace: &Collection, c: &Cell) -> Result<(), errors::HostError> {
if let Cell::Present { value_json, .. } = c {
if value_json.len() > server_api::PLUGIN_DATA_VALUE_LIMIT {
return Err(protocol());
}
let value = serde_json::from_str(value_json).map_err(|_| protocol())?;
plugin::schema::validate_data_value(self.data_schema(namespace)?, &value)
.map_err(|_| protocol())?;
}
Ok(())
}
async fn data_call(
&mut self,
target: Resource<Ticket>,
operation: Op,
) -> wasmtime::Result<Result<Reply, errors::HostError>> {
let context = self.protected_current()?;
let checked = (|| {
self.operation()?;
self.ticket_world()?;
crate::owner::validate_operation(&operation).map_err(|_| invalid())?;
let (namespace, write, lookup) = match &operation {
Op::Get(a) => (&a.collection, false, false),
Op::Mutate { address, .. } => (&address.collection, true, false),
Op::List { collection, .. } => (collection, false, false),
Op::LookupMutation { address, .. } => (&address.collection, false, true),
};
let schema = self.data_schema(namespace)?;
if !self.data_grant(&context, namespace, write || lookup) {
return Err(denied());
}
if let Op::Mutate {
mutation: Mutation::Put { value_json, .. },
..
} = &operation
{
let value = serde_json::from_str(value_json).map_err(|_| invalid())?;
plugin::schema::validate_data_value(schema, &value).map_err(|_| invalid())?;
}
Ok(())
})();
if let Err(e) = checked {
return Ok(Err(e));
}
let binding = self.table.get(&target)?.binding.clone();
let namespace = match &operation {
Op::Get(a) | Op::Mutate { address: a, .. } | Op::LookupMutation { address: a, .. } => {
a.collection.clone()
}
Op::List { collection, .. } => collection.clone(),
};
let list_limit = if let Op::List { limit, .. } = &operation {
Some(*limit as usize)
} else {
None
};
let mutating = matches!(&operation, Op::Mutate { .. });
let lookup = matches!(&operation, Op::LookupMutation { .. });
let expected_reply = match &operation {
Op::Get(_) => 0,
Op::Mutate { .. } => 1,
Op::List { .. } => 2,
Op::LookupMutation { .. } => 3,
};
let effect = mutating.then(|| self.effects.begin());
let reply = self
.provider
.ticket_data(&context, binding.as_ref(), operation)
.await;
if let Some(effect) = effect {
effect.finish(&reply);
}
let reply = match reply {
Ok(reply) => reply,
Err(e) => {
self.effects.observe(&e);
if lookup {
self.effects.unresolved();
}
return Ok(Err(e));
}
};
if matches!(
&reply,
Reply::MutationLookup(server_api::PluginDataMutationLookup::Expired)
) {
self.effects.unresolved();
}
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket data result"))?;
let actual_reply = match &reply {
Reply::Cell(_) => 0,
Reply::Mutated(_) => 1,
Reply::Listed(_) => 2,
Reply::MutationLookup(_) => 3,
};
if actual_reply != expected_reply {
let mut error = protocol();
if mutating {
error.code = errors::Code::Indeterminate;
error.effect = errors::EffectObservation::Indeterminate;
}
self.effects.observe(&error);
return Ok(Err(error));
}
let valid = match &reply {
Reply::Cell(c) => self.check_cell(&namespace, c),
Reply::Listed(page) => {
if page.entries.len() > list_limit.unwrap_or(0)
|| page.entries.windows(2).any(|p| p[0].key >= p[1].key)
|| page.entries.iter().any(|e| {
e.key.is_empty()
|| e.key.len() > 256
|| matches!(e.cell, Cell::NeverWritten)
})
|| page
.next_cursor
.as_ref()
.is_some_and(|s| s.is_empty() || s.len() > 4096)
{
Err(protocol())
} else {
page.entries
.iter()
.try_for_each(|e| self.check_cell(&namespace, &e.cell))
}
}
_ => Ok(()),
};
if let Err(e) = valid {
return Ok(Err(e));
}
Ok(Ok(reply))
}
}
impl objects::HostTicket for State {
async fn drop(&mut self, target: Resource<Ticket>) -> wasmtime::Result<()> {
self.table.delete(target)?;
self.resources -= 1;
Ok(())
}
}
impl objects::Host for State {
async fn resolve_ticket(
&mut self,
ticket_ref: String,
requested: objects::Access,
) -> wasmtime::Result<Result<Resource<Ticket>, errors::HostError>> {
let context = self.protected_current()?;
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
return Ok(Err(e));
}
if ticket_ref.is_empty() || ticket_ref.len() > 256 {
return Ok(Err(invalid()));
}
let writable = matches!(requested, objects::Access::ExtensionWrite);
let allowed = self.manifest.requested_grants.iter().any(|g| match g {
plugin::PluginRequestedGrant::TicketRead => !writable && context.grants.ticket_read,
plugin::PluginRequestedGrant::TicketDataRead {
collection,
schema_id,
schema_version,
} => {
!writable
&& context.grants.ticket_data_read.contains(&(
collection.clone(),
schema_id.clone(),
schema_version.clone(),
))
}
plugin::PluginRequestedGrant::TicketDataWrite {
collection,
schema_id,
schema_version,
} => {
writable
&& context.grants.ticket_data_write.contains(&(
collection.clone(),
schema_id.clone(),
schema_version.clone(),
))
}
_ => false,
});
if !allowed {
return Ok(Err(denied()));
}
if self.resources >= self.limits.resources {
return Ok(Err(host::error(
errors::Code::QuotaExceeded,
"resource limit",
)));
}
let binding = match self
.provider
.resolve_ticket(&context, &ticket_ref, requested)
.await
{
Ok(binding) => binding,
Err(e) => return Ok(Err(e)),
};
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket resource"))?;
let target = self.table.push(Ticket { binding })?;
self.resources += 1;
Ok(Ok(target))
}
}
impl ticket_read::Host for State {
async fn read(
&mut self,
target: Resource<Ticket>,
) -> wasmtime::Result<Result<ticket_read::Snapshot, errors::HostError>> {
let context = self.protected_current()?;
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
return Ok(Err(e));
}
if !context.grants.ticket_read
|| !self
.manifest
.requested_grants
.contains(&plugin::PluginRequestedGrant::TicketRead)
{
return Ok(Err(denied()));
}
let binding = self.table.get(&target)?.binding.clone();
let result = self.provider.read_ticket(&context, binding.as_ref()).await;
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket snapshot"))?;
Ok(result.and_then(|s| {
if s.body.len() <= server_api::PLUGIN_TICKET_BODY_LIMIT {
Ok(s)
} else {
Err(protocol())
}
}))
}
}
impl data::Host for State {
async fn get(
&mut self,
target: Resource<Ticket>,
cell_address: data::Address,
) -> wasmtime::Result<Result<data::Cell, errors::HostError>> {
Ok(
match self
.data_call(target, Op::Get(address(cell_address)))
.await?
{
Ok(Reply::Cell(c)) => Ok(cell(c)),
Err(e) => Err(e),
_ => Err(protocol()),
},
)
}
async fn put(
&mut self,
target: Resource<Ticket>,
cell_address: data::Address,
expected: data::Expectation,
operation_key: String,
value_json: String,
) -> wasmtime::Result<Result<data::Receipt, errors::HostError>> {
let expected = match expected {
data::Expectation::NeverWritten => Expectation::NeverWritten,
data::Expectation::LastWrite(id) => Expectation::LastWrite(id),
};
Ok(
match self
.data_call(
target,
Op::Mutate {
address: address(cell_address),
operation_key,
mutation: Mutation::Put {
expected,
value_json,
},
},
)
.await?
{
Ok(Reply::Mutated(r)) => Ok(receipt(r)),
Err(e) => Err(e),
_ => Err(protocol()),
},
)
}
async fn delete(
&mut self,
target: Resource<Ticket>,
cell_address: data::Address,
expected_last_write: String,
operation_key: String,
) -> wasmtime::Result<Result<data::Receipt, errors::HostError>> {
Ok(
match self
.data_call(
target,
Op::Mutate {
address: address(cell_address),
operation_key,
mutation: Mutation::Delete {
expected_last_write,
},
},
)
.await?
{
Ok(Reply::Mutated(r)) => Ok(receipt(r)),
Err(e) => Err(e),
_ => Err(protocol()),
},
)
}
async fn list(
&mut self,
target: Resource<Ticket>,
namespace: data::Collection,
cursor: Option<String>,
limit: u32,
) -> wasmtime::Result<Result<data::Page, errors::HostError>> {
Ok(
match self
.data_call(
target,
Op::List {
collection: collection(namespace),
cursor,
limit,
},
)
.await?
{
Ok(Reply::Listed(p)) => Ok(data::Page {
entries: p
.entries
.into_iter()
.map(|e| data::Entry {
key: e.key,
value: cell(e.cell),
})
.collect(),
next_cursor: p.next_cursor,
}),
Err(e) => Err(e),
_ => Err(protocol()),
},
)
}
async fn lookup_mutation(
&mut self,
target: Resource<Ticket>,
cell_address: data::Address,
operation_key: String,
) -> wasmtime::Result<Result<data::MutationLookup, errors::HostError>> {
use server_api::PluginDataMutationLookup as Lookup;
Ok(
match self
.data_call(
target,
Op::LookupMutation {
address: address(cell_address),
operation_key,
},
)
.await?
{
Ok(Reply::MutationLookup(outcome)) => Ok(match outcome {
Lookup::NotRecorded => data::MutationLookup::NotRecorded,
Lookup::Expired => data::MutationLookup::Expired,
Lookup::Committed(r) => data::MutationLookup::Committed(receipt(r)),
}),
Err(e) => Err(e),
_ => Err(protocol()),
},
)
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Host-bound identity, grants and asynchronous provider contracts.
//!
//! The provider is the authority adapter: opaque bindings are not credentials
//! supplied by guests. Implementations must live-check authorization, remain
//! cancellation-safe, bound their own allocations and never auto-retry effects.
pub use crate::bindings::yoi::host::{errors, http, logging};
pub use crate::ticket_bindings::yoi::host2_1_0::{extension_data, objects, ticket_read};
use async_trait::async_trait;
use std::{any::Any, collections::BTreeSet, sync::Arc};
use tokio::time::Instant;
pub use tokio_util::sync::CancellationToken;
/// Constructed by the authenticated Runtime integration, never from guest JSON.
/// All fields are a snapshot upper bound, not a cached live authorization.
#[derive(Clone, Debug)]
pub struct InvocationContext {
pub workspace: String,
pub installation: String,
pub plan: String,
pub subject: String,
pub invocation_id: String,
pub artifact: crate::ArtifactIdentity,
pub tool_name: String,
pub grants: Grants,
pub deadline: Instant,
pub cancellation: CancellationToken,
}
#[derive(Clone, Debug, Default)]
pub struct Grants {
pub logging: bool,
pub connection_slots: BTreeSet<String>,
pub ticket_read: bool,
pub ticket_events: bool,
pub ticket_data_read: BTreeSet<(String, String, String)>,
pub ticket_data_write: BTreeSet<(String, String, String)>,
}
/// Opaque Host-owned capability, stored only within one invocation's Store.
/// The provider must not interpret a guest resource representation as identity.
pub struct Connection {
pub(crate) slot: String,
pub(crate) binding: Arc<dyn Any + Send + Sync>,
}
/// Opaque Ticket capability stored only in one invocation's Store.
pub struct Ticket {
pub(crate) binding: Arc<dyn Any + Send + Sync>,
}
/// Each operation requires a fresh owner check; admission covers pure Tools too.
pub enum Operation<'a> {
Admit,
Publish,
Open {
slot: &'a str,
},
Send {
slot: &'a str,
binding: &'a (dyn Any + Send + Sync),
request: &'a http::Request,
},
Log,
}
/// Core operations require implementations. Additive APIs default to Unsupported
/// for old providers, never to permission or success. Test providers belong to tests.
///
/// `authorize` must check the current installation/artifact/plan/subject and
/// operation against live owner policy. `open`/`send` must also enforce concrete
/// binding policy, secret handling and effect receipts at dispatch time. The
/// executor only enforces static package and invocation ceilings. All waits are
/// dropped on cancellation/deadline; providers must not leave detached work.
#[async_trait]
pub trait Host: Send + Sync {
/// Old providers fail closed, never infer permission from an old grant.
async fn resolve_ticket(
&self,
_context: &InvocationContext,
_ticket_ref: &str,
_access: objects::Access,
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError> {
Err(error(errors::Code::Unsupported, "Ticket API unavailable"))
}
async fn read_ticket(
&self,
_context: &InvocationContext,
_binding: &(dyn Any + Send + Sync),
) -> Result<ticket_read::Snapshot, errors::HostError> {
Err(error(errors::Code::Unsupported, "Ticket API unavailable"))
}
/// Providers live-authorize the concrete operation, Ticket and namespace.
async fn ticket_data(
&self,
_context: &InvocationContext,
_binding: &(dyn Any + Send + Sync),
_operation: TicketDataOperation,
) -> Result<TicketDataResult, errors::HostError> {
Err(error(
errors::Code::Unsupported,
"Ticket data API unavailable",
))
}
async fn authorize(
&self,
context: &InvocationContext,
operation: Operation<'_>,
) -> Result<(), errors::HostError>;
async fn open(
&self,
context: &InvocationContext,
slot: &str,
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError>;
async fn send(
&self,
context: &InvocationContext,
binding: &(dyn Any + Send + Sync),
request: http::Request,
) -> Result<http::Response, errors::HostError>;
async fn log(
&self,
context: &InvocationContext,
entry: logging::Entry,
) -> Result<(), errors::HostError>;
}
pub(crate) fn error(code: errors::Code, message: &str) -> errors::HostError {
errors::HostError {
code,
message: message.into(),
diagnostic_id: String::new(),
effect: errors::EffectObservation::NotDispatched,
}
}
/// Portable provider boundary shared by Remote and Embedded owners.
#[derive(Debug, Clone)]
pub enum TicketDataOperation {
Get(server_api::PluginDataAddress),
Mutate {
address: server_api::PluginDataAddress,
operation_key: String,
mutation: server_api::PluginDataMutation,
},
List {
collection: server_api::PluginDataCollection,
cursor: Option<String>,
limit: u32,
},
LookupMutation {
address: server_api::PluginDataAddress,
operation_key: String,
},
}
#[derive(Debug)]
pub enum TicketDataResult {
Cell(server_api::PluginDataCell),
Mutated(server_api::PluginDataReceipt),
Listed(server_api::PluginDataPage),
MutationLookup(server_api::PluginDataMutationLookup),
}
+51
View File
@@ -0,0 +1,51 @@
#![doc = include_str!("../README.md")]
pub mod bindings {
wasmtime::component::bindgen!({
path: "../../resources/plugin/wit",
world: "https-logged-tool-plugin",
imports: { default: async | trappable },
exports: { default: async },
with: { "yoi:host/connections/connection": crate::host::Connection },
});
}
pub mod delivery;
mod executor;
pub mod host;
pub mod owner;
mod sandbox;
pub use executor::*;
/// Additive Ticket imports share the original v2 error types.
pub mod ticket_bindings {
wasmtime::component::bindgen!({
path: "../../resources/plugin/wit-ticket",
world: "ticket-tool-plugin",
imports: { default: async | trappable },
exports: { default: async },
with: {
"yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors,
"yoi:host/objects@2.1.0/ticket": crate::host::Ticket,
},
});
}
/// Service exports reuse the existing Host resources and their exact import ABI.
pub mod service_bindings {
wasmtime::component::bindgen!({
path: "../../resources/plugin/wit-service",
world: "ticket-service-plugin",
imports: { default: async | trappable },
exports: { default: async },
with: {
"yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors,
"yoi:host/connections@2.0.0": crate::bindings::yoi::host::connections,
"yoi:host/http@2.0.0": crate::bindings::yoi::host::http,
"yoi:host/logging@2.0.0": crate::bindings::yoi::host::logging,
"yoi:host/objects@2.1.0": crate::host::objects,
"yoi:host/ticket-read@2.1.0": crate::host::ticket_read,
"yoi:host/extension-data@2.1.0": crate::host::extension_data,
},
});
}
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
//! Invocation-local resource identity is independent of owner Ticket selectors.
use super::*;
use server_api::*;
struct OwnerTicket {
invocation_id: String,
ticket_id: String,
writable: bool,
identity: Arc<()>,
}
fn denied() -> PluginHostError {
local_error(
PluginHostErrorCode::Denied,
PluginEffectObservation::NotDispatched,
)
}
fn invalid() -> PluginHostError {
local_error(
PluginHostErrorCode::InvalidInput,
PluginEffectObservation::NotDispatched,
)
}
impl OwnerHost {
fn ticket<'a>(
&self,
binding: &'a (dyn Any + Send + Sync),
) -> Result<&'a OwnerTicket, PluginHostError> {
binding
.downcast_ref::<OwnerTicket>()
.filter(|t| {
t.invocation_id == self.context.invocation_id
&& Arc::ptr_eq(&t.identity, &self.binding_identity)
})
.ok_or_else(denied)
}
pub(super) async fn resolve_ticket_binding(
&self,
context: &InvocationContext,
ticket_ref: &str,
access: objects::Access,
) -> Result<Arc<dyn Any + Send + Sync>, PluginHostError> {
self.check_context(context)?;
if ticket_ref.is_empty() || ticket_ref.len() > 256 {
return Err(invalid());
}
let writable = matches!(access, objects::Access::ExtensionWrite);
if if writable {
context.grants.ticket_data_write.is_empty()
} else {
!context.grants.ticket_read && context.grants.ticket_data_read.is_empty()
} {
return Err(denied());
}
match call_owner(
self.owner.as_ref(),
context,
PluginHostRequest::ResolveTicket {
invocation_id: context.invocation_id.clone(),
ticket_ref: ticket_ref.into(),
access: if writable {
PluginTicketAccess::ExtensionWrite
} else {
PluginTicketAccess::Read
},
},
)
.await?
{
PluginHostResponse::TicketResolved { ticket_id }
if !ticket_id.is_empty() && ticket_id.len() <= 256 =>
{
Ok(Arc::new(OwnerTicket {
invocation_id: context.invocation_id.clone(),
ticket_id,
writable,
identity: self.binding_identity.clone(),
}))
}
_ => Err(protocol_error(false)),
}
}
pub(super) async fn read_ticket_binding(
&self,
context: &InvocationContext,
binding: &(dyn Any + Send + Sync),
) -> Result<ticket_read::Snapshot, PluginHostError> {
self.check_context(context)?;
let ticket = self.ticket(binding)?;
if !context.grants.ticket_read {
return Err(denied());
}
match call_owner(
self.owner.as_ref(),
context,
PluginHostRequest::ReadTicket {
invocation_id: context.invocation_id.clone(),
ticket_id: ticket.ticket_id.clone(),
},
)
.await?
{
PluginHostResponse::TicketRead { snapshot }
if snapshot.body.len() <= PLUGIN_TICKET_BODY_LIMIT =>
{
Ok(ticket_read::Snapshot {
title: snapshot.title,
state: match snapshot.case_state {
PluginTicketCaseState::Open => ticket_read::CaseState::Open,
PluginTicketCaseState::Closed => ticket_read::CaseState::Closed,
},
content_digest: snapshot.content_digest,
body: snapshot.body,
truncated: snapshot.truncated,
})
}
_ => Err(protocol_error(false)),
}
}
pub(super) async fn ticket_data_binding(
&self,
context: &InvocationContext,
binding: &(dyn Any + Send + Sync),
operation: TicketDataOperation,
) -> Result<TicketDataResult, PluginHostError> {
self.check_context(context)?;
let ticket = self.ticket(binding)?;
let (collection, write) = match &operation {
TicketDataOperation::Get(a)
| TicketDataOperation::LookupMutation { address: a, .. } => (&a.collection, false),
TicketDataOperation::Mutate { address: a, .. } => (&a.collection, true),
TicketDataOperation::List { collection, .. } => (collection, false),
};
let identity = (
collection.collection.clone(),
collection.schema_id.clone(),
collection.schema_version.clone(),
);
let lookup = matches!(&operation, TicketDataOperation::LookupMutation { .. });
let grants = if write || lookup {
&context.grants.ticket_data_write
} else {
&context.grants.ticket_data_read
};
if !grants.contains(&identity) || ((write || lookup) && !ticket.writable) {
return Err(denied());
}
validate_operation(&operation)?;
let invocation_id = context.invocation_id.clone();
let ticket_id = ticket.ticket_id.clone();
let mutation = matches!(&operation, TicketDataOperation::Mutate { .. });
let request = match operation {
TicketDataOperation::Get(address) => PluginHostRequest::DataGet {
invocation_id,
ticket_id,
address,
},
TicketDataOperation::Mutate {
address,
operation_key,
mutation,
} => PluginHostRequest::DataMutate {
invocation_id,
ticket_id,
address,
operation_key,
mutation,
},
TicketDataOperation::List {
collection,
cursor,
limit,
} => PluginHostRequest::DataList {
invocation_id,
ticket_id,
collection,
cursor,
limit,
},
TicketDataOperation::LookupMutation {
address,
operation_key,
} => PluginHostRequest::DataLookupMutation {
invocation_id,
ticket_id,
address,
operation_key,
},
};
match call_owner(self.owner.as_ref(), context, request.clone()).await? {
PluginHostResponse::DataCell { cell }
if matches!(request, PluginHostRequest::DataGet { .. }) =>
{
Ok(TicketDataResult::Cell(cell))
}
PluginHostResponse::DataMutated { receipt } if mutation => {
Ok(TicketDataResult::Mutated(receipt))
}
PluginHostResponse::DataListed { page }
if matches!(request, PluginHostRequest::DataList { .. }) =>
{
Ok(TicketDataResult::Listed(page))
}
PluginHostResponse::DataMutationLookup { outcome }
if matches!(request, PluginHostRequest::DataLookupMutation { .. }) =>
{
Ok(TicketDataResult::MutationLookup(outcome))
}
_ => Err(protocol_error(mutation)),
}
}
}
fn text(s: &str, limit: usize) -> bool {
!s.is_empty() && s.len() <= limit && !s.chars().any(char::is_control)
}
pub(crate) fn validate_operation(operation: &TicketDataOperation) -> Result<(), PluginHostError> {
let collection = match operation {
TicketDataOperation::Get(a)
| TicketDataOperation::Mutate { address: a, .. }
| TicketDataOperation::LookupMutation { address: a, .. } => {
if !text(&a.key, 256) {
return Err(invalid());
}
&a.collection
}
TicketDataOperation::List {
collection,
cursor,
limit,
} => {
if *limit == 0
|| *limit > PLUGIN_DATA_LIST_LIMIT
|| cursor.as_ref().is_some_and(|c| !text(c, 4096))
{
return Err(invalid());
}
collection
}
};
if [
&collection.collection,
&collection.schema_id,
&collection.schema_version,
]
.iter()
.any(|s| !text(s, 128))
{
return Err(invalid());
}
match operation {
TicketDataOperation::Mutate {
operation_key,
mutation,
..
} => {
if !text(operation_key, 256) {
return Err(invalid());
}
match mutation {
PluginDataMutation::Put {
expected,
value_json,
} => {
if value_json.len() > PLUGIN_DATA_VALUE_LIMIT
|| serde_json::from_str::<serde_json::Value>(value_json).is_err()
{
return Err(invalid());
}
if let PluginDataExpectation::LastWrite(id) = expected {
if !text(id, 256) {
return Err(invalid());
}
}
}
PluginDataMutation::Delete {
expected_last_write,
} => {
if !text(expected_last_write, 256) {
return Err(invalid());
}
}
}
}
TicketDataOperation::LookupMutation { operation_key, .. } if !text(operation_key, 256) => {
return Err(invalid());
}
_ => {}
}
Ok(())
}
+132
View File
@@ -0,0 +1,132 @@
//! Aggregate Store budgets: Wasmtime's built-in memory/table sizes are per
//! object, so count accepted growth across all objects as well. Nothing in a
//! Tool Store outlives that invocation; no cross-Store accounting is retained.
use crate::Limits;
use wasmtime::{ResourceLimiter, StoreLimits, StoreLimitsBuilder};
/// Additional execution-policy check, not a replacement ABI/package validator.
/// Wasmtime's Store limiter does not meter Component-internal resource tables.
/// The Tool ABI needs only imported Host resources, whose creation is metered
/// by our adapter; guest-defined resources and allocators therefore fail closed.
/// `parse_all` visits concrete nested Components as well as the outer one.
pub(crate) fn check_component_resources(bytes: &[u8]) -> wasmtime::Result<()> {
use wasmparser::{CanonicalFunction, ComponentType, Parser, Payload};
for payload in Parser::new(0).parse_all(bytes) {
match payload? {
Payload::ComponentTypeSection(types) => {
for ty in types {
if matches!(ty?, ComponentType::Resource { .. }) {
return Err(wasmtime::Error::msg(
"guest-defined Component resources are unsupported by the Tool sandbox",
));
}
}
}
Payload::ComponentCanonicalSection(functions) => {
for function in functions {
if matches!(function?, CanonicalFunction::ResourceNew { .. }) {
return Err(wasmtime::Error::msg(
"guest Component resource.new is unsupported by the Tool sandbox",
));
}
}
}
_ => (),
}
}
Ok(())
}
pub(crate) struct SandboxLimits {
inner: StoreLimits,
memory: Budget,
table: Budget,
}
struct Budget {
used: usize,
last_growth: usize,
ceiling: usize,
}
impl Budget {
fn new(ceiling: usize) -> Self {
Self {
used: 0,
last_growth: 0,
ceiling,
}
}
fn reserve(&mut self, current: usize, desired: usize, kind: &str) -> wasmtime::Result<()> {
let growth = desired.saturating_sub(current);
if growth > self.ceiling.saturating_sub(self.used) {
return Err(wasmtime::Error::msg(format!(
"aggregate {kind} budget exceeded"
)));
}
self.used += growth;
self.last_growth = growth;
Ok(())
}
fn failed(&mut self) {
self.used -= self.last_growth;
self.last_growth = 0;
}
}
impl SandboxLimits {
pub(crate) fn new(limits: &Limits) -> Self {
Self {
inner: StoreLimitsBuilder::new()
.memory_size(limits.memory_bytes)
.table_elements(limits.table_elements)
.instances(limits.instances)
.memories(limits.memories)
.tables(limits.tables)
.trap_on_grow_failure(true)
.build(),
memory: Budget::new(limits.memory_bytes),
table: Budget::new(limits.table_elements),
}
}
}
impl ResourceLimiter for SandboxLimits {
fn memory_growing(
&mut self,
current: usize,
desired: usize,
maximum: Option<usize>,
) -> wasmtime::Result<bool> {
if !self.inner.memory_growing(current, desired, maximum)? {
return Ok(false);
}
self.memory.reserve(current, desired, "memory")?;
Ok(true)
}
fn table_growing(
&mut self,
current: usize,
desired: usize,
maximum: Option<usize>,
) -> wasmtime::Result<bool> {
if !self.inner.table_growing(current, desired, maximum)? {
return Ok(false);
}
self.table.reserve(current, desired, "table")?;
Ok(true)
}
fn memory_grow_failed(&mut self, error: wasmtime::Error) -> wasmtime::Result<()> {
self.memory.failed();
self.inner.memory_grow_failed(error)
}
fn table_grow_failed(&mut self, error: wasmtime::Error) -> wasmtime::Result<()> {
self.table.failed();
self.inner.table_grow_failed(error)
}
fn instances(&self) -> usize {
self.inner.instances()
}
fn memories(&self) -> usize {
self.inner.memories()
}
fn tables(&self) -> usize {
self.inner.tables()
}
}
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "executor-test-guest"
version = "0.1.0"
dependencies = [
"yoi-plugin-pdk",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn 2.0.119",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.119",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "yoi-plugin-pdk"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
"wit-bindgen",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,13 @@
[package]
name = "executor-test-guest"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
@@ -0,0 +1,138 @@
use yoi_plugin_pdk::wit_bindgen;
wit_bindgen::generate!({
path: "../../../../resources/plugin/wit",
world: "https-logged-tool-plugin",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
use exports::yoi::plugin::tool::{Guest, Invocation, Output, ToolError};
use std::sync::atomic::{AtomicU32, Ordering};
use yoi::host::{connections, http, logging};
static CALLS: AtomicU32 = AtomicU32::new(0);
struct Plugin;
impl Guest for Plugin {
fn invoke(input: Invocation) -> Result<Output, ToolError> {
let count = CALLS.fetch_add(1, Ordering::SeqCst) + 1;
let mode = input.input_json.trim_matches('"');
match mode {
"trap" => panic!("guest trap"),
"loop" => loop {
std::hint::black_box(CALLS.load(Ordering::SeqCst));
},
"wall-loop" => {
logging::log(&logging::Entry {
level: logging::Level::Info,
event_code: "count".into(),
message: "loop entered".into(),
fields: vec![],
})
.map_err(failure)?;
loop {
std::hint::black_box(CALLS.load(Ordering::SeqCst));
}
}
"memory" => {
core::arch::wasm32::memory_grow::<0>(8192);
}
"error-output" => {
return Err(ToolError {
code: "typed".into(),
message: "\"".repeat(40_000),
});
}
"typed" | "unknown" => {
return Err(ToolError {
code: mode.into(),
message: "bounded business failure".into(),
});
}
"logging" => {
for _ in 0..3 {
logging::log(&logging::Entry {
level: logging::Level::Info,
event_code: "count".into(),
message: "safe diagnostic".into(),
fields: vec![logging::Field {
name: "calls".into(),
value: logging::Scalar::Unsigned(count as u64),
}],
})
.map_err(failure)?;
}
}
"http" | "stale" | "foreign" | "resources" => {
if mode == "resources" {
let _resources: Vec<_> =
(0..64).map(|_| connections::open("api").unwrap()).collect();
}
let target = if mode == "foreign" {
// An attacker can fabricate a representation, not a capability.
unsafe { connections::Connection::from_handle(0) }
} else {
connections::open("api").map_err(failure)?
};
let target = if mode == "stale" {
let handle = target.handle();
drop(target);
unsafe { connections::Connection::from_handle(handle) }
} else {
target
};
let response = http::send(
&target,
&http::Request {
method: http::Method::Get,
path: "/status".into(),
query: vec![],
headers: vec![],
body: vec![],
timeout_ms: 15_000,
effect_key: "one".into(),
},
)
.map_err(failure)?;
return Ok(Output {
summary: String::from_utf8(response.body).unwrap(),
content: None,
structured_json: Some(format!("{{\"calls\":{count}}}")),
});
}
_ => (),
}
Ok(Output {
summary: if mode == "output" {
"x".repeat(70_000)
} else {
input.invocation_id
},
content: None,
structured_json: Some(if mode == "bad-json" {
"not-json".into()
} else if mode == "bad-schema" {
"{}".into()
} else {
format!("{{\"calls\":{count}}}")
}),
})
}
}
fn failure(error: yoi::host::errors::HostError) -> ToolError {
ToolError {
code: "host".into(),
message: format!(
"{}:{}",
match error.code {
yoi::host::errors::Code::DeadlineExceeded => "deadline-exceeded",
yoi::host::errors::Code::QuotaExceeded => "quota-exceeded",
_ => "host-rejected",
},
match error.effect {
yoi::host::errors::EffectObservation::NotDispatched => "not-dispatched",
yoi::host::errors::EffectObservation::ResponseObserved => "response-observed",
yoi::host::errors::EffectObservation::Indeterminate => "indeterminate",
}
),
}
}
export!(Plugin);
+367
View File
@@ -0,0 +1,367 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "service-test-guest"
version = "0.1.0"
dependencies = [
"yoi-plugin-pdk",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn 2.0.119",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.119",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "yoi-plugin-pdk"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
"wit-bindgen",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,13 @@
[package]
name = "service-test-guest"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
@@ -0,0 +1,47 @@
schema_version = 2
id = "service-test"
name = "Service test"
version = "1.0.0"
surfaces = ["service"]
config_schema = "{ mode = String }"
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/ticket-service-plugin@2.2.0"
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/connections@2.0.0", "yoi:host/http@2.0.0", "yoi:host/logging@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"]
[[services]]
name = "ticket_events"
subscriptions = ["ticket_changed", "ticket_deleted"]
[[connections]]
name = "api"
methods = ["POST"]
path_prefixes = ["/events"]
[[data_collections]]
collection = "notes"
schema_id = "note"
schema_version = "1"
schema = { type = "string", maxLength = 64 }
[[requested_grants]]
kind = "logging"
[[requested_grants]]
kind = "https"
slot = "api"
[[requested_grants]]
kind = "ticket_events"
[[requested_grants]]
kind = "ticket_read"
[[requested_grants]]
kind = "ticket_data_read"
collection = "notes"
schema_id = "note"
schema_version = "1"
[[requested_grants]]
kind = "ticket_data_write"
collection = "notes"
schema_id = "note"
schema_version = "1"
@@ -0,0 +1,135 @@
use std::sync::atomic::{AtomicU32, Ordering};
use yoi_plugin_pdk::{ToolError, wit_bindgen};
wit_bindgen::generate!({
path: "../../../../resources/plugin/wit-service",
world: "ticket-service-plugin",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
use exports::yoi::plugin::service::{Envelope, EventResult, TicketEventType};
use yoi::host2_0_0::{connections, errors, http, logging};
use yoi::host2_1_0::{extension_data as data, objects, ticket_read};
static CALLS: AtomicU32 = AtomicU32::new(0);
fn fresh() {
assert_eq!(CALLS.fetch_add(1, Ordering::SeqCst), 0);
}
fn failure(e: errors::HostError) -> ToolError {
ToolError::failed(format!("{:?}", e.code))
}
fn address() -> data::Address {
data::Address {
namespace: data::Collection {
collection: "notes".into(),
schema_id: "note".into(),
schema_version: "1".into(),
},
key: "main".into(),
}
}
fn request() -> http::Request {
http::Request {
method: http::Method::Post,
path: "/events".into(),
query: vec![],
headers: vec![],
body: b"event".to_vec(),
timeout_ms: 15_000,
effect_key: "event-hook".into(),
}
}
fn lifecycle(mode: String) -> yoi_plugin_pdk::Result<()> {
fresh();
match mode.as_str() {
"open" => {
connections::open("api").map_err(failure)?;
}
"resolve" => {
objects::resolve_ticket("T-796", objects::Access::Read).map_err(failure)?;
}
"read" => {
let t = unsafe { objects::Ticket::from_handle(0) };
ticket_read::read(&t).map_err(failure)?;
}
"data" => {
let t = unsafe { objects::Ticket::from_handle(0) };
data::get(&t, &address()).map_err(failure)?;
}
"http" => {
let c = unsafe { connections::Connection::from_handle(0) };
http::send(&c, &request()).map_err(failure)?;
}
"loop" => loop {
std::hint::spin_loop();
},
_ => {
logging::log(&logging::Entry {
level: logging::Level::Info,
event_code: "lifecycle".into(),
message: "configured".into(),
fields: vec![],
})
.map_err(failure)?;
}
}
Ok(())
}
fn start(config: String) -> yoi_plugin_pdk::Result<()> {
let config: std::collections::BTreeMap<String, String> =
yoi_plugin_pdk::parse_json_input(&config)?;
lifecycle(config.get("mode").cloned().unwrap_or_default())
}
fn stop(reason: String) -> yoi_plugin_pdk::Result<()> {
lifecycle(reason)
}
fn work(input: &Envelope) -> yoi_plugin_pdk::Result<()> {
if matches!(input.event_type, TicketEventType::TicketDeleted) {
return Ok(());
}
let ticket = objects::resolve_ticket(&input.ticket_ref, objects::Access::ExtensionWrite)
.map_err(failure)?;
let snapshot = ticket_read::read(&ticket).map_err(failure)?;
if input.config_json == r#"{"mode":"abi-probe"}"# {
data::list(&ticket, &address().namespace, None, 1).map_err(failure)?;
data::delete(&ticket, &address(), "observed", "delete").map_err(failure)?;
}
// A saved mutation is checked before reading the current cell or rebuilding
// its CAS request. Failed lookup is never interpreted as not-recorded.
match data::lookup_mutation(&ticket, &address(), "event-note").map_err(failure)? {
data::MutationLookup::Committed(_) => (),
data::MutationLookup::Expired => return Err(ToolError::failed("expired")),
data::MutationLookup::NotRecorded => {
let expected = match data::get(&ticket, &address()).map_err(failure)? {
data::Cell::NeverWritten => data::Expectation::NeverWritten,
data::Cell::Deleted(last) => data::Expectation::LastWrite(last),
data::Cell::Present(cell) => data::Expectation::LastWrite(cell.last_write),
};
let value = yoi_plugin_pdk::extension_data_json(&snapshot.title)?;
data::put(&ticket, &address(), &expected, "event-note", &value).map_err(failure)?;
}
}
let connection = connections::open("api").map_err(failure)?;
// Repeating this exact request uses the Host receipt, not a new effect key.
http::send(&connection, &request()).map_err(failure)?;
Ok(())
}
fn event(input: Envelope) -> EventResult {
fresh();
if input.config_json == r#"{"mode":"loop"}"# {
loop {
std::hint::spin_loop();
}
}
if input.config_json == r#"{"mode":"reject"}"# {
return EventResult::Reject("rejected".into());
}
if input.config_json == r#"{"mode":"oversize-retry"}"# {
return EventResult::RetryAfter(300_001);
}
if work(&input).is_err() && input.config_json != r#"{"mode":"swallow-unknown"}"# {
EventResult::RetryAfter(10)
} else {
EventResult::Ack
}
}
yoi_plugin_pdk::export_component_service!(ServiceGuest, start, event, stop);
@@ -0,0 +1,877 @@
//! Authored Rust -> PDK -> validated Component -> fresh Wasmtime Store -> OwnerHost.
//! The deterministic owner replaces storage/network, not the execution adapter;
//! transaction/lease/live policy guarantees are tested by the domain owner suites.
use async_trait::async_trait;
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
use server_api::*;
use std::{
collections::BTreeMap,
fs,
path::Path,
process::Command,
sync::{Arc, Mutex, OnceLock},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::time::Instant;
use yoi_plugin_runtime::{
host::{CancellationToken, Grants, InvocationContext},
owner::{OwnerHost, PluginHostOwner},
*,
};
fn component() -> &'static [u8] {
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
COMPONENT.get_or_init(|| {
let target =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture/guest");
let output = Command::new(env!("CARGO"))
.args([
"build",
"--locked",
"--offline",
"--release",
"--target",
"wasm32-unknown-unknown",
"--manifest-path",
])
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/service-guest/Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.output()
.unwrap();
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(
target.parent().unwrap().join("guest-build.log"),
&output.stderr,
)
.unwrap();
assert!(
output.status.success(),
"Service fixture requires wasm32 std + lld on PATH: {}",
String::from_utf8_lossy(&output.stderr)
);
let core = fs::read(target.join("wasm32-unknown-unknown/release/service_test_guest.wasm"))
.unwrap();
let bytes = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
fs::write(
target.parent().unwrap().join("service.component.wasm"),
&bytes,
)
.unwrap();
bytes
})
}
const MANIFEST: &str = include_str!("service-guest/plugin.toml");
fn package(manifest: &str) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
package_bytes(manifest, component())
}
fn package_bytes(
manifest: &str,
bytes: &[u8],
) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture");
fs::create_dir_all(&target).unwrap();
let dir = tempfile::tempdir_in(target).unwrap();
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
fs::write(dir.path().join("plugin.component.wasm"), bytes).unwrap();
let materialized = plugin::read_plugin_directory(
dir.path(),
PluginSourceKind::Explicit,
&PluginPackageLimits::default(),
)?;
plugin::pack_plugin_package(&materialized, &PluginPackageLimits::default())
}
fn compile(ex: &Executor) -> CompiledService {
let p = package(MANIFEST).unwrap();
ex.compile_service(
&p.bytes,
&p.package_digest,
&p.artifact_digest,
p.artifact_size,
)
.unwrap()
}
#[derive(Clone, Copy, Default)]
enum Fault {
#[default]
None,
HttpUnknown,
DataUnknown,
HttpWait,
DataWait,
LookupUnavailable,
LookupExpired,
ReadDenied,
}
#[derive(Default)]
struct State {
calls: Vec<PluginHostRequest>,
writes: usize,
sends: usize,
receipts: BTreeMap<String, PluginDataReceipt>,
http: BTreeMap<String, PluginHttpResponse>,
}
struct Owner {
state: Mutex<State>,
fault: Fault,
waiting: tokio::sync::Notify,
}
impl Owner {
fn new(fault: Fault) -> Arc<Self> {
Arc::new(Self {
state: Mutex::new(State::default()),
fault,
waiting: tokio::sync::Notify::new(),
})
}
}
fn rejected(code: PluginHostErrorCode, effect: PluginEffectObservation) -> PluginHostError {
PluginHostError {
code,
effect,
diagnostic_id: "test".into(),
}
}
#[async_trait]
impl PluginHostOwner for Owner {
async fn call(
&self,
request: PluginHostRequest,
) -> Result<PluginHostResponse, PluginHostError> {
if (matches!(self.fault, Fault::HttpWait)
&& matches!(request, PluginHostRequest::Send { .. }))
|| (matches!(self.fault, Fault::DataWait)
&& matches!(request, PluginHostRequest::DataMutate { .. }))
{
self.waiting.notify_one();
return std::future::pending().await;
}
let mut s = self.state.lock().unwrap();
s.calls.push(request.clone());
Ok(match request {
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
invocation_id: "stable-service-invocation".into(),
expires_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
+ 60_000,
},
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
PluginHostRequest::Log { .. } => PluginHostResponse::Logged,
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
assert_eq!(ticket_ref, "T-796");
PluginHostResponse::TicketResolved {
ticket_id: "canonical-ticket".into(),
}
}
PluginHostRequest::ReadTicket { .. } => {
if matches!(self.fault, Fault::ReadDenied) {
return Err(rejected(
PluginHostErrorCode::Denied,
PluginEffectObservation::NotDispatched,
));
}
PluginHostResponse::TicketRead {
snapshot: PluginTicketSnapshot {
title: "note".into(),
case_state: PluginTicketCaseState::Open,
content_digest: "digest".into(),
body: "body".into(),
truncated: false,
},
}
}
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
if matches!(self.fault, Fault::LookupUnavailable) {
return Err(rejected(
PluginHostErrorCode::Unavailable,
PluginEffectObservation::NotDispatched,
));
}
PluginHostResponse::DataMutationLookup {
outcome: if matches!(self.fault, Fault::LookupExpired) {
PluginDataMutationLookup::Expired
} else {
s.receipts
.get(&operation_key)
.cloned()
.map(PluginDataMutationLookup::Committed)
.unwrap_or(PluginDataMutationLookup::NotRecorded)
},
}
}
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
cell: PluginDataCell::NeverWritten,
},
PluginHostRequest::DataMutate { operation_key, .. } => {
if matches!(self.fault, Fault::DataUnknown) {
return Err(rejected(
PluginHostErrorCode::Indeterminate,
PluginEffectObservation::Indeterminate,
));
}
s.writes += 1;
let receipt = PluginDataReceipt {
mutation_id: "write-1".into(),
result_digest: "result".into(),
};
assert!(s.receipts.insert(operation_key, receipt.clone()).is_none());
PluginHostResponse::DataMutated { receipt }
}
PluginHostRequest::Open { .. } => PluginHostResponse::Opened,
PluginHostRequest::Send { request, .. } => {
if matches!(self.fault, Fault::HttpUnknown) {
return Err(rejected(
PluginHostErrorCode::Indeterminate,
PluginEffectObservation::Indeterminate,
));
}
let response = if let Some(response) = s.http.get(&request.effect_key) {
response.clone()
} else {
s.sends += 1;
let response = PluginHttpResponse {
status: 200,
headers: vec![],
body: b"ok".to_vec(),
};
s.http.insert(request.effect_key, response.clone());
response
};
PluginHostResponse::Response { response }
}
_ => {
return Err(rejected(
PluginHostErrorCode::Unsupported,
PluginEffectObservation::NotDispatched,
));
}
})
}
}
fn context(c: &CompiledService) -> InvocationContext {
let namespace = ("notes".into(), "note".into(), "1".into());
InvocationContext {
workspace: "workspace".into(),
installation: "installation".into(),
plan: "plan".into(),
subject: "worker".into(),
invocation_id: "ignored-before-begin".into(),
artifact: c.identity().clone(),
tool_name: "ticket_events".into(),
grants: Grants {
logging: true,
connection_slots: ["api".into()].into(),
ticket_events: true,
ticket_read: true,
ticket_data_read: [namespace.clone()].into(),
ticket_data_write: [namespace].into(),
},
deadline: Instant::now() + Duration::from_secs(10),
cancellation: CancellationToken::new(),
}
}
async fn host(c: &CompiledService, owner: Arc<Owner>) -> Arc<OwnerHost> {
Arc::new(
OwnerHost::begin(
owner,
context(c),
PluginConsumer::Consumer {
consumer_id: "consumer".into(),
event_id: "event".into(),
export_name: "ticket_events".into(),
},
)
.await
.unwrap(),
)
}
fn envelope() -> PluginTicketEventEnvelope {
PluginTicketEventEnvelope {
event_id: "event".into(),
event_type: PluginTicketEventType::Changed,
version: 1,
occurred_at: "2026-10-30T00:00:00Z".into(),
ticket_id: "T-796".into(),
domain_event_ref: "domain-event".into(),
content_digest: "digest".into(),
correlation_id: "correlation".into(),
causation_id: None,
attempt: 1,
}
}
#[tokio::test]
async fn authored_service_recovers_with_receipt_lookup_before_rebuilding_a_mutation() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
assert!(matches!(
ex.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
r#"{"mode":"normal"}"#
)
.await
.outcome,
Ok(ServiceOutput::Started)
));
for attempt in 1..=2 {
let mut e = envelope();
e.attempt = attempt;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
r#"{"mode":"normal"}"#,
)
.await;
assert!(
matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
),
"{result:?}"
);
assert!(matches!(
result.effect,
host::errors::EffectObservation::ResponseObserved
));
}
assert!(matches!(
ex.service_stop(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
"normal"
)
.await
.outcome,
Ok(ServiceOutput::Stopped)
));
let state = owner.state.lock().unwrap();
assert_eq!(state.writes, 1);
assert_eq!(state.sends, 1);
assert_eq!(
state
.calls
.iter()
.filter(|r| matches!(r, PluginHostRequest::DataGet { .. }))
.count(),
1
);
assert_eq!(
state
.calls
.iter()
.filter(|r| matches!(r, PluginHostRequest::DataLookupMutation { .. }))
.count(),
2
);
}
#[tokio::test]
async fn lifecycle_hooks_allow_logging_but_never_open_resolve_read_data_or_http() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
for mode in ["open", "resolve", "read", "data", "http"] {
let config = format!(r#"{{"mode":"{mode}"}}"#);
let start = ex
.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&config,
)
.await;
let stop = ex
.service_stop(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
mode,
)
.await;
assert!(
matches!(start.outcome, Err(ExecutionError::Trap(_))),
"{mode}: {start:?}"
);
assert!(
matches!(stop.outcome, Err(ExecutionError::Trap(_))),
"{mode}: {stop:?}"
);
}
let s = owner.state.lock().unwrap();
assert!(s.calls.iter().all(|r| matches!(
r,
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
)));
}
#[tokio::test]
async fn guest_ack_cannot_hide_unknown_http_data_or_failed_receipt_lookup() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
for fault in [
Fault::HttpUnknown,
Fault::DataUnknown,
Fault::LookupUnavailable,
Fault::LookupExpired,
] {
let host = host(&c, Owner::new(fault)).await;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"swallow-unknown"}"#,
)
.await;
assert!(
matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
),
"{result:?}"
);
assert!(
matches!(
result.effect,
host::errors::EffectObservation::Indeterminate
),
"{result:?}"
);
}
}
#[tokio::test]
async fn cancellation_during_effect_wait_preserves_unknown_observation() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
for fault in [Fault::HttpWait, Fault::DataWait] {
let owner = Owner::new(fault);
let host = host(&c, owner.clone()).await;
let cancellation = host.context().cancellation.clone();
let e = envelope();
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
r#"{"mode":"normal"}"#
),
async {
owner.waiting.notified().await;
cancellation.cancel();
}
)
})
.await
.expect("effect was not dispatched");
assert!(
matches!(result.outcome, Err(ExecutionError::Cancelled)),
"{result:?}"
);
assert!(
matches!(
result.effect,
host::errors::EffectObservation::Indeterminate
),
"{result:?}"
);
}
}
#[tokio::test]
async fn deleted_metadata_never_resolves_a_ticket_and_live_read_denial_remains_a_failure() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::ReadDenied);
let host = host(&c, owner.clone()).await;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
result.outcome,
Ok(ServiceOutput::Event(
PluginServiceEventResult::RetryAfter { .. }
))
));
let before = owner.state.lock().unwrap().calls.len();
let mut e = envelope();
e.event_type = PluginTicketEventType::Deleted;
e.content_digest.clear();
let result = ex
.service_event(
&c,
host.context().clone(),
host,
"ticket_events",
&e,
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
));
assert!(
owner.state.lock().unwrap().calls[before..]
.iter()
.all(|r| matches!(r, PluginHostRequest::Authorize { .. }))
);
}
#[test]
fn service_manifest_rejects_old_world_subscription_and_grant_mismatches() {
package(MANIFEST).unwrap();
for bad in [
MANIFEST.replace("@2.2.0", "@2.1.0"),
MANIFEST.replace("ticket_changed", "arbitrary_event"),
MANIFEST.replace("kind = \"ticket_events\"", "kind = \"ticket_read\""),
MANIFEST.replace(
"ticket_changed\", \"ticket_deleted",
"ticket_changed\", \"ticket_changed",
),
MANIFEST.replace("surfaces = [\"service\"]", "surfaces = [\"tool\"]"),
] {
assert!(package(&bad).is_err());
}
}
#[tokio::test]
async fn service_host_and_guest_limits_fail_before_publication() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let host = host(&c, Owner::new(Fault::None)).await;
let invalid = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"oversize-retry"}"#,
)
.await;
assert!(matches!(
invalid.outcome,
Err(ExecutionError::InvalidOutput(_))
));
let mut ctx = host.context().clone();
ctx.grants.ticket_events = false;
let denied = ex
.service_event(
&c,
ctx,
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
denied.outcome,
Err(ExecutionError::InvalidInput(_))
));
let mut e = envelope();
e.version = 2;
assert!(matches!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
"{}"
)
.await
.outcome,
Err(ExecutionError::InvalidInput(_))
));
e = envelope();
e.correlation_id = "x".repeat(65536);
assert!(matches!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
"{}"
)
.await
.outcome,
Err(ExecutionError::InvalidInput(_))
));
let ctx = host.context().clone();
ctx.cancellation.cancel();
assert!(matches!(
ex.service_start(&c, ctx, host.clone(), "ticket_events", "{}")
.await
.outcome,
Err(ExecutionError::Cancelled)
));
}
fn startup_component(logging: bool) -> Vec<u8> {
let mut resolve = wit_parser::Resolve::default();
let (package, _) = resolve
.push_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources/plugin/wit-service"))
.unwrap();
let world = resolve
.select_world(&[package], Some("ticket-service-plugin"))
.unwrap();
let core = wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
let wat = wasmprinter::print_bytes(core).unwrap();
let init = wat
.lines()
.find(|l| l.contains("(export \"cm32p2_initialize\""))
.unwrap();
let index = init
.split("(func ")
.nth(1)
.unwrap()
.split(')')
.next()
.unwrap();
let needle = wat
.lines()
.find(|l| l.contains(&format!("(func (;{index};)")))
.unwrap();
assert!(needle.trim_end().ends_with("))"), "{needle}");
let import = wat
.lines()
.find(|l| {
l.contains(if logging {
"yoi:host/logging@2"
} else {
"yoi:host/connections@2"
}) && l.contains(if logging { "\"log\"" } else { "\"open\"" })
})
.unwrap();
let imported_index = import
.split("(func (;")
.nth(1)
.unwrap()
.split(';')
.next()
.unwrap();
let body = if logging {
"i32.const 0 ".repeat(7) + &format!("i32.const 128 call {imported_index}")
} else {
format!("i32.const 0 i32.const 0 i32.const 128 call {imported_index}")
};
let replacement = format!("{} {body})", needle.trim_end().strip_suffix(')').unwrap());
let wat = wat
.replace(needle, &replacement)
.replace("(memory (;0;) 0)", "(memory (;0;) 1)");
let mut core = wat::parse_str(wat).unwrap();
wit_component::embed_component_metadata(
&mut core,
&resolve,
world,
wit_component::StringEncoding::UTF8,
)
.unwrap();
wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap()
}
#[tokio::test]
async fn component_start_sections_never_receive_a_service_export_context() {
let ex = Executor::new(Limits::default()).unwrap();
for logging in [false, true] {
let p = package_bytes(MANIFEST, &startup_component(logging)).unwrap();
let c = ex
.compile_service(
&p.bytes,
&p.package_digest,
&p.artifact_digest,
p.artifact_size,
)
.unwrap();
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
let result = ex
.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
r#"{"mode":"normal"}"#,
)
.await;
assert!(
matches!(result.outcome, Err(ExecutionError::Trap(_))),
"{result:?}"
);
assert!(owner.state.lock().unwrap().calls.iter().all(|r| matches!(
r,
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
)));
}
}
#[tokio::test]
async fn artifact_delivery_binds_service_names_and_subscriptions_to_component_package() {
use delivery::{
ArtifactCache, ArtifactExpectation, ArtifactSource, ArtifactStream, CacheLimits,
DeliveryError,
};
struct Source {
bytes: Vec<u8>,
metadata: PluginArtifactDeliveryMetadata,
}
#[async_trait]
impl ArtifactSource for Source {
fn server_authority_identity(&self) -> &str {
"service-test-authority"
}
async fn authorize(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
Ok(self.metadata.clone())
}
async fn fetch(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<ArtifactStream, DeliveryError> {
Ok(Box::pin(std::io::Cursor::new(self.bytes.clone())))
}
}
fn metadata<T: serde::Serialize, U: serde::de::DeserializeOwned>(value: &T) -> U {
serde_json::from_value(serde_json::to_value(value).unwrap()).unwrap()
}
let artifact = package(MANIFEST).unwrap();
let verified = plugin::verify_plugin_artifact(
&artifact.bytes,
&artifact.package_digest,
&artifact.artifact_digest,
artifact.artifact_size,
&PluginPackageLimits::default(),
)
.unwrap();
let m = &verified.package.manifest;
let schema = plugin::plugin_config_schema_identity(m);
let descriptor = PluginArtifact {
package_id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
artifact_size: artifact.artifact_size.try_into().unwrap(),
schema_version: m.schema_version,
world: m.runtime.world.clone(),
required_imports: m.runtime.required_imports.clone(),
tools: metadata(&m.tools),
services: metadata(&m.services),
connections: metadata(&m.connections),
requested_grants: metadata(&m.requested_grants),
data_collections: metadata(&m.data_collections),
config_schema: PluginSchemaContribution {
identity: schema.identity,
version: schema.version,
digest: schema.digest,
namespace: schema.namespace,
dcdl: m.config_schema.clone(),
},
revoked: false,
revocation_reason: None,
};
assert_eq!(
descriptor.services[0].subscriptions,
["ticket_changed", "ticket_deleted"]
);
let request = PluginArtifactDeliveryRequest {
plan_id: "service-plan".into(),
plan_fingerprint: "service-plan-fingerprint".into(),
installation_id: "installation".into(),
artifact_digest: artifact.artifact_digest.clone(),
};
let directory = tempfile::tempdir().unwrap();
let cache = ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap();
let mut source = Source {
bytes: artifact.bytes.clone(),
metadata: PluginArtifactDeliveryMetadata {
workspace_id: "workspace".into(),
request: request.clone(),
artifact: descriptor.clone(),
},
};
let resolved = cache
.resolve(
&source,
"workspace",
&request,
&ArtifactExpectation::new(descriptor.clone()),
)
.await
.unwrap();
assert_eq!(resolved.bytes(), artifact.bytes);
let mut renamed = descriptor.clone();
renamed.services[0].name = "different_service".into();
let mut narrowed = descriptor.clone();
narrowed.services[0].subscriptions = vec!["ticket_changed".into()];
let mut unknown = descriptor.clone();
unknown.services[0].subscriptions = vec!["arbitrary_event".into()];
let mut missing = descriptor.clone();
missing.services.clear();
for tampered in [renamed, narrowed, unknown, missing] {
// Source metadata and the trusted descriptor agree; only verification
// against the actual package can detect this mismatch, including hits.
source.metadata.artifact = tampered.clone();
let expected = ArtifactExpectation::new(tampered);
assert!(matches!(
cache
.resolve(&source, "workspace", &request, &expected)
.await,
Err(DeliveryError::MetadataMismatch)
));
}
source.metadata.artifact = descriptor.clone();
assert_eq!(
cache
.resolve(
&source,
"workspace",
&request,
&ArtifactExpectation::new(descriptor)
)
.await
.unwrap()
.bytes(),
artifact.bytes
);
}
+367
View File
@@ -0,0 +1,367 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "ticket-test-guest"
version = "0.1.0"
dependencies = [
"yoi-plugin-pdk",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn 2.0.119",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.119",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "yoi-plugin-pdk"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
"wit-bindgen",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,13 @@
[package]
name = "ticket-test-guest"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
@@ -0,0 +1,38 @@
schema_version = 2
id = "ticket-test"
name = "Ticket test"
version = "1.0.0"
surfaces = ["tool"]
config_schema = "{}"
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/ticket-tool-plugin@2.1.0"
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"]
[[tools]]
name = "ticket_test"
description = "Exercise the typed Ticket extension boundary"
input_schema = { type = "string" }
output_schema = { type = "null" }
error_codes = ["failed"]
[[data_collections]]
collection = "notes"
schema_id = "note"
schema_version = "1"
schema = { type = "string", maxLength = 64 }
[[requested_grants]]
kind = "ticket_read"
[[requested_grants]]
kind = "ticket_data_read"
collection = "notes"
schema_id = "note"
schema_version = "1"
[[requested_grants]]
kind = "ticket_data_write"
collection = "notes"
schema_id = "note"
schema_version = "1"
@@ -0,0 +1,95 @@
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput, wit_bindgen};
wit_bindgen::generate!({
path: "../../../../resources/plugin/wit-ticket",
world: "ticket-tool-plugin",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
use yoi::host2_1_0::{extension_data as data, objects, ticket_read};
fn namespace() -> data::Collection {
data::Collection {
collection: "notes".into(),
schema_id: "note".into(),
schema_version: "1".into(),
}
}
fn address() -> data::Address {
data::Address {
namespace: namespace(),
key: "main".into(),
}
}
fn failure(error: yoi::host2_0_0::errors::HostError) -> ToolError {
ToolError::failed(format!("{:?}", error.code))
}
fn handle(_: ToolContext, mode: String) -> yoi_plugin_pdk::Result<ToolOutput> {
let ticket = if mode == "foreign" {
unsafe { objects::Ticket::from_handle(0) }
} else {
objects::resolve_ticket("T-795", objects::Access::ExtensionWrite).map_err(failure)?
};
let ticket = if mode == "stale" {
let handle = ticket.handle();
drop(ticket);
unsafe { objects::Ticket::from_handle(handle) }
} else {
ticket
};
let snapshot = ticket_read::read(&ticket).map_err(failure)?;
if mode == "other-schema" {
let mut a = address();
a.namespace.schema_version = "2".into();
data::get(&ticket, &a).map_err(failure)?;
}
if mode == "read" {
return ToolOutput::json(snapshot.title, ());
}
let json = if mode == "bad-schema" {
"{\"undeclared\":true}".into()
} else {
yoi_plugin_pdk::extension_data_json(&"note")?
};
let written = data::put(
&ticket,
&address(),
&data::Expectation::NeverWritten,
"put",
&json,
)
.map_err(failure)?;
assert!(matches!(
data::get(&ticket, &address()).map_err(failure)?,
data::Cell::Present(_)
));
let page = data::list(&ticket, &namespace(), None, 10).map_err(failure)?;
assert_eq!(page.entries.len(), 1);
let lookup = data::lookup_mutation(&ticket, &address(), "put").map_err(failure)?;
assert!(matches!(lookup, data::MutationLookup::Committed(_)));
let deleted =
data::delete(&ticket, &address(), &written.mutation_id, "delete").map_err(failure)?;
assert!(matches!(
data::get(&ticket, &address()).map_err(failure)?,
data::Cell::Deleted(_)
));
data::put(
&ticket,
&address(),
&data::Expectation::LastWrite(deleted.mutation_id),
"recreate",
&json,
)
.map_err(failure)?;
// Replay the original exact request even though the cell has since changed.
let replay = data::put(
&ticket,
&address(),
&data::Expectation::NeverWritten,
"put",
&json,
)
.map_err(failure)?;
assert_eq!(replay.mutation_id, written.mutation_id);
ToolOutput::json(snapshot.title, ())
}
yoi_plugin_pdk::export_ticket_component_tool!(TicketPlugin, handle);
@@ -0,0 +1,345 @@
//! Real Rust PDK -> Component -> Wasmtime -> OwnerHost adapter regression.
//! The deterministic owner here tests the adapter/ABI, not DB durability or
//! production authorization; real owner/storage races belong to workspace-server.
use async_trait::async_trait;
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
use server_api::*;
use std::{
collections::BTreeMap,
fs,
path::Path,
process::Command,
sync::{Arc, Mutex, OnceLock},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::time::Instant;
use yoi_plugin_runtime::{
host::{CancellationToken, Grants, InvocationContext},
owner::{OwnerHost, PluginHostOwner},
*,
};
fn component() -> &'static [u8] {
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
COMPONENT.get_or_init(|| {
// Fixture builds write only this explicitly isolated output directory.
let target =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts/guest");
let output = Command::new(env!("CARGO"))
.args([
"build",
"--locked",
"--offline",
"--release",
"--target",
"wasm32-unknown-unknown",
"--manifest-path",
])
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/ticket-guest/Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let core =
fs::read(target.join("wasm32-unknown-unknown/release/ticket_test_guest.wasm")).unwrap();
let bytes = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
fs::write(
target.parent().unwrap().join("ticket-tool.component.wasm"),
&bytes,
)
.unwrap();
bytes
})
}
fn artifact(manifest: &str) -> plugin::CanonicalPluginArtifact {
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts");
fs::create_dir_all(&target).unwrap();
let dir = tempfile::tempdir_in(target).unwrap();
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
fs::write(dir.path().join("plugin.component.wasm"), component()).unwrap();
let package = plugin::read_plugin_directory(
dir.path(),
PluginSourceKind::Explicit,
&PluginPackageLimits::default(),
)
.unwrap();
plugin::pack_plugin_package(&package, &PluginPackageLimits::default()).unwrap()
}
struct State {
cell: PluginDataCell,
receipts: BTreeMap<String, (PluginHostRequest, PluginDataReceipt)>,
calls: Vec<PluginHostRequest>,
}
struct Owner {
state: Mutex<State>,
revoke_after_resolve: bool,
}
impl Owner {
fn new(revoke_after_resolve: bool) -> Self {
Self {
state: Mutex::new(State {
cell: PluginDataCell::NeverWritten,
receipts: BTreeMap::new(),
calls: vec![],
}),
revoke_after_resolve,
}
}
}
fn rejected(code: PluginHostErrorCode) -> PluginHostError {
PluginHostError {
code,
effect: PluginEffectObservation::NotDispatched,
diagnostic_id: "isolated-test".into(),
}
}
#[async_trait]
impl PluginHostOwner for Owner {
async fn call(
&self,
request: PluginHostRequest,
) -> Result<PluginHostResponse, PluginHostError> {
let mut s = self.state.lock().unwrap();
s.calls.push(request.clone());
Ok(match request.clone() {
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
invocation_id: "logical-invocation".into(),
expires_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
+ 60_000,
},
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
assert_eq!(ticket_ref, "T-795");
PluginHostResponse::TicketResolved {
ticket_id: "canonical-ticket".into(),
}
}
PluginHostRequest::ReadTicket { ticket_id, .. } => {
assert_eq!(ticket_id, "canonical-ticket");
if self.revoke_after_resolve {
return Err(rejected(PluginHostErrorCode::Denied));
}
PluginHostResponse::TicketRead {
snapshot: PluginTicketSnapshot {
title: "unchanged title".into(),
case_state: PluginTicketCaseState::Open,
content_digest: "unchanged digest".into(),
body: "bounded body".into(),
truncated: false,
},
}
}
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
cell: s.cell.clone(),
},
PluginHostRequest::DataList { .. } => PluginHostResponse::DataListed {
page: PluginDataPage {
entries: vec![PluginDataEntry {
key: "main".into(),
cell: s.cell.clone(),
}],
next_cursor: None,
},
},
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
PluginHostResponse::DataMutationLookup {
outcome: s
.receipts
.get(&operation_key)
.map(|(_, r)| PluginDataMutationLookup::Committed(r.clone()))
.unwrap_or(PluginDataMutationLookup::NotRecorded),
}
}
PluginHostRequest::DataMutate {
operation_key,
mutation,
..
} => {
if let Some((original, receipt)) = s.receipts.get(&operation_key) {
if *original != request {
return Err(rejected(PluginHostErrorCode::Conflict));
}
return Ok(PluginHostResponse::DataMutated {
receipt: receipt.clone(),
});
}
let observed = match &s.cell {
PluginDataCell::NeverWritten => PluginDataExpectation::NeverWritten,
PluginDataCell::Present { last_write, .. } => {
PluginDataExpectation::LastWrite(last_write.clone())
}
PluginDataCell::Deleted { last_delete } => {
PluginDataExpectation::LastWrite(last_delete.clone())
}
};
let id = format!("mutation-{operation_key}");
s.cell = match mutation {
PluginDataMutation::Put {
expected,
value_json,
} => {
if expected != observed {
return Err(rejected(PluginHostErrorCode::Conflict));
}
PluginDataCell::Present {
value_json,
last_write: id.clone(),
}
}
PluginDataMutation::Delete {
expected_last_write,
} => {
if PluginDataExpectation::LastWrite(expected_last_write) != observed {
return Err(rejected(PluginHostErrorCode::Conflict));
}
PluginDataCell::Deleted {
last_delete: id.clone(),
}
}
};
let receipt = PluginDataReceipt {
mutation_id: id,
result_digest: "result-digest".into(),
};
s.receipts.insert(operation_key, (request, receipt.clone()));
PluginHostResponse::DataMutated { receipt }
}
_ => return Err(rejected(PluginHostErrorCode::Unsupported)),
})
}
}
fn context(compiled: &CompiledTool) -> InvocationContext {
let namespace: (String, String, String) = ("notes".into(), "note".into(), "1".into());
InvocationContext {
workspace: "ws".into(),
installation: "installation".into(),
plan: "plan".into(),
subject: "subject".into(),
invocation_id: "not-authority".into(),
artifact: compiled.identity().clone(),
tool_name: "ticket_test".into(),
grants: Grants {
ticket_read: true,
ticket_data_read: [namespace.clone()].into(),
ticket_data_write: [namespace].into(),
..Default::default()
},
deadline: Instant::now() + Duration::from_secs(60),
cancellation: CancellationToken::new(),
}
}
async fn run(
ex: &Executor,
compiled: &CompiledTool,
owner: Arc<Owner>,
mode: &str,
granted: bool,
) -> Result<InvocationResult, ExecutionError> {
let mut ctx = context(compiled);
if !granted {
ctx.grants.ticket_data_write.clear();
}
let host = Arc::new(
OwnerHost::begin(
owner,
ctx,
PluginConsumer::ToolCall {
tool_call_id: "call".into(),
tool_name: "ticket_test".into(),
},
)
.await
.unwrap(),
);
ex.invoke(
compiled,
host.context().clone(),
host,
"ticket_test",
&format!("\"{mode}\""),
"{}",
)
.await
}
#[tokio::test]
async fn actual_component_resolves_reads_mutates_lists_and_reconciles_receipts() {
let artifact = artifact(include_str!("ticket-guest/plugin.toml"));
let ex = Executor::new(Limits::default()).unwrap();
let compiled = ex
.compile(
&artifact.bytes,
&artifact.package_digest,
&artifact.artifact_digest,
artifact.artifact_size,
)
.unwrap();
let owner = Arc::new(Owner::new(false));
let result = run(&ex, &compiled, owner.clone(), "all", true)
.await
.unwrap();
assert_eq!(result.output.summary, "unchanged title");
let s = owner.state.lock().unwrap();
assert_eq!(s.receipts.len(), 3); // put/delete/recreate, not a fourth replay
assert!(
matches!(&s.cell, PluginDataCell::Present { last_write, .. } if last_write == "mutation-recreate")
);
assert_eq!(
s.calls
.iter()
.filter(|r| matches!(r, PluginHostRequest::DataMutate { .. }))
.count(),
4
);
}
#[tokio::test]
async fn actual_component_denies_old_grants_foreign_resources_revocation_and_bad_schema() {
let artifact = artifact(include_str!("ticket-guest/plugin.toml"));
let ex = Executor::new(Limits::default()).unwrap();
let compiled = ex
.compile(
&artifact.bytes,
&artifact.package_digest,
&artifact.artifact_digest,
artifact.artifact_size,
)
.unwrap();
for (mode, revoked, granted) in [
("read", true, true),
("foreign", false, true),
("stale", false, true),
("other-schema", false, true),
("read", false, false),
("bad-schema", false, true),
] {
let owner = Arc::new(Owner::new(revoked));
assert!(
run(&ex, &compiled, owner.clone(), mode, granted)
.await
.is_err(),
"{mode}"
);
assert!(
!owner
.state
.lock()
.unwrap()
.calls
.iter()
.any(|r| matches!(r, PluginHostRequest::DataMutate { .. })),
"rejection precedes write"
);
}
}
+60 -19
View File
@@ -52,7 +52,7 @@ pub enum AuthenticatedInputSource {
/// Assigned whenever a serialized tracked method crosses an untrusted
/// protocol boundary. Receivers must handle it exactly like public input.
UntrustedWire,
/// Stamped only by the operator-owned standalone in-process transport.
/// Stamped only by the operator-owned Standalone Host after local admission.
/// This is not a Backend Account or authority accepted from wire fields.
LocalOperator,
Account {
@@ -103,6 +103,33 @@ impl AuthenticatedInputSource {
}
}
/// Server-owned receipt partition, independent of client-visible ACK identity.
/// Console Notify keeps its original bounded ID without sharing HTTP/chat receipts.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationReceiptScope {
#[default]
Source,
BrowserConsole,
}
impl NotificationReceiptScope {
pub fn namespace(self, source_namespace: String) -> String {
match self {
Self::Source => source_namespace,
Self::BrowserConsole => format!("browser-console:{source_namespace}"),
}
}
}
// Like provenance, a receipt partition is rebound by the authenticated transport.
fn ignore_wire_notification_scope<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<NotificationReceiptScope, D::Error> {
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(NotificationReceiptScope::Source)
}
/// Caller-owned identity for one state-changing Worker command.
///
/// Commands operate on the controller state at admission time, not a caller's
@@ -311,6 +338,8 @@ pub enum Method {
NotifyTracked {
notification_request_id: String,
message: String,
#[serde(default, deserialize_with = "ignore_wire_notification_scope")]
receipt_scope: NotificationReceiptScope,
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
@@ -399,15 +428,12 @@ pub enum Method {
/// Typed lifecycle events sent from a child Worker to its parent.
///
/// Delivered as `Method::WorkerEvent` over the parent's Unix socket. The
/// parent Controller always applies variant-specific side effects
/// (registry / worker-allocation updates). Agent-visible variants are also
/// queued into the notification buffer; control-plane-only variants are
/// not injected into the parent's LLM context.
/// Delivered in-process to the parent Controller's protocol intake. Agent-visible variants
/// are queued into the notification buffer. Reports are advisory: receivers must check the
/// actual child session before treating its work as complete.
///
/// Transport is fire-and-forget; receivers must tolerate out-of-order
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
/// child Worker).
/// Retained legacy control-only variants can be decoded from saved history, but do not
/// register live children, delegate scope, or establish a socket callback.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -429,16 +455,8 @@ pub enum WorkerEvent {
/// Child has stopped (controller loop is exiting).
ShutDown { worker_name: String },
/// Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.
///
/// Control-plane only: receivers apply registry side effects and
/// propagate upward, but do not expose this as an agent notification.
///
/// The parent uses this to add the grandchild to its own
/// `spawned_workers.json` so it can manage the grandchild directly
/// even if the intermediate child dies. The parent then re-fires
/// this event upward (if it has a parent of its own) to maintain
/// the chain to root.
/// Legacy process-child scope report retained for saved Session decoding only.
/// Live Controllers ignore it; only the Host-owned Internal registry grants child scope.
ScopeSubDelegated {
/// Sub-delegating Worker (= the sender itself).
parent_worker: String,
@@ -2253,6 +2271,29 @@ mod tests {
);
}
#[test]
fn notification_receipt_scope_is_not_selected_by_wire_input() {
for scope in [
serde_json::json!("browser_console"),
serde_json::json!({"forged":true}),
serde_json::Value::Null,
] {
let method: Method = serde_json::from_value(serde_json::json!({
"method":"notify_tracked", "params":{
"notification_request_id":"original", "message":"advisory",
"receipt_scope":scope,
"source":{"kind":"account","account_id":"forged"}
}
}))
.unwrap();
assert!(matches!(method, Method::NotifyTracked {
receipt_scope: NotificationReceiptScope::Source,
source: AuthenticatedInputSource::UntrustedWire,
notification_request_id, ..
} if notification_request_id == "original"));
}
}
#[test]
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
for method in ["notify", "notify_tracked"] {
+2
View File
@@ -8,7 +8,9 @@ license.workspace = true
[dependencies]
api-macros = { workspace = true, features = ["reqwest", "axum"] }
manifest.workspace = true
plugin-plan = { path = "../plugin-plan" }
protocol.workspace = true
session-store.workspace = true
serde = { workspace = true, features = ["derive"] }
workdir.workspace = true
+229 -6
View File
@@ -86,6 +86,8 @@ pub enum WorkerRestoreState {
Accepted,
Rejected,
RolledBack,
/// A later Stop superseded this restore; neither rollback nor prior Accepted is asserted.
Superseded,
ReconciliationRequired,
}
@@ -406,6 +408,9 @@ pub enum WorkerInputKind {
pub struct WorkerInput {
pub kind: WorkerInputKind,
pub content: String,
/// Provenance stamped by the authenticated Backend, never by public/model input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<protocol::AuthenticatedInputSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -429,6 +434,17 @@ pub struct BackendJobExecutionBinding {
pub subjektiv_journal: bool,
}
/// Server-authored immutable launch input, retained unchanged across restoration.
/// A snapshot fixes selection and ceilings; each preparation and invocation must
/// still consult the current owner. No credential or cache location is serialized.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PluginExecutionSnapshot {
pub plan_id: String,
pub fingerprint: String,
pub plan: plugin_plan::WorkerPluginExecutionPlan,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CreateWorkerRequest {
pub worker_id: WorkerId,
@@ -445,6 +461,9 @@ pub struct CreateWorkerRequest {
pub workdir_attachment_requests: Vec<WorkingDirectoryAttachmentRequest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<WorkingDirectoryAttachmentClaim>,
/// Enables the optional Runtime-local peer observation provider, not operation
/// authority. Scoped Backend observation follows the Worker Feature and live
/// operation/control checks, including when this saved flag is false.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub worker_observation_enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -453,6 +472,8 @@ pub struct CreateWorkerRequest {
pub workspace_api: Option<WorkspaceApiRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_execution: Option<PluginExecutionSnapshot>,
/// Backend-attested subject or consolidation attachment. Profile policy alone
/// never activates subjektiv for an ordinary Workspace Worker.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
@@ -836,6 +857,34 @@ pub struct SessionMaterialPinResponse {
pub pin_id: String,
}
/// One body-bearing immutable slice. The byte budget includes slice metadata,
/// but not the enclosing availability enum or the caller's durable Job envelope.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionRecordingCaptureRequest {
pub workspace_id: String,
pub source: SessionPublicSource,
pub expected_session_id: String,
pub start: session_store::capture::RecordingPosition,
pub max_entries: usize,
pub max_serialized_bytes: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "availability", rename_all = "snake_case")]
pub enum SessionRecordingCaptureAvailability {
NotMaterialized {
session_id: String,
},
Captured {
slice: session_store::capture::RecordingCaptureSlice,
},
Unavailable {
reason: SessionPublicUnavailableReason,
message: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionMaterialCatalogRequest {
@@ -1163,8 +1212,66 @@ pub struct SubjectCreateRecoveryResponse {
pub operation_id: String,
}
/// Host requests this only after durably fencing the Subject in Backend authority.
/// Permanently exclude the Worker from creation and restoration, and confirm actual
/// execution shutdown. This does not delete Worker or Session records.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SubjectDeleteWorkerRequest {
pub workspace_id: String,
pub worker_id: WorkerId,
pub create_fingerprint: String,
pub operation_id: String,
}
/// Durable admission exclusion plus confirmed execution shutdown/absence. Errors,
/// timeouts and lost responses are unknown outcomes; retry the same full request.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SubjectDeleteWorkerResponse {
pub workspace_id: String,
pub worker_id: WorkerId,
pub create_fingerprint: String,
pub operation_id: String,
/// The canonical live Worker/Session/diagnostic aggregate is physically absent
/// under the permanent creation fence. False is not a deletion receipt.
pub originals_absent: bool,
}
/// Additional erasure of a Subject Job's newly reconciled removal copies.
/// Both identities are exact saved requests; this never changes the old receipt.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SubjectJobRemovalPurgeRequest {
pub deletion: SubjectDeleteWorkerRequest,
pub removal: WorkerRetentionExecutionRequest,
}
/// Confirms current physical erasure under the permanent deletion fence.
/// Operation identity belongs to `removal`, not the Subject deletion operation.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SubjectJobRemovalPurgeResponse {
pub workspace_id: String,
pub worker_id: WorkerId,
pub operation_id: String,
pub input_fingerprint: String,
}
#[api(reqwest, axum)]
pub trait RuntimeApi {
#[post("/v1/subject-job-removal-purge", status = 200, error_status = 400)]
async fn purge_subject_job_removal(
&self,
#[body] request: SubjectJobRemovalPurgeRequest,
) -> Result<SubjectJobRemovalPurgeResponse, RuntimeApiError>;
#[post("/v1/subject-delete-worker", status = 200, error_status = 400)]
async fn subject_delete_worker(
&self,
#[body] request: SubjectDeleteWorkerRequest,
) -> Result<SubjectDeleteWorkerResponse, RuntimeApiError>;
#[post("/v1/subject-create-recovery", status = 200, error_status = 400)]
async fn recover_subject_create(
&self,
@@ -1246,6 +1353,16 @@ pub trait RuntimeApi {
#[body] request: SessionMaterialPinRequest,
) -> Result<SessionMaterialPinResponse, RuntimeApiError>;
#[post(
"/v1/session-public/recording-capture",
status = 200,
error_status = 400
)]
async fn session_recording_capture(
&self,
#[body] request: SessionRecordingCaptureRequest,
) -> Result<SessionRecordingCaptureAvailability, RuntimeApiError>;
#[post(
"/v1/session-public/material/catalog",
status = 200,
@@ -1510,6 +1627,30 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
mod tests {
use super::*;
#[test]
fn trusted_notify_source_round_trips_and_omitted_source_defaults_to_none() {
let mut value = serde_json::json!({
"kind": "notify", "content": "message", "submission_request_id": "stable-id"
});
let legacy: WorkerInput = serde_json::from_value(value.clone()).unwrap();
assert!(legacy.source.is_none());
assert_eq!(serde_json::to_value(legacy).unwrap(), value);
for source in [
protocol::AuthenticatedInputSource::Account {
account_id: "account-a".into(),
},
protocol::AuthenticatedInputSource::Worker {
runtime_id: "runtime-a".into(),
worker_id: "worker-a".into(),
},
] {
value["source"] = serde_json::to_value(&source).unwrap();
let input: WorkerInput = serde_json::from_value(value.clone()).unwrap();
assert_eq!(input.source, Some(source));
assert_eq!(serde_json::to_value(input).unwrap(), value);
}
}
#[test]
fn backend_job_journal_grant_defaults_off_and_does_not_enable_memory_grant() {
let legacy = serde_json::json!({"job_id":"job", "attempt_id":"attempt", "subjektiv_consolidation":true});
@@ -1540,13 +1681,24 @@ mod tests {
)
.unwrap();
assert!(request.preparation.is_none());
for state in [
"accepted",
"rejected",
"rolled_back",
"reconciliation_required",
for (state, wire) in [
(WorkerRestoreState::Accepted, "accepted"),
(WorkerRestoreState::Rejected, "rejected"),
(WorkerRestoreState::RolledBack, "rolled_back"),
(WorkerRestoreState::Superseded, "superseded"),
(
WorkerRestoreState::ReconciliationRequired,
"reconciliation_required",
),
] {
assert!(serde_json::from_value::<WorkerRestoreState>(serde_json::json!(state)).is_ok());
assert_eq!(
serde_json::from_value::<WorkerRestoreState>(serde_json::json!(wire)).unwrap(),
state
);
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::json!(wire)
);
}
assert!(
serde_json::from_value::<WorkerRestoreState>(serde_json::json!(
@@ -1704,6 +1856,31 @@ mod tests {
) -> Result<SessionMaterialPinResponse, RuntimeApiError> {
Err(test_error(501))
}
async fn purge_subject_job_removal(
&self,
request: SubjectJobRemovalPurgeRequest,
) -> Result<SubjectJobRemovalPurgeResponse, RuntimeApiError> {
Ok(SubjectJobRemovalPurgeResponse {
workspace_id: request.removal.workspace_id,
worker_id: request.removal.worker_id,
operation_id: request.removal.operation_id,
input_fingerprint: request.removal.input_fingerprint,
})
}
async fn subject_delete_worker(
&self,
request: SubjectDeleteWorkerRequest,
) -> Result<SubjectDeleteWorkerResponse, RuntimeApiError> {
Ok(SubjectDeleteWorkerResponse {
originals_absent: true,
workspace_id: request.workspace_id,
worker_id: request.worker_id,
create_fingerprint: request.create_fingerprint,
operation_id: request.operation_id,
})
}
async fn recover_subject_create(
&self,
request: SubjectCreateRecoveryRequest,
@@ -1730,6 +1907,13 @@ mod tests {
Err(test_error(501))
}
async fn session_recording_capture(
&self,
_request: SessionRecordingCaptureRequest,
) -> Result<SessionRecordingCaptureAvailability, RuntimeApiError> {
Err(test_error(501))
}
async fn session_material_catalog(
&self,
_request: SessionMaterialCatalogRequest,
@@ -1876,6 +2060,45 @@ mod tests {
assert_eq!(response.create_fingerprint, recovery.create_fingerprint);
assert_eq!(response.operation_id, recovery.operation_id);
let deletion = SubjectDeleteWorkerRequest {
workspace_id: "workspace-a".into(),
worker_id: WorkerId::now_v7(),
create_fingerprint: "deleted-create".into(),
operation_id: "delete-1".into(),
};
let receipt = client
.subject_delete_worker(deletion.clone())
.await
.unwrap();
let mut expected = serde_json::to_value(&deletion).unwrap();
expected["originals_absent"] = true.into();
assert_eq!(serde_json::to_value(receipt).unwrap(), expected);
let purge = SubjectJobRemovalPurgeRequest {
removal: WorkerRetentionExecutionRequest {
operation_id: "old-removal".into(),
input_fingerprint: "old-removal-fingerprint".into(),
archive_id: Some("old-archive".into()),
workspace_id: deletion.workspace_id.clone(),
source_runtime_id: "runtime-a".into(),
worker_id: deletion.worker_id,
source_created_at: "2026-01-01T00:00:00Z".into(),
removed_at: "2026-01-02T00:00:00Z".into(),
effective_profile: None,
retention_class: None,
policy_id: "old-policy".into(),
session_disposition: SessionDisposition::Archive,
diagnostics_disposition: DiagnosticsDisposition::Retain,
},
deletion,
};
let receipt = client
.purge_subject_job_removal(purge.clone())
.await
.unwrap();
assert_eq!(receipt.workspace_id, purge.removal.workspace_id);
assert_eq!(receipt.worker_id, purge.removal.worker_id);
assert_eq!(receipt.operation_id, purge.removal.operation_id);
assert_eq!(receipt.input_fingerprint, purge.removal.input_fingerprint);
let summary = client.runtime_summary().await.unwrap();
assert_eq!(summary.runtime.display_name.as_deref(), Some("round-trip"));
let workers = client
+7
View File
@@ -12,6 +12,7 @@ typescript = ["dep:ts-rs", "protocol/typescript"]
[dependencies]
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
memory.workspace = true
plugin-plan.workspace = true
merge-request.workspace = true
protocol = { workspace = true, features = ["json-schema"] }
runtime-api.workspace = true
@@ -21,10 +22,12 @@ serde_json.workspace = true
sha2.workspace = true
ticket.workspace = true
ts-rs = { version = "12.0.1", optional = true }
url.workspace = true
webauthn-rs-proto = "0.5.5"
workdir.workspace = true
[dev-dependencies]
tempfile.workspace = true
tokio.workspace = true
[[example]]
@@ -59,3 +62,7 @@ required-features = ["typescript"]
[[example]]
name = "generate_drive_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_plugin_api_types"
required-features = ["typescript"]
@@ -0,0 +1,6 @@
fn main() {
print!(
"{}",
server_api::normalized_typescript(server_api::plugin_api_typescript())
);
}
+843 -29
View File
@@ -1,33 +1,33 @@
//! Neutral explicit-allow authorization contracts. Resource containment never crosses kinds.
//!
//! # Trusted Feature boundary
//! # Trusted operation boundary
//!
//! A Profile requests execution configuration, not authority. The Worker asks the
//! Host for each enabled Feature's exact activation name before initialization,
//! then trusted Feature code asks for each protected operation and its semantic
//! resource before access or effects. `ticket` on [`PermissionResource::Feature`]
//! activates Ticket; it implies neither `ticket.create` nor any future operation.
//! `ticket.create` on a Workspace permits creation there but cannot activate the
//! Feature. There are no wildcard permissions or implicit dotted-name inheritance.
//! A Profile requests local composition, not authority. Feature registration and
//! session-local state need no authorization. Trusted Feature/Host adapters check
//! each protected read, model supply, subscription or effect immediately before
//! it occurs; continued background work repeats that check. MCP alone retains
//! temporary exact activation permission on [`PermissionResource::Feature`]
//! before discovery can spawn a process.
//!
//! Identity is absent from the authorization request intentionally: the Host
//! authenticates the bound Runtime/Worker and resolves current source permissions,
//! body ownership and any per-Worker execution grant. A shared client, Profile,
//! remembered receipt or caller-supplied Subject claim is not authority. Features
//! depend on this contract, never on a particular Subject policy implementation.
//! Identity is absent from requests: the Host authenticates the bound Runtime/Worker
//! and resolves current source policy, body ownership, delegation and Job attenuation.
//! Bound target queries resolve existing connections, never caller-supplied source
//! IDs. A remembered receipt, Profile or shared client is not authority; transport
//! failure never means standalone.
//!
//! Supported cooperative boundaries are Feature activation, ordinary Worker
//! delegation, Ticket creation (Tool/native WIP/typed service), Workdir filesystem
//! read/write and whole-attachment command execution, and scoped result notify.
//! Workdir providers still enforce resolved paths, traversal, capability, Ticket
//! and provider ceilings. The returned scope is for provider narrowing, not a
//! transferable credential. Scoped checkout/overlap operations are refused until
//! their complete access semantics are adapted. Other Feature operations retain
//! their existing owner checks; they do not automatically acquire this operation
//! policy. Missing calls are trusted Feature bugs, not interception by a universal
//! Tool executor or protection against malicious Feature code. Connection tests
//! must exercise each exposed surface, assert a genuine permitted effect and
//! assert zero downstream calls on denial, including revocation and provider denial.
//! Workspace permissions cover collection operations only, not individual resources
//! of another kind. Exact names never imply dotted descendants. Legacy Feature and
//! Workspace grants retain their wire representation and exact meaning; reading saved
//! policy/grants does not issue permissions for newly protected operations/resources.
//! Non-MCP Feature grants remain inert; no schema rewrite or baseline grants are needed.
//!
//! Authorization composes with provider checks: ownership, resolved paths, capabilities,
//! Ticket actor conditions, observation/CAS, review capability, immutable Job batch and
//! attempt/writer fences remain mandatory. Queries use identity metadata, not protected
//! bodies, and do not certify existence. Missing targets are still refused by providers.
//! Connection tests observe permitted real work and zero downstream work on refusal,
//! including revocation. Missing calls are trusted Feature bugs, not interception by a
//! universal Tool executor or a sandbox for malicious Feature code.
//!
//! # Delegation callers and recovery
//!
@@ -42,8 +42,10 @@
//! terminal confirmation and does not undo completed effects.
//!
//! A child receives no Subject body identity, singleton, whole Memory or public
//! conversation-post authority. `worker.result` is explicit notify authority to a
//! named origin Worker, not arbitrary control or publication. Chat delivery and
//! conversation-post authority. `worker.messaging` permits the messaging operation;
//! the Worker Feature's Backend control grant separately limits its target and direction.
//! Stored `worker.result` and `worker.notify` names are read compatibility only, not
//! result publication, arbitrary control or current request names. Chat delivery and
//! conversation correlation belong to the caller integration, not this contract.
//! Unset existing Subject policies deny new Subject execution/delegation; ordinary
//! non-Subject Workers explicitly retain existing Backend/provider contracts.
@@ -51,12 +53,68 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
mod catalog;
pub use catalog::*;
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PermissionResource {
PluginInstallation {
workspace_id: String,
installation_id: String,
},
Job {
workspace_id: String,
job_id: String,
},
Ticket {
workspace_id: String,
ticket_id: String,
},
Objective {
workspace_id: String,
objective_id: String,
},
MergeRequest {
workspace_id: String,
merge_request_id: String,
},
Subject {
workspace_id: String,
subject_id: String,
},
DriveEntry {
workspace_id: String,
node_id: String,
},
DriveRequest {
workspace_id: String,
request_id: String,
},
Repository {
workspace_id: String,
repository_key: String,
},
WorkdirAttachment {
workspace_id: String,
connection_id: String,
},
WorkspaceConfig {
workspace_id: String,
path: String,
recursive: bool,
},
Network {
url: String,
},
SubWorker {
runtime_id: String,
worker_id: String,
name: String,
},
Feature,
Workspace {
workspace_id: String,
@@ -66,6 +124,11 @@ pub enum PermissionResource {
path: String,
recursive: bool,
},
/// Authorizes operation use, not a target Worker or a Workspace collection.
/// The Worker Feature must still enforce its directional target control grant.
WorkerOperation {
workspace_id: String,
},
Worker {
runtime_id: String,
worker_id: String,
@@ -79,6 +142,59 @@ impl<'de> Deserialize<'de> for PermissionResource {
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum Wire {
PluginInstallation {
workspace_id: String,
installation_id: String,
},
Job {
workspace_id: String,
job_id: String,
},
Ticket {
workspace_id: String,
ticket_id: String,
},
Objective {
workspace_id: String,
objective_id: String,
},
MergeRequest {
workspace_id: String,
merge_request_id: String,
},
Subject {
workspace_id: String,
subject_id: String,
},
DriveEntry {
workspace_id: String,
node_id: String,
},
DriveRequest {
workspace_id: String,
request_id: String,
},
Repository {
workspace_id: String,
repository_key: String,
},
WorkdirAttachment {
workspace_id: String,
connection_id: String,
},
WorkspaceConfig {
workspace_id: String,
path: String,
recursive: bool,
},
Network {
url: String,
},
SubWorker {
runtime_id: String,
worker_id: String,
name: String,
},
Feature {},
Workspace {
workspace_id: String,
@@ -88,12 +204,104 @@ impl<'de> Deserialize<'de> for PermissionResource {
path: String,
recursive: bool,
},
WorkerOperation {
workspace_id: String,
},
Worker {
runtime_id: String,
worker_id: String,
},
}
Ok(match Wire::deserialize(deserializer)? {
Wire::PluginInstallation {
workspace_id,
installation_id,
} => Self::PluginInstallation {
workspace_id,
installation_id,
},
Wire::Job {
workspace_id,
job_id,
} => Self::Job {
workspace_id,
job_id,
},
Wire::Ticket {
workspace_id,
ticket_id,
} => Self::Ticket {
workspace_id,
ticket_id,
},
Wire::Objective {
workspace_id,
objective_id,
} => Self::Objective {
workspace_id,
objective_id,
},
Wire::MergeRequest {
workspace_id,
merge_request_id,
} => Self::MergeRequest {
workspace_id,
merge_request_id,
},
Wire::Subject {
workspace_id,
subject_id,
} => Self::Subject {
workspace_id,
subject_id,
},
Wire::DriveEntry {
workspace_id,
node_id,
} => Self::DriveEntry {
workspace_id,
node_id,
},
Wire::DriveRequest {
workspace_id,
request_id,
} => Self::DriveRequest {
workspace_id,
request_id,
},
Wire::Repository {
workspace_id,
repository_key,
} => Self::Repository {
workspace_id,
repository_key,
},
Wire::WorkdirAttachment {
workspace_id,
connection_id,
} => Self::WorkdirAttachment {
workspace_id,
connection_id,
},
Wire::WorkspaceConfig {
workspace_id,
path,
recursive,
} => Self::WorkspaceConfig {
workspace_id,
path,
recursive,
},
Wire::Network { url } => Self::Network { url },
Wire::SubWorker {
runtime_id,
worker_id,
name,
} => Self::SubWorker {
runtime_id,
worker_id,
name,
},
Wire::Feature {} => Self::Feature,
Wire::Workspace { workspace_id } => Self::Workspace { workspace_id },
Wire::Workdir {
@@ -105,6 +313,7 @@ impl<'de> Deserialize<'de> for PermissionResource {
path,
recursive,
},
Wire::WorkerOperation { workspace_id } => Self::WorkerOperation { workspace_id },
Wire::Worker {
runtime_id,
worker_id,
@@ -117,10 +326,135 @@ impl<'de> Deserialize<'de> for PermissionResource {
}
impl PermissionResource {
/// Checks Workspace membership without a target existence or data lookup.
pub fn workspace_id(&self) -> Option<&str> {
match self {
Self::PluginInstallation { workspace_id, .. }
| Self::Job { workspace_id, .. }
| Self::Ticket { workspace_id, .. }
| Self::Objective { workspace_id, .. }
| Self::MergeRequest { workspace_id, .. }
| Self::Subject { workspace_id, .. }
| Self::DriveEntry { workspace_id, .. }
| Self::DriveRequest { workspace_id, .. }
| Self::Repository { workspace_id, .. }
| Self::WorkdirAttachment { workspace_id, .. }
| Self::WorkspaceConfig { workspace_id, .. }
| Self::WorkerOperation { workspace_id }
| Self::Workspace { workspace_id } => Some(workspace_id),
_ => None,
}
}
pub fn validate(&self) -> Result<(), String> {
match self {
Self::PluginInstallation {
workspace_id,
installation_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(installation_id)
}
Self::Job {
workspace_id,
job_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(job_id)
}
Self::Ticket {
workspace_id,
ticket_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(ticket_id)
}
Self::Objective {
workspace_id,
objective_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(objective_id)
}
Self::MergeRequest {
workspace_id,
merge_request_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(merge_request_id)
}
Self::Subject {
workspace_id,
subject_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(subject_id)
}
Self::DriveEntry {
workspace_id,
node_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(node_id)
}
Self::DriveRequest {
workspace_id,
request_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(request_id)
}
Self::Repository {
workspace_id,
repository_key,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(repository_key)
}
Self::WorkdirAttachment {
workspace_id,
connection_id,
} => {
validate_authorization_identifier(workspace_id)?;
validate_authorization_identifier(connection_id)
}
Self::WorkspaceConfig {
workspace_id, path, ..
} => {
validate_authorization_identifier(workspace_id)?;
if path.is_empty() {
Ok(())
} else {
validate_relative_permission_path(path)
}
}
Self::Network { url } => {
let parsed = url::Url::parse(url)
.map_err(|_| "network resource must be an absolute HTTP(S) URL")?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| url.len() > 8192
|| url.chars().any(char::is_control)
{
return Err("network resource must be a bounded absolute HTTP(S) URL without credentials".into());
}
Ok(())
}
Self::SubWorker {
runtime_id,
worker_id,
name,
} => {
validate_authorization_identifier(runtime_id)?;
validate_authorization_identifier(worker_id)?;
validate_authorization_identifier(name)
}
Self::Feature => Ok(()),
Self::Workspace { workspace_id } => validate_authorization_identifier(workspace_id),
Self::Workspace { workspace_id } | Self::WorkerOperation { workspace_id } => {
validate_authorization_identifier(workspace_id)
}
Self::Workdir {
working_directory_id,
path,
@@ -144,8 +478,149 @@ impl PermissionResource {
return false;
}
match (self, other) {
(
Self::PluginInstallation {
workspace_id: a,
installation_id: ai,
},
Self::PluginInstallation {
workspace_id: b,
installation_id: bi,
},
) => a == b && ai == bi,
(
Self::Job {
workspace_id: a,
job_id: ai,
},
Self::Job {
workspace_id: b,
job_id: bi,
},
) => a == b && ai == bi,
(
Self::Ticket {
workspace_id: a,
ticket_id: ai,
},
Self::Ticket {
workspace_id: b,
ticket_id: bi,
},
) => a == b && ai == bi,
(
Self::Objective {
workspace_id: a,
objective_id: ai,
},
Self::Objective {
workspace_id: b,
objective_id: bi,
},
) => a == b && ai == bi,
(
Self::MergeRequest {
workspace_id: a,
merge_request_id: ai,
},
Self::MergeRequest {
workspace_id: b,
merge_request_id: bi,
},
) => a == b && ai == bi,
(
Self::Subject {
workspace_id: a,
subject_id: ai,
},
Self::Subject {
workspace_id: b,
subject_id: bi,
},
) => a == b && ai == bi,
(
Self::DriveEntry {
workspace_id: a,
node_id: ai,
},
Self::DriveEntry {
workspace_id: b,
node_id: bi,
},
) => a == b && ai == bi,
(
Self::DriveRequest {
workspace_id: a,
request_id: ai,
},
Self::DriveRequest {
workspace_id: b,
request_id: bi,
},
) => a == b && ai == bi,
(
Self::Repository {
workspace_id: a,
repository_key: ai,
},
Self::Repository {
workspace_id: b,
repository_key: bi,
},
) => a == b && ai == bi,
(
Self::WorkdirAttachment {
workspace_id: a,
connection_id: ai,
},
Self::WorkdirAttachment {
workspace_id: b,
connection_id: bi,
},
) => a == b && ai == bi,
(Self::Network { url: a }, Self::Network { url: b }) => a == b,
(
Self::SubWorker {
runtime_id: a,
worker_id: aw,
name: an,
},
Self::SubWorker {
runtime_id: b,
worker_id: bw,
name: bn,
},
) => a == b && aw == bw && an == bn,
(
Self::WorkspaceConfig {
workspace_id: a,
path: ap,
recursive: ar,
},
Self::WorkspaceConfig {
workspace_id: b,
path: bp,
recursive: br,
},
) => {
a == b
&& if *ar {
ap.is_empty()
|| ap == "."
|| ap == bp
|| bp
.strip_prefix(ap)
.is_some_and(|tail| tail.starts_with("/"))
} else {
ap == bp && !br
}
}
(Self::Feature, Self::Feature) => true,
(Self::Workspace { workspace_id: a }, Self::Workspace { workspace_id: b }) => a == b,
(
Self::WorkerOperation { workspace_id: a },
Self::WorkerOperation { workspace_id: b },
) => a == b,
(
Self::Worker {
runtime_id: ar,
@@ -184,6 +659,14 @@ impl PermissionResource {
}
}
/// Stored scopes retain their original resources and exact operation names.
///
/// A legacy Worker read adapter may use [`canonical_worker_operation`] to project
/// operation use, but must retain the original target separately for the Worker
/// Feature's control-grant check, including direction, revocation and delegation
/// limits. Replacing an old Worker resource with WorkerOperation and discarding
/// that target would broaden authority. Neither validation nor `covers` performs
/// that conversion.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -199,6 +682,14 @@ impl ScopedPermission {
if matches!(self.resource, PermissionResource::Feature) && self.operation.contains('.') {
return Err("Feature resources authorize activation names only, not operations".into());
}
if matches!(self.resource, PermissionResource::WorkerOperation { .. })
&& canonical_worker_operation(&self.operation) != Some(self.operation.as_str())
{
return Err(
"WorkerOperation resources authorize only exact current Worker control operations"
.into(),
);
}
Ok(())
}
@@ -270,6 +761,18 @@ pub struct OperationAuthorizationRequest {
impl OperationAuthorizationRequest {
pub fn validate(&self) -> Result<(), String> {
if let Some(operation) = canonical_worker_operation(&self.operation) {
if operation != self.operation {
return Err(
"legacy Worker message names are storage-only; request worker.messaging".into(),
);
}
if !matches!(self.resource, PermissionResource::WorkerOperation { .. }) {
return Err(
"current Worker control requests require a WorkerOperation resource".into(),
);
}
}
ScopedPermission {
operation: self.operation.clone(),
resource: self.resource.clone(),
@@ -283,11 +786,58 @@ impl OperationAuthorizationRequest {
#[serde(deny_unknown_fields)]
pub struct OperationAuthorizationResponse {
/// Matching granted scope for provider-resolved checks, not a credential.
/// `None` identifies the explicit non-Subject legacy authorization path.
/// Ordinary checks return `None` on the non-Subject legacy path. Bound
/// metadata lookups can still return the concrete target for consistency checks.
#[serde(default)]
pub scope: Option<PermissionResource>,
}
/// Selects existing Host metadata; caller-supplied selectors are not ownership proof.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum BoundAuthorizationTarget {
Subject {},
WorkdirAttachment {
alias: String,
expected_connection_id: Option<String>,
},
SubWorker {
name: String,
},
NotificationSource {
source_event_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct BoundOperationAuthorizationRequest {
pub operation: String,
pub target: BoundAuthorizationTarget,
}
/// Maps exact runtime Worker control names to the current operation-use contract.
///
/// `worker.notify` and `worker.result` are accepted here only to read saved
/// policy/grants; current requests must use `worker.messaging`. This helper does
/// not grant authority or convert resources: adapters must preserve legacy Worker
/// target restrictions separately. Spawn, discovery/list and Internal SubWorker
/// operations are not part of this contract. Unknown names return `None`.
pub fn canonical_worker_operation(operation: &str) -> Option<&'static str> {
match operation {
"worker.messaging" | "worker.notify" | "worker.result" => Some("worker.messaging"),
"worker.send_input" => Some("worker.send_input"),
"worker.cancel" => Some("worker.cancel"),
"worker.stop" => Some("worker.stop"),
"worker.restore" => Some("worker.restore"),
"worker.remove" => Some("worker.remove"),
"worker.observe" => Some("worker.observe"),
_ => None,
}
}
pub fn validate_operation_name(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.len() > 256
@@ -350,6 +900,56 @@ pub fn validate_relative_permission_path(path: &str) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn plugin_installation_permissions_require_exact_workspace_and_installation() {
let resource = PermissionResource::PluginInstallation {
workspace_id: "ws".into(),
installation_id: "install".into(),
};
assert_eq!(resource.workspace_id(), Some("ws"));
let wire = serde_json::json!({"kind":"plugin_installation","workspace_id":"ws","installation_id":"install"});
assert_eq!(serde_json::to_value(&resource).unwrap(), wire);
assert_eq!(
serde_json::from_value::<PermissionResource>(wire.clone()).unwrap(),
resource
);
assert!(resource.covers(&resource));
for other in [
PermissionResource::PluginInstallation {
workspace_id: "other".into(),
installation_id: "install".into(),
},
PermissionResource::PluginInstallation {
workspace_id: "ws".into(),
installation_id: "other".into(),
},
PermissionResource::Workspace {
workspace_id: "ws".into(),
},
PermissionResource::Feature,
] {
assert!(!resource.covers(&other));
assert!(!other.covers(&resource));
}
for (field, value) in [
("workspace_id", ""),
("installation_id", "*"),
("installation_id", " install"),
] {
let mut invalid = wire.clone();
invalid[field] = value.into();
assert!(
serde_json::from_value::<PermissionResource>(invalid)
.unwrap()
.validate()
.is_err()
);
}
let mut claims = wire;
claims["subject"] = "owner".into();
assert!(serde_json::from_value::<PermissionResource>(claims).is_err());
}
fn workdir(path: &str, recursive: bool) -> PermissionResource {
PermissionResource::Workdir {
working_directory_id: "wd".into(),
@@ -450,6 +1050,220 @@ mod tests {
);
}
#[test]
fn worker_operation_wire_and_schema_require_only_workspace_identity() {
let wire = serde_json::json!({"kind":"worker_operation","workspace_id":"ws"});
let resource: PermissionResource = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(&resource).unwrap(), wire);
assert_eq!(resource.workspace_id(), Some("ws"));
assert!(resource.validate().is_ok());
for invalid in [
serde_json::json!({"kind":"worker_operation"}),
serde_json::json!({"kind":"worker_operation","workspace_id":"ws","worker_id":"w"}),
serde_json::json!({"kind":"worker_operation","workspace_id":"ws","runtime_id":"r"}),
] {
assert!(serde_json::from_value::<PermissionResource>(invalid).is_err());
}
for workspace_id in ["", " ws", "ws\n", "*", &"x".repeat(257)] {
let resource: PermissionResource = serde_json::from_value(
serde_json::json!({"kind":"worker_operation","workspace_id":workspace_id}),
)
.unwrap();
assert!(resource.validate().is_err(), "{workspace_id:?}");
assert!(!resource.covers(&resource));
}
let schema = serde_json::to_value(schemars::schema_for!(PermissionResource)).unwrap();
let variant = schema["oneOf"]
.as_array()
.unwrap()
.iter()
.find(|variant| variant["properties"]["kind"]["const"] == "worker_operation")
.unwrap();
assert_eq!(variant["properties"]["workspace_id"]["type"], "string");
assert_eq!(
variant["required"],
serde_json::json!(["kind", "workspace_id"])
);
assert_eq!(variant["additionalProperties"], false);
}
#[test]
fn worker_operation_containment_never_crosses_workspace_or_resource_kind() {
let resource = PermissionResource::WorkerOperation {
workspace_id: "ws".into(),
};
assert!(resource.covers(&resource));
for other in [
PermissionResource::WorkerOperation {
workspace_id: "other".into(),
},
PermissionResource::Workspace {
workspace_id: "ws".into(),
},
PermissionResource::Worker {
runtime_id: "r".into(),
worker_id: "w".into(),
},
PermissionResource::SubWorker {
runtime_id: "r".into(),
worker_id: "w".into(),
name: "child".into(),
},
] {
assert!(!resource.covers(&other), "{other:?}");
assert!(!other.covers(&resource), "{other:?}");
}
}
#[test]
fn worker_operation_scopes_accept_only_seven_exact_current_operations() {
let resource = PermissionResource::WorkerOperation {
workspace_id: "ws".into(),
};
for operation in [
"worker.messaging",
"worker.send_input",
"worker.cancel",
"worker.stop",
"worker.restore",
"worker.remove",
"worker.observe",
] {
assert_eq!(canonical_worker_operation(operation), Some(operation));
let permission = ScopedPermission {
operation: operation.into(),
resource: resource.clone(),
};
assert!(permission.validate().is_ok(), "{operation}");
assert!(permission.covers(&permission));
assert!(
OperationAuthorizationRequest {
operation: operation.into(),
resource: resource.clone(),
}
.validate()
.is_ok()
);
for old_resource in [
PermissionResource::Worker {
runtime_id: "r".into(),
worker_id: "w".into(),
},
PermissionResource::Workspace {
workspace_id: "ws".into(),
},
PermissionResource::SubWorker {
runtime_id: "r".into(),
worker_id: "w".into(),
name: "child".into(),
},
] {
assert!(
OperationAuthorizationRequest {
operation: operation.into(),
resource: old_resource,
}
.validate()
.is_err()
);
}
let descendant = format!("{operation}.extra");
assert_eq!(canonical_worker_operation(&descendant), None);
assert!(
ScopedPermission {
operation: descendant,
resource: resource.clone(),
}
.validate()
.is_err()
);
}
for operation in [
"worker",
"worker.spawn",
"worker.lifecycle",
"worker.list",
"worker.input",
"subworker.stop",
"sub_worker.stop",
"ticket.read",
"worker.notify",
"worker.result",
] {
assert!(
ScopedPermission {
operation: operation.into(),
resource: resource.clone(),
}
.validate()
.is_err(),
"{operation}"
);
if !matches!(operation, "worker.notify" | "worker.result") {
assert_eq!(canonical_worker_operation(operation), None);
}
}
}
#[test]
fn legacy_worker_message_storage_stays_exact_and_cannot_authorize_current_requests() {
let current = ScopedPermission {
operation: "worker.messaging".into(),
resource: PermissionResource::WorkerOperation {
workspace_id: "ws".into(),
},
};
for operation in ["worker.notify", "worker.result"] {
assert_eq!(
canonical_worker_operation(operation),
Some("worker.messaging")
);
let wire = serde_json::json!({
"operation":operation,
"resource":{"kind":"worker","runtime_id":"r","worker_id":"w"}
});
let stored: ScopedPermission = serde_json::from_value(wire.clone()).unwrap();
assert!(stored.validate().is_ok());
assert_eq!(serde_json::to_value(&stored).unwrap(), wire);
assert!(stored.covers(&stored));
assert!(!stored.covers(&current));
assert!(!current.covers(&stored));
let renamed = ScopedPermission {
operation: "worker.messaging".into(),
resource: stored.resource.clone(),
};
assert!(!stored.covers(&renamed));
assert!(!renamed.covers(&stored));
for resource in [
stored.resource,
current.resource.clone(),
PermissionResource::Workspace {
workspace_id: "ws".into(),
},
] {
assert!(
OperationAuthorizationRequest {
operation: operation.into(),
resource,
}
.validate()
.is_err()
);
}
}
// Creation and discovery remain outside runtime control operation-use scopes.
assert!(
OperationAuthorizationRequest {
operation: "worker.lifecycle".into(),
resource: PermissionResource::Workspace {
workspace_id: "ws".into()
},
}
.validate()
.is_ok()
);
}
#[test]
fn authorization_response_carries_matching_scope_and_defaults_legacy_absence() {
let legacy: OperationAuthorizationResponse =
@@ -0,0 +1,951 @@
//! Management metadata over the existing policy, not an authorization source.
//!
//! Only the management replacement path validates against this catalog. Historical
//! deserialization and execution grants retain their exact spelling and scope. New
//! operations require an explicit catalog entry; dotted prefixes grant nothing.
use super::*;
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PermissionResourceKind {
PluginInstallation,
Job,
Ticket,
Objective,
MergeRequest,
Subject,
DriveEntry,
DriveRequest,
Repository,
WorkdirAttachment,
WorkspaceConfig,
Network,
SubWorker,
Feature,
Workspace,
Workdir,
Worker,
WorkerOperation,
}
impl PermissionResource {
pub fn kind(&self) -> PermissionResourceKind {
use PermissionResourceKind as K;
match self {
Self::PluginInstallation { .. } => K::PluginInstallation,
Self::Job { .. } => K::Job,
Self::Ticket { .. } => K::Ticket,
Self::Objective { .. } => K::Objective,
Self::MergeRequest { .. } => K::MergeRequest,
Self::Subject { .. } => K::Subject,
Self::DriveEntry { .. } => K::DriveEntry,
Self::DriveRequest { .. } => K::DriveRequest,
Self::Repository { .. } => K::Repository,
Self::WorkdirAttachment { .. } => K::WorkdirAttachment,
Self::WorkspaceConfig { .. } => K::WorkspaceConfig,
Self::Network { .. } => K::Network,
Self::SubWorker { .. } => K::SubWorker,
Self::Feature => K::Feature,
Self::Workspace { .. } => K::Workspace,
Self::Workdir { .. } => K::Workdir,
Self::Worker { .. } => K::Worker,
Self::WorkerOperation { .. } => K::WorkerOperation,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PermissionOperationAvailability {
Available,
Unavailable { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PermissionOperationDescriptor {
pub operation: String,
pub label: String,
pub description: String,
/// Accepted management input kinds, not a claim that a target exists or is usable.
pub resource_kinds: Vec<PermissionResourceKind>,
pub availability: PermissionOperationAvailability,
}
/// Available means the operation/resource contract is implemented, not that the
/// current Profile, provider, target, assignment or Job permits its execution.
/// Body execution and delegation ceilings use the same descriptors, independently.
pub fn subject_permission_operations() -> Vec<PermissionOperationDescriptor> {
use PermissionResourceKind as K;
let mut catalog = Vec::new();
let mut add = |kind, entries: &[(&str, &str, &str)]| {
catalog.extend(entries.iter().map(|(operation, label, description)| {
PermissionOperationDescriptor {
operation: (*operation).into(),
label: (*label).into(),
description: (*description).into(),
resource_kinds: vec![kind],
availability: PermissionOperationAvailability::Available,
}
}));
};
add(
K::Workdir,
&[
(
"filesystem.read",
"Read files",
"Read files and enumerate directories within the selected path and recursive scope; provider scope still applies.",
),
(
"filesystem.write",
"Edit files",
"Create or edit files within the selected path and recursive scope; does not permit commands.",
),
(
"bash.execute",
"Run commands",
"Requires the whole Workdir: path '.' and recursive true. A shell is not confined to a subdirectory. Explicit provider command authority is also required.",
),
(
"workdir.read",
"Inspect Workdir",
"Read selected Workdir metadata; does not grant file access.",
),
(
"workdir.attach",
"Attach Workdir",
"Attach the selected Workdir subject to existing provider and attachment constraints.",
),
(
"workdir.delete",
"Delete Workdir",
"Delete the selected Workdir only if existing occupancy and retention conditions permit it.",
),
],
);
add(
K::Network,
&[
(
"web.fetch",
"Fetch web page",
"Fetch this exact HTTP(S) URL. Each redirect needs its own permission; private/local hosts remain denied.",
),
(
"web.search",
"Search web",
"Contact this exact search provider endpoint; credentials and network restrictions still apply.",
),
],
);
add(
K::Workspace,
&[
(
"ticket.create",
"Create Tickets",
"Create open Tickets in this Workspace; no read or edit rights to existing Tickets are implied.",
),
(
"ticket.query",
"Search Tickets",
"Query the Workspace Ticket collection.",
),
(
"ticket.list",
"List Tickets",
"List the Workspace Ticket collection.",
),
(
"ticket.doctor",
"Diagnose Tickets",
"Inspect Workspace Ticket diagnostics.",
),
(
"ticket.intake.default_body",
"Read intake template",
"Read the default intake transition body.",
),
(
"ticket.tag.list",
"List classification tags",
"Read Workspace classification tags.",
),
(
"ticket.tag.create",
"Create classification tags",
"Create Workspace classification tags, not workflow authority.",
),
(
"ticket.tag.rename",
"Rename classification tags",
"Rename Workspace tags with existing replay and compare checks.",
),
(
"ticket.tag.delete",
"Delete classification tags",
"Delete Workspace tags and their memberships with existing compare checks.",
),
(
"ticket.relation.query",
"Search Ticket relations",
"Query Workspace Ticket relation metadata.",
),
(
"ticket.orchestration_plan.query",
"Read orchestration plans",
"Query retained orchestration plan metadata; does not start work.",
),
(
"objective.query",
"Search Objectives",
"Query the Workspace Objective collection.",
),
(
"objective.create",
"Create Objectives",
"Create an Objective in this Workspace.",
),
(
"repository.list",
"List repositories",
"List repository metadata, not checkout or credential access.",
),
(
"workdir.list",
"List Workdirs",
"List Workdir metadata; does not grant attachment or file access.",
),
(
"workdir.attachment.list",
"List attachments",
"List Workdir attachment metadata.",
),
(
"worker.list",
"List Workers",
"List known Workers. Discovery does not grant control.",
),
(
"worker.discover",
"Discover Workspace Workers",
"Search bounded Worker metadata. Discovery does not grant control or observation.",
),
(
"worker.observe.list",
"List observable Workers",
"List observation subjects; does not grant access to individual sessions.",
),
(
"worker.create",
"Create background Workers",
"Use Profile-resolved Worker creation with explicit execution grants. Directional Backend grants separately control the created Worker and its message-only reply.",
),
(
"drive.capabilities",
"Read Drive capabilities",
"Read provider-supported Drive operations.",
),
(
"drive.root",
"Find Drive root",
"Read the Workspace Drive root reference.",
),
(
"drive.search",
"Search Drive",
"Search the Workspace Drive; provider visibility still applies.",
),
],
);
add(
K::Ticket,
&[
(
"ticket.read",
"Read Ticket",
"Read the selected Ticket and its thread.",
),
(
"ticket.edit_item",
"Edit Ticket",
"Edit the selected Ticket content; actor and concurrency checks still apply.",
),
(
"ticket.edit_post",
"Edit Ticket post",
"Edit an eligible post using its exact observed body.",
),
(
"ticket.event.add",
"Add Ticket event",
"Append an allowed typed event to the selected Ticket.",
),
(
"ticket.case.update",
"Judge Ticket case",
"Close, reopen or correct closure with explicit judgment and current compare keys.",
),
(
"ticket.dependency.check",
"Check dependencies",
"Inspect selected Ticket dependencies; does not start them.",
),
(
"ticket.intake_summary.add",
"Add intake summary",
"Append an intake summary to the selected Ticket.",
),
(
"ticket.tags.set",
"Classify Ticket",
"Replace selected Ticket classification tags using the observed full set.",
),
(
"ticket.relation.read",
"Read Ticket relations",
"Read selected Ticket relations.",
),
(
"ticket.relation.record",
"Record Ticket relation",
"Record an explicit relation from this Ticket.",
),
(
"ticket.relation.remove",
"Remove Ticket relation",
"Remove an exact relation from this Ticket.",
),
(
"ticket.orchestration_plan.record",
"Record orchestration plan",
"Record plan metadata for this Ticket; does not grant execution authority.",
),
(
"ticket.mark_ready",
"Update intake readiness",
"Record an intake readiness change; does not grant execution authority.",
),
(
"ticket.queue",
"Request Ticket queueing",
"Use the existing authorized Ticket queue route; does not bypass actor restrictions.",
),
(
"ticket.assignment.assign",
"Delegate Ticket",
"Assign to an authorized Subject; current body, Ticket targets and execution grants are checked separately. Assignment does not start a Worker.",
),
(
"ticket.assignment.clear",
"Release Ticket delegation",
"Release the observed current Subject assignment; does not stop Workers.",
),
(
"ticket.assignment.update",
"Report assignment handling",
"Report handling only for the authenticated authorized current delegation with event CAS.",
),
(
"ticket.assignment.answer",
"Answer assignment",
"Append fenced continuing input to the current assignment; not a new assignment.",
),
(
"ticket.assignment.query",
"Read current assignment",
"Read selected Ticket assignment tracking.",
),
(
"ticket.assignment.history",
"Read assignment history",
"Read retained assignment tracking events.",
),
(
"ticket.assignment.diagnostics",
"Read assignment diagnostics",
"Read retained delivery and start evidence, not execution authority.",
),
(
"ticket.implementation_result.write",
"Report implementation result",
"Save a bounded result, handoff or blocker; not approval or closure.",
),
(
"ticket.implementation_result.read",
"Read implementation result",
"Read a saved implementation result; not a new request.",
),
(
"merge_request.open",
"Open Merge Request",
"Open or reuse a repository-scoped Merge Request for this Ticket.",
),
],
);
add(
K::Objective,
&[
(
"objective.read",
"Read Objective",
"Read selected Objective and linked Ticket context.",
),
(
"objective.edit",
"Edit Objective",
"Edit selected Objective content.",
),
(
"objective.set_state",
"Set Objective state",
"Change selected Objective state.",
),
(
"objective.link_ticket",
"Link Ticket",
"Link a Ticket to this Objective; required Ticket read permission is separate.",
),
(
"objective.unlink_ticket",
"Unlink Ticket",
"Unlink a Ticket from this Objective.",
),
],
);
add(
K::MergeRequest,
&[
(
"merge_request.read",
"Read Merge Request",
"Read selected Merge Request and review evidence.",
),
(
"merge_request.readiness",
"Check MR readiness",
"Check recording-workflow readiness; does not adopt changes.",
),
(
"merge_request.review",
"Review Merge Request",
"Submit a review only with independently bound review capability and current source.",
),
(
"merge_request.complete",
"Record MR integration",
"Record an already-applied exact-source integration with existing approval and source checks.",
),
],
);
add(
K::Repository,
&[
(
"repository.read",
"Read repository metadata",
"Read selected repository metadata without exposing secrets.",
),
(
"workdir.create",
"Create Workdir",
"Materialize selected repository using existing provider grants and explicit selectors.",
),
],
);
add(
K::WorkdirAttachment,
&[
(
"workdir.attachment.read",
"Read attachment",
"Read metadata for this exact attachment connection.",
),
(
"workdir.detach",
"Detach Workdir",
"Detach this exact observed connection; stale aliases do not select a replacement.",
),
],
);
add(
K::WorkspaceConfig,
&[
(
"workspace_config.attach",
"Attach configuration",
"Attach the selected configuration path and recursive scope.",
),
(
"workspace_config.observe",
"Observe configuration",
"Observe selected configuration metadata within path scope.",
),
(
"workspace_config.read",
"Read configuration",
"Read selected configuration within path scope; secret rules still apply.",
),
(
"workspace_config.commit",
"Commit configuration",
"Commit selected configuration within path scope and provider compare checks.",
),
],
);
add(
K::DriveEntry,
&[
(
"drive.metadata",
"Read Drive metadata",
"Read metadata for this exact entry.",
),
(
"drive.list",
"List Drive folder",
"List children of this exact folder; does not include descendants implicitly.",
),
(
"drive.read_text",
"Read Drive text",
"Read text of this exact entry.",
),
(
"drive.read_chunk",
"Read Drive chunk",
"Read a bounded chunk of this exact entry.",
),
(
"drive.create_folder",
"Create Drive folder",
"Create a folder under this exact parent.",
),
(
"drive.create_text",
"Create Drive text",
"Create a text entry under this exact parent.",
),
(
"drive.update_text",
"Update Drive text",
"Update this exact entry with provider concurrency checks.",
),
(
"drive.delete",
"Delete Drive entry",
"Delete this exact entry if provider conditions permit.",
),
(
"drive.relocate",
"Move Drive entry",
"Move an entry; source and destination each require explicit permission.",
),
(
"drive.upload",
"Upload Drive file",
"Upload under this exact parent, subject to provider limits.",
),
],
);
add(
K::DriveRequest,
&[(
"drive.request_status",
"Read Drive request",
"Read the status of this exact provider request; no new execution authority.",
)],
);
add(
K::Feature,
&[(
"mcp",
"Enable MCP (temporary coarse permission)",
"Retained MCP activation permission before stdio discovery. Not per-server, per-tool or per-call authorization; no equivalent gate is needed by other Features.",
)],
);
add(
K::Subject,
&[
(
"session.record",
"Record Subject session",
"Attribute and record sessions for this Subject; current body ownership is still required.",
),
(
"session.list",
"List Subject sessions",
"List sessions attributed to this Subject.",
),
(
"session.search",
"Search Subject sessions",
"Search committed sessions attributed to this Subject.",
),
(
"session.read",
"Read Subject session",
"Read committed entries from this Subject's sessions.",
),
(
"memory.query",
"Search Subject Memory",
"Query this Subject's Memory, not Workspace Memory.",
),
(
"memory.read",
"Read Subject Memory",
"Read this Subject's selected Memory document.",
),
(
"memory.changes.list",
"List Memory changes",
"Read this Subject's retained Memory changes.",
),
(
"memory.resident.read",
"Read resident Memory",
"Read this Subject's resident Memory data.",
),
(
"memory.resident.supply",
"Supply resident Memory",
"Supply protected resident Memory to a model; read permission is separate.",
),
(
"memory.staging.write",
"Stage Memory candidate",
"Write a candidate to this Subject's staging area.",
),
(
"memory.staging.list",
"List Memory candidates",
"List this Subject's staging candidates.",
),
(
"memory.staging.read",
"Read Memory candidate",
"Read this Subject's staging candidate.",
),
(
"memory.candidate.decide",
"Decide Memory candidate",
"Decide a staged candidate using existing Job/domain constraints.",
),
(
"memory.proposal.validate",
"Validate Memory proposal",
"Validate a proposal for this Subject.",
),
(
"memory.receipt.read",
"Read Memory receipt",
"Read retained Memory operation evidence, not new authority.",
),
(
"memory.surface.prepare",
"Prepare Memory surface",
"Prepare this Subject's surface update under domain checks.",
),
(
"memory.surface.supply",
"Supply Memory surface",
"Supply protected surface input to a model.",
),
(
"memory.surface.publish",
"Publish Memory surface",
"Publish this Subject's prepared surface under writer and Job checks.",
),
(
"memory.surface.fail",
"Record surface failure",
"Record failure of this Subject's prepared surface.",
),
(
"memory.extract.read",
"Read extraction input",
"Read protected session input for Memory extraction.",
),
(
"memory.extract.supply",
"Supply extraction input",
"Supply protected extraction input to a model.",
),
(
"memory.extract.commit",
"Commit extraction",
"Commit extracted candidates under existing source/writer checks.",
),
(
"memory.consolidation.request",
"Request consolidation",
"Request a bounded consolidation Job; Job grants and origin constraints remain separate.",
),
(
"memory.consolidation.supply",
"Supply consolidation input",
"Supply frozen consolidation input only within its current Job capability.",
),
(
"journal.startup.read",
"Read startup Journal",
"Read this Subject's startup Journal context.",
),
(
"journal.startup.supply",
"Supply startup Journal",
"Supply protected startup Journal context to a model.",
),
(
"journal.compaction.read",
"Read compaction Journal",
"Read this Subject's compaction Journal context.",
),
(
"journal.compaction.supply",
"Supply compaction Journal",
"Supply protected compaction context to a model.",
),
(
"journal.recording.request",
"Request Journal recording",
"Request a bounded recording Job; does not grant arbitrary Job operations.",
),
(
"journal.job.input.read",
"Read Journal Job input",
"Read frozen input only with the bound current Journal Job capability.",
),
(
"journal.job.input.supply",
"Supply Journal Job input",
"Supply frozen input only with current Job, origin and policy authority.",
),
(
"journal.state.read",
"Read Journal state",
"Read this Subject's Journal state.",
),
(
"journal.text.read",
"Read Journal text",
"Read this Subject's Journal text.",
),
(
"journal.operation.query",
"Query Journal operations",
"Query retained Journal operations.",
),
(
"journal.operation.list",
"List Journal operations",
"List retained Journal operations.",
),
(
"journal.operation.coverage.read",
"Read Journal coverage",
"Read operation coverage evidence.",
),
(
"journal.entry.create",
"Create Journal entry",
"Create an entry within the bound Journal Job.",
),
(
"journal.entry.fix",
"Fix Journal entry",
"Fix an entry within the bound Journal Job.",
),
(
"journal.entry.rewrite",
"Rewrite Journal entry",
"Rewrite an entry within the bound Journal Job.",
),
(
"journal.position.update",
"Update Journal position",
"Update position within the current Journal writer/attempt.",
),
(
"journal.unchanged.confirm",
"Confirm unchanged Journal",
"Confirm no change within the current Journal writer/attempt.",
),
(
"journal.checkpoint",
"Checkpoint Journal",
"Checkpoint the current Journal recording Job.",
),
(
"journal.finish",
"Finish Journal recording",
"Finish the bound Journal Job; not a general work-completion judgment.",
),
],
);
add(
K::Workspace,
&[
(
"memory.document.read",
"Read Workspace Memory document",
"Read a legacy Workspace Memory document; not Subject Memory.",
),
(
"memory.document.update",
"Update Workspace Memory document",
"Update a legacy Workspace Memory document with existing storage constraints.",
),
(
"memory.audit.append",
"Append Workspace Memory audit",
"Append a Workspace Memory audit event.",
),
(
"memory.staging.close",
"Close Workspace Memory candidate",
"Close a legacy Workspace staging candidate.",
),
],
);
add(
K::Subject,
&[
(
"chat.read",
"Read Subject conversations",
"Read conversations belonging to the current Subject body; membership and current ownership remain required.",
),
(
"chat.send",
"Send Subject message",
"Explicitly publish to a permitted conversation as the current Subject body. Background Worker messaging does not grant publication.",
),
],
);
// The same exact operation may target either independent Memory domain. This
// adds accepted kinds, never cross-kind containment or a second descriptor.
for descriptor in &mut catalog {
if matches!(
descriptor.operation.as_str(),
"memory.query"
| "memory.resident.read"
| "memory.resident.supply"
| "memory.staging.write"
| "memory.staging.list"
| "memory.staging.read"
| "memory.extract.read"
| "memory.extract.supply"
| "memory.extract.commit"
| "memory.consolidation.request"
) {
descriptor.resource_kinds.push(K::Workspace);
descriptor.description.push_str(" The Workspace kind selects the separate legacy Workspace Memory domain, never all Subjects.");
}
}
catalog.push(PermissionOperationDescriptor {
operation: "notification.diagnostics.read".into(), label: "Read notification diagnostics".into(),
description: "Read bounded saved delivery evidence for the exact source resource; Runtime acceptance does not prove processing.".into(),
resource_kinds: vec![K::Workspace, K::Ticket, K::MergeRequest, K::Job],
availability: PermissionOperationAvailability::Available,
});
// Operation use is Workspace-bound but never grants a target. Legacy Worker
// scopes stay exact; management does not convert or discard their limits.
for (operation, label) in [
("worker.messaging", "Message Workers"),
("worker.send_input", "Submit to Worker"),
("worker.cancel", "Cancel Worker turn"),
("worker.stop", "Stop Worker"),
("worker.restore", "Restore Worker"),
("worker.remove", "Remove Worker"),
("worker.observe", "Observe Worker session"),
] {
catalog.push(PermissionOperationDescriptor {
operation: operation.into(), label: label.into(),
description: "Operation use permission and directional Backend target control grants are separate. Acceptance does not prove processing or success; lifecycle state conditions still apply.".into(),
resource_kinds: vec![K::WorkerOperation],
availability: PermissionOperationAvailability::Available,
});
}
for (operation, label) in [
("subworker.spawn", "Create Internal SubWorker"),
("subworker.send_input", "Submit to Internal SubWorker"),
("subworker.stop", "Stop Internal SubWorker"),
("subworker.observe", "Observe Internal SubWorker"),
] {
catalog.push(PermissionOperationDescriptor {
operation: operation.into(), label: label.into(),
description: "Parent-owned Internal SubWorkers are distinct from independently controlled Runtime Workers. Existing exact parent/name scopes are retained.".into(),
resource_kinds: vec![],
availability: PermissionOperationAvailability::Unavailable { reason: "A safe parent-bound management selector is not yet available. Existing scopes may be retained or removed, not widened by replacing parent identifiers.".into() },
});
}
catalog
}
/// Validate additions only, after receipt lookup and current-policy CAS in the
/// management store transaction. Unsupported entries may be kept exactly in their
/// original lane or removed, never copied to another lane or broadened.
pub fn validate_managed_subject_permissions(
current: Option<&SubjectPermissions>,
update: &SubjectPermissionsUpdate,
) -> Result<(), String> {
let catalog = subject_permission_operations();
for (next, previous) in [
(
&update.execution,
current.map(|p| p.execution.as_slice()).unwrap_or_default(),
),
(
&update.delegation,
current.map(|p| p.delegation.as_slice()).unwrap_or_default(),
),
] {
for permission in next {
if previous.contains(permission) {
continue;
}
permission.validate()?;
let descriptor = catalog
.iter()
.find(|d| d.operation == permission.operation)
.ok_or_else(|| format!("unknown management operation: {}", permission.operation))?;
if !matches!(
descriptor.availability,
PermissionOperationAvailability::Available
) {
return Err(format!(
"operation is unavailable for management: {}",
permission.operation
));
}
if !descriptor
.resource_kinds
.contains(&permission.resource.kind())
{
return Err(format!(
"invalid resource kind for operation: {}",
permission.operation
));
}
if permission.operation == "bash.execute"
&& !matches!(&permission.resource,
PermissionResource::Workdir { path, recursive: true, .. } if path == ".")
{
return Err(
"bash.execute requires the whole Workdir (path '.' and recursive true)".into(),
);
}
}
}
Ok(())
}
@@ -0,0 +1,142 @@
//! Management validation is exercised from JSON; stored legacy policy remains readable.
use super::*;
use serde_json::json;
fn permission(operation: &str, resource: serde_json::Value) -> ScopedPermission {
serde_json::from_value(json!({"operation":operation,"resource":resource})).unwrap()
}
fn update(
execution: Vec<ScopedPermission>,
delegation: Vec<ScopedPermission>,
) -> SubjectPermissionsUpdate {
SubjectPermissionsUpdate {
operation_id: "save".into(),
expected_change_id: Some("observed".into()),
execution,
delegation,
}
}
#[test]
fn managed_additions_reject_unknown_mismatched_and_unavailable_entries() {
let workspace = json!({"kind":"workspace","workspace_id":"w"});
for grant in [
permission("ticket.future", workspace.clone()),
permission("ticket.read", workspace.clone()),
permission(
"ticket.create",
json!({"kind":"ticket","workspace_id":"w","ticket_id":"T-1"}),
),
permission("worker.messaging", workspace.clone()),
permission(
"worker.stop",
json!({"kind":"worker","runtime_id":"r","worker_id":"worker"}),
),
permission("ticket", json!({"kind":"feature"})),
permission(
"bash.execute",
json!({"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}),
),
] {
assert!(
validate_managed_subject_permissions(None, &update(vec![grant.clone()], vec![]))
.is_err(),
"{grant:?}"
);
assert!(
validate_managed_subject_permissions(None, &update(vec![], vec![grant.clone()]))
.is_err(),
"{grant:?}"
);
}
assert!(validate_managed_subject_permissions(None, &update(vec![permission("ticket.create", workspace)], vec![permission("filesystem.write", json!({"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}))])).is_ok());
}
#[test]
fn unsupported_legacy_entries_are_exact_lane_preserved_not_converted_or_extended() {
let legacy: SubjectPermissions = serde_json::from_value(json!({
"change_id":"observed",
"execution":[
{"operation":"ticket","resource":{"kind":"feature"}},
{"operation":"worker.notify","resource":{"kind":"worker","runtime_id":"r","worker_id":"one"}},
{"operation":"old.unknown","resource":{"kind":"workspace","workspace_id":"w"}}
],
"delegation":[{"operation":"worker.result","resource":{"kind":"worker","runtime_id":"r","worker_id":"origin"}}]
})).unwrap();
let mut next = update(legacy.execution.clone(), legacy.delegation.clone());
next.execution.push(permission(
"ticket.create",
json!({"kind":"workspace","workspace_id":"w"}),
));
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_ok());
assert_eq!(
serde_json::to_value(&legacy).unwrap()["execution"][1]["operation"],
"worker.notify"
);
next.delegation.push(legacy.execution[1].clone());
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_err());
next.delegation.pop();
next.execution[1].resource = PermissionResource::Worker {
runtime_id: "r".into(),
worker_id: "two".into(),
};
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_err());
assert!(validate_managed_subject_permissions(Some(&legacy), &update(vec![], vec![])).is_ok());
}
#[test]
fn catalog_wire_has_no_worker_target_editors_and_no_implicit_feature_grants() {
let wire = serde_json::to_value(subject_permission_operations()).unwrap();
let entries = wire.as_array().unwrap();
let mut names = std::collections::BTreeSet::new();
for entry in entries {
assert!(
names.insert(entry["operation"].as_str().unwrap()),
"duplicate descriptor: {entry}"
);
assert!(!entry["label"].as_str().unwrap().is_empty());
assert!(!entry["description"].as_str().unwrap().is_empty());
assert!(
!entry["resource_kinds"]
.as_array()
.unwrap()
.contains(&json!("worker"))
);
if entry["availability"]["kind"] == "available" {
assert!(!entry["resource_kinds"].as_array().unwrap().is_empty());
}
}
assert!(!names.contains("ticket"));
assert!(!names.contains("worker.notify"));
assert!(!names.contains("worker.result"));
let mcp = entries.iter().find(|d| d["operation"] == "mcp").unwrap();
assert_eq!(mcp["resource_kinds"], json!(["feature"]));
let messaging = entries
.iter()
.find(|d| d["operation"] == "worker.messaging")
.unwrap();
assert_eq!(messaging["availability"]["kind"], "available");
assert_eq!(messaging["resource_kinds"], json!(["worker_operation"]));
}
#[test]
fn managed_worker_operations_accept_only_current_operation_use_in_each_lane() {
for name in [
"worker.messaging",
"worker.send_input",
"worker.cancel",
"worker.stop",
"worker.restore",
"worker.remove",
"worker.observe",
] {
let p = permission(name, json!({"kind":"worker_operation","workspace_id":"w"}));
assert!(
validate_managed_subject_permissions(None, &update(vec![p.clone()], vec![p])).is_ok()
);
}
for name in ["worker.notify", "worker.result", "worker.future"] {
let p = permission(name, json!({"kind":"worker_operation","workspace_id":"w"}));
assert!(validate_managed_subject_permissions(None, &update(vec![p], vec![])).is_err());
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Public text-only conversations. Stored messages are not Runtime delivery receipts.
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
pub const CHAT_MAX_TEXT_BYTES: usize = 16 * 1024;
pub const CHAT_MAX_PAGE_LIMIT: u32 = 100;
pub const CHAT_DEFAULT_PAGE_LIMIT: u32 = 50;
/// Largest counter representable without loss by the JSON/browser contract.
pub const CHAT_MAX_COUNTER: u64 = 9_007_199_254_740_991;
fn bounded_counter<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
let value = u64::deserialize(deserializer)?;
if value > CHAT_MAX_COUNTER {
return Err(serde::de::Error::custom(
"chat counter exceeds the JSON safe-integer range",
));
}
Ok(value)
}
fn optional_bounded_counter<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<u64>, D::Error> {
let value = Option::<u64>::deserialize(deserializer)?;
if value.is_some_and(|value| value > CHAT_MAX_COUNTER) {
return Err(serde::de::Error::custom(
"chat counter exceeds the JSON safe-integer range",
));
}
Ok(value)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatConversation {
pub id: String,
pub subject_id: String,
pub participant_account_id: String,
/// Latest user input in this conversation; empty conversations use null.
pub last_user_message_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatMessageSender {
User { account_id: String },
Subject { subject_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessage {
pub id: String,
pub conversation_id: String,
#[serde(deserialize_with = "bounded_counter")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub sequence: u64,
pub sender: ChatMessageSender,
pub text: String,
pub reply_to: Option<String>,
/// Opaque related-work reference, not a grant or an attachment path.
pub work_ref: Option<String>,
pub operation_id: String,
/// User messages refer to themselves; Subject messages pin the latest observed user input.
pub last_user_message_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatCreateConversationRequest {
pub operation_id: String,
pub subject_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatUserMessageRequest {
pub operation_id: String,
pub text: String,
pub reply_to: Option<String>,
pub work_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatSendMessageRequest {
pub conversation_id: String,
pub operation_id: String,
pub text: String,
pub reply_to: Option<String>,
pub work_ref: Option<String>,
/// Observed user input, independent of the optional reply target. Empty conversations use null.
#[serde(deserialize_with = "required_user_message_identity")]
#[schemars(required)]
pub expected_last_user_message_id: Option<String>,
}
// An explicit null is an observation of an empty conversation; omission is not.
fn required_user_message_identity<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessagePageQuery {
#[serde(default, deserialize_with = "optional_bounded_counter")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub after_sequence: Option<u64>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessagePage {
pub conversation: ChatConversation,
pub messages: Vec<ChatMessage>,
pub has_more: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn text_only_requests_reject_attachments_and_claimed_sender() {
for field in ["attachments", "sender", "subject_id", "account_id"] {
let mut input = json!({"operation_id":"op", "text":"hello"});
input[field] = json!([]);
assert!(serde_json::from_value::<ChatUserMessageRequest>(input).is_err());
}
for field in ["attachments", "sender", "subject_id", "account_id"] {
let mut send = json!({"conversation_id":"c", "operation_id":"op", "text":"hello", "expected_last_user_message_id":null});
send[field] = json!([]);
assert!(serde_json::from_value::<ChatSendMessageRequest>(send).is_err());
}
assert!(
serde_json::from_value::<ChatCreateConversationRequest>(
json!({"operation_id":"op", "subject_id":"s", "participant_account_id":"forged"})
)
.is_err()
);
}
#[test]
fn browser_counters_reject_lossy_json_and_schema_keeps_numeric_bounds() {
assert!(
serde_json::from_value::<ChatMessagePageQuery>(
json!({"after_sequence":CHAT_MAX_COUNTER+1})
)
.is_err()
);
let schema = serde_json::to_value(schemars::schema_for!(ChatMessage)).unwrap();
assert_eq!(
schema["properties"]["sequence"]["maximum"],
json!(CHAT_MAX_COUNTER)
);
assert_eq!(schema["additionalProperties"], json!(false));
}
#[test]
fn observed_user_identity_is_nullable_text_not_a_number() {
assert!(
serde_json::from_value::<ChatSendMessageRequest>(
json!({"conversation_id":"c", "operation_id":"op", "text":"answer"})
)
.is_err()
);
for baseline in [serde_json::Value::Null, json!("user-message-id")] {
let value = json!({"conversation_id":"c", "operation_id":"op", "text":"answer", "expected_last_user_message_id":baseline});
let request: ChatSendMessageRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(
serde_json::to_value(request).unwrap()["expected_last_user_message_id"],
baseline
);
}
assert!(serde_json::from_value::<ChatSendMessageRequest>(json!({"conversation_id":"c", "operation_id":"op", "text":"answer", "expected_last_user_message_id":1})).is_err());
let mut message = json!({"id":"m", "conversation_id":"c", "sequence":CHAT_MAX_COUNTER, "sender":{"kind":"subject","subject_id":"s"}, "text":"answer", "operation_id":"op", "last_user_message_id":null});
assert!(serde_json::from_value::<ChatMessage>(message.clone()).is_ok());
message["sequence"] = json!(CHAT_MAX_COUNTER + 1);
assert!(serde_json::from_value::<ChatMessage>(message).is_err());
for baseline in [serde_json::Value::Null, json!("user-message-id")] {
let conversation: ChatConversation = serde_json::from_value(json!({"id":"c", "subject_id":"s", "participant_account_id":"a", "last_user_message_id":baseline})).unwrap();
assert_eq!(
serde_json::to_value(conversation).unwrap()["last_user_message_id"],
baseline
);
}
}
#[test]
fn sender_boundary_rejects_extra_fields_and_unknown_kinds() {
assert!(
serde_json::from_value::<ChatMessageSender>(
json!({"kind":"user","account_id":"a","subject_id":"s"})
)
.is_err()
);
assert!(
serde_json::from_value::<ChatMessageSender>(json!({"kind":"worker","account_id":"a"}))
.is_err()
);
let sender = ChatMessageSender::Subject {
subject_id: "s".into(),
};
assert_eq!(
serde_json::to_value(sender).unwrap(),
json!({"kind":"subject","subject_id":"s"})
);
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Chat publication and Notify admission are independent results. No delivery state
//! below asserts committed Session incorporation, model processing or task completion.
use crate::{ChatMessage, ChatMessagePage, ChatMessagePageQuery, ChatSendMessageRequest};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatDelivery {
NoCurrentBody,
Accepted {
runtime_id: String,
worker_id: String,
},
Rejected {
runtime_id: String,
worker_id: String,
detail: String,
},
Unknown {
runtime_id: String,
worker_id: String,
detail: String,
},
Unavailable {
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatReceiveResponse {
pub message: ChatMessage,
pub delivery: ChatDelivery,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatWorkerRequest {
pub operation: ChatWorkerOperation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatWorkerOperation {
Read {
conversation_id: String,
query: ChatMessagePageQuery,
},
Send {
request: ChatSendMessageRequest,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatWorkerResponse {
Messages { page: ChatMessagePage },
Sent { message: ChatMessage },
}
+461 -17
View File
@@ -7,9 +7,24 @@ pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization;
mod chat;
pub use chat::*;
mod chat_transport;
pub use chat_transport::*;
mod drive;
mod notification;
mod plugin;
pub mod plugin_data;
pub mod plugin_host;
pub mod plugin_service;
pub use plugin_data::*;
pub use plugin_service::*;
mod plugin_types;
pub use notification::*;
pub use plugin::*;
pub use plugin_host::*;
#[cfg(feature = "typescript")]
pub use plugin_types::{plugin_api_typescript, plugin_host_typescript};
mod ticket_browse;
pub use ticket_browse::*;
pub mod repository_openapi_typescript;
@@ -29,6 +44,8 @@ pub use workspace_config::*;
#[serde(deny_unknown_fields)]
pub struct SubjectPermissionsResponse {
pub permissions: Option<authorization::SubjectPermissions>,
pub can_manage: bool,
pub operations: Vec<authorization::PermissionOperationDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -125,6 +142,8 @@ pub struct WorkdirOperationApiError {
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diagnostics: Option<Vec<Diagnostic>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub audit_failures: Vec<workdir::http::WorkdirAuditFailure>,
#[serde(skip, default = "default_repository_error_status")]
status: u16,
/// Server-local metadata retained by generated error response extensions, never the wire body.
@@ -132,6 +151,8 @@ pub struct WorkdirOperationApiError {
pub workdir_log: Option<WorkdirOperationLogContext>,
#[serde(skip)]
pub denial_reason: Option<workdir::WorkdirDenialReason>,
#[serde(skip)]
pub failure_reason: Option<workdir::WorkdirFailureReason>,
}
/// Internal operational correlation. Producers supply fixed-length ID fingerprints and
@@ -154,9 +175,11 @@ impl WorkdirOperationApiError {
code: None,
message: error.message,
diagnostics: Some(error.diagnostics),
audit_failures: Vec::new(),
status: error.status,
workdir_log: None,
denial_reason: None,
failure_reason: None,
}
}
@@ -167,9 +190,11 @@ impl WorkdirOperationApiError {
code: Some(error.code),
message: error.message,
diagnostics: None,
audit_failures: Vec::new(),
status,
workdir_log: None,
denial_reason: error.denial_reason,
failure_reason: error.failure_reason,
}
}
}
@@ -197,6 +222,34 @@ macro_rules! impl_openapi_schema {
}
impl_openapi_schema!(
PluginHostRequest,
PluginHostResponse,
PluginHostError,
ChatCreateConversationRequest,
ChatConversation,
ChatUserMessageRequest,
ChatSendMessageRequest,
ChatMessagePageQuery,
ChatMessagePage,
ChatMessage,
ChatReceiveResponse,
ChatWorkerRequest,
ChatWorkerResponse,
PluginInstallRequest,
PluginAddArtifactRequest,
PluginRevokeRequest,
PluginUninstallRequest,
PluginDataPurgeState,
PluginDataPurgeRequest,
PluginDataPurgeResponse,
PluginListRequest,
PluginUploadResponse,
PluginInstallation,
PluginListResponse,
PluginOperationResponse,
PluginArtifactDeliveryRequest,
PluginArtifactDeliveryMetadata,
PluginWorkerDiagnostics,
SubjectPermissionsResponse,
SubjectPermissionsMutationResponse,
SubjectPermissionInterruption,
@@ -221,6 +274,10 @@ impl_openapi_schema!(
PasskeyLoginOptionsResponse,
PasskeyLoginCompleteRequest,
PasskeyAdditionOptionsRequest,
PasskeySummary,
PasskeyListResponse,
PasskeyAdditionResultRequest,
PasskeyAdditionResultResponse,
PasskeyRegistrationOptionsRequest,
PasskeyRegistrationOptionsResponse,
PasskeyRegistrationCompleteRequest,
@@ -326,6 +383,8 @@ impl_openapi_schema!(
MemoryConsolidationResponse,
SubjektivSubjectCreateRequest,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectDeleteRequest,
SubjektivSubjectDeleteResponse,
SubjektivSubjectListQuery,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -544,10 +603,20 @@ pub struct WorkspaceWorkerSessionHistoryResponse {
/// comparisons do not replace admission checks under the Runtime operation lock; conflicts do
/// not trigger automatic token replacement.
/// [`WorkerRestoreState`] Accepted means Restore committed, not that the subsequent run succeeded.
/// Distinguish read-only preflight Rejected, confirmed RolledBack after side effects, and
/// ReconciliationRequired needing settled-state checks. Response loss does not imply rollback.
/// Distinguish read-only preflight Rejected, confirmed RolledBack after side effects,
/// Superseded by a later Stop, and ReconciliationRequired needing settled-state checks.
/// Response loss does not imply rollback.
#[api(reqwest, axum, openapi)]
pub trait ServerApi {
/// Runtime Host owner boundary; human plugin-management authority is not accepted here.
#[post("/api/w/{workspace_id}/plugin-host", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], normalize_body_errors = true)]
async fn plugin_host(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: PluginHostRequest,
) -> Result<PluginHostResponse, PluginHostError>;
#[get("/health", status = 200, error_status = 400)]
async fn health(&self) -> Result<HealthResponse, RepositoryApiError>;
@@ -644,6 +713,33 @@ pub trait ServerApi {
#[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
#[get(
"/api/auth/passkeys",
status = 200,
error_status = 400,
additional_error_statuses = [403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_list(
&self,
#[extension] context: ServerRequestContext,
) -> Result<PasskeyListResponse, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/result",
status = 200,
error_status = 400,
additional_error_statuses = [403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_addition_result(
&self,
#[extension] context: ServerRequestContext,
#[body] request: PasskeyAdditionResultRequest,
) -> Result<PasskeyAdditionResultResponse, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/options",
status = 200,
@@ -1157,6 +1253,20 @@ pub trait ServerApi {
#[path] workspace_id: String,
) -> Result<WorkspacePromptProjection, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/config/source-tree/preview",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true
)]
async fn workspace_config_tree_preview(
&self,
#[path] workspace_id: String,
#[body] request: ConfigCommitRequest,
) -> Result<WorkspaceConfigTreeResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/config/source-tree/commit",
status = 201,
@@ -1346,6 +1456,20 @@ pub trait ServerApi {
#[body] request: authorization::OperationAuthorizationRequest,
) -> Result<authorization::OperationAuthorizationResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/authorization/bound-check",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
openapi = false,
)]
async fn bound_operation_authorize(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: authorization::BoundOperationAuthorizationRequest,
) -> Result<authorization::OperationAuthorizationResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions",
status = 200,
@@ -1487,19 +1611,21 @@ pub trait ServerApi {
#[path] memory_id: String,
#[query] query: SubjektivMemoryChangesQuery,
) -> Result<SubjektivMemoryListChangesResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire",
#[delete(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn subjektiv_subject_retire(
async fn subjektiv_subject_delete(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] subject_id: String,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[body] request: SubjektivSubjectDeleteRequest,
) -> Result<SubjektivSubjectDeleteResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/subjektiv/staging",
status = 200,
@@ -1526,6 +1652,44 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: SubjektivRecordSessionRequest,
) -> Result<SubjektivRecordSessionResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/chat/conversations", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn chat_create_conversation(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ChatCreateConversationRequest,
) -> Result<ChatConversation, RepositoryApiError>;
#[get("/api/w/{workspace_id}/chat/conversations/{conversation_id}/messages", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn chat_messages(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] conversation_id: String,
#[query] query: ChatMessagePageQuery,
) -> Result<ChatMessagePage, RepositoryApiError>;
#[post("/api/w/{workspace_id}/chat/conversations/{conversation_id}/messages", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn chat_receive(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] conversation_id: String,
#[body] request: ChatUserMessageRequest,
) -> Result<ChatReceiveResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/subjektiv/chat/worker", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn chat_worker_operation(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ChatWorkerRequest,
) -> Result<ChatWorkerResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/subjektiv/memory",
operation_id = "subjektiv_memory_backend",
@@ -1709,6 +1873,21 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<WorkerControlListResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/permissions/{permission}",
status = 204,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn worker_control_authorize(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
#[path] permission: String,
) -> Result<(), RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/worker-control/workers",
status = 200,
@@ -2832,6 +3011,131 @@ pub trait ServerApi {
#[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest,
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugin-uploads", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_upload(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[binary] body: BinaryBody,
) -> Result<PluginUploadResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_install(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[body] request: PluginInstallRequest,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
async fn plugin_list(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[query] query: PluginListRequest,
) -> Result<PluginListResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins/worker-diagnostics/{runtime_id}/{worker_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,503], bearer_auth = true, browser_auth = true)]
async fn plugin_worker_diagnostics(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
) -> Result<PluginWorkerDiagnostics, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins/{installation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
async fn plugin_show(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
) -> Result<PluginInstallation, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/{installation_id}/artifacts", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_artifact_add(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
#[body] request: PluginAddArtifactRequest,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/{installation_id}/revoke", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_revoke(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
#[body] request: PluginRevokeRequest,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/{installation_id}/uninstall", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_uninstall(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
#[body] request: PluginUninstallRequest,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins/{installation_id}/data-state", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true)]
async fn plugin_data_observe(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
) -> Result<PluginDataPurgeState, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/{installation_id}/data-purge", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_data_purge(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
#[body] request: PluginDataPurgeRequest,
) -> Result<PluginDataPurgeResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins/service-consumers/{consumer_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true)]
async fn plugin_service_diagnostics(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] consumer_id: String,
) -> Result<PluginServiceDiagnostics, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/service-consumers/recover", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_service_recover(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[body] request: PluginServiceRecoveryRequest,
) -> Result<PluginServiceDiagnostics, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
async fn plugin_operation(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] operation_id: String,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/workers/self/plugin-artifacts/authorize", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], openapi = false, normalize_body_errors = true)]
async fn plugin_artifact_authorize(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: PluginArtifactDeliveryRequest,
) -> Result<PluginArtifactDeliveryMetadata, RepositoryApiError>;
#[post("/api/w/{workspace_id}/workers/self/plugin-artifacts/pull", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], openapi = false, normalize_body_errors = true)]
async fn plugin_artifact_pull(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: PluginArtifactDeliveryRequest,
) -> Result<BinaryBody, RepositoryApiError>;
// Ordinary operations accept member browser sessions or trusted Worker request
// context. The service resolves Worker grants; no operation accepts an actor or
// grant ID from the caller. Grant management is Workspace-owner-only.
@@ -4111,6 +4415,8 @@ impl RequestActor {
pub struct WhoamiResponse {
pub actor: Option<RequestActor>,
pub session: BrowserSessionState,
/// Non-secret comparison evidence, never an authentication credential.
pub authentication_reference: Option<String>,
}
/// Browser navigation evidence, not an authorization grant or a generic 401 classification.
@@ -4138,6 +4444,8 @@ pub struct AuthBootstrapUserRequest {
#[serde(deny_unknown_fields)]
pub struct AuthUserResponse {
pub user: AuthenticatedUser,
/// Comparison evidence for the exact newly issued session, not a credential.
pub authentication_reference: String,
}
/// Starts new-account registration only; an existing handle is never interpreted as key addition.
@@ -4154,16 +4462,64 @@ pub struct PasskeyRegistrationOptionsRequest {
pub browser_origin: Option<String>,
}
/// Adds a credential to the current authenticated actor; target identity is never public input.
/// Adds to the current actor. Expected identity/binding fields only fence stale UI;
/// they never select an owner or authorize an addition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyAdditionOptionsRequest {
pub expected_user_id: String,
pub expected_account_id: String,
pub expected_authentication_reference: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub browser_origin: Option<String>,
}
/// Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.
/// Normal-login verification is observed verifier success, not inferred discoverability.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeySummary {
pub key_reference: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub normal_login_verified_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyListResponse {
pub user: AuthenticatedUser,
pub keys: Vec<PasskeySummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyAdditionResultRequest {
pub challenge_id: String,
pub expected_user_id: String,
pub expected_account_id: String,
/// Original options authentication reference; the current same-owner session may differ.
pub expected_authentication_reference: String,
}
/// Unknown is not proof that registration failed: completion may still be in flight.
/// Saved proves the credential commit, not session issuance or current session health.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
pub enum PasskeyAdditionResultResponse {
Unknown {},
Saved {
user: AuthenticatedUser,
key: PasskeySummary,
},
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -4863,6 +5219,8 @@ pub struct WorkspacePermissionSummary {
pub manage_secrets: bool,
pub manage_runtimes: bool,
pub delete_workspace: bool,
/// Explicit Subject deletion authority for the current authenticated actor.
pub delete_subjects: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -5258,7 +5616,25 @@ pub struct MemoryConsolidationResponse {
#[serde(rename_all = "snake_case")]
pub enum SubjektivSubjectState {
Active,
Retired,
/// Saved compatibility only; not a current lifecycle operation.
#[serde(rename = "retired")]
LegacyRetired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectDeleteRequest {
/// Stable identity retained for every retry, including unknown outcomes.
pub operation_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectDeleteResponse {
pub subject_id: String,
pub operation_id: String,
/// False confirms the deletion fence, not external stop or erasure completion.
pub completed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7506,6 +7882,9 @@ pub struct CurrentWorkerWorkdirAttachmentResponse {
pub alias: String,
pub working_directory_id: String,
pub capabilities: workdir::WorkdirSessionCapabilities,
/// Present for attachment responses, absent after detach.
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<workdir::WorkdirSessionEnvironment>,
pub attached: bool,
}
@@ -7518,7 +7897,9 @@ pub struct CurrentWorkerWorkdirOperationRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
pub struct CurrentWorkerWorkdirOperationResponse(pub workdir::http::WorkdirSessionOperationResult);
pub struct CurrentWorkerWorkdirOperationResponse(
pub workdir::http::WorkdirSessionOperationResponse,
);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -7876,6 +8257,9 @@ pub struct TicketAssignmentSummary {
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))]
pub enum TicketAssignmentPrincipal {
Subject {
subject_id: String,
},
User {
account_id: String,
},
@@ -9270,6 +9654,9 @@ pub struct RuntimeWorkerInputRequest {
#[serde(default)]
pub kind: Option<String>,
pub content: String,
/// Stable Notify identity. Non-Notify unary input does not share this replay contract.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<protocol::Segment>>,
}
@@ -10566,8 +10953,8 @@ pub enum WorkerOperationState {
/// Cross-layer outcome of an explicit Worker restore operation.
///
/// `Rejected` is reserved for read-only preflight failures. Once live restore
/// work starts, failure is either a confirmed `RolledBack` operation or a
/// `ReconciliationRequired` result whose commit state must be reread/retried.
/// work starts, distinguish confirmed `RolledBack`, unresolved
/// `ReconciliationRequired`, and a later Stop's `Superseded` outcome.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
@@ -10575,6 +10962,8 @@ pub enum WorkerRestoreState {
Accepted,
Rejected,
RolledBack,
/// A later Stop superseded this restore; neither rollback nor prior Accepted is asserted.
Superseded,
ReconciliationRequired,
}
@@ -10842,6 +11231,16 @@ pub fn memory_api_typescript() -> String {
MemoryStagingRecord::decl(&config),
MemoryStagingEntry::decl(&config),
MemoryStagingListResponse::decl(&config),
authorization::PermissionResource::decl(&config),
authorization::ScopedPermission::decl(&config),
authorization::SubjectPermissions::decl(&config),
authorization::SubjectPermissionsUpdate::decl(&config),
authorization::PermissionResourceKind::decl(&config),
authorization::PermissionOperationAvailability::decl(&config),
authorization::PermissionOperationDescriptor::decl(&config),
SubjectPermissionsResponse::decl(&config),
SubjectPermissionsMutationResponse::decl(&config),
SubjectPermissionInterruption::decl(&config),
];
// These browser DTOs include nested types owned by `memory` and the Worker
// launch API. Keep their exported names explicit so the generated Web
@@ -10851,6 +11250,10 @@ pub fn memory_api_typescript() -> String {
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
export type SubjektivSubjectDeleteResponse = { subject_id: string, operation_id: string, completed: boolean, };
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
@@ -11638,6 +12041,10 @@ pub fn auth_api_typescript() -> String {
AuthBootstrapUserRequest::decl(&config),
AuthUserResponse::decl(&config),
PasskeyAdditionOptionsRequest::decl(&config),
PasskeySummary::decl(&config),
PasskeyListResponse::decl(&config),
PasskeyAdditionResultRequest::decl(&config),
PasskeyAdditionResultResponse::decl(&config),
PasskeyRegistrationOptionsRequest::decl(&config),
PasskeyRegistrationOptionsResponse::decl(&config),
PasskeyRegistrationCompleteRequest::decl(&config),
@@ -12580,6 +12987,11 @@ mod tests {
HttpMethod::Get,
"/api/w/{workspace_id}/config/projections/prompts",
),
(
"workspace_config_tree_preview",
HttpMethod::Post,
"/api/w/{workspace_id}/config/source-tree/preview",
),
(
"workspace_config_tree_commit",
HttpMethod::Post,
@@ -12668,6 +13080,7 @@ mod tests {
(WorkerRestoreState::Accepted, "accepted"),
(WorkerRestoreState::Rejected, "rejected"),
(WorkerRestoreState::RolledBack, "rolled_back"),
(WorkerRestoreState::Superseded, "superseded"),
(
WorkerRestoreState::ReconciliationRequired,
"reconciliation_required",
@@ -13276,6 +13689,7 @@ mod tests {
alias: "checkout".to_string(),
working_directory_id: "workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
environment: None,
attached: false,
};
let value = serde_json::to_value(&response).unwrap();
@@ -13378,7 +13792,8 @@ mod tests {
"manage_repositories": true,
"manage_secrets": true,
"manage_runtimes": true,
"delete_workspace": true
"delete_workspace": true,
"delete_subjects": true
},
"extension_points": {
"store": "sqlite",
@@ -14065,7 +14480,8 @@ mod tests {
"display_name": "Hare",
"auth_method": "browser_session"
},
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" }
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" },
"authentication_reference": "synthetic-session-reference"
});
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
.expect("server whoami fixture should match shared DTO");
@@ -14087,7 +14503,8 @@ mod tests {
"account_id": "account-1",
"handle": "hare",
"display_name": "Hare"
}
},
"authentication_reference": "synthetic-session-reference"
});
let decoded = serde_json::from_value::<AuthUserResponse>(auth_user.clone())
.expect("server auth-user fixture should match shared DTO");
@@ -14260,7 +14677,7 @@ mod tests {
serde_json::json!({"kind":"expired"}),
serde_json::json!({"kind":"authenticated", "expires_at":null}),
] {
let value = serde_json::json!({"actor":null, "session":session});
let value = serde_json::json!({"actor":null, "session":session, "authentication_reference": null});
let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value);
}
@@ -14661,7 +15078,10 @@ mod openapi_artifact_tests {
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
const SIGNED_INTERNAL: &[&str] = &[
"plugin_artifact_authorize",
"plugin_artifact_pull",
"operation_authorize",
"bound_operation_authorize",
"current_worker_workspace_config_get",
"current_worker_workspace_config_attach",
"current_worker_workspace_config_observe",
@@ -14679,6 +15099,7 @@ mod openapi_artifact_tests {
"merge_request_review_submit",
"merge_request_reviewer_child_register",
"runtime_resource_fetch",
"worker_control_authorize",
"worker_control_cancel",
"worker_control_input",
"worker_control_list",
@@ -14689,6 +15110,7 @@ mod openapi_artifact_tests {
"worker_observation_sessions",
"subjektiv_stage_candidate",
"subjektiv_record_session",
"chat_worker_operation",
"subjektiv_memory_backend",
"subjektiv_session_backend",
"subjektiv_memory_consolidation",
@@ -14698,7 +15120,7 @@ mod openapi_artifact_tests {
// Session observation retains its existing explicit non-OpenAPI boundary independently of
// the Runtime/Worker-source signed operation inventory above.
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"];
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"];
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config", "plugin_host"];
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
@@ -14798,10 +15220,28 @@ mod openapi_artifact_tests {
);
}
assert!(
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_ok()
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_err()
);
}
#[test]
fn passkey_addition_requires_expected_owner_and_binding() {
let expected = serde_json::json!({
"expected_user_id": "user-a", "expected_account_id": "account-a",
"expected_authentication_reference": "reference"
});
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(expected.clone()).is_ok());
for field in [
"expected_user_id",
"expected_account_id",
"expected_authentication_reference",
] {
let mut missing = expected.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(missing).is_err());
}
}
#[test]
fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
@@ -15047,10 +15487,12 @@ mod workdir_error_diagnostic_tests {
#[test]
fn workdir_internal_diagnostics_never_enter_public_error_body_or_schema() {
let mut error = WorkdirOperationApiError::provider(workdir::http::WorkdirTransportError {
failure_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Denied,
message: "Workdir operation was denied".to_string(),
denial_reason: Some(workdir::WorkdirDenialReason::OsPermissionDenied),
});
error.failure_reason = Some(workdir::WorkdirFailureReason::HttpTimeout);
error.workdir_log = Some(WorkdirOperationLogContext {
operation: "write",
workspace_id_hash: "internal-workspace-fingerprint".to_string(),
@@ -15064,10 +15506,12 @@ mod workdir_error_diagnostic_tests {
assert_eq!(api_macros::HttpError::status_code(&error), 403);
let decoded: WorkdirOperationApiError = serde_json::from_value(wire).unwrap();
assert!(decoded.denial_reason.is_none());
assert!(decoded.failure_reason.is_none());
assert!(decoded.workdir_log.is_none());
let schema = serde_json::to_value(schemars::schema_for!(WorkdirOperationApiError)).unwrap();
assert!(schema["properties"].get("workdir_log").is_none());
assert!(schema["properties"].get("denial_reason").is_none());
assert!(schema["properties"].get("failure_reason").is_none());
}
}
+477
View File
@@ -0,0 +1,477 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginInstallRequest {
pub upload_id: String,
pub alias: String,
pub operation_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginAddArtifactRequest {
pub upload_id: String,
pub expected_last_operation: String,
pub operation_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginRevokeRequest {
pub artifact_digest: String,
pub expected_last_operation: String,
pub reason: String,
pub operation_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginUninstallRequest {
pub expected_last_operation: String,
pub operation_key: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginListRequest {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginSchemaContribution {
pub identity: String,
pub version: String,
pub digest: String,
pub namespace: String,
pub dcdl: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginToolMetadata {
pub name: String,
pub description: String,
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pub input_schema: serde_json::Value,
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pub output_schema: serde_json::Value,
pub error_codes: Vec<String>,
pub external_write: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "UPPERCASE")]
pub enum PluginHttpMethod {
Get,
Head,
Post,
Put,
Patch,
Delete,
Options,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionMetadata {
pub name: String,
pub methods: Vec<PluginHttpMethod>,
pub path_prefixes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginGrantMetadata {
Logging,
Https {
slot: String,
},
TicketRead,
TicketEvents,
TicketDataRead {
collection: String,
schema_id: String,
schema_version: String,
},
TicketDataWrite {
collection: String,
schema_id: String,
schema_version: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataSchemaMetadata {
pub collection: String,
pub schema_id: String,
pub schema_version: String,
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pub schema: serde_json::Value,
}
/// Exact artifact subscription declarations; selection and current permission are separate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceMetadata {
pub name: String,
pub subscriptions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginArtifact {
pub package_id: String,
pub name: String,
pub version: String,
pub description: Option<String>,
pub package_digest: String,
pub artifact_digest: String,
pub artifact_size: u32,
pub schema_version: u32,
pub world: String,
pub required_imports: Vec<String>,
pub tools: Vec<PluginToolMetadata>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<PluginServiceMetadata>,
pub connections: Vec<PluginConnectionMetadata>,
pub requested_grants: Vec<PluginGrantMetadata>,
#[serde(default)]
pub data_collections: Vec<PluginDataSchemaMetadata>,
pub config_schema: PluginSchemaContribution,
pub revoked: bool,
pub revocation_reason: Option<String>,
}
impl plugin_plan::ArtifactDescriptor for PluginArtifact {
fn artifact_identity(&self) -> plugin_plan::ArtifactIdentityProjection {
plugin_plan::ArtifactIdentityProjection {
package_id: self.package_id.clone(),
version: self.version.clone(),
package_digest: self.package_digest.clone(),
artifact_digest: self.artifact_digest.clone(),
artifact_size: self.artifact_size,
schema_version: self.schema_version,
world: self.world.clone(),
required_imports: self.required_imports.clone(),
tools: self.tools.iter().map(|tool| tool.name.clone()).collect(),
services: self
.services
.iter()
.map(|service| service.name.clone())
.collect(),
connections: self
.connections
.iter()
.map(|slot| slot.name.clone())
.collect(),
requested_grants: self
.requested_grants
.iter()
.map(|grant| match grant {
PluginGrantMetadata::Logging => plugin_plan::PluginGrant::Logging,
PluginGrantMetadata::Https { slot } => {
plugin_plan::PluginGrant::Https { slot: slot.clone() }
}
PluginGrantMetadata::TicketRead => plugin_plan::PluginGrant::TicketRead,
PluginGrantMetadata::TicketEvents => plugin_plan::PluginGrant::TicketEvents,
PluginGrantMetadata::TicketDataRead {
collection,
schema_id,
schema_version,
} => plugin_plan::PluginGrant::TicketDataRead {
collection: collection.clone(),
schema_id: schema_id.clone(),
schema_version: schema_version.clone(),
},
PluginGrantMetadata::TicketDataWrite {
collection,
schema_id,
schema_version,
} => plugin_plan::PluginGrant::TicketDataWrite {
collection: collection.clone(),
schema_id: schema_id.clone(),
schema_version: schema_version.clone(),
},
})
.collect(),
config_schema: plugin_plan::PluginSchemaIdentity {
identity: self.config_schema.identity.clone(),
version: self.config_schema.version.clone(),
digest: self.config_schema.digest.clone(),
namespace: self.config_schema.namespace.clone(),
},
revoked: self.revoked,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataPurgeState {
pub installation_last_operation: String,
pub cells_digest: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub cell_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataPurgeRequest {
pub operation_key: String,
pub expected: PluginDataPurgeState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataPurgeResponse {
pub purge_id: String,
pub observed: PluginDataPurgeState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginUploadResponse {
pub upload_id: String,
pub expires_at: String,
pub artifact: PluginArtifact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginInstallation {
pub installation_id: String,
pub alias: String,
pub package_id: String,
pub last_operation: String,
pub uninstalled: bool,
pub artifacts: Vec<PluginArtifact>,
}
/// Catalog pages omit artifact/schema payloads so the item bound also bounds
/// response work. Exact immutable metadata is available from installation show.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginInstallationSummary {
pub installation_id: String,
pub alias: String,
pub package_id: String,
pub last_operation: String,
pub uninstalled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginListResponse {
pub installations: Vec<PluginInstallationSummary>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginOperationKind {
Install,
AddArtifact,
Revoke,
Uninstall,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginOperationResponse {
pub operation_id: String,
pub kind: PluginOperationKind,
pub installation: PluginInstallation,
}
/// Point-in-time owner observation only, never a permission or execution lease.
/// Deliberately excludes configuration, credentials and artifact locations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginWorkerDiagnostics {
pub workspace_id: String,
pub runtime_id: String,
pub worker_id: String,
pub runtime_limits: PluginRuntimeLimits,
pub selection: PluginWorkerDiagnosticSelection,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginWorkerDiagnosticSelection {
/// No sealed launch selection exists; do not infer current Profile selection.
Unrecorded,
/// The owner sealed an explicitly empty selection (a NULL plan).
Empty,
Plan {
plan_id: String,
fingerprint: String,
pin_state: PluginDiagnosticPinState,
plugins: Vec<PluginWorkerDiagnostic>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticPinState {
Pending,
Active,
Restorable,
Released,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticSurface {
Tool,
Service,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginWorkerDiagnostic {
pub installation_id: String,
pub package_id: String,
pub package_version: String,
pub package_digest: String,
pub artifact_digest: String,
pub artifact_size: u32,
pub surfaces: Vec<PluginDiagnosticSurface>,
pub selected_tools: Vec<String>,
pub selected_services: Vec<String>,
pub service_consumers: Vec<PluginServiceConsumerDiagnostics>,
pub admission: PluginDiagnosticAdmission,
pub connections: Vec<PluginDiagnosticConnection>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceConsumerDiagnostics {
pub service_name: String,
pub diagnostics: crate::PluginServiceDiagnostics,
}
/// A successful observation is not retained admission authority.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginDiagnosticAdmission {
Admissible,
Refused {
reason_code: crate::PluginHostErrorCode,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDiagnosticConnection {
pub slot: String,
pub effective_limits: PluginDiagnosticConnectionLimits,
}
/// Limits are absent when the current binding cannot be admitted; never fall
/// back to a launch-time policy or a permissive default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginDiagnosticConnectionLimits {
Available {
timeout_ms: u32,
max_request_bytes: u32,
max_response_bytes: u32,
max_header_bytes: u32,
},
Unavailable {
reason_code: crate::PluginHostErrorCode,
},
}
/// Fixed Tool Runtime budgets, not usage counters or caller-selectable limits.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginRuntimeLimits {
pub payload_bytes: u32,
pub config_bytes: u32,
pub memory_bytes: u32,
pub table_elements: u32,
pub instances: u32,
pub memories: u32,
pub tables: u32,
pub resources: u32,
pub fuel: u32,
pub wall_time_ms: u32,
pub host_operations: u32,
pub http_body_bytes: u32,
pub http_header_bytes: u32,
pub http_header_pairs: u32,
pub log_bytes: u32,
}
/// Namespace a byte cache by the authenticated Server origin and signing key.
/// This is not a package hash or an authorization decision. Hosts derive the
/// bundle from their current registered authority, never from a delivery reply.
pub fn plugin_server_authority_identity(bundle: &crate::WorkspacePublicIdentityBundle) -> String {
serde_json::to_string(&(
bundle.backend_url.trim_end_matches('/'),
&bundle.public_key_fingerprint,
))
.expect("string tuple is serializable")
}
/// A selection within a persisted plan, never digest-only retrieval authority.
/// Workspace and Worker identities come from authenticated Host context.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginArtifactDeliveryRequest {
pub plan_id: String,
pub plan_fingerprint: String,
pub installation_id: String,
pub artifact_digest: String,
}
/// Fresh owner observation for one exact selection. Cache only bytes, not this
/// observation or the Workspace's configuration, grants or installation state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginArtifactDeliveryMetadata {
pub workspace_id: String,
pub request: PluginArtifactDeliveryRequest,
pub artifact: PluginArtifact,
}
+177
View File
@@ -0,0 +1,177 @@
//! Ticket extension-data wire boundary. Workspace, installation and execution
//! identity come exclusively from the authenticated, owner-issued invocation.
//! Guest selectors are never authorization. Receipts share the HTTPS invocation
//! scope and expiry, and are checked after live authorization but before cell CAS.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const PLUGIN_TICKET_BODY_LIMIT: usize = 64 * 1024;
pub const PLUGIN_DATA_VALUE_LIMIT: usize = 64 * 1024;
pub const PLUGIN_DATA_LIST_LIMIT: u32 = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginTicketAccess {
Read,
ExtensionWrite,
}
/// Canonical Ticket identity is returned only after resolve authorizes it. It is
/// still a selector: every subsequent operation must reauthorize with the owner.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginTicketSnapshot {
pub title: String,
pub case_state: PluginTicketCaseState,
pub content_digest: String,
pub body: String,
pub truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginTicketCaseState {
Open,
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataCollection {
pub collection: String,
pub schema_id: String,
pub schema_version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataAddress {
pub collection: PluginDataCollection,
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginDataExpectation {
NeverWritten,
LastWrite(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginDataCell {
NeverWritten,
Present {
value_json: String,
last_write: String,
},
Deleted {
last_delete: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginDataMutation {
Put {
expected: PluginDataExpectation,
value_json: String,
},
Delete {
expected_last_write: String,
},
}
/// A saved mutation result, not an observation of the current cell. The digest
/// covers the canonical resulting cell (including its final operation identity).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataReceipt {
pub mutation_id: String,
pub result_digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginDataMutationLookup {
NotRecorded,
Committed(PluginDataReceipt),
Expired,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataEntry {
pub key: String,
pub cell: PluginDataCell,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDataPage {
pub entries: Vec<PluginDataEntry>,
pub next_cursor: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cell_states_and_receipt_absence_are_not_conflated() {
let never = serde_json::to_value(PluginDataCell::NeverWritten).unwrap();
let deleted = serde_json::to_value(PluginDataCell::Deleted {
last_delete: "delete-operation".into(),
})
.unwrap();
assert_ne!(never, deleted);
assert_eq!(
serde_json::from_value::<PluginDataCell>(never).unwrap(),
PluginDataCell::NeverWritten
);
assert_ne!(
serde_json::to_value(PluginDataMutationLookup::NotRecorded).unwrap(),
serde_json::to_value(PluginDataMutationLookup::Expired).unwrap()
);
}
#[test]
fn address_rejects_guest_workspace_installation_or_schema_aliases() {
let address = json!({"collection":{"collection":"notes","schema_id":"note","schema_version":"1"},"key":"main"});
assert!(serde_json::from_value::<PluginDataAddress>(address.clone()).is_ok());
for field in ["workspace_id", "installation_id", "ticket_id"] {
let mut forged = address.clone();
forged[field] = json!("foreign");
assert!(serde_json::from_value::<PluginDataAddress>(forged).is_err());
}
let mut numeric_version = address;
numeric_version["collection"]["schema_version"] = json!(1);
assert!(serde_json::from_value::<PluginDataAddress>(numeric_version).is_err());
}
}
+472
View File
@@ -0,0 +1,472 @@
//! Runtime-to-owner Host protocol. Authentication is transport-bound, never a DTO claim.
use crate::PluginHttpMethod;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
// The same operation variants define Tool transport and the narrower leased
// Service channel. Service cannot nest itself or issue Begin/Cancel, and the
// wire schema remains finite rather than recursively admitting control requests.
macro_rules! invocation_requests {
($($variant:ident { $($field:ident: $ty:ty),* $(,)? }),* $(,)?) => {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginInvocationRequest { $($variant { $($field: $ty),* }),* }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginHostRequest {
Service { request: crate::PluginServiceRequest },
Prepare { plan_id: String, installation_id: String, tools: Vec<String> },
Begin { plan_id: String, installation_id: String, consumer: PluginConsumer },
Cancel { invocation_id: String },
$($variant { $($field: $ty),* }),*
}
impl TryFrom<PluginHostRequest> for PluginInvocationRequest {
type Error = ();
fn try_from(request: PluginHostRequest) -> Result<Self, ()> {
match request {
$(PluginHostRequest::$variant { $($field),* } => Ok(Self::$variant { $($field),* }),)*
PluginHostRequest::Service { .. } | PluginHostRequest::Prepare { .. }
| PluginHostRequest::Begin { .. } | PluginHostRequest::Cancel { .. } => Err(()),
}
}
}
impl From<PluginInvocationRequest> for PluginHostRequest {
fn from(request: PluginInvocationRequest) -> Self {
match request { $(PluginInvocationRequest::$variant { $($field),* } => Self::$variant { $($field),* }),* }
}
}
};
}
invocation_requests! {
Authorize {
invocation_id: String,
operation: PluginHostOperation,
},
Open {
invocation_id: String,
slot: String,
},
Send {
invocation_id: String,
slot: String,
request: PluginHttpRequest,
},
Log {
invocation_id: String,
entry: PluginLogEntry,
},
ResolveTicket {
invocation_id: String,
ticket_ref: String,
access: crate::PluginTicketAccess,
},
ReadTicket {
invocation_id: String,
ticket_id: String,
},
DataGet {
invocation_id: String,
ticket_id: String,
address: crate::PluginDataAddress,
},
DataMutate {
invocation_id: String,
ticket_id: String,
address: crate::PluginDataAddress,
operation_key: String,
mutation: crate::PluginDataMutation,
},
DataList {
invocation_id: String,
ticket_id: String,
collection: crate::PluginDataCollection,
cursor: Option<String>,
limit: u32,
},
DataLookupMutation {
invocation_id: String,
ticket_id: String,
address: crate::PluginDataAddress,
operation_key: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginConsumer {
ToolCall {
tool_call_id: String,
tool_name: String,
},
/// Owner-issued Service delivery identity; never grants admission by itself.
Consumer {
consumer_id: String,
event_id: String,
export_name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginHostOperation {
Admit,
Publish,
Open { slot: String },
Send { slot: String },
Log,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginHttpRequest {
pub method: PluginHttpMethod,
pub path: String,
pub query: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub timeout_ms: u32,
pub effect_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginHttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
macro_rules! invocation_responses {
($($variant:ident $({ $($field:ident: $ty:ty),* $(,)? })?),* $(,)?) => {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", content = "data", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginInvocationResponse { $($variant $({ $($field: $ty),* })?),* }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", content = "data", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginHostResponse {
Service { response: crate::PluginServiceResponse },
Invocation {
invocation_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
expires_at_ms: i64,
},
$($variant $({ $($field: $ty),* })?),*
}
impl TryFrom<PluginHostResponse> for PluginInvocationResponse {
type Error = ();
fn try_from(response: PluginHostResponse) -> Result<Self, ()> {
match response {
$(PluginHostResponse::$variant $({ $($field),* })? => Ok(Self::$variant $({ $($field),* })?),)*
PluginHostResponse::Service { .. } | PluginHostResponse::Invocation { .. } => Err(()),
}
}
}
impl From<PluginInvocationResponse> for PluginHostResponse {
fn from(response: PluginInvocationResponse) -> Self {
match response { $(PluginInvocationResponse::$variant $({ $($field),* })? => Self::$variant $({ $($field),* })?),* }
}
}
};
}
invocation_responses! {
Authorized,
Opened,
Response {
response: PluginHttpResponse,
},
Logged,
TicketResolved {
ticket_id: String,
},
TicketRead {
snapshot: crate::PluginTicketSnapshot,
},
DataCell {
cell: crate::PluginDataCell,
},
DataMutated {
receipt: crate::PluginDataReceipt,
},
DataListed {
page: crate::PluginDataPage,
},
DataMutationLookup {
outcome: crate::PluginDataMutationLookup,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginLogEntry {
pub level: PluginLogLevel,
pub event_code: String,
pub message: String,
pub fields: Vec<PluginLogField>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginLogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginLogField {
pub name: String,
pub value: PluginLogScalar,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "value",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginLogScalar {
Text(String),
Boolean(bool),
Signed(
#[serde(with = "decimal_integer")]
#[schemars(with = "String")]
#[cfg_attr(feature = "typescript", ts(type = "string"))]
i64,
),
Unsigned(
#[serde(with = "decimal_integer")]
#[schemars(with = "String")]
#[cfg_attr(feature = "typescript", ts(type = "string"))]
u64,
),
Number(f64),
}
// WIT scalars retain their full 64-bit range without JSON/TypeScript precision loss.
mod decimal_integer {
pub fn serialize<T: std::fmt::Display, S: serde::Serializer>(
value: &T,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.collect_str(value)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: std::str::FromStr + std::fmt::Display,
D: serde::Deserializer<'de>,
{
let text = <String as serde::Deserialize>::deserialize(deserializer)?;
let value: T = text
.parse()
.map_err(|_| serde::de::Error::custom("invalid decimal integer"))?;
if value.to_string() != text {
return Err(serde::de::Error::custom("non-canonical decimal integer"));
}
Ok(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginHostErrorCode {
InvalidInput,
Denied,
NotFound,
Conflict,
Unsupported,
QuotaExceeded,
DeadlineExceeded,
Cancelled,
Unavailable,
Indeterminate,
ReceiptExpired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginEffectObservation {
NotDispatched,
ResponseObserved,
Indeterminate,
}
/// Deliberately excludes raw diagnostics, transport bodies and arbitrary messages.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginHostError {
pub code: PluginHostErrorCode,
pub effect: PluginEffectObservation,
pub diagnostic_id: String,
}
impl PluginHostError {
pub fn message(&self) -> &'static str {
match self.code {
PluginHostErrorCode::InvalidInput => "invalid plugin Host input",
PluginHostErrorCode::Denied => "plugin Host operation denied",
PluginHostErrorCode::NotFound => "plugin Host target not found",
PluginHostErrorCode::Conflict => "plugin Host operation conflict",
PluginHostErrorCode::Unsupported => "plugin Host operation unsupported",
PluginHostErrorCode::QuotaExceeded => "plugin Host quota exceeded",
PluginHostErrorCode::DeadlineExceeded => "plugin Host deadline exceeded",
PluginHostErrorCode::Cancelled => "plugin Host operation cancelled",
PluginHostErrorCode::Unavailable => "plugin Host unavailable",
PluginHostErrorCode::Indeterminate => "plugin Host outcome indeterminate",
PluginHostErrorCode::ReceiptExpired => "plugin Host receipt expired",
}
}
}
impl std::fmt::Display for PluginHostError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for PluginHostError {}
impl api_macros::HttpError for PluginHostError {
fn status_code(&self) -> u16 {
match self.code {
PluginHostErrorCode::InvalidInput | PluginHostErrorCode::Unsupported => 400,
PluginHostErrorCode::Denied => 403,
PluginHostErrorCode::NotFound => 404,
PluginHostErrorCode::Conflict
| PluginHostErrorCode::Indeterminate
| PluginHostErrorCode::ReceiptExpired => 409,
PluginHostErrorCode::QuotaExceeded => 429,
PluginHostErrorCode::DeadlineExceeded
| PluginHostErrorCode::Cancelled
| PluginHostErrorCode::Unavailable => 503,
}
}
}
impl api_macros::HttpRequestError for PluginHostError {
fn from_request_rejection(status: u16, _message: String) -> Self {
Self {
code: if status == 413 {
PluginHostErrorCode::QuotaExceeded
} else {
PluginHostErrorCode::InvalidInput
},
effect: PluginEffectObservation::NotDispatched,
diagnostic_id: String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_protocol_rejects_identity_claims_and_unknown_fields() {
for value in [
serde_json::json!({"kind":"begin","plan_id":"p","installation_id":"i","consumer":{"kind":"tool_call","tool_call_id":"t","tool_name":"n"},"subject":"owner"}),
serde_json::json!({"kind":"begin","plan_id":"p","installation_id":"i","consumer":{"kind":"tool_call","tool_call_id":"t","tool_name":"n","worker_id":"w"}}),
serde_json::json!({"kind":"authorize","invocation_id":"i","operation":{"kind":"admit","subject":"owner"}}),
serde_json::json!({"kind":"send","invocation_id":"i","slot":"s","request":{"method":"GET","path":"/","query":[],"headers":[],"body":[],"timeout_ms":1,"effect_key":"e","url":"https://untrusted"}}),
] {
assert!(serde_json::from_value::<PluginHostRequest>(value).is_err());
}
assert!(
serde_json::from_value::<PluginHostResponse>(
serde_json::json!({"kind":"authorized","subject":"owner"})
)
.is_err()
);
assert!(serde_json::from_value::<PluginHostError>(serde_json::json!({"code":"denied","effect":"not_dispatched","diagnostic_id":"d","message":"secret"})).is_err());
}
#[test]
fn service_wire_rejects_nested_control_and_identity_claims() {
let lease = serde_json::json!({"consumer_id":"consumer", "lease_token":"lease"});
for nested in [
serde_json::json!({"kind":"service", "request":{"kind":"poll", "lease":lease}}),
serde_json::json!({"kind":"prepare", "plan_id":"p", "installation_id":"i", "tools":[]}),
serde_json::json!({"kind":"begin", "plan_id":"p", "installation_id":"i", "consumer":{"kind":"consumer", "consumer_id":"c", "event_id":"e", "export_name":"s"}}),
serde_json::json!({"kind":"cancel", "invocation_id":"i"}),
serde_json::json!({"kind":"authorize", "invocation_id":"i", "operation":{"kind":"admit"}, "subject":"owner"}),
] {
let wire = serde_json::json!({"kind":"service", "request":{"kind":"host", "lease":lease, "request":nested}});
assert!(serde_json::from_value::<PluginHostRequest>(wire).is_err());
}
let wire = serde_json::json!({"kind":"service", "request":{"kind":"host", "lease":lease, "request":{"kind":"read_ticket", "invocation_id":"invocation", "ticket_id":"ticket"}}});
let request: PluginHostRequest = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), wire);
}
#[test]
fn log_integer_wire_preserves_full_wit_range_without_json_precision_loss() {
for scalar in [
PluginLogScalar::Signed(i64::MIN),
PluginLogScalar::Signed(i64::MAX),
PluginLogScalar::Unsigned(u64::MAX),
] {
let wire = serde_json::to_value(&scalar).unwrap();
assert!(wire["value"].is_string());
assert_eq!(
serde_json::from_value::<PluginLogScalar>(wire).unwrap(),
scalar
);
}
for value in [
serde_json::json!(1),
serde_json::json!("01"),
serde_json::json!("+1"),
serde_json::json!("18446744073709551616"),
] {
assert!(
serde_json::from_value::<PluginLogScalar>(
serde_json::json!({"kind":"unsigned","value":value})
)
.is_err()
);
}
}
#[test]
fn host_route_uses_runtime_context_and_closed_error_schema() {
let document = crate::canonical_openapi_document().unwrap();
let value: serde_json::Value = serde_json::from_str(&document.to_json().unwrap()).unwrap();
let route = &value["paths"]["/api/w/{workspace_id}/plugin-host"]["post"];
for status in [200, 400, 401, 403, 404, 409, 413, 429, 503] {
assert!(
route["responses"][status.to_string()].is_object(),
"{status}"
);
}
assert!(
route["security"].is_null(),
"signed Runtime context, not human bearer/browser auth"
);
let params = route["parameters"].as_array().unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params[0]["name"], "workspace_id");
let error = &value["components"]["schemas"]["PluginHostError"];
assert_eq!(error["additionalProperties"], false);
assert_eq!(error["properties"].as_object().unwrap().len(), 3);
}
}
+238
View File
@@ -0,0 +1,238 @@
//! Host-owned Ticket Service delivery. Lease proofs fence a driver, not authorize it.
//! All requests still require the authenticated Worker and current retained plan.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
impl api_macros::openapi::OpenApiSchema for PluginServiceRecoveryRequest {}
impl api_macros::openapi::OpenApiSchema for PluginServiceDiagnostics {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceLease {
pub consumer_id: String,
pub lease_token: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginTicketEventType {
Changed,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginTicketEventEnvelope {
pub event_id: String,
pub event_type: PluginTicketEventType,
pub version: u32,
pub occurred_at: String,
pub ticket_id: String,
pub domain_event_ref: String,
pub content_digest: String,
pub correlation_id: String,
pub causation_id: Option<String>,
pub attempt: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginServiceEventResult {
Ack,
RetryAfter {
delay_ms: u32,
},
Reject,
/// Host-observed unknown effect; never automatically redelivered.
Indeterminate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginServicePauseReason {
Rejected,
AttemptsExhausted,
QueueFull,
RetentionGap,
Indeterminate,
Authorization,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceDiagnostics {
pub consumer_id: String,
pub pause_reason: Option<PluginServicePauseReason>,
pub queued: u32,
pub attempt: u32,
pub current_event_id: Option<String>,
pub lease_active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginServiceLifecycleOperation {
Admit,
Publish,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginServiceRequest {
/// Live preflight for a reserved or running Worker. Does not create consumer
/// state, acquire a lease, or admit event effects.
Prepare {
plan_id: String,
installation_id: String,
service_name: String,
operation: PluginServiceLifecycleOperation,
},
/// Lifecycle logging uses live policy but never an event invocation/lease.
LifecycleLog {
plan_id: String,
installation_id: String,
service_name: String,
entry: crate::PluginLogEntry,
},
/// Creates a consumer at the current tail, or restores the exact retained one.
Acquire {
plan_id: String,
installation_id: String,
service_name: String,
},
Poll {
lease: PluginServiceLease,
},
Complete {
lease: PluginServiceLease,
event_id: String,
result: PluginServiceEventResult,
},
Release {
lease: PluginServiceLease,
},
Diagnostics {
consumer_id: String,
},
/// The only path for Service event Host operations. Nested Service requests,
/// Tool admission and cancellation are not accepted here.
Host {
lease: PluginServiceLease,
request: Box<crate::PluginInvocationRequest>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginServiceResponse {
Prepared,
Logged,
Acquired {
lease: PluginServiceLease,
},
Idle,
Delivery {
envelope: PluginTicketEventEnvelope,
invocation_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
expires_at_ms: i64,
},
Completed,
Released,
Diagnostics {
diagnostics: PluginServiceDiagnostics,
},
Host {
response: Box<crate::PluginInvocationResponse>,
},
}
/// Audited recovery is an explicit Workspace management operation, not a guest import.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginServiceRecoveryAction {
Resume,
Skip {
event_id: String,
},
/// A retention gap cannot be skipped by guessing a missing event identity.
AcceptGap,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceRecoveryRequest {
pub consumer_id: String,
pub expected_pause: PluginServicePauseReason,
pub action: PluginServiceRecoveryAction,
pub operation_key: String,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn prepare_is_lease_free_and_has_no_consumer_or_event_identity() {
let request = PluginServiceRequest::Prepare {
plan_id: "plan".into(),
installation_id: "installation".into(),
service_name: "watch".into(),
operation: PluginServiceLifecycleOperation::Publish,
};
let value = serde_json::to_value(&request).unwrap();
assert_eq!(
value,
json!({"kind":"prepare","plan_id":"plan","installation_id":"installation","service_name":"watch","operation":"publish"})
);
assert_eq!(
serde_json::from_value::<PluginServiceRequest>(value).unwrap(),
request
);
assert!(serde_json::from_value::<PluginServiceRequest>(json!({"kind":"prepare","plan_id":"plan","installation_id":"installation","service_name":"watch","lease":{"consumer_id":"consumer","lease_token":"token"}})).is_err());
}
#[test]
fn leased_host_wire_type_cannot_nest_service_or_admit_tool_calls() {
for nested in [
json!({"kind":"service","request":{"kind":"diagnostics","consumer_id":"other"}}),
json!({"kind":"begin","plan_id":"p","installation_id":"i","consumer":{"kind":"tool_call","tool_call_id":"call","tool_name":"tool"}}),
json!({"kind":"cancel","invocation_id":"invocation"}),
] {
assert!(serde_json::from_value::<PluginServiceRequest>(json!({"kind":"host","lease":{"consumer_id":"consumer","lease_token":"token"},"request":nested})).is_err());
}
// This must stay a finite schema for OpenAPI/TypeScript generation too.
let schema = schemars::schema_for!(PluginServiceRequest);
assert!(
!serde_json::to_string(&schema)
.unwrap()
.contains("PluginHostRequest")
);
}
#[test]
fn recovery_is_typed_management_input_not_a_guest_service_request() {
let recovery = json!({"consumer_id":"consumer","expected_pause":"indeterminate","action":{"kind":"skip","event_id":"event"},"operation_key":"reviewed-skip","reason":"effect independently reconciled"});
assert!(serde_json::from_value::<PluginServiceRecoveryRequest>(recovery.clone()).is_ok());
assert!(serde_json::from_value::<PluginServiceRequest>(recovery).is_err());
}
}
+229
View File
@@ -0,0 +1,229 @@
//! Generated plugin types share the exact DTOs used by the HTTP declarations.
#[cfg(feature = "typescript")]
pub fn plugin_host_typescript() -> String {
use crate::*;
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
PluginServiceLease::decl(&config),
PluginTicketEventType::decl(&config),
PluginTicketEventEnvelope::decl(&config),
PluginServiceEventResult::decl(&config),
PluginServicePauseReason::decl(&config),
PluginServiceDiagnostics::decl(&config),
PluginServiceLifecycleOperation::decl(&config),
PluginServiceRequest::decl(&config),
PluginServiceResponse::decl(&config),
PluginServiceRecoveryAction::decl(&config),
PluginHttpMethod::decl(&config),
PluginConsumer::decl(&config),
PluginTicketAccess::decl(&config),
PluginTicketCaseState::decl(&config),
PluginTicketSnapshot::decl(&config),
PluginDataCollection::decl(&config),
PluginDataAddress::decl(&config),
PluginDataExpectation::decl(&config),
PluginDataCell::decl(&config),
PluginDataMutation::decl(&config),
PluginDataReceipt::decl(&config),
PluginDataMutationLookup::decl(&config),
PluginDataEntry::decl(&config),
PluginDataPage::decl(&config),
PluginInvocationRequest::decl(&config),
PluginInvocationResponse::decl(&config),
PluginHostRequest::decl(&config),
PluginHostOperation::decl(&config),
PluginHttpRequest::decl(&config),
PluginHttpResponse::decl(&config),
PluginHostResponse::decl(&config),
PluginLogEntry::decl(&config),
PluginLogLevel::decl(&config),
PluginLogField::decl(&config),
PluginLogScalar::decl(&config),
PluginHostError::decl(&config),
PluginHostErrorCode::decl(&config),
PluginEffectObservation::decl(&config),
];
declarations.map(|d| format!("export {d}")).join("\n\n")
}
#[cfg(feature = "typescript")]
pub fn plugin_api_typescript() -> String {
use crate::*;
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
PluginInstallRequest::decl(&config),
PluginAddArtifactRequest::decl(&config),
PluginRevokeRequest::decl(&config),
PluginUninstallRequest::decl(&config),
PluginListRequest::decl(&config),
PluginSchemaContribution::decl(&config),
PluginToolMetadata::decl(&config),
PluginHttpMethod::decl(&config),
PluginConnectionMetadata::decl(&config),
PluginGrantMetadata::decl(&config),
PluginServiceMetadata::decl(&config),
PluginServicePauseReason::decl(&config),
PluginServiceDiagnostics::decl(&config),
PluginServiceRecoveryAction::decl(&config),
PluginServiceRecoveryRequest::decl(&config),
PluginDataSchemaMetadata::decl(&config),
PluginDataPurgeState::decl(&config),
PluginDataPurgeRequest::decl(&config),
PluginDataPurgeResponse::decl(&config),
PluginArtifact::decl(&config),
PluginUploadResponse::decl(&config),
PluginInstallation::decl(&config),
PluginInstallationSummary::decl(&config),
PluginListResponse::decl(&config),
PluginOperationKind::decl(&config),
PluginOperationResponse::decl(&config),
PluginArtifactDeliveryRequest::decl(&config),
PluginArtifactDeliveryMetadata::decl(&config),
PluginHostErrorCode::decl(&config),
PluginWorkerDiagnostics::decl(&config),
PluginWorkerDiagnosticSelection::decl(&config),
PluginDiagnosticPinState::decl(&config),
PluginDiagnosticSurface::decl(&config),
PluginServiceConsumerDiagnostics::decl(&config),
PluginWorkerDiagnostic::decl(&config),
PluginDiagnosticAdmission::decl(&config),
PluginDiagnosticConnection::decl(&config),
PluginDiagnosticConnectionLimits::decl(&config),
PluginRuntimeLimits::decl(&config),
];
format!(
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_plugin_api_types > web/workspace/src/lib/generated/plugin-api.ts\n\n{}\n",
declarations.map(|d| format!("export {d}")).join("\n\n")
)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "typescript")]
#[test]
fn host_typescript_module_resolves_all_wire_type_dependencies() {
// Exercise the actual generator and TypeScript checker, not a duplicated
// list of type names. Deno is a prerequisite of this typescript suite;
// the maintained runner bounds/reaps its process group on timeout.
let directory = tempfile::tempdir().unwrap();
let module = directory.path().join("plugin-host.ts");
std::fs::write(&module, super::plugin_host_typescript()).unwrap();
let output = std::process::Command::new("deno")
.args(["check", "--no-config", "--no-lock", "--no-remote"])
.arg(&module)
.current_dir(directory.path())
.output()
.expect("Host TypeScript contract tests require Deno on PATH");
assert!(
output.status.success(),
"generated Host TypeScript does not type-check:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
#[cfg(feature = "typescript")]
#[test]
fn host_typescript_preserves_wire_numbers_and_lossless_scalar_strings() {
let text = super::plugin_host_typescript();
for name in [
"PluginHostRequest",
"PluginHostResponse",
"PluginHostError",
"PluginConsumer",
"PluginHttpRequest",
"PluginHttpResponse",
"PluginLogEntry",
] {
assert!(text.contains(&format!("export type {name} =")), "{name}");
}
assert!(text.contains("expires_at_ms: number"));
assert!(text.contains("\"signed\", \"value\": string"));
assert!(text.contains("\"unsigned\", \"value\": string"));
}
use api_macros::ApiContract;
#[test]
fn plugin_routes_use_binary_upload_and_closed_management_contracts() {
let document = crate::canonical_openapi_document().unwrap();
let value: serde_json::Value = serde_json::from_str(&document.to_json().unwrap()).unwrap();
let upload = &value["paths"]["/api/w/{workspace_id}/plugin-uploads"]["post"];
assert!(upload["requestBody"]["content"]["application/octet-stream"].is_object());
assert!(upload["requestBody"]["content"]["application/json"].is_null());
for path in [
"plugins",
"plugins/{installation_id}",
"plugin-operations/{operation_id}",
] {
assert!(value["paths"][format!("/api/w/{{workspace_id}}/{path}")]["get"].is_object());
}
assert!(
serde_json::from_value::<crate::PluginInstallRequest>(serde_json::json!({
"upload_id":"x", "alias":"a", "operation_key":"k", "role":"owner"
}))
.is_err()
);
assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/enable"].is_null());
assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/data"].is_null());
}
#[test]
fn artifact_delivery_is_runtime_signed_and_has_no_caller_authority_or_transport_inputs() {
for operation in ["plugin_artifact_authorize", "plugin_artifact_pull"] {
assert!(
crate::ServerApiMetadata::OPERATIONS
.iter()
.any(|entry| entry.operation_id == operation)
);
}
let document = crate::canonical_openapi_document().unwrap();
let value: serde_json::Value = serde_json::from_str(&document.to_json().unwrap()).unwrap();
for suffix in ["authorize", "pull"] {
assert!(
value["paths"]
[format!("/api/w/{{workspace_id}}/workers/self/plugin-artifacts/{suffix}")]
.is_null()
);
}
let selection = serde_json::json!({
"plan_id":"plan", "plan_fingerprint":"fingerprint",
"installation_id":"installation", "artifact_digest":"digest"
});
assert!(
serde_json::from_value::<crate::PluginArtifactDeliveryRequest>(selection.clone())
.is_ok()
);
for field in [
"workspace_id",
"worker_id",
"principal_id",
"delegation_id",
"url",
"token",
"cache_path",
"bytes",
] {
let mut invalid = selection.clone();
invalid[field] = "caller-selected".into();
assert!(
serde_json::from_value::<crate::PluginArtifactDeliveryRequest>(invalid).is_err()
);
}
assert!(
serde_json::from_value::<crate::PluginArtifactDeliveryRequest>(
serde_json::json!({"artifact_digest":"digest"})
)
.is_err()
);
}
#[cfg(feature = "typescript")]
#[test]
fn generated_plugin_types_are_current() {
assert_eq!(
crate::normalized_typescript(super::plugin_api_typescript()),
include_str!("../../../web/workspace/src/lib/generated/plugin-api.ts")
);
}
}
@@ -0,0 +1,49 @@
use serde_json::json;
use server_api::{
SubjektivSubjectDeleteRequest, SubjektivSubjectDeleteResponse, SubjektivSubjectState,
};
#[test]
fn subject_delete_api_replaces_retire_and_requires_operation_identity() {
let document: serde_json::Value = serde_json::from_str(
&server_api::canonical_openapi_document()
.unwrap()
.to_json()
.unwrap(),
)
.unwrap();
let paths = &document["paths"];
assert!(
paths
.get("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire")
.is_none()
);
let operation = &paths["/api/w/{workspace_id}/subjektiv/subjects/{subject_id}"]["delete"];
assert_eq!(operation["operationId"], "subjektiv_subject_delete");
assert_eq!(operation["requestBody"]["required"], true);
assert!(serde_json::from_value::<SubjektivSubjectDeleteRequest>(json!({})).is_err());
assert!(
serde_json::from_value::<SubjektivSubjectDeleteRequest>(
json!({"operation_id":"op", "force":true})
)
.is_err()
);
for completed in [false, true] {
let response = SubjektivSubjectDeleteResponse {
subject_id: "subject".into(),
operation_id: "op".into(),
completed,
};
assert_eq!(
serde_json::to_value(response).unwrap(),
json!({"subject_id":"subject", "operation_id":"op", "completed":completed})
);
}
}
#[test]
fn old_subject_state_is_explicit_saved_compatibility_not_a_new_operation() {
let state: SubjektivSubjectState = serde_json::from_value(json!("retired")).unwrap();
assert_eq!(state, SubjektivSubjectState::LegacyRetired);
assert_eq!(serde_json::to_value(state).unwrap(), json!("retired"));
}
@@ -0,0 +1,45 @@
use server_api::{SetTicketRoleAssignmentRequest, TicketAssignmentPrincipal, TicketQueryRequest};
#[test]
fn subject_assignment_and_query_use_the_same_exact_principal_identity() {
for subject_id in ["subject-a", "subject-b"] {
let principal = serde_json::json!({ "kind": "subject", "subject_id": subject_id });
let assign: SetTicketRoleAssignmentRequest = serde_json::from_value(serde_json::json!({
"operation_id": "assign-subject", "expected_assignment_id": null, "principal": principal
}))
.unwrap();
assert!(
matches!(assign.principal, TicketAssignmentPrincipal::Subject { subject_id: ref id } if id == subject_id)
);
let query: TicketQueryRequest = serde_json::from_value(serde_json::json!({
"assigned_principal": principal
}))
.unwrap();
assert_eq!(
serde_json::to_value(query.assigned_principal).unwrap(),
principal
);
assert_eq!(serde_json::to_value(assign.principal).unwrap(), principal);
}
}
#[test]
fn assignment_principal_keeps_legacy_audit_and_rejects_subject_identity_spoofing() {
for principal in [
serde_json::json!({ "kind": "user", "account_id": "alice" }),
serde_json::json!({ "kind": "worker", "runtime_id": "runtime", "worker_id": "worker" }),
serde_json::json!({ "kind": "workspace_agent", "agent_key": "workspace-orchestrator" }),
] {
let retained: TicketAssignmentPrincipal =
serde_json::from_value(principal.clone()).unwrap();
assert_eq!(serde_json::to_value(retained).unwrap(), principal);
}
for malformed in [
serde_json::json!({ "kind": "subject" }),
serde_json::json!({ "kind": "subject", "subject_id": 1 }),
serde_json::json!({ "kind": "subject", "subject_id": "a", "worker_id": "old-body" }),
serde_json::json!({ "kind": "subject", "subject_id": "a", "agent_key": "workspace-orchestrator" }),
] {
assert!(serde_json::from_value::<TicketAssignmentPrincipal>(malformed).is_err());
}
}
+2
View File
@@ -11,5 +11,7 @@ checks or Runtime catalog lifecycle management.
- [`worker_session_store`]: storage boundary for a Session bound to a Worker
- [`worker_metadata`]: current Worker state and active Segment switching
- [`system_item`]: records of system input presented to the model
- [`capture`]: Worker-independent immutable public values and bounded body transport;
authorization, source I/O, and delivery lifecycle stay with the Host
- [`public_snapshot`] / [`public_index`]: public projections of stored history
- [`event_trace`]: diagnostic records independent of history replay
+967
View File
@@ -0,0 +1,967 @@
//! Exploration domain for immutable Session captures, with no Workspace/Memory mutation capability.
//!
//! A capture is an ordered projection of committed entries; exploration excludes reasoning and
//! system prompts, including Host compaction summaries and other derived context. Full inherited
//! summary input belongs to compaction, not public observation or Memory evidence. Overview,
//! search, bounded reads, and evidence conversion share `SessionEntryRef`.
//! `SessionEntryRef` is a contextual UI reference. `ScopedSessionEntryRef` additionally binds
//! the Session and exact immutable capture, and is required for portable checked operations.
//! Appends preserve existing entry identities. Sparse real user/assistant anchors and intervening
//! entry counts index long histories. Search with an empty filter also provides bounded range lists.
//!
//! Host-fixed snapshot exploration, Memory candidate evidence validation, and current Worker
//! observation share only this domain; they do not enable or modify each other's Features.
//! The provider owns authorization to recapture a current Worker. A capture itself proves neither
//! completion nor approval and grants no business-mutation authority.
//!
//! Recording Hosts use the common committed replay through the guarded APIs below.
//! [`recording_catalog_worker_guarded`] and [`recording_catalog_fs_guarded`] expose
//! descriptors only to an admission callback under the caller's existing publication/
//! erasure guard and persisted active selector, without allocating a full body DTO.
//! [`capture_recording_slice_worker_guarded`] and [`capture_recording_slice_fs_guarded`]
//! separately construct bounded body-bearing inputs. Admission descriptors are not
//! sufficient Job inputs; saved recording DTOs are read/sliced without live storage.
use std::sync::Arc;
use crate::{
LoggedSessionHistoryMetadata as SessionHistoryMetadata,
LoggedSessionHistoryOrigin as HistoryOrigin,
};
mod body;
mod recording;
use agen::{HistoryEntry, Item, Role};
pub use body::*;
use protocol::{
SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntryData,
};
pub use recording::*;
use serde::{Deserialize, Serialize};
const DEFAULT_SEARCH_LIMIT: usize = 20;
const MAX_SEARCH_LIMIT: usize = 50;
const DEFAULT_READ_MAX_ITEMS: usize = 40;
const MAX_READ_MAX_ITEMS: usize = 80;
const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024;
const OVERVIEW_ANCHOR_STRIDE: usize = 8;
/// Append-stable contextual UI reference derived from a logical entry identity.
/// Portable consumers must bind it with `SessionCapture::scoped_ref` before use.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionEntryRef(String);
impl SessionEntryRef {
pub fn from_history_entry_id(entry_id: &crate::LoggedSessionHistoryEntryId) -> Self {
Self(format!("E{}", entry_id.0))
}
pub fn parse(value: &str) -> Option<Self> {
let suffix = value.strip_prefix('E')?;
if suffix.is_empty()
// Legacy Host boundary identities contain both Session and Segment UUIDs.
|| suffix.len() > 256
|| !suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return None;
}
Some(Self(value.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SessionEntryRef {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferenceKind {
User,
Assistant,
System,
Tool,
}
impl ReferenceKind {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
Self::Tool => "tool",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"user" => Some(Self::User),
"assistant" | "agent" => Some(Self::Assistant),
"system" => Some(Self::System),
"tool" => Some(Self::Tool),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolPart {
Input,
Output,
Both,
}
impl ToolPart {
pub fn parse(value: &str) -> Option<Self> {
match value {
"input" => Some(Self::Input),
"output" => Some(Self::Output),
"both" => Some(Self::Both),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Input => "input",
Self::Output => "output",
Self::Both => "both",
}
}
pub fn matches(self, actual: ToolPart) -> bool {
matches!(self, ToolPart::Both) || self == actual
}
}
#[derive(Debug, Clone)]
pub struct OverviewItem {
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub label: String,
pub text: String,
pub intervening_entries: usize,
}
#[derive(Debug, Clone)]
pub struct ReferenceEntry {
pub attachment_count: usize,
use_policy: Option<protocol::SessionEntryUsePolicy>,
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
pub label: String,
pub summary: String,
search_text: String,
}
#[derive(Debug, Clone, Default)]
pub struct SearchOptions {
pub query: String,
pub kind: Option<ReferenceKind>,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
pub limit: Option<usize>,
pub min_entry_index: Option<u64>,
pub from: Option<SessionEntryRef>,
pub through: Option<SessionEntryRef>,
pub offset: usize,
}
#[derive(Debug, Clone)]
pub struct SearchHit {
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
pub entry_range: [u64; 2],
pub label: String,
pub summary: String,
}
#[derive(Debug, Clone, Copy)]
pub enum ReadSelector<'a> {
Id(&'a str),
EntryRange([u64; 2]),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadDetail {
Compact,
Full,
}
#[derive(Debug, Clone)]
pub struct ReadOptions {
pub include_tools: bool,
pub tool_part: ToolPart,
pub detail: ReadDetail,
pub max_items: usize,
pub max_bytes: usize,
}
impl Default for ReadOptions {
fn default() -> Self {
Self {
include_tools: true,
tool_part: ToolPart::Both,
detail: ReadDetail::Compact,
max_items: DEFAULT_READ_MAX_ITEMS,
max_bytes: DEFAULT_READ_MAX_BYTES,
}
}
}
#[derive(Debug, Clone)]
pub struct ReadEntry {
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
pub entry_range: [u64; 2],
pub label: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct ReadResult {
pub entries: Vec<ReadEntry>,
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub struct SessionEntryEvidence {
pub segment_id: String,
pub entry_ref: SessionEntryRef,
pub origin: SessionEntryProvenance,
/// Inclusive original-source positions in `segment_id`, not view/UI order.
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub label: String,
pub summary: String,
pub excerpt: String,
}
#[derive(Debug, Clone)]
struct CapturedHistoryEntry {
use_policy: Option<protocol::SessionEntryUsePolicy>,
item: Item,
entry_id: crate::LoggedSessionHistoryEntryId,
origin: SessionEntryProvenance,
dto: CaptureEntryDto,
}
#[derive(Debug, Clone)]
pub struct SessionCapture {
session_id: String,
segment_id: String,
capture_id: String,
view_entry_count: u64,
entries: Arc<Vec<CapturedHistoryEntry>>,
overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>,
}
impl SessionCapture {
pub fn from_session_snapshot(
session_id: impl Into<String>,
segment_id: impl Into<String>,
snapshot: SessionSnapshot,
) -> Self {
let segment_id = segment_id.into();
let view_entry_count = snapshot.entries.len() as u64;
let entries = snapshot
.entries
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
let policy = CaptureUsePolicy {
recording_material: false,
memory_evidence: entry.use_policy.is_none_or(|p| p.memory_evidence),
observation_readable: entry.use_policy.is_none_or(|p| p.observation_readable),
};
let explicit = entry.use_policy.is_some();
let item = match entry.data {
SessionSnapshotEntryData::UserInput { segments } => {
Item::user_message(protocol::Segment::flatten_to_text(&segments))
}
SessionSnapshotEntryData::Message { role, content } => Item::Message {
id: None,
status: None,
role: match role {
SessionMessageRole::User => Role::User,
SessionMessageRole::Assistant => Role::Assistant,
},
content: content
.into_iter()
.map(|part| match part {
SessionContentPart::Text { text } => {
agen::ContentPart::Text { text }
}
SessionContentPart::Refusal { refusal } => {
agen::ContentPart::Refusal { refusal }
}
})
.collect(),
},
SessionSnapshotEntryData::ToolCall {
call_id,
name,
arguments,
} => Item::tool_call(call_id, name, arguments),
SessionSnapshotEntryData::ToolResult {
call_id,
summary,
content,
is_error,
..
} => Item::tool_result_item(call_id, summary, content, is_error),
SessionSnapshotEntryData::SystemItem { content, .. } if explicit => {
Item::system_message(content)
}
_ => return None,
};
let body = CaptureBody::project(&item, &entry.provenance, explicit)?;
if !policy.any() {
return None;
}
Some(CaptureEntryDto {
entry_id: entry.entry_id,
order: index as u64,
source: CaptureSource {
segment_id: segment_id.clone(),
entry_index: index as u64,
provenance: entry.provenance,
history_origin: None,
derived_from: entry.derived_from,
},
policy,
body,
})
})
.collect();
Self::from_projected(session_id.into(), segment_id, view_entry_count, entries)
}
pub fn new(
session_id: impl Into<String>,
segment_id: impl Into<String>,
items: Vec<Item>,
) -> Self {
let segment_id = segment_id.into();
let view_entry_count = items.len() as u64;
let entries = items
.into_iter()
.enumerate()
.filter_map(|(index, item)| {
let origin = SessionEntryProvenance::LegacyUnknown;
let body = CaptureBody::project(&item, &origin, false)?;
Some(CaptureEntryDto {
entry_id: format!("{index:08}"),
order: index as u64,
source: CaptureSource {
segment_id: segment_id.clone(),
entry_index: index as u64,
provenance: origin,
history_origin: None,
derived_from: Vec::new(),
},
policy: CaptureUsePolicy::PUBLIC,
body,
})
})
.collect();
Self::from_projected(session_id.into(), segment_id, view_entry_count, entries)
}
/// Project a complete Host-fixed history whose source positions start at zero.
/// Select a suffix with `select_order_range` on the resulting value instead
/// of passing a rebased history vector: annotations do not carry source positions.
pub fn from_complete_history(
session_id: impl Into<String>,
segment_id: impl Into<String>,
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
) -> Self {
let segment_id = segment_id.into();
let view_entry_count = entries.len() as u64;
let entries = entries
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
let origin = public_provenance(&entry.annotation.origin);
let mut item = entry.item;
let mut explicit = false;
let policy = if let Some(input) = entry.annotation.input_metadata {
explicit = true;
if matches!(
item,
Item::Message {
role: Role::System,
..
}
) {
// Only the explicit public projection crosses this boundary.
let public = input.public_item()?;
item = Item::system_message(public.body.clone());
} else if input.feature_payload().is_some() || input.public_item().is_some() {
return None;
}
CaptureUsePolicy::from(input.usage())
} else {
if matches!(entry.annotation.origin, HistoryOrigin::Provided { .. }) {
return None;
}
CaptureUsePolicy::PUBLIC
};
if !policy.any() {
return None;
}
let body = CaptureBody::project(&item, &origin, explicit)?;
Some(CaptureEntryDto {
entry_id: entry.annotation.entry_id.0,
order: index as u64,
source: CaptureSource {
segment_id: segment_id.clone(),
entry_index: index as u64,
provenance: origin,
history_origin: Some(entry.annotation.origin),
derived_from: entry
.annotation
.derivation
.map(|d| d.sources.into_iter().map(|id| id.0).collect())
.unwrap_or_default(),
},
policy,
body,
})
})
.collect();
Self::from_projected(session_id.into(), segment_id, view_entry_count, entries)
}
fn from_projected(
session_id: String,
segment_id: String,
view_entry_count: u64,
dtos: Vec<CaptureEntryDto>,
) -> Self {
let dto = CaptureDto {
version: CAPTURE_VERSION,
session_id: session_id.clone(),
segment_id: segment_id.clone(),
view_entry_count,
entries: dtos,
};
let capture_id = dto.fingerprint();
let entries: Vec<_> = dto
.entries
.into_iter()
.map(|dto| CapturedHistoryEntry {
use_policy: Some(protocol::SessionEntryUsePolicy {
observation_readable: dto.policy.observation_readable,
memory_evidence: dto.policy.memory_evidence,
}),
item: dto.body.to_item(),
entry_id: crate::LoggedSessionHistoryEntryId(dto.entry_id.clone()),
origin: dto.source.provenance.clone(),
dto,
})
.collect();
let mut overview = Vec::new();
let mut index = Vec::new();
for entry in &entries {
let item = &entry.item;
let entry_range = [entry.dto.order; 2];
let attachment_count = match entry.dto.body {
CaptureBody::ToolResult {
attachment_count, ..
} => attachment_count,
_ => 0,
};
match item {
Item::Message { role, content, .. } => {
let Some(kind) = message_reference_kind(&entry.origin, role, entry.use_policy)
else {
continue;
};
let text = content
.iter()
.map(|p| p.as_text())
.collect::<Vec<_>>()
.join("");
let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240);
let id = SessionEntryRef::from_history_entry_id(&entry.entry_id);
index.push(ReferenceEntry {
use_policy: entry.use_policy,
attachment_count,
id: id.clone(),
origin: entry.origin.clone(),
entry_range,
kind,
tool_part: None,
tool_name: None,
label: label.clone(),
summary: summary.clone(),
search_text: text.clone(),
});
if entry.use_policy.is_none_or(|p| p.observation_readable)
&& matches!(kind, ReferenceKind::User | ReferenceKind::Assistant)
{
overview.push(OverviewItem {
id: id.clone(),
origin: entry.origin.clone(),
entry_range,
kind,
label,
text,
intervening_entries: 0,
});
}
}
Item::ToolCall {
name, arguments, ..
} => {
let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry {
use_policy: entry.use_policy,
attachment_count,
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
origin: entry.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input),
tool_name: Some(name.clone()),
label: format!("tool input: {name}"),
summary: format!("Tool call {name}"),
search_text: text,
});
}
Item::ToolResult {
summary,
content,
attachments: _,
..
} => {
let attachment_marker = if attachment_count == 0 {
String::new()
} else {
format!("\n[{} image attachment(s)]", attachment_count)
};
let text = format!(
"{summary}\n{}{attachment_marker}",
content.as_deref().unwrap_or_default(),
);
index.push(ReferenceEntry {
use_policy: entry.use_policy,
attachment_count,
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
origin: entry.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output),
tool_name: None,
label: "tool output".to_string(),
summary: truncate_chars(summary, 240),
search_text: text,
});
}
Item::AssistantResponseBoundary { .. } | Item::Reasoning { .. } => {}
}
}
if overview.len() > 2 {
let last = overview.len() - 1;
overview = overview
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
(index == 0 || index == last || index % OVERVIEW_ANCHOR_STRIDE == 0)
.then_some(entry)
})
.collect();
}
for overview_index in 0..overview.len().saturating_sub(1) {
let current_entry = overview[overview_index].entry_range[0];
let next_entry = overview[overview_index + 1].entry_range[0];
overview[overview_index].intervening_entries = index
.iter()
.filter(|entry| {
let entry_index = entry.entry_range[0];
entry_index > current_entry && entry_index < next_entry
})
.count();
}
Self {
session_id,
segment_id,
capture_id,
view_entry_count,
entries: Arc::new(entries),
overview,
index,
}
}
/// Public source entries are immutable; callers retain their own business decisions.
pub fn entries(&self) -> impl ExactSizeIterator<Item = &CaptureEntryDto> {
self.entries.iter().map(|entry| &entry.dto)
}
pub fn overview(&self) -> &[OverviewItem] {
&self.overview
}
/// Capture-local UI position, not the original source position used by evidence.
pub fn view_order_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
self.index
.iter()
.find(|entry| entry.id == *reference)
.map(|entry| entry.entry_range[0])
}
pub fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
if self.validate_search(options).is_err() {
return Vec::new();
}
let query = options.query.trim().to_lowercase();
let limit = options
.limit
.unwrap_or(DEFAULT_SEARCH_LIMIT)
.clamp(1, MAX_SEARCH_LIMIT);
let tool_name = options.tool_name.as_deref();
let min_entry_index = options
.from
.as_ref()
.and_then(|reference| self.view_order_for_ref(reference))
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
let max_entry_index = options
.through
.as_ref()
.and_then(|reference| self.view_order_for_ref(reference))
.unwrap_or(u64::MAX);
let mut skipped = 0usize;
let mut hits = Vec::new();
for entry in &self.index {
if entry.use_policy.is_some_and(|p| !p.observation_readable) {
continue;
}
if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index {
continue;
}
if let Some(kind) = options.kind {
if entry.kind != kind {
continue;
}
}
if let Some(part) = options.tool_part {
if entry.kind != ReferenceKind::Tool {
continue;
}
let Some(actual) = entry.tool_part else {
continue;
};
if !part.matches(actual) {
continue;
}
}
if let Some(name) = tool_name {
if entry.tool_name.as_deref() != Some(name) {
continue;
}
}
if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) {
continue;
}
if skipped < options.offset {
skipped += 1;
continue;
}
hits.push(SearchHit {
id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind,
tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(),
entry_range: entry.entry_range,
label: entry.label.clone(),
summary: entry.summary.clone(),
});
if hits.len() >= limit {
break;
}
}
hits
}
pub fn read(&self, selector: ReadSelector<'_>, options: ReadOptions) -> ReadResult {
let max_items = options.max_items.clamp(1, MAX_READ_MAX_ITEMS);
let max_bytes = options.max_bytes.max(1);
let mut entries = Vec::new();
let mut bytes = 0usize;
let mut truncated = false;
let selected: Vec<&ReferenceEntry> = match selector {
ReadSelector::Id(id) => self
.index
.iter()
.filter(|entry| entry.id.as_str() == id)
.collect(),
ReadSelector::EntryRange([start, end]) => self
.index
.iter()
.filter(|entry| entry.entry_range[0] >= start && entry.entry_range[0] <= end)
.collect(),
};
for entry in selected {
if entry.use_policy.is_some_and(|p| !p.observation_readable) {
continue;
}
if entries.len() >= max_items {
truncated = true;
break;
}
if entry.kind == ReferenceKind::Tool {
if !options.include_tools {
continue;
}
if let Some(actual) = entry.tool_part {
if !options.tool_part.matches(actual) {
continue;
}
}
}
let Some(item) = self
.entries
.iter()
.find(|captured| captured.dto.order == entry.entry_range[0])
.map(|entry| &entry.item)
else {
continue;
};
let remaining = max_bytes.saturating_sub(bytes);
if remaining == 0 {
truncated = true;
break;
}
let rendered = render_item(item, entry, options.detail, usize::MAX);
truncated |= rendered.len() > remaining;
let text = truncate_bytes(&rendered, remaining);
bytes = bytes.saturating_add(text.len());
entries.push(ReadEntry {
id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind,
tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(),
entry_range: entry.entry_range,
label: entry.label.clone(),
text,
});
if truncated {
break;
}
}
ReadResult { entries, truncated }
}
/// Resolves the committed tool-call input for a provider call id. This is
/// used by explicit-memory receipts only after the containing run is durable,
/// so a model-authored claim can cite its own committed tool invocation
/// without inventing a SessionEntryRef.
pub fn evidence_for_tool_call(&self, call_id: &str) -> Option<SessionEntryEvidence> {
let captured = self.entries.iter().find(|entry| {
matches!(
&entry.item,
Item::ToolCall {
call_id: stored_call_id,
..
} if stored_call_id == call_id
)
})?;
let reference = self.index.iter().find(|entry| {
entry.entry_range[0] == captured.dto.order
&& entry.kind == ReferenceKind::Tool
&& entry.tool_part == Some(ToolPart::Input)
})?;
self.evidence_for(reference.id.as_str())
}
pub fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
if entry.use_policy.is_some_and(|p| !p.memory_evidence) {
return None;
}
let captured = self
.entries
.iter()
.find(|captured| captured.dto.order == entry.entry_range[0])?;
let excerpt = render_item(&captured.item, entry, ReadDetail::Compact, 2 * 1024);
Some(SessionEntryEvidence {
segment_id: captured.dto.source.segment_id.clone(),
entry_ref: entry.id.clone(),
origin: entry.origin.clone(),
entry_range: [captured.dto.source.entry_index; 2],
kind: entry.kind,
tool_part: entry.tool_part,
label: entry.label.clone(),
summary: entry.summary.clone(),
excerpt,
})
}
}
pub(crate) fn public_provenance(origin: &HistoryOrigin) -> SessionEntryProvenance {
match origin {
HistoryOrigin::HumanInput { .. } | HistoryOrigin::LocalHumanInput => {
SessionEntryProvenance::HumanInput
}
HistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
HistoryOrigin::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction,
HistoryOrigin::BackendInstruction { .. } | HistoryOrigin::Provided { .. } => {
SessionEntryProvenance::BackendInstruction
}
HistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
HistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
HistoryOrigin::CompactionSummary
| HistoryOrigin::DerivedSummary
| HistoryOrigin::JournalContext => SessionEntryProvenance::DerivedSummary,
HistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
fn message_reference_kind(
origin: &SessionEntryProvenance,
provider_role: &Role,
use_policy: Option<protocol::SessionEntryUsePolicy>,
) -> Option<ReferenceKind> {
if use_policy.is_some() {
return match provider_role {
Role::System => Some(ReferenceKind::System),
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
};
}
match origin {
SessionEntryProvenance::HumanInput | SessionEntryProvenance::WorkerInput => {
Some(ReferenceKind::User)
}
SessionEntryProvenance::ModelOutput => Some(ReferenceKind::Assistant),
SessionEntryProvenance::ToolOutput => Some(ReferenceKind::Tool),
SessionEntryProvenance::LegacyUnknown => match provider_role {
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
Role::System => None,
},
// Flow/backend/system content remains out of the observation surface
// even when represented with a provider user/system role.
SessionEntryProvenance::FlowInstruction
| SessionEntryProvenance::BackendInstruction
| SessionEntryProvenance::DerivedSummary => None,
}
}
fn render_item(
item: &Item,
entry: &ReferenceEntry,
detail: ReadDetail,
max_bytes: usize,
) -> String {
let text = match item {
Item::Message { role, content, .. } => {
let text = content
.iter()
.map(|p| p.as_text())
.collect::<Vec<_>>()
.join("");
format!("[{} {:?}] {text}", entry.id, role)
}
Item::ToolCall {
name, arguments, ..
} => match detail {
ReadDetail::Compact => format!("[{} ToolInput {name}] (arguments omitted)", entry.id),
ReadDetail::Full => format!("[{} ToolInput {name}]\narguments: {arguments}", entry.id),
},
Item::ToolResult {
summary,
content,
attachments: _,
is_error,
..
} => {
let attachment_line = if entry.attachment_count == 0 {
String::new()
} else {
format!("\nattachments: {} image(s)", entry.attachment_count)
};
match detail {
ReadDetail::Compact => format!(
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: (omitted){attachment_line}",
entry.id,
if *is_error { " error" } else { "" },
),
ReadDetail::Full => format!(
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: {}{attachment_line}",
entry.id,
if *is_error { " error" } else { "" },
content.as_deref().unwrap_or_default(),
),
}
}
Item::AssistantResponseBoundary { .. } => {
format!("[{} Assistant response boundary omitted]", entry.id)
}
Item::Reasoning { .. } => format!("[{} Reasoning omitted]", entry.id),
};
truncate_bytes(&text, max_bytes)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
if max_chars == 0 {
return "… [truncated]".to_string();
}
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut out = text.chars().take(max_chars).collect::<String>();
out.push_str("… [truncated]");
out
}
fn truncate_bytes(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_owned();
}
let marker = "… [truncated]";
let mut end = max_bytes.saturating_sub(marker.len());
while !text.is_char_boundary(end) {
end -= 1;
}
let mut out = text[..end].to_owned();
if max_bytes >= marker.len() {
out.push_str(marker);
}
out
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,425 @@
//! Host capture and pure canonical slicing. No Job state or live-reader fallback lives here.
use super::*;
use crate::committed_replay::CommittedPublicReplay;
use crate::public_material::{
PublicMaterialDescriptor, PublicMaterialError, PublicMaterialLimits, PublicMaterialUse,
};
use crate::retention::SessionRetentionStore;
use crate::{SegmentId, SessionId};
use std::path::Path;
/// Continuation in canonical Journal order, not an observation/Memory view cursor.
/// A Host must reconcile the descriptor at this position with its saved checkpoint.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecordingPosition {
pub canonical_order: u64,
pub byte_offset: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecordingCaptureSlice {
pub capture: CaptureDto,
pub next: Option<RecordingPosition>,
}
/// Inspect recording admission descriptors without constructing a body-bearing DTO.
///
/// The Host must authorize the source and hold the **same** Session publication/erasure
/// guard from before reading its persisted active pointer until this function returns
/// (including the callback). Pass that pointer's Segment, not a filename/mtime-derived
/// selector. This function verifies the guard and stored Session identities but does
/// not authorize the caller or establish that the supplied guard fences this root.
/// No lock is recursively acquired, no active pointer is reread, and no replay handle
/// or body-read capability is exposed to `inspect`.
///
/// The common committed recording replay fixes per-file ends before decoding. It
/// retains only descriptors/locations, decoding one record at a time and streaming
/// canonical bodies into their digests. `replay_limits` bound record allocation,
/// aggregate storage extent, entries and Segments; there is no full-capture text/JSON
/// allocation or `CaptureLimits` ceiling. The callback receives only descriptors in
/// canonical order and may return a Host-specific mapping (including a `Result`). It
/// runs once on success and not at all on replay/identity/resource errors.
///
/// This catalog is for Host admission, **not** a descriptor-only Job input. Construct
/// the Job's bounded body input with the guarded slice API at its own authorized
/// boundary; after saving it, execute only that immutable value. Neither descriptors
/// nor a completed callback authorize delayed adoption after erasure.
///
/// ```no_run
/// # use std::path::Path;
/// # use session_store::{SessionId, SegmentId};
/// # use session_store::retention::SessionRetentionGuard;
/// # use session_store::public_material::PublicMaterialError;
/// # fn admit(root: &Path, session: SessionId, active: SegmentId,
/// # guard: &SessionRetentionGuard) -> Result<Vec<u64>, PublicMaterialError> {
/// // `active` was resolved from persisted Host metadata while holding `guard`.
/// session_store::capture::recording_catalog_worker_guarded(
/// root, session, active, Default::default(), guard,
/// |descriptors| descriptors.iter().map(|d| d.total_bytes).collect(),
/// )
/// # }
/// ```
pub fn recording_catalog_worker_guarded<T>(
session_root: &Path,
expected_session: SessionId,
active_segment: SegmentId,
replay_limits: PublicMaterialLimits,
guard: &crate::retention::SessionRetentionGuard,
inspect: impl FnOnce(&[PublicMaterialDescriptor]) -> T,
) -> Result<T, PublicMaterialError> {
if guard.session_id() != expected_session {
return Err(PublicMaterialError::Inconsistent);
}
let replay = CommittedPublicReplay::open_worker_guarded(
session_root,
active_segment,
replay_limits,
None,
PublicMaterialUse::Recording,
)?;
if replay.session_id() != expected_session {
return Err(PublicMaterialError::Inconsistent);
}
Ok(inspect(&replay.into_recording_descriptors()))
}
/// FsStore-layout counterpart of [`recording_catalog_worker_guarded`].
///
/// `sessions_root` is the directory containing per-Session directories. Invoke this
/// inside the Host's `with_recording_boundary` callback, passing **its existing guard
/// and persisted active Segment**. That same external retention control store must
/// fence publishers, pointer changes, archive moves and erasure. Do not reacquire the
/// guard or use a separately opened legacy public reader inside the boundary.
/// All descriptor-only, allocation and callback guarantees of the Worker variant
/// apply; this function does not construct an unbounded transient `CaptureDto`.
pub fn recording_catalog_fs_guarded<T>(
sessions_root: &Path,
session: SessionId,
active_segment: SegmentId,
replay_limits: PublicMaterialLimits,
guard: &crate::retention::SessionRetentionGuard,
inspect: impl FnOnce(&[PublicMaterialDescriptor]) -> T,
) -> Result<T, PublicMaterialError> {
if guard.session_id() != session {
return Err(PublicMaterialError::Inconsistent);
}
let replay = CommittedPublicReplay::open_fs_guarded(
sessions_root,
session,
active_segment,
replay_limits,
)?;
Ok(inspect(&replay.into_recording_descriptors()))
}
/// Capture the selected original lineage, including typed Host run boundaries.
/// The caller must hold the Session publication/erasure guard while selecting the
/// active pointer and through this call. Runtime publishers use this same lock
/// for appends and Segment creation. No lock is recursively acquired here.
pub fn capture_recording_worker_guarded(
session_root: &Path,
expected_session: SessionId,
active_segment: SegmentId,
replay_limits: PublicMaterialLimits,
capture_limits: CaptureLimits,
guard: &crate::retention::SessionRetentionGuard,
) -> Result<CaptureDto, PublicMaterialError> {
if guard.session_id() != expected_session {
return Err(PublicMaterialError::Inconsistent);
}
let replay = CommittedPublicReplay::open_worker_guarded(
session_root,
active_segment,
replay_limits,
None,
PublicMaterialUse::Recording,
)?;
if replay.session_id() != expected_session {
return Err(PublicMaterialError::Inconsistent);
}
replay.into_recording_dto(capture_limits)
}
/// Standalone convenience entry; Hosts resolving a mutable active pointer should
/// instead hold the fence while resolving it and call the guarded entry.
pub fn capture_recording_worker(
session_root: &Path,
expected_session: SessionId,
active_segment: SegmentId,
retention: &SessionRetentionStore,
replay_limits: PublicMaterialLimits,
capture_limits: CaptureLimits,
) -> Result<CaptureDto, PublicMaterialError> {
let guard = retention
.lock_live(expected_session)
.map_err(|error| match error {
crate::StoreError::SessionErased(_) => PublicMaterialError::Erased,
_ => PublicMaterialError::Unavailable,
})?;
capture_recording_worker_guarded(
session_root,
expected_session,
active_segment,
replay_limits,
capture_limits,
&guard,
)
}
/// Bounded construction for one Job input: descriptor replay is bounded separately;
/// only at most the requested body budget is materialized from the fixed files.
pub fn capture_recording_slice_worker_guarded(
session_root: &Path,
expected_session: SessionId,
active_segment: SegmentId,
replay_limits: PublicMaterialLimits,
start: RecordingPosition,
max_entries: usize,
max_serialized_bytes: usize,
guard: &crate::retention::SessionRetentionGuard,
) -> Result<RecordingCaptureSlice, PublicMaterialError> {
if guard.session_id() != expected_session {
return Err(PublicMaterialError::Inconsistent);
}
let replay = CommittedPublicReplay::open_worker_guarded(
session_root,
active_segment,
replay_limits,
None,
PublicMaterialUse::Recording,
)?;
if replay.session_id() != expected_session {
return Err(PublicMaterialError::Inconsistent);
}
replay.recording_slice(start, max_entries, max_serialized_bytes)
}
/// The standalone FsStore layout uses the same committed replay and fixed values.
pub fn capture_recording_fs_guarded(
sessions_root: &Path,
session: SessionId,
active_segment: SegmentId,
replay_limits: PublicMaterialLimits,
capture_limits: CaptureLimits,
guard: &crate::retention::SessionRetentionGuard,
) -> Result<CaptureDto, PublicMaterialError> {
if guard.session_id() != session {
return Err(PublicMaterialError::Inconsistent);
}
CommittedPublicReplay::open_fs_guarded(sessions_root, session, active_segment, replay_limits)?
.into_recording_dto(capture_limits)
}
/// Bounded standalone capture; the caller holds its FsStore retention fence.
pub fn capture_recording_slice_fs_guarded(
sessions_root: &Path,
session: SessionId,
active_segment: SegmentId,
replay_limits: PublicMaterialLimits,
start: RecordingPosition,
max_entries: usize,
max_serialized_bytes: usize,
guard: &crate::retention::SessionRetentionGuard,
) -> Result<RecordingCaptureSlice, PublicMaterialError> {
if guard.session_id() != session {
return Err(PublicMaterialError::Inconsistent);
}
CommittedPublicReplay::open_fs_guarded(sessions_root, session, active_segment, replay_limits)?
.recording_slice(start, max_entries, max_serialized_bytes)
}
impl CaptureDto {
/// Select the oldest canonical prefix fitting both budgets, including JSON
/// escaping, metadata and continuation. Only immutable owned bytes are read.
/// A later Job can recapture at `next`; never combine live pages into this value.
pub fn recording_slice(
&self,
start: RecordingPosition,
max_entries: usize,
max_serialized_bytes: usize,
) -> Result<RecordingCaptureSlice, CaptureError> {
if max_entries == 0 {
return Err(CaptureError::BudgetTooSmall);
}
if start.canonical_order > self.view_entry_count {
return Err(CaptureError::InvalidRange);
}
let mut result = RecordingCaptureSlice {
capture: CaptureDto {
version: self.version,
session_id: self.session_id.clone(),
segment_id: self.segment_id.clone(),
view_entry_count: self.view_entry_count,
entries: Vec::new(),
},
next: (start.canonical_order < self.view_entry_count).then_some(start),
};
if body::json_size(&result, max_serialized_bytes).is_err() {
return Err(CaptureError::BudgetTooSmall);
}
if start.canonical_order == self.view_entry_count {
if start.byte_offset != 0 {
return Err(CaptureError::InvalidRange);
}
result.capture.validate(CaptureLimits {
max_bytes: max_serialized_bytes,
max_text_bytes: max_serialized_bytes,
max_entries,
..Default::default()
})?;
return Ok(result);
}
let first = self
.entries
.iter()
.position(|entry| entry.order == start.canonical_order)
.ok_or(CaptureError::InvalidRange)?;
let mut expected_order = start.canonical_order;
for entry in self.entries.iter().skip(first).take(max_entries) {
if entry.order != expected_order {
return Err(CaptureError::InvalidRange);
}
let CaptureBody::CanonicalPublic {
descriptor,
byte_offset,
text,
boundary,
} = &entry.body
else {
return Err(CaptureError::Invalid("not a recording capture"));
};
if !entry.policy.recording_material {
return Err(CaptureError::PolicyDenied);
}
let offset = if entry.order == start.canonical_order {
start.byte_offset
} else {
*byte_offset
};
let local = offset
.checked_sub(*byte_offset)
.and_then(|o| usize::try_from(o).ok())
.ok_or(CaptureError::InvalidRange)?;
if local > text.len()
|| !text.is_char_boundary(local)
|| offset >= descriptor.total_bytes
{
return Err(CaptureError::InvalidRange);
}
let item = CaptureEntryDto {
entry_id: entry.entry_id.clone(),
order: entry.order,
source: entry.source.clone(),
policy: entry.policy,
body: CaptureBody::CanonicalPublic {
descriptor: descriptor.clone(),
byte_offset: offset,
text: String::new(),
boundary: *boundary,
},
};
result.capture.entries.push(item);
// At most budget bytes are considered before allocating a trial fragment.
let mut low = 0;
let mut high = text.len().saturating_sub(local).min(max_serialized_bytes);
let prior_next = result.next;
while low < high {
let mid = low + (high - low).div_ceil(2);
let mut end = local + mid;
while !text.is_char_boundary(end) {
end -= 1;
}
if let CaptureBody::CanonicalPublic { text: fragment, .. } =
&mut result.capture.entries.last_mut().unwrap().body
{
*fragment = text[local..end].to_owned();
}
let next_offset = offset + (end - local) as u64;
result.next = if next_offset < descriptor.total_bytes {
Some(RecordingPosition {
canonical_order: entry.order,
byte_offset: next_offset,
})
} else {
(entry.order + 1 < self.view_entry_count).then_some(RecordingPosition {
canonical_order: entry.order + 1,
byte_offset: 0,
})
};
if body::json_size(&result, max_serialized_bytes).is_ok() {
low = mid;
} else {
high = mid - 1;
}
}
let mut end = local + low;
while !text.is_char_boundary(end) {
end -= 1;
}
if end == local {
result.capture.entries.pop();
result.next = prior_next;
if result.capture.entries.is_empty() {
return Err(CaptureError::BudgetTooSmall);
}
break;
}
if let CaptureBody::CanonicalPublic { text: fragment, .. } =
&mut result.capture.entries.last_mut().unwrap().body
{
*fragment = text[local..end].to_owned();
}
let next_offset = offset + (end - local) as u64;
result.next = if next_offset < descriptor.total_bytes {
Some(RecordingPosition {
canonical_order: entry.order,
byte_offset: next_offset,
})
} else {
(entry.order + 1 < self.view_entry_count).then_some(RecordingPosition {
canonical_order: entry.order + 1,
byte_offset: 0,
})
};
if next_offset < descriptor.total_bytes {
break;
}
expected_order = entry.order + 1;
}
body::json_size(&result, max_serialized_bytes).map_err(|_| CaptureError::BudgetTooSmall)?;
result.capture.validate(CaptureLimits {
max_bytes: max_serialized_bytes,
max_text_bytes: max_serialized_bytes,
max_entries,
..Default::default()
})?;
Ok(result)
}
/// Resolve canonical text already in the value. No original storage is consulted.
pub fn recording_text(
&self,
order: u64,
) -> Result<(&crate::public_material::PublicMaterialDescriptor, u64, &str), CaptureError> {
let entry = self
.entries
.iter()
.find(|entry| entry.order == order)
.ok_or(CaptureError::UnknownReference)?;
if !entry.policy.recording_material {
return Err(CaptureError::PolicyDenied);
}
match &entry.body {
CaptureBody::CanonicalPublic {
descriptor,
byte_offset,
text,
..
} => Ok((descriptor, *byte_offset, text)),
_ => Err(CaptureError::Invalid("not canonical recording text")),
}
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -38,6 +38,8 @@ pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Res
}
}
pub mod capture;
mod committed_replay;
pub mod event_trace;
pub mod fs_store;
pub mod history;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -46,6 +46,13 @@ pub struct SessionRetentionStore {
/// Do not recursively acquire the same Session lock inside a guarded callback.
pub struct SessionRetentionGuard {
_file: File,
session_id: SessionId,
}
impl SessionRetentionGuard {
pub fn session_id(&self) -> SessionId {
self.session_id
}
}
impl SessionRetentionStore {
@@ -71,7 +78,10 @@ impl SessionRetentionStore {
}
let file = options.open(self.root.join(format!("{session}.lock")))?;
FileExt::lock_exclusive(&file)?;
Ok(SessionRetentionGuard { _file: file })
Ok(SessionRetentionGuard {
_file: file,
session_id: session,
})
}
fn read(&self, session: SessionId) -> Result<RetentionState, StoreError> {
@@ -194,6 +194,21 @@ impl WorkerSessionStore {
Ok(store)
}
/// Open a current retained Session without creating or migrating anything.
/// The live constraint is checked here and again by every later publication;
/// attaching the control store itself does not acquire publication authority.
pub fn open_existing_with_retention(
root: impl Into<PathBuf>,
retention: crate::retention::SessionRetentionStore,
) -> Result<Self, StoreError> {
let mut store = Self::open_read_only(root)?;
if let Some(session) = *store.session_id.lock().unwrap() {
retention.ensure_live(session)?;
}
store.retention = Some(retention);
Ok(store)
}
fn lock_publication(
&self,
session: SessionId,
@@ -0,0 +1,711 @@
//! Public consumer boundary: real capture adapters, checked DTO codec and pure readers.
//! No Worker, persistence, live Session provider, or substituted capture implementation.
use agen::{HistoryEntry, Item};
use protocol::{
SessionEntryProvenance, SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData,
};
use session_store::capture::*;
use session_store::{
FeatureInputPayload, HistoryInputMetadata, HistoryInputUsePolicy, InputCompactionUse,
InputEvidenceUse, InputObservationUse, LoggedHistoryDerivation, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, PublicSystemItem,
PublicSystemPresentation,
};
fn history(
id: &str,
item: Item,
input_metadata: Option<HistoryInputMetadata>,
) -> HistoryEntry<LoggedSessionHistoryMetadata> {
HistoryEntry::new(
item,
LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId(id.into()),
origin: LoggedSessionHistoryOrigin::LocalHumanInput,
derivation: Some(LoggedHistoryDerivation {
sources: vec![LoggedSessionHistoryEntryId("earlier-entry".into())],
}),
input_metadata,
},
)
}
fn usage(recording: bool, memory: bool, observation: bool) -> HistoryInputUsePolicy {
HistoryInputUsePolicy {
recording_material: if recording {
InputEvidenceUse::Include
} else {
InputEvidenceUse::Exclude
},
memory_evidence: if memory {
InputEvidenceUse::Include
} else {
InputEvidenceUse::Exclude
},
observation: if observation {
InputObservationUse::Readable
} else {
InputObservationUse::Hidden
},
compaction: InputCompactionUse::Retain,
}
}
fn reference(capture: &SessionCapture, id: &str) -> ScopedSessionEntryRef {
capture
.scoped_ref(&SessionEntryRef::parse(id).unwrap())
.unwrap()
}
#[test]
fn public_consumer_roundtrip_retains_body_original_source_order_origin_and_independent_policy() {
let entries = vec![
history(
"recording",
Item::user_message("record-only"),
Some(HistoryInputMetadata::new(usage(true, false, false), None, None).unwrap()),
),
history(
"memory",
Item::user_message("memory-only"),
Some(HistoryInputMetadata::new(usage(false, true, false), None, None).unwrap()),
),
history(
"observation",
Item::user_message("observation-only"),
Some(HistoryInputMetadata::new(usage(false, false, true), None, None).unwrap()),
),
];
let capture = SessionCapture::from_complete_history("synthetic-session", "selected", entries);
let mut dto = capture.to_dto();
// The checked boundary also accepts entries retained from different original segments.
dto.entries[0].source.segment_id = "original-a".into();
dto.entries[0].source.entry_index = 80;
dto.entries[1].source.segment_id = "original-b".into();
dto.entries[1].source.entry_index = 3;
let capture = SessionCapture::try_from_dto(dto.clone(), CaptureLimits::default()).unwrap();
let encoded = capture.encode(CaptureLimits::default()).unwrap();
let restored = SessionCapture::decode(&encoded, CaptureLimits::default()).unwrap();
assert_eq!(restored.to_dto(), dto);
assert_eq!(restored.capture_id(), capture.capture_id());
for (id, allowed) in [
("Erecording", CapturePurpose::Recording),
("Ememory", CapturePurpose::MemoryEvidence),
("Eobservation", CapturePurpose::Observation),
] {
let reference = reference(&capture, id);
assert_eq!(
restored.resolve_ref(&reference, allowed).unwrap(),
capture.resolve_ref(&reference, allowed).unwrap()
);
for purpose in [
CapturePurpose::Recording,
CapturePurpose::MemoryEvidence,
CapturePurpose::Observation,
] {
assert_eq!(
restored.resolve_ref(&reference, purpose).is_ok(),
purpose == allowed
);
}
}
assert_eq!(restored.overview().len(), 1);
assert_eq!(restored.search(&SearchOptions::default()).len(), 1);
assert!(restored.evidence_for("Erecording").is_none());
let evidence = restored.evidence_for("Ememory").unwrap();
assert_eq!(evidence.segment_id, "original-b");
assert_eq!(evidence.entry_range, [3, 3]);
assert!(evidence.excerpt.contains("memory-only"));
let recorded = restored
.slice(CapturePurpose::Recording, None, 10, 16_000)
.unwrap();
assert_eq!(recorded.slice().items.len(), 1);
assert_eq!(recorded.slice().items[0].text, "record-only");
for (purpose, expected) in [
(CapturePurpose::MemoryEvidence, "memory-only"),
(CapturePurpose::Observation, "observation-only"),
] {
let slice = restored.slice(purpose, None, 10, 16_000).unwrap();
assert_eq!(slice.slice().items.len(), 1);
assert_eq!(slice.slice().items[0].text, expected);
assert!(
restored
.read_raw(
&reference(&restored, "Erecording"),
purpose,
RawField::MessagePart(0),
0,
100
)
.is_err()
);
}
}
#[test]
fn common_data_drops_reasoning_raw_system_and_private_payload_even_when_unindexed() {
let payload = FeatureInputPayload::encode(
"capture-test",
1,
&serde_json::json!({"secret": "PRIVATE-PAYLOAD"}),
)
.unwrap();
let public = PublicSystemItem {
kind: "notice".into(),
body: "public notice".into(),
presentation: PublicSystemPresentation::Ignored,
};
let system =
HistoryInputMetadata::new(usage(true, true, true), Some(payload.clone()), Some(public))
.unwrap();
let private = HistoryInputMetadata::new(usage(true, true, true), Some(payload), None).unwrap();
let capture = SessionCapture::from_complete_history(
"s",
"segment",
vec![
history("raw", Item::system_message("RAW-SYSTEM-PROMPT"), None),
history("reasoning", Item::reasoning("HIDDEN-REASONING"), None),
history(
"private",
Item::system_message("PRIVATE-SYSTEM-BODY"),
Some(private),
),
history(
"public",
Item::system_message("MODEL-SYSTEM-BODY"),
Some(system),
),
history("ordinary", Item::user_message("ordinary"), None),
],
);
let encoded = String::from_utf8(capture.encode(CaptureLimits::default()).unwrap()).unwrap();
let retained_debug = format!("{capture:?}");
for secret in [
"RAW-SYSTEM-PROMPT",
"HIDDEN-REASONING",
"PRIVATE-SYSTEM-BODY",
"MODEL-SYSTEM-BODY",
"PRIVATE-PAYLOAD",
] {
assert!(!encoded.contains(secret));
assert!(!retained_debug.contains(secret));
}
assert_eq!(capture.to_dto().entries.len(), 2);
assert!(encoded.contains("public notice"));
// Filtered entries do not renumber the public source positions.
assert_eq!(capture.to_dto().entries[0].order, 3);
assert_eq!(
capture.view_order_for_ref(&SessionEntryRef::parse("Eordinary").unwrap()),
Some(4)
);
}
#[test]
fn protocol_projection_does_not_invent_recording_permission() {
let capture = SessionCapture::from_session_snapshot(
"s",
"seg",
SessionSnapshot {
pending_submissions: Default::default(),
entries: vec![SessionSnapshotEntry {
entry_id: "projected".into(),
timestamp: 0,
provenance: SessionEntryProvenance::HumanInput,
derived_from: vec!["source".into()],
use_policy: Some(protocol::SessionEntryUsePolicy {
observation_readable: true,
memory_evidence: false,
}),
data: SessionSnapshotEntryData::Message {
role: protocol::SessionMessageRole::User,
content: vec![protocol::SessionContentPart::Text {
text: "projected".into(),
}],
},
}],
},
);
let reference = reference(&capture, "Eprojected");
assert!(matches!(
capture.resolve_ref(&reference, CapturePurpose::Recording),
Err(CaptureError::PolicyDenied)
));
assert!(
capture
.resolve_ref(&reference, CapturePurpose::Observation)
.is_ok()
);
assert!(capture.evidence_for("Eprojected").is_none());
}
#[test]
fn malformed_dtos_and_oversized_representations_are_rejected() {
let capture = SessionCapture::new("s", "seg", vec![Item::user_message("body")]);
let original = serde_json::to_value(capture.to_dto()).unwrap();
let mut invalids = Vec::new();
let mut value = original.clone();
value["version"] = 999.into();
invalids.push(value);
let mut value = original.clone();
value["unexpected"] = true.into();
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["policy"]["recording_material"] = "include".into();
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["policy"]
.as_object_mut()
.unwrap()
.remove("memory_evidence");
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["body"] = serde_json::json!({"kind":"reasoning","text":"secret"});
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["body"]["feature_payload"] = serde_json::json!({"private":true});
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["body"]["role"] = "system".into();
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["source"]["unknown"] = true.into();
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["source"]["history_origin"] =
serde_json::json!({"kind":"legacy_unknown","unknown":true});
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["entry_id"] = "bad/id".into();
invalids.push(value);
let mut value = original.clone();
value["entries"]
.as_array_mut()
.unwrap()
.push(original["entries"][0].clone());
invalids.push(value);
for invalid in invalids {
let bytes = serde_json::to_vec(&invalid).unwrap();
assert!(
SessionCapture::decode(&bytes, CaptureLimits::default()).is_err(),
"accepted {invalid}"
);
}
let bytes = capture.encode(CaptureLimits::default()).unwrap();
assert!(
SessionCapture::decode(
&bytes,
CaptureLimits {
max_bytes: bytes.len() - 1,
..CaptureLimits::default()
}
)
.is_err()
);
assert!(
capture
.encode(CaptureLimits {
max_entries: 0,
..CaptureLimits::default()
})
.is_err()
);
assert!(
capture
.encode(CaptureLimits {
max_text_bytes: 3,
..CaptureLimits::default()
})
.is_err()
);
let mut reversed = capture.to_dto();
reversed.view_entry_count = 2;
let mut second = reversed.entries[0].clone();
second.entry_id = "second".into();
second.source.entry_index = 4;
second.order = 0;
reversed.entries[0].order = 1;
reversed.entries.push(second);
assert!(SessionCapture::try_from_dto(reversed, CaptureLimits::default()).is_err());
let mut duplicate_source = capture.to_dto();
duplicate_source.view_entry_count = 2;
let mut second = duplicate_source.entries[0].clone();
second.entry_id = "distinct-id".into();
second.order = 1;
duplicate_source.entries.push(second);
assert!(matches!(
SessionCapture::try_from_dto(duplicate_source, CaptureLimits::default()),
Err(CaptureError::Invalid("duplicate source position"))
));
}
#[test]
fn raw_utf8_tool_input_and_result_are_lossless_without_preview_markers() {
let arguments = "\"quoted\"\\path\n日本語🙂".repeat(300);
let result = "result\n\t\\\"λ🙂".repeat(400);
let capture = SessionCapture::new(
"s",
"seg",
vec![
Item::tool_call("call", "tool", arguments.clone()),
Item::tool_result_item("call", "summary", Some(result.clone()), false),
],
);
for (id, field, expected) in [
("E00000000", RawField::ToolArguments, arguments),
("E00000001", RawField::ToolContent, result),
] {
let reference = reference(&capture, id);
let mut offset = 0;
let mut actual = String::new();
loop {
let part = capture
.read_raw(&reference, CapturePurpose::Observation, field, offset, 17)
.unwrap();
assert!(part.text.len() <= 17);
assert_eq!(part.offset, offset);
actual.push_str(&part.text);
match part.next_offset {
Some(next) => {
assert!(next > offset);
offset = next;
}
None => break,
}
}
assert_eq!(actual, expected);
}
let unicode = SessionCapture::new("s", "seg", vec![Item::tool_call("call", "tool", "🙂")]);
let reference = reference(&unicode, "E00000000");
assert!(matches!(
unicode.read_raw(
&reference,
CapturePurpose::Observation,
RawField::ToolArguments,
1,
8
),
Err(CaptureError::InvalidRange)
));
assert!(matches!(
unicode.read_raw(
&reference,
CapturePurpose::Observation,
RawField::ToolArguments,
0,
3
),
Err(CaptureError::BudgetTooSmall)
));
assert!(matches!(
unicode.read_raw(
&reference,
CapturePurpose::Observation,
RawField::ToolArguments,
5,
8
),
Err(CaptureError::InvalidRange)
));
}
#[test]
fn escaped_json_slice_budget_includes_metadata_and_bound_continuation() {
let body = "\"\\\n\t🙂日本語".repeat(500);
let capture = SessionCapture::new(
"synthetic-session",
"segment",
vec![Item::tool_call("call", "tool", body.clone())],
);
let other_session = SessionCapture::new(
"other-session",
"segment",
vec![Item::tool_call("call", "tool", body.clone())],
);
let changed = SessionCapture::new(
"synthetic-session",
"segment",
vec![Item::tool_call("call", "tool", "changed")],
);
let mut cursor = None;
let mut actual = String::new();
let budget = 1400;
loop {
let part = capture
.slice(CapturePurpose::Recording, cursor.as_ref(), 1, budget)
.unwrap();
assert!(part.encoded_len() <= budget);
assert_eq!(part.encoded_len(), part.as_json().len());
assert_eq!(serde_json::to_vec(part.slice()).unwrap(), part.as_json());
assert_eq!(part.slice().items.len(), 1);
let item = &part.slice().items[0];
assert_eq!(item.offset, actual.len());
actual.push_str(&item.text);
cursor = part.slice().next_cursor.clone();
if let Some(cursor) = &cursor {
assert!(matches!(
other_session.slice(CapturePurpose::Recording, Some(cursor), 1, budget),
Err(CaptureError::ForeignReference)
));
assert!(matches!(
changed.slice(CapturePurpose::Recording, Some(cursor), 1, budget),
Err(CaptureError::ForeignReference)
));
assert!(matches!(
capture.slice(CapturePurpose::Observation, Some(cursor), 1, budget),
Err(CaptureError::ForeignReference)
));
} else {
break;
}
}
assert_eq!(actual, body);
assert!(matches!(
capture.slice(CapturePurpose::Recording, None, 1, 8),
Err(CaptureError::BudgetTooSmall)
));
assert!(matches!(
capture.slice(CapturePurpose::Recording, None, 0, budget),
Err(CaptureError::BudgetTooSmall)
));
}
#[test]
fn snapshot_immutability_and_scoped_operations_reject_foreign_or_unknown_refs_and_bad_ranges() {
let mut source = vec![
Item::user_message("before"),
Item::assistant_message("answer"),
];
let capture = SessionCapture::new("s", "seg", source.clone());
let fixed_bytes = capture.encode(CaptureLimits::default()).unwrap();
let first = reference(&capture, "E00000000");
let second = reference(&capture, "E00000001");
source.push(Item::user_message("appended"));
source[0] = Item::user_message("compacted");
source.clear();
assert_eq!(
capture.encode(CaptureLimits::default()).unwrap(),
fixed_bytes
);
assert_eq!(
capture.clone().encode(CaptureLimits::default()).unwrap(),
fixed_bytes
);
let foreign = SessionCapture::new(
"different",
"seg",
vec![
Item::user_message("before"),
Item::assistant_message("answer"),
],
);
assert!(matches!(
foreign.resolve_ref(&first, CapturePurpose::Observation),
Err(CaptureError::ForeignReference)
));
assert!(matches!(
capture.checked_range(&second, &first, CapturePurpose::Observation),
Err(CaptureError::InvalidRange)
));
assert_eq!(
capture
.checked_range(&first, &second, CapturePurpose::Observation)
.unwrap()
.len(),
2
);
let unknown = SessionEntryRef::parse("E99999999").unwrap();
assert_eq!(capture.view_order_for_ref(&unknown), None);
assert!(matches!(
capture.scoped_ref(&unknown),
Err(CaptureError::UnknownReference)
));
assert!(
capture
.search_checked(&SearchOptions {
from: Some(unknown.clone()),
..Default::default()
})
.is_err()
);
assert!(
capture
.search(&SearchOptions {
through: Some(unknown),
..Default::default()
})
.is_empty()
);
assert!(
capture
.read_checked(ReadSelector::EntryRange([1, 0]), ReadOptions::default())
.is_err()
);
assert!(
capture
.read_checked(ReadSelector::EntryRange([0, 99]), ReadOptions::default())
.is_err()
);
}
#[test]
fn slices_preserve_empty_and_long_result_fields_under_item_and_metadata_budgets() {
let summary = "summary\n\"🙂".repeat(100);
let content = "content\t\\日本語".repeat(200);
let capture = SessionCapture::new(
"s",
"seg",
vec![
Item::tool_result_item("first", summary.clone(), Some(content.clone()), true),
Item::tool_result_item("empty", "", Some(String::new()), false),
],
);
let mut cursor = None;
let mut fields: Vec<(String, RawField, String)> = Vec::new();
loop {
let part = capture
.slice(CapturePurpose::Recording, cursor.as_ref(), 2, 1500)
.unwrap();
assert!(part.slice().items.len() <= 2);
assert!(serde_json::to_vec(part.slice()).unwrap().len() <= 1500);
for item in &part.slice().items {
let id = item.reference.entry_ref.to_string();
if let Some((last_id, last_field, text)) = fields.last_mut() {
if *last_id == id && *last_field == item.field {
assert_eq!(text.len(), item.offset);
text.push_str(&item.text);
continue;
}
}
assert_eq!(item.offset, 0);
fields.push((id, item.field, item.text.clone()));
}
cursor = part.slice().next_cursor.clone();
if cursor.is_none() {
break;
}
}
assert_eq!(
fields,
vec![
("E00000000".into(), RawField::ToolSummary, summary),
("E00000000".into(), RawField::ToolContent, content),
("E00000001".into(), RawField::ToolSummary, String::new()),
("E00000001".into(), RawField::ToolContent, String::new()),
]
);
let metadata_heavy = SessionCapture::new(
"s",
"seg",
vec![Item::tool_call("call", "name".repeat(200), "x")],
);
assert!(matches!(
metadata_heavy.slice(CapturePurpose::Recording, None, 1, 700),
Err(CaptureError::BudgetTooSmall)
));
}
#[test]
fn evidence_uses_original_segment_positions_after_roundtrip_and_view_selection() {
let full = SessionCapture::new(
"s",
"selected",
vec![
Item::user_message("first"),
Item::assistant_message("second"),
],
);
let mut dto = full.to_dto();
dto.entries[0].source.segment_id = "original-a".into();
dto.entries[0].source.entry_index = 80;
dto.entries[1].source.segment_id = "original-b".into();
dto.entries[1].source.entry_index = 3;
let encoded = dto.encode(CaptureLimits::default()).unwrap();
let restored = SessionCapture::decode(&encoded, CaptureLimits::default()).unwrap();
for (id, segment, index, order) in [
("E00000000", "original-a", 80, 0),
("E00000001", "original-b", 3, 1),
] {
let evidence = restored.evidence_for(id).unwrap();
assert_eq!(evidence.segment_id, segment);
assert_eq!(evidence.entry_range, [index; 2]);
let read = restored.read(ReadSelector::Id(id), ReadOptions::default());
assert_eq!(read.entries[0].entry_range, [order; 2]);
}
let selected = restored.select_order_range(1..2).unwrap();
assert_eq!(selected.overview()[0].entry_range, [0, 0]);
let evidence = selected.evidence_for("E00000001").unwrap();
assert_eq!(evidence.segment_id, "original-b");
assert_eq!(evidence.entry_range, [3, 3]);
let roundtrip = SessionCapture::decode(
&selected.encode(CaptureLimits::default()).unwrap(),
CaptureLimits::default(),
)
.unwrap();
assert_eq!(roundtrip.to_dto(), selected.to_dto());
assert_eq!(
roundtrip.evidence_for("E00000001").unwrap().entry_range,
[3, 3]
);
}
#[test]
fn fixed_history_selection_preserves_original_coordinates_and_checks_private_position_bounds() {
let history = vec![
history("old", Item::user_message("old"), None),
history("hidden", Item::reasoning("PRIVATE"), None),
history("selected", Item::user_message("selected"), None),
history("private-tail", Item::system_message("PRIVATE"), None),
];
let full = SessionCapture::from_complete_history("s", "seg", history);
let suffix = full.select_order_range(1..4).unwrap();
let original = full
.entries()
.find(|entry| entry.entry_id == "selected")
.unwrap();
let selected = suffix.entries().next().unwrap();
assert_eq!(selected.entry_id, original.entry_id);
assert_eq!(selected.source, original.source);
assert_eq!(selected.body, original.body);
assert_eq!(selected.policy, original.policy);
assert!(suffix.evidence_for("Eold").is_none());
assert_eq!(suffix.search(&SearchOptions::default()).len(), 1);
assert_eq!(
suffix.view_order_for_ref(&SessionEntryRef::parse("Eselected").unwrap()),
Some(1)
);
assert_eq!(selected.source.entry_index, 2);
assert_eq!(selected.order, 1);
assert_eq!(suffix.to_dto().view_entry_count, 3);
assert_eq!(
suffix.evidence_for("Eselected").unwrap().entry_range,
[2, 2]
);
let nested = suffix.select_order_range(1..2).unwrap();
assert_eq!(nested.entries().next().unwrap().order, 0);
assert_eq!(nested.entries().next().unwrap().source, original.source);
let restored = SessionCapture::decode(
&suffix.encode(CaptureLimits::default()).unwrap(),
CaptureLimits::default(),
)
.unwrap();
assert_eq!(restored.to_dto(), suffix.to_dto());
assert!(full.select_order_range(4..5).is_err());
let reversed = std::ops::Range { start: 3, end: 2 };
assert!(full.select_order_range(reversed).is_err());
let hidden_only = full.select_order_range(3..4).unwrap();
assert_eq!(hidden_only.entries().len(), 0);
assert_eq!(hidden_only.to_dto().view_entry_count, 1);
let mut invalid = full.to_dto();
invalid.view_entry_count = 2; // the selected entry's view order is outside this extent
assert!(SessionCapture::try_from_dto(invalid, CaptureLimits::default()).is_err());
assert_eq!(
full.retain_entries(|entry| entry.entry_id == "selected")
.entries()
.next()
.unwrap()
.source,
original.source
);
assert_eq!(full.to_dto().view_entry_count, 4);
}
+1 -1
View File
@@ -23,7 +23,7 @@ serde.workspace = true
serde_json.workspace = true
session-store.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["rt", "sync", "time"] }
tokio = { workspace = true, features = ["rt", "sync", "time", "net", "io-util", "io-std", "signal"] }
uuid = { workspace = true, features = ["v7"] }
worker.workspace = true
+2 -1
View File
@@ -4,7 +4,8 @@ A Host that starts existing Workers in the same process. Assembles interactive W
## Reference index
- [`host`] — bootstrap and confirmed shutdown.
- [`host`] — bootstrap and confirmed shutdown, with or without a TUI.
- [`unix`] — optional operator transport; disconnect does not release execution ownership.
- [`launch`] — Profile, cwd, and Subject validation before launch.
- [`store`] — Worker records and leases.
- [`jobs`] — Job execution and independent result and cleanup lifetimes.

Some files were not shown because too many files have changed in this diff Show More